This commit is contained in:
Mateusz Gruszczyński committed 2026-10-04 16:47:24 +02:00
1 parent 59ddabb72b
commit 174389e5b1
48 files changed
+688 -212

No files matched your search

+1
View File
@@ -72,6 +72,7 @@ static READER_DOCUMENTS: &[(&str, &str, &str)] = &[
("docs/V0.24.8_AUDIT.md", "audits", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/V0.24.8_AUDIT.md"))),
("docs/V0.25.0_AUDIT.md", "audits", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/V0.25.0_AUDIT.md"))),
("docs/V0.25.1_AUDIT.md", "audits", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/V0.25.1_AUDIT.md"))),
("docs/V0.25.2_AUDIT.md", "audits", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/V0.25.2_AUDIT.md"))),
("docs/V0.25.0_SECURITY_PREPROD_AUDIT.md", "audits", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/V0.25.0_SECURITY_PREPROD_AUDIT.md"))),
("docs/VHOSTS.md", "guides", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/VHOSTS.md"))),
("docs/VHOST_ACCESS.md", "guides", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/VHOST_ACCESS.md"))),
+1 -1
View File
@@ -2,7 +2,7 @@
"openapi": "3.0.3",
"info": {
"title": "RustProxy Administration API",
"version": "0.25.1",
"version": "0.25.2",
"description": "Administration API for the active panel origin. Sign in through the panel; the same-origin HttpOnly cookie authenticates all private operations. Mutations also require the exact Origin, X-RustProxy-Request: 1 and a session-bound X-CSRF-Token. Loopback HTTP is the default; Settings can enable HTTPS before using a non-loopback administration bind. This document contains no live configuration or credentials. Object schemas marked extensible are summaries: full configuration validation is authoritative in Rust."
},
"servers": [
+19 -1
View File
@@ -3,6 +3,23 @@ const $ = (id) => document.getElementById(id);
let language = null;
try { language = localStorage.getItem("rp-language"); } catch { }
let csrf = "", authenticated = false, setupRequired = false, sessionInfo = null;
const ORIGIN_RECOVERY_KEY = "rp-origin-recovery-v1";
function recoverInvalidOrigin(error) {
if (error?.code !== "auth.invalid_origin" || typeof location === "undefined") return false;
const marker = `${location.origin}${location.pathname}${location.search}`;
try {
if (sessionStorage.getItem(ORIGIN_RECOVERY_KEY) === marker) return false;
sessionStorage.setItem(ORIGIN_RECOVERY_KEY, marker);
} catch {
if (recoverInvalidOrigin.attempted) return false;
recoverInvalidOrigin.attempted = true;
}
location.reload();
return true;
}
function clearInvalidOriginRecovery() {
try { sessionStorage.removeItem(ORIGIN_RECOVERY_KEY); } catch { recoverInvalidOrigin.attempted = false; }
}
const ADMIN_THEMES = new Set(["dark", "light"]);
let adminPreferences = null, adminPreferencesLoading = null, adminPreferencesWrite = Promise.resolve();
function normalizeAdminTheme(value) { return ADMIN_THEMES.has(value) ? value : "dark"; }
@@ -400,10 +417,11 @@ async function revalidateSession() {
const epoch = authEpoch;
try {
const info = await api("/api/auth/session");
clearInvalidOriginRecovery();
if (epoch !== authEpoch) return;
if (!authenticated) { await activate(info); return; }
sessionInfo = info; csrf = info.csrf_token; renderSession(); await refresh(true); startLive();
} catch (error) { if (error.status !== 401) toast(error.message, true); }
} catch (error) { if (recoverInvalidOrigin(error)) return; if (error.status !== 401) toast(error.message, true); }
}
window.addEventListener("pageshow", event => { if (event.persisted) void revalidateSession(); });
document.addEventListener("visibilitychange", () => { if (document.hidden) stopLive(); else if (authenticated) { void revalidateSession(); } });
+2
View File
@@ -4,12 +4,14 @@
// app.js belongs to the authenticated shell and must not fall back to its
// legacy embedded sign-in form when another startup step fails.
const info = await api("/api/auth/session");
clearInvalidOriginRecovery();
await loadLanguages();
setupRequired = false;
translate();
syncAuthForm();
await activate(info);
} catch (e) {
if (recoverInvalidOrigin(e)) return;
if (e.status === 401) {
location.replace(`${location.pathname}${location.search}`);
return;
+1 -1
View File
@@ -6,7 +6,7 @@ function renderSettings() {
if (!settingsDirty.has("response-form")) {
const server = config.server_header || {}, compression = config.compression || {}, diagnostics = config.diagnostic_headers || {};
$("expose-request-id").checked = !!diagnostics.request_id;
$("server-mode").value = server.mode || "preserve"; $("server-value").value = server.value || "RustProxy/v0.25.1";
$("server-mode").value = server.mode || "preserve"; $("server-value").value = server.value || "RustProxy/v0.25.2";
for (const name of ["gzip", "br", "zstd", "deflate"]) $(`compress-${name}`).checked = !!compression[name];
$("compress-min").value = compression.min_bytes ?? 1024; syncServerMode();
}
+2 -2
View File
@@ -1472,7 +1472,7 @@
<option data-i18n="serverCustom" value="custom">Replace / add custom value</option>
</select>
<label data-i18n="serverValue" for="server-value">Custom Server value</label><input
id="server-value" maxlength="256" placeholder="RustProxy/v0.25.1"
id="server-value" maxlength="256" placeholder="RustProxy/v0.25.2"
spellcheck="false" />
<h3 class="subheading" data-i18n="diagnosticHeaders">Diagnostic headers</h3>
<div class="switch-grid">
@@ -2219,7 +2219,7 @@
</section>
</div>
</section>
<footer class="page-footer"><span>RustProxy <b>0.25.1</b></span><span id="footer-status">API - /
<footer class="page-footer"><span>RustProxy <b>0.25.2</b></span><span id="footer-status">API - /
dropped: 0</span></footer>
</div>
</main>
+9 -4
View File
@@ -43,14 +43,19 @@ pub fn prepare_directory(path: &Path) -> Result<()> {
}
Ok(())
}
pub fn lock_directory(path: &Path) -> Result<File> {
pub fn try_lock_directory(path: &Path) -> Result<Option<File>> {
let file = private_options()
.create(true)
.truncate(false)
.open(path.join("process.lock"))?;
file.try_lock_exclusive()
.context("another RustProxy process already owns this data directory")?;
Ok(file)
match file.try_lock_exclusive() {
Ok(()) => Ok(Some(file)),
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => Ok(None),
Err(error) => Err(error).context("could not lock RustProxy data directory"),
}
}
pub fn lock_directory(path: &Path) -> Result<File> {
try_lock_directory(path)?.context("another RustProxy process already owns this data directory")
}
pub fn load_setup_token(dir: &Path) -> Result<String> {
let path = dir.join("setup-token");
+1 -1
View File
@@ -9,7 +9,7 @@ use argon2::{
Algorithm, Argon2, Params, Version,
};
pub use files::{
load_or_create_setup_token, load_setup_token, lock_directory, prepare_database,
load_or_create_setup_token, load_setup_token, lock_directory, prepare_database, try_lock_directory,
prepare_directory,
};
use http::{header::COOKIE, HeaderMap, StatusCode};
+1
View File
@@ -3,6 +3,7 @@
//! logs, sockets and temporary runtime state.
include!("backup/archive.rs");
include!("backup/snapshot.rs");
include!("backup/validation.rs");
#[cfg(test)]
+27 -4
View File
@@ -16,7 +16,7 @@ const BACKUP_MAGIC: &[u8; 8] = b"RPROXYB1";
const ARCHIVE_MAGIC: &[u8; 8] = b"RPROXYA1";
const KEY_MAGIC: &[u8; 4] = b"RPK1";
const FORMAT_VERSION: u32 = 1;
const ARCHIVE_VERSION: u32 = 1;
const ARCHIVE_VERSION: u32 = 2;
const CHUNK_SIZE: usize = 1024 * 1024;
const ARGON_MEMORY_KIB: u32 = 64 * 1024;
const ARGON_ITERATIONS: u32 = 3;
@@ -56,11 +56,29 @@ struct Manifest {
entries: Vec<ManifestEntry>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
enum EntryKind {
File,
Directory,
Symlink,
}
impl Default for EntryKind {
fn default() -> Self {
Self::File
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct ManifestEntry {
path: String,
size: u64,
sha256: String,
#[serde(default, skip_serializing_if = "is_file_entry")]
kind: EntryKind,
}
fn is_file_entry(kind: &EntryKind) -> bool {
*kind == EntryKind::File
}
enum EntrySource {
@@ -121,9 +139,14 @@ fn validate_backup_version(version: &str) -> Result<()> {
Ok(())
}
fn harden_tree(path: &Path) -> Result<()> {
fn harden_tree(root: &Path, path: &Path) -> Result<()> {
let metadata = fs::symlink_metadata(path)?;
ensure!(!metadata.file_type().is_symlink(), "restore staging contains a symlink");
if metadata.file_type().is_symlink() {
let resolved = fs::canonicalize(path).context("restored symlink target is missing")?;
let canonical_root = fs::canonicalize(root)?;
ensure!(resolved.starts_with(&canonical_root), "restored symlink escapes the data directory");
return Ok(());
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
@@ -132,7 +155,7 @@ fn harden_tree(path: &Path) -> Result<()> {
}
if metadata.is_dir() {
for entry in fs::read_dir(path)? {
harden_tree(&entry?.path())?;
harden_tree(root, &entry?.path())?;
}
}
Ok(())
+244
View File
@@ -0,0 +1,244 @@
fn excluded_file(name: &str) -> bool {
name == "process.lock"
|| name == "config.sqlite3"
|| name == "config.sqlite3-wal"
|| name == "config.sqlite3-shm"
|| name == "master.key"
|| name == ".lock"
|| name.ends_with(".sock")
|| name.ends_with(".pid")
|| name.ends_with(".log")
|| name.ends_with(".jsonl")
}
fn archive_data_path(relative: &Path) -> Result<String> {
let value = relative
.to_str()
.context("backup paths must be valid UTF-8")?
.replace('\\', "/");
ensure!(!value.is_empty(), "empty backup path");
Ok(format!("data/{value}"))
}
fn backup_input(path: &Path) -> Result<File> {
let mut options = OpenOptions::new();
options.read(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.custom_flags(libc::O_NOFOLLOW);
}
let file = options.open(path)?;
ensure!(file.metadata()?.is_file(), "backup input must be a regular file");
Ok(file)
}
fn snapshot_metadata_unchanged(before: &std::fs::Metadata, after: &std::fs::Metadata) -> bool {
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
return before.dev() == after.dev()
&& before.ino() == after.ino()
&& before.size() == after.size()
&& before.mtime() == after.mtime()
&& before.mtime_nsec() == after.mtime_nsec()
&& before.ctime() == after.ctime()
&& before.ctime_nsec() == after.ctime_nsec();
}
#[cfg(not(unix))]
{
before.len() == after.len() && before.modified().ok() == after.modified().ok()
}
}
fn copy_snapshot_file(source: &Path, target: &Path) -> Result<(u64, String)> {
let mut input = backup_input(source)?;
let before = input.metadata()?;
let mut output = private_file_options().create_new(true).open(target)?;
let mut hasher = Sha256::new();
let mut size = 0u64;
let mut buffer = [0u8; 1024 * 1024];
loop {
let read = input.read(&mut buffer)?;
if read == 0 {
break;
}
output.write_all(&buffer[..read])?;
hasher.update(&buffer[..read]);
size = size.checked_add(read as u64).context("backup input too large")?;
}
output.sync_all()?;
let after = input.metadata()?;
ensure!(
snapshot_metadata_unchanged(&before, &after),
"durable file changed while backup snapshot was being captured: {}",
source.display()
);
Ok((size, format!("{:x}", hasher.finalize())))
}
fn symlink_target_bytes(target: &Path) -> Result<Vec<u8>> {
#[cfg(unix)]
{
use std::os::unix::ffi::OsStrExt;
let bytes = target.as_os_str().as_bytes().to_vec();
ensure!(!bytes.is_empty() && bytes.len() <= 4096, "invalid symlink target");
return Ok(bytes);
}
#[cfg(not(unix))]
{
let bytes = target
.to_str()
.context("symlink target must be valid UTF-8")?
.as_bytes()
.to_vec();
ensure!(!bytes.is_empty() && bytes.len() <= 4096, "invalid symlink target");
Ok(bytes)
}
}
fn symlink_target_path(bytes: &[u8]) -> Result<PathBuf> {
ensure!(!bytes.is_empty() && bytes.len() <= 4096, "invalid symlink target");
#[cfg(unix)]
{
use std::os::unix::ffi::OsStringExt;
return Ok(PathBuf::from(std::ffi::OsString::from_vec(bytes.to_vec())));
}
#[cfg(not(unix))]
{
Ok(PathBuf::from(std::str::from_utf8(bytes).context("symlink target is not UTF-8")?))
}
}
fn validate_symlink_target(link_relative: &Path, target: &Path) -> Result<()> {
ensure!(!target.is_absolute(), "backup symlink target must be relative");
let mut depth = link_relative.parent().map_or(0usize, |parent| parent.components().count());
for component in target.components() {
match component {
Component::Normal(_) => depth = depth.checked_add(1).context("symlink target too deep")?,
Component::CurDir => {}
Component::ParentDir => {
ensure!(depth > 0, "backup symlink escapes the data directory");
depth -= 1;
}
Component::RootDir | Component::Prefix(_) => bail!("backup symlink target must be relative"),
}
}
Ok(())
}
fn create_symlink(target: &Path, link: &Path) -> Result<()> {
#[cfg(unix)]
{
std::os::unix::fs::symlink(target, link)?;
return Ok(());
}
#[cfg(not(unix))]
{
let _ = (target, link);
bail!("backup symlink restore is supported only on Unix")
}
}
fn collect_data_files(
root: &Path,
current: &Path,
snapshot_root: &Path,
entries: &mut Vec<ArchiveEntry>,
symlinks: &mut Vec<PathBuf>,
) -> Result<()> {
for item in fs::read_dir(current)? {
let item = item?;
let path = item.path();
let name = item.file_name();
let name_text = name.to_string_lossy();
if excluded_file(&name_text) || excluded_directory(&name_text) || path == root.join("secrets") {
continue;
}
let meta = fs::symlink_metadata(&path)?;
let relative = path.strip_prefix(root)?;
let archive_path = archive_data_path(relative)?;
let snapshot_path = snapshot_root.join(relative);
if meta.is_dir() {
private_dir(&snapshot_path)?;
entries.push(ArchiveEntry {
manifest: ManifestEntry {
path: archive_path,
size: 0,
sha256: hash_bytes(&[]),
kind: EntryKind::Directory,
},
source: EntrySource::Bytes(Vec::new()),
});
collect_data_files(root, &path, snapshot_root, entries, symlinks)?;
} else if meta.file_type().is_symlink() {
let target = fs::read_link(&path)?;
validate_symlink_target(relative, &target)?;
if let Some(parent) = snapshot_path.parent() {
private_dir(parent)?;
}
create_symlink(&target, &snapshot_path)?;
let bytes = symlink_target_bytes(&target)?;
entries.push(ArchiveEntry {
manifest: ManifestEntry {
path: archive_path,
size: bytes.len() as u64,
sha256: hash_bytes(&bytes),
kind: EntryKind::Symlink,
},
source: EntrySource::Bytes(bytes),
});
symlinks.push(snapshot_path);
} else if meta.is_file() {
if let Some(parent) = snapshot_path.parent() {
private_dir(parent)?;
}
let (size, sha256) = copy_snapshot_file(&path, &snapshot_path)?;
entries.push(ArchiveEntry {
manifest: ManifestEntry {
path: archive_path,
size,
sha256,
kind: EntryKind::File,
},
source: EntrySource::File(snapshot_path),
});
} else {
bail!("backup refuses non-file runtime object: {}", path.display());
}
}
Ok(())
}
fn validate_snapshot_symlinks(snapshot_root: &Path, symlinks: &[PathBuf]) -> Result<()> {
let root = fs::canonicalize(snapshot_root)?;
for link in symlinks {
let resolved = fs::canonicalize(link)
.with_context(|| format!("backup symlink target is missing: {}", link.display()))?;
ensure!(resolved.starts_with(&root), "backup symlink escapes the data directory: {}", link.display());
}
Ok(())
}
fn validate_snapshot_config(envelope: &crate::config::Envelope, snapshot_root: &Path) -> Result<()> {
let cert_dir = snapshot_root.join("certs");
for certificate in &envelope.config.tls.certificates {
for name in [&certificate.cert_file, &certificate.key_file] {
crate::tls::safe_name(name)?;
let path = cert_dir.join(name);
let meta = fs::symlink_metadata(&path)
.with_context(|| format!("configured certificate file changed during backup: {name}"))?;
ensure!(meta.is_file() && !meta.file_type().is_symlink(), "configured certificate file is not a regular file: {name}");
}
}
if envelope.config.administration.tls && envelope.config.administration.certificate_id.is_none() {
for name in ["default.pem", "default.key"] {
let path = cert_dir.join(name);
let meta = fs::symlink_metadata(&path)
.with_context(|| format!("default administration certificate changed during backup: {name}"))?;
ensure!(meta.is_file() && !meta.file_type().is_symlink(), "default administration certificate is not a regular file: {name}");
}
}
Ok(())
}
+64
View File
@@ -28,4 +28,68 @@ mod tests {
assert!(validate_backup_version(env!("CARGO_PKG_VERSION")).is_ok());
assert!(validate_backup_version("999.0.0").is_err());
}
#[cfg(unix)]
#[tokio::test]
async fn live_backup_round_trips_internal_certbot_symlink() {
use std::os::unix::fs::symlink;
let dir = tempfile::tempdir().unwrap();
let data = dir.path().join("data");
crate::auth::prepare_directory(&data).unwrap();
let database = data.join("config.sqlite3");
crate::auth::prepare_database(&database).unwrap();
let store = crate::db::Store::open(&database, &crate::config::Config::default())
.await
.unwrap();
crate::secret_store::initialize(&data).unwrap();
store.close().await;
let config = data.join("certs/acme/production-linuxiarz/config");
let archive_dir = config.join("archive/rustproxy");
let live_dir = config.join("live/rustproxy");
fs::create_dir_all(&archive_dir).unwrap();
fs::create_dir_all(&live_dir).unwrap();
fs::write(archive_dir.join("fullchain1.pem"), b"certificate-v1").unwrap();
symlink(
Path::new("../../archive/rustproxy/fullchain1.pem"),
live_dir.join("fullchain.pem"),
)
.unwrap();
let running_lock = crate::auth::lock_directory(&data).unwrap();
let archive = dir.path().join("live-backup.rpb");
create(&data, &archive, "correct horse battery staple")
.await
.unwrap();
drop(running_lock);
let restored = dir.path().join("restored");
restore(&archive, &restored, "correct horse battery staple")
.await
.unwrap();
let restored_link = restored.join("certs/acme/production-linuxiarz/config/live/rustproxy/fullchain.pem");
assert_eq!(
fs::read_link(&restored_link).unwrap(),
PathBuf::from("../../archive/rustproxy/fullchain1.pem")
);
assert_eq!(fs::read(restored_link).unwrap(), b"certificate-v1");
}
#[test]
fn symlink_targets_must_remain_inside_data_directory() {
assert!(validate_symlink_target(
Path::new("certs/acme/profile/config/live/rustproxy/fullchain.pem"),
Path::new("../../archive/rustproxy/fullchain1.pem")
)
.is_ok());
assert!(validate_symlink_target(
Path::new("certs/link.pem"),
Path::new("../../../etc/passwd")
)
.is_err());
assert!(validate_symlink_target(Path::new("certs/link.pem"), Path::new("/etc/passwd"))
.is_err());
}
}
+111 -78
View File
@@ -1,45 +1,3 @@
fn excluded_file(name: &str) -> bool {
name == "process.lock"
|| name == "config.sqlite3"
|| name == "config.sqlite3-wal"
|| name == "config.sqlite3-shm"
|| name == "master.key"
|| name == ".lock"
|| name.ends_with(".sock")
|| name.ends_with(".pid")
|| name.ends_with(".log")
|| name.ends_with(".jsonl")
}
fn collect_data_files(root: &Path, current: &Path, entries: &mut Vec<ArchiveEntry>) -> Result<()> {
for item in fs::read_dir(current)? {
let item = item?;
let path = item.path();
let meta = fs::symlink_metadata(&path)?;
ensure!(!meta.file_type().is_symlink(), "backup refuses symlinks in the data directory: {}", path.display());
let name = item.file_name();
let name_text = name.to_string_lossy();
if meta.is_dir() {
if excluded_directory(&name_text) || (path == root.join("secrets")) {
continue;
}
collect_data_files(root, &path, entries)?;
} else if meta.is_file() {
if excluded_file(&name_text) {
continue;
}
let relative = path.strip_prefix(root)?;
let archive_path = format!("data/{}", relative.to_string_lossy().replace('\\', "/"));
let (size, sha256) = hash_file(&path)?;
entries.push(ArchiveEntry {
manifest: ManifestEntry { path: archive_path, size, sha256 },
source: EntrySource::File(path),
});
}
}
Ok(())
}
fn write_inner_archive(path: &Path, manifest: &Manifest, entries: &[ArchiveEntry]) -> Result<()> {
let mut file = private_file_options().create_new(true).open(path)?;
file.write_all(ARCHIVE_MAGIC)?;
@@ -56,16 +14,16 @@ fn write_inner_archive(path: &Path, manifest: &Manifest, entries: &[ArchiveEntry
file.write_all(&entry.manifest.size.to_be_bytes())?;
match &entry.source {
EntrySource::File(source) => {
let mut input = File::open(source)?;
let mut input = backup_input(source)?;
let mut remaining = entry.manifest.size;
while remaining > 0 {
let take = usize::try_from(remaining.min(buffer.len() as u64))?;
let read = input.read(&mut buffer[..take])?;
ensure!(read > 0, "backup input changed while being read");
ensure!(read > 0, "backup snapshot changed while being archived");
file.write_all(&buffer[..read])?;
remaining -= read as u64;
}
ensure!(input.read(&mut buffer[..1])? == 0, "backup input changed while being read");
ensure!(input.read(&mut buffer[..1])? == 0, "backup snapshot changed while being archived");
}
EntrySource::Bytes(bytes) => file.write_all(bytes)?,
}
@@ -100,6 +58,43 @@ fn safe_relative(path: &str) -> Result<PathBuf> {
Ok(value.to_path_buf())
}
fn validate_manifest_entries(manifest: &Manifest) -> Result<()> {
let mut kinds = std::collections::HashMap::new();
for entry in &manifest.entries {
ensure!(kinds.insert(entry.path.clone(), entry.kind).is_none(), "duplicate backup entry");
if entry.path == "keys/secret-store-key.enc" {
ensure!(entry.kind == EntryKind::File, "invalid Secret Store key entry type");
continue;
}
let relative = entry.path.strip_prefix("data/").context("unsupported backup entry")?;
safe_relative(relative)?;
if manifest.archive_version == 1 {
ensure!(entry.kind == EntryKind::File, "legacy backup contains unsupported entry type");
}
match entry.kind {
EntryKind::Directory => ensure!(entry.size == 0 && entry.sha256 == hash_bytes(&[]), "invalid directory backup entry"),
EntryKind::Symlink => ensure!(entry.size > 0 && entry.size <= 4096, "invalid symlink backup entry"),
EntryKind::File => {}
}
}
for (path, kind) in &kinds {
let Some(relative) = path.strip_prefix("data/") else { continue };
let relative = Path::new(relative);
let mut parent = relative.parent();
while let Some(value) = parent.filter(|value| !value.as_os_str().is_empty()) {
let ancestor = format!("data/{}", value.to_string_lossy().replace('\\', "/"));
if let Some(ancestor_kind) = kinds.get(&ancestor) {
ensure!(*ancestor_kind == EntryKind::Directory, "backup entry has a non-directory ancestor");
}
parent = value.parent();
}
if *kind == EntryKind::Symlink {
ensure!(manifest.archive_version >= 2, "legacy backup cannot contain symlinks");
}
}
Ok(())
}
fn extract_inner_archive(path: &Path, stage: &Path) -> Result<(Manifest, Vec<u8>)> {
let mut file = File::open(path)?;
let mut magic = [0u8; 8];
@@ -110,11 +105,13 @@ fn extract_inner_archive(path: &Path, stage: &Path) -> Result<(Manifest, Vec<u8>
let mut manifest_bytes = vec![0u8; manifest_len];
file.read_exact(&mut manifest_bytes)?;
let manifest: Manifest = serde_json::from_slice(&manifest_bytes).context("invalid backup manifest")?;
ensure!(manifest.archive_version == ARCHIVE_VERSION, "unsupported backup archive version");
ensure!((1..=ARCHIVE_VERSION).contains(&manifest.archive_version), "unsupported backup archive version");
validate_backup_version(&manifest.rustproxy_version)?;
ensure!(manifest.schema_version <= crate::db::SCHEMA_VERSION, "backup database schema is newer than this RustProxy binary");
ensure!(manifest.entries.len() <= 100_000, "backup contains too many files");
validate_manifest_entries(&manifest)?;
let mut wrapped_key = None;
let mut pending_symlinks = Vec::new();
let mut buffer = vec![0u8; 1024 * 1024];
for expected in &manifest.entries {
let path_len = read_u16(&mut file)? as usize;
@@ -133,29 +130,47 @@ fn extract_inner_archive(path: &Path, stage: &Path) -> Result<(Manifest, Vec<u8>
hasher.update(&bytes);
wrapped_key = Some(bytes);
} else {
let relative = archive_path
.strip_prefix("data/")
.ok_or_else(|| anyhow::anyhow!("unsupported backup entry"))?;
let relative = safe_relative(relative)?;
let target = stage.join(relative);
if let Some(parent) = target.parent() {
private_dir(parent)?;
let relative_text = archive_path.strip_prefix("data/").context("unsupported backup entry")?;
let relative = safe_relative(relative_text)?;
let target = stage.join(&relative);
match expected.kind {
EntryKind::Directory => private_dir(&target)?,
EntryKind::Symlink => {
let mut bytes = vec![0u8; size as usize];
file.read_exact(&mut bytes)?;
hasher.update(&bytes);
pending_symlinks.push((relative, bytes));
}
EntryKind::File => {
if let Some(parent) = target.parent() {
private_dir(parent)?;
}
let mut output = private_file_options().create_new(true).open(&target)?;
let mut remaining = size;
while remaining > 0 {
let take = usize::try_from(remaining.min(buffer.len() as u64))?;
file.read_exact(&mut buffer[..take])?;
output.write_all(&buffer[..take])?;
hasher.update(&buffer[..take]);
remaining -= take as u64;
}
output.sync_all()?;
}
}
let mut output = private_file_options().create_new(true).open(&target)?;
let mut remaining = size;
while remaining > 0 {
let take = usize::try_from(remaining.min(buffer.len() as u64))?;
file.read_exact(&mut buffer[..take])?;
output.write_all(&buffer[..take])?;
hasher.update(&buffer[..take]);
remaining -= take as u64;
}
output.sync_all()?;
}
ensure!(format!("{:x}", hasher.finalize()) == expected.sha256, "backup entry checksum mismatch");
}
let mut extra = [0u8; 1];
ensure!(file.read(&mut extra)? == 0, "trailing data in backup payload");
for (relative, bytes) in pending_symlinks {
let target = symlink_target_path(&bytes)?;
validate_symlink_target(&relative, &target)?;
let link = stage.join(relative);
if let Some(parent) = link.parent() {
private_dir(parent)?;
}
create_symlink(&target, &link)?;
}
let key = wrapped_key.context("backup is missing Secret Store key material")?;
ensure!(stage.join("config.sqlite3").is_file(), "backup is missing database");
Ok((manifest, key))
@@ -163,47 +178,65 @@ fn extract_inner_archive(path: &Path, stage: &Path) -> Result<(Manifest, Vec<u8>
pub async fn create(data_dir: &Path, output: &Path, password: &str) -> Result<BackupSummary> {
crate::auth::prepare_directory(data_dir)?;
let _lock = crate::auth::lock_directory(data_dir)
.context("stop RustProxy before creating a full backup")?;
let offline_lock = crate::auth::try_lock_directory(data_dir)?;
let database = data_dir.join("config.sqlite3");
ensure!(database.is_file(), "database does not exist at {}", database.display());
crate::auth::prepare_database(&database)?;
let store = crate::db::Store::open(&database, &crate::config::Config::default()).await?;
crate::secret_store::initialize(data_dir)?;
let cert_dir = data_dir.join("certs");
if cert_dir.is_dir() {
if offline_lock.is_some() && cert_dir.is_dir() {
crate::secret_store::migrate_acme_credentials(&store, data_dir, &cert_dir).await?;
}
let parent = parent_or_dot(output);
let data_root = fs::canonicalize(data_dir).context("cannot resolve data directory")?;
let output_parent = fs::canonicalize(parent).context("cannot resolve backup output directory")?;
ensure!(
!output_parent.starts_with(&data_root),
"backup output must be outside the RustProxy data directory"
);
ensure!(!output_parent.starts_with(&data_root), "backup output must be outside the RustProxy data directory");
let temp = create_temp_root(parent, "rustproxy-backup")?;
let snapshot = temp.0.join("config.sqlite3");
store.backup_to(&snapshot).await?;
let envelope = store.load().await?;
let snapshot_root = temp.0.join("data-snapshot");
let mut captured = None;
let mut last_error = None;
for _ in 0..3 {
let _ = fs::remove_file(&snapshot);
let _ = fs::remove_dir_all(&snapshot_root);
private_dir(&snapshot_root)?;
let envelope = store.backup_to(&snapshot).await?;
let mut entries = Vec::new();
let mut symlinks = Vec::new();
let result = collect_data_files(data_dir, data_dir, &snapshot_root, &mut entries, &mut symlinks)
.and_then(|_| validate_snapshot_symlinks(&snapshot_root, &symlinks))
.and_then(|_| validate_snapshot_config(&envelope, &snapshot_root));
match result {
Ok(()) => {
captured = Some((envelope, entries));
break;
}
Err(error) => last_error = Some(error),
}
}
let (envelope, mut entries) = captured.ok_or_else(|| {
last_error.unwrap_or_else(|| anyhow::anyhow!("could not capture a stable live data snapshot"))
})?;
let crypto = new_crypto(password)?;
let master = crate::secret_store::read_master_key_for_backup(data_dir)?;
let key_blob = seal_master_key(master.as_ref(), crypto.key.as_ref())?;
let (db_size, db_hash) = hash_file(&snapshot)?;
let mut entries = vec![ArchiveEntry {
entries.push(ArchiveEntry {
manifest: ManifestEntry {
path: "data/config.sqlite3".into(),
size: db_size,
sha256: db_hash,
kind: EntryKind::File,
},
source: EntrySource::File(snapshot),
}];
collect_data_files(data_dir, data_dir, &mut entries)?;
});
entries.push(ArchiveEntry {
manifest: ManifestEntry {
path: "keys/secret-store-key.enc".into(),
size: key_blob.len() as u64,
sha256: hash_bytes(&key_blob),
kind: EntryKind::File,
},
source: EntrySource::Bytes(key_blob),
});
@@ -222,6 +255,7 @@ pub async fn create(data_dir: &Path, output: &Path, password: &str) -> Result<Ba
write_inner_archive(&plaintext, &manifest, &entries)?;
encrypt_archive(&plaintext, output, &crypto)?;
store.close().await;
drop(offline_lock);
Ok(BackupSummary {
path: output.to_path_buf(),
files: manifest.entries.len(),
@@ -248,8 +282,7 @@ pub async fn restore(archive: &Path, data_dir: &Path, password: &str) -> Result<
let (manifest, wrapped_key) = extract_inner_archive(&plaintext, &stage)?;
let master = open_master_key(&wrapped_key, backup_key.as_ref())?;
crate::secret_store::write_restored_master_key(&stage, master.as_ref())?;
harden_tree(&stage)?;
harden_tree(&stage, &stage)?;
crate::auth::prepare_directory(&stage)?;
let stage_db = stage.join("config.sqlite3");
crate::auth::prepare_database(&stage_db)?;
+17 -3
View File
@@ -104,7 +104,7 @@ impl Store {
tx.commit().await?;
Ok(Self { pool })
}
pub async fn backup_to(&self, path: &Path) -> Result<()> {
pub async fn backup_to(&self, path: &Path) -> Result<Envelope> {
ensure!(!path.exists(), "backup database destination already exists");
let path_text = path
.to_str()
@@ -130,9 +130,23 @@ impl Store {
let result: String = sqlx::query_scalar("PRAGMA quick_check")
.fetch_one(&check_pool)
.await?;
check_pool.close().await;
ensure!(result == "ok", "SQLite snapshot integrity check failed");
Ok(())
let row = sqlx::query("SELECT revision, document FROM configuration WHERE id = 1")
.fetch_one(&check_pool)
.await?;
let revision: i64 = row.try_get("revision")?;
let document: String = row.try_get("document")?;
ensure!(
document.len() <= crate::config::MAX_CONFIG_BYTES,
"stored configuration exceeds size limit"
);
let envelope = Envelope {
revision: revision.try_into()?,
config: serde_json::from_str(&document)
.context("invalid stored configuration in SQLite snapshot")?,
};
check_pool.close().await;
Ok(envelope)
}
pub async fn load(&self) -> Result<Envelope> {
+2 -2
View File
@@ -41,7 +41,7 @@ use tracing_subscriber::prelude::*;
#[command(
name = "rust-proxy",
version,
about = "RustProxy - independent Rust reverse proxy and static Web Server 0.25.1"
about = "RustProxy - independent Rust reverse proxy and static Web Server 0.25.2"
)]
struct Cli {
#[arg(
@@ -135,7 +135,7 @@ enum ConfigCommand {
}
#[derive(Subcommand)]
enum BackupCommand {
/// Create an encrypted full backup. Stop the RustProxy service first.
/// Create an encrypted full backup. Safe while RustProxy is serving.
Create {
/// Destination .rpb file. Existing files are never overwritten.
#[arg(long)]
+1 -1
View File
@@ -226,7 +226,7 @@ async fn live_logs_command(dir: PathBuf, addr: SocketAddr) -> Result<()> {
}
});
let interactive = io::stdin().is_terminal() && io::stdout().is_terminal();
println!("RustProxy live logs 0.25.1 - all vhosts. Type 'help' for interactive filters; Ctrl-C or 'quit' exits.");
println!("RustProxy live logs 0.25.2 - all vhosts. Type 'help' for interactive filters; Ctrl-C or 'quit' exits.");
if interactive { print!("> "); io::stdout().flush()?; }
let mut filters = LiveLogFilters::default();
let mut history = std::collections::BTreeMap::<u64, serde_json::Value>::new();
+1 -1
View File
@@ -156,7 +156,7 @@ pub(super) fn fastcgi_params(
}
params.insert("HTTPS".into(), if context.tls { "on" } else { "off" }.into());
params.insert("GATEWAY_INTERFACE".into(), "CGI/1.1".into());
params.insert("SERVER_SOFTWARE".into(), "RustProxy/0.25.1".into());
params.insert("SERVER_SOFTWARE".into(), "RustProxy/0.25.2".into());
params.insert("REDIRECT_STATUS".into(), "200".into());
if let Some(value) = headers.get(CONTENT_TYPE).and_then(|value| value.to_str().ok()) {
params.insert("CONTENT_TYPE".into(), value.into());
+1 -1
View File
@@ -180,7 +180,7 @@ pub(super) fn directory_listing(path: &str, entries: &[DirectoryEntry]) -> Vec<u
"<a class=\"{class}\" href=\"{href}\"><span class=\"type\">{type_label}</span><span class=\"name\">{label}{slash}</span><span class=\"kind\">{kind}</span></a>"
));
}
html.push_str(r#"</div></section><footer class="page-footer"><span>RustProxy <b>0.25.1</b></span><span>Web Server · Directory listing</span></footer></main></body></html>"#);
html.push_str(r#"</div></section><footer class="page-footer"><span>RustProxy <b>0.25.2</b></span><span>Web Server · Directory listing</span></footer></main></body></html>"#);
html.into_bytes()
}
+1 -1
View File
@@ -202,7 +202,7 @@ async fn empty_index_can_go_directly_to_directory_listing() {
let body = std::str::from_utf8(&body).unwrap();
assert!(body.contains("file.txt"));
assert!(body.contains("DIRECTORY LISTING"));
assert!(body.contains("RustProxy <b>0.25.1</b>"));
assert!(body.contains("RustProxy <b>0.25.2</b>"));
}
#[tokio::test]