9dfa06ffee
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
Signed-off-by: cls_宁波本机 <908705107@qq.com>
275 lines
10 KiB
Rust
275 lines
10 KiB
Rust
//! The mesh wire contract — FROZEN surface.
|
|
//!
|
|
//! Every byte that crosses the mesh is one of the frames in this module,
|
|
//! postcard-encoded behind a one-byte protocol version. This file is the
|
|
//! contract between all mesh lanes: transport (endpoint/peer), membership
|
|
//! (gossip/registry), the session directory, and the media fan-out all build
|
|
//! against these types. **Changes here require a post in the mesh thread
|
|
//! before the edit** — two lanes compiling against different frame layouts is
|
|
//! the failure mode this file exists to prevent.
|
|
//!
|
|
//! ## The fencing law (non-negotiable)
|
|
//!
|
|
//! Every session-bearing frame carries the fenced tuple
|
|
//! [`FencedHeader`] `{session_id, generation, owner_runtime_id}`. Receivers
|
|
//! MUST reject frames whose generation is stale for that session, at every
|
|
//! hop. Mesh membership is a hint; the fenced generation (Redis CAS lease)
|
|
//! is the arbiter. The mesh may say "don't dial" — it may never say "take
|
|
//! over."
|
|
//!
|
|
//! ## Framing
|
|
//!
|
|
//! - **Datagrams** (realtime-media): one [`MeshDatagram`] per QUIC datagram,
|
|
//! postcard-encoded, no length prefix (the datagram boundary is the frame
|
|
//! boundary). Senders MUST check the encoded size against the connection's
|
|
//! `max_datagram_size()` and fail loud, never truncate.
|
|
//! - **Bi-streams** (reliable-stream + gossip control): length-delimited
|
|
//! postcard. Each frame is a u32-LE length followed by that many bytes of
|
|
//! postcard-encoded [`MeshStreamFrame`]. Max frame size: [`MAX_STREAM_FRAME`].
|
|
//! The first frame on any stream MUST be `Hello`; a non-`Hello` first frame
|
|
//! is a protocol error and the stream is reset.
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
use uuid::Uuid;
|
|
|
|
/// ALPN for the mesh QUIC endpoint. Version bumps get a new ALPN so old and
|
|
/// new pods never half-speak to each other during a rolling deploy.
|
|
pub const ALPN: &[u8] = b"buzz/mesh/1";
|
|
|
|
/// Wire protocol version, first byte of every encoded frame (datagram or
|
|
/// stream frame). Receivers MUST reject unknown versions loudly (count it,
|
|
/// log it) rather than guessing.
|
|
pub const WIRE_VERSION: u8 = 1;
|
|
|
|
/// Hard cap on a single length-delimited stream frame (16 MiB). Anything
|
|
/// larger is a protocol error, not a bigger buffer.
|
|
pub const MAX_STREAM_FRAME: u32 = 16 * 1024 * 1024;
|
|
|
|
/// A relay runtime's mesh identity: the ed25519 public key of the **mesh
|
|
/// endpoint keypair generated fresh at process start**. This is both the
|
|
/// iroh endpoint id and the boot-unique runtime id used in the ready
|
|
/// registry and ownership leases — one value, boot-unique by construction.
|
|
///
|
|
/// It is deliberately NOT the deployment's Nostr relay key: that key is
|
|
/// secp256k1, and the helm chart shares one `BUZZ_RELAY_PRIVATE_KEY` Secret
|
|
/// across all pods of a release — using it here would give every pod the
|
|
/// same runtime id and collapse the ownership plane (Wren's contract-review
|
|
/// blocker). Binding to the deployment identity is done out-of-band: the
|
|
/// ready-registry record carries a relay-key-signed attestation of the
|
|
/// runtime pubkey (membership lane), and peers accept mesh connections only
|
|
/// from endpoint ids present in attested registry/gossip records.
|
|
#[derive(Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
|
pub struct RuntimeId(pub [u8; 32]);
|
|
|
|
impl RuntimeId {
|
|
pub fn to_hex(&self) -> String {
|
|
hex::encode(self.0)
|
|
}
|
|
}
|
|
|
|
impl std::fmt::Debug for RuntimeId {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
write!(f, "RuntimeId({}…)", &self.to_hex()[..8])
|
|
}
|
|
}
|
|
|
|
impl std::fmt::Display for RuntimeId {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.write_str(&self.to_hex())
|
|
}
|
|
}
|
|
|
|
/// The fenced tuple. Present on every session-bearing frame; checked at
|
|
/// every hop against the Redis lease.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct FencedHeader {
|
|
pub session_id: Uuid,
|
|
/// Monotonic lease generation from the Redis CAS. A receiver that has
|
|
/// observed generation G for a session rejects any frame with < G.
|
|
pub generation: u64,
|
|
/// The runtime the sender believes owns the session. Advisory for
|
|
/// routing/diagnostics; the generation is what fences.
|
|
pub owner_runtime_id: RuntimeId,
|
|
}
|
|
|
|
/// Tunnel profile, fixed at session establishment.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub enum Profile {
|
|
/// Ordered, reliable, backpressured (goose/berd). Rides `open_bi()`.
|
|
ReliableStream,
|
|
/// Lossy-by-design realtime media (huddle Opus). Rides QUIC datagrams.
|
|
RealtimeMedia,
|
|
/// Huddle roster/join/leave control. State-bearing — a dropped roster
|
|
/// delta is an unrecoverable peer-index desync, so this rides a reliable
|
|
/// stream like `ReliableStream`, never datagrams. Separate variant so
|
|
/// routing intent and `/_mesh` counters stay legible.
|
|
HuddleControl,
|
|
}
|
|
|
|
/// One QUIC datagram: realtime media only.
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct MeshDatagram {
|
|
pub fenced: FencedHeader,
|
|
/// Sender-scoped monotonic sequence for loss/reorder observability.
|
|
/// Receivers tolerate gaps and reordering; they never wait.
|
|
pub seq: u64,
|
|
/// Opaque at this layer: the profile owner defines the internal layout.
|
|
/// For realtime media it is `[peer_index: u8][client frame]` — the
|
|
/// peer_index is relay routing metadata (owner pod is sole allocator);
|
|
/// the client frame's encrypted content is NIP-44 between client
|
|
/// endpoints, so server-side plaintext of the media itself never exists.
|
|
pub payload: Vec<u8>,
|
|
}
|
|
|
|
/// One length-delimited frame on a mesh bi-stream.
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub enum MeshStreamFrame {
|
|
/// MUST be the first frame on every stream, in both directions.
|
|
Hello(StreamHello),
|
|
/// Opaque tunnel bytes for a reliable-stream session.
|
|
Data {
|
|
fenced: FencedHeader,
|
|
payload: Vec<u8>,
|
|
},
|
|
/// Clean close: the sender will send no more `Data` for this session.
|
|
/// Distinct from a QUIC reset — receivers treat reset as abnormal.
|
|
Goodbye {
|
|
fenced: FencedHeader,
|
|
reason: GoodbyeReason,
|
|
},
|
|
/// Membership gossip on the control stream (one per peer connection).
|
|
/// Payload is the gossip lane's postcard-encoded digest/delta exchange —
|
|
/// opaque at this layer so gossip can evolve without a wire bump here.
|
|
Gossip { payload: Vec<u8> },
|
|
}
|
|
|
|
/// Stream role, declared in the Hello.
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub enum StreamRole {
|
|
/// The per-connection control stream (gossip + liveness). Exactly one
|
|
/// per peer connection, opened by the dialer immediately after connect.
|
|
Control,
|
|
/// A reliable-stream tunnel session.
|
|
Session {
|
|
fenced: FencedHeader,
|
|
profile: Profile,
|
|
},
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct StreamHello {
|
|
pub sender: RuntimeId,
|
|
pub role: StreamRole,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub enum GoodbyeReason {
|
|
/// Client closed / session ended normally.
|
|
SessionEnded,
|
|
/// This runtime is draining (SIGTERM) — re-establish elsewhere.
|
|
Draining,
|
|
/// The sender observed a newer generation and is fencing itself out.
|
|
StaleGeneration,
|
|
}
|
|
|
|
/// Encode a frame: version byte + postcard.
|
|
pub fn encode<T: Serialize>(frame: &T) -> Result<Vec<u8>, crate::MeshError> {
|
|
let buf = vec![WIRE_VERSION];
|
|
postcard::to_extend(frame, buf).map_err(crate::MeshError::Encode)
|
|
}
|
|
|
|
/// Decode a frame: check version byte, then postcard.
|
|
pub fn decode<'a, T: Deserialize<'a>>(bytes: &'a [u8]) -> Result<T, crate::MeshError> {
|
|
match bytes.split_first() {
|
|
Some((&WIRE_VERSION, rest)) => postcard::from_bytes(rest).map_err(crate::MeshError::Decode),
|
|
Some((&v, _)) => Err(crate::MeshError::UnknownWireVersion(v)),
|
|
None => Err(crate::MeshError::EmptyFrame),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn fenced() -> FencedHeader {
|
|
FencedHeader {
|
|
session_id: Uuid::from_u128(0xDEAD_BEEF),
|
|
generation: 42,
|
|
owner_runtime_id: RuntimeId([7u8; 32]),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn datagram_roundtrip() {
|
|
let d = MeshDatagram {
|
|
fenced: fenced(),
|
|
seq: 9001,
|
|
payload: vec![1, 2, 3],
|
|
};
|
|
let bytes = encode(&d).unwrap();
|
|
assert_eq!(bytes[0], WIRE_VERSION);
|
|
let back: MeshDatagram = decode(&bytes).unwrap();
|
|
assert_eq!(back, d);
|
|
}
|
|
|
|
#[test]
|
|
fn stream_frame_roundtrip() {
|
|
for f in [
|
|
MeshStreamFrame::Hello(StreamHello {
|
|
sender: RuntimeId([1u8; 32]),
|
|
role: StreamRole::Session {
|
|
fenced: fenced(),
|
|
profile: Profile::ReliableStream,
|
|
},
|
|
}),
|
|
MeshStreamFrame::Data {
|
|
fenced: fenced(),
|
|
payload: b"opaque".to_vec(),
|
|
},
|
|
MeshStreamFrame::Goodbye {
|
|
fenced: fenced(),
|
|
reason: GoodbyeReason::Draining,
|
|
},
|
|
MeshStreamFrame::Gossip {
|
|
payload: vec![0xAA; 16],
|
|
},
|
|
] {
|
|
let back: MeshStreamFrame = decode(&encode(&f).unwrap()).unwrap();
|
|
assert_eq!(back, f);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn unknown_version_rejected() {
|
|
let d = MeshDatagram {
|
|
fenced: fenced(),
|
|
seq: 1,
|
|
payload: vec![],
|
|
};
|
|
let mut bytes = encode(&d).unwrap();
|
|
bytes[0] = 99;
|
|
assert!(matches!(
|
|
decode::<MeshDatagram>(&bytes),
|
|
Err(crate::MeshError::UnknownWireVersion(99))
|
|
));
|
|
}
|
|
|
|
/// Opus @ 20ms worst case (~160B) + header must clear the conservative
|
|
/// QUIC datagram floor (~1200B path MTU minus QUIC overhead). This pins
|
|
/// the header overhead so it can't silently grow past the budget.
|
|
#[test]
|
|
fn datagram_header_overhead_within_budget() {
|
|
let payload = vec![0u8; 160];
|
|
let d = MeshDatagram {
|
|
fenced: fenced(),
|
|
seq: u64::MAX,
|
|
payload: payload.clone(),
|
|
};
|
|
let overhead = encode(&d).unwrap().len() - payload.len();
|
|
assert!(
|
|
overhead <= 64,
|
|
"datagram header overhead {overhead}B exceeds 64B budget"
|
|
);
|
|
}
|
|
}
|