fix(chv): restore 路径补 H7 net 校验+登记,seed 暂存文件走 atomic_file
restore_vm 此前既不校验 net 冲突、也不登记 net.json: 已登记 VM 的 tap/mac/ip 会被恢复出的 VM 静默复用,且 恢复出的 VM 自身不登记、后来的 wake 也看不到它,两边 都漏判。现与 wake 同款:_validate_restore 在起 VMM 前 校验冲突(net_tap_conflict + vm_failed 审计), _restore_boot 在 restore 成功后登记 net.json,登记失败 审计 net_registry_write_failed + 拒绝恢复(P2-19 语义, stop_vm 清半恢复 VM)。 hostkeys.write_seed/_apply_seed_extras 的 meta-data、 user-data、network-config 改走 atomic_file(mkstemp 0600 → 写 → fsync → rename):user-data 含 host 私钥明文, 直接写 + 事后 chmod 会在 chmod 前以 umask 权限短暂可读; 半截文件本身到不了 ISO(唯一消费者 genisoimage 只在 write_seed 返回后跑),原子写同时堵住权限窗口与半截残留。 回归测试 6 个:restore 冲突拒起/恢复后登记/登记失败 拒起/无 net_json 不登记;seed 走原子写(0600、无 tmp 残留)/写失败不留半截文件。CHV 全套件 395 测试通过。
This commit is contained in:
1 parent
f448ecb74b
commit
06c3e172fe
5 files changed
+211
-18
No files matched your search
+42
-7
@@ -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)
|
||||
|
||||
+15
-10
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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"})
|
||||
|
||||
@@ -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 不应被
|
||||
# 当成生产路径:配置值本身可解析,且文档/代码里标弱。
|
||||
|
||||
@@ -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(每项有修复或有理由的结论)
|
||||
|
||||
|
||||
Reference in new issue
Block a user