Files
AuraVPN/crates/aura-cli/src/config.rs
T
xah30 65b26b555d feat(cli): OS-level split-tunnel routes (removes send_direct stub)
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>
2026-05-27 02:20:30 +03:00

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 &section.static_map {
let ip: IpAddr = ip_str
.parse()
.with_context(|| format!("invalid IP '{ip_str}' for static reservation '{cid}'"))?;
static_map.insert(cid.clone(), ip);
}
Ok(ResolvedPoolConfig {
cidr,
strategy,
static_map,
})
}
/// Read the `[pki]` PEM files and build an [`aura_proto::ServerConfig`].
pub fn to_proto(&self) -> anyhow::Result<aura_proto::ServerConfig> {
Ok(aura_proto::ServerConfig {
ca_cert_pem: read_pem(&self.pki.ca_cert)?,
server_cert_pem: read_pem(&self.pki.cert)?,
server_key_pem: read_pem(&self.pki.key)?,
})
}
/// Build the per-transport [`Endpoints`] the [`aura_transport::MultiServer`] should bind.
///
/// The listen **IP** is reused from `[server] listen`; each enabled transport (from
/// `[transport] order`) gets its `udp_port` / `tcp_port` / `quic_port`. Transports not listed in
/// `order` are left `None` (disabled).
pub fn transport_endpoints(&self) -> anyhow::Result<Endpoints> {
let ip = self.listen_addr()?.ip();
let modes = self.transport.modes()?;
let mut endpoints = Endpoints::default();
for mode in modes {
let addr = SocketAddr::new(ip, self.transport.port_for(mode));
match mode {
TransportMode::Udp => endpoints.udp = Some(addr),
TransportMode::Tcp => endpoints.tcp = Some(addr),
TransportMode::Quic => endpoints.quic = Some(addr),
}
}
Ok(endpoints)
}
/// Build the [`UdpOpts`] for the server's UDP transport from `[transport] obfuscate` (timeouts
/// keep their library defaults).
pub fn udp_opts(&self) -> UdpOpts {
UdpOpts {
obfuscate: self.transport.obfuscate,
..UdpOpts::default()
}
}
/// Build the [`TcpOpts`] for the server's TCP transport.
///
/// In v2 the TCP backend always uses a real outer TLS-443 layer, so there are no per-config
/// knobs here (ALPN keeps its `[h2, http/1.1]` default). The legacy `[transport] masquerade` /
/// `[mimicry] sni` values are still parsed for backwards compatibility but are no longer plumbed
/// into [`TcpOpts`].
pub fn tcp_opts(&self) -> TcpOpts {
TcpOpts::default()
}
}
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());
}
}