merge(chv-muse-round11): 合入 P3 authd 替身凭证 + Sentinel 出口审批 (a90cf78)

冲突解决(3 处,均为并集保留,双方改动无一丢弃):
- cloud/chv/executor.py _copy_config_fields:P1 的 balloon/
  vsock/vsock_cid 与 P3 的 sentinel_allow_hosts 合并进同一元组
- cloud/tests/test_chv_arch_maint_fe.py EXPECTED_FIELDS:P1 三字段
  + P3 sentinel_allow_hosts;len 断言 41/39 → 42(并集)
- cloud/chv/config.py 自动合入:P3 sentinel_allow_hosts(网络
  片段内)+ P1 _balloon_vsock_fragment 均保留;仅更新
  build_chv_config docstring 注明字段集含 P3 字段
This commit is contained in:
host committed 2026-11-06 21:36:56 +00:00
commit 304707b372
15 files changed
+2376 -95

No files matched your search

+223 -31
View File
@@ -289,6 +289,7 @@ class CloudHypervisorExecutor(Executor):
disk_gb: float
egress_proxy: str | None
allow_direct_egress: bool
sentinel_allow_hosts: tuple
task_timeout: float | None
snapshot_url: str | None
warm_pool_size: int
@@ -316,6 +317,8 @@ class CloudHypervisorExecutor(Executor):
audit_failure_count: int
_vms: dict
_guest_env_delivered: dict
_authd_delivered: dict
sentinel_resolver: "Callable[..., object] | None"
def __init__(self, binary: str | None = None,
kernel: str | None = None,
@@ -342,7 +345,9 @@ class CloudHypervisorExecutor(Executor):
api_timeout: float | None = None,
ready_timeout: float | None = None,
ready_interval: float | None = None,
host_key_mode: str | None = None) -> None:
host_key_mode: str | None = None,
sentinel_resolver: Callable[..., object] | None = None
) -> None:
self._env: dict = env if env is not None else os.environ # type: ignore[assignment]
# 配置解析已拆到 cloud/chv/config.py(参数优先于环境
# 变量,默认值与 #216 一致;显式空用户/越界端口的收紧
@@ -355,13 +360,15 @@ class CloudHypervisorExecutor(Executor):
"ssh_user": ssh_user, "ssh_port": ssh_port,
"guest_host_py": guest_host_py, "cmdline": cmdline,
"net": net, "net_json": net_json, "ssh_host": ssh_host,
"kvm_path": kvm_path,
"cpuinfo_path": cpuinfo_path, "ch_remote": ch_remote,
"model": model, "api_timeout": api_timeout,
"ready_timeout": ready_timeout,
"kvm_path": kvm_path, "cpuinfo_path": cpuinfo_path,
"ch_remote": ch_remote, "model": model,
"api_timeout": api_timeout, "ready_timeout": ready_timeout,
"ready_interval": ready_interval,
"host_key_mode": host_key_mode}, self._env)
self._copy_config_fields(_cfg)
# feat-chv-muse-p3:Sentinel 解析器可注入(测试用假解析器
# 测 SSRF 挡截;不给时 evaluate 用 socket.getaddrinfo)。
self.sentinel_resolver = sentinel_resolver
self._init_runtime(runner, launcher, api_client, sleeper)
def _init_runtime(self, runner: Callable[..., object] | None,
@@ -380,6 +387,7 @@ class CloudHypervisorExecutor(Executor):
# 名 source 客内文件,未投递的密钥名一律拒绝(防止
# “wake 没投递、执行时却按名 source 一个旧文件”)。
self._guest_env_delivered = {}
self._init_authd_runtime()
# feat-chv-closeup(P2-20):_guest_env_delivered 是
# 跨 wake/build_ssh_command/stop 的共享字典,wake 与
# 执行可并发在不同线程——此前无锁读写,set/get/pop
@@ -409,6 +417,19 @@ class CloudHypervisorExecutor(Executor):
self.launcher = _default_popen_launcher
self._vms = {}
def _init_authd_runtime(self) -> None:
"""feat-chv-muse-p3:authd 投递集与 Sentinel 待审批队列。
_authd_delivered 是本实例已走 authd 通道投递密钥的 VM
名 → 已投递密钥名集合(与 _guest_env_delivered 同锁、
同款语义,唯一区别:authd 投递的密钥名在 build_ssh_command
里不报错、但绝不 source 进 codex 进程环境——它只给客内
authd 守护进程,任务进程里永不出现)。_sentinel_queue
是 Sentinel ask 待审批队列(内存态,进程重启即清;
ask 本来就意味着这次执行已停止)。"""
self._authd_delivered = {}
from cloud.chv.sentinel import ApprovalQueue
self._sentinel_queue = ApprovalQueue()
def _delivered_secrets(self, name: str) -> set:
"""P2-20 唯一读入口:该 VM 已 seed 投递的密钥名集合。"""
with self._guest_env_lock:
@@ -428,6 +449,26 @@ class CloudHypervisorExecutor(Executor):
with self._guest_env_lock:
self._guest_env_delivered.pop(name, None)
def _delivered_authd_secrets(self, name: str) -> set:
"""feat-chv-muse-p3:该 VM 已走 authd 通道投递的密钥名
集合(与 _delivered_secrets 同款读入口)。"""
with self._guest_env_lock:
return set(self._authd_delivered.get(name, set()))
def _mark_authd_delivered(self, name: str, secrets) -> None:
"""feat-chv-muse-p3:记下该 VM 已走 authd 通道投递的
密钥名集合(secrets 为空即清记录,与旧语义一致)。"""
with self._guest_env_lock:
if secrets:
self._authd_delivered[name] = set(secrets)
else:
self._authd_delivered.pop(name, None)
def _clear_authd_delivered(self, name: str) -> None:
"""feat-chv-muse-p3:stop 时清掉该 VM 的 authd 投递记录。"""
with self._guest_env_lock:
self._authd_delivered.pop(name, None)
def _copy_config_fields(self, _cfg) -> None:
"""把 build_chv_config 的字段抄到 self(纯移动,字段集不变)。"""
for _field in (
@@ -445,7 +486,7 @@ class CloudHypervisorExecutor(Executor):
"snapshot_dir", "allow_remote_snapshot",
"gitea_sync_env", "seed_user_data_extra_file",
"seed_network_config_file", "balloon", "vsock",
"vsock_cid"):
"vsock_cid", "sentinel_allow_hosts"):
setattr(self, _field, getattr(_cfg, _field))
# -- 名字 / 路径 ---------------------------------------------------
@@ -882,7 +923,8 @@ class CloudHypervisorExecutor(Executor):
def provision_host_key(self, task: dict, secret_env: dict | None = None,
guest_env: dict | None = None,
user_data_extra: str | None = None,
network_config: str | None = None) -> dict:
network_config: str | None = None,
authd_env: dict | None = None) -> dict:
"""按 VM 预置 host key(补缺口轨强方案,见 hostkeys.py):
启动前为该 VM 生成一次性 ed25519 host key 对,公钥
立即写进该 VM 的 known_hosts(钉死),私钥只写进
@@ -901,17 +943,11 @@ class CloudHypervisorExecutor(Executor):
# to10:known_hosts 写入已抽到 _write_provisioned_known_hosts,
# 此处只留生成与 seed 写入的 import(删后不再有未用 import)。
from cloud.chv.hostkeys import (
dirty_marker_path,
generate_host_keypair,
write_seed,
)
vm = self.vm_name(task)
if os.path.exists(dirty_marker_path(self.state_dir, vm)):
raise ChvFailure(
"vm_dirty_refused",
"VM %s 上次清理失败(脏标记存在),拒绝复用旧 "
"host key 启动:请人工清理 state_dir 后删脏标记"
% vm)
self._refuse_dirty_vm(vm)
paths = generate_host_keypair(
self.state_dir, vm,
runner=lambda cmd: self._call_runner(cmd))
@@ -922,13 +958,25 @@ class CloudHypervisorExecutor(Executor):
secret_env=secret_env,
guest_env=guest_env,
user_data_extra=user_data_extra,
network_config=network_config)
network_config=network_config,
authd_env=authd_env)
self._emit("host_key_generated", task,
"vm=%s secret_names=%s"
% (vm, ",".join(sorted(secret_env or {}))))
return {"paths": paths, "seed_dir": seed_dir,
"known_hosts": paths["known_hosts"]}
def _refuse_dirty_vm(self, vm: str) -> None:
"""上次清理失败留下的脏标记存在时直接拒绝(清理失败
即拒起,不再复用旧 key 对)。"""
from cloud.chv.hostkeys import dirty_marker_path
if os.path.exists(dirty_marker_path(self.state_dir, vm)):
raise ChvFailure(
"vm_dirty_refused",
"VM %s 上次清理失败(脏标记存在),拒绝复用旧 "
"host key 启动:请人工清理 state_dir 后删脏标记"
% vm)
def capture_host_key(self, task: dict) -> bool:
"""首次捕获客机 host key(TOFU,弱回落,边界见
ssh_transport.py 与 hostkeys.py):ssh-keyscan 经本机
@@ -988,25 +1036,42 @@ class CloudHypervisorExecutor(Executor):
# 构造兼容,wake/wait 路径仍严格解析。
ssh_host = "127.0.0.1"
secrets, plain = split_secret_env(extra_env)
guest_env_file = None
if secrets:
from cloud.chv.hostkeys import GUEST_ENV_PATH
delivered = self._delivered_secrets(self.vm_name(task))
missing: list = [name for name in secrets if name not in delivered]
if missing:
raise ChvFailure(
"secret_env_not_delivered",
"密钥环境变量 %s 未经 seed 投递,拒绝进 ssh "
"argv:provisioned 模式下 wake 时投递,"
"keyscan 模式不支持密钥投递"
% ",".join(sorted(missing)))
guest_env_file = GUEST_ENV_PATH
guest_env_file = self._resolve_secret_env_file(task, secrets)
return _build_ssh_cmd(
self.ssh_port, self.ssh_key, self.ssh_user,
self.egress_proxy, self.known_hosts_path(task),
inner_cmd, ssh_host=ssh_host, extra_env=plain or None,
guest_env_file=guest_env_file)
def _resolve_secret_env_file(self, task: dict, secrets) -> str | None:
"""feat-chv-muse-p3:密钥分两路查投递,返回远程命令
该 source 的客内密钥文件(无 guest-env 路密钥即 None)。
authd 路的(GITEA_TOKEN)只认“已投递、不报错”,绝不
source 进 codex 进程环境;guest-env 路的才 source
guest-env 文件。两路任一路未投递都 fail-closed(同款 kind)。"""
if not secrets:
return None
from cloud.chv.gitea_sync import split_authd_env
from cloud.chv.hostkeys import GUEST_ENV_PATH
authd_secrets, guest_secrets = split_authd_env(secrets)
name = self.vm_name(task)
missing: list = [
n for n in authd_secrets
if n not in self._delivered_authd_secrets(name)]
missing += [
n for n in guest_secrets
if n not in self._delivered_secrets(name)]
if missing:
raise ChvFailure(
"secret_env_not_delivered",
"密钥环境变量 %s 未经 seed 投递,拒绝进 ssh "
"argv:provisioned 模式下 wake 时投递,"
"keyscan 模式不支持密钥投递"
% ",".join(sorted(missing)))
if guest_secrets:
return GUEST_ENV_PATH
return None
# -- API 模式:VmConfig / API 调用 / 就绪轮询 -----------------------
def _vm_config_disks(self, task_disk: str | None,
seed_disk: str | None) -> list:
@@ -1522,6 +1587,107 @@ class CloudHypervisorExecutor(Executor):
merged.update(sync)
return merged
def _sentinel_gate_gitea(self, task: dict,
extra_env: dict | None) -> None:
"""feat-chv-muse-p3:wake 时的 Gitea 出口 Sentinel 预审。
同步已请求(extra_env 里有 GITEA_URL)时才评估;无同步
变量即跳过(本次启动不同步,无出口可审)。评估目标:
GET https://<Gitea 主机>/api/v1/version。
- allow → 审计 sentinel_egress_allow,通过;
- deny → 审计 + ChvFailure(sentinel_egress_denied),
起 VMM 前 fail-closed;
- ask → 记入待审批队列 + 审计 sentinel_egress_ask,
同样 fail-closed:wake 等不了审批 UI,ask 在这里就是
deny,须运维把主机加进 CHV_SENTINEL_ALLOW_HOSTS 再起。
白名单来源:CHV_SENTINEL_ALLOW_HOSTS + 运维配置的 Gitea
主机(CHV_GITEA_URL 的主机,运维可信);任务级覆盖改了
GITEA_URL 主机时不自动放行——改到未知主机就是 ask。
SSRF 检查对白名单主机同样生效(DNS 可被投毒,白名单主机
解析到私网 IP 仍 deny)。审计 detail 只含 VM 名/主机/
verdict,不含任何凭证值。"""
sync_url = (extra_env or {}).get("GITEA_URL")
if not sync_url:
return
name = self.vm_name(task)
host, scheme, port = self._gate_gitea_target(task, name, sync_url)
self._gate_gitea_apply(task, name, host, scheme, port)
def _gate_gitea_target(self, task: dict, name: str,
sync_url: str) -> tuple:
"""解析 Gitea 出口预审目标(主机/协议/端口);主机解析
不出即审计 + fail-closed。"""
from urllib.parse import urlparse
parsed = urlparse(sync_url)
host = (parsed.hostname or "").lower()
scheme = (parsed.scheme or "").lower()
port = parsed.port or (443 if scheme == "https" else 80)
if not host:
self._emit("sentinel_egress_denied", task,
"vm=%s gitea_url_invalid" % name)
raise ChvFailure(
"sentinel_egress_denied",
"Gitea URL 主机解析不出,拒绝启动")
return host, scheme, port
def _gate_gitea_policy(self, host: str):
"""预审策略:白名单来源 CHV_SENTINEL_ALLOW_HOSTS +
运维配置的 Gitea 主机(CHV_GITEA_URL 的主机,运维可信);
任务级覆盖改了 GITEA_URL 主机时不自动放行——改到未知
主机就是 ask。"""
from urllib.parse import urlparse
from cloud.chv.sentinel import SentinelPolicy
cfg_sync = getattr(self, "gitea_sync_env", None) or {}
cfg_host = ""
cfg_url = cfg_sync.get("GITEA_URL")
if cfg_url:
cfg_host = (urlparse(cfg_url).hostname or "").lower()
extra_allow = {host} if host and host == cfg_host else set()
return SentinelPolicy.from_env(
self._env, extra_allow_hosts=extra_allow)
def _gate_gitea_apply(self, task: dict, name: str, host: str,
scheme: str, port: int) -> None:
"""对预审目标做 Sentinel 评估并按 verdict 处理:
allow → 审计通过;ask → 记入待审批队列 + 审计,同样
fail-closed(wake 等不了审批 UI,ask 在这里就是 deny,
须运维把主机加进 CHV_SENTINEL_ALLOW_HOSTS 再起);
deny → 审计 + ChvFailure,起 VMM 前 fail-closed。
SSRF 检查对白名单主机同样生效(DNS 可被投毒,白名单
主机解析到私网 IP 仍 deny)。审计 detail 只含 VM 名/
主机/verdict,不含任何凭证值。"""
from cloud.chv.sentinel import EgressRequest
policy = self._gate_gitea_policy(host)
request = EgressRequest("gitea", "GET", scheme, host, port,
"/api/v1/version")
decision = policy.evaluate(
request, resolve=getattr(self, "sentinel_resolver", None))
if decision.verdict == "allow":
capability = decision.capability
assert capability is not None # allow 恒带严格能力
self._emit("sentinel_egress_allow", task,
"vm=%s host=%s capability=%s"
% (name, host, capability.id))
return
if decision.verdict == "ask":
pending_id = self._sentinel_queue.record(
request, decision.reasons)
self._emit("sentinel_egress_ask", task,
"vm=%s host=%s pending=%s"
% (name, host, pending_id))
raise ChvFailure(
"sentinel_egress_denied",
"Gitea 主机 %s 不在 Sentinel 白名单内(待审批 %s),"
"拒绝启动:请运维加进 CHV_SENTINEL_ALLOW_HOSTS"
% (host, pending_id))
self._emit("sentinel_egress_denied", task,
"vm=%s host=%s reasons=%s"
% (name, host, ",".join(decision.reasons)))
raise ChvFailure(
"sentinel_egress_denied",
"Gitea 出口被 Sentinel 拒绝(%s),拒绝启动"
% ",".join(decision.reasons))
def _wake_prechecks(self, task: dict, name: str, extra_env) -> tuple:
"""wake 前置:preflight/容量/配额/net/密钥模式,纯移动。
失败时发 vm_failed 审计并原样抛 ChvFailure。"""
@@ -1553,6 +1719,9 @@ class CloudHypervisorExecutor(Executor):
"provisioned 模式支持(当前 %s)"
% (",".join(sorted(secrets)),
getattr(self, "host_key_mode", "provisioned")))
# feat-chv-muse-p3:Gitea 出口 Sentinel 预审(起 VMM
# 前 fail-closed,见 _sentinel_gate_gitea)。
self._sentinel_gate_gitea(task, extra_env)
except ChvFailure as exc:
self._emit("vm_failed", task,
"vm=%s wake 前置失败 kind=%s" % (name, exc.kind),
@@ -1560,6 +1729,20 @@ class CloudHypervisorExecutor(Executor):
raise
return secrets, net_entries
def _split_seed_authd_env(self, secrets,
guest_env: dict | None) -> tuple:
"""feat-chv-muse-p3:seed 密钥集先拆通道——GITEA_TOKEN
走 authd(独立的 authd-seed 文件,只给客内 authd 读),
其余密钥仍走 guest-env。guest_env(split_guest_env 拼
出来的)里也含 GITEA_TOKEN:一并拆出来(与 secrets 拆
出的合并,secrets 的值优先),否则 write_seed 会拒收。
返回 (authd_env, guest_secrets, guest_env)。"""
from cloud.chv.gitea_sync import split_authd_env
authd_env, guest_secrets = split_authd_env(secrets)
dup_authd, guest_env = split_authd_env(guest_env)
dup_authd.update(authd_env)
return dup_authd, guest_secrets, guest_env
def _provision_seed_disk(self, task: dict, name: str, secrets,
guest_env: dict | None = None,
seed_extras: tuple | None = None) -> str | None:
@@ -1585,16 +1768,24 @@ class CloudHypervisorExecutor(Executor):
seed_disk_path,
)
extra, netcfg = seed_extras or (None, None)
authd_env, guest_secrets, guest_env = \
self._split_seed_authd_env(secrets, guest_env)
provisioned = self.provision_host_key(
task, secret_env=secrets or None,
task, secret_env=guest_secrets or None,
guest_env=guest_env or None,
user_data_extra=extra, network_config=netcfg)
user_data_extra=extra, network_config=netcfg,
authd_env=authd_env or None)
seed_disk = seed_disk_path(self.state_dir, name)
build_seed_disk(
provisioned["seed_dir"], seed_disk,
runner=lambda cmd: self._call_runner(cmd))
delete_host_private_key(self.state_dir, name)
self._mark_guest_env_delivered(name, secrets)
# 两路投递记录分开记:guest-env 路的进
# _guest_env_delivered(任务期可 source),authd 路的进
# _authd_delivered(任务期只认“已投递、不报错”,绝不
# source 进 codex 环境)。
self._mark_guest_env_delivered(name, guest_secrets)
self._mark_authd_delivered(name, authd_env)
return seed_disk
def _boot_vm(self, task, name, task_disk, seed_disk,
@@ -1951,6 +2142,7 @@ class CloudHypervisorExecutor(Executor):
self._emit("vm_net_registry_delete_failed", task,
"vm=%s error=%s" % (name, _net_exc))
self._clear_guest_env_delivered(name)
self._clear_authd_delivered(name)
return failed
def _write_dirty_marker(self, name, failed):