//! 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 = 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) { let conn = server.accept().await.expect("relay accept"); drop(server); let conn: Arc = 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![ 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(); }