feat: CHV 执行器优化(拆模块/去重复/SSH 钉死/池持久化/中途死亡分类)

- 拆模块:CHV 从 cloud/sandbox.py 巨类拆到 cloud/chv/(executor/
  pool/config/api_client/ssh_transport/disk/errors),sandbox 只留
  兼容 re-export,旧导入路径与新路径是同一对象
- 去重复:goal 解析、host 顶层参数、runner 超时抽成共享 helper,
  Local 与 CHV 在矩阵输入下结论全等(Docker goal-resume 变体
  有意保留内联版,见 PR 描述)
- 安全:SSH host key 按 VM 钉死(state_dir/<vm>/known_hosts,
  ssh-keyscan 首次捕获 TOFU),废除 no + /dev/null;CHV_SSH_USER
  空串与 CHV_SSH_PORT 越界直接拒绝,不静默 fallback
- execute 语义:只读已运行 VM,未运行报 vm_not_running,不为
  status 起 VM
- 预热池:registry 落盘、重启载入只当孤儿、reconcile 清理、
  损坏 quarantine;条目仍一次性,绝不复用脏 VM
- 长任务:VM 中途死亡判 interrupted(断点可续跑);中途心跳
  经评估不做(无客内进度信号可采)
- #215 未合并:只列复用清单,不复制模块

测试:原 42 项 + 新 24 项全过;相关旧套件与 cloud 全量 1496 项
全过(skipped=4)。验证仍是 mock + 干跑 + 单元测试,未做真机、
压测、安全审计;堆叠于 #216 之上,base 为 feat-cloud-hypervisor。
This commit is contained in:
host committed 2026-10-30 19:45:33 +00:00
1 parent c371bbdf1e
commit fde7bf4c3e
14 files changed
+2706 -1408

No files matched your search

+93
View File
@@ -0,0 +1,93 @@
"""Cloud Hypervisor 客机 SSH 传输(叶子模块,不依赖
cloud.sandbox,避免循环导入)。
安全模型(本轨的核心变化,替代 #216 的
StrictHostKeyChecking=no + UserKnownHostsFile=/dev/null):
- 每 VM 一个独立 known_hosts 文件:
state_dir/<vm 名>/known_hosts(与每任务磁盘同目录,
任务销毁时不自动删——known_hosts 留在宿主 state_dir 里,
供事后审计该 VM 当时钉死的 key;VM 名本身已含任务 id,
不会串到别的 VM 上)。
- 首次信任(TOFU,边界必须写清,不假装是强验证):
首次启动时客机 host key 未知,执行器用 ssh-keyscan 经
宿主转发端口(127.0.0.1:<port>,只走本机回环)抓一次
当时呈现的 key,写入该 VM 的 known_hosts 文件;此后
同一 VM 的每次 SSH 都用 StrictHostKeyChecking=yes +
该文件校验,VM 被替换 / 中间人换 key 时 SSH 直接拒绝,
不再静默接受。首次捕获本身不验证“这台客机就是我们要
的那台”——它只防首次之后的替换;更强的方案是镜像构建
/cloud-init 时把客机 host key 预置进 known_hosts(未来
工作,见 docs/chv-production-readiness.md 的待办),本轨
在 mock 测试里只能验证“捕获→写文件→后续命令带钉死
选项”的管线,绝不声称 mock 验证了首次密钥的真实性。
- 密钥红线不变:argv 里只有 key 文件路径,绝无 key 值;
known_hosts 文件只含 host key 公钥行,不含任何私钥。
"""
import os
def known_hosts_path(state_dir, vm_name):
"""该 VM 的 known_hosts 文件路径:state_dir/<vm>/known_hosts。"""
return os.path.join(state_dir, vm_name, "known_hosts")
def build_ssh_command(ssh_port, ssh_key, ssh_user, egress_proxy,
known_hosts_file, inner_cmd):
"""构造 SSH 命令:ssh -p <端口> -i <key 文件> + 钉死选项
+ user@127.0.0.1 + 客内命令。
与 #216 的区别只有 host key 选项:StrictHostKeyChecking=yes
+ UserKnownHostsFile=<每 VM 文件>,不再是 no + /dev/null。
BatchMode=yes 不变(绝不交互问 host key / 口令)。
egress_proxy 已设时客内命令前加 env 前缀(代理地址本身
不是密钥;客机出口只走该白名单代理)。"""
inner = list(inner_cmd)
if egress_proxy:
inner = ["env",
"HTTPS_PROXY=%s" % egress_proxy,
"HTTP_PROXY=%s" % egress_proxy,
"NO_PROXY=localhost,127.0.0.1"] + inner
return ["ssh", "-p", str(ssh_port),
"-i", ssh_key,
"-o", "StrictHostKeyChecking=yes",
"-o", "UserKnownHostsFile=%s" % known_hosts_file,
"-o", "BatchMode=yes",
"%s@127.0.0.1" % ssh_user] + inner
def parse_keyscan_output(text):
"""从 ssh-keyscan 的 stdout 里挑出本机回环端口的 host key
行:形如 "[127.0.0.1]:<port> ssh-ed25519 AAAA..."(-p 时
的标准写法)或 "127.0.0.1 ssh-rsa AAAA..."。其它主机行、
注释行、空行一律丢掉,绝不把陌生主机的 key 写进文件。"""
kept = []
for line in (text or "").splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
parts = line.split()
if len(parts) < 3:
continue
host, keytype = parts[0], parts[1]
if host != "127.0.0.1" \
and not host.startswith("[127.0.0.1]:"):
continue
if not keytype.startswith("ssh-"):
continue
kept.append(line)
return kept
def write_known_hosts(path, key_lines):
"""原子写 known_hosts:先写同目录临时文件再 rename,
文件权限 0600(只含公钥行,仍按敏感文件对待)。"""
directory = os.path.dirname(path)
if directory:
os.makedirs(directory, exist_ok=True)
tmp_path = path + ".tmp"
with open(tmp_path, "w") as fh:
fh.write("\n".join(key_lines) + "\n")
os.chmod(tmp_path, 0o600)
os.replace(tmp_path, path)