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
+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());
}
}