ba8d6b796f
Live macOS test against the production server uncovered six bugs (one of which turned out to be a port collision with sing-box, not a real bug); this commit addresses all of them and adds v3.4 port discovery so the same collision is handled transparently next time. ## v3.4 server port-discovery - Defaults moved off 443/444 to 8443/8443/8444 (TransportSection::default, ServerInitOpts, ProvisionClientOpts, CLI flags). 443 is heavily contested in practice (sing-box, Hysteria2, reverse proxies) and the previous default silently lost the bind when a co-tenant was already there. - MultiServer::bind_with_outer_or_scan: scans forward up to DEFAULT_PORT_SCAN_MAX (20) candidates per transport when the requested port is occupied; QUIC keeps walking if it lands on the custom-UDP port. - MultiServer::bound_addrs(): the actual addresses each transport bound to. - Server logs the bound addresses and writes a runtime snapshot (server.toml.runtime.json) when they differ from the requested ones, so `aura sign-bridges` can re-sign the bridges manifest later. - BridgeManifest gains an optional `endpoints: Vec<BridgeEndpoint>` field with per-transport ports. Backward-compatible: old v3.3 clients ignore the field and continue to use the v1 `bridges` line. - `aura sign-bridges --endpoints HOST:tcp=N:quic=N:udp=N` to mint v3.4 manifests; bridges line is auto-synthesised for v3.3 clients. ## Bug fixes from the live test - macOS TUN naming (#41): the tun crate rejects names that don't match ^utun[0-9]+$. On macOS we now substitute `""` (kernel auto-assigns utunN), capture the assigned name via inner.tun_name(), and propagate it through to os_routes::OsRouteGuard::install — so `route add -interface utunN` uses the real interface, not "aura0". - Packet counters (#42): Stats { tx_packets, rx_packets } are now actually bumped by the data path. `aura status` shows live numbers instead of permanent zeros. - render_client_toml schema (#44): provisioner emits proper `[[tunnel.split.vpn]] cidr = "..."` / `[[tunnel.split.direct]]` blocks from new --vpn-cidrs / --direct-cidrs flags. The v3.3 `vpn_cidrs = [...]` flat array was silently ignored by serde, leaving users with `rules: 0` even when their CIDRs looked right. - #43 / #46 (TCP/443 dial early-eof / no payload back): diagnosed as the sing-box port collision, not an Aura bug. The v3.4 port-scan path makes it go away — the server picks a free port and clients learn it from the manifest. ## Test coverage Three new unit tests for the port-scanner (UDP busy, TCP busy, zero budget); two new tests for v3.4 BridgeManifest round-trip with endpoints; one integration test for the new `[[tunnel.split.vpn]]` rendering; tests for the runtime-state file write/read round-trip; agent-added router-counter tests in aura-tunnel/tests/routes.rs. cargo test --workspace, cargo clippy --workspace -- -D warnings, and cargo fmt --check all pass. #45 (silent client exit when underlying QUIC transport breaks) is still outstanding — needs deeper investigation; deferred to a follow-up. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
356 lines
13 KiB
Rust
356 lines
13 KiB
Rust
//! 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");
|
|
// v3.4: default udp_port is 8443 (was 443 in v3.3).
|
|
assert_eq!(cfg.client.server_addr, "203.0.113.10:8443");
|
|
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);
|
|
}
|
|
|
|
/// v3.2: `--circuit-hops N` issues N independent client certs, each with its own UUID v4 CN.
|
|
/// The bundled `client.toml` gains a `[client.circuit]` section with N `[[client.circuit.hops]]`
|
|
/// tables. Each hop's `cert_path` / `key_path` references the freshly-issued PEM file in the
|
|
/// bundle, and each cert's CN is a distinct UUID v4.
|
|
#[test]
|
|
fn provision_client_with_v3_2_circuit_hops() {
|
|
let root = temp_dir("v32hops");
|
|
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.7",
|
|
&bundle,
|
|
);
|
|
opts.circuit_hops = Some(3); // entry + middle + exit
|
|
let report = init::provision_client(&opts).expect("provision");
|
|
|
|
// Three distinct per-hop certs were issued, all with unique UUID-v4 CNs.
|
|
assert_eq!(report.circuit_hop_certs.len(), 3, "3 hop certs issued");
|
|
let mut cns: Vec<String> = report
|
|
.circuit_hop_certs
|
|
.iter()
|
|
.map(|(cn, _, _)| cn.clone())
|
|
.collect();
|
|
cns.sort();
|
|
cns.dedup();
|
|
assert_eq!(cns.len(), 3, "all hop CNs are distinct");
|
|
for (cn, _, _) in &report.circuit_hop_certs {
|
|
let parsed = uuid::Uuid::parse_str(cn).expect("hop cn is a uuid");
|
|
assert_eq!(parsed.get_version_num(), 4, "hop cn is uuid v4");
|
|
}
|
|
for (i, (_, cert, key)) in report.circuit_hop_certs.iter().enumerate() {
|
|
assert!(cert.exists(), "hop {i} cert exists");
|
|
assert!(key.exists(), "hop {i} key exists");
|
|
assert!(cert
|
|
.file_name()
|
|
.unwrap()
|
|
.to_string_lossy()
|
|
.contains(&format!("circuit-hop-{i}")));
|
|
}
|
|
|
|
// The bundled client.toml has `[client.circuit] enabled = true` and 3 hop tables.
|
|
let cfg = ClientConfigFile::load(&report.client_config).expect("parse client.toml");
|
|
assert!(cfg.client.circuit.enabled, "[client.circuit] enabled");
|
|
assert_eq!(cfg.client.circuit.hops.len(), 3, "3 hops in client.toml");
|
|
// Every hop entry is the Full variant (per-hop cert/key paths).
|
|
use aura_cli::config::CircuitHop;
|
|
for (i, hop) in cfg.client.circuit.hops.iter().enumerate() {
|
|
match hop {
|
|
CircuitHop::Full {
|
|
cert_path,
|
|
key_path,
|
|
..
|
|
} => {
|
|
let cert_str = cert_path.to_string_lossy();
|
|
let key_str = key_path.to_string_lossy();
|
|
assert!(
|
|
cert_str.contains(&format!("circuit-hop-{i}")),
|
|
"hop {i} cert_path references circuit-hop-{i}.crt; got {cert_str}"
|
|
);
|
|
assert!(
|
|
key_str.contains(&format!("circuit-hop-{i}")),
|
|
"hop {i} key_path references circuit-hop-{i}.key; got {key_str}"
|
|
);
|
|
}
|
|
_ => panic!("hop {i}: expected Full variant in rendered client.toml"),
|
|
}
|
|
}
|
|
// Cell padding is enabled by default in the v3.2 rendered config.
|
|
assert!(
|
|
cfg.client.circuit.cell_padding,
|
|
"cell_padding defaults true in v3.2 render"
|
|
);
|
|
|
|
let _ = std::fs::remove_dir_all(&root);
|
|
}
|
|
|
|
/// `--circuit-hops 1` is rejected (N must be >= 2).
|
|
#[test]
|
|
fn provision_client_circuit_hops_too_few_errors() {
|
|
let root = temp_dir("v32hops_few");
|
|
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.8",
|
|
&bundle,
|
|
);
|
|
opts.circuit_hops = Some(1);
|
|
let err = init::provision_client(&opts).unwrap_err().to_string();
|
|
assert!(err.contains("circuit-hops"), "got: {err}");
|
|
let _ = std::fs::remove_dir_all(&root);
|
|
}
|
|
|
|
/// v3.4: `vpn_cidrs` / `direct_cidrs` end up as `[[tunnel.split.vpn]]` / `[[tunnel.split.direct]]`
|
|
/// blocks in the rendered client.toml, and the server's parser actually loads them into the
|
|
/// `[tunnel.split]` rule table (proves we are not on the silently-ignored `vpn_cidrs = [...]`
|
|
/// flat-array footgun any more).
|
|
#[test]
|
|
fn provision_client_emits_split_cidr_blocks() {
|
|
let root = temp_dir("split-cidrs");
|
|
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.7",
|
|
&bundle,
|
|
);
|
|
opts.vpn_cidrs = vec!["10.7.0.0/24".to_string(), "1.1.1.1/32".to_string()];
|
|
opts.direct_cidrs = vec!["192.168.0.0/16".to_string()];
|
|
let report = init::provision_client(&opts).expect("provision");
|
|
|
|
let toml_text = std::fs::read_to_string(&report.client_config).expect("read client.toml");
|
|
// The rendered TOML uses the array-of-tables syntax the server parser actually understands.
|
|
assert!(
|
|
toml_text.contains("[[tunnel.split.vpn]]\ncidr = \"10.7.0.0/24\""),
|
|
"rendered toml missing 10.7.0.0/24 vpn block:\n{toml_text}"
|
|
);
|
|
assert!(
|
|
toml_text.contains("[[tunnel.split.vpn]]\ncidr = \"1.1.1.1/32\""),
|
|
"rendered toml missing 1.1.1.1/32 vpn block:\n{toml_text}"
|
|
);
|
|
assert!(
|
|
toml_text.contains("[[tunnel.split.direct]]\ncidr = \"192.168.0.0/16\""),
|
|
"rendered toml missing 192.168.0.0/16 direct block:\n{toml_text}"
|
|
);
|
|
|
|
// And the parser loads the rules — this is the bit v3.3 silently failed at.
|
|
let cfg = ClientConfigFile::load(&report.client_config).expect("parse");
|
|
assert_eq!(cfg.tunnel.split.vpn.len(), 2);
|
|
assert_eq!(cfg.tunnel.split.direct.len(), 1);
|
|
assert_eq!(cfg.tunnel.split.vpn[0].cidr.as_deref(), Some("10.7.0.0/24"));
|
|
assert_eq!(cfg.tunnel.split.vpn[1].cidr.as_deref(), Some("1.1.1.1/32"));
|
|
assert_eq!(
|
|
cfg.tunnel.split.direct[0].cidr.as_deref(),
|
|
Some("192.168.0.0/16")
|
|
);
|
|
|
|
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);
|
|
}
|