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:
1 parent
42b295fc71
commit
a2a5624736
2 files changed
+630
-160
No files matched your search
+129
-57
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user