Implement backups, commit and restoration
This commit is contained in:
@@ -0,0 +1,524 @@
|
||||
use std::fs;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use wireguard_admin::app::{AppError, Application};
|
||||
use wireguard_admin::backup::{BackupReason, preview_archive, preview_archive_bytes};
|
||||
use wireguard_admin::config::{ResolvedSettings, SettingsFile};
|
||||
use wireguard_admin::domain::{
|
||||
Client, ClientAddresses, ClientStates, PublicKey, RuntimeObservation, Snapshot,
|
||||
};
|
||||
use wireguard_admin::persistence::{PersistedState, SavedStore};
|
||||
use wireguard_admin::vault::{VaultBundle, WireGuardPrivateKey};
|
||||
use wireguard_admin::wireguard::ConfigDocument;
|
||||
|
||||
const SETTINGS: &str = r#"
|
||||
interface_name = "wg0"
|
||||
server_config_path = "server.conf"
|
||||
client_source_directory = "clients"
|
||||
server_endpoint = "vpn.example.test:51820"
|
||||
ipv4_allocation_range = "10.20.0.0/24"
|
||||
ipv6_allocation_range = "fd20::/64"
|
||||
dns_addresses = ["10.20.0.1"]
|
||||
systemd_unit = "wg-quick@wg0.service"
|
||||
restart_timeout_seconds = 30
|
||||
"#;
|
||||
|
||||
struct Fixture {
|
||||
root: tempfile::TempDir,
|
||||
store: SavedStore,
|
||||
settings: ResolvedSettings,
|
||||
states: ClientStates,
|
||||
key: PublicKey,
|
||||
secret: WireGuardPrivateKey,
|
||||
}
|
||||
|
||||
fn fixture(with_client: bool) -> Fixture {
|
||||
let root = tempfile::tempdir().expect("temporary fixture directory");
|
||||
let settings = SettingsFile::parse_toml(SETTINGS)
|
||||
.expect("settings parse")
|
||||
.validate()
|
||||
.expect("settings validate")
|
||||
.resolve_paths(root.path());
|
||||
fs::create_dir_all(&settings.client_source_directory).expect("source directory");
|
||||
fs::write(
|
||||
ResolvedSettings::settings_path(root.path()),
|
||||
SETTINGS.as_bytes(),
|
||||
)
|
||||
.expect("settings file");
|
||||
let secret = WireGuardPrivateKey::generate().expect("synthetic key");
|
||||
let key = secret.public_key();
|
||||
let mut server = b"[Interface]\nPrivateKey = fixture-server-secret\nAddress = 10.20.0.1/24\n\n# Keep this administrator note byte-for-byte.\n".to_vec();
|
||||
let clients = if with_client {
|
||||
server.extend_from_slice(
|
||||
format!("[Peer]\nPublicKey = {key}\nAllowedIPs = 10.20.0.2/32, fd20::2/128\n")
|
||||
.as_bytes(),
|
||||
);
|
||||
vec![
|
||||
Client::new(
|
||||
key,
|
||||
"fixture client",
|
||||
ClientAddresses {
|
||||
ipv4: "10.20.0.2".parse().expect("IPv4 fixture"),
|
||||
ipv6: Some("fd20::2".parse().expect("IPv6 fixture")),
|
||||
},
|
||||
)
|
||||
.expect("client"),
|
||||
]
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
fs::write(&settings.server_config_path, &server).expect("server configuration");
|
||||
fs::set_permissions(
|
||||
&settings.server_config_path,
|
||||
fs::Permissions::from_mode(0o600),
|
||||
)
|
||||
.expect("server fixture is private");
|
||||
let snapshot = Snapshot::new(clients).expect("initial snapshot");
|
||||
let states = ClientStates::new(
|
||||
snapshot.clone(),
|
||||
Some(snapshot),
|
||||
RuntimeObservation::Verified,
|
||||
)
|
||||
.expect("initial states");
|
||||
let store = SavedStore::new(root.path(), settings.clone()).expect("saved store");
|
||||
Fixture {
|
||||
root,
|
||||
store,
|
||||
settings,
|
||||
states,
|
||||
key,
|
||||
secret,
|
||||
}
|
||||
}
|
||||
|
||||
fn open(fixture: &Fixture) -> Application {
|
||||
Application::open(fixture.store.clone(), fixture.states.clone()).expect("application opens")
|
||||
}
|
||||
|
||||
fn archive_name(path: &Path) -> &str {
|
||||
path.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.expect("UTF-8 archive name")
|
||||
}
|
||||
|
||||
fn update_checksum(header: &mut [u8]) {
|
||||
header[148..156].fill(b' ');
|
||||
let checksum = header.iter().map(|byte| usize::from(*byte)).sum::<usize>();
|
||||
let encoded = format!("{checksum:06o}\0 ");
|
||||
assert_eq!(encoded.len(), 8);
|
||||
header[148..156].copy_from_slice(encoded.as_bytes());
|
||||
}
|
||||
|
||||
fn archive_with_header_change(
|
||||
source: &[u8],
|
||||
entry: usize,
|
||||
change: impl FnOnce(&mut [u8]),
|
||||
) -> Vec<u8> {
|
||||
let mut bytes = source.to_vec();
|
||||
let header_start = entry * 512;
|
||||
let header = bytes
|
||||
.get_mut(header_start..header_start + 512)
|
||||
.expect("header exists");
|
||||
change(header);
|
||||
update_checksum(header);
|
||||
bytes
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn archive_round_trip_records_exact_saved_bytes_absence_and_does_not_rewrite_old_archives() {
|
||||
let fixture = fixture(false);
|
||||
let mut app = open(&fixture);
|
||||
let original = fixture.store.read_saved().expect("saved bytes");
|
||||
let path = app.create_manual_backup().expect("manual backup");
|
||||
let archived_bytes = fs::read(&path).expect("archive bytes");
|
||||
let preview = preview_archive(&path).expect("archive round trip");
|
||||
|
||||
assert_eq!(preview.reason, BackupReason::Manual);
|
||||
assert_eq!(preview.saved_bytes.settings, original.settings);
|
||||
assert_eq!(preview.saved_bytes.server_config, original.server_config);
|
||||
assert_eq!(preview.saved_bytes.internal_state, None);
|
||||
assert_eq!(preview.saved_bytes.vault, None);
|
||||
|
||||
let secret = WireGuardPrivateKey::generate().expect("new synthetic key");
|
||||
app.prepare_initial_vault(VaultBundle::create("test password").expect("vault"))
|
||||
.expect("prepare vault");
|
||||
app.add_client(
|
||||
Client::new(
|
||||
secret.public_key(),
|
||||
"added later",
|
||||
ClientAddresses {
|
||||
ipv4: "10.20.0.3".parse().expect("IPv4"),
|
||||
ipv6: Some("fd20::3".parse().expect("IPv6")),
|
||||
},
|
||||
)
|
||||
.expect("client"),
|
||||
None,
|
||||
)
|
||||
.expect("prepare peer");
|
||||
app.commit().expect("commit later change");
|
||||
|
||||
assert_eq!(
|
||||
fs::read(path).expect("old archive remains readable"),
|
||||
archived_bytes
|
||||
);
|
||||
let reopened = fixture.store.read_saved().expect("new saved bytes");
|
||||
assert!(reopened.vault.is_some());
|
||||
assert!(reopened.internal_state.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn initial_setup_import_and_commit_leave_original_source_files_unchanged() {
|
||||
let fixture = fixture(true);
|
||||
let source = fixture
|
||||
.settings
|
||||
.client_source_directory
|
||||
.join("existing.conf");
|
||||
let server_identity = WireGuardPrivateKey::generate().expect("synthetic server identity");
|
||||
let source_bytes = format!(
|
||||
"[Interface]\nPrivateKey = {}\nAddress = 10.20.0.2/32\nDNS = 10.20.0.1\n\n[Peer]\nPublicKey = {}\nEndpoint = vpn.example.test:51820\nAllowedIPs = 0.0.0.0/0, ::/0\n",
|
||||
fixture.secret.to_base64(),
|
||||
server_identity.public_key()
|
||||
)
|
||||
.into_bytes();
|
||||
fs::write(&source, &source_bytes).expect("source fixture");
|
||||
fs::set_permissions(&source, fs::Permissions::from_mode(0o600))
|
||||
.expect("source fixture is private");
|
||||
let server = ConfigDocument::parse(&fixture.store.read_saved().expect("saved").server_config)
|
||||
.expect("server document");
|
||||
let prepared = wireguard_admin::vault::source::prepare_initial_setup(
|
||||
&fixture.settings,
|
||||
&server,
|
||||
"synthetic password",
|
||||
)
|
||||
.expect("initial import setup");
|
||||
let mut app = open(&fixture);
|
||||
app.prepare_initial_setup(prepared)
|
||||
.expect("stage initial import");
|
||||
assert_eq!(
|
||||
fs::read(&source).expect("source remains unchanged"),
|
||||
source_bytes
|
||||
);
|
||||
assert!(
|
||||
fixture
|
||||
.store
|
||||
.read_saved()
|
||||
.expect("before commit")
|
||||
.vault
|
||||
.is_none()
|
||||
);
|
||||
|
||||
app.commit().expect("install initial vault");
|
||||
let installed = fixture.store.read_saved().expect("installed state");
|
||||
let vault = VaultBundle::from_toml(
|
||||
std::str::from_utf8(installed.vault.as_deref().expect("vault installed"))
|
||||
.expect("vault UTF-8"),
|
||||
)
|
||||
.expect("vault parses without unlocking");
|
||||
assert_eq!(vault.record_count(), 1);
|
||||
assert!(vault.contains_record(fixture.key));
|
||||
assert_eq!(
|
||||
fs::read(&source).expect("source still unchanged"),
|
||||
source_bytes
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_reserves_addresses_of_current_saved_clients_that_the_archive_removes() {
|
||||
let fixture = fixture(false);
|
||||
let mut app = open(&fixture);
|
||||
let empty_archive = app.create_manual_backup().expect("archive empty state");
|
||||
|
||||
let client = Client::new(
|
||||
fixture.key,
|
||||
"created after archive",
|
||||
ClientAddresses {
|
||||
ipv4: "10.20.0.9".parse().expect("IPv4"),
|
||||
ipv6: Some("fd20::9".parse().expect("IPv6")),
|
||||
},
|
||||
)
|
||||
.expect("new client");
|
||||
app.add_client(client, None).expect("stage client");
|
||||
app.commit().expect("save client");
|
||||
assert_eq!(app.states().applied(), &RuntimeObservation::Unknown);
|
||||
|
||||
let preview = app
|
||||
.preview_restore(archive_name(&empty_archive))
|
||||
.expect("preview archived empty set");
|
||||
app.confirm_restore(preview).expect("confirm restore");
|
||||
let archived_state = PersistedState::from_toml(
|
||||
app.saved_bytes()
|
||||
.internal_state
|
||||
.as_deref()
|
||||
.expect("restore carries pending reservations"),
|
||||
)
|
||||
.expect("restored reservation state");
|
||||
assert!(
|
||||
archived_state
|
||||
.reserved_addresses
|
||||
.contains(&"10.20.0.9".to_owned())
|
||||
);
|
||||
assert!(
|
||||
archived_state
|
||||
.reserved_addresses
|
||||
.contains(&"fd20::9".to_owned())
|
||||
);
|
||||
|
||||
assert_eq!(app.states().applied(), &RuntimeObservation::Unknown);
|
||||
assert!(
|
||||
app.states()
|
||||
.reserved_addresses()
|
||||
.contains(&"10.20.0.9".parse().expect("IPv4"))
|
||||
);
|
||||
assert!(
|
||||
app.states()
|
||||
.reserved_addresses()
|
||||
.contains(&"fd20::9".parse().expect("IPv6"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_rejects_an_archive_that_switches_the_installations_interface() {
|
||||
let fixture = fixture(true);
|
||||
let app = open(&fixture);
|
||||
let archive_path = app.create_manual_backup().expect("valid source archive");
|
||||
let mut archive_bytes = fs::read(archive_path).expect("archive bytes");
|
||||
let settings_member = 1536; // Manifest data occupies one 512-byte block.
|
||||
let settings_end = archive_bytes[settings_member..]
|
||||
.iter()
|
||||
.position(|byte| *byte == 0)
|
||||
.map(|length| settings_member + length)
|
||||
.expect("settings data terminates before padding");
|
||||
let settings =
|
||||
std::str::from_utf8(&archive_bytes[settings_member..settings_end]).expect("settings UTF-8");
|
||||
let changed = settings.replace("interface_name = \"wg0\"", "interface_name = \"wg1\"");
|
||||
assert_ne!(changed, settings);
|
||||
archive_bytes[settings_member..settings_end].copy_from_slice(changed.as_bytes());
|
||||
let changed_archive = fixture
|
||||
.settings
|
||||
.archives_directory
|
||||
.join("wireguard-admin-interface-switch.tar");
|
||||
fs::write(&changed_archive, archive_bytes).expect("write mutated synthetic archive");
|
||||
|
||||
assert!(matches!(
|
||||
app.preview_restore(archive_name(&changed_archive)),
|
||||
Err(AppError::Persistence(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
app.preview_restore("../outside.tar"),
|
||||
Err(AppError::InvalidArchiveSelection)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn archive_parser_rejects_traversal_links_duplicates_and_oversized_members() {
|
||||
let fixture = fixture(false);
|
||||
let path = open(&fixture)
|
||||
.create_manual_backup()
|
||||
.expect("manual archive");
|
||||
let archive = fs::read(&path).expect("archive bytes");
|
||||
|
||||
let traversal = archive_with_header_change(&archive, 0, |header| {
|
||||
header[..100].fill(0);
|
||||
header[..11].copy_from_slice(b"../manifest");
|
||||
});
|
||||
assert!(preview_archive_bytes(&traversal).is_err());
|
||||
|
||||
let link = archive_with_header_change(&archive, 0, |header| header[156] = b'2');
|
||||
assert!(preview_archive_bytes(&link).is_err());
|
||||
let special = archive_with_header_change(&archive, 0, |header| header[156] = b'3');
|
||||
assert!(preview_archive_bytes(&special).is_err());
|
||||
|
||||
let duplicate = archive_with_header_change(&archive, 1, |header| {
|
||||
header[..100].fill(0);
|
||||
header[..11].copy_from_slice(b"server.conf");
|
||||
});
|
||||
assert!(preview_archive_bytes(&duplicate).is_err());
|
||||
|
||||
let oversized = archive_with_header_change(&archive, 1, |header| {
|
||||
header[124..136].fill(b'0');
|
||||
header[124..136].copy_from_slice(b"00050000000\0");
|
||||
});
|
||||
assert!(preview_archive_bytes(&oversized).is_err());
|
||||
assert!(preview_archive_bytes(&archive[..archive.len() - 512]).is_err());
|
||||
|
||||
let symlink = fixture.root.path().join("wireguard-admin-symlink.tar");
|
||||
std::os::unix::fs::symlink(&path, &symlink).expect("archive symlink");
|
||||
assert!(preview_archive(&symlink).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backup_and_installed_secret_bearing_files_are_private_and_debug_is_redacted() {
|
||||
let fixture = fixture(true);
|
||||
let mut app = open(&fixture);
|
||||
app.prepare_initial_vault(VaultBundle::create("fixture-only password").expect("vault"))
|
||||
.expect("stage vault");
|
||||
let backup = app.create_manual_backup().expect("backup");
|
||||
app.commit().expect("commit");
|
||||
|
||||
let archive_mode = fs::metadata(&backup)
|
||||
.expect("archive metadata")
|
||||
.permissions()
|
||||
.mode()
|
||||
& 0o777;
|
||||
assert_eq!(archive_mode, 0o600);
|
||||
let saved = fixture.store.read_saved().expect("saved bytes");
|
||||
assert_eq!(
|
||||
fs::metadata(fixture.settings.data_directory.join("vault.toml"))
|
||||
.expect("vault metadata")
|
||||
.permissions()
|
||||
.mode()
|
||||
& 0o777,
|
||||
0o600
|
||||
);
|
||||
assert_eq!(
|
||||
fs::metadata(&fixture.settings.data_directory)
|
||||
.expect("data directory metadata")
|
||||
.permissions()
|
||||
.mode()
|
||||
& 0o777,
|
||||
0o700
|
||||
);
|
||||
|
||||
let debug = format!("{saved:?} {:?}", preview_archive(&backup).expect("preview"));
|
||||
assert!(!debug.contains("fixture-server-secret"));
|
||||
assert!(!debug.contains(&fixture.secret.to_base64()));
|
||||
assert!(!debug.contains("fixture-only password"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_aliases_and_symlinked_destinations_fail_closed() {
|
||||
let fixture = fixture(true);
|
||||
let server = fixture.settings.server_config_path.clone();
|
||||
let target = fixture.root.path().join("target.conf");
|
||||
fs::rename(&server, &target).expect("move synthetic server fixture");
|
||||
std::os::unix::fs::symlink(&target, &server).expect("server symlink");
|
||||
assert!(fixture.store.read_saved().is_err());
|
||||
|
||||
let mut aliased_settings = SettingsFile::parse_toml(SETTINGS).expect("settings parse");
|
||||
aliased_settings.server_config_path = PathBuf::from("wireguard-admin.toml");
|
||||
let aliased = aliased_settings
|
||||
.validate()
|
||||
.expect("settings validate")
|
||||
.resolve_paths(fixture.root.path());
|
||||
assert!(SavedStore::new(fixture.root.path(), aliased).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backup_failure_blocks_commit_and_restore_without_losing_drafts_or_saved_bytes() {
|
||||
let primary = fixture(true);
|
||||
let mut app = open(&primary);
|
||||
let original = primary.store.read_saved().expect("saved bytes before");
|
||||
app.rename_client(primary.key, "draft alias")
|
||||
.expect("stage rename");
|
||||
|
||||
let archive_directory = primary.settings.archives_directory.clone();
|
||||
fs::create_dir_all(archive_directory.parent().expect("archive parent"))
|
||||
.expect("create archive parent");
|
||||
fs::write(&archive_directory, b"block backup creation").expect("block archive creation");
|
||||
assert!(app.commit().is_err());
|
||||
assert_eq!(
|
||||
primary
|
||||
.store
|
||||
.read_saved()
|
||||
.expect("saved bytes unchanged")
|
||||
.server_config,
|
||||
original.server_config
|
||||
);
|
||||
assert_eq!(
|
||||
app.states()
|
||||
.draft()
|
||||
.get(&primary.key)
|
||||
.expect("draft remains")
|
||||
.alias,
|
||||
"draft alias"
|
||||
);
|
||||
assert_eq!(
|
||||
app.states()
|
||||
.saved()
|
||||
.get(&primary.key)
|
||||
.expect("saved remains")
|
||||
.alias,
|
||||
"fixture client"
|
||||
);
|
||||
|
||||
let second = fixture(false);
|
||||
let mut restore_app = open(&second);
|
||||
let selected = restore_app
|
||||
.create_manual_backup()
|
||||
.expect("selected restore archive");
|
||||
let preview = restore_app
|
||||
.preview_restore(archive_name(&selected))
|
||||
.expect("preview archive");
|
||||
restore_app
|
||||
.add_client(
|
||||
Client::new(
|
||||
second.key,
|
||||
"draft survives failed restore",
|
||||
ClientAddresses {
|
||||
ipv4: "10.20.0.4".parse().expect("IPv4"),
|
||||
ipv6: Some("fd20::4".parse().expect("IPv6")),
|
||||
},
|
||||
)
|
||||
.expect("draft client"),
|
||||
None,
|
||||
)
|
||||
.expect("stage draft");
|
||||
let before_restore = second.store.read_saved().expect("saved before restore");
|
||||
fs::remove_dir_all(&second.settings.archives_directory).expect("remove archives");
|
||||
fs::write(
|
||||
&second.settings.archives_directory,
|
||||
b"block precautionary backup",
|
||||
)
|
||||
.expect("block pre-restore backup");
|
||||
assert!(restore_app.confirm_restore(preview).is_err());
|
||||
assert_eq!(
|
||||
second
|
||||
.store
|
||||
.read_saved()
|
||||
.expect("saved unchanged")
|
||||
.server_config,
|
||||
before_restore.server_config
|
||||
);
|
||||
assert_eq!(
|
||||
restore_app
|
||||
.states()
|
||||
.draft()
|
||||
.get(&second.key)
|
||||
.expect("draft survives")
|
||||
.alias,
|
||||
"draft survives failed restore"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deletion_uses_public_key_and_removes_only_that_vault_record() {
|
||||
let fixture = fixture(true);
|
||||
let mut app = open(&fixture);
|
||||
let mut vault = VaultBundle::create("fixture password").expect("vault");
|
||||
vault
|
||||
.encrypt_record(fixture.key, &fixture.secret)
|
||||
.expect("selected record");
|
||||
let retained_secret = WireGuardPrivateKey::generate().expect("file-only key");
|
||||
let retained_key = retained_secret.public_key();
|
||||
vault
|
||||
.encrypt_record(retained_key, &retained_secret)
|
||||
.expect("unrelated file-only record");
|
||||
app.prepare_initial_vault(vault).expect("stage vault");
|
||||
app.commit().expect("install vault");
|
||||
|
||||
assert!(
|
||||
app.delete_client(fixture.key)
|
||||
.expect("delete selected public key")
|
||||
);
|
||||
app.commit().expect("commit deletion");
|
||||
let saved = fixture.store.read_saved().expect("saved bytes");
|
||||
let bundle = VaultBundle::from_toml(
|
||||
std::str::from_utf8(saved.vault.as_deref().expect("vault remains installed"))
|
||||
.expect("vault UTF-8"),
|
||||
)
|
||||
.expect("vault parses");
|
||||
assert!(!bundle.contains_record(fixture.key));
|
||||
assert!(bundle.contains_record(retained_key));
|
||||
assert_eq!(app.states().applied(), &RuntimeObservation::Unknown);
|
||||
}
|
||||
Reference in New Issue
Block a user