Subscribe to peer pubsub snapshots

This commit is contained in:
Eric Wendland 2026-05-19 15:28:48 +02:00
commit df70b81a1d
8 changed files with 332 additions and 20 deletions

View file

@ -155,8 +155,10 @@ Roadmap items should be actionable and checkable:
and true CRDT sync are still roadmap work. and true CRDT sync are still roadmap work.
- Pubsub supports local daemon-lifetime publish/subscribe snapshots through a - Pubsub supports local daemon-lifetime publish/subscribe snapshots through a
bounded in-memory ring buffer. Remote publish over the protected Iroh control bounded in-memory ring buffer. Remote publish over the protected Iroh control
ALPN requires `pubsub.publish` on `resource:pubsub:<topic>`. Iroh-gossip ALPN requires `pubsub.publish` on `resource:pubsub:<topic>`. Remote subscribe
replication, private topics, and remote subscribe are still roadmap work. over the same path requires `pubsub.subscribe` and returns the peer's current
daemon-lifetime snapshot. Iroh-gossip replication and private topics are still
roadmap work.
- Pipe listen/connect supports a daemon-lifetime registry. Remote - Pipe listen/connect supports a daemon-lifetime registry. Remote
`geth pipe connect <name> --node <node-id>` uses the protected Iroh control `geth pipe connect <name> --node <node-id>` uses the protected Iroh control
ALPN and requires `pipe.connect` on `resource:pipe:<name>`. Iroh byte streams, ALPN and requires `pipe.connect` on `resource:pipe:<name>`. Iroh byte streams,

View file

@ -109,7 +109,8 @@ The bootstrap implementation provides:
- local JSON document commands: `geth document create/status/set/get`; `geth - local JSON document commands: `geth document create/status/set/get`; `geth
document sync <node-id> <name>` pulls authorized remote JSON state document sync <node-id> <name>` pulls authorized remote JSON state
- local daemon-lifetime pubsub snapshots: `geth pubsub pub/sub`; `geth pubsub - local daemon-lifetime pubsub snapshots: `geth pubsub pub/sub`; `geth pubsub
pub <topic> <message> --node <node-id>` publishes to an authorized peer pub <topic> <message> --node <node-id>` publishes to an authorized peer;
`geth pubsub sub <topic> --node <node-id>` reads an authorized peer snapshot
- SSH certificate flow metadata: - SSH certificate flow metadata:
- `geth ssh cert request --public-key <path> --principal <name>` - `geth ssh cert request --public-key <path> --principal <name>`
- `geth ssh cert requests` - `geth ssh cert requests`
@ -157,7 +158,9 @@ older than the local value.
Remote pubsub publish uses the protected Iroh control path too. The remote peer Remote pubsub publish uses the protected Iroh control path too. The remote peer
requires `pubsub.publish` on `resource:pubsub:<topic>` before recording the requires `pubsub.publish` on `resource:pubsub:<topic>` before recording the
message in its local daemon-lifetime ring buffer. Pubsub remains lossy and is message in its local daemon-lifetime ring buffer. Pubsub remains lossy and is
not durable storage. not durable storage. 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 pipe connect uses the same protected Iroh control path and requires Remote pipe connect uses the same protected Iroh control path and requires
`pipe.connect` on `resource:pipe:<name>`. The current prototype records a remote `pipe.connect` on `resource:pipe:<name>`. The current prototype records a remote
connection attempt and whether a listener exists; byte streaming and forwarding connection attempt and whether a listener exists; byte streaming and forwarding

View file

@ -320,6 +320,8 @@ pub enum PubsubCommand {
}, },
Sub { Sub {
topic: String, topic: String,
#[arg(long)]
node: Option<String>,
}, },
} }
@ -653,7 +655,7 @@ fn request_for_command(command: Command) -> Result<ControlRequest> {
message, message,
node, node,
}, },
PubsubCommand::Sub { topic } => ControlRequest::PubsubSub { topic }, PubsubCommand::Sub { topic, node } => ControlRequest::PubsubSub { topic, node },
}, },
Command::Pipe { command } => match command { Command::Pipe { command } => match command {
PipeCommand::Listen { name } => ControlRequest::PipeListen { name }, PipeCommand::Listen { name } => ControlRequest::PipeListen { name },
@ -1477,6 +1479,32 @@ fn print_response(response: ControlResponse, json: bool) -> Result<()> {
} }
println!("note: {note}"); println!("note: {note}");
} }
ControlResponse::PubsubRemoteMessages {
peer_node_id,
peer_agent_id,
endpoint_id,
topic,
messages,
allowed,
reason,
note,
} => {
if allowed {
println!("topic: {topic}");
println!("peer: {peer_node_id}");
println!("messages: {}", messages.len());
for message in messages {
println!("{}\t{}", message.published_at.0, message.message);
}
} else {
println!("pubsub subscribe denied by {peer_node_id}");
}
println!("agent: {peer_agent_id}");
println!("endpoint: {endpoint_id}");
println!("allowed: {allowed}");
println!("reason: {reason}");
println!("note: {note}");
}
ControlResponse::PipeListening { listener } => { ControlResponse::PipeListening { listener } => {
println!("listening pipe: {}", listener.name); println!("listening pipe: {}", listener.name);
println!("id: {}", listener.id); println!("id: {}", listener.id);

View file

@ -229,6 +229,7 @@ pub enum ControlRequest {
}, },
PubsubSub { PubsubSub {
topic: String, topic: String,
node: Option<String>,
}, },
PipeListen { PipeListen {
name: String, name: String,
@ -498,6 +499,16 @@ pub enum ControlResponse {
messages: Vec<PubsubMessage>, messages: Vec<PubsubMessage>,
note: String, note: String,
}, },
PubsubRemoteMessages {
peer_node_id: String,
peer_agent_id: String,
endpoint_id: String,
topic: String,
messages: Vec<PubsubMessage>,
allowed: bool,
reason: String,
note: String,
},
PipeListening { PipeListening {
listener: PipeListener, listener: PipeListener,
}, },
@ -602,6 +613,11 @@ pub enum PeerControlRequest {
message: String, message: String,
nonce: String, nonce: String,
}, },
PubsubSubscribe {
peer_card: PeerCard,
topic: String,
nonce: String,
},
PipeConnect { PipeConnect {
peer_card: PeerCard, peer_card: PeerCard,
target: String, target: String,
@ -723,6 +739,19 @@ pub enum PeerControlResponse {
nonce: String, nonce: String,
note: String, note: String,
}, },
PubsubSubscribed {
node_id: String,
agent_id: String,
endpoint_id: String,
remote_endpoint_id: String,
topic: String,
messages: Vec<PubsubMessage>,
allowed: bool,
reason: String,
evaluated_ops: usize,
nonce: String,
note: String,
},
PipeConnected { PipeConnected {
node_id: String, node_id: String,
agent_id: String, agent_id: String,
@ -987,6 +1016,15 @@ mod tests {
request request
); );
let request = ControlRequest::PubsubSub {
topic: "presence/test".to_owned(),
node: Some("node:peer".to_owned()),
};
assert_eq!(
decode_request(&encode_request(&request).expect("encode")).expect("decode"),
request
);
let response = ControlResponse::PubsubRemotePublished { let response = ControlResponse::PubsubRemotePublished {
peer_node_id: "node:peer".to_owned(), peer_node_id: "node:peer".to_owned(),
peer_agent_id: "agent:peer".to_owned(), peer_agent_id: "agent:peer".to_owned(),
@ -1005,6 +1043,25 @@ mod tests {
response response
); );
let response = ControlResponse::PubsubRemoteMessages {
peer_node_id: "node:peer".to_owned(),
peer_agent_id: "agent:peer".to_owned(),
endpoint_id: "endpoint:peer".to_owned(),
topic: "presence/test".to_owned(),
messages: vec![PubsubMessage {
topic: "presence/test".into(),
message: "online".to_owned(),
published_at: geth_types::UnixMillis(1),
}],
allowed: true,
reason: "direct grant".to_owned(),
note: "lossy".to_owned(),
};
assert_eq!(
decode_response(&encode_response(&response).expect("encode")).expect("decode"),
response
);
let response = ControlResponse::PipeRemoteConnected { let response = ControlResponse::PipeRemoteConnected {
peer_node_id: "node:peer".to_owned(), peer_node_id: "node:peer".to_owned(),
peer_agent_id: "agent:peer".to_owned(), peer_agent_id: "agent:peer".to_owned(),
@ -1303,6 +1360,50 @@ mod tests {
response response
); );
let request = PeerControlRequest::PubsubSubscribe {
peer_card: PeerCard {
node_id: "node:caller".into(),
agent_id: "agent:caller".into(),
endpoints: Vec::new(),
issued_at: geth_types::UnixMillis(1),
signature: geth_discovery::SignatureMetadata {
namespace: "geth.peer-card.v1@geth.local".to_owned(),
signer: "agent:caller".to_owned(),
public_key: "key".to_owned(),
signature: "sig".to_owned(),
},
},
topic: "presence/test".to_owned(),
nonce: "nonce".to_owned(),
};
assert_eq!(
decode_peer_request(&encode_peer_request(&request).expect("encode")).expect("decode"),
request
);
let response = PeerControlResponse::PubsubSubscribed {
node_id: "node:peer".to_owned(),
agent_id: "agent:peer".to_owned(),
endpoint_id: "endpoint:peer".to_owned(),
remote_endpoint_id: "endpoint:caller".to_owned(),
topic: "presence/test".to_owned(),
messages: vec![PubsubMessage {
topic: "presence/test".into(),
message: "online".to_owned(),
published_at: geth_types::UnixMillis(1),
}],
allowed: true,
reason: "direct grant".to_owned(),
evaluated_ops: 1,
nonce: "nonce".to_owned(),
note: "lossy".to_owned(),
};
assert_eq!(
decode_peer_response(&encode_peer_response(&response).expect("encode"))
.expect("decode"),
response
);
let response = PeerControlResponse::PipeConnected { let response = PeerControlResponse::PipeConnected {
node_id: "node:peer".to_owned(), node_id: "node:peer".to_owned(),
agent_id: "agent:peer".to_owned(), agent_id: "agent:peer".to_owned(),

View file

@ -273,6 +273,10 @@ pub async fn handle_request_async(
message, message,
node: Some(peer_node), node: Some(peer_node),
} => pubsub_publish_to_peer(node, &peer_node, topic, message).await, } => pubsub_publish_to_peer(node, &peer_node, topic, message).await,
ControlRequest::PubsubSub {
topic,
node: Some(peer_node),
} => pubsub_subscribe_from_peer(node, &peer_node, topic).await,
ControlRequest::PipeConnect { ControlRequest::PipeConnect {
target, target,
node: Some(peer_node), node: Some(peer_node),
@ -560,6 +564,7 @@ async fn peer_ping(node: &LocalNode, peer_node: &str) -> Result<ControlResponse,
| PeerControlResponse::SshRevocationSynced { .. } | PeerControlResponse::SshRevocationSynced { .. }
| PeerControlResponse::KvSynced { .. } | PeerControlResponse::KvSynced { .. }
| PeerControlResponse::PubsubPublished { .. } | PeerControlResponse::PubsubPublished { .. }
| PeerControlResponse::PubsubSubscribed { .. }
| PeerControlResponse::PipeConnected { .. } | PeerControlResponse::PipeConnected { .. }
| PeerControlResponse::DocumentSynced { .. } | PeerControlResponse::DocumentSynced { .. }
| PeerControlResponse::DbSynced { .. } => Err(NodeError::IrohPeer( | PeerControlResponse::DbSynced { .. } => Err(NodeError::IrohPeer(
@ -673,6 +678,7 @@ async fn peer_auth_check(
| PeerControlResponse::SshRevocationSynced { .. } | PeerControlResponse::SshRevocationSynced { .. }
| PeerControlResponse::KvSynced { .. } | PeerControlResponse::KvSynced { .. }
| PeerControlResponse::PubsubPublished { .. } | PeerControlResponse::PubsubPublished { .. }
| PeerControlResponse::PubsubSubscribed { .. }
| PeerControlResponse::PipeConnected { .. } | PeerControlResponse::PipeConnected { .. }
| PeerControlResponse::DocumentSynced { .. } | PeerControlResponse::DocumentSynced { .. }
| PeerControlResponse::DbSynced { .. } => Err(NodeError::IrohPeer( | PeerControlResponse::DbSynced { .. } => Err(NodeError::IrohPeer(
@ -815,6 +821,7 @@ async fn cas_fetch_from_peer(
| PeerControlResponse::SshRevocationSynced { .. } | PeerControlResponse::SshRevocationSynced { .. }
| PeerControlResponse::KvSynced { .. } | PeerControlResponse::KvSynced { .. }
| PeerControlResponse::PubsubPublished { .. } | PeerControlResponse::PubsubPublished { .. }
| PeerControlResponse::PubsubSubscribed { .. }
| PeerControlResponse::PipeConnected { .. } | PeerControlResponse::PipeConnected { .. }
| PeerControlResponse::DocumentSynced { .. } | PeerControlResponse::DocumentSynced { .. }
| PeerControlResponse::DbSynced { .. } => Err(NodeError::IrohPeer( | PeerControlResponse::DbSynced { .. } => Err(NodeError::IrohPeer(
@ -1093,6 +1100,51 @@ async fn pubsub_publish_to_peer(
} }
} }
async fn pubsub_subscribe_from_peer(
node: &LocalNode,
peer_node: &str,
topic: String,
) -> Result<ControlResponse, NodeError> {
geth_pubsub::validate_topic(&topic)?;
let response = request_peer_control(node, peer_node, "pubsub-subscribe", |peer_card, nonce| {
PeerControlRequest::PubsubSubscribe {
peer_card,
topic: topic.clone(),
nonce,
}
})
.await?;
match response {
PeerControlResponse::PubsubSubscribed {
node_id,
agent_id,
endpoint_id,
topic: response_topic,
messages,
allowed,
reason,
note,
..
} if response_topic == topic => Ok(ControlResponse::PubsubRemoteMessages {
peer_node_id: node_id,
peer_agent_id: agent_id,
endpoint_id,
topic: response_topic,
messages,
allowed,
reason,
note,
}),
PeerControlResponse::PubsubSubscribed { .. } => Err(NodeError::IrohPeer(
"peer pubsub subscribe response did not match request".to_owned(),
)),
PeerControlResponse::Error { message } => Err(NodeError::IrohPeer(message)),
_ => Err(NodeError::IrohPeer(
"peer returned wrong response type to pubsub subscribe".to_owned(),
)),
}
}
async fn pipe_connect_to_peer( async fn pipe_connect_to_peer(
node: &LocalNode, node: &LocalNode,
peer_node: &str, peer_node: &str,
@ -1549,6 +1601,10 @@ async fn request_peer_control(
nonce: response_nonce, nonce: response_nonce,
.. ..
} }
| PeerControlResponse::PubsubSubscribed {
nonce: response_nonce,
..
}
| PeerControlResponse::PipeConnected { | PeerControlResponse::PipeConnected {
nonce: response_nonce, nonce: response_nonce,
.. ..
@ -2082,6 +2138,52 @@ async fn handle_iroh_control_connection(
note: "pubsub publish authenticated endpoint/card binding and required pubsub.publish on the remote topic resource; pubsub is lossy".to_owned(), note: "pubsub publish authenticated endpoint/card binding and required pubsub.publish on the remote topic resource; pubsub is lossy".to_owned(),
} }
} }
PeerControlRequest::PubsubSubscribe {
peer_card,
topic,
nonce,
} => {
geth_pubsub::validate_topic(&topic)?;
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.subscribe".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 messages = if explanation.allowed {
pubsub_messages_for_topic(&node, &topic)?
} else {
Vec::new()
};
PeerControlResponse::PubsubSubscribed {
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,
topic,
messages,
allowed: explanation.allowed,
reason: explanation.reason,
evaluated_ops: explanation.evaluated_ops,
nonce,
note: "pubsub subscribe authenticated endpoint/card binding and required pubsub.subscribe on the remote topic resource; pubsub is lossy daemon-lifetime state".to_owned(),
}
}
PeerControlRequest::PipeConnect { PeerControlRequest::PipeConnect {
peer_card, peer_card,
target, target,
@ -3266,25 +3368,16 @@ pub fn handle_request(
Ok(ControlResponse::PubsubPublished { message }) Ok(ControlResponse::PubsubPublished { message })
} }
ControlRequest::PubsubPub { node: Some(_), .. } => Err(NodeError::IrohEndpointUnavailable), ControlRequest::PubsubPub { node: Some(_), .. } => Err(NodeError::IrohEndpointUnavailable),
ControlRequest::PubsubSub { topic } => { ControlRequest::PubsubSub { topic, node: None } => {
geth_pubsub::validate_topic(&topic)?; geth_pubsub::validate_topic(&topic)?;
let runtime = node let messages = pubsub_messages_for_topic(node, &topic)?;
.runtime
.pubsub
.lock()
.map_err(|_| NodeError::RuntimeLockPoisoned)?;
let messages = runtime
.messages
.iter()
.filter(|message| message.topic.as_str() == topic)
.cloned()
.collect();
Ok(ControlResponse::PubsubMessages { Ok(ControlResponse::PubsubMessages {
topic, topic,
messages, messages,
note: geth_pubsub::pubsub_storage_warning().to_owned(), note: geth_pubsub::pubsub_storage_warning().to_owned(),
}) })
} }
ControlRequest::PubsubSub { node: Some(_), .. } => Err(NodeError::IrohEndpointUnavailable),
ControlRequest::PipeListen { name } => { ControlRequest::PipeListen { name } => {
geth_pipe::validate_pipe_name(&name)?; geth_pipe::validate_pipe_name(&name)?;
let listener = PipeListener { let listener = PipeListener {
@ -3428,6 +3521,23 @@ fn record_pubsub_message(
Ok(message) Ok(message)
} }
fn pubsub_messages_for_topic(
node: &LocalNode,
topic: &str,
) -> Result<Vec<PubsubMessage>, NodeError> {
let runtime = node
.runtime
.pubsub
.lock()
.map_err(|_| NodeError::RuntimeLockPoisoned)?;
Ok(runtime
.messages
.iter()
.filter(|message| message.topic.as_str() == topic)
.cloned()
.collect())
}
fn record_pipe_connection( fn record_pipe_connection(
node: &LocalNode, node: &LocalNode,
target: String, target: String,
@ -4319,6 +4429,29 @@ mod tests {
other => panic!("unexpected denied pubsub publish response: {other:?}"), other => panic!("unexpected denied pubsub publish response: {other:?}"),
} }
let denied_pubsub_subscribe = handle_request_async(
&left,
ControlRequest::PubsubSub {
topic: "presence/test".to_owned(),
node: Some(right_card.node_id.to_string()),
},
)
.await
.expect("denied remote pubsub subscribe");
match denied_pubsub_subscribe {
ControlResponse::PubsubRemoteMessages {
allowed,
messages,
reason,
..
} => {
assert!(!allowed);
assert!(messages.is_empty());
assert!(reason.contains("no active direct or group grant"));
}
other => panic!("unexpected denied pubsub subscribe response: {other:?}"),
}
let denied_pipe = handle_request_async( let denied_pipe = handle_request_async(
&left, &left,
ControlRequest::PipeConnect { ControlRequest::PipeConnect {
@ -4421,6 +4554,16 @@ mod tests {
}, },
) )
.expect("grant left pubsub publish"); .expect("grant left pubsub publish");
handle_request(
&right,
ControlRequest::AuthGrant {
subject: left.node_id.clone(),
resource: "resource:pubsub:presence/test".to_owned(),
capability: "pubsub.subscribe".to_owned(),
grant_id: Some("grant:left-pubsub-subscribe".to_owned()),
},
)
.expect("grant left pubsub subscribe");
handle_request( handle_request(
&right, &right,
ControlRequest::AuthGrant { ControlRequest::AuthGrant {
@ -4578,6 +4721,7 @@ mod tests {
&right, &right,
ControlRequest::PubsubSub { ControlRequest::PubsubSub {
topic: "presence/test".to_owned(), topic: "presence/test".to_owned(),
node: None,
}, },
) )
.expect("right pubsub sub after remote publish"); .expect("right pubsub sub after remote publish");
@ -4589,6 +4733,32 @@ mod tests {
other => panic!("unexpected remote pubsub messages response: {other:?}"), other => panic!("unexpected remote pubsub messages response: {other:?}"),
} }
let subscribed = handle_request_async(
&left,
ControlRequest::PubsubSub {
topic: "presence/test".to_owned(),
node: Some(right_card.node_id.to_string()),
},
)
.await
.expect("allowed remote pubsub subscribe");
match subscribed {
ControlResponse::PubsubRemoteMessages {
allowed,
messages,
reason,
note,
..
} => {
assert!(allowed);
assert_eq!(messages.len(), 1);
assert_eq!(messages[0].message, "hello");
assert!(reason.contains("direct grant"));
assert!(note.contains("pubsub.subscribe"));
}
other => panic!("unexpected allowed pubsub subscribe response: {other:?}"),
}
let remote_pipe = handle_request_async( let remote_pipe = handle_request_async(
&left, &left,
ControlRequest::PipeConnect { ControlRequest::PipeConnect {

View file

@ -1241,6 +1241,7 @@ fn pubsub_pub_sub_uses_lossy_in_memory_runtime() {
&node, &node,
geth_control::ControlRequest::PubsubSub { geth_control::ControlRequest::PubsubSub {
topic: "presence/laptop".to_owned(), topic: "presence/laptop".to_owned(),
node: None,
}, },
) )
.expect("subscribe snapshot"); .expect("subscribe snapshot");
@ -1263,6 +1264,7 @@ fn pubsub_pub_sub_uses_lossy_in_memory_runtime() {
&reopened, &reopened,
geth_control::ControlRequest::PubsubSub { geth_control::ControlRequest::PubsubSub {
topic: "presence/laptop".to_owned(), topic: "presence/laptop".to_owned(),
node: None,
}, },
) )
.expect("subscribe reopened snapshot"); .expect("subscribe reopened snapshot");

View file

@ -177,8 +177,10 @@ presence channel, not authoritative storage. `geth pubsub pub <topic> <message>
--node <node-id>` can publish to an imported peer over the protected Iroh --node <node-id>` can publish to an imported peer over the protected Iroh
control ALPN. The remote daemon validates endpoint/card binding and requires control ALPN. The remote daemon validates endpoint/card binding and requires
`pubsub.publish` on `resource:pubsub:<topic>` before recording the message in `pubsub.publish` on `resource:pubsub:<topic>` before recording the message in
its local ring buffer. Iroh-gossip replication and private topics are future its local ring buffer. `geth pubsub sub <topic> --node <node-id>` can read an
work. 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-pipe` currently supports `pipe listen/connect` against a daemon-lifetime `geth-pipe` currently supports `pipe listen/connect` against a daemon-lifetime
registry. `geth pipe connect <name> --node <node-id>` sends an authorized remote registry. `geth pipe connect <name> --node <node-id>` sends an authorized remote

View file

@ -294,8 +294,12 @@ authorization and durable-state boundaries clear.
imported peer over Iroh. imported peer over Iroh.
- `[x]` Remote pubsub publish requires `pubsub.publish` on - `[x]` Remote pubsub publish requires `pubsub.publish` on
`resource:pubsub:<topic>`. `resource:pubsub:<topic>`.
- `[x]` `geth pubsub sub <topic> --node <node-id>` reads an authorized peer
snapshot over Iroh.
- `[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. - `[ ]` Replace bootstrap remote publish with iroh-gossip topics.
- `[ ]` Pubsub subscribe works across local test nodes over Iroh or gossip.
- `[ ]` Docs and tests keep durable state in CAS/KV/document/db instead. - `[ ]` Docs and tests keep durable state in CAS/KV/document/db instead.
## Phase 4: Pipes, SSH Proxy, And SSH Distribution ## Phase 4: Pipes, SSH Proxy, And SSH Distribution