65b26b555d
DIRECT-destination traffic now bypasses the TUN entirely via OS routing
table edits, instead of going through user-space and hitting the v1
send_direct stub. The user-space router only sees VPN-bound packets,
making the split-tunnel real.
- aura_cli::os_routes::OsRouteGuard: RAII install + rollback of OS routes.
Linux: `ip route show default` parser -> DIRECT CIDRs via original gw,
VPN default via TUN with metric 50. macOS: `route -n get default`
parser -> `route add -net/-host ... <gw>` for DIRECT, `route add -net
... -interface <tun>` for VPN. Windows: stub + warning (v3).
- dry_run works on every platform (logs `would run: ...`); useful for
tests and operator confidence-checks.
- SplitRoutes::from_config folds [[tunnel.split.direct]]/[[...vpn]] +
resolved domains (via AuraDns) into one declarative plan.
- New [tunnel.os_routes] {enabled (default true), dry_run, gateway,
egress_iface}; absent section = old user-space behavior (back-compat).
- client::run installs routes after AuraTun::create, before privdrop;
guard's Drop reverts everything on shutdown.
- aura-tunnel::router unchanged; AuraRouter::send_direct kept as a
defensive fallback (in v2 it should never fire — OS routes prevent
DIRECT packets from reaching the TUN at all).
20 new tests (linux/macos parser unit tests, install dry-run, config
back-compat). Workspace: 174 tests passed (+19), clippy -D warnings
clean, fmt clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1353 lines
48 KiB
Rust
1353 lines
48 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.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>,
|
|
/// 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>,
|
|
}
|
|
|
|
/// `[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.
|
|
pub client: ClientSection,
|
|
/// `[pki]` section: CA + leaf cert/key file paths.
|
|
pub pki: PkiSection,
|
|
/// `[tunnel]` section: TUN device, addressing, split-tunnel rules.
|
|
pub tunnel: ClientTunnelSection,
|
|
/// `[mimicry]` section: outer-TLS camouflage knobs.
|
|
#[serde(default)]
|
|
pub mimicry: ClientMimicrySection,
|
|
/// `[transport]` section: fallback order and per-transport ports/options.
|
|
#[serde(default)]
|
|
pub transport: TransportSection,
|
|
}
|
|
|
|
/// `[client]` section.
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
pub struct ClientSection {
|
|
/// Human-readable client name / id.
|
|
pub name: String,
|
|
/// Server UDP socket address, e.g. `"203.0.113.10:443"`.
|
|
pub server_addr: String,
|
|
/// Outer-TLS SNI (camouflage hostname) presented to the server.
|
|
pub sni: String,
|
|
/// Optional non-root user to drop privileges to **after** the TUN is open. When omitted
|
|
/// (or already non-root) the client keeps its current credentials. See [`crate::privdrop`].
|
|
#[serde(default)]
|
|
pub run_as: Option<String>,
|
|
}
|
|
|
|
/// `[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,
|
|
}
|
|
|
|
/// `[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,
|
|
}
|
|
|
|
impl Default for TransportSection {
|
|
fn default() -> Self {
|
|
Self {
|
|
order: default_transport_order(),
|
|
udp_port: 443,
|
|
tcp_port: 443,
|
|
quic_port: 444,
|
|
obfuscate: true,
|
|
masquerade: true,
|
|
masks: MasksSection::default(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// `[transport.masks]` section: automatic daily rotation of the obfuscation surface (SNI, HTTP
|
|
/// preamble headers, padding profile) at 05:00 MSK.
|
|
///
|
|
/// Both peers derive the current mask from `(CA fingerprint, MSK date)`; no wire coordination is
|
|
/// needed. When disabled, the static values from `[client] sni` / `[transport] obfuscate` /
|
|
/// `[mimicry] sni` are used as before (pre-rotation behaviour).
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
#[serde(default)]
|
|
pub struct MasksSection {
|
|
/// `true` (default): rotate the obfuscation surface daily at 05:00 MSK = 02:00 UTC. `false`:
|
|
/// use the static TOML values verbatim.
|
|
pub enabled: bool,
|
|
}
|
|
|
|
impl Default for MasksSection {
|
|
fn default() -> Self {
|
|
Self { enabled: true }
|
|
}
|
|
}
|
|
|
|
impl TransportSection {
|
|
/// Parse `order` into [`TransportMode`]s, rejecting unknown names and duplicates.
|
|
pub fn modes(&self) -> anyhow::Result<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()
|
|
}
|
|
}
|
|
|
|
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(),
|
|
})
|
|
}
|
|
|
|
/// 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");
|
|
}
|
|
|
|
/// `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());
|
|
}
|
|
}
|