813 lines
26 KiB
Rust
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)
|
|
);
|
|
}
|
|
}
|