数据截至 (上游 commit aae05d825bb5)
用户态网络栈与安全边界
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/types.rs:38 |
SmoltcpNetwork | 编排类:从配置 + 槽位算出 IP/MAC,起 poll 线程 | crates/network/lib/network.rs:58 |
SmoltcpBackend | libkrun 的 NetBackend:桥接虚拟网卡 ↔ 无锁队列 | crates/network/lib/netstack/backend.rs:46 |
SmoltcpDevice | smoltcp 的 phy::Device:单帧槽位设计 | crates/network/lib/netstack/device.rs:35 |
smoltcp_poll_loop | 核心事件循环:排帧、驱动栈、服务连接 | crates/network/lib/netstack/poll.rs:226 |
NetworkPolicy | 按目的地/端口/协议 first-match 放行/拒绝 | crates/network/lib/policy/types.rs:43 |
DnsInterceptor / DnsForwarder | 拦截客机 DNS,过滤 + 转发 | crates/network/lib/dns/interceptor.rs:60、forwarder.rs:86 |
TlsState / TlsProxy | TLS 中间人:签证书、解密、重加密 | crates/network/lib/tls/state.rs:29、proxy.rs:41 |
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/netstack/poll.rs:155)把 smoltcp 接口配成网关:它拥有网关 IP、回应针对网关的 ARP/NDP,并开启 any_ip 模式——这样 smoltcp 会接受发往任意远端 IP的流量(而不只是发给自己的),配合默认路由,客机的每个外联包都能被这段栈"截获"。子网是 /30(4 个地址:网关 + 客机):
// crates/network/lib/netstack/poll.rs:164 —— 每个沙箱一个 /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/netstack/backend.rs:26 —— 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() 才来消费这一帧(netstack/device.rs:82的stage_next_frame、:114的receive`)。
poll 循环的四个阶段。 整个栈跑在一条名为 smoltcp-poll 的专用 OS 线程上(crates/network/lib/network.rs:346),smoltcp_poll_loop(crates/network/lib/netstack/poll.rs:226)每轮做四件事:
| 阶段 | 做什么 |
|---|---|
| 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/netstack/poll.rs:64 的 FrameAction):
| 分类 | 触发 | 处理 |
|---|---|---|
TcpSyn{src,dst} | 纯 SYN(无 ACK) | 先建 socket 再放行,避免 smoltcp 自动 RST |
UdpRelay{src,dst} | 非 DNS 的 UDP | 完全在 smoltcp 之外,由 UDP relay 处理 |
Dns | UDP/53 | 交给 smoltcp 上那个绑定 53 的 UDP socket |
Passthrough | ARP/NDP/ICMP/TCP 数据 | smoltcp 正常处理 |
分类逻辑是纯函数、可测。 classify_frame(crates/network/lib/netstack/poll.rs:137)解不出来就一律 Passthrough——安全兜底。TCP 只在"SYN 且非 ACK"时才算新连接(classify_transport,crates/network/lib/netstack/poll.rs:1065)。
关键地址改写:回连主机。 客机看到的目的地是网关 IP,但主机侧真正要连的是 127.0.0.1。resolve_host_dst(crates/network/lib/netstack/poll.rs:827)在拨号时把"网关 IP"重写成回环,而回给客机的帧仍打上客机期望的 IP:
// crates/network/lib/netstack/poll.rs:827 —— 网关 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:47)能工作的底层机制:DNS 转发器把这个名字解析成网关 IP,连接时再被 resolve_host_dst 落到主机回环。
地址怎么来的。 SmoltcpNetwork::new(crates/network/lib/network.rs:148)从"沙箱槽位"确定性地推导 MAC / IP。默认 IPv4 池 172.16.0.0/12,每槽一个 /30,客机在块内 +2、网关 +1;IPv6 池 fd42:6d73:62::/48,每槽一个 /64(derive_guest_ipv4 network.rs:604、derive_guest_ipv6 network.rs:635)。只有当主机对该地址族有路由(或用户显式给了地址)时,该族才启用——host_has_ipv4_route(network.rs:676)用一次不发包的 UdpSocket::connect 探测路由表。这些参数最后打成 MSB_NET* 环境变量交给客机(guest_env_vars,network.rs:379)。
3.3 DNS 拦截与过滤
它要解决的小问题: DNS 是安全的第一道缝。如果客机能直接问任意 DNS 服务器,它就能:解析出你不想让它访问的域名、用 DNS rebinding 把公网名解析成你的内网 IP、或把数据编码进查询名外传。
思路: 让主机成为客机唯一的 DNS 前台。客机的 resolv.conf 指向网关(见 guest_env_vars 里 dns={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:171)对每一条查询依次做:
- 网络策略检查:DNS 被当作"经由 DNS 传输的 egress"来评估,拒绝时返回 NXDOMAIN(而非 REFUSED)——因为 glibc 之类的 stub 对 REFUSED 不会快速失败,会把 deny-by-default 沙箱里的查询挂死(
:167,注释解释得很细)。 - 本地合成 host alias:问
host.microsandbox.internal的 A/AAAA 直接回网关 IP(synthesize_host_alias_response,:548)。 - 选上游:瞄准网关的走"运营方配置的上游";瞄准别的解析器(
dig @x)则先过 egress 策略,允许才直连、拒绝就合成 NXDOMAIN(decide_upstream,:443)。 - rebind 防护:上游回来的答案里若含私有/保留 IP,直接判定为 rebinding 攻击、回 NXDOMAIN(
:221)。私有段判定见crates/network/lib/dns/common/filter.rs:13的is_private_ipv4(涵盖 10/8、172.16/12、192.168/16、CGNAT、link-local、loopback)。 - 缓存解析结果:把域名→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:144):Any / Cidr / Domain(精确域名)/ DomainSuffix(域名后缀)/ Group(预定义类别)。类别 DestinationGroup 把 IP 空间划成不相交的几类,由单一分类器 addr_classify(crates/network/lib/policy/destination.rs:40)判定:
| 类别 | 含义 | 备注 |
|---|---|---|
Host | 本沙箱网关 / host.microsandbox.internal | 优先级最高,压过 Private |
Metadata | 169.254.169.254 | 优先级压过 LinkLocal |
Loopback | 127.0.0.0/8、::1 | |
Private | RFC1918 + ULA + CGNAT | |
LinkLocal | 169.254/16、fe80::/10 | |
Multicast | 224.0.0.0/4、ff00::/8 | |
Public | 以上都不是 | 定义为其余的补集 |
Public 用"补集"而不是"排除清单"实现,这样新增一个类别时,分类器的穷尽匹配会强制你更新,不会漏(destination.rs:40 的注释和 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:917):
// 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 缓存存的规范形一致,不会因大小写/尾点漏配。