Files
wireguard-admin/tests/commit_restore.rs
T

521 lines
18 KiB
Rust

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::{
ApplyStatus, Client, ClientAddresses, ClientStates, PublicKey, 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), ApplyStatus::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(), &ApplyStatus::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(), &ApplyStatus::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(), &ApplyStatus::Unknown);
}