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::(); 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 { 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); }