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

+46 -4
View File
@@ -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)
+150
View File
@@ -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/<vm>/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/<vm>/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
+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:
+196
View File
@@ -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
+69 -5
View File
@@ -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
+9 -1
View File
@@ -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
+91 -22
View File
@@ -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/<pid>/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 <key 文件> + 钉死选项
+ user@<ssh_host> + 客内命令。
+ user@<ssh_host> + 客内命令(远程 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"):