@@ -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"
|
||||
|
||||
@@ -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()]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user