From 2be69af92c291b3dd759095ce1899ab1186a722b Mon Sep 17 00:00:00 2001 From: host Date: Sat, 7 Nov 2026 10:29:31 +0000 Subject: [PATCH] =?UTF-8?q?part=204=20=E7=BC=BA=E9=99=B7=202=EF=BC=9Avolat?= =?UTF-8?q?ile=20=E7=A9=BA=E6=A0=B9=E7=BC=BA=20/app=20=E4=B8=8E=20/etc=20?= =?UTF-8?q?=E5=85=B3=E9=94=AE=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RETEST3 缺陷 2:--volatile=yes 下单元根是基本空的 tmpfs, /app、/etc/resolv.conf、/etc/hosts、/etc/ssl/certs 全无, 执行器单元路径一律 ENOENT、DNS/TLS 不可用。 - build_nspawn_argv:固定只读绑 --bind-ro=/app 与 --bind-ro=/etc/ssl/certs;新增 extra_ro_binds 参数 (单元专属 resolv.conf/hosts 由 run_cell 传进来)。 - prepare_cell_files():按客机有效 DNS 生成单元专属 resolv.conf/hosts(/run/chv/resolv.conf.、 hosts.,0644——单元 root 映射为客机 uid 1000, 0600 的 root 文件单元内读不到;DNS/hosts 不含秘密)。 客机 resolv.conf 可能是 systemd-resolved stub (127.0.0.53),单元内无 resolved,直接绑 stub 即 DNS 全灭,故过滤 stub 后备选 /run/systemd/resolve/resolv.conf, 两处都没有即大声失败。nsswitch.conf 故意不绑(客机版 可能含 resolve 模块;glibc 默认 files+dns 正是单元需要的)。 新测试:CellFilesTest、CellPart4ArgvTest(绑定两法)。 run_cell 接线在缺陷 3 的重写里做。 --- chv-guest/chv-cell.py | 94 ++++++++++++++++++++++++++++++++- cloud/tests/test_chv_muse_p2.py | 78 +++++++++++++++++++++++++++ 2 files changed, 170 insertions(+), 2 deletions(-) diff --git a/chv-guest/chv-cell.py b/chv-guest/chv-cell.py index ec44293..660718e 100644 --- a/chv-guest/chv-cell.py +++ b/chv-guest/chv-cell.py @@ -123,9 +123,13 @@ def _playwright_package_dir(): def build_nspawn_argv(command, network="veth", workspace=WORKSPACE, codex_home=CODEX_HOME, broker_dir=BROKER_DIR, - playwright_package_dir=None): + playwright_package_dir=None, + extra_ro_binds=None): """产 nspawn 参数表(纯函数,可单测)。command 是单元内要跑的命令 - (list,如 ["python3", "/app/host.py", ...])。""" + (list,如 ["python3", "/app/host.py", ...])。 + extra_ro_binds 是 [(src, dest)],只读绑进单元(如单元专属 + resolv.conf/hosts;/app 与 /etc/ssl/certs 是固定只读绑定, + 直接列在下面)。""" if not command: raise ValueError("chv-cell: 单元内命令不能为空") if network not in ("veth", "shared"): @@ -162,8 +166,25 @@ def build_nspawn_argv(command, network="veth", "--bind=%s" % workspace, "--bind=%s" % codex_home, "--bind=%s" % broker_dir, + # part 4(volatile 空根缺口):--volatile=yes 下单元根是 + # 基本空的 tmpfs,执行器默认在单元内跑 python3 /app/host.py、 + # codex 要做 DNS 与 TLS——/app、CA bundle、resolv.conf、 + # hosts 都须显式绑进去,否则单元内一律 ENOENT/无 DNS。 + # /app 与 /etc/ssl/certs 是镜像/系统固定路径,只读绑; + # resolv.conf/hosts 是 run_cell 按客机有效 DNS 生成的 + # 单元专属文件,经 extra_ro_binds 传进来(见 + # prepare_cell_files:客机 /etc/resolv.conf 可能是 + # systemd-resolved stub(127.0.0.53),单元内无 resolved, + # 直接绑 stub 即 DNS 全灭,故须生成真实服务器列表)。 + # 不绑 /etc/nsswitch.conf:客机版可能含 resolve 模块, + # 单元内无 resolved/dbus,绑了反而可能挂起;glibc 缺 + # nsswitch.conf 时默认 files+dns,正是单元需要的。 + "--bind-ro=/app", + "--bind-ro=/etc/ssl/certs", "--tmpfs=/tmp", ] + for src, dest in (extra_ro_binds or []): + argv.append("--bind-ro=%s:%s" % (src, dest)) if network == "veth": argv.append("--network-veth") argv += [ @@ -317,6 +338,75 @@ def gate_wrapper_command(command, gate_path): gate_path] + list(command) +def _parse_nameservers(path): + """读一个 resolv.conf 风格文件,返回 nameserver 列表 + (去 127.0.0.0/8 与 ::1——单元内无 resolved,stub 指过去 + 即 DNS 全灭)。文件不存在或读不出即 []。""" + servers = [] + try: + with open(path) as fh: + for line in fh: + line = line.strip() + if not line.startswith("nameserver"): + continue + parts = line.split() + if len(parts) < 2: + continue + ip = parts[1] + if ip.startswith("127.") or ip == "::1": + continue + if ip not in servers: + servers.append(ip) + except OSError: + return [] + return servers + + +def _effective_nameservers(resolv_conf="/etc/resolv.conf", + resolved_conf="/run/systemd/resolve/resolv.conf"): + """客机有效 DNS:先读 /etc/resolv.conf;只剩 stub(127.0.0.53) + 或为空时读 resolved 的 resolv.conf;仍为空即大声失败。""" + servers = _parse_nameservers(resolv_conf) + if not servers: + servers = _parse_nameservers(resolved_conf) + if not servers: + raise RuntimeError( + "chv-cell: 客机无有效 DNS,配不出单元 resolv.conf") + return servers + + +def prepare_cell_files(machine, run_dir=BROKER_DIR, + resolv_conf="/etc/resolv.conf", + resolved_conf="/run/systemd/resolve/resolv.conf", + hosts_file="/etc/hosts"): + """准备单元专属文件(run_cell 在 Popen 之前调),返回 dict: + gate(起步门,run_cell 配网完成后才创建——注意 gate 文件本身 + 在这里不创建,只给路径)、resolv(单元专属 resolv.conf,已写 + 好)、hosts(单元专属 hosts,已写好)。文件都在 /run/chv 下 + (bind 进单元),0644(单元内 root 在客机侧是 chv,0600 的 + root 文件在单元内读不到;DNS/hosts 不含秘密,0644 安全)。 + /app 与 /etc/ssl/certs 是固定只读绑定,不走这里。""" + gate = os.path.join(run_dir, ".cell-gate-%s" % machine) + resolv = os.path.join(run_dir, "resolv.conf.%s" % machine) + hosts = os.path.join(run_dir, "hosts.%s" % machine) + servers = _effective_nameservers(resolv_conf, resolved_conf) + with open(resolv, "w") as fh: + for server in servers: + fh.write("nameserver %s\n" % server) + os.chmod(resolv, 0o644) + try: + with open(hosts_file) as src: + content = src.read() + except OSError: + content = "" + if "localhost" not in content: + content = "127.0.0.1 localhost\n" + content + with open(hosts, "w") as fh: + fh.write(content) + os.chmod(hosts, 0o644) + return {"gate": gate, "resolv": resolv, "hosts": hosts} + + def _wait_for_iface(iface, timeout=10.0): deadline = time.time() + timeout while time.time() < deadline: diff --git a/cloud/tests/test_chv_muse_p2.py b/cloud/tests/test_chv_muse_p2.py index e28ad0a..e56c662 100644 --- a/cloud/tests/test_chv_muse_p2.py +++ b/cloud/tests/test_chv_muse_p2.py @@ -812,5 +812,83 @@ class CellInitPidPollTest(unittest.TestCase): self.assertLess(_time.time() - start, 5.0) +class CellPart4ArgvTest(unittest.TestCase): + """part 4:volatile 空根补 bind + 唯一身份进 argv。""" + + def test_app_and_ca_bundle_bound_read_only(self): + argv = CELL.build_nspawn_argv(["true"]) + self.assertIn("--bind-ro=/app", argv) + self.assertIn("--bind-ro=/etc/ssl/certs", argv) + # /app 是只读绑:单元内 python3 /app/host.py 只读,不写。 + self.assertNotIn("--bind=/app", argv) + + def test_extra_ro_binds(self): + argv = CELL.build_nspawn_argv( + ["true"], extra_ro_binds=[ + ("/run/chv/resolv.conf.chv-cell-00", "/etc/resolv.conf"), + ("/run/chv/hosts.chv-cell-00", "/etc/hosts")]) + self.assertIn( + "--bind-ro=/run/chv/resolv.conf.chv-cell-00:/etc/resolv.conf", + argv) + self.assertIn( + "--bind-ro=/run/chv/hosts.chv-cell-00:/etc/hosts", argv) + + + +class CellFilesTest(unittest.TestCase): + """part 4:单元专属 resolv.conf/hosts 生成。""" + + def _write(self, path, content): + with open(path, "w") as fh: + fh.write(content) + return path + + def test_resolv_filters_stub_and_falls_back(self): + with tempfile.TemporaryDirectory() as tmp: + stub = self._write(os.path.join(tmp, "resolv.conf"), + "nameserver 127.0.0.53\n" + "options edns0\n") + real = self._write(os.path.join(tmp, "resolved.conf"), + "nameserver 8.8.8.8\n" + "nameserver 1.1.1.1\n") + files = CELL.prepare_cell_files( + "chv-cell-00", run_dir=tmp, resolv_conf=stub, + resolved_conf=real, + hosts_file=os.path.join(tmp, "no-hosts")) + with open(files["resolv"]) as fh: + content = fh.read() + self.assertEqual(content, + "nameserver 8.8.8.8\nnameserver 1.1.1.1\n") + self.assertNotIn("127.0.0.53", content) + with open(files["hosts"]) as fh: + self.assertIn("127.0.0.1 localhost", fh.read()) + self.assertEqual(oct(os.stat(files["resolv"]).st_mode + & 0o777), "0o644") + self.assertEqual(files["gate"], + os.path.join(tmp, ".cell-gate-chv-cell-00")) + # gate 文件本身不在这里创建(run_cell 配网后才开门)。 + self.assertFalse(os.path.exists(files["gate"])) + + def test_resolv_real_servers_used_directly(self): + with tempfile.TemporaryDirectory() as tmp: + resolv = self._write(os.path.join(tmp, "resolv.conf"), + "nameserver 8.8.4.4\n") + files = CELL.prepare_cell_files( + "chv-cell-01", run_dir=tmp, resolv_conf=resolv, + resolved_conf=os.path.join(tmp, "missing"), + hosts_file=os.path.join(tmp, "missing-hosts")) + with open(files["resolv"]) as fh: + self.assertEqual(fh.read(), "nameserver 8.8.4.4\n") + + def test_no_dns_anywhere_is_loud(self): + with tempfile.TemporaryDirectory() as tmp: + with self.assertRaises(RuntimeError): + CELL.prepare_cell_files( + "chv-cell-00", run_dir=tmp, + resolv_conf=os.path.join(tmp, "missing"), + resolved_conf=os.path.join(tmp, "missing2"), + hosts_file=os.path.join(tmp, "missing-hosts")) + + if __name__ == "__main__": unittest.main()