- 新测试 cloud/tests/test_chv_muse_p3.py 30 项:替身过期、真 token 泄露面、SSRF 挡截、deny/ask 路径、authd-seed 拆分、wake 预审、 客内 authd 守护进程、git credential helper 端到端(真 git fill) - 8 个旧测试按新 authd 行为改写(未删测试、未放宽断言): GITEA_TOKEN 改走 authd-seed,build_ssh_command 不再 source 进 codex 环境;wake 测试补 Sentinel 白名单 + 假解析器 - 7 处超 50 行函数拆到 50 行内(不改行为);补 authd/sentinel 公共 API 注解,mypy 在改动文件上干净 - 真问题修复:git 默认不把 path 发给 helper,chv-sync boot 加 credential.useHttpPath=true,否则 authd 路径每次都拒绝 - 全量 cloud 套件 1897 项 OK(skipped=8) 真机待验证(未做,等第三棒让出 WSL 测试机):见 docs/chv-muse-p3-gap.md §3.6 清单
398 lines
19 KiB
Python
398 lines
19 KiB
Python
"""按 VM 生成的 SSH host key 预置(替代 TOFU 的强方案)。
|
||
|
||
安全模型(补缺口轨,替代 #221 的 ssh-keyscan 首次捕获):
|
||
- 绝不用镜像固定同一把 host key:所有 VM 共用一把 key 时,
|
||
一台 VM 泄漏即全网可冒充,属安全事故。
|
||
- 每 VM 一次性生成:宿主在 VMM 启动前为该 VM 生成一对
|
||
ed25519 host key(私钥只活在该 VM 的 seed 里,随 VM
|
||
销毁而删,绝不跨 VM 复用、绝不写进镜像、绝不进已知
|
||
主机文件之外的共享位置)。
|
||
- 公钥预置钉死:生成后立即把公钥写进该 VM 的
|
||
state_dir/<vm>/known_hosts(格式 [127.0.0.1]:<port> ...),
|
||
执行器在 VM 启动前就已钉死,启动后 SSH 直接用该文件
|
||
校验,换 key 即拒绝——不再靠启动后 ssh-keyscan 抓到的
|
||
key 来“事后钉死”。
|
||
- 私钥注入通道(选 cloud-init NoCloud,写清唯一通道):
|
||
seed 目录 state_dir/<vm>/seed/ 含 meta-data(instance-id
|
||
即 VM 名)、user-data(write_files 写 /etc/ssh/ 的 host
|
||
key 对并重启 sshd)。客机镜像硬性要求(见
|
||
chv-guest/README.md §5):必须装 cloud-init 且支持
|
||
NoCloud seed、首启时先应用 seed 再启动 sshd、且不得在
|
||
seed 应用前/后无条件重新生成 host key(否则预置即失效)。
|
||
seed 磁盘/目录是 per-VM 敏感物,VM 关闭/回收时必须删。
|
||
- 回落边界:ssh-keyscan 首次捕获只作弱回落
|
||
(CHV_HOST_KEY_MODE=keyscan,且文档标为弱方案):
|
||
它只能防首次之后的替换,不能验证首次本身。生产部署
|
||
必须用 provisioned 模式;provisioned 模式下 seed 工具
|
||
或 keygen 不可用时直接 fail-fast,绝不静默回落 keyscan。
|
||
|
||
本模块只做纯函数与文件操作,不跑 VM、不联网、不含
|
||
任何私钥值(测试用假 runner 时私钥由测试自己写文件)。
|
||
"""
|
||
|
||
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 进环境;宿主侧
|
||
# 除 seed 目录/user-data(随 VM 销毁而删)外不另存该文件。
|
||
GUEST_ENV_PATH = "/etc/chv/guest-env"
|
||
|
||
# seed 投递的客内 authd 专用密钥文件路径(feat-chv-muse-p3):
|
||
# 只写 AUTHD_SECRET_KEYS 命中的密钥(本期只有 GITEA_TOKEN),
|
||
# 0600 root,只给客内 authd 守护进程读;codex 进程环境里永不
|
||
# 出现这些键(见执行器 _authd_delivered)。与 guest-env 分离
|
||
# 的理由:guest-env 会被远程命令 source 进任务进程环境,任何
|
||
# 进 guest-env 的密钥都会进 codex 环境;authd 密钥绝不能走
|
||
# 那条路。文件本身仍是 seed 写出的落盘文件(cloud-init 的唯一
|
||
# 投递通道就是写文件),真正让它对 agent 不可见的是 P2 的
|
||
# nspawn 单元隔离,见差距表 §3.1。
|
||
AUTHD_SEED_PATH = "/etc/chv/authd-seed"
|
||
|
||
|
||
def host_key_paths(state_dir: str, vm_name: str) -> dict:
|
||
"""该 VM 的 host key 文件路径(私钥/公钥/known_hosts)。"""
|
||
base = os.path.join(state_dir, vm_name)
|
||
return {
|
||
"private": os.path.join(base, "ssh_host_ed25519_key"),
|
||
"public": os.path.join(base, "ssh_host_ed25519_key.pub"),
|
||
"known_hosts": os.path.join(base, "known_hosts"),
|
||
"seed_dir": os.path.join(base, "seed"),
|
||
}
|
||
|
||
|
||
def dirty_marker_path(state_dir: str, vm_name: str) -> str:
|
||
"""清理失败脏标记路径(feat-chv-secfix,M2):stop_vm
|
||
清理 seed/私钥/磁盘任一步失败时写下该标记;标记存在
|
||
时同名 VM 拒绝启动(清理失败即拒起),人工清理并删
|
||
标记后才能再起——“已审计即可”不再当作根治。"""
|
||
return os.path.join(state_dir, vm_name, ".cleanup-dirty")
|
||
|
||
|
||
def delete_host_private_key(state_dir: str, vm_name: str) -> str:
|
||
"""删宿主侧 host 私钥文件(feat-chv-secfix,H5):seed
|
||
盘建好后宿主私钥已无存在必要(客机已有私钥、宿主只需
|
||
公钥 + known_hosts 做校验)。删失败即 OSError,由调用
|
||
方 fail-fast(VM 尚未启动,此时拒绝是最安全的)。"""
|
||
path = host_key_paths(state_dir, vm_name)["private"]
|
||
if os.path.exists(path):
|
||
os.remove(path)
|
||
return path
|
||
|
||
|
||
def known_hosts_line(port: int, public_key_text: str, host: str = "127.0.0.1") -> str:
|
||
"""公钥文件内容转 known_hosts 行:只取前两列(类型+数据)。
|
||
|
||
公钥文件形如 'ssh-ed25519 AAAA... [comment]'。22 端口
|
||
(OpenSSH 默认端口)时行形如 '<host> ssh-ed25519
|
||
AAAA...'(裸 host 名);非 22 端口时才用
|
||
'[<host>]:<port> ssh-ed25519 AAAA...'。OpenSSH 查
|
||
known_hosts 时,默认端口只查裸 host 名、非默认端口
|
||
只查括号形式,写错即永远匹配不上(2026-11-07 WSL
|
||
真机实测:provisioned 模式钉死 key 写成
|
||
'[192.168.10.10]:22',22 端口 ssh 报 Host key
|
||
verification failed;keyscan TOFU 输出裸行所以
|
||
lite9 没暴露)。host 缺省回环,仅作兼容;客机 IP
|
||
方案下 host 应为解析出的客机 IP。非法内容
|
||
ValueError,绝不把陌生行原样写进文件。"""
|
||
parts = (public_key_text or "").strip().split()
|
||
if len(parts) < 2 or not parts[0].startswith("ssh-"):
|
||
raise ValueError("host 公钥格式非法")
|
||
if int(port) == 22:
|
||
return "%s %s %s" % (host, parts[0], parts[1])
|
||
return "[%s]:%s %s %s" % (host, port, parts[0], parts[1])
|
||
|
||
|
||
def seed_disk_path(state_dir: str, vm_name: str) -> str:
|
||
"""该 VM 的 NoCloud seed 磁盘镜像路径(CIDATA 卷标 ISO)。"""
|
||
return os.path.join(state_dir, vm_name, "seed.iso")
|
||
|
||
|
||
def build_seed_disk(seed_dir: str, seed_disk: str,
|
||
runner: Callable[[list[str]], object] | None = None) -> str:
|
||
"""把 seed 目录做成 CIDATA 卷标 ISO(feat-chv-fix,P0-1 的
|
||
注入载体):genisoimage -volid CIDATA -joliet -rock。
|
||
|
||
runner 不给时用 subprocess;测试可注入假 runner(假
|
||
runner 负责把 seed_disk 文件写出来)。工具失败、返回码
|
||
非 0、或产物不存在/为空时一律 RuntimeError,调用方
|
||
(wake_vm provisioned 路径)fail-fast,绝不带着“seed
|
||
目录已写但未挂进 VmConfig”的 VM 继续启动。seed 目录
|
||
本身只是中间物,不再被当成注入通道。"""
|
||
cmd = ["genisoimage", "-output", seed_disk, "-volid", "CIDATA",
|
||
"-joliet", "-rock", seed_dir]
|
||
if runner is not None:
|
||
result = runner(cmd)
|
||
rc = getattr(result, "returncode", 0)
|
||
if rc not in (0, None):
|
||
raise RuntimeError("genisoimage 失败:returncode=%s" % rc)
|
||
else:
|
||
import subprocess
|
||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||
if result.returncode != 0:
|
||
raise RuntimeError("genisoimage 失败:%s" % result.stderr)
|
||
if not os.path.isfile(seed_disk) or os.path.getsize(seed_disk) == 0:
|
||
raise RuntimeError("seed 磁盘未生成:%s" % seed_disk)
|
||
# feat-chv-secfix(H5):seed.iso 内含 host 私钥明文
|
||
# user-data,生成即 0600,不再取 umask 默认 0644。
|
||
try:
|
||
os.chmod(seed_disk, 0o600)
|
||
except OSError as exc:
|
||
raise RuntimeError("seed 磁盘 chmod 0600 失败:%s" % exc)
|
||
return seed_disk
|
||
|
||
|
||
def build_seed_user_data(private_key_text: str, public_key_text: str) -> str:
|
||
"""cloud-init user-data:把 host key 对写进客机 /etc/ssh。
|
||
|
||
注意:user-data 本身含私钥,只写进 per-VM seed 目录
|
||
(0600),随 VM 销毁而删,绝不进镜像、绝不进日志、
|
||
绝不进任务 prompt 落库。
|
||
|
||
根因禁令(sync-test 残留,2026-11-06 实测):seed 的
|
||
user-data 里绝不能带 `runcmd:`。cloud-init 不跨配置
|
||
类型合并——user-data 的 `runcmd` 会整体替换掉镜像
|
||
`/etc/cloud/cloud.cfg.d/` 里的 `runcmd`(`merge_how`
|
||
只合并多个 user-data 段,救不了 conf.d,见 cloud-init
|
||
合并文档);旧版 seed 自带的 chmod + restart ssh 两行
|
||
就这样顶掉了镜像 `99-chv-sync.cfg` 的 `chv-sync boot`
|
||
钩子,首轮启动 clone 静默不跑。去掉的两行不需要
|
||
补在 seed 里:chmod 600 已由 write_files 的
|
||
permissions '0600' 保证;sshd 重启已搬到镜像自己的
|
||
runcmd(`99-chv-sync.cfg`,cloud-final 阶段,sshd
|
||
首次启动之后),那条是存活的列表,不会被 seed 覆盖。
|
||
任何人再给 seed 加 runcmd 即重犯此 bug,回归测试
|
||
`test_seed_user_data_has_no_runcmd` 会拦住。"""
|
||
if not private_key_text or not public_key_text:
|
||
raise ValueError("host key 对缺失,无法生成 seed")
|
||
lines = [
|
||
"#cloud-config",
|
||
"ssh_deletekeys: false",
|
||
"write_files:",
|
||
" - path: /etc/ssh/ssh_host_ed25519_key",
|
||
" permissions: '0600'",
|
||
" owner: root:root",
|
||
" content: |",
|
||
]
|
||
for line in private_key_text.rstrip("\n").split("\n"):
|
||
lines.append(" " + line)
|
||
lines += [
|
||
" - path: /etc/ssh/ssh_host_ed25519_key.pub",
|
||
" permissions: '0644'",
|
||
" owner: root:root",
|
||
" content: |",
|
||
]
|
||
for line in public_key_text.rstrip("\n").split("\n"):
|
||
lines.append(" " + line)
|
||
return "\n".join(lines) + "\n"
|
||
|
||
|
||
def _authd_env_entry(authd_env: dict) -> list:
|
||
"""authd-seed 的 write_files 条目行(0600 root,值经
|
||
shlex.quote 引用):只装 AUTHD_SECRET_KEYS 命中的密钥,
|
||
与 guest-env 条目并列写进同一 write_files 列表(绝不另写
|
||
一个 write_files: 键,见 _guest_env_entry 的 YAML 重复键
|
||
禁令)。"""
|
||
lines = [
|
||
" - path: %s" % AUTHD_SEED_PATH,
|
||
" permissions: '0600'",
|
||
" owner: root:root",
|
||
" content: |",
|
||
]
|
||
for key in sorted(authd_env):
|
||
lines.append(" %s=%s"
|
||
% (key, shlex.quote(str(authd_env[key]))))
|
||
return lines
|
||
|
||
|
||
def _guest_env_entry(merged_env: dict) -> list:
|
||
"""guest-env 的 write_files 条目行(0600 root,值经
|
||
shlex.quote 引用):只能并进已有的 write_files 列表,
|
||
绝不能再写一个 write_files: 键(YAML 重复键会覆盖掉
|
||
host key 的两条写文件条目,预置即失效)。"""
|
||
lines = [
|
||
" - path: %s" % GUEST_ENV_PATH,
|
||
" permissions: '0600'",
|
||
" owner: root:root",
|
||
" content: |",
|
||
]
|
||
for key in sorted(merged_env):
|
||
lines.append(" %s=%s"
|
||
% (key, shlex.quote(str(merged_env[key]))))
|
||
return lines
|
||
|
||
|
||
def _apply_seed_extras(seed_dir: str, user_data: str,
|
||
user_data_extra: str | None,
|
||
network_config: str | None) -> str:
|
||
"""seed 可选附加项(执行器 wake 路径的客机身份/网络供给):
|
||
user_data_extra 原样追加进 user-data(调用方用它供
|
||
`users:` 块——chv 用户 + authorized_keys,镜像本身不
|
||
建用户);network_config 写成 seed 目录的 network-config
|
||
文件(NoCloud 静态 IP,tap 无 DHCP 时客机才有地址)。
|
||
两者皆空即原样返回。user_data_extra 里含 runcmd: 行即
|
||
ValueError——seed 禁 runcmd 是 2026-11-06 根因禁令,
|
||
附加项也不得绕过(镜像的 99-chv-sync.cfg 靠它活着)。"""
|
||
if user_data_extra:
|
||
for line in user_data_extra.splitlines():
|
||
if line.strip().startswith("runcmd:"):
|
||
raise ValueError(
|
||
"seed 附加 user-data 里不得带 runcmd:")
|
||
if not user_data.endswith("\n"):
|
||
user_data += "\n"
|
||
user_data += user_data_extra
|
||
if not user_data.endswith("\n"):
|
||
user_data += "\n"
|
||
if network_config:
|
||
net_path = os.path.join(seed_dir, "network-config")
|
||
# feat-chv-cover100:走 atomic_file(与 user-data 同款
|
||
# 理由,见 write_seed)——直接写 + 事后 chmod 会在
|
||
# chmod 前以 umask 权限短暂可读。
|
||
atomic_write_text(net_path, network_config)
|
||
return user_data
|
||
|
||
|
||
def _validate_authd_env(authd_env: dict | None) -> None:
|
||
"""authd_env 的键必须全在 AUTHD_SECRET_KEYS 内(本期只有
|
||
GITEA_TOKEN),否则 ValueError——authd 通道只装 authd
|
||
密钥,别的密钥误进这里会绕过 guest-env 的 source 语义。"""
|
||
if not authd_env:
|
||
return
|
||
from cloud.chv.gitea_sync import AUTHD_SECRET_KEYS
|
||
unknown = sorted(set(authd_env) - AUTHD_SECRET_KEYS)
|
||
if unknown:
|
||
raise ValueError(
|
||
"authd 通道只接受 %s,拒绝:%s"
|
||
% (",".join(sorted(AUTHD_SECRET_KEYS)),
|
||
",".join(unknown)))
|
||
|
||
|
||
def _write_seed_meta(seed_dir: str, vm_name: str) -> str:
|
||
"""建 seed 目录并写 meta-data(0600),返回 user-data 路径。
|
||
feat-chv-cover100:seed 暂存文件走 atomic_file(mkstemp
|
||
0600 → 写 → fsync → rename):user-data 含 host 私钥
|
||
明文,直接写 + 事后 chmod 会在 chmod 前以 umask 权限
|
||
短暂可读;半截文件到不了 ISO(见 H5),原子写同时堵
|
||
住权限窗口与半截残留。"""
|
||
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")
|
||
atomic_write_text(
|
||
meta_path,
|
||
"instance-id: %s\nlocal-hostname: %s\n" % (vm_name, vm_name))
|
||
return user_path
|
||
|
||
|
||
def _seed_env_entries(merged_env: dict,
|
||
authd_env: dict | None) -> list:
|
||
"""guest-env 与 authd-seed 两路的 write_files 条目行
|
||
(皆空即空列表,不写条目)。"""
|
||
extra_lines = []
|
||
if merged_env:
|
||
extra_lines += _guest_env_entry(merged_env)
|
||
if authd_env:
|
||
extra_lines += _authd_env_entry(authd_env)
|
||
return extra_lines
|
||
|
||
|
||
def _insert_seed_entries(user_data: str, lines: list) -> str:
|
||
"""条目插在 runcmd 段之前、仍属同一 write_files 列表
|
||
(seed 自 2026-11-07 起不再带 runcmd,见
|
||
build_seed_user_data 的根因禁令;marker 分支只作
|
||
防御,将来模板若恢复 runcmd 仍插在其前)。"""
|
||
marker = "runcmd:\n"
|
||
if marker in user_data:
|
||
head, tail = user_data.split(marker, 1)
|
||
return head + "\n".join(lines) + "\n" + marker + tail
|
||
return user_data + "\n".join(lines) + "\n"
|
||
|
||
|
||
def write_seed(seed_dir: str, vm_name: str, private_key_text: str,
|
||
public_key_text: str,
|
||
secret_env: dict | None = None,
|
||
guest_env: dict | None = None,
|
||
user_data_extra: str | None = None,
|
||
network_config: str | None = None,
|
||
authd_env: dict | None = None) -> str:
|
||
"""写 seed 目录:meta-data + user-data(均为 0600),
|
||
返回 seed 目录路径。调用方负责随 VM 销毁删掉它。
|
||
|
||
secret_env 给定时写进客内 GUEST_ENV_PATH(0600 root,
|
||
远程命令 source 它得密钥环境,值不进宿主 argv/环境/
|
||
审计明文)。guest_env 给定时与 secret_env 合并写同一
|
||
个条目(secret 优先于同名键,防两行同名);guest_env
|
||
装 boot 时要用的非密钥同步变量(GITEA_URL 等,见
|
||
gitea_sync):只走 SSH 前缀到不了 cloud-init 时运行
|
||
的 chv-sync boot。两者皆空时不写该条目。
|
||
authd_env(feat-chv-muse-p3)给定时写进另一个独立条目
|
||
AUTHD_SEED_PATH(0600 root,只给客内 authd 守护进程读):
|
||
键必须全在 gitea_sync.AUTHD_SECRET_KEYS 内(本期只有
|
||
GITEA_TOKEN),否则 ValueError——authd 通道只装 authd
|
||
密钥,别的密钥误进这里会绕过 guest-env 的 source 语义。
|
||
user_data_extra / network_config 见 _apply_seed_extras
|
||
(客机用户与静态网络的供给,皆空即不写)。"""
|
||
_validate_authd_env(authd_env)
|
||
user_path = _write_seed_meta(seed_dir, 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 {})
|
||
# feat-chv-muse-p3:GITEA_TOKEN 绝不进 guest-env——它只能走
|
||
# authd_env(独立的 authd-seed 条目)。调用方(执行器)须先
|
||
# 用 gitea_sync.split_authd_env() 拆出来;这里再拦一次,
|
||
# 免得别的调用点把旧写法带进来、悄悄恢复泄露面。
|
||
if "GITEA_TOKEN" in merged_env:
|
||
raise ValueError(
|
||
"GITEA_TOKEN 须走 authd_env,不进 guest-env")
|
||
extra_lines = _seed_env_entries(merged_env, authd_env)
|
||
if extra_lines:
|
||
user_data = _insert_seed_entries(user_data, extra_lines)
|
||
user_data = _apply_seed_extras(seed_dir, user_data,
|
||
user_data_extra, network_config)
|
||
atomic_write_text(user_path, user_data)
|
||
return seed_dir
|
||
|
||
|
||
def generate_host_keypair(state_dir: str, vm_name: str,
|
||
runner: Callable[[list[str]], object] | None = None,
|
||
keygen: str = "ssh-keygen") -> dict:
|
||
"""为该 VM 生成(或复用本 VM 已有的)host key 对。
|
||
|
||
同一 VM 重启/恢复时复用已有对(否则钉死的 known_hosts
|
||
立即失效);不同 VM 的 state_dir/<vm> 不同,绝不共用。
|
||
runner 不给时用 subprocess,测试可注入假 runner(假
|
||
runner 负责把私钥/公钥文件写出来)。keygen 失败即
|
||
RuntimeError,调用方不得回落 keyscan(provisioned
|
||
模式)或必须显式标弱回落(keyscan 模式,见执行器)。"""
|
||
paths = host_key_paths(state_dir, vm_name)
|
||
if (os.path.exists(paths["private"])
|
||
and os.path.exists(paths["public"])):
|
||
return paths
|
||
base = os.path.dirname(paths["private"])
|
||
os.makedirs(base, exist_ok=True)
|
||
cmd = [keygen, "-t", "ed25519", "-N", "",
|
||
"-f", paths["private"]]
|
||
if runner is not None:
|
||
result = runner(cmd)
|
||
rc = getattr(result, "returncode", 0)
|
||
if rc not in (0, None):
|
||
raise RuntimeError("ssh-keygen 失败:returncode=%s" % rc)
|
||
else:
|
||
import subprocess
|
||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||
if result.returncode != 0:
|
||
raise RuntimeError("ssh-keygen 失败:%s" % result.stderr)
|
||
if not (os.path.exists(paths["private"])
|
||
and os.path.exists(paths["public"])):
|
||
raise RuntimeError("ssh-keygen 未生成 host key 对")
|
||
try:
|
||
os.chmod(paths["private"], 0o600)
|
||
except OSError as exc:
|
||
# feat-chv-secfix(H5):私钥 chmod 失败不再静默
|
||
# pass——权限不对的私钥文件绝不能留着继续用。
|
||
raise RuntimeError("host 私钥 chmod 0600 失败:%s" % exc)
|
||
return paths
|