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