diff --git a/AGENTS.md b/AGENTS.md index 71dd309..198f855 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -104,7 +104,7 @@ Roadmap items should be actionable and checkable: - Local daemon, local control socket, local identity, local store, local CAS, daemon-owned Iroh endpoint startup, built-in relay-mode config, SSH certificate metadata, revocation metadata, user service definitions, and a - pinned `geth-iroh` endpoint wrapper exist. + pinned `geth-iroh` endpoint wrapper with protocol-router scaffold exist. - Custom relay maps, mDNS discovery, peer auth over Iroh, cr-sqlite, iroh-docs, iroh-gossip, iroh-blobs, Automerge sync, real auth enforcement, OpenSSH KRL generation, and Keyhive/BeeKEM-style authorization are future roadmap items diff --git a/crates/geth-iroh/src/lib.rs b/crates/geth-iroh/src/lib.rs index 952c47e..d11aefa 100644 --- a/crates/geth-iroh/src/lib.rs +++ b/crates/geth-iroh/src/lib.rs @@ -1,4 +1,5 @@ use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; use std::net::{SocketAddrV4, SocketAddrV6}; use std::path::{Path, PathBuf}; @@ -36,11 +37,123 @@ impl GethIrohConfig { relay_mode, bind_ipv4: None, bind_ipv6: None, - alpns: all_alpns(), + alpns: default_protocol_router().alpns(), } } } +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum ProtocolKind { + Control, + Kv, + Cas, + Pubsub, + Pipe, + Db, + Document, + SshProxy, +} + +impl ProtocolKind { + #[must_use] + pub fn name(self) -> &'static str { + match self { + Self::Control => "control", + Self::Kv => "kv", + Self::Cas => "cas", + Self::Pubsub => "pubsub", + Self::Pipe => "pipe", + Self::Db => "db", + Self::Document => "document", + Self::SshProxy => "ssh-proxy", + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProtocolDescriptor { + pub kind: ProtocolKind, + pub name: String, + pub alpn: Vec, +} + +impl ProtocolDescriptor { + #[must_use] + pub fn new(kind: ProtocolKind, alpn: &'static [u8]) -> Self { + Self { + kind, + name: kind.name().to_owned(), + alpn: alpn.to_vec(), + } + } +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct ProtocolRouter { + protocols: BTreeMap, ProtocolDescriptor>, +} + +impl ProtocolRouter { + #[must_use] + pub fn new() -> Self { + Self::default() + } + + pub fn register(&mut self, descriptor: ProtocolDescriptor) -> Result<(), RouterError> { + if self.protocols.contains_key(&descriptor.alpn) { + return Err(RouterError::DuplicateAlpn { + alpn: display_alpn(&descriptor.alpn), + }); + } + self.protocols.insert(descriptor.alpn.clone(), descriptor); + Ok(()) + } + + pub fn require(&self, alpn: &[u8]) -> Result<&ProtocolDescriptor, RouterError> { + self.protocols + .get(alpn) + .ok_or_else(|| RouterError::UnknownAlpn { + alpn: display_alpn(alpn), + }) + } + + #[must_use] + pub fn alpns(&self) -> Vec> { + self.protocols.keys().cloned().collect() + } + + #[must_use] + pub fn descriptors(&self) -> Vec { + self.protocols.values().cloned().collect() + } +} + +#[must_use] +pub fn default_protocol_router() -> ProtocolRouter { + let mut router = ProtocolRouter::new(); + for descriptor in default_protocol_descriptors() { + router + .register(descriptor) + .expect("default geth ALPNs are unique"); + } + router +} + +#[must_use] +pub fn default_protocol_descriptors() -> Vec { + vec![ + ProtocolDescriptor::new(ProtocolKind::Control, ALPN_CONTROL), + ProtocolDescriptor::new(ProtocolKind::Kv, ALPN_KV), + ProtocolDescriptor::new(ProtocolKind::Cas, ALPN_CAS), + ProtocolDescriptor::new(ProtocolKind::Pubsub, ALPN_PUBSUB), + ProtocolDescriptor::new(ProtocolKind::Pipe, ALPN_PIPE), + ProtocolDescriptor::new(ProtocolKind::Db, ALPN_DB), + ProtocolDescriptor::new(ProtocolKind::Document, ALPN_DOCUMENT), + ProtocolDescriptor::new(ProtocolKind::SshProxy, ALPN_SSH_PROXY), + ] +} + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum GethRelayMode { @@ -142,19 +255,7 @@ pub fn load_secret_key(path: &Path) -> Result { #[must_use] pub fn all_alpns() -> Vec> { - [ - ALPN_CONTROL, - ALPN_KV, - ALPN_CAS, - ALPN_PUBSUB, - ALPN_PIPE, - ALPN_DB, - ALPN_DOCUMENT, - ALPN_SSH_PROXY, - ] - .into_iter() - .map(<[u8]>::to_vec) - .collect() + default_protocol_router().alpns() } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -189,12 +290,24 @@ pub enum IrohError { Bind(Box), } +#[derive(Debug, thiserror::Error)] +pub enum RouterError { + #[error("duplicate geth ALPN registration: {alpn}")] + DuplicateAlpn { alpn: String }, + #[error("unknown geth ALPN: {alpn}")] + UnknownAlpn { alpn: String }, +} + impl From for IrohError { fn from(error: iroh::endpoint::BindError) -> Self { Self::Bind(Box::new(error)) } } +fn display_alpn(alpn: &[u8]) -> String { + String::from_utf8(alpn.to_vec()).unwrap_or_else(|_| format!("0x{}", hex::encode(alpn))) +} + #[cfg(test)] mod tests { use super::*; @@ -208,6 +321,41 @@ mod tests { assert_eq!(alpns.len(), 8); } + #[test] + fn default_router_registers_all_protocols() { + let router = default_protocol_router(); + assert_eq!( + router.require(ALPN_CONTROL).expect("control").kind, + ProtocolKind::Control + ); + assert_eq!( + router.require(ALPN_SSH_PROXY).expect("ssh proxy").kind, + ProtocolKind::SshProxy + ); + assert_eq!(router.descriptors().len(), 8); + } + + #[test] + fn router_rejects_duplicate_alpns() { + let mut router = ProtocolRouter::new(); + router + .register(ProtocolDescriptor::new(ProtocolKind::Control, ALPN_CONTROL)) + .expect("first registration"); + let error = router + .register(ProtocolDescriptor::new(ProtocolKind::Cas, ALPN_CONTROL)) + .expect_err("duplicate error"); + assert!(matches!(error, RouterError::DuplicateAlpn { .. })); + } + + #[test] + fn router_rejects_unknown_alpn() { + let router = default_protocol_router(); + let error = router + .require(b"/geth/unknown/1") + .expect_err("unknown error"); + assert!(matches!(error, RouterError::UnknownAlpn { .. })); + } + #[test] fn iroh_secret_key_persists() { let dir = tempfile::tempdir().expect("tempdir"); diff --git a/docs/architecture.md b/docs/architecture.md index 6dc3627..b07e070 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -27,6 +27,12 @@ material, and shuts down through `Endpoint::close().await`. The default config uses Iroh's default relay policy; local-only/offline development can set `[iroh].relay_mode = "disabled"`. +Module ALPNs are registered through `geth-iroh`'s protocol router scaffold. The +router owns the default protocol descriptors, rejects duplicate ALPN +registrations, and returns explicit unknown-ALPN errors. It does not yet accept +or dispatch remote streams; peer authentication and module handlers are later +Phase 1 work. + The target product should use Iroh relay support for practical internet connectivity and mDNS/LAN discovery for local networks. These are connectivity and candidate-discovery mechanisms only. They do not grant trust, mutate diff --git a/docs/roadmap.md b/docs/roadmap.md index 38d6327..ff39ce2 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -95,7 +95,7 @@ geth-to-geth connections without granting trust from discovery alone. - Discovered EndpointIDs do not grant module access without keychain/auth validation. -- `[ ]` Protocol/router scaffold. +- `[x]` Protocol/router scaffold. Acceptance criteria: - ALPN constants are registered through one module router. - Unknown ALPNs are rejected explicitly.