feat(proto,cli): v3.1 multi-hop scaffold — control kinds + config sections

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 <noreply@anthropic.com>
This commit is contained in:
xah30
2026-05-27 12:54:12 +03:00
parent b98752b3eb
commit 6c14c0d103
5 changed files with 348 additions and 2 deletions
+99
View File
@@ -109,6 +109,14 @@ pub struct ServerSection {
/// this is the v1 behaviour where the operator manually pre-configures forwarding. /// this is the v1 behaviour where the operator manually pre-configures forwarding.
#[serde(default)] #[serde(default)]
pub nat: Option<ServerNatSection>, pub nat: Option<ServerNatSection>,
/// `[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 /// 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 /// (TUN open, low-port bind, NAT configuration). When omitted (or already non-root) the
/// server keeps its current credentials. /// server keeps its current credentials.
@@ -122,6 +130,30 @@ pub struct ServerSection {
pub no_logs: bool, 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<String>,
}
/// `[server.nat]` section: v2 auto-NAT configuration. See [`crate::nat`] for the apply / rollback /// `[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 /// semantics. Optional — when the section is omitted the server makes no changes to the host's
/// IP forwarding state, matching v1 behaviour. /// IP forwarding state, matching v1 behaviour.
@@ -182,6 +214,26 @@ pub struct ClientConfigFile {
/// `[transport]` section: fallback order and per-transport ports/options. /// `[transport]` section: fallback order and per-transport ports/options.
#[serde(default)] #[serde(default)]
pub transport: TransportSection, 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<String>,
} }
/// `[client]` section. /// `[client]` section.
@@ -734,6 +786,30 @@ impl ServerConfigFile {
pub fn tcp_opts(&self) -> TcpOpts { pub fn tcp_opts(&self) -> TcpOpts {
TcpOpts::default() 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<SocketAddr> {
let mut out = Vec::new();
for raw in &self.server.relay.allow_extend_to {
match raw.parse::<SocketAddr>() {
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 { 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<Vec<SocketAddr>> {
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]`. /// Build a [`RouteTable`] from `[tunnel.split]`.
/// ///
/// CIDR rules are applied directly. Domain rules are recorded via [`RouteTable::add_domain`] /// CIDR rules are applied directly. Domain rules are recorded via [`RouteTable::add_domain`]
+10
View File
@@ -224,6 +224,16 @@ impl AcceptPushedCrlConn {
ControlKind::CrlAck => { ControlKind::CrlAck => {
tracing::debug!("server CRL ack received (unexpected — client does not push CRLs)"); 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) => { ControlKind::Unknown(b) => {
tracing::debug!(kind = b, "unknown control envelope kind; ignoring"); tracing::debug!(kind = b, "unknown control envelope kind; ignoring");
} }
+167
View File
@@ -186,12 +186,28 @@ mod frame_tag {
/// `0x4X` / `0x6X`, so the 4-byte magic [`CONTROL_ENVELOPE_MAGIC`] (which starts with `0xAA`) can /// `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 /// be safely multiplexed alongside ordinary packets without changing the on-wire frame schema or
/// any transport-level `match Frame` that already exists. /// 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)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ControlKind { pub enum ControlKind {
/// Server -> client: push the server's current CRL (signed payload). /// Server -> client: push the server's current CRL (signed payload).
CrlPush, CrlPush,
/// Client -> server: acknowledge a [`ControlKind::CrlPush`]. /// Client -> server: acknowledge a [`ControlKind::CrlPush`].
CrlAck, 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. /// Any byte the receiver does not recognise. The connection keeps running.
Unknown(u8), Unknown(u8),
} }
@@ -203,6 +219,9 @@ impl ControlKind {
match self { match self {
ControlKind::CrlPush => 0x01, ControlKind::CrlPush => 0x01,
ControlKind::CrlAck => 0x02, ControlKind::CrlAck => 0x02,
ControlKind::ExtendBridge => 0x03,
ControlKind::CircuitReady => 0x04,
ControlKind::CircuitFailed => 0x05,
ControlKind::Unknown(b) => b, ControlKind::Unknown(b) => b,
} }
} }
@@ -213,11 +232,82 @@ impl ControlKind {
match b { match b {
0x01 => ControlKind::CrlPush, 0x01 => ControlKind::CrlPush,
0x02 => ControlKind::CrlAck, 0x02 => ControlKind::CrlAck,
0x03 => ControlKind::ExtendBridge,
0x04 => ControlKind::CircuitReady,
0x05 => ControlKind::CircuitFailed,
other => ControlKind::Unknown(other), 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<u8> {
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<std::net::SocketAddr, &'static str> {
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). /// Application-level frames carried inside encrypted [`MsgType::Data`] records (§6.3).
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
pub enum Frame { pub enum Frame {
@@ -513,4 +603,81 @@ mod tests {
assert_eq!(kind, ControlKind::Unknown(0x77)); assert_eq!(kind, ControlKind::Unknown(0x77));
assert_eq!(payload, b"abc"); 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);
}
} }
+2 -2
View File
@@ -48,8 +48,8 @@ pub mod session;
pub use conn::PacketConnection; pub use conn::PacketConnection;
pub use frame::{ pub use frame::{
decode_control_envelope, encode_control_envelope, ControlKind, Frame, MsgType, decode_control_envelope, decode_extend_bridge, encode_control_envelope, encode_extend_bridge,
CONTROL_ENVELOPE_MAGIC, ControlKind, Frame, MsgType, CONTROL_ENVELOPE_MAGIC,
}; };
pub use handshake::{client_handshake, server_handshake}; pub use handshake::{client_handshake, server_handshake};
pub use session::{DatagramReceiver, DatagramSender, Session, SessionReceiver, SessionSender}; pub use session::{DatagramReceiver, DatagramSender, Session, SessionReceiver, SessionSender};
+70
View File
@@ -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"
);
}