Files
codex-app-server/cloud/chv/hostkeys.py
T
host d7cc482ed5 feat(chv-guest): seed 附加项供客机用户与静态网络(wake 路径前置)
wake_vm 在 WSL 台架上跑通需要客机身份与网络:镜像本身不建
用户、tap 无 DHCP,缺 users 块的 VM 登不进去、缺 network-config
的 VM 拿不到静态 IP。最小补法(复用 write_seed 机制):
- hostkeys.write_seed 新增 user_data_extra(原样追加进
  user-data,供 users: 块)与 network_config(写 seed
  network-config 文件,0600);附加项含 runcmd: 即 ValueError
- config _seed_fragment:CHV_SEED_USER_DATA_EXTRA_FILE /
  CHV_SEED_NETWORK_CONFIG_FILE(只存路径)
- executor._seed_extras 在 wake 起 VMM 前读入,缺失即
  ChvFailure seed_extra_missing;provision_host_key 透传
- README §7 残留更新:guest-env 集成已做
- 测试:test_chv_gitea_sync 新增 5 项(写盘/禁 runcmd/
  配置映射/wake 带附加项/缺失 fail-closed)
2026-11-06 16:58:27 +00:00

304 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""按 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
# 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"
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]',known_hosts
行形如 '[<host>]:<port> ssh-ed25519 AAAA...'(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 公钥格式非法")
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 _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")
with open(net_path, "w") as fh:
fh.write(network_config)
os.chmod(net_path, 0o600)
return user_data
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) -> 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。两者皆空时不写该条目。
user_data_extra / network_config 见 _apply_seed_extras
(客机用户与静态网络的供给,皆空即不写)。"""
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)
user_data = build_seed_user_data(private_key_text, public_key_text)
merged_env = dict(guest_env or {})
merged_env.update(secret_env or {})
if merged_env:
lines = _guest_env_entry(merged_env)
# 条目插在 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)
user_data = head + "\n".join(lines) + "\n" + marker + tail
else:
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)
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