From 4b5f2f124f09f96c8e04b8ecbf50926ebcf9a77d Mon Sep 17 00:00:00 2001 From: host Date: Sat, 7 Nov 2026 14:11:54 +0000 Subject: [PATCH] =?UTF-8?q?chv-cell:=20ns=20=E5=86=85=20setuid=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=20+=20=E5=8D=95=E5=85=83=E5=86=85=20DNS=EF=BC=88?= =?UTF-8?q?=E4=B8=8A=E6=B8=B8=20resolv.conf=20=E7=BB=91=E5=AE=9A=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 中间进程不再先 setuid(1000) 再 unshare:真机对照实测那样 后续 unshare(CLONE_NEWNS) 得 EPERM;改为保持 root 外层身份 unshare(creator caps 在),孙进程在 ns 内 setgid/setuid(0) 切到内 0(映射到外层 chv 1000)。 - 单元内 DNS:客机 /etc/resolv.conf 是 127.0.0.53 stub,单元 独立 netns 里自己的 lo 上没有 resolver;把上游 /run/systemd/resolve/resolv.conf 绑到 /etc/resolv.conf (解析 symlink 后挂真实目标),只在单元私有 mount ns 里。 - 单测:CellResolvTest(bind 参数/symlink 解析/缺失回落) + 规约 dns_resolv_conf 字段;P2 + 阶段 2 共 73 项全过。 --- chv-guest/chv-cell.py | 71 +++++++++++++++++++++++++++------ cloud/tests/test_chv_muse_p2.py | 46 +++++++++++++++++++++ 2 files changed, 104 insertions(+), 13 deletions(-) diff --git a/chv-guest/chv-cell.py b/chv-guest/chv-cell.py index dd209f7..b4e805f 100644 --- a/chv-guest/chv-cell.py +++ b/chv-guest/chv-cell.py @@ -72,6 +72,10 @@ MACHINE = "chv-cell" UID_BASE = 1000 CELL_USER = "chv" CELL_UID = 1000 +# 单元内 DNS:客机 /etc/resolv.conf 是 127.0.0.53 stub,单元独立 +# netns 里自己的 lo 上没有 resolver;上游文件里是真实上游。 +RESOLV_UPSTREAM = "/run/systemd/resolve/resolv.conf" +RESOLV_CONF = "/etc/resolv.conf" CELL_GID = 1000 WORKSPACE = "/home/chv/workspace" CODEX_HOME = "/home/chv/.codex" @@ -253,6 +257,7 @@ def build_sandbox_spec(command, network="veth", "env": display_env, "hide": list(INACCESSIBLE_BASE) + [playwright_package_dir], "tmpfs": ["/tmp"], + "dns_resolv_conf": "upstream-bind", "network": network, "host_ip": HOST_IP, "cell_ip": CELL_IP, @@ -352,9 +357,12 @@ def _write_id_maps_for_pid(pid, uid=CELL_UID, gid=CELL_GID): 子进程自己写不进——unshare 后它的 euid 变成 overflow, 连 /proc/self/uid_map 的 open 都 EPERM(2026-11-07 真机 实测)。这是标准做法(unshare/bwrap 都是父进程写子进程 - 的映射)。注意:映射的外层 uid 必须是子进程当前的外层 - uid——中间进程须先 setuid(1000) 再 unshare,否则映射盖 - 不住它、它在新 ns 里仍是 overflow。失败即抛 RuntimeError。""" + 的映射)。注意:中间进程保持 root 外层身份 unshare, + 映射写上后它在新 ns 里仍是 overflow uid,但 creator + caps 在、能继续 unshare 其它 ns;切内 0 由孙进程在 + ns 内 setuid(0) 完成(见 _grandchild_main)。中间进程 + 绝不能先 setuid(1000) 再 unshare——实测那样后续 + unshare(CLONE_NEWNS) 得 EPERM。失败即抛 RuntimeError。""" try: with open("/proc/%d/setgroups" % pid, "w") as fh: fh.write("deny") @@ -377,9 +385,35 @@ def _mount(args, what): % (what, (proc.stderr or proc.stdout or "").strip())) +def _resolv_bind(source=RESOLV_UPSTREAM, target=RESOLV_CONF): + """返回把上游 resolv.conf 绑到单元 /etc/resolv.conf 的 mount + 参数,或 None(无上游文件/无 nameserver 时跳过,单元内 + DNS 保持现状)。客机 /etc/resolv.conf 是 127.0.0.53 的 + stub(systemd-resolved),单元有独立 netns、自己的 lo 上 + 没有 resolver,直接沿用则 DNS 全灭;上游文件 + /run/systemd/resolve/resolv.conf 里是真实上游(8.8.8.8 + 等),经 veth NAT 可达。target 是 symlink 时挂到真实目标 + (stub 文件),挂载只在单元的私有 mount ns 里,不动宿主。""" + if not os.path.isfile(source): + return None + try: + with open(source, "r", encoding="utf-8", + errors="replace") as f: + content = f.read() + except OSError: + return None + if "nameserver" not in content: + return None + real_target = os.path.realpath(target) + if not os.path.isfile(real_target): + return None + return ["--bind", source, real_target] + + def _setup_mounts(playwright_package_dir=None): """单元内挂载:根 rprivate、fresh /proc、/tmp tmpfs、 - 敏感路径遮蔽(目录盖 tmpfs,文件绑 /dev/null)。 + 敏感路径遮蔽(目录盖 tmpfs,文件绑 /dev/null)、上游 + resolv.conf 绑到 /etc/resolv.conf(单元内 DNS)。 只在新 mount ns 里做,不动宿主挂载表。""" if playwright_package_dir is None: playwright_package_dir = _playwright_package_dir() @@ -394,6 +428,12 @@ def _setup_mounts(playwright_package_dir=None): else: print("chv-cell: 遮蔽目标不存在,跳过:%s" % path, file=sys.stderr) + bind = _resolv_bind() + if bind: + _mount(bind, "DNS resolv.conf") + else: + print("chv-cell: 无上游 resolv.conf,单元内 DNS 可能不通", + file=sys.stderr) def parse_default_route_iface(ip_route_output): @@ -547,11 +587,17 @@ def _set_pdeathsig(sig): def _grandchild_main(command, network, ready_w, go_r): - """单元 init(新 pid ns 里的 pid 1):挂载、加固、报 READY、 - 等放行、exec payload。任何一步失败写 ERR 后退出(父进程 - 收到 ERR 即大声失败,不跑 payload)。""" + """单元 init(新 pid ns 里的 pid 1):切内 0、挂载、加固、 + 报 READY、等放行、exec payload。任何一步失败写 ERR 后退出 + (父进程收到 ERR 即大声失败,不跑 payload)。""" try: _set_pdeathsig(9) # SIGKILL:中间进程死即自杀,不留孤儿 + # 在 ns 内切到内 0(映射到外层 chv 1000):此时进程是 + # creator、有新 ns 的全部 caps,setgid/setuid(0) 合法; + # 必须先 setgid 再 setuid。绝不能在 unshare 之前以外层 + # 身份 setuid——那会弄丢后续 unshare 的能力(见上)。 + os.setgid(0) + os.setuid(0) _setup_mounts() _set_no_new_privileges() _apply_seccomp_filter() @@ -595,13 +641,12 @@ def _intermediate_main(command, network, pid_w, ready_r, ready_w, 只做命名空间搭建与收割。""" try: _set_pdeathsig(15) # SIGTERM:父进程死即退出,带走单元 - # 先以外层 chv 身份再 unshare:映射按外层 uid 翻译, - # 外层不是 1000 时 “0 1000 1” 盖不住进程(2026-11-07 - # 真机实测:root 外层 unshare 后映射写上了、进程在新 - # ns 里仍是 overflow)。setuid 前须先清附加组、设 gid。 + # 只设附加组(让它映射到内 0),不改 uid/gid:中间进程 + # 保持 root 外层身份 unshare(creator caps 在,后续 + # unshare 其它 ns 才通)。绝不能先 setuid(1000) 再 + # unshare——2026-11-07 真机对照实测:那样后续 + # unshare(CLONE_NEWNS) 得 EPERM;不 setuid 则通。 os.setgroups([CELL_GID]) - os.setgid(CELL_GID) - os.setuid(CELL_UID) os.unshare(CLONE_NEWUSER) # 映射由父进程写(子进程自己写不进,见 # _write_id_maps_for_pid);先报“已 unshare”,再等放行。 diff --git a/cloud/tests/test_chv_muse_p2.py b/cloud/tests/test_chv_muse_p2.py index 1b2451f..c70a0f6 100644 --- a/cloud/tests/test_chv_muse_p2.py +++ b/cloud/tests/test_chv_muse_p2.py @@ -111,6 +111,52 @@ class CellArgvTest(unittest.TestCase): self.assertNotIn("GITEA_TOKEN", real) + def test_dns_resolv_conf_upstream_bind(self): + spec = CELL.build_sandbox_spec(["true"]) + self.assertEqual(spec["dns_resolv_conf"], "upstream-bind") + + +class CellResolvTest(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.mkdtemp(prefix="cell-resolv-") + + def tearDown(self): + shutil.rmtree(self.tmp, ignore_errors=True) + + def test_upstream_bind_args(self): + upstream = os.path.join(self.tmp, "resolv.conf") + with open(upstream, "w", encoding="utf-8") as f: + f.write("nameserver 8.8.8.8\nnameserver 1.1.1.1\n") + target = os.path.join(self.tmp, "target-resolv.conf") + with open(target, "w", encoding="utf-8") as f: + f.write("nameserver 127.0.0.53\n") + self.assertEqual(CELL._resolv_bind(upstream, target), + ["--bind", upstream, target]) + + def test_symlink_target_resolved(self): + upstream = os.path.join(self.tmp, "resolv.conf") + with open(upstream, "w", encoding="utf-8") as f: + f.write("nameserver 8.8.8.8\n") + real = os.path.join(self.tmp, "stub-resolv.conf") + with open(real, "w", encoding="utf-8") as f: + f.write("nameserver 127.0.0.53\n") + link = os.path.join(self.tmp, "resolv-link") + os.symlink(real, link) + self.assertEqual(CELL._resolv_bind(upstream, link), + ["--bind", upstream, real]) + + def test_missing_or_no_nameserver_returns_none(self): + target = os.path.join(self.tmp, "target-resolv.conf") + with open(target, "w", encoding="utf-8") as f: + f.write("nameserver 127.0.0.53\n") + self.assertIsNone(CELL._resolv_bind( + os.path.join(self.tmp, "no-such-file"), target)) + empty = os.path.join(self.tmp, "empty-resolv.conf") + with open(empty, "w", encoding="utf-8") as f: + f.write("search .\n") + self.assertIsNone(CELL._resolv_bind(empty, target)) + + class CellSeccompTest(unittest.TestCase): def test_filter_structure(self): raw = CELL.build_seccomp_filter()