Add Iroh protocol router scaffold

This commit is contained in:
Eric Wendland 2026-05-16 03:37:51 +02:00
commit cf5a405276
4 changed files with 170 additions and 16 deletions

View file

@ -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

View file

@ -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<u8>,
}
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<Vec<u8>, 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<Vec<u8>> {
self.protocols.keys().cloned().collect()
}
#[must_use]
pub fn descriptors(&self) -> Vec<ProtocolDescriptor> {
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<ProtocolDescriptor> {
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<iroh::SecretKey, IrohError> {
#[must_use]
pub fn all_alpns() -> Vec<Vec<u8>> {
[
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<iroh::endpoint::BindError>),
}
#[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<iroh::endpoint::BindError> 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");

View file

@ -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

View file

@ -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.