From cb89312a275d76d883b14d8da34ee3e1bf7c8f3e Mon Sep 17 00:00:00 2001 From: xah30 Date: Mon, 25 May 2026 18:36:13 +0300 Subject: [PATCH] =?UTF-8?q?feat(cli):=20implement=20Wave=204=20=E2=80=94?= =?UTF-8?q?=20aura=20binary=20(PKI,=20server/client,=20admin,=20bench)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit aura-cli: clap command tree (pki init/issue-server/issue-client/revoke/list, server, client, route add/list/remove, status, bench-crypto); TOML config with ~ expansion and split-tunnel rules -> RouteTable; JSON-over-Unix-socket admin IPC; server/client data paths wiring transport + tunnel (TUN run needs root). config/{server,client}.toml.example. 15 tests (pki roundtrip, config parse, admin-socket roundtrip, loopback connection). Verified the real binary: --help, bench-crypto, and a full CA->server->client cert workflow. Co-Authored-By: Claude Opus 4.7 --- Cargo.lock | 4 + config/client.toml.example | 53 +++ config/server.toml.example | 32 ++ crates/aura-cli/Cargo.toml | 14 + crates/aura-cli/src/admin.rs | 555 ++++++++++++++++++++++++ crates/aura-cli/src/bench.rs | 95 ++++ crates/aura-cli/src/client.rs | 139 ++++++ crates/aura-cli/src/config.rs | 576 +++++++++++++++++++++++++ crates/aura-cli/src/lib.rs | 20 + crates/aura-cli/src/main.rs | 327 +++++++++++++- crates/aura-cli/src/pki.rs | 112 +++++ crates/aura-cli/src/server.rs | 118 +++++ crates/aura-cli/tests/admin_socket.rs | 147 +++++++ crates/aura-cli/tests/loopback.rs | 81 ++++ crates/aura-cli/tests/pki_roundtrip.rs | 109 +++++ 15 files changed, 2379 insertions(+), 3 deletions(-) create mode 100644 config/client.toml.example create mode 100644 config/server.toml.example create mode 100644 crates/aura-cli/src/admin.rs create mode 100644 crates/aura-cli/src/bench.rs create mode 100644 crates/aura-cli/src/client.rs create mode 100644 crates/aura-cli/src/config.rs create mode 100644 crates/aura-cli/src/lib.rs create mode 100644 crates/aura-cli/src/pki.rs create mode 100644 crates/aura-cli/src/server.rs create mode 100644 crates/aura-cli/tests/admin_socket.rs create mode 100644 crates/aura-cli/tests/loopback.rs create mode 100644 crates/aura-cli/tests/pki_roundtrip.rs diff --git a/Cargo.lock b/Cargo.lock index 27f1265..a3e51c2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -196,12 +196,16 @@ dependencies = [ "aura-transport", "aura-tunnel", "clap", + "ipnetwork", + "rustls-pki-types", "serde", + "serde_json", "tokio", "toml", "tracing", "tracing-subscriber", "uuid", + "x509-parser 0.16.0", ] [[package]] diff --git a/config/client.toml.example b/config/client.toml.example new file mode 100644 index 0000000..eea00b9 --- /dev/null +++ b/config/client.toml.example @@ -0,0 +1,53 @@ +# Aura VPN client configuration (project §9). +# Copy to client.toml and adjust. Paths may begin with `~` (expands to your home directory). + +[client] +# Human-readable client name / id. +name = "laptop" +# Server UDP socket address. +server_addr = "203.0.113.10:443" +# Outer-TLS SNI (camouflage hostname) presented to the server. Also the name verified +# inside the Aura handshake against the server certificate's SAN. +sni = "cdn.example.com" + +[pki] +# Trust anchor (the Aura CA) and this client's leaf cert/key, all PEM. +# Issue with: aura pki issue-client --id laptop --out ~/.aura --ca ~/.aura +ca_cert = "~/.aura/ca.crt" +cert = "~/.aura/client.crt" +key = "~/.aura/client.key" + +[tunnel] +# Requested TUN interface name (advisory on macOS, where the kernel assigns utunN). +tun_name = "aura0" +# Local address assigned to the TUN device, and its prefix length. +local_ip = "10.7.0.2" +prefix = 24 +# TUN MTU. +mtu = 1420 +# Tunnel resolver DNS (informational; the system resolver is used in v1). +dns = "10.7.0.1" + +# Split-tunnel routing: the default action plus per-destination overrides. +[tunnel.split] +# Default for destinations matching no rule below: "VPN" or "DIRECT". +default = "VPN" + +# Send these directly (bypass the tunnel): RFC1918 ranges stay on the LAN... +[[tunnel.split.direct]] +cidr = "192.168.0.0/16" + +[[tunnel.split.direct]] +cidr = "10.0.0.0/8" + +# ...and a corporate domain egresses directly (resolved to host routes at startup). +[[tunnel.split.direct]] +domain = "intranet.example.com" + +# Force a more-specific range back through the VPN (longest-prefix wins over 10.0.0.0/8). +[[tunnel.split.vpn]] +cidr = "10.7.0.0/24" + +[mimicry] +# Enable traffic padding to blend packet sizes into HTTPS buckets. +padding = false diff --git a/config/server.toml.example b/config/server.toml.example new file mode 100644 index 0000000..d7402dd --- /dev/null +++ b/config/server.toml.example @@ -0,0 +1,32 @@ +# Aura VPN server configuration (project §9). +# Copy to server.toml and adjust. Paths may begin with `~` (expands to your home directory). + +[server] +# Human-readable name (also the server's inner-handshake identity). +name = "aura-edge-1" +# UDP socket to listen on. ":443" mimics HTTPS; binding it needs privileges. +listen = "0.0.0.0:443" +# Accept workers (advisory in v1). +workers = 4 + +[pki] +# Trust anchor (the Aura CA) and this server's leaf cert/key, all PEM. +# Generate with: aura pki init --ca-name "Aura CA" --out ~/.aura +# aura pki issue-server --domain vpn.example.com --out ~/.aura --ca ~/.aura +ca_cert = "~/.aura/ca.crt" +cert = "~/.aura/server.crt" +key = "~/.aura/server.key" + +[tunnel] +# Address pool for clients; v1 uses a single shared server-side TUN on this network. +pool_cidr = "10.7.0.0/24" +# TUN MTU (leave headroom under the path MTU for QUIC + Aura framing). +mtu = 1420 +# DNS server advertised to clients (informational in v1). +dns = "10.7.0.1" + +[mimicry] +# Outer-TLS camouflage hostname the server presents/expects. +sni = "cdn.example.com" +# Enable traffic padding to blend packet sizes into HTTPS buckets. +padding = true diff --git a/crates/aura-cli/Cargo.toml b/crates/aura-cli/Cargo.toml index 49be5d7..1afaac3 100644 --- a/crates/aura-cli/Cargo.toml +++ b/crates/aura-cli/Cargo.toml @@ -5,6 +5,10 @@ edition.workspace = true license.workspace = true description = "Aura CLI: client/server binary, PKI management, split-tunnel admin" +[lib] +name = "aura_cli" +path = "src/lib.rs" + [[bin]] name = "aura" path = "src/main.rs" @@ -19,7 +23,17 @@ clap.workspace = true tokio.workspace = true toml.workspace = true serde.workspace = true +# Admin IPC line protocol (JSON requests/responses over the Unix socket). +serde_json = "1" +# Parse CIDR rules from the split-tunnel config and the `route` admin commands. +ipnetwork.workspace = true tracing.workspace = true tracing-subscriber.workspace = true anyhow.workspace = true uuid.workspace = true + +[dev-dependencies] +tokio.workspace = true +# Loopback + PKI-roundtrip tests build certificate chains for the verifier. +rustls-pki-types.workspace = true +x509-parser.workspace = true diff --git a/crates/aura-cli/src/admin.rs b/crates/aura-cli/src/admin.rs new file mode 100644 index 0000000..26c9a6a --- /dev/null +++ b/crates/aura-cli/src/admin.rs @@ -0,0 +1,555 @@ +//! Admin IPC: a tiny JSON line protocol over a Unix domain socket. +//! +//! A running `aura server` / `aura client` hosts a [`serve`] listener over a shared [`AdminState`] +//! (the live `RouteTable`, a rule mirror, and tunnel [`Stats`]). The `aura route ...` and +//! `aura status` subcommands connect to the same socket and exchange one JSON object per line: +//! +//! ```text +//! -> {"cmd":"route_add","cidr":"8.8.8.0/24","action":"direct"} +//! <- {"ok":true} +//! -> {"cmd":"route_add","domain":"example.com","action":"vpn"} +//! <- {"ok":true} +//! -> {"cmd":"route_list"} +//! <- {"ok":true,"default":"vpn","cidrs":[{"cidr":"8.8.8.0/24","action":"direct"}],"domains":[...]} +//! -> {"cmd":"route_remove","cidr":"8.8.8.0/24"} +//! <- {"ok":true,"removed":true} +//! -> {"cmd":"status"} +//! <- {"ok":true,"peer_id":"client-1","rx_packets":0,"tx_packets":0,"default":"vpn","rules":1} +//! ``` +//! +//! On error a response is `{"ok":false,"error":"..."}`. +//! +//! ## Why a rule mirror +//! The library [`RouteTable`] is the source of truth for *classification* but does not expose an +//! iterator over its rules, so the admin layer keeps a parallel [`RuleMirror`] updated in lockstep. +//! Every admin mutation touches both, so `route_list` can faithfully echo what is configured while +//! `classify` still goes through the real table. +//! +//! ## Platform note +//! The transport is `tokio::net::UnixListener` / `UnixStream`, available on Unix (the project's +//! Linux + macOS targets). On Windows this would be a named pipe; that path is a documented +//! `cfg`-gated stub ([`serve`] / [`request`] return an explanatory error) so the rest of the CLI +//! still compiles there. + +use std::collections::BTreeMap; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex as StdMutex}; + +use aura_tunnel::{RouteAction, RouteTable}; +use ipnetwork::IpNetwork; +use serde::{Deserialize, Serialize}; +use tokio::sync::RwLock; + +use crate::config::parse_action; + +/// Default admin socket path used when a config / flag does not override it. +pub const DEFAULT_SOCKET: &str = "/tmp/aura-admin.sock"; + +/// Live tunnel statistics shared between the data path and the admin listener. +#[derive(Debug, Default)] +pub struct Stats { + /// Packets received from the peer (inbound, toward the TUN). + pub rx_packets: AtomicU64, + /// Packets sent to the peer (outbound, from the TUN). + pub tx_packets: AtomicU64, + /// Verified peer identity, set once a connection is established. + pub peer_id: StdMutex>, +} + +impl Stats { + /// Create a zeroed stats block. + pub fn new() -> Self { + Self::default() + } + + /// Record the verified peer identity. + pub fn set_peer_id(&self, id: Option) { + if let Ok(mut g) = self.peer_id.lock() { + *g = id; + } + } +} + +/// A parallel record of admin-configured rules, so `route_list` can enumerate them (the library +/// [`RouteTable`] does not expose iteration). Kept in lockstep with the table. +#[derive(Debug, Default)] +pub struct RuleMirror { + /// CIDR rules, ordered for stable listing. + pub cidrs: StdMutex>, + /// Domain rules, ordered for stable listing. + pub domains: StdMutex>, +} + +impl RuleMirror { + /// Build a mirror pre-populated from an existing table snapshot's rules. + /// + /// The constructor takes already-extracted rule lists (the config layer has them at build + /// time) so the mirror starts consistent with the table the data path was given. + pub fn from_rules( + cidrs: impl IntoIterator, + domains: impl IntoIterator, + ) -> Self { + Self { + cidrs: StdMutex::new(cidrs.into_iter().collect()), + domains: StdMutex::new(domains.into_iter().collect()), + } + } +} + +/// Shared state the admin listener operates on. +#[derive(Clone)] +pub struct AdminState { + /// The live split-tunnel routing table (classification source of truth). + pub routes: Arc>, + /// Mirror of configured rules for enumeration. + pub mirror: Arc, + /// Live tunnel statistics. + pub stats: Arc, +} + +impl AdminState { + /// Construct admin state from a shared table and stats, seeding the mirror from the given rules. + pub fn new( + routes: Arc>, + stats: Arc, + cidrs: impl IntoIterator, + domains: impl IntoIterator, + ) -> Self { + Self { + routes, + mirror: Arc::new(RuleMirror::from_rules(cidrs, domains)), + stats, + } + } +} + +// ---- wire protocol --------------------------------------------------------------------------- + +/// A request from the `aura route` / `aura status` client. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "cmd", rename_all = "snake_case")] +pub enum Request { + /// Add a CIDR or domain rule. + RouteAdd { + /// CIDR to add (mutually exclusive with `domain`). + #[serde(default, skip_serializing_if = "Option::is_none")] + cidr: Option, + /// Domain to add (mutually exclusive with `cidr`). + #[serde(default, skip_serializing_if = "Option::is_none")] + domain: Option, + /// Action: `"vpn"` or `"direct"`. + action: String, + }, + /// List all rules and the default action. + RouteList, + /// Remove a CIDR rule (by exact network). + RouteRemove { + /// CIDR to remove. + cidr: String, + }, + /// Query tunnel statistics. + Status, +} + +/// One CIDR rule in a `route_list` response. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct CidrEntry { + /// The CIDR network. + pub cidr: String, + /// The action applied to it. + pub action: String, +} + +/// One domain rule in a `route_list` response. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct DomainEntry { + /// The domain. + pub domain: String, + /// The action applied to it. + pub action: String, +} + +/// A response to a [`Request`]. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Response { + /// Whether the command succeeded. + pub ok: bool, + /// Error message when `ok` is false. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option, + /// Default action (route_list / status). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default: Option, + /// CIDR rules (route_list). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cidrs: Option>, + /// Domain rules (route_list). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub domains: Option>, + /// Whether a `route_remove` actually removed something. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub removed: Option, + /// Verified peer id (status). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub peer_id: Option, + /// Inbound packet count (status). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub rx_packets: Option, + /// Outbound packet count (status). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tx_packets: Option, + /// Total rule count (status). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub rules: Option, +} + +impl Response { + /// A bare success response. + pub fn ok() -> Self { + Self { + ok: true, + error: None, + default: None, + cidrs: None, + domains: None, + removed: None, + peer_id: None, + rx_packets: None, + tx_packets: None, + rules: None, + } + } + + /// An error response carrying `msg`. + pub fn err(msg: impl Into) -> Self { + Self { + ok: false, + error: Some(msg.into()), + ..Self::ok() + } + } +} + +/// Render the action string for the wire. +fn action_str(a: RouteAction) -> &'static str { + match a { + RouteAction::Vpn => "vpn", + RouteAction::Direct => "direct", + } +} + +/// Apply a single request against the shared state and produce a response. +/// +/// Factored out from the socket I/O so it is directly unit-testable. +pub async fn handle_request(state: &AdminState, req: Request) -> Response { + match req { + Request::RouteAdd { + cidr, + domain, + action, + } => { + let action = match parse_action(&action) { + Ok(a) => a, + Err(e) => return Response::err(e.to_string()), + }; + match (cidr, domain) { + (Some(cidr), None) => match cidr.parse::() { + Ok(net) => { + state.routes.write().await.add_cidr(net, action); + if let Ok(mut m) = state.mirror.cidrs.lock() { + m.insert(net, action); + } + Response::ok() + } + Err(e) => Response::err(format!("invalid cidr '{cidr}': {e}")), + }, + (None, Some(domain)) => { + state.routes.write().await.add_domain(&domain, action); + if let Ok(mut m) = state.mirror.domains.lock() { + m.insert(domain, action); + } + Response::ok() + } + (Some(_), Some(_)) => { + Response::err("specify exactly one of 'cidr' or 'domain', not both") + } + (None, None) => Response::err("specify exactly one of 'cidr' or 'domain'"), + } + } + Request::RouteList => { + let default = action_str(state.routes.read().await.default_action()).to_string(); + let cidrs = state + .mirror + .cidrs + .lock() + .map(|m| { + m.iter() + .map(|(net, a)| CidrEntry { + cidr: net.to_string(), + action: action_str(*a).to_string(), + }) + .collect() + }) + .unwrap_or_default(); + let domains = state + .mirror + .domains + .lock() + .map(|m| { + m.iter() + .map(|(d, a)| DomainEntry { + domain: d.clone(), + action: action_str(*a).to_string(), + }) + .collect() + }) + .unwrap_or_default(); + Response { + default: Some(default), + cidrs: Some(cidrs), + domains: Some(domains), + ..Response::ok() + } + } + Request::RouteRemove { cidr } => match cidr.parse::() { + Ok(net) => { + // Removing from the live table requires rebuilding it (no remove API), preserving + // the default and every other rule from the mirror. + let removed = state + .mirror + .cidrs + .lock() + .map(|mut m| m.remove(&net).is_some()) + .unwrap_or(false); + if removed { + rebuild_table(state).await; + } + Response { + removed: Some(removed), + ..Response::ok() + } + } + Err(e) => Response::err(format!("invalid cidr '{cidr}': {e}")), + }, + Request::Status => { + let default = action_str(state.routes.read().await.default_action()).to_string(); + let rules = state.mirror.cidrs.lock().map(|m| m.len()).unwrap_or(0) + + state.mirror.domains.lock().map(|m| m.len()).unwrap_or(0); + let peer_id = state.stats.peer_id.lock().ok().and_then(|g| g.clone()); + Response { + default: Some(default), + peer_id, + rx_packets: Some(state.stats.rx_packets.load(Ordering::Relaxed)), + tx_packets: Some(state.stats.tx_packets.load(Ordering::Relaxed)), + rules: Some(rules), + ..Response::ok() + } + } + } +} + +/// Rebuild the live [`RouteTable`] from the mirror (used after a `route_remove`, since the library +/// table has no per-rule removal). The default action is preserved; domain rules are re-added (their +/// previously resolved host routes are dropped and will be re-resolved on demand — acceptable for an +/// admin remove in v1). +async fn rebuild_table(state: &AdminState) { + let default = state.routes.read().await.default_action(); + let mut fresh = RouteTable::new(default); + if let Ok(m) = state.mirror.cidrs.lock() { + for (net, a) in m.iter() { + fresh.add_cidr(*net, *a); + } + } + if let Ok(m) = state.mirror.domains.lock() { + for (d, a) in m.iter() { + fresh.add_domain(d, *a); + } + } + *state.routes.write().await = fresh; +} + +/// Run the admin listener until the task is cancelled. +/// +/// Removes any stale socket at `path`, binds a [`tokio::net::UnixListener`], and serves connections +/// (one request/response per accepted line) over the shared `state`. +#[cfg(unix)] +pub async fn serve(path: &str, state: AdminState) -> anyhow::Result<()> { + use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + use tokio::net::UnixListener; + + // Best-effort cleanup of a previous run's socket file. + let _ = std::fs::remove_file(path); + let listener = UnixListener::bind(path) + .map_err(|e| anyhow::anyhow!("binding admin socket {path}: {e}"))?; + tracing::info!(socket = path, "admin IPC listening"); + + loop { + let (stream, _addr) = match listener.accept().await { + Ok(pair) => pair, + Err(e) => { + tracing::warn!(error = %e, "admin accept failed"); + continue; + } + }; + let state = state.clone(); + tokio::spawn(async move { + let (read_half, mut write_half) = stream.into_split(); + let mut lines = BufReader::new(read_half).lines(); + while let Ok(Some(line)) = lines.next_line().await { + if line.trim().is_empty() { + continue; + } + let resp = match serde_json::from_str::(&line) { + Ok(req) => handle_request(&state, req).await, + Err(e) => Response::err(format!("bad request: {e}")), + }; + let mut buf = serde_json::to_vec(&resp) + .unwrap_or_else(|_| b"{\"ok\":false,\"error\":\"serialize failed\"}".to_vec()); + buf.push(b'\n'); + if write_half.write_all(&buf).await.is_err() { + break; + } + } + }); + } +} + +/// Windows stub: the admin socket uses Unix domain sockets; a named-pipe transport is future work. +#[cfg(not(unix))] +pub async fn serve(_path: &str, _state: AdminState) -> anyhow::Result<()> { + anyhow::bail!("admin IPC over Unix sockets is unavailable on this platform (Windows named-pipe transport is not yet implemented)") +} + +/// Connect to the admin socket, send one [`Request`], and return the [`Response`]. +#[cfg(unix)] +pub async fn request(path: &str, req: &Request) -> anyhow::Result { + use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + use tokio::net::UnixStream; + + let stream = UnixStream::connect(path).await.map_err(|e| { + anyhow::anyhow!( + "connecting to admin socket {path}: {e} (is `aura server`/`aura client` running?)" + ) + })?; + let (read_half, mut write_half) = stream.into_split(); + let mut buf = serde_json::to_vec(req)?; + buf.push(b'\n'); + write_half.write_all(&buf).await?; + write_half.flush().await?; + + let mut lines = BufReader::new(read_half).lines(); + let line = lines + .next_line() + .await? + .ok_or_else(|| anyhow::anyhow!("admin socket closed without a response"))?; + Ok(serde_json::from_str(&line)?) +} + +/// Windows stub mirroring [`serve`]. +#[cfg(not(unix))] +pub async fn request(_path: &str, _req: &Request) -> anyhow::Result { + anyhow::bail!("admin IPC over Unix sockets is unavailable on this platform (Windows named-pipe transport is not yet implemented)") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn state() -> AdminState { + AdminState::new( + Arc::new(RwLock::new(RouteTable::new(RouteAction::Vpn))), + Arc::new(Stats::new()), + std::iter::empty(), + std::iter::empty(), + ) + } + + #[tokio::test] + async fn route_add_cidr_then_classify_and_list() { + let st = state(); + let resp = handle_request( + &st, + Request::RouteAdd { + cidr: Some("8.8.8.0/24".into()), + domain: None, + action: "direct".into(), + }, + ) + .await; + assert!(resp.ok, "route_add should succeed: {:?}", resp.error); + assert_eq!( + st.routes.read().await.classify("8.8.8.8".parse().unwrap()), + RouteAction::Direct + ); + + let list = handle_request(&st, Request::RouteList).await; + assert_eq!(list.default.as_deref(), Some("vpn")); + let cidrs = list.cidrs.unwrap(); + assert_eq!(cidrs.len(), 1); + assert_eq!(cidrs[0].cidr, "8.8.8.0/24"); + assert_eq!(cidrs[0].action, "direct"); + } + + #[tokio::test] + async fn route_remove_updates_table_and_mirror() { + let st = state(); + for cidr in ["8.8.8.0/24", "1.1.1.0/24"] { + handle_request( + &st, + Request::RouteAdd { + cidr: Some(cidr.into()), + domain: None, + action: "direct".into(), + }, + ) + .await; + } + let resp = handle_request( + &st, + Request::RouteRemove { + cidr: "8.8.8.0/24".into(), + }, + ) + .await; + assert_eq!(resp.removed, Some(true)); + // The removed rule no longer classifies as Direct (falls back to default VPN). + assert_eq!( + st.routes.read().await.classify("8.8.8.8".parse().unwrap()), + RouteAction::Vpn + ); + // The other rule survives. + assert_eq!( + st.routes.read().await.classify("1.1.1.1".parse().unwrap()), + RouteAction::Direct + ); + let list = handle_request(&st, Request::RouteList).await; + assert_eq!(list.cidrs.unwrap().len(), 1); + } + + #[tokio::test] + async fn route_add_rejects_bad_cidr() { + let st = state(); + let resp = handle_request( + &st, + Request::RouteAdd { + cidr: Some("not-a-cidr".into()), + domain: None, + action: "vpn".into(), + }, + ) + .await; + assert!(!resp.ok); + } + + #[tokio::test] + async fn status_reports_default_and_counters() { + let st = state(); + st.stats.tx_packets.store(5, Ordering::Relaxed); + st.stats.set_peer_id(Some("client-9".into())); + let resp = handle_request(&st, Request::Status).await; + assert!(resp.ok); + assert_eq!(resp.default.as_deref(), Some("vpn")); + assert_eq!(resp.tx_packets, Some(5)); + assert_eq!(resp.peer_id.as_deref(), Some("client-9")); + } +} diff --git a/crates/aura-cli/src/bench.rs b/crates/aura-cli/src/bench.rs new file mode 100644 index 0000000..5b19fff --- /dev/null +++ b/crates/aura-cli/src/bench.rs @@ -0,0 +1,95 @@ +//! `aura bench-crypto`: quick KEM / AEAD / handshake timings. +//! +//! This is a lightweight, dependency-free micro-benchmark (no criterion) intended for an at-a-glance +//! feel of the crypto core's cost. It times, with [`std::time::Instant`], the hybrid KEM keygen, +//! encapsulation, decapsulation, a full hybrid handshake (keygen + encaps + decaps + key +//! derivation), and AEAD seal/open over 1 KiB and 64 KiB messages, then prints a table. + +use std::time::{Duration, Instant}; + +use aura_crypto::{derive_session_keys, AeadSession, HybridPrivateKey}; + +/// Number of iterations per measured operation. +const ITERS: u32 = 200; + +/// Run the crypto micro-benchmarks and print a results table to stdout. +pub fn run() -> anyhow::Result<()> { + println!("aura bench-crypto — {ITERS} iterations per op (hybrid X25519 + ML-KEM-768)\n"); + println!("{:<32} {:>12} {:>14}", "operation", "avg", "ops/sec"); + println!("{}", "-".repeat(60)); + + // KEM keygen. + let keygen = time(ITERS, || { + let _ = HybridPrivateKey::generate(); + }); + row("KEM keygen", keygen); + + // Encapsulate (server side) against a fixed public key. + let (_sk, pk) = HybridPrivateKey::generate(); + let encaps = time(ITERS, || { + let _ = pk.encapsulate(); + }); + row("KEM encapsulate", encaps); + + // Decapsulate (client side) of a fixed ciphertext. + let (sk, pk) = HybridPrivateKey::generate(); + let (ct, _ss) = pk.encapsulate(); + let decaps = time(ITERS, || { + let _ = sk.decapsulate(&ct).expect("decapsulate"); + }); + row("KEM decapsulate", decaps); + + // Full hybrid handshake: keygen + encaps + decaps + derive both directions' keys. + let nonce = [0u8; 32]; + let handshake = time(ITERS, || { + let (sk, pk) = HybridPrivateKey::generate(); + let (ct, server_ss) = pk.encapsulate(); + let client_ss = sk.decapsulate(&ct).expect("decapsulate"); + let _ = derive_session_keys(&server_ss, &nonce, &nonce); + let _ = derive_session_keys(&client_ss, &nonce, &nonce); + }); + row("full hybrid handshake", handshake); + + // AEAD seal/open round trips at two payload sizes. + for (label, size) in [ + ("AEAD seal+open 1KiB", 1024usize), + ("AEAD seal+open 64KiB", 64 * 1024), + ] { + let plaintext = vec![0xA5u8; size]; + let aad = b"aura-bench"; + let key = [7u8; 32]; + let d = time(ITERS, || { + let mut seal = AeadSession::new(key); + let mut open = AeadSession::new(key); + let ct = seal.seal(&plaintext, aad); + let pt = open.open(&ct, aad).expect("open"); + debug_assert_eq!(pt.len(), size); + }); + row(label, d); + } + + println!("\n(timings are wall-clock averages on this host; not a substitute for criterion)"); + Ok(()) +} + +/// Time `f` over `iters` iterations and return the total elapsed duration. +fn time(iters: u32, mut f: impl FnMut()) -> Duration { + // One warm-up iteration to avoid counting first-call setup. + f(); + let start = Instant::now(); + for _ in 0..iters { + f(); + } + start.elapsed() +} + +/// Print one results row given the total time for [`ITERS`] iterations. +fn row(label: &str, total: Duration) { + let avg = total / ITERS; + let per_sec = if avg.as_secs_f64() > 0.0 { + 1.0 / avg.as_secs_f64() + } else { + f64::INFINITY + }; + println!("{label:<32} {:>12} {per_sec:>14.0}", format!("{avg:?}")); +} diff --git a/crates/aura-cli/src/client.rs b/crates/aura-cli/src/client.rs new file mode 100644 index 0000000..70063f0 --- /dev/null +++ b/crates/aura-cli/src/client.rs @@ -0,0 +1,139 @@ +//! `aura client`: connect to an Aura server and route host traffic through the tunnel. +//! +//! ## v1 data path +//! 1. Load `client.toml`, read the `[pki]` PEM files, build [`aura_proto::ClientConfig`]. +//! 2. Build a shared [`RouteTable`] from `[tunnel.split]` (default action + direct/vpn CIDR rules); +//! record domain rules for resolution. +//! 3. [`AuraClient::connect`] to `[client] server_addr`, presenting `[client] sni` as the outer +//! (mimicry) hostname. +//! 4. Resolve any split-tunnel domain rules via [`AuraDns`] into host routes (best-effort). +//! 5. Create the local TUN ([`AuraTun::create`]) on `[tunnel] local_ip/prefix` and run +//! [`AuraRouter`] to bridge the TUN and the connection. +//! 6. Start the admin IPC listener over the same shared [`RouteTable`] + [`Stats`]. +//! +//! ## Privilege / scope notes (NOT auto-tested) +//! * Creating the TUN ([`AuraTun::create`]) needs **root**; the live router path runs only in a +//! privileged execution, so it is not covered by unit tests (the loopback test covers the +//! connection path short of the TUN). +//! * Domain resolution performs real DNS queries and so is not unit-tested either. + +use std::path::Path; +use std::sync::Arc; + +use anyhow::Context; +use aura_transport::AuraClient; +use aura_tunnel::{AuraDns, AuraRouter, AuraTun}; +use tokio::sync::RwLock; + +use crate::admin::{self, AdminState, Stats}; +use crate::config::ClientConfigFile; + +/// Entry point for `aura client --config ` (and optional `--admin-socket`). +pub async fn run(config_path: &Path, admin_socket: &str) -> anyhow::Result<()> { + let cfg = ClientConfigFile::load(config_path)?; + let server_addr = cfg.server_socket_addr()?; + let local_ip = cfg.local_ip()?; + let proto_cfg = cfg.to_proto()?; + let (table, domains) = cfg.build_route_table()?; + + tracing::info!( + name = %cfg.client.name, + %server_addr, + sni = %cfg.client.sni, + %local_ip, + dns = ?cfg.tunnel.dns, + mimicry_padding = cfg.mimicry.padding, + "starting Aura client" + ); + + // Snapshot the configured CIDR rules for the admin mirror before moving the table behind the + // lock. (We rebuild the parsed CIDRs from the config rather than reaching into the table.) + let cidr_mirror = collect_cidr_rules(&cfg); + + let routes = Arc::new(RwLock::new(table)); + let stats = Arc::new(Stats::new()); + + // Connect (outer QUIC + inner Aura mutual-auth handshake). + let conn = AuraClient::connect(server_addr, &cfg.client.sni, proto_cfg) + .await + .context("connecting to Aura server")?; + let peer = conn.peer_id().map(str::to_owned); + stats.set_peer_id(peer.clone()); + tracing::info!(peer = ?peer, "connected and authenticated to server"); + + // Resolve split-tunnel domain rules into host routes (best-effort; failures are logged). + if !domains.is_empty() { + match AuraDns::new(Arc::clone(&routes)).await { + Ok(mut dns) => { + for (domain, action) in &domains { + match dns.resolve_and_register(domain, *action).await { + Ok(ips) => { + tracing::info!( + domain, + count = ips.len(), + ?action, + "resolved domain rule" + ) + } + Err(e) => { + tracing::warn!(domain, error = %e, "failed to resolve domain rule") + } + } + } + } + Err(e) => tracing::warn!(error = %e, "could not start resolver for domain rules"), + } + } + + // Admin IPC over the shared table/stats. + let admin_state = AdminState::new( + Arc::clone(&routes), + Arc::clone(&stats), + cidr_mirror, + domains.clone(), + ); + let admin_path = admin_socket.to_string(); + tokio::spawn(async move { + if let Err(e) = admin::serve(&admin_path, admin_state).await { + tracing::error!(error = %e, "admin IPC listener exited"); + } + }); + + // Create the TUN and run the router (needs root). + let tun = AuraTun::create( + &cfg.tunnel.tun_name, + local_ip, + cfg.tunnel.prefix, + cfg.tunnel.mtu, + ) + .await + .context("creating TUN device (needs root)")?; + tracing::info!(tun = %cfg.tunnel.tun_name, "TUN device up; routing traffic"); + + let router = AuraRouter::new(tun, routes, conn.into_dyn()); + router.run().await.context("router run loop")?; + Ok(()) +} + +/// Re-parse the `[tunnel.split]` CIDR rules into `(IpNetwork, RouteAction)` pairs for the admin +/// mirror. Invalid CIDRs were already rejected by [`ClientConfigFile::build_route_table`], so this +/// silently skips any that somehow fail to re-parse. +fn collect_cidr_rules( + cfg: &ClientConfigFile, +) -> Vec<(ipnetwork::IpNetwork, aura_tunnel::RouteAction)> { + use aura_tunnel::RouteAction; + let mut out = Vec::new(); + for (rules, action) in [ + (&cfg.tunnel.split.direct, RouteAction::Direct), + (&cfg.tunnel.split.vpn, RouteAction::Vpn), + ] { + for rule in rules { + if let Some(cidr) = &rule.cidr { + if let Ok(net) = cidr.parse() { + out.push((net, action)); + } + } + } + } + out +} diff --git a/crates/aura-cli/src/config.rs b/crates/aura-cli/src/config.rs new file mode 100644 index 0000000..81b6e73 --- /dev/null +++ b/crates/aura-cli/src/config.rs @@ -0,0 +1,576 @@ +//! TOML configuration for the Aura server and client (project §9). +//! +//! This module defines the serde structs that mirror the `server.toml` / `client.toml` schemas, +//! plus the glue that turns them into the runtime types the libraries expect: +//! +//! * [`expand_tilde`] expands a leading `~` in any path field to the user's home directory (read +//! from `$HOME`, falling back to `$USERPROFILE` on Windows). +//! * [`ServerConfigFile::load`] / [`ClientConfigFile::load`] read and parse a config file. +//! * [`ServerConfigFile::to_proto`] / [`ClientConfigFile::to_proto`] read the PEM files named in +//! the `[pki]` table and build [`aura_proto::ServerConfig`] / [`aura_proto::ClientConfig`]. +//! * [`ClientConfigFile::build_route_table`] turns `[tunnel.split]` into a [`RouteTable`] (CIDR +//! rules applied directly; domain rules recorded for later DNS resolution). + +use std::fs; +use std::net::SocketAddr; +use std::path::{Path, PathBuf}; + +use anyhow::{anyhow, Context}; +use aura_tunnel::{RouteAction, RouteTable}; +use ipnetwork::IpNetwork; +use serde::Deserialize; + +// ---- server.toml ---------------------------------------------------------------------------- + +/// Top-level `server.toml` document. +#[derive(Debug, Clone, Deserialize)] +pub struct ServerConfigFile { + /// `[server]` section: identity and listen socket. + pub server: ServerSection, + /// `[pki]` section: CA + leaf cert/key file paths. + pub pki: PkiSection, + /// `[tunnel]` section: address pool, MTU, DNS. + pub tunnel: ServerTunnelSection, + /// `[mimicry]` section: outer-TLS camouflage knobs. + #[serde(default)] + pub mimicry: ServerMimicrySection, +} + +/// `[server]` section. +#[derive(Debug, Clone, Deserialize)] +pub struct ServerSection { + /// Human-readable server name (also the inner-handshake server identity). + pub name: String, + /// UDP socket to listen on, e.g. `"0.0.0.0:443"`. + #[serde(default = "default_listen")] + pub listen: String, + /// Number of accept workers (advisory in v1). + #[serde(default = "default_workers")] + pub workers: usize, +} + +/// `[tunnel]` section of `server.toml`. +#[derive(Debug, Clone, Deserialize)] +pub struct ServerTunnelSection { + /// CIDR of the address pool handed to clients (single shared TUN in v1). + pub pool_cidr: String, + /// MTU of the server-side TUN. + #[serde(default = "default_mtu")] + pub mtu: u16, + /// DNS server advertised to clients (informational in v1). + #[serde(default)] + pub dns: Option, +} + +/// `[mimicry]` section of `server.toml`. +#[derive(Debug, Clone, Default, Deserialize)] +pub struct ServerMimicrySection { + /// SNI the server expects / presents for outer-TLS camouflage. + #[serde(default)] + pub sni: Option, + /// Whether to enable traffic padding. + #[serde(default)] + pub padding: bool, +} + +// ---- client.toml ---------------------------------------------------------------------------- + +/// Top-level `client.toml` document. +#[derive(Debug, Clone, Deserialize)] +pub struct ClientConfigFile { + /// `[client]` section: identity and server address. + pub client: ClientSection, + /// `[pki]` section: CA + leaf cert/key file paths. + pub pki: PkiSection, + /// `[tunnel]` section: TUN device, addressing, split-tunnel rules. + pub tunnel: ClientTunnelSection, + /// `[mimicry]` section: outer-TLS camouflage knobs. + #[serde(default)] + pub mimicry: ClientMimicrySection, +} + +/// `[client]` section. +#[derive(Debug, Clone, Deserialize)] +pub struct ClientSection { + /// Human-readable client name / id. + pub name: String, + /// Server UDP socket address, e.g. `"203.0.113.10:443"`. + pub server_addr: String, + /// Outer-TLS SNI (camouflage hostname) presented to the server. + pub sni: String, +} + +/// `[tunnel]` section of `client.toml`. +#[derive(Debug, Clone, Deserialize)] +pub struct ClientTunnelSection { + /// Requested TUN interface name (advisory on macOS, where the kernel assigns `utunN`). + #[serde(default = "default_tun_name")] + pub tun_name: String, + /// Local IP address assigned to the TUN device. + pub local_ip: String, + /// Prefix length for the TUN address. + #[serde(default = "default_prefix")] + pub prefix: u8, + /// MTU of the TUN device. + #[serde(default = "default_mtu")] + pub mtu: u16, + /// DNS server used by the tunnel resolver (informational; the system resolver is used in v1). + #[serde(default)] + pub dns: Option, + /// `[tunnel.split]` split-tunnel configuration. + #[serde(default)] + pub split: SplitSection, +} + +/// `[tunnel.split]` section: default action plus direct/vpn override rules. +#[derive(Debug, Clone, Deserialize)] +pub struct SplitSection { + /// Default action when no rule matches: `"VPN"` or `"DIRECT"` (case-insensitive). + #[serde(default = "default_split_default")] + pub default: String, + /// Rules forcing matching destinations to egress directly. + #[serde(default)] + pub direct: Vec, + /// Rules forcing matching destinations through the VPN. + #[serde(default)] + pub vpn: Vec, +} + +impl Default for SplitSection { + fn default() -> Self { + Self { + default: default_split_default(), + direct: Vec::new(), + vpn: Vec::new(), + } + } +} + +/// A single split-tunnel rule: exactly one of `cidr` or `domain`. +#[derive(Debug, Clone, Deserialize)] +pub struct SplitRule { + /// A CIDR, e.g. `"192.168.0.0/16"`. Mutually exclusive with `domain`. + #[serde(default)] + pub cidr: Option, + /// A domain, e.g. `"example.com"`. Mutually exclusive with `cidr`. + #[serde(default)] + pub domain: Option, +} + +/// `[mimicry]` section of `client.toml`. +#[derive(Debug, Clone, Default, Deserialize)] +pub struct ClientMimicrySection { + /// Whether to enable traffic padding. + #[serde(default)] + pub padding: bool, +} + +// ---- shared sections ------------------------------------------------------------------------ + +/// `[pki]` section shared by both config files: paths to CA cert + this peer's leaf cert/key. +#[derive(Debug, Clone, Deserialize)] +pub struct PkiSection { + /// Path to the CA certificate PEM (trust anchor). + pub ca_cert: String, + /// Path to this peer's leaf certificate PEM. + pub cert: String, + /// Path to this peer's PKCS#8 private key PEM. + pub key: String, +} + +// ---- defaults ------------------------------------------------------------------------------- + +fn default_listen() -> String { + "0.0.0.0:443".to_string() +} +fn default_workers() -> usize { + 1 +} +fn default_mtu() -> u16 { + 1420 +} +fn default_prefix() -> u8 { + 24 +} +fn default_tun_name() -> String { + "aura0".to_string() +} +fn default_split_default() -> String { + "VPN".to_string() +} + +// ---- ~ expansion ---------------------------------------------------------------------------- + +/// Expand a leading `~` (or `~/...`) in a path to the user's home directory. +/// +/// The home directory is read from `$HOME` (Unix) or `$USERPROFILE` (Windows). A path that does +/// not begin with `~` is returned unchanged. A bare `~` expands to the home directory itself. +pub fn expand_tilde(path: &str) -> PathBuf { + if path == "~" { + if let Some(home) = home_dir() { + return home; + } + return PathBuf::from(path); + } + if let Some(rest) = path.strip_prefix("~/") { + if let Some(home) = home_dir() { + return home.join(rest); + } + } + PathBuf::from(path) +} + +/// Best-effort home directory from the environment (no extra crate dependency). +fn home_dir() -> Option { + std::env::var_os("HOME") + .or_else(|| std::env::var_os("USERPROFILE")) + .map(PathBuf::from) + .filter(|p| !p.as_os_str().is_empty()) +} + +/// Read a PEM (or any text) file whose path may begin with `~`. +fn read_pem(path: &str) -> anyhow::Result { + let resolved = expand_tilde(path); + fs::read_to_string(&resolved) + .with_context(|| format!("reading PEM file {}", resolved.display())) +} + +// ---- loading + conversion ------------------------------------------------------------------- + +impl ServerConfigFile { + /// Parse a `server.toml` document from a string. + pub fn parse(text: &str) -> anyhow::Result { + toml::from_str(text).context("parsing server.toml") + } + + /// Load and parse a `server.toml` file (the path itself may begin with `~`). + pub fn load(path: &Path) -> anyhow::Result { + let text = fs::read_to_string(path) + .with_context(|| format!("reading server config {}", path.display()))?; + Self::parse(&text) + } + + /// Parse the `[server] listen` address into a [`SocketAddr`]. + pub fn listen_addr(&self) -> anyhow::Result { + self.server + .listen + .parse() + .with_context(|| format!("invalid [server] listen '{}'", self.server.listen)) + } + + /// The server-side TUN address parsed from the `[tunnel] pool_cidr` (the network address). + pub fn pool_network(&self) -> anyhow::Result { + self.tunnel + .pool_cidr + .parse() + .with_context(|| format!("invalid [tunnel] pool_cidr '{}'", self.tunnel.pool_cidr)) + } + + /// Read the `[pki]` PEM files and build an [`aura_proto::ServerConfig`]. + pub fn to_proto(&self) -> anyhow::Result { + Ok(aura_proto::ServerConfig { + ca_cert_pem: read_pem(&self.pki.ca_cert)?, + server_cert_pem: read_pem(&self.pki.cert)?, + server_key_pem: read_pem(&self.pki.key)?, + }) + } +} + +impl ClientConfigFile { + /// Parse a `client.toml` document from a string. + pub fn parse(text: &str) -> anyhow::Result { + toml::from_str(text).context("parsing client.toml") + } + + /// Load and parse a `client.toml` file (the path itself may begin with `~`). + pub fn load(path: &Path) -> anyhow::Result { + let text = fs::read_to_string(path) + .with_context(|| format!("reading client config {}", path.display()))?; + Self::parse(&text) + } + + /// Parse the `[client] server_addr` into a [`SocketAddr`]. + pub fn server_socket_addr(&self) -> anyhow::Result { + self.client + .server_addr + .parse() + .with_context(|| format!("invalid [client] server_addr '{}'", self.client.server_addr)) + } + + /// Parse the `[tunnel] local_ip` into an [`std::net::IpAddr`]. + pub fn local_ip(&self) -> anyhow::Result { + self.tunnel + .local_ip + .parse() + .with_context(|| format!("invalid [tunnel] local_ip '{}'", self.tunnel.local_ip)) + } + + /// Read the `[pki]` PEM files and build an [`aura_proto::ClientConfig`]. + /// + /// The inner-handshake `server_name` is taken from `[client] sni` so the SAN verified against + /// the server certificate matches the camouflage hostname; deployments that separate the two + /// can extend this later. + pub fn to_proto(&self) -> anyhow::Result { + Ok(aura_proto::ClientConfig { + ca_cert_pem: read_pem(&self.pki.ca_cert)?, + client_cert_pem: read_pem(&self.pki.cert)?, + client_key_pem: read_pem(&self.pki.key)?, + server_name: self.client.sni.clone(), + }) + } + + /// Build a [`RouteTable`] from `[tunnel.split]`. + /// + /// CIDR rules are applied directly. Domain rules are recorded via [`RouteTable::add_domain`] + /// (they only become matchable once [`aura_tunnel::AuraDns::resolve_and_register`] resolves + /// them into host routes). Returns the table plus the list of `(domain, action)` pairs the + /// caller should resolve. + pub fn build_route_table(&self) -> anyhow::Result<(RouteTable, Vec<(String, RouteAction)>)> { + let default = parse_action(&self.tunnel.split.default)?; + let mut table = RouteTable::new(default); + let mut domains = Vec::new(); + + for (rules, action) in [ + (&self.tunnel.split.direct, RouteAction::Direct), + (&self.tunnel.split.vpn, RouteAction::Vpn), + ] { + for rule in rules { + match (&rule.cidr, &rule.domain) { + (Some(cidr), None) => { + let net: IpNetwork = cidr + .parse() + .with_context(|| format!("invalid split-tunnel cidr '{cidr}'"))?; + table.add_cidr(net, action); + } + (None, Some(domain)) => { + table.add_domain(domain, action); + domains.push((domain.clone(), action)); + } + (Some(_), Some(_)) => { + return Err(anyhow!( + "split-tunnel rule has both 'cidr' and 'domain'; specify exactly one" + )); + } + (None, None) => { + return Err(anyhow!( + "split-tunnel rule has neither 'cidr' nor 'domain'; specify exactly one" + )); + } + } + } + } + Ok((table, domains)) + } +} + +/// Parse a `"VPN"` / `"DIRECT"` action string (case-insensitive) into a [`RouteAction`]. +pub fn parse_action(s: &str) -> anyhow::Result { + match s.trim().to_ascii_lowercase().as_str() { + "vpn" => Ok(RouteAction::Vpn), + "direct" => Ok(RouteAction::Direct), + other => Err(anyhow!( + "invalid route action '{other}' (expected 'vpn' or 'direct')" + )), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::net::IpAddr; + + const SERVER_TOML: &str = r#" +[server] +name = "edge-1" +listen = "0.0.0.0:8443" +workers = 4 + +[pki] +ca_cert = "/etc/aura/ca.crt" +cert = "/etc/aura/server.crt" +key = "/etc/aura/server.key" + +[tunnel] +pool_cidr = "10.7.0.0/24" +mtu = 1380 +dns = "10.7.0.1" + +[mimicry] +sni = "cdn.example.com" +padding = true +"#; + + const CLIENT_TOML: &str = r#" +[client] +name = "laptop" +server_addr = "203.0.113.10:8443" +sni = "cdn.example.com" + +[pki] +ca_cert = "~/.aura/ca.crt" +cert = "~/.aura/client.crt" +key = "~/.aura/client.key" + +[tunnel] +tun_name = "aura0" +local_ip = "10.7.0.2" +prefix = 24 +mtu = 1380 + +[tunnel.split] +default = "VPN" + +[[tunnel.split.direct]] +cidr = "192.168.0.0/16" + +[[tunnel.split.direct]] +cidr = "10.0.0.0/8" + +[[tunnel.split.direct]] +domain = "intranet.example.com" + +[[tunnel.split.vpn]] +cidr = "10.7.0.0/24" + +[mimicry] +padding = false +"#; + + #[test] + fn parses_server_toml() { + let cfg = ServerConfigFile::parse(SERVER_TOML).expect("parse server.toml"); + assert_eq!(cfg.server.name, "edge-1"); + assert_eq!(cfg.server.workers, 4); + assert_eq!(cfg.listen_addr().unwrap().port(), 8443); + assert_eq!(cfg.tunnel.mtu, 1380); + assert_eq!(cfg.tunnel.pool_cidr, "10.7.0.0/24"); + assert!(cfg.mimicry.padding); + assert_eq!(cfg.mimicry.sni.as_deref(), Some("cdn.example.com")); + assert_eq!(cfg.pki.ca_cert, "/etc/aura/ca.crt"); + } + + #[test] + fn server_toml_defaults() { + let minimal = r#" +[server] +name = "edge" +[pki] +ca_cert = "a" +cert = "b" +key = "c" +[tunnel] +pool_cidr = "10.7.0.0/24" +"#; + let cfg = ServerConfigFile::parse(minimal).expect("parse minimal server.toml"); + assert_eq!(cfg.server.listen, "0.0.0.0:443"); + assert_eq!(cfg.server.workers, 1); + assert_eq!(cfg.tunnel.mtu, 1420); + assert!(!cfg.mimicry.padding); + } + + #[test] + fn parses_client_toml() { + let cfg = ClientConfigFile::parse(CLIENT_TOML).expect("parse client.toml"); + assert_eq!(cfg.client.name, "laptop"); + assert_eq!(cfg.server_socket_addr().unwrap().port(), 8443); + assert_eq!(cfg.client.sni, "cdn.example.com"); + assert_eq!( + cfg.local_ip().unwrap(), + "10.7.0.2".parse::().unwrap() + ); + assert_eq!(cfg.tunnel.prefix, 24); + assert_eq!(cfg.tunnel.split.direct.len(), 3); + assert_eq!(cfg.tunnel.split.vpn.len(), 1); + } + + #[test] + fn builds_route_table_from_split() { + let cfg = ClientConfigFile::parse(CLIENT_TOML).expect("parse client.toml"); + let (table, domains) = cfg.build_route_table().expect("build route table"); + + // Default is VPN. + assert_eq!(table.default_action(), RouteAction::Vpn); + // 192.168.x and 10.x are Direct... + assert_eq!( + table.classify("192.168.1.1".parse().unwrap()), + RouteAction::Direct + ); + assert_eq!( + table.classify("10.1.2.3".parse().unwrap()), + RouteAction::Direct + ); + // ...but the more-specific 10.7.0.0/24 VPN rule wins inside 10.0.0.0/8. + assert_eq!( + table.classify("10.7.0.9".parse().unwrap()), + RouteAction::Vpn + ); + // An address matching no rule falls back to the default (VPN). + assert_eq!(table.classify("8.8.8.8".parse().unwrap()), RouteAction::Vpn); + // The domain rule was recorded for later resolution. + assert_eq!(domains.len(), 1); + assert_eq!(domains[0].0, "intranet.example.com"); + assert_eq!(domains[0].1, RouteAction::Direct); + } + + #[test] + fn expand_tilde_uses_home() { + std::env::set_var("HOME", "/home/tester"); + assert_eq!( + expand_tilde("~/.aura/ca.crt"), + PathBuf::from("/home/tester/.aura/ca.crt") + ); + assert_eq!(expand_tilde("~"), PathBuf::from("/home/tester")); + assert_eq!(expand_tilde("/abs/path"), PathBuf::from("/abs/path")); + assert_eq!(expand_tilde("rel/path"), PathBuf::from("rel/path")); + } + + #[test] + fn shipped_example_configs_parse() { + // The example files live at /config/. CARGO_MANIFEST_DIR points at the crate + // (crates/aura-cli), so go up two levels. + let root = Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .and_then(Path::parent) + .expect("workspace root"); + let server = std::fs::read_to_string(root.join("config/server.toml.example")) + .expect("read server.toml.example"); + let client = std::fs::read_to_string(root.join("config/client.toml.example")) + .expect("read client.toml.example"); + + let s = ServerConfigFile::parse(&server).expect("server.toml.example parses"); + assert!(s.listen_addr().is_ok()); + assert!(s.pool_network().is_ok()); + + let c = ClientConfigFile::parse(&client).expect("client.toml.example parses"); + assert!(c.server_socket_addr().is_ok()); + assert!(c.local_ip().is_ok()); + let (table, domains) = c + .build_route_table() + .expect("example split builds a route table"); + assert_eq!(table.default_action(), RouteAction::Vpn); + assert!(!domains.is_empty()); + } + + #[test] + fn rejects_rule_with_both_cidr_and_domain() { + let bad = r#" +[client] +name = "x" +server_addr = "1.2.3.4:443" +sni = "a" +[pki] +ca_cert = "a" +cert = "b" +key = "c" +[tunnel] +local_ip = "10.7.0.2" +[tunnel.split] +default = "VPN" +[[tunnel.split.direct]] +cidr = "10.0.0.0/8" +domain = "x.example.com" +"#; + let cfg = ClientConfigFile::parse(bad).expect("parse"); + assert!(cfg.build_route_table().is_err()); + } +} diff --git a/crates/aura-cli/src/lib.rs b/crates/aura-cli/src/lib.rs new file mode 100644 index 0000000..2580005 --- /dev/null +++ b/crates/aura-cli/src/lib.rs @@ -0,0 +1,20 @@ +//! `aura-cli` library surface. +//! +//! The `aura` binary ([`main`](../main/index.html)) is a thin clap parser + dispatcher over the +//! modules exposed here. They are public so the crate's integration tests (in `tests/`, which +//! compile as separate crates) can drive the PKI handlers, the config parser, and the admin IPC +//! protocol directly — without spawning the binary or needing root. +//! +//! Module map (project §10): +//! * [`config`] — serde TOML structs, `~` expansion, PEM loading, `[tunnel.split]` -> `RouteTable`. +//! * [`pki`] — `aura pki` handlers (init / issue-server / issue-client / revoke / list). +//! * [`admin`] — the JSON-over-Unix-socket admin protocol (route management + status). +//! * [`server`] / [`client`] — the `aura server` / `aura client` data paths. +//! * [`bench`] — the `aura bench-crypto` micro-benchmarks. + +pub mod admin; +pub mod bench; +pub mod client; +pub mod config; +pub mod pki; +pub mod server; diff --git a/crates/aura-cli/src/main.rs b/crates/aura-cli/src/main.rs index 181075e..e0015af 100644 --- a/crates/aura-cli/src/main.rs +++ b/crates/aura-cli/src/main.rs @@ -1,5 +1,326 @@ -//! aura — client/server binary and PKI/admin CLI (skeleton; implemented in Wave 4). +//! `aura` — the Aura hybrid post-quantum VPN command-line binary. +//! +//! Wires together the five Aura library crates (`aura-crypto`, `aura-pki`, `aura-proto`, +//! `aura-transport`, `aura-tunnel`) behind a clap command tree (project §10): +//! +//! ```text +//! aura pki init|issue-server|issue-client|revoke|list # CA + certificate management +//! aura server --config # run the VPN server (needs root for TUN) +//! aura client --config # run the VPN client (needs root for TUN) +//! aura route add|list|remove # manage split-tunnel rules via admin socket +//! aura status # query the admin socket for tunnel stats +//! aura bench-crypto # quick KEM/AEAD/handshake timings +//! ``` +//! +//! Subcommand bodies live in sibling modules ([`pki`], [`server`], [`client`], [`admin`], +//! [`bench`], [`config`]); `main` is the parser + dispatcher + tracing setup. -fn main() { - println!("aura: skeleton binary (implemented in Wave 4)"); +use std::path::PathBuf; + +use aura_cli::{admin, bench, client, pki, server}; +use clap::{Args, Parser, Subcommand}; +use tracing_subscriber::EnvFilter; + +use crate::admin::{Request, DEFAULT_SOCKET}; + +/// Top-level `aura` CLI. +#[derive(Debug, Parser)] +#[command(name = "aura", version, about = "Aura — hybrid post-quantum VPN", long_about = None)] +struct Cli { + #[command(subcommand)] + command: Command, +} + +/// Top-level subcommands. +#[derive(Debug, Subcommand)] +enum Command { + /// PKI / certificate management (CA, server/client certs, revocation). + #[command(subcommand)] + Pki(PkiCommand), + + /// Run the Aura VPN server (binds QUIC, accepts clients; needs root for the TUN device). + Server(ServerArgs), + + /// Run the Aura VPN client (connects to a server; needs root for the TUN device). + Client(ClientArgs), + + /// Manage split-tunnel routes on a running client/server via the admin socket. + #[command(subcommand)] + Route(RouteCommand), + + /// Query a running client/server for tunnel status via the admin socket. + Status(AdminConnArgs), + + /// Quick crypto micro-benchmarks (KEM keygen/encaps/decaps, full handshake, AEAD). + BenchCrypto, +} + +/// `aura pki ...` subcommands. +#[derive(Debug, Subcommand)] +enum PkiCommand { + /// Generate a new CA into as ca.crt / ca.key. + Init { + /// Common Name for the CA certificate. + #[arg(long)] + ca_name: String, + /// Output directory for ca.crt / ca.key. + #[arg(long)] + out: PathBuf, + }, + /// Issue a server certificate (server.crt / server.key) for a DNS name. + IssueServer { + /// DNS name placed in the certificate SAN. + #[arg(long)] + domain: String, + /// Output directory for server.crt / server.key. + #[arg(long)] + out: PathBuf, + /// Directory holding the CA (ca.crt / ca.key); defaults to --out. + #[arg(long)] + ca: Option, + }, + /// Issue a client certificate (client.crt / client.key) with CN = . + IssueClient { + /// Client id placed in the certificate Common Name. + #[arg(long)] + id: String, + /// Output directory for client.crt / client.key. + #[arg(long)] + out: PathBuf, + /// Directory holding the CA (ca.crt / ca.key); defaults to --out. + #[arg(long)] + ca: Option, + }, + /// Add an identifier (client id or serial) to the revocation list. + Revoke { + /// Identifier to revoke. + #[arg(long)] + id: String, + /// CRL file path (defaults to ./revoked.crl). + #[arg(long)] + crl: Option, + }, + /// List revoked identifiers in the CRL file. + List { + /// CRL file path (defaults to ./revoked.crl). + #[arg(long)] + crl: Option, + }, +} + +/// Arguments for `aura server`. +#[derive(Debug, Args)] +struct ServerArgs { + /// Path to server.toml. + #[arg(long)] + config: PathBuf, + /// Admin socket path to host. + #[arg(long, default_value = DEFAULT_SOCKET)] + admin_socket: String, +} + +/// Arguments for `aura client`. +#[derive(Debug, Args)] +struct ClientArgs { + /// Path to client.toml. + #[arg(long)] + config: PathBuf, + /// Admin socket path to host. + #[arg(long, default_value = DEFAULT_SOCKET)] + admin_socket: String, +} + +/// Shared connection args for admin subcommands. +#[derive(Debug, Args)] +struct AdminConnArgs { + /// Admin socket path to connect to. + #[arg(long, default_value = DEFAULT_SOCKET)] + admin_socket: String, +} + +/// `aura route ...` subcommands. +#[derive(Debug, Subcommand)] +enum RouteCommand { + /// Add a route rule (exactly one of --cidr / --domain) with an action. + Add { + /// CIDR to route, e.g. 8.8.8.0/24 (mutually exclusive with --domain). + #[arg(long, conflicts_with = "domain", required_unless_present = "domain")] + cidr: Option, + /// Domain to route, e.g. example.com (mutually exclusive with --cidr). + #[arg(long)] + domain: Option, + /// Action: vpn or direct. + #[arg(long)] + action: String, + /// Admin socket path. + #[arg(long, default_value = DEFAULT_SOCKET)] + admin_socket: String, + }, + /// List the current route rules and default action. + List(AdminConnArgs), + /// Remove a CIDR route rule. + Remove { + /// CIDR to remove. + #[arg(long)] + cidr: String, + /// Admin socket path. + #[arg(long, default_value = DEFAULT_SOCKET)] + admin_socket: String, + }, +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + init_tracing(); + let cli = Cli::parse(); + match cli.command { + Command::Pki(cmd) => run_pki(cmd), + Command::Server(args) => server::run(&args.config, &args.admin_socket).await, + Command::Client(args) => client::run(&args.config, &args.admin_socket).await, + Command::Route(cmd) => run_route(cmd).await, + Command::Status(args) => run_status(&args.admin_socket).await, + Command::BenchCrypto => bench::run(), + } +} + +/// 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(); +} + +/// Default CRL path when `--crl` is omitted. +fn default_crl(crl: Option) -> PathBuf { + crl.unwrap_or_else(|| PathBuf::from(pki::CRL_FILE)) +} + +/// Dispatch `aura pki ...`. +fn run_pki(cmd: PkiCommand) -> anyhow::Result<()> { + match cmd { + PkiCommand::Init { ca_name, out } => { + let (cert, key) = pki::init(&ca_name, &out)?; + println!( + "CA generated:\n cert: {}\n key: {}", + cert.display(), + key.display() + ); + } + PkiCommand::IssueServer { domain, out, ca } => { + let ca_dir = ca.unwrap_or_else(|| out.clone()); + let (cert, key) = pki::issue_server(&domain, &out, &ca_dir)?; + println!( + "server certificate issued for '{domain}':\n cert: {}\n key: {}", + cert.display(), + key.display() + ); + } + PkiCommand::IssueClient { id, out, ca } => { + let ca_dir = ca.unwrap_or_else(|| out.clone()); + let (cert, key) = pki::issue_client(&id, &out, &ca_dir)?; + println!( + "client certificate issued for '{id}':\n cert: {}\n key: {}", + cert.display(), + key.display() + ); + } + PkiCommand::Revoke { id, crl } => { + let path = default_crl(crl); + pki::revoke(&id, &path)?; + println!("revoked '{id}' (CRL: {})", path.display()); + } + PkiCommand::List { crl } => { + let path = default_crl(crl); + let ids = pki::list(&path)?; + if ids.is_empty() { + println!("no revoked identifiers (CRL: {})", path.display()); + } else { + println!("revoked identifiers (CRL: {}):", path.display()); + for id in ids { + println!(" {id}"); + } + } + } + } + Ok(()) +} + +/// Dispatch `aura route ...` over the admin socket. +async fn run_route(cmd: RouteCommand) -> anyhow::Result<()> { + match cmd { + RouteCommand::Add { + cidr, + domain, + action, + admin_socket, + } => { + let req = Request::RouteAdd { + cidr, + domain, + action, + }; + print_response(admin::request(&admin_socket, &req).await?); + } + RouteCommand::List(args) => { + let resp = admin::request(&args.admin_socket, &Request::RouteList).await?; + print_route_list(resp); + } + RouteCommand::Remove { cidr, admin_socket } => { + let req = Request::RouteRemove { cidr }; + print_response(admin::request(&admin_socket, &req).await?); + } + } + Ok(()) +} + +/// Dispatch `aura status` over the admin socket. +async fn run_status(admin_socket: &str) -> anyhow::Result<()> { + let resp = admin::request(admin_socket, &Request::Status).await?; + if !resp.ok { + anyhow::bail!("status failed: {}", resp.error.unwrap_or_default()); + } + println!("Aura tunnel status"); + println!( + " peer: {}", + resp.peer_id.as_deref().unwrap_or("(none)") + ); + println!(" default: {}", resp.default.as_deref().unwrap_or("?")); + println!(" rules: {}", resp.rules.unwrap_or(0)); + println!(" rx packets: {}", resp.rx_packets.unwrap_or(0)); + println!(" tx packets: {}", resp.tx_packets.unwrap_or(0)); + Ok(()) +} + +/// Print a generic admin response (ok / error, with optional `removed`). +fn print_response(resp: admin::Response) { + if resp.ok { + match resp.removed { + Some(true) => println!("ok (removed)"), + Some(false) => println!("ok (nothing to remove)"), + None => println!("ok"), + } + } else { + eprintln!("error: {}", resp.error.unwrap_or_default()); + } +} + +/// Pretty-print a `route_list` response. +fn print_route_list(resp: admin::Response) { + if !resp.ok { + eprintln!("error: {}", resp.error.unwrap_or_default()); + return; + } + println!("default: {}", resp.default.as_deref().unwrap_or("?")); + let cidrs = resp.cidrs.unwrap_or_default(); + let domains = resp.domains.unwrap_or_default(); + if cidrs.is_empty() && domains.is_empty() { + println!("(no rules)"); + return; + } + for c in cidrs { + println!(" cidr {:<20} {}", c.cidr, c.action); + } + for d in domains { + println!(" domain {:<20} {}", d.domain, d.action); + } } diff --git a/crates/aura-cli/src/pki.rs b/crates/aura-cli/src/pki.rs new file mode 100644 index 0000000..7124b17 --- /dev/null +++ b/crates/aura-cli/src/pki.rs @@ -0,0 +1,112 @@ +//! `aura pki` subcommand handlers (project §10): CA init, server/client issuance, revocation list. +//! +//! Each handler is a thin, side-effecting wrapper over [`aura_pki`] that writes PEM/CRL files into +//! a directory. They are split out from the clap layer (see [`crate::cli`]) and take plain values so +//! the test suite can drive a full init -> issue -> verify roundtrip without spawning the binary. + +use std::path::{Path, PathBuf}; + +use anyhow::Context; +use aura_pki::{AuraCa, CrlStore}; + +use crate::config::expand_tilde; + +/// File name of the CA certificate within a CA directory. +pub const CA_CERT: &str = "ca.crt"; +/// File name of the CA private key within a CA directory. +pub const CA_KEY: &str = "ca.key"; +/// Default CRL file name. +pub const CRL_FILE: &str = "revoked.crl"; + +/// `aura pki init`: generate a new CA into `out_dir` as `ca.crt` / `ca.key`. +/// +/// Creates `out_dir` (and parents) if needed. Returns the paths written. +pub fn init(ca_name: &str, out_dir: &Path) -> anyhow::Result<(PathBuf, PathBuf)> { + std::fs::create_dir_all(out_dir) + .with_context(|| format!("creating output dir {}", out_dir.display()))?; + let ca = AuraCa::generate(ca_name).context("generating CA")?; + let cert_path = out_dir.join(CA_CERT); + let key_path = out_dir.join(CA_KEY); + ca.save(&cert_path, &key_path)?; + Ok((cert_path, key_path)) +} + +/// `aura pki issue-server`: issue a server cert for `domain` into `out_dir`. +/// +/// Loads the CA from `ca_dir` (`ca.crt`/`ca.key`) and writes `server.crt` / `server.key`. +pub fn issue_server( + domain: &str, + out_dir: &Path, + ca_dir: &Path, +) -> anyhow::Result<(PathBuf, PathBuf)> { + let ca = load_ca(ca_dir)?; + let issued = ca.issue_server_cert(domain)?; + write_leaf(out_dir, "server", &issued.cert_pem, &issued.key_pem) +} + +/// `aura pki issue-client`: issue a client cert with `CN = id` into `out_dir`. +/// +/// Loads the CA from `ca_dir` and writes `client.crt` / `client.key`. +pub fn issue_client(id: &str, out_dir: &Path, ca_dir: &Path) -> anyhow::Result<(PathBuf, PathBuf)> { + let ca = load_ca(ca_dir)?; + let issued = ca.issue_client_cert(id)?; + write_leaf(out_dir, "client", &issued.cert_pem, &issued.key_pem) +} + +/// `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() { + CrlStore::load(crl_path)? + } else { + if let Some(parent) = crl_path.parent() { + if !parent.as_os_str().is_empty() { + std::fs::create_dir_all(parent) + .with_context(|| format!("creating CRL dir {}", parent.display()))?; + } + } + CrlStore::new() + }; + crl.revoke(id.to_string()); + crl.save(crl_path)?; + Ok(()) +} + +/// `aura pki list`: return the revoked identifiers in the CRL file (empty if the file is absent). +pub fn list(crl_path: &Path) -> anyhow::Result> { + if !crl_path.exists() { + return Ok(Vec::new()); + } + let crl = CrlStore::load(crl_path)?; + Ok(crl.iter().map(str::to_string).collect()) +} + +/// Load a CA from a directory, expanding a leading `~` in the directory path. +fn load_ca(ca_dir: &Path) -> anyhow::Result { + let dir = expand_tilde(&ca_dir.to_string_lossy()); + let cert = dir.join(CA_CERT); + let key = dir.join(CA_KEY); + AuraCa::load(&cert, &key).with_context(|| { + format!( + "loading CA from {} (expected {CA_CERT} + {CA_KEY})", + dir.display() + ) + }) +} + +/// Write a leaf cert/key pair as `.crt` / `.key` into `out_dir`. +fn write_leaf( + out_dir: &Path, + stem: &str, + cert_pem: &str, + key_pem: &str, +) -> anyhow::Result<(PathBuf, PathBuf)> { + std::fs::create_dir_all(out_dir) + .with_context(|| format!("creating output dir {}", out_dir.display()))?; + let cert_path = out_dir.join(format!("{stem}.crt")); + let key_path = out_dir.join(format!("{stem}.key")); + std::fs::write(&cert_path, cert_pem) + .with_context(|| format!("writing {}", cert_path.display()))?; + std::fs::write(&key_path, key_pem) + .with_context(|| format!("writing {}", key_path.display()))?; + Ok((cert_path, key_path)) +} diff --git a/crates/aura-cli/src/server.rs b/crates/aura-cli/src/server.rs new file mode 100644 index 0000000..4620f3a --- /dev/null +++ b/crates/aura-cli/src/server.rs @@ -0,0 +1,118 @@ +//! `aura server`: bind an [`AuraServer`], accept connections, and pump packets to a server-side TUN. +//! +//! ## v1 data path +//! 1. Load `server.toml`, read the `[pki]` PEM files, build [`aura_proto::ServerConfig`]. +//! 2. [`AuraServer::bind`] on `[server] listen` (the outer QUIC/mimicry cert reuses the Aura server +//! leaf PEM, as the transport docs suggest). +//! 3. Start the admin IPC listener over a shared (empty) [`RouteTable`] + [`Stats`]. +//! 4. Accept loop: for each authenticated [`AuraConnection`], create a single shared server-side TUN +//! on `[tunnel] pool_cidr` (the network's first host address) and run [`AuraRouter`] to bridge +//! the connection and the TUN. +//! +//! ## Privilege / scope notes (NOT auto-tested) +//! * Creating the TUN ([`AuraTun::create`]) needs **root** — the accept loop's data path is +//! therefore exercised only in a live, privileged run, not in unit tests. +//! * Binding a UDP socket on `[server] listen` (e.g. `:443`) typically needs privileges too. +//! * Multi-client IP-pool allocation / NAT is **out of v1 scope**: v1 bridges to one shared TUN, so +//! it is correct for a single active client. The accept loop still accepts many connections (each +//! gets its own router task), which is enough to demonstrate the end-to-end path. + +use std::path::Path; +use std::sync::Arc; + +use anyhow::Context; +use aura_transport::AuraServer; +use aura_tunnel::{AuraRouter, AuraTun, RouteAction, RouteTable}; +use ipnetwork::IpNetwork; +use tokio::sync::RwLock; + +use crate::admin::{self, AdminState, Stats}; +use crate::config::ServerConfigFile; + +/// Entry point for `aura server --config ` (and optional `--admin-socket`). +pub async fn run(config_path: &Path, admin_socket: &str) -> anyhow::Result<()> { + let cfg = ServerConfigFile::load(config_path)?; + let listen = cfg.listen_addr()?; + let proto_cfg = cfg.to_proto()?; + let pool = cfg.pool_network()?; + + tracing::info!( + name = %cfg.server.name, + %listen, + pool = %cfg.tunnel.pool_cidr, + workers = cfg.server.workers, + dns = ?cfg.tunnel.dns, + mimicry_sni = ?cfg.mimicry.sni, + mimicry_padding = cfg.mimicry.padding, + "starting Aura server" + ); + + // The outer (mimicry) QUIC cert reuses the Aura server leaf, matching the transport's guidance. + let server = AuraServer::bind( + listen, + &proto_cfg.server_cert_pem, + &proto_cfg.server_key_pem, + proto_cfg.clone(), + ) + .context("binding Aura server")?; + let bound = server.local_addr().context("reading bound address")?; + tracing::info!(%bound, "Aura server bound"); + + // Shared routing table (server-side classification is trivial in v1: everything via VPN) + + // stats, exposed over the admin socket. + let routes = Arc::new(RwLock::new(RouteTable::new(RouteAction::Vpn))); + let stats = Arc::new(Stats::new()); + let admin_state = AdminState::new( + Arc::clone(&routes), + Arc::clone(&stats), + std::iter::empty(), + std::iter::empty(), + ); + let admin_path = admin_socket.to_string(); + tokio::spawn(async move { + if let Err(e) = admin::serve(&admin_path, admin_state).await { + tracing::error!(error = %e, "admin IPC listener exited"); + } + }); + + // Accept loop. Each accepted connection gets a server-side TUN and a router task. + let mtu = cfg.tunnel.mtu; + loop { + let conn = server.accept().await.context("accepting connection")?; + let peer = conn.peer_id().map(str::to_owned); + stats.set_peer_id(peer.clone()); + tracing::info!(peer = ?peer, "accepted authenticated client"); + + let routes = Arc::clone(&routes); + let tun_ip = first_host(pool); + let prefix = pool.prefix(); + tokio::spawn(async move { + let tun = match AuraTun::create("aura-srv0", tun_ip, prefix, mtu).await { + Ok(t) => t, + Err(e) => { + tracing::error!(error = %e, "failed to create server TUN (needs root)"); + return; + } + }; + let router = AuraRouter::new(tun, routes, conn.into_dyn()); + if let Err(e) = router.run().await { + tracing::warn!(peer = ?peer, error = %e, "server router stopped"); + } + }); + } +} + +/// The first usable host address of a network (network address + 1 for IPv4; the network address +/// itself for the degenerate cases). Used as the server-side TUN's own address from `pool_cidr`. +fn first_host(net: IpNetwork) -> std::net::IpAddr { + match net { + IpNetwork::V4(v4) => { + let base = u32::from(v4.network()); + std::net::Ipv4Addr::from(base.wrapping_add(1)).into() + } + IpNetwork::V6(v6) => { + let base = u128::from(v6.network()); + std::net::Ipv6Addr::from(base.wrapping_add(1)).into() + } + } +} diff --git a/crates/aura-cli/tests/admin_socket.rs b/crates/aura-cli/tests/admin_socket.rs new file mode 100644 index 0000000..1bfe03f --- /dev/null +++ b/crates/aura-cli/tests/admin_socket.rs @@ -0,0 +1,147 @@ +//! Admin socket roundtrip: start the admin listener on a temp Unix socket over a shared +//! [`RouteTable`], connect a client, send `route_add` / `route_list` / `route_remove` / `status`, +//! and assert the table changed and the responses are correct. +//! +//! Runs without root or network (an `AF_UNIX` socket in the temp dir). + +#![cfg(unix)] + +use std::path::PathBuf; +use std::sync::Arc; + +use aura_cli::admin::{self, AdminState, Request, Stats}; +use aura_tunnel::{RouteAction, RouteTable}; +use tokio::sync::RwLock; + +/// A unique socket path for this test (Unix socket paths are length-limited; temp dir keeps it +/// short enough on macOS/Linux). +fn socket_path() -> PathBuf { + let mut p = std::env::temp_dir(); + p.push(format!( + "aura-admin-{}-{}.sock", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + p +} + +#[tokio::test] +async fn admin_socket_route_roundtrip() { + let routes = Arc::new(RwLock::new(RouteTable::new(RouteAction::Vpn))); + let stats = Arc::new(Stats::new()); + stats.set_peer_id(Some("client-test".to_string())); + let state = AdminState::new( + Arc::clone(&routes), + Arc::clone(&stats), + std::iter::empty(), + std::iter::empty(), + ); + + let path = socket_path(); + let path_str = path.to_string_lossy().to_string(); + + // Spawn the listener. + let serve_path = path_str.clone(); + let listener = tokio::spawn(async move { + let _ = admin::serve(&serve_path, state).await; + }); + + // Wait until the socket file exists (the listener binds before serving). + for _ in 0..200 { + if path.exists() { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(5)).await; + } + assert!(path.exists(), "admin socket was not created"); + + // route_add (cidr, direct). + let resp = admin::request( + &path_str, + &Request::RouteAdd { + cidr: Some("8.8.8.0/24".into()), + domain: None, + action: "direct".into(), + }, + ) + .await + .expect("route_add request"); + assert!(resp.ok, "route_add ok: {:?}", resp.error); + + // The shared table actually changed. + assert_eq!( + routes.read().await.classify("8.8.8.8".parse().unwrap()), + RouteAction::Direct + ); + + // route_add (domain, vpn). + let resp = admin::request( + &path_str, + &Request::RouteAdd { + cidr: None, + domain: Some("example.com".into()), + action: "vpn".into(), + }, + ) + .await + .expect("route_add domain"); + assert!(resp.ok); + + // route_list reflects both rules and the default. + let resp = admin::request(&path_str, &Request::RouteList) + .await + .expect("route_list"); + assert!(resp.ok); + assert_eq!(resp.default.as_deref(), Some("vpn")); + let cidrs = resp.cidrs.expect("cidrs present"); + assert_eq!(cidrs.len(), 1); + assert_eq!(cidrs[0].cidr, "8.8.8.0/24"); + assert_eq!(cidrs[0].action, "direct"); + let domains = resp.domains.expect("domains present"); + assert_eq!(domains.len(), 1); + assert_eq!(domains[0].domain, "example.com"); + + // status reflects peer id + default + rule count. + let resp = admin::request(&path_str, &Request::Status) + .await + .expect("status"); + assert!(resp.ok); + assert_eq!(resp.peer_id.as_deref(), Some("client-test")); + assert_eq!(resp.default.as_deref(), Some("vpn")); + assert_eq!(resp.rules, Some(2)); + + // route_remove the CIDR; classification falls back to default VPN. + let resp = admin::request( + &path_str, + &Request::RouteRemove { + cidr: "8.8.8.0/24".into(), + }, + ) + .await + .expect("route_remove"); + assert_eq!(resp.removed, Some(true)); + assert_eq!( + routes.read().await.classify("8.8.8.8".parse().unwrap()), + RouteAction::Vpn + ); + + // A malformed CIDR yields an error response (not a panic / disconnect). + let resp = admin::request( + &path_str, + &Request::RouteAdd { + cidr: Some("nonsense".into()), + domain: None, + action: "vpn".into(), + }, + ) + .await + .expect("route_add bad cidr"); + assert!(!resp.ok); + assert!(resp.error.is_some()); + + listener.abort(); + let _ = std::fs::remove_file(&path); +} diff --git a/crates/aura-cli/tests/loopback.rs b/crates/aura-cli/tests/loopback.rs new file mode 100644 index 0000000..0533117 --- /dev/null +++ b/crates/aura-cli/tests/loopback.rs @@ -0,0 +1,81 @@ +//! CLI-level end-to-end loopback (no TUN): mint certs via [`aura_pki::AuraCa`], build proto +//! Client/Server configs, [`AuraServer::bind`] on `127.0.0.1:0`, [`AuraClient::connect`], and +//! exchange packets via the [`PacketConnection`] API, asserting integrity. +//! +//! This is the full CLI integration path short of the privileged TUN device: it proves the crate's +//! wiring of aura-pki + aura-proto + aura-transport works end to end without root or external +//! network. + +use std::sync::Arc; + +use aura_pki::AuraCa; +use aura_proto::{ClientConfig, PacketConnection, ServerConfig}; +use aura_transport::{AuraClient, AuraServer}; + +const SERVER_NAME: &str = "localhost"; +const CAMOUFLAGE_SNI: &str = "cdn.example.com"; + +#[tokio::test] +async fn cli_loopback_packet_exchange() { + // PKI: CA + server cert (SAN localhost) + client cert. + let ca = AuraCa::generate("Aura CLI Test CA").expect("generate CA"); + let server_cert = ca.issue_server_cert(SERVER_NAME).expect("server cert"); + let client_cert = ca.issue_client_cert("cli-client").expect("client cert"); + let ca_pem = ca.ca_cert_pem(); + + let server_cfg = ServerConfig { + ca_cert_pem: ca_pem.clone(), + server_cert_pem: server_cert.cert_pem.clone(), + server_key_pem: server_cert.key_pem.clone(), + }; + let client_cfg = ClientConfig { + ca_cert_pem: ca_pem.clone(), + client_cert_pem: client_cert.cert_pem.clone(), + client_key_pem: client_cert.key_pem.clone(), + server_name: SERVER_NAME.to_string(), + }; + + // Bind on an OS-assigned loopback port. + let server = AuraServer::bind( + "127.0.0.1:0".parse().unwrap(), + &server_cert.cert_pem, + &server_cert.key_pem, + server_cfg, + ) + .expect("bind server"); + let server_addr = server.local_addr().expect("local_addr"); + + // Accept + connect concurrently. + let accept = tokio::spawn(async move { server.accept().await }); + let connect = + tokio::spawn( + async move { AuraClient::connect(server_addr, CAMOUFLAGE_SNI, client_cfg).await }, + ); + + let server_conn = accept.await.expect("accept join").expect("accept"); + let client_conn = connect.await.expect("connect join").expect("connect"); + + // Mutual auth established the client's verified CN on the server side. + assert_eq!(server_conn.peer_id(), Some("cli-client")); + + let server_conn: Arc = Arc::new(server_conn); + let client_conn: Arc = Arc::new(client_conn); + + // Client -> server. + for pkt in [ + b"ping".to_vec(), + vec![0u8; 1400], + (0..=255u8).collect::>(), + ] { + client_conn.send_packet(&pkt).await.expect("client send"); + let got = server_conn.recv_packet().await.expect("server recv"); + assert_eq!(got, pkt); + } + + // Server -> client. + for pkt in [b"pong".to_vec(), vec![0x5Au8; 999]] { + server_conn.send_packet(&pkt).await.expect("server send"); + let got = client_conn.recv_packet().await.expect("client recv"); + assert_eq!(got, pkt); + } +} diff --git a/crates/aura-cli/tests/pki_roundtrip.rs b/crates/aura-cli/tests/pki_roundtrip.rs new file mode 100644 index 0000000..7fa39f0 --- /dev/null +++ b/crates/aura-cli/tests/pki_roundtrip.rs @@ -0,0 +1,109 @@ +//! PKI roundtrip: drive the `aura pki` handlers to init a CA, issue server + client certs, then +//! verify each against [`aura_pki::AuraCertVerifier`]. A cert from a *different* CA must fail. +//! +//! Runs without root or network: everything is file I/O into a unique temp directory. + +use std::path::PathBuf; + +use aura_cli::pki; +use aura_pki::{AuraCa, AuraCertVerifier}; +use rustls_pki_types::CertificateDer; + +/// A unique temp directory for this test process (no `tempfile` dependency in the workspace). +fn temp_dir(tag: &str) -> PathBuf { + let mut dir = std::env::temp_dir(); + dir.push(format!( + "aura-cli-test-{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 +} + +/// Decode a single-certificate PEM string into a DER chain for the verifier. +fn pem_chain(pem: &str) -> Vec> { + let (_, parsed) = x509_parser::pem::parse_x509_pem(pem.as_bytes()).expect("parse PEM"); + vec![CertificateDer::from(parsed.contents)] +} + +#[test] +fn ca_init_issue_and_verify_roundtrip() { + let dir = temp_dir("pki"); + + // init the CA. + let (ca_cert_path, ca_key_path) = pki::init("Aura Roundtrip CA", &dir).expect("pki init"); + assert!(ca_cert_path.exists() && ca_key_path.exists()); + assert_eq!(ca_cert_path.file_name().unwrap(), "ca.crt"); + + // issue server + client certs (CA dir defaults to the same dir). + let (server_crt, server_key) = + pki::issue_server("vpn.example.com", &dir, &dir).expect("issue server"); + let (client_crt, client_key) = + pki::issue_client("client-42", &dir, &dir).expect("issue client"); + assert!(server_crt.exists() && server_key.exists()); + assert!(client_crt.exists() && client_key.exists()); + + // Load the CA back and build a verifier from its PEM. + let ca = AuraCa::load(&ca_cert_path, &ca_key_path).expect("load CA"); + let verifier = AuraCertVerifier::new(&ca.ca_cert_pem()).expect("verifier"); + + // Verify the server cert for its SAN. + let server_pem = std::fs::read_to_string(&server_crt).unwrap(); + verifier + .verify_server_cert(&pem_chain(&server_pem), "vpn.example.com") + .expect("server cert verifies for its SAN"); + + // Wrong name must fail. + assert!(verifier + .verify_server_cert(&pem_chain(&server_pem), "wrong.example.com") + .is_err()); + + // Verify the client cert; the returned CN must be the issued id. + let client_pem = std::fs::read_to_string(&client_crt).unwrap(); + let cn = verifier + .verify_client_cert(&pem_chain(&client_pem)) + .expect("client cert verifies"); + assert_eq!(cn, "client-42"); + + // A certificate from a *different* CA must NOT verify against this CA. + let other_dir = temp_dir("pki-other"); + pki::init("Other CA", &other_dir).expect("other CA"); + let (other_client, _k) = + pki::issue_client("intruder", &other_dir, &other_dir).expect("other client"); + let other_pem = std::fs::read_to_string(&other_client).unwrap(); + assert!( + verifier.verify_client_cert(&pem_chain(&other_pem)).is_err(), + "a cert from a different CA must fail verification" + ); + + // Cleanup (best effort). + let _ = std::fs::remove_dir_all(&dir); + let _ = std::fs::remove_dir_all(&other_dir); +} + +#[test] +fn revoke_then_list() { + let dir = temp_dir("crl"); + let crl = dir.join("revoked.crl"); + + // Empty / absent CRL lists nothing. + assert!(pki::list(&crl).unwrap().is_empty()); + + pki::revoke("client-42", &crl).expect("revoke 1"); + pki::revoke("deadbeef", &crl).expect("revoke 2"); + // Re-revoking is idempotent (set semantics). + pki::revoke("client-42", &crl).expect("revoke dup"); + + let mut listed = pki::list(&crl).expect("list"); + listed.sort(); + assert_eq!( + listed, + vec!["client-42".to_string(), "deadbeef".to_string()] + ); + + let _ = std::fs::remove_dir_all(&dir); +}