#!/usr/bin/env python3 """chv-cell:客机内 systemd-nspawn 运行时单元(Hatch 式)启动器。 对齐 Muse P2(见 docs/chv-muse-p2-gap.md):codex 跑在单元内,不再 直接跑在客机用户态。单元是单命令容器(--as-pid2,不 boot 第二个 systemd),单元即沙箱——单元内 codex 用 danger-full-access,不再 套 bwrap(bwrap 的 --proc 挂载在嵌套命名空间里走不通,见 AGENTS.md 内层 proc 定位)。 关键映射(与 Muse“单元 root 映射为 host 侧非特权用户”同款): --private-users=1000:65536 —— 单元内 uid 0 → 客机侧 chv(uid 1000)。 效果:workspace / ~/.codex(chv 所有)单元内仍可写;host-root 0600 文件(如 /etc/chv/guest-env)在单元内显示为 overflow uid、不可读; 再加 --inaccessible 遮蔽作纵深防御(只遮 /usr 下路径:--volatile=yes 下容器根是基本空的 tmpfs,/etc/chv 等在容器根里根本不存在,按构造 即不可达,列出反而让 nspawn 解析失败而起不来,见 INACCESSIBLE_BASE)。 网络:--network-veth。每次 run_cell 在锁下分配唯一身份 (CellNet:machine chv-cell-、宿主 ve-chv-cell-、 10.200..0/24,宿主 .1、单元 .2),不再共用固定名与固定 子网——固定名是并发 cell 撞毁的根因(part 4)。客机侧 NAT 出站(MASQUERADE 走客机默认路由的上行口,动态发现)。单元内 配网经 nsenter 进单元 init 的 netns 做。veth 建不起来即大声 失败,不静默回落共享网络;CHV_CELL_NETWORK=shared 是显式调试 逃生口,须显式设置并打日志,绝不默认。 起步门(part 4):payload 在 Popen 的瞬间就开始跑,与配网全程 并发是启动竞态的根因——快 payload 在配网完成前退出,nspawn 随 之退出,包装器再去配网即撞墙。run_cell 把 payload 包一层 sh 起步门(等 /run/chv/.cell-gate- 出现再 exec), 配网完成后才开门;find_cell_init_pid 改成带超时轮询;nspawn 已死时返回它的退出码而不是抛错。 volatile 空根(part 4):--volatile=yes 下单元根是基本空的 tmpfs,/app、/etc/resolv.conf、/etc/hosts、/etc/ssl/certs 都须 显式绑进去(/app 与 CA 只读绑;resolv/hosts 是 run_cell 按客机 有效 DNS 生成的单元专属文件,只读绑;不绑 nsswitch.conf, glibc 默认 files+dns 正是单元需要的)。 环境透传:nspawn 不透传调用方环境,本脚本只转发白名单变量(模型 网关与代理变量)。GITEA_TOKEN 等凭证变量不在白名单里,绝不进单元。 用法(客机侧 root 运行,执行器经 SSH 调): chv-cell run [--network veth|shared] -- # 例:chv-cell run -- python3 /app/host.py --state # /home/chv/.codex/host-state.json --codex-home /home/chv/.codex # --sandbox danger-full-access dispatch --name t --task ... chv-cell argv [--network veth|shared] -- # 只打印 nspawn 参数表(JSON),不启动 chv-cell network-commands [--uplink IFACE] [--cell-pid PID] # 只打印配网命令表(JSON),不执行 只用标准库。单元启动失败、配网失败一律非零退出,不静默降级。 """ import fcntl import glob import json import os import pwd import re import subprocess import sys import time from typing import NamedTuple MACHINE = "chv-cell" # 单元映射:单元内 uid 0 → 客机侧 chv(uid 1000,非特权用户)。 UID_BASE = 1000 UID_COUNT = 65536 CELL_USER = "chv" CELL_UID = 1000 CELL_GID = 1000 WORKSPACE = "/home/chv/workspace" CODEX_HOME = "/home/chv/.codex" BROKER_DIR = "/run/chv" CELL_PID_FILE = "/run/chv/cell.pid" # 单元网络(与客机 tap 网 192.168.10.0/24 不重叠的独立 /24)。 # part 4(2026-11-07):固定名/固定子网是并发 cell 撞毁的根因—— # 每次 run_cell 都在锁下分配唯一身份(CellNet),不再共用常量。 # machine 名须短:nspawn 把宿主侧 veth 叫 ve-,接口名 # 上限 15 字符;"chv-cell-%02x" 为 11 字符,ve- 前缀后 14 字符。 CELL_NET_BASE = "10.200" CELL_NET = "10.200.0.0/24" HOST_IP = "10.200.0.1" CELL_IP = "10.200.0.2" HOST_IFACE = "ve-chv-cell" CELL_IFACE = "host0" MAX_CELL_INDEX = 250 ALLOC_LOCK_FILE = "/run/chv/.cell-alloc.lock" # Muse 点名要丢的能力 + 同批丢的危险能力(nspawn 默认集已有限, # 这里是显式再丢,--drop-capability 逐项写,测试逐项断言)。 DROP_CAPABILITIES = [ "CAP_SYS_ADMIN", "CAP_SYS_PTRACE", "CAP_NET_ADMIN", "CAP_NET_RAW", "CAP_SYS_MODULE", "CAP_SYS_RAWIO", "CAP_SYS_BOOT", "CAP_MKNOD", ] # syscall 过滤:@system-service 集合再扣掉 io_uring 三件套 #(Muse 点名禁用 io_uring)。 SYSCALL_FILTER = ("@system-service ~io_uring_setup " "~io_uring_enter ~io_uring_register") # 单元内遮掉的路径:浏览器(单元内跑不起自己的浏览器, # 只能走 broker 的 chv-browser)。只列 /usr 下的路径—— # round-11 复测纠正(第二轮):--volatile=yes 下容器根是 # 基本空的 tmpfs(只挂进只读 /usr),nspawn 按容器根解析 # --inaccessible 路径,不存在的路径即启动失败;/etc/chv 与 # /home/chv/.git-credentials 在容器根里根本不存在(无 /home、 # 无 /etc/chv),凭证文件按构造就不可达,不再列出遮蔽。 # 旧的两项若列回,cell 会报 Failed to resolve 而起不来。 INACCESSIBLE_BASE = [ "/usr/local/share/ms-playwright", ] # 只转发白名单环境变量进单元。凭证类变量(GITEA_TOKEN 等) # 不在白名单里,绝不进单元。 FORWARD_ENV_ALLOWLIST = ( "SUB2API_BASE_URL", "SUB2API_API_KEY", "XCM_API_KEY", "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "no_proxy", ) class CellNet(NamedTuple): """一次 cell 运行的唯一网络身份(锁下分配,见 allocate_cell_net)。 machine 同时决定宿主侧 veth 名(ve-,nspawn 约定)与 /run/chv 下的专属文件后缀(gate/resolv/hosts/pid)。""" index: int machine: str host_iface: str host_ip: str cell_ip: str subnet: str def legacy_cell_net(): """无身份时的旧常量(argv/network-commands 子命令与旧测试用; run_cell 一律走 allocate_cell_net,不用它)。""" return CellNet(index=0, machine=MACHINE, host_iface=HOST_IFACE, host_ip=HOST_IP, cell_ip=CELL_IP, subnet=CELL_NET) # Muse 点名要丢的能力 + 同批丢的危险能力(nspawn 默认集已有限, # 这里是显式再丢,--drop-capability 逐项写,测试逐项断言)。 DROP_CAPABILITIES = [ "CAP_SYS_ADMIN", "CAP_SYS_PTRACE", "CAP_NET_ADMIN", "CAP_NET_RAW", "CAP_SYS_MODULE", "CAP_SYS_RAWIO", "CAP_SYS_BOOT", "CAP_MKNOD", ] # syscall 过滤:@system-service 集合再扣掉 io_uring 三件套 #(Muse 点名禁用 io_uring)。 SYSCALL_FILTER = ("@system-service ~io_uring_setup " "~io_uring_enter ~io_uring_register") # 单元内遮掉的路径:浏览器(单元内跑不起自己的浏览器, # 只能走 broker 的 chv-browser)。只列 /usr 下的路径—— # round-11 复测纠正(第二轮):--volatile=yes 下容器根是 # 基本空的 tmpfs(只挂进只读 /usr),nspawn 按容器根解析 # --inaccessible 路径,不存在的路径即启动失败;/etc/chv 与 # /home/chv/.git-credentials 在容器根里根本不存在(无 /home、 # 无 /etc/chv),凭证文件按构造就不可达,不再列出遮蔽。 # 旧的两项若列回,cell 会报 Failed to resolve 而起不来。 INACCESSIBLE_BASE = [ "/usr/local/share/ms-playwright", ] # 只转发白名单环境变量进单元。凭证类变量(GITEA_TOKEN 等) # 不在白名单里,绝不进单元。 FORWARD_ENV_ALLOWLIST = ( "SUB2API_BASE_URL", "SUB2API_API_KEY", "XCM_API_KEY", "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "no_proxy", ) def _playwright_package_dir(): """playwright pip 包目录(遮蔽用)。算不出时回落到 noble 默认路径。""" try: import sysconfig purelib = sysconfig.get_path("purelib") if purelib: return os.path.join(purelib, "playwright") except Exception: pass return "/usr/local/lib/python3.12/dist-packages/playwright" def build_nspawn_argv(command, network="veth", workspace=WORKSPACE, codex_home=CODEX_HOME, broker_dir=BROKER_DIR, playwright_package_dir=None, cell_net=None, extra_ro_binds=None): """产 nspawn 参数表(纯函数,可单测)。command 是单元内要跑的命令 (list,如 ["python3", "/app/host.py", ...])。 cell_net 为 None 时用旧常量身份(argv 子命令与旧测试); run_cell 一律传 allocate_cell_net() 的唯一身份。 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"): raise ValueError("chv-cell: network 只能是 veth|shared:%r" % network) if playwright_package_dir is None: playwright_package_dir = _playwright_package_dir() machine = cell_net.machine if cell_net is not None else MACHINE argv = [ "systemd-nspawn", "--quiet", "--machine=%s" % machine, "--directory=/", # round-11 真机修复:nspawn 255 拒裸 --directory=/(须 # --ephemeral 或 --volatile),也拒裸 --no-new-privileges #(把它当作带必填参数的选项,吞掉下一个参数后报错)。 # 2026-11-07 复测纠正:首轮修用的 --ephemeral 在客内 # ext4 根盘(2.4G、已用 1.8G、可用 593M)上做整盘快照 # 拷贝,空间不够即 ENOSPC,全部 cell 调用失败。改 # --volatile=yes:根为基本空的 tmpfs、/usr 只读挂进, # 单元内除 bind 出去的目录外一律可写、用完即弃——与 # --ephemeral 的设计意图一致,但不拷盘、不占空间。 # --volatile=state 不行:整树只读、仅 /var 为 tmpfs, # codex 写 $HOME/.cache 等非 bind 路径会失败。 "--volatile=yes", "--as-pid2", "--private-users=%d:%d" % (UID_BASE, UID_COUNT), "--no-new-privileges=yes", "--system-call-filter=%s" % SYSCALL_FILTER, ] for cap in DROP_CAPABILITIES: argv.append("--drop-capability=%s" % cap) for path in INACCESSIBLE_BASE + [playwright_package_dir]: argv.append("--inaccessible=%s" % path) argv += [ "--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 += [ "--setenv=CHV_CELL=1", "--setenv=HOME=/home/chv", ] for name in FORWARD_ENV_ALLOWLIST: value = os.environ.get(name) if value: argv.append("--setenv=%s=%s" % (name, value)) argv += ["--chdir=%s" % workspace] argv += list(command) return argv def parse_default_route_iface(ip_route_output): """从 `ip route show default` 的输出里解析上行口名。 无默认路由时返回 None(调用方大声失败)。""" for line in (ip_route_output or "").splitlines(): line = line.strip() if not line.startswith("default "): continue m = re.search(r"\bdev\s+(\S+)", line) if m: return m.group(1) return None def find_uplink_iface(run=None): """发现客机上行口(默认路由的 dev)。run 供单测注入。""" run = run or subprocess.run proc = run(["ip", "route", "show", "default"], capture_output=True, text=True) if proc.returncode != 0: return None return parse_default_route_iface(proc.stdout) def host_network_commands(uplink_iface, net=None): """客机侧配网命令表(纯函数):ve 接口起 IP + 转发 + NAT。 调用方按顺序执行,任一失败即停。net 为 None 时用旧常量 (argv/network-commands 子命令与旧测试);run_cell 传 allocate_cell_net() 的唯一身份。""" if not uplink_iface: raise ValueError("chv-cell: 上行口名为空,配不出 NAT") net = net or legacy_cell_net() return [ ["ip", "addr", "add", "%s/24" % net.host_ip, "dev", net.host_iface], ["ip", "link", "set", net.host_iface, "up"], ["sysctl", "-w", "net.ipv4.ip_forward=1"], ["iptables", "-t", "nat", "-A", "POSTROUTING", "-s", net.subnet, "-o", uplink_iface, "-j", "MASQUERADE"], ["iptables", "-A", "FORWARD", "-i", net.host_iface, "-o", uplink_iface, "-j", "ACCEPT"], ["iptables", "-A", "FORWARD", "-i", uplink_iface, "-o", net.host_iface, "-m", "state", "--state", "RELATED,ESTABLISHED", "-j", "ACCEPT"], ] def host_network_cleanup_commands(uplink_iface, net=None): """配网清理命令表(与 host_network_commands 配对,-A 改 -D)。 清理是 best-effort:删不掉(规则已不在)不报错。""" if not uplink_iface: raise ValueError("chv-cell: 上行口名为空,清不了 NAT") cmds = [] for cmd in host_network_commands(uplink_iface, net): if "iptables" not in cmd: continue cleaned = [c if c != "-A" else "-D" for c in cmd] cmds.append(cleaned) return cmds def find_cell_init_pid(nspawn_pid, proc_root="/proc", timeout=0.0, interval=0.1, proc_alive=None): """找单元 init 的 pid:nspawn 进程的第一个子进程 (--as-pid2 时它是 stub init,payload 是 PID 2;两者同 netns)。 timeout=0(默认)即只查一次(旧行为);timeout>0 时带超时轮询 (part 4 竞态修复:init 可能还没 fork,单次查找即误判)。 proc_alive 供调用方传“nspawn 是否还活着”(如 proc.poll() is None 的 lambda):nspawn 已退出即立刻回 None,不再空等超时。 找不到返回 None。""" deadline = time.time() + timeout while True: found = None try: task_dir = os.path.join(proc_root, str(nspawn_pid), "task") for tid in os.listdir(task_dir): children_path = os.path.join(task_dir, tid, "children") try: with open(children_path) as fh: children = fh.read().split() except OSError: continue if children: try: found = int(children[0]) except ValueError: continue break except OSError: # 任务目录还没出现(init 未 fork)即当作“这次没找到”, # 继续轮询,不直接回 None(旧代码这里直接 return None, # 正是单次查找误判的根因之一)。 found = None if found is not None: return found if proc_alive is not None and not proc_alive(): return None if time.time() >= deadline: return None time.sleep(min(interval, max(0.0, deadline - time.time()))) def cell_network_commands(cell_pid, net=None): """单元内配网命令表(纯函数):nsenter 进单元 init 的 netns, 给 host0 配 IP、起接口、加默认路由。net 为 None 时用旧常量。""" if not cell_pid: raise ValueError("chv-cell: 单元 init pid 为空,配不了单元内网络") net = net or legacy_cell_net() prefix = ["nsenter", "-t", str(cell_pid), "-n"] return [ prefix + ["ip", "addr", "add", "%s/24" % net.cell_ip, "dev", CELL_IFACE], prefix + ["ip", "link", "set", CELL_IFACE, "up"], prefix + ["ip", "route", "add", "default", "via", net.host_ip], ] def _ensure_bind_source(path, uid=CELL_UID, gid=CELL_GID): """bind 源目录须存在且属 chv(单元内 root 即客机 chv,属主不对 就写不进 workspace)。root 建的目录要 chown 回 chv。""" os.makedirs(path, exist_ok=True) try: st = os.stat(path) if st.st_uid != uid or st.st_gid != gid: os.chown(path, uid, gid) except OSError as e: print("chv-cell: chown %s 失败:%s" % (path, e), file=sys.stderr) raise def gate_wrapper_command(command, gate_path): """把单元内命令包一层起步门(纯函数):payload 先在单元内 等 gate 文件出现再 exec 原命令。gate 文件由 run_cell 在配网 完成后才创建(见 run_cell)——payload 与配网零同步是 RETEST3 新缺陷 1 的根因(快 payload 在配网完成前退出,nspawn 随之 退出,包装器再去配网即撞墙)。/usr/bin/sh 用绝对路径: volatile 单元根里 /bin 未必有,/usr 是只读挂进来的,必有。 注意:$1 是 gate 路径,shift 后 "$@" 即原命令。""" script = ("while [ ! -e \"$1\" ]; do sleep 0.05; done; " "shift; exec \"$@\"") return ["/usr/bin/sh", "-c", script, "chv-cell-gate", 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: proc = subprocess.run(["ip", "link", "show", iface], capture_output=True) if proc.returncode == 0: return True time.sleep(0.2) return False def _alloc_lock(lock_path, timeout=30.0): """拿分配锁(fcntl 排他锁,阻塞拿不到即超时大声失败)。 返回锁 fd(调用方负责 close 即解锁)。""" fd = os.open(lock_path, os.O_CREAT | os.O_RDWR, 0o600) deadline = time.time() + timeout while True: try: fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) return fd except BlockingIOError: if time.time() >= deadline: os.close(fd) raise RuntimeError( "chv-cell: 分配锁 %s 拿不到(超时 %.0f 秒)" % (lock_path, timeout)) time.sleep(0.05) def _used_cell_indices(run): """扫描已占用的 cell 下标:活着的 ve-chv-cell- 接口名、 10.200..1/24 地址、machinectl 里仍在册的同名 machine (注销滞后时避让,见 RETEST3 新缺陷 1 的 veth 超时分析)。 run 供单测注入(subprocess.run 兼容)。machinectl 查不到 (无此命令/失败)即跳过该项,不因此失败。""" used = set() proc = run(["ip", "-o", "link", "show"], capture_output=True, text=True) if proc.returncode != 0: raise RuntimeError("chv-cell: 扫描已占用 cell 下标失败:" "ip -o link show") for m in re.finditer(r"\bve-chv-cell-([0-9a-f]{2})\b", proc.stdout or ""): try: used.add(int(m.group(1), 16)) except ValueError: continue proc = run(["ip", "-o", "addr", "show"], capture_output=True, text=True) if proc.returncode != 0: raise RuntimeError("chv-cell: 扫描已占用 cell 下标失败:" "ip -o addr show") for m in re.finditer(r"\b10\.200\.(\d+)\.1/24\b", proc.stdout or ""): try: used.add(int(m.group(1))) except ValueError: continue try: proc = run(["machinectl", "list", "--no-legend"], capture_output=True, text=True) if proc.returncode == 0: for m in re.finditer(r"\bchv-cell-([0-9a-f]{2})\b", proc.stdout or ""): try: used.add(int(m.group(1), 16)) except ValueError: continue except OSError: pass return used def allocate_cell_net(run=None, lock_path=ALLOC_LOCK_FILE): """锁下分配唯一 cell 身份(run_cell 每调一次即分配一个)。 取最小空闲下标 0..250;machine 名 chv-cell-(11 字符, ve- 前缀后 14 字符,符合接口名 15 字符上限);子网 10.200..0/24、宿主 10.200..1、单元 10.200..2。 全部下标占满即大声失败,不静默回落固定身份。""" run = run or subprocess.run fd = _alloc_lock(lock_path) try: used = _used_cell_indices(run) for index in range(MAX_CELL_INDEX + 1): if index not in used: break else: raise RuntimeError( "chv-cell: cell 下标已占满(0..%d)" % MAX_CELL_INDEX) machine = "chv-cell-%02x" % index return CellNet( index=index, machine=machine, host_iface="ve-%s" % machine, host_ip="%s.%d.1" % (CELL_NET_BASE, index), cell_ip="%s.%d.2" % (CELL_NET_BASE, index), subnet="%s.%d.0/24" % (CELL_NET_BASE, index)) finally: try: fcntl.flock(fd, fcntl.LOCK_UN) except OSError: pass os.close(fd) def _write_cell_pid_files(machine, cell_pid): """写 pid 文件:每单元一份 cell-.pid + 兼容旧路径 /run/chv/cell.pid(最新单元;P2-9 驱动与 broker 降级路径 仍读它)。""" per_cell = os.path.join(BROKER_DIR, "cell-%s.pid" % machine) with open(per_cell, "w") as fh: fh.write(str(cell_pid)) with open(CELL_PID_FILE, "w") as fh: fh.write(str(cell_pid)) def _remove_cell_pid_files(machine, cell_pid): """清 pid 文件(best-effort):每单元份必删;旧路径只在 内容仍指向本单元时才删(并发 cell 时别删别人的指针)。""" for path in (os.path.join(BROKER_DIR, "cell-%s.pid" % machine),): try: os.remove(path) except OSError: pass try: with open(CELL_PID_FILE) as fh: if fh.read().strip() == str(cell_pid): os.remove(CELL_PID_FILE) except OSError: pass def _run_cmds(cmds, what): for cmd in cmds: print("chv-cell: + %s" % " ".join(cmd), file=sys.stderr) proc = subprocess.run(cmd) if proc.returncode != 0: raise RuntimeError( "chv-cell: %s 失败(rc=%d):%s" % (what, proc.returncode, " ".join(cmd))) def run_cell(command, network="veth"): """起单元、配网、等 payload 退出、清 NAT,返回 payload 退出码。 part 4 顺序(RETEST3 新缺陷 1):分配唯一身份 → 准备单元专属 文件 → Popen 带起步门的 payload(payload 在门后等着,不与配网 并发)→ 等 veth → 客机侧配网 → 轮询找 init pid → 写 pid 文件 → 单元内配网 → 开门放行 payload → proc.wait()。payload 已退出 (nspawn 起不来等)时返回它的退出码而不是抛错;其余配网失败 仍大声抛错,不静默回落共享网络。""" if os.geteuid() != 0: print("chv-cell: 须以 root 运行(nspawn + 配网都要 root)", file=sys.stderr) return 2 _ensure_bind_source(WORKSPACE) _ensure_bind_source(CODEX_HOME) _ensure_bind_source(BROKER_DIR, uid=0, gid=0) net = None files = None uplink = None cell_pid = None proc = None if network == "veth": net = allocate_cell_net() files = prepare_cell_files(net.machine) wrapped = gate_wrapper_command(command, files["gate"]) else: wrapped = list(command) argv = build_nspawn_argv( wrapped, network=network, cell_net=net, extra_ro_binds=[(files["resolv"], "/etc/resolv.conf"), (files["hosts"], "/etc/hosts")] if files else None) print("chv-cell: 启动单元:%s" % " ".join(argv), file=sys.stderr) proc = subprocess.Popen(argv) try: if network == "veth": # veth 等待放宽到 20 秒:RETEST3 里连续调用在 10.2/10.3 # 秒处超时(疑似 machine 注销滞后);唯一身份 + 起步门 # 后配网不再与 payload 赛跑,等久一点无害。 if not _wait_for_iface(net.host_iface, timeout=20.0): raise RuntimeError( "chv-cell: veth 接口 %s 未出现,拒绝回落共享网络" % net.host_iface) uplink = find_uplink_iface() if not uplink: raise RuntimeError( "chv-cell: 客机无默认路由,配不出 NAT") _run_cmds(host_network_commands(uplink, net), "客机侧配网") cell_pid = find_cell_init_pid( proc.pid, timeout=10.0, proc_alive=lambda: proc.poll() is None) if not cell_pid: raise RuntimeError("chv-cell: 找不到单元 init pid") _write_cell_pid_files(net.machine, cell_pid) _run_cmds(cell_network_commands(cell_pid, net), "单元内配网") # 配网完成,开门放行 payload。 with open(files["gate"], "w") as fh: fh.write("") return proc.wait() except RuntimeError: # nspawn 已死(如 machine 名冲突、argv 错)即返回它的退出 # 码,不把“起不来”报成配网失败;还活着才是真配网失败,大声抛。 if proc is not None and proc.poll() is not None: return proc.returncode raise finally: if uplink: for cmd in host_network_cleanup_commands(uplink, net): subprocess.run(cmd, capture_output=True) if cell_pid: _remove_cell_pid_files(net.machine, cell_pid) if files: for key in ("gate", "resolv", "hosts"): try: os.remove(files[key]) except OSError: pass if proc is not None and proc.poll() is None: proc.terminate() try: proc.wait(timeout=10) except subprocess.TimeoutExpired: proc.kill() def main(argv): if len(argv) < 1: print("用法:chv-cell run|argv|network-commands …", file=sys.stderr) return 2 verb = argv[0] rest = argv[1:] network = os.environ.get("CHV_CELL_NETWORK", "veth") # --network 只在 run/argv 前接受,其它位置一律不认。 if rest and rest[0] == "--network": if len(rest) < 2: print("chv-cell: --network 缺值", file=sys.stderr) return 2 network = rest[1] rest = rest[2:] if network not in ("veth", "shared"): print("chv-cell: network 只能是 veth|shared:%r" % network, file=sys.stderr) return 2 if network == "shared": print("chv-cell: 警告:共享网络模式(调试逃生口),单元与" "客机共用网络命名空间", file=sys.stderr) if verb == "run": if not rest or rest[0] != "--": print("chv-cell: run 用法:chv-cell run -- ", file=sys.stderr) return 2 command = rest[1:] if not command: print("chv-cell: 单元内命令不能为空", file=sys.stderr) return 2 try: return run_cell(command, network=network) except RuntimeError as e: print(str(e), file=sys.stderr) return 1 if verb == "argv": if not rest or rest[0] != "--": print("chv-cell: argv 用法:chv-cell argv -- ", file=sys.stderr) return 2 print(json.dumps(build_nspawn_argv(rest[1:], network=network), ensure_ascii=False)) return 0 if verb == "network-commands": uplink = None cell_pid = None i = 0 while i < len(rest): if rest[i] == "--uplink" and i + 1 < len(rest): uplink = rest[i + 1] i += 2 elif rest[i] == "--cell-pid" and i + 1 < len(rest): cell_pid = int(rest[i + 1]) i += 2 else: print("chv-cell: network-commands 未知参数:%s" % rest[i], file=sys.stderr) return 2 out = {} if uplink: out["host"] = host_network_commands(uplink) out["host_cleanup"] = host_network_cleanup_commands(uplink) if cell_pid: out["cell"] = cell_network_commands(cell_pid) print(json.dumps(out, ensure_ascii=False)) return 0 print("chv-cell: 未知子命令:%s" % verb, file=sys.stderr) return 2 if __name__ == "__main__": raise SystemExit(main(sys.argv[1:]))