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:
@@ -126,3 +126,18 @@ knock_secret_source = "ca_fingerprint"
|
|||||||
enabled = false
|
enabled = false
|
||||||
mean_interval_ms = 500
|
mean_interval_ms = 500
|
||||||
jitter = 0.5
|
jitter = 0.5
|
||||||
|
|
||||||
|
# v3.1 multi-hop / onion routing: dial through an entry-relay before reaching the exit-server.
|
||||||
|
# When `enabled = true`, the client opens an OUTER Aura UDP connection to `hops[0]` (the relay),
|
||||||
|
# sends one ExtendBridge envelope describing `hops[1]` (the exit), waits for CircuitReady, and
|
||||||
|
# then runs an INNER Aura handshake addressed to the exit through that relay — two AEAD layers
|
||||||
|
# per packet, the exit knows the client's CN but not the source IP, the relay knows the source
|
||||||
|
# IP but not the destination nor a single plaintext byte. Exactly two hops are required in
|
||||||
|
# v3.1; configure the relay-server with [server.relay] enabled = true and
|
||||||
|
# allow_extend_to = ["<this client's exit IP:port>"].
|
||||||
|
#
|
||||||
|
# Omitting the section (or `enabled = false`) keeps the v2 single-hop dial path intact —
|
||||||
|
# [client] server_addr / [transport] order rules apply as before.
|
||||||
|
# [client.circuit]
|
||||||
|
# enabled = true
|
||||||
|
# hops = ["198.51.100.5:443", "203.0.113.10:443"] # [entry_relay, exit_server] — literal IP:port
|
||||||
|
|||||||
@@ -119,3 +119,28 @@ knock_secret_source = "ca_fingerprint"
|
|||||||
enabled = false
|
enabled = false
|
||||||
mean_interval_ms = 500
|
mean_interval_ms = 500
|
||||||
jitter = 0.5
|
jitter = 0.5
|
||||||
|
|
||||||
|
# v3.1 multi-hop / onion routing: turn THIS server into an **entry-relay** that can splice an
|
||||||
|
# inbound client connection to a downstream **exit-server**. Right after the inner Aura
|
||||||
|
# handshake completes, the relay waits up to 2 seconds for the client to send a single
|
||||||
|
# ExtendBridge control envelope describing the downstream exit's IP:port. When the address is
|
||||||
|
# on `allow_extend_to`, the relay opens a `connect()`ed UDP socket to that exit, replies
|
||||||
|
# CircuitReady, and forwards every byte verbatim — the inner client↔exit handshake travels
|
||||||
|
# through the relay opaquely, so the relay never sees destination IPs or plaintext bytes.
|
||||||
|
#
|
||||||
|
# The connection in that role is NOT registered with the IP pool / [`ServerRouter`]; bridged
|
||||||
|
# peers do not consume a tunnel address. If no ExtendBridge arrives within 2s the connection
|
||||||
|
# falls back to the normal VPN-client path (so one server can serve both roles on one port).
|
||||||
|
# v3.1 only supports the UDP transport for relay hops.
|
||||||
|
#
|
||||||
|
# Omitting the whole [server.relay] section (or `enabled = false`) keeps the v2 behaviour intact.
|
||||||
|
# [server.relay]
|
||||||
|
# enabled = true
|
||||||
|
# Whitelist of allowed downstream exit addresses. ONLY literal IP:port entries; DNS resolution
|
||||||
|
# is NOT performed in v3.1 (unparsable entries are logged at WARN and skipped). An empty list
|
||||||
|
# turns this server into an OPEN relay accepting any downstream — dangerous; the runtime logs
|
||||||
|
# a WARN on each accepted bridge.
|
||||||
|
# allow_extend_to = [
|
||||||
|
# "198.51.100.5:443", # the exit you operate
|
||||||
|
# "203.0.113.10:443",
|
||||||
|
# ]
|
||||||
|
|||||||
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -24,11 +24,12 @@ use std::path::Path;
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
use aura_transport::dial;
|
use aura_transport::{dial, TransportMode};
|
||||||
use aura_tunnel::{AuraDns, AuraRouter, AuraTun, RouteAction};
|
use aura_tunnel::{AuraDns, AuraRouter, AuraTun, RouteAction};
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
use crate::admin::{self, AdminState, Stats};
|
use crate::admin::{self, AdminState, Stats};
|
||||||
|
use crate::circuit;
|
||||||
use crate::config::{expand_tilde, ClientConfigFile};
|
use crate::config::{expand_tilde, ClientConfigFile};
|
||||||
use crate::crl_push::AcceptPushedCrlConn;
|
use crate::crl_push::AcceptPushedCrlConn;
|
||||||
use crate::masks::MaskRotator;
|
use crate::masks::MaskRotator;
|
||||||
@@ -96,14 +97,36 @@ pub async fn run(config_path: &Path, admin_socket: &str) -> anyhow::Result<()> {
|
|||||||
let routes = Arc::new(RwLock::new(table));
|
let routes = Arc::new(RwLock::new(table));
|
||||||
let stats = Arc::new(Stats::new());
|
let stats = Arc::new(Stats::new());
|
||||||
|
|
||||||
// Dial: try each transport in `[transport] order` (UDP→TCP→QUIC handover) until one connects.
|
// Dial: when [client.circuit] is enabled, build a 2-hop circuit `client → entry-relay → exit`
|
||||||
// Each transport runs the inner Aura mutual-auth handshake; the winner is returned as a uniform
|
// via [`circuit::dial_circuit`]. Otherwise fall back to the v2 single-hop dial across the
|
||||||
// `Arc<dyn PacketConnection>` along with which mode carried it. (The trait object does not surface
|
// configured [transport] order. In both cases the result is a uniform `Arc<dyn PacketConnection>`
|
||||||
// the verified server CN; the server identity was already checked against `[client] sni` inside
|
// so the downstream router does not care which path was taken.
|
||||||
// the handshake, so we record that as the peer for the admin/status mirror.)
|
let (conn, mode) = if cfg.circuit.enabled {
|
||||||
let (conn, mode) = dial(proto_cfg.clone(), dial_cfg)
|
let hops = cfg
|
||||||
|
.circuit_hops()
|
||||||
|
.context("parsing [client.circuit] hops")?;
|
||||||
|
tracing::info!(
|
||||||
|
entry = %hops[0],
|
||||||
|
exit = %hops[1],
|
||||||
|
"building v3.1 2-hop circuit"
|
||||||
|
);
|
||||||
|
let circuit_conn = circuit::dial_circuit(&hops, proto_cfg.clone(), dial_cfg.udp)
|
||||||
.await
|
.await
|
||||||
.context("connecting to Aura server")?;
|
.context("building multi-hop circuit (v3.1)")?;
|
||||||
|
let peer_id = circuit_conn.peer_id().map(str::to_owned);
|
||||||
|
tracing::info!(
|
||||||
|
peer = ?peer_id,
|
||||||
|
"v3.1 circuit established (inner handshake authenticated the EXIT server)"
|
||||||
|
);
|
||||||
|
(circuit_conn.into_dyn(), TransportMode::Udp)
|
||||||
|
} else {
|
||||||
|
// Each transport runs the inner Aura mutual-auth handshake; the winner is returned along
|
||||||
|
// with which mode carried it. (The trait object does not surface the verified server CN;
|
||||||
|
// the server identity was already checked against `[client] sni` inside the handshake.)
|
||||||
|
dial(proto_cfg.clone(), dial_cfg)
|
||||||
|
.await
|
||||||
|
.context("connecting to Aura server")?
|
||||||
|
};
|
||||||
let peer = Some(cfg.client.sni.clone());
|
let peer = Some(cfg.client.sni.clone());
|
||||||
stats.set_peer_id(peer.clone());
|
stats.set_peer_id(peer.clone());
|
||||||
tracing::info!(peer = ?peer, %mode, "connected and authenticated to server");
|
tracing::info!(peer = ?peer, %mode, "connected and authenticated to server");
|
||||||
|
|||||||
@@ -896,9 +896,9 @@ impl ClientConfigFile {
|
|||||||
pub fn circuit_hops(&self) -> anyhow::Result<Vec<SocketAddr>> {
|
pub fn circuit_hops(&self) -> anyhow::Result<Vec<SocketAddr>> {
|
||||||
let mut out = Vec::with_capacity(self.circuit.hops.len());
|
let mut out = Vec::with_capacity(self.circuit.hops.len());
|
||||||
for raw in &self.circuit.hops {
|
for raw in &self.circuit.hops {
|
||||||
let addr: SocketAddr = raw
|
let addr: SocketAddr = raw.parse().with_context(|| {
|
||||||
.parse()
|
format!("invalid [client.circuit] hop '{raw}' (expected IP:port)")
|
||||||
.with_context(|| format!("invalid [client.circuit] hop '{raw}' (expected IP:port)"))?;
|
})?;
|
||||||
out.push(addr);
|
out.push(addr);
|
||||||
}
|
}
|
||||||
if self.circuit.enabled && out.len() != 2 {
|
if self.circuit.enabled && out.len() != 2 {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
|
|
||||||
pub mod admin;
|
pub mod admin;
|
||||||
pub mod bench;
|
pub mod bench;
|
||||||
|
pub mod circuit;
|
||||||
pub mod client;
|
pub mod client;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod crl_push;
|
pub mod crl_push;
|
||||||
@@ -26,5 +27,6 @@ pub mod os_routes;
|
|||||||
pub mod pki;
|
pub mod pki;
|
||||||
pub mod pool;
|
pub mod pool;
|
||||||
pub mod privdrop;
|
pub mod privdrop;
|
||||||
|
pub mod relay;
|
||||||
pub mod server;
|
pub mod server;
|
||||||
pub mod server_router;
|
pub mod server_router;
|
||||||
|
|||||||
@@ -0,0 +1,339 @@
|
|||||||
|
//! v3.1 multi-hop / onion routing: the **server (entry-relay) side**.
|
||||||
|
//!
|
||||||
|
//! Companion to [`crate::circuit`]. When `[server.relay] enabled = true`, the server's accept
|
||||||
|
//! loop performs a short **rendezvous** on each fresh client connection: it waits up to
|
||||||
|
//! [`EXTEND_RENDEZVOUS_SECS`] seconds for a first packet, and:
|
||||||
|
//!
|
||||||
|
//! * If the packet decodes as a [`ControlKind::ExtendBridge`] envelope, the server resolves the
|
||||||
|
//! downstream `exit_addr`, checks it against the configured whitelist, opens a raw UDP socket
|
||||||
|
//! to the exit, sends [`ControlKind::CircuitReady`] back to the client, and starts two
|
||||||
|
//! forwarder tasks — one in each direction — splicing the client's [`PacketConnection`] to the
|
||||||
|
//! bridge socket. The connection is NOT registered with the [`crate::server_router::ServerRouter`];
|
||||||
|
//! bridged peers do not consume an IP from the pool.
|
||||||
|
//! * Otherwise the packet is replayed back into a fallback channel and the accept loop continues
|
||||||
|
//! handling the connection as a normal VPN client. This dual-role mode lets one server be a
|
||||||
|
//! relay for some peers and an exit for others, depending on what each client chose to send first.
|
||||||
|
//!
|
||||||
|
//! ## Whitelist semantics
|
||||||
|
//!
|
||||||
|
//! `[server.relay] allow_extend_to` is parsed by
|
||||||
|
//! [`ServerConfigFile::relay_whitelist`](crate::config::ServerConfigFile::relay_whitelist) into a
|
||||||
|
//! `Vec<SocketAddr>`. An empty whitelist is treated as **open relay** — every `exit_addr` is
|
||||||
|
//! accepted — and we emit a `warn` log so the operator notices the dangerous configuration. A
|
||||||
|
//! non-empty whitelist that does not contain the requested `exit_addr` causes us to reply with
|
||||||
|
//! [`ControlKind::CircuitFailed`] (payload: `"not in allow_extend_to"`) and drop the connection.
|
||||||
|
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use aura_proto::{
|
||||||
|
decode_control_envelope, decode_extend_bridge, encode_control_envelope, ControlKind,
|
||||||
|
PacketConnection,
|
||||||
|
};
|
||||||
|
use tokio::net::UdpSocket;
|
||||||
|
|
||||||
|
/// How long the relay waits for the client's first packet on a fresh connection before falling
|
||||||
|
/// back to treating the connection as a normal VPN client. Two seconds is comfortably longer than
|
||||||
|
/// a loopback round-trip (the client sends `ExtendBridge` immediately after the outer handshake
|
||||||
|
/// returns) but short enough that fallback clients do not perceive a stall.
|
||||||
|
pub const EXTEND_RENDEZVOUS_SECS: u64 = 2;
|
||||||
|
|
||||||
|
/// Outcome of the [`rendezvous`] phase on a fresh connection.
|
||||||
|
///
|
||||||
|
/// * [`RendezvousOutcome::Bridged`] — the client sent [`ControlKind::ExtendBridge`]; the bridge
|
||||||
|
/// socket has been opened and the relay can now spawn the forwarders. The caller MUST NOT
|
||||||
|
/// register this connection with the IP pool / router.
|
||||||
|
/// * [`RendezvousOutcome::Fallback`] — no `ExtendBridge` arrived in time, or the first packet
|
||||||
|
/// was not a control envelope. The caller should resume the v2 path and treat the connection
|
||||||
|
/// as a normal VPN client.
|
||||||
|
/// * [`RendezvousOutcome::Refused`] — the client asked for an exit that is not on the whitelist;
|
||||||
|
/// the relay has already replied with [`ControlKind::CircuitFailed`] and the caller should drop
|
||||||
|
/// the connection.
|
||||||
|
pub enum RendezvousOutcome {
|
||||||
|
/// The connection is now a bridge to `bridge` (a UDP socket connected to the exit). The
|
||||||
|
/// caller should spawn [`run_bridge`] to ferry packets.
|
||||||
|
Bridged { bridge: Arc<UdpSocket> },
|
||||||
|
/// No bridge was requested; the connection is a normal VPN client. `first_pkt` is the first
|
||||||
|
/// packet the caller observed during the rendezvous window (if any) so it can be replayed
|
||||||
|
/// into the normal processing pipeline; in v3.1 we drop it (the v2 path expects to call
|
||||||
|
/// `recv_packet` itself from a clean state) — see the callsite for details.
|
||||||
|
Fallback { first_pkt: Option<Vec<u8>> },
|
||||||
|
/// The client asked for an exit not on the whitelist. The caller should drop the connection.
|
||||||
|
Refused,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Perform the rendezvous on a freshly-accepted relay connection.
|
||||||
|
///
|
||||||
|
/// Reads (with a [`EXTEND_RENDEZVOUS_SECS`] timeout) the next packet from `conn`. When it decodes
|
||||||
|
/// as [`ControlKind::ExtendBridge`] and the requested exit is whitelisted, this function:
|
||||||
|
///
|
||||||
|
/// 1. Binds a UDP socket on `0.0.0.0:0` (`[::]:0` for an IPv6 exit) and `connect()`s it to the
|
||||||
|
/// exit address.
|
||||||
|
/// 2. Sends [`ControlKind::CircuitReady`] back to the client.
|
||||||
|
/// 3. Returns [`RendezvousOutcome::Bridged`] with the bridge socket.
|
||||||
|
///
|
||||||
|
/// On a whitelist miss it sends [`ControlKind::CircuitFailed`] and returns
|
||||||
|
/// [`RendezvousOutcome::Refused`]. On any timeout / non-control / decode failure it returns
|
||||||
|
/// [`RendezvousOutcome::Fallback`] so the caller can continue with the v2 VPN-client path.
|
||||||
|
pub async fn rendezvous(
|
||||||
|
conn: &Arc<dyn PacketConnection>,
|
||||||
|
whitelist: &[SocketAddr],
|
||||||
|
) -> RendezvousOutcome {
|
||||||
|
let pkt = match tokio::time::timeout(
|
||||||
|
Duration::from_secs(EXTEND_RENDEZVOUS_SECS),
|
||||||
|
conn.recv_packet(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(Ok(p)) => p,
|
||||||
|
Ok(Err(e)) => {
|
||||||
|
tracing::debug!(error = %e, "relay rendezvous: recv failed; fallback to VPN client path");
|
||||||
|
return RendezvousOutcome::Fallback { first_pkt: None };
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
tracing::debug!(
|
||||||
|
"relay rendezvous: no ExtendBridge within {EXTEND_RENDEZVOUS_SECS}s; \
|
||||||
|
fallback to VPN client path"
|
||||||
|
);
|
||||||
|
return RendezvousOutcome::Fallback { first_pkt: None };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let envelope = match decode_control_envelope(&pkt) {
|
||||||
|
Ok(Some((kind, payload))) => Some((kind, payload)),
|
||||||
|
Ok(None) => None,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::debug!(error = %e, "relay rendezvous: malformed envelope; fallback");
|
||||||
|
return RendezvousOutcome::Fallback {
|
||||||
|
first_pkt: Some(pkt),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let Some((kind, payload)) = envelope else {
|
||||||
|
tracing::debug!(
|
||||||
|
"relay rendezvous: first packet is not a control envelope; fallback to VPN client path"
|
||||||
|
);
|
||||||
|
return RendezvousOutcome::Fallback {
|
||||||
|
first_pkt: Some(pkt),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
if kind != ControlKind::ExtendBridge {
|
||||||
|
tracing::debug!(
|
||||||
|
kind = ?kind,
|
||||||
|
"relay rendezvous: first envelope is not ExtendBridge; fallback to VPN client path"
|
||||||
|
);
|
||||||
|
return RendezvousOutcome::Fallback {
|
||||||
|
first_pkt: Some(pkt),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let exit_addr: SocketAddr = match decode_extend_bridge(&payload) {
|
||||||
|
Ok(a) => a,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = %e, "relay rendezvous: malformed ExtendBridge payload; refusing");
|
||||||
|
let reply = encode_control_envelope(
|
||||||
|
ControlKind::CircuitFailed,
|
||||||
|
b"malformed ExtendBridge payload",
|
||||||
|
);
|
||||||
|
let _ = conn.send_packet(&reply).await;
|
||||||
|
return RendezvousOutcome::Refused;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Whitelist enforcement. Empty whitelist == open relay (operator was warned via the log line
|
||||||
|
// emitted when the section was loaded; we also re-log here so each accepted bridge leaves a
|
||||||
|
// breadcrumb).
|
||||||
|
if whitelist.is_empty() {
|
||||||
|
tracing::warn!(
|
||||||
|
exit = %exit_addr,
|
||||||
|
"relay running as OPEN relay (allow_extend_to is empty); accepting bridge"
|
||||||
|
);
|
||||||
|
} else if !whitelist.contains(&exit_addr) {
|
||||||
|
tracing::warn!(
|
||||||
|
exit = %exit_addr,
|
||||||
|
"relay rejecting bridge: exit not in allow_extend_to"
|
||||||
|
);
|
||||||
|
let reply = encode_control_envelope(ControlKind::CircuitFailed, b"not in allow_extend_to");
|
||||||
|
let _ = conn.send_packet(&reply).await;
|
||||||
|
return RendezvousOutcome::Refused;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open the bridge socket. We bind matching the exit's address family so a relay running on a
|
||||||
|
// dual-stack host does not accidentally try to use an IPv4 socket to reach an IPv6 exit.
|
||||||
|
let bind_addr: SocketAddr = if exit_addr.is_ipv4() {
|
||||||
|
"0.0.0.0:0".parse().expect("valid v4 bind addr")
|
||||||
|
} else {
|
||||||
|
"[::]:0".parse().expect("valid v6 bind addr")
|
||||||
|
};
|
||||||
|
let bridge = match UdpSocket::bind(bind_addr).await {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = %e, exit = %exit_addr, "relay could not bind bridge socket");
|
||||||
|
let msg = format!("bridge bind failed: {e}");
|
||||||
|
let reply = encode_control_envelope(ControlKind::CircuitFailed, msg.as_bytes());
|
||||||
|
let _ = conn.send_packet(&reply).await;
|
||||||
|
return RendezvousOutcome::Refused;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Err(e) = bridge.connect(exit_addr).await {
|
||||||
|
tracing::warn!(error = %e, exit = %exit_addr, "relay could not connect bridge socket to exit");
|
||||||
|
let msg = format!("bridge connect failed: {e}");
|
||||||
|
let reply = encode_control_envelope(ControlKind::CircuitFailed, msg.as_bytes());
|
||||||
|
let _ = conn.send_packet(&reply).await;
|
||||||
|
return RendezvousOutcome::Refused;
|
||||||
|
}
|
||||||
|
|
||||||
|
let ready = encode_control_envelope(ControlKind::CircuitReady, &[]);
|
||||||
|
if let Err(e) = conn.send_packet(&ready).await {
|
||||||
|
tracing::warn!(error = %e, "relay failed to send CircuitReady; dropping");
|
||||||
|
return RendezvousOutcome::Refused;
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
exit = %exit_addr,
|
||||||
|
"relay rendezvous succeeded; bridging client to exit"
|
||||||
|
);
|
||||||
|
RendezvousOutcome::Bridged {
|
||||||
|
bridge: Arc::new(bridge),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Splice a client-side [`PacketConnection`] to a `connect()`ed bridge UDP socket, ferrying bytes
|
||||||
|
/// in both directions until either side closes. Drives **two** tasks (each direction) and joins
|
||||||
|
/// them so the function returns when both have ended.
|
||||||
|
///
|
||||||
|
/// The relay never decrypts the inner Aura handshake / data: bytes from the client are sent as
|
||||||
|
/// raw UDP datagrams to the exit, and bytes from the exit are wrapped back in a
|
||||||
|
/// [`PacketConnection::send_packet`] call on the client connection. This is what makes the
|
||||||
|
/// `client ↔ exit` handshake travel through the relay opaquely.
|
||||||
|
pub async fn run_bridge(client_conn: Arc<dyn PacketConnection>, bridge: Arc<UdpSocket>) {
|
||||||
|
let conn_a = Arc::clone(&client_conn);
|
||||||
|
let br_a = Arc::clone(&bridge);
|
||||||
|
let to_exit = tokio::spawn(async move {
|
||||||
|
while let Ok(buf) = conn_a.recv_packet().await {
|
||||||
|
if br_a.send(&buf).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let conn_b = Arc::clone(&client_conn);
|
||||||
|
let br_b = Arc::clone(&bridge);
|
||||||
|
let to_client = tokio::spawn(async move {
|
||||||
|
let mut buf = vec![0u8; 2048];
|
||||||
|
while let Ok(n) = br_b.recv(&mut buf).await {
|
||||||
|
if conn_b.send_packet(&buf[..n]).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let _ = tokio::join!(to_exit, to_client);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use aura_proto::encode_extend_bridge;
|
||||||
|
use tokio::sync::Mutex as TokioMutex;
|
||||||
|
|
||||||
|
/// In-memory mock that lets us drive [`rendezvous`] without a real Aura connection.
|
||||||
|
struct MockConn {
|
||||||
|
to_recv: TokioMutex<VecDeque<anyhow::Result<Vec<u8>>>>,
|
||||||
|
sent: TokioMutex<Vec<Vec<u8>>>,
|
||||||
|
}
|
||||||
|
impl MockConn {
|
||||||
|
fn new(items: impl IntoIterator<Item = anyhow::Result<Vec<u8>>>) -> Arc<Self> {
|
||||||
|
Arc::new(Self {
|
||||||
|
to_recv: TokioMutex::new(items.into_iter().collect()),
|
||||||
|
sent: TokioMutex::new(Vec::new()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[async_trait]
|
||||||
|
impl PacketConnection for MockConn {
|
||||||
|
async fn send_packet(&self, packet: &[u8]) -> anyhow::Result<()> {
|
||||||
|
self.sent.lock().await.push(packet.to_vec());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
async fn recv_packet(&self) -> anyhow::Result<Vec<u8>> {
|
||||||
|
match self.to_recv.lock().await.pop_front() {
|
||||||
|
Some(item) => item,
|
||||||
|
None => {
|
||||||
|
// Block forever — the caller's timeout will trip first. Use
|
||||||
|
// `std::future::pending` so we do not pull in a `futures` dep.
|
||||||
|
std::future::pending::<()>().await;
|
||||||
|
unreachable!("pending future never resolves")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An ExtendBridge to an exit that is **not** on the whitelist is refused: the relay sends
|
||||||
|
/// CircuitFailed back and the rendezvous outcome is `Refused`.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn whitelist_miss_refuses_with_circuit_failed() {
|
||||||
|
let target: SocketAddr = "203.0.113.5:443".parse().unwrap();
|
||||||
|
let allowed: SocketAddr = "203.0.113.99:443".parse().unwrap();
|
||||||
|
let payload = encode_extend_bridge(target);
|
||||||
|
let envelope = encode_control_envelope(ControlKind::ExtendBridge, &payload);
|
||||||
|
// Keep a typed handle to the mock so we can introspect what was sent without unsafe.
|
||||||
|
let mock = MockConn::new([Ok(envelope)]);
|
||||||
|
let conn: Arc<dyn PacketConnection> = mock.clone();
|
||||||
|
|
||||||
|
let outcome = rendezvous(&conn, &[allowed]).await;
|
||||||
|
assert!(matches!(outcome, RendezvousOutcome::Refused));
|
||||||
|
|
||||||
|
// Verify the relay actually answered with a CircuitFailed envelope.
|
||||||
|
let sent = mock.sent.lock().await.clone();
|
||||||
|
assert_eq!(sent.len(), 1, "exactly one reply was sent");
|
||||||
|
let (kind, reason) = decode_control_envelope(&sent[0]).unwrap().unwrap();
|
||||||
|
assert_eq!(kind, ControlKind::CircuitFailed);
|
||||||
|
assert_eq!(
|
||||||
|
std::str::from_utf8(&reason).unwrap(),
|
||||||
|
"not in allow_extend_to"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Empty whitelist == open relay. A target that is anywhere succeeds (we open the bridge
|
||||||
|
/// against loopback so the bind / connect actually succeed in the test).
|
||||||
|
#[tokio::test]
|
||||||
|
async fn empty_whitelist_acts_as_open_relay() {
|
||||||
|
// Reserve a free UDP port for the dummy exit so connect() succeeds on the bridge side.
|
||||||
|
let exit_sock = std::net::UdpSocket::bind("127.0.0.1:0").unwrap();
|
||||||
|
let exit_addr = exit_sock.local_addr().unwrap();
|
||||||
|
let payload = encode_extend_bridge(exit_addr);
|
||||||
|
let envelope = encode_control_envelope(ControlKind::ExtendBridge, &payload);
|
||||||
|
let conn: Arc<dyn PacketConnection> = MockConn::new([Ok(envelope)]);
|
||||||
|
|
||||||
|
let outcome = rendezvous(&conn, &[]).await;
|
||||||
|
assert!(matches!(outcome, RendezvousOutcome::Bridged { .. }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// When no packet arrives within the rendezvous window, fall back to the normal VPN-client
|
||||||
|
/// path. The relay does not send any reply.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn timeout_falls_back_to_vpn_client_path() {
|
||||||
|
// Pass an empty mock so recv_packet blocks forever and the rendezvous timeout trips.
|
||||||
|
let conn: Arc<dyn PacketConnection> = MockConn::new([]);
|
||||||
|
// Tighten Tokio's clock: pause + advance is not appropriate here because rendezvous uses
|
||||||
|
// real timeouts (Duration::from_secs(2)); simply waiting in CI is fine because the test
|
||||||
|
// path is small. To keep CI fast, bump the timeout up: the test sets up a recv that
|
||||||
|
// blocks forever, so we want the rendezvous's own timeout to fire — that is the assertion.
|
||||||
|
//
|
||||||
|
// We use a `Box::pin(...)` + select to bound the test itself in case the rendezvous never
|
||||||
|
// returns (a regression).
|
||||||
|
let result = tokio::time::timeout(
|
||||||
|
std::time::Duration::from_secs(EXTEND_RENDEZVOUS_SECS + 2),
|
||||||
|
rendezvous(&conn, &[]),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("rendezvous returned within deadline");
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
result,
|
||||||
|
RendezvousOutcome::Fallback { first_pkt: None }
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,7 +31,7 @@ use std::sync::Arc;
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
use aura_transport::MultiServer;
|
use aura_transport::{MultiServer, TransportMode};
|
||||||
use aura_tunnel::{AuraTun, RouteAction, RouteTable};
|
use aura_tunnel::{AuraTun, RouteAction, RouteTable};
|
||||||
use ipnetwork::IpNetwork;
|
use ipnetwork::IpNetwork;
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
@@ -43,6 +43,7 @@ use crate::masks::MaskRotator;
|
|||||||
use crate::nat::NatGuard;
|
use crate::nat::NatGuard;
|
||||||
use crate::pool::IpPool;
|
use crate::pool::IpPool;
|
||||||
use crate::privdrop;
|
use crate::privdrop;
|
||||||
|
use crate::relay::{self, RendezvousOutcome};
|
||||||
use crate::server_router::ServerRouter;
|
use crate::server_router::ServerRouter;
|
||||||
|
|
||||||
/// Entry point for `aura server --config <PATH>` (and optional `--admin-socket`).
|
/// Entry point for `aura server --config <PATH>` (and optional `--admin-socket`).
|
||||||
@@ -243,10 +244,45 @@ pub async fn run(config_path: &Path, admin_socket: &str) -> anyhow::Result<()> {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// v3.1: when [server.relay] is enabled, parse the whitelist once and log a warning if it is
|
||||||
|
// empty (open relay). The whitelist is a `Vec<SocketAddr>`; an empty list means "all
|
||||||
|
// addresses allowed" (dangerous; see the section's docs).
|
||||||
|
let relay_enabled = cfg.server.relay.enabled;
|
||||||
|
let relay_whitelist: Vec<std::net::SocketAddr> = if relay_enabled {
|
||||||
|
let wl = cfg.relay_whitelist();
|
||||||
|
if wl.is_empty() {
|
||||||
|
tracing::warn!(
|
||||||
|
"[server.relay] is enabled with an EMPTY allow_extend_to — running as OPEN relay; \
|
||||||
|
every ExtendBridge request will be accepted. Set allow_extend_to to a curated list."
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
tracing::info!(
|
||||||
|
count = wl.len(),
|
||||||
|
"[server.relay] enabled with {} whitelisted exit address(es)",
|
||||||
|
wl.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
wl
|
||||||
|
} else {
|
||||||
|
Vec::new()
|
||||||
|
};
|
||||||
|
|
||||||
// Accept loop. Each accepted connection (from any transport) is assigned an IP from the pool
|
// Accept loop. Each accepted connection (from any transport) is assigned an IP from the pool
|
||||||
// and registered with the [`ServerRouter`]; a per-conn task forwards inbound packets into the
|
// and registered with the [`ServerRouter`]; a per-conn task forwards inbound packets into the
|
||||||
// shared TUN. `MultiServer::accept` yields `None` only when every transport's accept loop has
|
// shared TUN. `MultiServer::accept` yields `None` only when every transport's accept loop has
|
||||||
// stopped.
|
// stopped.
|
||||||
|
//
|
||||||
|
// v3.1: when [server.relay] is enabled, every accepted UDP connection first undergoes a short
|
||||||
|
// **rendezvous** ([`relay::rendezvous`]) to see whether the client wants to be bridged through
|
||||||
|
// to a downstream exit. The rendezvous:
|
||||||
|
// * Reads with a 2-second timeout. If an `ExtendBridge` envelope arrives and its `exit_addr`
|
||||||
|
// is on the whitelist, the relay opens a bridge socket, replies with `CircuitReady`, and
|
||||||
|
// the connection is spliced byte-for-byte to the exit — NOT registered with the IP pool.
|
||||||
|
// * If nothing arrives within 2s or the first packet is not an `ExtendBridge` envelope, the
|
||||||
|
// connection falls back to the normal VPN-client path (IP pool + ServerRouter), exactly as
|
||||||
|
// in v2. This dual-role mode lets one server be a relay for some peers and an exit for
|
||||||
|
// others on the same listening port. Non-UDP transports (TCP, QUIC) skip rendezvous in
|
||||||
|
// v3.1; only UDP is supported as a hop transport.
|
||||||
loop {
|
loop {
|
||||||
let next = {
|
let next = {
|
||||||
let mut srv = server.lock().await;
|
let mut srv = server.lock().await;
|
||||||
@@ -257,6 +293,43 @@ pub async fn run(config_path: &Path, admin_socket: &str) -> anyhow::Result<()> {
|
|||||||
let mode = accepted.mode;
|
let mode = accepted.mode;
|
||||||
let conn = accepted.conn;
|
let conn = accepted.conn;
|
||||||
|
|
||||||
|
// v3.1 relay rendezvous (only on UDP-mode connections; v3.1 does not bridge TCP / QUIC).
|
||||||
|
if relay_enabled && mode == TransportMode::Udp {
|
||||||
|
match relay::rendezvous(&conn, &relay_whitelist).await {
|
||||||
|
RendezvousOutcome::Bridged { bridge } => {
|
||||||
|
// Spawn the two forwarder tasks and skip everything else (no IP pool entry,
|
||||||
|
// no router registration, no CRL push — bridged peers are opaque).
|
||||||
|
tracing::info!(
|
||||||
|
peer = ?peer_id, %mode,
|
||||||
|
"v3.1 relay: bridging connection to exit"
|
||||||
|
);
|
||||||
|
let client_conn = Arc::clone(&conn);
|
||||||
|
tokio::spawn(async move {
|
||||||
|
relay::run_bridge(client_conn, bridge).await;
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
RendezvousOutcome::Refused => {
|
||||||
|
tracing::warn!(
|
||||||
|
peer = ?peer_id, %mode,
|
||||||
|
"v3.1 relay: refusing connection (CircuitFailed sent); dropping"
|
||||||
|
);
|
||||||
|
drop(conn);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
RendezvousOutcome::Fallback { .. } => {
|
||||||
|
// Fall through to the normal VPN-client handling below. (The first packet, if
|
||||||
|
// any, was either non-existent or non-control — for v3.1 we drop it; control
|
||||||
|
// envelopes that are not ExtendBridge are not expected on the first packet
|
||||||
|
// from a v2 client either.)
|
||||||
|
tracing::debug!(
|
||||||
|
peer = ?peer_id, %mode,
|
||||||
|
"v3.1 relay: no ExtendBridge received; handling as normal VPN client"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Pick the client id used for static-pool lookup. The certificate CN is the only
|
// Pick the client id used for static-pool lookup. The certificate CN is the only
|
||||||
// identity we can trust here; if absent (defensive — every authenticated connection has
|
// identity we can trust here; if absent (defensive — every authenticated connection has
|
||||||
// one in practice) fall back to a unique-per-instance marker so dynamic allocation still
|
// one in practice) fall back to a unique-per-instance marker so dynamic allocation still
|
||||||
|
|||||||
@@ -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