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:
@@ -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<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).
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user