P0:seed 磁盘 CIDATA ISO 挂进 VmConfig 第二块只读盘; restore provisioned 下复用钉死 known_hosts、缺失 fail-fast 绝不 keyscan;preflight 拦截 CHV_DISK_MODE=reuse(显式 实验二次确认 + 审计放行);SSH 目标选客机 IP 方案(显式 host > net 静态 ip > 显式转发宣称回环,三者皆无 fail-fast)。 P1:网关 extra_env 经 SSH env 前缀注入、worker 守卫收窄到 Local/CHV;取消/清理/看门狗接 CHV 通用路径;WarmPool acquire 内 resume;容量门持久化计数;磁盘配额总量+余量 与已存在磁盘校验重拷;API 按 Content-Length 验完整性; 快照 file:// 验落盘再 shutdown;execute 死端口归类 vm_not_running。 P2 全部排除未做(用户范围收窄令,留待以后一轨)。 测试:原 84 + 新增 29 = 113 项,mock,无真 VM。
108 lines
4.7 KiB
Python
108 lines
4.7 KiB
Python
"""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_host="127.0.0.1",
|
||
extra_env=None):
|
||
"""构造 SSH 命令:ssh -p <端口> -i <key 文件> + 钉死选项
|
||
+ user@<ssh_host> + 客内命令。
|
||
|
||
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 同一信任边界),执行器绝不把完整命令记入日志。
|
||
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
|
||
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
|
||
|
||
|
||
def parse_keyscan_output(text, host="127.0.0.1"):
|
||
"""从 ssh-keyscan 的 stdout 里挑出目标主机的 host key
|
||
行:形如 "[<host>]:<port> ssh-ed25519 AAAA..."(-p 时
|
||
的标准写法)或 "<host> ssh-rsa AAAA..."。其它主机行、
|
||
注释行、空行一律丢掉,绝不把陌生主机的 key 写进文件。
|
||
host 缺省回环仅为旧调用点兼容;客机 IP 方案下应传
|
||
解析出的客机 IP。"""
|
||
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
|
||
line_host, keytype = parts[0], parts[1]
|
||
if line_host != host \
|
||
and not line_host.startswith("[%s]:" % host):
|
||
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)
|