Define Rust environment and toolchain

This commit is contained in:
2026-10-18 21:42:33 +03:00
parent 7b475bfcc8
commit c2b899dd1e
15 changed files with 2058 additions and 9 deletions
+509
View File
@@ -0,0 +1,509 @@
//! Typed application settings, validation, and executable-relative paths.
use std::net::{IpAddr, Ipv4Addr};
use std::path::{Path, PathBuf};
use std::str::FromStr;
use std::time::Duration;
use serde::Deserialize;
use thiserror::Error;
/// Fixed settings filename, located beside the executable.
pub const SETTINGS_FILE_NAME: &str = "wireguard-admin.toml";
/// TOML representation. Parsing is separate from semantic validation and path resolution.
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct SettingsFile {
pub interface_name: String,
pub server_config_path: PathBuf,
pub client_source_directory: PathBuf,
pub server_endpoint: String,
pub ipv4_allocation_range: String,
pub ipv6_allocation_range: String,
pub dns_addresses: Vec<String>,
pub systemd_unit: String,
pub restart_timeout_seconds: u64,
#[serde(default)]
pub data_root: Option<PathBuf>,
}
/// Settings with validated values but paths not yet anchored to the executable.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ValidatedSettings {
pub interface_name: String,
pub server_config_path: PathBuf,
pub client_source_directory: PathBuf,
pub server_endpoint: ServerEndpoint,
pub ipv4_allocation_range: ipnet::Ipv4Net,
pub ipv6_allocation_range: ipnet::Ipv6Net,
pub dns_addresses: Vec<IpAddr>,
pub systemd_unit: String,
pub restart_timeout: Duration,
pub data_root: Option<PathBuf>,
}
/// Fully resolved application paths. No configured path is checked for existence here.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ResolvedSettings {
pub interface_name: String,
pub server_config_path: PathBuf,
pub client_source_directory: PathBuf,
pub server_endpoint: ServerEndpoint,
pub ipv4_allocation_range: ipnet::Ipv4Net,
pub ipv6_allocation_range: ipnet::Ipv6Net,
pub dns_addresses: Vec<IpAddr>,
pub systemd_unit: String,
pub restart_timeout: Duration,
pub data_root: PathBuf,
pub data_directory: PathBuf,
pub archives_directory: PathBuf,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ServerEndpoint {
pub host: EndpointHost,
pub port: u16,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum EndpointHost {
Hostname(String),
Ip(IpAddr),
}
#[derive(Debug, Error, Eq, PartialEq)]
pub enum SettingsError {
#[error("invalid TOML settings: {0}")]
Parse(String),
#[error("invalid {field}: {reason}")]
InvalidField { field: &'static str, reason: String },
}
impl SettingsFile {
/// Parse TOML only; semantic constraints are checked by [`Self::validate`].
pub fn parse_toml(input: &str) -> Result<Self, SettingsError> {
toml::from_str(input).map_err(|error| SettingsError::Parse(error.to_string()))
}
/// Validate names, network values, endpoint syntax, and timeout without filesystem or network access.
pub fn validate(self) -> Result<ValidatedSettings, SettingsError> {
validate_interface_name(&self.interface_name)?;
validate_systemd_unit(&self.systemd_unit)?;
validate_path("server_config_path", &self.server_config_path)?;
validate_path("client_source_directory", &self.client_source_directory)?;
if let Some(data_root) = &self.data_root {
validate_path("data_root", data_root)?;
}
let server_endpoint = parse_endpoint(&self.server_endpoint)?;
let ipv4_allocation_range = self
.ipv4_allocation_range
.parse()
.map_err(|_| invalid("ipv4_allocation_range", "must be a valid IPv4 CIDR range"))?;
let ipv6_allocation_range = self
.ipv6_allocation_range
.parse()
.map_err(|_| invalid("ipv6_allocation_range", "must be a valid IPv6 CIDR range"))?;
if self.restart_timeout_seconds == 0 {
return Err(invalid(
"restart_timeout_seconds",
"must be greater than zero",
));
}
if self.dns_addresses.is_empty() {
return Err(invalid(
"dns_addresses",
"must contain at least one IP address",
));
}
let dns_addresses = self
.dns_addresses
.iter()
.map(|address| {
address.parse::<IpAddr>().map_err(|_| {
invalid(
"dns_addresses",
format!("{address:?} is not an IPv4 or IPv6 address"),
)
})
})
.collect::<Result<Vec<_>, _>>()?;
Ok(ValidatedSettings {
interface_name: self.interface_name,
server_config_path: self.server_config_path,
client_source_directory: self.client_source_directory,
server_endpoint,
ipv4_allocation_range,
ipv6_allocation_range,
dns_addresses,
systemd_unit: self.systemd_unit,
restart_timeout: Duration::from_secs(self.restart_timeout_seconds),
data_root: self.data_root,
})
}
}
impl ValidatedSettings {
/// Resolve relative configured paths from the executable directory, independent of cwd.
pub fn resolve_paths(self, executable_directory: &Path) -> ResolvedSettings {
let resolve = |path: PathBuf| resolve_from(executable_directory, path);
let data_root = self
.data_root
.map(resolve)
.unwrap_or_else(|| executable_directory.join("Backup"));
let server_config_path = resolve(self.server_config_path);
let client_source_directory = resolve(self.client_source_directory);
ResolvedSettings {
interface_name: self.interface_name,
server_config_path,
client_source_directory,
server_endpoint: self.server_endpoint,
ipv4_allocation_range: self.ipv4_allocation_range,
ipv6_allocation_range: self.ipv6_allocation_range,
dns_addresses: self.dns_addresses,
systemd_unit: self.systemd_unit,
restart_timeout: self.restart_timeout,
data_directory: data_root.join("data"),
archives_directory: data_root.join("archives"),
data_root,
}
}
/// Return the fixed settings file path beside the executable directory.
pub fn settings_path(executable_directory: &Path) -> PathBuf {
executable_directory.join(SETTINGS_FILE_NAME)
}
}
impl ResolvedSettings {
pub fn settings_path(executable_directory: &Path) -> PathBuf {
ValidatedSettings::settings_path(executable_directory)
}
}
fn resolve_from(executable_directory: &Path, path: PathBuf) -> PathBuf {
if path.is_absolute() {
path
} else {
executable_directory.join(path)
}
}
fn validate_interface_name(name: &str) -> Result<(), SettingsError> {
if name.is_empty()
|| name.len() > 15
|| name == "."
|| name == ".."
|| !name
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.'))
{
return Err(invalid(
"interface_name",
"must be 1–15 ASCII letters, digits, '.', '_' or '-', and not '.' or '..'",
));
}
Ok(())
}
fn validate_systemd_unit(unit: &str) -> Result<(), SettingsError> {
if unit.is_empty()
|| unit.len() > 255
|| !unit.ends_with(".service")
|| !unit.bytes().all(|byte| {
byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.' | b'@' | b':')
})
{
return Err(invalid(
"systemd_unit",
"must be a service unit name ending in .service, without a path or whitespace",
));
}
Ok(())
}
fn validate_path(field: &'static str, path: &Path) -> Result<(), SettingsError> {
if path.as_os_str().is_empty() {
return Err(invalid(field, "must not be empty"));
}
Ok(())
}
fn parse_endpoint(value: &str) -> Result<ServerEndpoint, SettingsError> {
let (host_text, port_text) = if let Some(bracketed) = value.strip_prefix('[') {
let (host, suffix) = bracketed.split_once(']').ok_or_else(|| {
invalid(
"server_endpoint",
"bracketed IPv6 address is missing ']' or a port",
)
})?;
let port = suffix
.strip_prefix(':')
.ok_or_else(|| invalid("server_endpoint", "expected a port after bracketed host"))?;
let address = IpAddr::from_str(host).map_err(|_| {
invalid(
"server_endpoint",
"brackets are only valid around an IPv6 address",
)
})?;
if !address.is_ipv6() {
return Err(invalid(
"server_endpoint",
"brackets are only valid around IPv6",
));
}
(EndpointHost::Ip(address), port)
} else {
let (host, port) = value.rsplit_once(':').ok_or_else(|| {
invalid(
"server_endpoint",
"must include a port; bracket IPv6 addresses",
)
})?;
if host.contains(':') {
return Err(invalid(
"server_endpoint",
"IPv6 addresses must be enclosed in brackets",
));
}
let parsed_host = if let Ok(address) = host.parse::<Ipv4Addr>() {
EndpointHost::Ip(IpAddr::V4(address))
} else if valid_hostname(host) {
EndpointHost::Hostname(host.to_owned())
} else {
return Err(invalid(
"server_endpoint",
"host must be a valid hostname or IP address",
));
};
(parsed_host, port)
};
let port = port_text
.parse::<u16>()
.ok()
.filter(|port| *port != 0)
.ok_or_else(|| {
invalid(
"server_endpoint",
"port must be an integer from 1 through 65535",
)
})?;
Ok(ServerEndpoint {
host: host_text,
port,
})
}
fn valid_hostname(host: &str) -> bool {
!host.is_empty()
&& host.len() <= 253
&& host.split('.').all(|label| {
!label.is_empty()
&& label.len() <= 63
&& label.as_bytes()[0].is_ascii_alphanumeric()
&& label.as_bytes()[label.len() - 1].is_ascii_alphanumeric()
&& label
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
})
}
fn invalid(field: &'static str, reason: impl Into<String>) -> SettingsError {
SettingsError::InvalidField {
field,
reason: reason.into(),
}
}
#[cfg(test)]
mod tests {
use super::{EndpointHost, SETTINGS_FILE_NAME, SettingsError, SettingsFile};
use std::net::{IpAddr, Ipv4Addr};
use std::path::Path;
use std::time::Duration;
const VALID: &str = r#"
interface_name = "wg0"
server_config_path = "etc/wireguard/wg0.conf"
client_source_directory = "clients"
server_endpoint = "vpn.example.org:51820"
ipv4_allocation_range = "10.80.0.0/24"
ipv6_allocation_range = "fd80::/64"
dns_addresses = ["10.80.0.1", "fd80::1"]
systemd_unit = "wg-quick@wg0.service"
restart_timeout_seconds = 30
"#;
fn validated(input: &str) -> super::ValidatedSettings {
SettingsFile::parse_toml(input)
.and_then(SettingsFile::validate)
.unwrap_or_else(|error| panic!("fixture settings should validate: {error}"))
}
#[test]
fn parses_required_values_and_resolves_defaults_relative_to_executable() {
let settings = validated(VALID);
assert_eq!(settings.server_endpoint.port, 51820);
assert_eq!(
settings.dns_addresses[0],
IpAddr::V4(Ipv4Addr::new(10, 80, 0, 1))
);
assert_eq!(settings.restart_timeout, Duration::from_secs(30));
let resolved = settings.resolve_paths(Path::new("/opt/wireguard-admin"));
assert_eq!(
resolved.server_config_path,
Path::new("/opt/wireguard-admin/etc/wireguard/wg0.conf")
);
assert_eq!(
resolved.client_source_directory,
Path::new("/opt/wireguard-admin/clients")
);
assert_eq!(resolved.data_root, Path::new("/opt/wireguard-admin/Backup"));
assert_eq!(
resolved.data_directory,
Path::new("/opt/wireguard-admin/Backup/data")
);
assert_eq!(
resolved.archives_directory,
Path::new("/opt/wireguard-admin/Backup/archives")
);
assert_eq!(
super::ResolvedSettings::settings_path(Path::new("/opt/wireguard-admin")),
Path::new("/opt/wireguard-admin").join(SETTINGS_FILE_NAME)
);
}
#[test]
fn explicit_data_root_and_absolute_paths_are_preserved() {
let input = VALID.replace(
"server_config_path = \"etc/wireguard/wg0.conf\"",
"server_config_path = \"/etc/wireguard/wg0.conf\"",
) + "data_root = \"state\"\n";
let resolved = validated(&input).resolve_paths(Path::new("/opt/app"));
assert_eq!(
resolved.server_config_path,
Path::new("/etc/wireguard/wg0.conf")
);
assert_eq!(resolved.data_root, Path::new("/opt/app/state"));
}
#[test]
fn parses_hostname_ipv4_and_bracketed_ipv6_endpoints() {
assert!(matches!(
validated(VALID).server_endpoint.host,
EndpointHost::Hostname(_)
));
let ipv4 = VALID.replace("vpn.example.org:51820", "192.0.2.2:51820");
assert_eq!(
validated(&ipv4).server_endpoint.host,
EndpointHost::Ip("192.0.2.2".parse().unwrap())
);
let ipv6 = VALID.replace("vpn.example.org:51820", "[2001:db8::2]:51820");
assert_eq!(
validated(&ipv6).server_endpoint.host,
EndpointHost::Ip("2001:db8::2".parse().unwrap())
);
}
#[test]
fn rejects_missing_or_malformed_values_with_field_context() {
let missing = VALID.replace("systemd_unit = \"wg-quick@wg0.service\"\n", "");
assert!(matches!(
SettingsFile::parse_toml(&missing),
Err(SettingsError::Parse(_))
));
for (input, field) in [
(
VALID.replace(
"interface_name = \"wg0\"",
"interface_name = \"interface/name\"",
),
"interface_name",
),
(
VALID.replace(
"server_config_path = \"etc/wireguard/wg0.conf\"",
"server_config_path = \"\"",
),
"server_config_path",
),
(
VALID.replace(
"server_endpoint = \"vpn.example.org:51820\"",
"server_endpoint = \"2001:db8::2:51820\"",
),
"server_endpoint",
),
(
VALID.replace(
"ipv4_allocation_range = \"10.80.0.0/24\"",
"ipv4_allocation_range = \"fd80::/64\"",
),
"ipv4_allocation_range",
),
(
VALID.replace(
"ipv6_allocation_range = \"fd80::/64\"",
"ipv6_allocation_range = \"10.80.0.0/24\"",
),
"ipv6_allocation_range",
),
(
VALID.replace(
"dns_addresses = [\"10.80.0.1\", \"fd80::1\"]",
"dns_addresses = [\"vpn.example.org\"]",
),
"dns_addresses",
),
(
VALID.replace(
"systemd_unit = \"wg-quick@wg0.service\"",
"systemd_unit = \"../wg0.service\"",
),
"systemd_unit",
),
(
VALID.replace(
"restart_timeout_seconds = 30",
"restart_timeout_seconds = 0",
),
"restart_timeout_seconds",
),
(
VALID.replace(
"server_endpoint = \"vpn.example.org:51820\"",
"server_endpoint = \"vpn.example.org:0\"",
),
"server_endpoint",
),
] {
let error = SettingsFile::parse_toml(&input)
.and_then(SettingsFile::validate)
.expect_err("invalid fixture must fail validation");
assert!(error.to_string().contains(field), "{error}");
}
}
#[test]
fn paths_are_only_resolved_and_not_checked_for_existence() {
let mut input = VALID.replace(
"server_config_path = \"etc/wireguard/wg0.conf\"",
"server_config_path = \"does-not-exist/server.conf\"",
);
input = input.replace(
"client_source_directory = \"clients\"",
"client_source_directory = \"also-absent\"",
);
assert!(
validated(&input)
.resolve_paths(Path::new("/fixture/bin"))
.server_config_path
.ends_with("does-not-exist/server.conf")
);
}
}
+791
View File
@@ -0,0 +1,791 @@
//! 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
}
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 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,
}
/// 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)
);
}
}
+3
View File
@@ -0,0 +1,3 @@
pub mod config;
pub mod domain;
pub mod startup;
+34
View File
@@ -0,0 +1,34 @@
use std::env;
use std::ffi::OsString;
use std::process::ExitCode;
use wireguard_admin::startup::{self, StartupAction};
const USAGE: &str = "Usage: wireguard-admin [--help | --version]\n\
With no arguments, starts the root-only management interface.\n\
The management interface is not implemented yet.";
fn main() -> ExitCode {
let arguments: Vec<OsString> = env::args_os().skip(1).collect();
let result = startup::start(&arguments, || {
rustix::process::geteuid() == rustix::process::Uid::ROOT
});
match result {
Ok(StartupAction::Help) => {
println!("{USAGE}");
ExitCode::SUCCESS
}
Ok(StartupAction::Version) => {
println!("wireguard-admin {}", env!("CARGO_PKG_VERSION"));
ExitCode::SUCCESS
}
Ok(StartupAction::ManagementUnavailable) => {
eprintln!("The management interface is not implemented yet.");
ExitCode::from(1)
}
Err(error) => {
eprintln!("Error: {error}.\n{USAGE}");
ExitCode::from(2)
}
}
}
+92
View File
@@ -0,0 +1,92 @@
use std::ffi::OsString;
use std::fmt;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum StartupAction {
Help,
Version,
ManagementUnavailable,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum StartupError {
UnknownArguments,
RootRequired,
}
impl fmt::Display for StartupError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::UnknownArguments => write!(formatter, "unknown or unsupported arguments"),
Self::RootRequired => write!(formatter, "management operations require root"),
}
}
}
impl std::error::Error for StartupError {}
/// Resolves the supported command line and checks management privilege lazily.
///
/// The injected root check keeps this boundary testable without running tests as
/// root. Help, version, and invalid arguments do not query process privileges.
pub fn start(
arguments: &[OsString],
effective_uid_is_root: impl FnOnce() -> bool,
) -> Result<StartupAction, StartupError> {
match arguments {
[] => {
if effective_uid_is_root() {
Ok(StartupAction::ManagementUnavailable)
} else {
Err(StartupError::RootRequired)
}
}
[argument] if argument == "--help" => Ok(StartupAction::Help),
[argument] if argument == "--version" => Ok(StartupAction::Version),
_ => Err(StartupError::UnknownArguments),
}
}
#[cfg(test)]
mod tests {
use super::{StartupAction, StartupError, start};
use std::ffi::OsString;
#[test]
fn help_and_version_do_not_query_root() {
for (argument, expected) in [
("--help", StartupAction::Help),
("--version", StartupAction::Version),
] {
let result = start(&[OsString::from(argument)], || {
panic!("root check must not run for {argument}")
});
assert_eq!(result, Ok(expected));
}
}
#[test]
fn non_root_is_rejected_before_management_startup() {
let result = start(&[], || false);
assert_eq!(result, Err(StartupError::RootRequired));
}
#[test]
fn root_management_startup_reports_unimplemented_without_side_effects() {
let result = start(&[], || true);
assert_eq!(result, Ok(StartupAction::ManagementUnavailable));
}
#[test]
fn unknown_or_extra_arguments_fail_without_root_check() {
for arguments in [
vec![OsString::from("--settings")],
vec![OsString::from("--help"), OsString::from("unexpected")],
] {
let result = start(&arguments, || {
panic!("root check must not run for invalid arguments")
});
assert_eq!(result, Err(StartupError::UnknownArguments));
}
}
}