From 8f0cf1f01765893bd4a8a83f065aad8f67df254d Mon Sep 17 00:00:00 2001 From: xah30 Date: Wed, 27 May 2026 12:14:57 +0300 Subject: [PATCH] feat(cli): automation bundle + identity-minimization features MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reduces manual setup steps and trims user-identifying data exposed by the server/client, in the spirit of the deployment story: an operator on the wire sees less, and the admin types fewer commands. New CLI subcommands: - `aura server-init`: one shot — pki init + issue-server + writes a ready server.toml with auto-detected egress iface; flags --enable-knock, --enable-cover-traffic, --no-nat, --run-as toggle the new transport defenses and privilege drop. - `aura provision-client`: issues a client cert and assembles the full bundle (ca.crt + client.crt + client.key + client.toml in one directory) ready to hand over to the client device. --id is optional (defaults to a fresh UUIDv4, so client identities don't have to encode anything real). Identity / log minimization: - `aura pki issue-client --id` is now optional — UUIDv4 by default. - `[server]/[client] no_logs = true` filters peer_id, client_ip, source_addr, client_id, local_ip, user, id, assigned_ip, peer field values through a custom tracing FormatFields layer (events still fire but the identifying fields are redacted before being written). - `[client] bridges = [...]`: secondary server addresses; build_dial_targets shuffles them after the primary, so blocking one IP doesn't kill the client. - Auto-detect egress iface in [server.nat] (via detect_default_egress_iface); egress_iface in config becomes optional with graceful fallback. Config examples updated; backward-compatible (all new sections optional with serde defaults). Workspace: 207 tests passed (+22), clippy -D warnings clean, fmt clean. No new workspace deps. Co-Authored-By: Claude Opus 4.7 --- config/client.toml.example | 19 + config/server.toml.example | 21 + crates/aura-cli/src/config.rs | 77 +++ crates/aura-cli/src/dial_targets.rs | 189 +++++++ crates/aura-cli/src/init.rs | 464 ++++++++++++++++++ crates/aura-cli/src/lib.rs | 3 + crates/aura-cli/src/main.rs | 250 +++++++++- crates/aura-cli/src/no_logs.rs | 110 +++++ crates/aura-cli/src/os_routes.rs | 19 + crates/aura-cli/src/pki.rs | 18 + crates/aura-cli/src/server.rs | 32 +- crates/aura-cli/tests/cli_bridges.rs | 96 ++++ crates/aura-cli/tests/cli_no_logs.rs | 139 ++++++ crates/aura-cli/tests/cli_provision_client.rs | 201 ++++++++ crates/aura-cli/tests/cli_server_init.rs | 134 +++++ 15 files changed, 1749 insertions(+), 23 deletions(-) create mode 100644 crates/aura-cli/src/dial_targets.rs create mode 100644 crates/aura-cli/src/init.rs create mode 100644 crates/aura-cli/src/no_logs.rs create mode 100644 crates/aura-cli/tests/cli_bridges.rs create mode 100644 crates/aura-cli/tests/cli_no_logs.rs create mode 100644 crates/aura-cli/tests/cli_provision_client.rs create mode 100644 crates/aura-cli/tests/cli_server_init.rs diff --git a/config/client.toml.example b/config/client.toml.example index 3ca9f15..54daac4 100644 --- a/config/client.toml.example +++ b/config/client.toml.example @@ -15,6 +15,14 @@ sni = "cdn.example.com" # no-op (use a service account instead). When omitted (or already running as non-root) no # privilege change happens. # run_as = "nobody" +# Suppress identifier fields (peer_id, client_ip, source_addr, ...) from log output. The events +# still fire; only the identifying fields are dropped before formatting. Default: false. Set to +# true to keep the local log file from accumulating per-session identifiers. +no_logs = false +# Optional fallback server addresses (IP or IP:port). When the primary `server_addr` cannot be +# reached on any transport, the client retries the bridges in a process-randomised order, using +# the same per-transport ports from [transport]. The bridge `:port` part is parsed but ignored. +# bridges = ["203.0.113.11", "203.0.113.12"] [pki] # Trust anchor (the Aura CA) and this client's leaf cert/key, all PEM. @@ -107,3 +115,14 @@ masquerade = true # Existing connections keep the mask they connected with. Default: true. # When `false`, the static values above ([client] sni, [transport] obfuscate, ...) are used as-is. enabled = true + +[transport.knock] +# UDP port-knocking. Must match the server's setting. Default: false. +enabled = false +knock_secret_source = "ca_fingerprint" + +[transport.cover] +# Idle-time cover traffic. Must match the server's setting. Default: false. +enabled = false +mean_interval_ms = 500 +jitter = 0.5 diff --git a/config/server.toml.example b/config/server.toml.example index 97e92fb..50076c9 100644 --- a/config/server.toml.example +++ b/config/server.toml.example @@ -14,6 +14,11 @@ workers = 4 # uses setgid/setuid; Windows is a no-op (use a service account instead). When omitted (or # already running as non-root) no privilege change happens. # run_as = "nobody" +# Suppress identifier fields (peer_id, client_ip, source_addr, ...) from log output. The events +# still fire (so counters and rates are unaffected); only the offending fields are dropped before +# formatting. Default: false. Set to true on production hosts to keep the log file from accumulating +# the per-client identifiers Russian telcos may be compelled to forward on request. +no_logs = false [pki] # Trust anchor (the Aura CA) and this server's leaf cert/key, all PEM. @@ -98,3 +103,19 @@ masquerade = true # needed. Existing connections keep the mask they accepted with. Default: true. # When `false`, the static values above ([mimicry] sni, [transport] obfuscate, ...) are used as-is. enabled = true + +[transport.knock] +# UDP port-knocking. When `enabled = true`, the UDP transport demands a 16-byte HMAC prefix on +# every HS datagram, derived from `knock_secret_source` (`"ca_fingerprint"` = SHA-256 of the CA +# cert DER). To a passive scanner the listening UDP port looks closed. Default: false. +enabled = false +knock_secret_source = "ca_fingerprint" + +[transport.cover] +# Idle-time cover traffic. When `enabled = true`, an established UDP connection periodically +# injects encrypted Ping frames during idle windows so the on-wire byte rate stays roughly +# constant. `mean_interval_ms` controls how often the chaffer wakes up; `jitter` is the +# uniform-random fraction applied (e.g. 0.5 = ±50%). Default: disabled. +enabled = false +mean_interval_ms = 500 +jitter = 0.5 diff --git a/crates/aura-cli/src/config.rs b/crates/aura-cli/src/config.rs index 6c3f842..3ebd50f 100644 --- a/crates/aura-cli/src/config.rs +++ b/crates/aura-cli/src/config.rs @@ -114,6 +114,12 @@ pub struct ServerSection { /// server keeps its current credentials. #[serde(default)] pub run_as: Option, + /// When `true`, the tracing layer suppresses event fields that would identify a peer + /// (`peer_id`, `client_ip`, `source_addr`, `client_id`, `local_ip`, `user`, `id`). The event + /// itself still fires (for operational counters like rx/tx packets), but no identifier is + /// written. Default `false` (verbose). See [`crate::no_logs`]. + #[serde(default)] + pub no_logs: bool, } /// `[server.nat]` section: v2 auto-NAT configuration. See [`crate::nat`] for the apply / rollback @@ -191,6 +197,17 @@ pub struct ClientSection { /// (or already non-root) the client keeps its current credentials. See [`crate::privdrop`]. #[serde(default)] pub run_as: Option, + /// When `true`, the tracing layer suppresses event fields that would identify the user + /// (`peer_id`, `client_ip`, `source_addr`, `client_id`, `local_ip`, `user`, `id`). Default + /// `false` (verbose). See [`crate::no_logs`]. + #[serde(default)] + pub no_logs: bool, + /// Optional fallback server addresses tried in random order if the primary `server_addr` + /// fails on every transport. Each entry is an IP (or `IP:port`); the per-transport ports come + /// from `[transport]` as for the primary endpoint. Empty / omitted means no fallbacks. + /// See [`crate::dial_targets::build_dial_targets`]. + #[serde(default)] + pub bridges: Vec, } /// `[tunnel]` section of `client.toml`. @@ -346,6 +363,13 @@ pub struct TransportSection { pub masquerade: bool, /// `[transport.masks]`: daily protocol-mask rotation knobs. pub masks: MasksSection, + /// `[transport.knock]`: UDP port-knocking (probe-resistance) toggle. When `enabled`, the UDP + /// transport demands a 16-byte HMAC prefix on every HS datagram derived from the shared + /// `knock_secret_source`. Default `enabled = false` for backwards compat. + pub knock: KnockSection, + /// `[transport.cover]`: idle-time cover-traffic injection on the UDP transport. Default + /// `enabled = false`. + pub cover: CoverSection, } impl Default for TransportSection { @@ -358,6 +382,59 @@ impl Default for TransportSection { obfuscate: true, masquerade: true, masks: MasksSection::default(), + knock: KnockSection::default(), + cover: CoverSection::default(), + } + } +} + +/// `[transport.knock]` section: UDP port-knocking (probe-resistance) toggle. When `enabled`, the +/// UDP transport requires a 16-byte HMAC prefix on every HS datagram derived from the shared key. +/// +/// `knock_secret_source` selects how the 32-byte key is computed: +/// +/// * `"ca_fingerprint"` (default): `SHA-256(CA-cert-DER)`. Both peers can compute this +/// independently from the CA they already trust — no wire coordination needed. +#[derive(Debug, Clone, Deserialize)] +#[serde(default)] +pub struct KnockSection { + /// Master switch. `false` (the default) keeps backwards compat — no knock prefix is added or + /// validated. + pub enabled: bool, + /// Selector for the shared knock key. Default `"ca_fingerprint"`. + pub knock_secret_source: String, +} + +impl Default for KnockSection { + fn default() -> Self { + Self { + enabled: false, + knock_secret_source: "ca_fingerprint".to_string(), + } + } +} + +/// `[transport.cover]` section: idle-time cover-traffic injection on the UDP transport. When +/// `enabled`, an established `UdpConnection` periodically injects encrypted `Ping`s if no user +/// DATA was sent in the previous interval, blurring on-wire bursts. +#[derive(Debug, Clone, Deserialize)] +#[serde(default)] +pub struct CoverSection { + /// Master switch. `false` (the default) disables cover traffic. + pub enabled: bool, + /// Mean interval, in milliseconds, between cover-traffic attempts. Default `500`. + pub mean_interval_ms: u64, + /// Uniform jitter fraction applied to `mean_interval_ms` (e.g. `0.5` gives ±50%). Clamped + /// into `[0.0, 1.0)` by the transport layer. Default `0.5`. + pub jitter: f32, +} + +impl Default for CoverSection { + fn default() -> Self { + Self { + enabled: false, + mean_interval_ms: 500, + jitter: 0.5, } } } diff --git a/crates/aura-cli/src/dial_targets.rs b/crates/aura-cli/src/dial_targets.rs new file mode 100644 index 0000000..63269f9 --- /dev/null +++ b/crates/aura-cli/src/dial_targets.rs @@ -0,0 +1,189 @@ +//! Helpers that turn `[client] server_addr + bridges` into the ordered list of [`Endpoints`] a +//! client should try in turn. +//! +//! ## Why +//! +//! A real-world Aura deployment often runs multiple servers (different IPs, same CA). The +//! `[client]` section now accepts a `bridges = [...]` list of additional server addresses; when +//! the primary `server_addr` cannot be reached on any transport, the client retries against each +//! bridge in turn. The bridge order is shuffled per-process so a flapping primary does not always +//! pin clients to the same fallback (the "thundering herd to bridge[0]" failure mode). +//! +//! The transport per-port mapping (`udp_port` / `tcp_port` / `quic_port`) is identical across all +//! bridges — only the destination IP changes — so a bridge is just a copy of the primary +//! [`Endpoints`] with each `SocketAddr` rewritten in place. +//! +//! ## Scope +//! +//! This module only builds the candidate list. The actual sequential dial loop lives in +//! [`crate::client::run`]; it iterates the returned `Vec` and, for each entry, calls +//! [`aura_transport::dial`] with the shared [`DialConfig`] template, returning on the first +//! successful connect. +//! +//! Each bridge string is parsed as either: +//! +//! * `"IP:port"` — the port is *ignored* (transports use the `[transport]` per-mode ports), the +//! IP is taken; +//! * `"IP"` — taken as is. +//! +//! Unparseable bridges are skipped with a `tracing::warn!`. + +use std::net::{IpAddr, SocketAddr}; + +use aura_transport::Endpoints; + +/// Build the ordered list of [`Endpoints`] the client should attempt in turn. +/// +/// * The **first** entry is always the primary `server_addr` from the config (so the deterministic +/// "primary first" expectation holds). +/// * Subsequent entries are the parsed `bridges`, shuffled into a random order using a +/// `SystemTime`-derived seed (no `rand` dep). Each bridge inherits the primary's per-transport +/// ports; only the IP changes. +/// +/// Invalid bridge strings are silently skipped (after a `warn!` log line via the caller — the +/// helper itself stays pure). +#[must_use] +pub fn build_dial_targets(primary: &Endpoints, bridges: &[String]) -> Vec { + let mut out = Vec::with_capacity(1 + bridges.len()); + out.push(primary.clone()); + + // Parse every bridge string into an IpAddr, dropping the ones that fail to parse. + let mut parsed: Vec = bridges.iter().filter_map(|s| parse_bridge_ip(s)).collect(); + + // Shuffle the remaining bridges. We avoid pulling in `rand` for this single shuffle — a tiny + // Fisher–Yates seeded from the wall-clock nanoseconds is sufficient to break the thundering + // herd. Deterministic across a single dial attempt; differs between processes / second-ticks. + shuffle_in_place(&mut parsed); + + for ip in parsed { + out.push(endpoints_with_ip(primary, ip)); + } + out +} + +/// Parse a single bridge string. Accepts `"IP"` or `"IP:port"` (the port is ignored). +fn parse_bridge_ip(s: &str) -> Option { + let trimmed = s.trim(); + if trimmed.is_empty() { + return None; + } + if let Ok(addr) = trimmed.parse::() { + return Some(addr.ip()); + } + trimmed.parse::().ok() +} + +/// Replace the IP of every populated transport socket in `primary` with `ip`, leaving the ports +/// (and the None-ness of disabled transports) intact. +fn endpoints_with_ip(primary: &Endpoints, ip: IpAddr) -> Endpoints { + let rewrite = |addr: Option| addr.map(|sa| SocketAddr::new(ip, sa.port())); + Endpoints { + udp: rewrite(primary.udp), + tcp: rewrite(primary.tcp), + quic: rewrite(primary.quic), + } +} + +/// Tiny in-place Fisher–Yates shuffle using a `SystemTime`-derived seed. +fn shuffle_in_place(v: &mut [T]) { + if v.len() < 2 { + return; + } + // Wall-clock nanoseconds give us a low-quality but sufficient seed for breaking ties between + // bridges — we don't need cryptographic randomness here, just a different order across runs. + let mut state: u64 = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos() as u64) + .unwrap_or(0xa5a5_a5a5_a5a5_a5a5) + .wrapping_mul(0x9E37_79B9_7F4A_7C15) + .wrapping_add(1); + for i in (1..v.len()).rev() { + // xorshift64* + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + let j = (state as usize) % (i + 1); + v.swap(i, j); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashSet; + + fn endpoints(udp: &str, tcp: &str, quic: &str) -> Endpoints { + Endpoints { + udp: Some(udp.parse().unwrap()), + tcp: Some(tcp.parse().unwrap()), + quic: Some(quic.parse().unwrap()), + } + } + + /// No bridges → only the primary is returned, untouched. + #[test] + fn no_bridges_yields_only_primary() { + let p = endpoints("203.0.113.10:443", "203.0.113.10:443", "203.0.113.10:444"); + let targets = build_dial_targets(&p, &[]); + assert_eq!(targets.len(), 1); + assert_eq!(targets[0].udp, p.udp); + assert_eq!(targets[0].tcp, p.tcp); + assert_eq!(targets[0].quic, p.quic); + } + + /// With bridges, the primary is always first and bridges keep the primary's per-transport + /// ports but use the bridge IP. + #[test] + fn bridges_inherit_primary_ports() { + let p = endpoints("203.0.113.10:443", "203.0.113.10:443", "203.0.113.10:444"); + let targets = build_dial_targets( + &p, + &["203.0.113.11".to_string(), "203.0.113.12:9999".to_string()], + ); + assert_eq!(targets.len(), 3, "primary + two bridges"); + assert_eq!(targets[0].udp.unwrap().port(), 443); + // Each bridge entry must keep the primary's per-transport ports (the bridge `:9999` is + // ignored — transports always use [transport] ports). + for t in &targets[1..] { + assert_eq!(t.udp.unwrap().port(), 443); + assert_eq!(t.tcp.unwrap().port(), 443); + assert_eq!(t.quic.unwrap().port(), 444); + } + // The two bridge IPs both show up among the non-primary entries. + let bridge_ips: HashSet = + targets[1..].iter().map(|e| e.udp.unwrap().ip()).collect(); + assert!(bridge_ips.contains(&"203.0.113.11".parse::().unwrap())); + assert!(bridge_ips.contains(&"203.0.113.12".parse::().unwrap())); + } + + /// Bad bridges are skipped (no panic, no None entries returned). + #[test] + fn invalid_bridges_skipped() { + let p = endpoints("203.0.113.10:443", "203.0.113.10:443", "203.0.113.10:444"); + let targets = build_dial_targets( + &p, + &[ + "not-an-ip".to_string(), + "".to_string(), + "203.0.113.20".to_string(), + ], + ); + assert_eq!(targets.len(), 2, "primary + one valid bridge"); + assert_eq!(targets[1].udp.unwrap().ip().to_string(), "203.0.113.20"); + } + + /// A disabled transport (None in primary) stays None across all bridges. + #[test] + fn disabled_transport_propagates() { + let p = Endpoints { + udp: Some("203.0.113.10:443".parse().unwrap()), + tcp: None, + quic: Some("203.0.113.10:444".parse().unwrap()), + }; + let targets = build_dial_targets(&p, &["203.0.113.11".to_string()]); + assert!(targets[0].tcp.is_none()); + assert!(targets[1].tcp.is_none()); + assert!(targets[1].udp.is_some()); + assert!(targets[1].quic.is_some()); + } +} diff --git a/crates/aura-cli/src/init.rs b/crates/aura-cli/src/init.rs new file mode 100644 index 0000000..7ce133b --- /dev/null +++ b/crates/aura-cli/src/init.rs @@ -0,0 +1,464 @@ +//! `aura server-init` and `aura provision-client`: one-shot bootstrap and per-client provisioning. +//! +//! ## Motivation +//! +//! Aura v1 left every step of server bring-up to the operator: generate a CA, issue a server +//! cert, write a server.toml by hand, manually configure NAT, then for every client repeat the +//! cert issuance and hand-author a client.toml. Each manual step is an opportunity to leak a real +//! hostname / username / SAN into a config file — exactly the kind of data Russian operators are +//! now compelled to forward on request. +//! +//! These two helpers collapse the entire workflow into two commands: +//! +//! * [`server_init`] — generate the CA, issue the server cert, optionally auto-detect the egress +//! interface, and write a ready-to-run `server.toml`. Optional anti-surveillance toggles +//! (`enable_knock`, `enable_cover_traffic`) and `no_logs` switch on the corresponding TOML +//! sections. +//! * [`provision_client`] — generate a UUID-v4 id (or accept one), issue the matching client +//! cert, and assemble a bundle directory with `ca.crt`, `client.crt`, `client.key`, and a +//! pre-rendered `client.toml`. The operator hands the directory to the client over any secure +//! channel. +//! +//! Both helpers are pure functions (no clap parsing inside them) so the integration tests can +//! drive them directly without spawning the binary. The clap layer in `main.rs` is a thin wrapper. + +use std::path::{Path, PathBuf}; + +use anyhow::{anyhow, Context}; + +use crate::os_routes::detect_default_egress_iface; +use crate::pki; + +// ---- server_init ----------------------------------------------------------------------------- + +/// Inputs to [`server_init`]. Mirrors the `aura server-init` flag set; see the module docs. +#[derive(Debug, Clone)] +pub struct ServerInitOpts { + /// DNS name placed in the server cert's SAN and used as the client-side `[client] sni`. + pub domain: String, + /// Output directory for the CA + server cert/key. + pub pki_dir: PathBuf, + /// Listen IP for `[server] listen` and `[transport]` bindings. Default `0.0.0.0`. + pub listen_ip: String, + /// UDP transport port. Default 443. + pub udp_port: u16, + /// TCP fallback port. Default 443. + pub tcp_port: u16, + /// QUIC fallback port. Default 444. Must differ from `udp_port`. + pub quic_port: u16, + /// VPN address pool. Default `10.7.0.0/24`. + pub pool_cidr: String, + /// Optional explicit egress interface for `[server.nat] egress_iface`. When `None`, the + /// helper tries [`detect_default_egress_iface`]; when both fail, `[server.nat]` is omitted. + pub egress_iface: Option, + /// Path to write the rendered `server.toml`. + pub out_config: PathBuf, + /// Enable `[transport.knock]` (`enabled = true`, `knock_secret_source = "ca_fingerprint"`). + pub enable_knock: bool, + /// Enable `[transport.cover]` (`enabled = true`, default interval / jitter). + pub enable_cover_traffic: bool, + /// Disable `[server.nat]` even if an egress iface is known. Useful when the operator runs + /// the host behind an existing NAT (router, cloud LB, ...). + pub no_nat: bool, + /// Optional non-root user to drop privileges to (`[server] run_as`). + pub run_as: Option, + /// When `true`, refuse to overwrite an existing CA / server.toml. When `false`, missing + /// files are written and existing files are overwritten (use with care). + pub force: bool, +} + +impl ServerInitOpts { + /// Defaults matching the `aura server-init` flag defaults. + pub fn new(domain: impl Into, pki_dir: impl Into) -> Self { + Self { + domain: domain.into(), + pki_dir: pki_dir.into(), + listen_ip: "0.0.0.0".to_string(), + udp_port: 443, + tcp_port: 443, + quic_port: 444, + pool_cidr: "10.7.0.0/24".to_string(), + egress_iface: None, + out_config: PathBuf::from("/etc/aura/server.toml"), + enable_knock: false, + enable_cover_traffic: false, + no_nat: false, + run_as: None, + force: false, + } + } +} + +/// Summary of what [`server_init`] did, useful for the CLI to print a "next steps" message. +#[derive(Debug, Clone)] +pub struct ServerInitReport { + /// Path of the generated CA cert (always `/ca.crt`). + pub ca_cert: PathBuf, + /// Path of the generated CA key (always `/ca.key`). + pub ca_key: PathBuf, + /// Path of the generated server cert. + pub server_cert: PathBuf, + /// Path of the generated server key. + pub server_key: PathBuf, + /// Path of the rendered server.toml. + pub server_config: PathBuf, + /// Egress interface that ended up in `[server.nat]`, or `None` if the section was omitted. + pub nat_egress_iface: Option, +} + +/// Run the full server-init workflow. Pure: returns a [`ServerInitReport`] without printing. +/// +/// 1. Create `pki_dir`, write `ca.crt` + `ca.key`. +/// 2. Create a `pki_dir/server/` subdir and write `server.crt` + `server.key` for `domain`. +/// 3. Resolve the egress iface (explicit > auto-detected). If `no_nat` is set the result is +/// treated as `None`. +/// 4. Render a `server.toml` reflecting every option and write it to `out_config`. Parent +/// directories are created. +pub fn server_init(opts: &ServerInitOpts) -> anyhow::Result { + let pki_dir = &opts.pki_dir; + let ca_cert = pki_dir.join(pki::CA_CERT); + let ca_key = pki_dir.join(pki::CA_KEY); + + // 1. CA: refuse to clobber an existing CA unless --force. + if (ca_cert.exists() || ca_key.exists()) && !opts.force { + return Err(anyhow!( + "CA already exists at {}/{{ca.crt,ca.key}}; pass --force to overwrite", + pki_dir.display() + )); + } + let (ca_cert_path, ca_key_path) = + pki::init(&format!("Aura CA for {}", opts.domain), pki_dir).context("initialising CA")?; + + // 2. Server cert. + let server_dir = pki_dir.join("server"); + let server_cert_path = server_dir.join("server.crt"); + let server_key_path = server_dir.join("server.key"); + if (server_cert_path.exists() || server_key_path.exists()) && !opts.force { + return Err(anyhow!( + "server cert already exists at {}; pass --force to overwrite", + server_dir.display() + )); + } + let (server_cert, server_key) = + pki::issue_server(&opts.domain, &server_dir, pki_dir).context("issuing server cert")?; + + // 3. Egress iface: explicit > auto-detected > None. + let nat_egress = if opts.no_nat { + None + } else { + opts.egress_iface + .clone() + .or_else(detect_default_egress_iface) + }; + + // 4. Render server.toml. + if opts.out_config.exists() && !opts.force { + return Err(anyhow!( + "{} already exists; pass --force to overwrite", + opts.out_config.display() + )); + } + let toml_text = render_server_toml(opts, &ca_cert_path, &server_cert, &server_key, &nat_egress); + if let Some(parent) = opts.out_config.parent() { + if !parent.as_os_str().is_empty() { + std::fs::create_dir_all(parent) + .with_context(|| format!("creating config dir {}", parent.display()))?; + } + } + std::fs::write(&opts.out_config, toml_text) + .with_context(|| format!("writing {}", opts.out_config.display()))?; + + Ok(ServerInitReport { + ca_cert: ca_cert_path, + ca_key: ca_key_path, + server_cert, + server_key, + server_config: opts.out_config.clone(), + nat_egress_iface: nat_egress, + }) +} + +/// Render the `server.toml` document for `opts`. Public for tests that want to parse-roundtrip. +pub fn render_server_toml( + opts: &ServerInitOpts, + ca_cert: &Path, + server_cert: &Path, + server_key: &Path, + nat_egress: &Option, +) -> String { + let mut s = String::new(); + s.push_str( + "# Generated by `aura server-init`. Edit by hand if you know what you're doing.\n\n", + ); + s.push_str("[server]\n"); + s.push_str("name = \"aura-server\"\n"); + s.push_str(&format!( + "listen = \"{}:{}\"\n", + opts.listen_ip, opts.udp_port + )); + s.push_str("workers = 4\n"); + s.push_str("no_logs = false\n"); + if let Some(user) = &opts.run_as { + s.push_str(&format!("run_as = \"{}\"\n", user)); + } + s.push('\n'); + + s.push_str("[pki]\n"); + s.push_str(&format!("ca_cert = \"{}\"\n", ca_cert.display())); + s.push_str(&format!("cert = \"{}\"\n", server_cert.display())); + s.push_str(&format!("key = \"{}\"\n", server_key.display())); + s.push('\n'); + + s.push_str("[tunnel]\n"); + s.push_str(&format!("pool_cidr = \"{}\"\n", opts.pool_cidr)); + s.push_str("mtu = 1420\n\n"); + + s.push_str("[server.pool]\n"); + s.push_str(&format!("cidr = \"{}\"\n", opts.pool_cidr)); + s.push_str("strategy = \"static_or_dynamic\"\n\n"); + + if let Some(iface) = nat_egress { + s.push_str("[server.nat]\n"); + s.push_str("auto = true\n"); + s.push_str(&format!("egress_iface = \"{}\"\n", iface)); + s.push_str("dry_run = false\n\n"); + } + + s.push_str("[mimicry]\n"); + s.push_str(&format!("sni = \"{}\"\n", opts.domain)); + s.push_str("padding = true\n\n"); + + s.push_str("[transport]\n"); + s.push_str("order = [\"udp\", \"tcp\", \"quic\"]\n"); + s.push_str(&format!("udp_port = {}\n", opts.udp_port)); + s.push_str(&format!("tcp_port = {}\n", opts.tcp_port)); + s.push_str(&format!("quic_port = {}\n", opts.quic_port)); + s.push_str("obfuscate = true\n"); + s.push_str("masquerade = true\n\n"); + + s.push_str("[transport.masks]\n"); + s.push_str("enabled = true\n\n"); + + s.push_str("[transport.knock]\n"); + s.push_str(&format!( + "enabled = {}\n", + if opts.enable_knock { "true" } else { "false" } + )); + s.push_str("knock_secret_source = \"ca_fingerprint\"\n\n"); + + s.push_str("[transport.cover]\n"); + s.push_str(&format!( + "enabled = {}\n", + if opts.enable_cover_traffic { + "true" + } else { + "false" + } + )); + s.push_str("mean_interval_ms = 500\n"); + s.push_str("jitter = 0.5\n"); + + s +} + +// ---- provision_client ------------------------------------------------------------------------ + +/// Inputs to [`provision_client`]. +#[derive(Debug, Clone)] +pub struct ProvisionClientOpts { + /// Optional client id (CN). When `None`, a fresh UUID v4 is generated. + pub id: Option, + /// Path to the CA directory (`ca.crt` + `ca.key`). + pub ca_dir: PathBuf, + /// Server IP placed in the `[client] server_addr`. + pub server_addr: String, + /// Server SAN / SNI, placed in `[client] sni` and used as the inner-handshake server name. + pub server_name: String, + /// Per-transport ports — must match the server's `[transport]` values. + pub udp_port: u16, + pub tcp_port: u16, + pub quic_port: u16, + /// Tunnel-side IP placed in `[tunnel] local_ip`. Must fall inside the server's pool. + pub tun_ip: String, + /// Tunnel prefix length. + pub tun_prefix: u8, + /// Output bundle directory. + pub out_dir: PathBuf, + /// Enable `[transport.knock]` in the bundled client.toml. Must match the server. + pub enable_knock: bool, + /// Enable `[transport.cover]` in the bundled client.toml. Must match the server. + pub enable_cover_traffic: bool, + /// Optional bridge addresses (`bridges = [...]`). + pub bridges: Vec, + /// When `true`, overwrite existing files in `out_dir`. Default `false` errors. + pub force: bool, +} + +impl ProvisionClientOpts { + /// Build with required fields; everything else defaults to the matching `aura provision-client` + /// flag defaults. + pub fn new( + ca_dir: impl Into, + server_addr: impl Into, + server_name: impl Into, + tun_ip: impl Into, + out_dir: impl Into, + ) -> Self { + Self { + id: None, + ca_dir: ca_dir.into(), + server_addr: server_addr.into(), + server_name: server_name.into(), + udp_port: 443, + tcp_port: 443, + quic_port: 444, + tun_ip: tun_ip.into(), + tun_prefix: 24, + out_dir: out_dir.into(), + enable_knock: false, + enable_cover_traffic: false, + bridges: Vec::new(), + force: false, + } + } +} + +/// Summary of what [`provision_client`] produced — the assigned id and the bundle paths. +#[derive(Debug, Clone)] +pub struct ProvisionClientReport { + /// Assigned client id (the certificate's CN). Always populated; matches `opts.id` when set. + pub id: String, + /// Bundle directory (== `opts.out_dir`). + pub bundle_dir: PathBuf, + /// CA cert copied into the bundle. + pub ca_cert: PathBuf, + /// Client cert. + pub client_cert: PathBuf, + /// Client key. + pub client_key: PathBuf, + /// Rendered client.toml. + pub client_config: PathBuf, +} + +/// Run the provision-client workflow. Pure: returns a [`ProvisionClientReport`] without printing. +/// +/// 1. Compute the id (UUID v4 if `opts.id` is None). +/// 2. Issue the client cert into `out_dir/`. +/// 3. Copy the CA cert into `out_dir/ca.crt`. +/// 4. Render a `client.toml` referencing the files in `out_dir` and write it. +pub fn provision_client(opts: &ProvisionClientOpts) -> anyhow::Result { + if opts.out_dir.exists() && !opts.force { + // Allow the directory to exist if it is empty; refuse only if it has files. + let has_content = std::fs::read_dir(&opts.out_dir) + .map(|mut it| it.next().is_some()) + .unwrap_or(false); + if has_content { + return Err(anyhow!( + "bundle directory {} is not empty; pass --force to overwrite", + opts.out_dir.display() + )); + } + } + std::fs::create_dir_all(&opts.out_dir) + .with_context(|| format!("creating bundle dir {}", opts.out_dir.display()))?; + + // 1 + 2: issue cert (assigns id if missing). + let (id, client_cert, client_key) = + pki::issue_client_with_id(opts.id.as_deref(), &opts.out_dir, &opts.ca_dir) + .context("issuing client cert")?; + + // 3: copy CA cert into the bundle so the client has everything in one place. + let bundled_ca = opts.out_dir.join("ca.crt"); + let ca_src = opts.ca_dir.join(pki::CA_CERT); + std::fs::copy(&ca_src, &bundled_ca) + .with_context(|| format!("copying {} -> {}", ca_src.display(), bundled_ca.display()))?; + + // 4: render client.toml. Use file names (not absolute paths) so the bundle is portable — + // the client can drop the whole directory anywhere and `cd` in to run `aura client`. + let toml_text = render_client_toml(opts, &id); + let client_config = opts.out_dir.join("client.toml"); + std::fs::write(&client_config, toml_text) + .with_context(|| format!("writing {}", client_config.display()))?; + + Ok(ProvisionClientReport { + id, + bundle_dir: opts.out_dir.clone(), + ca_cert: bundled_ca, + client_cert, + client_key, + client_config, + }) +} + +/// Render the `client.toml` document for `opts` + the assigned `id`. Public for tests that want +/// to parse-roundtrip the output without going through the full filesystem dance. +pub fn render_client_toml(opts: &ProvisionClientOpts, id: &str) -> String { + let mut s = String::new(); + s.push_str( + "# Generated by `aura provision-client`. Edit by hand if you know what you're doing.\n\n", + ); + s.push_str("[client]\n"); + s.push_str(&format!("name = \"{}\"\n", id)); + s.push_str(&format!( + "server_addr = \"{}:{}\"\n", + opts.server_addr, opts.udp_port + )); + s.push_str(&format!("sni = \"{}\"\n", opts.server_name)); + s.push_str("no_logs = false\n"); + if !opts.bridges.is_empty() { + s.push_str("bridges = ["); + let formatted: Vec = opts.bridges.iter().map(|b| format!("\"{}\"", b)).collect(); + s.push_str(&formatted.join(", ")); + s.push_str("]\n"); + } + s.push('\n'); + + s.push_str("[pki]\n"); + s.push_str("ca_cert = \"ca.crt\"\n"); + s.push_str("cert = \"client.crt\"\n"); + s.push_str("key = \"client.key\"\n\n"); + + s.push_str("[tunnel]\n"); + s.push_str("tun_name = \"aura0\"\n"); + s.push_str(&format!("local_ip = \"{}\"\n", opts.tun_ip)); + s.push_str(&format!("prefix = {}\n", opts.tun_prefix)); + s.push_str("mtu = 1420\n\n"); + + s.push_str("[tunnel.split]\n"); + s.push_str("default = \"VPN\"\n\n"); + + s.push_str("[mimicry]\n"); + s.push_str("padding = true\n\n"); + + s.push_str("[transport]\n"); + s.push_str("order = [\"udp\", \"tcp\", \"quic\"]\n"); + s.push_str(&format!("udp_port = {}\n", opts.udp_port)); + s.push_str(&format!("tcp_port = {}\n", opts.tcp_port)); + s.push_str(&format!("quic_port = {}\n", opts.quic_port)); + s.push_str("obfuscate = true\n"); + s.push_str("masquerade = true\n\n"); + + s.push_str("[transport.masks]\n"); + s.push_str("enabled = true\n\n"); + + s.push_str("[transport.knock]\n"); + s.push_str(&format!( + "enabled = {}\n", + if opts.enable_knock { "true" } else { "false" } + )); + s.push_str("knock_secret_source = \"ca_fingerprint\"\n\n"); + + s.push_str("[transport.cover]\n"); + s.push_str(&format!( + "enabled = {}\n", + if opts.enable_cover_traffic { + "true" + } else { + "false" + } + )); + s.push_str("mean_interval_ms = 500\n"); + s.push_str("jitter = 0.5\n"); + + s +} diff --git a/crates/aura-cli/src/lib.rs b/crates/aura-cli/src/lib.rs index b57d58d..b843158 100644 --- a/crates/aura-cli/src/lib.rs +++ b/crates/aura-cli/src/lib.rs @@ -16,8 +16,11 @@ pub mod admin; pub mod bench; pub mod client; pub mod config; +pub mod dial_targets; +pub mod init; pub mod masks; pub mod nat; +pub mod no_logs; pub mod os_routes; pub mod pki; pub mod pool; diff --git a/crates/aura-cli/src/main.rs b/crates/aura-cli/src/main.rs index 4087244..df51add 100644 --- a/crates/aura-cli/src/main.rs +++ b/crates/aura-cli/src/main.rs @@ -17,9 +17,8 @@ use std::path::PathBuf; -use aura_cli::{admin, bench, client, pki, server}; +use aura_cli::{admin, bench, client, init, no_logs, pki, server}; use clap::{Args, Parser, Subcommand}; -use tracing_subscriber::EnvFilter; use crate::admin::{Request, DEFAULT_SOCKET}; @@ -53,6 +52,15 @@ enum Command { /// Quick crypto micro-benchmarks (KEM keygen/encaps/decaps, full handshake, AEAD). BenchCrypto, + + /// Bootstrap a new Aura server end-to-end: generate a CA + server cert, optionally auto-detect + /// the egress interface, and write a ready-to-run `server.toml`. See [`init::ServerInitOpts`]. + ServerInit(ServerInitArgs), + + /// Provision a new client: issue a client cert (UUID-v4 if `--id` is omitted), copy the CA, + /// and assemble a `client.toml` in a portable bundle directory. See + /// [`init::ProvisionClientOpts`]. + ProvisionClient(ProvisionClientArgs), } /// `aura pki ...` subcommands. @@ -81,9 +89,11 @@ enum PkiCommand { }, /// Issue a client certificate (client.crt / client.key) with CN = . IssueClient { - /// Client id placed in the certificate Common Name. + /// Client id placed in the certificate Common Name. When omitted, a fresh UUID v4 is + /// generated and used (and the assigned id is printed). This is the recommended path — + /// minting an opaque id keeps the cert from carrying a real username / hostname. #[arg(long)] - id: String, + id: Option, /// Output directory for client.crt / client.key. #[arg(long)] out: PathBuf, @@ -138,6 +148,100 @@ struct AdminConnArgs { admin_socket: String, } +/// Arguments for `aura server-init`. +#[derive(Debug, Args)] +struct ServerInitArgs { + /// DNS name placed in the server cert SAN; also the `[client] sni` value. + #[arg(long)] + domain: String, + /// Output directory for CA + server cert/key. + #[arg(long)] + pki_dir: PathBuf, + /// Listen IP for the server (default 0.0.0.0). + #[arg(long, default_value = "0.0.0.0")] + listen_ip: String, + /// UDP transport port (default 443). + #[arg(long, default_value_t = 443)] + udp_port: u16, + /// TCP fallback port (default 443). + #[arg(long, default_value_t = 443)] + tcp_port: u16, + /// QUIC fallback port (default 444). Must differ from --udp-port. + #[arg(long, default_value_t = 444)] + quic_port: u16, + /// VPN address pool (default 10.7.0.0/24). + #[arg(long, default_value = "10.7.0.0/24")] + pool_cidr: String, + /// Egress interface for [server.nat]. When omitted, auto-detected from the host default route. + #[arg(long)] + egress_iface: Option, + /// Path to write the rendered server.toml. + #[arg(long)] + out_config: PathBuf, + /// Enable [transport.knock] in the rendered server.toml. + #[arg(long)] + enable_knock: bool, + /// Enable [transport.cover] in the rendered server.toml. + #[arg(long)] + enable_cover_traffic: bool, + /// Skip the [server.nat] section even if an egress interface is known. + #[arg(long)] + no_nat: bool, + /// Optional non-root user for [server] run_as. + #[arg(long)] + run_as: Option, + /// Overwrite existing CA / server cert / config files. + #[arg(long)] + force: bool, +} + +/// Arguments for `aura provision-client`. +#[derive(Debug, Args)] +struct ProvisionClientArgs { + /// Optional client id (CN). Default: a fresh UUID v4. + #[arg(long)] + id: Option, + /// Directory holding the CA (ca.crt + ca.key). + #[arg(long)] + ca: PathBuf, + /// Server IP (placed in [client] server_addr). + #[arg(long)] + server_addr: String, + /// Server SAN / SNI (placed in [client] sni). + #[arg(long)] + server_name: String, + /// UDP transport port (default 443). + #[arg(long, default_value_t = 443)] + udp_port: u16, + /// TCP fallback port (default 443). + #[arg(long, default_value_t = 443)] + tcp_port: u16, + /// QUIC fallback port (default 444). + #[arg(long, default_value_t = 444)] + quic_port: u16, + /// TUN local IP (placed in [tunnel] local_ip). Must fall inside the server's pool. + #[arg(long)] + tun_ip: String, + /// TUN prefix length (default 24). + #[arg(long, default_value_t = 24)] + tun_prefix: u8, + /// Output bundle directory. + #[arg(long)] + out: PathBuf, + /// Enable [transport.knock] in the bundled client.toml. Must match the server. + #[arg(long)] + enable_knock: bool, + /// Enable [transport.cover] in the bundled client.toml. Must match the server. + #[arg(long)] + enable_cover_traffic: bool, + /// Comma-separated list of fallback server addresses (IP or IP:port). + #[arg(long)] + bridges: Option, + /// Overwrite an existing bundle directory. + #[arg(long)] + force: bool, +} + /// `aura route ...` subcommands. #[derive(Debug, Subcommand)] enum RouteCommand { @@ -171,8 +275,18 @@ enum RouteCommand { #[tokio::main] async fn main() -> anyhow::Result<()> { - init_tracing(); let cli = Cli::parse(); + // Honour [server]/[client] no_logs when we already know which config we are about to load — + // this lets the very first tracing event of `aura server` / `aura client` go through the + // identifier-suppressing formatter (otherwise startup info lines would leak peer ids before + // the filter is installed). Other subcommands use the unfiltered default. + let no_logs = match &cli.command { + Command::Server(args) => probe_no_logs_server(&args.config), + Command::Client(args) => probe_no_logs_client(&args.config), + _ => false, + }; + no_logs::init_filtered_tracing(no_logs); + match cli.command { Command::Pki(cmd) => run_pki(cmd), Command::Server(args) => server::run(&args.config, &args.admin_socket).await, @@ -180,14 +294,26 @@ async fn main() -> anyhow::Result<()> { Command::Route(cmd) => run_route(cmd).await, Command::Status(args) => run_status(&args.admin_socket).await, Command::BenchCrypto => bench::run(), + Command::ServerInit(args) => run_server_init(args), + Command::ProvisionClient(args) => run_provision_client(args), } } -/// Install the tracing subscriber with an env filter (defaults to `info`). -fn init_tracing() { - let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")); - // `try_init` so re-initialization (e.g. in embedded use) is a no-op rather than a panic. - let _ = tracing_subscriber::fmt().with_env_filter(filter).try_init(); +/// Best-effort read of `[server] no_logs` for the early tracing-init step. We deliberately swallow +/// errors here: if the config does not parse the actual `server::run` call will report the issue +/// with a proper message — we just don't want to install a redacting layer on top of a config we +/// failed to read. +fn probe_no_logs_server(path: &std::path::Path) -> bool { + aura_cli::config::ServerConfigFile::load(path) + .map(|c| c.server.no_logs) + .unwrap_or(false) +} + +/// Same as [`probe_no_logs_server`] but for the client config. +fn probe_no_logs_client(path: &std::path::Path) -> bool { + aura_cli::config::ClientConfigFile::load(path) + .map(|c| c.client.no_logs) + .unwrap_or(false) } /// Default CRL path when `--crl` is omitted. @@ -217,9 +343,9 @@ fn run_pki(cmd: PkiCommand) -> anyhow::Result<()> { } PkiCommand::IssueClient { id, out, ca } => { let ca_dir = ca.unwrap_or_else(|| out.clone()); - let (cert, key) = pki::issue_client(&id, &out, &ca_dir)?; + let (cn, cert, key) = pki::issue_client_with_id(id.as_deref(), &out, &ca_dir)?; println!( - "client certificate issued for '{id}':\n cert: {}\n key: {}", + "client certificate issued for '{cn}':\n cert: {}\n key: {}", cert.display(), key.display() ); @@ -324,3 +450,103 @@ fn print_route_list(resp: admin::Response) { println!(" domain {:<20} {}", d.domain, d.action); } } + +/// Dispatch `aura server-init`. +fn run_server_init(args: ServerInitArgs) -> anyhow::Result<()> { + let opts = init::ServerInitOpts { + domain: args.domain, + pki_dir: args.pki_dir, + listen_ip: args.listen_ip, + udp_port: args.udp_port, + tcp_port: args.tcp_port, + quic_port: args.quic_port, + pool_cidr: args.pool_cidr, + egress_iface: args.egress_iface, + out_config: args.out_config, + enable_knock: args.enable_knock, + enable_cover_traffic: args.enable_cover_traffic, + no_nat: args.no_nat, + run_as: args.run_as, + force: args.force, + }; + let report = init::server_init(&opts)?; + + println!("Aura server bootstrap complete."); + println!(" CA cert: {}", report.ca_cert.display()); + println!(" CA key: {}", report.ca_key.display()); + println!(" server cert: {}", report.server_cert.display()); + println!(" server key: {}", report.server_key.display()); + println!(" config: {}", report.server_config.display()); + match &report.nat_egress_iface { + Some(iface) => { + println!(" [server.nat] egress_iface = \"{iface}\" (auto-detected if not explicit)") + } + None => println!(" [server.nat] omitted — configure NAT manually or pass --egress-iface."), + } + println!(); + println!("Next steps:"); + println!( + " 1. Start the server: sudo aura server --config {}", + report.server_config.display() + ); + println!( + " 2. Provision the first client: aura provision-client --ca {} \\\n --server-addr --server-name {} --tun-ip --out ./client-bundle", + report.ca_cert.parent().unwrap_or_else(|| std::path::Path::new(".")).display(), + opts_domain_for_hint(&report.server_config), + ); + Ok(()) +} + +/// Cheap reconstruction of the domain for the printed hint (the report does not carry it; we +/// re-read it from the freshly written server.toml). On any parse failure, the placeholder is +/// returned so the message still prints. +fn opts_domain_for_hint(server_toml: &std::path::Path) -> String { + aura_cli::config::ServerConfigFile::load(server_toml) + .ok() + .and_then(|c| c.mimicry.sni) + .unwrap_or_else(|| "".to_string()) +} + +/// Dispatch `aura provision-client`. +fn run_provision_client(args: ProvisionClientArgs) -> anyhow::Result<()> { + let bridges = args + .bridges + .map(|s| { + s.split(',') + .map(|t| t.trim().to_string()) + .filter(|t| !t.is_empty()) + .collect::>() + }) + .unwrap_or_default(); + let opts = init::ProvisionClientOpts { + id: args.id, + ca_dir: args.ca, + server_addr: args.server_addr, + server_name: args.server_name, + udp_port: args.udp_port, + tcp_port: args.tcp_port, + quic_port: args.quic_port, + tun_ip: args.tun_ip, + tun_prefix: args.tun_prefix, + out_dir: args.out, + enable_knock: args.enable_knock, + enable_cover_traffic: args.enable_cover_traffic, + bridges, + force: args.force, + }; + let report = init::provision_client(&opts)?; + + println!("Aura client provisioned: id = {}", report.id); + println!(" bundle: {}", report.bundle_dir.display()); + println!(" ca.crt: {}", report.ca_cert.display()); + println!(" client.crt: {}", report.client_cert.display()); + println!(" client.key: {}", report.client_key.display()); + println!(" client.toml: {}", report.client_config.display()); + println!(); + println!("Hand the entire bundle directory to the client via any secure channel."); + println!( + "On the client host run: cd {} && sudo aura client --config client.toml", + report.bundle_dir.display() + ); + Ok(()) +} diff --git a/crates/aura-cli/src/no_logs.rs b/crates/aura-cli/src/no_logs.rs new file mode 100644 index 0000000..a76ec8b --- /dev/null +++ b/crates/aura-cli/src/no_logs.rs @@ -0,0 +1,110 @@ +//! Identifier-suppressing tracing layer driven by `[server] no_logs` / `[client] no_logs`. +//! +//! ## Motivation +//! +//! Russian telecom regulations now require operators to forward identifying customer data +//! (passport / INN / IP / domain / logins / geolocation) on request. To keep an Aura node from +//! becoming a treasure-trove of those exact fields in its own logs, `no_logs = true` swaps the +//! default tracing formatter for one that skips writing a configured list of "identifier" fields +//! to the log line. The event still fires (counters and rates are unaffected), but the offending +//! field is rendered as nothing in the formatted output. +//! +//! ## Mechanism +//! +//! [`init_filtered_tracing`] installs a `tracing-subscriber::fmt` subscriber whose +//! [`FormatFields`](tracing_subscriber::fmt::FormatFields) is a custom +//! [`debug_fn`](tracing_subscriber::fmt::format::debug_fn) closure. The closure inspects +//! [`Field::name`](tracing::field::Field::name) against the [`REDACTED_FIELD_NAMES`] blacklist; on +//! a match it writes nothing (not even the field name), so the resulting log line literally +//! contains no token from the redacted value. Non-blacklisted fields go through the standard +//! `"key=value "` formatting. +//! +//! The redaction set targets the specific identifiers Aura's own code emits in its accept / dial +//! paths: `peer_id` (verified client CN), `client_ip` / `local_ip` / `assigned_ip` (per-tunnel +//! addresses), `source_addr` (UDP peer), `client_id` / `id` / `user` (generic id slots). +//! +//! ## Compatibility +//! +//! When `no_logs = false` (the default), [`init_filtered_tracing`] degenerates to the same +//! `tracing_subscriber::fmt().with_env_filter(...).try_init()` call that lived in `main` before, +//! so existing log output is unchanged for operators who did not opt in. + +use std::collections::HashSet; + +use tracing_subscriber::fmt::format::{debug_fn, Writer}; +use tracing_subscriber::fmt::FormatFields; +use tracing_subscriber::EnvFilter; + +/// Field names treated as personally-identifying and dropped from formatted output when +/// `no_logs = true`. Matches the field keys Aura emits in `tracing::info!` / `warn!` macros +/// across the server-accept and client-dial paths. +pub const REDACTED_FIELD_NAMES: &[&str] = &[ + "peer_id", + "client_ip", + "source_addr", + "client_id", + "local_ip", + "user", + "id", + "assigned_ip", + "peer", +]; + +/// Install the global tracing subscriber, honouring `no_logs`. +/// +/// * `no_logs = false`: standard `fmt` subscriber + `EnvFilter` (default `info`). +/// * `no_logs = true`: same filter and formatter shell, but the per-field writer skips +/// [`REDACTED_FIELD_NAMES`] so identifying fields never reach the output stream. +/// +/// Calls `try_init` so re-initialisation (e.g. in embedded use or repeated test setup) is a +/// no-op rather than a panic. +pub fn init_filtered_tracing(no_logs: bool) { + let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")); + if no_logs { + let _ = tracing_subscriber::fmt() + .with_env_filter(filter) + .fmt_fields(redacting_field_formatter()) + .try_init(); + } else { + let _ = tracing_subscriber::fmt().with_env_filter(filter).try_init(); + } +} + +/// Build a [`FormatFields`] that writes every field through the default rendering EXCEPT those +/// whose name matches [`REDACTED_FIELD_NAMES`] — those are silently dropped. Exposed so the +/// integration tests can swap the writer for an in-memory buffer and assert the redaction. +pub fn redacting_field_formatter() -> impl for<'w> FormatFields<'w> + 'static { + let redacted: HashSet<&'static str> = REDACTED_FIELD_NAMES.iter().copied().collect(); + debug_fn(move |w: &mut Writer<'_>, field, value| { + if redacted.contains(field.name()) { + // Drop the field entirely. The default formatter emits `key=value` separated by spaces, + // so a no-op preserves that overall structure for the remaining fields. + return Ok(()); + } + write!(w, "{}={:?} ", field.name(), value) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The blacklist captures every identifier the spec calls out. + #[test] + fn redacted_set_covers_spec_identifiers() { + for name in [ + "peer_id", + "client_ip", + "source_addr", + "client_id", + "local_ip", + "user", + "id", + ] { + assert!( + REDACTED_FIELD_NAMES.contains(&name), + "missing redaction for {name}" + ); + } + } +} diff --git a/crates/aura-cli/src/os_routes.rs b/crates/aura-cli/src/os_routes.rs index 9ac04bf..1a78a7b 100644 --- a/crates/aura-cli/src/os_routes.rs +++ b/crates/aura-cli/src/os_routes.rs @@ -403,6 +403,25 @@ fn resolve_gateway( Ok((gw, egress)) } +/// Best-effort auto-detection of the host's egress interface name (e.g. `"eth0"` on Linux, `"en0"` +/// on macOS). Returns `None` when detection is not supported on this platform or when the host's +/// default route could not be parsed. Used by `aura server-init` to pre-fill `[server.nat] +/// egress_iface` and by [`crate::server::run`] as a fallback when the operator omitted the field. +/// +/// This is a thin wrapper over the per-platform `detect_default_gateway()` so it works on every +/// host (including Windows, where it always returns `None`). +#[must_use] +pub fn detect_default_egress_iface() -> Option { + #[cfg(any(target_os = "linux", target_os = "macos"))] + { + detect_default_gateway().ok().map(|(_gw, iface)| iface) + } + #[cfg(not(any(target_os = "linux", target_os = "macos")))] + { + None + } +} + /// Auto-detect the host's IPv4 default gateway + egress interface. #[cfg(target_os = "linux")] fn detect_default_gateway() -> Result<(IpAddr, String)> { diff --git a/crates/aura-cli/src/pki.rs b/crates/aura-cli/src/pki.rs index 7124b17..f36b76d 100644 --- a/crates/aura-cli/src/pki.rs +++ b/crates/aura-cli/src/pki.rs @@ -53,6 +53,24 @@ pub fn issue_client(id: &str, out_dir: &Path, ca_dir: &Path) -> anyhow::Result<( write_leaf(out_dir, "client", &issued.cert_pem, &issued.key_pem) } +/// `aura pki issue-client` with an *optional* id: when `id` is `None` a fresh UUID v4 is +/// generated, used as the certificate CN, and returned alongside the file paths. This is what the +/// CLI now exposes as the default — passing no `--id` no longer fails, and the operator just sees +/// the assigned id in the log line. +/// +/// Returns `(cn, cert_path, key_path)` so the caller can echo the id without re-parsing the cert. +pub fn issue_client_with_id( + id: Option<&str>, + out_dir: &Path, + ca_dir: &Path, +) -> anyhow::Result<(String, PathBuf, PathBuf)> { + let cn = id + .map(str::to_string) + .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); + let (cert, key) = issue_client(&cn, out_dir, ca_dir)?; + Ok((cn, cert, key)) +} + /// `aura pki revoke`: add `id` (a client id or serial) to the CRL file, creating it if absent. pub fn revoke(id: &str, crl_path: &Path) -> anyhow::Result<()> { let mut crl = if crl_path.exists() { diff --git a/crates/aura-cli/src/server.rs b/crates/aura-cli/src/server.rs index d322a30..fbec603 100644 --- a/crates/aura-cli/src/server.rs +++ b/crates/aura-cli/src/server.rs @@ -123,18 +123,28 @@ pub async fn run(config_path: &Path, admin_socket: &str) -> anyhow::Result<()> { // configured forwarding by hand and no guard is created. let _nat_guard: Option = if let Some(nat) = cfg.server.nat.as_ref() { if nat.auto { - if nat.egress_iface.trim().is_empty() { - anyhow::bail!( - "[server.nat] auto = true requires `egress_iface` to be set (no auto-detection in v1)" - ); - } + // v2: if `egress_iface` is not set in the config, fall back to auto-detection of the + // host's default-route interface. This makes `[server.nat] auto = true` work on + // typical single-NIC hosts without manual configuration. If detection also fails we + // fall back to the original hard error so the operator gets a clear message. + let iface = if nat.egress_iface.trim().is_empty() { + match crate::os_routes::detect_default_egress_iface() { + Some(iface) => { + tracing::info!(target: "aura::nat", iface = %iface, + "egress_iface not set in [server.nat]; auto-detected from host default route"); + iface + } + None => anyhow::bail!( + "[server.nat] auto = true requires `egress_iface` to be set \ + (auto-detection failed on this host)" + ), + } + } else { + nat.egress_iface.clone() + }; Some( - NatGuard::enable( - &resolved_pool.cidr.to_string(), - &nat.egress_iface, - nat.dry_run, - ) - .context("enabling auto-NAT (see [server.nat] in server.toml)")?, + NatGuard::enable(&resolved_pool.cidr.to_string(), &iface, nat.dry_run) + .context("enabling auto-NAT (see [server.nat] in server.toml)")?, ) } else { None diff --git a/crates/aura-cli/tests/cli_bridges.rs b/crates/aura-cli/tests/cli_bridges.rs new file mode 100644 index 0000000..f96573f --- /dev/null +++ b/crates/aura-cli/tests/cli_bridges.rs @@ -0,0 +1,96 @@ +//! Integration tests for the `[client] bridges` field + [`aura_cli::dial_targets::build_dial_targets`]. +//! +//! Parses a synthetic `client.toml` with bridges, walks through `build_dial_targets`, and asserts +//! the resulting candidate list shape. Real dial attempts are out of scope (no server running); +//! this test focuses on the parse-build-shape contract that `client::run` relies on. + +use aura_cli::config::ClientConfigFile; +use aura_cli::dial_targets::build_dial_targets; + +const CLIENT_TOML: &str = r#" +[client] +name = "laptop" +server_addr = "203.0.113.10:443" +sni = "vpn.example.com" +bridges = ["203.0.113.11", "203.0.113.12:9999"] + +[pki] +ca_cert = "ca.crt" +cert = "client.crt" +key = "client.key" + +[tunnel] +local_ip = "10.7.0.2" +"#; + +#[test] +fn bridges_parse_into_client_config() { + let cfg = ClientConfigFile::parse(CLIENT_TOML).expect("parse"); + assert_eq!(cfg.client.bridges.len(), 2); + assert!(cfg.client.bridges.contains(&"203.0.113.11".to_string())); + assert!(cfg + .client + .bridges + .contains(&"203.0.113.12:9999".to_string())); +} + +#[test] +fn build_dial_targets_from_parsed_client_config() { + let cfg = ClientConfigFile::parse(CLIENT_TOML).expect("parse"); + let dial = cfg.dial_config().expect("dial config"); + let targets = build_dial_targets(&dial.endpoints, &cfg.client.bridges); + assert_eq!(targets.len(), 3, "primary + two bridges"); + + // The primary is always first. + assert_eq!(targets[0].udp.unwrap().to_string(), "203.0.113.10:443"); + + // Each bridge entry must keep the per-transport ports (the bridge `:9999` in the second + // string is ignored — transports always use [transport] ports). + for t in &targets[1..] { + assert_eq!(t.udp.unwrap().port(), 443); + assert_eq!(t.quic.unwrap().port(), 444); + } + + // Both bridge IPs are represented. + let bridge_ips: std::collections::HashSet = targets[1..] + .iter() + .map(|e| e.udp.unwrap().ip().to_string()) + .collect(); + assert!(bridge_ips.contains("203.0.113.11")); + assert!(bridge_ips.contains("203.0.113.12")); +} + +#[test] +fn empty_bridges_field_yields_only_primary() { + let toml = r#" +[client] +name = "laptop" +server_addr = "203.0.113.10:443" +sni = "vpn.example.com" + +[pki] +ca_cert = "ca.crt" +cert = "client.crt" +key = "client.key" + +[tunnel] +local_ip = "10.7.0.2" +"#; + let cfg = ClientConfigFile::parse(toml).expect("parse minimal"); + assert!(cfg.client.bridges.is_empty(), "no bridges field"); + let dial = cfg.dial_config().expect("dial config"); + let targets = build_dial_targets(&dial.endpoints, &cfg.client.bridges); + assert_eq!(targets.len(), 1, "only primary when bridges omitted"); +} + +/// `detect_default_egress_iface` is best-effort and tolerated to be `None`. When it does return a +/// value, the iface name must be non-empty. +#[test] +fn detect_default_egress_iface_is_tolerant() { + match aura_cli::os_routes::detect_default_egress_iface() { + Some(iface) => assert!(!iface.is_empty(), "detected iface name must be non-empty"), + None => { + // CI / sandboxed environments often have no default route. Tolerated. + } + } +} diff --git a/crates/aura-cli/tests/cli_no_logs.rs b/crates/aura-cli/tests/cli_no_logs.rs new file mode 100644 index 0000000..fc1c5a6 --- /dev/null +++ b/crates/aura-cli/tests/cli_no_logs.rs @@ -0,0 +1,139 @@ +//! Integration test for [`aura_cli::no_logs::redacting_field_formatter`]. +//! +//! The production code installs the same `FormatFields` against the global subscriber via +//! [`aura_cli::no_logs::init_filtered_tracing`]. We cannot use a global subscriber inside a unit +//! test (it stays installed for the whole test binary and leaks across tests). Instead we mount +//! the same formatter on a *per-test* subscriber using the `with_default` guard, route output +//! through an in-memory writer, and assert that the redacted field values are absent while +//! non-redacted fields still appear. + +use std::io::Write; +use std::sync::{Arc, Mutex}; + +use tracing_subscriber::fmt::MakeWriter; + +/// An in-memory writer factory: each `make_writer` returns a guard that locks the shared `Vec` +/// and writes into it. Cheap enough for one-shot test setups. +#[derive(Clone, Default)] +struct BufWriter { + inner: Arc>>, +} + +impl BufWriter { + fn snapshot(&self) -> String { + let guard = self.inner.lock().unwrap(); + String::from_utf8(guard.clone()).expect("utf8") + } +} + +impl<'a> MakeWriter<'a> for BufWriter { + type Writer = BufWriterGuard; + fn make_writer(&'a self) -> Self::Writer { + BufWriterGuard { + inner: Arc::clone(&self.inner), + } + } +} + +struct BufWriterGuard { + inner: Arc>>, +} + +impl Write for BufWriterGuard { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + let mut g = self.inner.lock().unwrap(); + g.extend_from_slice(buf); + Ok(buf.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + +/// Drive `tracing::info!` with one redacted and one safe field, route output through the redacting +/// formatter into a buffer, and assert the redacted value is absent while the safe value is present. +#[test] +fn no_logs_drops_peer_id_field_from_output() { + let buf = BufWriter::default(); + let subscriber = tracing_subscriber::fmt() + .with_writer(buf.clone()) + .with_ansi(false) + .fmt_fields(aura_cli::no_logs::redacting_field_formatter()) + .finish(); + + tracing::subscriber::with_default(subscriber, || { + // peer_id (redacted) and bytes (kept) — the message itself ("client accepted") is fine. + tracing::info!( + peer_id = "SECRET-CLIENT-ID-12345", + bytes = 64u64, + "client accepted" + ); + }); + + let out = buf.snapshot(); + assert!( + !out.contains("SECRET-CLIENT-ID-12345"), + "redacted peer_id leaked: {out}" + ); + assert!( + out.contains("bytes=64"), + "non-redacted field missing: {out}" + ); + assert!(out.contains("client accepted"), "message missing: {out}"); +} + +/// Every spec-listed identifier is suppressed in one go. +#[test] +fn no_logs_drops_every_listed_identifier() { + let buf = BufWriter::default(); + let subscriber = tracing_subscriber::fmt() + .with_writer(buf.clone()) + .with_ansi(false) + .fmt_fields(aura_cli::no_logs::redacting_field_formatter()) + .finish(); + + tracing::subscriber::with_default(subscriber, || { + tracing::info!( + peer_id = "PEERVAL", + client_ip = "CLIPVAL", + source_addr = "SRCVAL", + client_id = "CIDVAL", + local_ip = "LIPVAL", + user = "USERVAL", + id = "IDVAL", + assigned_ip = "ASSVAL", + peer = "PEERVAL2", + bytes = 42u64, + "test" + ); + }); + + let out = buf.snapshot(); + for redacted in [ + "PEERVAL", "CLIPVAL", "SRCVAL", "CIDVAL", "LIPVAL", "USERVAL", "IDVAL", "ASSVAL", + "PEERVAL2", + ] { + assert!( + !out.contains(redacted), + "value '{redacted}' leaked into output: {out}" + ); + } + // bytes is a kept field — must still be visible. + assert!(out.contains("bytes=42"), "kept field missing: {out}"); +} + +/// Sanity: the unfiltered default formatter (no `fmt_fields` swap) DOES emit the peer_id value — +/// this guards against accidentally enabling redaction by default for non-`no_logs` deployments. +#[test] +fn default_formatter_keeps_peer_id() { + let buf = BufWriter::default(); + let subscriber = tracing_subscriber::fmt() + .with_writer(buf.clone()) + .with_ansi(false) + .finish(); + tracing::subscriber::with_default(subscriber, || { + tracing::info!(peer_id = "SHOULD-APPEAR", "ev"); + }); + let out = buf.snapshot(); + assert!(out.contains("SHOULD-APPEAR"), "default did not emit: {out}"); +} diff --git a/crates/aura-cli/tests/cli_provision_client.rs b/crates/aura-cli/tests/cli_provision_client.rs new file mode 100644 index 0000000..4ec07f4 --- /dev/null +++ b/crates/aura-cli/tests/cli_provision_client.rs @@ -0,0 +1,201 @@ +//! Integration tests for [`aura_cli::init::provision_client`]. +//! +//! These tests first generate a CA + server cert via `pki::init` / `pki::issue_server`, then +//! drive `provision_client` against that CA and verify: +//! +//! * the bundle directory ends up with `ca.crt`, `client.crt`, `client.key`, `client.toml`; +//! * the rendered `client.toml` parses; +//! * the issued client cert verifies against the original CA via [`AuraCertVerifier`]; +//! * `--id` defaults to a UUID v4 and is reflected as the cert CN. + +use std::path::PathBuf; + +use aura_cli::config::ClientConfigFile; +use aura_cli::init::{self, ProvisionClientOpts}; +use aura_cli::pki; +use aura_pki::AuraCertVerifier; +use rustls_pki_types::CertificateDer; + +/// Per-test temp dir. +fn temp_dir(tag: &str) -> PathBuf { + let mut dir = std::env::temp_dir(); + dir.push(format!( + "aura-cli-provision-{tag}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&dir).expect("create temp dir"); + dir +} + +/// Generate a CA at `ca_dir` for the rest of the test to use. +fn bootstrap_ca(ca_dir: &std::path::Path) { + pki::init("Aura Provision Test CA", ca_dir).expect("ca init"); +} + +/// Decode a single-cert PEM into a DER chain for the verifier. +fn pem_chain(pem_path: &std::path::Path) -> Vec> { + let pem = std::fs::read(pem_path).expect("read cert"); + let (_, parsed) = x509_parser::pem::parse_x509_pem(&pem).expect("parse PEM"); + vec![CertificateDer::from(parsed.contents)] +} + +/// Extract the certificate's CN via `x509-parser` so we can check that the assigned id ended up +/// in the cert. +fn cert_common_name(pem_path: &std::path::Path) -> String { + let pem = std::fs::read(pem_path).expect("read cert"); + let (_, parsed) = x509_parser::pem::parse_x509_pem(&pem).expect("parse PEM"); + let (_, cert) = x509_parser::parse_x509_certificate(&parsed.contents).expect("parse cert"); + let subject = cert.subject(); + for cn in subject.iter_common_name() { + if let Ok(s) = cn.as_str() { + return s.to_string(); + } + } + panic!("no CN in subject {subject:?}"); +} + +/// Happy path: explicit id, bundle materialises and parses, cert verifies against CA. +#[test] +fn provision_client_with_explicit_id() { + let root = temp_dir("happy"); + let ca_dir = root.join("ca"); + bootstrap_ca(&ca_dir); + let bundle = root.join("client-bundle"); + + let mut opts = ProvisionClientOpts::new( + &ca_dir, + "203.0.113.10", + "vpn.example.com", + "10.7.0.2", + &bundle, + ); + opts.id = Some("phone-1".to_string()); + let report = init::provision_client(&opts).expect("provision"); + assert_eq!(report.id, "phone-1", "explicit id preserved"); + assert!(report.ca_cert.exists()); + assert!(report.client_cert.exists()); + assert!(report.client_key.exists()); + assert!(report.client_config.exists()); + + // The bundled cert's CN matches the id we passed. + assert_eq!(cert_common_name(&report.client_cert), "phone-1"); + + // The client.toml round-trips through the parser cleanly. + let cfg = ClientConfigFile::load(&report.client_config).expect("parse client.toml"); + assert_eq!(cfg.client.server_addr, "203.0.113.10:443"); + assert_eq!(cfg.client.sni, "vpn.example.com"); + assert_eq!(cfg.tunnel.local_ip, "10.7.0.2"); + assert!(cfg.client.bridges.is_empty(), "no bridges by default"); + + // The verifier accepts the bundled chain against the same CA we issued from. + let ca_pem = std::fs::read_to_string(ca_dir.join(pki::CA_CERT)).expect("read ca"); + let verifier = AuraCertVerifier::new(&ca_pem).expect("verifier"); + let chain = pem_chain(&report.client_cert); + let cn = verifier + .verify_client_cert(&chain) + .expect("issued client cert chains to the CA"); + assert_eq!(cn, "phone-1"); + + let _ = std::fs::remove_dir_all(&root); +} + +/// Default `--id` path: a fresh UUID v4 is assigned and ends up as the CN. +#[test] +fn provision_client_default_id_is_uuid_v4() { + let root = temp_dir("uuid"); + let ca_dir = root.join("ca"); + bootstrap_ca(&ca_dir); + let bundle = root.join("bundle"); + + let opts = ProvisionClientOpts::new( + &ca_dir, + "203.0.113.10", + "vpn.example.com", + "10.7.0.5", + &bundle, + ); + let report = init::provision_client(&opts).expect("provision"); + + // The id is a valid UUID v4 and equals the cert CN. + let parsed = uuid::Uuid::parse_str(&report.id).expect("id is uuid"); + assert_eq!(parsed.get_version_num(), 4, "uuid v4"); + assert_eq!(cert_common_name(&report.client_cert), report.id); + let _ = std::fs::remove_dir_all(&root); +} + +/// `bridges = [...]` ends up in the rendered client.toml and parses back through the config. +#[test] +fn provision_client_writes_bridges() { + let root = temp_dir("bridges"); + let ca_dir = root.join("ca"); + bootstrap_ca(&ca_dir); + let bundle = root.join("bundle"); + + let mut opts = ProvisionClientOpts::new( + &ca_dir, + "203.0.113.10", + "vpn.example.com", + "10.7.0.3", + &bundle, + ); + opts.bridges = vec!["203.0.113.11".to_string(), "203.0.113.12".to_string()]; + let report = init::provision_client(&opts).expect("provision"); + + let cfg = ClientConfigFile::load(&report.client_config).expect("parse"); + assert_eq!(cfg.client.bridges.len(), 2); + assert!(cfg.client.bridges.contains(&"203.0.113.11".to_string())); + assert!(cfg.client.bridges.contains(&"203.0.113.12".to_string())); + let _ = std::fs::remove_dir_all(&root); +} + +/// `enable_knock` / `enable_cover_traffic` flip the rendered TOML's `[transport.knock]` / +/// `[transport.cover]` sections. +#[test] +fn provision_client_anti_surveillance_toggles() { + let root = temp_dir("knock"); + let ca_dir = root.join("ca"); + bootstrap_ca(&ca_dir); + let bundle = root.join("bundle"); + + let mut opts = ProvisionClientOpts::new( + &ca_dir, + "203.0.113.10", + "vpn.example.com", + "10.7.0.4", + &bundle, + ); + opts.enable_knock = true; + opts.enable_cover_traffic = true; + let report = init::provision_client(&opts).expect("provision"); + + let cfg = ClientConfigFile::load(&report.client_config).expect("parse"); + assert!(cfg.transport.knock.enabled); + assert!(cfg.transport.cover.enabled); + let _ = std::fs::remove_dir_all(&root); +} + +/// A non-empty bundle directory triggers an error without `--force`. +#[test] +fn provision_client_refuses_non_empty_bundle() { + let root = temp_dir("nonempty"); + let ca_dir = root.join("ca"); + bootstrap_ca(&ca_dir); + let bundle = root.join("bundle"); + std::fs::create_dir_all(&bundle).unwrap(); + std::fs::write(bundle.join("junk.txt"), b"hi").unwrap(); + + let opts = ProvisionClientOpts::new( + &ca_dir, + "203.0.113.10", + "vpn.example.com", + "10.7.0.6", + &bundle, + ); + let err = init::provision_client(&opts).unwrap_err().to_string(); + assert!(err.contains("not empty"), "got: {err}"); + let _ = std::fs::remove_dir_all(&root); +} diff --git a/crates/aura-cli/tests/cli_server_init.rs b/crates/aura-cli/tests/cli_server_init.rs new file mode 100644 index 0000000..edade7e --- /dev/null +++ b/crates/aura-cli/tests/cli_server_init.rs @@ -0,0 +1,134 @@ +//! Integration tests for [`aura_cli::init::server_init`]. +//! +//! Drives the in-process helper directly (no clap parsing, no binary spawn) and asserts that the +//! generated CA + server cert + server.toml exist on disk and parse cleanly. Each switch on the +//! `ServerInitOpts` flips the corresponding section in the rendered TOML. + +use std::path::PathBuf; + +use aura_cli::config::ServerConfigFile; +use aura_cli::init::{self, ServerInitOpts}; + +/// Unique temp dir for one test (no `tempfile` dependency in the workspace). +fn temp_dir(tag: &str) -> PathBuf { + let mut dir = std::env::temp_dir(); + dir.push(format!( + "aura-cli-server-init-{tag}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&dir).expect("create temp dir"); + dir +} + +/// Build a baseline options struct with the temp directories pre-filled. Per-test mutations layer +/// on top of this. +fn base_opts(tag: &str) -> (ServerInitOpts, PathBuf) { + let root = temp_dir(tag); + let pki = root.join("pki"); + let cfg = root.join("server.toml"); + let mut opts = ServerInitOpts::new("vpn.example.com", &pki); + opts.out_config = cfg.clone(); + // Force the no_nat path by default — the integration test runner may or may not have a + // detectable default route, so the per-test `egress_iface` / `no_nat` overrides are explicit. + opts.no_nat = true; + (opts, root) +} + +/// Happy path: CA, server cert and server.toml all written and the TOML parses back. +#[test] +fn server_init_writes_and_parses() { + let (opts, root) = base_opts("happy"); + let report = init::server_init(&opts).expect("server-init succeeds"); + + assert!(report.ca_cert.exists(), "ca.crt exists"); + assert!(report.ca_key.exists(), "ca.key exists"); + assert!(report.server_cert.exists(), "server.crt exists"); + assert!(report.server_key.exists(), "server.key exists"); + assert!(report.server_config.exists(), "server.toml exists"); + + let cfg = ServerConfigFile::load(&report.server_config).expect("server.toml parses"); + assert_eq!(cfg.server.listen, "0.0.0.0:443"); + assert_eq!(cfg.tunnel.pool_cidr, "10.7.0.0/24"); + assert_eq!(cfg.transport.udp_port, 443); + assert_eq!(cfg.transport.quic_port, 444); + // no-nat was set in the baseline. + assert!(cfg.server.nat.is_none(), "no [server.nat] section"); + // knock / cover default to disabled. + assert!(!cfg.transport.knock.enabled); + assert!(!cfg.transport.cover.enabled); + // PKI section points at the generated files. + assert_eq!(cfg.pki.ca_cert, report.ca_cert.to_string_lossy()); + + // Cleanup is best-effort. + let _ = std::fs::remove_dir_all(&root); +} + +/// `--enable-knock` and `--enable-cover-traffic` flip the [transport.*] sections on. +#[test] +fn server_init_enables_anti_surveillance() { + let (mut opts, root) = base_opts("knock"); + opts.enable_knock = true; + opts.enable_cover_traffic = true; + let report = init::server_init(&opts).expect("server-init succeeds"); + + let cfg = ServerConfigFile::load(&report.server_config).expect("parse"); + assert!(cfg.transport.knock.enabled, "knock enabled"); + assert_eq!(cfg.transport.knock.knock_secret_source, "ca_fingerprint"); + assert!(cfg.transport.cover.enabled, "cover enabled"); + assert_eq!(cfg.transport.cover.mean_interval_ms, 500); + let _ = std::fs::remove_dir_all(&root); +} + +/// `egress_iface = "eth0"` + `no_nat = false` writes a `[server.nat]` section. +#[test] +fn server_init_writes_nat_when_egress_explicit() { + let (mut opts, root) = base_opts("nat"); + opts.no_nat = false; + opts.egress_iface = Some("eth0".to_string()); + let report = init::server_init(&opts).expect("server-init succeeds"); + + let cfg = ServerConfigFile::load(&report.server_config).expect("parse"); + let nat = cfg.server.nat.expect("[server.nat] present"); + assert!(nat.auto, "nat.auto = true"); + assert_eq!(nat.egress_iface, "eth0"); + let _ = std::fs::remove_dir_all(&root); +} + +/// `run_as = "nobody"` ends up in `[server] run_as` and `no_logs` toggles parse cleanly. +#[test] +fn server_init_run_as_and_no_logs_present() { + let (mut opts, root) = base_opts("runas"); + opts.run_as = Some("nobody".to_string()); + let report = init::server_init(&opts).expect("server-init succeeds"); + + let cfg = ServerConfigFile::load(&report.server_config).expect("parse"); + assert_eq!(cfg.server.run_as.as_deref(), Some("nobody")); + // `no_logs` is emitted with the default `false`. + assert!(!cfg.server.no_logs); + let _ = std::fs::remove_dir_all(&root); +} + +/// Without `--force`, re-running over an existing CA errors out cleanly. +#[test] +fn server_init_refuses_to_clobber_without_force() { + let (opts, root) = base_opts("clobber"); + init::server_init(&opts).expect("first run succeeds"); + + // Re-run should fail because the CA already exists. + let err = init::server_init(&opts).unwrap_err().to_string(); + assert!( + err.contains("CA already exists") || err.contains("already exists"), + "expected overwrite refusal, got: {err}" + ); + + // With force the second run succeeds. + let mut forced = opts.clone(); + forced.force = true; + let report = init::server_init(&forced).expect("--force overwrites"); + assert!(report.ca_cert.exists()); + let _ = std::fs::remove_dir_all(&root); +}