跳转到内容

接入现有 iroh 应用

iroh-pairing(crates/iroh-pairing/)专为嵌入已有的 iroh 应用而设计:宿主应用先创建并持有自己的 iroh::Endpoint 与业务协议 handler,再将 Pairing 附加到同一 Endpoint 上。

配对库绝不创建第二个 Endpoint、绝不启动竞争性的后台 accept loop,也绝不在配对取消或超时时调用 Router::shutdown 或 Endpoint::close。完整的独立外部消费者工程可参考仓库中的 examples/host-integration/。

在宿主项目的 Cargo.toml 中引入与 iroh 1.3.0 一致的依赖:

[dependencies]
iroh = { version = "=1.3.0", default-features = false, features = ["tls-ring"] }
iroh-pairing = { git = "https://git.gjz010.com/gjz010/iroh-pairing" }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time"] }

iroh-pairing 默认不引入任何 CLI、GUI、相机、数据库、平台 FFI 或完整 Matrix 客户端 SDK。

2. 挂载到宿主 Endpoint 的两种方式

Section titled “2. 挂载到宿主 Endpoint 的两种方式”

方式一:由 iroh::protocol::Router 统一分发(推荐)

Section titled “方式一:由 iroh::protocol::Router 统一分发(推荐)”

如果宿主使用 Router 管理多条 ALPN,只需将 PAIRING_ALPN(b"/iroh-pairing/1")与业务 ALPN 注册在同一个 Router::builder 上:

use iroh::endpoint::presets;
use iroh::protocol::Router;
use iroh::Endpoint;
use iroh_pairing::{Pairing, PairingConfig, PAIRING_ALPN};
let endpoint = Endpoint::builder(presets::N0).bind().await?;
let pairing = Pairing::attach(endpoint.clone(), PairingConfig::default());
let router = Router::builder(endpoint.clone())
.accept(PAIRING_ALPN, pairing.handler())
.accept(BUSINESS_ALPN, my_business_handler)
.spawn();

方式二:在手写 accept 循环中按 ALPN 分发

Section titled “方式二:在手写 accept 循环中按 ALPN 分发”

如果宿主维护自定义的 endpoint.accept() 循环,请在构建 Endpoint 时声明 PAIRING_ALPN,并在完成普通 QUIC/TLS 握手后按 connection.alpn() 分发给 pairing.accept(connection):

use iroh::endpoint::presets;
use iroh::Endpoint;
use iroh_pairing::{Pairing, PairingConfig, PAIRING_ALPN};
let endpoint = Endpoint::builder(presets::N0)
.alpns(vec![PAIRING_ALPN.to_vec(), BUSINESS_ALPN.to_vec()])
.bind()
.await?;
let pairing = Pairing::attach(endpoint.clone(), PairingConfig::default());
while let Some(incoming) = endpoint.accept().await {
let connection = incoming.await?; // 必须先完成完整握手(禁止 0-RTT)
match connection.alpn() {
PAIRING_ALPN => {
let pairing = pairing.clone();
tokio::spawn(async move {
let _ = pairing.accept(connection).await;
});
}
BUSINESS_ALPN => {
// 交给宿主自己的业务连接处理逻辑
}
_ => connection.close(0u32.into(), b"unsupported alpn"),
}
}

可运行的完整对比实现见 examples/host-integration/src/bin/router_host.rs 与 examples/host-integration/src/bin/manual_accept.rs。

3. 创建邀请、本地确认 SAS 与处理载荷回执

Section titled “3. 创建邀请、本地确认 SAS 与处理载荷回执”

通过 pairing.create_invite(...) 创建一次配对会话(Session),并订阅结构化事件流:

use iroh_pairing::{
ApplicationContext, InviteRequest, NetworkMode, Payload, SessionEvent,
};
use tokio::sync::broadcast::error::RecvError;
let app_ctx = ApplicationContext {
namespace: "com.example.sync".to_owned(),
profile_id: "device.sync".to_owned(),
profile_version: 1,
role: "peer".to_owned(),
accepts_roles: vec!["peer".to_owned()],
purpose: "Pair two sync nodes".to_owned(),
scope: vec!["sync.read".to_owned(), "sync.write".to_owned()],
required_capabilities: vec![],
metadata: Default::default(),
};
let request = InviteRequest::new("MySyncApp", "Desktop-A", app_ctx, NetworkMode::Internet)
.with_payload(Payload::new("application/json", 1, br#"{"cluster":"alpha"}"#.to_vec())?);
let session = pairing.create_invite(request).await?;
// 导出二维码字符串:
// - 若由已打开的手机应用直接扫描裸邀请,可使用 pairing_wire::encode_bare(&session.invite())
// - 若已部署自托管 HTTPS Web 扫码页,可生成带 fragment 的入口 URL:
let invite_url = session.invite_url("https://pair.example.com/pair")?;
let mut events = session.subscribe();
loop {
match events.recv().await {
Ok(SessionEvent::SasReady { display }) => {
// 必须在宿主本机 UI 展示完整 7 个 display.emojis 与 3 个 display.decimals,
// 由本地用户核对一致后调用 confirm(true),不一致调用 confirm(false):
let matched = prompt_local_operator(&display);
session.confirm(matched)?;
}
Ok(SessionEvent::Verified { peer }) => {
// 此时双方已完成本地确认、双向 MAC 校验与 SAS Done,获得不可伪造的 VerifiedPeer
println!("Verified peer: {}", peer.remote_id());
}
Ok(SessionEvent::PayloadReceived { payload, content_digest }) => {
// 宿主先校验 payload 的 content_type、schema_version 与内容合法性,
// 确认接收后显式发送协议回执:
validate_peer_payload(&payload)?;
session.accept_payload_with_digest(Some(&content_digest))?;
}
Ok(SessionEvent::ExchangeFinished { outcome }) => {
println!("Exchange finished: {:?}", outcome);
break;
}
Ok(SessionEvent::Failed { code, message }) => {
eprintln!("Pairing failed: {code} ({message:?})");
break;
}
Ok(SessionEvent::Indeterminate { reason }) => {
eprintln!("Verified, but exchange outcome is indeterminate: {reason}");
break;
}
Err(RecvError::Lagged(_skipped)) => {
// 事件通道有界;若消费者掉队,直接从权威快照恢复当前状态,无需解析日志:
let snapshot = session.snapshot();
sync_ui_from_snapshot(&snapshot);
}
Err(RecvError::Closed) => break,
_ => {}
}
}
  1. VerifiedPeer 不可外部构造或反序列化:VerifiedPeer 的字段全部私有,且不实现 Deserialize。只有完整通过 Matrix SAS 流程的会话才能向宿主产出 VerifiedPeer。
  2. 身份验证(Verified)与载荷交换(ExchangeOutcome)独立:仅向 QUIC 流写入成功不算交换完成;对端必须调用 accept_payload / accept_payload_with_digest 返回协议回执后,双方互发 ExchangeDone 才进入 ExchangeOutcome::Complete。若在验证后因断连或超时无法确认对端是否收到回执,会显式返回 ExchangeOutcome::Indeterminate,不会谎报成功或抹去已验证身份。
  3. 取消隔离:调用 session.cancel() 或丢弃未完成的 Session 句柄只会清理该次配对事务的任务与 QUIC 流,宿主既有业务连接与 Endpoint 不受任何影响。

4. 可选:安装连接级 PairingGate(AuthorizationState)

Section titled “4. 可选:安装连接级 PairingGate(AuthorizationState)”

如果宿主希望在 QUIC 握手完成后自动拦截未配对设备的业务 ALPN 连接,可在构建 Endpoint 时(bind() 之前)安装 AuthorizationState::gate():

use iroh::endpoint::presets;
use iroh::Endpoint;
use iroh_pairing::{AuthorizationState, GrantLifetime};
// 1. 声明受保护的业务 ALPN(严禁填入 PAIRING_ALPN)
let auth_state = AuthorizationState::new([BUSINESS_ALPN])?;
// 2. 在 EndpointBuilder::hooks 中安装 Gate(EndpointHooks 为构建期属性,bind 后不可追加)
let endpoint = Endpoint::builder(presets::N0)
.hooks(auth_state.gate())
.bind()
.await?;

当配对会话产出 VerifiedPeer 后,注意 PairingGate 只执行 (EndpointId, ALPN) 的连接级准入,不会替宿主自动检查业务 profile 或 scope。宿主必须先按本地业务策略校验 verified_peer.application() 与 verified_peer.scope(),通过后再显式调用 grant:

let app = verified_peer.application();
let profile_ok = app.namespace == "com.example.sync"
&& app.profile_id == "device.sync"
&& app.profile_version == 1
&& app.role == "peer";
let scope_ok = verified_peer.scope().iter().any(|s| s == "sync.write");
if profile_ok && scope_ok {
// GrantLifetime::Once 仅允许消费一次新连接;
// GrantLifetime::Persistent 允许建立多条新连接,直至调用 revoke。
let record = auth_state.grant(&verified_peer, BUSINESS_ALPN, GrantLifetime::Persistent)?;
}
  • 固定拒绝码防探测:对受管业务 ALPN 上的未授权连接(或已消费完的 Once 记录),PairingGate 在 after_handshake 阶段统一使用应用错误码 403(GATE_REJECT_ERROR_CODE)与固定原因 b"pairing authorization required" 拒绝,不向对端泄露本地策略细节。
  • 连接级 vs 资源级权限:PairingGate 只检查 (remote EndpointId, ALPN)。具体文件路径、操作命令或细分资源范围仍须由业务 handler 通过 auth_state.authorization(remote_id, BUSINESS_ALPN) 读取已验证的 TransactionContext 与 scope 自行校验。
  • 持久恢复与撤销:
    • auth_state.records() 导出的 AuthorizationRecord 可由宿主写入自己的可信本地存储,重启后使用相同的宿主长期密钥创建 Endpoint,并调用 auth_state.restore(record) 恢复(仅接受 Persistent 记录;严禁将网络输入或二维码内容传给 restore)。
    • auth_state.revoke(remote_id) 或 auth_state.revoke_alpn(remote_id, BUSINESS_ALPN) 会立即拒绝后续新建立的连接;已建立的存量连接是否强制断开由宿主显式决定。

更完整的说明可参阅 docs/library-integration.md 与 docs/authorization.md。