Files

410 lines
15 KiB
Rust

use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use wireguard_admin::app::{
Application, FirstIssueAttempt, FirstIssueDeliveryOutcome, ReissueFailureKind, ReissueStage,
};
use wireguard_admin::config::{ResolvedSettings, SettingsFile};
use wireguard_admin::delivery::files::FileOutputError;
use wireguard_admin::domain::{ApplyStatus, Client, ClientAddresses, ClientStates, Snapshot};
use wireguard_admin::persistence::SavedStore;
use wireguard_admin::vault::import::{parse_client_config, reconcile};
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,
settings: ResolvedSettings,
store: SavedStore,
states: ClientStates,
}
fn fixture(server: String, states: ClientStates) -> Fixture {
let root = tempfile::tempdir().expect("private temporary fixture");
let settings = SettingsFile::parse_toml(SETTINGS)
.expect("fixture settings parse")
.validate()
.expect("fixture 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).expect("settings file");
fs::write(&settings.server_config_path, server).expect("server configuration");
fs::set_permissions(
&settings.server_config_path,
fs::Permissions::from_mode(0o600),
)
.expect("private server configuration");
let store = SavedStore::new(root.path(), settings.clone()).expect("saved store");
Fixture {
root,
settings,
store,
states,
}
}
fn empty_states() -> ClientStates {
ClientStates::new(Snapshot::default(), None, ApplyStatus::Unknown).expect("empty state")
}
fn open(fixture: &Fixture) -> Application {
Application::open(fixture.store.clone(), fixture.states.clone()).expect("application opens")
}
fn new_client_fixture() -> (Fixture, Application, wireguard_admin::domain::PublicKey) {
let server_key = WireGuardPrivateKey::generate().expect("synthetic server key");
let server = format!(
"[Interface]\nPrivateKey = {}\nAddress = 10.20.0.1/24, fd20::1/64\n",
server_key.to_base64()
);
let fixture = fixture(server, empty_states());
let mut app = open(&fixture);
app.prepare_initial_vault(VaultBundle::create("fixture vault password").expect("vault"))
.expect("stage vault");
app.commit().expect("install vault");
let identity = app
.prepare_new_client("same alias", None, None)
.expect("prepare client");
app.commit().expect("commit client");
(fixture, app, identity)
}
fn destination(
app: &Application,
path: impl AsRef<Path>,
) -> wireguard_admin::delivery::files::PreparedDestination {
app.prepare_first_issue_destination(path)
.expect("prepare selected destination")
}
fn archive_name(path: &Path) -> &str {
path.file_name()
.and_then(|name| name.to_str())
.expect("synthetic archive filename")
}
#[test]
fn committed_creation_retries_after_overwrite_decline_and_duplicate_aliases_are_independent() {
let server_key = WireGuardPrivateKey::generate().expect("synthetic server key");
let server = format!(
"[Interface]\nPrivateKey = {}\nAddress = 10.20.0.1/24, fd20::1/64\n",
server_key.to_base64()
);
let fixture = fixture(server, empty_states());
let mut app = open(&fixture);
app.prepare_initial_vault(VaultBundle::create("fixture vault password").expect("vault"))
.expect("stage vault");
app.commit().expect("install vault");
let first = app
.prepare_new_client("duplicate", None, None)
.expect("first client");
let second = app
.prepare_new_client("duplicate", None, None)
.expect("second client");
app.commit().expect("commit clients");
let existing = fixture.root.path().join("occupied.conf");
fs::write(&existing, b"existing output").expect("existing regular file");
let declined = app
.deliver_first_issue_file(
first,
FirstIssueAttempt::Initial,
destination(&app, &existing),
)
.expect("decline reported as an operation result");
assert!(matches!(
declined,
FirstIssueDeliveryOutcome::Failed {
error: FileOutputError::OverwriteDeclined,
..
}
));
let retry_path = fixture.root.path().join("new-dir/first.conf");
let retried = app
.deliver_first_issue_file(
first,
FirstIssueAttempt::Retry {
confirm_possible_overwrite: false,
confirm_manual_cleanup: false,
},
destination(&app, &retry_path),
)
.expect("retry remains password free in creation session");
assert!(matches!(
retried,
FirstIssueDeliveryOutcome::Delivered(delivery) if delivery.activation_warning
));
assert_eq!(
fs::metadata(&retry_path)
.expect("delivered file")
.permissions()
.mode()
& 0o777,
0o600
);
let second_path = fixture.root.path().join("second.conf");
let delivered_second = app
.deliver_first_issue_file(
second,
FirstIssueAttempt::Initial,
destination(&app, second_path),
)
.expect("second key has independent eligibility");
assert!(matches!(
delivered_second,
FirstIssueDeliveryOutcome::Delivered(delivery) if delivery.activation_warning
));
}
#[test]
fn reissue_unlocks_installed_saved_record_each_time_and_warns_after_reopen() {
let (fixture, mut app, identity) = new_client_fixture();
let initial_path = fixture.root.path().join("initial.conf");
let issued = app
.deliver_first_issue_file(
identity,
FirstIssueAttempt::Initial,
destination(&app, &initial_path),
)
.expect("first file issued");
assert!(matches!(
issued,
FirstIssueDeliveryOutcome::Delivered(delivery) if delivery.activation_warning
));
// A draft rename must not change the saved configuration used by reissue.
app.rename_client(identity, "unsaved draft alias")
.expect("prepare unrelated draft rename");
let wrong_path = fixture.root.path().join("wrong-password.conf");
let wrong = app.reissue_client_file(
identity,
"wrong password",
destination(&app, &wrong_path),
false,
);
let error = wrong.expect_err("wrong password rejected");
assert_eq!(error.stage, ReissueStage::Authorization);
assert_eq!(error.kind, ReissueFailureKind::PasswordOrRecordRejected);
assert!(!wrong_path.exists());
let output = fixture.root.path().join("reissue/client.conf");
let result = app
.reissue_client_file(
identity,
"fixture vault password",
destination(&app, &output),
true,
)
.expect("fresh password authorizes reissue");
assert!(result.activation_warning);
assert!(result.qr.is_some_and(|qr| qr.is_ok()));
let bytes = fs::read(&output).expect("mandatory reissue file");
let parsed = parse_client_config(&bytes).expect("rendered file parses");
assert_eq!(parsed.identity, identity);
assert_eq!(
fs::metadata(&output)
.expect("private output")
.permissions()
.mode()
& 0o777,
0o600
);
let next_action_path = fixture.root.path().join("next-action.conf");
// A successful action does not preserve an unlocked identity for the next
// action in the same session.
let next_action = app.reissue_client_file(
identity,
"wrong password",
destination(&app, next_action_path),
false,
);
assert_eq!(
next_action
.expect_err("previous action does not cache an unlocked identity")
.kind,
ReissueFailureKind::PasswordOrRecordRejected
);
}
#[test]
fn imported_ipv4_only_parameters_are_preserved_and_authenticated_conflict_blocks_reissue() {
let server_key = WireGuardPrivateKey::generate().expect("synthetic server key");
let client_key = WireGuardPrivateKey::generate().expect("synthetic client key");
let identity = client_key.public_key();
let server = format!(
"[Interface]\nPrivateKey = {}\nAddress = 10.20.0.1/24\n\n[Peer]\nPublicKey = {identity}\nAllowedIPs = 10.20.0.2/32\n",
server_key.to_base64()
);
let client = Client::new(
identity,
"imported",
ClientAddresses {
ipv4: "10.20.0.2".parse().expect("IPv4"),
ipv6: None,
},
)
.expect("IPv4-only domain client");
let snapshot = Snapshot::new([client]).expect("snapshot");
let import_fixture = fixture(
server.clone(),
ClientStates::new(snapshot, None, ApplyStatus::Unknown).expect("client state"),
);
let document = ConfigDocument::parse(server.as_bytes()).expect("server document");
let psk = WireGuardPrivateKey::generate().expect("synthetic preshared key");
let source = format!(
"[Interface]\nPrivateKey = {}\nAddress = 10.20.0.2/32\nDNS = 1.1.1.1\nMTU = 1380\n\n[Peer]\nPublicKey = {}\nPresharedKey = {}\nEndpoint = imported.example.test:12345\nAllowedIPs = 0.0.0.0/0\nPersistentKeepalive = 25\n",
client_key.to_base64(),
server_key.public_key(),
psk.to_base64()
);
let report = reconcile(&document, [("client.conf".to_owned(), source.into_bytes())]);
let mut bundle = VaultBundle::create("fixture vault password").expect("vault");
report
.encrypt_candidate(&mut bundle, 0)
.expect("encrypt matching import");
let mut app = open(&import_fixture);
app.prepare_initial_vault(bundle)
.expect("stage imported vault");
app.commit().expect("commit imported vault");
let output = import_fixture.root.path().join("imported.conf");
app.reissue_client_file(
identity,
"fixture vault password",
destination(&app, &output),
false,
)
.expect("imported record reissues");
let rendered = fs::read_to_string(&output).expect("rendered imported file");
assert!(rendered.contains("Address = 10.20.0.2/32\n"));
assert!(rendered.contains("DNS = 1.1.1.1\n"));
assert!(rendered.contains("MTU = 1380\n"));
assert!(rendered.contains("Endpoint = imported.example.test:12345\n"));
assert!(rendered.contains("AllowedIPs = 0.0.0.0/0\n"));
assert!(rendered.contains("PersistentKeepalive = 25\n"));
assert!(rendered.contains("PresharedKey = "));
assert!(!rendered.contains("Address = 10.20.0.2/32, fd20::"));
// A saved peer with a mismatching retained address is authenticated as
// blocked during import; an edited public status bit cannot authorize it.
let conflict_key = WireGuardPrivateKey::generate().expect("second synthetic key");
let blocked_server = format!(
"[Interface]\nPrivateKey = {}\nAddress = 10.20.0.1/24\n\n[Peer]\nPublicKey = {}\nAllowedIPs = 10.20.0.9/32\n",
server_key.to_base64(),
conflict_key.public_key()
);
let blocked_document = ConfigDocument::parse(blocked_server.as_bytes()).expect("server");
let blocked_source = format!(
"[Interface]\nPrivateKey = {}\nAddress = 10.20.0.2/32\n[Peer]\nPublicKey = {}\nEndpoint = vpn.example.test:51820\nAllowedIPs = 0.0.0.0/0\n",
conflict_key.to_base64(),
server_key.public_key()
);
let blocked_report = reconcile(
&blocked_document,
[("blocked.conf".to_owned(), blocked_source.into_bytes())],
);
let mut blocked_bundle = VaultBundle::create("fixture vault password").expect("vault");
blocked_report
.encrypt_candidate(&mut blocked_bundle, 0)
.expect("encrypt blocked candidate");
let blocked_fixture = fixture(blocked_server, empty_states());
let mut blocked_app = open(&blocked_fixture);
blocked_app
.prepare_initial_vault(blocked_bundle)
.expect("stage blocked vault");
blocked_app.commit().expect("commit blocked record");
let blocked_path = blocked_fixture.root.path().join("blocked.conf");
let blocked = blocked_app.reissue_client_file(
conflict_key.public_key(),
"fixture vault password",
destination(&blocked_app, blocked_path),
false,
);
assert_eq!(
blocked
.expect_err("authenticated discrepancy blocks reissue")
.kind,
ReissueFailureKind::PasswordOrRecordRejected
);
}
#[test]
fn deletion_and_restore_remove_password_free_authority_and_reopen_requires_vault_password() {
let (fixture, mut app, identity) = new_client_fixture();
let before_delete_archive = app.create_manual_backup().expect("saved client archive");
assert_eq!(
fs::metadata(&fixture.settings.server_config_path)
.expect("server remains installed")
.permissions()
.mode()
& 0o777,
0o600
);
assert!(app.delete_client(identity).expect("delete client"));
app.commit().expect("commit deletion");
let deleted = app.reissue_client_file(
identity,
"fixture vault password",
destination(&app, fixture.root.path().join("deleted.conf")),
false,
);
assert!(deleted.is_err());
assert!(!fixture.root.path().join("deleted.conf").exists());
let preview = app
.preview_restore(archive_name(&before_delete_archive))
.expect("preview prior saved client");
app.confirm_restore(preview)
.expect("restore saved client only");
let reopened = open(&fixture);
let restored_path = fixture.root.path().join("restored.conf");
let restored = reopened
.reissue_client_file(
identity,
"fixture vault password",
destination(&reopened, &restored_path),
false,
)
.expect("restored encrypted record requires and accepts password");
assert!(restored.activation_warning);
assert!(restored_path.exists());
}
#[test]
fn overwrite_confirmation_is_bound_to_the_selected_destination() {
let (fixture, app, identity) = new_client_fixture();
let path: PathBuf = fixture.root.path().join("output.conf");
fs::write(&path, b"replace only after confirmation").expect("existing file");
let prepared = destination(&app, &path);
let declined = app.reissue_client_file(identity, "fixture vault password", prepared, false);
assert!(matches!(
declined,
Err(error) if error.stage == ReissueStage::FilePublication
&& error.kind == ReissueFailureKind::FileOutput(FileOutputError::OverwriteDeclined)
));
let confirmed = destination(&app, &path)
.confirm_overwrite()
.expect("confirm exact destination");
app.reissue_client_file(identity, "fixture vault password", confirmed, false)
.expect("confirmed reissue replaces destination");
let parsed = parse_client_config(&fs::read(&path).expect("replacement output"))
.expect("replacement is valid config");
assert_eq!(parsed.identity, identity);
}