Handle client keys, encrypted vault
This commit is contained in:
@@ -0,0 +1,233 @@
|
||||
use std::fs;
|
||||
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 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);
|
||||
fs::write(directory.path().join("laptop.conf"), &source_bytes).unwrap();
|
||||
fs::write(
|
||||
directory.path().join("broken.conf"),
|
||||
b"not a WireGuard file",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(directory.path().join("ignored.txt"), &source_bytes).unwrap();
|
||||
fs::create_dir(directory.path().join("nested")).unwrap();
|
||||
fs::write(directory.path().join("nested/server.conf"), &source_bytes).unwrap();
|
||||
|
||||
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;
|
||||
fs::write(
|
||||
directory.path().join("client.conf"),
|
||||
client_file(&client, &server),
|
||||
)
|
||||
.unwrap();
|
||||
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());
|
||||
}
|
||||
Reference in New Issue
Block a user