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:
@@ -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