//! TOML configuration for the Aura server and client (project §9). //! //! This module defines the serde structs that mirror the `server.toml` / `client.toml` schemas, //! plus the glue that turns them into the runtime types the libraries expect: //! //! * [`expand_tilde`] expands a leading `~` in any path field to the user's home directory (read //! from `$HOME`, falling back to `$USERPROFILE` on Windows). //! * [`ServerConfigFile::load`] / [`ClientConfigFile::load`] read and parse a config file. //! * [`ServerConfigFile::to_proto`] / [`ClientConfigFile::to_proto`] read the PEM files named in //! the `[pki]` table and build [`aura_proto::ServerConfig`] / [`aura_proto::ClientConfig`]. //! * [`ClientConfigFile::build_route_table`] turns `[tunnel.split]` into a [`RouteTable`] (CIDR //! rules applied directly; domain rules recorded for later DNS resolution). use std::collections::{BTreeMap, HashMap}; use std::fs; use std::net::{IpAddr, SocketAddr}; use std::path::{Path, PathBuf}; use std::str::FromStr; use std::time::Duration; use anyhow::{anyhow, Context}; use aura_transport::{DialConfig, Endpoints, TcpOpts, TransportMode, UdpOpts}; use aura_tunnel::{RouteAction, RouteTable}; use ipnetwork::IpNetwork; use serde::Deserialize; use crate::pool::PoolStrategy; // ---- server.toml ---------------------------------------------------------------------------- /// Top-level `server.toml` document. #[derive(Debug, Clone, Deserialize)] pub struct ServerConfigFile { /// `[server]` section: identity and listen socket. pub server: ServerSection, /// `[pki]` section: CA + leaf cert/key file paths. pub pki: PkiSection, /// `[tunnel]` section: address pool, MTU, DNS. pub tunnel: ServerTunnelSection, /// `[mimicry]` section: outer-TLS camouflage knobs. #[serde(default)] pub mimicry: ServerMimicrySection, /// `[transport]` section: which transports to enable and their per-transport ports/options. #[serde(default)] pub transport: TransportSection, } /// `[server.pool]` section: the v2 per-client IP pool + static reservations. /// /// Optional for backwards compatibility. When the section is omitted the server falls back to /// `[tunnel] pool_cidr` interpreted as a [`PoolStrategy::DynamicOnly`] pool. The server's own IP /// (the network-address + 1) is implicit; it is reserved automatically by [`crate::pool::IpPool`]. /// /// Example: /// ```toml /// [server.pool] /// cidr = "10.8.0.0/24" /// strategy = "static_or_dynamic" # or "static_only" / "dynamic_only" /// /// [server.pool.static] /// "phone-1" = "10.8.0.2" /// "laptop-1" = "10.8.0.3" /// ``` #[derive(Debug, Clone, Default, Deserialize)] #[serde(default)] pub struct ServerPoolSection { /// Optional pool CIDR; when omitted the section's existence still selects the v2 path but the /// CIDR falls back to `[tunnel] pool_cidr`. (The two-keys-are-fine semantics keeps editing /// a `pool_cidr`-style config painless.) pub cidr: Option, /// Allocation strategy: `"static_only"`, `"dynamic_only"`, or `"static_or_dynamic"`. pub strategy: Option, /// `client_id -> ip` reservations applied under StaticOnly / StaticOrDynamic. The map key is /// the verified Common Name from the client's certificate; the value is an IP in `cidr`. #[serde(rename = "static")] pub static_map: BTreeMap, } /// Fully resolved [`ServerPoolSection`] with parsed CIDR + strategy + static map. /// /// Built by [`ServerConfigFile::resolve_pool_config`]; fed to [`crate::pool::IpPool::new`]. #[derive(Debug, Clone)] pub struct ResolvedPoolConfig { /// Pool CIDR. pub cidr: IpNetwork, /// Allocation strategy. pub strategy: PoolStrategy, /// Parsed `client_id -> ip` static reservations. pub static_map: HashMap, } /// `[server]` section. #[derive(Debug, Clone, Deserialize)] pub struct ServerSection { /// Human-readable server name (also the inner-handshake server identity). pub name: String, /// UDP socket to listen on, e.g. `"0.0.0.0:443"`. #[serde(default = "default_listen")] pub listen: String, /// Number of accept workers (advisory in v1). #[serde(default = "default_workers")] pub workers: usize, /// `[server.pool]` sub-section: v2 per-client IP pool. Omitting it triggers a v1-compatible /// fallback that interprets `[tunnel] pool_cidr` as a [`PoolStrategy::DynamicOnly`] pool. #[serde(default)] pub pool: ServerPoolSection, /// `[server.nat]` sub-section: v2 auto-NAT (IP forward + MASQUERADE) applied at startup and /// rolled back at shutdown. Omitting it (the default) leaves the host network untouched — /// this is the v1 behaviour where the operator manually pre-configures forwarding. #[serde(default)] pub nat: Option, /// Optional non-root user to drop privileges to **after** all startup work that needs root /// (TUN open, low-port bind, NAT configuration). When omitted (or already non-root) the /// server keeps its current credentials. #[serde(default)] pub run_as: Option, } /// `[server.nat]` section: v2 auto-NAT configuration. See [`crate::nat`] for the apply / rollback /// semantics. Optional — when the section is omitted the server makes no changes to the host's /// IP forwarding state, matching v1 behaviour. #[derive(Debug, Clone, Default, Deserialize)] #[serde(default)] pub struct ServerNatSection { /// Master switch. When `false` (or the section is omitted) the server does NOT touch the /// host network — the operator is expected to have configured forwarding by hand. When /// `true` the server applies the platform-appropriate set of commands at startup and /// rolls them back on shutdown. pub auto: bool, /// Name of the host interface traffic egresses through (e.g. `"eth0"` on Linux, `"en0"` on /// macOS). REQUIRED when `auto = true` — there is no auto-detection in v1 (that is v3). pub egress_iface: String, /// When `true`, every command is only logged (`would run: ...`) and not executed. Useful /// for verifying the plan without root privileges and for the unit tests. pub dry_run: bool, } /// `[tunnel]` section of `server.toml`. #[derive(Debug, Clone, Deserialize)] pub struct ServerTunnelSection { /// CIDR of the address pool handed to clients (single shared TUN in v1). pub pool_cidr: String, /// MTU of the server-side TUN. #[serde(default = "default_mtu")] pub mtu: u16, /// DNS server advertised to clients (informational in v1). #[serde(default)] pub dns: Option, } /// `[mimicry]` section of `server.toml`. #[derive(Debug, Clone, Default, Deserialize)] pub struct ServerMimicrySection { /// SNI the server expects / presents for outer-TLS camouflage. #[serde(default)] pub sni: Option, /// Whether to enable traffic padding. #[serde(default)] pub padding: bool, } // ---- client.toml ---------------------------------------------------------------------------- /// Top-level `client.toml` document. #[derive(Debug, Clone, Deserialize)] pub struct ClientConfigFile { /// `[client]` section: identity and server address. pub client: ClientSection, /// `[pki]` section: CA + leaf cert/key file paths. pub pki: PkiSection, /// `[tunnel]` section: TUN device, addressing, split-tunnel rules. pub tunnel: ClientTunnelSection, /// `[mimicry]` section: outer-TLS camouflage knobs. #[serde(default)] pub mimicry: ClientMimicrySection, /// `[transport]` section: fallback order and per-transport ports/options. #[serde(default)] pub transport: TransportSection, } /// `[client]` section. #[derive(Debug, Clone, Deserialize)] pub struct ClientSection { /// Human-readable client name / id. pub name: String, /// Server UDP socket address, e.g. `"203.0.113.10:443"`. pub server_addr: String, /// Outer-TLS SNI (camouflage hostname) presented to the server. pub sni: String, /// Optional non-root user to drop privileges to **after** the TUN is open. When omitted /// (or already non-root) the client keeps its current credentials. See [`crate::privdrop`]. #[serde(default)] pub run_as: Option, } /// `[tunnel]` section of `client.toml`. #[derive(Debug, Clone, Deserialize)] pub struct ClientTunnelSection { /// Requested TUN interface name (advisory on macOS, where the kernel assigns `utunN`). #[serde(default = "default_tun_name")] pub tun_name: String, /// Local IP address assigned to the TUN device. pub local_ip: String, /// Prefix length for the TUN address. #[serde(default = "default_prefix")] pub prefix: u8, /// MTU of the TUN device. #[serde(default = "default_mtu")] pub mtu: u16, /// DNS server used by the tunnel resolver (informational; the system resolver is used in v1). #[serde(default)] pub dns: Option, /// `[tunnel.split]` split-tunnel configuration. #[serde(default)] pub split: SplitSection, /// `[tunnel.os_routes]` sub-section: v2 OS-level split tunnelling. Omitting it (or setting /// `enabled = false`) preserves the v1 user-space behaviour where the [`AuraRouter`] sees /// every packet (the `send_direct` path was a stub). When enabled, the client programs the /// system routing table so DIRECT destinations bypass the TUN entirely and only /// VPN-classified traffic reaches it. See [`crate::os_routes`]. #[serde(default)] pub os_routes: Option, } /// `[tunnel.os_routes]` section: v2 OS-level split-tunnel programming. When `enabled` (the /// default), the client adds system routes at startup so DIRECT-classified traffic never enters /// the TUN; when omitted or `enabled = false`, behaviour falls back to the v1 user-space router. /// /// See [`crate::os_routes`] for the apply / rollback semantics. #[derive(Debug, Clone, Deserialize)] #[serde(default)] pub struct OsRoutesSection { /// Master switch. `true` (default) installs system routes via [`crate::os_routes::OsRouteGuard`]; /// `false` leaves the host routing table alone and behaves like v1. pub enabled: bool, /// When `true`, every routing command is only logged (`would run: ...`) and not executed. /// Useful for testing and for verifying the plan without root privileges. pub dry_run: bool, /// Optional explicit IPv4 default gateway. When set, the gateway-auto-detection step is /// skipped and this value is used for every DIRECT bypass route. When omitted (the default), /// the gateway is read from the host (Linux: `ip route show default`; macOS: /// `route -n get default`). pub gateway: Option, /// Optional explicit egress interface name (e.g. `"eth0"` on Linux, `"en0"` on macOS). When /// omitted (the default), derived from the same auto-detection step as `gateway`. pub egress_iface: Option, } impl Default for OsRoutesSection { fn default() -> Self { Self { enabled: true, dry_run: false, gateway: None, egress_iface: None, } } } /// `[tunnel.split]` section: default action plus direct/vpn override rules. #[derive(Debug, Clone, Deserialize)] pub struct SplitSection { /// Default action when no rule matches: `"VPN"` or `"DIRECT"` (case-insensitive). #[serde(default = "default_split_default")] pub default: String, /// Rules forcing matching destinations to egress directly. #[serde(default)] pub direct: Vec, /// Rules forcing matching destinations through the VPN. #[serde(default)] pub vpn: Vec, } impl Default for SplitSection { fn default() -> Self { Self { default: default_split_default(), direct: Vec::new(), vpn: Vec::new(), } } } /// A single split-tunnel rule: exactly one of `cidr` or `domain`. #[derive(Debug, Clone, Deserialize)] pub struct SplitRule { /// A CIDR, e.g. `"192.168.0.0/16"`. Mutually exclusive with `domain`. #[serde(default)] pub cidr: Option, /// A domain, e.g. `"example.com"`. Mutually exclusive with `cidr`. #[serde(default)] pub domain: Option, } /// `[mimicry]` section of `client.toml`. #[derive(Debug, Clone, Default, Deserialize)] pub struct ClientMimicrySection { /// Whether to enable traffic padding. #[serde(default)] pub padding: bool, } // ---- shared sections ------------------------------------------------------------------------ /// `[pki]` section shared by both config files: paths to CA cert + this peer's leaf cert/key. #[derive(Debug, Clone, Deserialize)] pub struct PkiSection { /// Path to the CA certificate PEM (trust anchor). pub ca_cert: String, /// Path to this peer's leaf certificate PEM. pub cert: String, /// Path to this peer's PKCS#8 private key PEM. pub key: String, } /// `[transport]` section shared by both config files: the set/order of transports and their ports. /// /// Aura's primary transport is its own post-quantum protocol over **plain UDP**, with **TCP/443** /// and **QUIC** (HTTP/3 mimicry) as fallbacks. This section maps directly onto /// [`aura_transport::Endpoints`] (server) and [`aura_transport::DialConfig`] (client): /// /// * On the **client**, `order` is the fallback order tried left-to-right ("handover"); the first /// transport that connects wins. /// * On the **server**, `order` selects exactly which transports are bound and accepted at once. /// /// The UDP transport and QUIC both ride UDP, so they **must** use different ports (`udp_port` vs /// `quic_port`); TCP may reuse the UDP port number (different protocol). Backwards compatible: when /// the whole section is omitted, [`TransportSection::default`] enables `udp, tcp, quic` on the /// standard ports, so pre-transport-v2 configs keep working. #[derive(Debug, Clone, Deserialize)] #[serde(default)] pub struct TransportSection { /// Client fallback order; server enables exactly these. Strings: `"udp"`, `"tcp"`, `"quic"`. pub order: Vec, /// Port for Aura's custom UDP transport. Must differ from `quic_port`. pub udp_port: u16, /// Port for the TCP fallback. May equal `udp_port` (different protocol). pub tcp_port: u16, /// Port for the QUIC fallback. Must differ from `udp_port`. pub quic_port: u16, /// UDP transport: pad datagrams up to HTTPS size buckets to blur on-wire sizes. pub obfuscate: bool, /// **Deprecated, ignored.** The TCP transport used to optionally prepend a minimal HTTP/1.1 /// preamble (a light disguise); in v2 it always uses a real outer TLS-443 handshake (a much /// stronger camouflage), so this knob has no effect. Kept for backwards-compat config parsing. pub masquerade: bool, /// `[transport.masks]`: daily protocol-mask rotation knobs. pub masks: MasksSection, } impl Default for TransportSection { fn default() -> Self { Self { order: default_transport_order(), udp_port: 443, tcp_port: 443, quic_port: 444, obfuscate: true, masquerade: true, masks: MasksSection::default(), } } } /// `[transport.masks]` section: automatic daily rotation of the obfuscation surface (SNI, HTTP /// preamble headers, padding profile) at 05:00 MSK. /// /// Both peers derive the current mask from `(CA fingerprint, MSK date)`; no wire coordination is /// needed. When disabled, the static values from `[client] sni` / `[transport] obfuscate` / /// `[mimicry] sni` are used as before (pre-rotation behaviour). #[derive(Debug, Clone, Deserialize)] #[serde(default)] pub struct MasksSection { /// `true` (default): rotate the obfuscation surface daily at 05:00 MSK = 02:00 UTC. `false`: /// use the static TOML values verbatim. pub enabled: bool, } impl Default for MasksSection { fn default() -> Self { Self { enabled: true } } } impl TransportSection { /// Parse `order` into [`TransportMode`]s, rejecting unknown names and duplicates. pub fn modes(&self) -> anyhow::Result> { let mut modes = Vec::with_capacity(self.order.len()); for raw in &self.order { let mode = TransportMode::from_str(raw) .map_err(|e| anyhow!("invalid [transport] order entry: {e}"))?; if modes.contains(&mode) { return Err(anyhow!("duplicate transport '{mode}' in [transport] order")); } modes.push(mode); } if modes.is_empty() { return Err(anyhow!( "[transport] order must list at least one transport" )); } // UDP and QUIC share the UDP socket layer, so they cannot collide on the same port. if modes.contains(&TransportMode::Udp) && modes.contains(&TransportMode::Quic) && self.udp_port == self.quic_port { return Err(anyhow!( "[transport] udp_port and quic_port must differ ({} used for both); \ the UDP transport and QUIC both use UDP", self.udp_port )); } Ok(modes) } /// The configured port for a given transport mode. fn port_for(&self, mode: TransportMode) -> u16 { match mode { TransportMode::Udp => self.udp_port, TransportMode::Tcp => self.tcp_port, TransportMode::Quic => self.quic_port, } } } // ---- defaults ------------------------------------------------------------------------------- fn default_listen() -> String { "0.0.0.0:443".to_string() } fn default_workers() -> usize { 1 } fn default_mtu() -> u16 { 1420 } fn default_prefix() -> u8 { 24 } fn default_tun_name() -> String { "aura0".to_string() } fn default_split_default() -> String { "VPN".to_string() } /// Default transport set/order when `[transport]` (or its `order`) is omitted: UDP first, then the /// TCP/443 and QUIC fallbacks. Keeps pre-transport-v2 configs working. fn default_transport_order() -> Vec { vec!["udp".to_string(), "tcp".to_string(), "quic".to_string()] } // ---- ~ expansion ---------------------------------------------------------------------------- /// Expand a leading `~` (or `~/...`) in a path to the user's home directory. /// /// The home directory is read from `$HOME` (Unix) or `$USERPROFILE` (Windows). A path that does /// not begin with `~` is returned unchanged. A bare `~` expands to the home directory itself. pub fn expand_tilde(path: &str) -> PathBuf { if path == "~" { if let Some(home) = home_dir() { return home; } return PathBuf::from(path); } if let Some(rest) = path.strip_prefix("~/") { if let Some(home) = home_dir() { return home.join(rest); } } PathBuf::from(path) } /// Best-effort home directory from the environment (no extra crate dependency). fn home_dir() -> Option { std::env::var_os("HOME") .or_else(|| std::env::var_os("USERPROFILE")) .map(PathBuf::from) .filter(|p| !p.as_os_str().is_empty()) } /// Read a PEM (or any text) file whose path may begin with `~`. fn read_pem(path: &str) -> anyhow::Result { let resolved = expand_tilde(path); fs::read_to_string(&resolved) .with_context(|| format!("reading PEM file {}", resolved.display())) } // ---- loading + conversion ------------------------------------------------------------------- impl ServerConfigFile { /// Parse a `server.toml` document from a string. pub fn parse(text: &str) -> anyhow::Result { toml::from_str(text).context("parsing server.toml") } /// Load and parse a `server.toml` file (the path itself may begin with `~`). pub fn load(path: &Path) -> anyhow::Result { let text = fs::read_to_string(path) .with_context(|| format!("reading server config {}", path.display()))?; Self::parse(&text) } /// Parse the `[server] listen` address into a [`SocketAddr`]. pub fn listen_addr(&self) -> anyhow::Result { self.server .listen .parse() .with_context(|| format!("invalid [server] listen '{}'", self.server.listen)) } /// The server-side TUN address parsed from the `[tunnel] pool_cidr` (the network address). pub fn pool_network(&self) -> anyhow::Result { self.tunnel .pool_cidr .parse() .with_context(|| format!("invalid [tunnel] pool_cidr '{}'", self.tunnel.pool_cidr)) } /// Resolve the v2 `[server.pool]` configuration with v1 fallback. /// /// Resolution order: /// /// 1. If `[server.pool]` is non-empty (any of `cidr`, `strategy`, or a static entry), use it. /// The `cidr` defaults to `[tunnel] pool_cidr` if unset; the `strategy` defaults to /// [`PoolStrategy::StaticOrDynamic`]. /// 2. Otherwise, fall back to `[tunnel] pool_cidr` as a [`PoolStrategy::DynamicOnly`] pool /// with no static reservations. This is the v1-compatible path so old configs still work. /// /// Errors are returned as readable strings on bad CIDRs / strategies / static-IP parses. pub fn resolve_pool_config(&self) -> anyhow::Result { let section = &self.server.pool; let section_is_empty = section.cidr.is_none() && section.strategy.is_none() && section.static_map.is_empty(); // Pick the CIDR: [server.pool] cidr wins, then [tunnel] pool_cidr. let cidr_str = section .cidr .as_deref() .unwrap_or(self.tunnel.pool_cidr.as_str()); if cidr_str.is_empty() { return Err(anyhow!( "neither [server.pool] cidr nor [tunnel] pool_cidr is set — \ the server needs an address pool to allocate per-client IPs" )); } let cidr: IpNetwork = cidr_str .parse() .with_context(|| format!("invalid pool cidr '{cidr_str}'"))?; // Pick the strategy. When the section is wholly absent, fall back to DynamicOnly so // old [tunnel] pool_cidr-only configs keep working without per-client static pinning. let strategy = if section_is_empty { PoolStrategy::DynamicOnly } else { match section.strategy.as_deref().unwrap_or("static_or_dynamic") { "static_only" => PoolStrategy::StaticOnly, "dynamic_only" => PoolStrategy::DynamicOnly, "static_or_dynamic" => PoolStrategy::StaticOrDynamic, other => { return Err(anyhow!( "invalid [server.pool] strategy '{other}' \ (expected 'static_only' | 'dynamic_only' | 'static_or_dynamic')" )); } } }; // Parse the static map. let mut static_map: HashMap = HashMap::new(); for (cid, ip_str) in §ion.static_map { let ip: IpAddr = ip_str .parse() .with_context(|| format!("invalid IP '{ip_str}' for static reservation '{cid}'"))?; static_map.insert(cid.clone(), ip); } Ok(ResolvedPoolConfig { cidr, strategy, static_map, }) } /// Read the `[pki]` PEM files and build an [`aura_proto::ServerConfig`]. pub fn to_proto(&self) -> anyhow::Result { Ok(aura_proto::ServerConfig { ca_cert_pem: read_pem(&self.pki.ca_cert)?, server_cert_pem: read_pem(&self.pki.cert)?, server_key_pem: read_pem(&self.pki.key)?, }) } /// Build the per-transport [`Endpoints`] the [`aura_transport::MultiServer`] should bind. /// /// The listen **IP** is reused from `[server] listen`; each enabled transport (from /// `[transport] order`) gets its `udp_port` / `tcp_port` / `quic_port`. Transports not listed in /// `order` are left `None` (disabled). pub fn transport_endpoints(&self) -> anyhow::Result { let ip = self.listen_addr()?.ip(); let modes = self.transport.modes()?; let mut endpoints = Endpoints::default(); for mode in modes { let addr = SocketAddr::new(ip, self.transport.port_for(mode)); match mode { TransportMode::Udp => endpoints.udp = Some(addr), TransportMode::Tcp => endpoints.tcp = Some(addr), TransportMode::Quic => endpoints.quic = Some(addr), } } Ok(endpoints) } /// Build the [`UdpOpts`] for the server's UDP transport from `[transport] obfuscate` (timeouts /// keep their library defaults). pub fn udp_opts(&self) -> UdpOpts { UdpOpts { obfuscate: self.transport.obfuscate, ..UdpOpts::default() } } /// Build the [`TcpOpts`] for the server's TCP transport. /// /// In v2 the TCP backend always uses a real outer TLS-443 layer, so there are no per-config /// knobs here (ALPN keeps its `[h2, http/1.1]` default). The legacy `[transport] masquerade` / /// `[mimicry] sni` values are still parsed for backwards compatibility but are no longer plumbed /// into [`TcpOpts`]. pub fn tcp_opts(&self) -> TcpOpts { TcpOpts::default() } } impl ClientConfigFile { /// Parse a `client.toml` document from a string. pub fn parse(text: &str) -> anyhow::Result { toml::from_str(text).context("parsing client.toml") } /// Load and parse a `client.toml` file (the path itself may begin with `~`). pub fn load(path: &Path) -> anyhow::Result { let text = fs::read_to_string(path) .with_context(|| format!("reading client config {}", path.display()))?; Self::parse(&text) } /// Parse the `[client] server_addr` into a [`SocketAddr`]. pub fn server_socket_addr(&self) -> anyhow::Result { self.client .server_addr .parse() .with_context(|| format!("invalid [client] server_addr '{}'", self.client.server_addr)) } /// Parse the `[tunnel] local_ip` into an [`std::net::IpAddr`]. pub fn local_ip(&self) -> anyhow::Result { self.tunnel .local_ip .parse() .with_context(|| format!("invalid [tunnel] local_ip '{}'", self.tunnel.local_ip)) } /// Build the [`DialConfig`] the client passes to [`aura_transport::dial`]. /// /// The server **IP** is taken from `[client] server_addr` (its port is ignored: each transport /// uses its own port from `[transport]`). `order` becomes the fallback order. Per-transport /// options: UDP gets `obfuscate` from `[transport]`; TCP/QUIC both use `[client] sni` as their /// outer-TLS camouflage SNI (TLS is now real on the TCP side too, see /// [`aura_transport::TcpClient::connect`]). pub fn dial_config(&self) -> anyhow::Result { let ip = self.server_socket_addr()?.ip(); let order = self.transport.modes()?; let mut endpoints = Endpoints::default(); for mode in &order { let addr = SocketAddr::new(ip, self.transport.port_for(*mode)); match mode { TransportMode::Udp => endpoints.udp = Some(addr), TransportMode::Tcp => endpoints.tcp = Some(addr), TransportMode::Quic => endpoints.quic = Some(addr), } } Ok(DialConfig { endpoints, sni: self.client.sni.clone(), order, udp: UdpOpts { obfuscate: self.transport.obfuscate, ..UdpOpts::default() }, tcp: TcpOpts::default(), attempt_timeout: Duration::from_secs(8), }) } /// Read the `[pki]` PEM files and build an [`aura_proto::ClientConfig`]. /// /// The inner-handshake `server_name` is taken from `[client] sni` so the SAN verified against /// the server certificate matches the camouflage hostname; deployments that separate the two /// can extend this later. pub fn to_proto(&self) -> anyhow::Result { Ok(aura_proto::ClientConfig { ca_cert_pem: read_pem(&self.pki.ca_cert)?, client_cert_pem: read_pem(&self.pki.cert)?, client_key_pem: read_pem(&self.pki.key)?, server_name: self.client.sni.clone(), }) } /// Build a [`RouteTable`] from `[tunnel.split]`. /// /// CIDR rules are applied directly. Domain rules are recorded via [`RouteTable::add_domain`] /// (they only become matchable once [`aura_tunnel::AuraDns::resolve_and_register`] resolves /// them into host routes). Returns the table plus the list of `(domain, action)` pairs the /// caller should resolve. pub fn build_route_table(&self) -> anyhow::Result<(RouteTable, Vec<(String, RouteAction)>)> { let default = parse_action(&self.tunnel.split.default)?; let mut table = RouteTable::new(default); let mut domains = Vec::new(); for (rules, action) in [ (&self.tunnel.split.direct, RouteAction::Direct), (&self.tunnel.split.vpn, RouteAction::Vpn), ] { for rule in rules { match (&rule.cidr, &rule.domain) { (Some(cidr), None) => { let net: IpNetwork = cidr .parse() .with_context(|| format!("invalid split-tunnel cidr '{cidr}'"))?; table.add_cidr(net, action); } (None, Some(domain)) => { table.add_domain(domain, action); domains.push((domain.clone(), action)); } (Some(_), Some(_)) => { return Err(anyhow!( "split-tunnel rule has both 'cidr' and 'domain'; specify exactly one" )); } (None, None) => { return Err(anyhow!( "split-tunnel rule has neither 'cidr' nor 'domain'; specify exactly one" )); } } } } Ok((table, domains)) } } /// Parse a `"VPN"` / `"DIRECT"` action string (case-insensitive) into a [`RouteAction`]. pub fn parse_action(s: &str) -> anyhow::Result { match s.trim().to_ascii_lowercase().as_str() { "vpn" => Ok(RouteAction::Vpn), "direct" => Ok(RouteAction::Direct), other => Err(anyhow!( "invalid route action '{other}' (expected 'vpn' or 'direct')" )), } } #[cfg(test)] mod tests { use super::*; use std::net::IpAddr; const SERVER_TOML: &str = r#" [server] name = "edge-1" listen = "0.0.0.0:8443" workers = 4 [pki] ca_cert = "/etc/aura/ca.crt" cert = "/etc/aura/server.crt" key = "/etc/aura/server.key" [tunnel] pool_cidr = "10.7.0.0/24" mtu = 1380 dns = "10.7.0.1" [mimicry] sni = "cdn.example.com" padding = true [transport] order = ["udp", "tcp", "quic"] udp_port = 4433 tcp_port = 4433 quic_port = 4434 obfuscate = true masquerade = true "#; const CLIENT_TOML: &str = r#" [client] name = "laptop" server_addr = "203.0.113.10:8443" sni = "cdn.example.com" [pki] ca_cert = "~/.aura/ca.crt" cert = "~/.aura/client.crt" key = "~/.aura/client.key" [tunnel] tun_name = "aura0" local_ip = "10.7.0.2" prefix = 24 mtu = 1380 [tunnel.split] default = "VPN" [[tunnel.split.direct]] cidr = "192.168.0.0/16" [[tunnel.split.direct]] cidr = "10.0.0.0/8" [[tunnel.split.direct]] domain = "intranet.example.com" [[tunnel.split.vpn]] cidr = "10.7.0.0/24" [mimicry] padding = false [transport] order = ["tcp", "udp"] udp_port = 4433 tcp_port = 4433 quic_port = 4434 obfuscate = false masquerade = true "#; #[test] fn parses_server_toml() { let cfg = ServerConfigFile::parse(SERVER_TOML).expect("parse server.toml"); assert_eq!(cfg.server.name, "edge-1"); assert_eq!(cfg.server.workers, 4); assert_eq!(cfg.listen_addr().unwrap().port(), 8443); assert_eq!(cfg.tunnel.mtu, 1380); assert_eq!(cfg.tunnel.pool_cidr, "10.7.0.0/24"); assert!(cfg.mimicry.padding); assert_eq!(cfg.mimicry.sni.as_deref(), Some("cdn.example.com")); assert_eq!(cfg.pki.ca_cert, "/etc/aura/ca.crt"); // [transport]: order + ports parse and the server endpoints reuse the listen IP. assert_eq!(cfg.transport.order, vec!["udp", "tcp", "quic"]); assert_eq!(cfg.transport.quic_port, 4434); let eps = cfg.transport_endpoints().expect("server endpoints"); assert_eq!(eps.udp.unwrap().to_string(), "0.0.0.0:4433"); assert_eq!(eps.tcp.unwrap().to_string(), "0.0.0.0:4433"); assert_eq!(eps.quic.unwrap().to_string(), "0.0.0.0:4434"); assert!(cfg.udp_opts().obfuscate); // TCP options are now ALPN-only (real outer TLS handles the camouflage); the legacy // [transport] masquerade / [mimicry] sni values are parsed but no longer plumbed into TcpOpts. let tcp = cfg.tcp_opts(); assert!(tcp.alpn.is_none(), "default ALPN is used"); } #[test] fn server_toml_defaults() { let minimal = r#" [server] name = "edge" [pki] ca_cert = "a" cert = "b" key = "c" [tunnel] pool_cidr = "10.7.0.0/24" "#; let cfg = ServerConfigFile::parse(minimal).expect("parse minimal server.toml"); assert_eq!(cfg.server.listen, "0.0.0.0:443"); assert_eq!(cfg.server.workers, 1); assert_eq!(cfg.tunnel.mtu, 1420); assert!(!cfg.mimicry.padding); // Omitting [transport] yields the backward-compatible defaults (udp/tcp/quic on 443/443/444). assert_eq!(cfg.transport.order, vec!["udp", "tcp", "quic"]); assert_eq!(cfg.transport.udp_port, 443); assert_eq!(cfg.transport.tcp_port, 443); assert_eq!(cfg.transport.quic_port, 444); assert!(cfg.transport.obfuscate); assert!(cfg.transport.masquerade); let eps = cfg.transport_endpoints().expect("default endpoints"); assert_eq!(eps.udp.unwrap().to_string(), "0.0.0.0:443"); assert_eq!(eps.quic.unwrap().to_string(), "0.0.0.0:444"); } #[test] fn parses_client_toml() { let cfg = ClientConfigFile::parse(CLIENT_TOML).expect("parse client.toml"); assert_eq!(cfg.client.name, "laptop"); assert_eq!(cfg.server_socket_addr().unwrap().port(), 8443); assert_eq!(cfg.client.sni, "cdn.example.com"); assert_eq!( cfg.local_ip().unwrap(), "10.7.0.2".parse::().unwrap() ); assert_eq!(cfg.tunnel.prefix, 24); assert_eq!(cfg.tunnel.split.direct.len(), 3); assert_eq!(cfg.tunnel.split.vpn.len(), 1); // [transport]: the client dial config honors `order` and reuses the server IP per transport. let dial = cfg.dial_config().expect("client dial config"); assert_eq!( dial.order, vec![TransportMode::Tcp, TransportMode::Udp] // as written in CLIENT_TOML ); assert_eq!(dial.endpoints.tcp.unwrap().to_string(), "203.0.113.10:4433"); assert_eq!(dial.endpoints.udp.unwrap().to_string(), "203.0.113.10:4433"); // QUIC was not in `order`, so it is left unconfigured. assert!(dial.endpoints.quic.is_none()); assert_eq!(dial.sni, "cdn.example.com"); assert!(!dial.udp.obfuscate); // TCP is wrapped in real outer TLS now; the legacy HTTP `Host` / masquerade fields are gone. // The outer TLS SNI is `dial.sni`, asserted above. assert!(dial.tcp.alpn.is_none(), "default ALPN is used"); } #[test] fn builds_route_table_from_split() { let cfg = ClientConfigFile::parse(CLIENT_TOML).expect("parse client.toml"); let (table, domains) = cfg.build_route_table().expect("build route table"); // Default is VPN. assert_eq!(table.default_action(), RouteAction::Vpn); // 192.168.x and 10.x are Direct... assert_eq!( table.classify("192.168.1.1".parse().unwrap()), RouteAction::Direct ); assert_eq!( table.classify("10.1.2.3".parse().unwrap()), RouteAction::Direct ); // ...but the more-specific 10.7.0.0/24 VPN rule wins inside 10.0.0.0/8. assert_eq!( table.classify("10.7.0.9".parse().unwrap()), RouteAction::Vpn ); // An address matching no rule falls back to the default (VPN). assert_eq!(table.classify("8.8.8.8".parse().unwrap()), RouteAction::Vpn); // The domain rule was recorded for later resolution. assert_eq!(domains.len(), 1); assert_eq!(domains[0].0, "intranet.example.com"); assert_eq!(domains[0].1, RouteAction::Direct); } #[test] fn expand_tilde_uses_home() { std::env::set_var("HOME", "/home/tester"); assert_eq!( expand_tilde("~/.aura/ca.crt"), PathBuf::from("/home/tester/.aura/ca.crt") ); assert_eq!(expand_tilde("~"), PathBuf::from("/home/tester")); assert_eq!(expand_tilde("/abs/path"), PathBuf::from("/abs/path")); assert_eq!(expand_tilde("rel/path"), PathBuf::from("rel/path")); } #[test] fn shipped_example_configs_parse() { // The example files live at /config/. CARGO_MANIFEST_DIR points at the crate // (crates/aura-cli), so go up two levels. let root = Path::new(env!("CARGO_MANIFEST_DIR")) .parent() .and_then(Path::parent) .expect("workspace root"); let server = std::fs::read_to_string(root.join("config/server.toml.example")) .expect("read server.toml.example"); let client = std::fs::read_to_string(root.join("config/client.toml.example")) .expect("read client.toml.example"); let s = ServerConfigFile::parse(&server).expect("server.toml.example parses"); assert!(s.listen_addr().is_ok()); assert!(s.pool_network().is_ok()); // The shipped [transport] section builds valid endpoints (udp_port != quic_port). let eps = s .transport_endpoints() .expect("example server endpoints build"); assert!(eps.udp.is_some() && eps.tcp.is_some() && eps.quic.is_some()); let c = ClientConfigFile::parse(&client).expect("client.toml.example parses"); assert!(c.server_socket_addr().is_ok()); assert!(c.local_ip().is_ok()); assert!(c.dial_config().is_ok(), "example client dial config builds"); let (table, domains) = c .build_route_table() .expect("example split builds a route table"); assert_eq!(table.default_action(), RouteAction::Vpn); assert!(!domains.is_empty()); } #[test] fn rejects_rule_with_both_cidr_and_domain() { let bad = r#" [client] name = "x" server_addr = "1.2.3.4:443" sni = "a" [pki] ca_cert = "a" cert = "b" key = "c" [tunnel] local_ip = "10.7.0.2" [tunnel.split] default = "VPN" [[tunnel.split.direct]] cidr = "10.0.0.0/8" domain = "x.example.com" "#; let cfg = ClientConfigFile::parse(bad).expect("parse"); assert!(cfg.build_route_table().is_err()); } /// A client config with no `[transport]` section falls back to the udp→tcp→quic defaults so old /// configs keep working. #[test] fn client_transport_defaults_when_omitted() { let minimal = r#" [client] name = "x" server_addr = "1.2.3.4:443" sni = "a" [pki] ca_cert = "a" cert = "b" key = "c" [tunnel] local_ip = "10.7.0.2" "#; let cfg = ClientConfigFile::parse(minimal).expect("parse"); let dial = cfg.dial_config().expect("dial config"); assert_eq!( dial.order, vec![TransportMode::Udp, TransportMode::Tcp, TransportMode::Quic] ); assert_eq!(dial.endpoints.udp.unwrap().to_string(), "1.2.3.4:443"); assert_eq!(dial.endpoints.tcp.unwrap().to_string(), "1.2.3.4:443"); assert_eq!(dial.endpoints.quic.unwrap().to_string(), "1.2.3.4:444"); } /// `[server.pool]` is parsed in full (cidr + strategy + static reservations) and /// `resolve_pool_config` builds a usable `ResolvedPoolConfig`. #[test] fn parses_full_server_pool_section() { let s = r#" [server] name = "edge" [server.pool] cidr = "10.8.0.0/24" strategy = "static_or_dynamic" [server.pool.static] "phone-1" = "10.8.0.20" "laptop-1" = "10.8.0.21" [pki] ca_cert = "a" cert = "b" key = "c" [tunnel] pool_cidr = "10.7.0.0/24" "#; let cfg = ServerConfigFile::parse(s).expect("parse"); // Raw section state. assert_eq!(cfg.server.pool.cidr.as_deref(), Some("10.8.0.0/24")); assert_eq!( cfg.server.pool.strategy.as_deref(), Some("static_or_dynamic") ); assert_eq!(cfg.server.pool.static_map.len(), 2); assert_eq!( cfg.server .pool .static_map .get("phone-1") .map(String::as_str), Some("10.8.0.20") ); // Resolved view honours [server.pool] over [tunnel] pool_cidr. let resolved = cfg.resolve_pool_config().expect("resolve"); assert_eq!(resolved.cidr.to_string(), "10.8.0.0/24"); assert_eq!(resolved.strategy, PoolStrategy::StaticOrDynamic); assert_eq!(resolved.static_map.len(), 2); assert_eq!( resolved.static_map.get("laptop-1").copied(), Some("10.8.0.21".parse::().unwrap()) ); } /// `[server.pool]` strategies parse: static_only / dynamic_only / static_or_dynamic. #[test] fn parses_pool_strategies() { for (raw, expected) in [ ("static_only", PoolStrategy::StaticOnly), ("dynamic_only", PoolStrategy::DynamicOnly), ("static_or_dynamic", PoolStrategy::StaticOrDynamic), ] { let s = format!( r#" [server] name = "edge" [server.pool] cidr = "10.8.0.0/24" strategy = "{raw}" [pki] ca_cert = "a" cert = "b" key = "c" [tunnel] pool_cidr = "10.7.0.0/24" "# ); let cfg = ServerConfigFile::parse(&s).expect("parse"); let resolved = cfg.resolve_pool_config().expect("resolve"); assert_eq!(resolved.strategy, expected, "strategy {raw}"); } } /// An unknown strategy string is a hard error with a readable message. #[test] fn rejects_unknown_pool_strategy() { let s = r#" [server] name = "edge" [server.pool] cidr = "10.8.0.0/24" strategy = "nonsense" [pki] ca_cert = "a" cert = "b" key = "c" [tunnel] pool_cidr = "10.7.0.0/24" "#; let cfg = ServerConfigFile::parse(s).expect("parse"); let err = cfg.resolve_pool_config().unwrap_err().to_string(); assert!(err.contains("strategy"), "{err}"); assert!(err.contains("nonsense"), "{err}"); } /// Backwards compat: an old server.toml without `[server.pool]` resolves to /// dynamic_only over `[tunnel] pool_cidr` — the v1-compatible fallback. #[test] fn pool_fallback_when_section_omitted() { let s = r#" [server] name = "edge" [pki] ca_cert = "a" cert = "b" key = "c" [tunnel] pool_cidr = "10.7.0.0/24" "#; let cfg = ServerConfigFile::parse(s).expect("parse minimal v1 server.toml"); // No [server.pool] at all. assert!(cfg.server.pool.cidr.is_none()); assert!(cfg.server.pool.strategy.is_none()); assert!(cfg.server.pool.static_map.is_empty()); let resolved = cfg.resolve_pool_config().expect("v1 fallback resolves"); assert_eq!(resolved.cidr.to_string(), "10.7.0.0/24"); assert_eq!( resolved.strategy, PoolStrategy::DynamicOnly, "fallback strategy is dynamic_only" ); assert!(resolved.static_map.is_empty()); } /// `[server.pool]` without `cidr` reuses `[tunnel] pool_cidr`. Strategy still defaults to /// static_or_dynamic when only the section header is present (i.e. some pool field exists, /// e.g. a static reservation). #[test] fn pool_cidr_defaults_to_tunnel_pool_cidr_when_section_partial() { let s = r#" [server] name = "edge" [server.pool.static] "phone-1" = "10.7.0.20" [pki] ca_cert = "a" cert = "b" key = "c" [tunnel] pool_cidr = "10.7.0.0/24" "#; let cfg = ServerConfigFile::parse(s).expect("parse"); let resolved = cfg.resolve_pool_config().expect("resolve"); assert_eq!(resolved.cidr.to_string(), "10.7.0.0/24"); assert_eq!(resolved.strategy, PoolStrategy::StaticOrDynamic); assert_eq!(resolved.static_map.len(), 1); } /// UDP and QUIC share the UDP socket layer; configuring the same port for both must be rejected. #[test] fn rejects_udp_quic_port_collision() { let bad = r#" [server] name = "edge" [pki] ca_cert = "a" cert = "b" key = "c" [tunnel] pool_cidr = "10.7.0.0/24" [transport] order = ["udp", "quic"] udp_port = 443 quic_port = 443 "#; let cfg = ServerConfigFile::parse(bad).expect("parse"); let err = cfg.transport_endpoints().unwrap_err().to_string(); assert!( err.contains("udp_port") && err.contains("quic_port"), "{err}" ); } /// Sharing the UDP/QUIC port number is fine if only one of them is enabled. #[test] fn allows_shared_port_when_quic_disabled() { let ok = r#" [server] name = "edge" [pki] ca_cert = "a" cert = "b" key = "c" [tunnel] pool_cidr = "10.7.0.0/24" [transport] order = ["udp", "tcp"] udp_port = 443 tcp_port = 443 quic_port = 443 "#; let cfg = ServerConfigFile::parse(ok).expect("parse"); let eps = cfg.transport_endpoints().expect("endpoints"); assert!(eps.udp.is_some()); assert!(eps.tcp.is_some()); assert!(eps.quic.is_none()); } /// `[server.nat]` parses end-to-end (auto + egress_iface + dry_run) and exposes the values /// to the server startup path. #[test] fn parses_server_nat_section() { let s = r#" [server] name = "edge" [server.nat] auto = true egress_iface = "eth0" dry_run = true [pki] ca_cert = "a" cert = "b" key = "c" [tunnel] pool_cidr = "10.7.0.0/24" "#; let cfg = ServerConfigFile::parse(s).expect("parse server.toml with [server.nat]"); let nat = cfg.server.nat.as_ref().expect("nat section present"); assert!(nat.auto, "auto = true"); assert_eq!(nat.egress_iface, "eth0"); assert!(nat.dry_run, "dry_run = true"); } /// Backwards compat: an old server.toml without `[server.nat]` parses fine and exposes /// `nat = None`. This preserves the v1 "operator configures NAT by hand" behaviour. #[test] fn server_nat_section_optional() { let s = r#" [server] name = "edge" [pki] ca_cert = "a" cert = "b" key = "c" [tunnel] pool_cidr = "10.7.0.0/24" "#; let cfg = ServerConfigFile::parse(s).expect("parse minimal v1 server.toml"); assert!(cfg.server.nat.is_none(), "nat section absent by default"); } /// `run_as` is parsed off both [server] and [client] sections and is optional. #[test] fn parses_run_as_on_both_configs() { let s = r#" [server] name = "edge" run_as = "nobody" [pki] ca_cert = "a" cert = "b" key = "c" [tunnel] pool_cidr = "10.7.0.0/24" "#; let cfg = ServerConfigFile::parse(s).expect("parse server.toml with run_as"); assert_eq!(cfg.server.run_as.as_deref(), Some("nobody")); let c = r#" [client] name = "x" server_addr = "1.2.3.4:443" sni = "a" run_as = "nobody" [pki] ca_cert = "a" cert = "b" key = "c" [tunnel] local_ip = "10.7.0.2" "#; let cfg = ClientConfigFile::parse(c).expect("parse client.toml with run_as"); assert_eq!(cfg.client.run_as.as_deref(), Some("nobody")); } /// An unknown transport name in `order` is a hard error (not silently dropped). #[test] fn rejects_unknown_transport_name() { let bad = r#" [client] name = "x" server_addr = "1.2.3.4:443" sni = "a" [pki] ca_cert = "a" cert = "b" key = "c" [tunnel] local_ip = "10.7.0.2" [transport] order = ["udp", "smoke-signals"] "#; let cfg = ClientConfigFile::parse(bad).expect("parse"); assert!(cfg.dial_config().is_err()); } }