Working library with real iroh connections, not an interface sketch: - persistent device identity in state.sqlite, stable across restarts - deterministic network space derived from name + secret via HKDF-SHA256, with frozen labels and unambiguous length-prefixed encoding - replaceable discovery returning unverified candidates only; static bootstrap, in-memory test backend and a composite - real iroh connections plus an explicit mutual membership proof: HMAC-SHA256 over a role-separated transcript bound to the TLS exporter, the network id and both endpoint identities - small versioned control protocol: handshake, announcement, ping/pong - multiple networks per agent with enforced isolation - automatic reconnect with bounded backoff and jitter - mandatory state vs disposable cache, with a real directory ownership lock - status snapshots, event stream and honest diagnostics 47 integration and unit tests cover the required scenarios offline on loopback. Snapshots, revocations and WireGuard are designed for and documented, not implemented. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
185 lines
6.2 KiB
Rust
185 lines
6.2 KiB
Rust
//! Control message formats.
|
|
//!
|
|
//! This is deliberately a small, closed set of messages, not a general RPC
|
|
//! framework. Bodies are encoded with [postcard], a compact, deterministic,
|
|
//! non-self-describing serde format.
|
|
//!
|
|
//! Every message that arrives from the network passes [`validate`] against the
|
|
//! configured [`Limits`] before it reaches anything else.
|
|
//!
|
|
//! [postcard]: https://docs.rs/postcard
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use crate::config::Limits;
|
|
use crate::dataplane::{MAX_PROTOCOL_ID_LEN, PluginCapability};
|
|
use crate::error::ProtocolError;
|
|
|
|
/// ALPN of the tsunagi control plane.
|
|
///
|
|
/// The version in the ALPN is the wire-compatibility version of the control
|
|
/// protocol. It is independent of the network identity scheme version, so
|
|
/// bumping it must not change any existing [`crate::NetworkId`].
|
|
pub const ALPN: &[u8] = b"tsunagi/ctrl/1";
|
|
|
|
/// Control protocol version carried inside the handshake.
|
|
pub const PROTOCOL_VERSION: u16 = 1;
|
|
|
|
/// First message of the handshake, sent by the initiator.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct Hello {
|
|
/// Control protocol version the initiator speaks.
|
|
pub version: u16,
|
|
/// Public network identifier the initiator wants to join.
|
|
pub network_id: [u8; 32],
|
|
/// Initiator's fresh handshake nonce.
|
|
pub nonce: [u8; 16],
|
|
}
|
|
|
|
/// Responder's reply to [`Hello`]. Carries no proof yet.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct HelloAck {
|
|
/// Control protocol version the responder speaks.
|
|
pub version: u16,
|
|
/// Responder's fresh handshake nonce.
|
|
pub nonce: [u8; 16],
|
|
}
|
|
|
|
/// A handshake proof, i.e. one HMAC tag over a role-specific transcript.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct AuthProof {
|
|
/// HMAC-SHA256 tag.
|
|
pub proof: [u8; 32],
|
|
}
|
|
|
|
/// What this agent tells a peer about itself.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct Announcement {
|
|
/// Human-readable hostname. A mutable binding, not an identity.
|
|
pub hostname: String,
|
|
/// Announced IP plugin capabilities. Opaque to the core.
|
|
pub capabilities: Vec<PluginCapability>,
|
|
}
|
|
|
|
/// A control message exchanged after a successful handshake.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[non_exhaustive]
|
|
pub enum ControlMessage {
|
|
/// Hostname and capability announcement.
|
|
Announce(Announcement),
|
|
/// A small request used to verify that the exchange works.
|
|
Ping {
|
|
/// Caller-chosen sequence number, echoed back.
|
|
seq: u64,
|
|
/// Opaque bounded payload, echoed back.
|
|
payload: Vec<u8>,
|
|
},
|
|
/// The reply to a [`ControlMessage::Ping`].
|
|
Pong {
|
|
/// Sequence number of the request being answered.
|
|
seq: u64,
|
|
/// Echoed payload.
|
|
payload: Vec<u8>,
|
|
},
|
|
/// Graceful goodbye.
|
|
///
|
|
/// A peer going away is not a revocation of anything.
|
|
Bye {
|
|
/// Short free-text reason.
|
|
reason: String,
|
|
},
|
|
}
|
|
|
|
/// A control message together with the network it belongs to.
|
|
///
|
|
/// Every session is bound to exactly one network at handshake time. The
|
|
/// `network_id` here is re-checked on every message, so an authenticated
|
|
/// session for network A can never be used to speak to network B, even over a
|
|
/// shared physical connection.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct Envelope {
|
|
/// Network this message belongs to.
|
|
pub network_id: [u8; 32],
|
|
/// The message itself.
|
|
pub message: ControlMessage,
|
|
}
|
|
|
|
/// Encodes a value into a postcard byte vector.
|
|
pub fn encode<T: Serialize>(value: &T) -> Result<Vec<u8>, ProtocolError> {
|
|
postcard::to_stdvec(value).map_err(|_| ProtocolError::Malformed("cannot encode message"))
|
|
}
|
|
|
|
/// Decodes a value from postcard bytes.
|
|
pub fn decode<T: for<'de> Deserialize<'de>>(bytes: &[u8]) -> Result<T, ProtocolError> {
|
|
postcard::from_bytes(bytes).map_err(|_| ProtocolError::Malformed("cannot decode message"))
|
|
}
|
|
|
|
fn check_len(field: &'static str, len: usize, limit: usize) -> Result<(), ProtocolError> {
|
|
if len > limit {
|
|
return Err(ProtocolError::FieldTooLarge { field, len, limit });
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Validates a decoded capability against the configured limits.
|
|
pub fn validate_capability(
|
|
capability: &PluginCapability,
|
|
limits: &Limits,
|
|
) -> Result<(), ProtocolError> {
|
|
if capability.protocol.is_empty() {
|
|
return Err(ProtocolError::Malformed("empty plugin protocol id"));
|
|
}
|
|
check_len(
|
|
"capability.protocol",
|
|
capability.protocol.len(),
|
|
MAX_PROTOCOL_ID_LEN,
|
|
)?;
|
|
check_len(
|
|
"capability.data",
|
|
capability.data.len(),
|
|
limits.max_capability_data_len,
|
|
)?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Validates a decoded control message against the configured limits.
|
|
///
|
|
/// Returning an error rejects that single message. It never stops the session's
|
|
/// network, the other networks or the agent.
|
|
pub fn validate(message: &ControlMessage, limits: &Limits) -> Result<(), ProtocolError> {
|
|
match message {
|
|
ControlMessage::Announce(announcement) => {
|
|
check_len(
|
|
"announce.hostname",
|
|
announcement.hostname.len(),
|
|
limits.max_hostname_len,
|
|
)?;
|
|
check_len(
|
|
"announce.capabilities",
|
|
announcement.capabilities.len(),
|
|
limits.max_capabilities,
|
|
)?;
|
|
for capability in &announcement.capabilities {
|
|
validate_capability(capability, limits)?;
|
|
}
|
|
}
|
|
ControlMessage::Ping { payload, .. } | ControlMessage::Pong { payload, .. } => {
|
|
check_len("echo.payload", payload.len(), limits.max_echo_payload_len)?;
|
|
}
|
|
ControlMessage::Bye { reason } => {
|
|
check_len("bye.reason", reason.len(), limits.max_reason_len)?;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// A short, stable label for a message kind, for metrics and diagnostics.
|
|
pub fn kind(message: &ControlMessage) -> &'static str {
|
|
match message {
|
|
ControlMessage::Announce(_) => "announce",
|
|
ControlMessage::Ping { .. } => "ping",
|
|
ControlMessage::Pong { .. } => "pong",
|
|
ControlMessage::Bye { .. } => "bye",
|
|
}
|
|
}
|