Files
codex-app-server/cloud/chv/config.py
T
host baf26beb78 feat(chv): 安全审计修复 High/Medium/Low(堆叠于 #239)
审计报告 ~/workspace/chv-security-audit/REPORT.md(No-Go,
Critical 0 / High 7 / Medium 8 / Low 5)代码层修复,mock
测试覆盖,真机动态验证一律未做、只列清单(见
docs/chv-production-readiness.md §11)。

High:
- H1 egress 代码门:代理地址校验、直连显式允许+审计、
  每 VM 独立 tap/mac/ip 校验、net 字段白名单;宿主防火墙
  列为部署清单,代码门+防火墙缺一不可。
- H2 网关 key 移出 argv:provisioned seed 盘客内文件
  /etc/chv/guest-env(0600)投递,传输层密钥进 argv 直接
  ValueError;keyscan 模式密钥投递 fail-closed。
- H3 任务盘删除:stop/recycle/cleanup 删 disk.raw(保留期
  0)+ 残留盘 GC,删失败审计+脏标记。
- H4 默认值改 provisioned:keyscan 只作显式 opt-in。
- H5 seed.iso 生成即 0600;宿主私钥 seed 盘建好后即删;
  崩溃残留扫描 cleanup_stale_secrets。
- H6 state_dir 默认出 /tmp(~/.local/share/.../chv-vms,
  preflight 保 0700);api-socket 组/其它可写即 fail-fast。
- H7 tap 隔离校验:net.json 登记+stop 释放,共享即拒绝审计。
Medium:M1 shlex 远程转义;M2 清理失败即拒起(脏标记);
M3 配额默认 50GB + 快照数/总量/保留期上限;M4 SHA256 必填
+ BUILDINFO versions 实测记录(事前 lockfile 列清单);
M5 host key/ preflight/失败审计补齐;M6 reuse 须声明单租户
项目;M7 容量计数只认连通 socket;M8 sshd 加固进 build.sh
断言。Low:L1 链接不跟随;L2 路径白名单;L3 失败审计;
L4 双变量警告+字段白名单;L5 预热盘/任务盘对应关系定义。
旧测试:实验室默认值与生产默认值区分标注;行为已变的旧
测试按新语义更新(容量死 socket、快照白名单、磁盘路径
白名单、网关 argv、reuse 租户声明)。
测试:新 test_chv_secfix 39 项;旧 117 项 + 2 项新增断言 =
158 项;worker 相关 129 项;前端 vitest 9 项;cloud 全量
1588 项(skipped 4)。
2026-10-31 03:02:10 +00:00

212 lines
10 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 执行器配置解析(叶子模块,不依赖
cloud.sandbox,避免循环导入)。
职责只有两件:内存字符串解析,以及把构造参数 + 环境变量
解析成一份显式配置。参数优先于环境变量、环境变量优先于
默认值——与 #216 原 __init__ 的 `or` 链完全一致,只收紧了
两处原来会静默 fallback 的 nonsense 输入(见下文),收紧点
在 PR 描述里明写、单测钉住:
- CHV_SSH_USER 显式传空字符串:原来 `or` 链会静默 fallback
成 root(调用方以为没给用户、实际跑了 root),现在直接
ValueError。
- CHV_SSH_PORT 显式传 0、或解析后超出 1-65535:原来 0 会
静默 fallback 成 2222、其它越界值会原样进 ssh 命令,现在
直接 ValueError。
与 #215 的关系(只列清单,不复制模块):#215 未合并,
本轨不假装复用它。#215 合并后应再复用的点:超时类
(api_timeout/ready_timeout/ready_interval/task_timeout →
timeouts 模块)、门禁类(preflight fail-fast、容量门、磁盘
配额门、egress fail-closed → gates 模块)、配额类
(max_vms、disk_gb、任务成本走 worker quota → quota 模块)、
隔离类(单任务单 VM、磁盘 copy 模式、egress 代理、SSH 钉死
→ sandbox_policy 模块)。见 PR 描述的复用清单。
"""
import os
import re
def _parse_memory_bytes(value):
"""内存字符串转字节:2G/512M/1024K/2048(无单位=字节)。
非法值 ValueError,绝不静默按默认算。"""
m = re.match(r"^\s*(\d+)\s*([KMGkmg]?)\s*[Bb]?\s*$",
str(value))
if not m:
raise ValueError("CHV_MEM 格式非法:%r" % (value,))
mult = {"": 1, "K": 1024, "M": 1024 ** 2, "G": 1024 ** 3}
return int(m.group(1)) * mult[m.group(2).upper()]
class ChvConfig:
"""解析后的 CHV 配置:纯数据对象,字段名与
CloudHypervisorExecutor 原 __init__ 的属性名一一对应,
执行器 __init__ 直接把字段抄到 self 上,旧测试读属性
的方式全部不变。"""
def __init__(self, **fields):
for key, value in fields.items():
setattr(self, key, value)
def build_chv_config(params, env):
"""把显式构造参数(params,None=未给)与环境变量(env,
默认为 os.environ 的调用方传入 dict)解析成 ChvConfig。
显式参数只有 __init__ 本来就有的那些;max_vms、磁盘模式、
磁盘配额、egress、快照、预热池目标数本来就没有显式参数,
仍只走环境变量(与 #216 一致,不悄悄加新参数面)。"""
env = env if env is not None else {}
binary = params.get("binary") or env.get("CHV_BINARY") \
or "cloud-hypervisor"
kernel = params.get("kernel") or env.get("CHV_KERNEL")
rootfs = params.get("rootfs") or params.get("image") \
or env.get("CHV_ROOTFS") or env.get("CHV_IMAGE")
cpus = str(params.get("cpus") or env.get("CHV_CPU") or "2")
memory = params.get("memory") or env.get("CHV_MEM") or "2G"
state_dir = params.get("state_dir") or env.get("CHV_STATE_DIR") \
or os.path.join(os.path.expanduser("~"),
".local", "share", "codex-app-server", "chv-vms")
ssh_key = params.get("ssh_key") or env.get("CHV_SSH_KEY")
if params.get("ssh_user") is not None \
and params.get("ssh_user") == "":
raise ValueError("CHV_SSH_USER 不能为空字符串")
ssh_user = params.get("ssh_user") or env.get("CHV_SSH_USER") \
or "root"
if params.get("ssh_port") is not None:
raw_port = params.get("ssh_port")
else:
raw_port = env.get("CHV_SSH_PORT") or 2222
try:
ssh_port = int(raw_port)
except (TypeError, ValueError):
raise ValueError("CHV_SSH_PORT 必须是整数")
if not 1 <= ssh_port <= 65535:
raise ValueError("CHV_SSH_PORT 必须在 1-65535 之间")
guest_host_py = params.get("guest_host_py") or "/app/host.py"
cmdline = params.get("cmdline") or env.get("CHV_CMDLINE") \
or "console=hvc0 root=/dev/vda1 rw"
net = params.get("net") or env.get("CHV_NET")
net_json = params.get("net_json") or env.get("CHV_NET_JSON")
kvm_path = params.get("kvm_path") \
if params.get("kvm_path") is not None else "/dev/kvm"
cpuinfo_path = params.get("cpuinfo_path") \
if params.get("cpuinfo_path") is not None else "/proc/cpuinfo"
ch_remote = params.get("ch_remote") or env.get("CHV_REMOTE") \
or "ch-remote"
# P0-4 SSH 目标解析(feat-chv-fix):显式 CHV_SSH_HOST 优先;
# 其次取 VmConfig net 首个带 ip 的条目(静态 tap 客机 IP,
# 即选定的客机 IP 发现方案);两者皆无时只有显式
# CHV_SSH_FORWARD=1 才允许回环 127.0.0.1(运维宣称宿主
# NAT 已把该端口转发到客机 sshd),否则执行器在 wake/
# wait 时报 ssh_target_undeclared,绝不无条件假设回环能通。
# vsock 未选:需客内 agent + 宿主代理两套新代码(#216 已
# 否决);纯 NAT 方案未选:DNAT 是宿主防火墙外部状态,
# 执行器代码既不能创建也不能校验它,写进代码仍是假保证。
ssh_host = params.get("ssh_host") or env.get("CHV_SSH_HOST")
ssh_forward = env.get("CHV_SSH_FORWARD") == "1"
# P0-3:reuse 实验二次确认(名字即带实验含义)。
reuse_experiment = env.get(
"CHV_REUSE_SINGLE_TENANT_EXPERIMENT") == "1"
model = params.get("model")
try:
max_vms = int(env.get("CHV_MAX_VMS") or 4)
except ValueError:
raise ValueError("CHV_MAX_VMS 必须是整数")
disk_mode = env.get("CHV_DISK_MODE") or "copy"
if disk_mode not in ("copy", "reuse"):
raise ValueError(
"CHV_DISK_MODE 只能是 copy 或 reuse:%s" % disk_mode)
disk_gb_raw = env.get("CHV_DISK_GB")
# feat-chv-secfix(M3):配额默认开启。CHV_DISK_GB 未设时
# 不再是 None(配额门直接返回),而是 50 GB 总量预算;
# 显式设 0 或负数同样非法——配额门是 fail-closed,不是
# “设个小值绕过”。check_disk_quota 仍接受 None(直接
# 调用者的第二道门),但执行器路径永远带默认值。
try:
disk_gb = float(disk_gb_raw) if disk_gb_raw else 50.0
except ValueError:
raise ValueError("CHV_DISK_GB 必须是数字")
if disk_gb <= 0:
raise ValueError("CHV_DISK_GB 必须大于 0")
egress_proxy = env.get("CHV_EGRESS_PROXY")
allow_direct_egress = env.get("CHV_ALLOW_DIRECT_EGRESS") == "1"
host_key_mode = params.get("host_key_mode") \
or env.get("CHV_HOST_KEY_MODE") or "provisioned"
if host_key_mode not in ("provisioned", "keyscan"):
raise ValueError(
"CHV_HOST_KEY_MODE 只能是 provisioned 或 keyscan:%s"
% host_key_mode)
# feat-chv-secfix(M6):reuse 实验放行须同时声明单租户
# 项目(部署声明 + 审计,代码无法从环境变量验证数据库
# 里是否真的只有一个租户——这一点在文档与 preflight 的
# kind 里明写,不假装已校验)。
single_tenant_project = env.get("CHV_SINGLE_TENANT_PROJECT")
# feat-chv-secfix(M3 快照上限 / L2 快照目录白名单):
# 快照数、总量、保留期均有默认值;file:// 快照只允许在
# state_dir 或 CHV_SNAPSHOT_DIR 内,远端协议须显式放行
# (见 hardening.validate_snapshot_url)。
try:
snapshot_max_count = int(env.get("CHV_SNAPSHOT_MAX_COUNT")
or 10)
snapshot_max_total_gb = float(
env.get("CHV_SNAPSHOT_MAX_TOTAL_GB") or 20.0)
snapshot_retention_days = float(
env.get("CHV_SNAPSHOT_RETENTION_DAYS") or 30.0)
except ValueError:
raise ValueError("CHV_SNAPSHOT_* 必须是数字")
if snapshot_max_count < 0 or snapshot_max_total_gb < 0 \
or snapshot_retention_days < 0:
raise ValueError("CHV_SNAPSHOT_* 不能为负数")
snapshot_dir = env.get("CHV_SNAPSHOT_DIR")
allow_remote_snapshot = env.get("CHV_ALLOW_REMOTE_SNAPSHOT") \
== "1"
task_timeout_raw = env.get("CHV_TASK_TIMEOUT")
task_timeout = float(task_timeout_raw) \
if task_timeout_raw else None
snapshot_url = env.get("CHV_SNAPSHOT_URL")
try:
warm_pool_size = int(env.get("CHV_WARM_POOL_SIZE") or 0)
except ValueError:
raise ValueError("CHV_WARM_POOL_SIZE 必须是整数")
try:
api_timeout = float(
params.get("api_timeout")
if params.get("api_timeout") is not None
else env.get("CHV_API_TIMEOUT") or 30)
ready_timeout = float(
params.get("ready_timeout")
if params.get("ready_timeout") is not None
else env.get("CHV_READY_TIMEOUT") or 120)
ready_interval = float(
params.get("ready_interval")
if params.get("ready_interval") is not None
else env.get("CHV_READY_INTERVAL") or 5)
except ValueError:
raise ValueError("CHV_*_TIMEOUT/INTERVAL 必须是数字")
if ready_interval <= 0:
ready_interval = 5
return ChvConfig(
binary=binary, kernel=kernel, rootfs=rootfs, cpus=cpus,
memory=memory, state_dir=state_dir, ssh_key=ssh_key,
ssh_user=ssh_user, ssh_port=ssh_port,
guest_host_py=guest_host_py, cmdline=cmdline, net=net,
net_json=net_json, kvm_path=kvm_path,
cpuinfo_path=cpuinfo_path, ch_remote=ch_remote, model=model,
max_vms=max_vms, disk_mode=disk_mode, disk_gb=disk_gb,
ssh_host=ssh_host, ssh_forward=ssh_forward,
reuse_experiment=reuse_experiment,
egress_proxy=egress_proxy,
allow_direct_egress=allow_direct_egress,
host_key_mode=host_key_mode,
task_timeout=task_timeout, snapshot_url=snapshot_url,
warm_pool_size=warm_pool_size, api_timeout=api_timeout,
ready_timeout=ready_timeout, ready_interval=ready_interval,
single_tenant_project=single_tenant_project,
snapshot_max_count=snapshot_max_count,
snapshot_max_total_gb=snapshot_max_total_gb,
snapshot_retention_days=snapshot_retention_days,
snapshot_dir=snapshot_dir,
allow_remote_snapshot=allow_remote_snapshot)