Files
wireguard-admin/src/runtime.rs
T

675 lines
24 KiB
Rust

//! Public, read-only runtime observations and pure saved-peer verification.
//!
//! The transport performs bounded read-only Linux access; comparison remains
//! pure and accepts caller-supplied observations.
use std::collections::{BTreeMap, BTreeSet};
use ipnet::IpNet;
use time::OffsetDateTime;
use crate::domain::PublicKey;
use crate::wireguard::{ConfigDocument, DocumentIssue, PeerIssue};
#[allow(dead_code)] // Integrated by the M5 orchestration step.
pub(crate) mod netlink;
#[allow(dead_code)] // Consumed by the M5 application integration step.
pub(crate) mod transport;
/// A value that could not be observed is distinct from a valid zero or empty value.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum Observed<T> {
Available(T),
Unavailable,
}
/// Current statistics for a peer, from the server interface's perspective.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct RuntimePeer {
pub allowed_ips: Observed<Vec<IpNet>>,
/// `Available(None)` means WireGuard has never recorded a handshake.
pub last_handshake: Observed<Option<OffsetDateTime>>,
/// These are current counters and may reset when the interface restarts.
pub received_bytes: Observed<u64>,
pub transmitted_bytes: Observed<u64>,
}
/// Whether the peer map is exhaustive enough to establish absence and equality.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum PeerSetCompleteness {
Unknown,
Unavailable,
Partial,
Complete,
}
/// Runtime peers keyed by their public key, with duplicate identities retained
/// as an explicit ambiguity signal rather than silently overwritten.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct RuntimePeerSet {
pub peers: BTreeMap<PublicKey, RuntimePeer>,
pub duplicate_public_keys: BTreeSet<PublicKey>,
pub completeness: PeerSetCompleteness,
}
impl RuntimePeerSet {
/// Collect records while preserving evidence that the adapter returned a
/// duplicate public key. The first record remains available for diagnostics.
pub fn from_records(
completeness: PeerSetCompleteness,
records: impl IntoIterator<Item = (PublicKey, RuntimePeer)>,
) -> Self {
let mut peers = BTreeMap::new();
let mut duplicate_public_keys = BTreeSet::new();
for (identity, peer) in records {
if let std::collections::btree_map::Entry::Vacant(entry) = peers.entry(identity) {
entry.insert(peer);
} else {
duplicate_public_keys.insert(identity);
}
}
Self {
peers,
duplicate_public_keys,
completeness,
}
}
}
/// Interface presence and peer observation are independent facts.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum InterfaceObservation {
Unknown,
Unavailable,
Missing,
Present {
/// Interface returned by the runtime adapter.
name: String,
peers: RuntimePeerSet,
},
}
/// One read-only sample. A missing timestamp means the sample time is unknown.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct RuntimeObservation {
pub observed_at: Option<OffsetDateTime>,
pub interface: InterfaceObservation,
}
impl RuntimeObservation {
pub const fn unknown() -> Self {
Self {
observed_at: None,
interface: InterfaceObservation::Unknown,
}
}
}
/// One saved peer's complete technical routes, independent of alias metadata
/// and the narrower `domain::Client` address representation.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SavedPeer {
pub identity: PublicKey,
pub allowed_ips: BTreeSet<IpNet>,
}
/// Technical peer projection from the authoritative byte-preserving document.
/// Invalid or ambiguous records remain represented as unverifiable entries.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SavedPeerProjection {
pub peers: BTreeMap<PublicKey, SavedPeer>,
pub duplicate_public_keys: BTreeSet<PublicKey>,
pub unverifiable_peer_indices: BTreeSet<usize>,
pub document_unverifiable: bool,
}
impl SavedPeerProjection {
/// Project every `[Peer]` section, retaining valid but unsupported route
/// shapes that cannot fit the UI's ordinary client address model.
pub fn from_document(document: &ConfigDocument) -> Self {
let mut peers = BTreeMap::new();
let mut duplicate_public_keys = BTreeSet::new();
let mut unverifiable_peer_indices = BTreeSet::new();
for peer in document.peers() {
let Some(identity) = peer.identity else {
unverifiable_peer_indices.insert(peer.index);
continue;
};
let invalid_route = peer
.issues
.iter()
.any(|issue| matches!(issue, PeerIssue::MalformedAllowedIp { .. }));
let duplicate_key_field = peer.issues.iter().any(|issue| {
matches!(
issue,
PeerIssue::MalformedPublicKey { .. }
| PeerIssue::DuplicatePublicKeyField { .. }
| PeerIssue::DuplicatePeerIdentity { .. }
)
});
if invalid_route || duplicate_key_field {
unverifiable_peer_indices.insert(peer.index);
}
let routes = peer
.allowed_ips
.iter()
.map(|network| network.trunc())
.collect();
if peers
.insert(
identity,
SavedPeer {
identity,
allowed_ips: routes,
},
)
.is_some()
{
duplicate_public_keys.insert(identity);
unverifiable_peer_indices.insert(peer.index);
}
}
let document_unverifiable = document
.issues()
.iter()
.any(|issue| !matches!(issue, DocumentIssue::ManagedMetadata(_)));
Self {
peers,
duplicate_public_keys,
unverifiable_peer_indices,
document_unverifiable,
}
}
}
/// A technical discrepancy or a reason verification could not safely decide.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum ComparisonFinding {
InterfaceUnknown,
InterfaceUnavailable,
InterfaceMissing,
InterfaceMismatch { expected: String, observed: String },
PeerSetUnknown,
PeerSetUnavailable,
PeerSetPartial,
SavedDocumentUnverifiable,
SavedPeerUnverifiable { peer_index: usize },
DuplicateSavedPublicKey { identity: PublicKey },
DuplicateRuntimePublicKey { identity: PublicKey },
MissingRuntimePeer { identity: PublicKey },
ExtraRuntimePeer { identity: PublicKey },
RuntimeRoutesUnavailable { identity: PublicKey },
AllowedIpsMismatch { identity: PublicKey },
}
/// Outcome does not depend on handshake, counters, aliases, or record ordering.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum TechnicalMatch {
Match,
Mismatch(Vec<ComparisonFinding>),
Unverifiable(Vec<ComparisonFinding>),
}
/// Compare the complete saved peer set with a runtime sample, requiring the
/// observed interface to be the interface selected by current configuration.
pub fn compare_peers(
saved: &SavedPeerProjection,
expected_interface: &str,
runtime: &RuntimeObservation,
) -> TechnicalMatch {
let mut findings = Vec::new();
let runtime_peers = match &runtime.interface {
InterfaceObservation::Unknown => {
return TechnicalMatch::Unverifiable(vec![ComparisonFinding::InterfaceUnknown]);
}
InterfaceObservation::Unavailable => {
return TechnicalMatch::Unverifiable(vec![ComparisonFinding::InterfaceUnavailable]);
}
InterfaceObservation::Missing => {
return TechnicalMatch::Mismatch(vec![ComparisonFinding::InterfaceMissing]);
}
InterfaceObservation::Present { name, peers } => {
if name != expected_interface {
return TechnicalMatch::Mismatch(vec![ComparisonFinding::InterfaceMismatch {
expected: expected_interface.to_owned(),
observed: name.clone(),
}]);
}
peers
}
};
if saved.document_unverifiable {
findings.push(ComparisonFinding::SavedDocumentUnverifiable);
}
findings.extend(saved.unverifiable_peer_indices.iter().map(|peer_index| {
ComparisonFinding::SavedPeerUnverifiable {
peer_index: *peer_index,
}
}));
findings.extend(saved.duplicate_public_keys.iter().map(|identity| {
ComparisonFinding::DuplicateSavedPublicKey {
identity: *identity,
}
}));
findings.extend(runtime_peers.duplicate_public_keys.iter().map(|identity| {
ComparisonFinding::DuplicateRuntimePublicKey {
identity: *identity,
}
}));
match runtime_peers.completeness {
PeerSetCompleteness::Unknown => findings.push(ComparisonFinding::PeerSetUnknown),
PeerSetCompleteness::Unavailable => findings.push(ComparisonFinding::PeerSetUnavailable),
PeerSetCompleteness::Partial => findings.push(ComparisonFinding::PeerSetPartial),
PeerSetCompleteness::Complete => {}
}
for (identity, expected) in &saved.peers {
match runtime_peers.peers.get(identity) {
None if runtime_peers.completeness == PeerSetCompleteness::Complete => {
findings.push(ComparisonFinding::MissingRuntimePeer {
identity: *identity,
});
}
None => {}
Some(actual) => match &actual.allowed_ips {
Observed::Unavailable => {
findings.push(ComparisonFinding::RuntimeRoutesUnavailable {
identity: *identity,
})
}
Observed::Available(routes) => {
let normalized: BTreeSet<IpNet> =
routes.iter().map(|network| network.trunc()).collect();
if normalized != expected.allowed_ips {
findings.push(ComparisonFinding::AllowedIpsMismatch {
identity: *identity,
});
}
}
},
}
}
if runtime_peers.completeness == PeerSetCompleteness::Complete {
for identity in runtime_peers.peers.keys() {
if !saved.peers.contains_key(identity) {
findings.push(ComparisonFinding::ExtraRuntimePeer {
identity: *identity,
});
}
}
}
if findings.is_empty() {
TechnicalMatch::Match
} else if findings.iter().any(is_unverifiable_finding) {
TechnicalMatch::Unverifiable(findings)
} else {
TechnicalMatch::Mismatch(findings)
}
}
fn is_unverifiable_finding(finding: &ComparisonFinding) -> bool {
matches!(
finding,
ComparisonFinding::InterfaceUnknown
| ComparisonFinding::InterfaceUnavailable
| ComparisonFinding::PeerSetUnknown
| ComparisonFinding::PeerSetUnavailable
| ComparisonFinding::PeerSetPartial
| ComparisonFinding::SavedDocumentUnverifiable
| ComparisonFinding::SavedPeerUnverifiable { .. }
| ComparisonFinding::DuplicateSavedPublicKey { .. }
| ComparisonFinding::DuplicateRuntimePublicKey { .. }
| ComparisonFinding::RuntimeRoutesUnavailable { .. }
)
}
#[cfg(test)]
mod tests {
use std::str::FromStr;
use super::*;
const KEY_A: &str = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
const KEY_B: &str = "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=";
fn key(value: &str) -> PublicKey {
PublicKey::from_str(value).unwrap()
}
fn runtime_peer(routes: Observed<Vec<IpNet>>) -> RuntimePeer {
RuntimePeer {
allowed_ips: routes,
last_handshake: Observed::Available(None),
received_bytes: Observed::Available(0),
transmitted_bytes: Observed::Available(0),
}
}
fn observation(records: Vec<(PublicKey, RuntimePeer)>) -> RuntimeObservation {
RuntimeObservation {
observed_at: None,
interface: InterfaceObservation::Present {
name: "wg0".to_owned(),
peers: RuntimePeerSet::from_records(PeerSetCompleteness::Complete, records),
},
}
}
fn compare(saved: &SavedPeerProjection, runtime: &RuntimeObservation) -> TechnicalMatch {
compare_peers(saved, "wg0", runtime)
}
fn projection(source: &str) -> SavedPeerProjection {
SavedPeerProjection::from_document(&ConfigDocument::parse(source).unwrap())
}
fn route(value: &str) -> IpNet {
value.parse().unwrap()
}
#[test]
fn observation_distinguishes_unknown_missing_unavailable_zero_and_no_handshake() {
assert_eq!(
RuntimeObservation::unknown().interface,
InterfaceObservation::Unknown
);
assert_eq!(
RuntimeObservation {
observed_at: None,
interface: InterfaceObservation::Unavailable,
}
.interface,
InterfaceObservation::Unavailable
);
assert!(matches!(
runtime_peer(Observed::Available(Vec::new())).last_handshake,
Observed::Available(None)
));
assert_eq!(
runtime_peer(Observed::Available(Vec::new())).received_bytes,
Observed::Available(0)
);
assert_eq!(
runtime_peer(Observed::Unavailable).allowed_ips,
Observed::Unavailable
);
}
#[test]
fn comparison_accepts_empty_set_and_normalized_reordered_dual_stack_routes() {
let empty = projection("");
assert_eq!(
compare(&empty, &observation(Vec::new())),
TechnicalMatch::Match
);
let source =
format!("[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = fd00::2/128, 10.0.0.2/32\n");
let expected = projection(&source);
let actual = observation(vec![(
key(KEY_A),
runtime_peer(Observed::Available(vec![
route("10.0.0.2/32"),
route("fd00::2/128"),
route("10.0.0.2/32"),
])),
)]);
assert_eq!(compare(&expected, &actual), TechnicalMatch::Match);
}
#[test]
fn comparison_requires_the_configured_interface_identity() {
let expected = projection(&format!(
"[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.0.0.2/32\n"
));
let wrong_interface = RuntimeObservation {
observed_at: None,
interface: InterfaceObservation::Present {
name: "wg1".to_owned(),
peers: RuntimePeerSet::from_records(
PeerSetCompleteness::Complete,
[(
key(KEY_A),
runtime_peer(Observed::Available(vec![route("10.0.0.2/32")])),
)],
),
},
};
assert_eq!(
compare_peers(&expected, "wg0", &wrong_interface),
TechnicalMatch::Mismatch(vec![ComparisonFinding::InterfaceMismatch {
expected: "wg0".to_owned(),
observed: "wg1".to_owned(),
}])
);
}
#[test]
fn counters_and_handshake_changes_do_not_change_technical_match() {
let expected = projection(&format!(
"[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.0.0.2/32\n"
));
let mut before_restart_peer = runtime_peer(Observed::Available(vec![route("10.0.0.2/32")]));
before_restart_peer.last_handshake = Observed::Available(Some(
OffsetDateTime::from_unix_timestamp(1_700_000_000).unwrap(),
));
before_restart_peer.received_bytes = Observed::Available(1_000);
before_restart_peer.transmitted_bytes = Observed::Available(300);
let before_restart = observation(vec![(key(KEY_A), before_restart_peer)]);
let mut after_restart_peer = runtime_peer(Observed::Available(vec![route("10.0.0.2/32")]));
after_restart_peer.last_handshake = Observed::Available(None);
after_restart_peer.received_bytes = Observed::Available(0);
after_restart_peer.transmitted_bytes = Observed::Available(7);
let after_restart = observation(vec![(key(KEY_A), after_restart_peer)]);
assert_eq!(compare(&expected, &before_restart), TechnicalMatch::Match);
assert_eq!(compare(&expected, &after_restart), TechnicalMatch::Match);
}
#[test]
fn projection_keeps_valid_unsupported_routes_and_ipv4_only_peers() {
let source = format!(
"[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.0.0.2/32, 10.20.0.0/16\n[Peer]\nPublicKey = {KEY_B}\nAllowedIPs = 10.0.0.3/32\n"
);
let expected = projection(&source);
assert_eq!(expected.peers.len(), 2);
assert!(expected.unverifiable_peer_indices.is_empty());
let actual = observation(vec![
(
key(KEY_A),
runtime_peer(Observed::Available(vec![
route("10.20.0.0/16"),
route("10.0.0.2/32"),
])),
),
(
key(KEY_B),
runtime_peer(Observed::Available(vec![route("10.0.0.3/32")])),
),
]);
assert_eq!(compare(&expected, &actual), TechnicalMatch::Match);
}
#[test]
fn comparison_reports_missing_extra_and_route_mismatch() {
let expected = projection(&format!(
"[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.0.0.2/32\n"
));
let actual = observation(vec![(
key(KEY_B),
runtime_peer(Observed::Available(vec![route("10.0.0.3/32")])),
)]);
let TechnicalMatch::Mismatch(findings) = compare(&expected, &actual) else {
panic!("expected concrete set differences");
};
assert!(findings.contains(&ComparisonFinding::MissingRuntimePeer {
identity: key(KEY_A)
}));
assert!(findings.contains(&ComparisonFinding::ExtraRuntimePeer {
identity: key(KEY_B)
}));
let actual = observation(vec![(
key(KEY_A),
runtime_peer(Observed::Available(vec![route("10.0.0.9/32")])),
)]);
assert!(matches!(
compare(&expected, &actual),
TechnicalMatch::Mismatch(findings)
if findings.contains(&ComparisonFinding::AllowedIpsMismatch { identity: key(KEY_A) })
));
}
#[test]
fn partial_duplicate_and_unavailable_records_never_pass() {
let expected = projection(&format!(
"[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.0.0.2/32\n"
));
let duplicate = RuntimePeerSet::from_records(
PeerSetCompleteness::Complete,
[
(
key(KEY_A),
runtime_peer(Observed::Available(vec![route("10.0.0.2/32")])),
),
(
key(KEY_A),
runtime_peer(Observed::Available(vec![route("10.0.0.2/32")])),
),
],
);
assert!(matches!(
compare(
&expected,
&RuntimeObservation {
observed_at: None,
interface: InterfaceObservation::Present {
name: "wg0".to_owned(),
peers: duplicate,
},
}
),
TechnicalMatch::Unverifiable(findings)
if findings.contains(&ComparisonFinding::DuplicateRuntimePublicKey { identity: key(KEY_A) })
));
for completeness in [
PeerSetCompleteness::Unknown,
PeerSetCompleteness::Unavailable,
PeerSetCompleteness::Partial,
] {
let partial = RuntimeObservation {
observed_at: None,
interface: InterfaceObservation::Present {
name: "wg0".to_owned(),
peers: RuntimePeerSet {
peers: BTreeMap::from([(
key(KEY_A),
runtime_peer(Observed::Available(vec![route("10.0.0.2/32")])),
)]),
duplicate_public_keys: BTreeSet::new(),
completeness,
},
},
};
let TechnicalMatch::Unverifiable(findings) = compare(&expected, &partial) else {
panic!("an incomplete peer set cannot verify");
};
if completeness != PeerSetCompleteness::Complete {
assert!(!findings.iter().any(|finding| matches!(
finding,
ComparisonFinding::MissingRuntimePeer { .. }
)));
}
}
let no_routes = observation(vec![(key(KEY_A), runtime_peer(Observed::Unavailable))]);
assert!(matches!(
compare(&expected, &no_routes),
TechnicalMatch::Unverifiable(_)
));
}
#[test]
fn duplicate_saved_identities_are_unverifiable_even_if_the_map_would_match() {
let source = format!(
"[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.0.0.2/32\n[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.0.0.3/32\n"
);
let expected = projection(&source);
let actual = observation(vec![(
key(KEY_A),
runtime_peer(Observed::Available(vec![route("10.0.0.2/32")])),
)]);
assert!(matches!(
compare(&expected, &actual),
TechnicalMatch::Unverifiable(findings)
if findings.contains(&ComparisonFinding::DuplicateSavedPublicKey { identity: key(KEY_A) })
));
}
#[test]
fn invalid_saved_peer_and_interface_states_fail_closed_but_metadata_does_not() {
let invalid = projection("[Peer]\nAllowedIPs = 10.0.0.2/32\n");
assert!(matches!(
compare(&invalid, &observation(Vec::new())),
TechnicalMatch::Unverifiable(_)
));
let valid_with_bad_alias = projection(&format!(
"# wireguard-admin:v1 alias=bad%0A date=unknown\n[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.0.0.2/32\n"
));
assert!(!valid_with_bad_alias.document_unverifiable);
for interface in [
InterfaceObservation::Unknown,
InterfaceObservation::Unavailable,
InterfaceObservation::Missing,
] {
let result = compare(
&projection(""),
&RuntimeObservation {
observed_at: None,
interface,
},
);
assert!(!matches!(result, TechnicalMatch::Match));
}
}
#[test]
fn malformed_routes_and_structural_saved_issues_cannot_be_omitted_for_a_match() {
let malformed_route = projection(&format!(
"[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.0.0.2/32, not-a-network\n"
));
assert!(!malformed_route.unverifiable_peer_indices.is_empty());
let apparent_subset_match = observation(vec![(
key(KEY_A),
runtime_peer(Observed::Available(vec![route("10.0.0.2/32")])),
)]);
assert!(matches!(
compare(&malformed_route, &apparent_subset_match),
TechnicalMatch::Unverifiable(findings)
if findings.iter().any(|finding| matches!(finding, ComparisonFinding::SavedPeerUnverifiable { .. }))
));
let structural = projection(&format!(
"[Interface]\nMalformed Directive\n[Peer]\nPublicKey = {KEY_A}\nAllowedIPs = 10.0.0.2/32\n"
));
assert!(structural.document_unverifiable);
let exact_runtime = observation(vec![(
key(KEY_A),
runtime_peer(Observed::Available(vec![route("10.0.0.2/32")])),
)]);
assert!(matches!(
compare(&structural, &exact_runtime),
TechnicalMatch::Unverifiable(findings)
if findings.contains(&ComparisonFinding::SavedDocumentUnverifiable)
));
}
}