Gossip 聊天室示例(pair-demo chat)
pair-demo chat(源码位于 crates/pair-demo/src/chat.rs,详细说明见 docs/chat.md)展示了如何将 iroh-pairing 接入一个使用 iroh-gossip 0.101.0 的宿主应用:两个对等端点共享同一个 Endpoint 与 Router,在完成完整 Matrix SAS 本地确认并校验 chat.message 权限范围后,动态放行 iroh_gossip::ALPN 并进入双端实时文本聊天。
定位与边界说明:
pair-demo chat是一个双端点、单房间的配对接入演示,旨在展示已有iroh-gossip应用如何复用同一Endpoint完成配对准入控制;它不是多人聊天室平台、群组频道系统或多租户 ACL 框架。
1. 运行与交互流程
Section titled “1. 运行与交互流程”1.1 启动两个聊天端点
Section titled “1.1 启动两个聊天端点”先编译 pair-demo,然后在两个终端分别启动:
cargo build -p pair-demo
# 终端 A(默认使用 iroh 官方 presets::N0:UDP 直连 + N0 relay + DNS 发现)./target/debug/pair-demo chat --name alice
# 终端 B./target/debug/pair-demo chat --name bob如果需要在无公网 relay 的局域网环境中配合 connector-android 的“仅局域网”模式运行,可在两端同时传入 --lan-only:
./target/debug/pair-demo chat --name alice --lan-only./target/debug/pair-demo chat --name bob --lan-only可用命令行参数:
| 参数 | 默认值 | 说明 |
|---|---|---|
--name <TEXT> |
pair-demo-chat |
自报设备名称,展示在手机卡片与终端提示中 |
--lan-only |
关闭 | 禁用 relay 与地址发现,仅使用直连 UDP 地址(与 --relay 互斥) |
--relay <URL> |
无 | 仅使用指定的自定义 HTTPS relay(与 --lan-only 互斥) |
--ttl <SECS> |
300 |
未使用邀请的有效期(秒,范围 1..=3600) |
1.2 扫码、本地确认与收发消息
Section titled “1.2 扫码、本地确认与收发消息”- 手机扫码自动介绍:使用
connector-android或connector-web点击底部**“扫码 / Scan”**依次读取alice与bob的邀请;第二份邀请录入后手机自动发起Introduce。 - 双端本机输入
yes:两个终端各自打印 7 个 Matrix SAS 表情(及中英文名称)与 3 个十进制数。双方操作者在本机核对一致后,分别在两个终端输入yes(或完全一致)回车;若输入no则立即取消退出,不会授予任何 gossip 权限。 - 加入聊天并收发文本:两端完成验证与候选 topic 交换后,终端显示:
此时在任一终端输入单行 UTF-8 文本回车即可发送(本端显示Chat joined with verified peer ...
[you] ...,对端显示[verified peer] ...)。- 单行消息正文上限为 4096 字节(底层 gossip frame 上限为
5120字节);超出 4096 字节的输入会在本地给出提示并拒绝发送,不会导致进程 panic。 - 接收端在打印前会过滤终端控制字符,防止控制序列注入。
- 单行消息正文上限为 4096 字节(底层 gossip frame 上限为
- 干净退出与脱敏输出:输入
/quit、按Ctrl-C或关闭stdin(EOF)即可退出(在等待加入 gossip 期间同样支持立即取消)。退出前进程会先记录消息计数、撤销内存中的 gossip 授权,再关闭Router,最后在stdout输出单行脱敏 JSON:{"command": "chat","ok": true,"state": "complete","joined": true,"messages_sent": 2,"messages_received": 2,"error": null}stdout绝不包含邀请 token、topic 字节、对端地址或任何聊天消息正文。
2. 宿主接入 iroh-gossip 的 5 步代码架构
Section titled “2. 宿主接入 iroh-gossip 的 5 步代码架构”crates/pair-demo/src/chat.rs 严格按照以下 5 个步骤将 iroh-pairing 与 iroh-gossip 组合在同一个宿主进程内,无需修改配对库源码或开启第二个 Endpoint:
第 1 步:构建期安装只保护 iroh_gossip::ALPN 的 Gate
Section titled “第 1 步:构建期安装只保护 iroh_gossip::ALPN 的 Gate”在创建宿主 Endpoint 时,先构造 AuthorizationState::new([iroh_gossip::ALPN]),并将 state.gate() 挂载到 Endpoint::builder(...).hooks(...) 上,随后再调用 bind():
let auth_state = AuthorizationState::new([iroh_gossip::ALPN])?;let endpoint = Endpoint::builder(presets::N0) .hooks(auth_state.gate()) .bind() .await?;此时配对专用 ALPN(/iroh-pairing/1)保持开放以接收手机介绍与对端 SAS 握手,而任何未经授权的 /iroh-gossip/1 连接都会在握手后被 PairingGate 以应用错误码 403 拒绝。
第 2 步:同一 Endpoint + 同一 Router 注册两个 Handler
Section titled “第 2 步:同一 Endpoint + 同一 Router 注册两个 Handler”复用同一个 endpoint 启动 Gossip actor 并附着 Pairing,再将两者注册到同一个 Router:
let gossip = Gossip::builder() .max_message_size(5120) .spawn(endpoint.clone());
let pairing = Pairing::attach(endpoint.clone(), pairing_config);
let router = Router::builder(endpoint.clone()) .accept(PAIRING_ALPN, pairing.handler()) .accept(iroh_gossip::ALPN, gossip.clone()) .spawn();整个进程只有一个 Endpoint 和一个 Router accept loop,不存在竞争接收连接的问题。
第 3 步:在邀请中声明聊天 Profile 与公开候选 Topic 载荷
Section titled “第 3 步:在邀请中声明聊天 Profile 与公开候选 Topic 载荷”pair-demo chat 使用固定的业务上下文:
namespace:iroh.pairing.demoprofile_id:iroh-pairing.demo.chat(profile_version = 1)role:peer(accepts_roles = ["peer"])scope:["chat.message"]
同时,通过 InviteRequest::with_payload(...) 附带一个 32 字节的验证后载荷(content_type = "application/vnd.iroh-pairing.chat-topic.v1",schema_version = 1),内容即为本端公开 EndpointId 的 32 字节。由于 profile_id 与普通 generic.pair 不同,误扫 pair-demo invite 二维码时会在兼容性检查阶段被直接拦截。
第 4 步:SAS 验证后先校验 Scope 并授予权限,再确认载荷回执
Section titled “第 4 步:SAS 验证后先校验 Scope 并授予权限,再确认载荷回执”- 收到
SessionEvent::Verified { peer }后,admit_peer(&peer)先核对已验证上下文中的namespace、profile_id、role以及对端scope是否包含chat.message。若不满足,在授予任何 gossip 权限前直接中止。 - 校验通过后,调用
state.grant(&peer, iroh_gossip::ALPN, GrantLifetime::Persistent)在本进程内存中放行该对端的 gossip 连接(不写入磁盘)。 - 收到
SessionEvent::PayloadReceived后,校验对端发来的 32 字节候选 topic 恰好等于已验证peer.remote_id()的 32 字节,然后调用session.accept_payload()发送协议回执。
第 5 步:注入已验证地址提示并订阅确定性 TopicId
Section titled “第 5 步:注入已验证地址提示并订阅确定性 TopicId”当载荷交换到达 ExchangeFinished(ExchangeOutcome::Complete)后:
- 从
VerifiedPeer::addresses()构造EndpointAddr,通过MemoryLookup::from_endpoint_info([target])注入endpoint.address_lookup()?.add(...)(在--lan-only无 DNS 发现模式下,这一步使iroh-gossip能够将对端EndpointId解析为直连 UDP 地址)。 - 双方按相同规则取两端 32 字节
EndpointId中字典序较小者作为最终TopicId,调用gossip.subscribe(TopicId::from_bytes(topic), vec![peer_id]),并在 20 秒有界超时内等待receiver.joined()。
3. 为什么 Topic 是公开标识而非密码?
Section titled “3. 为什么 Topic 是公开标识而非密码?”pair-demo chat 故意不将 TopicId 设计为秘密口令:
- 候选 topic 直接取自公开的
EndpointId,最终TopicId为双方公开EndpointId较小者,不生成额外随机密钥,也不对房间名做哈希。 - 真正的安全边界来自
VerifiedPeer与连接级PairingGate:即使第三方节点完全知晓双方的EndpointId与TopicId,当它尝试向任一端点发起/iroh-gossip/1连接时,也会在 QUIC 握手后被PairingGate以403拒绝,无法加入双端聊天拓扑。