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:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user