"""Cloud Hypervisor 客机 SSH 传输(叶子模块,不依赖 cloud.sandbox,避免循环导入)。 安全模型(本轨的核心变化,替代 #216 的 StrictHostKeyChecking=no + UserKnownHostsFile=/dev/null): - 每 VM 一个独立 known_hosts 文件: state_dir//known_hosts(与每任务磁盘同目录, 任务销毁时不自动删——known_hosts 留在宿主 state_dir 里, 供事后审计该 VM 当时钉死的 key;VM 名本身已含任务 id, 不会串到别的 VM 上)。 - 首次信任(TOFU,边界必须写清,不假装是强验证): 首次启动时客机 host key 未知,执行器用 ssh-keyscan 经 宿主转发端口(127.0.0.1:,只走本机回环)抓一次 当时呈现的 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//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 + 钉死选项 + 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]: 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)