Add server configuration editing with byte preservation
This commit is contained in:
@@ -0,0 +1,395 @@
|
||||
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"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user