297 lines
9.6 KiB
Rust
297 lines
9.6 KiB
Rust
fn excluded_file(name: &str) -> bool {
|
|
name == "process.lock"
|
|
|| name == ".geoip-update.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 {
|
|
if crate::tls::certificate_files_are_external(
|
|
&certificate.cert_file,
|
|
&certificate.key_file,
|
|
)? {
|
|
// External certificate material is intentionally outside the RustProxy
|
|
// data tree and is therefore referenced by configuration, not copied
|
|
// into a RustProxy backup. The operator must restore/mount it separately.
|
|
continue;
|
|
}
|
|
for name in [&certificate.cert_file, &certificate.key_file] {
|
|
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(())
|
|
}
|