//! Pure client identity, configuration-state, and address-allocation logic. use std::collections::{BTreeMap, BTreeSet}; use std::fmt; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; use std::str::FromStr; use base64::Engine as _; use base64::engine::general_purpose::STANDARD; use ipnet::{Ipv4Net, Ipv6Net}; use thiserror::Error; /// A WireGuard public key, decoded and validated as exactly 32 bytes. #[derive(Clone, Copy, Eq, Hash, Ord, PartialEq, PartialOrd)] pub struct PublicKey([u8; 32]); impl PublicKey { pub const fn from_bytes(bytes: [u8; 32]) -> Self { Self(bytes) } pub const fn as_bytes(&self) -> &[u8; 32] { &self.0 } } impl FromStr for PublicKey { type Err = DomainError; fn from_str(value: &str) -> Result { let decoded = STANDARD .decode(value) .map_err(|_| DomainError::InvalidPublicKey)?; let bytes: [u8; 32] = decoded .try_into() .map_err(|_| DomainError::InvalidPublicKey)?; // Reject alternate or noncanonical encodings, including whitespace and missing padding. if STANDARD.encode(bytes) != value { return Err(DomainError::InvalidPublicKey); } Ok(Self(bytes)) } } impl fmt::Display for PublicKey { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter.write_str(&STANDARD.encode(self.0)) } } impl fmt::Debug for PublicKey { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter .debug_tuple("PublicKey") .field(&self.to_string()) .finish() } } /// Client addresses. Existing imported IPv4-only records keep `ipv6` absent. #[derive(Clone, Debug, Eq, PartialEq)] pub struct ClientAddresses { pub ipv4: Ipv4Addr, pub ipv6: Option, } impl ClientAddresses { pub fn iter(&self) -> impl Iterator + '_ { std::iter::once(IpAddr::V4(self.ipv4)).chain(self.ipv6.map(IpAddr::V6)) } } /// Public client data only; private keys and connection secrets belong elsewhere. #[derive(Clone, Debug, Eq, PartialEq)] pub struct Client { pub identity: PublicKey, pub alias: String, pub addresses: ClientAddresses, } impl Client { pub fn new( identity: PublicKey, alias: impl Into, addresses: ClientAddresses, ) -> Result { let alias = alias.into(); validate_alias(&alias)?; Ok(Self { identity, alias, addresses, }) } } pub fn validate_alias(alias: &str) -> Result<(), DomainError> { if alias.is_empty() { return Err(DomainError::EmptyAlias); } if alias.chars().any(char::is_control) { return Err(DomainError::InvalidAlias); } Ok(()) } /// A client snapshot keyed by public key, so aliases can repeat without ambiguity. #[derive(Clone, Debug, Default, Eq, PartialEq)] pub struct Snapshot { clients: BTreeMap, } impl Snapshot { pub fn new(clients: impl IntoIterator) -> Result { let mut snapshot = Self::default(); for client in clients { let identity = client.identity; if snapshot.insert(client)?.is_some() { return Err(DomainError::DuplicatePublicKey(identity)); } } Ok(snapshot) } pub fn get(&self, identity: &PublicKey) -> Option<&Client> { self.clients.get(identity) } pub fn clients(&self) -> impl Iterator { self.clients.values() } pub fn insert(&mut self, client: Client) -> Result, DomainError> { validate_alias(&client.alias)?; Ok(self.clients.insert(client.identity, client)) } pub fn remove(&mut self, identity: &PublicKey) -> Option { self.clients.remove(identity) } fn addresses(&self) -> impl Iterator + '_ { self.clients .values() .flat_map(|client| client.addresses.iter()) } } /// Runtime knowledge is not inferred from saved state or missing data. #[derive(Clone, Debug, Eq, PartialEq)] pub enum RuntimeObservation { Unknown, Unavailable, Verified, } /// Draft, saved, and last verified applied data remain independent snapshots. #[derive(Clone, Debug, Eq, PartialEq)] pub struct ClientStates { draft: Snapshot, saved: Snapshot, applied_snapshot: Option, applied: RuntimeObservation, reserved_addresses: BTreeSet, } impl ClientStates { /// Start with a saved snapshot and an explicit initial runtime observation. pub fn new( saved: Snapshot, applied_snapshot: Option, applied: RuntimeObservation, ) -> Result { if applied == RuntimeObservation::Verified && applied_snapshot.as_ref() != Some(&saved) { return Err(DomainError::AppliedSnapshotMismatch); } let saved_addresses = saved.addresses().collect::>(); let reserved_addresses = match &applied_snapshot { Some(snapshot) => snapshot .addresses() .filter(|address| !saved_addresses.contains(address)) .collect(), None => BTreeSet::new(), }; Ok(Self { draft: saved.clone(), saved, applied_snapshot, applied, reserved_addresses, }) } pub fn draft(&self) -> &Snapshot { &self.draft } pub fn saved(&self) -> &Snapshot { &self.saved } pub fn applied(&self) -> &RuntimeObservation { &self.applied } pub fn applied_snapshot(&self) -> Option<&Snapshot> { self.applied_snapshot.as_ref() } pub fn reserved_addresses(&self) -> &BTreeSet { &self.reserved_addresses } /// Restore conservative reservations from saved metadata without asserting /// that the running interface matches the saved snapshot. pub fn restore_persisted_reservations( &mut self, reservations: impl IntoIterator, ) -> Result<(), DomainError> { let reservations = reservations.into_iter().collect::>(); if reservations .iter() .any(|address| self.saved.addresses().any(|saved| saved == *address)) { return Err(DomainError::ReservationConflictsWithSavedClient); } self.reserved_addresses = reservations; self.applied_snapshot = None; self.applied = RuntimeObservation::Unknown; Ok(()) } pub fn draft_mut(&mut self) -> &mut Snapshot { &mut self.draft } /// Save the draft in the model only. This performs no persistence or runtime operation. pub fn commit_draft(&mut self) { let next_addresses = self.draft.addresses().collect::>(); self.reserved_addresses.extend( self.saved .addresses() .filter(|address| !next_addresses.contains(address)), ); self.saved = self.draft.clone(); if self.applied == RuntimeObservation::Verified && self.applied_snapshot.as_ref() != Some(&self.saved) { self.applied = RuntimeObservation::Unknown; } } /// Discard draft edits by returning the draft to the saved snapshot. pub fn discard_draft(&mut self) { self.draft = self.saved.clone(); } /// Record an applied state only when the verified observation matches saved clients. pub(crate) fn mark_applied_after_verification( &mut self, observed: Snapshot, ) -> Result<(), DomainError> { if observed != self.saved { self.applied = RuntimeObservation::Unknown; return Err(DomainError::AppliedSnapshotMismatch); } self.applied_snapshot = Some(observed); self.applied = RuntimeObservation::Verified; self.reserved_addresses.clear(); Ok(()) } pub fn mark_runtime_unavailable(&mut self) { self.applied = RuntimeObservation::Unavailable; } pub fn mark_runtime_unknown(&mut self) { self.applied = RuntimeObservation::Unknown; } /// Addresses in draft, saved, or last verified applied data cannot be reused. /// The applied snapshot preserves reservations for committed deletions until verification. pub fn addresses_in_use(&self) -> BTreeSet { self.draft .addresses() .chain(self.saved.addresses()) .chain(self.reserved_addresses.iter().copied()) .chain(self.applied_snapshot.iter().flat_map(Snapshot::addresses)) .collect() } } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum AddressFamily { Ipv4, Ipv6, } #[derive(Debug, Error, Eq, PartialEq)] pub enum DomainError { #[error("public key must be canonical standard Base64 encoding of exactly 32 bytes")] InvalidPublicKey, #[error("client alias must not be empty")] EmptyAlias, #[error("client alias must not contain control characters")] InvalidAlias, #[error("snapshot contains a duplicate public key: {0}")] DuplicatePublicKey(PublicKey), #[error("address range is exhausted for {0:?}")] AddressExhausted(AddressFamily), #[error("address family does not match the configured range")] AddressFamilyMismatch, #[error("address is outside the configured allocation range")] AddressOutsideRange, #[error("address is unavailable or not usable in the configured range")] AddressUnavailable, #[error("verified applied clients do not match the saved snapshot")] AppliedSnapshotMismatch, #[error("persisted reservation conflicts with a saved client address")] ReservationConflictsWithSavedClient, } /// Return the lowest available host in `range` without walking the subnet. pub fn allocate_ipv4( range: Ipv4Net, availability: &AddressAvailability<'_>, ) -> Result { let network = u32::from(range.network()); let broadcast = u32::from(range.broadcast()); let (first, last) = match range.prefix_len() { 0..=30 => (network + 1, broadcast - 1), 31 => (network, broadcast), 32 => (network, network), _ => return Err(DomainError::AddressExhausted(AddressFamily::Ipv4)), }; first_gap( AddressFamily::Ipv4, u128::from(first), u128::from(last), availability .addresses() .filter_map(|address| match address { IpAddr::V4(address) => Some(u128::from(u32::from(address))), IpAddr::V6(_) => None, }), ) .and_then(|address| { u32::try_from(address) .map(Ipv4Addr::from) .map_err(|_| DomainError::AddressExhausted(AddressFamily::Ipv4)) }) } /// Return the lowest available address in an IPv6 range without walking the subnet. pub fn allocate_ipv6( range: Ipv6Net, availability: &AddressAvailability<'_>, ) -> Result { let first = u128::from(range.network()); let last = u128::from(range.broadcast()); first_gap( AddressFamily::Ipv6, first, last, availability .addresses() .filter_map(|address| match address { IpAddr::V4(_) => None, IpAddr::V6(address) => Some(u128::from(address)), }), ) .map(Ipv6Addr::from) } /// The categories are separate inputs to allocation and manual-address validation. #[derive(Clone, Copy, Debug, Default)] pub struct AddressAvailability<'a> { pub server: &'a [IpAddr], pub occupied: &'a [IpAddr], pub reserved: &'a [IpAddr], } impl AddressAvailability<'_> { fn addresses(self) -> impl Iterator { self.server .iter() .chain(self.occupied) .chain(self.reserved) .copied() } } pub fn validate_manual_ipv4( address: Ipv4Addr, range: Ipv4Net, availability: &AddressAvailability<'_>, ) -> Result<(), DomainError> { if !range.contains(&address) { return Err(DomainError::AddressOutsideRange); } let value = u32::from(address); let network = u32::from(range.network()); let broadcast = u32::from(range.broadcast()); let usable = match range.prefix_len() { 0..=30 => value > network && value < broadcast, 31 | 32 => true, _ => false, }; if !usable || availability .addresses() .any(|used| used == IpAddr::V4(address)) { return Err(DomainError::AddressUnavailable); } Ok(()) } pub fn validate_manual_ipv6( address: Ipv6Addr, range: Ipv6Net, availability: &AddressAvailability<'_>, ) -> Result<(), DomainError> { if !range.contains(&address) { return Err(DomainError::AddressOutsideRange); } if availability .addresses() .any(|used| used == IpAddr::V6(address)) { return Err(DomainError::AddressUnavailable); } Ok(()) } pub fn validate_manual_address( address: IpAddr, ipv4_range: Ipv4Net, ipv6_range: Ipv6Net, availability: &AddressAvailability<'_>, ) -> Result<(), DomainError> { match address { IpAddr::V4(address) => validate_manual_ipv4(address, ipv4_range, availability), IpAddr::V6(address) => validate_manual_ipv6(address, ipv6_range, availability), } } fn first_gap( family: AddressFamily, first: u128, last: u128, occupied: impl Iterator, ) -> Result { let mut occupied = occupied .filter(|address| *address >= first && *address <= last) .collect::>(); occupied.sort_unstable(); let mut candidate = first; for address in occupied { if address < candidate { continue; } if address > candidate { return Ok(candidate); } if candidate == last { return Err(DomainError::AddressExhausted(family)); } candidate = candidate .checked_add(1) .ok_or(DomainError::AddressExhausted(family))?; } Ok(candidate) } #[cfg(test)] mod tests { use super::*; fn key(byte: u8) -> PublicKey { PublicKey::from_bytes([byte; 32]) } fn client(byte: u8, alias: &str, ipv4: &str, ipv6: Option<&str>) -> Client { Client::new( key(byte), alias, ClientAddresses { ipv4: ipv4.parse().expect("valid fixture IPv4"), ipv6: ipv6.map(|address| address.parse().expect("valid fixture IPv6")), }, ) .expect("fixture client should validate") } fn empty_availability<'a>() -> AddressAvailability<'a> { AddressAvailability::default() } #[test] fn public_key_accepts_canonical_wireguard_base64_and_rejects_invalid_values() { let expected = key(7); let encoded = expected.to_string(); assert_eq!(encoded.len(), 44); assert_eq!(encoded.parse::(), Ok(expected)); for invalid in ["", "not a key", &STANDARD.encode([1_u8; 31]), "AQ=="] { assert_eq!( invalid.parse::(), Err(DomainError::InvalidPublicKey) ); } assert_eq!( format!("{}\n", expected).parse::(), Err(DomainError::InvalidPublicKey) ); } #[test] fn aliases_may_repeat_but_control_characters_are_rejected() { let snapshot = Snapshot::new([ client(1, "laptop", "10.0.0.2", None), client(2, "laptop", "10.0.0.3", None), ]) .expect("duplicate aliases are valid"); assert_eq!(snapshot.clients().count(), 2); for alias in ["bad\nname", "bad\rname", "bad\u{1b}name", "bad\u{7f}name"] { assert_eq!( Client::new( key(3), alias, ClientAddresses { ipv4: "10.0.0.4".parse().expect("valid fixture IPv4"), ipv6: None, }, ) .err(), Some(DomainError::InvalidAlias) ); } } #[test] fn unknown_and_unavailable_runtime_observations_are_explicit() { let saved = Snapshot::new([client(1, "one", "10.0.0.2", None)]).unwrap(); let unknown = ClientStates::new(saved.clone(), None, RuntimeObservation::Unknown).unwrap(); assert_eq!(unknown.applied(), &RuntimeObservation::Unknown); let unavailable = ClientStates::new(saved, None, RuntimeObservation::Unavailable).unwrap(); assert_eq!(unavailable.applied(), &RuntimeObservation::Unavailable); } #[test] fn verified_state_requires_a_matching_applied_snapshot() { let saved = Snapshot::new([client(1, "one", "10.0.0.2", None)]).unwrap(); let other = Snapshot::new([client(2, "two", "10.0.0.3", None)]).unwrap(); assert_eq!( ClientStates::new(saved.clone(), None, RuntimeObservation::Verified).err(), Some(DomainError::AppliedSnapshotMismatch) ); assert_eq!( ClientStates::new(saved.clone(), Some(other), RuntimeObservation::Verified).err(), Some(DomainError::AppliedSnapshotMismatch) ); let verified = ClientStates::new(saved.clone(), Some(saved), RuntimeObservation::Verified).unwrap(); assert_eq!(verified.applied(), &RuntimeObservation::Verified); } #[test] fn discard_restores_draft_and_commit_keeps_applied_snapshot_separate() { let saved = Snapshot::new([ client(1, "one", "10.0.0.2", None), client(2, "two", "10.0.0.3", None), ]) .unwrap(); let mut states = ClientStates::new( saved.clone(), Some(saved.clone()), RuntimeObservation::Verified, ) .unwrap(); states.draft_mut().remove(&key(2)); states.discard_draft(); assert_eq!(states.draft(), &saved); states.draft_mut().remove(&key(2)); states.commit_draft(); assert_eq!(states.saved().clients().count(), 1); assert_eq!(states.applied(), &RuntimeObservation::Unknown); assert_eq!(states.applied_snapshot(), Some(&saved)); states.mark_runtime_unavailable(); assert_eq!(states.applied(), &RuntimeObservation::Unavailable); assert!(states.applied_snapshot().is_some()); assert!( states .addresses_in_use() .contains(&IpAddr::V4("10.0.0.3".parse().unwrap())) ); assert_eq!( states.reserved_addresses(), &[IpAddr::V4("10.0.0.3".parse().unwrap())].into() ); assert!( states .addresses_in_use() .contains(&IpAddr::V4("10.0.0.3".parse().unwrap())) ); } #[test] fn a_draft_only_deletion_does_not_create_a_committed_reservation() { let saved = Snapshot::new([client(1, "one", "10.0.0.2", None)]).unwrap(); let mut states = ClientStates::new(saved, None, RuntimeObservation::Unknown).unwrap(); states.draft_mut().remove(&key(1)); assert!(states.reserved_addresses().is_empty()); assert!( states .addresses_in_use() .contains(&IpAddr::V4("10.0.0.2".parse().unwrap())) ); } #[test] fn successful_verified_apply_releases_deleted_addresses_and_mismatch_does_not() { let initial = Snapshot::new([client(1, "one", "10.0.0.2", None)]).unwrap(); let mut states = ClientStates::new( initial.clone(), Some(initial.clone()), RuntimeObservation::Verified, ) .unwrap(); let wrong = Snapshot::new([client(2, "other", "10.0.0.3", None)]).unwrap(); assert_eq!( states.mark_applied_after_verification(wrong), Err(DomainError::AppliedSnapshotMismatch) ); assert_eq!(states.applied(), &RuntimeObservation::Unknown); assert_eq!(states.applied_snapshot(), Some(&initial)); states.draft_mut().remove(&key(1)); states.commit_draft(); assert!(!states.reserved_addresses().is_empty()); let saved = states.saved().clone(); states .mark_applied_after_verification(saved.clone()) .unwrap(); assert_eq!(states.applied(), &RuntimeObservation::Verified); assert!(states.reserved_addresses().is_empty()); assert!( !states .addresses_in_use() .contains(&IpAddr::V4("10.0.0.2".parse().unwrap())) ); } #[test] fn existing_ipv4_only_clients_remain_ipv4_only() { let ipv4_only = client(1, "legacy", "10.0.0.2", None); assert_eq!(ipv4_only.addresses.ipv6, None); assert_eq!(ipv4_only.addresses.iter().count(), 1); } #[test] fn ipv4_allocation_skips_server_occupied_reserved_network_and_broadcast() { let server = [IpAddr::V4("10.0.0.1".parse().unwrap())]; let occupied = [IpAddr::V4("10.0.0.2".parse().unwrap())]; let reserved = [IpAddr::V4("10.0.0.3".parse().unwrap())]; let availability = AddressAvailability { server: &server, occupied: &occupied, reserved: &reserved, }; assert_eq!( allocate_ipv4("10.0.0.0/29".parse().unwrap(), &availability), Ok("10.0.0.4".parse().unwrap()) ); } #[test] fn ipv4_tiny_ranges_and_exhaustion_follow_rfc3021_and_host_rules() { let none = empty_availability(); assert_eq!( allocate_ipv4("192.0.2.0/31".parse().unwrap(), &none), Ok("192.0.2.0".parse().unwrap()) ); assert_eq!( allocate_ipv4("192.0.2.8/32".parse().unwrap(), &none), Ok("192.0.2.8".parse().unwrap()) ); let occupied = [IpAddr::V4("192.0.2.8".parse().unwrap())]; let availability = AddressAvailability { occupied: &occupied, ..none }; assert_eq!( allocate_ipv4("192.0.2.8/31".parse().unwrap(), &availability), Ok("192.0.2.9".parse().unwrap()) ); let both = [ IpAddr::V4("192.0.2.8".parse().unwrap()), IpAddr::V4("192.0.2.9".parse().unwrap()), ]; assert_eq!( allocate_ipv4( "192.0.2.8/31".parse().unwrap(), &AddressAvailability { occupied: &both, ..none } ), Err(DomainError::AddressExhausted(AddressFamily::Ipv4)) ); assert_eq!( allocate_ipv4( "192.0.2.8/32".parse().unwrap(), &AddressAvailability { occupied: &occupied, ..none } ), Err(DomainError::AddressExhausted(AddressFamily::Ipv4)) ); } #[test] fn ipv6_allocates_first_free_address_from_a_very_large_range() { // /32 has almost 2^96 addresses; work here depends on the small occupied set. let occupied = (0..20_000_u32) .map(|offset| { IpAddr::V6(Ipv6Addr::from( 0x2001_0db8_0000_0000_0000_0000_0000_0000_u128 + u128::from(offset), )) }) .collect::>(); let availability = AddressAvailability { occupied: &occupied, ..AddressAvailability::default() }; assert_eq!( allocate_ipv6("2001:db8::/32".parse().unwrap(), &availability), Ok(Ipv6Addr::from( 0x2001_0db8_0000_0000_0000_0000_0000_4e20_u128 )) ); } #[test] fn ipv6_narrow_ranges_manual_selection_and_family_validation_work() { let none = empty_availability(); assert_eq!( allocate_ipv6("2001:db8::/127".parse().unwrap(), &none), Ok("2001:db8::".parse().unwrap()) ); let server = [IpAddr::V6("2001:db8::".parse().unwrap())]; let availability = AddressAvailability { server: &server, ..none }; assert_eq!( allocate_ipv6("2001:db8::/128".parse().unwrap(), &availability), Err(DomainError::AddressExhausted(AddressFamily::Ipv6)) ); assert_eq!( validate_manual_ipv6( "2001:db8::2".parse().unwrap(), "2001:db8::/126".parse().unwrap(), &none ), Ok(()) ); assert_eq!( validate_manual_ipv6( "2001:db8::".parse().unwrap(), "2001:db8::/126".parse().unwrap(), &availability ), Err(DomainError::AddressUnavailable) ); assert_eq!( validate_manual_address( IpAddr::V6("2001:db8::3".parse().unwrap()), "10.0.0.0/24".parse().unwrap(), "2001:db8::/126".parse().unwrap(), &none, ), Ok(()) ); } #[test] fn manual_ipv4_requires_range_membership_usability_and_availability() { let occupied = [IpAddr::V4("10.0.0.3".parse().unwrap())]; let availability = AddressAvailability { occupied: &occupied, ..AddressAvailability::default() }; let range = "10.0.0.0/29".parse().unwrap(); assert_eq!( validate_manual_ipv4("10.0.0.3".parse().unwrap(), range, &availability), Err(DomainError::AddressUnavailable) ); assert_eq!( validate_manual_ipv4("10.0.0.0".parse().unwrap(), range, &availability), Err(DomainError::AddressUnavailable) ); assert_eq!( validate_manual_ipv4("10.0.1.1".parse().unwrap(), range, &availability), Err(DomainError::AddressOutsideRange) ); } }