背景

我想基于 Managed Agents 做一个 MR review 的 agent,整体流程大致如下:

  1. git push 触发 GitLab CI Runner 运行一个 job;
  2. job 向内网的 code review service 发送一个 POST 请求;
  3. code review service 收到请求后,调用 Managed Agents API 拉起一个公网沙箱,在沙箱中运行 code review agent;
  4. code review agent 拉取代码,进行 review。

整体流程图

问题出在第 4 步:沙箱在公网环境,无法访问企业内网的 GitLab,代码拉不下来。

我又一次撞上了网络这堵空气墙。

Managed Agents API 提供的能力我都评估过,但没有一个能很好地解决这个问题:

方案 说明 为什么不行
Files API 把文件上传到对象存储,对象存储会直接挂载到沙箱上 每次都要把整个 repo 上传一遍,不优雅
Custom Tools 订阅 event stream 监听 custom tool call,调用内部服务后把结果回传给 agent 没办法把整个 repo 塞进上下文
MCP Tunnel 把内网 MCP Server 通过 Cloudflare Tunnel 连到 Anthropic 的 VPC,避免暴露到公网 GitLab 的 MCP 服务器没有一个方法能方便地 clone 整个 repo

MCP Tunnel 给了我一个思路:利用反向隧道,把内网的特定服务定向暴露给沙箱。

要打通这条路,需要先理解两个问题:为什么公网访问不了内网(NAT),以及怎么打洞(内网穿透 / 隧道)。

为什么公网访问不了内网:NAT / NAPT

由于 IPv4 地址资源紧缺,内网机器通常没有公网 IP,只有私有 IP。它们访问公网服务时,路由器会把数据包的源 IP 和源端口改写成自己的公网 IP + 一个临时分配的端口,并在 NAT 转发表中记录这条映射(内网 IP:端口 ↔ 公网 IP:端口)。这种"地址 + 端口"一起转换的做法,严格来说叫 NAPT(Network Address Port Translation),也就是日常生活中常说的 NAT。

之后公网服务器返回响应时,路由器查表找到对应的内网机器,再把包转发回去。整个过程对内网机器是透明的,它感觉不到自己被"改过地址"。

但这也意味着:公网无法主动连接内网。NAT 转发表里只有内网主动发起的连接记录;公网主动发来的数据包查不到映射条目,路由器不知道该转发给谁,只能丢弃。

这就是部署在内网的服务没有公网入口的根本原因——它缺的不是服务本身,而是一条"从外往里"的路。

NAT 工作原理示意

内网穿透:打一个"公网可见"的洞

内网穿透(也叫反向隧道)的思路是:让内网服务主动去够公网,而不是等公网来访问。

内网中的服务主动向公网的一个 relay server 发起连接,并保持这条 TCP 长连接;

relay server 把它映射到自己的一个端口上。

之后任何 client 访问 relay server 的这个端口时,relay server 都会把流量顺着这条长连接转发给内网服务。

内网穿透示意

这个方案的代价也很明显:内网服务被完整暴露到了公网——任何知道 relay server 地址和端口的人都能访问,存在被扫描和攻击的风险。

而我想要的效果是:内网 GitLab 只允许我的沙箱访问,对公网其他任何人不可见。

普通内网穿透做不到这一点,需要更"定向"的隧道。

定向打洞:frp 的 stcp 模式

我用的工具是 frp,一个开源的内网穿透工具。frp 由 frps(服务端)和 frpc(客户端)组成:

  • frps 运行在有公网 IP 的 relay server 上,负责接收注册和转发流量;
  • frpc 有两种角色:
    • 服务侧(proxy):运行在内网,把内网服务注册到 frps;
    • 访问侧(visitor):运行在需要访问的一方(这里是沙箱),把远端服务折射到本地端口。

普通 frp 代理(type = "tcp")会在 frps 上开放一个公网端口,效果等同于前面说的内网穿透;而 stcp(Secret TCP)模式下,frps 只为双方牵线,不在公网开放任何服务端口——只有持有配对密钥的 visitor 才能通过 frps 找到服务侧的代理。这正好满足"只给沙箱开一条缝"的需求。

最终拓扑

image-20260825181402406

  1. 内网(我自己的 Mac)上用 Docker 跑了一个 frpc(服务侧),注册服务 corp-gitlab,指向内网 GitLab;
  2. GCP 上起了一台有公网 IP 的 VM 跑 frps(203.0.113.10:7000),只开控制通道;
  3. 另一台 GCP VM 模拟沙箱,跑 frpc 的 visitor 角色,把 corp-gitlab 折射到本地 127.0.0.1:80,再用 hosts 把 gitlab.internal.example.com 指向 127.0.0.1——沙箱里 git clone http://gitlab.internal.example.com/... 就能走了。

三份配置

Mac 上(frpc,服务侧):

serverAddr = "203.0.113.10"        # frps 的公网地址(端口单独配置)
serverPort = 7000                  # 控制通道端口
auth.token = "<your-frp-auth-token>"   # 连接 frps 的认证 token

[[proxies]]
name = "corp-gitlab"               # 服务名,visitor 靠它找到本代理
type = "stcp"                      # ★ 关键:stcp 模式,frps 不开放公网服务端口
secretKey = "<your-stcp-secret-key>"   # 配对密钥,与 visitor 必须一字不差
localIP = "gitlab.internal.example.com" # ★ 有人来访问时,我去连谁
localPort = 80

GCP VM 上(frps):

bindPort = 7000                    # 只开控制通道(stcp 模式下连服务端口都没有)
auth.token = "<your-frp-auth-token>"

模拟沙箱的 GCP VM 上(frpc,访问侧 / visitor):

serverAddr = "203.0.113.10"
serverPort = 7000
auth.token = "<your-frp-auth-token>"

[[visitors]]
name = "gitlab-visitor"
type = "stcp"
serverName = "corp-gitlab"         # 我要消费服务侧注册的哪个服务
secretKey = "<your-stcp-secret-key>"   # 配对凭证(必须和服务侧一字不差)
bindAddr = "127.0.0.1"             # 监听口只开在本地,不对外
bindPort = 80

另外在沙箱 VM 上配置 hosts 映射:

127.0.0.1 gitlab.internal.example.com

流量是怎么走的

沙箱里执行 git clone http://gitlab.internal.example.com/... 时:

  1. hosts 把 gitlab.internal.example.com 解析到 127.0.0.1;
  2. 连接打到 visitor 监听的 127.0.0.1:80;
  3. visitor 通过 frps 找到注册了 corp-gitlab 且密钥匹配的服务侧 frpc;
  4. frps 在两者之间中转流量,Mac 上的 frpc 再去连接内网 GitLab(gitlab.internal.example.com:80);
  5. 数据原路返回。对沙箱来说,GitLab 就像"长在本地"一样。

为什么这样就不算"暴露到公网"

  • frps 的 7000 端口是控制通道,只接受持有 token 的 frpc 注册,且不对外开放任何服务端口;
  • 服务侧注册的 corp-gitlab 不会被分配公网端口,公网扫描扫不到;
  • 即使有人能连上 frps,没有与 corp-gitlab 匹配的 secretKey,也拿不到服务;
  • 沙箱侧 visitor 只监听 127.0.0.1,不会额外暴露任何东西。

实践中的注意点

  1. GCP 防火墙:VPC 默认拒绝入站流量,需要为 frps 所在的 VM 放行 7000 端口的入站 TCP 规则,否则 frpc 连不上 frps。
  2. 流量加密:frp 默认不加密隧道流量,内网 GitLab 的请求会明文经过公网上的 frps。建议开启 frp 的 TLS(transport.tls.enable = true),GitLab 侧尽量走 HTTPS。
  3. 沙箱侧初始化:沙箱是临时的,每次拉起都要安装 frpc、写入 visitor 配置、改 hosts。建议把这几步做成初始化脚本,由 code review service 在创建沙箱时注入;visitor 配置里的 token / secretKey 不要写死在公共镜像里。
  4. 端口权限:Linux 上监听 1024 以下端口需要 root。若 frpc 不是以 root 运行,要么把 visitor 的 bindPort 换成 8080 之类的高位端口,要么调整非特权端口范围(sysctl net.ipv4.ip_unprivileged_port_start=0)。
  5. 密钥管理:配置里有两层凭据(连接 frps 的 auth.token、stcp 配对的 secretKey),均为明文,注意保管与定期轮换。

效果

在模拟沙箱的 VM 上执行 git clone http://gitlab.internal.example.com/example-repo.git,可以正常拉取代码:

image-20260825181525701

整个链路(沙箱 → frps → Mac → 内网 GitLab)一次打通,沙箱里感知不到隧道的存在,体验上就像 GitLab 部署在沙箱本地一样。

总结

需求 方案
沙箱能访问内网 GitLab frp stcp 隧道:内网 frpc(proxy)+ 公网 frps + 沙箱 frpc(visitor)
GitLab 不暴露给公网 stcp 模式不在 frps 上开放服务端口,访问需要配对密钥

回顾最初对三种 Managed Agents 能力的评估:Files API 太重、Custom Tools 塞不下 repo、MCP Tunnel 只适用于 MCP 协议——而 frp stcp 在协议层面无侵入,沙箱里就是一次普通的 git clone,因此是最贴合"让沙箱拉代码"这个场景的方案。

后续可以考虑的方向:把沙箱侧初始化封装成镜像或启动脚本;开启 frp TLS 加密;使用短期密钥;以及评估 frp 的 xtcp 模式(P2P 直连,流量不经过 frps,但受双方 NAT 类型限制)。