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 绑定将信任边界清晰地划定在操作系统进程隔离层。只有以下实体可以合法连接:
- 同主机上的 CLI 客户端(
openclaw命令行工具) - 同主机上的 Web UI(通过本地 HTTP 服务代理)
- 反向代理(如 nginx、caddy),由管理员显式配置转发
这种设计将网络暴露的责任转移给管理员的代理配置,而非默认开放。管理员如需向外暴露 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 状态版本号,支持断线重连后的状态同步 |
seq 和 stateVersion 的区别: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
}
nonce:32 字节随机数,十六进制编码,每次连接唯一生成timestamp:服务端当前 Unix 时间戳(毫秒),客户端需在 30 秒内完成认证
时间窗口限制防止重放攻击(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
))
这种设计确保:
- 令牌本身不在网络上传输(防止中间人截获)
- 每次连接的签名值不同(防止重放攻击)
- 服务端可以验证客户端持有正确的密钥
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 控制平面的核心机制:
- localhost 绑定 是安全边界的第一道防线,将信任决策留给管理员的代理配置
- 三种消息格式(req/res/event)覆盖所有交互场景,
seq和stateVersion支持可靠传输 - 三步握手(nonce 挑战→认证请求→hello-ok)结合 HMAC-SHA256 签名防止重放攻击
- 四种认证路径 适应从本地开发到企业部署的不同安全需求
- dmPolicy 的四种模式提供灵活的设备访问控制,从完全开放到严格白名单
- Session Key 的三段复合结构(agent:ID:context)支持多 Agent 多上下文的精确定位
- Single-writer 模式以架构简单性换取分布式一致性问题的彻底消除
下一章将深入 Command Queue 的实现,理解 Lane 隔离如何保证不同类型任务的正确并发语义。