feat(cli): implement Wave 4 — aura binary (PKI, server/client, admin, bench)

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 <noreply@anthropic.com>
This commit is contained in:
xah30
2026-05-25 18:36:13 +03:00
parent c19a6c5586
commit cb89312a27
15 changed files with 2379 additions and 3 deletions
Generated
+4
View File
@@ -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]]
+53
View File
@@ -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
+32
View File
@@ -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
+14
View File
@@ -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
+555
View File
@@ -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<Option<String>>,
}
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<String>) {
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<BTreeMap<IpNetwork, RouteAction>>,
/// Domain rules, ordered for stable listing.
pub domains: StdMutex<BTreeMap<String, RouteAction>>,
}
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<Item = (IpNetwork, RouteAction)>,
domains: impl IntoIterator<Item = (String, RouteAction)>,
) -> 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<RwLock<RouteTable>>,
/// Mirror of configured rules for enumeration.
pub mirror: Arc<RuleMirror>,
/// Live tunnel statistics.
pub stats: Arc<Stats>,
}
impl AdminState {
/// Construct admin state from a shared table and stats, seeding the mirror from the given rules.
pub fn new(
routes: Arc<RwLock<RouteTable>>,
stats: Arc<Stats>,
cidrs: impl IntoIterator<Item = (IpNetwork, RouteAction)>,
domains: impl IntoIterator<Item = (String, RouteAction)>,
) -> 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<String>,
/// Domain to add (mutually exclusive with `cidr`).
#[serde(default, skip_serializing_if = "Option::is_none")]
domain: Option<String>,
/// 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<String>,
/// Default action (route_list / status).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default: Option<String>,
/// CIDR rules (route_list).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cidrs: Option<Vec<CidrEntry>>,
/// Domain rules (route_list).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub domains: Option<Vec<DomainEntry>>,
/// Whether a `route_remove` actually removed something.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub removed: Option<bool>,
/// Verified peer id (status).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub peer_id: Option<String>,
/// Inbound packet count (status).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rx_packets: Option<u64>,
/// Outbound packet count (status).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tx_packets: Option<u64>,
/// Total rule count (status).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rules: Option<usize>,
}
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<String>) -> 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::<IpNetwork>() {
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::<IpNetwork>() {
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::<Request>(&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<Response> {
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<Response> {
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"));
}
}
+95
View File
@@ -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:?}"));
}
+139
View File
@@ -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 <PATH>` (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
}
+576
View File
@@ -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<String>,
}
/// `[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<String>,
/// 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<String>,
/// `[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<SplitRule>,
/// Rules forcing matching destinations through the VPN.
#[serde(default)]
pub vpn: Vec<SplitRule>,
}
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<String>,
/// A domain, e.g. `"example.com"`. Mutually exclusive with `cidr`.
#[serde(default)]
pub domain: Option<String>,
}
/// `[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<PathBuf> {
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<String> {
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<Self> {
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<Self> {
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<SocketAddr> {
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<IpNetwork> {
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<aura_proto::ServerConfig> {
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<Self> {
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<Self> {
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<SocketAddr> {
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<std::net::IpAddr> {
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<aura_proto::ClientConfig> {
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<RouteAction> {
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::<IpAddr>().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 <workspace>/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());
}
}
+20
View File
@@ -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;
+324 -3
View File
@@ -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 <PATH> # run the VPN server (needs root for TUN)
//! aura client --config <PATH> # 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 <OUT> 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<PathBuf>,
},
/// Issue a client certificate (client.crt / client.key) with CN = <ID>.
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<PathBuf>,
},
/// 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<PathBuf>,
},
/// List revoked identifiers in the CRL file.
List {
/// CRL file path (defaults to ./revoked.crl).
#[arg(long)]
crl: Option<PathBuf>,
},
}
/// 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<String>,
/// Domain to route, e.g. example.com (mutually exclusive with --cidr).
#[arg(long)]
domain: Option<String>,
/// 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>) -> 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);
}
}
+112
View File
@@ -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<Vec<String>> {
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<AuraCa> {
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 `<stem>.crt` / `<stem>.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))
}
+118
View File
@@ -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 <PATH>` (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()
}
}
}
+147
View File
@@ -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);
}
+81
View File
@@ -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<dyn PacketConnection> = Arc::new(server_conn);
let client_conn: Arc<dyn PacketConnection> = Arc::new(client_conn);
// Client -> server.
for pkt in [
b"ping".to_vec(),
vec![0u8; 1400],
(0..=255u8).collect::<Vec<u8>>(),
] {
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);
}
}
+109
View File
@@ -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<CertificateDer<'static>> {
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);
}