feat(chv): 安全审计修复 High/Medium/Low(堆叠于 #239)

审计报告 ~/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)。
This commit is contained in:
host committed 2026-10-31 03:02:10 +00:00
1 parent 003ea357f9
commit baf26beb78
16 files changed
+2106 -132

No files matched your search

+504 -61
View File
@@ -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 重启复用已有对(否则钉死立即失效)。"""
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 <key 文件> + 钉死选项
+ user@127.0.0.1 + 客内命令。argv 里只有 key 文件路径,
+ user@<SSH 目标> + 客内命令。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
<source_url>。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: