跳转到内容

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 框架。

先编译 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. 手机扫码自动介绍:使用 connector-android 或 connector-web 点击底部**“扫码 / Scan”**依次读取 alice 与 bob 的邀请;第二份邀请录入后手机自动发起 Introduce。
  2. 双端本机输入 yes:两个终端各自打印 7 个 Matrix SAS 表情(及中英文名称)与 3 个十进制数。双方操作者在本机核对一致后,分别在两个终端输入 yes(或 完全一致)回车;若输入 no 则立即取消退出,不会授予任何 gossip 权限。
  3. 加入聊天并收发文本:两端完成验证与候选 topic 交换后,终端显示:
    Chat joined with verified peer ...
    此时在任一终端输入单行 UTF-8 文本回车即可发送(本端显示 [you] ...,对端显示 [verified peer] ...)。
    • 单行消息正文上限为 4096 字节(底层 gossip frame 上限为 5120 字节);超出 4096 字节的输入会在本地给出提示并拒绝发送,不会导致进程 panic。
    • 接收端在打印前会过滤终端控制字符,防止控制序列注入。
  4. 干净退出与脱敏输出:输入 /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.demo
  • profile_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 并授予权限,再确认载荷回执”
  1. 收到 SessionEvent::Verified { peer } 后,admit_peer(&peer) 先核对已验证上下文中的 namespace、profile_id、role 以及对端 scope 是否包含 chat.message。若不满足,在授予任何 gossip 权限前直接中止。
  2. 校验通过后,调用 state.grant(&peer, iroh_gossip::ALPN, GrantLifetime::Persistent) 在本进程内存中放行该对端的 gossip 连接(不写入磁盘)。
  3. 收到 SessionEvent::PayloadReceived 后,校验对端发来的 32 字节候选 topic 恰好等于已验证 peer.remote_id() 的 32 字节,然后调用 session.accept_payload() 发送协议回执。

第 5 步:注入已验证地址提示并订阅确定性 TopicId

Section titled “第 5 步:注入已验证地址提示并订阅确定性 TopicId”

当载荷交换到达 ExchangeFinished(ExchangeOutcome::Complete)后:

  1. 从 VerifiedPeer::addresses() 构造 EndpointAddr,通过 MemoryLookup::from_endpoint_info([target]) 注入 endpoint.address_lookup()?.add(...)(在 --lan-only 无 DNS 发现模式下,这一步使 iroh-gossip 能够将对端 EndpointId 解析为直连 UDP 地址)。
  2. 双方按相同规则取两端 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 拒绝,无法加入双端聊天拓扑。