Added stat version
CI / check (push) Successful in 2m41s

This commit is contained in:
Ultradesu
2026-07-28 22:00:11 +01:00
parent 8de7d12927
commit 10fa97a915
19 changed files with 703 additions and 407 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "federation-net"
version = "0.1.0"
version = "0.2.0"
description = "Generic peer-to-peer networking engine built on Iroh"
readme = "README.md"
documentation = "https://docs.rs/federation-net"
+53
View File
@@ -41,6 +41,9 @@ pub struct NetworkConfig {
/// Auxiliary ALPN protocols on which this peer accepts raw byte streams
/// (see [`crate::NetworkEngine::stream_acceptor`]).
pub stream_protocols: Vec<Vec<u8>>,
/// Auxiliary stream protocols whose handshake is independent of the
/// application schema. Intended for compatibility/capability discovery.
pub schema_independent_stream_protocols: Vec<Vec<u8>>,
}
impl NetworkConfig {
@@ -65,6 +68,7 @@ pub struct NetworkConfigBuilder {
max_concurrent_streams_per_peer: Option<usize>,
rendezvous: Option<RendezvousConfig>,
stream_protocols: Vec<Vec<u8>>,
schema_independent_stream_protocols: Vec<Vec<u8>>,
}
impl NetworkConfigBuilder {
@@ -124,6 +128,17 @@ impl NetworkConfigBuilder {
self
}
/// Declares an auxiliary ALPN protocol that validates the network and
/// federation transport version, but deliberately does not require the
/// application schema id to match.
///
/// This is suitable for bounded, self-versioned capability discovery
/// protocols that must remain reachable across schema upgrades.
pub fn schema_independent_stream_protocol(mut self, alpn: impl Into<Vec<u8>>) -> Self {
self.schema_independent_stream_protocols.push(alpn.into());
self
}
/// Validates the configuration and builds a [`NetworkConfig`].
pub fn build(self) -> Result<NetworkConfig> {
let data_dir = self
@@ -210,6 +225,27 @@ impl NetworkConfigBuilder {
)));
}
}
for (index, alpn) in self.schema_independent_stream_protocols.iter().enumerate() {
if alpn.is_empty() {
return Err(NetworkError::InvalidConfig(
"schema-independent stream protocol ALPN must not be empty".into(),
));
}
if alpn.as_slice() == ALPN {
return Err(NetworkError::InvalidConfig(
"schema-independent stream protocol ALPN must differ from the engine ALPN"
.into(),
));
}
if self.schema_independent_stream_protocols[..index].contains(alpn)
|| self.stream_protocols.contains(alpn)
{
return Err(NetworkError::InvalidConfig(format!(
"duplicate stream protocol ALPN: {}",
String::from_utf8_lossy(alpn)
)));
}
}
Ok(NetworkConfig {
data_dir,
@@ -221,6 +257,7 @@ impl NetworkConfigBuilder {
max_concurrent_streams_per_peer,
rendezvous: self.rendezvous,
stream_protocols: self.stream_protocols,
schema_independent_stream_protocols: self.schema_independent_stream_protocols,
})
}
}
@@ -326,5 +363,21 @@ mod tests {
.build()
.is_err()
);
assert!(
base_builder()
.stream_protocol("dup/1")
.schema_independent_stream_protocol("dup/1")
.build()
.is_err()
);
let config = base_builder()
.schema_independent_stream_protocol("furumi/capabilities/1")
.build()
.expect("valid schema-independent stream protocol");
assert_eq!(
config.schema_independent_stream_protocols,
vec![b"furumi/capabilities/1".to_vec()]
);
}
}
+31 -1
View File
@@ -383,6 +383,28 @@ impl<M: Message> Shared<M> {
}
}
fn validate_stream_handshake(
&self,
alpn: &[u8],
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 !self
.config
.schema_independent_stream_protocols
.iter()
.any(|protocol| protocol.as_slice() == alpn)
&& 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(
@@ -627,7 +649,7 @@ impl<M: Message> Shared<M> {
.and_then(|res| res);
let verdict = match &handshake {
Ok(handshake) => self.validate_handshake(handshake),
Ok(handshake) => self.validate_stream_handshake(alpn, handshake),
Err(_) => Some(HandshakeErrorCode::InvalidHandshake),
};
if let Some(code) = verdict {
@@ -948,6 +970,7 @@ impl<M: Message> NetworkEngine<M> {
let stream_acceptors = config
.stream_protocols
.iter()
.chain(config.schema_independent_stream_protocols.iter())
.map(|alpn| {
let (sender, receiver) = mpsc::channel(STREAM_ACCEPT_QUEUE);
(
@@ -982,6 +1005,13 @@ impl<M: Message> NetworkEngine<M> {
};
router_builder = router_builder.accept(alpn.clone(), handler);
}
for alpn in &shared.config.schema_independent_stream_protocols {
let handler = StreamProtocol::<M> {
shared: Arc::downgrade(&shared),
alpn: alpn.clone(),
};
router_builder = router_builder.accept(alpn.clone(), handler);
}
let router = router_builder.spawn();
*lock(&shared.router) = Some(router);
if let Some(rendezvous) = shared.config.rendezvous.clone() {
+1
View File
@@ -72,6 +72,7 @@ pub use engine::{
pub use error::{NetworkError, Result};
pub use event::{ConnectionDirection, NetworkEvent, NetworkEventReceiver};
pub use protocol::{ALPN, NetworkId, PROTOCOL_VERSION, SchemaId};
pub use rendezvous::RENDEZVOUS_RECORD_VERSION;
pub use rendezvous::{DEFAULT_RENDEZVOUS_ENTRY_TTL, DEFAULT_RENDEZVOUS_INTERVAL, RendezvousConfig};
pub use ticket::{PeerTicket, TICKET_VERSION};
+5 -4
View File
@@ -38,7 +38,8 @@ pub const DEFAULT_RENDEZVOUS_INTERVAL: Duration = Duration::from_secs(60);
pub const DEFAULT_RENDEZVOUS_ENTRY_TTL: Duration = Duration::from_secs(30 * 60);
/// Version of the rendezvous record wire format.
const RECORD_VERSION: u16 = 1;
/// Version of the Mainline-DHT rendezvous record envelope.
pub const RENDEZVOUS_RECORD_VERSION: u16 = 1;
/// Domain separation context for deriving the record signing key.
const KEY_DERIVATION_CONTEXT: &str = "federation-net:rendezvous:v1";
/// BEP44 caps mutable values at 1000 bytes; stay safely below.
@@ -104,7 +105,7 @@ pub(crate) fn now_ms() -> u64 {
fn decode_record(bytes: &[u8]) -> Option<Vec<RendezvousEntry>> {
let record: RendezvousRecord = postcard::from_bytes(bytes).ok()?;
(record.version == RECORD_VERSION).then_some(record.entries)
(record.version == RENDEZVOUS_RECORD_VERSION).then_some(record.entries)
}
/// Merges entries from every record instance seen this round with our own,
@@ -144,7 +145,7 @@ fn merge_entries(
fn encode_record_capped(mut entries: Vec<RendezvousEntry>) -> Result<Vec<u8>> {
loop {
let record = RendezvousRecord {
version: RECORD_VERSION,
version: RENDEZVOUS_RECORD_VERSION,
entries,
};
let encoded = postcard::to_stdvec(&record).map_err(|err| {
@@ -279,7 +280,7 @@ mod tests {
fn malformed_and_wrong_version_records_are_ignored() {
assert!(decode_record(b"garbage").is_none());
let record = RendezvousRecord {
version: RECORD_VERSION + 1,
version: RENDEZVOUS_RECORD_VERSION + 1,
entries: vec![],
};
let encoded = postcard::to_stdvec(&record).expect("encode");
@@ -12,6 +12,7 @@ use federation_net::{
const TEST_TIMEOUT: Duration = Duration::from_secs(120);
/// Timeout used when waiting for a single event.
const EVENT_TIMEOUT: Duration = Duration::from_secs(30);
const CAPABILITY_ALPN: &[u8] = b"test/capabilities/1";
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
enum TestMessage {
@@ -207,6 +208,64 @@ async fn schema_mismatch_is_rejected() {
.expect("test timed out");
}
#[tokio::test]
async fn schema_independent_stream_crosses_schema_versions() {
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 config_a = NetworkConfig::builder()
.data_dir(dir_a.path())
.network_id(NetworkId::from_name("same-network"))
.schema_id(SchemaId::from_name("schema-one"))
.schema_independent_stream_protocol(CAPABILITY_ALPN)
.build()
.expect("config a");
let config_b = NetworkConfig::builder()
.data_dir(dir_b.path())
.network_id(NetworkId::from_name("same-network"))
.schema_id(SchemaId::from_name("schema-two"))
.schema_independent_stream_protocol(CAPABILITY_ALPN)
.build()
.expect("config b");
let (engine_a, _events_a) = Engine::start(config_a).await.expect("engine a");
let (engine_b, _events_b) = Engine::start(config_b).await.expect("engine b");
let mut acceptor = engine_a
.stream_acceptor(CAPABILITY_ALPN)
.expect("capability acceptor");
let address = engine_a.ticket().await.expect("ticket").endpoint_addr;
let accepting = tokio::spawn(async move {
let mut stream = acceptor.accept().await.expect("incoming stream");
let value = stream.recv.read_to_end(16).await.expect("read request");
assert_eq!(value, b"versions?");
stream.send.write_all(b"v2").await.expect("write response");
stream.send.finish().expect("finish response");
let _ = stream.send.stopped().await;
});
let mut stream = engine_b
.open_stream(address, CAPABILITY_ALPN)
.await
.expect("schema-independent stream");
stream
.send
.write_all(b"versions?")
.await
.expect("write request");
stream.send.finish().expect("finish request");
assert_eq!(
stream.recv.read_to_end(16).await.expect("read response"),
b"v2"
);
accepting.await.expect("accept task");
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;