Files
codex-app-server/chv-guest/chv-broker.py
T
host c61484aa68 feat(chv-guest): 对齐 Muse P2——nspawn 单元 + broker + 浏览器子代理
- chv-cell:systemd-nspawn 单命令单元,--private-users=1000:65536
  (单元 root → 客机 chv),丢 8 项能力(含 SYS_PTRACE/NET_ADMIN),
  syscall 过滤扣 io_uring,遮蔽 /etc/chv 与 playwright,
  --network-veth(10.200.0.0/24 + NAT),白名单环境透传
- chv-broker:Unix socket + SO_PEERCRED(uid 范围 + pid ns 对照),
  白名单 ping/sync/browser;sync 只调 chv-sync(runuser -u chv)
- chv-browser:单元外独立进程,只暴露 open/snapshot/screenshot/
  close/ping,返回无障碍树快照(截断);evaluate/content 拒绝
- build.sh:装 systemd-container/iproute2/iptables,拷入三脚本,
  建 chv-browser 用户与两个 systemd 单元;vsock_notify 条件拷入
- buildinfo:内容项加 P2 四项,支持可选 extra_contents
- 测试:cloud/tests/test_chv_muse_p2.py 40 项全过;broker/browser
  真实 socket 回路冒烟通过(root 连 broker 被拒)
- 差距表:docs/chv-muse-p2-gap.md;真机验证待测试机空闲(round 11)
  本地做,不推、不合、不部署
2026-11-06 20:53:15 +00:00

268 lines
9.7 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.
#!/usr/bin/env python3
"""chv-broker:单元外 IPC broker(客机侧、单元外服务)。
对齐 Muse P2(见 docs/chv-muse-p2-gap.md):单元内外走 Unix socket +
SO_PEERCRED 的最小权限 IPC。broker 只认白名单操作,凭证与浏览器
都在单元外——单元看不见 token,只能请 broker 代办。
校验(双重,缺一不可才放行;cell.pid 不存在时只做 uid 检查并打日志):
1. SO_PEERCRED 的 uid 须在单元映射范围 [1000, 1000+65536) 内;
2. 对端 pid 的 pid ns 须与 /run/chv/cell.pid 记的单元 init 同 ns
(读不到 cell.pid 时跳过第 2 项并警告;cell.pid 存在但对端 ns
读不到时拒绝,不降级)。
操作白名单:
ping -> {"ok": true}
sync {action: pull|push|status, repo?} -> 单元外以 chv 身份跑
chv-sync(list argv,不进 shell;仓目录只允许
~/workspace 下,realpath + commonpath 校验)。
browser {…} -> 原样转发给 chv-browser.sock(浏览器子代理),
回包原样返回;浏览器服务不在时报错,不替它跑 playwright。
只用标准库。单连接一请求一响应(JSON 行,≤64KB);异常只断本
连接,绝不杀死 broker 循环。
"""
import grp
import json
import os
import socket
import struct
import subprocess
import sys
BROKER_SOCK = "/run/chv/broker.sock"
BROWSER_SOCK = "/run/chv/browser.sock"
CELL_PID_FILE = "/run/chv/cell.pid"
UID_BASE = 1000
UID_COUNT = 65536
WORKSPACE = "/home/chv/workspace"
SYNC_USER = "chv"
SYNC_BIN = "/usr/local/bin/chv-sync"
MAX_REQUEST_BYTES = 65536
MAX_OUTPUT_BYTES = 8192
SYNC_ACTIONS = ("pull", "push", "status")
def _read_ns_link(pid, proc_root="/proc"):
"""读 /proc/<pid>/ns/pid 的链接目标;读不到返回 None。"""
try:
return os.readlink(os.path.join(proc_root, str(pid), "ns", "pid"))
except OSError:
return None
def peer_allowed(peer_uid, peer_pid, cell_pid=None, proc_root="/proc"):
"""对端是否允许用 broker。返回 (allowed, reason)。
cell_pid 为 None 时只做 uid 检查(broker 打日志警告,调用方负责)。"""
if not (UID_BASE <= peer_uid < UID_BASE + UID_COUNT):
return False, "uid %d 不在单元映射范围 [%d, %d)" % (
peer_uid, UID_BASE, UID_BASE + UID_COUNT)
if cell_pid is None:
return True, "uid 在范围内(cell.pid 缺失,未做 pid ns 对照)"
cell_ns = _read_ns_link(cell_pid, proc_root)
if cell_ns is None:
return False, "cell.pid 的 pid ns 读不到,拒绝(不降级)"
peer_ns = _read_ns_link(peer_pid, proc_root)
if peer_ns is None:
return False, "对端 pid ns 读不到,拒绝"
if peer_ns != cell_ns:
return False, "对端 pid ns 与单元不一致"
return True, "ok"
def _read_cell_pid():
try:
with open(CELL_PID_FILE) as fh:
return int(fh.read().strip())
except (OSError, ValueError):
return None
def _resolve_repo(repo):
"""仓目录只允许 ~/workspace 下。repo 为空时返回 None(由
chv-sync 自己取唯一仓)。返回解析后的绝对路径或抛 ValueError。"""
if not repo:
return None
base = os.path.realpath(WORKSPACE)
target = os.path.realpath(repo)
if os.path.commonpath([base, target]) != base or target == base:
raise ValueError("仓目录只允许 %s 下:%s" % (WORKSPACE, repo))
return target
def _truncate(text, limit=MAX_OUTPUT_BYTES):
if text is None:
return ""
if len(text) > limit:
return text[:limit] + "\n…(已截断)"
return text
def _run_sync(action, repo):
if action not in SYNC_ACTIONS:
return {"ok": False,
"error": "sync action 只允许 %s:%s"
% ("/".join(SYNC_ACTIONS), action)}
try:
repo_path = _resolve_repo(repo)
except ValueError as e:
return {"ok": False, "error": str(e)}
cmd = ["runuser", "-u", SYNC_USER, "--", "env", "HOME=/home/chv",
SYNC_BIN, action]
if repo_path:
cmd.append(repo_path)
try:
proc = subprocess.run(cmd, capture_output=True, text=True,
timeout=300)
except subprocess.TimeoutExpired:
return {"ok": False, "error": "chv-sync 超时(300 秒)"}
except OSError as e:
return {"ok": False, "error": "chv-sync 起不来:%s" % e}
return {"ok": proc.returncode == 0,
"stdout": _truncate(proc.stdout),
"stderr": _truncate(proc.stderr),
"returncode": proc.returncode}
def _forward_browser(req, sock_path=BROWSER_SOCK, timeout=120):
"""转发给浏览器子代理;回包须是带 ok 的 dict,否则报错。"""
try:
conn = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
conn.settimeout(timeout)
conn.connect(sock_path)
except OSError as e:
return {"ok": False,
"error": "浏览器子代理未就绪:%s" % e}
try:
conn.sendall((json.dumps(req, ensure_ascii=False) + "\n").encode())
data = b""
while len(data) < MAX_REQUEST_BYTES:
chunk = conn.recv(4096)
if not chunk:
break
data += chunk
if data.endswith(b"\n"):
break
except OSError as e:
return {"ok": False, "error": "浏览器子代理通信失败:%s" % e}
finally:
conn.close()
try:
resp = json.loads(data.decode())
except (ValueError, UnicodeDecodeError):
return {"ok": False, "error": "浏览器子代理回包不是 JSON"}
if not isinstance(resp, dict) or "ok" not in resp:
return {"ok": False, "error": "浏览器子代理回包格式不对"}
return resp
def handle_request(req, browser_sock=BROWSER_SOCK):
"""处理一个请求 dict,返回响应 dict(纯逻辑,可单测)。"""
if not isinstance(req, dict):
return {"ok": False, "error": "请求须是 JSON 对象"}
op = req.get("op")
if op == "ping":
return {"ok": True, "cell": True}
if op == "sync":
return _run_sync(req.get("action"), req.get("repo"))
if op == "browser":
return _forward_browser(req, sock_path=browser_sock)
return {"ok": False, "error": "未知操作:%r(白名单:ping/sync/browser)"
% (op,)}
def _peer_creds(conn):
"""SO_PEERCRED:返回 (pid, uid, gid)。Linux struct ucred = 3 个 int。"""
raw = conn.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED,
struct.calcsize("3i"))
pid, uid, gid = struct.unpack("3i", raw)
return pid, uid, gid
def serve(sock_path=BROKER_SOCK, browser_sock=BROWSER_SOCK):
"""监听 Unix socket,逐连接处理。返回码 0 只能是被信号杀死;
socket 建不起来即抛错(systemd 会重启服务,不要静默带病跑)。"""
if os.path.exists(sock_path):
os.remove(sock_path)
os.makedirs(os.path.dirname(sock_path), exist_ok=True)
srv = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
srv.bind(sock_path)
srv.listen(16)
# 0660 + 组 chv:单元内 root 在客机侧是 uid 1000(chv),
# 组 chv 可连;其它用户连不上(再叠加 SO_PEERCRED 校验)。
os.chmod(sock_path, 0o660)
try:
gid = grp.getgrnam("chv").gr_gid
except KeyError:
print("chv-broker: 组 chv 不存在,socket 权限置不全,拒绝启动",
file=sys.stderr)
raise SystemExit(1)
os.chown(sock_path, -1, gid)
print("chv-broker: 监听 %s" % sock_path, file=sys.stderr, flush=True)
while True:
try:
conn, _ = srv.accept()
except OSError:
continue
try:
pid, uid, _gid = _peer_creds(conn)
cell_pid = _read_cell_pid()
allowed, reason = peer_allowed(uid, pid, cell_pid)
if not allowed:
print("chv-broker: 拒绝连接 pid=%d uid=%d:%s"
% (pid, uid, reason), file=sys.stderr, flush=True)
conn.close()
continue
if cell_pid is None:
print("chv-broker: 警告:cell.pid 缺失,只做了 uid 检查 "
"(pid=%d uid=%d)" % (pid, uid), file=sys.stderr,
flush=True)
conn.settimeout(310)
data = b""
while len(data) < MAX_REQUEST_BYTES:
chunk = conn.recv(4096)
if not chunk:
break
data += chunk
if data.endswith(b"\n"):
break
try:
req = json.loads(data.decode())
except (ValueError, UnicodeDecodeError):
resp = {"ok": False, "error": "请求不是合法 JSON"}
else:
resp = handle_request(req, browser_sock=browser_sock)
conn.sendall((json.dumps(resp, ensure_ascii=False)
+ "\n").encode())
except OSError as e:
print("chv-broker: 连接处理异常:%s" % e, file=sys.stderr,
flush=True)
finally:
try:
conn.close()
except OSError:
pass
def main(argv):
sock_path = BROKER_SOCK
browser_sock = BROWSER_SOCK
i = 0
while i < len(argv):
if argv[i] == "--socket" and i + 1 < len(argv):
sock_path = argv[i + 1]
i += 2
elif argv[i] == "--browser-socket" and i + 1 < len(argv):
browser_sock = argv[i + 1]
i += 2
else:
print("chv-broker: 未知参数:%s" % argv[i], file=sys.stderr)
return 2
serve(sock_path, browser_sock)
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))