geth/crates/geth-iroh/src/lib.rs

399 lines
12 KiB
Rust
Raw Normal View History

2026-05-15 15:08:20 +02:00
use serde::{Deserialize, Serialize};
2026-05-16 03:37:51 +02:00
use std::collections::BTreeMap;
2026-05-16 01:54:00 +02:00
use std::net::{SocketAddrV4, SocketAddrV6};
use std::path::{Path, PathBuf};
2026-05-15 15:08:20 +02:00
pub const ALPN_CONTROL: &[u8] = b"/geth/control/1";
pub const ALPN_KV: &[u8] = b"/geth/kv/1";
pub const ALPN_CAS: &[u8] = b"/geth/cas/1";
pub const ALPN_PUBSUB: &[u8] = b"/geth/pubsub/1";
pub const ALPN_PIPE: &[u8] = b"/geth/pipe/1";
pub const ALPN_DB: &[u8] = b"/geth/db/1";
pub const ALPN_DOCUMENT: &[u8] = b"/geth/document/1";
pub const ALPN_SSH_PROXY: &[u8] = b"/geth/ssh-proxy/1";
2026-05-16 01:54:00 +02:00
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct GethIrohConfig {
pub secret_key_path: PathBuf,
pub relay_mode: GethRelayMode,
pub bind_ipv4: Option<SocketAddrV4>,
pub bind_ipv6: Option<SocketAddrV6>,
pub alpns: Vec<Vec<u8>>,
}
impl GethIrohConfig {
#[must_use]
pub fn local(secret_key_path: impl Into<PathBuf>) -> Self {
2026-05-16 03:17:45 +02:00
Self::local_with_relay(secret_key_path, GethRelayMode::Default)
}
#[must_use]
pub fn local_with_relay(
secret_key_path: impl Into<PathBuf>,
relay_mode: GethRelayMode,
) -> Self {
2026-05-16 01:54:00 +02:00
Self {
secret_key_path: secret_key_path.into(),
2026-05-16 03:17:45 +02:00
relay_mode,
2026-05-16 01:54:00 +02:00
bind_ipv4: None,
bind_ipv6: None,
2026-05-16 03:37:51 +02:00
alpns: default_protocol_router().alpns(),
2026-05-16 01:54:00 +02:00
}
}
}
2026-05-16 03:37:51 +02:00
#[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),
]
}
2026-05-16 01:54:00 +02:00
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum GethRelayMode {
Disabled,
Default,
Staging,
}
impl GethRelayMode {
fn to_iroh(&self) -> iroh::RelayMode {
match self {
Self::Disabled => iroh::RelayMode::Disabled,
Self::Default => iroh::RelayMode::Default,
Self::Staging => iroh::RelayMode::Staging,
}
}
2026-05-16 03:17:45 +02:00
#[must_use]
pub fn as_str(&self) -> &'static str {
match self {
Self::Disabled => "disabled",
Self::Default => "default",
Self::Staging => "staging",
}
}
2026-05-16 01:54:00 +02:00
}
pub struct GethIrohEndpoint {
endpoint: iroh::Endpoint,
status: EndpointStatus,
}
impl GethIrohEndpoint {
#[must_use]
pub fn status(&self) -> EndpointStatus {
self.status.clone()
}
#[must_use]
pub fn node_id(&self) -> String {
self.endpoint.node_id().to_string()
}
pub async fn shutdown(&self) {
self.endpoint.close().await;
}
}
pub async fn start_endpoint(config: &GethIrohConfig) -> Result<GethIrohEndpoint, IrohError> {
let secret_key = load_or_create_secret_key(&config.secret_key_path)?;
let mut builder = iroh::Endpoint::builder()
.secret_key(secret_key)
.relay_mode(config.relay_mode.to_iroh())
.alpns(config.alpns.clone());
if let Some(bind_ipv4) = config.bind_ipv4 {
builder = builder.bind_addr_v4(bind_ipv4);
}
if let Some(bind_ipv6) = config.bind_ipv6 {
builder = builder.bind_addr_v6(bind_ipv6);
}
let endpoint = builder.bind().await.map_err(IrohError::from)?;
let status = EndpointStatus {
enabled: true,
endpoint_id: Some(endpoint.node_id().to_string()),
2026-05-16 03:17:45 +02:00
relay_mode: config.relay_mode.as_str().to_owned(),
2026-05-16 01:54:00 +02:00
note: format!(
"Iroh endpoint started with iroh 0.90.0; relay mode: {:?}",
config.relay_mode
),
};
Ok(GethIrohEndpoint { endpoint, status })
}
pub fn load_or_create_secret_key(path: &Path) -> Result<iroh::SecretKey, IrohError> {
if path.exists() {
return load_secret_key(path);
}
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let secret_key = iroh::SecretKey::generate(rand_core::OsRng);
let tmp = path.with_extension("tmp");
std::fs::write(&tmp, hex::encode(secret_key.to_bytes()))?;
std::fs::rename(tmp, path)?;
Ok(secret_key)
}
pub fn load_secret_key(path: &Path) -> Result<iroh::SecretKey, IrohError> {
let encoded = std::fs::read_to_string(path)?;
let bytes = hex::decode(encoded.trim())?;
let bytes: [u8; 32] = bytes
.try_into()
.map_err(|_| IrohError::InvalidSecretKeyLength)?;
Ok(iroh::SecretKey::from_bytes(&bytes))
}
#[must_use]
pub fn all_alpns() -> Vec<Vec<u8>> {
2026-05-16 03:37:51 +02:00
default_protocol_router().alpns()
2026-05-16 01:54:00 +02:00
}
2026-05-15 15:08:20 +02:00
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct EndpointStatus {
pub enabled: bool,
pub endpoint_id: Option<String>,
2026-05-16 03:17:45 +02:00
pub relay_mode: String,
2026-05-15 15:08:20 +02:00
pub note: String,
}
impl EndpointStatus {
#[must_use]
pub fn scaffolded() -> Self {
Self {
enabled: false,
endpoint_id: None,
2026-05-16 03:17:45 +02:00
relay_mode: "not-started".to_owned(),
2026-05-16 01:54:00 +02:00
note: "Iroh endpoint is not running outside daemon mode".to_owned(),
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum IrohError {
#[error("io error: {0}")]
Io(#[from] std::io::Error),
#[error("invalid hex iroh secret key: {0}")]
Hex(#[from] hex::FromHexError),
#[error("invalid iroh secret key length")]
InvalidSecretKeyLength,
#[error("failed to bind iroh endpoint: {0}")]
Bind(Box<iroh::endpoint::BindError>),
}
2026-05-16 03:37:51 +02:00
#[derive(Debug, thiserror::Error)]
pub enum RouterError {
#[error("duplicate geth ALPN registration: {alpn}")]
DuplicateAlpn { alpn: String },
#[error("unknown geth ALPN: {alpn}")]
UnknownAlpn { alpn: String },
}
2026-05-16 01:54:00 +02:00
impl From<iroh::endpoint::BindError> for IrohError {
fn from(error: iroh::endpoint::BindError) -> Self {
Self::Bind(Box::new(error))
}
}
2026-05-16 03:37:51 +02:00
fn display_alpn(alpn: &[u8]) -> String {
String::from_utf8(alpn.to_vec()).unwrap_or_else(|_| format!("0x{}", hex::encode(alpn)))
}
2026-05-16 01:54:00 +02:00
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn all_alpns_contains_expected_protocols() {
let alpns = all_alpns();
assert!(alpns.contains(&ALPN_CONTROL.to_vec()));
assert!(alpns.contains(&ALPN_CAS.to_vec()));
assert!(alpns.contains(&ALPN_SSH_PROXY.to_vec()));
assert_eq!(alpns.len(), 8);
}
2026-05-16 03:37:51 +02:00
#[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 { .. }));
}
2026-05-16 01:54:00 +02:00
#[test]
fn iroh_secret_key_persists() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("iroh.ed25519");
let first = load_or_create_secret_key(&path).expect("create key");
let second = load_or_create_secret_key(&path).expect("load key");
assert_eq!(first.public(), second.public());
}
2026-05-16 03:17:45 +02:00
#[test]
fn local_config_selects_relay_mode() {
let config = GethIrohConfig::local_with_relay("iroh.ed25519", GethRelayMode::Staging);
assert_eq!(config.relay_mode, GethRelayMode::Staging);
assert!(matches!(
config.relay_mode.to_iroh(),
iroh::RelayMode::Staging
));
}
2026-05-16 01:54:00 +02:00
#[tokio::test]
async fn endpoint_status_tracks_node_id_when_bind_is_available() {
let dir = tempfile::tempdir().expect("tempdir");
2026-05-16 03:17:45 +02:00
let config = GethIrohConfig::local_with_relay(
dir.path().join("iroh.ed25519"),
GethRelayMode::Disabled,
);
2026-05-16 01:54:00 +02:00
match start_endpoint(&config).await {
Ok(endpoint) => {
let status = endpoint.status();
assert!(status.enabled);
assert_eq!(status.endpoint_id, Some(endpoint.node_id()));
2026-05-16 03:17:45 +02:00
assert_eq!(status.relay_mode, "disabled");
2026-05-16 01:54:00 +02:00
endpoint.shutdown().await;
}
Err(IrohError::Bind(error)) => {
eprintln!("skipping endpoint bind assertion; UDP unavailable: {error}");
}
Err(error) => panic!("unexpected iroh startup error: {error}"),
2026-05-15 15:08:20 +02:00
}
}
}