feat(cli): v3.1 multi-hop runtime — circuit client + relay rendezvous
Completes v3.1 multi-hop / onion routing (2 hops: client → entry-relay →
exit-server). Combined with the scaffold commit (6c14c0d), the property
holds: entry-relay knows the client IP + client_id but cannot decrypt the
data; exit knows the destination but sees the relay's IP as source.
- aura-cli::circuit: dial_circuit(&[entry, exit], proto_cfg, udp_opts) →
CircuitConnection. Connects to entry as a normal UdpClient, sends an
ExtendBridge control envelope, awaits CircuitReady, then runs a SECOND
Aura handshake to the exit through a local loopback UDP proxy — the
forwarder ferries datagrams between that proxy socket and the outer
relay PacketConnection. The inner handshake therefore authenticates the
EXIT cert (verified by the integration test asserting
circuit.peer_id() == "localhost-exit"); the relay never sees the inner
session keys.
- aura-cli::relay: rendezvous(conn, whitelist) -> Bridged{bridge} |
Fallback{first_pkt} | Refused. 2-second window after handshake to receive
ExtendBridge. Whitelist enforced; CircuitFailed on miss. Empty whitelist
logs a warning and runs open. Timeout / non-control → Fallback so the
same server can be both relay (for circuit clients) and exit (for direct
clients) simultaneously.
- aura-cli::client: when [client.circuit] enabled → dial_circuit; falls
back to normal aura_transport::dial when disabled.
- aura-cli::server: relay rendezvous wired before pool/CRL/router path.
run_bridge spawns two forwarder tasks (conn↔bridge UDP socket).
- 3 integration tests: end-to-end (with peer_id assertion), whitelist
rejection, back-compat (relay disabled → Err). 3 unit tests in relay.rs.
Workspace: 253 tests passed (247 baseline + 6 new), clippy -D warnings clean,
fmt clean. No new workspace deps. All 28 tracked tasks (v1 + v2 + v3.1) now
complete.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,310 @@
|
||||
//! v3.1 multi-hop / onion-routing integration test.
|
||||
//!
|
||||
//! Drives three actors on loopback in one process:
|
||||
//!
|
||||
//! * **Exit** — a vanilla [`UdpServer`] bound on a free UDP port. Its cert SAN is
|
||||
//! `"localhost-exit"`. The server's accept task echoes the first three received packets back to
|
||||
//! the sender, then drops.
|
||||
//!
|
||||
//! * **Relay** — another [`UdpServer`] on a free port, cert SAN `"localhost-relay"`. Its accept
|
||||
//! task:
|
||||
//! 1. accepts one connection (running its own outer Aura mutual-auth handshake with the
|
||||
//! client),
|
||||
//! 2. uses [`crate::relay::rendezvous`] to read the client's `ExtendBridge` envelope and open
|
||||
//! a `connect()`ed UDP socket to the exit,
|
||||
//! 3. spawns [`crate::relay::run_bridge`] to ferry bytes between the client and the bridge.
|
||||
//!
|
||||
//! * **Client** — calls [`circuit::dial_circuit_with_relay_name`] with
|
||||
//! `relay_server_name = Some("localhost-relay")` and `proto_cfg.server_name = "localhost-exit"`.
|
||||
//! The returned [`circuit::CircuitConnection`] should have `peer_id() == Some("localhost-exit")`
|
||||
//! — the core multi-hop invariant: the **inner** handshake authenticated the exit's cert
|
||||
//! through the relay opaquely, even though the outer hop authenticated the relay's cert.
|
||||
//!
|
||||
//! The test then exchanges three packets of varying sizes through the circuit and asserts that
|
||||
//! every echoed reply matches.
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use aura_cli::circuit;
|
||||
use aura_cli::relay::{self, RendezvousOutcome};
|
||||
use aura_pki::AuraCa;
|
||||
use aura_proto::{ClientConfig, PacketConnection, ServerConfig};
|
||||
use aura_transport::{UdpOpts, UdpServer};
|
||||
|
||||
const EXIT_SAN: &str = "localhost-exit";
|
||||
const RELAY_SAN: &str = "localhost-relay";
|
||||
const CLIENT_ID: &str = "client-multihop";
|
||||
|
||||
/// Reserve and immediately release a free UDP port on loopback (the window before re-bind in the
|
||||
/// same process is negligible on a quiet test).
|
||||
fn free_udp_port() -> u16 {
|
||||
let sock = std::net::UdpSocket::bind("127.0.0.1:0").expect("bind ephemeral udp");
|
||||
sock.local_addr().expect("local_addr").port()
|
||||
}
|
||||
|
||||
/// Build a [`ServerConfig`] from one shared CA, with the given SAN.
|
||||
fn server_cfg(ca: &AuraCa, san: &str) -> ServerConfig {
|
||||
let issued = ca.issue_server_cert(san).expect("issue server cert");
|
||||
ServerConfig {
|
||||
ca_cert_pem: ca.ca_cert_pem(),
|
||||
server_cert_pem: issued.cert_pem,
|
||||
server_key_pem: issued.key_pem,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a [`ClientConfig`] from one shared CA. `server_name` is used by the **inner** handshake
|
||||
/// (the exit). The outer handshake's expected SAN is overridden separately at
|
||||
/// [`circuit::dial_circuit_with_relay_name`] callsite.
|
||||
fn client_cfg(ca: &AuraCa, server_name: &str) -> ClientConfig {
|
||||
let issued = ca.issue_client_cert(CLIENT_ID).expect("issue client cert");
|
||||
ClientConfig {
|
||||
ca_cert_pem: ca.ca_cert_pem(),
|
||||
client_cert_pem: issued.cert_pem,
|
||||
client_key_pem: issued.key_pem,
|
||||
server_name: server_name.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn the exit server: accept one connection and echo the first three packets back.
|
||||
async fn spawn_exit(server: UdpServer) {
|
||||
let conn = server.accept().await.expect("exit accept");
|
||||
// The dropped server keeps the master loop alive via the connection's anchor.
|
||||
drop(server);
|
||||
let conn: Arc<dyn PacketConnection> = Arc::new(conn);
|
||||
for _ in 0..3 {
|
||||
match conn.recv_packet().await {
|
||||
Ok(pkt) => {
|
||||
if conn.send_packet(&pkt).await.is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
Err(_) => return,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn the relay server: accept one connection, run the rendezvous, and bridge to the exit.
|
||||
async fn spawn_relay(server: UdpServer, whitelist: Vec<SocketAddr>) {
|
||||
let conn = server.accept().await.expect("relay accept");
|
||||
drop(server);
|
||||
let conn: Arc<dyn PacketConnection> = Arc::new(conn);
|
||||
match relay::rendezvous(&conn, &whitelist).await {
|
||||
RendezvousOutcome::Bridged { bridge } => {
|
||||
relay::run_bridge(conn, bridge).await;
|
||||
}
|
||||
RendezvousOutcome::Refused => {
|
||||
// Test path that exercises whitelist refusal — the relay sent CircuitFailed
|
||||
// already; just exit.
|
||||
}
|
||||
RendezvousOutcome::Fallback { .. } => {
|
||||
// The client did not send ExtendBridge — should not happen in the happy path.
|
||||
panic!("relay rendezvous fell back unexpectedly");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn multihop_v3_1_end_to_end() {
|
||||
// One shared CA. Each role gets its own server cert with its own SAN.
|
||||
let ca = AuraCa::generate("Aura Multi-Hop Test CA").expect("ca");
|
||||
let exit_proto = server_cfg(&ca, EXIT_SAN);
|
||||
let relay_proto = server_cfg(&ca, RELAY_SAN);
|
||||
let client_proto = client_cfg(&ca, EXIT_SAN);
|
||||
|
||||
let exit_port = free_udp_port();
|
||||
let relay_port = free_udp_port();
|
||||
let exit_addr: SocketAddr = format!("127.0.0.1:{exit_port}").parse().unwrap();
|
||||
let relay_addr: SocketAddr = format!("127.0.0.1:{relay_port}").parse().unwrap();
|
||||
|
||||
// Bind both servers BEFORE spawning the client so they are ready to accept.
|
||||
let exit_server =
|
||||
UdpServer::bind(exit_addr, exit_proto, UdpOpts::default()).expect("bind exit");
|
||||
let relay_server =
|
||||
UdpServer::bind(relay_addr, relay_proto, UdpOpts::default()).expect("bind relay");
|
||||
let exit_actual = exit_server.local_addr().expect("exit addr");
|
||||
let relay_actual = relay_server.local_addr().expect("relay addr");
|
||||
|
||||
// Whitelist contains exactly the exit address.
|
||||
let whitelist = vec![exit_actual];
|
||||
|
||||
let exit_task = tokio::spawn(spawn_exit(exit_server));
|
||||
let relay_task = tokio::spawn(spawn_relay(relay_server, whitelist));
|
||||
|
||||
// Give the servers a beat to enter their accept loops. Not strictly required (accept is
|
||||
// resumable) but makes the trace easier to follow on failure.
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
|
||||
// Client: dial circuit. proto_cfg.server_name = "localhost-exit" so the inner handshake's
|
||||
// verifier checks the exit's SAN; the outer handshake checks the relay's SAN via the explicit
|
||||
// override.
|
||||
let circuit_conn = tokio::time::timeout(
|
||||
Duration::from_secs(30),
|
||||
circuit::dial_circuit_with_relay_name(
|
||||
&[relay_actual, exit_actual],
|
||||
client_proto,
|
||||
UdpOpts::default(),
|
||||
Some(RELAY_SAN),
|
||||
),
|
||||
)
|
||||
.await
|
||||
.expect("dial_circuit did not finish within 30s")
|
||||
.expect("dial_circuit succeeded");
|
||||
|
||||
// The core invariant: the INNER handshake authenticated the EXIT (not the relay).
|
||||
assert_eq!(
|
||||
circuit_conn.peer_id(),
|
||||
Some(EXIT_SAN),
|
||||
"circuit.peer_id() must be the exit's SAN — the inner handshake verified the exit's cert"
|
||||
);
|
||||
|
||||
// Echo three packets of varying sizes through the circuit.
|
||||
let payloads: Vec<Vec<u8>> = vec![
|
||||
b"hello multi-hop".to_vec(),
|
||||
vec![0xCDu8; 800],
|
||||
(0..=255u8).collect(),
|
||||
];
|
||||
for pkt in &payloads {
|
||||
circuit_conn.send_packet(pkt).await.expect("circuit send");
|
||||
let echoed = tokio::time::timeout(Duration::from_secs(5), circuit_conn.recv_packet())
|
||||
.await
|
||||
.expect("recv timeout")
|
||||
.expect("recv from exit through circuit");
|
||||
assert_eq!(&echoed, pkt, "echoed payload must match");
|
||||
}
|
||||
|
||||
// Clean shutdown — drop the client first, then wait for the actors to finish.
|
||||
drop(circuit_conn);
|
||||
let _ = tokio::time::timeout(Duration::from_secs(5), exit_task).await;
|
||||
let _ = tokio::time::timeout(Duration::from_secs(5), relay_task).await;
|
||||
}
|
||||
|
||||
/// A whitelist that does NOT contain the exit's address must cause `dial_circuit` to fail with an
|
||||
/// error mentioning "allow_extend_to" (the reason string sent in `CircuitFailed`).
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn multihop_whitelist_rejects_disallowed_exit() {
|
||||
let ca = AuraCa::generate("Aura Multi-Hop Test CA").expect("ca");
|
||||
let exit_proto = server_cfg(&ca, EXIT_SAN);
|
||||
let relay_proto = server_cfg(&ca, RELAY_SAN);
|
||||
let client_proto = client_cfg(&ca, EXIT_SAN);
|
||||
|
||||
let exit_port = free_udp_port();
|
||||
let relay_port = free_udp_port();
|
||||
let exit_addr: SocketAddr = format!("127.0.0.1:{exit_port}").parse().unwrap();
|
||||
let relay_addr: SocketAddr = format!("127.0.0.1:{relay_port}").parse().unwrap();
|
||||
|
||||
let exit_server =
|
||||
UdpServer::bind(exit_addr, exit_proto, UdpOpts::default()).expect("bind exit");
|
||||
let relay_server =
|
||||
UdpServer::bind(relay_addr, relay_proto, UdpOpts::default()).expect("bind relay");
|
||||
let exit_actual = exit_server.local_addr().expect("exit addr");
|
||||
let relay_actual = relay_server.local_addr().expect("relay addr");
|
||||
|
||||
// Whitelist contains a different (fake) exit; the real exit is NOT allowed.
|
||||
let fake: SocketAddr = "10.255.255.1:9".parse().unwrap();
|
||||
let whitelist = vec![fake];
|
||||
|
||||
// Exit task: just sit there; we expect the relay never bridges to it.
|
||||
let _exit_task = tokio::spawn(async move {
|
||||
// Accept may never resolve; exit when test ends.
|
||||
let _ = exit_server.accept().await;
|
||||
});
|
||||
let relay_task = tokio::spawn(spawn_relay(relay_server, whitelist));
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
|
||||
// dial_circuit must error with a message mentioning "allow_extend_to".
|
||||
let res = tokio::time::timeout(
|
||||
Duration::from_secs(15),
|
||||
circuit::dial_circuit_with_relay_name(
|
||||
&[relay_actual, exit_actual],
|
||||
client_proto,
|
||||
UdpOpts::default(),
|
||||
Some(RELAY_SAN),
|
||||
),
|
||||
)
|
||||
.await
|
||||
.expect("dial_circuit_with_relay_name returned within 15s");
|
||||
|
||||
let err = match res {
|
||||
Ok(_) => panic!("dial_circuit must fail when exit is not on the whitelist"),
|
||||
Err(e) => e,
|
||||
};
|
||||
let msg = format!("{err:#}");
|
||||
assert!(
|
||||
msg.contains("allow_extend_to") || msg.contains("not in"),
|
||||
"expected 'allow_extend_to' / 'not in' in error, got: {msg}"
|
||||
);
|
||||
|
||||
let _ = tokio::time::timeout(Duration::from_secs(2), relay_task).await;
|
||||
}
|
||||
|
||||
/// When the v3.1 relay path is **disabled** at the server, the server's accept-side never reads
|
||||
/// the client's ExtendBridge envelope as a control message — instead the server would treat the
|
||||
/// connection as a normal VPN client. From the client's `dial_circuit` perspective the relay
|
||||
/// never sends `CircuitReady`, so the client times out (`READY_TIMEOUT_SECS`-bounded).
|
||||
///
|
||||
/// This test exercises that exact fallback: we run a `UdpServer` with NO rendezvous task,
|
||||
/// accept the connection, and just keep it open. The client's `dial_circuit` must return an Err
|
||||
/// whose message mentions a timeout / CircuitReady.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn multihop_back_compat_relay_disabled() {
|
||||
let ca = AuraCa::generate("Aura Multi-Hop Test CA").expect("ca");
|
||||
let exit_proto = server_cfg(&ca, EXIT_SAN);
|
||||
let relay_proto = server_cfg(&ca, RELAY_SAN);
|
||||
let client_proto = client_cfg(&ca, EXIT_SAN);
|
||||
|
||||
let exit_port = free_udp_port();
|
||||
let relay_port = free_udp_port();
|
||||
let exit_addr: SocketAddr = format!("127.0.0.1:{exit_port}").parse().unwrap();
|
||||
let relay_addr: SocketAddr = format!("127.0.0.1:{relay_port}").parse().unwrap();
|
||||
|
||||
let exit_server =
|
||||
UdpServer::bind(exit_addr, exit_proto, UdpOpts::default()).expect("bind exit");
|
||||
let relay_server =
|
||||
UdpServer::bind(relay_addr, relay_proto, UdpOpts::default()).expect("bind relay");
|
||||
let exit_actual = exit_server.local_addr().expect("exit addr");
|
||||
let relay_actual = relay_server.local_addr().expect("relay addr");
|
||||
|
||||
// Exit task: idle.
|
||||
let _exit_task = tokio::spawn(async move {
|
||||
let _ = exit_server.accept().await;
|
||||
});
|
||||
// Relay task: just accept and keep the connection alive WITHOUT running the rendezvous. This
|
||||
// models a v2 server that does not know about `ExtendBridge`. The client's incoming
|
||||
// `ExtendBridge` envelope is just an opaque payload from the server's perspective.
|
||||
let relay_task = tokio::spawn(async move {
|
||||
let conn = relay_server.accept().await.expect("relay accept");
|
||||
// Hold the connection until the test ends.
|
||||
tokio::time::sleep(Duration::from_secs(20)).await;
|
||||
drop(conn);
|
||||
});
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
|
||||
// The client must time out waiting for CircuitReady.
|
||||
let res = tokio::time::timeout(
|
||||
Duration::from_secs(20),
|
||||
circuit::dial_circuit_with_relay_name(
|
||||
&[relay_actual, exit_actual],
|
||||
client_proto,
|
||||
UdpOpts::default(),
|
||||
Some(RELAY_SAN),
|
||||
),
|
||||
)
|
||||
.await
|
||||
.expect("dial_circuit returned within 20s");
|
||||
|
||||
let err = match res {
|
||||
Ok(_) => panic!("dial_circuit must fail when the relay never sends CircuitReady"),
|
||||
Err(e) => e,
|
||||
};
|
||||
let msg = format!("{err:#}");
|
||||
assert!(
|
||||
msg.contains("timeout") || msg.contains("CircuitReady"),
|
||||
"expected timeout / CircuitReady in error, got: {msg}"
|
||||
);
|
||||
|
||||
relay_task.abort();
|
||||
}
|
||||
Reference in New Issue
Block a user