Files

243 lines
8.8 KiB
Rust

use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::path::Path;
use wireguard_admin::config::{ResolvedSettings, SettingsFile};
use wireguard_admin::domain::PublicKey;
use wireguard_admin::vault::import::{CandidateStatus, parse_client_config};
use wireguard_admin::vault::source::{
MAX_CANDIDATE_BYTES, SourceFailureKind, import_configured_directory, prepare_initial_setup,
};
use wireguard_admin::vault::{VaultBundle, WireGuardPrivateKey};
use wireguard_admin::wireguard::ConfigDocument;
fn settings(source: &Path) -> ResolvedSettings {
let raw = format!(
r#"
interface_name = "wg0"
server_config_path = "server.conf"
client_source_directory = {:?}
server_endpoint = "vpn.example:51820"
ipv4_allocation_range = "10.8.0.0/24"
ipv6_allocation_range = "fd00::/64"
dns_addresses = ["10.8.0.1"]
systemd_unit = "wg-quick@wg0.service"
restart_timeout_seconds = 30
"#,
source.display().to_string()
);
SettingsFile::parse_toml(&raw)
.unwrap()
.validate()
.unwrap()
.resolve_paths(Path::new("/synthetic/app"))
}
fn make_private(path: &Path) {
fs::set_permissions(path, fs::Permissions::from_mode(0o600)).unwrap();
}
fn server_and_client() -> (ConfigDocument, WireGuardPrivateKey, PublicKey) {
let server_key = WireGuardPrivateKey::generate().unwrap();
let client_key = WireGuardPrivateKey::generate().unwrap();
let client_public = client_key.public_key();
let server = ConfigDocument::parse(
format!(
"[Interface]\nPrivateKey = {}\nAddress = 10.8.0.1/24\n\n[Peer]\nPublicKey = {}\nAllowedIPs = 10.8.0.2/32\n# admin note\n",
server_key.to_base64(),
client_public
)
.as_bytes(),
)
.unwrap();
(server, client_key, client_public)
}
fn client_file(client: &WireGuardPrivateKey, server: &ConfigDocument) -> Vec<u8> {
let server_key = server
.source_slice(server.sections()[0].span)
.unwrap()
.lines()
.find_map(|line| line.strip_prefix("PrivateKey = "))
.map(|value| {
WireGuardPrivateKey::parse_base64(value)
.unwrap()
.public_key()
})
.unwrap();
format!(
"[Interface]\nPrivateKey = {}\nAddress = 10.8.0.2/32\n\n[Peer]\nPublicKey = {}\nEndpoint = vpn.example:51820\nAllowedIPs = 0.0.0.0/0, ::/0\n",
client.to_base64(), server_key
)
.into_bytes()
}
#[test]
fn initial_setup_encrypts_valid_files_and_leaves_sources_and_server_unchanged() {
let directory = tempfile::tempdir().unwrap();
let (server, client, identity) = server_and_client();
let original_server = server.render();
let source_bytes = client_file(&client, &server);
let laptop = directory.path().join("laptop.conf");
fs::write(&laptop, &source_bytes).unwrap();
make_private(&laptop);
fs::write(
directory.path().join("broken.conf"),
b"not a WireGuard file",
)
.unwrap();
let ignored = directory.path().join("ignored.txt");
fs::write(&ignored, &source_bytes).unwrap();
make_private(&ignored);
fs::create_dir(directory.path().join("nested")).unwrap();
let nested = directory.path().join("nested/server.conf");
fs::write(&nested, &source_bytes).unwrap();
make_private(&nested);
let prepared =
prepare_initial_setup(&settings(directory.path()), &server, "setup password").unwrap();
assert!(prepared.report.scan_complete);
assert_eq!(prepared.bundle.record_count(), 1);
let statuses = prepared.report.reconciliation.candidate_summaries();
assert!(statuses.iter().any(|(_, status)| matches!(
status,
CandidateStatus::Matched {
address_discrepancy: false,
server_identity: wireguard_admin::vault::import::ServerIdentityComparison::Matches,
missing_metadata: true,
invalid_metadata: false,
unsupported_projection: false,
}
)));
assert!(
statuses
.iter()
.any(|(_, status)| matches!(status, CandidateStatus::Invalid(_)))
);
let encoded = prepared.bundle.to_toml().unwrap();
assert!(!encoded.contains(&client.to_base64()));
let loaded = VaultBundle::from_toml(&encoded).unwrap();
assert_eq!(loaded.record_count(), 1);
assert!(loaded.contains_record(identity));
assert_eq!(
loaded
.retrieve_record(identity, "setup password")
.unwrap()
.public_key(),
identity
);
assert!(loaded.retrieve_record(identity, "wrong password").is_err());
assert!(fs::read(directory.path().join("laptop.conf")).unwrap() == source_bytes);
assert_eq!(
fs::read(directory.path().join("broken.conf")).unwrap(),
b"not a WireGuard file"
);
assert!(server.render() == original_server);
}
#[test]
fn symlinks_and_oversized_candidates_are_reported_without_import() {
let directory = tempfile::tempdir().unwrap();
let external = tempfile::NamedTempFile::new().unwrap();
fs::write(external.path(), b"outside source root").unwrap();
#[cfg(unix)]
std::os::unix::fs::symlink(external.path(), directory.path().join("linked.conf")).unwrap();
fs::write(
directory.path().join("large.conf"),
vec![b'x'; MAX_CANDIDATE_BYTES + 1],
)
.unwrap();
let (server, _, _) = server_and_client();
let mut bundle = VaultBundle::create("password").unwrap();
let report =
import_configured_directory(&mut bundle, &settings(directory.path()), &server).unwrap();
assert_eq!(bundle.record_count(), 0);
assert!(!report.scan_complete);
assert!(report.issues.iter().any(|issue| {
issue.kind == SourceFailureKind::FileTooLarge
&& issue.file_name.as_deref() == Some("large.conf")
}));
#[cfg(unix)]
assert!(report.issues.iter().any(|issue| {
issue.kind == SourceFailureKind::FileUnavailable
&& issue.file_name.as_deref() == Some("linked.conf")
}));
}
#[test]
fn enumeration_failure_is_visible_and_does_not_claim_a_complete_scan() {
let absent = tempfile::tempdir().unwrap().path().join("missing");
let (server, _, _) = server_and_client();
let mut bundle = VaultBundle::create("password").unwrap();
let report = import_configured_directory(&mut bundle, &settings(&absent), &server).unwrap();
assert!(!report.scan_complete);
assert_eq!(
report.issues[0].kind,
SourceFailureKind::DirectoryUnavailable
);
assert_eq!(bundle.record_count(), 0);
}
#[test]
fn candidate_count_limit_marks_the_scan_incomplete() {
let directory = tempfile::tempdir().unwrap();
for index in 0..=wireguard_admin::vault::source::MAX_CANDIDATES {
fs::write(
directory.path().join(format!("candidate-{index}.conf")),
b"bad",
)
.unwrap();
}
let (server, _, _) = server_and_client();
let mut bundle = VaultBundle::create("password").unwrap();
let report =
import_configured_directory(&mut bundle, &settings(directory.path()), &server).unwrap();
assert!(!report.scan_complete);
assert!(
report
.issues
.iter()
.any(|issue| issue.kind == SourceFailureKind::TooManyEntries)
);
assert_eq!(bundle.record_count(), 0);
}
#[test]
fn explicit_import_uses_existing_identity_and_rejects_duplicate_without_replacement() {
let directory = tempfile::tempdir().unwrap();
let (server, client, identity) = server_and_client();
let initial_source = tempfile::tempdir().unwrap();
let initial =
prepare_initial_setup(&settings(initial_source.path()), &server, "password").unwrap();
let original_recipient = initial.bundle.recipient().to_owned();
let mut bundle = initial.bundle;
let client_path = directory.path().join("client.conf");
fs::write(&client_path, client_file(&client, &server)).unwrap();
make_private(&client_path);
let report =
import_configured_directory(&mut bundle, &settings(directory.path()), &server).unwrap();
assert_eq!(bundle.recipient(), original_recipient);
assert_eq!(bundle.record_count(), 1);
assert!(
report
.reconciliation
.candidate_summaries()
.iter()
.any(|(_, status)| matches!(status, CandidateStatus::Matched { .. }))
);
let before_repeat = bundle.to_toml().unwrap();
let repeated =
import_configured_directory(&mut bundle, &settings(directory.path()), &server).unwrap();
assert_eq!(bundle.to_toml().unwrap(), before_repeat);
assert_eq!(bundle.record_count(), 1);
assert!(
repeated
.issues
.iter()
.any(|issue| issue.kind == SourceFailureKind::DuplicateRecordEqualityUnverified)
);
let result = bundle.retrieve_record(identity, "password").unwrap();
assert_eq!(result.public_key(), identity);
assert!(parse_client_config(&client_file(&client, &server)).is_ok());
}