Wire pubsub through iroh-gossip

This commit is contained in:
Eric Wendland 2026-05-22 16:45:49 +02:00
commit eec4c92145
8 changed files with 291 additions and 38 deletions

2
Cargo.lock generated
View file

@ -1599,6 +1599,8 @@ name = "geth-node"
version = "0.1.0"
dependencies = [
"base64",
"blake3",
"bytes",
"futures",
"geth-auth",
"geth-cas",

View file

@ -160,9 +160,9 @@ The bootstrap implementation provides:
CLI input and output are JSON views, while the store keeps durable Automerge
save bytes. `geth document sync <node-id> <name> [--bearer-secret <secret>]`
pulls authorized remote Automerge state.
- local daemon-lifetime pubsub snapshots: `geth pubsub pub/sub`; `geth pubsub
pub <topic> <message> --node <node-id>` publishes to an authorized peer;
`geth pubsub sub <topic> --node <node-id>` reads an authorized peer snapshot
- lossy pubsub wakeups: `geth pubsub pub/sub`; local messages are retained in
a daemon-lifetime ring buffer, while authorized remote publish/subscribe joins
deterministic native `iroh-gossip` topics after geth control authorization
- SSH certificate flow metadata:
- `geth ssh cert request --public-key <path> --principal <name> [--subject <principal>]`
- `geth ssh cert requests [--subject <principal>]`
@ -204,8 +204,9 @@ serving peer as a provider visible with `geth cas providers <hash>`.
libraries `iroh-blobs 0.97.0`, `iroh-docs 0.95.0`, and `iroh-gossip 0.95.0`
against the same daemon-owned endpoint generation. KV stores are mirrored into
native `iroh-docs` namespaces and peers receive read-only document tickets only
after geth authorization succeeds. Pubsub still uses its documented bootstrap
equivalent until the native gossip migration lands.
after geth authorization succeeds. Pubsub joins native `iroh-gossip` topics
only after the geth control path has authenticated the peer-card endpoint
binding and checked the topic capability.
Remote resource commands that accept `--bearer-secret` can also authorize with a
resource-scoped bearer proof generated from the private bearer token returned at
creation time. The persisted auth log stores a public bearer id and token
@ -245,14 +246,15 @@ manual `geth kv sync <node-id> <name>` and background ticks require `kv.read`
on the remote `resource:kv:<name>`. Authorized sync imports from the remote
Iroh Documents namespace where available, keeps SQLite as the durable local
index, and imports only remote entries that are not older than the local value.
Remote pubsub publish uses the protected Iroh control path too. The remote peer
requires `pubsub.publish` on `resource:pubsub:<topic>` before recording the
message in its local daemon-lifetime ring buffer. Pubsub remains lossy and is
not durable storage; facts that must survive restart or reconcile offline
belong in CAS, KV, document, or DB resources. Remote pubsub subscribe uses the
same protected path and requires `pubsub.subscribe` on
`resource:pubsub:<topic>` before returning the peer's current daemon-lifetime
snapshot for that topic.
Remote pubsub publish uses the protected Iroh control path as an authorization
preflight. The remote peer requires `pubsub.publish` on
`resource:pubsub:<topic>` before recording the message and broadcasting it on a
deterministic native `iroh-gossip` topic. Pubsub remains lossy and is not
durable storage; facts that must survive restart or reconcile offline belong in
CAS, KV, document, or DB resources. Remote pubsub subscribe uses the same
protected path, requires `pubsub.subscribe` on `resource:pubsub:<topic>`, joins
the gossip topic, and returns the peer's current daemon-lifetime snapshot for
that topic.
Remote pipe connect uses the same protected Iroh control path and requires
`pipe.connect` on `resource:pipe:<name>`. The current prototype records a remote
connection attempt and whether a listener exists. `geth pipe send <name>
@ -313,8 +315,8 @@ Everything meaningful is modeled as a resource. Planned resource kinds are:
- `pipe`: dumbpipe-like byte streams over Iroh; the bootstrap has a local
daemon registry only
- `document`: Automerge documents over Iroh streams
- `pubsub`: lossy notifications, not authoritative storage; the bootstrap
keeps only an in-memory daemon-lifetime ring buffer
- `pubsub`: lossy notifications over native `iroh-gossip` after geth
authorization, with only an in-memory daemon-lifetime ring buffer
- `cas`: content-addressed blob storage and distribution
- `ssh-proxy`: authorized SSH proxy/admin access over Iroh

View file

@ -7,6 +7,8 @@ license.workspace = true
[dependencies]
base64.workspace = true
blake3.workspace = true
bytes.workspace = true
serde.workspace = true
serde_json.workspace = true
thiserror.workspace = true

View file

@ -175,6 +175,12 @@ struct NodeRuntime {
#[derive(Debug, Default)]
struct PubsubRuntime {
messages: VecDeque<PubsubMessage>,
gossip_topics: BTreeMap<String, PubsubGossipTopicRuntime>,
}
#[derive(Debug, Clone)]
struct PubsubGossipTopicRuntime {
sender: iroh_gossip::api::GossipSender,
}
#[derive(Debug, Default)]
@ -785,6 +791,15 @@ pub async fn handle_request_async(
.unwrap_or(entry.store.as_str());
mirror_kv_store_to_iroh_docs(node, name).await?;
}
ControlResponse::PubsubPublished { message } => {
broadcast_pubsub_gossip(
node,
message.topic.as_str(),
message.message.as_str(),
Vec::new(),
)
.await?;
}
_ => {}
}
Ok(response)
@ -2169,6 +2184,8 @@ async fn pubsub_publish_to_peer(
) -> Result<ControlResponse, NodeError> {
geth_pubsub::validate_topic(&topic)?;
geth_pubsub::validate_message(&message)?;
let bootstrap_peer = peer_gossip_endpoint_id(node, peer_node)?;
ensure_pubsub_gossip_topic(node, topic.as_str(), vec![bootstrap_peer]).await?;
let response = request_peer_control(node, peer_node, "pubsub-publish", |peer_card, nonce| {
PeerControlRequest::PubsubPublish {
peer_card,
@ -2224,6 +2241,8 @@ async fn pubsub_subscribe_from_peer(
bearer_secret: Option<String>,
) -> Result<ControlResponse, NodeError> {
geth_pubsub::validate_topic(&topic)?;
let bootstrap_peer = peer_gossip_endpoint_id(node, peer_node)?;
ensure_pubsub_gossip_topic(node, topic.as_str(), vec![bootstrap_peer]).await?;
let response = request_peer_control(node, peer_node, "pubsub-subscribe", |peer_card, nonce| {
PeerControlRequest::PubsubSubscribe {
peer_card,
@ -5429,8 +5448,20 @@ async fn handle_iroh_control_connection(
&nonce,
bearer_proof.as_ref(),
)?;
drop(store);
let published = if explanation.allowed {
Some(record_pubsub_message(&node, topic, message)?)
let bootstrap_peer = remote_endpoint_id
.parse::<iroh::EndpointId>()
.map_err(|error| NodeError::IrohPeer(error.to_string()))?;
let published = record_pubsub_message(&node, topic.clone(), message.clone())?;
broadcast_pubsub_gossip(
&node,
topic.as_str(),
message.as_str(),
vec![bootstrap_peer],
)
.await?;
Some(published)
} else {
None
};
@ -5477,7 +5508,12 @@ async fn handle_iroh_control_connection(
&nonce,
bearer_proof.as_ref(),
)?;
drop(store);
let messages = if explanation.allowed {
let bootstrap_peer = remote_endpoint_id
.parse::<iroh::EndpointId>()
.map_err(|error| NodeError::IrohPeer(error.to_string()))?;
ensure_pubsub_gossip_topic(&node, topic.as_str(), vec![bootstrap_peer]).await?;
pubsub_messages_for_topic(&node, &topic)?
} else {
Vec::new()
@ -6384,6 +6420,28 @@ fn ensure_peer_card_matches_endpoint(card: &PeerCard, endpoint_id: &str) -> Resu
}
}
fn peer_gossip_endpoint_id(
node: &LocalNode,
peer_node: &str,
) -> Result<iroh::EndpointId, NodeError> {
let store = Store::open(&node.paths.metadata_db())?;
let peer_node = resolve_peer_node_for_control(&store, peer_node);
let stored = store
.get_peer_card(&peer_node)?
.ok_or_else(|| NodeError::PeerNotFound(peer_node.clone()))?;
let peer_card: PeerCard = serde_json::from_str(&stored.card_json)?;
peer_card.validate_candidate()?;
let candidate = peer_card
.endpoints
.first()
.ok_or(geth_discovery::DiscoveryError::MissingEndpoint)?;
ensure_peer_card_matches_endpoint(&peer_card, &candidate.endpoint_id)?;
candidate
.endpoint_id
.parse::<iroh::EndpointId>()
.map_err(|error| NodeError::IrohPeer(error.to_string()))
}
fn restricted_admin_shell_output(node: &LocalNode, command: &str) -> Result<String, NodeError> {
match command {
"help" => Ok("commands: help, status, node-id".to_owned()),
@ -8078,6 +8136,13 @@ fn native_backend_statuses() -> Vec<NativeBackendStatus> {
library.alpn
),
),
"pubsub" => (
"wired",
format!(
"none; pubsub topics broadcast over native ALPN {} after geth control authorization preflight",
library.alpn
),
),
_ => (
"ready-to-wire",
format!(
@ -8190,11 +8255,20 @@ fn record_pubsub_message(
node: &LocalNode,
topic: String,
message: String,
) -> Result<PubsubMessage, NodeError> {
record_pubsub_message_at(node, topic, message, UnixMillis(geth_store::now_ms()))
}
fn record_pubsub_message_at(
node: &LocalNode,
topic: String,
message: String,
published_at: UnixMillis,
) -> Result<PubsubMessage, NodeError> {
let message = PubsubMessage {
topic: topic.into(),
message,
published_at: UnixMillis(geth_store::now_ms()),
published_at,
};
let mut runtime = node
.runtime
@ -8225,6 +8299,158 @@ fn pubsub_messages_for_topic(
.collect())
}
#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
struct PubsubGossipPayload {
version: u8,
source_node_id: String,
source_agent_id: String,
topic: String,
message: String,
published_at_ms: i64,
}
fn pubsub_gossip_topic_id(topic: &str) -> iroh_gossip::TopicId {
blake3::hash(format!("geth.pubsub.v1\0{topic}").as_bytes()).into()
}
async fn ensure_pubsub_gossip_topic(
node: &LocalNode,
topic: &str,
bootstrap: Vec<iroh::EndpointId>,
) -> Result<Option<iroh_gossip::api::GossipSender>, NodeError> {
let existing = {
node.runtime
.pubsub
.lock()
.map_err(|_| NodeError::RuntimeLockPoisoned)?
.gossip_topics
.get(topic)
.cloned()
};
if let Some(existing) = existing {
if !bootstrap.is_empty() {
existing
.sender
.join_peers(bootstrap)
.await
.map_err(|error| NodeError::IrohPeer(error.to_string()))?;
}
return Ok(Some(existing.sender));
}
let Some(gossip) = iroh_gossip(node)? else {
return Ok(None);
};
let topic_id = pubsub_gossip_topic_id(topic);
let subscribed = gossip
.subscribe(topic_id, bootstrap)
.await
.map_err(|error| NodeError::IrohPeer(error.to_string()))?;
let (sender, mut receiver) = subscribed.split();
let topic_name = topic.to_owned();
let receiver_node = node.clone();
tokio::spawn(async move {
while let Some(event) = receiver.next().await {
match event {
Ok(iroh_gossip::api::Event::Received(message)) => {
if message.content.len() > 64 * 1024 {
tracing::warn!(
topic = %topic_name,
bytes = message.content.len(),
"dropping oversized pubsub gossip message"
);
continue;
}
match serde_json::from_slice::<PubsubGossipPayload>(&message.content) {
Ok(payload) => {
if let Err(error) =
record_pubsub_gossip_payload(&receiver_node, &topic_name, payload)
{
tracing::warn!(%error, topic = %topic_name, "dropping invalid pubsub gossip message");
}
}
Err(error) => {
tracing::warn!(%error, topic = %topic_name, "dropping undecodable pubsub gossip message");
}
}
}
Ok(iroh_gossip::api::Event::Lagged) => {
tracing::warn!(topic = %topic_name, "pubsub gossip receiver lagged");
}
Ok(iroh_gossip::api::Event::NeighborUp(endpoint_id)) => {
tracing::debug!(%endpoint_id, topic = %topic_name, "pubsub gossip neighbor up");
}
Ok(iroh_gossip::api::Event::NeighborDown(endpoint_id)) => {
tracing::debug!(%endpoint_id, topic = %topic_name, "pubsub gossip neighbor down");
}
Err(error) => {
tracing::warn!(%error, topic = %topic_name, "pubsub gossip receiver closed");
break;
}
}
}
});
node.runtime
.pubsub
.lock()
.map_err(|_| NodeError::RuntimeLockPoisoned)?
.gossip_topics
.insert(
topic.to_owned(),
PubsubGossipTopicRuntime {
sender: sender.clone(),
},
);
Ok(Some(sender))
}
fn record_pubsub_gossip_payload(
node: &LocalNode,
expected_topic: &str,
payload: PubsubGossipPayload,
) -> Result<(), NodeError> {
if payload.version != 1
|| payload.topic != expected_topic
|| payload.source_node_id == node.node_id
{
return Ok(());
}
geth_pubsub::validate_topic(&payload.topic)?;
geth_pubsub::validate_message(&payload.message)?;
record_pubsub_message_at(
node,
payload.topic,
payload.message,
UnixMillis(payload.published_at_ms),
)?;
Ok(())
}
async fn broadcast_pubsub_gossip(
node: &LocalNode,
topic: &str,
message: &str,
bootstrap: Vec<iroh::EndpointId>,
) -> Result<(), NodeError> {
let Some(sender) = ensure_pubsub_gossip_topic(node, topic, bootstrap).await? else {
return Ok(());
};
let payload = PubsubGossipPayload {
version: 1,
source_node_id: node.node_id.clone(),
source_agent_id: node.agent_id.clone(),
topic: topic.to_owned(),
message: message.to_owned(),
published_at_ms: geth_store::now_ms(),
};
let bytes = serde_json::to_vec(&payload)?;
sender
.broadcast(bytes::Bytes::from(bytes))
.await
.map_err(|error| NodeError::IrohPeer(error.to_string()))
}
fn record_pipe_listener(node: &LocalNode, name: String) -> Result<PipeListener, NodeError> {
let listener = PipeListener {
id: format!("pipe:{name}").into(),
@ -11874,6 +12100,23 @@ mod tests {
}
other => panic!("unexpected allowed pubsub publish response: {other:?}"),
}
let mut gossiped_to_left = false;
for _ in 0..20 {
let local_messages = pubsub_messages_for_topic(&left, "presence/test")
.expect("left pubsub local messages");
if local_messages
.iter()
.any(|message| message.message == "hello")
{
gossiped_to_left = true;
break;
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
assert!(
gossiped_to_left,
"authorized remote pubsub publish should also arrive through iroh-gossip"
);
let remote_messages = handle_request(
&right,
ControlRequest::PubsubSub {

View file

@ -163,7 +163,7 @@ fn geth_status_against_running_daemon() {
)
);
assert!(stdout.contains(
"native backend pubsub: shared-iroh-endpoint-ready target iroh-gossip 0.95.0 (ready-to-wire)"
"native backend pubsub: shared-iroh-endpoint-ready target iroh-gossip 0.95.0 (wired)"
));
}

View file

@ -12,8 +12,12 @@ status, and ephemeral notifications.
Pubsub is not authoritative storage. Important facts must live in durable
resources such as kv, document, db, CAS manifests, or future auth logs.
The prototype uses the geth control ALPN as an authorization preflight before a
daemon joins or broadcasts on a deterministic native `iroh-gossip` topic.
## Consequences
The modules can support ad hoc workflow and presence without confusing gossip
with durable state.
Gossip delivery is best-effort. Consumers must tolerate missed, duplicated, and
out-of-order notifications and use durable resources for reconciliation.

View file

@ -38,16 +38,15 @@ geth control-ALPN authorization preflight before transferring payload bytes over
`iroh-blobs`. KV now starts `iroh-docs` with `iroh-gossip` and the same native
blob store, mirrors named KV stores into read-shared Iroh Documents namespaces,
and sends read-only docs tickets only after geth control authorization succeeds.
`geth status` reports CAS and KV as wired native backends. Pubsub still uses its
explicit documented bootstrap equivalent until it is migrated to native gossip.
Pubsub joins deterministic native `iroh-gossip` topics after the geth control
path authenticates the peer-card endpoint binding and authorizes the topic
capability. `geth status` reports CAS, KV, and pubsub as wired native backends.
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. The current daemon
accept loop dispatches geth control, pipe, SSH-proxy, and native CAS blob
streams directly, plus native docs and gossip streams used by KV. The next
backend migration should attach application pubsub behavior to iroh-gossip
instead of creating parallel endpoints.
streams directly, plus native docs and gossip streams used by KV and pubsub.
The target product should use Iroh relay support for practical internet
connectivity and mDNS/LAN discovery for local networks. These are connectivity
@ -228,19 +227,18 @@ loop runs the same sync for local documents and known peers using
per-peer/per-document cursors. Received Automerge documents are merged before
being stored.
`geth-pubsub` currently supports local publish/subscribe snapshots through the
daemon control protocol. Messages live in a bounded in-memory ring buffer and
are lost when the daemon stops. This is deliberate: pubsub is a lossy wakeup and
presence channel, not authoritative storage. Durable facts must be written to
CAS, KV, document, or DB resources before pubsub is used as a wakeup. `geth
pubsub pub <topic> <message> --node <node-id>` can publish to an imported peer
over the protected Iroh control ALPN. The remote daemon validates endpoint/card
binding and requires
`pubsub.publish` on `resource:pubsub:<topic>` before recording the message in
its local ring buffer. `geth pubsub sub <topic> --node <node-id>` can read an
authorized peer's current snapshot for that topic over the same protected path
when the caller has `pubsub.subscribe` on `resource:pubsub:<topic>`. Iroh-gossip
replication and private topics are future work.
`geth-pubsub` supports local publish/subscribe snapshots through the daemon
control protocol. Messages live in a bounded in-memory ring buffer and are lost
when the daemon stops. This is deliberate: pubsub is a lossy wakeup and presence
channel, not authoritative storage. Durable facts must be written to CAS, KV,
document, or DB resources before pubsub is used as a wakeup. `geth pubsub pub
<topic> <message> --node <node-id>` first uses the protected Iroh control ALPN
for authorization. The remote daemon validates endpoint/card binding and
requires `pubsub.publish` on `resource:pubsub:<topic>` before recording the
message and broadcasting it through a deterministic native `iroh-gossip` topic.
`geth pubsub sub <topic> --node <node-id>` uses the same protected path, joins
the gossip topic when the caller has `pubsub.subscribe`, and returns the peer's
current daemon-lifetime snapshot. Private topics remain future work.
`geth-pipe` currently supports `pipe listen/connect/send/recv` against a
daemon-lifetime runtime. `geth pipe connect <name> --node <node-id>` sends an

View file

@ -505,7 +505,7 @@ authorization and durable-state boundaries clear.
capabilities, after geth control authorization succeeds.
- `[x]` Tests cover imported KV docs state after authorized remote sync.
- `[~]` Iroh-gossip pubsub integration.
- `[x]` Iroh-gossip pubsub integration.
Acceptance criteria:
- `[x]` `geth pubsub pub/sub` works against the local daemon.
- `[x]` Local pubsub messages are kept in a bounded daemon-lifetime ring
@ -521,7 +521,9 @@ authorization and durable-state boundaries clear.
- `[x]` Remote pubsub subscribe requires `pubsub.subscribe` on
`resource:pubsub:<topic>`.
- `[x]` Tests cover denied and allowed remote pubsub subscribe.
- `[ ]` Replace bootstrap remote publish with iroh-gossip topics.
- `[x]` Replace bootstrap remote publish with iroh-gossip topics.
- `[x]` Authorized remote publish/subscribe joins deterministic
`iroh-gossip` topics after geth control authorization.
- `[x]` Docs and tests keep durable state in CAS/KV/document/db instead.
## Phase 4: Pipes, SSH Proxy, And SSH Distribution