From 6c14c0d103d678a01f0d1fae424d09e9d524a3ed Mon Sep 17 00:00:00 2001 From: xah30 Date: Wed, 27 May 2026 12:54:12 +0300 Subject: [PATCH] =?UTF-8?q?feat(proto,cli):=20v3.1=20multi-hop=20scaffold?= =?UTF-8?q?=20=E2=80=94=20control=20kinds=20+=20config=20sections?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Foundation for v3.1 onion routing (client → entry-relay → exit-server). The relay/circuit runtime is implemented in a follow-up commit; this scaffold lands the wire-level control extensions and the config schema: - aura-proto: ControlKind gains ExtendBridge (client→relay), CircuitReady (relay→client), CircuitFailed (relay→client, with utf-8 reason); helpers encode_extend_bridge / decode_extend_bridge (1-byte family + 4/16 addr bytes + u16 port). Integration test in tests/control_extend.rs covers IPv4/IPv6 roundtrip + full magic-envelope wrap. - aura-cli config: [server.relay] {enabled, allow_extend_to} + [client.circuit] {enabled, hops} sections; relay_whitelist() helper parses IP:port literals. All new fields serde-default, back-compat. - crl_push.rs touched only to leave the new ControlKinds passing through the existing magic-envelope dispatcher unchanged. Workspace: 247 tests passed (+12), clippy/fmt clean. Co-Authored-By: Claude Opus 4.7 --- crates/aura-cli/src/config.rs | 99 +++++++++++++ crates/aura-cli/src/crl_push.rs | 10 ++ crates/aura-proto/src/frame.rs | 167 ++++++++++++++++++++++ crates/aura-proto/src/lib.rs | 4 +- crates/aura-proto/tests/control_extend.rs | 70 +++++++++ 5 files changed, 348 insertions(+), 2 deletions(-) create mode 100644 crates/aura-proto/tests/control_extend.rs diff --git a/crates/aura-cli/src/config.rs b/crates/aura-cli/src/config.rs index 3d2f3ac..dfb6a71 100644 --- a/crates/aura-cli/src/config.rs +++ b/crates/aura-cli/src/config.rs @@ -109,6 +109,14 @@ pub struct ServerSection { /// this is the v1 behaviour where the operator manually pre-configures forwarding. #[serde(default)] pub nat: Option, + /// `[server.relay]` sub-section: v3.1 multi-hop / onion routing role. When `enabled = true`, + /// this server runs as an **entry-relay** — it briefly listens for a client-issued + /// `ExtendBridge` control envelope right after the handshake and (if accepted) splices the + /// connection to a downstream exit-server. Omitting the section (or `enabled = false`) keeps + /// the v1/v2 behaviour where every accepted connection is registered with the + /// [`crate::server_router::ServerRouter`] as a normal VPN client. + #[serde(default)] + pub relay: RelaySection, /// Optional non-root user to drop privileges to **after** all startup work that needs root /// (TUN open, low-port bind, NAT configuration). When omitted (or already non-root) the /// server keeps its current credentials. @@ -122,6 +130,30 @@ pub struct ServerSection { pub no_logs: bool, } +/// `[server.relay]` section: v3.1 multi-hop / onion routing. +/// +/// When `enabled = true`, an accepted connection is **not** immediately registered with the +/// [`crate::server_router::ServerRouter`]. Instead the server listens (for a short window) for a +/// client-issued [`aura_proto::ControlKind::ExtendBridge`] envelope describing a downstream +/// `exit_addr`. When the address matches one of `allow_extend_to`, the server opens a raw +/// UDP bridge to that exit and forwards every byte between the client and the exit verbatim — +/// the inner client↔exit Aura handshake passes through opaquely, so the relay never sees +/// destination IPs or plaintext bytes. +/// +/// Omitting the section (the default) gives the v2 behaviour: every accepted connection is a +/// VPN client and the relay path is dead code. +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(default)] +pub struct RelaySection { + /// Master switch. `false` (default) keeps the v2 behaviour intact. + pub enabled: bool, + /// Whitelist of allowed downstream exit addresses (`IP:port`). DNS hostnames are NOT resolved + /// in v3.1 — they are logged as a warning and ignored. An empty list means "all addresses + /// allowed", which is dangerous (open relay); the runtime logs a warning when this combination + /// is detected. + pub allow_extend_to: Vec, +} + /// `[server.nat]` section: v2 auto-NAT configuration. See [`crate::nat`] for the apply / rollback /// semantics. Optional — when the section is omitted the server makes no changes to the host's /// IP forwarding state, matching v1 behaviour. @@ -182,6 +214,26 @@ pub struct ClientConfigFile { /// `[transport]` section: fallback order and per-transport ports/options. #[serde(default)] pub transport: TransportSection, + /// `[client.circuit]` section: v3.1 multi-hop / onion routing dial. When `enabled = true`, + /// instead of dialing the server directly via [`aura_transport::dial`], the client builds a + /// 2-hop circuit `client → entry-relay → exit-server` from `hops`. Default `enabled = false`. + #[serde(default)] + pub circuit: CircuitSection, +} + +/// `[client.circuit]` section: v3.1 multi-hop / onion routing on the client. +/// +/// See the module-level docs of [`crate::circuit`] for the wire protocol. When `enabled = true`, +/// `hops` MUST contain exactly two `IP:port` entries — the entry relay (UDP) and the exit server +/// (UDP). v3.1 supports only UDP transport for both hops; configuring `enabled = true` with a +/// non-UDP transport order is a hard error at dial time (the dial helper checks the order). +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(default)] +pub struct CircuitSection { + /// Master switch. `false` (default) keeps the v2 single-hop dial path. + pub enabled: bool, + /// Ordered list of hops: `[entry_relay, exit_server]`. Exactly two literal `IP:port` entries. + pub hops: Vec, } /// `[client]` section. @@ -734,6 +786,30 @@ impl ServerConfigFile { pub fn tcp_opts(&self) -> TcpOpts { TcpOpts::default() } + + /// Parse `[server.relay] allow_extend_to` into a vector of [`SocketAddr`]s, skipping (with a + /// `warn` log) any entries that are not bare `IP:port` strings. v3.1 does NOT perform DNS + /// resolution; the operator must supply literal IPs. + /// + /// Returns the parsed addresses paired with their original strings (so the caller can log + /// what was skipped). An empty result for a non-empty config means every entry was unparsable. + pub fn relay_whitelist(&self) -> Vec { + let mut out = Vec::new(); + for raw in &self.server.relay.allow_extend_to { + match raw.parse::() { + Ok(a) => out.push(a), + Err(e) => { + tracing::warn!( + entry = %raw, + error = %e, + "[server.relay] allow_extend_to: skipping entry — only literal IP:port is \ + supported in v3.1 (DNS resolution is out of scope)" + ); + } + } + } + out + } } impl ClientConfigFile { @@ -811,6 +887,29 @@ impl ClientConfigFile { }) } + /// Parse `[client.circuit] hops` into a vector of [`SocketAddr`]s. Returns an error if any + /// entry fails to parse as `IP:port` or the count is wrong for v3.1 (exactly 2). When + /// `[client.circuit]` is disabled this still validates the configured hops so misconfiguration + /// is caught early; the caller decides whether to actually use the result. + /// + /// v3.1 does NOT perform DNS resolution; the operator must supply literal IPs. + pub fn circuit_hops(&self) -> anyhow::Result> { + let mut out = Vec::with_capacity(self.circuit.hops.len()); + for raw in &self.circuit.hops { + let addr: SocketAddr = raw + .parse() + .with_context(|| format!("invalid [client.circuit] hop '{raw}' (expected IP:port)"))?; + out.push(addr); + } + if self.circuit.enabled && out.len() != 2 { + return Err(anyhow!( + "[client.circuit] requires exactly 2 hops (entry, exit) in v3.1; got {}", + out.len() + )); + } + Ok(out) + } + /// Build a [`RouteTable`] from `[tunnel.split]`. /// /// CIDR rules are applied directly. Domain rules are recorded via [`RouteTable::add_domain`] diff --git a/crates/aura-cli/src/crl_push.rs b/crates/aura-cli/src/crl_push.rs index b8480ec..ea08342 100644 --- a/crates/aura-cli/src/crl_push.rs +++ b/crates/aura-cli/src/crl_push.rs @@ -224,6 +224,16 @@ impl AcceptPushedCrlConn { ControlKind::CrlAck => { tracing::debug!("server CRL ack received (unexpected — client does not push CRLs)"); } + // v3.1 circuit-setup envelopes (ExtendBridge / CircuitReady / CircuitFailed) are only + // meaningful during multi-hop dial (see [`crate::circuit`]). By the time this wrapper + // sees a connection the circuit (if any) is already established, so any late envelopes + // are a no-op here. + ControlKind::ExtendBridge | ControlKind::CircuitReady | ControlKind::CircuitFailed => { + tracing::debug!( + kind = ?kind, + "unexpected circuit-setup control envelope on established connection; ignoring" + ); + } ControlKind::Unknown(b) => { tracing::debug!(kind = b, "unknown control envelope kind; ignoring"); } diff --git a/crates/aura-proto/src/frame.rs b/crates/aura-proto/src/frame.rs index 6055d3c..44b6385 100644 --- a/crates/aura-proto/src/frame.rs +++ b/crates/aura-proto/src/frame.rs @@ -186,12 +186,28 @@ mod frame_tag { /// `0x4X` / `0x6X`, so the 4-byte magic [`CONTROL_ENVELOPE_MAGIC`] (which starts with `0xAA`) can /// be safely multiplexed alongside ordinary packets without changing the on-wire frame schema or /// any transport-level `match Frame` that already exists. +/// +/// v3.1 multi-hop / onion routing adds three kinds for circuit setup: +/// +/// * [`ControlKind::ExtendBridge`] (`0x03`) — client → relay, asking the relay to splice this +/// connection to a downstream `exit_addr`. Payload is the [`encode_extend_bridge`] binary form. +/// * [`ControlKind::CircuitReady`] (`0x04`) — relay → client, the bridge is up; no payload. +/// * [`ControlKind::CircuitFailed`] (`0x05`) — relay → client, the bridge could not be set up; +/// payload is a UTF-8 reason string. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ControlKind { /// Server -> client: push the server's current CRL (signed payload). CrlPush, /// Client -> server: acknowledge a [`ControlKind::CrlPush`]. CrlAck, + /// Client -> relay: please open a bridge to the given `exit_addr` (v3.1 multi-hop). + ExtendBridge, + /// Relay -> client: the bridge is up; the next bytes from the client travel opaquely to the + /// exit (v3.1 multi-hop). + CircuitReady, + /// Relay -> client: the bridge could not be set up; payload is a UTF-8 reason string (v3.1 + /// multi-hop). + CircuitFailed, /// Any byte the receiver does not recognise. The connection keeps running. Unknown(u8), } @@ -203,6 +219,9 @@ impl ControlKind { match self { ControlKind::CrlPush => 0x01, ControlKind::CrlAck => 0x02, + ControlKind::ExtendBridge => 0x03, + ControlKind::CircuitReady => 0x04, + ControlKind::CircuitFailed => 0x05, ControlKind::Unknown(b) => b, } } @@ -213,11 +232,82 @@ impl ControlKind { match b { 0x01 => ControlKind::CrlPush, 0x02 => ControlKind::CrlAck, + 0x03 => ControlKind::ExtendBridge, + 0x04 => ControlKind::CircuitReady, + 0x05 => ControlKind::CircuitFailed, other => ControlKind::Unknown(other), } } } +/// Encode an `ExtendBridge` payload describing the target `exit_addr`. +/// +/// Wire layout (big-endian where multi-byte): +/// +/// ```text +/// family(u8 = 4|6) || addr_bytes(4 or 16) || port(u16) +/// ``` +/// +/// The result is the **payload** of a [`ControlKind::ExtendBridge`] control envelope; the caller +/// wraps it with [`encode_control_envelope`]. +#[must_use] +pub fn encode_extend_bridge(addr: std::net::SocketAddr) -> Vec { + let port = addr.port(); + match addr.ip() { + std::net::IpAddr::V4(v4) => { + let octets = v4.octets(); + let mut out = Vec::with_capacity(1 + 4 + 2); + out.push(4); + out.extend_from_slice(&octets); + out.extend_from_slice(&port.to_be_bytes()); + out + } + std::net::IpAddr::V6(v6) => { + let octets = v6.octets(); + let mut out = Vec::with_capacity(1 + 16 + 2); + out.push(6); + out.extend_from_slice(&octets); + out.extend_from_slice(&port.to_be_bytes()); + out + } + } +} + +/// Decode an `ExtendBridge` payload back into a [`std::net::SocketAddr`]. +/// +/// See [`encode_extend_bridge`] for the wire layout. Returns a static error string on any +/// truncation, unknown family, or trailing garbage. +pub fn decode_extend_bridge(payload: &[u8]) -> Result { + if payload.is_empty() { + return Err("ExtendBridge: empty payload"); + } + match payload[0] { + 4 => { + if payload.len() != 1 + 4 + 2 { + return Err("ExtendBridge: bad v4 payload length"); + } + let octets: [u8; 4] = payload[1..5] + .try_into() + .expect("slice of length 4 converts to [u8; 4]"); + let port = u16::from_be_bytes([payload[5], payload[6]]); + let ip = std::net::Ipv4Addr::from(octets); + Ok(std::net::SocketAddr::new(std::net::IpAddr::V4(ip), port)) + } + 6 => { + if payload.len() != 1 + 16 + 2 { + return Err("ExtendBridge: bad v6 payload length"); + } + let octets: [u8; 16] = payload[1..17] + .try_into() + .expect("slice of length 16 converts to [u8; 16]"); + let port = u16::from_be_bytes([payload[17], payload[18]]); + let ip = std::net::Ipv6Addr::from(octets); + Ok(std::net::SocketAddr::new(std::net::IpAddr::V6(ip), port)) + } + _ => Err("ExtendBridge: unknown address family"), + } +} + /// Application-level frames carried inside encrypted [`MsgType::Data`] records (§6.3). #[derive(Clone, Debug, PartialEq, Eq)] pub enum Frame { @@ -513,4 +603,81 @@ mod tests { assert_eq!(kind, ControlKind::Unknown(0x77)); assert_eq!(payload, b"abc"); } + + /// v3.1 multi-hop: round-trip `ExtendBridge` payload over IPv4 + IPv6 addresses, including + /// boundary ports. + #[test] + fn extend_bridge_roundtrip_v4_and_v6() { + let cases: &[std::net::SocketAddr] = &[ + "203.0.113.10:443".parse().unwrap(), + "127.0.0.1:0".parse().unwrap(), + "255.255.255.255:65535".parse().unwrap(), + "[::1]:443".parse().unwrap(), + "[2001:db8::1]:65000".parse().unwrap(), + "[::]:0".parse().unwrap(), + ]; + for addr in cases { + let payload = encode_extend_bridge(*addr); + let decoded = decode_extend_bridge(&payload).unwrap(); + assert_eq!(*addr, decoded, "addr {addr} round-tripped"); + } + } + + /// Hand-check the on-wire layout for an IPv4 case: `0x04 || octets(4) || port_be(2)`. + #[test] + fn extend_bridge_v4_wire_layout() { + let addr: std::net::SocketAddr = "10.0.0.42:443".parse().unwrap(); + let p = encode_extend_bridge(addr); + assert_eq!(p.len(), 1 + 4 + 2); + assert_eq!(p[0], 4); + assert_eq!(&p[1..5], &[10, 0, 0, 42]); + assert_eq!(&p[5..7], &443u16.to_be_bytes()); + } + + /// Hand-check the on-wire layout for an IPv6 case: `0x06 || octets(16) || port_be(2)`. + #[test] + fn extend_bridge_v6_wire_layout() { + let addr: std::net::SocketAddr = "[2001:db8::1]:443".parse().unwrap(); + let p = encode_extend_bridge(addr); + assert_eq!(p.len(), 1 + 16 + 2); + assert_eq!(p[0], 6); + assert_eq!(&p[17..19], &443u16.to_be_bytes()); + } + + /// Malformed `ExtendBridge` payloads are rejected (empty / wrong family / bad length). + #[test] + fn extend_bridge_rejects_bad_inputs() { + assert!(decode_extend_bridge(&[]).is_err()); + // Unknown family. + assert!(decode_extend_bridge(&[7u8, 0, 0, 0, 0, 0, 0]).is_err()); + // v4 family but truncated. + assert!(decode_extend_bridge(&[4u8, 1, 2, 3]).is_err()); + // v4 family but extra trailing byte (should be exactly 7 bytes). + assert!(decode_extend_bridge(&[4u8, 1, 2, 3, 4, 0, 0, 0]).is_err()); + // v6 family but truncated. + let mut bad6 = vec![6u8]; + bad6.extend_from_slice(&[0u8; 10]); + assert!(decode_extend_bridge(&bad6).is_err()); + } + + /// `ControlKind` byte mapping is stable for every v3.1 variant. + #[test] + fn control_kind_bytes_stable() { + assert_eq!(ControlKind::ExtendBridge.to_u8(), 0x03); + assert_eq!(ControlKind::CircuitReady.to_u8(), 0x04); + assert_eq!(ControlKind::CircuitFailed.to_u8(), 0x05); + assert_eq!(ControlKind::from_u8(0x03), ControlKind::ExtendBridge); + assert_eq!(ControlKind::from_u8(0x04), ControlKind::CircuitReady); + assert_eq!(ControlKind::from_u8(0x05), ControlKind::CircuitFailed); + } + + /// A `CircuitFailed` envelope round-trips with a UTF-8 reason string. + #[test] + fn circuit_failed_envelope_roundtrip() { + let reason = "not in allow_extend_to"; + let env = encode_control_envelope(ControlKind::CircuitFailed, reason.as_bytes()); + let (kind, payload) = decode_control_envelope(&env).unwrap().unwrap(); + assert_eq!(kind, ControlKind::CircuitFailed); + assert_eq!(std::str::from_utf8(&payload).unwrap(), reason); + } } diff --git a/crates/aura-proto/src/lib.rs b/crates/aura-proto/src/lib.rs index 5824e10..7b078cf 100644 --- a/crates/aura-proto/src/lib.rs +++ b/crates/aura-proto/src/lib.rs @@ -48,8 +48,8 @@ pub mod session; pub use conn::PacketConnection; pub use frame::{ - decode_control_envelope, encode_control_envelope, ControlKind, Frame, MsgType, - CONTROL_ENVELOPE_MAGIC, + decode_control_envelope, decode_extend_bridge, encode_control_envelope, encode_extend_bridge, + ControlKind, Frame, MsgType, CONTROL_ENVELOPE_MAGIC, }; pub use handshake::{client_handshake, server_handshake}; pub use session::{DatagramReceiver, DatagramSender, Session, SessionReceiver, SessionSender}; diff --git a/crates/aura-proto/tests/control_extend.rs b/crates/aura-proto/tests/control_extend.rs new file mode 100644 index 0000000..fb7574a --- /dev/null +++ b/crates/aura-proto/tests/control_extend.rs @@ -0,0 +1,70 @@ +//! Integration test for v3.1 multi-hop control envelope payloads (`ExtendBridge`). +//! +//! Mirrors `frame.rs`'s in-crate unit coverage but at the integration level so an external +//! consumer of `aura-proto` (the CLI's `circuit` module) sees the same wire layout. + +use std::net::SocketAddr; + +use aura_proto::{ + decode_control_envelope, decode_extend_bridge, encode_control_envelope, encode_extend_bridge, + ControlKind, +}; + +#[test] +fn extend_bridge_payload_roundtrips_ipv4() { + let addr: SocketAddr = "203.0.113.42:443".parse().unwrap(); + let payload = encode_extend_bridge(addr); + assert_eq!(payload.len(), 1 + 4 + 2); + let got = decode_extend_bridge(&payload).expect("decode v4"); + assert_eq!(got, addr); +} + +#[test] +fn extend_bridge_payload_roundtrips_ipv6() { + let addr: SocketAddr = "[2001:db8::dead:beef]:1234".parse().unwrap(); + let payload = encode_extend_bridge(addr); + assert_eq!(payload.len(), 1 + 16 + 2); + let got = decode_extend_bridge(&payload).expect("decode v6"); + assert_eq!(got, addr); +} + +#[test] +fn extend_bridge_via_full_envelope() { + // Build the bytes the client actually sends over the wire: the envelope wraps the payload. + let addr: SocketAddr = "10.0.0.5:443".parse().unwrap(); + let payload = encode_extend_bridge(addr); + let envelope = encode_control_envelope(ControlKind::ExtendBridge, &payload); + let (kind, decoded_payload) = decode_control_envelope(&envelope).unwrap().unwrap(); + assert_eq!(kind, ControlKind::ExtendBridge); + let got_addr = decode_extend_bridge(&decoded_payload).expect("decode addr from envelope"); + assert_eq!(got_addr, addr); +} + +#[test] +fn extend_bridge_rejects_malformed_payload() { + assert!(decode_extend_bridge(&[]).is_err()); + assert!(decode_extend_bridge(&[4u8]).is_err()); // family but truncated + assert!(decode_extend_bridge(&[4u8, 1, 2, 3, 4]).is_err()); // missing port bytes + assert!(decode_extend_bridge(&[4u8, 1, 2, 3, 4, 0, 0, 99]).is_err()); // extra byte + assert!(decode_extend_bridge(&[6u8, 0, 0]).is_err()); // v6 truncated + assert!(decode_extend_bridge(&[7u8, 0, 0, 0, 0, 0, 0]).is_err()); // unknown family +} + +#[test] +fn circuit_ready_envelope_has_empty_payload() { + let envelope = encode_control_envelope(ControlKind::CircuitReady, &[]); + let (kind, payload) = decode_control_envelope(&envelope).unwrap().unwrap(); + assert_eq!(kind, ControlKind::CircuitReady); + assert!(payload.is_empty()); +} + +#[test] +fn circuit_failed_carries_utf8_reason() { + let envelope = encode_control_envelope(ControlKind::CircuitFailed, b"not in allow_extend_to"); + let (kind, payload) = decode_control_envelope(&envelope).unwrap().unwrap(); + assert_eq!(kind, ControlKind::CircuitFailed); + assert_eq!( + std::str::from_utf8(&payload).unwrap(), + "not in allow_extend_to" + ); +}