6c14c0d103
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>
684 lines
25 KiB
Rust
684 lines
25 KiB
Rust
//! Wire format: the 5-byte protocol header (§6.1) and the application [`Frame`] enum (§6.3).
|
|
//!
|
|
//! Every Aura protocol message on the wire is a 5-byte header followed by a payload:
|
|
//!
|
|
//! ```text
|
|
//! byte 0 : msg_type (u8)
|
|
//! bytes 1..4 : length (u24, big-endian) = payload length in bytes
|
|
//! byte 4 : version = 0x01
|
|
//! bytes 5.. : payload (length bytes)
|
|
//! ```
|
|
//!
|
|
//! [`Frame`] is the post-handshake application payload. Each `Frame` is serialized with
|
|
//! [`Frame::encode`], AEAD-sealed, and shipped inside a [`MsgType::Data`] record (see
|
|
//! [`crate::session`]).
|
|
|
|
use bytes::Bytes;
|
|
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
|
|
|
|
use crate::ProtoError;
|
|
|
|
/// Length in bytes of the protocol frame header.
|
|
pub const HEADER_LEN: usize = 5;
|
|
|
|
/// Protocol version carried in byte 4 of every header.
|
|
pub const PROTOCOL_VERSION: u8 = 0x01;
|
|
|
|
/// Largest payload expressible by the u24 length field.
|
|
pub const MAX_PAYLOAD_LEN: usize = 0x00FF_FFFF;
|
|
|
|
/// Message types carried in byte 0 of the header (§6.1).
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
#[repr(u8)]
|
|
pub enum MsgType {
|
|
/// Handshake message 1 (C->S): hybrid public key + client nonce.
|
|
ClientHello = 0x01,
|
|
/// Handshake message 2 (S->C): hybrid ciphertext + server nonce.
|
|
ServerHello = 0x02,
|
|
/// Handshake message 4 (C->S, encrypted): client cert + signature.
|
|
ClientAuth = 0x03,
|
|
/// Handshake message 3 (S->C, encrypted): server cert + signature.
|
|
ServerAuth = 0x04,
|
|
/// Handshake Finished (encrypted): HMAC over the handshake hash.
|
|
Finished = 0x05,
|
|
/// Application data (encrypted): an AEAD-sealed [`Frame`].
|
|
Data = 0x06,
|
|
/// Fatal alert / error notification.
|
|
Alert = 0xFF,
|
|
}
|
|
|
|
impl MsgType {
|
|
/// Map the on-wire byte to a [`MsgType`].
|
|
///
|
|
/// # Errors
|
|
/// Returns [`ProtoError::UnknownMsgType`] for an unrecognized byte.
|
|
pub fn from_u8(b: u8) -> Result<Self, ProtoError> {
|
|
Ok(match b {
|
|
0x01 => Self::ClientHello,
|
|
0x02 => Self::ServerHello,
|
|
0x03 => Self::ClientAuth,
|
|
0x04 => Self::ServerAuth,
|
|
0x05 => Self::Finished,
|
|
0x06 => Self::Data,
|
|
0xFF => Self::Alert,
|
|
other => return Err(ProtoError::UnknownMsgType(other)),
|
|
})
|
|
}
|
|
}
|
|
|
|
/// Build a 5-byte header for `msg_type` carrying a payload of `payload_len` bytes.
|
|
///
|
|
/// # Errors
|
|
/// Returns [`ProtoError::FrameTooLarge`] if `payload_len` does not fit in the u24 length field.
|
|
pub fn encode_header(
|
|
msg_type: MsgType,
|
|
payload_len: usize,
|
|
) -> Result<[u8; HEADER_LEN], ProtoError> {
|
|
if payload_len > MAX_PAYLOAD_LEN {
|
|
return Err(ProtoError::FrameTooLarge(payload_len));
|
|
}
|
|
let len = payload_len as u32;
|
|
Ok([
|
|
msg_type as u8,
|
|
((len >> 16) & 0xFF) as u8,
|
|
((len >> 8) & 0xFF) as u8,
|
|
(len & 0xFF) as u8,
|
|
PROTOCOL_VERSION,
|
|
])
|
|
}
|
|
|
|
/// Parse a 5-byte header into `(msg_type, payload_len)`.
|
|
///
|
|
/// # Errors
|
|
/// Returns [`ProtoError::UnknownMsgType`] for an unrecognized type byte or
|
|
/// [`ProtoError::BadVersion`] if byte 4 is not [`PROTOCOL_VERSION`].
|
|
pub fn decode_header(header: &[u8; HEADER_LEN]) -> Result<(MsgType, usize), ProtoError> {
|
|
let msg_type = MsgType::from_u8(header[0])?;
|
|
let version = header[4];
|
|
if version != PROTOCOL_VERSION {
|
|
return Err(ProtoError::BadVersion(version));
|
|
}
|
|
let len = ((header[1] as usize) << 16) | ((header[2] as usize) << 8) | (header[3] as usize);
|
|
Ok((msg_type, len))
|
|
}
|
|
|
|
/// Write one full frame (`header || payload`) to `writer`.
|
|
///
|
|
/// # Errors
|
|
/// Returns [`ProtoError::FrameTooLarge`] if the payload is too long, or [`ProtoError::Io`] on a
|
|
/// write failure.
|
|
pub async fn write_frame<W>(
|
|
writer: &mut W,
|
|
msg_type: MsgType,
|
|
payload: &[u8],
|
|
) -> Result<(), ProtoError>
|
|
where
|
|
W: AsyncWrite + Unpin,
|
|
{
|
|
let header = encode_header(msg_type, payload.len())?;
|
|
writer.write_all(&header).await?;
|
|
writer.write_all(payload).await?;
|
|
writer.flush().await?;
|
|
Ok(())
|
|
}
|
|
|
|
/// A frame read off the wire: its type, the raw header bytes (useful as AEAD AAD and for the
|
|
/// handshake transcript hash), and the payload.
|
|
#[derive(Clone, Debug)]
|
|
pub struct RawFrame {
|
|
/// The decoded message type.
|
|
pub msg_type: MsgType,
|
|
/// The 5 header bytes exactly as transmitted.
|
|
pub header: [u8; HEADER_LEN],
|
|
/// The payload bytes.
|
|
pub payload: Vec<u8>,
|
|
}
|
|
|
|
impl RawFrame {
|
|
/// The full serialized frame (`header || payload`) exactly as it appeared on the wire.
|
|
///
|
|
/// Used to feed the handshake transcript hash, which must hash the bytes as transmitted.
|
|
#[must_use]
|
|
pub fn wire_bytes(&self) -> Vec<u8> {
|
|
let mut out = Vec::with_capacity(HEADER_LEN + self.payload.len());
|
|
out.extend_from_slice(&self.header);
|
|
out.extend_from_slice(&self.payload);
|
|
out
|
|
}
|
|
}
|
|
|
|
/// Read one full frame (`header || payload`) from `reader`.
|
|
///
|
|
/// # Errors
|
|
/// Returns [`ProtoError::Io`] on a read failure (including a truncated frame / EOF), or a header
|
|
/// decode error from [`decode_header`].
|
|
pub async fn read_frame<R>(reader: &mut R) -> Result<RawFrame, ProtoError>
|
|
where
|
|
R: AsyncRead + Unpin,
|
|
{
|
|
let mut header = [0u8; HEADER_LEN];
|
|
reader.read_exact(&mut header).await?;
|
|
let (msg_type, len) = decode_header(&header)?;
|
|
let mut payload = vec![0u8; len];
|
|
reader.read_exact(&mut payload).await?;
|
|
Ok(RawFrame {
|
|
msg_type,
|
|
header,
|
|
payload,
|
|
})
|
|
}
|
|
|
|
/// Frame type tags used in the application [`Frame`] encoding (§6.3).
|
|
mod frame_tag {
|
|
pub const DATA: u8 = 0x01;
|
|
pub const PING: u8 = 0x02;
|
|
pub const PONG: u8 = 0x03;
|
|
pub const CLOSE: u8 = 0x04;
|
|
}
|
|
|
|
/// Kinds of in-band control message carried inside a [`CONTROL_ENVELOPE_MAGIC`]-prefixed payload.
|
|
///
|
|
/// The wire byte is the discriminant. Unknown values decode as [`ControlKind::Unknown`] so peers
|
|
/// running older builds gracefully ignore future kinds without dropping the connection.
|
|
///
|
|
/// v2's CRL push reuses the existing post-handshake [`crate::PacketConnection::send_packet`] path
|
|
/// rather than introducing a new [`Frame`] variant: a real IPv4/IPv6 packet always starts with
|
|
/// `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),
|
|
}
|
|
|
|
impl ControlKind {
|
|
/// Encode this control kind to its on-wire byte.
|
|
#[must_use]
|
|
pub fn to_u8(self) -> u8 {
|
|
match self {
|
|
ControlKind::CrlPush => 0x01,
|
|
ControlKind::CrlAck => 0x02,
|
|
ControlKind::ExtendBridge => 0x03,
|
|
ControlKind::CircuitReady => 0x04,
|
|
ControlKind::CircuitFailed => 0x05,
|
|
ControlKind::Unknown(b) => b,
|
|
}
|
|
}
|
|
|
|
/// Decode an on-wire byte into a [`ControlKind`]. Unknown bytes yield [`ControlKind::Unknown`].
|
|
#[must_use]
|
|
pub fn from_u8(b: u8) -> Self {
|
|
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 {
|
|
/// A stream data payload.
|
|
Data {
|
|
/// Logical stream identifier.
|
|
stream_id: u32,
|
|
/// Opaque application bytes.
|
|
payload: Bytes,
|
|
},
|
|
/// Liveness probe.
|
|
Ping {
|
|
/// Monotonic sequence number echoed back in the matching [`Frame::Pong`].
|
|
seq: u32,
|
|
},
|
|
/// Reply to a [`Frame::Ping`].
|
|
Pong {
|
|
/// Sequence number copied from the [`Frame::Ping`].
|
|
seq: u32,
|
|
},
|
|
/// Orderly shutdown of the logical connection.
|
|
Close {
|
|
/// Application-defined close code.
|
|
code: u8,
|
|
/// Human-readable reason (UTF-8).
|
|
reason: String,
|
|
},
|
|
}
|
|
|
|
impl Frame {
|
|
/// Serialize this frame to its compact byte encoding.
|
|
///
|
|
/// Layout (all multi-byte integers big-endian):
|
|
/// * `Data` : `0x01 || stream_id(u32) || payload`
|
|
/// * `Ping` : `0x02 || seq(u32)`
|
|
/// * `Pong` : `0x03 || seq(u32)`
|
|
/// * `Close` : `0x04 || code(u8) || reason_len(u32) || reason_utf8`
|
|
#[must_use]
|
|
pub fn encode(&self) -> Vec<u8> {
|
|
let mut out = Vec::new();
|
|
match self {
|
|
Frame::Data { stream_id, payload } => {
|
|
out.push(frame_tag::DATA);
|
|
out.extend_from_slice(&stream_id.to_be_bytes());
|
|
out.extend_from_slice(payload);
|
|
}
|
|
Frame::Ping { seq } => {
|
|
out.push(frame_tag::PING);
|
|
out.extend_from_slice(&seq.to_be_bytes());
|
|
}
|
|
Frame::Pong { seq } => {
|
|
out.push(frame_tag::PONG);
|
|
out.extend_from_slice(&seq.to_be_bytes());
|
|
}
|
|
Frame::Close { code, reason } => {
|
|
out.push(frame_tag::CLOSE);
|
|
out.push(*code);
|
|
let bytes = reason.as_bytes();
|
|
out.extend_from_slice(&(bytes.len() as u32).to_be_bytes());
|
|
out.extend_from_slice(bytes);
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Parse a frame from its byte encoding (the inverse of [`Frame::encode`]).
|
|
///
|
|
/// # Errors
|
|
/// Returns [`ProtoError::MalformedFrame`] if the buffer is truncated, has an unknown tag, or
|
|
/// (for `Close`) does not contain valid UTF-8.
|
|
pub fn decode(buf: &[u8]) -> Result<Self, ProtoError> {
|
|
let (&tag, rest) = buf
|
|
.split_first()
|
|
.ok_or(ProtoError::MalformedFrame("empty frame"))?;
|
|
match tag {
|
|
frame_tag::DATA => {
|
|
let stream_id = read_u32(rest, "Data.stream_id")?;
|
|
let payload = Bytes::copy_from_slice(&rest[4..]);
|
|
Ok(Frame::Data { stream_id, payload })
|
|
}
|
|
frame_tag::PING => Ok(Frame::Ping {
|
|
seq: read_u32(rest, "Ping.seq")?,
|
|
}),
|
|
frame_tag::PONG => Ok(Frame::Pong {
|
|
seq: read_u32(rest, "Pong.seq")?,
|
|
}),
|
|
frame_tag::CLOSE => {
|
|
let code = *rest
|
|
.first()
|
|
.ok_or(ProtoError::MalformedFrame("Close: missing code"))?;
|
|
let reason_len = read_u32(&rest[1..], "Close.reason_len")? as usize;
|
|
let reason_bytes = rest
|
|
.get(5..5 + reason_len)
|
|
.ok_or(ProtoError::MalformedFrame("Close: truncated reason"))?;
|
|
let reason = String::from_utf8(reason_bytes.to_vec())
|
|
.map_err(|_| ProtoError::MalformedFrame("Close: reason not UTF-8"))?;
|
|
Ok(Frame::Close { code, reason })
|
|
}
|
|
_ => Err(ProtoError::MalformedFrame("unknown frame tag")),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Read a big-endian u32 from the start of `buf`, erroring if it is too short.
|
|
fn read_u32(buf: &[u8], what: &'static str) -> Result<u32, ProtoError> {
|
|
let bytes: [u8; 4] = buf
|
|
.get(..4)
|
|
.ok_or(ProtoError::MalformedFrame(what))?
|
|
.try_into()
|
|
.expect("slice of length 4 converts to [u8; 4]");
|
|
Ok(u32::from_be_bytes(bytes))
|
|
}
|
|
|
|
/// Magic prefix marking a v2 control-envelope multiplexed through [`PacketConnection::send_packet`].
|
|
///
|
|
/// An IPv4 packet's first byte is `0x4X` and an IPv6 packet's first byte is `0x6X`, so the four
|
|
/// magic bytes `[0xAA, 0xAA, 0xC0, 0x01]` can never collide with a real IP packet — the TUN layer
|
|
/// already rejects anything starting with a byte whose top nibble is not `4` or `6`.
|
|
///
|
|
/// Envelope layout:
|
|
///
|
|
/// ```text
|
|
/// CONTROL_ENVELOPE_MAGIC (4 bytes) || kind (u8) || u32_be(payload_len) || payload
|
|
/// ```
|
|
pub const CONTROL_ENVELOPE_MAGIC: [u8; 4] = [0xAA, 0xAA, 0xC0, 0x01];
|
|
|
|
/// Build a control envelope around `kind` + `payload`, suitable for
|
|
/// [`crate::PacketConnection::send_packet`].
|
|
///
|
|
/// Layout: `MAGIC(4) || kind(u8) || u32_be(payload_len) || payload`.
|
|
#[must_use]
|
|
pub fn encode_control_envelope(kind: ControlKind, payload: &[u8]) -> Vec<u8> {
|
|
let mut out = Vec::with_capacity(CONTROL_ENVELOPE_MAGIC.len() + 1 + 4 + payload.len());
|
|
out.extend_from_slice(&CONTROL_ENVELOPE_MAGIC);
|
|
out.push(kind.to_u8());
|
|
out.extend_from_slice(&(payload.len() as u32).to_be_bytes());
|
|
out.extend_from_slice(payload);
|
|
out
|
|
}
|
|
|
|
/// Try to decode a buffer as a control envelope.
|
|
///
|
|
/// Returns `None` if `buf` does not start with [`CONTROL_ENVELOPE_MAGIC`] (i.e. it is a normal IP
|
|
/// packet). Returns [`ProtoError::MalformedFrame`] if the buffer starts with the magic but is
|
|
/// truncated or its length field overflows the buffer.
|
|
pub fn decode_control_envelope(buf: &[u8]) -> Result<Option<(ControlKind, Vec<u8>)>, ProtoError> {
|
|
if buf.len() < CONTROL_ENVELOPE_MAGIC.len() || &buf[..4] != CONTROL_ENVELOPE_MAGIC.as_slice() {
|
|
return Ok(None);
|
|
}
|
|
let rest = &buf[CONTROL_ENVELOPE_MAGIC.len()..];
|
|
let kind_byte = *rest
|
|
.first()
|
|
.ok_or(ProtoError::MalformedFrame("control envelope: missing kind"))?;
|
|
let kind = ControlKind::from_u8(kind_byte);
|
|
let len_bytes: [u8; 4] = rest
|
|
.get(1..5)
|
|
.ok_or(ProtoError::MalformedFrame(
|
|
"control envelope: missing payload length",
|
|
))?
|
|
.try_into()
|
|
.expect("slice of length 4 converts to [u8; 4]");
|
|
let payload_len = u32::from_be_bytes(len_bytes) as usize;
|
|
let payload = rest
|
|
.get(5..5 + payload_len)
|
|
.ok_or(ProtoError::MalformedFrame(
|
|
"control envelope: truncated payload",
|
|
))?
|
|
.to_vec();
|
|
Ok(Some((kind, payload)))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn header_roundtrip_all_types() {
|
|
for (ty, byte) in [
|
|
(MsgType::ClientHello, 0x01u8),
|
|
(MsgType::ServerHello, 0x02),
|
|
(MsgType::ClientAuth, 0x03),
|
|
(MsgType::ServerAuth, 0x04),
|
|
(MsgType::Finished, 0x05),
|
|
(MsgType::Data, 0x06),
|
|
(MsgType::Alert, 0xFF),
|
|
] {
|
|
let h = encode_header(ty, 0x0012_3456).unwrap();
|
|
assert_eq!(h[0], byte);
|
|
assert_eq!(h[4], PROTOCOL_VERSION);
|
|
let (got_ty, got_len) = decode_header(&h).unwrap();
|
|
assert_eq!(got_ty, ty);
|
|
assert_eq!(got_len, 0x0012_3456);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn header_rejects_oversize_and_bad_version() {
|
|
assert!(matches!(
|
|
encode_header(MsgType::Data, MAX_PAYLOAD_LEN + 1),
|
|
Err(ProtoError::FrameTooLarge(_))
|
|
));
|
|
let mut h = encode_header(MsgType::Data, 1).unwrap();
|
|
h[4] = 0x02;
|
|
assert!(matches!(
|
|
decode_header(&h),
|
|
Err(ProtoError::BadVersion(0x02))
|
|
));
|
|
h[0] = 0x77;
|
|
assert!(matches!(
|
|
decode_header(&h),
|
|
Err(ProtoError::UnknownMsgType(0x77))
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn frame_roundtrip() {
|
|
let frames = vec![
|
|
Frame::Data {
|
|
stream_id: 0xDEAD_BEEF,
|
|
payload: Bytes::from_static(b"hello world"),
|
|
},
|
|
Frame::Data {
|
|
stream_id: 0,
|
|
payload: Bytes::new(),
|
|
},
|
|
Frame::Ping { seq: 42 },
|
|
Frame::Pong { seq: 0xFFFF_FFFF },
|
|
Frame::Close {
|
|
code: 7,
|
|
reason: "going away \u{1f44b}".to_string(),
|
|
},
|
|
Frame::Close {
|
|
code: 0,
|
|
reason: String::new(),
|
|
},
|
|
];
|
|
for f in frames {
|
|
let encoded = f.encode();
|
|
let decoded = Frame::decode(&encoded).unwrap();
|
|
assert_eq!(f, decoded);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn frame_decode_rejects_garbage() {
|
|
assert!(Frame::decode(&[]).is_err());
|
|
assert!(Frame::decode(&[0x99]).is_err());
|
|
assert!(Frame::decode(&[frame_tag::PING, 0x00]).is_err()); // truncated u32
|
|
assert!(Frame::decode(&[frame_tag::CLOSE]).is_err()); // missing code
|
|
}
|
|
|
|
#[test]
|
|
fn control_envelope_roundtrip() {
|
|
let env = encode_control_envelope(ControlKind::CrlPush, b"hello");
|
|
assert_eq!(&env[..4], &CONTROL_ENVELOPE_MAGIC);
|
|
let (kind, payload) = decode_control_envelope(&env).unwrap().unwrap();
|
|
assert_eq!(kind, ControlKind::CrlPush);
|
|
assert_eq!(payload, b"hello");
|
|
}
|
|
|
|
#[test]
|
|
fn control_envelope_skips_normal_ip_packets() {
|
|
// IPv4 packet: first byte's top nibble is 4. Never collides with magic.
|
|
let ipv4 = vec![0x45u8, 0x00, 0x00, 0x14];
|
|
assert!(decode_control_envelope(&ipv4).unwrap().is_none());
|
|
// IPv6 packet: first byte's top nibble is 6.
|
|
let ipv6 = vec![0x60u8, 0x00, 0x00, 0x00];
|
|
assert!(decode_control_envelope(&ipv6).unwrap().is_none());
|
|
// Random short bytes that do not match the magic.
|
|
let other = vec![0xAAu8, 0xAA, 0xC0, 0x02];
|
|
assert!(decode_control_envelope(&other).unwrap().is_none());
|
|
// Shorter than the magic.
|
|
assert!(decode_control_envelope(&[0xAA, 0xAA]).unwrap().is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn control_envelope_rejects_truncated_payload() {
|
|
let mut env = encode_control_envelope(ControlKind::CrlPush, b"payload-bytes");
|
|
// Trim a few bytes from the end to truncate the payload claimed by the length field.
|
|
env.truncate(env.len() - 3);
|
|
assert!(decode_control_envelope(&env).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn control_envelope_unknown_kind_decodes_as_unknown() {
|
|
// Hand-craft an envelope with a future kind byte.
|
|
let mut env = Vec::new();
|
|
env.extend_from_slice(&CONTROL_ENVELOPE_MAGIC);
|
|
env.push(0x77); // unknown kind
|
|
env.extend_from_slice(&3u32.to_be_bytes());
|
|
env.extend_from_slice(b"abc");
|
|
let (kind, payload) = decode_control_envelope(&env).unwrap().unwrap();
|
|
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);
|
|
}
|
|
}
|