第 6 章

Gateway 控制平面:WebSocket 协议、三步握手与 Session 解析算法

第六章:Gateway 控制平面:WebSocket 协议、三步握手与 Session 解析算法

6.1 为什么 Gateway 绑定 localhost

在分布式系统中,网络暴露面(attack surface)是安全设计的第一道防线。OpenClaw Gateway 默认将 WebSocket 服务绑定到 localhost:18789,而非 0.0.0.0:18789。这个看似微小的配置决策,背后蕴含着深刻的安全哲学。

6.1.1 localhost 绑定的安全基础

绑定到 0.0.0.0 意味着服务监听所有网络接口,包括公网 IP、VPN 接口、容器网桥等。任何能够访问主机网络的进程或远程节点都可以尝试建立连接。对于一个持有 AI Agent 控制权的服务来说,这种暴露是不可接受的。

绑定到 127.0.0.1(localhost)则将连接来源限定为同一主机上的进程。外部网络流量在操作系统 TCP/IP 栈层面就被丢弃,不会到达 Gateway 进程。这提供了以下几层保护:

网络隔离层级(由外到内):

[外部网络]  ──×──→  [路由器/防火墙]
                          │
[VPN/容器网络] ──×──→  [网络接口]
                          │
[局域网内其他主机] ──×──→ [OS TCP/IP 栈]
                          │
[同主机其他进程] ──────→ [127.0.0.1:18789]  ← Gateway 实际监听

6.1.2 信任边界的明确划分

localhost 绑定将信任边界清晰地划定在操作系统进程隔离层。只有以下实体可以合法连接:

这种设计将网络暴露的责任转移给管理员的代理配置,而非默认开放。管理员如需向外暴露 Gateway,必须主动配置反向代理并承担相应安全责任,这是"默认安全"(secure by default)原则的体现。

6.1.3 dmPolicy 与网络层的协同防御

即使 Gateway 被反向代理暴露到公网,dmPolicy(设备管理策略)提供第二道防线。这种纵深防御(defense in depth)确保单一配置错误不会导致系统完全沦陷。


6.2 WebSocket 三种消息格式

Gateway 协议定义了三种消息类型,覆盖了客户端-服务端交互的所有场景。所有消息均为 UTF-8 编码的 JSON 文本帧。

6.2.1 请求消息(req)

客户端发起的请求消息格式:

{
  "type": "req",
  "id": "7f3a9b2c-4e1d-4f8a-b6e5-2c8d9f0a1b3c",
  "method": "sessions.list",
  "params": {
    "filter": "active",
    "limit": 20
  }
}

字段说明:

字段 类型 说明
type "req" 固定标识符,标识这是一条请求消息
id UUID v4 字符串 请求唯一标识符,服务端响应时回显该 ID
method 字符串 RPC 方法名,使用点分层次命名(namespace.action)
params 对象 方法参数,可为空对象 {}

常见 method 列表:

sessions.list          - 列出所有会话
sessions.create        - 创建新会话
sessions.get           - 获取单个会话详情
sessions.terminate     - 终止会话
messages.send          - 向会话发送消息
agent.interrupt        - 中断当前 Agent 执行
config.get             - 读取配置项
config.set             - 写入配置项

6.2.2 响应消息(res)

服务端对请求的响应:

成功响应:

{
  "type": "res",
  "id": "7f3a9b2c-4e1d-4f8a-b6e5-2c8d9f0a1b3c",
  "ok": true,
  "payload": {
    "sessions": [
      {
        "id": "sess_01HXYZ",
        "contextKey": "default",
        "agentId": "agent_claude",
        "status": "idle",
        "createdAt": "2024-01-15T08:30:00Z"
      }
    ],
    "total": 1
  }
}

错误响应:

{
  "type": "res",
  "id": "7f3a9b2c-4e1d-4f8a-b6e5-2c8d9f0a1b3c",
  "ok": false,
  "error": {
    "code": "SESSION_NOT_FOUND",
    "message": "Session sess_NOTEXIST does not exist",
    "details": {
      "sessionId": "sess_NOTEXIST"
    }
  }
}
字段 类型 说明
type "res" 固定标识符
id UUID v4 与对应请求的 id 相同
ok boolean true 表示成功,false 表示失败
payload 对象 成功时的响应数据(ok=true 时存在)
error 对象 错误信息(ok=false 时存在)

6.2.3 事件消息(event)

服务端主动推送的事件,不对应任何请求:

{
  "type": "event",
  "event": "session.message",
  "payload": {
    "sessionId": "sess_01HXYZ",
    "messageId": "msg_001",
    "role": "assistant",
    "content": [
      {
        "type": "text",
        "text": "我已经分析了您的代码,发现以下三个问题..."
      }
    ]
  },
  "seq": 42,
  "stateVersion": 7
}
字段 类型 说明
type "event" 固定标识符
event 字符串 事件类型名称
payload 对象 事件数据
seq 整数 全局递增序列号,客户端可检测消息丢失
stateVersion 整数 Gateway 状态版本号,支持断线重连后的状态同步

seqstateVersion 的区别:seq 是消息发送顺序的单调递增计数器;stateVersion 是 Gateway 内部状态发生变更的次数,用于判断客户端持有的缓存是否仍然有效。

常见事件类型:

session.message         - Agent 产生新消息(文本/工具调用)
session.message.delta   - 流式消息增量
session.status.changed  - 会话状态变更(idle/running/error)
session.tool.started    - 工具开始执行
session.tool.completed  - 工具执行完成
gateway.reload          - Gateway 配置热重载通知

6.3 三步握手序列图

连接建立不是简单的 WebSocket 升级,而是一个明确的三步认证握手过程。

客户端                                        Gateway
  │                                              │
  │  ─── HTTP Upgrade (WebSocket) ──────────→   │
  │                                              │
  │  ←── 101 Switching Protocols ────────────   │
  │                                              │
  │         ┌──── Step 1: 挑战 ────┐             │
  │         │                      │             │
  │  ←── {"type":"challenge",      │             │
  │         "nonce":"a3f8...",      │             │
  │         "timestamp":1705123456} │             │
  │         └──────────────────────┘             │
  │                                              │
  │         ┌──── Step 2: 认证请求 ─┐            │
  │         │                       │            │
  │  ─── {"type":"connect",         │            │
  │         "role":"client",        │            │
  │         "scopes":["read","write"],            │
  │         "credentials":{...},    │            │
  │         "signedNonce":"b9c2..."}│            │
  │  ───────────────────────────────────────→   │
  │         └───────────────────────┘           │
  │                                              │
  │         ┌──── Step 3: 确认 ────┐             │
  │         │                      │             │
  │  ←── {"type":"hello-ok",       │             │
  │         "protocolVersion":"1.2",│             │
  │         "features":["streaming",│             │
  │           "sub-agents"],        │             │
  │         "payloadPolicy":{...}}  │             │
  │         └──────────────────────┘             │
  │                                              │
  │  ════ 正式通信阶段 ══════════════════════   │

6.3.1 Step 1:Nonce 挑战

WebSocket 握手完成后,Gateway 立即(不等待客户端发送任何数据)推送挑战消息:

{
  "type": "challenge",
  "nonce": "a3f8b9c2d1e4f5a6b7c8d9e0f1a2b3c4",
  "timestamp": 1705123456789
}

时间窗口限制防止重放攻击(replay attack):即使攻击者截获了旧的认证消息,也无法在超时后重用。

6.3.2 Step 2:认证请求

客户端收到挑战后,发送 connect 消息:

{
  "type": "connect",
  "role": "client",
  "scopes": ["sessions.read", "sessions.write", "messages.send"],
  "credentials": {
    "method": "shared-secret",
    "token": "sk-openclaw-..."
  },
  "signedNonce": "HMAC-SHA256(token, nonce + timestamp)"
}

signedNonce 的计算方式:

signedNonce = base64url(HMAC-SHA256(
  key   = credentials.token,
  data  = nonce + ":" + timestamp
))

这种设计确保:

  1. 令牌本身不在网络上传输(防止中间人截获)
  2. 每次连接的签名值不同(防止重放攻击)
  3. 服务端可以验证客户端持有正确的密钥

6.3.3 Step 3:Hello-OK 确认

认证成功后,Gateway 发送握手完成消息:

{
  "type": "hello-ok",
  "protocolVersion": "1.2",
  "features": [
    "streaming",
    "sub-agents",
    "tool-interruption",
    "session-branching"
  ],
  "payloadPolicy": {
    "maxMessageSize": 4194304,
    "compressionEnabled": true,
    "streamingChunkSize": 1024
  },
  "sessionId": "conn_01HXYZ",
  "serverTime": 1705123457123
}

客户端应根据 features 列表决定启用哪些高级功能,确保向后兼容性。


6.4 四种认证路径

Gateway 支持四种认证方式,适用于不同的部署场景。

6.4.1 共享密钥(Shared Secret)

最简单的认证方式,适用于单用户或受信任环境:

{
  "credentials": {
    "method": "shared-secret",
    "token": "sk-openclaw-a1b2c3d4e5f6..."
  }
}

配置文件 config/gateway.yaml

auth:
  sharedSecret:
    enabled: true
    token: "${OPENCLAW_SECRET}"  # 从环境变量读取,避免明文存储
    minLength: 32                 # 强制最小长度

适用场景: 本地开发、单人使用、受信任的内网环境 安全警告: 令牌一旦泄露需要立即轮换,建议配合密钥管理系统(KMS)

6.4.2 身份代理(Identity Proxy)

通过受信任的反向代理传递身份信息:

{
  "credentials": {
    "method": "trusted-proxy",
    "proxyHeader": "X-Tailscale-User"
  }
}
客户端 ──→ [Tailscale/nginx] ──X-Tailscale-User: [email protected]──→ Gateway

配置:

auth:
  trustedProxy:
    enabled: true
    trustedNetworks:
      - "100.64.0.0/10"   # Tailscale CGNAT 范围
      - "127.0.0.1/32"
    userHeader: "X-Tailscale-User"
    groupHeader: "X-Tailscale-Groups"

适用场景: 企业内网、Tailscale 组网、SSO 集成 安全要求: 必须确保 Gateway 只接受来自受信任代理的连接,否则攻击者可伪造 Header

6.4.3 设备令牌(Device Token)

设备首次配对后获得长期令牌,后续连接复用该令牌:

{
  "credentials": {
    "method": "device-token",
    "deviceId": "dev_01HXYZ",
    "token": "dt-a1b2c3..."
  }
}

配对流程:

1. 设备发起配对请求 → Gateway 生成验证码(6位数字)
2. 管理员在控制台确认验证码
3. Gateway 颁发设备令牌,存储在 devices.json
4. 后续连接使用设备令牌,无需再次配对

适用场景: IoT 设备、无人值守的自动化节点、移动客户端 安全特性: 设备令牌可独立吊销,不影响其他设备

6.4.4 Bootstrap Token

用于初始化配置或紧急恢复的一次性高权限令牌:

# 只在配置文件中设置,不通过 API 暴露
auth:
  bootstrapToken:
    token: "${OPENCLAW_BOOTSTRAP}"
    allowedScopes:
      - "admin.config"
      - "devices.pair"
    expiresAt: "2024-02-01T00:00:00Z"  # 强制设置过期时间

适用场景: 首次部署、管理员密码重置、紧急访问 安全警告: 使用后应立即禁用或设置极短的过期时间


6.5 dmPolicy 四种模式

设备管理策略(Device Management Policy)控制哪些客户端可以连接 Gateway。

6.5.1 pairing(默认模式)

gateway:
  dmPolicy: pairing

工作流程:

新设备首次连接
       │
       ▼
Gateway 生成 6 位验证码
       │
       ▼
显示在 Gateway 控制台/日志
       │
       ▼
管理员手动确认 ──否──→ 拒绝连接
       │
      是
       ▼
设备加入白名单,颁发设备令牌

特点: 人工审核每台新设备,防止未授权设备接入 适用场景: 生产环境、多用户团队、安全要求较高的部署

6.5.2 allowlist(白名单模式)

gateway:
  dmPolicy: allowlist
  allowedDevices:
    - deviceId: "dev_laptop_alice"
      name: "Alice 的工作笔记本"
      publicKey: "ssh-ed25519 AAAA..."
    - deviceId: "dev_ci_runner"
      name: "CI/CD 构建机"
      publicKey: "ssh-ed25519 BBBB..."

只有明确列出的设备才能连接,未列出的设备直接拒绝,不生成验证码。

适用场景: 固定设备集合、预配置的企业环境

6.5.3 open(开放模式)

gateway:
  dmPolicy: open
  # 必须同时配置 allowlist 作为补充过滤
  requireValidCredentials: true

任何持有有效凭证的客户端都可以连接,不需要预先注册。

安全取舍: 便于开发测试,但失去了设备级别的访问控制。必须配合强凭证认证(共享密钥或身份代理)使用。

适用场景: 开发测试环境、一次性脚本工具

6.5.4 disabled(完全禁用)

gateway:
  dmPolicy: disabled

完全禁用设备管理功能,所有设备管理 API(配对、吊销、列举)均返回 FEATURE_DISABLED 错误。

适用场景: 完全依赖外部 IAM 系统(如 Tailscale ACL)进行访问控制的部署


6.6 Session Key 生成算法与 sessions.json 存储

6.6.1 Session Key 的三段结构

Session Key 是 OpenClaw 中唯一标识一个对话会话的复合键,格式为:

agent:<agentId>:<contextKey>

例如:

agent:claude-3-opus:workspace-alice
agent:gpt-4:project-backend
agent:claude-3-sonnet:default

三个组成部分:

部分 说明 示例
agent 固定前缀,标识这是一个 Agent 会话键 agent
agentId Agent 配置中定义的唯一 ID claude-3-opus
contextKey 区分同一 Agent 的不同上下文 workspace-alice

6.6.2 contextKey 的生成规则

contextKey 根据渠道来源自动生成:

// 伪代码:contextKey 生成逻辑
function generateContextKey(source) {
  switch (source.type) {
    case "discord-dm":
      return `dm:${source.userId}`;
    case "discord-channel":
      return `channel:${source.channelId}`;
    case "slack-thread":
      return `thread:${source.threadTs}`;
    case "api-explicit":
      return source.contextKey;  // 客户端显式指定
    default:
      return "default";
  }
}

6.6.3 sessions.json 存储格式

会话状态持久化在 data/sessions.json

{
  "version": 2,
  "sessions": {
    "agent:claude-3-opus:dm:user123": {
      "id": "sess_01HXYZ",
      "key": "agent:claude-3-opus:dm:user123",
      "agentId": "claude-3-opus",
      "contextKey": "dm:user123",
      "status": "idle",
      "createdAt": "2024-01-15T08:00:00Z",
      "lastActiveAt": "2024-01-15T09:30:00Z",
      "messageCount": 47,
      "transcriptPath": "data/transcripts/sess_01HXYZ.jsonl",
      "metadata": {
        "userId": "user123",
        "channelType": "discord-dm",
        "guildId": null
      }
    }
  },
  "updatedAt": "2024-01-15T09:30:00Z"
}

6.6.4 Session 查找算法

输入:agentId + source 信息
        │
        ▼
计算 contextKey(根据来源类型)
        │
        ▼
构造 Session Key = "agent:" + agentId + ":" + contextKey
        │
        ▼
在 sessions.json 中查找该 Key
        │
   ┌────┴────┐
找到       未找到
   │           │
   ▼           ▼
检查 freshness  创建新 Session
(见 ch10)    并持久化
   │
   ▼
返回现有 Session

6.7 Single-writer 模式:消除分布式一致性问题

6.7.1 问题的本质

在传统的分布式 AI 系统中,多个节点可能同时持有会话状态并尝试修改它:

[节点 A] ──读取 session 状态──→ [共享存储]
[节点 B] ──读取 session 状态──→ [共享存储]
[节点 A] ──写入(追加消息)──→ [共享存储]  ← 覆盖了节点 B 的视图!
[节点 B] ──写入(追加消息)──→ [共享存储]  ← 状态不一致!

这需要引入锁(distributed lock)、CAS(compare-and-swap)、版本向量等复杂机制。

6.7.2 Single-writer 的解决思路

OpenClaw Gateway 采用更简单的策略:每个 Session 只有一个 writer——Gateway 自身

[客户端 A]  ──req──→ [Gateway] ──写入──→ [sessions.json]
[客户端 B]  ──req──→ [Gateway] ──写入──→ [sessions.json]

Gateway 内部:Command Queue 的 Session Lane(串行)
               保证对同一 Session 的操作依次执行

所有对 Session 的修改都通过 Gateway 的 Session Lane(串行队列)串行化。客户端只是发送请求,Gateway 是唯一的状态写入者。

6.7.3 实际效果

问题 传统分布式方案 Single-writer 方案
并发写入冲突 需要分布式锁 不存在(串行化)
状态不一致 需要最终一致性协议 不存在(单一存储)
读写一致性 需要读写栅栏 天然保证
故障恢复 需要日志重放 sessions.json 即真相源
复杂度 极高 极低

代价: Gateway 成为单点。但对于 AI Agent 控制平面来说,这是合理的取舍——Gateway 通常与用户在同一台机器上运行,高可用需求由上层(如 Kubernetes Pod 重启)处理,而非 Gateway 内部分布式化。


6.8 完整连接示例

以下是一个完整的 WebSocket 交互示例,展示从连接到会话创建的全过程:

// 客户端实现示例(Node.js / TypeScript)
import { WebSocket } from "ws";
import { createHmac } from "crypto";

const ws = new WebSocket("ws://localhost:18789");

ws.on("message", async (data) => {
  const msg = JSON.parse(data.toString());
  
  if (msg.type === "challenge") {
    // Step 2: 发送认证请求
    const signedNonce = createHmac("sha256", process.env.OPENCLAW_SECRET!)
      .update(`${msg.nonce}:${msg.timestamp}`)
      .digest("base64url");
    
    ws.send(JSON.stringify({
      type: "connect",
      role: "client",
      scopes: ["sessions.read", "sessions.write", "messages.send"],
      credentials: {
        method: "shared-secret",
        token: process.env.OPENCLAW_SECRET!
      },
      signedNonce
    }));
  }
  
  if (msg.type === "hello-ok") {
    console.log(`协议版本: ${msg.protocolVersion}`);
    console.log(`支持特性: ${msg.features.join(", ")}`);
    
    // 发送第一个请求
    ws.send(JSON.stringify({
      type: "req",
      id: crypto.randomUUID(),
      method: "sessions.list",
      params: {}
    }));
  }
  
  if (msg.type === "res" && msg.ok) {
    console.log("会话列表:", msg.payload.sessions);
  }
  
  if (msg.type === "event" && msg.event === "session.message") {
    console.log(`[seq:${msg.seq}] Agent 消息:`, msg.payload.content);
  }
});

本章小结

本章深入探讨了 OpenClaw Gateway 控制平面的核心机制:

  1. localhost 绑定 是安全边界的第一道防线,将信任决策留给管理员的代理配置
  2. 三种消息格式(req/res/event)覆盖所有交互场景,seqstateVersion 支持可靠传输
  3. 三步握手(nonce 挑战→认证请求→hello-ok)结合 HMAC-SHA256 签名防止重放攻击
  4. 四种认证路径 适应从本地开发到企业部署的不同安全需求
  5. dmPolicy 的四种模式提供灵活的设备访问控制,从完全开放到严格白名单
  6. Session Key 的三段复合结构(agent:ID:context)支持多 Agent 多上下文的精确定位
  7. Single-writer 模式以架构简单性换取分布式一致性问题的彻底消除

下一章将深入 Command Queue 的实现,理解 Lane 隔离如何保证不同类型任务的正确并发语义。

本章评分
4.7  / 5  (64 评分)

💬 留言讨论