a173ced9b2
Closes the v3.3 "bridges by hand" honest limitation. Admins now publish a
CA-signed manifest with the current bridge list; clients re-read it from
disk on a timer and merge it with the static [client] bridges. Cuts the
"rotate the bridge list" cycle from "edit every client config" to
"distribute one signed file".
- New aura sign-bridges CLI:
aura sign-bridges --ca /etc/aura/pki \
--bridges "ip1:443,ip2:443" \
--ttl-days 7 \
--out /var/aura/bridges.signed
- Manifest format (single file, text + signature block, same shape as the
in-band CRL):
AURA-BRIDGES-v1
{"version":1,"generated_at":...,"expires_at":...,"bridges":[...]}
--SIGNATURE--
<hex ECDSA-P256/SHA-256 over body>
- aura-pki now exports `sign_ecdsa_p256` / `verify_ecdsa_p256` so CRL and
bridges share ONE signing primitive (no copy-paste). CRL keeps working.
- aura-cli::bridges::BridgeManifest + BridgesDiscoveryWatcher: new
module. encode_signed/load_signed_verified verifies signature + rejects
expired manifests. Watcher spawns a tokio interval that re-reads the
file; on load failure (truncated, expired, bad sig) the previous
snapshot is kept — bridges never collapse to empty.
- New [client.bridges_discovery] {enabled, manifest_path,
refresh_interval_secs}; serde(default) so v3.2 configs keep working.
- Merge strategy: manifest EXTENDS static [client] bridges, dedup by
SocketAddr, static-first ordering. Static remains as fallback.
- 13 new tests (8 lib unit + 4 integration + 1 config). Workspace: 310
tests passed (+13), clippy -D warnings clean, fmt clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2372 lines
90 KiB
Rust
2372 lines
90 KiB
Rust
//! 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 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<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: 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<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(),
|
||
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<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 §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<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);
|
||
|
||
// 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::<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]
|
||
);
|
||
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::<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`. 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<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);
|
||
}
|
||
}
|