v0.25.2
This commit is contained in:
1 parent
59ddabb72b
commit
174389e5b1
48 files changed
+688
-212
No files matched your search
@@ -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"))),
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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(); } });
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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};
|
||||
|
||||
@@ -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
@@ -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(())
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)?;
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in new issue
Block a user