feat(cli): automation bundle + identity-minimization features

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 <noreply@anthropic.com>
This commit is contained in:
xah30
2026-05-27 12:14:57 +03:00
parent 7d711d8938
commit 8f0cf1f017
15 changed files with 1749 additions and 23 deletions
+19
View File
@@ -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
+21
View File
@@ -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
+77
View File
@@ -114,6 +114,12 @@ pub struct ServerSection {
/// server keeps its current credentials.
#[serde(default)]
pub run_as: Option<String>,
/// When `true`, the tracing layer suppresses event fields that would identify a peer
/// (`peer_id`, `client_ip`, `source_addr`, `client_id`, `local_ip`, `user`, `id`). The event
/// itself still fires (for operational counters like rx/tx packets), but no identifier is
/// written. Default `false` (verbose). See [`crate::no_logs`].
#[serde(default)]
pub no_logs: bool,
}
/// `[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<String>,
/// When `true`, the tracing layer suppresses event fields that would identify the user
/// (`peer_id`, `client_ip`, `source_addr`, `client_id`, `local_ip`, `user`, `id`). Default
/// `false` (verbose). See [`crate::no_logs`].
#[serde(default)]
pub no_logs: bool,
/// Optional fallback server addresses tried in random order if the primary `server_addr`
/// fails on every transport. Each entry is an IP (or `IP:port`); the per-transport ports come
/// from `[transport]` as for the primary endpoint. Empty / omitted means no fallbacks.
/// See [`crate::dial_targets::build_dial_targets`].
#[serde(default)]
pub bridges: Vec<String>,
}
/// `[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,
}
}
}
+189
View File
@@ -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<Endpoints>` 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<Endpoints> {
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<IpAddr> = 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
// FisherYates 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<IpAddr> {
let trimmed = s.trim();
if trimmed.is_empty() {
return None;
}
if let Ok(addr) = trimmed.parse::<SocketAddr>() {
return Some(addr.ip());
}
trimmed.parse::<IpAddr>().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<SocketAddr>| addr.map(|sa| SocketAddr::new(ip, sa.port()));
Endpoints {
udp: rewrite(primary.udp),
tcp: rewrite(primary.tcp),
quic: rewrite(primary.quic),
}
}
/// Tiny in-place FisherYates shuffle using a `SystemTime`-derived seed.
fn shuffle_in_place<T>(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<IpAddr> =
targets[1..].iter().map(|e| e.udp.unwrap().ip()).collect();
assert!(bridge_ips.contains(&"203.0.113.11".parse::<IpAddr>().unwrap()));
assert!(bridge_ips.contains(&"203.0.113.12".parse::<IpAddr>().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());
}
}
+464
View File
@@ -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<String>,
/// 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<String>,
/// 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<String>, pki_dir: impl Into<PathBuf>) -> 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 `<pki_dir>/ca.crt`).
pub ca_cert: PathBuf,
/// Path of the generated CA key (always `<pki_dir>/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<String>,
}
/// 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<ServerInitReport> {
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>,
) -> 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<String>,
/// 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<String>,
/// 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<PathBuf>,
server_addr: impl Into<String>,
server_name: impl Into<String>,
tun_ip: impl Into<String>,
out_dir: impl Into<PathBuf>,
) -> 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<ProvisionClientReport> {
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<String> = 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
}
+3
View File
@@ -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;
+238 -12
View File
@@ -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 = <ID>.
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<String>,
/// 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<String>,
/// 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<String>,
/// 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<String>,
/// 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<String>,
/// 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-IP> --server-name {} --tun-ip <POOL-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(|| "<server-domain>".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::<Vec<_>>()
})
.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(())
}
+110
View File
@@ -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}"
);
}
}
}
+19
View File
@@ -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<String> {
#[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)> {
+18
View File
@@ -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() {
+21 -11
View File
@@ -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<NatGuard> = 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
+96
View File
@@ -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<String> = 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.
}
}
}
+139
View File
@@ -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<u8>`
/// and writes into it. Cheap enough for one-shot test setups.
#[derive(Clone, Default)]
struct BufWriter {
inner: Arc<Mutex<Vec<u8>>>,
}
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<Mutex<Vec<u8>>>,
}
impl Write for BufWriterGuard {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
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}");
}
@@ -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<CertificateDer<'static>> {
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);
}
+134
View File
@@ -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);
}