diff --git a/cloud/chv/executor.py b/cloud/chv/executor.py index 62dc20d..07d37f1 100644 --- a/cloud/chv/executor.py +++ b/cloud/chv/executor.py @@ -1695,14 +1695,22 @@ class CloudHypervisorExecutor(Executor): return snapshot_url def _validate_restore(self, task, snapshot_url): - """restore 前置校验:返回归一化后的 snapshot_url。 + """restore 前置校验:返回(归一化后的 snapshot_url, + 归一化后的 net 条目)。 restore 不走 vm.create、没有 VmConfig 可挂 seed: 快照内已固化原 VM host key,重预置反而破坏钉死, 故 provisioned 下复用已钉死的 known_hosts、缺失 fail-fast、绝不 keyscan、绝不重生成。restore 时不 重复验快照文件存在(远端/对象存储源合法,ch-remote - 本身会在源缺失时失败)。""" + 本身会在源缺失时失败)。 + feat-chv-cover100:restore 也走 H7 net 隔离——此前 + restore 路径既不校验冲突、也不登记 net.json:已登 + 记 VM 的 tap/mac/ip 会被恢复出的 VM 静默复用,且 + 恢复出的 VM 自身不登记、后来的 wake 也看不到它, + 两边都漏判。与 wake 的 _wake_prechecks 同款:冲突 + 在起 VMM 前拒绝(net_tap_conflict),登记失败见 + _restore_boot(P2-19 语义)。""" if not isinstance(task, dict) or task.get("id") is None: raise ValueError("任务缺少 id") if not snapshot_url: @@ -1720,9 +1728,23 @@ class CloudHypervisorExecutor(Executor): "host_key_not_pinned", "provisioned 模式下 restore 缺 known_hosts,拒绝 " "keyscan 回落:VM %s" % self.vm_name(task)) - return snapshot_url + net_entries = [] + if not self.net_json: + return snapshot_url, net_entries + try: + net_entries = validate_net_entries( + parse_net_entries(self.net_json), + self._registered_net_entries( + exclude_vm=self.vm_name(task))) + except ChvFailure as exc: + self._emit("vm_failed", task, + "vm=%s restore 前置失败 kind=%s" + % (self.vm_name(task), exc.kind), + exc.kind) + raise + return snapshot_url, net_entries - def _restore_boot(self, task, snapshot_url): + def _restore_boot(self, task, snapshot_url, net_entries): """restore 的启动段:起 VMM → 等 ping → restore → 显式 resume → 等 SSH 就绪(恢复后默认 paused,不 resume 就不算醒)。""" @@ -1732,6 +1754,16 @@ class CloudHypervisorExecutor(Executor): self._check_api_socket_permissions(self.api_socket(task)) self._ch_remote(task, "restore", "source_url=%s" % snapshot_url) + # feat-chv-cover100:与 _boot_vm 同款——恢复成功后登记 + # net.json,登记失败即拒恢复(P2-19 语义:审计 + + # 返回 False + 调用方 ChvFailure);restore_vm 的 + # except 会 stop_vm 清掉半恢复 VM。 + if net_entries and not self._record_net_entries( + name, net_entries): + raise ChvFailure( + "net_registry_write_failed", + "vm=%s net 登记写失败,拒绝恢复(缺登记会" + "漏判 tap/mac/ip 冲突)" % name) self._emit("vm_restore", task, "vm=%s snapshot=%s" % (name, snapshot_url)) self.resume_vm(task) @@ -1742,12 +1774,15 @@ class CloudHypervisorExecutor(Executor): resume → 等 SSH 就绪。恢复后默认 paused,不 resume 就不算醒。任一步失败都关 VM 再抛;成功返回 {vm, api_socket, state: "running", ready: True, - mode: "restored"}。""" - snapshot_url = self._validate_restore(task, snapshot_url) + mode: "restored"}。恢复前校验 net 冲突、恢复成功后 + 登记 net.json(与 wake 的 H7/P2-19 同款,见 + _validate_restore/_restore_boot)。""" + snapshot_url, net_entries = self._validate_restore( + task, snapshot_url) name = self.vm_name(task) self._check_capacity(exclude=name) try: - self._restore_boot(task, snapshot_url) + self._restore_boot(task, snapshot_url, net_entries) except Exception: try: self.stop_vm(task) diff --git a/cloud/chv/hostkeys.py b/cloud/chv/hostkeys.py index b118857..7ab8727 100644 --- a/cloud/chv/hostkeys.py +++ b/cloud/chv/hostkeys.py @@ -34,6 +34,8 @@ import os import shlex from collections.abc import Callable +from cloud.chv.atomic_file import atomic_write_text + # seed 投递的客内密钥环境文件路径(feat-chv-secfix,H2): # 只在 provisioned 模式的 seed user-data 里写出,0600 root, # 客内 host.py 命令执行前由远程命令 source 进环境;宿主侧 @@ -218,9 +220,10 @@ def _apply_seed_extras(seed_dir: str, user_data: str, user_data += "\n" if network_config: net_path = os.path.join(seed_dir, "network-config") - with open(net_path, "w") as fh: - fh.write(network_config) - os.chmod(net_path, 0o600) + # feat-chv-cover100:走 atomic_file(与 user-data 同款 + # 理由,见 write_seed)——直接写 + 事后 chmod 会在 + # chmod 前以 umask 权限短暂可读。 + atomic_write_text(net_path, network_config) return user_data @@ -245,10 +248,14 @@ def write_seed(seed_dir: str, vm_name: str, private_key_text: str, 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") - with open(meta_path, "w") as fh: - fh.write("instance-id: %s\nlocal-hostname: %s\n" - % (vm_name, vm_name)) - os.chmod(meta_path, 0o600) + # feat-chv-cover100:seed 暂存文件走 atomic_file(mkstemp + # 0600 → 写 → fsync → rename):user-data 含 host 私钥 + # 明文,直接写 + 事后 chmod 会在 chmod 前以 umask 权限 + # 短暂可读;半截文件到不了 ISO(见 H5),原子写同时堵 + # 住权限窗口与半截残留。 + atomic_write_text( + meta_path, + "instance-id: %s\nlocal-hostname: %s\n" % (vm_name, vm_name)) user_data = build_seed_user_data(private_key_text, public_key_text) merged_env = dict(guest_env or {}) merged_env.update(secret_env or {}) @@ -266,9 +273,7 @@ def write_seed(seed_dir: str, vm_name: str, private_key_text: str, user_data = user_data + "\n".join(lines) + "\n" user_data = _apply_seed_extras(seed_dir, user_data, user_data_extra, network_config) - with open(user_path, "w") as fh: - fh.write(user_data) - os.chmod(user_path, 0o600) + atomic_write_text(user_path, user_data) return seed_dir diff --git a/cloud/tests/test_chv_closeup.py b/cloud/tests/test_chv_closeup.py index 9aae550..52b4741 100644 --- a/cloud/tests/test_chv_closeup.py +++ b/cloud/tests/test_chv_closeup.py @@ -312,6 +312,77 @@ class RestoreSnapshotTest(CloseupCase): out2 = ex.wake_from_hibernate(self._task(), url) self.assertEqual(out2["mode"], "restored") + def _register_other_vm_net(self, vm="chv-other"): + vm_dir = os.path.join(self.state_dir, vm) + os.makedirs(vm_dir, exist_ok=True) + with open(os.path.join(vm_dir, "net.json"), "w") as fh: + json.dump([{"tap": "tap0", + "mac": "aa:bb:cc:dd:ee:01", + "ip": "192.168.10.10", + "mask": ""}], fh) + + def test_restore_net_conflict_refused_before_launch(self): + # feat-chv-cover100:restore 也须过 H7 冲突校验—— + # 此前 restore 路径不校验不登记,恢复出的 VM 会与 + # 已登记 VM 静默复用 tap/mac/ip,且自身不登记、后 + # 来的 wake 也看不到它,两边都漏判。 + url, _ = self._snap_url() + self._register_other_vm_net() + ex = self.make_exec(env_extra={ + "CHV_NET_JSON": '[{"tap": "tap0", ' + '"mac": "aa:bb:cc:dd:ee:02"}]'}) + with self.assertRaises(ChvFailure) as ctx: + ex.restore_vm(self._task(), url) + self.assertEqual(ctx.exception.kind, "net_tap_conflict") + self.assertIn("vm_failed", self._audit_events()) + self.assertEqual(self.launches, []) + self.assertFalse(os.path.exists( + os.path.join(self.state_dir, "chv-abc123", "net.json"))) + + def test_restore_registers_net_entries(self): + # feat-chv-cover100:恢复成功后登记 net.json,后来 + # 的 wake 才能看到恢复出的 VM 占用的 tap/mac/ip。 + url, _ = self._snap_url() + ex = self.make_exec(env_extra={ + "CHV_NET_JSON": '[{"tap": "tap0", ' + '"mac": "aa:bb:cc:dd:ee:01", ' + '"ip": "192.168.10.10"}]'}) + out = ex.restore_vm(self._task(), url) + self.assertEqual(out["mode"], "restored") + with open(os.path.join(self.state_dir, "chv-abc123", + "net.json")) as fh: + entries = json.load(fh) + self.assertEqual(entries[0]["tap"], "tap0") + self.assertEqual(entries[0]["mac"], "aa:bb:cc:dd:ee:01") + self.assertEqual(entries[0]["ip"], "192.168.10.10") + + def test_restore_net_register_failure_refuses(self): + # feat-chv-cover100:登记失败与 wake 同款审计+拒绝 + #(P2-19 语义),restore_vm 的 except 会 stop_vm + # 清掉半恢复 VM(shutdown 已发出)。 + url, _ = self._snap_url() + blocker = os.path.join(self.state_dir, "chv-abc123") + os.makedirs(self.state_dir, exist_ok=True) + with open(blocker, "w") as fh: + fh.write("x") + ex = self.make_exec(env_extra={ + "CHV_NET_JSON": '[{"tap": "tap0"}]'}) + with self.assertRaises(ChvFailure) as ctx: + ex.restore_vm(self._task(), url) + self.assertEqual(ctx.exception.kind, + "net_registry_write_failed") + self.assertIn("net_registry_write_failed", + self._audit_events()) + self.assertTrue(any(c[-1] == "shutdown" for c in self.calls)) + + def test_restore_without_net_json_writes_no_registry(self): + url, _ = self._snap_url() + ex = self.make_exec() + out = ex.restore_vm(self._task(), url) + self.assertEqual(out["mode"], "restored") + self.assertFalse(os.path.exists( + os.path.join(self.state_dir, "chv-abc123", "net.json"))) + def test_restore_provisioned_missing_known_hosts(self): url, _ = self._snap_url() ex = self.make_exec(env_extra={"CHV_HOST_KEY_MODE": "provisioned"}) diff --git a/cloud/tests/test_chv_gaps.py b/cloud/tests/test_chv_gaps.py index 88eb9b2..d2c2103 100644 --- a/cloud/tests/test_chv_gaps.py +++ b/cloud/tests/test_chv_gaps.py @@ -382,6 +382,74 @@ class HostKeyProvisionTest(ChvGapCase): # 条目内是设计内的,见 hostkeys.write_seed)。 self.assertIn("GITEA_TOKEN='s3cr3t v'", content) + def test_write_seed_uses_atomic_write(self): + # feat-chv-cover100:seed 暂存文件(user-data 含 host + # 私钥明文)此前直接 open("w") 再 chmod 0600——崩溃 + # 半截文件本身到不了 ISO(唯一消费者 genisoimage 只 + # 在 write_seed 返回后跑,失败重试会整文件重写), + # 但 chmod 之前文件以 umask 权限(常为 0644)可读, + # 同 uid 进程能在该窗口读到私钥;现走 atomic_file + #(mkstemp 0600 → 写 → fsync → rename),文件从 + # 创建起就是 0600,且写失败不留半截文件。 + import stat as _stat + from unittest import mock + + import cloud.chv.hostkeys as hostkeys_mod + from cloud.chv.atomic_file import atomic_write_text as _real + calls = [] + + def _spy(path, text, mode=0o600): + calls.append(path) + return _real(path, text, mode=mode) + + with (tempfile.TemporaryDirectory() as tmp, + mock.patch.object(hostkeys_mod, "atomic_write_text", + side_effect=_spy)): + seed_dir = os.path.join(tmp, "seed") + write_seed(seed_dir, "vm1", "PRIV", + "ssh-ed25519 AAAAx", + network_config="version: 2\n") + self.assertEqual( + sorted(os.path.basename(p) for p in calls), + ["meta-data", "network-config", "user-data"]) + for name in ("meta-data", "user-data", "network-config"): + mode = _stat.S_IMODE(os.stat( + os.path.join(seed_dir, name)).st_mode) + self.assertEqual(mode, 0o600, name) + leftovers = [n for n in os.listdir(seed_dir) + if n.startswith(".tmp-")] + self.assertEqual(leftovers, []) + + def test_write_seed_failure_leaves_no_partial_file(self): + # feat-chv-cover100:原子写的回归——写 user-data 中途 + # 失败时不留半截 user-data、不留 tmp 残留;旧的直接 + # 写会在崩溃后留半截文件(虽到不了 ISO,但含私钥明 + # 文的半截文件滞留 seed 目录本身即泄漏面)。 + from unittest import mock + + import cloud.chv.hostkeys as hostkeys_mod + real = hostkeys_mod.atomic_write_text + + def _fail_on_user_data(path, text, mode=0o600): + if path.endswith("user-data"): + raise OSError("disk boom") + return real(path, text, mode=mode) + + with (tempfile.TemporaryDirectory() as tmp, + mock.patch.object(hostkeys_mod, "atomic_write_text", + side_effect=_fail_on_user_data)): + seed_dir = os.path.join(tmp, "seed") + os.makedirs(seed_dir) + + with self.assertRaises(OSError): + write_seed(seed_dir, "vm1", "PRIV", + "ssh-ed25519 AAAAx") + self.assertFalse(os.path.exists( + os.path.join(seed_dir, "user-data"))) + leftovers = [n for n in os.listdir(seed_dir) + if n.startswith(".tmp-")] + self.assertEqual(leftovers, []) + def test_keyscan_is_weak_fallback_only(self): # provisioned 模式下 wake 前必须预置;keyscan 不应被 # 当成生产路径:配置值本身可解析,且文档/代码里标弱。 diff --git a/docs/chv-production-readiness.md b/docs/chv-production-readiness.md index e1157c0..bb4e6f2 100644 --- a/docs/chv-production-readiness.md +++ b/docs/chv-production-readiness.md @@ -317,6 +317,13 @@ 私钥在 seed 盘建好后即删(只留公钥 + known_hosts),删 失败 fail-fast(VM 尚未启动);私钥 chmod 失败不再静默; 启动/清理时 cleanup_stale_secrets 扫描残留私钥与 seed。 + seed 暂存文件(meta-data/user-data/network-config)走 + atomic_file(mkstemp 0600 → 写 → fsync → rename): + user-data 含 host 私钥明文,直接写 + 事后 chmod 会在 + chmod 前以 umask 权限短暂可读(同 uid 进程可读到私钥), + 原子写让文件从创建起就是 0600;崩溃半截文件本身到不了 + ISO(唯一消费者 genisoimage 只在 write_seed 返回后跑, + 失败重试整文件重写),原子写同时堵住该残留。 - H6 state_dir 出 /tmp:默认 ~/.local/share/codex-app-server/chv-vms,preflight 建目录 并保证 0700(失败即 state_dir_insecure);api-socket 组/ @@ -326,7 +333,14 @@ - H7 tap 隔离校验:NET_JSON 透传前校验 tap/mac/ip 跨 VM 不重复(wake 成功后登记 net.json,stop 时释放预约), 共用即拒绝并审计;东西向隔离的宿主防火墙部分见 §11.4, - 不写进代码假装已防。 + 不写进代码假装已防。restore_vm/wake_from_hibernate 同款 + (feat-chv-cover100 补齐:此前恢复路径既不校验冲突、也 + 不登记 net.json,恢复出的 VM 会与已登记 VM 静默复用 + tap/mac/ip,且自身不登记、后来的 wake 也看不到它,两边 + 都漏判):恢复前校验冲突(net_tap_conflict,起 VMM 前 + 拒绝并审计 vm_failed)、恢复成功后登记 net.json,登记 + 失败审计 net_registry_write_failed + 拒绝恢复(P2-19 + 语义,stop_vm 清掉半恢复 VM)。 ### 11.2 Medium(每项有修复或有理由的结论)