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
+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);
}