412 lines
15 KiB
Rust
412 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::{
|
|
Client, ClientAddresses, ClientStates, RuntimeObservation, 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, RuntimeObservation::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, RuntimeObservation::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);
|
|
}
|