# 信令服务接入指南(下游客户端/设备端) 本文档面向接入方:被控设备端(Harness 所在内网机器)与控制端(桌面/移动/Web)。 > 术语澄清:本服务是**信令 + 验权服务**,负责鉴权、设备目录、打洞信令交换。 > 经典 STUN binding / TURN 中继在 M2 由 coturn 提供——当前 `signal` 帧的 payload > 里应由你们自行封装 ICE 候选地址/SDP(端到端加密,信令不读明文)。 ## 环境 | 项 | 值 | |---|---| | API 基址 | `https://fmn.idtree.cn` | | WebSocket | `wss://fmn.idtree.cn/v1/ws` | | 区域 | cn(国际区后续独立部署) | ## 流程总览 ``` 设备端 控制端 │ │ │ 1. 登录拿 access_token │ 1. 登录拿 access_token │ 2. POST /v1/devices 绑定设备 │ 4. POST /v1/session/token {device_id} │ 3. WSS role=device 长连接+心跳 │ 5. WSS role=client 长连接 │ │ │◀──── signal 帧(含 E2E 密文 payload)──▶ │ 6. 双向 signal 交换打洞信息 │ │ ├── P2P 直连建立(数据不经信令)───────────┤ │ │ │◀──── lease 帧:各自每 epoch 领半边种子 ──▶ 7. 数据通道密钥按 epoch 轮换, │ │ 任一端断失信令 → 通道死亡 ``` ## 1. 登录(身份层验权) 无密码验证码登录。`target` 支持**邮箱或手机号**(含 `@` 判邮箱,否则按手机号处理, 6–15 位数字,可带 `+` 前缀): ```bash # 1) 请求验证码 —— mode: "login"(要求已注册) | "register"(要求未注册) | 不传=自动建号 # 其余 mode 值 → 400 BAD_REQUEST POST /v1/auth/code {"target": "you@example.com", "mode": "login"} # 或手机号 "+8613800138000" → {"ok": true, "dev_code": "433705"} # dev_code 仅 DEV_MODE 返回 # mode=login 且未注册 → 404 NOT_REGISTERED;mode=register 且已注册 → 409 ALREADY_REGISTERED # (在发码阶段即拦截,避免白发短信/邮件) # 2) 验证换 token(mode 语义同上;register 才建号) # 建号即发放 TRIAL_DAYS(默认 14)天 Personal 试用订阅,到期自动降 Free POST /v1/auth/verify {"target": "you@example.com", "code": "433705", "mode": "login"} → { "access_token": "", # 12h 有效(ACCESS_TOKEN_TTL 可调) "refresh_token": "", # 180d,单次轮换、可撤销(REFRESH_TOKEN_TTL 可调) "account_id": "a51206a2-...", "expires_in": 43200 } ``` 之后所有请求带 `Authorization: Bearer `(WS 也可用 `?token=`)。 token 过期:`POST /v1/auth/refresh {"refresh_token": "..."}` 换新对; 登出:`POST /v1/auth/logout {"refresh_token": "..."}`(立即撤销,且连带整条轮换祖先链)。 ### 1.1 refresh token 轮换语义(客户端必读) - **单次轮换**:换票成功的同时旧票即被作废,返回的新对必须**立刻持久化**再返回业务层。 移动端上"先返回、后落盘"会输给进程回收——库里旧票已废、本地还留着它, 下次冷启动只能重新收验证码。 - **单飞**:并发请求不能各自拿同一张旧票去换(必有一方吃 `REFRESH_INVALID`)。 把换票收敛到唯一入口并让并发调用共用同一次往返。 - **宽限窗口**(`REFRESH_GRACE`,默认 120s):刚被轮换顶替的旧票在窗口内仍可再兑换一次, 用于兜住"换票响应丢失"(断网/进程被杀/客户端重入)。服务端记审计 `authn.refresh_replay`;此前签发的**继承票不会被作废**,健康客户端不受影响。 `REFRESH_GRACE=0` 恢复严格单次。 - **业务接口的 401 不代表登录态失效**:那只是 access token 过期,强刷重放即可。 唯有 `/v1/auth/refresh` 返回 `401 REFRESH_INVALID` 才该清本地凭证回登录页; 订阅过期是 `402 SUBSCRIPTION_EXPIRED`,网络/5xx 一律按退避重试处理。 ## 2. 设备端接入(席位凭证模型) 设备不用账户 access token 登录,而是用**席位凭证(device cred)**: 你购买几个设备额度,就能签发几个凭证;凭证可生成、可作废、总量=配额; 同一凭证给两台机器时,**同时只有一台在线**(后登录的踢掉先登录的)。 ### 2.1 签发凭证(控制端/管理端操作) ```bash POST /v1/creds # 需账户 access token {"name": "office-pc"} → {"id": "60847c90-...", "name": "office-pc", "hint": "U-etgQ", "token": "dc_05WV-FZU..."} # 完整 token 只返回这一次,请保存 ``` - 签发数 ≤ 设备包配额(free=1 / personal=3 / personal+=5 / business=15),超限 `DEVICE_QUOTA_EXCEEDED` - 服务端只存 token 哈希;泄露时可 `DELETE /v1/devices/{id}` 作废释放席位 - `hint` 是 token 尾 6 位,用于列表里辨别是哪张凭证 ### 2.2 设备登录(设备上的"开关") ``` wss://fmn.idtree.cn/v1/ws?role=device&cred=&name=<机器名> ``` - `cred` 是唯一验票口,**不需要**账户 access token - `name` 可选,每次连接上报机器名(同凭证换机器后目录名跟着变) - 无效/已作废凭证 → 401 `CRED_INVALID` 连接后每 **≤30s** 发一次心跳: ```json {"type": "hb"} ``` 心跳同时刷新在线路由表(TTL 45s)与 `last_seen_at`。断线后设备目录显示离线; 重新连接会用更高 epoch 顶掉旧连接(防脑裂),旧连接收到关闭帧。 ### 2.3 接收信令帧 ```json {"type": "signal", "from": "cli:", "payload": { ... }} ``` `payload` 是控制端的 E2E 密文(ICE candidates / SDP offer 等),服务端不解析。 ## 3. 控制端接入 ### 3.1 设备目录 ```bash GET /v1/devices → {"devices": [{"id": "...", "name": "macbook-dev", "online": true, "last_seen_at": "..."}]} ``` ### 3.2 申请会话 token(外网连接前置,服务端硬校验) ```bash POST /v1/session/token {"device_id": ""} # device_id 就是凭证 id → {"token": "", "sid": "", "expires_at": "..."} ``` 校验链(任一失败拒签):登录态 → 凭证归属本账户 → 订阅有效(free 档仅内网)→ 凭证在线。 token 特性:Ed25519 签名、5 分钟过期、aud 绑定目标凭证、jti 在线复核单次消费。 `sid` 是会话句柄,之后 lease 帧用它索取 epoch 种子(见 §3.5)。 ### 3.3 建立长连接并交换信令 ``` wss://fmn.idtree.cn/v1/ws?role=client&token= ``` 发送打洞信息给设备(`to` = 凭证 id): ```json {"type": "signal", "to": "", "payload": {"sdp": "...", "candidates": [...]}} ``` 设备回包同理,`to` 填 `"cli:"`。对端离线时收到 `{"type": "error", "code": "PEER_OFFLINE"}`。 ### 3.4 设备端如何验证 token(SR-23) 设备只有 cred、没有账户 token,在线复核走 **WS 帧**(权威、激活会话): ```json → {"type": "activate", "token": ""} ← {"type": "activate", "sid": "", "device_id": ""} ``` 首次成功即消耗 jti 并激活会话租约;重放/已撤销返回 `{"type":"error","code":"TOKEN_USED_OR_REVOKED"}`。 本地验签(`GET /v1/keys` + 校验 `signature + exp + aud==自己的cred.id`)可做 低延迟第一道检查,但**activate 是 lease 的前置**——未经权威消费的会话领不到 epoch 种子(`SESSION_NOT_ACTIVE`)。 ### 3.5 会话密钥租约(防跳过,双方必做) P2P 通道建立后,数据面密钥**按 epoch 轮换**,每个 epoch 的密钥材料有一部分 只能从信令领取。任一端停止向信令取种子,通道在下一个 epoch 边界死亡。 这使信令依赖贯穿会话全程(订阅到期/凭证作废/会话被踢 → 断种即断链), 同时给出精确的信令会话计量(`lease_count × epoch` ≈ 会话秒数)。 ``` epoch(e) = floor(unix_seconds / epoch_seconds) # epoch_seconds 默认 60 K_e = HKDF-SHA256(ikm = seed_dev ‖ seed_cli, # 固定顺序 dev 在前 salt = sid, info = "dsh-p2p-e" ‖ epoch) ``` **领种子**(双方各自的 WSS 连接上,每 epoch 一次即可): ```json → {"type": "lease", "sid": ""} ← {"type": "lease", "sid": "", "epoch": 34123, "epoch_seconds": 60, "seeds": [{"epoch": 34123, "seed": ""}, {"epoch": 34124, "seed": ""}]} ``` - **一人一半,只发本人**:设备连接(cred 认证)只会拿到 `seed_dev`, 控制端连接(账户认证)只会拿到 `seed_cli`。任一半无法替对方领取。 - **半边种子经 P2P 通道互发**:收到自己的半边后,在数据通道内(当前 epoch 密钥保护下)发给对端;双方拼齐两半才能导出 K_e。 - 响应总是返回**当前 + 下一个** epoch 的种子(预取窗口),允许网络抖动; 建议每半个 epoch 轮询一次。租约帧有最小间隔限制(`LEASE_TOO_SOON`)。 - 会话撤销(`DELETE /v1/devices/{id}` 作废凭证、`closed_at`)与订阅到期 都会使下一次 lease 失败(`SESSION_CLOSED` / `SUBSCRIPTION_*`)。 注意:信令只派生熵、不接触通道明文——种子是服务端 master secret 对 `(sid, half, epoch)` 的 HMAC,与 E2E 语义(SR-40)兼容。 ## 4. 错误码 | HTTP | code | 含义 | |---|---|---| | 401 | `UNAUTHORIZED` / `CODE_INVALID` / `CODE_EXPIRED` / `REFRESH_INVALID` | 登录态问题 | | 402 | `SUBSCRIPTION_REQUIRED` / `SUBSCRIPTION_EXPIRED` | 未订阅/已过期(外网连接前必检) | | 403 | `DEVICE_QUOTA_EXCEEDED` / `DEVICE_NOT_OWNED` / `ACCOUNT_BANNED` | 配额/归属/封禁 | | 404 | `CRED_NOT_FOUND` | 凭证不存在或已作废 | | 401 | `CRED_INVALID` | 凭证 token 无效/已作废 | | 409 | `DEVICE_OFFLINE` / `TOKEN_USED_OR_REVOKED` | 设备不在线 / token 已消费 | WS 帧内错误:`PEER_OFFLINE`、`PEER_NOT_ALLOWED`、`BAD_TYPE`、 `TOKEN_INVALID`、租约相关 `SESSION_NOT_FOUND` / `SESSION_NOT_ACTIVE` / `SESSION_CLOSED` / `SESSION_NOT_YOURS` / `SUBSCRIPTION_*` / `LEASE_TOO_SOON`; 被踢时收到 policy-violation 关闭帧(解绑 `kick` / 被新连接顶替 `superseded`)。 ## 5. 完整 curl 示例 ```bash BASE=https://fmn.idtree.cn CODE=$(curl -s -X POST $BASE/v1/auth/code -d '{"email":"you@example.com"}' \ -H 'Content-Type: application/json' | jq -r .dev_code) TOKEN=$(curl -s -X POST $BASE/v1/auth/verify -H 'Content-Type: application/json' \ -d "{\"email\":\"you@example.com\",\"code\":\"$CODE\"}" | jq -r .access_token) # 签发席位凭证(占一个配额) CREDRESP=$(curl -s -X POST $BASE/v1/creds -H "Authorization: Bearer $TOKEN" \ -H 'Content-Type: application/json' -d '{"name":"my-node"}') CREDID=$(echo "$CREDRESP" | jq -r .id) CRED=$(echo "$CREDRESP" | jq -r .token) # 保存好,只返回一次 # 设备持凭证上线(常驻进程;"登录信令"就是客户端的这个开关) ./bin/wsclient "wss://fmn.idtree.cn/v1/ws?role=device&cred=$CRED&name=my-node" # 控制端申请会话 token + 发信令 curl -X POST $BASE/v1/session/token -H "Authorization: Bearer $TOKEN" \ -d "{\"device_id\":\"$CREDID\"}" ./bin/wsclient "wss://fmn.idtree.cn/v1/ws?role=client&token=$TOKEN" \ "{\"type\":\"signal\",\"to\":\"$CREDID\",\"payload\":{\"sdp\":\"...\"}}" # 作废凭证(释放席位 + 踢在线连接) curl -X DELETE $BASE/v1/devices/$CREDID -H "Authorization: Bearer $TOKEN" ``` 仓库内 `cmd/wsclient` 是现成的最小可运行示例(Go,50 行),可直接对照实现。 ## 6. 支付与订阅(客户端"开通"入口) 客户端内不嵌支付 UI(D13):"开通/续费"按钮 → 调 `/v1/billing/checkout` 拿收银台 URL → 浏览器打开。URL 带一次性 `pay_session`(15min 有效),用户无需在网页重新登录。 ```bash POST /v1/billing/checkout # → {"url": "https://fmn.idtree.cn/checkout.html?pay_session=…"} ``` 如需在自有 UI 内嵌下单(不走收银台页): ```bash GET /v1/billing/skus # 套餐目录 + 当前可用渠道(wxpay/alipay) POST /v1/billing/orders # {"sku":"personal_yearly","provider":"wxpay"} # → {"order_id":"…","pay":{"kind":"qrcode","qr_png":"data:image/png;base64,…"}} GET /v1/billing/orders/{id} # 轮询 status:pending→paid ``` 到账的唯一权威是服务端(渠道回调/查单),客户端轮询订单状态即可; 订阅生效后 `/v1/me` 的 tier/valid_until 立即更新(服务端已失效缓存)。 ## 7. 注意事项 - **内网免费**:localhost/局域网直连 Harness 不经信令、不受订阅限制(D4)。 - **payload 必须 E2E 加密**:信令只做转发,不保证对内容保密义务之外的隔离(SR-40/SEC-S6)。 - **会话保持**:WS 每 30s 心跳;nginx 层 read timeout 3600s,超时无心跳会被断开。 - **单活语义**:同一凭证在两台机器同时登录 → 后登录的顶替先登录的(收到 `superseded` 关闭帧)。 - **重连**:WS 断开后用同凭证重连即可。 - **当前限制**:DEV_MODE 开启期间验证码随响应返回;TURN 中继、配对码绑定在 M2。