Files
codex-app-server/cloud/chv/ssh_transport.py
T
host 65504ae8cb fix(chv): V-3/F1/F2 真机缺陷修复 + V-1/V-4 附带修复
V-3 快照/restore:
- snapshot 改传裸 URL(source_url= 是 restore 的键,服务端
  Could not extract path from URL);快照产物是目录,校验
  改为目录非空,登记计量/剪枝同步支持目录
- restore 起 VMM 前补 _restore_prepare:杀旧 VMM(登记的 +
  未登记跨实例扫描)并清旧 socket,与 _wake_prepare 对等
- hibernate 评估结论:快照 config.json 引用原盘,旧 stop 删
  盘致 restore 必失败;hibernate 的 stop 改 keep_disk=True,
  GC 不删有登记快照的 VM 的盘,restore 前预检快照引用的盘
  (snapshot_disk_missing fail-fast)

F1:re-wake 删旧盘——H5 删私钥致 re-wake 必然生成新 key 对,
旧盘复用时 cloud-init 不重应用新 seed,sshd 仍呈旧 key 而
known_hosts 钉新 key;wake 本就是冷启动,旧盘不是要保留的状态

F2:stop_vm 跨实例按 socket 进程扫描杀未登记 VMM
(/proc cmdline 精确 socket + VMM 二进制名 + 跳过自身/pid 1,
绝不误杀 ch-remote 与他人 VMM)

V-4:remote_command source 失败改 || exit 126 fail-fast;
guest-env 条目 owner 改归 SSH 用户(0600 不变),root 仍 root:root

V-1 附带:build.sh 预建 /app/work(1777 粘滞位 + stat 断言);
host.py default_codex_bin() 改 shutil.which,裸名不再让
health 报 ok:false

测试:新 test_chv_v3_fixes.py 23 项;旧快照测试按目录产物改写;
test_chv* 458 全过,全量 cloud 套件 1930 全过(skipped=8)
2026-11-08 09:45:00 +00:00

262 lines
12 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.
"""Cloud Hypervisor 客机 SSH 传输(叶子模块,不依赖
cloud.sandbox,避免循环导入)。
安全模型(本轨的核心变化,替代 #216 的
StrictHostKeyChecking=no + UserKnownHostsFile=/dev/null):
- 每 VM 一个独立 known_hosts 文件:
state_dir/<vm 名>/known_hosts(与每任务磁盘同目录,
任务销毁时不自动删——known_hosts 留在宿主 state_dir 里,
供事后审计该 VM 当时钉死的 key;VM 名本身已含任务 id,
不会串到别的 VM 上)。
- 首次信任(TOFU,边界必须写清,不假装是强验证):
首次启动时客机 host key 未知,执行器用 ssh-keyscan 经
宿主转发端口(127.0.0.1:<port>,只走本机回环)抓一次
当时呈现的 key,写入该 VM 的 known_hosts 文件;此后
同一 VM 的每次 SSH 都用 StrictHostKeyChecking=yes +
该文件校验,VM 被替换 / 中间人换 key 时 SSH 直接拒绝,
不再静默接受。首次捕获本身不验证“这台客机就是我们要
的那台”——它只防首次之后的替换;更强的方案是镜像构建
/cloud-init 时把客机 host key 预置进 known_hosts(未来
工作,见 docs/chv-production-readiness.md 的待办),本轨
在 mock 测试里只能验证“捕获→写文件→后续命令带钉死
选项”的管线,绝不声称 mock 验证了首次密钥的真实性。
- 密钥红线不变且收紧(feat-chv-secfix,H2/M1):argv 里只有
key 文件路径,绝无 key 值;网关密钥类变量(见
_CHV_SECRET_ENV_NAMES,与 Docker 的 _DOCKER_SECRET_ENV_NAMES
同款“只传名”口径,但 CHV 更进一步:值连宿主子进程环境
都不进,改走 seed 盘里的客内文件,见 hostkeys.write_seed)
出现在 extra_env 里时本函数直接 ValueError——传输层
fail-closed,任何调用方都不能再把密钥值拼回 argv。
非密钥 extra_env 仍可走 env 前缀(值在 argv 里可见,
但按定义不是密钥)。
- 远程 shell 转义(feat-chv-secfix,M1):SSH 协议的 exec
请求本身是一条字符串,ssh 客户端把多个参数用空格拼成
远程 shell 命令——此前 inner 参数原样进 argv,prompt 里
的 ;/$()/引号/换行会在远程 shell 里被二次解析。现在每个
远程 token 都经 shlex.quote 引用后仍以列表元素形式返回
(ssh 拼接后远程解析结果与本地 list 语义一致);客内
agent 本来就是 root,本项修的是“任务执行语义不被 prompt
改写 + 审计不失真”,不是“防客内提权”(客内 root 无
需注入即可为所欲为,边界见执行器文档)。
"""
import os
import shlex
from cloud.chv.atomic_file import atomic_write_text
from cloud.chv.gitea_sync import (
GITEA_SYNC_PLAIN_KEYS,
GITEA_SYNC_SECRET_KEYS,
)
# CHV 密钥类环境变量名:值绝不进 ssh argv。Docker 线是
# “-e 只传名、值由 CLI 子进程环境提供”(值仍在宿主进程
# 环境里,/proc/<pid>/environ 对同用户可见);CHV 线是
# “值只进 seed 盘客内文件”(宿主进程表与宿主进程环境里
# 都没有值),比 Docker 线更严——差异见执行器文档。
_CHV_SECRET_ENV_NAMES = frozenset({"SUB2API_API_KEY"}) \
| GITEA_SYNC_SECRET_KEYS
# CHV guest-env 类非密钥变量名:值可进 SSH env 前缀(按定义
# 不是密钥),但 chv-sync boot 在 cloud-init 时运行、SSH 之
# 前,只能读 seed 投递的 /etc/chv/guest-env,所以这些键也
# 必须随 seed 写进 guest-env(见 split_guest_env 与执行器
# wake_vm),只走 SSH 前缀是不够的。
_CHV_GUEST_ENV_NAMES = frozenset(GITEA_SYNC_PLAIN_KEYS)
def split_secret_env(extra_env: dict | None) -> tuple[dict, dict]:
"""把 extra_env 拆成(密钥子集,非密钥子集)。"""
secrets, plain = {}, {}
for key, value in dict(extra_env or {}).items():
if key in _CHV_SECRET_ENV_NAMES:
secrets[key] = value
else:
plain[key] = value
return secrets, plain
def split_guest_env(extra_env: dict | None) -> tuple[dict, dict]:
"""把 extra_env 拆成(须写进 seed guest-env 的子集,
非密钥子集)。
guest-env 子集 = 密钥子集 + _CHV_GUEST_ENV_NAMES 命中的
非密钥同步变量(GITEA_URL 等 boot 时就要用的键);第二
个返回仍是全部非密钥子集(含 guest-env 命中键——它们
任务期也要经 SSH env 前缀进客机环境,seed 只解决
boot 时可见性,不替代任务期前缀)。"""
secrets, plain = split_secret_env(extra_env)
guest_env = dict(secrets)
for key, value in plain.items():
if key in _CHV_GUEST_ENV_NAMES:
guest_env[key] = value
return guest_env, plain
def _merge_no_proxy(extra_env, egress_proxy):
"""调用方 NO_PROXY/no_proxy 与 egress 的 localhost、
127.0.0.1 去重合并(单条 NO_PROXY 的值)。"""
parts = []
if extra_env:
for key in ("NO_PROXY", "no_proxy"):
val = extra_env.get(key)
if val:
parts += [p.strip() for p in str(val).split(",")
if p.strip()]
merged = []
for part in parts:
if part not in merged:
merged.append(part)
if egress_proxy:
for part in ("localhost", "127.0.0.1"):
if part not in merged:
merged.append(part)
return merged
def _env_assignments(extra_env, egress_proxy):
"""env 前缀的赋值列表(feat-chv-closeup,H1 附带)。
NO_PROXY 覆盖顺序已核实并修掉:旧拼装是 extra_env 的
赋值在前、egress 的 NO_PROXY 在后,`env` 遇重复赋值以
后者为准(实测 env NO_PROXY=a NO_PROXY=b → b),调用方
传的 NO_PROXY 被 egress 的 localhost,127.0.0.1 静默覆盖。
现改为合并(见 _merge_no_proxy),单条 NO_PROXY 发出。
egress 代理本身须赢:egress 已设时调用方的
HTTPS_PROXY/HTTP_PROXY(含小写,curl 等工具读小写优先,
留着会架空 egress)一律不发出,只发 egress 的三条;
无 egress 时调用方的值原样保留(只去重,不丢)。"""
skip = {"NO_PROXY", "no_proxy"}
if egress_proxy:
skip |= {"HTTPS_PROXY", "HTTP_PROXY",
"https_proxy", "http_proxy"}
assignments = []
if extra_env:
for key in sorted(extra_env):
if key in skip:
continue
assignments.append("%s=%s"
% (key, shlex.quote(
str(extra_env[key]))))
merged = _merge_no_proxy(extra_env, egress_proxy)
if egress_proxy:
assignments += [
"HTTPS_PROXY=%s" % shlex.quote(egress_proxy),
"HTTP_PROXY=%s" % shlex.quote(egress_proxy),
"NO_PROXY=%s" % shlex.quote(",".join(merged))]
elif merged:
assignments += [
"NO_PROXY=%s" % shlex.quote(",".join(merged))]
return assignments
def remote_command(inner_cmd: list, egress_proxy: str | None = None,
extra_env: dict | None = None,
guest_env_file: str | None = None) -> list:
"""构造远程 shell 命令的 token 列表(不含 ssh 前缀)。
每个 token 都经 shlex.quote:ssh 客户端把 argv 用空格拼
成一条远程命令时,远程 shell 解析回来的恰好是本地 list
语义。guest_env_file 给定时,远程命令先 `set -a; . <文件> ||
exit 126; set +a`(把 seed 投递的密钥文件 source 进环境并
export),再接 env 前缀与客内命令;文件路径本身不是密钥。
source 失败(文件缺失、权限不足)时远程 shell 立即以 126
退出——此前 `;` 会继续执行,密钥静默缺失、任务带着空密钥
跑下去(2026-11-07 WSL 真机 V-4:guest-env 0600 root,
远程 shell 以 chv 运行,source 失败但不中断)。
extra_env 含密钥名时 ValueError(调用方须先 split 出
密钥子集走 seed 投递,见 split_secret_env)。env 前缀的
拼装见 _env_assignments(NO_PROXY 合并、egress 优先)。"""
if extra_env:
secrets, _ = split_secret_env(extra_env)
if secrets:
raise ValueError(
"密钥类环境变量 %s 不得进 ssh argv,须走 seed "
"文件投递" % ",".join(sorted(secrets)))
tokens = []
if guest_env_file:
tokens += ["set", "-a;", ".",
shlex.quote(guest_env_file) + " || exit 126;",
"set", "+a;"]
env_assignments = _env_assignments(extra_env, egress_proxy)
inner = [shlex.quote(str(arg)) for arg in inner_cmd]
if env_assignments:
tokens += ["env"] + env_assignments + inner
else:
tokens += inner
return tokens
def known_hosts_path(state_dir: str, vm_name: str) -> str:
"""该 VM 的 known_hosts 文件路径:state_dir/<vm>/known_hosts。"""
return os.path.join(state_dir, vm_name, "known_hosts")
def build_ssh_command(ssh_port: int, ssh_key: str | None,
ssh_user: str, egress_proxy: str | None,
known_hosts_file: str, inner_cmd: list,
ssh_host: str = "127.0.0.1",
extra_env: dict | None = None,
guest_env_file: str | None = None) -> list:
"""构造 SSH 命令:ssh -p <端口> -i <key 文件> + 钉死选项
+ user@<ssh_host> + 客内命令(远程 token 均经 shlex.quote,
见 remote_command)。
feat-chv-fix(P0-4):ssh_host 不再在传输层写死回环,
由执行器解析传入(显式 CHV_SSH_HOST / net 客机 IP /
显式转发宣称下的回环);本函数缺省回环仅为旧调用点
兼容,执行器路径绝不依赖该缺省。
feat-chv-secfix(H2):extra_env 只接受非密钥变量;
密钥类变量须先经 split_secret_env 拆出、走 seed 盘客内
文件投递,调用方把 guest_env_file 传进来(客内 seed
写出的 /etc/chv/guest-env),本函数在远程命令前 source
它;密钥值绝不出现在本函数返回的任何 argv 元素里。
egress_proxy 已设时客内命令前加 env 前缀(代理地址本身
不是密钥;客机出口只走该白名单代理——但这只是 env 约
定,网络层强制见宿主防火墙部署清单,代码不声称已隔离)。"""
remote = remote_command(inner_cmd, egress_proxy=egress_proxy,
extra_env=extra_env,
guest_env_file=guest_env_file)
return ["ssh", "-p", str(ssh_port),
"-i", ssh_key,
"-o", "StrictHostKeyChecking=yes",
"-o", "UserKnownHostsFile=%s" % known_hosts_file,
"-o", "BatchMode=yes",
"%s@%s" % (ssh_user, ssh_host)] + remote
def parse_keyscan_output(text: str, host: str = "127.0.0.1") -> list:
"""从 ssh-keyscan 的 stdout 里挑出目标主机的 host key
行:形如 "[<host>]:<port> ssh-ed25519 AAAA..."(-p 时
的标准写法)或 "<host> ssh-rsa AAAA..."。其它主机行、
注释行、空行一律丢掉,绝不把陌生主机的 key 写进文件。
host 缺省回环仅为旧调用点兼容;客机 IP 方案下应传
解析出的客机 IP。"""
kept = []
for line in (text or "").splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
parts = line.split()
if len(parts) < 3:
continue
line_host, keytype = parts[0], parts[1]
if line_host != host \
and not line_host.startswith("[%s]:" % host):
continue
if not keytype.startswith("ssh-"):
continue
kept.append(line)
return kept
def write_known_hosts(path: str, key_lines: list) -> None:
"""原子写 known_hosts:随机 tmp 名 + fsync 后 rename
(feat-chv-closeup,P2-18:旧固定 `<path>.tmp` 无 fsync),
文件权限 0600(只含公钥行,仍按敏感文件对待)。"""
directory = os.path.dirname(path)
if directory:
os.makedirs(directory, exist_ok=True)
atomic_write_text(path, "\n".join(key_lines) + "\n", mode=0o600)