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

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 {