From baf26beb78e0896b54fd248a86d3b0ef57b83b63 Mon Sep 17 00:00:00 2001 From: host Date: Sat, 31 Oct 2026 03:02:10 +0000 Subject: [PATCH] =?UTF-8?q?feat(chv):=20=E5=AE=89=E5=85=A8=E5=AE=A1?= =?UTF-8?q?=E8=AE=A1=E4=BF=AE=E5=A4=8D=20High/Medium/Low=EF=BC=88=E5=A0=86?= =?UTF-8?q?=E5=8F=A0=E4=BA=8E=20#239=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 审计报告 ~/workspace/chv-security-audit/REPORT.md(No-Go, Critical 0 / High 7 / Medium 8 / Low 5)代码层修复,mock 测试覆盖,真机动态验证一律未做、只列清单(见 docs/chv-production-readiness.md §11)。 High: - H1 egress 代码门:代理地址校验、直连显式允许+审计、 每 VM 独立 tap/mac/ip 校验、net 字段白名单;宿主防火墙 列为部署清单,代码门+防火墙缺一不可。 - H2 网关 key 移出 argv:provisioned seed 盘客内文件 /etc/chv/guest-env(0600)投递,传输层密钥进 argv 直接 ValueError;keyscan 模式密钥投递 fail-closed。 - H3 任务盘删除:stop/recycle/cleanup 删 disk.raw(保留期 0)+ 残留盘 GC,删失败审计+脏标记。 - H4 默认值改 provisioned:keyscan 只作显式 opt-in。 - H5 seed.iso 生成即 0600;宿主私钥 seed 盘建好后即删; 崩溃残留扫描 cleanup_stale_secrets。 - H6 state_dir 默认出 /tmp(~/.local/share/.../chv-vms, preflight 保 0700);api-socket 组/其它可写即 fail-fast。 - H7 tap 隔离校验:net.json 登记+stop 释放,共享即拒绝审计。 Medium:M1 shlex 远程转义;M2 清理失败即拒起(脏标记); M3 配额默认 50GB + 快照数/总量/保留期上限;M4 SHA256 必填 + BUILDINFO versions 实测记录(事前 lockfile 列清单); M5 host key/ preflight/失败审计补齐;M6 reuse 须声明单租户 项目;M7 容量计数只认连通 socket;M8 sshd 加固进 build.sh 断言。Low:L1 链接不跟随;L2 路径白名单;L3 失败审计; L4 双变量警告+字段白名单;L5 预热盘/任务盘对应关系定义。 旧测试:实验室默认值与生产默认值区分标注;行为已变的旧 测试按新语义更新(容量死 socket、快照白名单、磁盘路径 白名单、网关 argv、reuse 租户声明)。 测试:新 test_chv_secfix 39 项;旧 117 项 + 2 项新增断言 = 158 项;worker 相关 129 项;前端 vitest 9 项;cloud 全量 1588 项(skipped 4)。 --- chv-guest/README.md | 8 +- chv-guest/build.sh | 54 ++- cloud/chv/config.py | 50 +- cloud/chv/disk.py | 150 ++++++ cloud/chv/executor.py | 565 ++++++++++++++++++--- cloud/chv/hardening.py | 196 ++++++++ cloud/chv/hostkeys.py | 74 ++- cloud/chv/pool.py | 10 +- cloud/chv/ssh_transport.py | 113 ++++- cloud/tests/test_chv_fix.py | 78 ++- cloud/tests/test_chv_gaps.py | 8 + cloud/tests/test_chv_optimize.py | 5 + cloud/tests/test_chv_secfix.py | 701 +++++++++++++++++++++++++++ cloud/tests/test_cloud_hypervisor.py | 21 +- cloud/worker.py | 9 +- docs/chv-production-readiness.md | 196 +++++++- 16 files changed, 2106 insertions(+), 132 deletions(-) create mode 100644 cloud/chv/hardening.py create mode 100644 cloud/tests/test_chv_secfix.py diff --git a/chv-guest/README.md b/chv-guest/README.md index d1bd439..781e393 100644 --- a/chv-guest/README.md +++ b/chv-guest/README.md @@ -29,7 +29,7 @@ | 文档/图片工具 | pandoc、poppler-utils(pdftotext)、latexmk、texlive-latex-base、python-docx、openpyxl、python-pptx、PyMuPDF、Pillow | 逐项照抄 `cloud/docker/Dockerfile` 的 apt/pip 清单,不擅自加减 | | agent 引擎 | codex、codex-code-mode-host、codex-resources/bwrap(成套,约 367M,缺 sibling 直接构建失败) | codex 0.164.0-alpha.9(与生产任务镜像一致);`CODEX_BIN_DIR` 由构建方提供,bin/ 不进仓库 | | 浏览器 | Playwright + Chromium(浏览器自动化 MCP 用,PLAYWRIGHT=1 时才装) | Python playwright 装当时的 Chromium,未在 BUILDINFO 里钉浏览器版本——这是已知缺口,不是已钉死 | -| 客机基础 | openssh-server(SSH 通道用)、cloud-init、qemu-guest-agent(可选,宿主信息用) | sshd 默认只允许 key 登录,禁止密码登录 | +| 客机基础 | openssh-server(SSH 通道用)、cloud-init、qemu-guest-agent(可选,宿主信息用) | sshd 只允许 key 登录,禁止密码登录——build.sh 写入 /etc/ssh/sshd_config.d/99-chv.conf 并经 sshd -t + grep 断言(feat-chv-secfix,M8,此前只是文档声称) | | 任务代码 | codex-app-server 仓库代码置于 `/app`(host.py 入口) | 版本见 §3,不挂载宿主代码目录(VM 与宿主无共享路径) | 中文 LaTeX 说明:与 Docker 镜像同款口径——xelatex + CJK @@ -81,7 +81,11 @@ KVM;全量模式在无 /dev/kvm 的机器上第一步就明确退出 `ssh_genkeytypes: []`(build.sh 已写),首启不得无条件 重新生成 host key——重生成会使预置的 known_hosts 立即 失效,SSH 钉死直接拒绝,这是设计,不是 bug; -- sshd 只允许 key 登录,禁止密码登录; +- sshd 只允许 key 登录,禁止密码登录(build.sh 写入 + /etc/ssh/sshd_config.d/99-chv.conf:PasswordAuthentication no、 + PermitRootLogin prohibit-password、ChallengeResponseAuthentication no、 + KbdInteractiveAuthentication no、PubkeyAuthentication yes,并经 + sshd -t + grep 断言;feat-chv-secfix,M8); - seed 磁盘是 per-VM 敏感物,执行器在 VM 关闭/ 回收时删除,镜像构建方不得把任何一把 host key 固化 进镜像(所有 VM 同 key 是安全事故)。 diff --git a/chv-guest/build.sh b/chv-guest/build.sh index 0a932d6..23fa29f 100755 --- a/chv-guest/build.sh +++ b/chv-guest/build.sh @@ -10,9 +10,15 @@ # vmlinux # virt-get-kernel 从云镜像取出的内核(CHV --kernel 指向它) # disk.raw # qemu-img 转成的 raw 根文件系统镜像(CHV_ROOTFS 指向它) # BUILDINFO.json # {tag, ubuntu, codex_version, repo_commit, built_at, contents} +# + versions {dpkg, pip}(构建时从客机读回的实际版本)+ +# ubuntu_image_sha256_set(SHA256 必填,见下) # 绝不进镜像的东西:任何模型 key、系统 key、用户账号数据、 # 生产凭证;模型 key 由客机 cloud-init 预置的运行环境变量名 # 注入,不写进镜像层。 +# feat-chv-secfix(M4/M8):UBUNTU_IMAGE_SHA256 必填; +# sshd 加固由构建写入 /etc/ssh/sshd_config.d/99-chv.conf +# 并经 sshd -t + grep 断言(README 的“只允许 key 登录” +# 从此是构建断言,不再只是文档声称)。 # # 客机 host key 硬性要求(见 README §5,补缺口轨新增): # 客机必须装 cloud-init 且支持 NoCloud seed,首启先应用 @@ -66,7 +72,7 @@ if [ "$DRY_RUN" = "0" ]; then echo "CHV_GUEST_BUILD needs a KVM host: /dev/kvm 不存在" >&2 exit 1 fi - for tool in curl qemu-img virt-customize virt-get-kernel python3; do + for tool in curl qemu-img virt-customize virt-get-kernel python3 virt-cat; do if ! command -v "$tool" >/dev/null 2>&1; then echo "缺少构建工具:$tool,构建失败" >&2 exit 1 @@ -76,17 +82,23 @@ fi run mkdir -p "$OUT" IMG="$OUT/ubuntu-${UBUNTU_VERSION}-server-cloudimg-amd64.img" +# feat-chv-secfix(M4):云镜像 SHA256 改为必填(干跑只验 +# “已设”,不验文件;全量模式下载后验哈希)。未设即失败, +# 不再只依赖 HTTPS 传输层作唯一锚。 +if [ -z "${UBUNTU_IMAGE_SHA256:-}" ]; then + echo "UBUNTU_IMAGE_SHA256 未设:云镜像须以哈希锚定,构建失败" >&2 + exit 1 +fi if [ "$DRY_RUN" = "1" ]; then echo "DRY-RUN: curl -fL $UBUNTU_IMAGE_URL -o $IMG" echo "DRY-RUN: [ -s $IMG ] || exit 1" + echo "DRY-RUN: sha256sum -c (UBUNTU_IMAGE_SHA256)" else if [ ! -s "$IMG" ]; then curl -fL "$UBUNTU_IMAGE_URL" -o "$IMG" fi [ -s "$IMG" ] || { echo "下载的云镜像为空:$IMG" >&2; exit 1; } - if [ -n "${UBUNTU_IMAGE_SHA256:-}" ]; then - echo "$UBUNTU_IMAGE_SHA256 $IMG" | sha256sum -c - - fi + echo "$UBUNTU_IMAGE_SHA256 $IMG" | sha256sum -c - fi run qemu-img convert -f qcow2 -O raw "$IMG" "$OUT/disk.raw" # 清单软件(照抄 cloud/docker/Dockerfile 的 apt/pip 清单, @@ -102,12 +114,27 @@ run virt-customize -a "$OUT/disk.raw" \ --mkdir /app \ --copy-in "$REPO_DIR/host.py:/app/" \ --copy-in "$REPO_DIR/cloud:/app/" \ - --run-command 'printf "ssh_deletekeys: false\nssh_genkeytypes: []\n" > /etc/cloud/cloud.cfg.d/99-chv-hostkey.cfg; systemctl enable ssh || true' + --run-command 'printf "ssh_deletekeys: false\nssh_genkeytypes: []\n" > /etc/cloud/cloud.cfg.d/99-chv-hostkey.cfg; systemctl enable ssh || true' \ + --run-command 'printf "PasswordAuthentication no\nPermitRootLogin prohibit-password\nChallengeResponseAuthentication no\nKbdInteractiveAuthentication no\nPubkeyAuthentication yes\n" > /etc/ssh/sshd_config.d/99-chv.conf; sshd -t && grep -q "^PasswordAuthentication no" /etc/ssh/sshd_config.d/99-chv.conf && grep -q "^PermitRootLogin prohibit-password" /etc/ssh/sshd_config.d/99-chv.conf' \ + --run-command 'dpkg-query -W -f "${Package} ${Version}\n" > /app/guest-versions-dpkg.txt; pip3 freeze > /app/guest-versions-pip.txt || true' if [ "${PLAYWRIGHT:-0}" = "1" ]; then run virt-customize -a "$OUT/disk.raw" \ --run-command 'pip3 install --no-cache-dir playwright; python3 -m playwright install --with-deps chromium' fi run virt-get-kernel -a "$OUT/disk.raw" -o "$OUT/vmlinux" +# feat-chv-secfix(M4):构建时把客机内实际装好的版本 +# 读回 BUILDINFO.versions(dpkg + pip freeze),名字列表 +# 之外再有逐项版本——可审计、可复现比对。事前 lockfile +# 钉死(apt/pip 精确版本预先锁定)仍待构建宿主生成, +# 见 README 与就绪清单 §11;本步先保证“建成什么版本 +# 都有记录”,不再只有名字。 +if [ "$DRY_RUN" = "1" ]; then + echo "DRY-RUN: virt-cat -a $OUT/disk.raw /app/guest-versions-dpkg.txt > $OUT/guest-versions-dpkg.txt" + echo "DRY-RUN: virt-cat -a $OUT/disk.raw /app/guest-versions-pip.txt > $OUT/guest-versions-pip.txt" +else + virt-cat -a "$OUT/disk.raw" /app/guest-versions-dpkg.txt > "$OUT/guest-versions-dpkg.txt" + virt-cat -a "$OUT/disk.raw" /app/guest-versions-pip.txt > "$OUT/guest-versions-pip.txt" +fi # 产物校验(干跑只打印,不执行校验命令的副作用部分)。 if [ "$DRY_RUN" = "1" ]; then echo "DRY-RUN: test -s $OUT/vmlinux && test -s $OUT/disk.raw" @@ -118,16 +145,29 @@ else python3 - "$OUT/BUILDINFO.json" "$TAG" "$UBUNTU_VERSION" "$CODEX_VERSION" "$REPO_COMMIT" <<'PYEOF' import json, sys, time path, tag, ubuntu, codex, commit = sys.argv[1:6] +def _read_lines(p): + try: + with open(p) as fh: + return [l.strip() for l in fh if l.strip()] + except OSError: + return [] +import os +outdir = os.path.dirname(path) info = {"tag": tag, "ubuntu": ubuntu, "codex_version": codex, "repo_commit": commit, "built_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "contents": ["git", "pandoc", "poppler-utils", "latexmk", "texlive-latex-base", "openssh-server", "cloud-init", "qemu-guest-agent", "python-docx", "openpyxl", "python-pptx", "PyMuPDF", "Pillow", - "codex", "codex-code-mode-host", "codex-resources/bwrap", "/app/host.py"]} + "codex", "codex-code-mode-host", "codex-resources/bwrap", "/app/host.py"], + "versions": { + "dpkg": _read_lines(os.path.join(outdir, "guest-versions-dpkg.txt")), + "pip": _read_lines(os.path.join(outdir, "guest-versions-pip.txt")), + }, + "ubuntu_image_sha256_set": True} with open(path, "w") as fh: json.dump(info, fh, indent=2) PYEOF - python3 -c "import json; d=json.load(open('$OUT/BUILDINFO.json')); assert d['tag'] == '$TAG', d" + python3 -c "import json; d=json.load(open('$OUT/BUILDINFO.json')); assert d['tag'] == '$TAG', d; assert d['versions']['dpkg'], 'BUILDINFO versions.dpkg 为空'; assert d['versions']['pip'], 'BUILDINFO versions.pip 为空'" fi echo "tag=$TAG" echo "out=$OUT" diff --git a/cloud/chv/config.py b/cloud/chv/config.py index 900fb53..cd35b86 100644 --- a/cloud/chv/config.py +++ b/cloud/chv/config.py @@ -24,6 +24,7 @@ timeouts 模块)、门禁类(preflight fail-fast、容量门、磁盘 → sandbox_policy 模块)。见 PR 描述的复用清单。 """ +import os import re @@ -65,7 +66,8 @@ def build_chv_config(params, env): cpus = str(params.get("cpus") or env.get("CHV_CPU") or "2") memory = params.get("memory") or env.get("CHV_MEM") or "2G" state_dir = params.get("state_dir") or env.get("CHV_STATE_DIR") \ - or "/tmp/chv-vms" + or os.path.join(os.path.expanduser("~"), + ".local", "share", "codex-app-server", "chv-vms") ssh_key = params.get("ssh_key") or env.get("CHV_SSH_KEY") if params.get("ssh_user") is not None \ and params.get("ssh_user") == "": @@ -117,15 +119,49 @@ def build_chv_config(params, env): raise ValueError( "CHV_DISK_MODE 只能是 copy 或 reuse:%s" % disk_mode) disk_gb_raw = env.get("CHV_DISK_GB") - disk_gb = float(disk_gb_raw) if disk_gb_raw else None + # feat-chv-secfix(M3):配额默认开启。CHV_DISK_GB 未设时 + # 不再是 None(配额门直接返回),而是 50 GB 总量预算; + # 显式设 0 或负数同样非法——配额门是 fail-closed,不是 + # “设个小值绕过”。check_disk_quota 仍接受 None(直接 + # 调用者的第二道门),但执行器路径永远带默认值。 + try: + disk_gb = float(disk_gb_raw) if disk_gb_raw else 50.0 + except ValueError: + raise ValueError("CHV_DISK_GB 必须是数字") + if disk_gb <= 0: + raise ValueError("CHV_DISK_GB 必须大于 0") egress_proxy = env.get("CHV_EGRESS_PROXY") allow_direct_egress = env.get("CHV_ALLOW_DIRECT_EGRESS") == "1" host_key_mode = params.get("host_key_mode") \ - or env.get("CHV_HOST_KEY_MODE") or "keyscan" + or env.get("CHV_HOST_KEY_MODE") or "provisioned" if host_key_mode not in ("provisioned", "keyscan"): raise ValueError( "CHV_HOST_KEY_MODE 只能是 provisioned 或 keyscan:%s" % host_key_mode) + # feat-chv-secfix(M6):reuse 实验放行须同时声明单租户 + # 项目(部署声明 + 审计,代码无法从环境变量验证数据库 + # 里是否真的只有一个租户——这一点在文档与 preflight 的 + # kind 里明写,不假装已校验)。 + single_tenant_project = env.get("CHV_SINGLE_TENANT_PROJECT") + # feat-chv-secfix(M3 快照上限 / L2 快照目录白名单): + # 快照数、总量、保留期均有默认值;file:// 快照只允许在 + # state_dir 或 CHV_SNAPSHOT_DIR 内,远端协议须显式放行 + # (见 hardening.validate_snapshot_url)。 + try: + snapshot_max_count = int(env.get("CHV_SNAPSHOT_MAX_COUNT") + or 10) + snapshot_max_total_gb = float( + env.get("CHV_SNAPSHOT_MAX_TOTAL_GB") or 20.0) + snapshot_retention_days = float( + env.get("CHV_SNAPSHOT_RETENTION_DAYS") or 30.0) + except ValueError: + raise ValueError("CHV_SNAPSHOT_* 必须是数字") + if snapshot_max_count < 0 or snapshot_max_total_gb < 0 \ + or snapshot_retention_days < 0: + raise ValueError("CHV_SNAPSHOT_* 不能为负数") + snapshot_dir = env.get("CHV_SNAPSHOT_DIR") + allow_remote_snapshot = env.get("CHV_ALLOW_REMOTE_SNAPSHOT") \ + == "1" task_timeout_raw = env.get("CHV_TASK_TIMEOUT") task_timeout = float(task_timeout_raw) \ if task_timeout_raw else None @@ -166,4 +202,10 @@ def build_chv_config(params, env): host_key_mode=host_key_mode, task_timeout=task_timeout, snapshot_url=snapshot_url, warm_pool_size=warm_pool_size, api_timeout=api_timeout, - ready_timeout=ready_timeout, ready_interval=ready_interval) + ready_timeout=ready_timeout, ready_interval=ready_interval, + single_tenant_project=single_tenant_project, + snapshot_max_count=snapshot_max_count, + snapshot_max_total_gb=snapshot_max_total_gb, + snapshot_retention_days=snapshot_retention_days, + snapshot_dir=snapshot_dir, + allow_remote_snapshot=allow_remote_snapshot) diff --git a/cloud/chv/disk.py b/cloud/chv/disk.py index ad297de..a650971 100644 --- a/cloud/chv/disk.py +++ b/cloud/chv/disk.py @@ -85,6 +85,14 @@ def provision_task_disk(state_dir, vm_name, rootfs, disk_mode): task_dir = os.path.join(state_dir, vm_name) os.makedirs(task_dir, exist_ok=True) task_disk = os.path.join(task_dir, "disk.raw") + # feat-chv-secfix(L1):目标是符号链接时先删链接本身, + # 绝不跟随链接写入目标文件(copyfile 跟随链接会截断并 + # 写进链接目标)。删链接与重建之间的 TOCTOU 窗口已在 + # 文档里明写:state_dir 须 0700 且仅 worker 用户可写, + # 宿主本地攻击者本就写不进该目录,L1 修的是“链接已 + # 预置”情形下的最后一道门。 + if os.path.islink(task_disk): + os.remove(task_disk) if os.path.exists(task_disk): try: same = os.path.getsize(task_disk) == os.path.getsize(rootfs) @@ -102,3 +110,145 @@ def provision_task_disk(state_dir, vm_name, rootfs, disk_mode): "disk_quota_unverified", "任务磁盘拷贝失败:%s" % exc) return task_disk + + +def task_disk_path(state_dir, vm_name): + """该 VM 的任务磁盘路径:state_dir//disk.raw。""" + return os.path.join(state_dir, vm_name, "disk.raw") + + +def delete_task_disk(state_dir, vm_name): + """删任务磁盘(feat-chv-secfix,H3):stop/recycle/ + 清理路径都调它。返回 True 表示确实删掉了一个文件, + False 表示本来就没有;删失败抛 OSError(调用方记审计, + 绝不静默——残留盘由 gc_stale_disks 下次再清)。 + 符号链接只删链接本身,不跟随。""" + path = task_disk_path(state_dir, vm_name) + if os.path.islink(path) or os.path.exists(path): + os.remove(path) + return True + return False + + +# -- 快照登记与上限(feat-chv-secfix,M3)------------------------- +# 登记文件:state_dir//snapshots.json,内容为 +# [{url, path(file:// 才有), size, created_at}]。登记是 +# 执行器在宿主侧的账本,不是 VMM 的快照元数据;VMM 不认 +# 它,删登记不等于删快照文件(反之亦然),两者在 +# prune/record 里成对处理并审计。 + +def _snapshots_path(state_dir, vm_name): + return os.path.join(state_dir, vm_name, "snapshots.json") + + +def _load_snapshots(state_dir, vm_name): + import json + path = _snapshots_path(state_dir, vm_name) + try: + with open(path, "r") as fh: + data = json.load(fh) + except (OSError, ValueError): + return [] + return data if isinstance(data, list) else [] + + +def _save_snapshots(state_dir, vm_name, entries): + import json + path = _snapshots_path(state_dir, vm_name) + os.makedirs(os.path.dirname(path), exist_ok=True) + tmp_path = path + ".tmp" + with open(tmp_path, "w") as fh: + json.dump(entries, fh) + os.replace(tmp_path, path) + + +def prune_snapshots(state_dir, vm_name, retention_days, + now=None, delete_files=True): + """按保留期剪枝:删过期条目;delete_files 时同时删 + 对应的 file:// 快照文件(只删登记里记的 path,不猜 + 文件名)。返回(保留条目,剪掉条目)。""" + import time + now = time.time() if now is None else now + kept, pruned = [], [] + for entry in _load_snapshots(state_dir, vm_name): + created = entry.get("created_at") or 0 + if retention_days is not None \ + and now - created > retention_days * 86400: + pruned.append(entry) + if delete_files and entry.get("path"): + try: + if os.path.exists(entry["path"]): + os.remove(entry["path"]) + except OSError: + pass + else: + kept.append(entry) + if pruned: + _save_snapshots(state_dir, vm_name, kept) + return kept, pruned + + +def check_snapshot_limits(state_dir, vm_name, max_count, + max_total_gb, retention_days): + """快照上限预检(snapshot_vm 调 ch-remote 之前调): + 先按保留期剪枝,再查数量(已有数 + 本次 > 上限即 + snapshot_quota_exceeded)与总量(已有总量已达上限即 + 拒绝;本次大小事前未知,总量在 record 时二次收紧, + 见 record_snapshot)。返回剪枝后条目。""" + kept, _ = prune_snapshots(state_dir, vm_name, retention_days) + if len(kept) + 1 > max_count: + raise ChvFailure( + "snapshot_quota_exceeded", + "快照数量已达上限 %d(VM %s)" % (max_count, vm_name)) + total = 0 + for entry in kept: + size = entry.get("size") + if isinstance(size, (int, float)): + total += size + elif entry.get("path") and os.path.exists(entry["path"]): + try: + total += os.path.getsize(entry["path"]) + except OSError: + pass + if total >= max_total_gb * 1024 ** 3: + raise ChvFailure( + "snapshot_quota_exceeded", + "快照总量 %d 字节已达上限 %.1f GB(VM %s)" + % (total, max_total_gb, vm_name)) + return kept + + +def record_snapshot(state_dir, vm_name, snapshot_url, max_total_gb): + """快照成功后登记;file:// 目标记实际大小。登记后若 + 总量超上限,按最旧优先删登记外的旧快照文件(总量硬 + 帽,删动作由调用方审计——本函数返回被删的旧条目)。 + 返回(全部条目,被总量帽删掉的旧条目)。""" + import time + path = None + size = None + if snapshot_url.startswith("file://"): + path = snapshot_url[len("file://"):] + try: + size = os.path.getsize(path) + except OSError: + size = None + entries = _load_snapshots(state_dir, vm_name) + entries.append({"url": snapshot_url, "path": path, + "size": size, "created_at": time.time()}) + _save_snapshots(state_dir, vm_name, entries) + removed = [] + if max_total_gb is not None: + cap = max_total_gb * 1024 ** 3 + total = sum(e.get("size") or 0 for e in entries) + while len(entries) > 1 and total > cap: + oldest = entries.pop(0) + if oldest.get("path") and os.path.exists(oldest["path"]): + try: + os.remove(oldest["path"]) + except OSError: + pass + total -= oldest.get("size") or 0 + removed.append(oldest) + if removed: + _save_snapshots(state_dir, vm_name, entries) + return entries, removed diff --git a/cloud/chv/executor.py b/cloud/chv/executor.py index 489d943..37f87e4 100644 --- a/cloud/chv/executor.py +++ b/cloud/chv/executor.py @@ -40,11 +40,22 @@ from cloud.executor_base import Executor from cloud.chv.config import _parse_memory_bytes from cloud.chv.config import build_chv_config from cloud.chv.disk import check_disk_quota +from cloud.chv.disk import check_snapshot_limits +from cloud.chv.disk import delete_task_disk from cloud.chv.disk import provision_task_disk +from cloud.chv.disk import record_snapshot +from cloud.chv.disk import task_disk_path from cloud.chv.errors import ChvFailure +from cloud.chv.hardening import ensure_secure_state_dir +from cloud.chv.hardening import parse_net_entries +from cloud.chv.hardening import socket_is_live +from cloud.chv.hardening import validate_egress_proxy +from cloud.chv.hardening import validate_net_entries +from cloud.chv.hardening import validate_snapshot_url from cloud.chv.ssh_transport import build_ssh_command as _build_ssh_cmd from cloud.chv.ssh_transport import known_hosts_path as _known_hosts_path from cloud.chv.ssh_transport import parse_keyscan_output +from cloud.chv.ssh_transport import split_secret_env from cloud.chv.ssh_transport import write_known_hosts _LOGGER = logging.getLogger("cloud.cloud_hypervisor") @@ -89,7 +100,10 @@ class CloudHypervisorExecutor(Executor): - 密钥红线:SSH 只传 -i 密钥文件路径,绝不把任何 key 值、 模型 key 值放进 argv、日志或返回结果;客内 host.py 的 模型 key 由客机镜像/cloud-init 预置(运维责任),v1 不 - 在 SSH 命令里注入任务级 key。 + 在 SSH 命令里注入任务级 key。feat-chv-secfix 起网关 + 密钥改走 seed 盘客内文件投递(见 wake_vm 与 + hostkeys.write_seed),keyscan 模式下密钥投递 + fail-closed;非密钥 extra_env 仍走 env 前缀。 VM 唤醒流程(写进代码,不是只留文档;API 模式为准): - 主唤醒路径是 API 模式,不是“一条直接启动命令就完事”: @@ -180,7 +194,9 @@ class CloudHypervisorExecutor(Executor): 存在;CHV_IMAGE 是同义别名,仅当 CHV_ROOTFS 未设时用) - CHV_CPU:vCPU 数(默认 2,命令里写 boot=N) - CHV_MEM:内存(默认 2G,命令里写 size=2G) - - CHV_STATE_DIR:api-socket 目录(默认 /tmp/chv-vms) + - CHV_STATE_DIR:api-socket 目录(默认 + ~/.local/share/codex-app-server/chv-vms,0700; + feat-chv-secfix 起不再默认 /tmp/chv-vms) - CHV_SSH_KEY:SSH 私钥文件路径(必填,文件必须存在) - CHV_SSH_USER:SSH 用户(默认 root) - CHV_SSH_PORT:宿主侧转发到客机的 SSH 端口(默认 2222) @@ -260,8 +276,16 @@ class CloudHypervisorExecutor(Executor): "allow_direct_egress", "task_timeout", "snapshot_url", "warm_pool_size", "api_timeout", "ready_timeout", "ready_interval", "host_key_mode", "ssh_host", - "ssh_forward", "reuse_experiment"): + "ssh_forward", "reuse_experiment", + "single_tenant_project", "snapshot_max_count", + "snapshot_max_total_gb", "snapshot_retention_days", + "snapshot_dir", "allow_remote_snapshot"): setattr(self, _field, getattr(_cfg, _field)) + # feat-chv-secfix(H2):本实例已 seed 投递密钥的 VM + # 名 → 已投递密钥名集合。_run_guest 只对集合内的密钥 + # 名 source 客内文件,未投递的密钥名一律拒绝(防止 + # “wake 没投递、执行时却按名 source 一个旧文件”)。 + self._guest_env_delivered = {} self.api_client = api_client if api_client is not None \ else _unix_socket_api self.sleeper = sleeper if sleeper is not None else time.sleep @@ -329,6 +353,18 @@ class CloudHypervisorExecutor(Executor): "CHV_DISK_MODE=reuse 未经显式实验确认:" "须同时设 CHV_REUSE_SINGLE_TENANT_" "EXPERIMENT=1(仅单租户实验)")) + # feat-chv-secfix(M6):实验放行还须声明单租户项目。 + # 代码无法从环境变量验证数据库里是否真的只有一个 + # 租户——声明 + 审计就是本项的机制,文档明写该边界, + # 不假装已校验多租户状态。 + if getattr(self, "disk_mode", "copy") == "reuse" \ + and getattr(self, "reuse_experiment", False) \ + and not getattr(self, "single_tenant_project", None): + missing.append(("reuse_tenant_undeclared", + "CHV_DISK_MODE=reuse 实验放行须同时声明" + " CHV_SINGLE_TENANT_PROJECT(单租户" + "项目 id,仅部署声明 + 审计,不验证" + "数据库多租户状态)")) # P2-6:配置校验时机统一到 preflight。CPU 非整数、内存 # 字符串非法过去只在 build_vm_config 才炸,现在 preflight # 一次报出(kind=config_invalid)。 @@ -424,26 +460,48 @@ class CloudHypervisorExecutor(Executor): missing.append(("egress_not_configured", "CHV_EGRESS_PROXY 未设且未显式允许直连:" "VM 出口必须过白名单代理,默认不给直连外网")) + if self.egress_proxy: + try: + validate_egress_proxy(self.egress_proxy) + except ChvFailure as exc: + missing.append((exc.kind, str(exc))) + # feat-chv-secfix(H6):state_dir 安全目录门。 + # 默认值已出 /tmp(见 config.py);这里再校验一次: + # 目录须存在且 0700,否则 preflight 直接失败。 + try: + ensure_secure_state_dir(self.state_dir) + except ChvFailure as exc: + missing.append((exc.kind, str(exc))) # 补缺口轨 (4):tap/网络配置校验前置到 preflight。 # CHV_NET_JSON 非法 JSON 或非 list/dict 时直接报 # net_config_invalid,不等到 vm.create 才炸;直连 # 只在显式 CHV_ALLOW_DIRECT_EGRESS=1 时放行,且 # wake_vm 里发 egress_direct_allowed 审计。tap 桥接 # 本身是否真只走代理仍需宿主实测,代码不声称已验证。 + # feat-chv-secfix(L4):再加字段白名单与条目内重复 + # 校验;CHV_NET 与 CHV_NET_JSON 双设时只发警告审计 + # (net_config_dual),不硬拒——CHV_NET 是手动直接 + # 启动命令的透传值,双设不一定是误配,硬拒会断掉 + # 手动运维路径;误配的代价是“API 模式忽略 CHV_NET”, + # 警告 + 文档已写清。 + if self.net and self.net_json: + self._emit("net_config_dual", None, + "CHV_NET 与 CHV_NET_JSON 双设:API 模式只用" + " CHV_NET_JSON,CHV_NET 仅手动直接启动命令用") if self.net_json: try: - _net = json.loads(self.net_json) - except ValueError: - missing.append(("net_config_invalid", - "CHV_NET_JSON 不是合法 JSON")) - else: - if not isinstance(_net, (list, dict)): - missing.append(("net_config_invalid", - "CHV_NET_JSON 须为 JSON list 或 dict")) + _entries = parse_net_entries(self.net_json) + validate_net_entries(_entries, []) + except ChvFailure as exc: + missing.append((exc.kind, str(exc))) self._preflight_config_checks(missing) if missing: kind = missing[0][0] detail = "; ".join(text for _, text in missing) + # feat-chv-secfix(M5/L3):preflight 失败本身也 + # 审计(此前 try 块之前失败一律无审计事件)。 + self._emit("preflight_failed", None, + "preflight 失败:%s" % detail, kind) raise ChvFailure( kind, "cloud-hypervisor preflight 失败:%s" % detail) return True @@ -456,18 +514,22 @@ class CloudHypervisorExecutor(Executor): def _emit(self, event, task, detail="", kind=None): """VM 生命周期审计事件:有 audit_callback 时调它 (worker 接线后转 AuthStore 审计,即任务 timeline 的 - 审计来源),无 callback 时只记日志,绝不因审计失败炸 - 任务。event 取值:vm_create/vm_boot/vm_ready/vm_pause/ - vm_resume/vm_snapshot/vm_restore/vm_shutdown/vm_destroy/ - vm_failed/vm_recycled/warm_pool_acquire/warm_pool_release。""" + 审计来源),无 callback 时只记日志。 + feat-chv-secfix(M5):审计回调失败不再双重静默 + pass——记宿主 error 日志(审计完整性本身没有保证, + 但失败至少可见;任务仍不因此炸,审计失败不改变 + 任务状态是 worker 层的既定语义)。审计 detail 里 + 绝不含密钥值(调用方负责:本类所有 _emit 的 detail + 只含 VM 名、路径、URL、kind,不含任何 key 值)。""" try: if self.audit_callback is not None: self.audit_callback(event, task, detail, kind) else: _LOGGER.info("chv_audit event=%s kind=%s %s", event, kind, detail) - except Exception: - pass + except Exception as exc: + _LOGGER.error("chv_audit_failed event=%s kind=%s error=%s", + event, kind, exc) # -- 命令构造(纯函数,不做文件校验) ------------------------------- def _build_vm_command(self, task): @@ -535,7 +597,7 @@ class CloudHypervisorExecutor(Executor): "里给 net 条目配 ip(静态 tap 客机 IP)、或显式设 " "CHV_SSH_FORWARD=1 宣称宿主 NAT 已转发回环端口") - def provision_host_key(self, task): + def provision_host_key(self, task, secret_env=None): """按 VM 预置 host key(补缺口轨强方案,见 hostkeys.py): 启动前为该 VM 生成一次性 ed25519 host key 对,公钥 立即写进该 VM 的 known_hosts(钉死),私钥只写进 @@ -543,12 +605,23 @@ class CloudHypervisorExecutor(Executor): 而删。返回 {paths, seed_dir, known_hosts}。keygen 失败即 raise,provisioned 模式下绝不回落 keyscan; 不同 VM 的 key 对不同(state_dir/ 隔离),同一 - VM 重启复用已有对(否则钉死立即失效)。""" + VM 重启复用已有对(否则钉死立即失效)。 + feat-chv-secfix(H2/M2):secret_env 给定时写进 + seed 的客内密钥文件(0600);上次清理失败留下的 + 脏标记存在时直接拒绝(清理失败即拒起,不再复用 + 旧 key 对)。成功后审计 host_key_generated(只含 + VM 名与密钥名,不含任何 key 值)。""" from cloud.chv.hostkeys import ( - generate_host_keypair, host_key_paths, known_hosts_line, - write_seed) + dirty_marker_path, generate_host_keypair, host_key_paths, + known_hosts_line, write_seed) from cloud.chv.ssh_transport import write_known_hosts vm = self.vm_name(task) + if os.path.exists(dirty_marker_path(self.state_dir, vm)): + raise ChvFailure( + "vm_dirty_refused", + "VM %s 上次清理失败(脏标记存在),拒绝复用旧 " + "host key 启动:请人工清理 state_dir 后删脏标记" + % vm) paths = generate_host_keypair( self.state_dir, vm, runner=lambda cmd: self._call_runner(cmd)) @@ -567,7 +640,11 @@ class CloudHypervisorExecutor(Executor): [known_hosts_line(self.ssh_port, public_text, host=ssh_host)]) seed_dir = write_seed(paths["seed_dir"], vm, - private_text, public_text) + private_text, public_text, + secret_env=secret_env) + self._emit("host_key_generated", task, + "vm=%s secret_names=%s" + % (vm, ",".join(sorted(secret_env or {})))) return {"paths": paths, "seed_dir": seed_dir, "known_hosts": paths["known_hosts"]} @@ -599,27 +676,56 @@ class CloudHypervisorExecutor(Executor): write_known_hosts(self.known_hosts_path(task), key_lines) except OSError: return False + # feat-chv-secfix(M5):捕获成功独立审计(只含 VM + # 名与主机,不含 key 行本身)。 + self._emit("host_key_captured", task, + "vm=%s host=%s" % (self.vm_name(task), ssh_host)) return True def build_ssh_command(self, task, inner_cmd, extra_env=None): """构造 SSH 命令:ssh -p <端口> -i + 钉死选项 - + user@127.0.0.1 + 客内命令。argv 里只有 key 文件路径, + + user@ + 客内命令。argv 里只有 key 文件路径, 绝无任何 key 值。egress_proxy 已设时客内命令前加 env 前缀(HTTPS_PROXY/HTTP_PROXY/NO_PROXY,代理地址本身 不是密钥;客机出口只走该白名单代理,见类 docstring)。 Host key 选项是钉死式(StrictHostKeyChecking=yes + 每 VM known_hosts 文件),#216 的 no + /dev/null 已 - 在本轨废除,见类 docstring 与 ssh_transport.py。""" + 在本轨废除,见类 docstring 与 ssh_transport.py。 + feat-chv-secfix(H2):extra_env 先拆密钥子集与非 + 密钥子集。密钥子集须已在本实例的 wake 里 seed 投递 + (_guest_env_delivered 有记录),否则直接 ChvFailure + (secret_env_not_delivered),绝不回落 argv;已投递 + 时远程命令 source 客内密钥文件。keyscan 模式下 + wake 根本不建 seed,密钥子集到这里必然未投递—— + 即 CHV + 网关密钥现在要求 provisioned 模式(已是 + 默认),keyscan 模式下的网关任务 fail-closed,不再 + 把密钥值拼进 argv 将就跑。""" try: ssh_host = self.resolve_ssh_host() except ChvFailure: # 纯命令构造(旧测试/干跑)不炸:回环缺省仅作 # 构造兼容,wake/wait 路径仍严格解析。 ssh_host = "127.0.0.1" + secrets, plain = split_secret_env(extra_env) + guest_env_file = None + if secrets: + from cloud.chv.hostkeys import GUEST_ENV_PATH + delivered = self._guest_env_delivered.get( + self.vm_name(task), set()) + missing = [name for name in secrets if name not in delivered] + if missing: + raise ChvFailure( + "secret_env_not_delivered", + "密钥环境变量 %s 未经 seed 投递,拒绝进 ssh " + "argv:provisioned 模式下 wake 时投递," + "keyscan 模式不支持密钥投递" + % ",".join(sorted(missing))) + guest_env_file = GUEST_ENV_PATH return _build_ssh_cmd( self.ssh_port, self.ssh_key, self.ssh_user, self.egress_proxy, self.known_hosts_path(task), - inner_cmd, ssh_host=ssh_host, extra_env=extra_env) + inner_cmd, ssh_host=ssh_host, extra_env=plain or None, + guest_env_file=guest_env_file) # -- API 模式:VmConfig / API 调用 / 就绪轮询 ----------------------- def build_vm_config(self, task_disk=None, seed_disk=None): @@ -634,6 +740,22 @@ class CloudHypervisorExecutor(Executor): cpus = int(self.cpus) except ValueError: raise ValueError("CHV_CPU 必须是整数:%r" % (self.cpus,)) + # feat-chv-secfix(L2):磁盘路径白名单。显式给的 + # task_disk 若不是基础镜像本身(reuse 模式的合法 + # 情形),须在 state_dir 内;seed 盘须在 state_dir + # 内——VmConfig 再也不能挂进宿主任意文件。 + if task_disk and task_disk != self.rootfs: + if not os.path.realpath(task_disk).startswith( + os.path.realpath(self.state_dir) + os.sep): + raise ChvFailure( + "disk_path_not_allowed", + "任务磁盘须在 state_dir 内:%s" % task_disk) + if seed_disk: + if not os.path.realpath(seed_disk).startswith( + os.path.realpath(self.state_dir) + os.sep): + raise ChvFailure( + "disk_path_not_allowed", + "seed 磁盘须在 state_dir 内:%s" % seed_disk) config = { "cpus": {"boot_vcpus": cpus, "max_vcpus": cpus}, "memory": {"size": _parse_memory_bytes(self.memory)}, @@ -691,14 +813,16 @@ class CloudHypervisorExecutor(Executor): return len(self._vms) def _persistent_vm_count(self, exclude=None): - """跨重启容量计数(feat-chv-fix,P1-4):本实例登记 - 的活进程名 ∪ state_dir 下 chv-*.sock 对应的 VM 名。 - worker 重启后 _vms 为空,但旧 VMM 的 socket 仍在, - 仍计入容量——重启不再绕过 CHV_MAX_VMS。注意这是 - 保守计数:已死 VMM 的残留 socket 在清理前也计入, - 直到 cleanup_stale_vms / worker reconcile 清掉它。 - exclude:调用方自己的 VM 名(同任务重复 wake 时先 - 关旧 VM,不应被自己的残留 socket 误拒)。""" + """跨重启容量计数(feat-chv-fix,P1-4 + feat-chv-secfix + M7):本实例登记的活进程名 ∪ state_dir 下 chv-*.sock + 对应的 VM 名。worker 重启后 _vms 为空,但旧 VMM 的 + socket 仍在,仍计入容量——重启不再绕过 CHV_MAX_VMS。 + M7 修正:残留 socket 不再无条件计入——只有 socket + 真能连通(有监听者)才计数;空文件/死 socket 不计, + 否则 CHV_MAX_VMS 会被崩溃残留占满、新任务全部被 + 拒(可用性 DoS)。exclude:调用方自己的 VM 名(同 + 任务重复 wake 时先关旧 VM,不应被自己的残留 socket + 误拒)。""" names = set() with self._vms_lock: for name, proc in self._vms.items(): @@ -714,7 +838,9 @@ class CloudHypervisorExecutor(Executor): entries = [] for entry in entries: if entry.endswith(".sock") and entry[:-5].startswith("chv-"): - names.add(entry[:-5]) + sock_path = os.path.join(self.state_dir, entry) + if socket_is_live(sock_path): + names.add(entry[:-5]) if exclude: names.discard(exclude) return len(names) @@ -784,7 +910,7 @@ class CloudHypervisorExecutor(Executor): # feat-chv-fix(P0-2):provisioned 模式下缺 known_hosts # 时直接 fail-fast,绝不调用 capture_host_key 回落 TOFU。 # (keyscan 模式才走下面的捕获分支。) - if getattr(self, "host_key_mode", "keyscan") == "provisioned" \ + if getattr(self, "host_key_mode", "provisioned") == "provisioned" \ and not os.path.exists(self.known_hosts_path(task)): try: self.stop_vm(task) @@ -803,6 +929,28 @@ class CloudHypervisorExecutor(Executor): self.build_ssh_command(task, ["true"])) if getattr(result, "returncode", 1) == 0: return True + # feat-chv-secfix(M5):host key 不匹配是独立 + # 安全事件,不是“sshd 没起”。SSH stderr 含 + # 钉死拒绝标记时立即审计 host_key_mismatch 并 + # fail-fast(不把 MITM 尝试轮询成普通超时), + # 审计 detail 只含 VM 名与标记,不含 key 值。 + stderr = getattr(result, "stderr", "") or "" + if "Host key verification failed" in stderr \ + or "REMOTE HOST IDENTIFICATION" in stderr: + self._emit("host_key_mismatch", task, + "vm=%s ssh host key 被拒绝(钉死不匹配)" + % self.vm_name(task), "host_key_mismatch") + try: + self.stop_vm(task) + except Exception: + pass + raise ChvFailure( + "host_key_mismatch", + "SSH host key 不匹配(钉死拒绝):VM %s," + "可能被替换或中间人,已关 VM" + % self.vm_name(task)) + except ChvFailure: + raise except Exception: pass if attempt + 1 < attempts: @@ -861,7 +1009,75 @@ class CloudHypervisorExecutor(Executor): % (" ".join(args), getattr(result, "returncode"))) return result - def wake_vm(self, task): + def _registered_net_entries(self, exclude_vm=None): + """已登记 VM 的 net 条目(H7):扫描 state_dir/*/net.json + (wake 成功后写,stop 时删)。文件损坏/缺失即跳过该 + VM——登记缺失只会漏判冲突,不会误拒;漏判的兜底是 + 宿主防火墙部署清单里的“每 VM 独立 tap”检查项。""" + registered = [] + try: + entries = os.listdir(self.state_dir) + except Exception: + return registered + for entry in entries: + if entry == exclude_vm: + continue + path = os.path.join(self.state_dir, entry, "net.json") + try: + with open(path, "r") as fh: + data = json.load(fh) + except Exception: + continue + if not isinstance(data, list): + continue + for item in data: + if isinstance(item, dict): + row = dict(item) + row["vm"] = entry + registered.append(row) + return registered + + def _record_net_entries(self, vm, entries): + """wake 成功后登记本 VM 的 net 条目(best-effort 写, + 失败只记日志——登记缺失的后果是漏判冲突,见上文)。""" + try: + vm_dir = os.path.join(self.state_dir, vm) + os.makedirs(vm_dir, exist_ok=True) + tmp_path = os.path.join(vm_dir, "net.json.tmp") + with open(tmp_path, "w") as fh: + json.dump(entries, fh) + os.replace(tmp_path, os.path.join(vm_dir, "net.json")) + except OSError as exc: + _LOGGER.warning("chv_net_registry_write_failed vm=%s " + "error=%s", vm, exc) + + def _check_api_socket_permissions(self, socket_path): + """VMM api-socket 权限门(H6):socket 文件存在时, + 组/其它可写(mode & 0o022)即 fail-fast——Unix socket + 的 connect 需要写权限,可写即任何同组/其它用户都 + 能调 VMM API。仅禁写位,不禁读/执行位:VMM 默认 + umask 下的 0755 类权限不误杀(VMM 默认值代码控制 + 不了,真机上的实际权限仍须按部署清单核对)。 + socket 文件不存在时(mock/带外)跳过,不假装已验。 + 普通文件(mock 占位)也跳过——只对真正的 socket + 文件校验权限。""" + if not os.path.exists(socket_path): + return + try: + import stat as _stat + st = os.stat(socket_path) + if not _stat.S_ISSOCK(st.st_mode): + return + mode = st.st_mode & 0o777 + except OSError: + return + if mode & 0o022: + raise ChvFailure( + "socket_permissions_insecure", + "VMM api-socket 组/其它可写(%o),拒绝启动:%s" + % (mode, socket_path)) + + def wake_vm(self, task, extra_env=None): """主唤醒路径(API 模式,dispatch/run/goal 的前置步骤): preflight → 容量门 → 磁盘配额门 → 每任务磁盘供给 → launcher 只起 VMM(--api-socket)→ 等 vmm.ping 通 → @@ -871,21 +1087,54 @@ class CloudHypervisorExecutor(Executor): 成功返回 {vm, api_socket, state: "running", ready: True, mode: "cold"},并审计 vm_create/vm_boot/vm_ready。 单任务单 VM:同一任务重复 wake 时先关旧 VM 再起新 - 的,绝不两个 VM 共用一个任务名。""" + 的,绝不两个 VM 共用一个任务名。 + feat-chv-secfix:extra_env(_run_with_vm 透传)里的 + 密钥子集在 provisioned 模式下写进 seed 客内文件, + keyscan 模式下有密钥子集即 fail-closed;起 VM 前的 + preflight/容量/配额失败也发 vm_failed 审计(L3); + net 隔离在起 VMM 前校验(H7),成功后登记 net.json。""" if not isinstance(task, dict) or task.get("id") is None: raise ValueError("任务缺少 id") - self.preflight() - # feat-chv-fix:SSH 目标与 reuse 放行在启动前就定性。 - # resolve 失败即 fail-fast(不先起 VMM 再超时); - # reuse 实验放行必须审计,留痕给运维复核。 - self.resolve_ssh_host() name = self.vm_name(task) - self._check_capacity(exclude=name) - self._check_disk_quota() + try: + self.preflight() + # feat-chv-fix:SSH 目标与 reuse 放行在启动前就定性。 + # resolve 失败即 fail-fast(不先起 VMM 再超时); + # reuse 实验放行必须审计,留痕给运维复核。 + self.resolve_ssh_host() + self._check_capacity(exclude=name) + self._check_disk_quota() + # feat-chv-secfix(H7):跨 VM tap/mac/ip 冲突在 + # 起 VMM 前拒绝(preflight 只做了条目内校验)。 + net_entries = [] + if self.net_json: + net_entries = validate_net_entries( + parse_net_entries(self.net_json), + self._registered_net_entries(exclude_vm=name)) + # feat-chv-secfix(H2):密钥子集的投递能力在起 + # VM 前就定性——keyscan 模式无 seed 可投递,有 + # 密钥即 fail-closed,不等到执行时才在 argv 前 + # 被拦(那时 VM 已起,失败语义更脏)。 + secrets, _plain = split_secret_env(extra_env) + if secrets and getattr(self, "host_key_mode", + "provisioned") != "provisioned": + raise ChvFailure( + "secret_env_requires_provisioned", + "密钥环境变量 %s 须走 seed 文件投递,仅 " + "provisioned 模式支持(当前 %s)" + % (",".join(sorted(secrets)), + getattr(self, "host_key_mode", "provisioned"))) + except ChvFailure as exc: + self._emit("vm_failed", task, + "vm=%s wake 前置失败 kind=%s" % (name, exc.kind), + exc.kind) + raise if getattr(self, "disk_mode", "copy") == "reuse": self._emit("disk_reuse_experiment_allowed", task, "vm=%s CHV_REUSE_SINGLE_TENANT_EXPERIMENT=1 " - "explicit" % name) + "explicit single_tenant_project=%s" + % (name, getattr(self, "single_tenant_project", + None))) old = None with self._vms_lock: if name in self._vms: @@ -903,21 +1152,36 @@ class CloudHypervisorExecutor(Executor): "vm=%s CHV_ALLOW_DIRECT_EGRESS=1 explicit" % name) try: seed_disk = None - if getattr(self, "host_key_mode", "keyscan") == "provisioned": + if getattr(self, "host_key_mode", "provisioned") == "provisioned": # 强方案:启动前预置每 VM host key 并钉死, # 再把 seed 目录做成 CIDATA seed 磁盘并挂进 # VmConfig(P0-1 闭环:生成→制盘→挂盘三步缺 # 一即 fail-fast,绝不回落 keyscan、绝不只 # 生成不注入)。 + # feat-chv-secfix(H2/H5):secret_env 随 seed + # 写进客内密钥文件;seed.iso 建好(0600,见 + # build_seed_disk)后立即删宿主侧私钥——此后 + # 宿主上只剩公钥 + known_hosts,私钥只活在客 + # 机与 seed.iso(随 VM 销毁而删)里。删私钥 + # 失败即 fail-fast(VM 尚未启动,此时拒绝最 + # 安全,except 里 stop_vm 会清掉 seed)。 from cloud.chv.hostkeys import ( - build_seed_disk, seed_disk_path) - provisioned = self.provision_host_key(task) + build_seed_disk, delete_host_private_key, + seed_disk_path) + provisioned = self.provision_host_key( + task, secret_env=secrets or None) seed_disk = seed_disk_path(self.state_dir, name) build_seed_disk( provisioned["seed_dir"], seed_disk, runner=lambda cmd: self._call_runner(cmd)) + delete_host_private_key(self.state_dir, name) + if secrets: + self._guest_env_delivered[name] = set(secrets) + else: + self._guest_env_delivered.pop(name, None) self._launch_api_only(task) self.wait_api_ready(task) + self._check_api_socket_permissions(socket_path) status, _ = self._api_call( socket_path, "PUT", "/vm.create", self.build_vm_config(task_disk, seed_disk=seed_disk)) @@ -927,6 +1191,8 @@ class CloudHypervisorExecutor(Executor): "vm.create 失败:status=%s" % status) self._emit("vm_create", task, "vm=%s disk=%s" % (name, task_disk)) + if net_entries: + self._record_net_entries(name, net_entries) status, _ = self._api_call(socket_path, "PUT", "/vm.boot") if status not in (200, 204): raise ChvFailure( @@ -978,9 +1244,23 @@ class CloudHypervisorExecutor(Executor): """给(已暂停的)VM 做快照:ch-remote snapshot 。snapshot_url 为空即 ValueError;成功 审计 vm_snapshot。快照本身不改变 VM 状态(仍 paused, - 需 resume 才醒)。""" + 需 resume 才醒)。 + feat-chv-secfix(L2/M3):ch-remote 之前先过 URL + 白名单(file:// 须在 state_dir/CHV_SNAPSHOT_DIR 内, + 远端须显式放行)与数量/总量/保留期上限;成功后登 + 记并在总量超帽时按最旧优先剪枝(删动作审计)。""" if not snapshot_url: raise ValueError("snapshot_url 不能为空") + validate_snapshot_url( + snapshot_url, self.state_dir, + snapshot_dir=getattr(self, "snapshot_dir", None), + allow_remote=getattr(self, "allow_remote_snapshot", False)) + vm_name = self.vm_name(task_or_name) + check_snapshot_limits( + self.state_dir, vm_name, + getattr(self, "snapshot_max_count", 10), + getattr(self, "snapshot_max_total_gb", 20.0), + getattr(self, "snapshot_retention_days", 30.0)) self._ch_remote(task_or_name, "snapshot", "source_url=%s" % snapshot_url) # feat-chv-fix(P1-7):只查 returncode 不够。file:// @@ -997,6 +1277,13 @@ class CloudHypervisorExecutor(Executor): "快照未落盘或为空:%s" % snapshot_url) task = task_or_name if isinstance(task_or_name, dict) \ else {"id": task_or_name} + _entries, _removed = record_snapshot( + self.state_dir, vm_name, snapshot_url, + getattr(self, "snapshot_max_total_gb", 20.0)) + for _old in _removed: + self._emit("vm_snapshot_pruned", task, + "vm=%s pruned snapshot=%s(总量超帽,最旧优先)" + % (vm_name, _old.get("url"))) self._emit("vm_snapshot", task, "vm=%s snapshot=%s" % (self.vm_name(task_or_name), snapshot_url)) @@ -1015,6 +1302,13 @@ class CloudHypervisorExecutor(Executor): raise ValueError("snapshot_url 不能为空") if "://" not in snapshot_url: snapshot_url = "file://%s" % snapshot_url + # feat-chv-secfix(L2):恢复源同样过 URL 白名单 + # (file:// 须在白名单目录内,远端须显式放行), + # 在 preflight/起 VMM 之前就拒绝。 + validate_snapshot_url( + snapshot_url, self.state_dir, + snapshot_dir=getattr(self, "snapshot_dir", None), + allow_remote=getattr(self, "allow_remote_snapshot", False)) self.preflight() self.resolve_ssh_host() # feat-chv-fix(P0-2 修正版):restore 不走 vm.create, @@ -1029,7 +1323,7 @@ class CloudHypervisorExecutor(Executor): # 校验是 snapshot_vm/hibernate_vm 创建时的门(P1-7), # restore 的 ch-remote 本身会在源缺失时失败;在这里 # 再验一次会把“源在远端/对象存储”的合法路径也挡掉。 - if getattr(self, "host_key_mode", "keyscan") == "provisioned" \ + if getattr(self, "host_key_mode", "provisioned") == "provisioned" \ and not os.path.exists(self.known_hosts_path(task)): raise ChvFailure( "host_key_not_pinned", @@ -1040,6 +1334,7 @@ class CloudHypervisorExecutor(Executor): try: self._launch_api_only(task) self.wait_api_ready(task) + self._check_api_socket_permissions(self.api_socket(task)) self._ch_remote(task, "restore", "source_url=%s" % snapshot_url) self._emit("vm_restore", task, @@ -1117,6 +1412,17 @@ class CloudHypervisorExecutor(Executor): # 公钥文件保留供事后审计,删失败不炸关闭。下次 # 同名 VM 预置时若私钥已删即重新生成并覆盖 # known_hosts,不会用旧钉死去校验新 VM。 + # feat-chv-secfix(H3/M2):清理分三块独立记账—— + # seed/私钥块、任务磁盘块、net 登记块,任一块失败 + # 都记对应审计 + 写脏标记(清理失败即拒起),绝不 + # 再以“一块失败、整段吞掉”收场;任务磁盘块是 H3 + # 的主修复:stop 即删 disk.raw(保留期 0;需取证 + # 须在 shutdown 前经 SSH 拷出,见 recycle_vm 文档)。 + # net.json 是 live 预约(非审计 artifact),随 VM + # 关闭而删,tap/mac/ip 预约随即释放。 + _task = task_or_name if isinstance(task_or_name, dict) \ + else {"id": task_or_name} + _cleanup_failed = False try: seed_dir = os.path.join(self.state_dir, name, "seed") if os.path.isdir(seed_dir): @@ -1128,16 +1434,45 @@ class CloudHypervisorExecutor(Executor): if os.path.exists(_p): os.remove(_p) except Exception as _cleanup_exc: - # feat-chv-fix(P2-5):seed/私钥删除失败不再静默 - # 吞掉——记 vm_key_cleanup_failed 审计;且下次同名 - # VM 预置时若私钥残留,generate_host_keypair 的 - # “复用已有对”语义不变,但审计痕迹让“一性”破 - # 坏可被发现(彻底根治须另做“清理失败即拒起”, - # 见 PR 描述的遗留项,不在本轨假装已根治)。 - task = task_or_name if isinstance(task_or_name, dict) \ - else {"id": task_or_name} - self._emit("vm_key_cleanup_failed", task, + _cleanup_failed = True + self._emit("vm_key_cleanup_failed", _task, "vm=%s error=%s" % (name, _cleanup_exc)) + try: + if delete_task_disk(self.state_dir, name): + self._emit("vm_disk_deleted", _task, + "vm=%s disk deleted on stop" % name) + except Exception as _disk_exc: + _cleanup_failed = True + self._emit("vm_disk_delete_failed", _task, + "vm=%s error=%s" % (name, _disk_exc)) + try: + _net_path = os.path.join(self.state_dir, name, "net.json") + if os.path.exists(_net_path): + os.remove(_net_path) + except Exception: + _cleanup_failed = True + self._guest_env_delivered.pop(name, None) + if _cleanup_failed: + # M2 根治:脏标记。标记本身写失败也不炸关闭, + # 但下次 provision_host_key 仍会因残留文件复用 + # 旧 key——该残留情形由 cleanup_stale_secrets 的 + # 扫描兜底(见 cleanup_stale_vms),文档明写。 + try: + from cloud.chv.hostkeys import dirty_marker_path + _marker = dirty_marker_path(self.state_dir, name) + os.makedirs(os.path.dirname(_marker), exist_ok=True) + with open(_marker, "w") as fh: + fh.write("cleanup failed\n") + except Exception: + pass + else: + try: + from cloud.chv.hostkeys import dirty_marker_path + _marker = dirty_marker_path(self.state_dir, name) + if os.path.exists(_marker): + os.remove(_marker) + except Exception: + pass task = task_or_name if isinstance(task_or_name, dict) \ else {"id": task_or_name} if had: @@ -1146,6 +1481,100 @@ class CloudHypervisorExecutor(Executor): _LOGGER.info("chv_vm_stop vm=%s had=%s", name, had) return had + def gc_stale_disks(self): + """残留盘 GC(H3):扫描 state_dir/*/disk.raw,VM + 非 live(无 api-socket 文件且无登记活进程)即删盘 + 并审计;删失败审计 vm_disk_delete_failed 并计入 + failed。返回 {deleted, failed}。""" + deleted, failed = [], [] + try: + entries = os.listdir(self.state_dir) + except Exception: + return {"deleted": deleted, "failed": failed} + for entry in entries: + if not entry.startswith("chv-"): + continue + disk = task_disk_path(self.state_dir, entry) + if not (os.path.islink(disk) or os.path.exists(disk)): + continue + live = os.path.exists( + os.path.join(self.state_dir, entry + ".sock")) + with self._vms_lock: + proc = self._vms.get(entry) + if proc is not None: + try: + live = live or proc.poll() is None + except Exception: + pass + if live: + continue + try: + delete_task_disk(self.state_dir, entry) + deleted.append(entry) + self._emit("vm_disk_gc", {"id": entry}, + "vm=%s stale disk deleted" % entry) + except Exception as exc: + failed.append(entry) + self._emit("vm_disk_delete_failed", {"id": entry}, + "vm=%s gc error=%s" % (entry, exc)) + return {"deleted": deleted, "failed": failed} + + def cleanup_stale_secrets(self): + """崩溃残留扫描(H5):state_dir 下的 host 私钥文件 + 在本轨不变量下只可能是残留——live VM 的私钥在 seed + 盘建好后即删(见 wake_vm),所以任何残留私钥文件 + 一律删除并审计;非 live VM 的 seed.iso/seed 目录同 + 理。返回已清理的 VM 名列表。worker 启动与 + cleanup_stale_vms 时调它,不依赖 socket 是否存在。""" + cleaned = [] + try: + entries = os.listdir(self.state_dir) + except Exception: + return cleaned + for entry in entries: + if not entry.startswith("chv-"): + continue + vm_dir = os.path.join(self.state_dir, entry) + if not os.path.isdir(vm_dir): + continue + priv = os.path.join(vm_dir, "ssh_host_ed25519_key") + removed_priv = False + if os.path.exists(priv): + try: + os.remove(priv) + removed_priv = True + except Exception: + pass + live = os.path.exists( + os.path.join(self.state_dir, entry + ".sock")) + with self._vms_lock: + proc = self._vms.get(entry) + if proc is not None: + try: + live = live or proc.poll() is None + except Exception: + pass + removed_seed = False + if not live: + for _suffix in ("seed.iso", "seed"): + _p = os.path.join(vm_dir, _suffix) + try: + if os.path.isdir(_p): + shutil.rmtree(_p) + removed_seed = True + elif os.path.exists(_p): + os.remove(_p) + removed_seed = True + except Exception: + pass + if removed_priv or removed_seed: + cleaned.append(entry) + self._emit("host_secret_residue_cleaned", + {"id": entry}, + "vm=%s private_key=%s seed=%s" + % (entry, removed_priv, removed_seed)) + return cleaned + def shutdown_vm(self, task_or_name): """关闭入口的正式名:即 stop_vm(ch-remote shutdown)。 任务结束/失败/取消时调它。""" @@ -1181,6 +1610,9 @@ class CloudHypervisorExecutor(Executor): def cleanup_stale_vms(self): """清理残留 VM:只清 socket 残留与已死进程对应的 VM。 + feat-chv-secfix(H3/H5):先跑残留盘 GC 与残留 + 密钥扫描(不依赖 socket 是否存在,覆盖 worker 崩溃 + 后 socket 已删、文件仍在的情形),再走原 socket 扫描。 扫描 state_dir 下 *.sock:登记进程仍活(poll() 为 None) 的跳过;其余一律走 stop_vm。返回已清理的 VM 名列表; state_dir 不存在时返回 [],绝不抛异常。基础设施或 @@ -1189,6 +1621,14 @@ class CloudHypervisorExecutor(Executor): 的 chv- 命名)。 """ cleaned = [] + try: + self.cleanup_stale_secrets() + except Exception: + pass + try: + self.gc_stale_disks() + except Exception: + pass try: entries = os.listdir(self.state_dir) except Exception: @@ -1350,8 +1790,11 @@ class CloudHypervisorExecutor(Executor): 判 interrupted(断点可续跑)而不是笼统 failed 或 原样抛异常;VM 还活着时的异常/失败原样处理,不 误判。goal 结果本身已有 interrupted 分类(见 - parse_goal_result),这里不再二次改写 goal 结论。""" - self.wake_vm(task) + parse_goal_result),这里不再二次改写 goal 结论。 + feat-chv-secfix(H2):extra_env 透传给 wake_vm(密钥 + 子集在 provisioned 模式下随 seed 投递),再透传给 + _run_guest(远程命令 source 客内密钥文件)。""" + self.wake_vm(task, extra_env=extra_env) try: try: if goal: diff --git a/cloud/chv/hardening.py b/cloud/chv/hardening.py new file mode 100644 index 0000000..6284480 --- /dev/null +++ b/cloud/chv/hardening.py @@ -0,0 +1,196 @@ +"""CHV 安全加固 helper(feat-chv-secfix,叶子模块)。 + +这里只放不依赖执行器类的纯校验/文件操作:state_dir 安全 +目录、VMM socket 活性、net 条目隔离校验、快照 URL 白名单、 +egress 代理地址校验。宿主防火墙规则、tap 桥接本身是运维 +外部状态,代码建不出来也不校验——那部分只写进部署清单 +(docs/chv-production-readiness.md §11),绝不在代码里 +假装已隔离。 +""" + +import json +import os +import socket + +from cloud.chv.errors import ChvFailure + +# NET_JSON 条目字段白名单(L4):tap/mac/ip/mask 之外的字段 +# 一律拒绝——未知字段原样送进 VMM API 的行为不确定,静默 +# 透传等于把 VMM 的字段语义赌在运维手写上。 +_NET_FIELDS = frozenset({"tap", "mac", "ip", "mask"}) + + +def ensure_secure_state_dir(state_dir): + """建 state_dir 并保证 0700(H6):目录不存在即按 0700 + 建;已存在但组/其它位可访时 chmod 0700;失败即 + ChvFailure(state_dir_insecure),绝不带着 /tmp 式 + 宽松目录启动 VM。umask 影响 mkdir 的默认权限,故建 + 后一律显式 chmod 校验一次。""" + try: + os.makedirs(state_dir, exist_ok=True) + os.chmod(state_dir, 0o700) + except OSError as exc: + raise ChvFailure( + "state_dir_insecure", + "state_dir 无法建成安全目录(0700):%s:%s" + % (state_dir, exc)) + try: + mode = os.stat(state_dir).st_mode & 0o777 + except OSError as exc: + raise ChvFailure( + "state_dir_insecure", + "state_dir 无法校验权限:%s:%s" % (state_dir, exc)) + if mode & 0o077: + raise ChvFailure( + "state_dir_insecure", + "state_dir 权限仍宽松(%o),拒绝启动:%s" + % (mode, state_dir)) + return state_dir + + +def socket_is_live(socket_path): + """VMM api-socket 是否真有监听者(M7):能连通才算活。 + 普通文件、空文件、已死 VMM 的残留 socket 一律算死—— + 容量计数不再被残留文件占满。连接失败的任何异常都算 + 死(不区分 refused/超时,计数是尽力而为的读操作)。 + 注意竞态:刚启动、尚未 listen 的 VMM 会被判死,容量 + 门在并发唤醒时可能短暂低估——文档明写该窗口,容量门 + 是“拒绝超限”的护栏,不是精确的分布式锁。""" + try: + if not socket_path or not os.path.exists(socket_path): + return False + sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + try: + sock.settimeout(0.5) + return sock.connect_ex(socket_path) == 0 + finally: + sock.close() + except OSError: + return False + + +def parse_net_entries(net_json): + """解析 CHV_NET_JSON 为条目 list(非法 JSON / 非 + list/dict 即 net_config_invalid)。""" + if not net_json: + return [] + try: + net = json.loads(net_json) + except ValueError: + raise ChvFailure("net_config_invalid", + "CHV_NET_JSON 不是合法 JSON") + entries = net if isinstance(net, list) else [net] + if not all(isinstance(e, dict) for e in entries): + raise ChvFailure("net_config_invalid", + "CHV_NET_JSON 条目须为 dict") + return entries + + +def validate_net_entries(entries, existing_entries): + """net 条目隔离校验(H7 + L4): + + - 字段白名单:未知字段即 net_config_invalid; + - 条目内 tap/mac/ip 归一化(mac 小写、空 tap 视为未 + 指定,不参与 tap 冲突比较——空 tap 是“VMM 默认网卡” + 的运维写法,代码不编造 tap 名,但空 tap 多 VM 共用 + 默认网卡的风险仍在部署清单里明写); + - 本次条目内 tap/mac/ip 重复即 net_config_invalid; + - 与已登记 VM(existing_entries,每项为 {tap, mac, ip, + vm})的 tap/mac/ip 任一重复即 net_tap_conflict, + 拒绝启动并由调用方审计。 + 返回归一化后的条目 list。""" + seen_taps, seen_macs, seen_ips = set(), set(), set() + normalized = [] + for entry in entries: + unknown = set(entry) - _NET_FIELDS + if unknown: + raise ChvFailure( + "net_config_invalid", + "CHV_NET_JSON 条目含未知字段:%s(白名单:%s)" + % (",".join(sorted(unknown)), + ",".join(sorted(_NET_FIELDS)))) + tap = str(entry.get("tap") or "").strip() + mac = str(entry.get("mac") or "").strip().lower() + ip = str(entry.get("ip") or "").strip() + for kind, value, seen in (("tap", tap, seen_taps), + ("mac", mac, seen_macs), + ("ip", ip, seen_ips)): + if not value: + continue + if kind == "tap" and not tap: + continue + if value in seen: + raise ChvFailure( + "net_config_invalid", + "CHV_NET_JSON 内 %s 重复:%s" % (kind, value)) + seen.add(value) + normalized.append({"tap": tap, "mac": mac, "ip": ip, + "mask": str(entry.get("mask") or "")}) + for entry in normalized: + for other in existing_entries: + for kind in ("tap", "mac", "ip"): + value = entry.get(kind) + if value and value == other.get(kind): + raise ChvFailure( + "net_tap_conflict", + "net %s %s 已被 VM %s 使用,拒绝共享" + " tap/mac/ip:每 VM 必须独立" + % (kind, value, other.get("vm"))) + return normalized + + +def validate_egress_proxy(egress_proxy): + """egress 代理地址校验(H1 代码门的一部分):须为 + http/https URL 且带主机名;否则 net_config_invalid… + 实际 kind 用 config_invalid(preflight 的配置类失败)。 + 空值由调用方另行处理(未设 + 未显式允许直连时 + preflight 已有 egress_not_configured)。""" + if not egress_proxy: + return None + try: + from urllib.parse import urlparse + parsed = urlparse(egress_proxy) + except ValueError: + parsed = None + if parsed is None or parsed.scheme not in ("http", "https") \ + or not parsed.hostname: + raise ChvFailure( + "config_invalid", + "CHV_EGRESS_PROXY 必须是 http/https 代理地址:%r" + % (egress_proxy,)) + return egress_proxy + + +def _is_within(directory, path): + directory = os.path.realpath(directory) + path = os.path.realpath(path) + return path == directory or path.startswith(directory + os.sep) + + +def validate_snapshot_url(snapshot_url, state_dir, snapshot_dir=None, + allow_remote=False): + """快照 URL 白名单(L2): + + - file://:目标须在 state_dir 或 CHV_SNAPSHOT_DIR 内, + 否则 snapshot_path_not_allowed; + - 其它协议(含无 :// 的裸路径由调用方先补 file://): + 须显式 CHV_ALLOW_REMOTE_SNAPSHOT=1,否则 + snapshot_remote_not_allowed。 + 返回 file:// 目标的本地路径(非 file:// 时返回 None)。""" + if snapshot_url.startswith("file://"): + path = snapshot_url[len("file://"):] + allowed = [state_dir] + if snapshot_dir: + allowed.append(snapshot_dir) + if not any(_is_within(d, path) for d in allowed): + raise ChvFailure( + "snapshot_path_not_allowed", + "file:// 快照须在 state_dir 或 CHV_SNAPSHOT_DIR " + "内:%s" % snapshot_url) + return path + if not allow_remote: + raise ChvFailure( + "snapshot_remote_not_allowed", + "非 file:// 快照须显式设 CHV_ALLOW_REMOTE_SNAPSHOT=1:%s" + % snapshot_url) + return None diff --git a/cloud/chv/hostkeys.py b/cloud/chv/hostkeys.py index 627daa5..34898f0 100644 --- a/cloud/chv/hostkeys.py +++ b/cloud/chv/hostkeys.py @@ -31,6 +31,13 @@ """ import os +import shlex + +# seed 投递的客内密钥环境文件路径(feat-chv-secfix,H2): +# 只在 provisioned 模式的 seed user-data 里写出,0600 root, +# 客内 host.py 命令执行前由远程命令 source 进环境;宿主侧 +# 除 seed 目录/user-data(随 VM 销毁而删)外不另存该文件。 +GUEST_ENV_PATH = "/etc/chv/guest-env" def host_key_paths(state_dir, vm_name): @@ -44,6 +51,25 @@ def host_key_paths(state_dir, vm_name): } +def dirty_marker_path(state_dir, vm_name): + """清理失败脏标记路径(feat-chv-secfix,M2):stop_vm + 清理 seed/私钥/磁盘任一步失败时写下该标记;标记存在 + 时同名 VM 拒绝启动(清理失败即拒起),人工清理并删 + 标记后才能再起——“已审计即可”不再当作根治。""" + return os.path.join(state_dir, vm_name, ".cleanup-dirty") + + +def delete_host_private_key(state_dir, vm_name): + """删宿主侧 host 私钥文件(feat-chv-secfix,H5):seed + 盘建好后宿主私钥已无存在必要(客机已有私钥、宿主只需 + 公钥 + known_hosts 做校验)。删失败即 OSError,由调用 + 方 fail-fast(VM 尚未启动,此时拒绝是最安全的)。""" + path = host_key_paths(state_dir, vm_name)["private"] + if os.path.exists(path): + os.remove(path) + return path + + def known_hosts_line(port, public_key_text, host="127.0.0.1"): """公钥文件内容转 known_hosts 行:只取前两列(类型+数据)。 @@ -86,6 +112,12 @@ def build_seed_disk(seed_dir, seed_disk, runner=None): raise RuntimeError("genisoimage 失败:%s" % result.stderr) if not os.path.isfile(seed_disk) or os.path.getsize(seed_disk) == 0: raise RuntimeError("seed 磁盘未生成:%s" % seed_disk) + # feat-chv-secfix(H5):seed.iso 内含 host 私钥明文 + # user-data,生成即 0600,不再取 umask 默认 0644。 + try: + os.chmod(seed_disk, 0o600) + except OSError as exc: + raise RuntimeError("seed 磁盘 chmod 0600 失败:%s" % exc) return seed_disk @@ -124,11 +156,19 @@ def build_seed_user_data(private_key_text, public_key_text): return "\n".join(lines) + "\n" -def write_seed(seed_dir, vm_name, private_key_text, public_key_text): +def write_seed(seed_dir, vm_name, private_key_text, public_key_text, + secret_env=None): """写 seed 目录:meta-data + user-data(均为 0600)。 返回 seed 目录路径。seed 目录本身不含已知主机之外的 - 共享数据,调用方(执行器关闭/回收)负责删除它。""" + 共享数据,调用方(执行器关闭/回收)负责删除它。 + feat-chv-secfix(H2):secret_env 给定时,user-data 再 + 写一个 write_files 条目,把密钥环境变量写进客内 + GUEST_ENV_PATH(0600 root),值经 shlex.quote 引用后 + 才进文件内容——远程命令 source 该文件即可得到密钥 + 环境,密钥值从不出现在宿主 ssh argv、宿主进程环境与 + 审计明文里。secret_env 为空时不写该条目(客内文件不 + 存在时远程命令也不 source 它,见执行器)。""" os.makedirs(seed_dir, exist_ok=True) meta_path = os.path.join(seed_dir, "meta-data") user_path = os.path.join(seed_dir, "user-data") @@ -136,8 +176,30 @@ def write_seed(seed_dir, vm_name, private_key_text, public_key_text): fh.write("instance-id: %s\nlocal-hostname: %s\n" % (vm_name, vm_name)) os.chmod(meta_path, 0o600) + user_data = build_seed_user_data(private_key_text, public_key_text) + if secret_env: + # 注意:只能把条目并进已有的 write_files 列表,绝不能 + # 再写一个 write_files: 键(YAML 重复键会覆盖掉 host + # key 的两条写文件条目,预置即失效)。条目插在 runcmd + # 段之前、仍属同一列表。 + lines = [ + " - path: %s" % GUEST_ENV_PATH, + " permissions: '0600'", + " owner: root:root", + " content: |", + ] + for key in sorted(secret_env): + lines.append(" %s=%s" + % (key, shlex.quote(str(secret_env[key])))) + # 条目插在 runcmd 段之前、仍属同一 write_files 列表。 + marker = "runcmd:\n" + if marker in user_data: + head, tail = user_data.split(marker, 1) + user_data = head + "\n".join(lines) + "\n" + marker + tail + else: + user_data = user_data + "\n".join(lines) + "\n" with open(user_path, "w") as fh: - fh.write(build_seed_user_data(private_key_text, public_key_text)) + fh.write(user_data) os.chmod(user_path, 0o600) return seed_dir @@ -175,6 +237,8 @@ def generate_host_keypair(state_dir, vm_name, runner=None, raise RuntimeError("ssh-keygen 未生成 host key 对") try: os.chmod(paths["private"], 0o600) - except OSError: - pass + except OSError as exc: + # feat-chv-secfix(H5):私钥 chmod 失败不再静默 + # pass——权限不对的私钥文件绝不能留着继续用。 + raise RuntimeError("host 私钥 chmod 0600 失败:%s" % exc) return paths diff --git a/cloud/chv/pool.py b/cloud/chv/pool.py index 1f06a4e..4b73b94 100644 --- a/cloud/chv/pool.py +++ b/cloud/chv/pool.py @@ -230,7 +230,15 @@ class WarmPool: 注意:本方法只解决“acquire 不再悬空 resume 责任” 这一半;worker dispatch 前查池的另一半仍未接线 (见执行器/就绪文档的“未接线缺口”标注),预热收益 - 在生产路径上仍不是已交付特性。""" + 在生产路径上仍不是已交付特性。 + feat-chv-secfix(L5,接线时的盘对应关系定义——接线 + 本身仍未做,不假装已接):预热 VM 的磁盘是干净快照 + 盘,不是任务盘。将来 dispatch 前查池接线时必须做到: + acquire 返回的 VM 在派任务前仍须按任务重新供给磁盘 + (provision_task_disk 的 copy 盘,或校验快照来源与该 + 任务的对应关系后才能用快照盘),绝不能把预热盘直接 + 当任务盘用;接线时须补“任务盘来源”不变量的测试, + 否则 acquire 的 warm 路径不得合入生产路径。""" for name, entry in list(self._entries.items()): if entry["state"] != "warm": continue diff --git a/cloud/chv/ssh_transport.py b/cloud/chv/ssh_transport.py index 52721da..8ae8f0e 100644 --- a/cloud/chv/ssh_transport.py +++ b/cloud/chv/ssh_transport.py @@ -21,11 +21,86 @@ StrictHostKeyChecking=no + UserKnownHostsFile=/dev/null): 工作,见 docs/chv-production-readiness.md 的待办),本轨 在 mock 测试里只能验证“捕获→写文件→后续命令带钉死 选项”的管线,绝不声称 mock 验证了首次密钥的真实性。 -- 密钥红线不变:argv 里只有 key 文件路径,绝无 key 值; - known_hosts 文件只含 host key 公钥行,不含任何私钥。 +- 密钥红线不变且收紧(feat-chv-secfix,H2/M1):argv 里只有 + key 文件路径,绝无 key 值;网关密钥类变量(见 + _CHV_SECRET_ENV_NAMES,与 Docker 的 _DOCKER_SECRET_ENV_NAMES + 同款“只传名”口径,但 CHV 更进一步:值连宿主子进程环境 + 都不进,改走 seed 盘里的客内文件,见 hostkeys.write_seed) + 出现在 extra_env 里时本函数直接 ValueError——传输层 + fail-closed,任何调用方都不能再把密钥值拼回 argv。 + 非密钥 extra_env 仍可走 env 前缀(值在 argv 里可见, + 但按定义不是密钥)。 +- 远程 shell 转义(feat-chv-secfix,M1):SSH 协议的 exec + 请求本身是一条字符串,ssh 客户端把多个参数用空格拼成 + 远程 shell 命令——此前 inner 参数原样进 argv,prompt 里 + 的 ;/$()/引号/换行会在远程 shell 里被二次解析。现在每个 + 远程 token 都经 shlex.quote 引用后仍以列表元素形式返回 + (ssh 拼接后远程解析结果与本地 list 语义一致);客内 + agent 本来就是 root,本项修的是“任务执行语义不被 prompt + 改写 + 审计不失真”,不是“防客内提权”(客内 root 无 + 需注入即可为所欲为,边界见执行器文档)。 """ import os +import shlex + +# CHV 密钥类环境变量名:值绝不进 ssh argv。Docker 线是 +# “-e 只传名、值由 CLI 子进程环境提供”(值仍在宿主进程 +# 环境里,/proc//environ 对同用户可见);CHV 线是 +# “值只进 seed 盘客内文件”(宿主进程表与宿主进程环境里 +# 都没有值),比 Docker 线更严——差异见执行器文档。 +_CHV_SECRET_ENV_NAMES = frozenset({"SUB2API_API_KEY"}) + + +def split_secret_env(extra_env): + """把 extra_env 拆成(密钥子集,非密钥子集)。""" + secrets, plain = {}, {} + for key, value in dict(extra_env or {}).items(): + if key in _CHV_SECRET_ENV_NAMES: + secrets[key] = value + else: + plain[key] = value + return secrets, plain + + +def remote_command(inner_cmd, egress_proxy=None, extra_env=None, + guest_env_file=None): + """构造远程 shell 命令的 token 列表(不含 ssh 前缀)。 + + 每个 token 都经 shlex.quote:ssh 客户端把 argv 用空格拼 + 成一条远程命令时,远程 shell 解析回来的恰好是本地 list + 语义。guest_env_file 给定时,远程命令先 `set -a; . <文件>; + set +a`(把 seed 投递的密钥文件 source 进环境并 export), + 再接 env 前缀与客内命令;文件路径本身不是密钥。 + extra_env 含密钥名时 ValueError(调用方须先 split 出 + 密钥子集走 seed 投递,见 split_secret_env)。""" + if extra_env: + secrets, _ = split_secret_env(extra_env) + if secrets: + raise ValueError( + "密钥类环境变量 %s 不得进 ssh argv,须走 seed " + "文件投递" % ",".join(sorted(secrets))) + tokens = [] + if guest_env_file: + tokens += ["set", "-a;", ".", + shlex.quote(guest_env_file) + ";", "set", "+a;"] + env_assignments = [] + if extra_env: + for key in sorted(extra_env): + env_assignments.append("%s=%s" + % (key, shlex.quote( + str(extra_env[key])))) + if egress_proxy: + env_assignments += [ + "HTTPS_PROXY=%s" % shlex.quote(egress_proxy), + "HTTP_PROXY=%s" % shlex.quote(egress_proxy), + "NO_PROXY=localhost,127.0.0.1"] + inner = [shlex.quote(str(arg)) for arg in inner_cmd] + if env_assignments: + tokens += ["env"] + env_assignments + inner + else: + tokens += inner + return tokens def known_hosts_path(state_dir, vm_name): @@ -35,38 +110,32 @@ def known_hosts_path(state_dir, vm_name): def build_ssh_command(ssh_port, ssh_key, ssh_user, egress_proxy, known_hosts_file, inner_cmd, ssh_host="127.0.0.1", - extra_env=None): + extra_env=None, guest_env_file=None): """构造 SSH 命令:ssh -p <端口> -i + 钉死选项 - + user@ + 客内命令。 + + user@ + 客内命令(远程 token 均经 shlex.quote, + 见 remote_command)。 feat-chv-fix(P0-4):ssh_host 不再在传输层写死回环, 由执行器解析传入(显式 CHV_SSH_HOST / net 客机 IP / 显式转发宣称下的回环);本函数缺省回环仅为旧调用点 兼容,执行器路径绝不依赖该缺省。 - extra_env(feat-chv-fix,P1-1 网关注入):经同一 env - 前缀进客机环境(与 egress 代理前缀同款机制),不进 - 镜像/代码/prompt;值本身只出现在宿主侧 ssh argv(与 - worker 同一信任边界),执行器绝不把完整命令记入日志。 + feat-chv-secfix(H2):extra_env 只接受非密钥变量; + 密钥类变量须先经 split_secret_env 拆出、走 seed 盘客内 + 文件投递,调用方把 guest_env_file 传进来(客内 seed + 写出的 /etc/chv/guest-env),本函数在远程命令前 source + 它;密钥值绝不出现在本函数返回的任何 argv 元素里。 egress_proxy 已设时客内命令前加 env 前缀(代理地址本身 - 不是密钥;客机出口只走该白名单代理)。""" - inner = list(inner_cmd) - env_assignments = [] - if extra_env: - for key in sorted(extra_env): - env_assignments.append("%s=%s" % (key, extra_env[key])) - if egress_proxy: - env_assignments += [ - "HTTPS_PROXY=%s" % egress_proxy, - "HTTP_PROXY=%s" % egress_proxy, - "NO_PROXY=localhost,127.0.0.1"] - if env_assignments: - inner = ["env"] + env_assignments + inner + 不是密钥;客机出口只走该白名单代理——但这只是 env 约 + 定,网络层强制见宿主防火墙部署清单,代码不声称已隔离)。""" + remote = remote_command(inner_cmd, egress_proxy=egress_proxy, + extra_env=extra_env, + guest_env_file=guest_env_file) return ["ssh", "-p", str(ssh_port), "-i", ssh_key, "-o", "StrictHostKeyChecking=yes", "-o", "UserKnownHostsFile=%s" % known_hosts_file, "-o", "BatchMode=yes", - "%s@%s" % (ssh_user, ssh_host)] + inner + "%s@%s" % (ssh_user, ssh_host)] + remote def parse_keyscan_output(text, host="127.0.0.1"): diff --git a/cloud/tests/test_chv_fix.py b/cloud/tests/test_chv_fix.py index de8196a..b8c400a 100644 --- a/cloud/tests/test_chv_fix.py +++ b/cloud/tests/test_chv_fix.py @@ -150,14 +150,23 @@ class ChvFixCase(unittest.TestCase): class SeedDiskTest(ChvFixCase): def test_vm_config_carries_seed_disk_readonly(self): ex = self.make_exec() - cfg = ex.build_vm_config("/tmp/x/disk.raw", - seed_disk="/tmp/x/seed.iso") + # feat-chv-secfix(L2):磁盘路径须在 state_dir 内, + # 旧 /tmp/x 路径不再合法,改用 state_dir 下路径。 + disk = os.path.join(self.state_dir, "x", "disk.raw") + seed = os.path.join(self.state_dir, "x", "seed.iso") + cfg = ex.build_vm_config(disk, seed_disk=seed) self.assertEqual(len(cfg["disks"]), 2) - self.assertEqual(cfg["disks"][1]["path"], "/tmp/x/seed.iso") + self.assertEqual(cfg["disks"][1]["path"], seed) self.assertTrue(cfg["disks"][1]["readonly"]) - cfg2 = ex.build_vm_config("/tmp/x/disk.raw") + cfg2 = ex.build_vm_config(disk) self.assertEqual(len(cfg2["disks"]), 1) + def test_vm_config_rejects_disk_outside_state_dir(self): + ex = self.make_exec() + with self.assertRaises(ChvFailure) as ctx: + ex.build_vm_config("/tmp/x/disk.raw") + self.assertEqual(ctx.exception.kind, "disk_path_not_allowed") + def test_provisioned_wake_mounts_seed_disk(self): ex = self.make_exec( env_extra={"CHV_HOST_KEY_MODE": "provisioned"}) @@ -208,8 +217,11 @@ class RestoreNoFallbackTest(ChvFixCase): env_extra={"CHV_HOST_KEY_MODE": "provisioned"}) launches = [] ex.launcher = lambda cmd: launches.append(cmd) or FakeProc(True) + # feat-chv-secfix(L2):恢复源须在白名单目录内, + # 本测试的意图是“起 VMM 前失败”,用 state_dir 下路径。 + snap_url = "file://" + os.path.join(self.state_dir, "snap", "x") with self.assertRaises(ChvFailure) as ctx: - ex.restore_vm({"id": "r1"}, "file:///snap/x") + ex.restore_vm({"id": "r1"}, snap_url) self.assertEqual(ctx.exception.kind, "host_key_not_pinned") self.assertEqual(launches, []) @@ -226,7 +238,9 @@ class ReuseBlockTest(ChvFixCase): def test_reuse_passes_with_experiment_flag_and_is_audited(self): ex = self.make_exec(env_extra={ "CHV_DISK_MODE": "reuse", - "CHV_REUSE_SINGLE_TENANT_EXPERIMENT": "1"}) + "CHV_REUSE_SINGLE_TENANT_EXPERIMENT": "1", + # feat-chv-secfix(M6):实验放行还须声明单租户项目。 + "CHV_SINGLE_TENANT_PROJECT": "p1"}) self.assertTrue(ex.preflight()) out = ex.wake_vm({"id": "reuse1"}) self.assertEqual(out["state"], "running") @@ -264,12 +278,19 @@ class SshTargetTest(ChvFixCase): # -- P1-1 网关 extra_env ------------------------------------------------ class GatewayExtraEnvTest(ChvFixCase): def test_chv_ssh_command_carries_extra_env(self): + # feat-chv-secfix(H2):密钥值绝不再进 argv。 + # 非密钥 extra_env 仍走 env 前缀;密钥子集须 seed + # 投递后才 source 客内文件(见 worker 网关测试)。 ex = self.make_exec() cmd = ex.build_ssh_command({"id": "t"}, ["true"], - extra_env={"SUB2API_API_KEY": "k1"}) + extra_env={"SUB2API_BASE_URL": + "http://gw/"}) joined = " ".join(cmd) - self.assertIn("SUB2API_API_KEY=k1", joined) + self.assertIn("SUB2API_BASE_URL=http://gw/", joined) self.assertIn("HTTPS_PROXY=http://10.0.0.1:8888", joined) + with self.assertRaises(ChvFailure): + ex.build_ssh_command({"id": "t"}, ["true"], + extra_env={"SUB2API_API_KEY": "k1"}) def test_worker_gateway_guard_allows_chv(self): from cloud.server import init_db @@ -287,8 +308,20 @@ class GatewayExtraEnvTest(ChvFixCase): self.assertIsNone(error) self.assertEqual(result["status"], "completed") ssh_calls = [c for c in self.calls if c and c[0] == "ssh"] - self.assertTrue(any("SUB2API_API_KEY=gwkey" in " ".join(c) + # feat-chv-secfix(H2):密钥值不得出现在任何 ssh + # argv 里;远程命令改为 source 客内 seed 密钥文件。 + self.assertFalse(any("SUB2API_API_KEY=gwkey" in " ".join(c) + for c in ssh_calls)) + self.assertTrue(any("/etc/chv/guest-env" in " ".join(c) for c in ssh_calls)) + # 任务结束 stop 后 seed 目录与宿主私钥均已清(H3/H5; + # seed 内容本身的断言见 test_chv_secfix 的直接 + # provision_host_key 测试,stop 前 seed 仍在)。 + self.assertFalse(os.path.exists( + os.path.join(self.state_dir, "chv-gw1", "seed"))) + self.assertFalse(os.path.exists( + os.path.join(self.state_dir, "chv-gw1", + "ssh_host_ed25519_key"))) # -- P1-2 取消/清理/看门狗 ---------------------------------------------- @@ -420,14 +453,30 @@ class PoolAcquireResumeTest(unittest.TestCase): # -- P1-4 容量持久化计数 ------------------------------------------------- class CapacityPersistentTest(ChvFixCase): def test_socket_counts_after_restart(self): + # feat-chv-secfix(M7):只有真能连通的 socket 才 + # 计数——用真实监听 socket 代表活 VMM。 + ex = self.make_exec(env_extra={"CHV_MAX_VMS": "1"}) + os.makedirs(self.state_dir, exist_ok=True) + sock_path = os.path.join(self.state_dir, "chv-other.sock") + srv = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + srv.bind(sock_path) + srv.listen(1) + try: + with self.assertRaises(ChvFailure) as ctx: + ex.wake_vm({"id": "new1"}) + self.assertEqual(ctx.exception.kind, "host_capacity_exceeded") + finally: + srv.close() + + def test_dead_socket_file_does_not_block(self): + # M7 的另一面:空文件/死 socket 不再占满容量。 ex = self.make_exec(env_extra={"CHV_MAX_VMS": "1"}) os.makedirs(self.state_dir, exist_ok=True) with open(os.path.join(self.state_dir, "chv-other.sock"), "w") as fh: fh.write("") - with self.assertRaises(ChvFailure) as ctx: - ex.wake_vm({"id": "new1"}) - self.assertEqual(ctx.exception.kind, "host_capacity_exceeded") + out = ex.wake_vm({"id": "new1"}) + self.assertEqual(out["state"], "running") def test_own_socket_does_not_block_rewake(self): ex = self.make_exec(env_extra={"CHV_MAX_VMS": "1"}) @@ -523,8 +572,11 @@ class ApiTruncationTest(unittest.TestCase): class SnapshotPersistTest(ChvFixCase): def test_snapshot_missing_file_does_not_shutdown(self): ex = self.make_exec() + # feat-chv-secfix(L2):快照须在白名单目录内, + # 用 state_dir 下的不存在路径,仍验 snapshot_not_persisted。 + missing = os.path.join(self.state_dir, "missing.snap") with self.assertRaises(ChvFailure) as ctx: - ex.hibernate_vm({"id": "h1"}, "file:///nonexistent/snap") + ex.hibernate_vm({"id": "h1"}, "file://" + missing) self.assertEqual(ctx.exception.kind, "snapshot_not_persisted") kinds = [c[-1] for c in self.calls if c[0] == self.ch_remote] self.assertIn("pause", kinds) diff --git a/cloud/tests/test_chv_gaps.py b/cloud/tests/test_chv_gaps.py index d514dc8..cfd798b 100644 --- a/cloud/tests/test_chv_gaps.py +++ b/cloud/tests/test_chv_gaps.py @@ -122,6 +122,11 @@ class ChvGapCase(unittest.TestCase): env = {"CHV_EGRESS_PROXY": "http://10.0.0.1:8888", # 实验室宣称:mock runner 假装回环端口已转发到客机 #(feat-chv-fix 起,执行器不再无条件假设回环能通)。 + # 实验室显式 opt-in keyscan:生产默认已是 + # provisioned;本套件旧 fake 不实现 ssh-keygen/ + # genisoimage,测的不是 host key 模式,故显式 + # 声明实验室路径,绝不把它写成生产默认。 + "CHV_HOST_KEY_MODE": "keyscan", "CHV_SSH_FORWARD": "1"} if env_extra: env.update(env_extra) @@ -379,6 +384,9 @@ class BuildScriptTest(unittest.TestCase): "UBUNTU_VERSION": "24.04", "CODEX_VERSION": "0.164.0-alpha.9", "REPO_COMMIT": "test123", + # feat-chv-secfix(M4):SHA256 必填, + # 干跑只验“已设”,用 FAKE 占位。 + "UBUNTU_IMAGE_SHA256": "0" * 64, "REPO_DIR": self.repo}) if env_extra: env.update(env_extra) diff --git a/cloud/tests/test_chv_optimize.py b/cloud/tests/test_chv_optimize.py index 288e07a..ffa778c 100644 --- a/cloud/tests/test_chv_optimize.py +++ b/cloud/tests/test_chv_optimize.py @@ -111,6 +111,11 @@ class ChvOptCase(unittest.TestCase): env = {"CHV_EGRESS_PROXY": "http://10.0.0.1:8888", # 实验室宣称:mock runner 假装回环端口已转发到客机 #(feat-chv-fix 起,执行器不再无条件假设回环能通)。 + # 实验室显式 opt-in keyscan:生产默认已是 + # provisioned;本套件旧 fake 不实现 ssh-keygen/ + # genisoimage,测的不是 host key 模式,故显式 + # 声明实验室路径,绝不把它写成生产默认。 + "CHV_HOST_KEY_MODE": "keyscan", "CHV_SSH_FORWARD": "1"} if env_extra: env.update(env_extra) diff --git a/cloud/tests/test_chv_secfix.py b/cloud/tests/test_chv_secfix.py new file mode 100644 index 0000000..ea1ba66 --- /dev/null +++ b/cloud/tests/test_chv_secfix.py @@ -0,0 +1,701 @@ +"""CHV 安全审计修复轨测试(feat-chv-secfix,堆叠于 #239)。 + +只用标准库与 mock runner/launcher/api_client,绝不启动真实 +VM:当前环境没有可跑真 VM 的宿主。本文件只证明代码层面 +的修复(密钥移出 argv、磁盘删除、默认值、校验、审计等) +成立;真机否定测试(tap 防火墙实效、unset 代理后直连、 +IP spoof 后 SSH 拒绝等)一律列在就绪清单 §11,不在本 +文件声称已验证。测试里只用 FAKE 占位密钥,绝无真实密钥。 +""" + +import json +import os +import shlex +import socket +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path +from types import SimpleNamespace + +try: + from cloud.chv.config import build_chv_config + from cloud.chv.disk import ( + check_snapshot_limits, + delete_task_disk, + provision_task_disk, + record_snapshot, + ) + from cloud.chv.hardening import ( + ensure_secure_state_dir, + socket_is_live, + validate_net_entries, + validate_snapshot_url, + ) + from cloud.chv.hostkeys import ( + build_seed_disk, + dirty_marker_path, + write_seed, + ) + from cloud.chv.pool import WarmPool + from cloud.chv.ssh_transport import ( + build_ssh_command, + split_secret_env, + ) + from cloud.sandbox import ( + ChvFailure, + CloudHypervisorExecutor, + ) +except ImportError: + sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + from cloud.chv.config import build_chv_config + from cloud.chv.disk import ( + check_snapshot_limits, + delete_task_disk, + provision_task_disk, + record_snapshot, + ) + from cloud.chv.hardening import ( + ensure_secure_state_dir, + socket_is_live, + validate_net_entries, + validate_snapshot_url, + ) + from cloud.chv.hostkeys import ( + build_seed_disk, + dirty_marker_path, + write_seed, + ) + from cloud.chv.pool import WarmPool + from cloud.chv.ssh_transport import ( + build_ssh_command, + split_secret_env, + ) + from cloud.sandbox import ( + ChvFailure, + CloudHypervisorExecutor, + ) + + +class FakeResult: + def __init__(self, returncode=0, stdout="", stderr=""): + self.returncode = returncode + self.stdout = stdout + self.stderr = stderr + + +class FakeProc: + def __init__(self, alive=True): + self._alive = alive + + def poll(self): + return None if self._alive else 1 + + def terminate(self): + self._alive = False + + def wait(self, timeout=None): + return 0 + + def kill(self): + self._alive = False + + +def _write(path, content=b"x"): + with open(path, "wb") as fh: + fh.write(content) + os.chmod(path, 0o755) + return path + + +class SecfixCase(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.dir = self.tmp.name + self.binary = _write(os.path.join(self.dir, "cloud-hypervisor")) + self.ch_remote = _write(os.path.join(self.dir, "ch-remote")) + self.kernel = _write(os.path.join(self.dir, "vmlinux")) + self.rootfs = _write(os.path.join(self.dir, "disk.raw"), + b"R" * 100) + self.ssh_key = _write(os.path.join(self.dir, "id_chv")) + self.kvm = _write(os.path.join(self.dir, "kvm")) + self.cpuinfo = os.path.join(self.dir, "cpuinfo") + with open(self.cpuinfo, "w") as fh: + fh.write("flags : fpu vmx sse\n") + self.state_dir = os.path.join(self.dir, "state") + self.calls = [] + self.audit = [] + + def tearDown(self): + self.tmp.cleanup() + + def _default_runner(self, cmd, timeout=None): + self.calls.append(list(cmd)) + if cmd and cmd[0] == "ssh": + if cmd[-1] == "true": + return FakeResult(0, "", "") + return FakeResult(0, "", "") + if cmd and cmd[0] == "ssh-keygen": + priv = cmd[cmd.index("-f") + 1] + with open(priv, "w") as fh: + fh.write("-----BEGIN OPENSSH PRIVATE KEY-----\nFAKE\n") + os.chmod(priv, 0o600) + with open(priv + ".pub", "w") as fh: + fh.write("ssh-ed25519 AAAAfake1 vm-key\n") + return FakeResult(0, "", "") + if cmd and cmd[0] == "genisoimage": + out = cmd[cmd.index("-output") + 1] + with open(out, "wb") as fh: + fh.write(b"CIDATA" * 16) + return FakeResult(0, "", "") + return FakeResult(0, "", "") + + def _default_api(self, socket_path, method, path, payload=None): + return (200, {}) + + def make_exec(self, runner=None, env_extra=None, **overrides): + # 生产默认:provisioned(不显式设 keyscan),回环仍 + # 是实验室宣称(mock runner 假装转发已通)。 + env = {"CHV_EGRESS_PROXY": "http://10.0.0.1:8888", + "CHV_SSH_FORWARD": "1"} + if env_extra: + env.update(env_extra) + kwargs = dict( + binary=self.binary, ch_remote=self.ch_remote, + kernel=self.kernel, rootfs=self.rootfs, + ssh_key=self.ssh_key, kvm_path=self.kvm, + cpuinfo_path=self.cpuinfo, state_dir=self.state_dir, + runner=runner or self._default_runner, + launcher=lambda cmd: FakeProc(alive=True), + api_client=self._default_api, + sleeper=lambda s: None, env=env) + kwargs.update(overrides) + ex = CloudHypervisorExecutor(**kwargs) + ex.audit_callback = lambda e, t, d, k=None: self.audit.append( + (e, t.get("id") if isinstance(t, dict) else t, d, k)) + return ex + + +# -- H1 egress 代码门 + H7 tap 隔离 ------------------------------------ +class EgressAndNetTest(SecfixCase): + def test_egress_proxy_non_http_rejected(self): + ex = self.make_exec( + env_extra={"CHV_EGRESS_PROXY": "ftp://10.0.0.1:21"}) + with self.assertRaises(ChvFailure) as ctx: + ex.preflight() + self.assertEqual(ctx.exception.kind, "config_invalid") + + def test_egress_proxy_without_host_rejected(self): + ex = self.make_exec( + env_extra={"CHV_EGRESS_PROXY": "http:///path"}) + with self.assertRaises(ChvFailure) as ctx: + ex.preflight() + self.assertEqual(ctx.exception.kind, "config_invalid") + + def test_net_unknown_field_rejected(self): + with self.assertRaises(ChvFailure) as ctx: + validate_net_entries( + [{"tap": "tap0", "mac": "aa:bb:cc:dd:ee:01", + "ip": "10.0.0.2", "gateway": "10.0.0.1"}], []) + self.assertEqual(ctx.exception.kind, "net_config_invalid") + + def test_net_intra_config_dup_rejected(self): + with self.assertRaises(ChvFailure) as ctx: + validate_net_entries( + [{"tap": "tap0", "mac": "aa:bb:cc:dd:ee:01", + "ip": "10.0.0.2"}, + {"tap": "tap0", "mac": "aa:bb:cc:dd:ee:02", + "ip": "10.0.0.3"}], []) + self.assertEqual(ctx.exception.kind, "net_config_invalid") + + def test_net_cross_vm_conflict_refuses_second_wake(self): + net = '[{"tap": "tap0", "mac": "aa:bb:cc:dd:ee:01",' \ + '"ip": "10.0.0.2", "mask": "255.255.255.0"}]' + ex = self.make_exec(env_extra={"CHV_NET_JSON": net}) + out = ex.wake_vm({"id": "vm1"}) + self.assertEqual(out["state"], "running") + # 第一台的 net.json 已登记;第二台同 tap 即拒绝, + # 且未起 VMM(launcher 只被调过一次)。 + launches = [] + ex.launcher = lambda cmd: launches.append(cmd) or FakeProc(True) + with self.assertRaises(ChvFailure) as ctx: + ex.wake_vm({"id": "vm2"}) + self.assertEqual(ctx.exception.kind, "net_tap_conflict") + self.assertEqual(launches, []) + self.assertTrue(any(e == "vm_failed" + for e, _, _, _ in self.audit)) + + +# -- H2 密钥移出 argv --------------------------------------------------- +class SecretDeliveryTest(SecfixCase): + def test_secret_env_split_and_argv_refusal(self): + secrets, plain = split_secret_env( + {"SUB2API_API_KEY": "FAKE-KEY", + "SUB2API_BASE_URL": "http://gw/"}) + self.assertEqual(secrets, {"SUB2API_API_KEY": "FAKE-KEY"}) + self.assertEqual(plain, {"SUB2API_BASE_URL": "http://gw/"}) + with self.assertRaises(ValueError): + build_ssh_command(2222, "/k", "root", None, "/kh", + ["true"], + extra_env={"SUB2API_API_KEY": "FAKE-KEY"}) + + def test_seed_writes_single_write_files_with_guest_env(self): + seed_dir = os.path.join(self.dir, "seed") + write_seed(seed_dir, "chv-t1", "FAKE-PRIV", "ssh-ed25519 AAAAx", + secret_env={"SUB2API_API_KEY": "FAKE-KEY-123"}) + text = "" + with open(os.path.join(seed_dir, "user-data")) as fh: + text = fh.read() + # 只能有一个 write_files 键(重复键会覆盖 host key 条目)。 + self.assertEqual(text.count("write_files:"), 1) + self.assertIn("/etc/chv/guest-env", text) + self.assertIn("SUB2API_API_KEY=", text) + self.assertEqual(oct(os.stat( + os.path.join(seed_dir, "user-data")).st_mode & 0o777), + "0o600") + + def test_keyscan_with_secret_fails_closed_before_launch(self): + ex = self.make_exec( + env_extra={"CHV_HOST_KEY_MODE": "keyscan"}) + launches = [] + ex.launcher = lambda cmd: launches.append(cmd) or FakeProc(True) + with self.assertRaises(ChvFailure) as ctx: + ex.wake_vm({"id": "k1"}, + extra_env={"SUB2API_API_KEY": "FAKE-KEY"}) + self.assertEqual(ctx.exception.kind, + "secret_env_requires_provisioned") + self.assertEqual(launches, []) + + def test_provisioned_wake_deletes_host_private_key(self): + ex = self.make_exec() + out = ex.wake_vm({"id": "s1"}, + extra_env={"SUB2API_API_KEY": "FAKE-KEY"}) + self.assertEqual(out["state"], "running") + vm_dir = os.path.join(self.state_dir, "chv-s1") + # 宿主私钥已删(seed 盘建好后即删);seed.iso 在且 0600; + # 密钥投递有审计(只含名,不含值)。 + self.assertFalse(os.path.exists( + os.path.join(vm_dir, "ssh_host_ed25519_key"))) + seed_iso = os.path.join(vm_dir, "seed.iso") + self.assertTrue(os.path.exists(seed_iso)) + self.assertEqual(oct(os.stat(seed_iso).st_mode & 0o777), + "0o600") + self.assertTrue(any(e == "host_key_generated" + for e, _, _, _ in self.audit)) + self.assertFalse(any("FAKE-KEY" in (d or "") + for _, _, d, _ in self.audit)) + + def test_seed_iso_build_sets_0600(self): + seed_dir = os.path.join(self.dir, "seed2") + os.makedirs(seed_dir, exist_ok=True) + out = os.path.join(self.dir, "seed2.iso") + build_seed_disk(seed_dir, out, runner=self._default_runner) + self.assertEqual(oct(os.stat(out).st_mode & 0o777), "0o600") + + +# -- H3 任务盘删除 + GC -------------------------------------------------- +class DiskDeletionTest(SecfixCase): + def test_stop_deletes_task_disk_and_audits(self): + ex = self.make_exec() + disk = provision_task_disk(self.state_dir, "chv-d1", + self.rootfs, "copy") + self.assertTrue(os.path.exists(disk)) + ex.stop_vm({"id": "d1"}) + self.assertFalse(os.path.exists(disk)) + self.assertTrue(any(e == "vm_disk_deleted" + for e, _, _, _ in self.audit)) + + def test_gc_deletes_orphan_disk_only(self): + ex = self.make_exec() + provision_task_disk(self.state_dir, "chv-orphan", + self.rootfs, "copy") + live_disk = provision_task_disk(self.state_dir, "chv-live", + self.rootfs, "copy") + # live VM 有 socket 文件(mock 占位,不代表活 VMM, + # 但 GC 的 live 判定含 socket 存在——见 gc_stale_disks + # 的 socket 判定,此处只验 orphan 被删、live 不动)。 + os.makedirs(self.state_dir, exist_ok=True) + with open(os.path.join(self.state_dir, "chv-live.sock"), + "w") as fh: + fh.write("") + result = ex.gc_stale_disks() + self.assertIn("chv-orphan", result["deleted"]) + self.assertTrue(os.path.exists(live_disk)) + + def test_disk_delete_failure_audited_and_marks_dirty(self): + ex = self.make_exec() + provision_task_disk(self.state_dir, "chv-f1", + self.rootfs, "copy") + from unittest import mock + with mock.patch("cloud.chv.executor.delete_task_disk", + side_effect=OSError("disk busy")): + ex.stop_vm({"id": "f1"}) + self.assertTrue(any(e == "vm_disk_delete_failed" + for e, _, _, _ in self.audit)) + self.assertTrue(os.path.exists( + dirty_marker_path(self.state_dir, "chv-f1"))) + + +# -- H4 默认值 ------------------------------------------------------------ +class DefaultsTest(unittest.TestCase): + def test_production_defaults(self): + cfg = build_chv_config({}, {}) + self.assertEqual(cfg.host_key_mode, "provisioned") + self.assertNotEqual(cfg.state_dir, "/tmp/chv-vms") + self.assertTrue(cfg.state_dir.startswith(os.path.expanduser("~"))) + self.assertEqual(cfg.disk_gb, 50.0) + self.assertEqual(cfg.snapshot_max_count, 10) + self.assertEqual(cfg.snapshot_max_total_gb, 20.0) + self.assertEqual(cfg.snapshot_retention_days, 30.0) + + def test_keyscan_still_explicitly_selectable(self): + cfg = build_chv_config( + {}, {"CHV_HOST_KEY_MODE": "keyscan"}) + self.assertEqual(cfg.host_key_mode, "keyscan") + + def test_disk_gb_zero_or_negative_rejected(self): + with self.assertRaises(ValueError): + build_chv_config({}, {"CHV_DISK_GB": "0"}) + with self.assertRaises(ValueError): + build_chv_config({}, {"CHV_DISK_GB": "-5"}) + + +# -- H5 崩溃残留扫描 ------------------------------------------------------- +class ResidueScanTest(SecfixCase): + def test_cleanup_stale_secrets_removes_residue(self): + ex = self.make_exec() + vm_dir = os.path.join(self.state_dir, "chv-old") + os.makedirs(os.path.join(vm_dir, "seed"), exist_ok=True) + with open(os.path.join(vm_dir, "ssh_host_ed25519_key"), + "w") as fh: + fh.write("FAKE-PRIV") + with open(os.path.join(vm_dir, "seed.iso"), "wb") as fh: + fh.write(b"CIDATA") + cleaned = ex.cleanup_stale_secrets() + self.assertIn("chv-old", cleaned) + self.assertFalse(os.path.exists( + os.path.join(vm_dir, "ssh_host_ed25519_key"))) + self.assertFalse(os.path.exists( + os.path.join(vm_dir, "seed.iso"))) + self.assertTrue(any(e == "host_secret_residue_cleaned" + for e, _, _, _ in self.audit)) + + +# -- H6 state_dir 与 socket 权限 ------------------------------------------- +class StateDirTest(SecfixCase): + def test_preflight_creates_state_dir_0700(self): + ex = self.make_exec() + self.assertTrue(ex.preflight()) + mode = os.stat(self.state_dir).st_mode & 0o777 + self.assertEqual(oct(mode), "0o700") + + def test_ensure_secure_state_dir_fixes_loose_dir(self): + os.makedirs(self.state_dir, exist_ok=True) + os.chmod(self.state_dir, 0o755) + ensure_secure_state_dir(self.state_dir) + self.assertEqual( + oct(os.stat(self.state_dir).st_mode & 0o777), "0o700") + + def test_group_writable_socket_fails_fast(self): + ex = self.make_exec() + os.makedirs(self.state_dir, exist_ok=True) + sock_path = os.path.join(self.state_dir, "chv-sock.sock") + srv = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + srv.bind(sock_path) + srv.listen(1) + os.chmod(sock_path, 0o666) + try: + with self.assertRaises(ChvFailure) as ctx: + ex.wake_vm({"id": "sock"}) + self.assertEqual(ctx.exception.kind, + "socket_permissions_insecure") + finally: + srv.close() + + def test_dead_socket_not_counted_live_socket_counted(self): + ex = self.make_exec(env_extra={"CHV_MAX_VMS": "1"}) + os.makedirs(self.state_dir, exist_ok=True) + with open(os.path.join(self.state_dir, "chv-dead.sock"), + "w") as fh: + fh.write("") + self.assertEqual(ex._persistent_vm_count(), 0) + self.assertFalse(socket_is_live( + os.path.join(self.state_dir, "chv-dead.sock"))) + srv = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + srv.bind(os.path.join(self.state_dir, "chv-live.sock")) + srv.listen(1) + try: + self.assertTrue(socket_is_live( + os.path.join(self.state_dir, "chv-live.sock"))) + self.assertEqual(ex._persistent_vm_count(), 1) + finally: + srv.close() + + +# -- M1 远程转义 ------------------------------------------------------------ +class RemoteQuotingTest(unittest.TestCase): + def test_metachars_round_trip_through_remote_shell(self): + inner = ["python3", "/app/host.py", "dispatch", + "--task", "a; rm -rf / $(whoami) 'q' \"d\""] + cmd = build_ssh_command(2222, "/k", "root", None, "/kh", + inner, ssh_host="10.0.0.5") + user_idx = cmd.index("root@10.0.0.5") + remote = cmd[user_idx + 1:] + # ssh 拼接后远程 shell 解析回来的恰好是本地 list。 + self.assertEqual(shlex.split(" ".join(remote)), inner) + # 含元字符的 token 在 argv 里是被引用的单个元素, + # 不是裸的 shell 元字符。 + task_token = [t for t in cmd if "whoami" in t] + self.assertEqual(len(task_token), 1) + self.assertTrue(task_token[0].startswith("'")) + + +# -- M2 脏标记拒起 ------------------------------------------------------------ +class DirtyMarkerTest(SecfixCase): + def test_dirty_marker_refuses_provision_and_wake(self): + ex = self.make_exec() + vm_dir = os.path.join(self.state_dir, "chv-dirty") + os.makedirs(vm_dir, exist_ok=True) + with open(dirty_marker_path(self.state_dir, "chv-dirty"), + "w") as fh: + fh.write("cleanup failed\n") + with self.assertRaises(ChvFailure) as ctx: + ex.provision_host_key({"id": "dirty"}) + self.assertEqual(ctx.exception.kind, "vm_dirty_refused") + with self.assertRaises(ChvFailure) as ctx2: + ex.wake_vm({"id": "dirty"}) + self.assertEqual(ctx2.exception.kind, "vm_dirty_refused") + + +# -- M3 快照上限 --------------------------------------------------------------- +class SnapshotLimitTest(SecfixCase): + def _snap_file(self, name, size=64): + path = os.path.join(self.state_dir, name) + os.makedirs(self.state_dir, exist_ok=True) + with open(path, "wb") as fh: + fh.write(b"S" * size) + return "file://" + path + + def test_snapshot_count_limit_refused_before_chremote(self): + ex = self.make_exec(env_extra={"CHV_SNAPSHOT_MAX_COUNT": "1"}) + url1 = self._snap_file("s1.snap") + record_snapshot(self.state_dir, "chv-q", url1, 20.0) + calls_before = len(self.calls) + with self.assertRaises(ChvFailure) as ctx: + ex.snapshot_vm({"id": "q"}, self._snap_file("s2.snap")) + self.assertEqual(ctx.exception.kind, "snapshot_quota_exceeded") + # 上限在 ch-remote 之前拒绝,runner 未被调新命令。 + self.assertEqual(len(self.calls), calls_before) + + def test_snapshot_retention_prunes_expired(self): + import time + ex = self.make_exec() + old_url = self._snap_file("old.snap") + entries = [{"url": old_url, + "path": old_url[len("file://"):], + "size": 64, + "created_at": time.time() - 40 * 86400}] + import json as _json + vm_dir = os.path.join(self.state_dir, "chv-r") + os.makedirs(vm_dir, exist_ok=True) + with open(os.path.join(vm_dir, "snapshots.json"), "w") as fh: + _json.dump(entries, fh) + new_url = self._snap_file("new.snap") + ex.snapshot_vm({"id": "r"}, new_url) + self.assertFalse(os.path.exists(old_url[len("file://"):])) + self.assertTrue(os.path.exists(new_url[len("file://"):])) + + def test_snapshot_total_cap_prunes_oldest(self): + ex = self.make_exec( + env_extra={"CHV_SNAPSHOT_MAX_TOTAL_GB": "0.0000001"}) + # 总量帽约 107 字节:两块 64 字节快照登记后,旧的被剪。 + url1 = self._snap_file("t1.snap") + record_snapshot(self.state_dir, "chv-t", url1, 20.0) + url2 = self._snap_file("t2.snap") + _entries, removed = record_snapshot( + self.state_dir, "chv-t", url2, 0.0000001) + self.assertEqual(len(removed), 1) + self.assertFalse(os.path.exists(url1[len("file://"):])) + + +# -- M4/M8 构建脚本 -------------------------------------------------------------- +class BuildScriptTest(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.dir = self.tmp.name + self.bin_dir = os.path.join(self.dir, "bin") + os.makedirs(os.path.join(self.bin_dir, "codex-resources")) + for name in ("codex", "codex-code-mode-host"): + p = os.path.join(self.bin_dir, name) + with open(p, "w") as fh: + fh.write("#!/bin/sh\n") + os.chmod(p, 0o755) + self.repo = os.path.join(self.dir, "repo") + os.makedirs(os.path.join(self.repo, "cloud")) + with open(os.path.join(self.repo, "host.py"), "w") as fh: + fh.write("# host\n") + self.script = str(Path(__file__).resolve().parents[2] + / "chv-guest" / "build.sh") + + def tearDown(self): + self.tmp.cleanup() + + def _run(self, args, env_extra=None): + env = dict(os.environ) + env.update({"CODEX_BIN_DIR": self.bin_dir, + "UBUNTU_VERSION": "24.04", + "CODEX_VERSION": "0.164.0-alpha.9", + "REPO_COMMIT": "test123", + "REPO_DIR": self.repo}) + if env_extra: + env.update(env_extra) + return subprocess.run(["bash", self.script] + args, + capture_output=True, text=True, env=env) + + def test_dry_run_requires_sha_and_prints_sshd_hardening(self): + out_dir = os.path.join(self.dir, "out") + r = self._run(["--dry-run", out_dir]) + self.assertNotEqual(r.returncode, 0) + self.assertIn("UBUNTU_IMAGE_SHA256", r.stderr) + r2 = self._run(["--dry-run", out_dir], + env_extra={"UBUNTU_IMAGE_SHA256": "0" * 64}) + self.assertEqual(r2.returncode, 0, r2.stderr) + self.assertIn("99-chv.conf", r2.stdout) + self.assertIn("PasswordAuthentication no", r2.stdout) + self.assertIn("sshd -t", r2.stdout) + self.assertIn("virt-cat", r2.stdout) + + +# -- M5 审计补齐 ------------------------------------------------------------------ +class AuditCoverageTest(SecfixCase): + def test_host_key_mismatch_audited_and_fail_fast(self): + ex = self.make_exec( + env_extra={"CHV_HOST_KEY_MODE": "keyscan"}) + kh = ex.known_hosts_path({"id": "m1"}) + os.makedirs(os.path.dirname(kh), exist_ok=True) + with open(kh, "w") as fh: + fh.write("[127.0.0.1]:2222 ssh-ed25519 AAAAfake\n") + + def bad_ssh(cmd, timeout=None): + self.calls.append(list(cmd)) + if cmd and cmd[0] == "ssh": + return FakeResult( + 255, "", + "Host key verification failed.\n" + "REMOTE HOST IDENTIFICATION HAS CHANGED\n") + return FakeResult(0, "", "") + + ex.runner = bad_ssh + with self.assertRaises(ChvFailure) as ctx: + ex.wait_guest_ready({"id": "m1"}) + self.assertEqual(ctx.exception.kind, "host_key_mismatch") + self.assertTrue(any(e == "host_key_mismatch" + for e, _, _, _ in self.audit)) + + def test_preflight_failure_is_audited(self): + ex = self.make_exec(binary="/tmp/no-such-chv-binary") + with self.assertRaises(ChvFailure): + ex.preflight() + self.assertTrue(any(e == "preflight_failed" + for e, _, _, _ in self.audit)) + + def test_audit_callback_failure_does_not_break_task(self): + ex = self.make_exec() + ex.audit_callback = lambda *args: (_ for _ in ()).throw( + RuntimeError("audit store down")) + # _emit 只记宿主 error 日志,不抛异常。 + ex._emit("vm_create", {"id": "t"}, "vm=chv-t") + + def test_capture_success_is_audited(self): + ex = self.make_exec( + env_extra={"CHV_HOST_KEY_MODE": "keyscan"}) + + def keyscan_runner(cmd, timeout=None): + self.calls.append(list(cmd)) + if cmd and cmd[0] == "ssh-keyscan": + return FakeResult( + 0, "[127.0.0.1]:2222 ssh-ed25519 AAAAfake1\n", "") + return FakeResult(0, "", "") + + ex.runner = keyscan_runner + self.assertTrue(ex.capture_host_key({"id": "cap1"})) + self.assertTrue(any(e == "host_key_captured" + for e, _, _, _ in self.audit)) + + +# -- M6 reuse 租户声明 --------------------------------------------------------------- +class ReuseTenantTest(SecfixCase): + def test_reuse_without_tenant_declaration_refused(self): + ex = self.make_exec(env_extra={ + "CHV_DISK_MODE": "reuse", + "CHV_REUSE_SINGLE_TENANT_EXPERIMENT": "1"}) + with self.assertRaises(ChvFailure) as ctx: + ex.preflight() + self.assertEqual(ctx.exception.kind, "reuse_tenant_undeclared") + + def test_reuse_with_declaration_passes_preflight(self): + ex = self.make_exec(env_extra={ + "CHV_DISK_MODE": "reuse", + "CHV_REUSE_SINGLE_TENANT_EXPERIMENT": "1", + "CHV_SINGLE_TENANT_PROJECT": "p1"}) + self.assertTrue(ex.preflight()) + + +# -- L1/L2/L3/L4 ----------------------------------------------------------------------- +class LowItemsTest(SecfixCase): + def test_task_disk_symlink_not_followed(self): + target = os.path.join(self.dir, "victim.bin") + with open(target, "wb") as fh: + fh.write(b"VICTIM") + vm_dir = os.path.join(self.state_dir, "chv-l1") + os.makedirs(vm_dir, exist_ok=True) + os.symlink(target, os.path.join(vm_dir, "disk.raw")) + disk = provision_task_disk(self.state_dir, "chv-l1", + self.rootfs, "copy") + self.assertFalse(os.path.islink(disk)) + with open(disk, "rb") as fh: + self.assertEqual(fh.read(), b"R" * 100) + with open(target, "rb") as fh: + self.assertEqual(fh.read(), b"VICTIM") + + def test_snapshot_url_outside_allowed_dir_refused(self): + with self.assertRaises(ChvFailure) as ctx: + validate_snapshot_url("file:///etc/passwd", + self.state_dir) + self.assertEqual(ctx.exception.kind, + "snapshot_path_not_allowed") + + def test_remote_snapshot_requires_explicit_flag(self): + with self.assertRaises(ChvFailure) as ctx: + validate_snapshot_url("s3://bucket/snap", + self.state_dir) + self.assertEqual(ctx.exception.kind, + "snapshot_remote_not_allowed") + self.assertIsNone(validate_snapshot_url( + "s3://bucket/snap", self.state_dir, allow_remote=True)) + + def test_capacity_failure_is_audited(self): + ex = self.make_exec(env_extra={"CHV_MAX_VMS": "0"}) + with self.assertRaises(ChvFailure) as ctx: + ex.wake_vm({"id": "cap"}) + self.assertEqual(ctx.exception.kind, "host_capacity_exceeded") + self.assertTrue(any(e == "vm_failed" and k == "host_capacity_exceeded" + for e, _, _, k in self.audit)) + + def test_dual_net_vars_warn_but_pass(self): + ex = self.make_exec(env_extra={ + "CHV_NET": "tap=tap0", + "CHV_NET_JSON": '[{"tap": "tap0"}]'}) + self.assertTrue(ex.preflight()) + self.assertTrue(any(e == "net_config_dual" + for e, _, _, _ in self.audit)) + + def test_warm_pool_acquire_doc_defines_task_disk_policy(self): + # L5 是文档项(接线仍未做):acquire 文档必须写清 + # 预热盘与任务盘的对应关系,不假装已接。 + self.assertIn("provision_task_disk", WarmPool.acquire.__doc__) + + +if __name__ == "__main__": + unittest.main() diff --git a/cloud/tests/test_cloud_hypervisor.py b/cloud/tests/test_cloud_hypervisor.py index 7b0f1c0..c9e62eb 100644 --- a/cloud/tests/test_cloud_hypervisor.py +++ b/cloud/tests/test_cloud_hypervisor.py @@ -101,6 +101,11 @@ class ChvCase(unittest.TestCase): env = {"CHV_EGRESS_PROXY": "http://10.0.0.1:8888", # 实验室宣称:mock runner 假装回环端口已转发到客机 #(feat-chv-fix 起,执行器不再无条件假设回环能通)。 + # 实验室显式 opt-in keyscan:生产默认已是 + # provisioned;本套件旧 fake 不实现 ssh-keygen/ + # genisoimage,测的不是 host key 模式,故显式 + # 声明实验室路径,绝不把它写成生产默认。 + "CHV_HOST_KEY_MODE": "keyscan", "CHV_SSH_FORWARD": "1"} if env_extra: env.update(env_extra) @@ -232,12 +237,14 @@ class PreflightTest(ChvCase): class ConfigCommandTest(ChvCase): def test_vm_config_shape_and_memory_bytes(self): ex = self.make_exec() - cfg = ex.build_vm_config("/tmp/x/disk.raw") + # feat-chv-secfix(L2):磁盘须在 state_dir 内。 + disk = os.path.join(self.state_dir, "x", "disk.raw") + cfg = ex.build_vm_config(disk) self.assertEqual(cfg["cpus"], {"boot_vcpus": 2, "max_vcpus": 2}) self.assertEqual(cfg["memory"]["size"], 2 * 1024 ** 3) self.assertEqual(cfg["kernel"]["path"], self.kernel) - self.assertEqual(cfg["disks"], [{"path": "/tmp/x/disk.raw"}]) + self.assertEqual(cfg["disks"], [{"path": disk}]) self.assertEqual(cfg["cmdline"]["args"], "console=hvc0 root=/dev/vda1 rw") @@ -391,7 +398,10 @@ class LifecycleTest(ChvCase): self.assertEqual(kinds, ["pause", "resume"]) def test_restore_then_explicit_resume_order(self): - ex = self.make_exec() + # feat-chv-secfix(L2/H4):本测试走实验室 keyscan + # 路径 + 声明快照目录(生产默认 provisioned + 白名单 + # 目录,旧 file:///snap/1 不再直通)。 + ex = self.make_exec(env_extra={"CHV_SNAPSHOT_DIR": "/snap"}) out = ex.restore_vm(self._task(), "file:///snap/1") self.assertEqual(out["mode"], "restored") self.assertTrue(out["ready"]) @@ -416,7 +426,10 @@ class LifecycleTest(ChvCase): ex = self.make_exec() # feat-chv-fix(P1-7):file:// 快照须真实存在且非空, # 否则 hibernate 不 shutdown;用临时文件作快照目标。 - snap = os.path.join(self.dir, "h1.snap") + # feat-chv-secfix(L2):快照须在白名单目录内,放在 + # state_dir 下。 + snap = os.path.join(self.state_dir, "h1.snap") + os.makedirs(self.state_dir, exist_ok=True) with open(snap, "wb") as fh: fh.write(b"S" * 64) snap_url = "file://" + snap diff --git a/cloud/worker.py b/cloud/worker.py index be14c42..83e37ce 100644 --- a/cloud/worker.py +++ b/cloud/worker.py @@ -340,10 +340,11 @@ def _call_goal_resume_breakpoint(executor, task, model_provider=None, Local 仍走原路径(调用方直接传 model_provider,不经过这里, 以免改生产语义);这里只用于非 Local 执行器:签名接受 model_provider 时传它(CHV/Docker 均接受),否则只传 task - (旧假执行器单参)。feat-chv-fix(P1-1):CHV 的 - gateway_env 经此进入 SSH env 前缀注入客机环境(不进 - 镜像/代码/prompt);CHV 没有 Local 那样的进程 env 通道, - SSH env 前缀是唯一通道,值只出现在宿主侧 ssh argv。""" + (旧假执行器单参)。feat-chv-secfix(H2):CHV 的 + gateway_env 不再经 SSH env 前缀注入(密钥值进 argv 的 + 旧路径已废除),改走 provisioned seed 盘客内文件投递; + CHV 没有 Local 那样的进程 env 通道,seed 文件是唯一通道, + keyscan 模式下密钥投递 fail-closed。""" method = getattr(executor, "goal_resume_breakpoint_task") try: params = inspect.signature(method).parameters diff --git a/docs/chv-production-readiness.md b/docs/chv-production-readiness.md index 427904e..55a2bcd 100644 --- a/docs/chv-production-readiness.md +++ b/docs/chv-production-readiness.md @@ -41,7 +41,10 @@ 密钥目录;客机代码在镜像内 `/app`,不在宿主共享挂载上。 - ✅🧪 密钥不进镜像/代码/prompt:SSH argv 只有 key 文件路径; 模型 key 由客机 cloud-init 预置的环境变量名注入;测试断言 - 命令中不含哨兵密钥值。 + 命令中不含哨兵密钥值。feat-chv-secfix 起网关密钥不再走 + SSH env 前缀(旧路径值进 argv 已废除),改走 provisioned + seed 盘客内文件(/etc/chv/guest-env,0600),keyscan 模式 + 下密钥投递 fail-closed;审计 detail 里绝无密钥值。 - ✅🧪 SSH host key 按 VM 钉死(优化轨,#216 的 no + /dev/null 已废除):每 VM 在 state_dir//known_hosts 有独立文件, 首次启动经 ssh-keyscan 在解析出的 SSH 目标上捕获 key 写入该文件, @@ -55,8 +58,9 @@ cloud-init seed(随 VM 关闭/回收而删),客机镜像硬性 要求见 chv-guest/README.md §5(cloud-init NoCloud、 首启先应用 seed 再起 sshd、不得无条件重生成 host key)。 - ssh-keyscan 首次捕获只保留为弱回落(CHV_HOST_KEY_MODE - =keyscan),生产部署必须用 provisioned 模式;TOFU 首次 + ssh-keyscan 首次捕获只保留为弱回落(须显式设 + CHV_HOST_KEY_MODE=keyscan),生产默认值已是 provisioned + (feat-chv-secfix,H4,此前默认值 keyscan 是反的);TOFU 首次 真实性仍待预置 key 方案在真机上验证,安全审计时须单独立项确认。 - 🔒 需安全审计:VM 逃逸面(virtio 设备、tap 桥接、SSH 配置)、 客机 root 提权面、租户间东西向隔离的宿主网络配置,须由 @@ -97,6 +101,9 @@ shutdown + 杀进程 + 删 socket)、recycle_vm(保留 task_checkpoints/task_attempts,丢失 guest /tmp 与未拷出 的 worktree 临时物,绝不声称恢复临时文件)。 + feat-chv-secfix 起 stop 即删任务 disk.raw(保留期 0, + 需取证须在 shutdown 前经 SSH 拷出),残留盘由 + gc_stale_disks 扫描清理,删失败记审计 + 脏标记。 - ✅🧪 execute 只读已运行 VM(优化轨):execute() 先查 VM 是否在运行(登记活进程或 api-socket 存在),未运行报 ChvFailure(kind=vm_not_running),不为一次 status 起 VM, @@ -131,8 +138,12 @@ ## 5. 资源与成本 - ✅🧪 每 VM 配额:CPU/内存进 VmConfig(CHV_CPU/CHV_MEM), - 磁盘配额门(CHV_DISK_GB,超限拒绝),宿主容量门 - (CHV_MAX_VMS,超限明确拒绝排队,不降级)。 + 磁盘配额门(CHV_DISK_GB,默认 50 GB 总量预算、超限拒绝, + feat-chv-secfix 起默认开启,此前未设即不查),快照上限 + (每 VM 数量默认 10、总量默认 20 GB、保留期默认 30 天, + 超量最旧优先剪枝),宿主容量门 + (CHV_MAX_VMS,超限明确拒绝排队,不降级;残留 socket + 须真连通才计数,死 socket 不再占满容量)。 - ✅ 超时与最大轮次:SSH 单次超时(CHV_TASK_TIMEOUT)+ worker task_timeout 外层护栏;goal 最大轮次 100(与 Local 同款),执行器内部不重试(重试是 worker max_retries 语义)。 @@ -152,9 +163,15 @@ - ✅🧪 受控 egress:默认 fail-closed(CHV_EGRESS_PROXY 未设且 未显式允许直连时 preflight 直接失败);客机命令加 env 代理前缀,出口只走白名单代理(Tinyproxy 思路复用)。 + feat-chv-secfix 措辞纠正:env 前缀只是代码门,不是网络层 + 隔离——客内 root 可 unset/裸 socket 绕过;真正的隔离是 + “代码门 + 宿主防火墙缺一不可”(见 §11 部署清单), + 代码不再把 fail-closed 写成已隔离。 - ⏳🔒 需宿主网络配置 + 安全审计:tap 桥接是否真的只路由到 白名单代理、VM 能否绕过代理直连外网,是宿主网络配置问题, 执行器代码不能自证,必须在 KVM 宿主上实测并审计。 + 代码已做:每 VM 独立 tap/mac/ip 校验(共享即拒绝并审计)、 + net 条目字段白名单、egress 代理地址校验。 - ✅ 审批门:任务 approval 经 `_approval_cli_args` 进客内 host.py 顶层参数,VM 内 agent 不能自改;delete/deploy/spend 类动作 仍走 server 的 approval/merge_gate,不由 VM 内 agent 自批 @@ -169,7 +186,12 @@ - ✅🧪 VM 事件审计:create/boot/ready/pause/resume/snapshot/ restore/shutdown/destroy/failed/recycled/warm_pool_acquire/ release 均经 audit_callback 发出;worker 接线后转 AuthStore - 审计(_audit 本身 best-effort)。 + 审计(_audit 本身 best-effort)。feat-chv-secfix 起补齐: + host_key_generated/host_key_captured/host_key_mismatch、 + preflight_failed、wake 前置失败(容量/配额/net 冲突)、 + vm_disk_deleted/vm_disk_delete_failed/vm_disk_gc、 + host_secret_residue_cleaned、net_config_dual、快照剪枝; + 审计回调失败记宿主 error 日志,不再双重静默。 - ✅🧪 失败分类:ChvFailure 带 kind(kvm_missing/binary_missing/ kernel_missing/image_missing/ssh_key_missing/cpu_flag_missing/ egress_not_configured/api_timeout/guest_not_ready/vm_create_failed/ @@ -194,7 +216,9 @@ - ✅🧪 多 VM 并行:`_vms` 登记 + 锁保护,wake 按任务名登记, 不同任务互不覆盖;容量门在 wake 前检查。 - ✅🧪 残留 VM 清理:cleanup_stale_vms 处理已死/已关/socket - 残留三种情况,只清 chv- 前缀 socket。 + 残留三种情况,只清 chv- 前缀 socket;feat-chv-secfix 起 + 清理前先跑残留盘 GC 与残留密钥扫描(不依赖 socket 是否 + 存在,覆盖 worker 崩溃后 socket 已删、文件仍在的情形)。 - ✅🧪 容量不足明确拒绝:kind=host_capacity_exceeded,调用方 排队/拒绝,绝不降级 Docker/Local(测试断言 launcher 未调用)。 - 📈 需压测:多 VM 并行下的宿主容量、VM 启动并发上限、残留 @@ -236,8 +260,9 @@ - ✅🧪 P2 已做(范围已恢复为全做,上一条收窄令作废): test_chv_gaps.py 的 grep 文本断言改成真实 Worker._run_attempt 行为断言;stop_vm 删除失败记 vm_key_cleanup_failed 审计 - (同名复用旧 key 的彻底根治“清理失败即拒起”列为遗留,不 - 假装已根治);配置校验时机统一到 preflight(CPU/内存非法 + (同名复用旧 key 的彻底根治在本轨之前列为遗留—— + feat-chv-secfix 起已根治:清理失败写脏标记,同名 VM + 拒绝启动,见 §11 M2);配置校验时机统一到 preflight(CPU/内存非法 进 preflight,kind=config_invalid);build.sh 与 README 口径 对齐(章节顺序 §5 host key → §6 对接、BUILDINFO contents 纠正为名字列表无逐项版本、Node.js 不装写实);第二启动路径 @@ -247,3 +272,156 @@ 小提取(preflight 的 reuse/CPU/内存校验块抽成 _preflight_config_checks,语义不变;config.py 字段解析与 其余长函数深耦合,本轨如实停、不硬拆)。 + +## 11. 安全审计修复轨 feat-chv-secfix(堆叠于 #239 之上) + +> 审计报告:`~/workspace/chv-security-audit/REPORT.md`(只读静态 +> 审计,verdict No-Go,Critical 0 / High 7 / Medium 8 / Low 5)。 +> 本节逐项给结论:代码已做 = 本轨代码 + mock 测试已覆盖; +> 真机未验证 = 代码改了,但动态实效仍须 KVM 宿主验证, +> 绝不把静态通过写成动态已验。审计 verdict 本身仍是 No-Go, +> 直到 §11 末的真机清单全部通过——本轨只修代码层,不改 verdict。 + +### 11.1 High(全部代码已做、真机未验证) + +- H1 egress 网络层强制:代码能做的全部做了——egress 代理 + 地址校验(http/https + 主机名)、直连仍须显式允许 + 审计、 + 每 VM 独立 tap/mac/ip 校验(共享即拒绝并审计)、net 条目 + 字段白名单。代码做不到的宿主防火墙规则见 §11.4 部署清单, + 文档明写“代码门 + 宿主防火墙缺一不可”;env 前缀不再被 + 写成已隔离。 +- H2 网关 key 移出 argv:密钥子集(SUB2API_API_KEY)在 + provisioned 模式下随 seed 写进客内 /etc/chv/guest-env + (0600 root),远程命令 source 该文件;传输层对密钥名 + 进 argv 直接 ValueError(fail-closed)。比 argv 安全的理由: + 值既不在宿主进程表(ps /proc cmdline)里,也不在宿主子 + 进程环境(/proc environ)里,只活在 seed.iso(0600,随 + VM 销毁而删)与客机内——客内 agent 本来就有该 key,修 + 的是“宿主暴露面”,不是“客内可见性”。与 Docker 线的差异: + Docker 是“-e 只传名、值由 CLI 子进程环境提供”(值仍在 + 宿主进程环境里),CHV 是“值只进 seed 客内文件”(宿主 + 进程环境里也没有),比 Docker 线更严。keyscan 模式无 + seed 可投递,有密钥即 fail-closed(secret_env_requires_ + provisioned),不再将就跑。残留清理:seed 目录与 seed.iso + 随 stop 而删,崩溃残留由 cleanup_stale_secrets 扫描。 +- H3 任务盘删除:stop/recycle/cleanup 路径删 disk.raw + (保留期 0,需取证须在 shutdown 前经 SSH 拷出),删失败 + 记 vm_disk_delete_failed 审计 + 脏标记;gc_stale_disks + 扫描非 live VM 的残留盘并审计。 +- H4 默认值改 provisioned:CHV_HOST_KEY_MODE 默认 + provisioned,keyscan 只作显式 opt-in;旧测试的 + CHV_SSH_FORWARD=1 等实验室宣称已明确标注为实验室路径, + 不再写成生产默认。 +- H5 seed.iso 权限与私钥清理:seed.iso 生成即 0600;宿主 + 私钥在 seed 盘建好后即删(只留公钥 + known_hosts),删 + 失败 fail-fast(VM 尚未启动);私钥 chmod 失败不再静默; + 启动/清理时 cleanup_stale_secrets 扫描残留私钥与 seed。 +- H6 state_dir 出 /tmp:默认 + ~/.local/share/codex-app-server/chv-vms,preflight 建目录 + 并保证 0700(失败即 state_dir_insecure);api-socket 组/ + 其它可写即 fail-fast(socket_permissions_insecure;仅禁 + 写位,VMM 默认 0755 类权限不误杀——VMM 默认值代码控制 + 不了,真机权限仍须按清单核对)。 +- H7 tap 隔离校验:NET_JSON 透传前校验 tap/mac/ip 跨 VM + 不重复(wake 成功后登记 net.json,stop 时释放预约), + 共用即拒绝并审计;东西向隔离的宿主防火墙部分见 §11.4, + 不写进代码假装已防。 + +### 11.2 Medium(每项有修复或有理由的结论) + +- M1 SSH 远程命令转义:远程 token 全部 shlex.quote 后仍以 + 列表返回(ssh 拼接后远程解析结果与本地 list 语义一致), + prompt 里的 ;/$()/引号不再改写执行语义。边界写清:客内 + 已是 root,本项防的是语义篡改与审计失真,不是客内提权。 +- M2 清理失败同名复用旧 key:彻底根治,选“清理失败即拒起” + (脏标记 + provision/wake 双重拒绝),不再写“已审计即可”。 +- M3 磁盘配额默认与快照上限:CHV_DISK_GB 默认 50 GB + (未设不再不查,0/负数拒绝);快照每 VM 数量默认 10、 + 总量默认 20 GB(超帽最旧优先剪枝)、保留期默认 30 天。 +- M4 供应链版本钉死:已钉死——云镜像 SHA256 必填(干跑验 + “已设”、全量验哈希)、Ubuntu 版本与 codex 版本均为必填 + 环境变量;BUILDINFO 新增 versions(构建时从客机读回的 + dpkg + pip freeze 实际版本,可审计、可复现比对)。未钉死 + 的清单:apt/pip 事前精确版本 lockfile(须在构建宿主上 + 生成一次、再固化进脚本,本轨无构建宿主、未编造版本号); + Playwright Chromium 二进制版本同样只随 playwright pip + 版本记录,未单独钉死。 +- M5 审计补齐:host_key_generated/host_key_captured/ + host_key_mismatch(stderr 含钉死拒绝标记时立即 fail-fast, + 不轮询成普通超时)、preflight_failed、wake 前置失败 + (容量/配额/net 冲突)均独立审计;审计回调失败记宿主 + error 日志,不再双重静默。worker 层 _audit 本身仍 + best-effort(未改,见 §7 既定语义)。 +- M6 reuse 实验标志单租户校验:代码能校验的已校验—— + 实验放行须同时声明 CHV_SINGLE_TENANT_PROJECT,否则 + preflight 拒绝(reuse_tenant_undeclared);不能校验的 + 部分明写:代码无法验证数据库里是否真的只有一个租户, + 声明 + 审计就是本项的机制,不假装已校验。 +- M7 容量计数残留 socket 误伤:只有真能连通的 socket 才 + 计数,空文件/死 socket 不计入 CHV_MAX_VMS;竞态窗口 + 写清(刚启动未 listen 的 VMM 会被判死,容量门是护栏 + 不是分布式锁)。 +- M8 sshd 加固进构建:build.sh 写入 + /etc/ssh/sshd_config.d/99-chv.conf(PasswordAuthentication no、 + PermitRootLogin prohibit-password、ChallengeResponseAuthentication no、 + KbdInteractiveAuthentication no、PubkeyAuthentication yes), + 并经 sshd -t + grep 断言;README 声称与构建一致。 + +### 11.3 Low(顺手做) + +- L1 磁盘拷贝符号链接:目标是链接时先删链接本身再拷贝, + 不跟随写入目标文件(TOCTOU 窗口已在 disk.py 写清, + state_dir 0700 是前提)。 +- L2 磁盘/快照路径白名单:task_disk/seed_disk 须在 + state_dir 内(reuse 的基础镜像本身除外);file:// 快照 + 须在 state_dir 或 CHV_SNAPSHOT_DIR 内,远端协议须显式 + CHV_ALLOW_REMOTE_SNAPSHOT=1。 +- L3 失败审计:preflight/容量/配额/net 冲突失败均发 + vm_failed 审计(见 M5)。 +- L4 CHV_NET 与 CHV_NET_JSON 双变量:双设时发 + net_config_dual 警告审计(不硬拒,CHV_NET 是手动直接 + 启动命令的透传值);NET_JSON 条目字段白名单 + (tap/mac/ip/mask),未知字段直接拒绝。 +- L5 WarmPool 预热盘与任务盘对应关系:定义已写进 + pool.acquire 文档——预热盘是干净快照盘,不是任务盘; + 将来接线时任务盘仍须按任务重新供给或校验快照来源与 + 任务的对应关系;接线本身仍未做,不假装已接。 + +### 11.4 宿主防火墙部署清单(代码做不到的部分,缺一不可) + +- 每 VM 独立 tap + 独立 mac + 独立静态 ip(运维分配, + 代码在 wake 时校验冲突,但分配本身是运维责任); +- tap 桥接默认 DROP,只放行到 egress 代理的流量 + (iptables/nftables:默认 DROP + 仅代理地址/端口 ACCEPT, + 不给 tap 直连外网的 NAT,不把 tap 桥接到宿主内网网段); +- 东西向隔离:VM 间默认禁通(每 VM 独立 tap + 桥接隔离, + 或每 tap 独立网段 + 转发禁令),ARP spoof 场景下 SSH + 钉死仍是第二道门,不是唯一门; +- 宿主进程隔离:worker 用户最小化、VMM api-socket 所在 + state_dir 0700 且仅 worker 用户可访、宿主启用 hidepid + 收紧 /proc 可见性、进程表采集不记录完整 cmdline; +- 部署后核对:state_dir 权限、socket 实际权限、 + tap 规则实测(见 §11.5 否定测试)。 + +### 11.5 仍需真机验证的清单(本轨一律未做,不声称已验) + +1. 否定测试:客内 unset 代理后 curl 外网必须不通; + VM A 扫 VM B / spoof B 的 ip 后 SSH 必须被钉死拒绝; +2. provisioned 首启时序:seed 先应用再起 sshd、不重生成 + host key;sshd -T 实际输出与密码登录关闭; +3. 宿主 SSH argv 实测:真机 ps 下网关 key 不可见; +4. 长跑/崩溃注入:残留盘总量、快照增长、残留 socket 与 + seed/私钥残留的实际表现、GC 覆盖; +5. 压测:配额与容量门在并发下的实际表现; +6. 渗透类:任何以“拿到宿主 shell / 读到别租户数据”为 + 目标的真实攻击尝试——本轨一律未做,静态通过不等于 + 已证明不可逃逸; +7. 审计 verdict:以上全部通过后,才能把 verdict 从 No-Go + 改为“有条件启用(默认关闭 + 灰度单租户/受信项目)”, + 不直接全量多租户。 + +### 11.6 本轨明确不做 + +- VM 控制按钮、预热池管理页、成本/配额新页面(前端零改动); +- 真机动态渗透、压测(无 KVM,做不了,只列清单); +- WarmPool dispatch 前查池接线(仍未接线,见 L5 定义)。