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:
1 parent
003ea357f9
commit
baf26beb78
16 files changed
+2106
-132
No files matched your search
+46
-4
@@ -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)
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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"):
|
||||
|
||||
Reference in new issue
Block a user