//! 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.outer_cert]` sub-section: v3 explicit outer-TLS cert/key for QUIC and TCP. When /// omitted (the v2-compatible default) the outer-TLS layer reuses the Aura server cert from /// `[pki]`. When set, a passive observer on `:443` sees a normal CA-trusted handshake (e.g. /// Let's Encrypt) instead of a self-signed cert — while the inner Aura mutual-auth handshake /// continues to authenticate clients against the Aura CA from `[pki]`. #[serde(default)] pub outer_cert: Option, /// `[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, /// `[server.relay]` sub-section: v3.1 multi-hop / onion routing role. When `enabled = true`, /// this server runs as an **entry-relay** — it briefly listens for a client-issued /// `ExtendBridge` control envelope right after the handshake and (if accepted) splices the /// connection to a downstream exit-server. Omitting the section (or `enabled = false`) keeps /// the v1/v2 behaviour where every accepted connection is registered with the /// [`crate::server_router::ServerRouter`] as a normal VPN client. #[serde(default)] pub relay: RelaySection, /// 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, /// When `true`, the tracing layer suppresses event fields that would identify a peer /// (`peer_id`, `client_ip`, `source_addr`, `client_id`, `local_ip`, `user`, `id`). The event /// itself still fires (for operational counters like rx/tx packets), but no identifier is /// written. Default `false` (verbose). See [`crate::no_logs`]. #[serde(default)] pub no_logs: bool, /// v3.2: when `true`, **every** accepted UDP connection that ends up serving as a normal VPN /// client (i.e. not bridged through the relay path) is wrapped in /// [`crate::cells::CellPaddingConn`] using `[server.relay] cell_size` bytes per cell. This is /// the server-side complement to `[client.circuit] cell_padding`: the **exit** of a multi-hop /// circuit MUST enable this so its inner-handshake session decodes the client's padded cells. /// Default `false` (v3.1-compatible). Operators running an exit-only server with cell-padded /// circuit clients should set this to `true`. #[serde(default)] pub cell_padding_for_circuit_clients: bool, } /// `[server.relay]` section: v3.1 / v3.2 multi-hop / onion routing. /// /// When `enabled = true`, an accepted connection is **not** immediately registered with the /// [`crate::server_router::ServerRouter`]. Instead the server listens (for a short window) for a /// client-issued [`aura_proto::ControlKind::ExtendBridge`] envelope describing a downstream /// `exit_addr`. When the address matches one of `allow_extend_to`, the server opens a raw /// UDP bridge to that exit and forwards every byte between the client and the exit verbatim — /// the inner client↔exit Aura handshake passes through opaquely, so the relay never sees /// destination IPs or plaintext bytes. /// /// Omitting the section (the default) gives the v2 behaviour: every accepted connection is a /// VPN client and the relay path is dead code. /// /// ## v3.2 `cell_padding` /// /// When `cell_padding = true`, this server treats every bridged client connection as a /// constant-size cell stream (see [`crate::cells`]) — every accepted [`aura_proto::PacketConnection`] /// on the relay path is wrapped in [`crate::cells::CellPaddingConn`] using `cell_size` bytes per /// cell (default 1280). The **client must enable the matching flag in `[client.circuit]`** or the /// transport bytes will not be a valid cell stream. Default `false` (v3.1-compatible). #[derive(Debug, Clone, Default, Deserialize)] #[serde(default)] pub struct RelaySection { /// Master switch. `false` (default) keeps the v2 behaviour intact. pub enabled: bool, /// Whitelist of allowed downstream exit destinations. Each entry is either: /// /// * A literal `IP:port` — exact match. /// * A CIDR `IP/prefix` — matches any port at any IP in the subnet (v3.2). /// * A CIDR with explicit port `IP/prefix:port` — matches the port on any IP in the subnet /// (v3.2). For IPv6 the syntax is `[2001:db8::/32]:443`; bare-IPv6 syntax mirrors the /// `SocketAddr` brackets convention. /// /// DNS hostnames are NOT resolved (logged at WARN and skipped). An empty list means "all /// addresses allowed" — dangerous (open relay); the runtime logs a warning when this is /// detected. pub allow_extend_to: Vec, /// When `true`, every relayed connection's bytes pass through [`crate::cells::CellPaddingConn`] /// at `cell_size`. The client MUST enable the matching flag. Default `false`. pub cell_padding: bool, /// Cell size for [`crate::cells::CellPaddingConn`] when `cell_padding = true`. Default 1280. /// MUST match the client's `[client.circuit] cell_size`. #[serde(default = "default_cell_size")] pub cell_size: usize, } /// Default cell size (bytes) for the cell-padding wrapper. 1280 is the IPv6 minimum MTU and a /// commonly-seen HTTPS path MTU, so it is unlikely to look suspicious on the wire. fn default_cell_size() -> usize { crate::cells::CellPaddingConn::DEFAULT_CELL_SIZE } /// `[server.outer_cert]` section: v3 explicit outer-TLS cert/key for the QUIC and TCP transports. /// /// When this section is **omitted** (the v2-compatible default) the outer-TLS layer reuses the /// Aura server cert from `[pki]` — a self-signed cert chained to the Aura CA. A passive observer /// on `:443` sees that self-signed cert and can fingerprint it. /// /// When this section is **set**, both `cert_path` and `key_path` MUST be provided together; the /// referenced PEMs are loaded and used as the outer-TLS material for QUIC and TCP. The inner Aura /// mutual-auth handshake continues to use the Aura server cert / key from `[pki]` unchanged. The /// typical deployment points this at a Let's Encrypt fullchain.pem + privkey.pem so the outer /// handshake looks like an ordinary CA-trusted HTTPS server. /// /// Example: /// ```toml /// [server.outer_cert] /// cert_path = "/etc/letsencrypt/live/vpn.example.com/fullchain.pem" /// key_path = "/etc/letsencrypt/live/vpn.example.com/privkey.pem" /// ``` #[derive(Debug, Clone, Default, Deserialize)] #[serde(default)] pub struct ServerOuterCertSection { /// Path to the outer-TLS certificate PEM (e.g. Let's Encrypt `fullchain.pem`). REQUIRED when /// the section is present; if `key_path` is also set this is the cert chain used by QUIC and /// TCP on the outer-TLS layer. Path may begin with `~`. pub cert_path: Option, /// Path to the outer-TLS private key PEM (e.g. Let's Encrypt `privkey.pem`). REQUIRED when /// `cert_path` is set. Path may begin with `~`. pub key_path: Option, } impl ServerOuterCertSection { /// Read the outer-TLS cert/key PEMs from disk. /// /// Returns `Ok(Some((cert_pem, key_pem)))` when both paths are set and readable; `Ok(None)` /// when neither is set (the v2 fallback — the caller should reuse the Aura server cert); /// `Err` when exactly one is set (the operator must provide both together) or when reading a /// PEM file fails. pub fn resolve(&self) -> anyhow::Result> { match (&self.cert_path, &self.key_path) { (Some(c), Some(k)) => { let c_resolved = expand_tilde(&c.to_string_lossy()); let k_resolved = expand_tilde(&k.to_string_lossy()); let cert = fs::read_to_string(&c_resolved).with_context(|| { format!( "reading [server.outer_cert] cert_path {}", c_resolved.display() ) })?; let key = fs::read_to_string(&k_resolved).with_context(|| { format!( "reading [server.outer_cert] key_path {}", k_resolved.display() ) })?; Ok(Some((cert, key))) } (None, None) => Ok(None), _ => Err(anyhow!( "[server.outer_cert]: cert_path and key_path must be set together \ (got one but not the other)" )), } } } /// `[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 (and v3.1 / v3.2 `[client.circuit]` sub). 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, } impl ClientConfigFile { /// Shorthand accessor for `[client.circuit]`. The section lives on the [`ClientSection`] so /// the TOML key path matches (`client.circuit`); callers conventionally write `cfg.circuit`. pub fn circuit(&self) -> &CircuitSection { &self.client.circuit } } /// `[client.circuit]` section: v3.1 / v3.2 multi-hop / onion routing on the client. /// /// See the module-level docs of [`crate::circuit`] for the wire protocol. /// /// ## Two hop formats (both accepted) /// /// **v3.1 flat** (back-compat — every hop uses the global `[pki]` cert/key): /// /// ```toml /// [client.circuit] /// enabled = true /// hops = ["198.51.100.5:443", "203.0.113.10:443"] /// ``` /// /// **v3.2 per-hop** (each hop carries its own client cert so the entry and the exit cannot /// link the two handshakes by certificate CN): /// /// ```toml /// [client.circuit] /// enabled = true /// /// [[client.circuit.hops]] /// addr = "198.51.100.5:443" /// cert_path = "~/.config/aura/circuit/entry.crt" /// key_path = "~/.config/aura/circuit/entry.key" /// /// [[client.circuit.hops]] /// addr = "203.0.113.10:443" /// cert_path = "~/.config/aura/circuit/exit.crt" /// key_path = "~/.config/aura/circuit/exit.key" /// ``` /// /// In v3.2 the `hops` array MAY also mix string entries with table entries — the string entries /// fall back to the global `[pki]` cert/key, as in v3.1. /// /// `hops.len()` must be 2 OR 3 (v3.2 extended). v3.1 only accepted 2. #[derive(Debug, Clone, Default, Deserialize)] #[serde(default)] pub struct CircuitSection { /// Master switch. `false` (default) keeps the v2 single-hop dial path. pub enabled: bool, /// Ordered list of hops. Each entry is either a literal `"IP:port"` string (v3.1 flat /// format — uses the global `[pki]` cert/key) or a table with per-hop overrides: /// `{ addr, cert_path, key_path, server_name? }` (v3.2). Serde's `untagged` enum /// resolves the two formats transparently. pub hops: Vec, /// v3.2: pad every outgoing packet to a constant `cell_size`-byte cell before sending it /// through the circuit. Must match the relay's `[server.relay] cell_padding`. Default `false`. pub cell_padding: bool, /// v3.2: cell size in bytes when `cell_padding = true`. Default 1280. Must match the relay's /// `[server.relay] cell_size`. #[serde(default = "default_cell_size")] pub cell_size: usize, /// v3.3: background rotation interval in seconds. When greater than zero, the client wraps /// the dialed circuit in a [`crate::circuit::RotatingCircuit`] that silently rebuilds the /// N-hop chain every `rotation_interval_secs` seconds — new outer handshakes, fresh AEAD /// keys, and (with v3.2 per-hop client certs) rotated CNs. /// /// `0` (the default) keeps the v3.2 behaviour: the circuit is dialed once and reused for the /// lifetime of the session. Recommended values: 300–900 seconds (5–15 min). Very low values /// (< 60 s) hammer the entry-relay's accept path and risk wedging the circuit on flaky links. #[serde(default)] pub rotation_interval_secs: u64, } /// One entry in `[[client.circuit.hops]]`. Accepts either a flat `"IP:port"` string (v3.1 back /// compat — uses the global `[pki]` cert/key for the outer handshake to this hop) or a table with /// per-hop cert/key overrides (v3.2). The two variants are distinguished by serde's /// `#[serde(untagged)]`. #[derive(Debug, Clone, Deserialize)] #[serde(untagged)] pub enum CircuitHop { /// v3.1 flat: just the wire `IP:port`. The hop's outer handshake uses the client's global /// `[pki]` cert/key, same as every other hop — NOT identity-unlinkable. Addr(String), /// v3.2 full: `IP:port` plus per-hop cert/key paths. The optional `server_name` overrides the /// SAN expected on this hop's server cert (defaults to the global `[client] sni`). Full { /// Wire address of the hop. addr: String, /// PEM file holding this client's certificate for the handshake to **this hop**. Path may /// begin with `~`. cert_path: PathBuf, /// PEM file holding the matching PKCS#8 private key. Path may begin with `~`. key_path: PathBuf, /// Optional SAN expected on the hop's server cert. When omitted, the global `[client] sni` /// is used (matching v3.1 behaviour where every hop's SAN comes from one place). #[serde(default)] server_name: Option, }, } impl CircuitHop { /// The wire address of this hop, regardless of variant. pub fn addr(&self) -> &str { match self { Self::Addr(s) => s.as_str(), Self::Full { addr, .. } => addr.as_str(), } } } /// `[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, /// When `true`, the tracing layer suppresses event fields that would identify the user /// (`peer_id`, `client_ip`, `source_addr`, `client_id`, `local_ip`, `user`, `id`). Default /// `false` (verbose). See [`crate::no_logs`]. #[serde(default)] pub no_logs: bool, /// Optional fallback server addresses tried in random order if the primary `server_addr` /// fails on every transport. Each entry is an IP (or `IP:port`); the per-transport ports come /// from `[transport]` as for the primary endpoint. Empty / omitted means no fallbacks. /// See [`crate::dial_targets::build_dial_targets`]. #[serde(default)] pub bridges: Vec, /// `[client.circuit]` sub-section: v3.1 / v3.2 multi-hop / onion routing dial. When /// `enabled = true`, instead of dialing the server directly via [`aura_transport::dial`], the /// client builds an N-hop circuit (N = 2 or 3) from `hops`. Default `enabled = false`. /// Living inside `[client]` matches the TOML path operators write: `[client.circuit]`. #[serde(default)] pub circuit: CircuitSection, /// `[client.bridges_discovery]` sub-section: v3.3 CA-signed bridges manifest. When /// `enabled = true`, the client periodically reloads a signed manifest from /// `manifest_path` and merges the resulting bridge list with the static /// `[client] bridges` baseline. See [`crate::bridges`]. Default `enabled = false` /// (back-compat — the static list is used verbatim). #[serde(default)] pub bridges_discovery: BridgesDiscoverySection, } /// `[client.bridges_discovery]` section: v3.3 signed bridges manifest configuration. See /// [`crate::bridges::BridgeManifest`] for the wire format and [`crate::bridges::BridgesDiscoveryWatcher`] /// for the runtime behaviour. #[derive(Debug, Clone, Deserialize)] #[serde(default)] pub struct BridgesDiscoverySection { /// Master switch. `false` (the default) keeps the v3.2 behaviour where `[client] bridges` is /// the only source. `true` enables the watcher. pub enabled: bool, /// File path of the signed manifest on disk. Path may begin with `~`. REQUIRED when `enabled`. pub manifest_path: PathBuf, /// Refresh cadence in seconds. The watcher reloads the file every `refresh_interval_secs` /// (defaults to 3600 = one hour). Zero disables the background timer (one-shot load). pub refresh_interval_secs: u64, } impl Default for BridgesDiscoverySection { fn default() -> Self { Self { enabled: false, manifest_path: PathBuf::new(), refresh_interval_secs: 3600, } } } /// `[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, /// Optional CRL file path. /// /// On the **server** side this is the CRL the operator maintains via `aura pki revoke` and /// (when [`PkiSection::crl_push`] is true) is signed and pushed to every freshly handshaked /// client. On the **client** side this is the on-disk location where pushed CRLs are cached /// so revocations survive a restart even without a fresh server push. /// /// Optional — when omitted the v1 behaviour applies (server: nobody is revoked at the /// post-handshake check; client: pushed CRLs are applied to the live verifier only). #[serde(default)] pub crl: Option, /// Path to the CA **private** key, used by the server to sign the CRL before pushing it. Only /// read on the server when [`PkiSection::crl_push`] is true. Optional — when omitted and /// `crl_push` is true the server logs a warning and does not push (the v1 behaviour). #[serde(default)] pub ca_key: Option, /// Server-side toggle: push the CRL to every authenticated client right after the handshake. /// Default `true` in v2. #[serde(default = "default_true")] pub crl_push: bool, /// Client-side toggle: accept CRL pushes from the server and apply them to the live verifier. /// Default `true` in v2. #[serde(default = "default_true")] pub accept_pushed_crl: bool, } /// Default helper for serde: `true`. fn default_true() -> bool { true } /// `[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, /// `[transport.knock]`: UDP port-knocking (probe-resistance) toggle. When `enabled`, the UDP /// transport demands a 16-byte HMAC prefix on every HS datagram derived from the shared /// `knock_secret_source`. Default `enabled = false` for backwards compat. pub knock: KnockSection, /// `[transport.cover]`: idle-time cover-traffic injection on the UDP transport. Default /// `enabled = false`. pub cover: CoverSection, } 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(), knock: KnockSection::default(), cover: CoverSection::default(), } } } /// `[transport.knock]` section: UDP port-knocking (probe-resistance) toggle. When `enabled`, the /// UDP transport requires a 16-byte HMAC prefix on every HS datagram derived from the shared key. /// /// `knock_secret_source` selects how the 32-byte key is computed: /// /// * `"ca_fingerprint"` (default): `SHA-256(CA-cert-DER)`. Both peers can compute this /// independently from the CA they already trust — no wire coordination needed. #[derive(Debug, Clone, Deserialize)] #[serde(default)] pub struct KnockSection { /// Master switch. `false` (the default) keeps backwards compat — no knock prefix is added or /// validated. pub enabled: bool, /// Selector for the shared knock key. Default `"ca_fingerprint"`. pub knock_secret_source: String, } impl Default for KnockSection { fn default() -> Self { Self { enabled: false, knock_secret_source: "ca_fingerprint".to_string(), } } } /// `[transport.cover]` section: idle-time cover-traffic injection on the UDP transport. When /// `enabled`, an established `UdpConnection` periodically injects encrypted `Ping`s if no user /// DATA was sent in the previous interval, blurring on-wire bursts. #[derive(Debug, Clone, Deserialize)] #[serde(default)] pub struct CoverSection { /// Master switch. `false` (the default) disables cover traffic. pub enabled: bool, /// Mean interval, in milliseconds, between cover-traffic attempts. Default `500`. pub mean_interval_ms: u64, /// Uniform jitter fraction applied to `mean_interval_ms` (e.g. `0.5` gives ±50%). Clamped /// into `[0.0, 1.0)` by the transport layer. Default `0.5`. pub jitter: f32, } impl Default for CoverSection { fn default() -> Self { Self { enabled: false, mean_interval_ms: 500, jitter: 0.5, } } } /// `[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). /// /// v3.2 adds the `palette` field, which selects the SNI palette the rotator picks from: /// /// * `"default"` (back-compat with every pre-v3.2 deployment) — global CDN-like names. /// * `"russian"` — top Russian domains (use when the SNI should look domestic to a Russian ISP). /// * `"mixed"` — alternates between the two palettes day-to-day under HKDF control. /// /// Server and client MUST agree on the palette only if both sides want their SNIs to match — the /// SNI is per-connection on the wire and the server does not advertise an SNI itself, so a /// mismatch is not an error; it just means the client and the server log slightly different "today's /// SNI" hostnames. Recommended: pick the same palette on both ends. #[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, /// v3.2: which SNI palette the daily rotator picks from. Default `"default"` (the pre-v3.2 /// CDN palette). pub palette: MaskPalette, } impl Default for MasksSection { fn default() -> Self { Self { enabled: true, palette: MaskPalette::default(), } } } /// v3.2: selector for [`MasksSection::palette`] — which SNI palette the daily rotator draws from. /// /// Mirror of [`aura_crypto::SniPalette`]. Kept as a separate type so the TOML parser does not need /// to depend on the crypto crate's enum and so the back-compat default lives next to the config. #[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq)] #[serde(rename_all = "lowercase")] pub enum MaskPalette { /// Global CDN palette (pre-v3.2 default). #[default] Default, /// Top Russian domains. Use when the SNI should look like ordinary HTTPS to a large Russian /// site (typical case: an entry-relay on a Russian VPS for the domestic-traffic deployment /// documented in `docs/deployment.md`). Russian, /// Mixed: HKDF picks Default vs Russian per day. Mixed, } impl MaskPalette { /// Lift the TOML enum into the corresponding [`aura_crypto::SniPalette`] variant. #[must_use] pub fn to_crypto(self) -> aura_crypto::SniPalette { match self { Self::Default => aura_crypto::SniPalette::Default, Self::Russian => aura_crypto::SniPalette::Russian, Self::Mixed => aura_crypto::SniPalette::Mixed, } } } 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() } /// Parse `[server.relay] allow_extend_to` into a vector of [`SocketAddr`]s (v3.1 back /// compat). Use [`Self::relay_allow_rules`] if you also want to honour CIDR entries /// introduced in v3.2. /// /// Returns the parsed addresses. Non-`IP:port` entries are skipped with a warn log. pub fn relay_whitelist(&self) -> Vec { let mut out = Vec::new(); for raw in &self.server.relay.allow_extend_to { match raw.parse::() { Ok(a) => out.push(a), Err(_) => { // v3.2 may have non-literal entries (CIDRs); skip silently here — the v3.2 // path uses [`Self::relay_allow_rules`] which understands both. } } } out } /// v3.2: parse `[server.relay] allow_extend_to` into a list of structured allow-rules that /// may be literal `IP:port`, bare CIDR (any port), or CIDR with an explicit port. The /// returned vector is meant to be fed straight to [`RelayAllowRule::matches`]. /// /// Format: /// /// * `"203.0.113.10:443"` — exact `SocketAddr`. /// * `"10.0.0.0/24"` — any port at any IP in the IPv4 subnet. /// * `"10.0.0.0/24:443"` — port 443 at any IP in the IPv4 subnet. /// * `"[2001:db8::/32]:443"` — port 443 at any IP in the IPv6 subnet (square-bracket form). /// * `"2001:db8::/32"` — any port at any IP in the IPv6 subnet (no port). /// /// Unparseable entries are logged at WARN and skipped. An empty result for a non-empty config /// means every entry was rejected; the caller decides whether to refuse all extends or to /// treat that as an open relay. pub fn relay_allow_rules(&self) -> Vec { let mut out = Vec::new(); for raw in &self.server.relay.allow_extend_to { match RelayAllowRule::parse(raw) { Some(r) => out.push(r), None => { tracing::warn!( entry = %raw, "[server.relay] allow_extend_to: skipping unparseable entry \ (expected IP:port, CIDR, or CIDR:port)" ); } } } out } } /// A single entry in `[server.relay] allow_extend_to`, normalised to one of three shapes: /// /// * [`RelayAllowRule::Exact`] — literal `IP:port`, matches only that exact `SocketAddr`. /// * [`RelayAllowRule::Cidr`] — bare CIDR, matches any port at any IP in the subnet. /// * [`RelayAllowRule::CidrPort`] — CIDR with explicit port, matches only that port at any IP in /// the subnet. /// /// `matches(addr)` returns `true` when the given destination satisfies the rule. #[derive(Debug, Clone, PartialEq, Eq)] pub enum RelayAllowRule { /// Exact `SocketAddr` match — the v3.1 literal-IP:port form. Exact(SocketAddr), /// CIDR with no port restriction. Cidr(IpNetwork), /// CIDR with a specific port. CidrPort(IpNetwork, u16), } impl RelayAllowRule { /// Parse one `allow_extend_to` entry. Returns `None` on any format error (the caller is /// expected to log at WARN). pub fn parse(s: &str) -> Option { let s = s.trim(); if s.is_empty() { return None; } // Detect the IPv6-with-explicit-port form first: `[...]:port`. if let Some(stripped) = s.strip_prefix('[') { // Find the closing bracket. Whatever follows must be `:port` (or empty for bare). if let Some(end) = stripped.find(']') { let inside = &stripped[..end]; let after = &stripped[end + 1..]; // `inside` is either a bare IPv6 (no slash) or an IPv6 CIDR. let net = if inside.contains('/') { inside.parse::().ok()? } else { // bare IPv6: treat as a /128 CIDR for uniformity. let ip: std::net::Ipv6Addr = inside.parse().ok()?; IpNetwork::V6(ipnetwork::Ipv6Network::new(ip, 128).ok()?) }; if after.is_empty() { return Some(Self::Cidr(net)); } let port = after.strip_prefix(':')?.parse::().ok()?; return Some(Self::CidrPort(net, port)); } return None; } // Not IPv6-bracketed: try as a literal SocketAddr first (v4 `1.2.3.4:443`, or v6 in plain // form — though the latter wouldn't fit here without brackets, leave it to SocketAddr). if let Ok(a) = s.parse::() { return Some(Self::Exact(a)); } // Try CIDR (with optional port suffix). Split on `:` *after* the slash so we do not eat // an IPv6 inside a bracket — we already handled that branch above. if let Some(slash) = s.find('/') { // Everything before slash is the IP; everything after slash is `prefix[:port]`. let ip_part = &s[..slash]; let after = &s[slash + 1..]; // If `after` contains a colon, port is the trailing piece. if let Some(colon) = after.find(':') { let prefix_str = &after[..colon]; let port_str = &after[colon + 1..]; let prefix: u8 = prefix_str.parse().ok()?; let port: u16 = port_str.parse().ok()?; let ip: IpAddr = ip_part.parse().ok()?; let net = IpNetwork::new(ip, prefix).ok()?; return Some(Self::CidrPort(net, port)); } else { let net: IpNetwork = s.parse().ok()?; return Some(Self::Cidr(net)); } } None } /// Does this rule allow `addr`? pub fn matches(&self, addr: SocketAddr) -> bool { match self { Self::Exact(a) => *a == addr, Self::Cidr(net) => net.contains(addr.ip()), Self::CidrPort(net, p) => *p == addr.port() && net.contains(addr.ip()), } } } 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(), }) } /// Parse `[client.circuit] hops` into a vector of [`SocketAddr`]s. Both the v3.1 flat string /// form and the v3.2 per-hop table form are accepted (the addresses are extracted from /// either). Returns an error if any address fails to parse or the count is wrong for v3.2 /// (must be 2 or 3 when enabled). pub fn circuit_hops(&self) -> anyhow::Result> { let mut out = Vec::with_capacity(self.client.circuit.hops.len()); for hop in &self.client.circuit.hops { let raw = hop.addr(); let addr: SocketAddr = raw.parse().with_context(|| { format!("invalid [client.circuit] hop addr '{raw}' (expected IP:port)") })?; out.push(addr); } if self.client.circuit.enabled && !(2..=3).contains(&out.len()) { return Err(anyhow!( "[client.circuit] requires 2 or 3 hops in v3.2; got {}", out.len() )); } Ok(out) } /// v3.2: build the per-hop dial configs for [`crate::circuit::dial_circuit`]. /// /// For each `CircuitHop` entry: /// /// * [`CircuitHop::Addr`] (flat string): uses the global `[pki]` cert/key and the global /// `[client] sni` as the expected server SAN (v3.1 back compat). /// * [`CircuitHop::Full`] (table): loads the per-hop cert/key PEMs and applies the optional /// `server_name` override (defaulting to `[client] sni`). pub fn build_circuit_hop_configs(&self) -> anyhow::Result> { let mut hops = Vec::with_capacity(self.client.circuit.hops.len()); // Cache the global PKI cert/key once — every flat entry needs them. let global_ca = read_pem(&self.pki.ca_cert)?; let global_cert = read_pem(&self.pki.cert)?; let global_key = read_pem(&self.pki.key)?; for hop in &self.client.circuit.hops { match hop { CircuitHop::Addr(s) => { let addr: SocketAddr = s.parse().with_context(|| { format!("invalid [client.circuit] hop addr '{s}' (expected IP:port)") })?; let proto_cfg = aura_proto::ClientConfig { ca_cert_pem: global_ca.clone(), client_cert_pem: global_cert.clone(), client_key_pem: global_key.clone(), server_name: self.client.sni.clone(), }; hops.push(crate::circuit::HopConfig { addr, proto_cfg }); } CircuitHop::Full { addr, cert_path, key_path, server_name, } => { let parsed_addr: SocketAddr = addr.parse().with_context(|| { format!("invalid [client.circuit] hop addr '{addr}' (expected IP:port)") })?; let cert_pem = read_pem(&cert_path.to_string_lossy())?; let key_pem = read_pem(&key_path.to_string_lossy())?; let proto_cfg = aura_proto::ClientConfig { ca_cert_pem: global_ca.clone(), client_cert_pem: cert_pem, client_key_pem: key_pem, server_name: server_name .clone() .unwrap_or_else(|| self.client.sni.clone()), }; hops.push(crate::circuit::HopConfig { addr: parsed_addr, proto_cfg, }); } } } if self.client.circuit.enabled && !(2..=3).contains(&hops.len()) { return Err(anyhow!( "[client.circuit] requires 2 or 3 hops in v3.2; got {}", hops.len() )); } Ok(hops) } /// 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"); } /// v3.2: `[transport.masks] palette = "russian"` parses into [`MaskPalette::Russian`] and /// maps to [`aura_crypto::SniPalette::Russian`]. The other two values round-trip the same way. #[test] fn parses_mask_palette_russian() { let s = r#" [server] name = "edge" [pki] ca_cert = "a" cert = "b" key = "c" [tunnel] pool_cidr = "10.7.0.0/24" [transport.masks] enabled = true palette = "russian" "#; let cfg = ServerConfigFile::parse(s).expect("parse server.toml with russian palette"); assert!(cfg.transport.masks.enabled); assert_eq!(cfg.transport.masks.palette, MaskPalette::Russian); assert_eq!( cfg.transport.masks.palette.to_crypto(), aura_crypto::SniPalette::Russian ); // Other accepted values: "default" and "mixed". for (raw, expected, crypto) in [ ( "default", MaskPalette::Default, aura_crypto::SniPalette::Default, ), ("mixed", MaskPalette::Mixed, aura_crypto::SniPalette::Mixed), ] { let s = format!( r#" [server] name = "edge" [pki] ca_cert = "a" cert = "b" key = "c" [tunnel] pool_cidr = "10.7.0.0/24" [transport.masks] palette = "{raw}" "# ); let cfg = ServerConfigFile::parse(&s).expect("parse server.toml"); assert_eq!(cfg.transport.masks.palette, expected, "palette = {raw}"); assert_eq!(cfg.transport.masks.palette.to_crypto(), crypto); } } /// Back-compat: omitting `[transport.masks] palette` falls back to [`MaskPalette::Default`] so /// every pre-v3.2 config keeps its behaviour byte-for-byte. #[test] fn mask_palette_defaults_when_omitted() { // Server side: no [transport.masks] block at all. let s_server = 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_server).expect("parse minimal server.toml"); assert_eq!(cfg.transport.masks.palette, MaskPalette::Default); // Server side: section present but no `palette` key. let s_server_partial = r#" [server] name = "edge" [pki] ca_cert = "a" cert = "b" key = "c" [tunnel] pool_cidr = "10.7.0.0/24" [transport.masks] enabled = true "#; let cfg = ServerConfigFile::parse(s_server_partial) .expect("parse server.toml with masks but no palette"); assert_eq!(cfg.transport.masks.palette, MaskPalette::Default); // Client side: same checks. let c_client = 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(c_client).expect("parse minimal client.toml"); assert_eq!(cfg.transport.masks.palette, MaskPalette::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()); } // -------- v3.2: [[client.circuit.hops]] / CIDR whitelist / cell_padding --------------------- /// v3.1 back-compat: the flat `hops = ["a:port", "b:port"]` form still parses, with the v3.2 /// `CircuitSection::hops` now typed as `Vec` via the `untagged` enum. The TOML /// table is `[client.circuit]` because `CircuitSection` lives inside `ClientSection`. #[test] fn circuit_v3_1_flat_hops_back_compat() { let c = r#" [client] name = "x" server_addr = "1.2.3.4:443" sni = "vpn.example.com" [client.circuit] enabled = true hops = ["198.51.100.5:443", "203.0.113.10:443"] [pki] ca_cert = "a" cert = "b" key = "c" [tunnel] local_ip = "10.7.0.2" "#; let cfg = ClientConfigFile::parse(c).expect("parse v3.1 flat hops"); assert!(cfg.client.circuit.enabled); assert_eq!(cfg.client.circuit.hops.len(), 2); match &cfg.client.circuit.hops[0] { CircuitHop::Addr(s) => assert_eq!(s, "198.51.100.5:443"), _ => panic!("expected flat Addr variant"), } let addrs = cfg.circuit_hops().expect("addrs"); assert_eq!(addrs.len(), 2); assert_eq!(addrs[0].to_string(), "198.51.100.5:443"); // cell_padding defaults to false (v3.1 behaviour). assert!(!cfg.client.circuit.cell_padding); assert_eq!(cfg.client.circuit.cell_size, 1280); } /// v3.2 per-hop format: `[[client.circuit.hops]]` tables parse and `build_circuit_hop_configs` /// honours per-hop cert/key paths (the read fails here because the paths point at synthetic /// names; we only check addr-level parsing in this test). #[test] fn circuit_v3_2_per_hop_table_parses() { let c = r#" [client] name = "x" server_addr = "1.2.3.4:443" sni = "vpn.example.com" [client.circuit] enabled = true [[client.circuit.hops]] addr = "198.51.100.5:443" cert_path = "/path/entry.crt" key_path = "/path/entry.key" [[client.circuit.hops]] addr = "203.0.113.10:443" cert_path = "/path/exit.crt" key_path = "/path/exit.key" server_name = "alt-exit.example.com" [pki] ca_cert = "a" cert = "b" key = "c" [tunnel] local_ip = "10.7.0.2" "#; let cfg = ClientConfigFile::parse(c).expect("parse v3.2 per-hop hops"); assert!(cfg.client.circuit.enabled); assert_eq!(cfg.client.circuit.hops.len(), 2); match &cfg.client.circuit.hops[1] { CircuitHop::Full { addr, cert_path, key_path, server_name, } => { assert_eq!(addr, "203.0.113.10:443"); assert_eq!(cert_path.to_string_lossy(), "/path/exit.crt"); assert_eq!(key_path.to_string_lossy(), "/path/exit.key"); assert_eq!(server_name.as_deref(), Some("alt-exit.example.com")); } _ => panic!("expected Full variant for hop[1]"), } let addrs = cfg.circuit_hops().expect("addrs"); assert_eq!(addrs.len(), 2); assert_eq!(addrs[1].to_string(), "203.0.113.10:443"); } /// v3.2 allows 3 hops (entry, middle, exit) — both for the addr-only validator and as part of /// the new per-hop tables. #[test] fn circuit_v3_2_three_hops_parses() { let c = r#" [client] name = "x" server_addr = "1.2.3.4:443" sni = "vpn.example.com" [client.circuit] enabled = true hops = ["198.51.100.5:443", "198.51.100.99:443", "203.0.113.10:443"] [pki] ca_cert = "a" cert = "b" key = "c" [tunnel] local_ip = "10.7.0.2" "#; let cfg = ClientConfigFile::parse(c).expect("parse"); let addrs = cfg.circuit_hops().expect("addrs"); assert_eq!(addrs.len(), 3); assert_eq!(addrs[2].to_string(), "203.0.113.10:443"); } /// `[client.circuit] cell_padding = true` parses and the `cell_size` default kicks in. #[test] fn circuit_cell_padding_flag_parses() { let c = r#" [client] name = "x" server_addr = "1.2.3.4:443" sni = "vpn.example.com" [client.circuit] enabled = true hops = ["198.51.100.5:443", "203.0.113.10:443"] cell_padding = true [pki] ca_cert = "a" cert = "b" key = "c" [tunnel] local_ip = "10.7.0.2" "#; let cfg = ClientConfigFile::parse(c).expect("parse"); assert!(cfg.client.circuit.cell_padding); assert_eq!(cfg.client.circuit.cell_size, 1280); } /// `[server.relay]` allow_extend_to with an exact `IP:port` matches only that exact address. #[test] fn cidr_whitelist_exact_ip() { let rule = RelayAllowRule::parse("203.0.113.10:443").expect("parse exact"); assert!(rule.matches("203.0.113.10:443".parse().unwrap())); assert!(!rule.matches("203.0.113.10:444".parse().unwrap())); assert!(!rule.matches("203.0.113.11:443".parse().unwrap())); } /// CIDR with no port matches any port at any IP in the subnet (v3.2). #[test] fn cidr_whitelist_subnet() { let rule = RelayAllowRule::parse("10.0.0.0/24").expect("parse cidr"); assert!(rule.matches("10.0.0.5:443".parse().unwrap())); assert!(rule.matches("10.0.0.250:8080".parse().unwrap())); assert!( !rule.matches("10.0.1.5:443".parse().unwrap()), "outside /24" ); assert!(!rule.matches("11.0.0.5:443".parse().unwrap())); } /// CIDR with explicit port matches only that port within the subnet. #[test] fn cidr_whitelist_subnet_with_port() { let rule = RelayAllowRule::parse("10.0.0.0/24:443").expect("parse cidr+port"); assert!(rule.matches("10.0.0.5:443".parse().unwrap())); assert!( !rule.matches("10.0.0.5:8080".parse().unwrap()), "wrong port" ); assert!( !rule.matches("11.0.0.5:443".parse().unwrap()), "outside subnet" ); } /// IPv6 CIDR forms: bare `2001:db8::/32` (no port) and `[2001:db8::/32]:443` (with port). #[test] fn cidr_whitelist_v6() { let bare = RelayAllowRule::parse("2001:db8::/32").expect("parse v6 cidr"); assert!(bare.matches("[2001:db8::1]:443".parse().unwrap())); assert!(bare.matches("[2001:db8:abcd::5]:9999".parse().unwrap())); assert!( !bare.matches("[2001:db9::1]:443".parse().unwrap()), "outside /32" ); let with_port = RelayAllowRule::parse("[2001:db8::/32]:443").expect("parse v6 cidr+port"); assert!(with_port.matches("[2001:db8::1]:443".parse().unwrap())); assert!( !with_port.matches("[2001:db8::1]:8080".parse().unwrap()), "wrong port on v6 cidr+port rule" ); } /// `relay_allow_rules` parses a heterogeneous list (literal + CIDR + CIDR:port) and skips bad /// entries with a warn log (still returning the valid ones). #[test] fn relay_allow_rules_heterogeneous_list() { let s = r#" [server] name = "edge" [server.relay] enabled = true allow_extend_to = [ "203.0.113.10:443", "10.0.0.0/24", "10.1.0.0/24:443", "garbage-not-an-ip", ] [pki] ca_cert = "a" cert = "b" key = "c" [tunnel] pool_cidr = "10.7.0.0/24" "#; let cfg = ServerConfigFile::parse(s).expect("parse"); let rules = cfg.relay_allow_rules(); assert_eq!(rules.len(), 3, "3 valid rules; 1 garbage entry skipped"); assert!(rules[0].matches("203.0.113.10:443".parse().unwrap())); assert!(rules[1].matches("10.0.0.5:9999".parse().unwrap())); assert!(rules[2].matches("10.1.0.5:443".parse().unwrap())); assert!(!rules[2].matches("10.1.0.5:444".parse().unwrap())); } /// `[server.relay] cell_padding` parses and the default `cell_size` kicks in (1280). #[test] fn relay_cell_padding_parses() { let s = r#" [server] name = "edge" [server.relay] enabled = true allow_extend_to = ["203.0.113.10:443"] cell_padding = true [pki] ca_cert = "a" cert = "b" key = "c" [tunnel] pool_cidr = "10.7.0.0/24" "#; let cfg = ServerConfigFile::parse(s).expect("parse"); assert!(cfg.server.relay.cell_padding); assert_eq!(cfg.server.relay.cell_size, 1280); } }