Files
AuraVPN/crates/aura-cli/src/config.rs
T
xah30 15c7da12fe fix(server): v3.6 — implicit auto-NAT on Linux (root cause of full-VPN dying)
Symptoms: in default = "VPN" full-VPN mode external internet was dead even
though tunnel-internal ping (10.7.0.1) worked perfectly. The tunnel itself
was assembled and AEAD-encrypted (see TEST_CASES.md), but packets sent
through it died on the server side.

Root cause: server's `[server.nat]` was opt-in. On the production server
(187.77.67.17) deployed before v2, the section is absent in
/etc/aura/server.toml, so `aura server` never ran the iptables MASQUERADE
plan. Packets egressed to the upstream router with src = 10.7.0.10 (RFC1918),
which the provider's reverse-path filter dropped — full-VPN clients saw
"internet is dead". Tunnel-internal pool addresses worked because they
don't need NAT.

Fix:
* `server.rs`: when `[server.nat]` is absent in server.toml AND we are on
  Linux, attempt auto-NAT with an auto-detected egress_iface. If detection
  or the iptables call fails we DON'T bail — we log a loud error and let
  the server come up so safe-mode clients keep working.
* `config.rs`: `ServerNatSection::default()` now defaults `auto = true`.
  A bare `[server.nat]` header (no `auto =`) now means "yes, enable it"
  instead of the silent-noop it used to be.
* New tests for both bare-header and explicit `auto = false` opt-out paths.
* `docs/server_nat_fix.md`: step-by-step instructions for fixing the
  existing 187.77.67.17 server (binary upgrade vs. manual server.toml
  patch vs. fully-manual sysctl + iptables).
* `docs/deployment.md`: replaces "manual mandatory step" wording with
  the new auto-NAT story.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 14:11:24 +03:00

2458 lines
93 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<String>,
/// Allocation strategy: `"static_only"`, `"dynamic_only"`, or `"static_or_dynamic"`.
pub strategy: Option<String>,
/// `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<String, String>,
}
/// 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<String, IpAddr>,
}
/// `[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<ServerOuterCertSection>,
/// `[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<ServerNatSection>,
/// `[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<String>,
/// 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<String>,
/// 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<PathBuf>,
/// 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<PathBuf>,
}
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<Option<(String, String)>> {
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 falls back to the v3.6
/// **implicit auto-NAT** path on Linux (see [`crate::server`]): it tries `auto = true` with an
/// auto-detected `egress_iface`, logging a clear notice. To opt out explicitly write
/// `[server.nat]\nauto = false` (or upgrade to a config with `[server.nat] auto = true`
/// and an explicit `egress_iface`).
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct ServerNatSection {
/// Master switch. **Defaults to `true`** so that an operator who writes `[server.nat]` at all
/// gets working NAT without having to also remember `auto = true`. Set it to `false`
/// explicitly to disable auto-NAT while still keeping the section (e.g. only to pin
/// `egress_iface` for documentation purposes).
#[serde(default = "default_true")]
pub auto: bool,
/// Name of the host interface traffic egresses through (e.g. `"eth0"` on Linux, `"en0"` on
/// macOS). Optional since v3 — when empty the server auto-detects from the host's default
/// route via [`crate::os_routes::detect_default_egress_iface`]; only set this if the host
/// has multiple egresses or auto-detection fails.
#[serde(default)]
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.
#[serde(default)]
pub dry_run: bool,
}
impl Default for ServerNatSection {
fn default() -> Self {
Self {
auto: true,
egress_iface: String::new(),
dry_run: false,
}
}
}
/// `[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<String>,
}
/// `[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<String>,
/// 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<CircuitHop>,
/// 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: 300900 seconds (515 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<String>,
},
}
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<String>,
/// 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<String>,
/// `[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<String>,
/// `[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<OsRoutesSection>,
}
/// `[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<String>,
/// 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<String>,
}
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<SplitRule>,
/// Rules forcing matching destinations through the VPN.
#[serde(default)]
pub vpn: Vec<SplitRule>,
}
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<String>,
/// A domain, e.g. `"example.com"`. Mutually exclusive with `cidr`.
#[serde(default)]
pub domain: Option<String>,
}
/// `[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<String>,
/// 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<String>,
/// 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<String>,
/// 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(),
// v3.4: defaults moved off 443/444 because in practice 443 is heavily contested
// (sing-box, Hysteria2, Cloudflare tunnels, ...). Picking 8443/8444 gives us a free
// port on most boxes; servers that *do* want 443 still set it explicitly in
// server.toml. The provisioned client.toml is always re-generated from the server's
// actually-bound ports (see [crate::bridges::BridgeManifest] v2).
udp_port: 8443,
tcp_port: 8443,
quic_port: 8444,
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<Vec<TransportMode>> {
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<String> {
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<PathBuf> {
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<String> {
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<Self> {
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<Self> {
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<SocketAddr> {
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<IpNetwork> {
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<ResolvedPoolConfig> {
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<String, IpAddr> = HashMap::new();
for (cid, ip_str) in &section.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<aura_proto::ServerConfig> {
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<Endpoints> {
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<SocketAddr> {
let mut out = Vec::new();
for raw in &self.server.relay.allow_extend_to {
match raw.parse::<SocketAddr>() {
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<RelayAllowRule> {
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<Self> {
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::<IpNetwork>().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::<u16>().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::<SocketAddr>() {
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<Self> {
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<Self> {
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<SocketAddr> {
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<std::net::IpAddr> {
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<DialConfig> {
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<aura_proto::ClientConfig> {
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<Vec<SocketAddr>> {
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<Vec<crate::circuit::HopConfig>> {
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<RouteAction> {
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);
// v3.4: omitting [transport] yields defaults of udp/tcp/quic on 8443/8443/8444 (was
// 443/443/444 before; moved to dodge sing-box/Hysteria2 on 443).
assert_eq!(cfg.transport.order, vec!["udp", "tcp", "quic"]);
assert_eq!(cfg.transport.udp_port, 8443);
assert_eq!(cfg.transport.tcp_port, 8443);
assert_eq!(cfg.transport.quic_port, 8444);
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:8443");
assert_eq!(eps.quic.unwrap().to_string(), "0.0.0.0:8444");
}
#[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::<IpAddr>().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 <workspace>/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]
);
// v3.4: when [transport] is omitted the defaults are 8443/8443/8444 (was 443/443/444 in
// v3.3); the `server_addr` port is informational here — actual transport ports come from
// [transport] *_port.
assert_eq!(dial.endpoints.udp.unwrap().to_string(), "1.2.3.4:8443");
assert_eq!(dial.endpoints.tcp.unwrap().to_string(), "1.2.3.4:8443");
assert_eq!(dial.endpoints.quic.unwrap().to_string(), "1.2.3.4:8444");
}
/// `[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::<IpAddr>().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`. v3.6 keeps the *type* the same (`Option<ServerNatSection>`) — the new
/// implicit-auto-NAT behaviour lives in [`crate::server::run`], not in the parser.
#[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 in toml");
}
/// v3.6: `ServerNatSection::default()` is now `auto = true` (was `false` in v1/v2). This
/// makes a bare `[server.nat]` section (no `auto =` field) work out of the box — the
/// operator who wrote the section evidently wants it enabled.
#[test]
fn server_nat_section_default_is_auto_true() {
let d = ServerNatSection::default();
assert!(d.auto, "v3.6 default: auto = true");
assert!(
d.egress_iface.is_empty(),
"v3.6 default: egress_iface empty (server.rs auto-detects)"
);
assert!(!d.dry_run, "v3.6 default: dry_run = false");
}
/// v3.6: an operator who writes a bare `[server.nat]` section without specifying `auto =`
/// gets `auto = true` (the new default). Egress is left empty so the runtime auto-detects.
#[test]
fn server_nat_section_bare_header_enables_auto() {
let s = r#"
[server]
name = "edge"
[server.nat]
[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 bare [server.nat]");
let nat = cfg.server.nat.as_ref().expect("section present");
assert!(nat.auto, "v3.6: bare [server.nat] defaults to auto = true");
assert!(nat.egress_iface.is_empty(), "egress empty -> runtime auto-detect");
assert!(!nat.dry_run);
}
/// v3.6 opt-out: writing `auto = false` explicitly keeps the historical v1/v2 behaviour
/// (server does not touch the host NAT). This is the explicit escape hatch for operators
/// who have already configured iptables / nftables by hand.
#[test]
fn server_nat_section_explicit_opt_out() {
let s = r#"
[server]
name = "edge"
[server.nat]
auto = false
[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 auto = false");
let nat = cfg.server.nat.as_ref().expect("section present");
assert!(!nat.auto, "explicit auto = false is honoured");
}
/// 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<CircuitHop>` 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);
}
}