525 lines
18 KiB
Rust
525 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::{
|
|
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);
|
|
}
|