Define Rust environment and toolchain
This commit is contained in:
+509
@@ -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
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod config;
|
||||
pub mod domain;
|
||||
pub mod startup;
|
||||
+34
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user