Skip to content

Android Remote Operate — Plan A + B(WebRTC P2P + STUN/TURN)设计文档

状态: 🟡 基础设施落地 v5.0.3.51 (2026-05-14),WebRTC 端到端实测待补 关联: Plan C — Signaling Forward(前置) 实现 commits: e9f9d6275 (signaling-relay-service) + af11daa6e (RelayClient WebRTC dispatch + iceServers push)

1. Plan C 之后

Plan C 让低频命令(Ping / 系统状态 / 一次性查询)跨 LAN+WAN 跑通。命令走信令转发,p99 延迟 100-400ms 实测。但有两个硬约束:

  • 吞吐:中继带宽全平台共享 — 流式 token / 文件 / 视频不能走
  • 隐私:公网 wss 之外,中继服务器仍看到 payload 明文(已 TLS 但中间人可见)

为这两个场景,需要 真 WebRTC P2P DataChannel(端到端加密 + 直连带宽)。

2. 三段位完整图景

                      ┌─── Plan C (signaling forward) ───┐
                      │  低频命令 / 短消息             │
                      ├─── Plan A (WebRTC DC)─────────────┤
                      │  高吞吐 / 流式 token / 文件     │
                      ├─── Plan B (STUN/TURN)─────────────┤
                      │  NAT 穿透兜底                    │
                      └───────────────────────────────────┘

                  根据场景在客户端 fall-through:
                  优先尝试 DataChannel → 失败 fallback 信令 forward

3. Plan A — WebRTC Signaling 透传中继

问题:WebRTC 建连依赖三种 signaling 消息(offer / answer / ice-candidate),在 LAN 由 desktop SignalingServer 转发,WAN 下没人转发

改动:

3.1 中继 server (backend/signaling-relay-service/server.js)

js
// before: 只转发 type === "message"
case "message": handleMessage(ws, msg); break;

// after: WebRTC 三件套也走 forward 路径,与 LAN signaling-server 对齐
case "message":
case "offer":
case "answer":
case "ice-candidate":
case "ice-candidates":
case "peer-status":
  handleMessage(ws, msg);
  break;

handleMessage 注入 from 字段(取 ws 已 register 的 peerId),与 LAN signaling-handlers 行为一致 — 桌面 handleOffer 内部用 socket.peerId || message.from 取对端,relay 路径必须显式补 from。

3.2 桌面 main (desktop-app-vue/src/main/index.js)

startRelayClient.onMessage 简化为统一 dispatch — 不再按 type 自己 case:

js
onMessage: (msg) => {
  // pair-ack 还是单独路由(写 sessionState)
  if (msg.payload?.type === "pair-ack" || msg.type === "pair-ack") {
    recordPairAck(msg.payload || msg);
  }
  // 其余(command:request / offer / answer / ice)交给 mobile-bridge 同款 dispatcher
  this.mobileBridge?.handleSignalingMessage?.(msg);
}

mobileBridge.handleSignalingMessage(msg) 本来就按 msg.type 分发到 handleOffer / handleAnswer / handleICECandidate / handleP2PMessage — LAN 路径与 relay 路径完全等价。

3.3 验证

场景路径状态
Mobile 发 offer 到中继 → desktop 收✅ relay forward → handleOffer 触发 setRemoteDescription / createAnswer协议通了,端到端 P2P DC 建连未实测
Mobile 发 ICE candidate → desktop✅ relay forward → handleICECandidate peerConnection.addIceCandidate同上
Desktop 发 answer → mobile✅ 桌面 sendToMobile 不可达 LAN dataChannel 时双发 (LAN + relay)Plan C 同款,已实测

实际 P2P DataChannel 是否真打通依赖 Plan B 的 STUN/TURN(家用 NAT 直接 hairpin / symmetric NAT 没有 STUN/TURN 通常不通)。

4. Plan B — STUN/TURN 部署

部署文档另见 signaling-relay-and-turn-deploy。要点:

4.1 coturn 容器(/opt/cc-turn/ on VPS 47.111.5.128)

  • 镜像 coturn/coturn:4.6,host network(UDP relay 端口范围 49152-65535)
  • 监听 0.0.0.0:3478 UDP+TCP + 5349 TLS
  • 域名 turn.chainlesschain.com(A 记录 → 47.111.5.128,Let's Encrypt 证书 acme.sh 续)
  • 鉴权 use-auth-secret(time-limited credentials,HMAC-SHA1)

4.2 凭证签发(桌面端)

desktop-pair-handlers.jssignIceCredentials(userId)

js
const username = `${expiry}:${userId}`;        // <expiry-ts>:<user-id>
const credential = HMAC-SHA1(TURN_SECRET, username).toBase64();
return {
  iceServers: [
    { urls: [`stun:turn.chainlesschain.com:3478`] },
    {
      urls: [
        `turn:turn.chainlesschain.com:3478?transport=udp`,
        `turn:turn.chainlesschain.com:3478?transport=tcp`,
        `turns:turn.chainlesschain.com:5349`,
      ],
      username,
      credential,
    },
  ],
  expiry,
};
  • TTL = 24h(足够单设备使用,过期前用户多半已重新配对)
  • env CC_TURN_SECRET 强制必填 — 源码里没有 fallback,绝不硬编码 secret(任何 fork 也无法签出生效凭证)
  • 缺 env 时降级 STUN-only(LAN + 双 NAT 友好场景仍能 WebRTC,跨 NAT 不可用)

4.3 客户端接入(Android)

iceServers 不塞 QR(QR payload 650+ 字符 + 高纠错 → 280px 扫描识别率暴跌实测,2026-05-14 阻塞 30s 扫不出)。改成扫码后异步推送:

  • 桌面 pair-ack matchedpushIceServersToMobile(ackPayload) —— LAN signaling + 公网中继双发 {type:"chainlesschain:ice:config", payload:{pcPeerId, iceServers, iceExpiry}}
  • 手机 WebRTCClient.setOnForwardedMessageReceived 拦截该 type → 持久化到 PairedDesktopsStore.iceServersJson / iceExpiry
  • 同步加 SignalingRpcClient 监听备份(race-tolerant — 两侧 listener 都 upsert 同一 store)

WebRTCClient 创建 PeerConnection 时按 pcPeerId 从 store 查 iceServers,过期/缺失 fallback Google STUN:

kotlin
private fun resolveIceServersFor(pcPeerId: String): List<PeerConnection.IceServer> {
    val desktop = pairedDesktopsStore.devices.value.firstOrNull { it.pcPeerId == pcPeerId }
    val json = desktop?.iceServersJson ?: return fallbackIceServers
    val now = System.currentTimeMillis() / 1000
    if (desktop.iceExpiry > 0 && now > desktop.iceExpiry) return fallbackIceServers
    return parseIceServersJson(json).ifEmpty { fallbackIceServers }
}

5. 数据流(Plan A+B 配合)

┌──── 配对阶段 ────────────────────────────────────────┐
│ 1. Phone scans desktop QR (含 signalingUrl + relayUrl)
│ 2. Phone sends pair-ack via signaling
│ 3. Desktop matches → persist SQLite + push iceServers
│ 4. Phone receives ice:config → PairedDesktopsStore upsert
└──────────────────────────────────────────────────────┘

┌──── WebRTC 建连阶段 ─────────────────────────────────┐
│ 5. Phone opens RemoteControl → WebRTCClient.connect(pcPeerId)
│ 6. WebRTCClient.createPeerConnection 用 stored iceServers (含 TURN)
│ 7. createOffer → signaling forward (LAN or relay) → desktop
│ 8. Desktop handleOffer → setRemoteDescription → createAnswer → forward back
│ 9. ICE candidate exchange via forward
│ 10. STUN/TURN 打通 → DataChannel "open"
└──────────────────────────────────────────────────────┘

┌──── 命令路径 ────────────────────────────────────────┐
│ Low-freq: SignalingRpcClient → relay forward (Plan C)
│ Hi-throughput: DataChannel direct (Plan A)
│ NAT-hard: TURN relay (Plan B; DataChannel through relay)
└──────────────────────────────────────────────────────┘

6. 阿里云安全组要求

协议端口用途
UDP3478STUN/TURN UDP(首选)
TCP3478STUN/TURN TCP fallback
TCP5349TURNS over TLS
UDP49152-65535TURN relay 范围

未开端口 → coturn 容器跑但外部不可达 → WebRTC ICE 收集失败 → DC 建不起来。

7. 已知限制 / 待办

现状后续
iceServers TTL 过期24h,过期后 fallback Google STUN,跨 NAT 不通后台 refresh:mobile 检测 expiry 临近 → 经信令请求新凭证
WebRTC P2P 端到端真机实测infra 已通,未跨 NAT 真测v1.4 GA 前做一次:phone 4G + desktop home WiFi 完整 file-transfer 流
Signal Protocol E2EEPlan C 当前明文经中继;e2ee 模块独立但未挂等 Plan A DC 通了再考虑(DC 已是直连,TLS 之外加 Signal 收益边际)
Coturn 凭证管理 API当前 secret 直接签;TTL 短 ok长期需 backend HMAC 签发 API(鉴权用户身份后才签)

8. 实现 commits

Commit内容
e9f9d6275feat(signaling-relay): public WebSocket signaling relay (#21 plan A+C infra)
af11daa6efeat(remote-operate): plan A+B — RelayClient WebRTC dispatch + iceServers push

9. 部署位置

  • 中继: /opt/cc-signaling-relay/ on 47.111.5.128 → wss://signaling.chainlesschain.com
  • coturn: /opt/cc-turn/ on 47.111.5.128 → turn.chainlesschain.com(3478 UDP/TCP + 5349 TLS)
  • nginx vhost + cert via acme.sh @gitee(GitHub 大陆访问受限)

基于 MIT 许可发布