Publish pubsub messages over Iroh

This commit is contained in:
Eric Wendland 2026-05-18 18:41:04 +02:00
commit e294965833
8 changed files with 357 additions and 28 deletions

View file

@ -263,6 +263,11 @@ pub async fn handle_request_async(
node: peer_node,
name,
} => kv_sync_from_peer(node, &peer_node, &name).await,
ControlRequest::PubsubPub {
topic,
message,
node: Some(peer_node),
} => pubsub_publish_to_peer(node, &peer_node, topic, message).await,
other => handle_request(node, other),
}
}
@ -539,7 +544,8 @@ async fn peer_ping(node: &LocalNode, peer_node: &str) -> Result<ControlResponse,
PeerControlResponse::CasFetched { .. }
| PeerControlResponse::SshCertSynced { .. }
| PeerControlResponse::SshRevocationSynced { .. }
| PeerControlResponse::KvSynced { .. } => Err(NodeError::IrohPeer(
| PeerControlResponse::KvSynced { .. }
| PeerControlResponse::PubsubPublished { .. } => Err(NodeError::IrohPeer(
"peer returned wrong response type to ping request".to_owned(),
)),
}
@ -647,7 +653,8 @@ async fn peer_auth_check(
PeerControlResponse::CasFetched { .. }
| PeerControlResponse::SshCertSynced { .. }
| PeerControlResponse::SshRevocationSynced { .. }
| PeerControlResponse::KvSynced { .. } => Err(NodeError::IrohPeer(
| PeerControlResponse::KvSynced { .. }
| PeerControlResponse::PubsubPublished { .. } => Err(NodeError::IrohPeer(
"peer returned wrong response type to auth-check request".to_owned(),
)),
}
@ -784,7 +791,8 @@ async fn cas_fetch_from_peer(
| PeerControlResponse::AuthChecked { .. }
| PeerControlResponse::SshCertSynced { .. }
| PeerControlResponse::SshRevocationSynced { .. }
| PeerControlResponse::KvSynced { .. } => Err(NodeError::IrohPeer(
| PeerControlResponse::KvSynced { .. }
| PeerControlResponse::PubsubPublished { .. } => Err(NodeError::IrohPeer(
"peer returned wrong response type to CAS fetch".to_owned(),
)),
}
@ -1010,6 +1018,56 @@ async fn kv_sync_from_peer(
}
}
async fn pubsub_publish_to_peer(
node: &LocalNode,
peer_node: &str,
topic: String,
message: String,
) -> Result<ControlResponse, NodeError> {
geth_pubsub::validate_topic(&topic)?;
geth_pubsub::validate_message(&message)?;
let response = request_peer_control(node, peer_node, "pubsub-publish", |peer_card, nonce| {
PeerControlRequest::PubsubPublish {
peer_card,
topic: topic.clone(),
message: message.clone(),
nonce,
}
})
.await?;
match response {
PeerControlResponse::PubsubPublished {
node_id,
agent_id,
endpoint_id,
message,
allowed,
reason,
note,
..
} => {
let message = message.unwrap_or_else(|| PubsubMessage {
topic: topic.into(),
message: String::new(),
published_at: UnixMillis(0),
});
Ok(ControlResponse::PubsubRemotePublished {
peer_node_id: node_id,
peer_agent_id: agent_id,
endpoint_id,
message,
allowed,
reason,
note,
})
}
PeerControlResponse::Error { message } => Err(NodeError::IrohPeer(message)),
_ => Err(NodeError::IrohPeer(
"peer returned wrong response type to pubsub publish".to_owned(),
)),
}
}
fn live_sync_cursor_key(peer_node: &str, stream: &str) -> String {
format!("live-sync:{peer_node}:{stream}")
}
@ -1106,6 +1164,10 @@ async fn request_peer_control(
| PeerControlResponse::KvSynced {
nonce: response_nonce,
..
}
| PeerControlResponse::PubsubPublished {
nonce: response_nonce,
..
} if response_nonce == &nonce => Ok(response),
PeerControlResponse::Error { .. } => Ok(response),
_ => Err(NodeError::IrohPeer(format!(
@ -1497,6 +1559,53 @@ async fn handle_iroh_control_connection(
}
}
}
PeerControlRequest::PubsubPublish {
peer_card,
topic,
message,
nonce,
} => {
geth_pubsub::validate_topic(&topic)?;
geth_pubsub::validate_message(&message)?;
peer_card.validate_candidate()?;
ensure_peer_card_matches_endpoint(&peer_card, &remote_endpoint_id)?;
let discovered = DiscoveredPeer::candidate(
peer_card.clone(),
UnixMillis(geth_store::now_ms()),
DiscoverySource::PeerExchange,
)?;
let store = Store::open(&node.paths.metadata_db())?;
store.upsert_peer_card(&StoredPeerCard {
peer_id: peer_card.node_id.to_string(),
card_json: serde_json::to_string(&peer_card)?,
updated_at_ms: discovered.discovered_at.0,
})?;
let resource = format!("resource:pubsub:{topic}");
let capability = "pubsub.publish".to_owned();
let explanation = geth_auth::explain_auth_ops(
&load_auth_ops_for_resource(&store, &resource)?,
PrincipalId::new(peer_card.node_id.to_string()),
ResourceId::new(resource),
Capability::new(capability),
);
let published = if explanation.allowed {
Some(record_pubsub_message(&node, topic, message)?)
} else {
None
};
PeerControlResponse::PubsubPublished {
node_id: node.node_id.clone(),
agent_id: node.agent_id.clone(),
endpoint_id: node.iroh_status.endpoint_id.clone().unwrap_or_default(),
remote_endpoint_id,
message: published,
allowed: explanation.allowed,
reason: explanation.reason,
evaluated_ops: explanation.evaluated_ops,
nonce,
note: "pubsub publish authenticated endpoint/card binding and required pubsub.publish on the remote topic resource; pubsub is lossy".to_owned(),
}
}
};
send.write_all(geth_control::encode_peer_response(&response)?.as_bytes())
.await
@ -2492,25 +2601,17 @@ pub fn handle_request(
state: document_state_from_stored(&stored),
})
}
ControlRequest::PubsubPub { topic, message } => {
ControlRequest::PubsubPub {
topic,
message,
node: None,
} => {
geth_pubsub::validate_topic(&topic)?;
geth_pubsub::validate_message(&message)?;
let message = PubsubMessage {
topic: topic.clone().into(),
message,
published_at: UnixMillis(geth_store::now_ms()),
};
let mut runtime = node
.runtime
.pubsub
.lock()
.map_err(|_| NodeError::RuntimeLockPoisoned)?;
runtime.messages.push_back(message.clone());
while runtime.messages.len() > PUBSUB_RING_LIMIT {
runtime.messages.pop_front();
}
let message = record_pubsub_message(node, topic, message)?;
Ok(ControlResponse::PubsubPublished { message })
}
ControlRequest::PubsubPub { node: Some(_), .. } => Err(NodeError::IrohEndpointUnavailable),
ControlRequest::PubsubSub { topic } => {
geth_pubsub::validate_topic(&topic)?;
let runtime = node
@ -2659,6 +2760,28 @@ fn ensure_local_kv_store(store: &Store, name: &str) -> Result<StoredKvStore, Nod
Ok(stored)
}
fn record_pubsub_message(
node: &LocalNode,
topic: String,
message: String,
) -> Result<PubsubMessage, NodeError> {
let message = PubsubMessage {
topic: topic.into(),
message,
published_at: UnixMillis(geth_store::now_ms()),
};
let mut runtime = node
.runtime
.pubsub
.lock()
.map_err(|_| NodeError::RuntimeLockPoisoned)?;
runtime.messages.push_back(message.clone());
while runtime.messages.len() > PUBSUB_RING_LIMIT {
runtime.messages.pop_front();
}
Ok(message)
}
fn document_resource_from_stored(stored: &StoredDocumentResource) -> DocumentResource {
DocumentResource {
id: stored.document_id.clone().into(),
@ -3280,6 +3403,30 @@ mod tests {
other => panic!("unexpected denied KV sync response: {other:?}"),
}
let denied_pubsub = handle_request_async(
&left,
ControlRequest::PubsubPub {
topic: "presence/test".to_owned(),
message: "hello".to_owned(),
node: Some(right_card.node_id.to_string()),
},
)
.await
.expect("denied remote pubsub publish");
match denied_pubsub {
ControlResponse::PubsubRemotePublished {
allowed,
reason,
message,
..
} => {
assert!(!allowed);
assert!(message.message.is_empty());
assert!(reason.contains("no active direct or group grant"));
}
other => panic!("unexpected denied pubsub publish response: {other:?}"),
}
handle_request(
&right,
ControlRequest::AuthGrant {
@ -3300,6 +3447,16 @@ mod tests {
},
)
.expect("grant left kv read");
handle_request(
&right,
ControlRequest::AuthGrant {
subject: left.node_id.clone(),
resource: "resource:pubsub:presence/test".to_owned(),
capability: "pubsub.publish".to_owned(),
grant_id: Some("grant:left-pubsub-publish".to_owned()),
},
)
.expect("grant left pubsub publish");
let allowed = handle_request_async(
&left,
@ -3397,6 +3554,47 @@ mod tests {
other => panic!("unexpected synced KV get response: {other:?}"),
}
let published = handle_request_async(
&left,
ControlRequest::PubsubPub {
topic: "presence/test".to_owned(),
message: "hello".to_owned(),
node: Some(right_card.node_id.to_string()),
},
)
.await
.expect("allowed remote pubsub publish");
match published {
ControlResponse::PubsubRemotePublished {
allowed,
message,
reason,
note,
..
} => {
assert!(allowed);
assert_eq!(message.topic.as_str(), "presence/test");
assert_eq!(message.message, "hello");
assert!(reason.contains("direct grant"));
assert!(note.contains("lossy"));
}
other => panic!("unexpected allowed pubsub publish response: {other:?}"),
}
let remote_messages = handle_request(
&right,
ControlRequest::PubsubSub {
topic: "presence/test".to_owned(),
},
)
.expect("right pubsub sub after remote publish");
match remote_messages {
ControlResponse::PubsubMessages { messages, .. } => {
assert_eq!(messages.len(), 1);
assert_eq!(messages[0].message, "hello");
}
other => panic!("unexpected remote pubsub messages response: {other:?}"),
}
let denied_cert_sync = handle_request_async(
&left,
ControlRequest::SshCertSync {