跳到主要内容

用户态网络栈与安全边界

30 秒导读: microsandbox 里,客机(微VM)的网卡不是接到主机内核的,而是接到主机进程里一段自己写的 TCP/IP 栈(基于 smoltcp)。因为每一个包都要先经过这段用户态代码,主机就能在三个层次上做安全策略:按目的地放行/拒绝(网络策略)、看懂并过滤 DNS、拦截 TLS 看明文。最出名的一招是——API 密钥永远不进 VM,客机只拿到一个占位串,真实值只在请求确实发往被允许的主机时,才由 TLS 代理临时替换进去。

本章是「微沙箱是怎么造出来的」系列的第 5 章。前四章讲进程与生命周期(01)、主机↔客机通信(02)、镜像与根文件系统(03)、客机文件系统(04)。本章讲的是这个系统最有辨识度的安全子系统:网络

代码几乎全在 crates/network/lib 下(相对克隆根)。


1. 这是什么(零基础也能懂)

一句话定义: 把整条客机网络栈搬进主机进程的用户态,让主机成为客机唯一的"网关 + DNS + TLS 前台",从而对客机的一切外联做策略、过滤和密钥保护。

它解决什么问题。 你要在沙箱里跑一段不可信的 AI 生成代码,它可能想联网。你有两个矛盾的诉求:

  • 让它能正常上网(拉 PyPI 包、调 OpenAI API);
  • 又不想让它偷偷把你的密钥外传、访问云元数据端点(169.254.169.254)、或连你内网。

传统做法是靠主机内核的 iptables / 防火墙规则去拦。microsandbox 走了另一条路:客机根本没有真的网络设备——它的"网卡"只是一段 virtio-net,另一头接的是主机进程里一段普通 Rust 代码。既然每个以太网帧都要流过这段代码,主机就有了天然的、无需 root、跨平台一致的检查点。

它能做什么(功能清单):

  • 给每个沙箱分配确定的私有 IP / 网关(用户态虚拟 LAN),客机能 ping 网关、能通过 host.microsandbox.internal 回连主机。
  • 端口发布:把主机端口映射进客机(ports),或把客机服务暴露到主机。
  • DNS 拦截:所有 DNS 查询由主机的转发器代答,支持域名黑名单、DNS rebinding 防护、按域名做策略。
  • 网络策略:按"目的地类别 / IP / 域名 / 端口 / 协议"放行或拒绝(egress + ingress)。
  • TLS 中间人:为被拦截端口(默认 443)动态签发每域名证书,解密看明文再重新加密到真实服务器。
  • 密钥占位符注入:客机只见占位串,真值只在 TLS 代理确认目的地被允许时才替换。

用起来什么样(直觉): SDK 里配置大概长这样(示意):

# 示意,非源码——展示用户看到的配置面
policy = NetworkPolicy.public_only() # 只放行公网,拒绝私网/回环/元数据
policy = policy.allow_domain("api.openai.com") # 显式放行一个域名

sandbox.network(
policy=policy,
tls={"enabled": True}, # 打开 TLS 拦截
secrets=[{ # 密钥永不进 VM
"env_var": "OPENAI_API_KEY",
"value": "sk-real-...", # 真值只留在主机
"placeholder": "$MSB_a8f3b2c1", # 客机只拿到这个
"allowed_hosts": [{"exact": "api.openai.com"}],
}],
)

CLI 侧则用 --net-rule allow@public--net-rule deny@meta 这类短语法(见 crates/cli/lib/net_rule.rs:6 的 token 语法)。

一句话类比: 把主机进程想象成客机唯一能接触的"路由器 + 前台 DNS + 会拆包检查的海关"。客机以为自己在直接上网,其实每一个包都被这台"软路由"看过一遍——而这台软路由完全是用户态的、可编程的。

本节不出现底层代码。下面开始一层层往下钻。


2. 顶层全景(它大概怎么转)

怎么读这张图: 从左到右是一个包的旅程——客机把以太网帧交给 virtio-net,主机侧的 smoltcp 栈解出连接,再按端口分派给不同的代理任务,代理任务才真正代替客机去连外网。

客机(微VM) 主机进程(用户态)
┌──────────┐ 以太网帧 ┌───────────┐ 连接 ┌──────────────┐ 真 socket
│ 应用/curl │ ─────────▶ │ smoltcp 栈 │ ───────▶ │ 分派 + 代理 │ ─────────▶ 外网/主机
│ eth0 │ ◀───────── │ (poll 循环)│ ◀─────── │ TCP/DNS/TLS │ ◀───────── 真服务器
└──────────┘ virtio-net └───────────┘ └──────────────┘
│ 每个帧先被 classify_frame 预检

┌───────────────────────────────┐
│ 策略 policy / DNS 过滤 / TLS MITM │ ← 三层安全检查点
└───────────────────────────────┘

部件一句话职责:

部件干什么在哪个文件(符号)
NetworkConfig用户声明式配置(策略/端口/DNS/TLS/密钥)crates/network/lib/config.rs:28
SmoltcpNetwork编排类:从配置 + 槽位算出 IP/MAC,起 poll 线程crates/network/lib/network.rs:42
SmoltcpBackendlibkrun 的 NetBackend:桥接虚拟网卡 ↔ 无锁队列crates/network/lib/backend.rs:46
SmoltcpDevicesmoltcp 的 phy::Device:单帧槽位设计crates/network/lib/device.rs:35
smoltcp_poll_loop核心事件循环:排帧、驱动栈、服务连接crates/network/lib/stack.rs:206
NetworkPolicy按目的地/端口/协议 first-match 放行/拒绝crates/network/lib/policy/types.rs:43
DnsInterceptor / DnsForwarder拦截客机 DNS,过滤 + 转发crates/network/lib/dns/interceptor.rs:61forwarder.rs:85
TlsState / tls_proxy_taskTLS 中间人:签证书、解密、重加密crates/network/lib/tls/state.rs:28proxy.rs:96
SecretsHandler密钥占位符替换crates/network/lib/secrets/handler.rs:72

主线走一遍(高层,不进代码): 客机启动时,agentd(见 01)从 MSB_NET* 环境变量拿到 IP/网关/DNS,配好 eth0。之后客机发一个包 → virtio-net → SmoltcpBackend::write_frame 把帧塞进 tx_ring → poll 线程醒来 → classify_frame 预检(SYN?DNS?普通 UDP?)→ 对新连接建 smoltcp socket → 连接建立后 spawn 一个 tokio 代理任务 → 代理任务查策略、必要时做 TLS 拦截和密钥替换,再用主机真 socket连出去。回程反向走一遍。


3. 核心原理(逐个机制,由浅入深)

3.1 为什么要用户态网络栈:smoltcp 与单帧槽位

它要解决的小问题: 客机需要一块网卡,但你不能给不可信客机一块真网卡——那等于给它主机内核的网络访问。

思路/直觉: 给客机一块 virtio-net 虚拟网卡,另一头不接内核,而接一段用户态 TCP/IP 实现(smoltcp,一个 no_std 的纯软件网络栈)。这样"网络"就变成了主机进程里可以随便读、改、拦的字节流。整个 crate 的定位就写在门面注释里:

crates/network/lib/lib.rs:1 — "the smoltcp in-process networking engine for sandbox network isolation and policy enforcement."

这个栈扮演"网关"角色。 create_interface(crates/network/lib/stack.rs:136)把 smoltcp 接口配成网关:它拥有网关 IP、回应针对网关的 ARP/NDP,并开启 any_ip 模式——这样 smoltcp 会接受发往任意远端 IP的流量(而不只是发给自己的),配合默认路由,客机的每个外联包都能被这段栈"截获"。子网是 /30(4 个地址:网关 + 客机):

// crates/network/lib/stack.rs:145 —— 每个沙箱一个 /30:网关 + 客机
addrs.push(IpCidr::new(IpAddress::Ipv4(ipv4), 30))

帧怎么在 virtio-net 和 smoltcp 之间流。 边界是两个无锁环形队列 tx_ring(客机→栈)和 rx_ring(栈→客机),外加 eventfd 唤醒。SmoltcpBackend::write_frame 剥掉 12 字节的 virtio-net 头再入队,read_frame 出队时再补一个全零头:

// crates/network/lib/backend.rs:31 —— libkrun 给每帧前缀一个 virtio_net_hdr_v1
const VIRTIO_NET_HDR_LEN: usize = 12;

最巧的设计是"单帧槽位"。 一般 phy::Device 会让 smoltcp 自己去队列里取帧,但 microsandbox 需要在 smoltcp 看到帧之前先偷看一眼(下面 3.2 会讲为什么)。于是 SmoltcpDevice 用一个 pending_rx: Option<Vec<u8>> 槽位:poll 循环先 stage_next_frame() 把一帧放进槽位、检查它,然后 smoltcp 的 receive() 才来消费这一帧(crates/network/lib/device.rs:82stage_next_frame:106receive)。

poll 循环的四个阶段。 整个栈跑在一条名为 smoltcp-poll 的专用 OS 线程上(crates/network/lib/network.rs:201),smoltcp_poll_loop(crates/network/lib/stack.rs:206)每轮做四件事:

阶段做什么
1 排帧tx_ring 弹帧,classify_frame 预检,必要时先建 socket
2 出口+维护冲刷 smoltcp 生成的 ACK/SYN-ACK,跑定时器
3 服务连接在 smoltcp socket 和代理任务通道之间搬数据,派发新连接
4 冲刷+睡眠再冲一遍出口,用 poll(2) 在两个唤醒 fd 上睡到下次事件

3.2 预检分派:为什么要在 smoltcp 之前看一眼

它要解决的小问题: smoltcp 有两个"不方便"的行为——(a) 收到没有对应 socket 的 SYN 会自动回 RST;(b) 它不支持通配端口的 UDP 绑定。如果一切都交给 smoltcp,很多流量根本没法按 microsandbox 的方式处理。

思路: 在帧进 smoltcp 前,用 smoltcp 的 wire 模块零拷贝地解一层,分成四类动作(crates/network/lib/stack.rs:61FrameAction):

分类触发处理
TcpSyn{src,dst}纯 SYN(无 ACK)先建 socket 再放行,避免 smoltcp 自动 RST
UdpRelay{src,dst}非 DNS 的 UDP完全在 smoltcp 之外,由 UDP relay 处理
DnsUDP/53交给 smoltcp 上那个绑定 53 的 UDP socket
PassthroughARP/NDP/ICMP/TCP 数据smoltcp 正常处理

分类逻辑是纯函数、可测。 classify_frame(crates/network/lib/stack.rs:118)解不出来就一律 Passthrough——安全兜底。TCP 只在"SYN 且非 ACK"时才算新连接(classify_transport,crates/network/lib/stack.rs:866)。

关键地址改写:回连主机。 客机看到的目的地是网关 IP,但主机侧真正要连的是 127.0.0.1resolve_host_dst(crates/network/lib/stack.rs:660)在拨号时把"网关 IP"重写成回环,而回给客机的帧仍打上客机期望的 IP:

// crates/network/lib/stack.rs:660 —— 网关 IP → 回环,其余透传
IpAddr::V4(v4) if gateway.ipv4 == Some(v4) =>
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), dst.port())

这正是 host.microsandbox.internal(常量 HOST_ALIAS,crates/network/lib/lib.rs:35)能工作的底层机制:DNS 转发器把这个名字解析成网关 IP,连接时再被 resolve_host_dst 落到主机回环。

地址怎么来的。 SmoltcpNetwork::new(crates/network/lib/network.rs:92)从"沙箱槽位"确定性地推导 MAC / IP。默认 IPv4 池 172.16.0.0/12,每槽一个 /30,客机在块内 +2、网关 +1;IPv6 池 fd42:6d73:62::/48,每槽一个 /64(derive_guest_ipv4 :348derive_guest_ipv6 :379)。只有当主机对该地址族有路由(或用户显式给了地址)时,该族才启用——host_has_ipv4_route(:420)用一次不发包的 UdpSocket::connect 探测路由表。这些参数最后打成 MSB_NET* 环境变量交给客机(guest_env_vars,:234)。

3.3 DNS 拦截与过滤

它要解决的小问题: DNS 是安全的第一道缝。如果客机能直接问任意 DNS 服务器,它就能:解析出你不想让它访问的域名、用 DNS rebinding 把公网名解析成你的内网 IP、或把数据编码进查询名外传。

思路: 让主机成为客机唯一的 DNS 前台。客机的 resolv.conf 指向网关(见 guest_env_varsdns={gateway}),所有 UDP/53 查询都进主机的转发器。

数据流分三段: 拦截器(smoltcp ↔ 通道)→ 转发器(过滤 + 选上游)→ 上游客户端。

客机 dig smoltcp UDP:53 DnsForwarder 上游解析器
┌───────┐ ┌────────────┐ ┌──────────────┐ ┌──────────┐
│query │──▶ │DnsInterceptor│─▶ │策略→黑名单→选上游│ ─────▶│ 1.1.1.1 │
│ │◀── │ process() │◀─ │→rebind 防护 │ ◀──────│ 等 │
└───────┘ └────────────┘ └──────────────┘ └──────────┘

拦截器为什么绑 addr:None DNS socket 绑在所有本地地址的 53 端口(addr: None,crates/network/lib/dns/interceptor.rs:114),这样连 dig @1.1.1.1 这种指定服务器的查询也能被抓住;并且它保存了客机原本瞄准的目的 IP(original_dst),回包时用这个 IP 作源,免得客机的 stub 因为"回包来自网关而不是我问的那台"而丢弃。

转发器是过滤的核心。 DnsForwarder::forward(crates/network/lib/dns/forwarder.rs:148)对每一条查询依次做:

  1. 网络策略检查:DNS 被当作"经由 DNS 传输的 egress"来评估,拒绝时返回 NXDOMAIN(而非 REFUSED)——因为 glibc 之类的 stub 对 REFUSED 不会快速失败,会把 deny-by-default 沙箱里的查询挂死(:167,注释解释得很细)。
  2. 本地合成 host alias:问 host.microsandbox.internal 的 A/AAAA 直接回网关 IP(synthesize_host_alias_response,:548)。
  3. 选上游:瞄准网关的走"运营方配置的上游";瞄准别的解析器(dig @x)则先过 egress 策略,允许才直连、拒绝就合成 NXDOMAIN(decide_upstream,:443)。
  4. rebind 防护:上游回来的答案里若含私有/保留 IP,直接判定为 rebinding 攻击、回 NXDOMAIN(:221)。私有段判定见 crates/network/lib/dns/common/filter.rs:9is_private_ipv4(涵盖 10/8、172.16/12、192.168/16、CGNAT、link-local、loopback)。
  5. 缓存解析结果:把域名→IP 存进"已解析主机名缓存",供后面按域名做策略时反查(见 3.4)。
// crates/network/lib/dns/forwarder.rs:228 —— 上游回来含私有 IP = rebinding,拒绝
if is_private {
return build_status_response(&query_msg, ResponseCode::NXDomain);
}

别的 DNS 协议怎么办。 DnsPortType(crates/network/lib/dns/common/ports.rs:33)按端口分类:UDP/53 = 明文 DNS(拦);TCP/853 = DoT(仅当 TLS MITM 开启时才拦,否则拒绝逼客机退回 TCP/53);DoQ / mDNS / LLMNR / NetBIOS-NS 等一律拒绝,逼 stub 回落到能被看见的明文 53。DoH(TCP/443)刻意不在这里处理——它和普通 HTTPS 共用端口,只能靠 TLS 拦截或 SNI 黑名单,不能靠端口匹配。

上游从哪来。 nameserver 模块(crates/network/lib/dns/nameserver/mod.rs)按序解析:先用配置里显式的 nameservers(主机 OS 解析,不依赖尚未起来的拦截器),否则读主机的解析器——macOS 优先走 SystemConfiguration 动态存储(scdynamicstore.rs,因为 VPN/split-DNS 会让 /etc/resolv.conf 过期),Linux 以 /etc/resolv.conf 为准,Windows 走 IP Helper。

3.4 网络策略:按目的地放行/拒绝

它要解决的小问题: 需要一套既能表达"只准上公网"、又能表达"额外放行 api.openai.com、封掉 169.254.169.254"的规则语言,并且要失败关闭(fail closed)。

思路: 一条有序规则表 + 两个方向默认动作,首个匹配即生效(first-match-wins)。这就是 SDK 的 NetworkPolicy(crates/network/lib/policy/types.rs:43),也是 CLI --net-rule 解析后的产物。

目的地能按什么匹配。 Destination(crates/network/lib/policy/types.rs:126):Any / Cidr / Domain(精确域名)/ DomainSuffix(域名后缀)/ Group(预定义类别)。类别 DestinationGroup 把 IP 空间划成不相交的几类,由单一分类器 addr_classify(crates/network/lib/policy/destination.rs:38)判定:

类别含义备注
Host本沙箱网关 / host.microsandbox.internal优先级最高,压过 Private
Metadata169.254.169.254优先级压过 LinkLocal
Loopback127.0.0.0/8、::1
PrivateRFC1918 + ULA + CGNAT
LinkLocal169.254/16、fe80::/10
Multicast224.0.0.0/4、ff00::/8
Public以上都不是定义为其余的补集

Public 用"补集"而不是"排除清单"实现,这样新增一个类别时,分类器的穷尽匹配会强制你更新,不会漏(destination.rs:38 的注释和 classifier_covers_every_destination_group 见证测试)。默认策略 public_only()(types.rs:273)= 默认拒绝 egress + 一条允许 DNS + 一条允许 Public

难点:域名规则和 IP 层不在同一时刻。 包到达时你只有 IP,没有域名。microsandbox 的解法是一个三态求值 EgressEvaluation(types.rs:237):Allow / Deny / DeferUntilHostname。域名从哪来由 HostnameSource(types.rs:212)决定:

  • Deferred(SYN 时,还不知道 SNI):碰到 Domain/DomainSuffix 规则就返回 DeferUntilHostname——先放 SYN 过、等第一次数据(SNI)再评估;
  • Sni(TLS ClientHello 里的 SNI):按字节比;
  • CacheOnly(无 SNI):去"已解析主机名缓存"反查这个 IP 曾被解析成什么名字。

一个精妙的安全细节:allow 规则要求缓存背书,deny 不要求。matches_egress_destination_with_source(crates/network/lib/policy/types.rs:789):

// crates/network/lib/policy/types.rs:803 —— allow 侧要求 DNS 缓存里确有 IP↔名绑定
let cache_matches =
|| shared.any_resolved_hostname(addr, |hostname| hostname == domain.as_str());
(name_matches && (action.is_deny() || cache_matches())).into()

含义:客机不能靠"随便声明一个 SNI 指向一个没解析过的 IP"来骗过 Domain-allow 规则(防止 lax-SNI 服务器伪装);但 Domain-deny 只看 SNI 就拦,这样直连 IP 也绕不过域名黑名单。后缀匹配 matches_suffix(types.rs:854)带标签边界,example.com 不会误配 evilexample.com

域名先规范化再存。 规则里的域名是 DomainName 类型(crates/network/lib/policy/name.rs:51),构造时就小写化、去尾点,匹配时退化成字节相等——和 DNS 缓存存的规范形一致,不会因大小写/尾点漏配。

3.5 TLS 中间人:为被拦截连接动态签证书

它要解决的小问题: 一旦是 HTTPS,主机看到的只是密文——域名黑名单、密钥保护全都失效。要么放弃这些能力,要么把 TLS 拆开看明文。

思路: 对被拦截端口(默认 [443],crates/network/lib/tls/config.rs:24)做 MITM:客机以为在跟真服务器握手,其实是在跟主机握手;主机用一张为该域名现签的证书冒充服务器,解出明文,再自己作为客户端跟真服务器建一条 TLS。客机之所以信这张假证书,是因为启动时主机把自签 CA 装进了客机信任库(ca_cert_pem,network.rs:272)。

客机 TLS 主机(MITM 代理) 真服务器 TLS
┌────────┐ 握手 ┌──────────────────┐ 握手 ┌──────────┐
│ curl │◀──────▶│ 假证书(现签) │◀───────▶│ api.x.com │
│信任 CA │ 明文 │ ↑解密 ↓看明文/改写 │ 明文 │ │
└────────┘ └──────────────────┘ └──────────┘
↑ SecretsHandler 在这里替换密钥

证书怎么现签。 CertAuthority::generate(crates/network/lib/tls/ca.rs:26)造一个自签 CA;generate_domain_cert(crates/network/lib/tls/certgen.rs:56)用它给某域名签叶子证书,SAN 填该域名,并把 not_before 回拨 2 秒以容忍主机(签)和客机(验)之间的亚秒级时钟偏移(certgen.rs:117)。签好的 ServerConfig 连同过期时间进 LRU 缓存(TlsState::get_or_generate_cert,state.rs:110),临过期 5 分钟会重签,避免长命沙箱端出过期证书。

代理任务怎么跑。 tls_proxy_task(crates/network/lib/tls/proxy.rs:96)的流程:

  1. 从通道缓冲字节,直到能从 ClientHello 里解出 SNI(extract_sni,手写解析器,crates/network/lib/tls/sni.rs:14),带 10 秒超时防止慢速客机占坑。
  2. 用 SNI 跑一遍域名策略(HostnameSource::Sni)——这就是 3.4 的"延迟求值"落地的地方;拒绝就 mark_policy_denied 关连接。
  3. 若命中 bypass 名单(should_bypass,state.rs:134),就不解密、纯 TCP 拼接转发(bypass_relay);否则进 intercept_relay(proxy.rs:237):用缓存的每域名 ServerConfig 终止客机 TLS,再用 connector 连真服务器,双向搬明文。

上游校验与"信任主机 CA"。 默认 verify_upstream=true,主机侧用系统根证书校验真服务器(build_upstream_connector,state.rs:253);关掉则用 NoVerify(state.rs:202)接受一切。另有一个 opt-in 的 trust_host_cas(config.rs:68):把主机信任库里的根 CA打成 PEM 塞进客机(collect_host_cas,crates/network/lib/tls/host_cas.rs:38),让客机在企业 MITM 代理(Zscaler、Cloudflare Warp 等)后面也能出网。这个函数只吐 CERTIFICATE 块、按 DER 去重,结构上保证私钥不会漏进客机

QUIC 阻断。 TLS 拦截开启时,发往被拦截端口的 UDP 会被丢弃(block_quic_on_intercept,默认 true,见 stack.rs:378),逼 HTTP/3 回落到可被拦截的 TCP/TLS。

3.6 招牌特性:密钥永不进 VM

它要解决的小问题: AI agent 常常需要一个 API 密钥去调外部服务。但你在沙箱里跑的是不可信代码——只要密钥进了 VM 的环境变量,它随时可以把密钥打印、写盘、或发到攻击者的服务器。

思路(占位符注入): 客机拿到的从来不是真密钥,而是一个占位串(如 $MSB_a8f3b2c1)。真值只留在主机。当客机发出的请求经过 TLS 代理、且目的地确实是这个密钥被允许发往的主机时,代理才在明文里把占位串换成真值。整个模块的门面注释把这句话说死了:

crates/network/lib/secrets/mod.rs:1 — "the sandbox receives a placeholder string (e.g. $MSB_a8f3b2c1), never the real value. The TLS proxy substitutes the real value only when the request goes to an allowed host."

占位符怎么进客机。 guest_env_vars(crates/network/lib/network.rs:262)把每个密钥的 env_var → placeholder 作为环境变量暴露给客机——注意暴露的是 placeholder,不是 value。而 SecretEntry(crates/network/lib/secrets/config.rs:30)的 value 字段甚至连 Debug 都被 [REDACTED] 掉(config.rs:245)。

替换只在被允许的主机发生。 TLS 代理在 intercept_relay 里为这条连接建一个 SecretsHandler(crates/network/lib/tls/proxy.rs:250),构造时就用 SNI 过滤出对本连接合格的密钥:

// crates/network/lib/tls/proxy.rs:253 —— 只有 SNI + DNS 绑定都对得上,密钥才"合格"
SecretsHandler::new_tls_intercepted(&tls_state.secrets, sni_name, guest_dst.ip(), &shared)

new_tls_intercepted(crates/network/lib/secrets/handler.rs:483)对每个密钥用 secret_host_allowed 判定:SNI 要匹配 allowed_hosts(HostPattern::Exact/Wildcard/Any,config.rs:69),而且——对 DNS-pin 的连接——还要求"已解析主机名缓存"里确有 guest_ip → allowed_host 的绑定。匹配的进 eligible_for_substitution,不匹配的进 ineligible_for_substitution(将来见到它的占位串就当违规)。

明文里怎么换。 每批解密后的明文,forward_plaintext(tls/proxy.rs:424)交给 SecretsHandler::substitute(handler.rs:648)。它按 HTTP 语义分区替换,替换范围受每个密钥的 SecretInjection 开关约束(config.rs:148):默认改 header 和 Basic Auth,不动 query 参数和 body(改 body 要重算 Content-Length / 重编 chunked,风险更大所以默认关)。它还处理 HTTP/1 和 HTTP/2(HPACK 解码/编码),并用一个滑动窗口 prev_tail 防止占位串跨 TCP 写被拆开而漏检。

看到占位串却发往不该发的主机 = 违规。 若在明文里检出一个"对本连接不合格"的占位串,substitute 返回一个 ViolationAction(config.rs:176):Block / BlockAndLog(默认)/ BlockAndTerminate(直接杀沙箱)/ Passthrough(对某些主机原样放行占位串)。forward_plaintext 据此丢连接、必要时 trigger_termination():

// crates/network/lib/tls/proxy.rs:456 —— 占位串发往不该发的主机:丢连接,可选杀沙箱
if matches!(action, ViolationAction::BlockAndTerminate) {
shared.trigger_termination();
}
return Err(io::Error::new(io::ErrorKind::PermissionDenied,
"secret violation: placeholder sent to disallowed host"));

为什么它安全: 真密钥从头到尾没进过 VM 的地址空间。就算客机把整个环境变量、整块内存都外传,传出去的也只是 $MSB_a8f3b2c1。而占位串一旦想发往非白名单主机,主机在明文层就拦住了——攻击者既拿不到真值,也没法把占位串"寄"给自己去换。默认 require_tls_identity=true(config.rs:62)进一步要求"必须是被拦截的 TLS 连接(有可验证的 SNI 身份)才替换",堵死了明文 HTTP 伪造 Host 头骗替换的路。


4. 深入实现(给要读源码的人)

连接的完整生命周期(TCP)。 poll 循环里,ConnectionTracker(crates/network/lib/conn.rs:73)在 SYN 时建 smoltcp socket(此前先过一遍策略,见 stack.rs:352evaluate_egress_with_source),连接 ESTABLISHED 后 take_new_connections 摘出通道两端,再按目的端口派发(stack.rs:465 起):

目的端口/条件派发到文件
被拦截端口(默认 443)TLS MITM 代理tls::proxy::spawn_tls_proxy(tls/proxy.rs:65)
53DNS over TCP 代理DnsTcpProxy::spawn(dns/proxies/tcp.rs)
853 且 TLS 开启DoT 代理DotProxy::spawn(dns/proxies/dot.rs)
其余普通 TCP 代理proxy::spawn_tcp_proxy(proxy.rs:148)

普通 TCP 代理里也有密钥逻辑。 即使不是 443,若配置了密钥且允许明文(require_tls_identity=false),spawn_tcp_proxy 会先分类首个 flight(TLS 还是明文 HTTP),明文则按 Host 头建 new_plain_http 处理器(proxy.rs:314)。它还有个巧思:先连上游再完成密钥分类(proxy.rs:259 注释),这样 SSH/SMTP 这类"服务器先说话"的协议能一边等 banner 一边省下偷看预算。

ProxyConnectState:代理任务如何影响客机看到的 TCP 语义。 代理任务退出时会留下一个状态(Connected / PolicyDenied / UpstreamConnectFailed,conn.rs:44),poll 循环据此决定给客机回干净 FIN 还是 RST——例如策略拒绝就让客机看到连接被拒,而非无声挂起。

端口发布(ingress)。 PortPublisher(crates/network/lib/publisher.rs:84)为每个 PublishedPort 在主机起 tokio 监听器,把入站连接排队给 poll 循环去建"进客机"的 smoltcp socket;入站也走 evaluate_ingress 策略。UDP 发布额外维护"主机对端 → 客机内临时端口"的映射,60 秒空闲超时。

ICMP 与 UDP relay。 客机 ping 网关由 handle_gateway_icmp_echo(stack.rs:690)在栈里本地应答;ping 外部则由 IcmpRelay(crates/network/lib/icmp_relay.rs)转成主机 echo(受策略约束)。非 DNS 的 UDP 走 UdpRelay(crates/network/lib/udp_relay.rs),因为 smoltcp 不便做通配端口 UDP。二者都需要 any_ip 但又不能让 smoltcp 替远端伪造 ICMP 回应,所以只本地处理网关 IP、其余不碰(stack.rs:684 注释)。

背压与丢帧。 SmoltcpDevice::transmit(device.rs:110)在 rx_ring 满时返回 None,让 smoltcp 把数据留在 socket 缓冲里重试;SmoltcpBackend::write_frame(backend.rs:68)在 tx_ring 满时直接丢帧(当成有损网卡队列),而不是让 virtio worker 卡住——因为这个 backend 暴露的是唤醒管道而非真可写 socket。

本章不覆盖协议里 TcpConnect / TcpData 的帧编码——那是主机↔客机二进制帧协议的一部分,见 02-protocol-relay.md


5. 巧妙之处(可借鉴的技术)

  • 用户态网络栈 = 天然、无 root、跨平台的检查点。 不靠内核 iptables,把"网络"变成进程内可编程的字节流,策略/DNS/TLS 三层都在同一处执行(crates/network/lib/lib.rs:1)。
  • 单帧槽位让"先偷看再放行"成为可能。 stage_next_frame + pending_rx 把预检插到 smoltcp 之前,才能在 SYN 前建 socket、避免自动 RST(device.rs:82)。
  • 域名策略的三态延迟求值。 IP 层看不到域名,于是 DeferUntilHostname 先放 SYN、等 SNI 再判(policy/types.rs:237)。
  • allow 要缓存背书、deny 只看 SNI。 一行布尔表达式同时挡住"伪 SNI 骗放行"和"直连 IP 绕黑名单"(policy/types.rs:803)。
  • DNS 拒绝回 NXDOMAIN 而非 REFUSED。 迎合真实 stub 解析器的失败语义,避免 deny-by-default 沙箱把查询挂死(dns/forwarder.rs:167)。
  • 证书 not_before 回拨 2 秒。 主机签、客机验,亚秒时钟偏移会让刚签的证书"还没生效",回拨消除这个坑(tls/certgen.rs:117)。
  • 密钥占位符注入。 真值不进 VM,只在明文层、且目的地被允许时替换;违规可直接杀沙箱(secrets/mod.rs:1tls/proxy.rs:456)。
  • host_cas 结构性防漏。 只 emit CERTIFICATE 标签、DER 去重,从类型和编码两端保证私钥不进客机(tls/host_cas.rs:29)。

6. 边界与局限(诚实)

  • DoH(TCP/443)无法靠端口拦。 它与 HTTPS 共用端口,只能靠 TLS 拦截 + SNI/IP 黑名单;ports.rs:23 明说这是已知的绕过面。
  • QUIC/HTTP-3 只能"阻断",不能拦。 crate 内没有 QUIC MITM 库,所以对 DoQ 和被拦截端口的 UDP 采取丢弃逼回落(ports.rsstack.rs:378),而不是解密。
  • HTTP/2 的 body 占位符不支持替换。 SecretInjection.body 对 HTTP/2 DATA 帧不支持,匹配到的 body 占位串会被 block(secrets/config.rs:163)。固定长度 HTTP/1 body 超过 16 MiB 也不替换。
  • 占位符替换依赖 TLS 拦截打开。 关掉 TLS 时,除非某密钥显式 require_tls_identity=false,否则加密流量里没法替换(默认 true)。
  • slot 上限 65535。 MAC/IPv6 只编码低 16 位,所以并发沙箱数受此限(network.rs:30)。
  • 单帧槽位是串行点。 整条栈跑在一条 poll 线程上;高并发下代理任务在 tokio 上并行,但帧的 classify/ingress 是单线程串行的。

7. 横向对比

同属 ai-agent-reference 货架,别的沙箱/agent-runtime 方案通常直接复用宿主内核网络 + iptables,或干脆给容器一个 CNI 接口。microsandbox 的取舍是把网络栈拉进用户态:换来的是跨平台一致(macOS/Linux/Windows 同一套策略代码)、无需 root、以及在"包 / DNS / TLS 明文"三个粒度上统一执法的能力;代价是要自己实现/驱动一整套 TCP/IP(smoltcp)、DNS 转发器和 TLS MITM。"密钥永不进 VM"这一招在纯内核方案里很难做——因为那需要在数据平面上看到并改写明文,而 microsandbox 恰好本来就站在明文的必经之路上。


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

主题文件路径符号名
crate 门面 / HOST_ALIAScrates/network/lib/lib.rsHOST_ALIAS
用户面配置crates/network/lib/config.rsNetworkConfigDnsConfigPublishedPort
编排 / IP 推导 / env varscrates/network/lib/network.rsSmoltcpNetworkderive_guest_ipv4guest_env_varshost_cas_cert_pem
poll 循环 / 预检 / 地址改写crates/network/lib/stack.rssmoltcp_poll_loopclassify_frameFrameActionresolve_host_dstcreate_interface
smoltcp 设备(单帧槽位)crates/network/lib/device.rsSmoltcpDevicestage_next_frame
libkrun 网卡后端crates/network/lib/backend.rsSmoltcpBackendwrite_frameread_frame
连接跟踪 / 代理状态crates/network/lib/conn.rsConnectionTrackerProxyConnectState
端口发布crates/network/lib/publisher.rsPortPublisher
普通 TCP 代理crates/network/lib/proxy.rsspawn_tcp_proxyclassify_first_flight
UDP / ICMP relaycrates/network/lib/udp_relay.rsicmp_relay.rsUdpRelayIcmpRelay
网络策略类型 / 求值crates/network/lib/policy/types.rsNetworkPolicyEgressEvaluationHostnameSourcematches_egress_destination_with_sourcepublic_only
目的地分类crates/network/lib/policy/destination.rsaddr_classifymatches_groupmatches_host
域名规范化crates/network/lib/policy/name.rsDomainName
DNS 拦截器crates/network/lib/dns/interceptor.rsDnsInterceptorDnsQuery
DNS 转发器 / rebind / 选上游crates/network/lib/dns/forwarder.rsDnsForwarderforwarddecide_upstreamsynthesize_host_alias_response
DNS 私有 IP 判定crates/network/lib/dns/common/filter.rsis_private_ipv4is_private_ipv6
DNS 端口分类crates/network/lib/dns/common/ports.rsDnsPortType
DNS 上游发现crates/network/lib/dns/nameserver/mod.rsread_host_dns_serversresolve_nameservers
TLS CA / 现签叶子证书crates/network/lib/tls/ca.rscertgen.rsCertAuthoritygenerate_domain_cert
TLS 状态 / 证书缓存 / bypasscrates/network/lib/tls/state.rsTlsStateget_or_generate_certshould_bypassNoVerify
SNI 解析crates/network/lib/tls/sni.rsextract_sni
TLS MITM 代理crates/network/lib/tls/proxy.rstls_proxy_taskintercept_relayforward_plaintext
主机 CA 收集crates/network/lib/tls/host_cas.rscollect_host_cas
密钥配置crates/network/lib/secrets/config.rsSecretEntryHostPatternViolationActionSecretInjection
密钥替换处理器crates/network/lib/secrets/handler.rsSecretsHandlernew_tls_interceptedsubstitute
CLI 规则语法crates/cli/lib/net_rule.rsparse_rule_token