Claude Code 源码分析第 36 课 · 第 06
第 36 课

上游代理

Claude Code 如何通过 CCR 会话容器内支持 MITM 的 WebSocket 隧道路由出站流量,并且无需信任具有会话令牌的代理循环。

01 这解决了什么问题?

Claude Code 远程 (CCR) 在沙盒 GKE 容器内运行。 企业客户需要出站 HTTP 流量 — curl,Git中心CLI, npm install、Datadog 代理 — 携带注入的凭据(例如 DD-API-KEY)并可审计。 解决方案是一个 HTTPS 连接代理 本地运行于 127.0.0.1 在每个容器内,通过 WebSocket 隧道返回 Anthropic 的网关。 网关 MITM TLS,注入组织配置的标头,然后转发到真正的上游。

架构限制
CCR 入口是具有路径前缀路由的 GKE L7 负载均衡器。 原始 TCP CONNECT 隧道在那里不可路由,因此中继使用 WebSocket - 会话隧道已使用相同的升级模式。
根据设计无法打开
每迈出一步 initUpstreamProxy 被包裹以便 any 错误记录警告并返回 { enabled: false }。 损坏的代理绝不能中断工作会话。
02 初始化序列(upstreamproxy.ts)

initUpstreamProxy() 被调用一次 init.ts 在启动时。 它运行六个步骤,每个步骤都以前一个步骤的成功为条件:

步骤1

保护环境变量

提前返回,如果 CLAUDE_CODE_REMOTE or CCR_UPSTREAM_PROXY_ENABLED 不真实。 无法在开发人员机器上进行工作。

步骤2

读取会话令牌

读取短期凭证 /run/ccr/session_token。 文件是在代理启动之前由 CCR 容器编排器写入的。

步骤3

设置不可转储

Calls prctl(PR_SET_DUMPABLE, 0) 通过 Bun FFI。 阻止相同 UID ptrace / gdb,因此提示注入的调试器无法从堆内存中抓取令牌。

步骤4

下载 CA 捆绑包

从以下位置获取 MITM 代理的 CA 证书 /v1/code/upstreamproxy/ca-cert 并将其与系统 CA 捆绑包连接起来。 所有运行时(curl、Python、Node)都通过环境变量进行修补。

步骤5

启动继电器

Calls startUpstreamProxyRelay(),它在临时端口上打开本地 TCP 服务器。 返回端口号。

步骤6

取消链接令牌文件

Deletes /run/ccr/session_token 从磁盘 仅在之后 中继已确认监听。 从此时起,令牌仅存在于堆内存中。

安全细节
令牌已删除 after 中继确认它正在监听,而不是之前。 这允许主管进程重新启动容器并重试 - 如果序列中较早的任何内容失败,令牌文件仍将位于磁盘上。
03 CONNECT-over-WebSocket 中继 (relay.ts)

中继是本地 TCP 服务器。 客户(curl, gh, kubectl) 连接并发送标准 HTTP CONNECT host:port 请求。 中继将 WebSocket 升级到 Anthropic 的网关并双向传输字节。

sequenceDiagram participant C as curl / gh / npm participant R as Local Relay
127.0.0.1:<port> participant G as CCR Gateway
(WebSocket) participant U as Real Upstream C->>R: HTTP CONNECT api.example.com:443 R->>G: WS upgrade (Bearer token) G-->>R: 101 Switching Protocols R->>G: encodeChunk(CONNECT line + Proxy-Auth) G-->>R: encodeChunk(HTTP/1.1 200 Connection Established) R-->>C: HTTP/1.1 200 Connection Established C->>R: TLS ClientHello (raw bytes) R->>G: encodeChunk(TLS bytes) G->>U: Forward (MITM TLS, inject headers) U-->>G: Response G-->>R: encodeChunk(response) R-->>C: Response bytes

两阶段连接处理

The handleData() 函数为每个 TCP 连接实现一个干净的两阶段状态机:

// Phase 1: accumulate until CRLF CRLF terminates the CONNECT header
if (!st.ws) {
  st.connectBuf = Buffer.concat([st.connectBuf, data])
  const headerEnd = st.connectBuf.indexOf('\r\n\r\n')
  if (headerEnd === -1) { /* guard: reject if > 8192 bytes */ return }
  // parse CONNECT host:port, stash trailing bytes, open WS tunnel
  openTunnel(sock, st, firstLine, wsUrl, authHeader, wsAuthHeader)
  return
}
// Phase 2: pump bytes to WebSocket (buffer if WS not open yet)
if (!st.wsOpen) { st.pending.push(Buffer.from(data)); return }
forwardToWs(st.ws, data)
竞争条件已解决
TCP 可以将 CONNECT 标头和 TLS ClientHello 合并到单个数据包中。 之后到达的任何字节 \r\n\r\n 进入 st.pending[] 并被冲入 ws.onopen。 如果没有这个,ClientHello 就会被默默地丢弃。
04 手工制作的 Protobuf 编码

穿过 WebSocket 的字节被包装在一个 UpstreamProxyChunk 协议缓冲区消息。 而不是拉进去 protobufjs 对于单个单字段消息,代码手动编码和解码:

// message UpstreamProxyChunk { bytes data = 1; }
// tag = (field 1 << 3) | wire_type 2 (length-delimited) = 0x0a
export function encodeChunk(data: Uint8Array): Uint8Array {
  const len = data.length
  const varint: number[] = []
  let n = len
  while (n > 0x7f) { varint.push((n & 0x7f) | 0x80); n >>>= 7 }
  varint.push(n)
  const out = new Uint8Array(1 + varint.length + len)
  out[0] = 0x0a
  out.set(varint, 1)
  out.set(data, 1 + varint.length)
  return out
}

标签字节总是 0x0a。 长度是标准的 protobuf varint — 每字节 7 位,MSB 设置在连续字节上。 decodeChunk 验证标签以及声明的长度是否适合缓冲区,返回 null 对于格式错误的框架和 new Uint8Array(0) 对于零长度的保活块。

为什么是 protobuf 而不是普通字节?
CCR 网关使用 gateway.NewWebSocketStreamAdapter 服务器端,它期望这个框架。 这 Content-Type: application/proto 升级标头告诉服务器使用二进制 proto 反序列化而不是 protojson - 没有它,服务器会以 EOF 静默失败。
05 Bun 与节点运行时调度

startUpstreamProxyRelay() 在运行时调度:如果 typeof Bun !== 'undefined' 它调用 startBunRelay; 否则 startNodeRelay。 CCR 容器在 Node 下运行 Claude Code,而不是 Bun,因此 startNodeRelay 是生产路径。 startBunRelay 在开发人员环境中使用,必须明确测试。

主要区别:写背压

Node's net.Socket.write() 总是缓冲——a false 返回信号背压但没有字节丢失。 Bun 的 sock.write() 是否有部分内​​核写入 默默地丢弃剩余的。 Bun 继电器跟踪每个套接字中未写入的字节 writeBuf: Uint8Array[] 并将它们冲入 drain handler.

// Bun: tail-queue what the kernel didn't accept
const n = sock.write(bytes)
if (n < bytes.length) st.writeBuf.push(bytes.subarray(n))

// drain handler: flush the queue
while (st.writeBuf.length > 0) {
  const chunk = st.writeBuf[0]!
  const n = sock.write(chunk)
  if (n < chunk.length) { st.writeBuf[0] = chunk.subarray(n); return }
  st.writeBuf.shift()
}

WebSocket代理

CCR 容器位于出口网关后面 - 直接出站被阻止。 WS 升级本身必须通过 HTTP CONNECT 代理。 节点的 undici/globalThis.WebSocket 不会向全局调度程序咨询升级请求,因此节点中继导入 ws npm 包并传递一个显式的 agent。 Bun 的本机 WebSocket 接受 proxy 直接网址。

06 环境变量传播

getUpstreamProxyEnv() 返回环境变量的记录 subprocessEnv() 合并到代理生成的每个子进程中 - Bash 工具、MCP 服务器、LSP、git hooks。 这可确保所有子进程流量都通过中继路由,而无需针对每个工具进行配置。

VariableValuePurpose
HTTPS_PROXY / https_proxyhttp://127.0.0.1:<port>通过中继路由 HTTPS 流量(大多数工具)
NO_PROXY / no_proxy环回、RFC1918、Anthropic API、GitHub、npm、PyPI、板条箱绕过不得拦截的流量的中继
SSL_CERT_FILE~/.ccr/ca-bundle.crtOpenSSL/curl CA 信任
NODE_EXTRA_CA_CERTSsameNode.js TLS 信任
REQUESTS_CA_BUNDLEsamePython requests / httpx trust
CURL_CA_BUNDLEsame卷曲 CA 信任
仅 HTTPS
Only HTTPS_PROXY 已设定,从不 HTTP_PROXY。 继电器只处理 CONNECT — 通过它路由纯 HTTP 将返回 405 Method Not Allowed.

NO_PROXY:Anthropic API 排除

Anthropic API (api.anthropic.com)以三种方式列出 NO_PROXY: 作为 anthropic.com, .anthropic.com, 和 *.anthropic.com。 这是因为 NO_PROXY 解析在不同的运行时有所不同 — Bun 和 Go 使用全局匹配,Python urllib/httpx 使用后缀匹配。 可靠旁路需要所有三种形式。

继承的代理变量

子 CLI 进程(例如,会话内生成的子代理)无法重新初始化中继 — 令牌文件已取消链接。 如果父母的 HTTPS_PROXY and SSL_CERT_FILE 两者都设置在环境中, getUpstreamProxyEnv() 检测到这一点并将所有代理变量传递给孙进程,从而维护链。

07 安全模型

提示注入和令牌

恶意提示可能会指示代理运行 gdb -p $PPID 从堆中读取会话令牌。 这 prctl(PR_SET_DUMPABLE, 0) 调用(通过 Bun FFI 进入 libc.so.6) 会阻止此操作 — 在此调用之后,内核会拒绝相同 UID ptrace。

const PR_SET_DUMPABLE = 4
const rc = lib.symbols.prctl(PR_SET_DUMPABLE, 0n, 0n, 0n, 0n)
// Linux-only; silently no-ops on macOS/Windows

双重身份验证层

中继在两个不同的点使用两个单独的凭据:

  • WS升级: Authorization: Bearer <token> — 在 WebSocket 协议级别验证到 CCR 网关的中继。
  • 连接隧道: Proxy-Authorization: Basic base64(sessionId:token) — 在第一个 protobuf 块中携带,授权特定的上游隧道请求。
TLS 腐败卫士
一旦服务器的 200 Connection Established 被转发后, st.established 标志已设置。 如果 WebSocket 随后出错,中继会关闭 TCP 连接,而不是写入明文 502 响应 — 将 ASCII 写入活动的 TLS 流会破坏客户端的密码状态。
08 保活策略

CCR sidecar 有 50 秒的空闲超时。 中继每 30 秒发送一次 ping (PING_INTERVAL_MS = 30_000)。 并非所有 WebSocket 实现都公开 ping() 帧 API,因此中继使用应用程序级 keepalive:零长度 encodeChunk(new Uint8Array(0)) 服务器忽略但会重置空闲计时器。 pinger 存储在 ConnState.pinger 并清除于 cleanupConn().

09 块大小和背压

forwardToWs() 将出站数据分割成最多 MAX_CHUNK_BYTES = 512 * 1024 (512 KB)。 这是 Envoy 的每个请求缓冲区上限。 Git 推送有效负载可以超出此范围; 分块确保它们被流式传输而不是被拒绝。

for (let off = 0; off < data.length; off += MAX_CHUNK_BYTES) {
  const slice = data.subarray(off, off + MAX_CHUNK_BYTES)
  ws.send(encodeChunk(slice))
}

要点

  • 上游代理是 container-only:两个环境变量守卫(CLAUDE_CODE_REMOTE + CCR_UPSTREAM_PROXY_ENABLED)确保它永远不会在开发人员机器上激活。
  • 会话令牌已从磁盘中删除 after 中继确认它正在监听——允许主管重新启动以在部分失败时重试。
  • prctl(PR_SET_DUMPABLE, 0) 是针对提示注入驱动的内核级防御 ptrace 对堆内存中的令牌的攻击。
  • WebSocket 使用手工卷起的 protobuf 框架(encodeChunk / decodeChunk)来满足服务器的 NewWebSocketStreamAdapter — 一个标签字节 + varint 长度 + 有效负载。
  • Bun 和节点中继实现在协议上相同,但在写反压方面有所不同: Bun 静默截断部分内核写入; 节点无条件缓冲。
  • 所有八个与代理相关的环境变量都通过以下方式注入到每个子进程中 getUpstreamProxyEnv(),涵盖curl、Node、Python 和OpenSSL 信任存储。
  • 当本机 WebSocket ping API 不可用时,零长度 protobuf 块充当应用程序级 keepalive。
深入探讨:CA 捆绑包下载和 5 秒超时

downloadCaBundle() 从以下位置获取 MITM 代理的 CA 证书 <baseUrl>/v1/code/upstreamproxy/ca-cert。 Bun 没有默认的获取超时,因此挂起的端点将无限期地阻止 CLI 启动。 调用使用 AbortSignal.timeout(5000).

下载的 PEM 与现有系统 CA 捆绑包 (/etc/ssl/certs/ca-certificates.crt),写给 ~/.ccr/ca-bundle.crt,并通过所有特定于运行时的环境变量引用。 如果系统捆绑包读取失败,则连接会以空字符串继续进行 - 由于 CCR 容器没有其他可信任的出站 TLS,因此仅 MITM CA 就足够了。

基本 URL 来自 ANTHROPIC_BASE_URL (由容器编排器注入),而不是来自 getOauthConfig()。 OAuth 配置键关闭 USER_TYPE and USE_{LOCAL,STAGING}_OAUTH,这两者都没有在 CCR 容器中设置,因此即使在登台时它也始终返回产品 URL — 在 CA 端点上进行 404 处理。

深入探讨:康涅狄格州的田野和封闭/既定的警卫

每个TCP连接携带一个 ConnState 具有多个布尔守卫的对象:

  • wsOpen: 真一次 ws.onopen 火灾。 在那之前,传入的客户端字节进入 st.pending[].
  • established:当服务器的 200 Connection Established 被转发。 此后,编写明文错误响应将损坏 TLS 流。
  • closed:防止双重关闭。 ws.onerror 总是跟随 ws.onclose; 如果没有这个标志,第二个处理程序将调用 sock.end() 在已经结束的套接字上。

The pending: Buffer[] 数组处理两个不同的竞争:在 CONNECT 标头之后但之前到达的字节 ws.onopen (TCP 合并),以及 data 当 WS 握手仍在进行时触发的事件。

深入探讨:为什么 NO_PROXY 列出 Anthropic 域

Anthropic API 列于 NO_PROXY 有两个原因:

  1. CCR 网关从来没有与 Anthropic 自己的 API 匹配的上游路由。 通过 MITM 路由 API 调用会导致它们在网关处失败,而不仅仅是 TLS 验证失败。
  2. Python's httpx/certifi 使用自己捆绑的CA商店,不提货 REQUESTS_CA_BUNDLE 在所有配置中。 即使路由有效,某些运行时也会拒绝伪造的证书。

Git集线器和包注册表(registry.npmjs.org, pypi.org等)也被绕过,因为 CCR 容器已经有直接出口到它们 - 通过 MITM 路由这些将是不必要的开销,并且如果 MITM 代理不可用,就会中断。

检查你的理解情况

问题1
为什么是 prctl(PR_SET_DUMPABLE, 0) 在代理设置中的其他操作之前调用?
问题2
中继发送的每个出站 WebSocket 消息的第一个字节是什么,为什么?
问题3
令牌文件被删除 after 中继已确认监听。 这可以实现什么功能?
问题4
为什么 Bun 继电器需要一个 writeBuf 排空队列但节点中继不?
问题5
一旦 st.established 如果设置了标志,如果 WebSocket 出错,中继会做什么?