fix(chv): cell 后端由 nspawn 改为纯 stdlib userns 启动器

nspawn --directory=/ 在 systemd 255.4 上走不通(2026-11-07 真机
实测,修一个冒一个):裸 --no-new-privileges 被当成要值吞掉
下一个参数;--directory=/ 被根目录检查挡住;--volatile=state
又要 /run/host/os-release 挂载点与空 /dev(--tmpfs=/dev 不
生效)。cell 从未 live 跑通过(阶段 1 只测参数表纯函数)。
bwrap 作 chv 被 AppArmor userns 门挡住,newuidmap 客机未装。

新后端(同文件、同 CLI、同调用方):root 起进程,中间进程
unshare 用户 ns 并自己写 uid_map/gid_map(内 0 → 外 chv
1000),再进 mount/pid/net ns、挂 fresh /proc、/tmp tmpfs、
遮蔽敏感路径(目录盖 tmpfs、文件绑 /dev/null)、设
no_new_privileges + seccomp(禁 io_uring 三件套)+ 丢
capability,最后 exec payload;父进程配 veth/NAT、等退出码、
清 NAT。veth/NAT/nsenter 流程与旧版一致,seccomp BPF 与
capability 语义与 P2 设计一致。无新依赖、无 sysctl 改动。
This commit is contained in:
host committed 2026-11-08 09:45:00 +00:00
1 parent 42b295fc71
commit a2a5624736
2 files changed
+630 -160

No files matched your search

+129 -57
View File
@@ -1,9 +1,10 @@
"""对齐 Muse P2 测试:客机 nspawn 单元 + 浏览器子代理独立。
"""对齐 Muse P2 测试:客机 userns 单元 + 浏览器子代理独立。
只测本轨交付物(chv-guest/chv-cell.py、chv-broker.py、chv-browser.py、
build.sh 干跑、buildinfo.py 内容项)。全部用假 socket/假进程/假
playwright,绝不连真实网络、不起真实 VM、不改生产行为。
"""
import ctypes
import importlib.util
import json
import os
@@ -38,63 +39,58 @@ BUILDINFO = _load_script("buildinfo.py")
class CellArgvTest(unittest.TestCase):
def test_private_users_maps_cell_root_to_chv(self):
argv = CELL.build_nspawn_argv(["true"])
self.assertIn("--private-users=1000:65536", argv)
self.assertIn("--machine=chv-cell", argv)
self.assertIn("--directory=/", argv)
self.assertIn("--as-pid2", argv)
self.assertIn("--no-new-privileges", argv)
spec = CELL.build_sandbox_spec(["true"])
self.assertEqual(spec["backend"], "userns")
self.assertEqual(spec["uid_map"], "0 1000 1")
self.assertEqual(spec["gid_map"], "0 1000 1")
self.assertEqual(spec["cell"], "chv-cell")
self.assertTrue(spec["no_new_privileges"])
def test_muse_named_capabilities_dropped(self):
argv = CELL.build_nspawn_argv(["true"])
spec = CELL.build_sandbox_spec(["true"])
for cap in ("CAP_SYS_PTRACE", "CAP_NET_ADMIN", "CAP_SYS_ADMIN",
"CAP_NET_RAW", "CAP_SYS_MODULE"):
self.assertIn("--drop-capability=%s" % cap, argv)
# 绝不能出现“保留这些能力”的写法。
for token in argv:
self.assertFalse(token.startswith("--capability=CAP_SYS_PTRACE"))
self.assertFalse(token.startswith("--capability=CAP_NET_ADMIN"))
self.assertIn(cap, spec["capabilities_dropped"])
def test_io_uring_blocked_in_syscall_filter(self):
argv = CELL.build_nspawn_argv(["true"])
filt = [a for a in argv if a.startswith("--system-call-filter=")]
self.assertEqual(len(filt), 1)
self.assertIn("@system-service", filt[0])
spec = CELL.build_sandbox_spec(["true"])
filt = spec["syscall_filter"]
self.assertIn("@system-service", filt)
for call in ("io_uring_setup", "io_uring_enter",
"io_uring_register"):
self.assertIn("~%s" % call, filt[0])
self.assertIn("~%s" % call, filt)
def test_secrets_and_browser_hidden_from_cell(self):
argv = CELL.build_nspawn_argv(
spec = CELL.build_sandbox_spec(
["true"],
playwright_package_dir="/usr/local/lib/python3.12/dist-packages/playwright")
hidden = [a.split("=", 1)[1] for a in argv
if a.startswith("--inaccessible=")]
for path in ("/etc/chv", "/home/chv/.git-credentials",
"/usr/local/share/ms-playwright",
"/usr/local/lib/python3.12/dist-packages/playwright"):
self.assertIn(path, hidden)
self.assertIn(path, spec["hide"])
def test_writable_binds_and_tmpfs(self):
argv = CELL.build_nspawn_argv(["true"])
self.assertIn("--bind=/home/chv/workspace", argv)
self.assertIn("--bind=/home/chv/.codex", argv)
self.assertIn("--bind=/run/chv", argv)
self.assertIn("--tmpfs=/tmp", argv)
self.assertIn("--chdir=/home/chv/workspace", argv)
self.assertIn("--setenv=CHV_CELL=1", argv)
self.assertIn("--setenv=HOME=/home/chv", argv)
spec = CELL.build_sandbox_spec(["true"])
# 新后端不 chroot:根就是客机根,workspace/.codex/broker
# 直接就是同名目录(单元内 root 即 chv,属主不变)。
self.assertEqual(spec["workspace"], "/home/chv/workspace")
self.assertEqual(spec["codex_home"], "/home/chv/.codex")
self.assertEqual(spec["broker_dir"], "/run/chv")
self.assertEqual(spec["tmpfs"], ["/tmp"])
self.assertEqual(spec["chdir"], "/home/chv/workspace")
self.assertEqual(spec["env"]["CHV_CELL"], "1")
self.assertEqual(spec["env"]["HOME"], "/home/chv")
def test_veth_default_and_shared_opt_in(self):
self.assertIn("--network-veth",
CELL.build_nspawn_argv(["true"]))
self.assertNotIn("--network-veth",
CELL.build_nspawn_argv(["true"],
network="shared"))
self.assertEqual(
CELL.build_sandbox_spec(["true"])["network"], "veth")
self.assertEqual(
CELL.build_sandbox_spec(["true"], network="shared")["network"],
"shared")
with self.assertRaises(ValueError):
CELL.build_nspawn_argv(["true"], network="macvlan")
CELL.build_sandbox_spec(["true"], network="macvlan")
with self.assertRaises(ValueError):
CELL.build_nspawn_argv([])
CELL.build_sandbox_spec([])
def test_env_forward_allowlist(self):
env = {"SUB2API_API_KEY": "sk-test",
@@ -102,10 +98,85 @@ class CellArgvTest(unittest.TestCase):
"GITEA_TOKEN": "must-never-enter-cell",
"XCM_API_KEY": "xk-test"}
with mock.patch.dict(os.environ, env, clear=False):
argv = CELL.build_nspawn_argv(["true"])
self.assertIn("--setenv=SUB2API_API_KEY=sk-test", argv)
self.assertIn("--setenv=XCM_API_KEY=xk-test", argv)
self.assertFalse(any("GITEA_TOKEN" in a for a in argv))
spec = CELL.build_sandbox_spec(["true"])
real = CELL._forwarded_env()
# 规约只印名不印值:白名单里的值一律 <redacted>。
self.assertEqual(spec["env"]["SUB2API_API_KEY"], "<redacted>")
self.assertEqual(spec["env"]["SUB2API_BASE_URL"], "<redacted>")
self.assertEqual(spec["env"]["XCM_API_KEY"], "<redacted>")
self.assertNotIn("GITEA_TOKEN", spec["env"])
# 真实转发:白名单进、GITEA_TOKEN 绝不进。
self.assertEqual(real["SUB2API_BASE_URL"],
"https://example.invalid")
self.assertNotIn("GITEA_TOKEN", real)
class CellSeccompTest(unittest.TestCase):
def test_filter_structure(self):
raw = CELL.build_seccomp_filter()
self.assertEqual(len(raw), 80) # 10 条指令 × 8 字节
insns = [struct.unpack_from("HBBI", raw, i * 8)
for i in range(10)]
# [0] 读 arch;[1] 非 x86_64 跳去放行;[2] 读 nr。
self.assertEqual(insns[0], (0x20, 0, 0, 4))
self.assertEqual(insns[1], (0x15, 0, 7, 0xC000003E))
self.assertEqual(insns[2], (0x20, 0, 0, 0))
# 三个 io_uring 号各拦一次,命中回 EPERM,末尾放行。
for idx, nr in ((3, 425), (5, 426), (7, 427)):
self.assertEqual(insns[idx], (0x15, 0, 1, nr))
for idx in (4, 6, 8):
self.assertEqual(insns[idx], (0x06, 0, 0, 0x00050001))
self.assertEqual(insns[9], (0x06, 0, 0, 0x7fff0000))
def test_filter_blocks_io_uring_setup_live(self):
"""子进程里装过滤后调 io_uring_setup 须得 EPERM,
getpid 仍正常。装不上(环境无 seccomp)则跳过。"""
pid = os.fork()
if pid == 0:
try:
CELL._set_no_new_privileges()
CELL._apply_seccomp_filter()
libc = ctypes.CDLL("libc.so.6", use_errno=True)
ctypes.set_errno(0)
ret = libc.syscall(425, 0, 0, 0, 0, 0, 0)
err = ctypes.get_errno()
ok = (ret == -1 and err == 1)
ret2 = libc.syscall(39)
ok = ok and ret2 > 0
os._exit(0 if ok else 1)
except Exception:
os._exit(2)
_, status = os.waitpid(pid, 0)
code = os.waitstatus_to_exitcode(status)
if code == 2:
self.skipTest("seccomp 不可用,跳过 live 验证")
self.assertEqual(code, 0)
class CellUsernsTest(unittest.TestCase):
def test_write_id_maps_live(self):
"""子进程里 unshare 用户 ns 并写映射:内 0 → 外 1000。
环境建不了 userns(容器/AppArmor 限制)则跳过。"""
pid = os.fork()
if pid == 0:
try:
os.unshare(CELL.CLONE_NEWUSER)
CELL._write_id_maps()
with open("/proc/self/uid_map") as fh:
uid_map = fh.read().strip()
with open("/proc/self/gid_map") as fh:
gid_map = fh.read().strip()
ok = (uid_map == "0 1000 1"
and gid_map == "0 1000 1"
and os.getuid() == 0)
os._exit(0 if ok else 1)
except Exception:
os._exit(2)
_, status = os.waitpid(pid, 0)
code = os.waitstatus_to_exitcode(status)
if code == 2:
self.skipTest("userns 不可用,跳过 live 验证")
self.assertEqual(code, 0)
class CellNetworkTest(unittest.TestCase):
@@ -123,13 +194,24 @@ class CellNetworkTest(unittest.TestCase):
with self.assertRaises(ValueError):
CELL.host_network_commands("")
def test_host_network_commands_with_cell_pid_builds_veth(self):
cmds = CELL.host_network_commands("enp0s5", cell_pid=4321)
flat = [" ".join(c) for c in cmds]
self.assertEqual(
flat[0], "ip link add ve-chv-cell type veth peer name host0")
self.assertEqual(flat[1], "ip link set host0 netns 4321")
self.assertIn("ip addr add 10.200.0.1/24 dev ve-chv-cell", flat)
def test_host_network_cleanup_mirrors_with_delete(self):
cmds = CELL.host_network_cleanup_commands("enp0s5")
self.assertTrue(cmds)
for cmd in cmds:
self.assertIn("iptables", cmd)
ipt = [c for c in cmds if "iptables" in c]
self.assertTrue(ipt)
for cmd in ipt:
self.assertIn("-D", cmd)
self.assertNotIn("-A", cmd)
# 清理最后删 ve 接口(防父进程被 SIGKILL 后残留)。
self.assertEqual(cmds[-1], ["ip", "link", "del", "ve-chv-cell"])
def test_cell_network_commands_via_nsenter(self):
cmds = CELL.cell_network_commands(4321)
@@ -162,17 +244,6 @@ class CellNetworkTest(unittest.TestCase):
self.assertEqual(CELL.find_uplink_iface(run=fake_run), "tap0")
def test_find_cell_init_pid_from_fake_proc(self):
with tempfile.TemporaryDirectory() as tmp:
task_dir = os.path.join(tmp, "777", "task", "777")
os.makedirs(task_dir)
with open(os.path.join(task_dir, "children"), "w") as fh:
fh.write("778 779")
self.assertEqual(
CELL.find_cell_init_pid(777, proc_root=tmp), 778)
self.assertIsNone(
CELL.find_cell_init_pid(999, proc_root=tmp))
class BrokerTest(unittest.TestCase):
def _fake_proc(self, root, mapping):
@@ -473,9 +544,10 @@ class CellCliTest(unittest.TestCase):
with contextlib.redirect_stdout(buf):
rc = CELL.main(["argv", "--", "echo", "hi"])
self.assertEqual(rc, 0)
argv = json.loads(buf.getvalue())
self.assertIn("--private-users=1000:65536", argv)
self.assertEqual(argv[-2:], ["echo", "hi"])
spec = json.loads(buf.getvalue())
self.assertEqual(spec["backend"], "userns")
self.assertEqual(spec["uid_map"], "0 1000 1")
self.assertEqual(spec["command"], ["echo", "hi"])
def test_cli_run_requires_double_dash(self):
self.assertEqual(CELL.main(["run", "echo", "hi"]), 2)