开源项目中的生产级实时通信:Orca Cloud Relay 的 WebSocket 中继设计

摘要

WebSocket 中继是许多需要"手机远程操控桌面"场景的基础设施,但开源社区中生产级的实现并不多见。Orca 是一个开源 IDE(MIT 协议),其 Cloud Relay 子系统实现了一套完整的 WebSocket 中继服务,支持手机 companion app 在任意网络环境下远程监控和操控桌面端的 AI 编码助手。本文基于 Orca 仓库中 cloud/ 目录的源码,深入分析其 Director/Cell 两层架构、双向帧转发与背压控制、端到端加密配对机制、断连恢复策略,以及跨版本线路兼容性保障。


1. 问题场景

Orca 桌面端可以同时运行多个 AI 编码助手。与之配对的手机 companion app(iOS / Android)允许用户在外出时查看 agent 的工作状态、发送 prompt、接收完成通知。

核心的通信难题是:手机如何连到用户的电脑?

  • 局域网直连:只在同一 Wi-Fi 下可用,离开局域网即失效;家庭网络和企业网络普遍存在 NAT,手机无法直接发现电脑的内网地址
  • P2P 打洞:STUN/TURN 方案实现复杂,对称型 NAT 下成功率不高
  • 云端中继:手机和电脑各自与云端服务器保持长连接,服务器在中间透明转发

Orca 采用第三种方案,同时支持局域网直连作为备选(local-only 模式)。Cloud Relay 是这套云端中继的完整实现。


2. 架构概览

2.1 独立的工程工作区

Cloud Relay 的代码位于 Orca 仓库的 cloud/ 目录,是一个独立的 pnpm workspace ,拥有自己的 package.json、pnpm-lock.yaml 和 tsconfig.base.json。所有构建和测试命令在 cloud/ 目录下执行,与桌面端 Electron 应用完全解耦。

perl 复制代码
cloud/
├── apps/
│   ├── relay/             # Relay 中继服务(Director 或 Cell)
│   ├── push/              # Push 推送网关(APNS + FCM)
│   ├── relay-fence-broker/ # 变更租约服务
│   └── relay-ops/         # 运维控制台
├── packages/
│   ├── relay-contract/    # 线路协议(桌面端/手机/relay 共用)
│   └── push-contract/     # 推送协议
└── infra/terraform/       # GCP 基础设施定义

2.2 三方参与者

scss 复制代码
┌──────────┐          ┌────────────────┐          ┌──────────┐
│  手机端  │──WebSocket──▶│  Cloud Relay  │◀──WebSocket──│  桌面端  │
│ (Phone)  │    E2EE     │ (Director+Cell)│    JWT      │  (Host)  │
└──────────┘          └────────────────┘          └──────────┘
                              │
                     ┌────────┴────────┐
                     │  Push Gateway   │
                     │  (APNS + FCM)   │
                     └─────────────────┘
  • 桌面端(Host):Orca 桌面应用,以 JWT 认证连接 relay,维护 control 和 data 两条 WebSocket 通道
  • 手机端(Phone):Orca companion app,以 invite token 或 resume credential 连接 relay
  • Relay:云端 WebSocket 中继,分为 Director 和 Cell 两个角色
  • Push Gateway:独立的推送通知服务,通过 APNS(iOS)和 FCM(Android)发送离线通知

3. Director/Cell 两层架构

3.1 角色分离

Relay 服务的同一份代码(apps/relay/)通过环境变量 ORCA_RELAY_ROLE 决定运行角色:

typescript 复制代码
ORCA_RELAY_ROLE: z.enum(['combined', 'director', 'cell']).default('combined')
  • Director:入口节点,负责"host 应该连到哪个 cell"。维护全局的 host → cell 分配表,但不承载实时转发流量
  • Cell:实际的数据平面节点,承载 host 和 phone 之间的 WebSocket 长连接和帧转发
  • Combined:本地开发模式,一个进程同时充当 Director 和 Cell

这种分离的动机是水平扩展与区域就近分配。Director 是轻量级的分配服务,可以全球单点部署;Cell 是重 I/O 的转发节点,可以在多个区域部署多个实例。

3.2 连接流程

手机首次配对的连接流程如下:

  1. 手机携带 invite token 连接到 Director 的 /v1/connect/<hostId> 端点
  2. Director 查找 invite,解析 host 的 cell 分配;若无分配则创建一个
  3. Director 向手机发送 relay-moved 消息,指示其转向分配的 cell:
typescript 复制代码
webSocket.send(JSON.stringify({
  type: 'relay-moved',
  v: 1,
  cellUrl: assignment.cellUrl,
  assignmentEpoch: assignment.assignmentEpoch
}))
webSocket.close(RELAY_CLOSE_CODE.DRAINING, 'connect to assigned cell')
  1. 手机断开与 Director 的连接,转向 Cell 建立持久会话
  2. Cell 验证 invite,建立 host-phone 之间的 splice(拼接转发)

桌面端的连接更简单:直接以 JWT Bearer token 连接到分配的 Cell 的 /v1/host/control 端点,Cell 通过 JWKS 验证 token 后建立 control 通道。

3.3 三条 WebSocket 通道

Cell 上维护三种 WebSocket 连接,各有不同的认证方式和用途:

路径 参与者 认证 用途
/v1/host/control 桌面 → Cell JWT Bearer 控制通道,桌面保持长连接
/v1/host/data/<connId> 桌面 → Cell connTicket 数据通道,按 phone 连接建立
/v1/connect/<hostId> 手机 → Cell invite / resume token 手机连接通道

Control 和 data 通道的分离允许桌面端在 phone 连接/断开时不必重建 control 通道,减少了认证开销。


4. SpliceForwarder:双向帧转发与背压控制

Relay 的核心职责是在 phone 和 host 之间透明转发 WebSocket 帧。这个转发器实现在 cloud/apps/relay/src/splice-forwarder.ts 中的 wireSplice() 函数。

4.1 直传快路径

当目标 socket 可写且缓冲区低于阈值时,帧直接转发,不经过队列:

typescript 复制代码
source.on('message', (data, binary) => {
  if (
    queue.length === 0 &&
    target.readyState === target.OPEN &&
    target.bufferedAmount <= RELAY_ADMISSION_BUDGETS.spliceHighWaterBytes
  ) {
    target.send(data, { binary })
    input.onForwardedBytes?.(bytes)
    return
  }
  // 进入排队路径...
})

4.2 排队与背压

当目标 socket 的 bufferedAmount 超过 high water mark(256KB)时,帧进入内存队列,同时暂停源 socket 的读取(TCP 级背压):

typescript 复制代码
queue.push({ data, binary, bytes })
queuedBytes += bytes
wedgedSince ??= Date.now()
transport(source)?.pause()   // TCP 级背压

刷新定时器每 25ms 尝试排空队列,当 bufferedAmount 降至 low water mark(64KB)以下时恢复源 socket 的读取:

typescript 复制代码
while (
  queue.length > 0 &&
  target.bufferedAmount <= RELAY_ADMISSION_BUDGETS.spliceLowWaterBytes
) {
  const frame = queue.shift()!
  target.send(frame.data, { binary: frame.binary })
}
if (queue.length === 0) {
  transport(source)?.resume()
}

4.3 三层过载保护

  1. Per-splice 队列硬上限(~8.25MB):单个 splice 的排队字节超过此值时立即断开
typescript 复制代码
if (queuedBytes + bytes > RELAY_ADMISSION_BUDGETS.spliceHardQueuedBytes) {
  close(RELAY_CLOSE_CODE.LIMIT_EXCEEDED, 'relay queue limit exceeded', 'queue-limit')
}
  1. 进程级全局字节预算 (64MB):ProcessQueuedByteBudget 跟踪所有 splice 的累计排队字节,防止整个进程 OOM
typescript 复制代码
export class ProcessQueuedByteBudget {
  reserve(bytes: number): boolean {
    if (this.queued + bytes > RELAY_ADMISSION_BUDGETS.maxProcessQueuedBytes) return false
    this.queued += bytes
    return true
  }
}
  1. 楔死检测(10 秒超时):如果排队状态持续 10 秒无法排空,判定链路楔死,强制断开
typescript 复制代码
if (wedgedSince !== null &&
    Date.now() - wedgedSince >= RELAY_ADMISSION_BUDGETS.spliceWedgedTimeoutMs) {
  close(RELAY_CLOSE_CODE.LIMIT_EXCEEDED, 'wedged relay link', 'wedged')
}

4.4 TCP Nagle 优化

所有 WebSocket 连接在建立时关闭 Nagle 算法(setNoDelay(true)),减少小帧的传输延迟。这对交互式的 agent 状态更新尤为重要。


5. 连接准入与 DDoS 防护

Relay 实现了多层准入控制,定义在 cloud/packages/relay-contract/src/admission-budgets.ts 中:

typescript 复制代码
export const RELAY_ADMISSION_BUDGETS = {
  cloudRunConcurrency: 900,          // Cloud Run 并发上限
  maxPreAuthConnections: 45,          // 全局未认证连接上限
  maxPreAuthPerSource: 4,             // 单 IP 未认证连接上限
  maxPreAuthAttemptsPerSourcePerMinute: 30,  // 单 IP 每分钟尝试次数
  reservedHostControls: 100,          // 为 host control 通道预留的连接数
  reservedHostDataSockets: 150,       // 为 host data 通道预留的连接数
  maxProcessQueuedBytes: 64 * 1024 * 1024,   // 进程级排队字节预算
  // ...
}

关键的设计决策:

  • Pre-auth 阶段单独限流:WebSocket 升级完成后、第一帧认证前的连接被单独计数。这防止了攻击者通过大量建立连接但不发送认证帧来耗尽资源
  • Per-source 限制 :按源 IP(从 X-Forwarded-For 取倒数第二跳)限制,防止单一来源的洪泛
  • Host control 通道预留:即使 cell 的连接数已满,也为 host 的 control rebind(重连)预留空间。这确保桌面端在手机端大量重连时不会被挤出
  • LRU 清理:per-source 的计数器 map 限制在 4096 条,超出时清理最旧条目,防止源 IP churn 本身成为内存攻击

协议限制定义在 protocol-limits.ts 中:

typescript 复制代码
export const RELAY_PROTOCOL_LIMITS = {
  firstFrameDeadlineMs: 2_000,     // 第一帧必须在 2 秒内到达
  maxFrameBytes: 8 * 1024 * 1024,  // 单帧最大 8MB
  inviteTtlMs: 10 * 60 * 1000,     // invite token 10 分钟过期
  inviteMaxAttempts: 5,             // invite 最多 5 次尝试
  resumeTtlMs: 30 * 24 * 60 * 60 * 1000,  // resume token 30 天过期
  controlPingIntervalMs: 15_000,    // control 通道 15 秒 ping
  controlSilenceTimeoutMs: 75_000,  // 75 秒无响应判定断开
}

maxFrameBytes 设为 8MB 的原因在注释中说明:桌面端的 worktree catalog 响应在大型工作区中已经超过 1MB,一个过小的帧限制会在每次重连时杀死会话。


6. 端到端加密与配对

6.1 配对流程

配对的入口是桌面端生成一个 QR 码,手机扫码完成配对。QR 码编码为 orca://pair?code=<base64url> 格式的深度链接。

PairingOffer(v2)的结构定义在 src/shared/mobile-relay-pairing-offer.ts:

typescript 复制代码
export const PairingOfferSchema = z.object({
  v: z.literal(PAIRING_OFFER_VERSION),       // 版本号 = 2
  endpoint: z.string(),                       // 桌面端可达地址
  deviceToken: z.string(),                    // 设备标识
  publicKeyB64: z.string(),                   // Curve25519 公钥(Base64)
  pairedDeviceId: z.string().optional(),      // 已配对设备 ID
  scope: z.enum(['mobile', 'runtime']).optional(),
  relay: z.object({                           // 可选的 relay 信息
    directorUrl: z.string(),                  // Director 地址
    cellUrl: z.string(),                      // Cell 地址
    relayHostId: z.string(),                  // Relay 上的 host 标识
    inviteToken: z.string(),                  // 一次性 invite token
    inviteExpiresAt: z.number(),              // 过期时间
    e2eeFraming: z.literal(2),                // E2EE 帧版本
  }).optional()
})

6.2 两种连接模式

src/shared/mobile-pairing-connection-mode.ts 定义了两种模式:

  • automatic(Anywhere):通过 cloud relay 连接,需要桌面端登录。适用于任何网络环境
  • local-only:局域网直连,不经 relay。不需要登录,但要求手机和电脑在同一网络

当用户选择 Anywhere 模式但桌面端未登录时,自动降级为 local-only:

typescript 复制代码
export function effectiveMobilePairingConnectionMode(args: {
  preferred: MobilePairingConnectionMode
  signedIn: boolean
}): MobilePairingConnectionMode {
  if (args.preferred === 'automatic' && !args.signedIn) {
    return 'local-only'
  }
  return args.preferred
}

6.3 E2EE 保障

所有通过 relay 转发的消息使用 E2EE v2 帧格式加密。加密密钥在配对时通过 QR 码中的 Curve25519 公钥协商。Relay 服务器看不到消息内容,只做透明转发。

Relay offer 中对公钥有额外的校验------必须是规范的 32 字节 Base64,因为 relayHostId 是从公钥字节导出的:

typescript 复制代码
.superRefine((offer, ctx) => {
  if (offer.relay && !isCanonicalBase64Key(offer.publicKeyB64)) {
    ctx.addIssue({
      code: 'custom',
      path: ['publicKeyB64'],
      message: 'Relay offers require a canonical 32-byte public key'
    })
  }
})

7. 断连恢复策略

7.1 Close Code 体系

Relay 定义了六个自定义 WebSocket close code,每个对应明确的语义和恢复策略:

typescript 复制代码
export const RELAY_CLOSE_CODE = {
  BAD_OUTER_CREDENTIAL: 4401,  // 凭证无效
  HOST_OFFLINE: 4404,          // 桌面端不在线
  PEER_DROPPED: 4408,          // 对端连接断开
  WRONG_CELL: 4409,            // 连到了错误的 cell
  LIMIT_EXCEEDED: 4429,        // 超过限制
  DRAINING: 4503               // Cell 正在排空(维护/迁移)
}

7.2 恢复策略映射

src/shared/mobile-relay-close-codes.ts 将每种 close code 映射到精确的恢复动作:

Close Code 恢复策略 说明
4401 BAD_OUTER_CREDENTIAL disable-relay-credential 禁用 relay 凭证,直连不受影响
4404 HOST_OFFLINE retry-after-host-offline 带 full jitter 的退避重试
4408 PEER_DROPPED reconnect-fresh-e2ee 重新建立 E2EE 会话
4409 WRONG_CELL(phone invite) resolve-invite-through-director-ws 通过 Director 的 WebSocket 重新解析,要求严格更新的 epoch
4409 WRONG_CELL(phone resume) resolve-resume-through-director-post 通过 Director 的 HTTP POST 解析
4429 LIMIT_EXCEEDED backoff 带 full jitter 的退避
4503 DRAINING resolve-configured-director 重新从 Director 获取分配

每种恢复策略都标注了是否需要 full jitter(随机退避),以避免大量客户端同时重连造成的"惊群效应"。

7.3 凭证生命周期

首次配对使用 invite token(一次性,10 分钟过期,最多 5 次尝试)。成功后,手机获得一个 resume token,用于后续重连:

  • Resume token 有效期 30 天
  • 支持版本号和 grace period(新旧 token 短期内并存)
  • 完整的 install → resume → confirm 生命周期

凭证合约定义在 src/shared/mobile-relay-credential-contract.ts,包含 resume token hash、版本号、过期时间等字段,均通过 zod schema 严格校验。


8. Push 通知网关

当手机不在前台(app 被杀、锁屏)时,agent 完成任务或需要用户关注的事件通过推送通知触达。

Push Gateway 是一个独立的 Cloud Run 服务 (cloud/apps/push/),与 relay 共享仓库但独立部署。

8.1 认证机制

手机不直接与 Push Gateway 通信。认证流程是:

  1. 桌面端使用与 relay 相同的 X25519 密钥,向 Push Gateway 回答加密 challenge
  2. 成功后获得 24 小时的 session
  3. 桌面端将手机的原生推送 token(APNS / FCM)注册到 Push Gateway
  4. 当需要推送时,桌面端向 Push Gateway 发请求,Gateway 向手机推送

8.2 推送简化

Agent 在桌面端有四种状态(working / blocked / waiting / done),但推送到手机时简化为两种:

typescript 复制代码
export const MOBILE_PUSH_AGENT_STATES = ['needs-input', 'finished'] as const

推送来源有三类:

typescript 复制代码
export const MOBILE_PUSH_SOURCES = ['agent-task-complete', 'terminal-bell', 'plugin'] as const

8.3 推送过滤

用户可以配置推送行为:

typescript 复制代码
export type MobilePushFilter = {
  onlyWhenDesktopAway?: boolean  // 仅当桌面端不活跃时推送
  sound?: boolean                // 是否播放提示音
}

Gateway 的日志严格限制在聚合计数器层面:token、通知标题、通知正文和完整的 host 指纹永远不会出现在日志中。


9. 线路兼容性保障

9.1 问题

桌面端和 relay 服务是独立更新的。用户可能在新版桌面端配对一台旧版 relay 服务的 cell,或反之。混合版本是常态,不是异常。

9.2 四条规则

docs/reference/remote-wire-compatibility.md 定义了严格的协议演进规则:

Rule 1------新增可选 JSON 字段是安全的。 所有 JSON payload 的解码器都忽略未知键(zod .strip()),旧版 peer 简单地不读它。

Rule 2------新增 stream opcode 不安全,必须协商。 解码器对未知 opcode 返回 null 并静默丢弃,发送方永远不会知道功能失效。新 opcode 必须在握手中声明,对端确认后才能发送。

Rule 3------改变 host 发布的内容,即使帧格式不变,也是破坏性变更。 因为客户端对帧内容有反应。停止填充的字段、改变含义的值、停止发送的帧都属于此类。

Rule 4------枚举值集合是 wire surface,未知值必须降级而非拒绝。 一个严格的 z.enum 会导致新增值时整个响应被拒绝。应使用 openEnum,将未知值降级到一个安全的默认值。

9.3 自动化验证

项目中有跨版本兼容性测试,将当前代码与最新 release tag 的代码双向配对,覆盖终端流(terminal stream)和结构化 agent session 两个通信表面。测试会在帧被对端解码器拒绝(Rule 2)、帧序列变化(Rule 3)时失败。


10. 部署模型

10.1 生产环境

Orca 官方在 Google Cloud 上运营 relay 服务:

  • 计算:Cloud Run(容器化部署)
  • 数据库:Cloud SQL(PostgreSQL)
  • 基础设施 :Terraform 管理(cloud/infra/terraform/)
  • CI/CD:25 个 GitHub Actions workflow 管理部署、扩容、区域迁移、监控

所有 workflow 都有 vars.ORCA_CLOUD_OPERATIONS_ENABLED == 'true' 的门控,在开源仓库中默认关闭。

10.2 本地开发

本地开发使用 SQLite 替代 PostgreSQL,无需外部依赖:

bash 复制代码
cd cloud
pnpm install
pnpm build
pnpm test

需要 PostgreSQL 集成测试时:

bash 复制代码
docker run --rm -d --name orca-relay-pg \
  -e POSTGRES_HOST_AUTH_METHOD=trust \
  -e POSTGRES_DB=orca_relay_test \
  -p 55440:5432 postgres:16-alpine

ORCA_RELAY_TEST_POSTGRES_URL=postgres://postgres@127.0.0.1:55440/orca_relay_test \
  pnpm --filter @orca-cloud/relay test

唯一的必需配置是 ORCA_RELAY_ASSIGNMENT_SIGNING_KEY(≥32 字节),其余环境变量有本地默认值。

10.3 普通用户

普通用户无需自行部署 relay。Orca 桌面端默认连接官方运营的 relay 服务。选择 "Local Only" 模式时则完全不经 relay,走局域网直连。


11. 总结与可复用的设计模式

Orca Cloud Relay 是一个面向特定场景的 WebSocket 中继实现,但其中几个设计模式具有较高的通用性:

设计模式 适用场景
Director/Cell 分层 任何需要全局分配 + 区域就近转发的实时通信系统
三层背压控制(per-splice 队列 + 进程级预算 + 楔死检测) WebSocket / TCP 代理、消息队列中间件
Pre-auth 独立计数 + per-source 限流 面向公网的任何长连接服务
Close code → 恢复策略的确定性映射 需要可靠重连的移动端 / IoT 场景
线路兼容性四规则 客户端与服务端独立升级的任何 RPC / 流式协议
E2EE 透明中继 对中间人不信任的中继场景

值得注意的是,relay-contract 包(cloud/packages/relay-contract/)将协议常量、close code、准入预算、splice 状态机等提取为桌面端、手机端和 relay 服务端的共享依赖。这意味着三方对协议的理解在编译期就是统一的,减少了手动同步的风险。

对于正在设计类似"手机远程操控桌面"或"IoT 设备云端中继"系统的工程团队,Orca Cloud Relay 的完整实现提供了一个可供参考的生产级样本。


本文基于 Orca 开源仓库(MIT 协议)v1.4.197 版本的 cloud/ 目录源码分析。

相关推荐
IT_陈寒1 小时前
当心!你以为简单的 JavaScript 数组操作其实坑不少
前端·人工智能·后端
阿里云大数据AI技术1 小时前
数据·智能·进化:Agent 时代的数据与 AI 基础设施
大数据·人工智能·agent
桃西西呀1 小时前
给 Agent装了个门卫Jev拦危险操作,它说 92% 安全,我就放行了
人工智能·llm·agent
代码方舟1 小时前
PHP数据工程:利用天远天远入职背调报告优化自由职业者平台合规体验
人工智能
甲维斯1 小时前
“国模一哥”小米MiMo2.6Pro测完了!
人工智能
IamZJT_1 小时前
04|接上历史工单和知识库:让 Agent 有依据地查相似问题
人工智能
吴佳浩1 小时前
单体 Agent 的天花板:为什么复杂任务必然走向 Multi-Agent?
人工智能·agent·ai编程