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:
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
// Fisher–Yates 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 Fisher–Yates 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());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)> {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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