396 lines
15 KiB
Rust
396 lines
15 KiB
Rust
use std::net::Ipv4Addr;
|
|
use std::str::FromStr;
|
|
|
|
use wireguard_admin::domain::{Client, ClientAddresses, PublicKey};
|
|
use wireguard_admin::wireguard::{
|
|
ConfigDocument, DocumentIssue, EditError, MetadataAttachment, MetadataIssue, PeerIssue,
|
|
};
|
|
|
|
const FIXTURE: &str = include_str!("fixtures/wireguard/representative.conf");
|
|
const KEY_A: &str = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
|
|
const KEY_B: &str = "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=";
|
|
const KEY_C: &str = "AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgI=";
|
|
const KEY_D: &str = "AwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwM=";
|
|
const SECRET_MARKERS: [&str; 3] = [
|
|
"SYNTHETIC-PLACEHOLDER-INTERFACE-PRIVATE-KEY",
|
|
"SYNTHETIC-PLACEHOLDER-PEER-PSK-A",
|
|
"SYNTHETIC-PLACEHOLDER-PEER-PSK-B",
|
|
];
|
|
type MetadataCase = (&'static str, &'static str, fn(&MetadataIssue) -> bool);
|
|
|
|
fn key(text: &str) -> PublicKey {
|
|
PublicKey::from_str(text).expect("synthetic public key is valid")
|
|
}
|
|
|
|
fn client(public_key: &str, alias: &str, last_octet: u8) -> Client {
|
|
Client::new(
|
|
key(public_key),
|
|
alias,
|
|
ClientAddresses {
|
|
ipv4: Ipv4Addr::new(10, 44, 0, last_octet),
|
|
ipv6: None,
|
|
},
|
|
)
|
|
.expect("test alias is valid")
|
|
}
|
|
|
|
fn text(document: &ConfigDocument) -> &str {
|
|
std::str::from_utf8(document.render()).expect("fixture is UTF-8")
|
|
}
|
|
|
|
fn assert_secret_free_debug<T: std::fmt::Debug>(value: &T) {
|
|
let debug = format!("{value:?}");
|
|
for marker in SECRET_MARKERS {
|
|
assert!(
|
|
!debug.contains(marker),
|
|
"Debug leaked a synthetic secret marker"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn round_trip_preserves_lf_crlf_mixed_and_missing_final_newline_inputs() {
|
|
let lf = FIXTURE.as_bytes().to_vec();
|
|
let crlf = FIXTURE.replace('\n', "\r\n").into_bytes();
|
|
let mixed = FIXTURE
|
|
.lines()
|
|
.enumerate()
|
|
.flat_map(|(line, value)| {
|
|
let ending = if line % 3 == 1 { "\r\n" } else { "\n" };
|
|
format!("{value}{ending}").into_bytes()
|
|
})
|
|
.collect::<Vec<_>>();
|
|
let mut no_final_newline = lf.clone();
|
|
while no_final_newline.last() == Some(&b'\n') || no_final_newline.last() == Some(&b'\r') {
|
|
no_final_newline.pop();
|
|
}
|
|
|
|
for (label, source) in [
|
|
("LF", lf),
|
|
("CRLF", crlf),
|
|
("mixed", mixed),
|
|
("no final newline", no_final_newline),
|
|
] {
|
|
let parsed = ConfigDocument::parse(&source).expect("synthetic UTF-8 parses");
|
|
assert!(
|
|
parsed.render() == source.as_slice(),
|
|
"{label} round trip changed bytes"
|
|
);
|
|
assert_eq!(parsed.peers().len(), 2, "{label} lost a peer");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn edits_keep_interface_and_unowned_bytes_exact_and_can_be_reparsed() {
|
|
let original = ConfigDocument::parse(FIXTURE).unwrap();
|
|
let original_text = text(&original).to_owned();
|
|
let interface_end = original_text.find("\n\n# Admin note").unwrap();
|
|
let expected_interface = &original_text[..interface_end + 1];
|
|
let with_metadata = original
|
|
.insert_legacy_metadata(&key(KEY_A), "old", Some("2024-02-29T12:30:00Z"))
|
|
.unwrap();
|
|
let renamed = with_metadata
|
|
.rename_client(&key(KEY_A), "new # = \"café\"")
|
|
.unwrap();
|
|
let result = text(&renamed);
|
|
assert!(
|
|
result.starts_with(expected_interface),
|
|
"interface bytes changed"
|
|
);
|
|
assert!(result.contains("# Another administrator note.\n"));
|
|
assert!(result.contains("PresharedKey = SYNTHETIC-PLACEHOLDER-PEER-PSK-A\n"));
|
|
assert!(result.contains("CustomPeerOption = keep-me\n"));
|
|
assert!(result.contains("# wg-admin:version=1\n"));
|
|
assert!(result.contains("# wg-admin:created-utc=2024-02-29T12:30:00Z\n"));
|
|
assert_eq!(
|
|
renamed.project_client(&key(KEY_A)).unwrap().alias,
|
|
"new # = \"café\""
|
|
);
|
|
assert_eq!(
|
|
ConfigDocument::parse(renamed.render()).unwrap().render(),
|
|
renamed.render()
|
|
);
|
|
|
|
let deleted = renamed.delete_client(&key(KEY_A)).unwrap();
|
|
let deleted_text = text(&deleted);
|
|
assert!(
|
|
deleted_text.starts_with(expected_interface),
|
|
"delete changed interface bytes"
|
|
);
|
|
assert!(!deleted_text.contains(KEY_A));
|
|
assert!(deleted_text.contains("# Admin note: retained between interface and peer.\n"));
|
|
assert!(deleted_text.contains("# Another administrator note.\n"));
|
|
assert!(deleted_text.contains("PresharedKey = SYNTHETIC-PLACEHOLDER-PEER-PSK-B\n"));
|
|
assert_eq!(deleted.peers().len(), 1);
|
|
assert_eq!(deleted.peers()[0].identity, Some(key(KEY_B)));
|
|
}
|
|
|
|
#[test]
|
|
fn rename_in_mixed_ending_metadata_changes_only_alias_representation() {
|
|
let source = format!(
|
|
"# admin preface\r\n# wg-admin:begin\n# wg-admin:version=1\r\n# wg-admin:alias=b2xk\n# wg-admin:created-utc=2024-01-02T03:04:05Z\r\n# wg-admin:end\n[Peer]\r\nPublicKey = {KEY_A}\nAllowedIPs = 10.44.0.2/32\r\nPresharedKey = SYNTHETIC-PLACEHOLDER-PEER-PSK-A"
|
|
);
|
|
let original = ConfigDocument::parse(&source).unwrap();
|
|
let renamed = original.rename_client(&key(KEY_A), "new").unwrap();
|
|
let expected = source.replace("# wg-admin:alias=b2xk", "# wg-admin:alias=bmV3");
|
|
assert!(
|
|
text(&renamed) == expected,
|
|
"rename must replace only alias bytes"
|
|
);
|
|
assert_eq!(renamed.project_client(&key(KEY_A)).unwrap().alias, "new");
|
|
}
|
|
|
|
#[test]
|
|
fn add_delete_and_repeated_edits_recompute_spans_without_touching_neighbors() {
|
|
let source = "[Interface]\nListenPort = 51820\n\n[Peer]\nPublicKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\nAllowedIPs = 10.44.0.2/32\n\n# inter-peer note\n[Peer]\nPublicKey = AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=\nAllowedIPs = 10.44.0.3/32\n\n[Peer]\nPublicKey = AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgI=\nAllowedIPs = 10.44.0.4/32";
|
|
let original = ConfigDocument::parse(source).unwrap();
|
|
let added = original
|
|
.add_client(&client(KEY_D, "duplicate alias", 9), None)
|
|
.unwrap();
|
|
assert!(
|
|
text(&added).starts_with(source),
|
|
"add changed existing source bytes"
|
|
);
|
|
let with_duplicate_aliases = added
|
|
.insert_legacy_metadata(&key(KEY_A), "shared alias", None)
|
|
.unwrap()
|
|
.insert_legacy_metadata(&key(KEY_B), "shared alias", None)
|
|
.unwrap();
|
|
assert_eq!(
|
|
with_duplicate_aliases
|
|
.project_client(&key(KEY_A))
|
|
.unwrap()
|
|
.alias,
|
|
with_duplicate_aliases
|
|
.project_client(&key(KEY_B))
|
|
.unwrap()
|
|
.alias
|
|
);
|
|
|
|
let delete_first = original.delete_client(&key(KEY_A)).unwrap();
|
|
assert!(text(&delete_first).contains("# inter-peer note\n"));
|
|
assert!(text(&delete_first).contains(KEY_B));
|
|
assert!(text(&delete_first).contains(KEY_C));
|
|
let delete_last = delete_first.delete_client(&key(KEY_C)).unwrap();
|
|
assert!(text(&delete_last).contains(KEY_B));
|
|
assert!(!text(&delete_last).contains(KEY_C));
|
|
let delete_only_peer = delete_last.delete_client(&key(KEY_B)).unwrap();
|
|
assert!(delete_only_peer.peers().is_empty());
|
|
|
|
let twice_renamed = added
|
|
.rename_client(&key(KEY_D), "#marker = \"café\"")
|
|
.unwrap()
|
|
.rename_client(&key(KEY_D), "duplicate alias")
|
|
.unwrap();
|
|
assert_eq!(
|
|
twice_renamed.project_client(&key(KEY_D)).unwrap().alias,
|
|
"duplicate alias"
|
|
);
|
|
assert_eq!(text(&twice_renamed).matches("[Peer]").count(), 4);
|
|
assert!(text(&twice_renamed).contains("# inter-peer note\n"));
|
|
}
|
|
|
|
#[test]
|
|
fn metadata_issues_are_preserved_and_unsafe_edits_fail_without_mutation() {
|
|
let cases: [MetadataCase; 3] = [
|
|
(
|
|
"unknown version",
|
|
"# wg-admin:begin\n# wg-admin:version=2\n# wg-admin:alias=b2xk\n# wg-admin:end\n",
|
|
|issue| matches!(issue, MetadataIssue::UnsupportedVersion { .. }),
|
|
),
|
|
(
|
|
"unknown field",
|
|
"# wg-admin:begin\n# wg-admin:version=1\n# wg-admin:alias=b2xk\n# wg-admin:future=x\n# wg-admin:end\n",
|
|
|issue| matches!(issue, MetadataIssue::UnknownField { .. }),
|
|
),
|
|
(
|
|
"duplicate field",
|
|
"# wg-admin:begin\n# wg-admin:version=1\n# wg-admin:alias=b2xk\n# wg-admin:alias=b3RoZXI\n# wg-admin:end\n",
|
|
|issue| matches!(issue, MetadataIssue::UnknownField { .. }),
|
|
),
|
|
];
|
|
for (label, block, matches_issue) in cases {
|
|
let source = format!("{block}[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.44.0.2/32\n");
|
|
let parsed = ConfigDocument::parse(&source).unwrap();
|
|
assert!(
|
|
matches!(&parsed.peers()[0].metadata, MetadataAttachment::Invalid(issue) if matches_issue(issue)),
|
|
"{label} not reported"
|
|
);
|
|
assert!(matches!(
|
|
parsed.rename_client(&key(KEY_A), "safe"),
|
|
Err(EditError::UnsafeDocument | EditError::UnsafePeer)
|
|
));
|
|
assert!(
|
|
parsed.render() == source.as_bytes(),
|
|
"{label} rejection mutated source"
|
|
);
|
|
}
|
|
|
|
let malformed = format!(
|
|
"# wg-admin:begin \n# wg-admin:version=1\n# wg-admin:alias=b2xk\n# wg-admin:end\n[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.44.0.2/32\n"
|
|
);
|
|
let parsed = ConfigDocument::parse(&malformed).unwrap();
|
|
assert!(!parsed.issues().is_empty());
|
|
assert!(parsed.rename_client(&key(KEY_A), "safe").is_err());
|
|
assert!(
|
|
parsed.render() == malformed.as_bytes(),
|
|
"malformed input bytes changed"
|
|
);
|
|
|
|
let misattached = format!(
|
|
"# wg-admin:begin\n# wg-admin:version=1\n# wg-admin:alias=b2xk\n# wg-admin:end\n# admin intervenes\n[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.44.0.2/32\n"
|
|
);
|
|
let parsed = ConfigDocument::parse(&misattached).unwrap();
|
|
assert!(parsed.issues().iter().any(|issue| matches!(
|
|
issue,
|
|
DocumentIssue::ManagedMetadata(MetadataIssue::Misattached { .. })
|
|
)));
|
|
assert!(parsed.rename_client(&key(KEY_A), "safe").is_err());
|
|
assert!(
|
|
parsed.render() == misattached.as_bytes(),
|
|
"misattached input bytes changed"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn duplicate_adjacent_metadata_blocks_and_ambiguous_peers_remain_visible() {
|
|
let block = "# wg-admin:begin\n# wg-admin:version=1\n# wg-admin:alias=b25l\n# wg-admin:end\n";
|
|
let duplicate_blocks =
|
|
format!("{block}{block}[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.44.0.2/32\n");
|
|
let parsed = ConfigDocument::parse(&duplicate_blocks).unwrap();
|
|
assert!(matches!(
|
|
&parsed.peers()[0].metadata,
|
|
MetadataAttachment::Invalid(MetadataIssue::DuplicateBlocks { count: 2, .. })
|
|
));
|
|
assert!(parsed.rename_client(&key(KEY_A), "blocked").is_err());
|
|
assert!(
|
|
parsed.render() == duplicate_blocks.as_bytes(),
|
|
"duplicate blocks changed"
|
|
);
|
|
|
|
let ambiguous = format!(
|
|
"[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.44.0.2/32\nPublicKey = {KEY_B}\n\n[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.44.0.3/32\n"
|
|
);
|
|
let parsed = ConfigDocument::parse(&ambiguous).unwrap();
|
|
assert!(
|
|
parsed.peers()[0]
|
|
.issues
|
|
.iter()
|
|
.any(|issue| matches!(issue, PeerIssue::DuplicatePublicKeyField { .. }))
|
|
);
|
|
assert!(
|
|
parsed.peers()[1]
|
|
.issues
|
|
.iter()
|
|
.any(|issue| matches!(issue, PeerIssue::DuplicatePeerIdentity { .. }))
|
|
);
|
|
assert!(parsed.rename_client(&key(KEY_A), "blocked").is_err());
|
|
assert!(
|
|
parsed.render() == ambiguous.as_bytes(),
|
|
"ambiguous source bytes changed"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn aliases_encode_unicode_and_syntax_and_reject_control_injection() {
|
|
for alias in ["# café = \"laptop\"", "# wg-admin:begin", "x=y # z"] {
|
|
let document = ConfigDocument::parse(format!(
|
|
"[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.44.0.2/32\n"
|
|
))
|
|
.unwrap()
|
|
.insert_legacy_metadata(&key(KEY_A), alias, None)
|
|
.unwrap();
|
|
assert_eq!(document.project_client(&key(KEY_A)).unwrap().alias, alias);
|
|
assert!(text(&document).contains("# wg-admin:alias="));
|
|
let rendered_alias = text(&document)
|
|
.lines()
|
|
.find(|line| line.starts_with("# wg-admin:alias="))
|
|
.expect("metadata has an alias line");
|
|
assert!(!rendered_alias.contains(alias));
|
|
}
|
|
|
|
let original = ConfigDocument::parse(format!(
|
|
"[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.44.0.2/32\n"
|
|
))
|
|
.unwrap();
|
|
for alias in ["line\nbreak", "carriage\rreturn", "terminal\u{001b}escape"] {
|
|
assert!(matches!(
|
|
original.insert_legacy_metadata(&key(KEY_A), alias, None),
|
|
Err(EditError::InvalidAlias)
|
|
));
|
|
assert_eq!(original.render().last(), Some(&b'\n'));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn unsupported_routes_multiple_addresses_and_ipv6_only_round_trip_but_projection_fails() {
|
|
let cases = [
|
|
("multiple host routes", "10.44.0.2/32, 10.44.0.22/32"),
|
|
("network route", "10.44.0.0/24"),
|
|
("default route", "0.0.0.0/0"),
|
|
("IPv6 only", "fd42::2/128"),
|
|
];
|
|
for (label, routes) in cases {
|
|
let source = format!(
|
|
"# wg-admin:begin\n# wg-admin:version=1\n# wg-admin:alias=Y2xpZW50\n# wg-admin:end\n[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = {routes}\n"
|
|
);
|
|
let parsed = ConfigDocument::parse(&source).unwrap();
|
|
let peer = &parsed.peers()[0];
|
|
assert!(
|
|
parsed.render() == source.as_bytes(),
|
|
"{label} round trip changed bytes"
|
|
);
|
|
match label {
|
|
"multiple host routes" => assert!(peer.issues.iter().any(|issue| matches!(
|
|
issue,
|
|
PeerIssue::UnsupportedAllowedIpShape { field: 0, item: 1 }
|
|
))),
|
|
"network route" | "default route" => assert!(peer.issues.iter().any(|issue| matches!(
|
|
issue,
|
|
PeerIssue::UnsupportedAllowedIpShape { field: 0, item: 0 }
|
|
))),
|
|
"IPv6 only" => assert!(peer.issues.contains(&PeerIssue::MissingIpv4HostRoute)),
|
|
_ => unreachable!("test case has an expected route shape"),
|
|
}
|
|
let issue_debug = format!("{:?}", peer.issues);
|
|
assert!(
|
|
!issue_debug.contains(routes),
|
|
"{label} issue exposed route text"
|
|
);
|
|
assert_eq!(
|
|
parsed.project_client(&key(KEY_A)),
|
|
Err(EditError::UnsupportedAddresses),
|
|
"{label} projection"
|
|
);
|
|
let renamed = parsed.rename_client(&key(KEY_A), "updated").unwrap();
|
|
let expected = source.replace("# wg-admin:alias=Y2xpZW50", "# wg-admin:alias=dXBkYXRlZA");
|
|
assert_eq!(
|
|
text(&renamed),
|
|
expected,
|
|
"{label} rename changed other bytes"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn parse_errors_debug_and_failed_edits_never_expose_document_secrets() {
|
|
let document = ConfigDocument::parse(FIXTURE).unwrap();
|
|
assert_secret_free_debug(&document);
|
|
assert_secret_free_debug(&document.peers());
|
|
let duplicate_identity = document
|
|
.add_client(&client(KEY_A, "duplicate", 9), None)
|
|
.unwrap_err();
|
|
assert_secret_free_debug(&duplicate_identity);
|
|
let invalid = ConfigDocument::parse(b"[Interface]\nPrivateKey = SYNTHETIC\n\xff").unwrap_err();
|
|
assert_secret_free_debug(&invalid);
|
|
assert!(!invalid.to_string().contains("SYNTHETIC"));
|
|
|
|
let original_bytes = document.render().to_vec();
|
|
assert!(document.rename_client(&key(KEY_C), "not present").is_err());
|
|
assert!(document.delete_client(&key(KEY_C)).is_err());
|
|
assert!(
|
|
document.render() == original_bytes.as_slice(),
|
|
"failed edits changed source bytes"
|
|
);
|
|
}
|