243 lines
8.8 KiB
Rust
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());
|
|
}
|