跳到主要内容

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:28Mode):

模式路由信息放哪例子
header请求头 OpenSandbox-Ingress-ToHost 的首段域名sb-abc123-8080.gw.example.com
uriURL 路径/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-sandboxport=8080expires=abcsignature=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_endpointuse_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.pytls_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 限长),并按 MinInterval per-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。 maybeNotifyResolvedWriteMsg 之前同步装动态允许集(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. 相关章节

14. 代码地图(导航索引)

主题文件路径关键符号
入站反代主循环components/ingress/pkg/proxy/proxy.goProxy.ServeHTTPresolveRealHostserve
路由解析(host/uri)components/ingress/pkg/proxy/host_route_parse.goparseHostRouteparseURIRouteparseURILegacy
路由 token 切分components/ingress/pkg/signature/signature.goParseRouteToken
网关验签 + 过期components/ingress/pkg/signature/signature.goVerifySignatureExpectedHex8CanonicalBytes
访问校验分支components/ingress/pkg/signature/ingress_access.goCheckIngressSecureAccesssecureAccessTokenEqualConstantTime
上游地址本地缓存components/ingress/pkg/sandbox/provider.goProvider.GetEndpointEndpointInfo.AccessVerificationRequired
控制面签名生成server/opensandbox_server/services/signing.pycompute_signaturecompute_hex8encode_expires_b36build_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.pyget_sandbox_endpoint
出站边车启动components/egress/main.gomainsetupNft
DNS 拦截 + 策略components/egress/pkg/dnsproxy/proxy.goProxy.serveDNSmaybeNotifyResolvedforward
域名策略评估components/egress/pkg/policy/policy.goNetworkPolicy.EvaluatematchesDomainStaticIPSets
DNS 透明重定向components/egress/pkg/iptables/redirect.goSetupRedirectdnsRedirectRules
nft 动态放行components/egress/nft.gosetupNftcreateNftManager
TLS 透明重定向components/egress/pkg/iptables/transparent.goSetupTransparentHTTPtransparentHTTPRules
mitmproxy 生命周期components/egress/mitmproxy_transparent.gostartMitmproxyTransparentIfEnabledwatchMitmproxy
凭证保险箱状态机components/egress/pkg/credentialvault/vault.goStorerenderInjectionHeadersActiveSnapshotvalidateBindingPolicy
保险箱私有 socketcomponents/egress/pkg/credentialvault/active_socket.goStartActiveSocketServer
凭证注入 addoncomponents/egress/mitmscripts/system.pyrequest_select_binding_load_active_vault_redact_response_headers
策略/凭证 HTTP 服务components/egress/policy_server.gostartPolicyServerpolicyServer.authorizehandleCredentialVault
renew-intent 发布(ingress)components/ingress/pkg/renewintent/redis.goRedisPublisher.PublishIntentdoPublishshouldSendIntent
renew-intent 消费(server)server/opensandbox_server/integrations/renew_intent/consumer.pyRenewIntentConsumer_brpop_feeder_loopsubmit_from_proxy
server 反代续期桥server/opensandbox_server/integrations/renew_intent/proxy_renew.pyProxyRenewCoordinator.schedule