Add Iroh relay mode config

This commit is contained in:
Eric Wendland 2026-05-16 03:17:45 +02:00
commit 0f2ad755d9
13 changed files with 210 additions and 21 deletions

View file

@ -102,10 +102,10 @@ Roadmap items should be actionable and checkable:
## Current Feature Boundaries
- Local daemon, local control socket, local identity, local store, local CAS,
daemon-owned Iroh endpoint startup, SSH certificate metadata, revocation
metadata, user service definitions, and a pinned `geth-iroh` endpoint wrapper
exist.
- Relay policy, mDNS discovery, peer auth over Iroh, cr-sqlite, iroh-docs,
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.
- 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
unless implemented later.

8
Cargo.lock generated
View file

@ -1090,6 +1090,7 @@ dependencies = [
"directories",
"serde",
"thiserror 2.0.18",
"toml_edit",
]
[[package]]
@ -3860,6 +3861,7 @@ dependencies = [
"indexmap",
"toml_datetime",
"toml_parser",
"toml_writer",
"winnow",
]
@ -3872,6 +3874,12 @@ dependencies = [
"winnow",
]
[[package]]
name = "toml_writer"
version = "1.1.1+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db"
[[package]]
name = "tower"
version = "0.5.3"

View file

@ -54,6 +54,7 @@ tempfile = "3"
thiserror = "2"
time = { version = "0.3", features = ["formatting", "serde"] }
tokio = { version = "1", features = ["fs", "io-util", "macros", "net", "rt-multi-thread", "signal", "time"] }
toml_edit = "0.25.11"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }

View file

@ -22,7 +22,7 @@ geth resource list
geth cas add ./file
```
The daemon owns local identity, the future Iroh endpoint, trust state, resource
The daemon owns local identity, the Iroh endpoint, trust state, resource
registry, module router, local metadata store, and synchronized data structures.
Most non-daemon commands talk to the daemon through a local Unix socket at
`$GETH_HOME/run/geth.sock`.
@ -43,6 +43,9 @@ services, not system services.
All remote node-to-node geth communication is designed to happen over Iroh only.
SSH is not a geth transport backend, and there is no SSH fallback transport.
The default node config uses Iroh's default relay policy for practical
connectivity; set `[iroh].relay_mode = "disabled"` for local-only/offline
development.
SSH keys are used as admin trust anchors and ecosystem integration points.
OpenSSH, FIDO, and YubiKey-backed keys can sign geth trust objects through
@ -110,6 +113,7 @@ $GETH_HOME/
geth.sqlite
config.toml
identity/agent.ed25519
identity/iroh.ed25519
cas/blobs/
run/geth.sock
```

View file

@ -507,6 +507,7 @@ fn print_response(response: ControlResponse, json: bool) -> Result<()> {
"endpoint: {}",
status.endpoint_id.as_deref().unwrap_or("not started")
);
println!("iroh relay: {}", status.iroh_relay_mode);
println!("iroh: {}", status.iroh);
}
ControlResponse::NodeId(node) => {

View file

@ -9,3 +9,4 @@ license.workspace = true
directories.workspace = true
serde.workspace = true
thiserror.workspace = true
toml_edit.workspace = true

View file

@ -1,5 +1,14 @@
use std::path::{Path, PathBuf};
pub const DEFAULT_CONFIG_TOML: &str = r#"# geth local node config
# Remote node-to-node communication is Iroh-only.
#
# relay_mode controls Iroh relay use for practical internet connectivity.
# Values: "default", "staging", "disabled".
[iroh]
relay_mode = "default"
"#;
#[derive(Clone, Debug)]
pub struct GethPaths {
home: PathBuf,
@ -83,4 +92,106 @@ pub enum ConfigError {
NoDataDirectory,
#[error("io error: {0}")]
Io(#[from] std::io::Error),
#[error("toml parse error: {0}")]
TomlParse(#[from] toml_edit::TomlError),
#[error("invalid iroh relay_mode: {0}")]
InvalidRelayMode(String),
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct GethConfig {
pub iroh: IrohConfig,
}
impl GethConfig {
#[must_use]
pub fn default_toml() -> &'static str {
DEFAULT_CONFIG_TOML
}
pub fn load(path: &Path) -> Result<Self, ConfigError> {
if !path.exists() {
return Ok(Self::default());
}
Self::parse(&std::fs::read_to_string(path)?)
}
pub fn parse(text: &str) -> Result<Self, ConfigError> {
let document = text.parse::<toml_edit::DocumentMut>()?;
let relay_mode = match document.get("iroh").and_then(|iroh| iroh.get("relay_mode")) {
Some(item) => {
let value = item
.as_str()
.ok_or_else(|| ConfigError::InvalidRelayMode(item.to_string()))?;
RelayMode::parse(value)?
}
None => RelayMode::default(),
};
Ok(Self {
iroh: IrohConfig { relay_mode },
})
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct IrohConfig {
pub relay_mode: RelayMode,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum RelayMode {
Disabled,
#[default]
Default,
Staging,
}
impl RelayMode {
pub fn parse(value: &str) -> Result<Self, ConfigError> {
match value {
"disabled" => Ok(Self::Disabled),
"default" => Ok(Self::Default),
"staging" => Ok(Self::Staging),
other => Err(ConfigError::InvalidRelayMode(other.to_owned())),
}
}
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Disabled => "disabled",
Self::Default => "default",
Self::Staging => "staging",
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_config_uses_iroh_relays() {
let config = GethConfig::parse(GethConfig::default_toml()).expect("parse config");
assert_eq!(config.iroh.relay_mode, RelayMode::Default);
}
#[test]
fn config_parses_disabled_relay_mode() {
let config = GethConfig::parse("[iroh]\nrelay_mode = \"disabled\"\n").expect("parse");
assert_eq!(config.iroh.relay_mode, RelayMode::Disabled);
}
#[test]
fn config_rejects_unknown_relay_mode() {
let error = GethConfig::parse("[iroh]\nrelay_mode = \"magic\"\n").expect_err("error");
assert!(matches!(error, ConfigError::InvalidRelayMode(value) if value == "magic"));
}
#[test]
fn config_rejects_non_string_relay_mode() {
let error = GethConfig::parse("[iroh]\nrelay_mode = true\n").expect_err("error");
assert!(matches!(error, ConfigError::InvalidRelayMode(_)));
}
}

View file

@ -148,6 +148,7 @@ pub struct StatusResponse {
pub node_id: String,
pub iroh_enabled: bool,
pub endpoint_id: Option<String>,
pub iroh_relay_mode: String,
pub iroh: String,
}

View file

@ -23,9 +23,17 @@ pub struct GethIrohConfig {
impl GethIrohConfig {
#[must_use]
pub fn local(secret_key_path: impl Into<PathBuf>) -> Self {
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 {
Self {
secret_key_path: secret_key_path.into(),
relay_mode: GethRelayMode::Disabled,
relay_mode,
bind_ipv4: None,
bind_ipv6: None,
alpns: all_alpns(),
@ -49,6 +57,15 @@ impl GethRelayMode {
Self::Staging => iroh::RelayMode::Staging,
}
}
#[must_use]
pub fn as_str(&self) -> &'static str {
match self {
Self::Disabled => "disabled",
Self::Default => "default",
Self::Staging => "staging",
}
}
}
pub struct GethIrohEndpoint {
@ -90,6 +107,7 @@ pub async fn start_endpoint(config: &GethIrohConfig) -> Result<GethIrohEndpoint,
let status = EndpointStatus {
enabled: true,
endpoint_id: Some(endpoint.node_id().to_string()),
relay_mode: config.relay_mode.as_str().to_owned(),
note: format!(
"Iroh endpoint started with iroh 0.90.0; relay mode: {:?}",
config.relay_mode
@ -143,6 +161,7 @@ pub fn all_alpns() -> Vec<Vec<u8>> {
pub struct EndpointStatus {
pub enabled: bool,
pub endpoint_id: Option<String>,
pub relay_mode: String,
pub note: String,
}
@ -152,6 +171,7 @@ impl EndpointStatus {
Self {
enabled: false,
endpoint_id: None,
relay_mode: "not-started".to_owned(),
note: "Iroh endpoint is not running outside daemon mode".to_owned(),
}
}
@ -197,15 +217,29 @@ mod tests {
assert_eq!(first.public(), second.public());
}
#[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
));
}
#[tokio::test]
async fn endpoint_status_tracks_node_id_when_bind_is_available() {
let dir = tempfile::tempdir().expect("tempdir");
let config = GethIrohConfig::local(dir.path().join("iroh.ed25519"));
let config = GethIrohConfig::local_with_relay(
dir.path().join("iroh.ed25519"),
GethRelayMode::Disabled,
);
match start_endpoint(&config).await {
Ok(endpoint) => {
let status = endpoint.status();
assert!(status.enabled);
assert_eq!(status.endpoint_id, Some(endpoint.node_id()));
assert_eq!(status.relay_mode, "disabled");
endpoint.shutdown().await;
}
Err(IrohError::Bind(error)) => {

View file

@ -2,13 +2,13 @@ pub mod service;
use geth_auth::AuthExplanation;
use geth_cas::{LocalCas, hash_path};
use geth_config::GethPaths;
use geth_config::{GethConfig, GethPaths, RelayMode};
use geth_control::{
CasBlob, ControlRequest, ControlResponse, KeychainStatusResponse, NodeIdResponse,
StatusResponse,
};
use geth_crypto::AgentKey;
use geth_iroh::{EndpointStatus, GethIrohConfig, GethIrohEndpoint};
use geth_iroh::{EndpointStatus, GethIrohConfig, GethIrohEndpoint, GethRelayMode};
use geth_resource::ResourceDescriptor;
use geth_ssh_identity::{
SshCertApproval, SshCertKind, SshCertRequest, SshCertRequestStatus, SshCertificateRecord,
@ -68,10 +68,7 @@ pub struct LocalNode {
pub fn init_node(paths: &GethPaths) -> Result<LocalNode, NodeError> {
paths.ensure_base_dirs()?;
if !paths.config_file().exists() {
std::fs::write(
paths.config_file(),
"# geth local node config\n# Remote node-to-node communication is Iroh-only.\n",
)?;
std::fs::write(paths.config_file(), GethConfig::default_toml())?;
}
let key = AgentKey::load_or_create(&paths.agent_key())?;
let agent_id = key.agent_id().to_string();
@ -163,6 +160,7 @@ pub fn handle_request(
node_id: node.node_id.clone(),
iroh_enabled: node.iroh_status.enabled,
endpoint_id: node.iroh_status.endpoint_id.clone(),
iroh_relay_mode: node.iroh_status.relay_mode.clone(),
iroh: node.iroh_status.note.clone(),
})),
ControlRequest::NodeId => Ok(ControlResponse::NodeId(NodeIdResponse {
@ -447,7 +445,10 @@ fn stable_node_id(agent_id: &str) -> String {
async fn start_daemon_iroh_endpoint(
node: &mut LocalNode,
) -> Result<Option<GethIrohEndpoint>, NodeError> {
let config = GethIrohConfig::local(node.paths.iroh_key());
let node_config = GethConfig::load(&node.paths.config_file())?;
let relay_mode = node_config.iroh.relay_mode;
let iroh_relay_mode = config_relay_mode_to_iroh(relay_mode);
let config = GethIrohConfig::local_with_relay(node.paths.iroh_key(), iroh_relay_mode);
match geth_iroh::start_endpoint(&config).await {
Ok(endpoint) => {
let status = endpoint.status();
@ -462,6 +463,7 @@ async fn start_daemon_iroh_endpoint(
node.iroh_status = EndpointStatus {
enabled: false,
endpoint_id: None,
relay_mode: relay_mode.as_str().to_owned(),
note: format!("Iroh endpoint failed to start: {error}"),
};
Ok(None)
@ -469,6 +471,14 @@ async fn start_daemon_iroh_endpoint(
}
}
fn config_relay_mode_to_iroh(mode: RelayMode) -> GethRelayMode {
match mode {
RelayMode::Disabled => GethRelayMode::Disabled,
RelayMode::Default => GethRelayMode::Default,
RelayMode::Staging => GethRelayMode::Staging,
}
}
fn expected_openssh_cert_path(public_key_path: &Path) -> String {
let text = public_key_path.display().to_string();
if let Some(prefix) = text.strip_suffix(".pub") {

View file

@ -59,6 +59,11 @@ fn geth_init_in_temp_home() {
assert!(home.path().join("geth.sqlite").exists());
assert!(home.path().join("identity/agent.ed25519").exists());
assert!(home.path().join("config.toml").exists());
assert!(
std::fs::read_to_string(home.path().join("config.toml"))
.expect("read config")
.contains("relay_mode = \"default\"")
);
}
#[test]
@ -68,6 +73,11 @@ fn geth_status_against_running_daemon() {
return;
}
assert!(run_geth(home.path(), &["init"]).status.success());
std::fs::write(
home.path().join("config.toml"),
"[iroh]\nrelay_mode = \"disabled\"\n",
)
.expect("write config");
let mut daemon = spawn_daemon(home.path());
wait_for_socket(&home.path().join("run/geth.sock"));
@ -84,6 +94,7 @@ fn geth_status_against_running_daemon() {
assert!(stdout.contains("geth daemon: running"));
assert!(stdout.contains("agent:"));
assert!(stdout.contains("endpoint:"));
assert!(stdout.contains("iroh relay: disabled"));
}
#[test]

View file

@ -2,8 +2,8 @@
`geth` is a single-binary local-first mesh runtime. One executable provides both
daemon mode and control mode. The daemon owns local identity, metadata storage,
the future shared Iroh endpoint, resource registry, module routing, and local
control socket. Control commands connect to the Unix socket and send typed JSONL
the shared Iroh endpoint, resource registry, module routing, and local control
socket. Control commands connect to the Unix socket and send typed JSONL
requests.
Service management is also exposed through the single binary. `geth daemon
@ -23,8 +23,9 @@ Rust-1.85-compatible candidates in the 0.93-0.95 range failed to compile through
a transitive `ed25519-dalek` prerelease dependency. `geth-iroh` wraps
`iroh::Endpoint::builder()`, configures geth ALPNs with `Builder::alpns`, uses
`Builder::relay_mode`, persists an `iroh::SecretKey` as hex-encoded 32-byte key
material, and shuts down through `Endpoint::close().await`. Relay mode defaults
to disabled until daemon policy and discovery are implemented.
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"`.
The target product should use Iroh relay support for practical internet
connectivity and mDNS/LAN discovery for local networks. These are connectivity

View file

@ -74,14 +74,20 @@ geth-to-geth connections without granting trust from discovery alone.
- Endpoint identity is bound to agent/node identity in local metadata.
- Restarting the daemon preserves higher-level node identity.
- `[ ]` Relay policy and configuration.
- `[x]` Built-in relay policy and configuration.
Acceptance criteria:
- Config can select disabled, default Iroh relays, staging relays, and future
custom relay maps.
- Config can select disabled, default Iroh relays, and staging relays.
- The intended product default uses relays unless explicitly disabled.
- `geth status --json` reports the selected relay mode.
- Tests cover config parsing and endpoint builder relay-mode selection.
- `[ ]` Custom relay maps.
Acceptance criteria:
- Config can define and select named custom relay maps.
- Invalid relay URLs fail config validation with clear errors.
- Status output identifies the selected custom relay map without exposing
unrelated config.
- `[ ]` LAN mDNS discovery.
Acceptance criteria:
- The daemon can advertise and discover local geth peer cards over mDNS.