06 — 网络平面:入站路由、出站策略与凭证保险箱
本章讲什么: 前几章讲了沙箱怎么被创建、命令怎么在里面跑。这一章讲网络边界——两个方向的问题: 别人怎么访问到沙箱里跑的服务(入站 ingress),以及沙箱能访 问外面的谁(出站 egress)。 外加一个精巧的东西:凭证保险箱,让沙箱里的 AI 工作负载能带着 API 密钥去调外部服务, 却始终看不到密钥本身。数据面主要是两个 Go 组件
components/ingress/和components/egress/, 控制面(Python server)在两侧各搭一座桥。
1. 先建立直觉(零基础也能懂)
沙箱是一个隔离的容器。里面可能跑了一个 Web 服务(比如 agent 起的 python -m http.server 8080),
你想从外面访问它;里面的 agent 也可能想 curl https://api.openai.com。这两件事都得穿过一道墙。
- 入站(ingress): 外部请求 → 网关 → 找到「哪个沙箱的哪个端口」→ 反代进去。 难点是怎么在一个共享网关上把请求路由到成千上万个沙箱,还得防止任意人猜个 ID 就访问到你的内部端口。
- 出站(egress): 沙箱发起的连接 → 一道策略闸 → 放行或拦截。 难点是在容器内透明地拦下所有 DNS 和 TCP,按域名规则判定,而不需要工作负载配合。
- 凭证保险箱(Credential Vault): 出站路上有个 TLS 中间人,能在特定请求里偷偷塞进真实密钥。 agent 发一个「裸」请求(不带 Authorization),密钥由出站边车注入。这样密钥不进沙箱、不进 prompt、不会泄漏。
一句类比:入站像小区门禁(得刷对的签名门卡才让进,门卡还有有效期); 出站像海关(每个包裹按白名单查验放行);凭证保险箱像保税仓的报关代理(帮你在货物上贴对的关税单,你自己没碰过单子)。
2. 顶层全景(它大概怎么转)
怎么读这张图: 左边是「访问沙箱」的入站方向,右边是「沙箱访问外界」的出站方向,中间是被保护的沙箱。 控制面(Python server)不在数据路径上,只负责下发配置 + 签发凭据。
入站 ingress 沙箱 出站 egress
┌───────────────────────┐ ┌───────────────┐ ┌──────────────────────────┐
外部 │ ingress 网关 │ │ 沙箱容器 │ │ egress 边车 │
请求→│ ① 解析路由(host/uri) │──反代→ │ 用户服务:8080 │ │ ② DNS 拦截 15353 │
│ ② 校验签名/token │ │ agent 工作负载 │─出站→│ ③ nft L3 放行/拦截 │
└───────────────────────┘ └───────────────┘ │ ④ mitmproxy TLS 中间人 │
▲ ▲ │ └ 凭证注入 │
│下发 endpoint/签名密钥 │下发 egress 策略 │ │
┌────────┴──────────────────────────────┴───────────────┴──────────────────────────┐
│ 控制面 Python server:签名(services/signing.py)、反代兜底(api/proxy.py)、 │
│ 凭证/策略下发、renew-intent 续期消费 │
└───────────────────────────────────────────────────────────────────────────────────┘
三大件各自的职责:
| 组件 | 干什么 | 在哪 |
|---|---|---|
| ingress 网关 | 把外部请求按 sandbox_id + port 反代进沙箱,校验签名路由 | components/ingress/ |
| egress 边车 | 拦 DNS、按策略放行/拦截、TLS 中间人、注入凭证 | components/egress/ |
| 控制面 server | 签发签名路由、可选地自己反代、下发策略/凭证、续期 | server/opensandbox_server/ |
两条主线,后面各展开一节:
- 入站主线(§3、§4):请求带着一个「路由标签」进来 → 网关解析出沙箱/端口 → 校验访问凭据 → 反代。
- 出站主线(§6、§7、§8):沙箱发 DNS/TCP → 被 iptables 透明重定向到边车 → DNS 按域名策略判定 → (dns+nft 模式)把放行域名的 IP 加进 nft 动态允许集 → TLS 流量过 mitmproxy →(若命中绑定)注入凭证。
3. 入站(一):网关怎么把请求送进对的沙箱
3.1 它要解决的小问题
一个 ingress 网关前面挂着一堆沙箱。请求进来,网关得先回答两个问题:这是发给哪个沙箱的?哪个端口?
OpenSandbox 用一个「路由标签」编码这两样,支持两 种放法(components/ingress/pkg/flag/flags.go:28 的 Mode):
| 模式 | 路由信息放哪 | 例子 |
|---|---|---|
header | 请求头 OpenSandbox-Ingress-To 或 Host 的首段域名 | sb-abc123-8080.gw.example.com |
uri | URL 路径 | /sb-abc123/8080/some/path |
3.2 一次请求走一遍
入口是 Proxy.ServeHTTP(components/ingress/pkg/proxy/proxy.go:48),它把工作分成三步:
ServeHTTP
├─ getSandboxHostDefinition(r) # 解析路由 + 校验访问权限 → {sandbox_id, port, endpoint, uri}
├─ resolveRealHost(host) # endpoint:port 拼出真实上游地址
└─ serve(w, r) # WebSocket 走 ws 代理,否则走 http 代理
解析在 getSandboxHostDefinition(proxy.go 里定义于 host.go:32):
// components/ingress/pkg/proxy/host.go:32(节选)
switch p.mode {
case ModeHeader:
pr, err = parseHostRoute(targetHost) // 从域名首段解析
case ModeURI:
pr, err = parseURIRoute(r.URL.Path) // 从路径解析
}
...
endpoint, err := p.sandboxProvider.GetEndpoint(pr.sandboxID) // 本地缓存查上游地址
GetEndpoint 不是网络调用——Provider 接口(components/ingress/pkg/sandbox/provider.go:47)明确注释
「This is a local cache query, no network I/O」:网关通过 K8s informer 在本地缓存里维护
sandbox_id → endpoint 的映射,查询是内存操作,所以每个请求都很便宜。
3.3 路由标签的解析(为什么右向切分)
parseHostRoute(host_route_parse.go:35)先试着按签名路由解析,失败再退回无签名的 <id>-<port>。
关键是沙箱 ID 本身可以含 -,所以端口/过期/签名都从右边切:
// components/ingress/pkg/signature/signature.go:138 ParseRouteToken(节选)
signature = parts[len(parts)-1] // 最后一段:签名
expiresB36 = parts[len(parts)-2] // 倒数第二:过期时间(base36)
portStr = parts[len(parts)-3] // 倒数第三:端口
sandboxID = strings.Join(parts[:len(parts)-3], "-") // 剩下全是 ID(可含 -)
于是 my-sandbox-8080-abc-1a2b3c4d5 会被正确切成 sandbox_id=my-sandbox、port=8080、
expires=abc、signature=1a2b3c4d5。
4. 入站(二):签名路由 token(OSEP-0011)——为什么必须签名
4.1 不签名会怎样
网关面向公网。如果路由只是 <sandbox_id>-<port>,那么任何人只要猜到(或拿到)一个沙箱 ID,
就能把 port 改成 22、5432、6379…… 直接反代到沙箱内部任意监听端口。这是任意内网访问漏洞。
OpenSandbox 的答案是 OSEP-0011 签名路由:控制面用密钥对 (sandbox_id, port, expires) 算一个短签名,
拼进路由标签。网关用同一把密钥验签,验不过或过期就拒。密钥只在控制面和网关手里,外部伪造不出来。
4.2 签名怎么算(生成在控制面)
签名规范定义在 server/opensandbox_server/services/signing.py:15 的模块 docstring 里,核心三步:
# server/opensandbox_server/services/signing.py(规范,节选自 docstring)
# 1) canonical = "v1\nshort\n{sandbox_id}\n{port}\n{expires_b36}\n"
# 2) inner = BE32(len(secret)) || secret || BE32(len(canonical)) || canonical
# digest = SHA256(inner); hex8 = hex(digest)[0:8]
# 3) signature = hex8 + key_id # 共 9 个字符
expires是 uint64 的 Unix 秒,用 base36 小写、无前导零编码(encode_expires_b36,signing.py:47), 目的是塞进一个域名 label / URI 段里还短。- 签名只取 SHA256 的前 8 个 hex 字符(32 位)(
compute_hex8,signing.py:142),再拼 1 个字符的key_id(compute_signature,signing.py:164)。短是刻意的——它得能当域名首段的一部分。32 位 tag 的暴力空间不大, 但配合过期时间和per-(sandbox,port) 绑定,加上密钥保密,足以挡住「改端口 / 猜路由」这类攻击。
K8s 侧真正调用它的是 _build_signed_endpoint(server/opensandbox_server/services/k8s/kubernetes_service.py:1353),
只有当 ingress 处于 gateway 模式且配了 secure_access 密钥时才可用(_get_secure_access_config,:1380,否则 400)。
4.3 验签怎么做(在网关)
网关侧对称实现:CanonicalBytes/Inner/ExpectedHex8(components/ingress/pkg/signature/signature.go:179-202)
逐字节复刻同一套算法,VerifySignature(:205)做两件事——先查过期,再比签名:
// components/ingress/pkg/signature/signature.go:205 VerifySignature(节选)
nowSec := time.Now().Unix()
if nowSec < 0 || uint64(nowSec) > expiresSec {
return ErrAccessExpired // 过期直接拒
}
...
want := ExpectedHex8(inner)
if hex8 != want {
return fmt.Errorf("%w: signature mismatch", ErrUnauthorized) // 签名不符拒
}
4.4 两条访问路径:明文 token vs 签名路由
访问 校验的总入口是 CheckIngressSecureAccess(components/ingress/pkg/signature/ingress_access.go:82)。
一个沙箱是否「需要校验」,取决于它有没有注解 opensandbox.io/secure-access-token——
非空即 AccessVerificationRequired()(components/ingress/pkg/sandbox/endpoint.go:13)。逻辑分三支:
| 情形 | 判定 |
|---|---|
| 不需要校验(注解为空) | 直接放行 |
需要校验,且带了 OpenSandbox-Secure-Access 头 | 常量时间比对注解 token,不符则 401(不再回退签名) |
| 需要校验,没带头,但路由里有签名+过期 | 走 VerifySignature 验签 |
明文 token 走头部,签名 token 走路由标签。用常量时间比较(secureAccessTokenEqualConstantTime,
ingress_access.go:57,底层 subtle.ConstantTimeCompare)是为了防时序侧信道。反代前网关还会
删掉这两个凭据头(proxy.go:91-92),不让它们泄漏进沙箱。
5. 入站(三):控制面自己也能反代(server proxy)
除了独立 ingress 网关,Python server 自己也带一个反向代理(server/opensandbox_server/api/proxy.py),
路由挂在 /sandboxes/{sandbox_id}/proxy/{port}/{full_path}(proxy.py:406),HTTP 和 WebSocket 都支持
(proxy.py:420 起是 WS 路由)。它经 Lifecycle API 拿到沙箱内部 IP 再直连:
# server/opensandbox_server/api/proxy.py:163
endpoint = lifecycle.sandbox_service.get_endpoint(sandbox_id, port, resolve_internal=True)
resolve_internal=True 让 K8s 侧直接返回 Pod IP(kubernetes_service.py:1317 分支),而不是走网关地址。
反代时会剥离敏感头(SENSITIVE_HEADERS = {authorization, cookie, x-sandbox-api-key},proxy.py:53,
在 _filter_proxy_headers,:94),并补上标准的 X-Forwarded-*。
get_endpoint 的 use_server_proxy 分支 决定客户端拿到的是哪种 URL
(server/opensandbox_server/api/lifecycle.py:566):
# server/opensandbox_server/api/lifecycle.py:566(节选)
if use_server_proxy:
base_url = str(request.base_url).rstrip("/")
...
endpoint.endpoint = f"{base_url}/sandboxes/{sandbox_id}/proxy/{port}"
即:use_server_proxy=true 返回一条「经 server 反代」的 URL;不带则返回网关直达 URL。
注意它和签名互斥——use_server_proxy 不能和 expires 同时用(lifecycle.py:551,400),
因为签名路由是网关的能力,server 反代不参与签名。
6. 出站(一):DNS 拦截 + 策略评估
6.1 数据面的启动顺序
出站边车 main(components/egress/main.go:44)按固定顺序把闸门装好:
main()
① 加载策略(env/文件)+ always allow/deny 文件 policy.LoadInitialPolicyDetailed main.go:66
② 起 DNS 代理,监听 127.0.0.1:15353 dnsproxy.New / Start main.go:81
③ 装 iptables:OUTPUT :53 → 15353(带 SO_MARK 旁路) iptables.SetupRedirect main.go:111
④ dns+nft 模式:静态策略入 nft + 动态放行钩子 setupNft main.go:116
⑤ 起策略/凭证 HTTP 服务(POST /policy 等) startPolicyServer main.go:120
⑥ 可选:起 mitmproxy 透明 拦截(80/443) startMitmproxyTransparentIfEnabled main.go:126
6.2 为什么用 iptables 把 DNS 抢过来
沙箱里的进程发 DNS 查询(UDP/TCP 53)时,并不知道有代理。边车用 iptables NAT 把所有出向 53 端口
重定向到本地 DNS 代理(SetupRedirect,components/egress/pkg/iptables/redirect.go:71):
// components/egress/pkg/iptables/redirect.go:47(dnsRedirectRules 节选)
{"iptables","-t","nat",op,"OUTPUT","-p","udp","--dport","53","-m","mark","--mark",constants.MarkHex,"-j","RETURN"},
{"iptables","-t","nat",op,"OUTPUT","-p","udp","--dport","53","-j","REDIRECT","--to-port",targetPort},
两条规则的顺序很关键:带 SO_MARK=0x1 的流量先 RETURN(旁路),其余才 REDIRECT 到代理。
被旁路的是谁?正是 DNS 代理自己去问上游的那些包——不然代理转发出去的查询又会被重定向回自己,无限递归。
(exempt list 里的上游地址也会 RETURN,redirect.go:32。)
6.3 策略怎么判(域名匹配)
DNS 查询落到 Proxy.serveDNS(components/egress/pkg/dnsproxy/proxy.go:165),第一件事就是评估域名:
// components/egress/pkg/dnsproxy/proxy.go:177(节选)
if currentPolicy != nil && currentPolicy.Evaluate(domain) == policy.ActionDeny {
telemetry.RecordDNSDenied()
p.publishBlocked(domain)
resp.SetRcode(r, dns.RcodeNameError) // 拒绝 = 返回 NXDOMAIN
_ = w.WriteMsg(resp)
return
}
策略是默认拒绝的白名单模型(DefaultDenyPolicy,components/egress/pkg/policy/policy.go:40)。
Evaluate(policy.go:82)先查编译好的域名索引,命中规则用其 action,否则落到 DefaultAction。
域名规则支持精确和 *. 通配(matchesDomain,policy.go:238):
// components/egress/pkg/policy/policy.go:248(节选)
if strings.HasPrefix(pattern, "*.") {
// "*.example.com" 匹配 "a.example.com" 但不匹配 "example.com"
suffix := strings.TrimPrefix(pattern, "*")
return strings.HasSuffix(domain, suffix) && domain != strings.TrimPrefix(pattern, "*.")
}
放行的查询才被转发到上游解析器(forward,proxy.go:233,上游发现见 DiscoverUpstreams,:389)。
6.4 光拦 DNS 不够:dns+nft 的 L3 兜底
只拦 DNS 有个洞:如果工作负载硬编码 IP、绕过 DNS 直连,DNS 闸门就形同虚设。
所以有 dns+nft 模式(components/egress/pkg/constants/mode.go:23,dns-only 是弱模式,dns+nft 加 L3 强制)。
nft 侧的巧妙点:把「域名放行」翻译成「IP 放行」。DNS 代理解析出一个放行域名的 A/AAAA 记录后,
在回包给客户端之前,先把这些 IP 加进 nft 动态允许集(setupNft 里注册 SetOnResolved,
components/egress/nft.go:54;DNS 侧在 maybeNotifyResolved,proxy.go:220,注释明说「install before client connects」):
沙箱查 api.example.com ──▶ DNS 代理:策略放行 ──▶ 解析得 IP 1.2.3.4
│
先把 1.2.3.4 加进 nft 动态允许集
│
再把 DNS 应答返回给沙箱
▼
沙箱连 1.2.3.4 ──▶ nft 命中允许集,放行
这样域名白名单和 L3 放行是因果一致的:能连的 IP 必然来自你放行域名刚解析出的结果。
静态 IP/CIDR 规则则由 StaticIPSets(policy.go:193)分桶后进 nft 静态集。
7. 出站(二):TLS 中间人(mitmproxy)
DNS + nft 只能按「域名/IP」放行,看不到 HTTPS 里面的内容。要做内容级能力(注入凭证、审计), 就需要解密 TLS——这就是透明 mitmproxy 的作用。
startMitmproxyTransparentIfEnabled(components/egress/mitmproxy_transparent.go:95)启动 mitmdump,
等它监听就绪,然后用 iptables 把 80/443 出向流量透明重定向到它,最后同步 CA 证书:
// components/egress/pkg/iptables/transparent.go:33(transparentHTTPRules 节选)
{"iptables","-t","nat",op,"OUTPUT","-p","tcp",
"-m","owner","!","--uid-owner",uid, // 排除 mitm 自己(否则死循环)
"-m","multiport","--dports","80,443",
"-j","REDIRECT","--to-ports",target}
两个排除项是关键:loopback 流量 RETURN(transparent.go:31)、mitm 自己的 uid 用 ! --uid-owner 排除——
mitm 解密后要真正连出去,那部分流量不能再被抓回自己。要让解密成功,客户端得信任 mitm 的 CA
(SyncRootCA,mitmproxy_transparent.go:133 把 CA 导出给沙箱信任)。
透明模式有个 SNI 细节:mitmproxy 内建的 ignore_hosts 在透明模式下先按目的 IP 判,而 SNI 主机名要等
TLS ClientHello 才拿得到。addon system.py 在 tls_clienthello 层用 SNI 再判一次同样的 pattern,
命中就 ignore_connection=True(components/egress/mitmscripts/system.py:88),让基于域名的 TLS 直通可靠工作。
mitmdump 崩了会被 watchMitmproxy(mitmproxy_transparent.go:150)按指数退避无限重启——
出站是命门,不能因为一次 OOM 就永久失能。
8. 凭证保险箱(Credential Vault):注入密钥而不暴露
8.1 它解决什么
你想让沙箱里的 agent 调 https://api.openai.com,但绝不想把真实 API key 放进沙箱——
一旦进了沙箱,它就可能被打印进日志、塞进 prompt、被越狱套走。凭证保险箱的思路:
agent 发一个不带认证头的裸请求,出站边车在 mitmproxy 层替它注入真实密钥。密钥只存在于 egress 边车进程里。
8.2 数据结构:credential 与 binding
保险箱状态存在 Go 边车内存里(Store,components/egress/pkg/credentialvault/vault.go:65,从不落盘),两类对象:
| 对象 | 是什么 | 字段 |
|---|---|---|
| credential | 一条具名密钥 | name + inline value(vault.go:106) |
| binding | 「哪些请求」→「注入什么认证」 | match(host/port/method/path)+ auth(vault.go:116) |
auth 支持 bearer / basic / apiKey / customHeaders(normalizeAuth,vault.go:508),都引用具名 credential。
渲染成实际注入头由 renderInjectionHeaders(vault.go:582)完成,例如 bearer 渲染成 Authorization: Bearer <value>。
8.3 下发时的安全校验(创建即拒绝危险绑定)
控制面 POST 到 /credential-vault(components/egress/policy_server.go:92,需 egress token 鉴权,
写操作还要求 TLS/loopback 传输,policy_server.go:299)。validateBindingPolicy(vault.go:401)把关:
- binding 的 host 必须被 egress 策略显式放行(
explicitAllowCoversHost,vault.go:809)—— 不能给一个策略根本不让访问的域名配密钥。 - 端口必须是拦截端口 80/443(
interceptPorts,vault.go:197)——不在 mitm 范围内的口注入不了。 - host 不能落在 mitmproxy 的
ignore_hosts里(bindingHostMatchesIgnoreHosts,vault.go:411)—— 被直通的连接根本不解密,注入无从谈起。 - binding 之间不能有歧义(
validateBindingAmbiguity,vault.go:927)——同一请求不能同时命中两条规则。
8.4 注入怎么发生(mitm addon 读私有 socket)
mitm addon system.py 在每个请求上跑 request(flow)(components/egress/mitmscripts/system.py:240):
mitmproxy 拦到出站请求
│ _load_active_vault() 经私有 unix socket GET /credential-vault/_active(system.py:113)
│ _select_binding() 按 host/port/method/path 选唯一命中的 binding(system.py:215)
▼
把 binding 的注入头写进 flow.request.headers ← 真实密钥在此刻才出现
│
响应回来时 _redact_response_headers 把密钥值从响应头里抹成 [REDACTED](system.py:282)
「active 快照」来自 ActiveSnapshot(vault.go:321),它把 binding 渲染成具体注入头 + 一份 redactions
(所有密钥明文,供响应侧脱敏)。这份快照通过一个egress 容器私有的 unix socket 暴露
(StartActiveSocketServer,components/egress/pkg/credentialvault/active_socket.go:27),socket 属 mitm 的 gid、
权限 0660(active_socket.go:72),沙箱工作负载访问不到。于是:密钥从控制面 → egress 内存 → mitm 注入,
全程绕开沙箱;工作负载发出的请求里本来没有密钥,是在离开沙箱之后才被贴上的。
命中多条 binding 时 addon 直接回 403「ambiguous」(system.py:227),宁可拒绝也不乱注入。
9. renew-intent:让「在用」的沙箱不被回收
沙箱通常有 TTL,闲置会被回收。但一个沙箱正被人通过网关频繁访问时,它其实是活的——
renew-intent 就是把「有人在访问」这个信号从数据面回传给控制面去续期。
两个信号源汇入同一条续期流水线:
① ingress 网关每次反代 ──PublishIntent──▶ Redis 队列(LPUSH,按 sandbox 节流)
② server 反代每次命中 ──schedule────────▶ 进程内队列(无 Redis 路径)
│
server RenewIntentConsumer 统一消费 → 给沙箱续期
- ingress 侧:
Proxy.ServeHTTP里调renewIntentPublisher.PublishIntent(components/ingress/pkg/proxy/proxy.go:80),RedisPublisher(components/ingress/pkg/renewintent/redis.go:100)异步LPUSH到 Redis 队列 (doPublish,redis.go:124,配LTRIM限长),并按MinIntervalper-sandbox 节流(shouldSendIntent,redis.go:83), 避免高频访问打爆队列。 - server 侧:
RenewIntentConsumer(server/opensandbox_server/integrations/renew_intent/consumer.py:71) 用BRPOP消费 Redis 队列(_brpop_feeder_loop,:217);同时 server 自己的反代路径通过ProxyRenewCoordinator.schedule(.../renew_intent/proxy_renew.py:37)把submit_from_proxy塞进同一条队列(consumer.py:154)。两路都进_processor_loop统一续期。
一句话:ingress 用 Redis 跨进程回传,server 反代用进程内队列,最后汇成一条续期管道—— 所以无论沙箱是经网关还是经 server 反代被访问,只要有流量就会被续命。
10. 和第 03 章的分工:编排 vs 数据面
第 03 章讲 K8s 运行时时会提到 egress_helper.py(server/opensandbox_server/services/k8s/egress_helper.py),
但那只是编排——往 Pod spec 里注入 egress 边车容器、设环境变量(模式、规则、token)、配 SecurityContext
(把 NET_ADMIN 只给边车、从主容器 drop)。真正的数据面——DNS 拦截、iptables/nft 强制、TLS 中间人、凭证注入——
全在本章这两个 Go 组件的运行时里执行。
11. 巧妙之处(可借鉴的技术)
- 签名短到能塞进域名 label。 OSEP-0011 只取 SHA256 前 32 位 + 1 字符 key_id(
signing.py:142/compute_signature), 用 base36 编过期时间(signing.py:47),换来「路由标签自带防伪+时效」而几乎不占长度。 - DNS 放行先于回包写 nft。
maybeNotifyResolved在WriteMsg之前同步装动态允许集(dnsproxy/proxy.go:220), 消除了「客户端拿到 IP 就连,但 nft 还没放行」的竞态。 - SO_MARK 旁路防 DNS 自递归。 重定向规则把带 mark 的代理上游流量先 RETURN(
iptables/redirect.go:47), 一个 mark 就解决了透明代理最经典的回环坑。 - 密钥永不进沙箱、也不落盘。 保险箱只在边车内存(
vault.go:65),注入发生在离开沙箱之后的 mitm 层 (mitmscripts/system.py:240),active 快照走 0660 私有 socket(active_socket.go:72),响应头再脱敏(system.py:282)。 - 歧义即拒。 下发时校验 binding 不重叠(
vault.go:927),运行时命中多条也回 403(system.py:227)——两道都不赌。
12. 边界与局限
- 签名 tag 只有 32 位。 理论暴力空间 ~2^32;安全性依赖密钥保密 + 过期时间 + per-(sandbox,port) 绑定, 不适合当长期无过期的能力令牌用。
- 凭证注入依赖客户端信任 mitm CA。 若沙箱内的客户端做证书钉扎或不信任 导出的 CA,TLS 握手会失败,注入不了。
- dns-only 模式可被绕过。 硬编码 IP 直连能躲开 DNS 闸门;要 L3 强制必须开
dns+nft(constants/mode.go)。 - 凭证保险箱要求非 insecure 的 mitm。
Ready(vault.go:370)会拒绝没有透明 mitm、或ssl_insecure开启的环境。
13. 相关章节
- 01-protocol-and-lifecycle.md — endpoint 与 secure-access 注解从哪来。
- 02-control-plane.md — 控制面如何签发 endpoint、下发配置。
- 03-runtime-backends.md — K8s 侧
egress_helper的编排职责(与本章数据面互补)。 - 04-execd-data-plane.md — 被反代/被保护的沙箱内服务在哪跑。
14. 代码地图(导航索引)
| 主题 | 文件路径 | 关键符号 |
|---|---|---|
| 入站反代主循环 | components/ingress/pkg/proxy/proxy.go | Proxy.ServeHTTP、resolveRealHost、serve |
| 路由解析(host/uri) | components/ingress/pkg/proxy/host_route_parse.go | parseHostRoute、parseURIRoute、parseURILegacy |
| 路由 token 切分 | components/ingress/pkg/signature/signature.go | ParseRouteToken |
| 网关验签 + 过期 | components/ingress/pkg/signature/signature.go | VerifySignature、ExpectedHex8、CanonicalBytes |
| 访问校验分支 | components/ingress/pkg/signature/ingress_access.go | CheckIngressSecureAccess、secureAccessTokenEqualConstantTime |
| 上游地址本地缓存 | components/ingress/pkg/sandbox/provider.go | Provider.GetEndpoint、EndpointInfo.AccessVerificationRequired |
| 控制面签名生成 | server/opensandbox_server/services/signing.py | compute_signature、compute_hex8、encode_expires_b36、build_canonical_bytes |
| 签名 endpoint 构造 | server/opensandbox_server/services/k8s/kubernetes_service.py | _build_signed_endpoint、_get_secure_access_config |
| server 反代 | server/opensandbox_server/api/proxy.py | _proxy_http_request、_proxy_websocket_request、_filter_proxy_headers |
| use_server_proxy 分支 | server/opensandbox_server/api/lifecycle.py | get_sandbox_endpoint |
| 出站边车启动 | components/egress/main.go | main、setupNft |
| DNS 拦 截 + 策略 | components/egress/pkg/dnsproxy/proxy.go | Proxy.serveDNS、maybeNotifyResolved、forward |
| 域名策略评估 | components/egress/pkg/policy/policy.go | NetworkPolicy.Evaluate、matchesDomain、StaticIPSets |
| DNS 透明重定向 | components/egress/pkg/iptables/redirect.go | SetupRedirect、dnsRedirectRules |
| nft 动态放行 | components/egress/nft.go | setupNft、createNftManager |
| TLS 透明重定向 | components/egress/pkg/iptables/transparent.go | SetupTransparentHTTP、transparentHTTPRules |
| mitmproxy 生命周期 | components/egress/mitmproxy_transparent.go | startMitmproxyTransparentIfEnabled、watchMitmproxy |
| 凭证保险箱状态机 | components/egress/pkg/credentialvault/vault.go | Store、renderInjectionHeaders、ActiveSnapshot、validateBindingPolicy |
| 保险箱私有 socket | components/egress/pkg/credentialvault/active_socket.go | StartActiveSocketServer |
| 凭证注入 addon | components/egress/mitmscripts/system.py | request、_select_binding、_load_active_vault、_redact_response_headers |
| 策略/凭证 HTTP 服务 | components/egress/policy_server.go | startPolicyServer、policyServer.authorize、handleCredentialVault |
| renew-intent 发布(ingress) | components/ingress/pkg/renewintent/redis.go | RedisPublisher.PublishIntent、doPublish、shouldSendIntent |
| renew-intent 消费(server) | server/opensandbox_server/integrations/renew_intent/consumer.py | RenewIntentConsumer、_brpop_feeder_loop、submit_from_proxy |
| server 反代续期桥 | server/opensandbox_server/integrations/renew_intent/proxy_renew.py | ProxyRenewCoordinator.schedule |