Implement backups, commit and restoration
This commit is contained in:
+1482
File diff suppressed because it is too large
Load Diff
+973
@@ -0,0 +1,973 @@
|
||||
//! Bounded, versioned backups of installed saved bytes.
|
||||
//!
|
||||
//! This module never extracts archive paths and never performs runtime VPN operations.
|
||||
|
||||
use std::fmt;
|
||||
use std::fs::{self, File};
|
||||
use std::io::{self, Read, Write};
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
use rustix::fs::{self as rfs, AtFlags, Mode, OFlags};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
use time::OffsetDateTime;
|
||||
use time::format_description::well_known::Rfc3339;
|
||||
|
||||
const VERSION: u32 = 1;
|
||||
const BLOCK: usize = 512;
|
||||
const MAX_ARCHIVE: usize = 26 * 1024 * 1024;
|
||||
const MAX_SETTINGS: usize = 1024 * 1024;
|
||||
const MAX_SERVER: usize = 4 * 1024 * 1024;
|
||||
const MAX_STATE: usize = 2 * 1024 * 1024;
|
||||
const MAX_VAULT: usize = 16 * 1024 * 1024;
|
||||
const MAX_LISTING_ENTRIES: usize = 4096;
|
||||
const MANIFEST: &str = "manifest.toml";
|
||||
const SETTINGS: &str = "settings.toml";
|
||||
const SERVER: &str = "server.conf";
|
||||
const STATE: &str = "internal-state.toml";
|
||||
const VAULT: &str = "vault.toml";
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(crate) enum ArchiveCheckpoint {
|
||||
Write,
|
||||
Sync,
|
||||
Publish,
|
||||
TemporaryCleanup,
|
||||
DirectorySync,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum BackupReason {
|
||||
Manual,
|
||||
PreCommit,
|
||||
PreRestore,
|
||||
PreRestart,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(tag = "status", rename_all = "kebab-case", deny_unknown_fields)]
|
||||
pub enum HistoricalApplyStatus {
|
||||
Unknown,
|
||||
Verified { verified_at_utc: String },
|
||||
}
|
||||
|
||||
/// Exact installed bytes captured by a trusted caller. `None` means the owned
|
||||
/// vault/state component was genuinely absent at capture time.
|
||||
pub struct SavedBytes {
|
||||
pub settings: Vec<u8>,
|
||||
pub server_config: Vec<u8>,
|
||||
pub internal_state: Option<Vec<u8>>,
|
||||
pub vault: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl fmt::Debug for SavedBytes {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("SavedBytes")
|
||||
.field("settings_len", &self.settings.len())
|
||||
.field("server_config_len", &self.server_config.len())
|
||||
.field("internal_state_present", &self.internal_state.is_some())
|
||||
.field("vault_present", &self.vault.is_some())
|
||||
.field("contents", &"[REDACTED]")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Manifest {
|
||||
version: u32,
|
||||
created_at_utc: String,
|
||||
reason: BackupReason,
|
||||
historical_apply: HistoricalApplyStatus,
|
||||
members: Vec<MemberRecord>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct MemberRecord {
|
||||
name: String,
|
||||
present: bool,
|
||||
length: Option<u64>,
|
||||
}
|
||||
|
||||
/// Validated read-only archive contents. Byte payloads are deliberately omitted by Debug.
|
||||
pub struct ArchivePreview {
|
||||
pub created_at_utc: String,
|
||||
pub reason: BackupReason,
|
||||
pub historical_apply: HistoricalApplyStatus,
|
||||
pub saved_bytes: SavedBytes,
|
||||
}
|
||||
|
||||
impl fmt::Debug for ArchivePreview {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("ArchivePreview")
|
||||
.field("created_at_utc", &self.created_at_utc)
|
||||
.field("reason", &self.reason)
|
||||
.field("historical_apply", &self.historical_apply)
|
||||
.field("saved_bytes", &self.saved_bytes)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct ArchiveSummary {
|
||||
pub file_name: String,
|
||||
pub created_at_utc: String,
|
||||
pub reason: BackupReason,
|
||||
pub historical_apply: HistoricalApplyStatus,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum BackupError {
|
||||
#[error("backup input exceeds a size limit")]
|
||||
TooLarge,
|
||||
#[error("backup archive is invalid or incomplete")]
|
||||
InvalidArchive,
|
||||
#[error("backup manifest version is not supported")]
|
||||
UnsupportedVersion,
|
||||
#[error("backup timestamp is invalid")]
|
||||
InvalidTimestamp,
|
||||
#[error("backup filesystem operation failed")]
|
||||
Io(#[source] io::Error),
|
||||
#[error("backup archive serialization failed")]
|
||||
Serialization,
|
||||
}
|
||||
|
||||
impl From<io::Error> for BackupError {
|
||||
fn from(error: io::Error) -> Self {
|
||||
Self::Io(error)
|
||||
}
|
||||
}
|
||||
impl From<rustix::io::Errno> for BackupError {
|
||||
fn from(error: rustix::io::Errno) -> Self {
|
||||
Self::Io(error.into())
|
||||
}
|
||||
}
|
||||
|
||||
/// Write a complete archive to a private temporary file and publish it without replacement.
|
||||
pub(crate) fn create_archive(
|
||||
archive_directory: &Path,
|
||||
reason: BackupReason,
|
||||
historical_apply: HistoricalApplyStatus,
|
||||
saved: &SavedBytes,
|
||||
) -> Result<PathBuf, BackupError> {
|
||||
create_archive_with_hook(archive_directory, reason, historical_apply, saved, |_| {
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn create_archive_with_hook(
|
||||
archive_directory: &Path,
|
||||
reason: BackupReason,
|
||||
historical_apply: HistoricalApplyStatus,
|
||||
saved: &SavedBytes,
|
||||
mut checkpoint: impl FnMut(ArchiveCheckpoint) -> io::Result<()>,
|
||||
) -> Result<PathBuf, BackupError> {
|
||||
validate_saved(saved)?;
|
||||
validate_status(&historical_apply)?;
|
||||
let created_at_utc = OffsetDateTime::now_utc()
|
||||
.replace_nanosecond(0)
|
||||
.map_err(|_| BackupError::InvalidTimestamp)?
|
||||
.format(&Rfc3339)
|
||||
.map_err(|_| BackupError::InvalidTimestamp)?;
|
||||
let bytes = encode_archive(&created_at_utc, reason, historical_apply, saved)?;
|
||||
let directory = ensure_archive_directory(archive_directory)?;
|
||||
|
||||
let stamp = created_at_utc.replace([':', '-'], "");
|
||||
for suffix in 0_u32..1000 {
|
||||
let name = if suffix == 0 {
|
||||
format!("wireguard-admin-{stamp}.tar")
|
||||
} else {
|
||||
format!("wireguard-admin-{stamp}-{suffix}.tar")
|
||||
};
|
||||
let target = archive_directory.join(name);
|
||||
let temporary = format!(
|
||||
".wireguard-admin-backup-{}-{}",
|
||||
std::process::id(),
|
||||
getrandom::u32().map_err(|_| BackupError::Serialization)?
|
||||
);
|
||||
let fd = rfs::openat(
|
||||
&directory,
|
||||
temporary.as_str(),
|
||||
OFlags::WRONLY | OFlags::CREATE | OFlags::EXCL | OFlags::NOFOLLOW | OFlags::CLOEXEC,
|
||||
Mode::from_bits_truncate(0o600),
|
||||
)?;
|
||||
let mut file = File::from(fd);
|
||||
let write_result = (|| -> Result<(), BackupError> {
|
||||
checkpoint(ArchiveCheckpoint::Write)?;
|
||||
file.write_all(&bytes)?;
|
||||
checkpoint(ArchiveCheckpoint::Sync)?;
|
||||
file.sync_all()?;
|
||||
Ok(())
|
||||
})();
|
||||
if let Err(error) = write_result {
|
||||
let _ = rfs::unlinkat(&directory, temporary.as_str(), AtFlags::empty());
|
||||
return Err(error);
|
||||
}
|
||||
let target_name = target.file_name().ok_or(BackupError::InvalidArchive)?;
|
||||
if let Err(error) = checkpoint(ArchiveCheckpoint::Publish) {
|
||||
let _ = rfs::unlinkat(&directory, temporary.as_str(), AtFlags::empty());
|
||||
return Err(BackupError::Io(error));
|
||||
}
|
||||
match rfs::linkat(
|
||||
&directory,
|
||||
temporary.as_str(),
|
||||
&directory,
|
||||
target_name,
|
||||
AtFlags::empty(),
|
||||
) {
|
||||
Ok(()) => {
|
||||
checkpoint(ArchiveCheckpoint::TemporaryCleanup)?;
|
||||
rfs::unlinkat(&directory, temporary.as_str(), AtFlags::empty())?;
|
||||
checkpoint(ArchiveCheckpoint::DirectorySync)?;
|
||||
File::from(directory.try_clone()?).sync_all()?;
|
||||
return Ok(target);
|
||||
}
|
||||
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
|
||||
rfs::unlinkat(&directory, temporary.as_str(), AtFlags::empty())?;
|
||||
continue;
|
||||
}
|
||||
Err(error) => {
|
||||
let _ = rfs::unlinkat(&directory, temporary.as_str(), AtFlags::empty());
|
||||
return Err(BackupError::Io(error.into()));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(BackupError::Io(io::Error::new(
|
||||
io::ErrorKind::AlreadyExists,
|
||||
"archive name collisions",
|
||||
)))
|
||||
}
|
||||
|
||||
/// Parse and validate a complete bounded archive without extracting any member.
|
||||
pub fn preview_archive_bytes(input: &[u8]) -> Result<ArchivePreview, BackupError> {
|
||||
if input.len() > MAX_ARCHIVE || input.len() < BLOCK * 4 || !input.len().is_multiple_of(BLOCK) {
|
||||
return Err(BackupError::InvalidArchive);
|
||||
}
|
||||
let mut offset = 0;
|
||||
let mut records = Vec::new();
|
||||
let mut previous_name: Option<String> = None;
|
||||
loop {
|
||||
let header = input
|
||||
.get(offset..offset + BLOCK)
|
||||
.ok_or(BackupError::InvalidArchive)?;
|
||||
if header.iter().all(|byte| *byte == 0) {
|
||||
let second = input
|
||||
.get(offset + BLOCK..offset + BLOCK * 2)
|
||||
.ok_or(BackupError::InvalidArchive)?;
|
||||
if second.iter().any(|byte| *byte != 0)
|
||||
|| input[offset + BLOCK * 2..].iter().any(|byte| *byte != 0)
|
||||
{
|
||||
return Err(BackupError::InvalidArchive);
|
||||
}
|
||||
break;
|
||||
}
|
||||
if records.len() >= 5 {
|
||||
return Err(BackupError::InvalidArchive);
|
||||
}
|
||||
let (name, size) = parse_header(header)?;
|
||||
if previous_name
|
||||
.as_ref()
|
||||
.is_some_and(|previous| previous == &name)
|
||||
{
|
||||
return Err(BackupError::InvalidArchive);
|
||||
}
|
||||
previous_name = Some(name.clone());
|
||||
let data_start = offset + BLOCK;
|
||||
let size_usize = usize::try_from(size).map_err(|_| BackupError::InvalidArchive)?;
|
||||
let padded = size_usize
|
||||
.checked_add(BLOCK - 1)
|
||||
.ok_or(BackupError::InvalidArchive)?
|
||||
/ BLOCK
|
||||
* BLOCK;
|
||||
let data_end = data_start
|
||||
.checked_add(size_usize)
|
||||
.ok_or(BackupError::InvalidArchive)?;
|
||||
let next = data_start
|
||||
.checked_add(padded)
|
||||
.ok_or(BackupError::InvalidArchive)?;
|
||||
let body = input
|
||||
.get(data_start..data_end)
|
||||
.ok_or(BackupError::InvalidArchive)?;
|
||||
if input
|
||||
.get(data_end..next)
|
||||
.ok_or(BackupError::InvalidArchive)?
|
||||
.iter()
|
||||
.any(|byte| *byte != 0)
|
||||
{
|
||||
return Err(BackupError::InvalidArchive);
|
||||
}
|
||||
records.push((name, body.to_vec()));
|
||||
offset = next;
|
||||
}
|
||||
let first = records.first().ok_or(BackupError::InvalidArchive)?;
|
||||
if first.0 != MANIFEST {
|
||||
return Err(BackupError::InvalidArchive);
|
||||
}
|
||||
let manifest: Manifest =
|
||||
toml::from_str(std::str::from_utf8(&first.1).map_err(|_| BackupError::InvalidArchive)?)
|
||||
.map_err(|_| BackupError::InvalidArchive)?;
|
||||
if manifest.version != VERSION {
|
||||
return Err(BackupError::UnsupportedVersion);
|
||||
}
|
||||
validate_timestamp(&manifest.created_at_utc)?;
|
||||
validate_status(&manifest.historical_apply)?;
|
||||
let expected_names = [SETTINGS, SERVER, STATE, VAULT];
|
||||
if manifest.members.len() != expected_names.len() {
|
||||
return Err(BackupError::InvalidArchive);
|
||||
}
|
||||
let mut payloads: Vec<Option<Vec<u8>>> = vec![None; 4];
|
||||
for (idx, expected) in expected_names.iter().enumerate() {
|
||||
let spec = &manifest.members[idx];
|
||||
if spec.name != *expected || spec.present != spec.length.is_some() {
|
||||
return Err(BackupError::InvalidArchive);
|
||||
}
|
||||
let max = limit_for(expected);
|
||||
if spec.length.is_some_and(|len| len > max as u64) {
|
||||
return Err(BackupError::TooLarge);
|
||||
}
|
||||
}
|
||||
if records.len() != 1 + manifest.members.iter().filter(|m| m.present).count() {
|
||||
return Err(BackupError::InvalidArchive);
|
||||
}
|
||||
let mut last_payload_index = None;
|
||||
for (name, body) in records.iter().skip(1) {
|
||||
let idx = expected_names
|
||||
.iter()
|
||||
.position(|expected| *expected == name)
|
||||
.ok_or(BackupError::InvalidArchive)?;
|
||||
if last_payload_index.is_some_and(|last| idx <= last) {
|
||||
return Err(BackupError::InvalidArchive);
|
||||
}
|
||||
last_payload_index = Some(idx);
|
||||
let spec = &manifest.members[idx];
|
||||
if !spec.present || spec.length != Some(body.len() as u64) || body.len() > limit_for(name) {
|
||||
return Err(BackupError::InvalidArchive);
|
||||
}
|
||||
if payloads[idx].replace(body.clone()).is_some() {
|
||||
return Err(BackupError::InvalidArchive);
|
||||
}
|
||||
}
|
||||
for (idx, spec) in manifest.members.iter().enumerate() {
|
||||
if spec.present != payloads[idx].is_some() {
|
||||
return Err(BackupError::InvalidArchive);
|
||||
}
|
||||
}
|
||||
Ok(ArchivePreview {
|
||||
created_at_utc: manifest.created_at_utc,
|
||||
reason: manifest.reason,
|
||||
historical_apply: manifest.historical_apply,
|
||||
saved_bytes: SavedBytes {
|
||||
settings: payloads[0].take().ok_or(BackupError::InvalidArchive)?,
|
||||
server_config: payloads[1].take().ok_or(BackupError::InvalidArchive)?,
|
||||
internal_state: payloads[2].take(),
|
||||
vault: payloads[3].take(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
pub fn preview_archive(path: &Path) -> Result<ArchivePreview, BackupError> {
|
||||
validate_path_components(path)?;
|
||||
let metadata = fs::symlink_metadata(path)?;
|
||||
if !metadata.file_type().is_file() || metadata.len() > MAX_ARCHIVE as u64 {
|
||||
return Err(BackupError::InvalidArchive);
|
||||
}
|
||||
let file = fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.custom_flags(0o400000)
|
||||
.open(path)?;
|
||||
if !file.metadata()?.file_type().is_file() {
|
||||
return Err(BackupError::InvalidArchive);
|
||||
}
|
||||
let mut bytes = Vec::with_capacity(metadata.len() as usize);
|
||||
file.take((MAX_ARCHIVE + 1) as u64)
|
||||
.read_to_end(&mut bytes)?;
|
||||
preview_archive_bytes(&bytes)
|
||||
}
|
||||
|
||||
/// List only validated archives. Invalid files are omitted rather than trusted for display.
|
||||
pub fn list_archives(directory: &Path) -> Result<Vec<ArchiveSummary>, BackupError> {
|
||||
validate_path_components(directory)?;
|
||||
if !fs::symlink_metadata(directory)?.is_dir() {
|
||||
return Err(BackupError::InvalidArchive);
|
||||
}
|
||||
let mut summaries = Vec::new();
|
||||
let mut inspected = 0;
|
||||
for entry in fs::read_dir(directory)? {
|
||||
inspected += 1;
|
||||
if inspected > MAX_LISTING_ENTRIES {
|
||||
return Err(BackupError::TooLarge);
|
||||
}
|
||||
let entry = entry?;
|
||||
let file_type = entry.file_type()?;
|
||||
let name = entry.file_name();
|
||||
let Some(name_text) = name.to_str() else {
|
||||
continue;
|
||||
};
|
||||
if !file_type.is_file()
|
||||
|| !name_text.starts_with("wireguard-admin-")
|
||||
|| !name_text.ends_with(".tar")
|
||||
|| !name_text
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.'))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if let Ok(preview) = preview_archive(&entry.path()) {
|
||||
summaries.push(ArchiveSummary {
|
||||
file_name: name_text.to_owned(),
|
||||
created_at_utc: preview.created_at_utc,
|
||||
reason: preview.reason,
|
||||
historical_apply: preview.historical_apply,
|
||||
});
|
||||
}
|
||||
}
|
||||
summaries.sort_by(|a, b| {
|
||||
b.created_at_utc
|
||||
.cmp(&a.created_at_utc)
|
||||
.then_with(|| a.file_name.cmp(&b.file_name))
|
||||
});
|
||||
Ok(summaries)
|
||||
}
|
||||
|
||||
fn ensure_archive_directory(path: &Path) -> Result<std::os::fd::OwnedFd, BackupError> {
|
||||
if !path.is_absolute() {
|
||||
return Err(BackupError::InvalidArchive);
|
||||
}
|
||||
let mut directory = rfs::open(
|
||||
"/",
|
||||
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC,
|
||||
Mode::empty(),
|
||||
)?;
|
||||
for component in path.components().skip(1) {
|
||||
let Component::Normal(name) = component else {
|
||||
return Err(BackupError::InvalidArchive);
|
||||
};
|
||||
match rfs::mkdirat(&directory, name, Mode::from_bits_truncate(0o700)) {
|
||||
Ok(()) => {}
|
||||
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {}
|
||||
Err(error) => return Err(BackupError::Io(error.into())),
|
||||
}
|
||||
directory = rfs::openat(
|
||||
&directory,
|
||||
name,
|
||||
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
|
||||
Mode::empty(),
|
||||
)?;
|
||||
}
|
||||
rfs::fchmod(&directory, Mode::from_bits_truncate(0o700))?;
|
||||
File::from(directory.try_clone()?).sync_all()?;
|
||||
Ok(directory)
|
||||
}
|
||||
|
||||
fn validate_path_components(path: &Path) -> Result<(), BackupError> {
|
||||
if !path.is_absolute() {
|
||||
return Err(BackupError::InvalidArchive);
|
||||
}
|
||||
let mut directory = rfs::open(
|
||||
"/",
|
||||
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC,
|
||||
Mode::empty(),
|
||||
)?;
|
||||
let components = path.components().skip(1).collect::<Vec<_>>();
|
||||
for (index, component) in components.iter().enumerate() {
|
||||
let Component::Normal(name) = component else {
|
||||
return Err(BackupError::InvalidArchive);
|
||||
};
|
||||
if index + 1 == components.len() {
|
||||
// Final item may be a regular archive file, so reject symlinks via
|
||||
// no-follow open at the call site instead of requiring a directory.
|
||||
match rfs::openat(
|
||||
&directory,
|
||||
*name,
|
||||
OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
|
||||
Mode::empty(),
|
||||
) {
|
||||
Ok(fd) => {
|
||||
let metadata = File::from(fd).metadata()?;
|
||||
if !metadata.is_file() && !metadata.is_dir() {
|
||||
return Err(BackupError::InvalidArchive);
|
||||
}
|
||||
}
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
|
||||
Err(error) => return Err(BackupError::Io(error.into())),
|
||||
}
|
||||
} else {
|
||||
directory = rfs::openat(
|
||||
&directory,
|
||||
*name,
|
||||
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
|
||||
Mode::empty(),
|
||||
)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_saved(saved: &SavedBytes) -> Result<(), BackupError> {
|
||||
for (bytes, max) in [
|
||||
(&saved.settings, MAX_SETTINGS),
|
||||
(&saved.server_config, MAX_SERVER),
|
||||
] {
|
||||
if bytes.len() > max {
|
||||
return Err(BackupError::TooLarge);
|
||||
}
|
||||
}
|
||||
if saved
|
||||
.internal_state
|
||||
.as_ref()
|
||||
.is_some_and(|bytes| bytes.len() > MAX_STATE)
|
||||
|| saved
|
||||
.vault
|
||||
.as_ref()
|
||||
.is_some_and(|bytes| bytes.len() > MAX_VAULT)
|
||||
{
|
||||
return Err(BackupError::TooLarge);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_status(status: &HistoricalApplyStatus) -> Result<(), BackupError> {
|
||||
if let HistoricalApplyStatus::Verified { verified_at_utc } = status {
|
||||
validate_timestamp(verified_at_utc)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_timestamp(value: &str) -> Result<(), BackupError> {
|
||||
let parsed =
|
||||
OffsetDateTime::parse(value, &Rfc3339).map_err(|_| BackupError::InvalidTimestamp)?;
|
||||
if parsed.offset().whole_seconds() != 0
|
||||
|| parsed.nanosecond() != 0
|
||||
|| parsed
|
||||
.format(&Rfc3339)
|
||||
.map_err(|_| BackupError::InvalidTimestamp)?
|
||||
!= value
|
||||
{
|
||||
return Err(BackupError::InvalidTimestamp);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn encode_archive(
|
||||
created_at: &str,
|
||||
reason: BackupReason,
|
||||
status: HistoricalApplyStatus,
|
||||
saved: &SavedBytes,
|
||||
) -> Result<Vec<u8>, BackupError> {
|
||||
encode_archive_version(VERSION, created_at, reason, status, saved)
|
||||
}
|
||||
|
||||
fn encode_archive_version(
|
||||
version: u32,
|
||||
created_at: &str,
|
||||
reason: BackupReason,
|
||||
status: HistoricalApplyStatus,
|
||||
saved: &SavedBytes,
|
||||
) -> Result<Vec<u8>, BackupError> {
|
||||
let members = vec![
|
||||
MemberRecord {
|
||||
name: SETTINGS.into(),
|
||||
present: true,
|
||||
length: Some(saved.settings.len() as u64),
|
||||
},
|
||||
MemberRecord {
|
||||
name: SERVER.into(),
|
||||
present: true,
|
||||
length: Some(saved.server_config.len() as u64),
|
||||
},
|
||||
MemberRecord {
|
||||
name: STATE.into(),
|
||||
present: saved.internal_state.is_some(),
|
||||
length: saved.internal_state.as_ref().map(|v| v.len() as u64),
|
||||
},
|
||||
MemberRecord {
|
||||
name: VAULT.into(),
|
||||
present: saved.vault.is_some(),
|
||||
length: saved.vault.as_ref().map(|v| v.len() as u64),
|
||||
},
|
||||
];
|
||||
let manifest = toml::to_string(&Manifest {
|
||||
version,
|
||||
created_at_utc: created_at.into(),
|
||||
reason,
|
||||
historical_apply: status,
|
||||
members,
|
||||
})
|
||||
.map_err(|_| BackupError::Serialization)?;
|
||||
let mut builder = tar::Builder::new(Vec::new());
|
||||
builder.mode(tar::HeaderMode::Deterministic);
|
||||
append(&mut builder, MANIFEST, manifest.as_bytes())?;
|
||||
append(&mut builder, SETTINGS, &saved.settings)?;
|
||||
append(&mut builder, SERVER, &saved.server_config)?;
|
||||
if let Some(state) = &saved.internal_state {
|
||||
append(&mut builder, STATE, state)?;
|
||||
}
|
||||
if let Some(vault) = &saved.vault {
|
||||
append(&mut builder, VAULT, vault)?;
|
||||
}
|
||||
builder.finish().map_err(|_| BackupError::Serialization)?;
|
||||
let bytes = builder
|
||||
.into_inner()
|
||||
.map_err(|_| BackupError::Serialization)?;
|
||||
if bytes.len() > MAX_ARCHIVE {
|
||||
return Err(BackupError::TooLarge);
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
fn append(
|
||||
builder: &mut tar::Builder<Vec<u8>>,
|
||||
name: &str,
|
||||
bytes: &[u8],
|
||||
) -> Result<(), BackupError> {
|
||||
let mut header = tar::Header::new_ustar();
|
||||
header.set_entry_type(tar::EntryType::Regular);
|
||||
header
|
||||
.set_path(name)
|
||||
.map_err(|_| BackupError::Serialization)?;
|
||||
header.set_size(bytes.len() as u64);
|
||||
header.set_mode(0o600);
|
||||
header.set_uid(0);
|
||||
header.set_gid(0);
|
||||
header.set_mtime(0);
|
||||
header.set_cksum();
|
||||
builder
|
||||
.append(&header, bytes)
|
||||
.map_err(|_| BackupError::Serialization)
|
||||
}
|
||||
|
||||
fn parse_header(header: &[u8]) -> Result<(String, u64), BackupError> {
|
||||
if header.len() != BLOCK
|
||||
|| &header[257..263] != b"ustar\0"
|
||||
|| &header[263..265] != b"00"
|
||||
|| header[156] != 0 && header[156] != b'0'
|
||||
|| header[157..257].iter().any(|byte| *byte != 0)
|
||||
|| header[265..345].iter().any(|byte| *byte != 0)
|
||||
|| header[345..500].iter().any(|byte| *byte != 0)
|
||||
|| header[500..512].iter().any(|byte| *byte != 0)
|
||||
{
|
||||
return Err(BackupError::InvalidArchive);
|
||||
}
|
||||
let stored_checksum = parse_octal(&header[148..156])?;
|
||||
let checksum = header[..148]
|
||||
.iter()
|
||||
.chain(&header[156..])
|
||||
.map(|byte| *byte as u64)
|
||||
.sum::<u64>()
|
||||
+ 8 * b' ' as u64;
|
||||
if stored_checksum != checksum {
|
||||
return Err(BackupError::InvalidArchive);
|
||||
}
|
||||
if parse_octal(&header[100..108])? != 0o600
|
||||
|| parse_octal(&header[108..116])? != 0
|
||||
|| parse_octal(&header[116..124])? != 0
|
||||
|| parse_octal(&header[136..148])? != 0
|
||||
{
|
||||
return Err(BackupError::InvalidArchive);
|
||||
}
|
||||
let raw_name = &header[..100];
|
||||
let end = raw_name
|
||||
.iter()
|
||||
.position(|byte| *byte == 0)
|
||||
.unwrap_or(raw_name.len());
|
||||
if raw_name[end..].iter().any(|byte| *byte != 0) {
|
||||
return Err(BackupError::InvalidArchive);
|
||||
}
|
||||
let name = std::str::from_utf8(&raw_name[..end])
|
||||
.map_err(|_| BackupError::InvalidArchive)?
|
||||
.to_owned();
|
||||
if ![MANIFEST, SETTINGS, SERVER, STATE, VAULT].contains(&name.as_str()) {
|
||||
return Err(BackupError::InvalidArchive);
|
||||
}
|
||||
let size = parse_octal(&header[124..136])?;
|
||||
if size > limit_for(&name) as u64 && name != MANIFEST {
|
||||
return Err(BackupError::TooLarge);
|
||||
}
|
||||
if name == MANIFEST && size > 64 * 1024 {
|
||||
return Err(BackupError::TooLarge);
|
||||
}
|
||||
Ok((name, size))
|
||||
}
|
||||
|
||||
fn parse_octal(field: &[u8]) -> Result<u64, BackupError> {
|
||||
if field.is_empty() || field[0] & 0x80 != 0 {
|
||||
return Err(BackupError::InvalidArchive);
|
||||
}
|
||||
let text = std::str::from_utf8(field).map_err(|_| BackupError::InvalidArchive)?;
|
||||
let text = text.trim_matches(['\0', ' ']);
|
||||
if text.is_empty() || !text.bytes().all(|b| (b'0'..=b'7').contains(&b)) {
|
||||
return Err(BackupError::InvalidArchive);
|
||||
}
|
||||
u64::from_str_radix(text, 8).map_err(|_| BackupError::InvalidArchive)
|
||||
}
|
||||
|
||||
fn limit_for(name: &str) -> usize {
|
||||
match name {
|
||||
SETTINGS => MAX_SETTINGS,
|
||||
SERVER => MAX_SERVER,
|
||||
STATE => MAX_STATE,
|
||||
VAULT => MAX_VAULT,
|
||||
_ => 64 * 1024,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
fn fixture() -> SavedBytes {
|
||||
SavedBytes {
|
||||
settings: b"settings fixture".to_vec(),
|
||||
server_config: b"[Interface]\nPrivateKey = synthetic\n".to_vec(),
|
||||
internal_state: None,
|
||||
vault: Some(b"encrypted fixture".to_vec()),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn archive_round_trip_preserves_bytes_and_absence() {
|
||||
let saved = fixture();
|
||||
let bytes = encode_archive(
|
||||
"2026-10-19T12:30:00Z",
|
||||
BackupReason::PreCommit,
|
||||
HistoricalApplyStatus::Unknown,
|
||||
&saved,
|
||||
)
|
||||
.expect("archive fixture");
|
||||
let preview = preview_archive_bytes(&bytes).expect("preview");
|
||||
assert_eq!(preview.saved_bytes.settings, saved.settings);
|
||||
assert_eq!(preview.saved_bytes.server_config, saved.server_config);
|
||||
assert_eq!(preview.saved_bytes.internal_state, None);
|
||||
assert_eq!(preview.saved_bytes.vault, saved.vault);
|
||||
assert_eq!(preview.reason, BackupReason::PreCommit);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn archive_publication_is_private_and_listing_validates() {
|
||||
let dir = tempfile::tempdir().expect("temporary directory");
|
||||
let path = create_archive(
|
||||
dir.path(),
|
||||
BackupReason::Manual,
|
||||
HistoricalApplyStatus::Unknown,
|
||||
&fixture(),
|
||||
)
|
||||
.expect("create");
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
assert_eq!(
|
||||
fs::metadata(&path).expect("metadata").permissions().mode() & 0o777,
|
||||
0o600
|
||||
);
|
||||
assert_eq!(list_archives(dir.path()).expect("list").len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn injected_archive_write_and_finalization_failures_never_report_success() {
|
||||
let saved = fixture();
|
||||
for point in [
|
||||
ArchiveCheckpoint::Write,
|
||||
ArchiveCheckpoint::Sync,
|
||||
ArchiveCheckpoint::Publish,
|
||||
ArchiveCheckpoint::TemporaryCleanup,
|
||||
ArchiveCheckpoint::DirectorySync,
|
||||
] {
|
||||
let dir = tempfile::tempdir().expect("temporary directory");
|
||||
let result = create_archive_with_hook(
|
||||
dir.path(),
|
||||
BackupReason::PreCommit,
|
||||
HistoricalApplyStatus::Unknown,
|
||||
&saved,
|
||||
|current| {
|
||||
if current == point {
|
||||
Err(io::Error::other("archive fault injection"))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
},
|
||||
);
|
||||
assert!(result.is_err(), "{point:?} must not report archive success");
|
||||
let entries = fs::read_dir(dir.path())
|
||||
.expect("archive directory remains readable")
|
||||
.map(|entry| entry.unwrap().path())
|
||||
.collect::<Vec<_>>();
|
||||
assert!(entries.iter().all(|path| {
|
||||
path.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.is_some_and(|name| {
|
||||
name.starts_with(".") || name.starts_with("wireguard-admin-")
|
||||
})
|
||||
}));
|
||||
for path in entries.iter().filter(|path| {
|
||||
path.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.is_some_and(|name| name.starts_with("wireguard-admin-"))
|
||||
}) {
|
||||
assert!(
|
||||
preview_archive(path).is_ok(),
|
||||
"published archive is complete"
|
||||
);
|
||||
}
|
||||
if matches!(
|
||||
point,
|
||||
ArchiveCheckpoint::Write | ArchiveCheckpoint::Sync | ArchiveCheckpoint::Publish
|
||||
) {
|
||||
assert!(
|
||||
entries.is_empty(),
|
||||
"pre-publication failure leaves no artifact"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_truncation_and_nonzero_trailing_bytes() {
|
||||
let saved = fixture();
|
||||
let mut bytes = encode_archive(
|
||||
"2026-10-19T12:30:00Z",
|
||||
BackupReason::Manual,
|
||||
HistoricalApplyStatus::Unknown,
|
||||
&saved,
|
||||
)
|
||||
.expect("archive");
|
||||
assert!(matches!(
|
||||
preview_archive_bytes(&bytes[..bytes.len() - BLOCK]),
|
||||
Err(BackupError::InvalidArchive)
|
||||
));
|
||||
let last = bytes.len() - 1;
|
||||
bytes[last] = 1;
|
||||
assert!(preview_archive_bytes(&bytes).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unsupported_version_and_extensions() {
|
||||
let saved = fixture();
|
||||
let unsupported = encode_archive_version(
|
||||
99,
|
||||
"2026-10-19T12:30:00Z",
|
||||
BackupReason::Manual,
|
||||
HistoricalApplyStatus::Unknown,
|
||||
&saved,
|
||||
)
|
||||
.expect("archive");
|
||||
assert!(matches!(
|
||||
preview_archive_bytes(&unsupported),
|
||||
Err(BackupError::UnsupportedVersion)
|
||||
));
|
||||
let mut bytes = encode_archive(
|
||||
"2026-10-19T12:30:00Z",
|
||||
BackupReason::Manual,
|
||||
HistoricalApplyStatus::Unknown,
|
||||
&saved,
|
||||
)
|
||||
.expect("archive");
|
||||
bytes[156] = b'x';
|
||||
let checksum = bytes[..148]
|
||||
.iter()
|
||||
.chain(&bytes[156..BLOCK])
|
||||
.map(|byte| *byte as u64)
|
||||
.sum::<u64>()
|
||||
+ 8 * b' ' as u64;
|
||||
let checksum_text = format!("{checksum:06o}\0 ");
|
||||
bytes[148..156].copy_from_slice(checksum_text.as_bytes());
|
||||
assert!(preview_archive_bytes(&bytes).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_traversal_and_link_headers_even_when_checksums_match() {
|
||||
let saved = fixture();
|
||||
let original = encode_archive(
|
||||
"2026-10-19T12:30:00Z",
|
||||
BackupReason::Manual,
|
||||
HistoricalApplyStatus::Unknown,
|
||||
&saved,
|
||||
)
|
||||
.expect("archive");
|
||||
for (offset, field) in [(0, 0), (0, 1)] {
|
||||
let mut bytes = original.clone();
|
||||
match field {
|
||||
0 => {
|
||||
bytes[offset..offset + 100].fill(0);
|
||||
bytes[offset..offset + 14].copy_from_slice(b"../server.conf");
|
||||
}
|
||||
_ => bytes[offset + 156] = b'2',
|
||||
}
|
||||
let checksum = bytes[offset..offset + 148]
|
||||
.iter()
|
||||
.chain(&bytes[offset + 156..offset + BLOCK])
|
||||
.map(|byte| *byte as u64)
|
||||
.sum::<u64>()
|
||||
+ 8 * b' ' as u64;
|
||||
let checksum_text = format!("{checksum:06o}\0 ");
|
||||
bytes[offset + 148..offset + 156].copy_from_slice(checksum_text.as_bytes());
|
||||
assert!(preview_archive_bytes(&bytes).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_creation_does_not_overwrite_and_absence_is_explicit() {
|
||||
let dir = tempfile::tempdir().expect("temporary directory");
|
||||
let saved = SavedBytes {
|
||||
settings: b"settings".to_vec(),
|
||||
server_config: b"server".to_vec(),
|
||||
internal_state: None,
|
||||
vault: None,
|
||||
};
|
||||
let first = create_archive(
|
||||
dir.path(),
|
||||
BackupReason::PreRestart,
|
||||
HistoricalApplyStatus::Unknown,
|
||||
&saved,
|
||||
)
|
||||
.expect("first archive");
|
||||
let second = create_archive(
|
||||
dir.path(),
|
||||
BackupReason::PreRestart,
|
||||
HistoricalApplyStatus::Unknown,
|
||||
&saved,
|
||||
)
|
||||
.expect("second archive");
|
||||
assert_ne!(first, second);
|
||||
let preview = preview_archive(&second).expect("preview");
|
||||
assert_eq!(preview.saved_bytes.internal_state, None);
|
||||
assert_eq!(preview.saved_bytes.vault, None);
|
||||
assert_eq!(list_archives(dir.path()).expect("list").len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn archive_directory_and_selected_file_symlinks_are_rejected() {
|
||||
let root = tempfile::tempdir().expect("temporary directory");
|
||||
let real_directory = root.path().join("real-archives");
|
||||
fs::create_dir(&real_directory).expect("archive target directory");
|
||||
let linked_directory = root.path().join("archives-link");
|
||||
symlink(&real_directory, &linked_directory).expect("directory symlink");
|
||||
assert!(
|
||||
create_archive(
|
||||
&linked_directory,
|
||||
BackupReason::Manual,
|
||||
HistoricalApplyStatus::Unknown,
|
||||
&fixture(),
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
fs::read_dir(&real_directory)
|
||||
.expect("real directory is readable")
|
||||
.next()
|
||||
.is_none()
|
||||
);
|
||||
|
||||
let archive = create_archive(
|
||||
&real_directory,
|
||||
BackupReason::Manual,
|
||||
HistoricalApplyStatus::Unknown,
|
||||
&fixture(),
|
||||
)
|
||||
.expect("regular archive");
|
||||
let linked_archive = root.path().join("archive-link.tar");
|
||||
symlink(archive, &linked_archive).expect("archive symlink");
|
||||
assert!(preview_archive(&linked_archive).is_err());
|
||||
}
|
||||
}
|
||||
@@ -211,6 +211,25 @@ impl ClientStates {
|
||||
&self.reserved_addresses
|
||||
}
|
||||
|
||||
/// Restore conservative reservations from saved metadata without asserting
|
||||
/// that the running interface matches the saved snapshot.
|
||||
pub fn restore_persisted_reservations(
|
||||
&mut self,
|
||||
reservations: impl IntoIterator<Item = IpAddr>,
|
||||
) -> Result<(), DomainError> {
|
||||
let reservations = reservations.into_iter().collect::<BTreeSet<_>>();
|
||||
if reservations
|
||||
.iter()
|
||||
.any(|address| self.saved.addresses().any(|saved| saved == *address))
|
||||
{
|
||||
return Err(DomainError::ReservationConflictsWithSavedClient);
|
||||
}
|
||||
self.reserved_addresses = reservations;
|
||||
self.applied_snapshot = None;
|
||||
self.applied = RuntimeObservation::Unknown;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn draft_mut(&mut self) -> &mut Snapshot {
|
||||
&mut self.draft
|
||||
}
|
||||
@@ -297,6 +316,8 @@ pub enum DomainError {
|
||||
AddressUnavailable,
|
||||
#[error("verified applied clients do not match the saved snapshot")]
|
||||
AppliedSnapshotMismatch,
|
||||
#[error("persisted reservation conflicts with a saved client address")]
|
||||
ReservationConflictsWithSavedClient,
|
||||
}
|
||||
|
||||
/// Return the lowest available host in `range` without walking the subnet.
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
pub mod app;
|
||||
pub mod backup;
|
||||
pub mod config;
|
||||
pub mod domain;
|
||||
pub mod persistence;
|
||||
pub mod startup;
|
||||
pub mod vault;
|
||||
pub mod wireguard;
|
||||
|
||||
+1299
File diff suppressed because it is too large
Load Diff
@@ -258,6 +258,13 @@ impl VaultBundle {
|
||||
self.records.contains_key(&label.to_string())
|
||||
}
|
||||
|
||||
/// Remove only the record identified by its canonical WireGuard public key.
|
||||
/// The encrypted identity and every other record remain unchanged.
|
||||
pub fn remove_record(&mut self, label: PublicKey) -> Result<bool, VaultError> {
|
||||
self.validate_shape()?;
|
||||
Ok(self.records.remove(&label.to_string()).is_some())
|
||||
}
|
||||
|
||||
/// Retrieve a single record, freshly unlocking and verifying the identity on every call.
|
||||
pub fn retrieve_record(
|
||||
&self,
|
||||
|
||||
+14
-3
@@ -366,6 +366,11 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::vault::WireGuardPrivateKey;
|
||||
use std::fs;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
fn make_private(path: &Path) {
|
||||
fs::set_permissions(path, fs::Permissions::from_mode(0o600)).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entry_enumeration_error_is_visible_and_other_files_still_import() {
|
||||
@@ -386,7 +391,9 @@ mod tests {
|
||||
client_key.to_base64(),
|
||||
server_key.public_key()
|
||||
);
|
||||
fs::write(directory.path().join("valid.conf"), &client_bytes).unwrap();
|
||||
let source_file = directory.path().join("valid.conf");
|
||||
fs::write(&source_file, &client_bytes).unwrap();
|
||||
make_private(&source_file);
|
||||
let dirfd = open_source_directory(directory.path()).unwrap();
|
||||
let entries = [
|
||||
Ok(OsString::from("valid.conf")),
|
||||
@@ -424,13 +431,16 @@ mod tests {
|
||||
client_key.to_base64(),
|
||||
server_key.public_key()
|
||||
);
|
||||
fs::write(source.join("client.conf"), &bytes).unwrap();
|
||||
let client_file = source.join("client.conf");
|
||||
fs::write(&client_file, &bytes).unwrap();
|
||||
make_private(&client_file);
|
||||
|
||||
let directory = open_source_directory(&source).unwrap();
|
||||
fs::rename(&source, &pinned_path).unwrap();
|
||||
fs::create_dir(&source).unwrap();
|
||||
let replacement = source.join("replacement.conf");
|
||||
fs::write(
|
||||
source.join("replacement.conf"),
|
||||
&replacement,
|
||||
format!(
|
||||
"[Interface]\nPrivateKey = {}\nAddress = 10.8.0.3/32\n\n[Peer]\nPublicKey = {}\n",
|
||||
decoy_key.to_base64(),
|
||||
@@ -438,6 +448,7 @@ mod tests {
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
make_private(&replacement);
|
||||
|
||||
let entries = rfs::Dir::read_from(&directory).unwrap();
|
||||
let mut bundle = VaultBundle::create("password").unwrap();
|
||||
|
||||
Reference in New Issue
Block a user