This commit is contained in:
Ultradesu
2026-07-10 12:45:47 +03:00
commit 057436adb6
17 changed files with 7052 additions and 0 deletions
+25
View File
@@ -0,0 +1,25 @@
[package]
name = "federation-net"
version = "0.1.0"
description = "Generic peer-to-peer networking engine built on Iroh"
edition.workspace = true
license.workspace = true
rust-version.workspace = true
[dependencies]
iroh = { workspace = true }
iroh-base = { workspace = true }
iroh-tickets = { workspace = true }
tokio = { workspace = true }
serde = { workspace = true }
postcard = { workspace = true }
blake3 = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
data-encoding = { workspace = true }
rand = { workspace = true }
[dev-dependencies]
anyhow = { workspace = true }
tempfile = { workspace = true }
tokio = { workspace = true, features = ["test-util"] }
+226
View File
@@ -0,0 +1,226 @@
//! Engine configuration.
use std::path::PathBuf;
use std::time::Duration;
use crate::error::{NetworkError, Result};
use crate::protocol::{NetworkId, SchemaId};
/// Default maximum size of an encoded domain message (256 KiB).
pub const DEFAULT_MAX_MESSAGE_SIZE: usize = 256 * 1024;
/// Default timeout applied to handshakes and message round-trips.
pub const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
/// Default capacity of the event channel.
pub const DEFAULT_EVENT_CHANNEL_CAPACITY: usize = 1024;
/// Default limit of concurrently processed streams per peer.
pub const DEFAULT_MAX_CONCURRENT_STREAMS_PER_PEER: usize = 64;
/// Configuration for a [`crate::NetworkEngine`].
///
/// Use [`NetworkConfig::builder`] to construct a validated instance.
#[derive(Debug, Clone)]
pub struct NetworkConfig {
/// Directory where the persistent peer identity is stored.
pub data_dir: PathBuf,
/// Identifier of the network this peer participates in.
pub network_id: NetworkId,
/// Identifier of the domain message schema.
pub schema_id: SchemaId,
/// Maximum size in bytes of a single encoded domain message.
pub max_message_size: usize,
/// Timeout applied to handshakes and message round-trips.
pub request_timeout: Duration,
/// Capacity of the bounded event channel.
pub event_channel_capacity: usize,
/// Maximum number of concurrently processed streams per peer.
pub max_concurrent_streams_per_peer: usize,
}
impl NetworkConfig {
/// Returns a new [`NetworkConfigBuilder`].
pub fn builder() -> NetworkConfigBuilder {
NetworkConfigBuilder::default()
}
}
/// Builder for [`NetworkConfig`].
///
/// `data_dir`, `network_id` and `schema_id` are required; everything else has
/// sensible defaults.
#[derive(Debug, Default, Clone)]
pub struct NetworkConfigBuilder {
data_dir: Option<PathBuf>,
network_id: Option<NetworkId>,
schema_id: Option<SchemaId>,
max_message_size: Option<usize>,
request_timeout: Option<Duration>,
event_channel_capacity: Option<usize>,
max_concurrent_streams_per_peer: Option<usize>,
}
impl NetworkConfigBuilder {
/// Sets the directory where the persistent peer identity is stored.
pub fn data_dir(mut self, dir: impl Into<PathBuf>) -> Self {
self.data_dir = Some(dir.into());
self
}
/// Sets the network identifier.
pub fn network_id(mut self, network_id: NetworkId) -> Self {
self.network_id = Some(network_id);
self
}
/// Sets the schema identifier.
pub fn schema_id(mut self, schema_id: SchemaId) -> Self {
self.schema_id = Some(schema_id);
self
}
/// Sets the maximum size in bytes of a single encoded domain message.
pub fn max_message_size(mut self, size: usize) -> Self {
self.max_message_size = Some(size);
self
}
/// Sets the timeout applied to handshakes and message round-trips.
pub fn request_timeout(mut self, timeout: Duration) -> Self {
self.request_timeout = Some(timeout);
self
}
/// Sets the capacity of the bounded event channel.
pub fn event_channel_capacity(mut self, capacity: usize) -> Self {
self.event_channel_capacity = Some(capacity);
self
}
/// Sets the maximum number of concurrently processed streams per peer.
pub fn max_concurrent_streams_per_peer(mut self, limit: usize) -> Self {
self.max_concurrent_streams_per_peer = Some(limit);
self
}
/// Validates the configuration and builds a [`NetworkConfig`].
pub fn build(self) -> Result<NetworkConfig> {
let data_dir = self
.data_dir
.ok_or_else(|| NetworkError::InvalidConfig("data_dir is required".into()))?;
let network_id = self
.network_id
.ok_or_else(|| NetworkError::InvalidConfig("network_id is required".into()))?;
let schema_id = self
.schema_id
.ok_or_else(|| NetworkError::InvalidConfig("schema_id is required".into()))?;
if data_dir.as_os_str().is_empty() {
return Err(NetworkError::InvalidConfig(
"data_dir must not be empty".into(),
));
}
let max_message_size = self.max_message_size.unwrap_or(DEFAULT_MAX_MESSAGE_SIZE);
if max_message_size == 0 {
return Err(NetworkError::InvalidConfig(
"max_message_size must be greater than zero".into(),
));
}
if max_message_size > u32::MAX as usize {
return Err(NetworkError::InvalidConfig(
"max_message_size must fit into a 32-bit length prefix".into(),
));
}
let request_timeout = self.request_timeout.unwrap_or(DEFAULT_REQUEST_TIMEOUT);
if request_timeout.is_zero() {
return Err(NetworkError::InvalidConfig(
"request_timeout must be greater than zero".into(),
));
}
let event_channel_capacity = self
.event_channel_capacity
.unwrap_or(DEFAULT_EVENT_CHANNEL_CAPACITY);
if event_channel_capacity == 0 {
return Err(NetworkError::InvalidConfig(
"event_channel_capacity must be greater than zero".into(),
));
}
let max_concurrent_streams_per_peer = self
.max_concurrent_streams_per_peer
.unwrap_or(DEFAULT_MAX_CONCURRENT_STREAMS_PER_PEER);
if max_concurrent_streams_per_peer == 0 {
return Err(NetworkError::InvalidConfig(
"max_concurrent_streams_per_peer must be greater than zero".into(),
));
}
Ok(NetworkConfig {
data_dir,
network_id,
schema_id,
max_message_size,
request_timeout,
event_channel_capacity,
max_concurrent_streams_per_peer,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn base_builder() -> NetworkConfigBuilder {
NetworkConfig::builder()
.data_dir("./some-dir")
.network_id(NetworkId::from_name("test-network"))
.schema_id(SchemaId::from_name("test-schema"))
}
#[test]
fn builder_applies_defaults() {
let config = base_builder().build().expect("valid config");
assert_eq!(config.max_message_size, DEFAULT_MAX_MESSAGE_SIZE);
assert_eq!(config.request_timeout, DEFAULT_REQUEST_TIMEOUT);
assert_eq!(
config.event_channel_capacity,
DEFAULT_EVENT_CHANNEL_CAPACITY
);
assert_eq!(
config.max_concurrent_streams_per_peer,
DEFAULT_MAX_CONCURRENT_STREAMS_PER_PEER
);
}
#[test]
fn builder_rejects_missing_required_fields() {
assert!(NetworkConfig::builder().build().is_err());
assert!(
NetworkConfig::builder()
.data_dir("./dir")
.network_id(NetworkId::from_name("n"))
.build()
.is_err()
);
}
#[test]
fn builder_rejects_invalid_values() {
assert!(base_builder().max_message_size(0).build().is_err());
assert!(
base_builder()
.request_timeout(Duration::ZERO)
.build()
.is_err()
);
assert!(base_builder().event_channel_capacity(0).build().is_err());
assert!(
base_builder()
.max_concurrent_streams_per_peer(0)
.build()
.is_err()
);
}
}
+757
View File
@@ -0,0 +1,757 @@
//! The network engine: endpoint lifecycle, connection registry and messaging.
use std::collections::HashMap;
use std::fmt;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex, MutexGuard, PoisonError, Weak};
use std::time::Duration;
use iroh::endpoint::{Connection, RecvStream, SendStream, VarInt, presets};
use iroh::protocol::{AcceptError, ProtocolHandler, Router};
use iroh::{Endpoint, EndpointId};
use serde::Serialize;
use serde::de::DeserializeOwned;
use tokio::sync::{Semaphore, mpsc};
use tokio::task::JoinSet;
use tokio::time::timeout;
use tracing::{debug, info, warn};
use crate::config::NetworkConfig;
use crate::error::{NetworkError, Result};
use crate::event::{ConnectionDirection, NetworkEvent, NetworkEventReceiver};
use crate::identity;
use crate::protocol::{
ALPN, Handshake, HandshakeAck, HandshakeErrorCode, MAX_HANDSHAKE_FRAME_SIZE,
MessageRejectReason, MessageRequest, MessageResponse, NetworkId, PROTOCOL_VERSION, SchemaId,
};
use crate::ticket::{PeerTicket, TICKET_VERSION};
use crate::wire;
/// Close code for a graceful, error-free close.
const CLOSE_CODE_OK: VarInt = VarInt::from_u32(0);
/// Close code used when the handshake was rejected by either side.
const CLOSE_CODE_HANDSHAKE_REJECTED: VarInt = VarInt::from_u32(1);
/// Close code used when the engine shuts down.
const CLOSE_CODE_SHUTDOWN: VarInt = VarInt::from_u32(2);
/// Close code used when a connection is replaced by a newer one.
const CLOSE_CODE_REPLACED: VarInt = VarInt::from_u32(3);
/// Extra bytes allowed on top of `max_message_size` for message framing
/// (request id, length varints and enum tags).
const MESSAGE_FRAME_OVERHEAD: usize = 64;
/// Maximum size of an encoded [`MessageResponse`] frame.
const MAX_RESPONSE_FRAME_SIZE: usize = 1024;
/// How long the accepting side keeps a rejected connection open so the
/// rejection ack reaches the initiator before the connection is torn down.
const REJECT_LINGER: Duration = Duration::from_secs(3);
/// How long [`NetworkEngine::shutdown`] waits for background tasks before
/// aborting them.
const SHUTDOWN_TASK_GRACE: Duration = Duration::from_secs(5);
/// Bounds required of a domain message type.
///
/// This trait is implemented automatically for every type that satisfies the
/// bounds; applications never implement it by hand.
pub trait Message: Serialize + DeserializeOwned + Send + Sync + 'static {}
impl<T> Message for T where T: Serialize + DeserializeOwned + Send + Sync + 'static {}
/// Locks a mutex, recovering the guard if the mutex was poisoned.
///
/// The engine never panics while holding one of its locks, so poisoning can
/// only originate from a panic in unrelated user code; recovering is safe.
fn lock<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
mutex.lock().unwrap_or_else(PoisonError::into_inner)
}
/// State kept for every active peer connection.
struct PeerState {
connection: Connection,
direction: ConnectionDirection,
semaphore: Arc<Semaphore>,
/// Monotonic registration counter, used so a stale connection's cleanup
/// never removes a newer connection to the same peer.
generation: u64,
}
/// State shared between the engine handles, the protocol handler and all
/// background tasks.
struct Shared<M> {
config: NetworkConfig,
endpoint: Endpoint,
router: Mutex<Option<Router>>,
peers: Mutex<HashMap<EndpointId, PeerState>>,
events: Mutex<Option<mpsc::Sender<NetworkEvent<M>>>>,
tasks: Mutex<JoinSet<()>>,
next_generation: AtomicU64,
shutting_down: AtomicBool,
}
impl<M: Message> Shared<M> {
fn is_shutting_down(&self) -> bool {
self.shutting_down.load(Ordering::SeqCst)
}
fn ensure_running(&self) -> Result<()> {
if self.is_shutting_down() {
Err(NetworkError::ShuttingDown)
} else {
Ok(())
}
}
/// Delivers an event to the application.
///
/// The channel is bounded; if it is full this awaits until the
/// application consumes events, applying natural back-pressure without
/// unbounded buffering.
async fn emit(&self, event: NetworkEvent<M>) {
let sender = lock(&self.events).clone();
if let Some(sender) = sender
&& sender.send(event).await.is_err()
{
debug!("event receiver dropped; event discarded");
}
}
/// Spawns a background task tracked until shutdown.
fn spawn_task<F>(&self, future: F)
where
F: Future<Output = ()> + Send + 'static,
{
let mut tasks = lock(&self.tasks);
// Reap already-finished tasks so the set does not grow unboundedly.
while tasks.try_join_next().is_some() {}
tasks.spawn(future);
}
/// Adds a connection to the registry, replacing (and closing) any previous
/// connection to the same peer. Returns the registration generation.
fn register_connection(
&self,
peer_id: EndpointId,
connection: Connection,
direction: ConnectionDirection,
) -> u64 {
let generation = self.next_generation.fetch_add(1, Ordering::Relaxed);
let state = PeerState {
connection,
direction,
semaphore: Arc::new(Semaphore::new(self.config.max_concurrent_streams_per_peer)),
generation,
};
let replaced = lock(&self.peers).insert(peer_id, state);
if let Some(old) = replaced {
debug!(peer = %peer_id, "replacing existing connection");
old.connection
.close(CLOSE_CODE_REPLACED, b"replaced by a new connection");
}
generation
}
/// Removes the connection from the registry and reports the disconnect,
/// unless a newer connection to the same peer took its place.
async fn cleanup_connection(
&self,
peer_id: EndpointId,
generation: u64,
reason: Option<String>,
) {
let removed = {
let mut peers = lock(&self.peers);
match peers.get(&peer_id) {
Some(state) if state.generation == generation => peers.remove(&peer_id),
_ => None,
}
};
if let Some(state) = removed {
info!(
peer = %peer_id,
direction = ?state.direction,
reason = reason.as_deref().unwrap_or("unknown"),
"peer disconnected"
);
self.emit(NetworkEvent::PeerDisconnected { peer_id, reason })
.await;
}
}
/// Validates a remote handshake against the local configuration.
fn validate_handshake(&self, handshake: &Handshake) -> Option<HandshakeErrorCode> {
if handshake.protocol_version != PROTOCOL_VERSION {
Some(HandshakeErrorCode::UnsupportedProtocolVersion)
} else if handshake.network_id != self.config.network_id {
Some(HandshakeErrorCode::NetworkMismatch)
} else if handshake.schema_id != self.config.schema_id {
Some(HandshakeErrorCode::SchemaMismatch)
} else {
None
}
}
/// Drives an established connection: accepts message streams until the
/// connection closes, then cleans up the registry entry.
async fn connection_loop(
self: Arc<Self>,
peer_id: EndpointId,
connection: Connection,
generation: u64,
) {
let semaphore = {
let peers = lock(&self.peers);
match peers.get(&peer_id) {
Some(state) if state.generation == generation => state.semaphore.clone(),
// The connection was already replaced; nothing to drive.
_ => return,
}
};
loop {
let (send, recv) = match connection.accept_bi().await {
Ok(streams) => streams,
Err(_) => break,
};
let permit = match semaphore.clone().acquire_owned().await {
Ok(permit) => permit,
Err(_) => break,
};
let shared = self.clone();
self.spawn_task(async move {
shared
.handle_message_stream(peer_id, send, recv, permit)
.await;
// The permit is released when the task finishes.
});
}
let reason = connection.close_reason().map(|err| err.to_string());
self.cleanup_connection(peer_id, generation, reason).await;
}
/// Handles a single incoming message stream: decodes the request, hands
/// the message to the application and acknowledges it.
async fn handle_message_stream(
&self,
peer_id: EndpointId,
mut send: SendStream,
mut recv: RecvStream,
_permit: tokio::sync::OwnedSemaphorePermit,
) {
let request_timeout = self.config.request_timeout;
let max_frame = self.config.max_message_size + MESSAGE_FRAME_OVERHEAD;
let request: MessageRequest =
match timeout(request_timeout, wire::read_frame(&mut recv, max_frame)).await {
Ok(Ok(request)) => request,
Ok(Err(err)) => {
self.report_protocol_error(Some(peer_id), format!("bad message frame: {err}"))
.await;
return;
}
Err(_) => {
self.report_protocol_error(
Some(peer_id),
"timed out reading message frame".to_string(),
)
.await;
return;
}
};
let request_id = request.request_id;
let response = if self.is_shutting_down() {
MessageResponse::Rejected {
request_id,
reason: MessageRejectReason::ShuttingDown,
}
} else {
match wire::decode::<M>(&request.payload) {
Ok(message) => {
self.emit(NetworkEvent::MessageReceived { peer_id, message })
.await;
MessageResponse::Accepted { request_id }
}
Err(err) => {
self.report_protocol_error(
Some(peer_id),
format!("failed to decode domain message: {err}"),
)
.await;
MessageResponse::Rejected {
request_id,
reason: MessageRejectReason::MalformedPayload,
}
}
}
};
let write = timeout(
request_timeout,
wire::write_frame(&mut send, &response, MAX_RESPONSE_FRAME_SIZE),
)
.await;
match write {
Ok(Ok(())) => {
let _ = send.finish();
}
Ok(Err(err)) => {
debug!(peer = %peer_id, error = %err, "failed to send message response");
}
Err(_) => {
debug!(peer = %peer_id, "timed out sending message response");
}
}
}
async fn report_protocol_error(&self, peer_id: Option<EndpointId>, error: String) {
match peer_id {
Some(peer) => warn!(peer = %peer, error = %error, "protocol error"),
None => warn!(error = %error, "protocol error"),
}
self.emit(NetworkEvent::ProtocolError { peer_id, error })
.await;
}
/// Handles a freshly accepted incoming connection: performs the handshake
/// and, if accepted, drives the connection until it closes.
async fn handle_incoming(self: &Arc<Self>, connection: &Connection) -> Result<()> {
let peer_id = connection.remote_id();
if self.is_shutting_down() {
connection.close(CLOSE_CODE_SHUTDOWN, b"engine is shutting down");
return Err(NetworkError::ShuttingDown);
}
debug!(peer = %peer_id, "incoming connection");
let request_timeout = self.config.request_timeout;
let (mut send, mut recv) = timeout(request_timeout, connection.accept_bi())
.await
.map_err(|_| NetworkError::Timeout)
.and_then(|res| {
res.map_err(|err| {
NetworkError::Transport(format!("failed to accept handshake stream: {err}"))
})
})
.inspect_err(|_| {
connection.close(CLOSE_CODE_HANDSHAKE_REJECTED, b"handshake failed");
})?;
let handshake: Result<Handshake> = timeout(
request_timeout,
wire::read_frame(&mut recv, MAX_HANDSHAKE_FRAME_SIZE),
)
.await
.map_err(|_| NetworkError::Timeout)
.and_then(|res| res);
let verdict = match &handshake {
Ok(handshake) => self.validate_handshake(handshake),
Err(_) => Some(HandshakeErrorCode::InvalidHandshake),
};
if let Some(code) = verdict {
warn!(peer = %peer_id, code = ?code, "handshake rejected");
let ack = HandshakeAck {
accepted: false,
error: Some(code),
};
let sent = timeout(
request_timeout,
wire::write_frame(&mut send, &ack, MAX_HANDSHAKE_FRAME_SIZE),
)
.await;
if matches!(sent, Ok(Ok(()))) {
let _ = send.finish();
// Keep the connection open briefly so QUIC delivers the ack;
// the initiator closes as soon as it has read the rejection.
let _ = timeout(REJECT_LINGER, connection.closed()).await;
}
connection.close(CLOSE_CODE_HANDSHAKE_REJECTED, b"handshake rejected");
return Err(handshake_code_to_error(code));
}
let ack = HandshakeAck {
accepted: true,
error: None,
};
timeout(
request_timeout,
wire::write_frame(&mut send, &ack, MAX_HANDSHAKE_FRAME_SIZE),
)
.await
.map_err(|_| NetworkError::Timeout)
.and_then(|res| res)
.inspect_err(|_| {
connection.close(CLOSE_CODE_HANDSHAKE_REJECTED, b"handshake failed");
})?;
let _ = send.finish();
let generation =
self.register_connection(peer_id, connection.clone(), ConnectionDirection::Incoming);
info!(peer = %peer_id, "peer connected (incoming)");
self.emit(NetworkEvent::PeerConnected {
peer_id,
direction: ConnectionDirection::Incoming,
})
.await;
// Drive the connection inside the router's accept task; it runs for
// as long as the connection lives.
self.clone()
.connection_loop(peer_id, connection.clone(), generation)
.await;
Ok(())
}
/// Runs the client side of the handshake on a fresh outgoing connection.
async fn run_client_handshake(&self, connection: &Connection) -> Result<()> {
let (mut send, mut recv) = connection.open_bi().await.map_err(|err| {
NetworkError::Transport(format!("failed to open handshake stream: {err}"))
})?;
let handshake = Handshake {
protocol_version: PROTOCOL_VERSION,
network_id: self.config.network_id,
schema_id: self.config.schema_id,
};
wire::write_frame(&mut send, &handshake, MAX_HANDSHAKE_FRAME_SIZE).await?;
let _ = send.finish();
let ack: HandshakeAck = wire::read_frame(&mut recv, MAX_HANDSHAKE_FRAME_SIZE).await?;
if ack.accepted {
Ok(())
} else {
let code = ack.error.unwrap_or(HandshakeErrorCode::InvalidHandshake);
Err(handshake_code_to_error(code))
}
}
}
fn handshake_code_to_error(code: HandshakeErrorCode) -> NetworkError {
match code {
HandshakeErrorCode::UnsupportedProtocolVersion => NetworkError::UnsupportedProtocolVersion,
HandshakeErrorCode::NetworkMismatch => NetworkError::NetworkMismatch,
HandshakeErrorCode::SchemaMismatch => NetworkError::SchemaMismatch,
HandshakeErrorCode::InvalidHandshake => NetworkError::HandshakeRejected,
}
}
/// Protocol handler registered with the Iroh [`Router`] for [`ALPN`].
///
/// Holds only a weak reference to the shared state so the router does not
/// keep the engine alive after all engine handles are dropped.
struct FederationProtocol<M> {
shared: Weak<Shared<M>>,
}
impl<M> fmt::Debug for FederationProtocol<M> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("FederationProtocol").finish_non_exhaustive()
}
}
impl<M: Message> ProtocolHandler for FederationProtocol<M> {
async fn accept(&self, connection: Connection) -> Result<(), AcceptError> {
let Some(shared) = self.shared.upgrade() else {
connection.close(CLOSE_CODE_SHUTDOWN, b"engine is gone");
return Ok(());
};
let peer_id = connection.remote_id();
if let Err(err) = shared.handle_incoming(&connection).await {
shared
.report_protocol_error(Some(peer_id), format!("incoming connection failed: {err}"))
.await;
}
// Errors of a single connection never bring down the router; the
// connection has already been closed with an appropriate code.
Ok(())
}
}
/// A generic peer-to-peer network engine on top of Iroh.
///
/// `M` is the application-defined domain message type; the engine treats it
/// as an opaque, postcard-serializable payload. The engine is cheaply
/// clonable: all clones share the same endpoint and connection registry.
pub struct NetworkEngine<M> {
shared: Arc<Shared<M>>,
}
impl<M> Clone for NetworkEngine<M> {
fn clone(&self) -> Self {
Self {
shared: self.shared.clone(),
}
}
}
impl<M> fmt::Debug for NetworkEngine<M> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("NetworkEngine").finish_non_exhaustive()
}
}
impl<M: Message> NetworkEngine<M> {
/// Starts the engine: loads (or creates) the persistent identity, binds
/// the Iroh endpoint and starts accepting connections.
///
/// Returns the engine handle together with the single receiver for
/// [`NetworkEvent`]s.
pub async fn start(config: NetworkConfig) -> Result<(Self, NetworkEventReceiver<M>)> {
let secret_key = identity::load_or_create(&config.data_dir).await?;
let endpoint = Endpoint::builder(presets::N0)
.secret_key(secret_key)
.bind()
.await
.map_err(|err| NetworkError::Transport(format!("failed to bind endpoint: {err}")))?;
let endpoint_id = endpoint.id();
let (sender, receiver) = mpsc::channel(config.event_channel_capacity);
let shared = Arc::new(Shared {
config,
endpoint: endpoint.clone(),
router: Mutex::new(None),
peers: Mutex::new(HashMap::new()),
events: Mutex::new(Some(sender)),
tasks: Mutex::new(JoinSet::new()),
next_generation: AtomicU64::new(0),
shutting_down: AtomicBool::new(false),
});
let handler = FederationProtocol {
shared: Arc::downgrade(&shared),
};
let router = Router::builder(endpoint).accept(ALPN, handler).spawn();
*lock(&shared.router) = Some(router);
info!(
endpoint_id = %endpoint_id,
network_id = %shared.config.network_id,
schema_id = %shared.config.schema_id,
"network engine started"
);
Ok((Self { shared }, NetworkEventReceiver::new(receiver)))
}
/// Returns the stable identifier of this peer.
pub fn endpoint_id(&self) -> EndpointId {
self.shared.endpoint.id()
}
/// Returns the network this engine participates in.
pub fn network_id(&self) -> NetworkId {
self.shared.config.network_id
}
/// Returns the message schema this engine uses.
pub fn schema_id(&self) -> SchemaId {
self.shared.config.schema_id
}
/// Creates a shareable [`PeerTicket`] for this peer.
///
/// Waits (bounded by the configured request timeout) for the endpoint to
/// come online so the ticket contains relay information whenever
/// possible; on an isolated network the ticket falls back to the locally
/// known direct addresses.
pub async fn ticket(&self) -> Result<PeerTicket> {
self.shared.ensure_running()?;
let endpoint = &self.shared.endpoint;
let has_relay = endpoint.addr().relay_urls().next().is_some();
if !has_relay {
let _ = timeout(self.shared.config.request_timeout, endpoint.online()).await;
}
let endpoint_addr = endpoint.addr();
if endpoint_addr.is_empty() {
return Err(NetworkError::Transport(
"endpoint has no reachable addresses yet".to_string(),
));
}
Ok(PeerTicket {
ticket_version: TICKET_VERSION,
protocol_version: PROTOCOL_VERSION,
network_id: self.shared.config.network_id,
schema_id: self.shared.config.schema_id,
endpoint_addr,
})
}
/// Connects to the peer described by `ticket` and performs the handshake.
///
/// Incompatible tickets (different network, schema or protocol version)
/// are rejected before any connection is attempted. An existing healthy
/// connection to the same peer is reused.
pub async fn connect(&self, ticket: PeerTicket) -> Result<EndpointId> {
let shared = &self.shared;
shared.ensure_running()?;
if ticket.ticket_version != TICKET_VERSION {
return Err(NetworkError::InvalidTicket(format!(
"unsupported ticket version {}",
ticket.ticket_version
)));
}
if ticket.protocol_version != PROTOCOL_VERSION {
return Err(NetworkError::UnsupportedProtocolVersion);
}
if ticket.network_id != shared.config.network_id {
return Err(NetworkError::NetworkMismatch);
}
if ticket.schema_id != shared.config.schema_id {
return Err(NetworkError::SchemaMismatch);
}
let target = ticket.endpoint_id();
if target == self.endpoint_id() {
return Err(NetworkError::InvalidTicket(
"the ticket points to this peer itself".to_string(),
));
}
if lock(&shared.peers).contains_key(&target) {
debug!(peer = %target, "reusing existing connection");
return Ok(target);
}
let request_timeout = shared.config.request_timeout;
let connection = timeout(
request_timeout,
shared.endpoint.connect(ticket.endpoint_addr, ALPN),
)
.await
.map_err(|_| NetworkError::Timeout)?
.map_err(|err| NetworkError::Transport(format!("failed to connect: {err}")))?;
// The remote identity comes from the authenticated Iroh connection,
// never from the ticket payload.
let peer_id = connection.remote_id();
let handshake = timeout(request_timeout, shared.run_client_handshake(&connection))
.await
.map_err(|_| NetworkError::Timeout)
.and_then(|res| res);
if let Err(err) = handshake {
connection.close(CLOSE_CODE_HANDSHAKE_REJECTED, b"handshake failed");
return Err(err);
}
let generation =
shared.register_connection(peer_id, connection.clone(), ConnectionDirection::Outgoing);
info!(peer = %peer_id, "peer connected (outgoing)");
shared
.emit(NetworkEvent::PeerConnected {
peer_id,
direction: ConnectionDirection::Outgoing,
})
.await;
let loop_shared = shared.clone();
shared.spawn_task(async move {
loop_shared
.connection_loop(peer_id, connection, generation)
.await;
});
Ok(peer_id)
}
/// Sends a domain message to a connected peer.
///
/// Returns only after the peer acknowledged the message. Fails with
/// [`NetworkError::PeerNotConnected`] if there is no active connection to
/// the peer; no implicit dialing or peer lookup is performed.
pub async fn send(&self, peer: EndpointId, message: &M) -> Result<()> {
let shared = &self.shared;
shared.ensure_running()?;
let connection = lock(&shared.peers)
.get(&peer)
.map(|state| state.connection.clone())
.ok_or(NetworkError::PeerNotConnected(peer))?;
let payload = wire::encode(message, shared.config.max_message_size)?;
let request_id: [u8; 16] = rand::random();
let request = MessageRequest {
request_id,
payload,
};
let max_frame = shared.config.max_message_size + MESSAGE_FRAME_OVERHEAD;
timeout(shared.config.request_timeout, async move {
let (mut send, mut recv) = connection.open_bi().await.map_err(|err| {
NetworkError::Transport(format!("failed to open message stream: {err}"))
})?;
wire::write_frame(&mut send, &request, max_frame).await?;
let _ = send.finish();
let response: MessageResponse =
wire::read_frame(&mut recv, MAX_RESPONSE_FRAME_SIZE).await?;
match response {
MessageResponse::Accepted { request_id: id } if id == request_id => Ok(()),
MessageResponse::Rejected {
request_id: id,
reason,
} if id == request_id => Err(NetworkError::MessageRejected(reason.to_string())),
_ => Err(NetworkError::Transport(
"peer responded to a different request".to_string(),
)),
}
})
.await
.map_err(|_| NetworkError::Timeout)?
}
/// Returns the ids of all currently connected peers.
pub fn connected_peers(&self) -> Vec<EndpointId> {
lock(&self.shared.peers).keys().copied().collect()
}
/// Closes the connection to `peer` and removes it from the registry.
pub async fn disconnect(&self, peer: EndpointId) -> Result<()> {
let state = lock(&self.shared.peers)
.remove(&peer)
.ok_or(NetworkError::PeerNotConnected(peer))?;
state
.connection
.close(CLOSE_CODE_OK, b"disconnected by local peer");
info!(peer = %peer, "peer disconnected (local request)");
self.shared
.emit(NetworkEvent::PeerDisconnected {
peer_id: peer,
reason: Some("disconnected by local peer".to_string()),
})
.await;
Ok(())
}
/// Shuts the engine down gracefully.
///
/// Rejects new operations, closes all connections, stops the protocol
/// router, closes the endpoint, waits for background tasks and finally
/// closes the event channel. Calling `shutdown` on several clones of the
/// same engine is safe; only the first call does the work.
pub async fn shutdown(self) -> Result<()> {
let shared = &self.shared;
if shared.shutting_down.swap(true, Ordering::SeqCst) {
return Ok(());
}
info!("network engine shutting down");
// Close all active connections.
let states: Vec<PeerState> = lock(&shared.peers)
.drain()
.map(|(_, state)| state)
.collect();
for state in &states {
state
.connection
.close(CLOSE_CODE_SHUTDOWN, b"engine is shutting down");
}
drop(states);
// Shut down the router; this stops the protocol handler and closes
// the endpoint.
let router = lock(&shared.router).take();
if let Some(router) = router
&& let Err(err) = router.shutdown().await
{
warn!(error = %err, "router shutdown reported an error");
}
shared.endpoint.close().await;
// Wait for the remaining background tasks; abort stragglers.
let mut tasks = std::mem::take(&mut *lock(&shared.tasks));
let drained = timeout(SHUTDOWN_TASK_GRACE, async {
while tasks.join_next().await.is_some() {}
})
.await;
if drained.is_err() {
warn!("background tasks did not finish in time; aborting them");
tasks.abort_all();
}
// Close the event channel: dropping the last sender makes
// `NetworkEventReceiver::recv` return `None` once drained.
*lock(&shared.events) = None;
info!("network engine shut down");
Ok(())
}
}
+67
View File
@@ -0,0 +1,67 @@
//! Error types for the federation-net library.
use iroh::EndpointId;
/// Convenient result alias used across the library.
pub type Result<T, E = NetworkError> = std::result::Result<T, E>;
/// All errors that can be returned by the public API of this library.
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum NetworkError {
/// The provided [`crate::NetworkConfig`] is invalid.
#[error("invalid configuration: {0}")]
InvalidConfig(String),
/// Loading or persisting the peer identity failed.
#[error("identity error: {0}")]
Identity(String),
/// A peer ticket could not be parsed or validated.
#[error("invalid ticket: {0}")]
InvalidTicket(String),
/// The remote peer belongs to a different network.
#[error("network id mismatch")]
NetworkMismatch,
/// The remote peer uses an incompatible message schema.
#[error("schema id mismatch")]
SchemaMismatch,
/// The remote peer speaks an unsupported protocol version.
#[error("unsupported protocol version")]
UnsupportedProtocolVersion,
/// The remote peer rejected the handshake as malformed.
#[error("handshake rejected as invalid by remote peer")]
HandshakeRejected,
/// No active connection to the given peer exists.
#[error("peer is not connected: {0}")]
PeerNotConnected(EndpointId),
/// A message exceeds the configured maximum size.
#[error("message exceeds maximum size")]
MessageTooLarge,
/// The operation did not complete within the configured timeout.
#[error("request timed out")]
Timeout,
/// Encoding or decoding a wire payload failed.
#[error("serialization error: {0}")]
Serialization(String),
/// The underlying Iroh transport reported an error.
#[error("transport error: {0}")]
Transport(String),
/// The remote peer rejected a message.
#[error("message rejected by peer: {0}")]
MessageRejected(String),
/// The engine is shutting down and no longer accepts operations.
#[error("engine is shutting down")]
ShuttingDown,
}
+79
View File
@@ -0,0 +1,79 @@
//! Network events delivered to the application.
use iroh::EndpointId;
use tokio::sync::mpsc;
/// Direction of an established peer connection.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConnectionDirection {
/// The remote peer connected to us.
Incoming,
/// We connected to the remote peer.
Outgoing,
}
/// Events emitted by a [`crate::NetworkEngine`].
///
/// `M` is the application-defined domain message type.
#[derive(Debug)]
pub enum NetworkEvent<M> {
/// A peer completed the handshake and is now connected.
PeerConnected {
/// Identifier of the connected peer.
peer_id: EndpointId,
/// Whether the peer connected to us or we connected to it.
direction: ConnectionDirection,
},
/// A peer disconnected.
PeerDisconnected {
/// Identifier of the disconnected peer.
peer_id: EndpointId,
/// Human-readable reason, if one is known.
reason: Option<String>,
},
/// A domain message was received from a peer.
MessageReceived {
/// Identifier of the sending peer.
peer_id: EndpointId,
/// The decoded domain message.
message: M,
},
/// A protocol-level error occurred.
///
/// Errors of a single connection never bring down the engine; they are
/// reported here instead.
ProtocolError {
/// The peer involved, if known.
peer_id: Option<EndpointId>,
/// Human-readable error description.
error: String,
},
}
/// Receiving side of the engine's event channel.
///
/// There is exactly one receiver per engine; it is returned from
/// [`crate::NetworkEngine::start`]. The underlying channel is bounded, so the
/// application should consume events promptly to avoid back-pressuring the
/// engine.
#[derive(Debug)]
pub struct NetworkEventReceiver<M> {
rx: mpsc::Receiver<NetworkEvent<M>>,
}
impl<M> NetworkEventReceiver<M> {
pub(crate) fn new(rx: mpsc::Receiver<NetworkEvent<M>>) -> Self {
Self { rx }
}
/// Receives the next event.
///
/// Returns `None` after the engine has shut down and all pending events
/// have been consumed.
pub async fn recv(&mut self) -> Option<NetworkEvent<M>> {
self.rx.recv().await
}
}
+154
View File
@@ -0,0 +1,154 @@
//! Persistent peer identity.
//!
//! Each peer owns a long-lived Iroh secret key stored at
//! `<data_dir>/identity.key`. The key is created on first start and loaded on
//! every subsequent start, so the peer's [`iroh::EndpointId`] stays stable
//! across restarts.
use std::path::Path;
use iroh::SecretKey;
use crate::error::{NetworkError, Result};
/// File name of the persisted secret key inside the data directory.
pub(crate) const IDENTITY_FILE_NAME: &str = "identity.key";
/// Loads the peer identity from `data_dir`, creating a new one if none exists.
///
/// File I/O runs on a blocking thread so the async runtime is never blocked.
pub(crate) async fn load_or_create(data_dir: &Path) -> Result<SecretKey> {
let dir = data_dir.to_path_buf();
tokio::task::spawn_blocking(move || load_or_create_blocking(&dir))
.await
.map_err(|err| NetworkError::Identity(format!("identity task panicked: {err}")))?
}
fn load_or_create_blocking(dir: &Path) -> Result<SecretKey> {
std::fs::create_dir_all(dir).map_err(|err| {
NetworkError::Identity(format!(
"failed to create data directory {}: {err}",
dir.display()
))
})?;
let path = dir.join(IDENTITY_FILE_NAME);
if path.exists() {
load_key(&path)
} else {
let key = SecretKey::generate();
store_key(dir, &path, &key)?;
Ok(key)
}
}
fn load_key(path: &Path) -> Result<SecretKey> {
let content = std::fs::read_to_string(path).map_err(|err| {
NetworkError::Identity(format!(
"failed to read identity file {}: {err}",
path.display()
))
})?;
let trimmed = content.trim();
let bytes = data_encoding::HEXLOWER_PERMISSIVE
.decode(trimmed.as_bytes())
.map_err(|_| corrupted(path))?;
let bytes: [u8; 32] = bytes.try_into().map_err(|_| corrupted(path))?;
Ok(SecretKey::from_bytes(&bytes))
}
fn corrupted(path: &Path) -> NetworkError {
NetworkError::Identity(format!(
"identity file {} is corrupted: expected 64 hex characters; \
remove the file to generate a new identity",
path.display()
))
}
/// Persists the key atomically: write to a temporary file in the same
/// directory, then rename it over the final path.
fn store_key(dir: &Path, path: &Path, key: &SecretKey) -> Result<()> {
use std::io::Write;
let tmp_path = dir.join(format!("{IDENTITY_FILE_NAME}.tmp"));
let mut options = std::fs::OpenOptions::new();
options.write(true).create(true).truncate(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
let write_result = (|| {
let mut file = options.open(&tmp_path)?;
let mut encoded = data_encoding::HEXLOWER.encode(&key.to_bytes());
encoded.push('\n');
file.write_all(encoded.as_bytes())?;
file.sync_all()?;
drop(file);
std::fs::rename(&tmp_path, path)
})();
if let Err(err) = write_result {
// Best effort removal of the temporary file; the original error wins.
let _ = std::fs::remove_file(&tmp_path);
return Err(NetworkError::Identity(format!(
"failed to persist identity file {}: {err}",
path.display()
)));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn key_is_created_and_reloaded() {
let dir = tempfile::tempdir().expect("tempdir");
let first = load_or_create(dir.path()).await.expect("create key");
let second = load_or_create(dir.path()).await.expect("load key");
assert_eq!(first.public(), second.public());
assert_eq!(first.to_bytes(), second.to_bytes());
}
#[tokio::test]
async fn existing_key_is_not_overwritten() {
let dir = tempfile::tempdir().expect("tempdir");
let first = load_or_create(dir.path()).await.expect("create key");
let path = dir.path().join(IDENTITY_FILE_NAME);
let before = std::fs::read(&path).expect("read identity file");
let second = load_or_create(dir.path()).await.expect("load key");
let after = std::fs::read(&path).expect("read identity file");
assert_eq!(before, after);
assert_eq!(first.public(), second.public());
}
#[tokio::test]
async fn corrupted_key_file_is_an_error() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join(IDENTITY_FILE_NAME);
std::fs::write(&path, "definitely not a hex key").expect("write garbage");
let result = load_or_create(dir.path()).await;
assert!(matches!(result, Err(NetworkError::Identity(_))));
}
#[tokio::test]
async fn truncated_key_file_is_an_error() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join(IDENTITY_FILE_NAME);
// Valid hex, but not 32 bytes.
std::fs::write(&path, "deadbeef").expect("write short key");
let result = load_or_create(dir.path()).await;
assert!(matches!(result, Err(NetworkError::Identity(_))));
}
#[cfg(unix)]
#[tokio::test]
async fn key_file_has_restrictive_permissions() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().expect("tempdir");
load_or_create(dir.path()).await.expect("create key");
let metadata = std::fs::metadata(dir.path().join(IDENTITY_FILE_NAME)).expect("metadata");
assert_eq!(metadata.permissions().mode() & 0o777, 0o600);
}
}
+73
View File
@@ -0,0 +1,73 @@
//! # federation-net
//!
//! A generic peer-to-peer networking engine built on [Iroh](https://iroh.computer).
//!
//! The library establishes direct (NAT-traversing, relay-assisted) QUIC
//! connections between peers and exchanges typed, application-defined
//! messages over them. It deliberately contains no domain logic: the
//! application chooses its own message type and the engine treats it as an
//! opaque, serde-serializable payload.
//!
//! ## Core concepts
//!
//! * [`NetworkId`] — isolates independent P2P networks from each other.
//! * [`SchemaId`] — isolates applications with incompatible message schemas.
//! * [`PeerTicket`] — a shareable string invitation used to reach a peer.
//! * [`NetworkEngine`] — the engine itself; [`NetworkEventReceiver`] delivers
//! [`NetworkEvent`]s to the application.
//!
//! ## Example
//!
//! ```no_run
//! use federation_net::{NetworkConfig, NetworkEngine, NetworkId, SchemaId};
//!
//! #[derive(Debug, serde::Serialize, serde::Deserialize)]
//! enum MyMessage {
//! Hello { from: String },
//! }
//!
//! # async fn run() -> federation_net::Result<()> {
//! let config = NetworkConfig::builder()
//! .data_dir("./peer-a")
//! .network_id(NetworkId::from_name("example-network"))
//! .schema_id(SchemaId::from_name("my-message-v1"))
//! .build()?;
//!
//! let (engine, mut events) = NetworkEngine::<MyMessage>::start(config).await?;
//! let ticket = engine.ticket().await?;
//! println!("share this ticket: {ticket}");
//!
//! while let Some(event) = events.recv().await {
//! println!("{event:?}");
//! }
//! engine.shutdown().await?;
//! # Ok(())
//! # }
//! ```
#![warn(missing_docs)]
#![forbid(unsafe_code)]
mod config;
mod engine;
mod error;
mod event;
mod identity;
mod protocol;
mod ticket;
mod wire;
pub use config::{
DEFAULT_EVENT_CHANNEL_CAPACITY, DEFAULT_MAX_CONCURRENT_STREAMS_PER_PEER,
DEFAULT_MAX_MESSAGE_SIZE, DEFAULT_REQUEST_TIMEOUT, NetworkConfig, NetworkConfigBuilder,
};
pub use engine::{Message, NetworkEngine};
pub use error::{NetworkError, Result};
pub use event::{ConnectionDirection, NetworkEvent, NetworkEventReceiver};
pub use protocol::{ALPN, NetworkId, PROTOCOL_VERSION, SchemaId};
pub use ticket::{PeerTicket, TICKET_VERSION};
// Re-exported Iroh types that appear in the public API.
pub use iroh::{EndpointAddr, EndpointId};
// Re-exported so applications can use the generic ticket helpers.
pub use iroh_tickets::Ticket;
+215
View File
@@ -0,0 +1,215 @@
//! Protocol constants, network/schema identifiers and internal wire structures.
use std::fmt;
use serde::{Deserialize, Serialize};
/// ALPN identifier for the federation-net protocol.
///
/// [`NetworkId`] and [`SchemaId`] are deliberately not part of the ALPN;
/// they are verified during the application-level handshake instead.
pub const ALPN: &[u8] = b"/federation-net/1";
/// Version of the application-level wire protocol.
pub const PROTOCOL_VERSION: u16 = 1;
/// Maximum size of an encoded handshake frame.
///
/// Handshake frames are tiny; this limit only guards against malicious peers.
pub(crate) const MAX_HANDSHAKE_FRAME_SIZE: usize = 16 * 1024;
fn hash_with_domain(domain: &str, name: &str) -> [u8; 32] {
let mut hasher = blake3::Hasher::new();
hasher.update(domain.as_bytes());
hasher.update(name.as_bytes());
*hasher.finalize().as_bytes()
}
fn fmt_id(bytes: &[u8; 32], f: &mut fmt::Formatter<'_>) -> fmt::Result {
for byte in bytes {
write!(f, "{byte:02x}")?;
}
Ok(())
}
/// Identifier of a distinct P2P network.
///
/// Peers with different network ids refuse to establish an application-level
/// session even though they share the same transport protocol.
#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
pub struct NetworkId([u8; 32]);
impl NetworkId {
/// Creates a network id from raw bytes.
pub fn from_bytes(bytes: [u8; 32]) -> Self {
Self(bytes)
}
/// Derives a network id from a human-readable name.
///
/// The derivation is deterministic: the same name always yields the same
/// id. Internally this computes `BLAKE3("federation-net:network:" + name)`.
pub fn from_name(name: &str) -> Self {
Self(hash_with_domain("federation-net:network:", name))
}
/// Returns the raw bytes of this id.
pub fn as_bytes(&self) -> &[u8; 32] {
&self.0
}
}
impl fmt::Display for NetworkId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
fmt_id(&self.0, f)
}
}
impl fmt::Debug for NetworkId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "NetworkId(")?;
fmt_id(&self.0, f)?;
write!(f, ")")
}
}
/// Identifier of the domain message schema used on top of the network.
///
/// Peers on the same network but with different schema ids reject each other,
/// because they would not be able to decode each other's messages. A backwards
/// incompatible change to the domain message type requires a new schema id.
#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
pub struct SchemaId([u8; 32]);
impl SchemaId {
/// Creates a schema id from raw bytes.
pub fn from_bytes(bytes: [u8; 32]) -> Self {
Self(bytes)
}
/// Derives a schema id from a human-readable name.
///
/// The derivation is deterministic: the same name always yields the same
/// id. Internally this computes `BLAKE3("federation-net:schema:" + name)`.
pub fn from_name(name: &str) -> Self {
Self(hash_with_domain("federation-net:schema:", name))
}
/// Returns the raw bytes of this id.
pub fn as_bytes(&self) -> &[u8; 32] {
&self.0
}
}
impl fmt::Display for SchemaId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
fmt_id(&self.0, f)
}
}
impl fmt::Debug for SchemaId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "SchemaId(")?;
fmt_id(&self.0, f)?;
write!(f, ")")
}
}
/// Handshake sent by the connecting side on the first bidirectional stream.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct Handshake {
pub protocol_version: u16,
pub network_id: NetworkId,
pub schema_id: SchemaId,
}
/// Reply to a [`Handshake`], sent by the accepting side.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct HandshakeAck {
pub accepted: bool,
pub error: Option<HandshakeErrorCode>,
}
/// Reasons for rejecting a handshake.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) enum HandshakeErrorCode {
UnsupportedProtocolVersion,
NetworkMismatch,
SchemaMismatch,
InvalidHandshake,
}
/// A single domain message request, sent on its own bidirectional stream.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct MessageRequest {
pub request_id: [u8; 16],
pub payload: Vec<u8>,
}
/// Reasons for rejecting a [`MessageRequest`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) enum MessageRejectReason {
/// The payload could not be decoded into the domain message type.
MalformedPayload,
/// The receiving peer is shutting down.
ShuttingDown,
/// The receiving peer is overloaded and dropped the message.
Overloaded,
}
impl fmt::Display for MessageRejectReason {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::MalformedPayload => write!(f, "malformed payload"),
Self::ShuttingDown => write!(f, "peer is shutting down"),
Self::Overloaded => write!(f, "peer is overloaded"),
}
}
}
/// Reply to a [`MessageRequest`].
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) enum MessageResponse {
Accepted {
request_id: [u8; 16],
},
Rejected {
request_id: [u8; 16],
reason: MessageRejectReason,
},
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn network_id_from_name_is_deterministic() {
let a = NetworkId::from_name("example-network");
let b = NetworkId::from_name("example-network");
assert_eq!(a, b);
}
#[test]
fn different_network_names_produce_different_ids() {
let a = NetworkId::from_name("network-a");
let b = NetworkId::from_name("network-b");
assert_ne!(a, b);
}
#[test]
fn schema_id_from_name_is_deterministic() {
let a = SchemaId::from_name("demo-message-v1");
let b = SchemaId::from_name("demo-message-v1");
assert_eq!(a, b);
assert_ne!(a, SchemaId::from_name("demo-message-v2"));
}
#[test]
fn network_and_schema_domains_are_separated() {
// The same name hashed under different domain prefixes must differ.
let network = NetworkId::from_name("same-name");
let schema = SchemaId::from_name("same-name");
assert_ne!(network.as_bytes(), schema.as_bytes());
}
}
+193
View File
@@ -0,0 +1,193 @@
//! Peer tickets: self-contained connection invitations.
use std::fmt;
use std::str::FromStr;
use iroh::{EndpointAddr, EndpointId};
use iroh_tickets::{ParseError, Ticket};
use serde::{Deserialize, Serialize};
use crate::protocol::{NetworkId, SchemaId};
/// Current version of the ticket wire format.
pub const TICKET_VERSION: u16 = 1;
/// Maximum size of a decoded ticket in bytes.
const MAX_TICKET_BYTES: usize = 8 * 1024;
/// Maximum length of a ticket string accepted by [`PeerTicket::from_str`].
const MAX_TICKET_STRING_LEN: usize = 16 * 1024;
/// A shareable invitation to connect to a peer.
///
/// The ticket contains everything needed to dial the peer over Iroh, plus the
/// network and schema identifiers so incompatibility is detected before any
/// domain message is exchanged.
///
/// Tickets serialize to a string with the `fnet` prefix via [`fmt::Display`]
/// and parse back via [`FromStr`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PeerTicket {
/// Version of the ticket format itself.
pub ticket_version: u16,
/// Version of the application-level wire protocol the peer speaks.
pub protocol_version: u16,
/// Network the issuing peer participates in.
pub network_id: NetworkId,
/// Message schema the issuing peer uses.
pub schema_id: SchemaId,
/// Iroh address of the issuing peer.
pub endpoint_addr: EndpointAddr,
}
impl PeerTicket {
/// Returns the endpoint id of the peer this ticket points to.
pub fn endpoint_id(&self) -> EndpointId {
self.endpoint_addr.id
}
}
/// Versioned body of the ticket; everything after the leading version number.
#[derive(Serialize, Deserialize)]
struct TicketBody {
protocol_version: u16,
network_id: NetworkId,
schema_id: SchemaId,
endpoint_addr: EndpointAddr,
}
impl Ticket for PeerTicket {
const KIND: &'static str = "fnet";
fn encode_bytes(&self) -> Vec<u8> {
let body = TicketBody {
protocol_version: self.protocol_version,
network_id: self.network_id,
schema_id: self.schema_id,
endpoint_addr: self.endpoint_addr.clone(),
};
// Serializing plain owned data into a growable Vec is infallible;
// postcard only errors on unsupported types or fixed-size buffers.
postcard::to_stdvec(&(self.ticket_version, body))
.expect("postcard serialization of a ticket into a Vec cannot fail")
}
fn decode_bytes(bytes: &[u8]) -> Result<Self, ParseError> {
if bytes.len() > MAX_TICKET_BYTES {
return Err(ParseError::verification_failed(
"ticket exceeds the maximum allowed size",
));
}
let (ticket_version, rest) = postcard::take_from_bytes::<u16>(bytes)?;
if ticket_version != TICKET_VERSION {
return Err(ParseError::verification_failed(
"unsupported ticket version",
));
}
let body: TicketBody = postcard::from_bytes(rest)?;
Ok(Self {
ticket_version,
protocol_version: body.protocol_version,
network_id: body.network_id,
schema_id: body.schema_id,
endpoint_addr: body.endpoint_addr,
})
}
}
impl fmt::Display for PeerTicket {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&Ticket::encode_string(self))
}
}
impl FromStr for PeerTicket {
type Err = ParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
if s.len() > MAX_TICKET_STRING_LEN {
return Err(ParseError::verification_failed(
"ticket string exceeds the maximum allowed length",
));
}
Ticket::decode_string(s)
}
}
#[cfg(test)]
mod tests {
use std::net::{Ipv4Addr, SocketAddr};
use iroh::SecretKey;
use super::*;
fn sample_ticket() -> PeerTicket {
let endpoint_id = SecretKey::generate().public();
let addr = EndpointAddr::new(endpoint_id)
.with_ip_addr(SocketAddr::from((Ipv4Addr::LOCALHOST, 4242)));
PeerTicket {
ticket_version: TICKET_VERSION,
protocol_version: 1,
network_id: NetworkId::from_name("test-network"),
schema_id: SchemaId::from_name("test-schema"),
endpoint_addr: addr,
}
}
#[test]
fn ticket_string_round_trip() {
let ticket = sample_ticket();
let encoded = ticket.to_string();
assert!(encoded.starts_with("fnet"));
let decoded: PeerTicket = encoded.parse().expect("parse ticket");
assert_eq!(decoded, ticket);
// The canonical string form must be stable across round-trips.
assert_eq!(decoded.to_string(), encoded);
}
#[test]
fn corrupted_ticket_is_rejected() {
let ticket = sample_ticket();
let mut encoded = ticket.to_string();
// Truncate the payload; the result must not parse.
encoded.truncate(encoded.len() - 10);
assert!(encoded.parse::<PeerTicket>().is_err());
// Corrupt the alphabet: '!' is not valid base32.
let corrupted = format!("fnet!{}", &ticket.to_string()[5..]);
assert!(corrupted.parse::<PeerTicket>().is_err());
}
#[test]
fn wrong_prefix_is_rejected() {
let ticket = sample_ticket();
let encoded = ticket.to_string();
let renamed = format!("blob{}", &encoded[4..]);
assert!(renamed.parse::<PeerTicket>().is_err());
}
#[test]
fn unknown_ticket_version_is_rejected() {
let ticket = sample_ticket();
let body = TicketBody {
protocol_version: ticket.protocol_version,
network_id: ticket.network_id,
schema_id: ticket.schema_id,
endpoint_addr: ticket.endpoint_addr.clone(),
};
let bytes = postcard::to_stdvec(&(99u16, body)).expect("encode");
let mut encoded = String::from("fnet");
data_encoding::BASE32_NOPAD.encode_append(&bytes, &mut encoded);
encoded.make_ascii_lowercase();
assert!(encoded.parse::<PeerTicket>().is_err());
}
#[test]
fn oversized_ticket_is_rejected() {
let bytes = vec![0u8; MAX_TICKET_BYTES + 1];
assert!(PeerTicket::decode_bytes(&bytes).is_err());
let huge = "fnet".to_string() + &"a".repeat(MAX_TICKET_STRING_LEN);
assert!(huge.parse::<PeerTicket>().is_err());
}
}
+152
View File
@@ -0,0 +1,152 @@
//! Length-prefixed framing of postcard-encoded values.
//!
//! Every frame on the wire is laid out as:
//!
//! ```text
//! 4 bytes: payload length, unsigned big-endian
//! N bytes: postcard payload
//! ```
use serde::Serialize;
use serde::de::DeserializeOwned;
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
use crate::error::{NetworkError, Result};
/// Encodes a value with postcard, enforcing `max_size` on the encoded bytes.
pub(crate) fn encode<T: Serialize>(value: &T, max_size: usize) -> Result<Vec<u8>> {
let bytes = postcard::to_stdvec(value)
.map_err(|err| NetworkError::Serialization(format!("postcard encoding failed: {err}")))?;
if bytes.len() > max_size {
return Err(NetworkError::MessageTooLarge);
}
Ok(bytes)
}
/// Decodes a postcard-encoded value.
pub(crate) fn decode<T: DeserializeOwned>(bytes: &[u8]) -> Result<T> {
postcard::from_bytes(bytes)
.map_err(|err| NetworkError::Serialization(format!("postcard decoding failed: {err}")))
}
/// Writes one length-prefixed frame containing the postcard encoding of
/// `value`.
pub(crate) async fn write_frame<W, T>(writer: &mut W, value: &T, max_size: usize) -> Result<()>
where
W: AsyncWrite + Unpin,
T: Serialize,
{
let payload = encode(value, max_size)?;
// `max_size` is validated to fit into u32 by the configuration.
let len = payload.len() as u32;
writer
.write_all(&len.to_be_bytes())
.await
.map_err(|err| NetworkError::Transport(format!("failed to write frame header: {err}")))?;
writer
.write_all(&payload)
.await
.map_err(|err| NetworkError::Transport(format!("failed to write frame payload: {err}")))?;
Ok(())
}
/// Reads one length-prefixed frame and decodes it with postcard.
///
/// The length prefix is validated against `max_size` before any payload
/// memory is allocated. A frame that exceeds the limit is rejected without
/// reading its payload.
pub(crate) async fn read_frame<R, T>(reader: &mut R, max_size: usize) -> Result<T>
where
R: AsyncRead + Unpin,
T: DeserializeOwned,
{
let mut len_bytes = [0u8; 4];
reader
.read_exact(&mut len_bytes)
.await
.map_err(|err| NetworkError::Transport(format!("failed to read frame header: {err}")))?;
let len = u32::from_be_bytes(len_bytes) as usize;
if len > max_size {
return Err(NetworkError::Transport(format!(
"incoming frame of {len} bytes exceeds limit of {max_size} bytes"
)));
}
let mut payload = vec![0u8; len];
reader
.read_exact(&mut payload)
.await
.map_err(|err| NetworkError::Transport(format!("failed to read frame payload: {err}")))?;
decode(&payload)
}
#[cfg(test)]
mod tests {
use serde::Deserialize;
use super::*;
#[derive(Debug, PartialEq, Serialize, Deserialize)]
struct TestValue {
text: String,
number: u64,
}
#[tokio::test]
async fn frame_round_trip() {
let value = TestValue {
text: "hello".into(),
number: 42,
};
let mut buf = Vec::new();
write_frame(&mut buf, &value, 1024).await.expect("write");
let decoded: TestValue = read_frame(&mut buf.as_slice(), 1024).await.expect("read");
assert_eq!(decoded, value);
}
#[tokio::test]
async fn oversized_frame_is_rejected_before_allocation() {
// A header declaring a huge payload with no payload bytes present.
// If the length check happened after allocation, this would try to
// allocate 4 GiB; instead it must fail fast on the limit check.
let mut buf = Vec::new();
buf.extend_from_slice(&u32::MAX.to_be_bytes());
let result: Result<TestValue> = read_frame(&mut buf.as_slice(), 1024).await;
assert!(matches!(result, Err(NetworkError::Transport(_))));
}
#[tokio::test]
async fn oversized_value_is_rejected_on_write() {
let value = TestValue {
text: "x".repeat(2048),
number: 1,
};
let mut buf = Vec::new();
let result = write_frame(&mut buf, &value, 1024).await;
assert!(matches!(result, Err(NetworkError::MessageTooLarge)));
assert!(buf.is_empty());
}
#[tokio::test]
async fn malformed_payload_is_rejected() {
// Valid header, but payload bytes that do not decode as TestValue.
let payload = [0xffu8; 8];
let mut buf = Vec::new();
buf.extend_from_slice(&(payload.len() as u32).to_be_bytes());
buf.extend_from_slice(&payload);
let result: Result<TestValue> = read_frame(&mut buf.as_slice(), 1024).await;
assert!(matches!(result, Err(NetworkError::Serialization(_))));
}
#[tokio::test]
async fn truncated_stream_is_an_error() {
let value = TestValue {
text: "hello".into(),
number: 42,
};
let mut buf = Vec::new();
write_frame(&mut buf, &value, 1024).await.expect("write");
buf.truncate(buf.len() - 1);
let result: Result<TestValue> = read_frame(&mut buf.as_slice(), 1024).await;
assert!(matches!(result, Err(NetworkError::Transport(_))));
}
}
+299
View File
@@ -0,0 +1,299 @@
//! Integration tests: two engines in one Tokio runtime.
use std::path::Path;
use std::time::Duration;
use federation_net::{
ConnectionDirection, EndpointId, NetworkConfig, NetworkEngine, NetworkError, NetworkEvent,
NetworkEventReceiver, NetworkId, PeerTicket, SchemaId,
};
/// Hard cap on every test so a regression can never hang CI.
const TEST_TIMEOUT: Duration = Duration::from_secs(120);
/// Timeout used when waiting for a single event.
const EVENT_TIMEOUT: Duration = Duration::from_secs(30);
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
enum TestMessage {
Text { sender: String, body: String },
Ping { nonce: u64 },
}
type Engine = NetworkEngine<TestMessage>;
type Events = NetworkEventReceiver<TestMessage>;
/// Serializes the network-facing tests: running many endpoints at once makes
/// relay discovery contend and produces spurious connect timeouts.
static NET_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
fn config(dir: &Path, network: &str, schema: &str) -> NetworkConfig {
NetworkConfig::builder()
.data_dir(dir)
.network_id(NetworkId::from_name(network))
.schema_id(SchemaId::from_name(schema))
.request_timeout(Duration::from_secs(10))
.build()
.expect("valid test config")
}
async fn start(dir: &Path, network: &str, schema: &str) -> (Engine, Events) {
NetworkEngine::start(config(dir, network, schema))
.await
.expect("engine starts")
}
/// Waits for the next event, panicking on timeout or channel close.
async fn next_event(events: &mut Events) -> NetworkEvent<TestMessage> {
tokio::time::timeout(EVENT_TIMEOUT, events.recv())
.await
.expect("timed out waiting for an event")
.expect("event channel closed unexpectedly")
}
/// Waits until a `PeerConnected` event for `peer` arrives, skipping unrelated
/// events (e.g. protocol errors from earlier rejected attempts).
async fn wait_connected(events: &mut Events, peer: EndpointId) -> ConnectionDirection {
loop {
if let NetworkEvent::PeerConnected { peer_id, direction } = next_event(events).await
&& peer_id == peer
{
return direction;
}
}
}
async fn wait_disconnected(events: &mut Events, peer: EndpointId) {
loop {
if let NetworkEvent::PeerDisconnected { peer_id, .. } = next_event(events).await
&& peer_id == peer
{
return;
}
}
}
async fn wait_message(events: &mut Events, peer: EndpointId) -> TestMessage {
loop {
if let NetworkEvent::MessageReceived { peer_id, message } = next_event(events).await
&& peer_id == peer
{
return message;
}
}
}
#[tokio::test]
async fn connect_and_exchange_messages() {
let _net = NET_LOCK.lock().await;
tokio::time::timeout(TEST_TIMEOUT, async {
let dir_a = tempfile::tempdir().expect("tempdir");
let dir_b = tempfile::tempdir().expect("tempdir");
let (engine_a, mut events_a) = start(dir_a.path(), "test-net", "test-schema-v1").await;
let (engine_b, mut events_b) = start(dir_b.path(), "test-net", "test-schema-v1").await;
// 1. Peer A creates a ticket.
let ticket = engine_a.ticket().await.expect("ticket");
assert_eq!(ticket.endpoint_id(), engine_a.endpoint_id());
// 2. Peer B connects using the ticket.
let peer_a = engine_b.connect(ticket).await.expect("connect");
assert_eq!(peer_a, engine_a.endpoint_id());
// 3. Both sides observe PeerConnected with the right direction.
let dir_on_b = wait_connected(&mut events_b, engine_a.endpoint_id()).await;
assert_eq!(dir_on_b, ConnectionDirection::Outgoing);
let dir_on_a = wait_connected(&mut events_a, engine_b.endpoint_id()).await;
assert_eq!(dir_on_a, ConnectionDirection::Incoming);
// 8. connected_peers contains the expected endpoint ids.
assert_eq!(engine_b.connected_peers(), vec![engine_a.endpoint_id()]);
assert_eq!(engine_a.connected_peers(), vec![engine_b.endpoint_id()]);
// 4-5. B sends a message; A receives the correctly typed object.
let hello = TestMessage::Text {
sender: "bob".into(),
body: "hello alice".into(),
};
engine_b
.send(engine_a.endpoint_id(), &hello)
.await
.expect("send b -> a");
let received = wait_message(&mut events_a, engine_b.endpoint_id()).await;
assert_eq!(received, hello);
// 6-7. A sends back over the same connection; B receives it.
let pong = TestMessage::Ping { nonce: 4242 };
engine_a
.send(engine_b.endpoint_id(), &pong)
.await
.expect("send a -> b");
let received = wait_message(&mut events_b, engine_a.endpoint_id()).await;
assert_eq!(received, pong);
engine_a.shutdown().await.expect("shutdown a");
engine_b.shutdown().await.expect("shutdown b");
})
.await
.expect("test timed out");
}
#[tokio::test]
async fn network_mismatch_is_rejected() {
let _net = NET_LOCK.lock().await;
tokio::time::timeout(TEST_TIMEOUT, async {
let dir_a = tempfile::tempdir().expect("tempdir");
let dir_b = tempfile::tempdir().expect("tempdir");
let (engine_a, _events_a) = start(dir_a.path(), "network-one", "schema-v1").await;
let (engine_b, _events_b) = start(dir_b.path(), "network-two", "schema-v1").await;
let ticket = engine_a.ticket().await.expect("ticket");
// Local pre-validation: the ticket carries A's network id.
let err = engine_b
.connect(ticket.clone())
.await
.expect_err("must fail");
assert!(matches!(err, NetworkError::NetworkMismatch), "got {err:?}");
// Remote validation: forge a ticket claiming B's own network id, so
// the local check passes and the remote handshake must reject it.
let forged = PeerTicket {
network_id: engine_b.network_id(),
..ticket
};
let err = engine_b.connect(forged).await.expect_err("must fail");
assert!(matches!(err, NetworkError::NetworkMismatch), "got {err:?}");
assert!(engine_a.connected_peers().is_empty());
assert!(engine_b.connected_peers().is_empty());
engine_a.shutdown().await.expect("shutdown a");
engine_b.shutdown().await.expect("shutdown b");
})
.await
.expect("test timed out");
}
#[tokio::test]
async fn schema_mismatch_is_rejected() {
let _net = NET_LOCK.lock().await;
tokio::time::timeout(TEST_TIMEOUT, async {
let dir_a = tempfile::tempdir().expect("tempdir");
let dir_b = tempfile::tempdir().expect("tempdir");
let (engine_a, _events_a) = start(dir_a.path(), "same-network", "schema-one").await;
let (engine_b, _events_b) = start(dir_b.path(), "same-network", "schema-two").await;
let ticket = engine_a.ticket().await.expect("ticket");
// Local pre-validation.
let err = engine_b
.connect(ticket.clone())
.await
.expect_err("must fail");
assert!(matches!(err, NetworkError::SchemaMismatch), "got {err:?}");
// Remote validation with a forged schema id.
let forged = PeerTicket {
schema_id: engine_b.schema_id(),
..ticket
};
let err = engine_b.connect(forged).await.expect_err("must fail");
assert!(matches!(err, NetworkError::SchemaMismatch), "got {err:?}");
engine_a.shutdown().await.expect("shutdown a");
engine_b.shutdown().await.expect("shutdown b");
})
.await
.expect("test timed out");
}
#[tokio::test]
async fn shutdown_disconnects_peers() {
let _net = NET_LOCK.lock().await;
tokio::time::timeout(TEST_TIMEOUT, async {
let dir_a = tempfile::tempdir().expect("tempdir");
let dir_b = tempfile::tempdir().expect("tempdir");
let (engine_a, mut events_a) = start(dir_a.path(), "test-net", "schema-v1").await;
let (engine_b, mut events_b) = start(dir_b.path(), "test-net", "schema-v1").await;
let ticket = engine_a.ticket().await.expect("ticket");
engine_b.connect(ticket).await.expect("connect");
wait_connected(&mut events_a, engine_b.endpoint_id()).await;
wait_connected(&mut events_b, engine_a.endpoint_id()).await;
let id_b = engine_b.endpoint_id();
engine_b.shutdown().await.expect("shutdown b");
// 11. A notices the disconnect and drops the peer from its registry.
wait_disconnected(&mut events_a, id_b).await;
assert!(engine_a.connected_peers().is_empty());
// Sending to the gone peer now fails without hanging.
let err = engine_a
.send(id_b, &TestMessage::Ping { nonce: 1 })
.await
.expect_err("peer is gone");
assert!(
matches!(err, NetworkError::PeerNotConnected(_)),
"got {err:?}"
);
// The event channel of the stopped engine closes after draining.
while events_b.recv().await.is_some() {}
engine_a.shutdown().await.expect("shutdown a");
})
.await
.expect("test timed out");
}
#[tokio::test]
async fn identity_persists_across_restarts() {
let _net = NET_LOCK.lock().await;
tokio::time::timeout(TEST_TIMEOUT, async {
let dir = tempfile::tempdir().expect("tempdir");
let (engine, _events) = start(dir.path(), "test-net", "schema-v1").await;
let first_id = engine.endpoint_id();
engine.shutdown().await.expect("shutdown");
let (engine, _events) = start(dir.path(), "test-net", "schema-v1").await;
let second_id = engine.endpoint_id();
engine.shutdown().await.expect("shutdown");
assert_eq!(first_id, second_id);
})
.await
.expect("test timed out");
}
#[tokio::test]
async fn disconnect_removes_peer() {
let _net = NET_LOCK.lock().await;
tokio::time::timeout(TEST_TIMEOUT, async {
let dir_a = tempfile::tempdir().expect("tempdir");
let dir_b = tempfile::tempdir().expect("tempdir");
let (engine_a, mut events_a) = start(dir_a.path(), "test-net", "schema-v1").await;
let (engine_b, mut events_b) = start(dir_b.path(), "test-net", "schema-v1").await;
let ticket = engine_a.ticket().await.expect("ticket");
let peer_a = engine_b.connect(ticket).await.expect("connect");
wait_connected(&mut events_a, engine_b.endpoint_id()).await;
wait_connected(&mut events_b, peer_a).await;
engine_b.disconnect(peer_a).await.expect("disconnect");
wait_disconnected(&mut events_b, peer_a).await;
assert!(engine_b.connected_peers().is_empty());
// Disconnecting twice reports PeerNotConnected.
let err = engine_b.disconnect(peer_a).await.expect_err("already gone");
assert!(
matches!(err, NetworkError::PeerNotConnected(_)),
"got {err:?}"
);
engine_a.shutdown().await.expect("shutdown a");
engine_b.shutdown().await.expect("shutdown b");
})
.await
.expect("test timed out");
}