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:
Generated
+4
@@ -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]]
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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:?}"));
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user