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:
xah30
2026-05-27 13:16:07 +03:00
parent 6c14c0d103
commit fe618b839d
9 changed files with 1090 additions and 13 deletions
+290
View File
@@ -0,0 +1,290 @@
//! v3.1 multi-hop / onion routing: the **client side** of the 2-hop circuit
//! `client → entry-relay → exit-server`.
//!
//! ## Wire dance
//!
//! 1. The client opens a normal UDP transport connection to the **entry relay** via
//! [`UdpClient::connect`]. The relay's cert is mutually authenticated by this **outer** Aura
//! handshake.
//! 2. Through the established outer connection, the client sends one
//! [`aura_proto::ControlKind::ExtendBridge`] envelope carrying the literal `IP:port` of the
//! downstream **exit server**.
//! 3. The relay either replies with [`aura_proto::ControlKind::CircuitReady`] (the bridge to the
//! exit is up; every subsequent byte travels opaquely) or
//! [`aura_proto::ControlKind::CircuitFailed`] (the relay refused — payload is a UTF-8 reason).
//! 4. Once `CircuitReady` arrives the client opens a **local proxy UDP socket** on loopback and
//! runs a second [`UdpClient::connect`] **at that loopback address** — this is the **inner**
//! handshake, addressed semantically to the exit-server. A background forwarder ferries every
//! datagram between the local proxy socket and the outer relay connection: the relay extracts
//! each datagram and ships it to the exit verbatim. The exit therefore runs an ordinary
//! [`aura_transport::UdpServer`] accepting one connection whose source address is the relay's
//! bridge socket.
//!
//! Result: traffic is wrapped under **two AEAD layers** — first the exit's session keys (inner
//! handshake) and again the relay's session keys (outer handshake). The exit knows the client's
//! certificate CN but not the client's real source IP; the relay knows the client's source IP but
//! not the destination IP nor a single plaintext byte.
//!
//! ## Why a local proxy UDP socket?
//!
//! The Aura UDP transport (`aura_transport::udp`) is built around a [`tokio::net::UdpSocket`]: its
//! reliable-handshake adapter writes/reads complete datagrams with a 1-byte type prefix
//! (`0x01` HS, `0x02` DATA). Re-using the transport without that socket would mean re-implementing
//! the whole reliability layer. The loopback proxy is the smallest hack that lets the inner
//! [`UdpClient`] talk over its expected datagram interface while every datagram is actually being
//! tunnelled through the outer relay connection.
use std::net::SocketAddr;
use std::sync::Arc;
use anyhow::{anyhow, bail, Context};
use async_trait::async_trait;
use aura_proto::{
decode_control_envelope, encode_control_envelope, encode_extend_bridge, ClientConfig,
ControlKind, PacketConnection,
};
use aura_transport::{UdpClient, UdpConnection, UdpOpts};
use tokio::net::UdpSocket;
use tokio::task::JoinHandle;
/// How long the client waits for the relay to reply with [`ControlKind::CircuitReady`] (or
/// [`ControlKind::CircuitFailed`]) after sending the [`ControlKind::ExtendBridge`] envelope.
const READY_TIMEOUT_SECS: u64 = 5;
/// An established 2-hop circuit: it is **literally** a [`UdpConnection`] in disguise. The inner
/// connection's outgoing datagrams go to a local proxy socket, which forwards them through the
/// outer relay connection to the exit. From the inner handshake / data exchange's point of view
/// nothing is special — it is talking to a normal Aura UDP server.
///
/// The two background tasks (proxy forwarders) and the outer connection are owned here, so dropping
/// the circuit tears everything down in order.
pub struct CircuitConnection {
/// The inner UDP connection (target of the second handshake addressed to the exit). All
/// `send_packet` / `recv_packet` go through this; the proxy forwarder splices the bytes onto
/// the outer relay connection.
inner: UdpConnection,
/// Outer relay connection — pinned alive for the lifetime of the circuit. The forwarder owns
/// clones, but holding it here means the outer is dropped at exactly the same time as `Self`.
_outer_conn_holder: Arc<dyn PacketConnection>,
/// Background task: local proxy socket ↔ outer relay connection. Aborted in [`Drop`].
forwarder: JoinHandle<()>,
/// Local proxy socket kept alive for the forwarder's lifetime (the forwarder also holds an
/// `Arc<UdpSocket>` clone, but this prevents close-on-last-clone races during shutdown).
_proxy_socket: Arc<UdpSocket>,
}
impl Drop for CircuitConnection {
fn drop(&mut self) {
self.forwarder.abort();
}
}
impl CircuitConnection {
/// The verified peer Common Name as learned during the **inner** handshake. This is the
/// **exit-server's** identity (NOT the relay's) — the whole point of multi-hop is that the
/// inner handshake authenticates the exit through the relay opaquely.
#[must_use]
pub fn peer_id(&self) -> Option<&str> {
self.inner.peer_id()
}
/// Promote into a trait object so the router / dialer layer can treat the circuit the same way
/// it treats a single-hop UDP / TCP / QUIC connection.
#[must_use]
pub fn into_dyn(self) -> Arc<dyn PacketConnection> {
Arc::new(self)
}
}
#[async_trait]
impl PacketConnection for CircuitConnection {
async fn send_packet(&self, packet: &[u8]) -> anyhow::Result<()> {
// Delegate to the inner UdpConnection — the proxy forwarder picks up its outgoing
// datagrams from the local proxy socket and tunnels them through the outer relay.
self.inner.send_packet(packet).await
}
async fn recv_packet(&self) -> anyhow::Result<Vec<u8>> {
self.inner.recv_packet().await
}
}
/// Build a 2-hop circuit `client → hops[0] (entry relay) → hops[1] (exit server)` and return it
/// as a [`CircuitConnection`].
///
/// Both hops are reached via the [`UdpClient`] transport in v3.1. `proto_cfg.server_name` is used
/// by the **inner** handshake to verify the EXIT's certificate SAN. The relay's own cert is also
/// CA-verified by the outer handshake; pass [`dial_circuit_with_relay_name`] when the relay's SAN
/// differs from the exit's.
///
/// # Errors
/// * The outer UDP connection to the entry relay failed.
/// * The relay refused (`CircuitFailed`) or did not reply within [`READY_TIMEOUT_SECS`] seconds.
/// * The inner Aura handshake (through the relay) failed (bad exit cert chain, SAN mismatch, etc.).
pub async fn dial_circuit(
hops: &[SocketAddr],
proto_cfg: ClientConfig,
udp_opts: UdpOpts,
) -> anyhow::Result<CircuitConnection> {
dial_circuit_with_relay_name(hops, proto_cfg, udp_opts, None).await
}
/// Variant of [`dial_circuit`] letting the caller override the SAN expected on the relay's cert
/// (the outer handshake) independently of the exit's expected SAN (`proto_cfg.server_name`, used
/// by the inner handshake). See [`dial_circuit`] for the high-level wire dance.
pub async fn dial_circuit_with_relay_name(
hops: &[SocketAddr],
proto_cfg: ClientConfig,
udp_opts: UdpOpts,
relay_server_name: Option<&str>,
) -> anyhow::Result<CircuitConnection> {
if hops.len() != 2 {
bail!(
"v3.1 multi-hop requires exactly 2 hops (entry, exit), got {}",
hops.len()
);
}
let entry = hops[0];
// 1) Dial entry via the existing UDP transport. The outer mutual-auth handshake against the
// relay's certificate runs here; when `relay_server_name` is supplied the verifier
// validates the relay's SAN against that name instead of the exit's.
let mut outer_cfg = proto_cfg.clone();
if let Some(name) = relay_server_name {
outer_cfg.server_name = name.to_string();
}
let outer = UdpClient::connect(entry, outer_cfg, udp_opts)
.await
.with_context(|| format!("dial entry relay at {entry}"))?;
let outer: Arc<dyn PacketConnection> = outer.into_dyn();
// 2) Send the ExtendBridge control envelope describing the downstream exit address.
let exit = hops[1];
let payload = encode_extend_bridge(exit);
let envelope = encode_control_envelope(ControlKind::ExtendBridge, &payload);
outer
.send_packet(&envelope)
.await
.context("send ExtendBridge to relay")?;
// 3) Wait for CircuitReady (with a hard timeout). The relay may send unrelated control
// envelopes in front of ours (e.g. a CRL push from the v2 path) — those are ignored until
// the expected envelope arrives or the deadline elapses.
let ready_deadline =
tokio::time::Instant::now() + std::time::Duration::from_secs(READY_TIMEOUT_SECS);
loop {
let now = tokio::time::Instant::now();
if now >= ready_deadline {
bail!("timeout waiting for CircuitReady from relay at {entry}");
}
let remaining = ready_deadline - now;
let pkt = tokio::time::timeout(remaining, outer.recv_packet())
.await
.map_err(|_| anyhow!("timeout waiting for CircuitReady from relay at {entry}"))?
.context("recv from entry relay")?;
match decode_control_envelope(&pkt) {
Ok(Some((ControlKind::CircuitReady, _))) => break,
Ok(Some((ControlKind::CircuitFailed, reason))) => {
let r = String::from_utf8_lossy(&reason);
bail!("relay refused circuit: {r}");
}
Ok(Some((other, _))) => {
tracing::debug!(
kind = ?other,
"ignoring unexpected control envelope while waiting for CircuitReady"
);
continue;
}
Ok(None) => {
tracing::debug!("ignoring non-control packet from relay before CircuitReady");
continue;
}
Err(e) => {
tracing::debug!(error = %e, "malformed envelope from relay before CircuitReady");
continue;
}
}
}
// 4) Bring up the local proxy UDP socket. The inner UdpClient will `connect()` to its address;
// every datagram it sends goes through the forwarder below to the outer relay connection,
// and every datagram the relay forwards from the exit is replayed back to the inner socket.
let proxy_socket = UdpSocket::bind("127.0.0.1:0")
.await
.context("bind local circuit proxy socket")?;
let proxy_addr = proxy_socket
.local_addr()
.context("read local proxy address")?;
let proxy_socket = Arc::new(proxy_socket);
// 5) Spawn the forwarder BEFORE running the inner handshake — the handshake's first datagram
// must already be flowing while it is being written.
let outer_for_send = Arc::clone(&outer);
let outer_for_recv = Arc::clone(&outer);
let proxy_for_send = Arc::clone(&proxy_socket);
let proxy_for_recv = Arc::clone(&proxy_socket);
let forwarder = tokio::spawn(async move {
// Source address of the inner UdpClient, learned from its first datagram on the proxy
// socket. We need it to know where to deliver `outer.recv_packet` payloads back.
let inner_peer: Arc<tokio::sync::Mutex<Option<SocketAddr>>> =
Arc::new(tokio::sync::Mutex::new(None));
// Task A: proxy.recv_from → outer.send_packet
let inner_peer_a = Arc::clone(&inner_peer);
let to_outer = async move {
let mut buf = vec![0u8; 4096];
loop {
let (n, from) = match proxy_for_recv.recv_from(&mut buf).await {
Ok(v) => v,
Err(_) => break,
};
{
let mut latch = inner_peer_a.lock().await;
if latch.is_none() {
*latch = Some(from);
}
}
if outer_for_send.send_packet(&buf[..n]).await.is_err() {
break;
}
}
};
// Task B: outer.recv_packet → proxy.send_to(inner_peer_addr)
let inner_peer_b = Arc::clone(&inner_peer);
let from_outer = async move {
loop {
let pkt = match outer_for_recv.recv_packet().await {
Ok(p) => p,
Err(_) => break,
};
let dest = { *inner_peer_b.lock().await };
if let Some(dest) = dest {
if proxy_for_send.send_to(&pkt, dest).await.is_err() {
break;
}
}
// Else: the inner UdpClient has not sent its first datagram yet; drop. (The
// reliable adapter will retransmit on its RTO timer.) This race window is tiny —
// we always spawn the forwarder before `UdpClient::connect`.
}
};
tokio::select! {
_ = to_outer => {}
_ = from_outer => {}
}
});
// 6) Inner Aura handshake addressed to the EXIT, via the local proxy. The peer_id we capture
// is the exit's verified CN (the core invariant: the inner handshake authenticates the
// exit, not the relay).
let inner = UdpClient::connect(proxy_addr, proto_cfg, udp_opts)
.await
.context("inner handshake to exit through relay")?;
Ok(CircuitConnection {
inner,
_outer_conn_holder: outer,
forwarder,
_proxy_socket: proxy_socket,
})
}