堆叠于 #246(feat-chv-secfix),只到 PR OPEN,未合并、未部署。 安全语义不变:egress、key seed 投递、磁盘删除、默认 provisioned、 tap 校验一律保留;前端零改动。 - WarmPool 定案为实验性(ADR-5):dispatch 永远冷启动, wired_to_dispatch 恒 False,acquire/resume 只保留实验接口, worker 仍调 reconcile_orphans 清理残留。 - 长函数提取:preflight 126→31、wake_vm 135→36、__init__ 69→31、 build_chv_config 159→58;错误文案逐字不变。 - 严格测试替身:fake API 校验 VmConfig(任务盘+seed 盘、net 无重复),fake SSH 校验 known_hosts 钉死;新增 5 项测试。 - Docker 同步守卫:8 个真实标记用例下 Docker 内联版与正典 parse_goal_result 状态全等,改分叉即 fail。 - 配置统一校验:preflight 以 config_invalid 统一兜底端口/配额/ 超时/快照/host-key 模式(构造时已校验正常值,preflight 兜底 事后改写);CHV_NET 与 CHV_NET_JSON 双设仍警告不硬拒。 - 类型与 lint:chv 叶子模块公开 API 补类型注解;stdlib 扫描 揪出 executor 内 1 个真死 import(host_key_paths)并删除。 - ADR:docs/chv-architecture.md(ADR-1 至 ADR-7)。 测试:CHV 163 项全过;cloud 全量 1593 项三跑结果为 8 errors (首跑详细日志因 tail 截断丢失)、全过、1 失败(浏览器代理 时序测试,与 CHV 无关),全量本身存在时序偶发,CHV 套件 确定性全过。
197 lines
7.9 KiB
Python
197 lines
7.9 KiB
Python
"""CHV 安全加固 helper(feat-chv-secfix,叶子模块)。
|
||
|
||
这里只放不依赖执行器类的纯校验/文件操作:state_dir 安全
|
||
目录、VMM socket 活性、net 条目隔离校验、快照 URL 白名单、
|
||
egress 代理地址校验。宿主防火墙规则、tap 桥接本身是运维
|
||
外部状态,代码建不出来也不校验——那部分只写进部署清单
|
||
(docs/chv-production-readiness.md §11),绝不在代码里
|
||
假装已隔离。
|
||
"""
|
||
|
||
import json
|
||
import os
|
||
import socket
|
||
|
||
from cloud.chv.errors import ChvFailure
|
||
|
||
# NET_JSON 条目字段白名单(L4):tap/mac/ip/mask 之外的字段
|
||
# 一律拒绝——未知字段原样送进 VMM API 的行为不确定,静默
|
||
# 透传等于把 VMM 的字段语义赌在运维手写上。
|
||
_NET_FIELDS = frozenset({"tap", "mac", "ip", "mask"})
|
||
|
||
|
||
def ensure_secure_state_dir(state_dir: str) -> None:
|
||
"""建 state_dir 并保证 0700(H6):目录不存在即按 0700
|
||
建;已存在但组/其它位可访时 chmod 0700;失败即
|
||
ChvFailure(state_dir_insecure),绝不带着 /tmp 式
|
||
宽松目录启动 VM。umask 影响 mkdir 的默认权限,故建
|
||
后一律显式 chmod 校验一次。"""
|
||
try:
|
||
os.makedirs(state_dir, exist_ok=True)
|
||
os.chmod(state_dir, 0o700)
|
||
except OSError as exc:
|
||
raise ChvFailure(
|
||
"state_dir_insecure",
|
||
"state_dir 无法建成安全目录(0700):%s:%s"
|
||
% (state_dir, exc))
|
||
try:
|
||
mode = os.stat(state_dir).st_mode & 0o777
|
||
except OSError as exc:
|
||
raise ChvFailure(
|
||
"state_dir_insecure",
|
||
"state_dir 无法校验权限:%s:%s" % (state_dir, exc))
|
||
if mode & 0o077:
|
||
raise ChvFailure(
|
||
"state_dir_insecure",
|
||
"state_dir 权限仍宽松(%o),拒绝启动:%s"
|
||
% (mode, state_dir))
|
||
return state_dir
|
||
|
||
|
||
def socket_is_live(socket_path: str) -> bool:
|
||
"""VMM api-socket 是否真有监听者(M7):能连通才算活。
|
||
普通文件、空文件、已死 VMM 的残留 socket 一律算死——
|
||
容量计数不再被残留文件占满。连接失败的任何异常都算
|
||
死(不区分 refused/超时,计数是尽力而为的读操作)。
|
||
注意竞态:刚启动、尚未 listen 的 VMM 会被判死,容量
|
||
门在并发唤醒时可能短暂低估——文档明写该窗口,容量门
|
||
是“拒绝超限”的护栏,不是精确的分布式锁。"""
|
||
try:
|
||
if not socket_path or not os.path.exists(socket_path):
|
||
return False
|
||
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||
try:
|
||
sock.settimeout(0.5)
|
||
return sock.connect_ex(socket_path) == 0
|
||
finally:
|
||
sock.close()
|
||
except OSError:
|
||
return False
|
||
|
||
|
||
def parse_net_entries(net_json: str) -> list:
|
||
"""解析 CHV_NET_JSON 为条目 list(非法 JSON / 非
|
||
list/dict 即 net_config_invalid)。"""
|
||
if not net_json:
|
||
return []
|
||
try:
|
||
net = json.loads(net_json)
|
||
except ValueError:
|
||
raise ChvFailure("net_config_invalid",
|
||
"CHV_NET_JSON 不是合法 JSON")
|
||
entries = net if isinstance(net, list) else [net]
|
||
if not all(isinstance(e, dict) for e in entries):
|
||
raise ChvFailure("net_config_invalid",
|
||
"CHV_NET_JSON 条目须为 dict")
|
||
return entries
|
||
|
||
|
||
def validate_net_entries(entries: list, existing_entries: list) -> list:
|
||
"""net 条目隔离校验(H7 + L4):
|
||
|
||
- 字段白名单:未知字段即 net_config_invalid;
|
||
- 条目内 tap/mac/ip 归一化(mac 小写、空 tap 视为未
|
||
指定,不参与 tap 冲突比较——空 tap 是“VMM 默认网卡”
|
||
的运维写法,代码不编造 tap 名,但空 tap 多 VM 共用
|
||
默认网卡的风险仍在部署清单里明写);
|
||
- 本次条目内 tap/mac/ip 重复即 net_config_invalid;
|
||
- 与已登记 VM(existing_entries,每项为 {tap, mac, ip,
|
||
vm})的 tap/mac/ip 任一重复即 net_tap_conflict,
|
||
拒绝启动并由调用方审计。
|
||
返回归一化后的条目 list。"""
|
||
seen_taps, seen_macs, seen_ips = set(), set(), set()
|
||
normalized = []
|
||
for entry in entries:
|
||
unknown = set(entry) - _NET_FIELDS
|
||
if unknown:
|
||
raise ChvFailure(
|
||
"net_config_invalid",
|
||
"CHV_NET_JSON 条目含未知字段:%s(白名单:%s)"
|
||
% (",".join(sorted(unknown)),
|
||
",".join(sorted(_NET_FIELDS))))
|
||
tap = str(entry.get("tap") or "").strip()
|
||
mac = str(entry.get("mac") or "").strip().lower()
|
||
ip = str(entry.get("ip") or "").strip()
|
||
for kind, value, seen in (("tap", tap, seen_taps),
|
||
("mac", mac, seen_macs),
|
||
("ip", ip, seen_ips)):
|
||
if not value:
|
||
continue
|
||
if kind == "tap" and not tap:
|
||
continue
|
||
if value in seen:
|
||
raise ChvFailure(
|
||
"net_config_invalid",
|
||
"CHV_NET_JSON 内 %s 重复:%s" % (kind, value))
|
||
seen.add(value)
|
||
normalized.append({"tap": tap, "mac": mac, "ip": ip,
|
||
"mask": str(entry.get("mask") or "")})
|
||
for entry in normalized:
|
||
for other in existing_entries:
|
||
for kind in ("tap", "mac", "ip"):
|
||
value = entry.get(kind)
|
||
if value and value == other.get(kind):
|
||
raise ChvFailure(
|
||
"net_tap_conflict",
|
||
"net %s %s 已被 VM %s 使用,拒绝共享"
|
||
" tap/mac/ip:每 VM 必须独立"
|
||
% (kind, value, other.get("vm")))
|
||
return normalized
|
||
|
||
|
||
def validate_egress_proxy(egress_proxy: str) -> None:
|
||
"""egress 代理地址校验(H1 代码门的一部分):须为
|
||
http/https URL 且带主机名;否则 net_config_invalid…
|
||
实际 kind 用 config_invalid(preflight 的配置类失败)。
|
||
空值由调用方另行处理(未设 + 未显式允许直连时
|
||
preflight 已有 egress_not_configured)。"""
|
||
if not egress_proxy:
|
||
return None
|
||
try:
|
||
from urllib.parse import urlparse
|
||
parsed = urlparse(egress_proxy)
|
||
except ValueError:
|
||
parsed = None
|
||
if parsed is None or parsed.scheme not in ("http", "https") \
|
||
or not parsed.hostname:
|
||
raise ChvFailure(
|
||
"config_invalid",
|
||
"CHV_EGRESS_PROXY 必须是 http/https 代理地址:%r"
|
||
% (egress_proxy,))
|
||
return egress_proxy
|
||
|
||
|
||
def _is_within(directory, path):
|
||
directory = os.path.realpath(directory)
|
||
path = os.path.realpath(path)
|
||
return path == directory or path.startswith(directory + os.sep)
|
||
|
||
|
||
def validate_snapshot_url(snapshot_url: str, state_dir: str, snapshot_dir=None,
|
||
allow_remote=False):
|
||
"""快照 URL 白名单(L2):
|
||
|
||
- file://:目标须在 state_dir 或 CHV_SNAPSHOT_DIR 内,
|
||
否则 snapshot_path_not_allowed;
|
||
- 其它协议(含无 :// 的裸路径由调用方先补 file://):
|
||
须显式 CHV_ALLOW_REMOTE_SNAPSHOT=1,否则
|
||
snapshot_remote_not_allowed。
|
||
返回 file:// 目标的本地路径(非 file:// 时返回 None)。"""
|
||
if snapshot_url.startswith("file://"):
|
||
path = snapshot_url[len("file://"):]
|
||
allowed = [state_dir]
|
||
if snapshot_dir:
|
||
allowed.append(snapshot_dir)
|
||
if not any(_is_within(d, path) for d in allowed):
|
||
raise ChvFailure(
|
||
"snapshot_path_not_allowed",
|
||
"file:// 快照须在 state_dir 或 CHV_SNAPSHOT_DIR "
|
||
"内:%s" % snapshot_url)
|
||
return path
|
||
if not allow_remote:
|
||
raise ChvFailure(
|
||
"snapshot_remote_not_allowed",
|
||
"非 file:// 快照须显式设 CHV_ALLOW_REMOTE_SNAPSHOT=1:%s"
|
||
% snapshot_url)
|
||
return None
|