Files
wireguard-admin/src/domain.rs
T

813 lines
26 KiB
Rust

//! 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<Self, Self::Err> {
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<Ipv6Addr>,
}
impl ClientAddresses {
pub fn iter(&self) -> impl Iterator<Item = IpAddr> + '_ {
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<String>,
addresses: ClientAddresses,
) -> Result<Self, DomainError> {
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<PublicKey, Client>,
}
impl Snapshot {
pub fn new(clients: impl IntoIterator<Item = Client>) -> Result<Self, DomainError> {
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<Item = &Client> {
self.clients.values()
}
pub fn insert(&mut self, client: Client) -> Result<Option<Client>, DomainError> {
validate_alias(&client.alias)?;
Ok(self.clients.insert(client.identity, client))
}
pub fn remove(&mut self, identity: &PublicKey) -> Option<Client> {
self.clients.remove(identity)
}
fn addresses(&self) -> impl Iterator<Item = IpAddr> + '_ {
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<Snapshot>,
applied: RuntimeObservation,
reserved_addresses: BTreeSet<IpAddr>,
}
impl ClientStates {
/// Start with a saved snapshot and an explicit initial runtime observation.
pub fn new(
saved: Snapshot,
applied_snapshot: Option<Snapshot>,
applied: RuntimeObservation,
) -> Result<Self, DomainError> {
if applied == RuntimeObservation::Verified && applied_snapshot.as_ref() != Some(&saved) {
return Err(DomainError::AppliedSnapshotMismatch);
}
let saved_addresses = saved.addresses().collect::<BTreeSet<_>>();
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<IpAddr> {
&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<Item = IpAddr>,
) -> Result<(), DomainError> {
let reservations = reservations.into_iter().collect::<BTreeSet<_>>();
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::<BTreeSet<_>>();
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<IpAddr> {
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<Ipv4Addr, DomainError> {
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<Ipv6Addr, DomainError> {
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<Item = IpAddr> {
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<Item = u128>,
) -> Result<u128, DomainError> {
let mut occupied = occupied
.filter(|address| *address >= first && *address <= last)
.collect::<Vec<_>>();
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::<PublicKey>(), Ok(expected));
for invalid in ["", "not a key", &STANDARD.encode([1_u8; 31]), "AQ=="] {
assert_eq!(
invalid.parse::<PublicKey>(),
Err(DomainError::InvalidPublicKey)
);
}
assert_eq!(
format!("{}\n", expected).parse::<PublicKey>(),
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::<Vec<_>>();
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)
);
}
}