merge(chv-muse-round12): 合入 round-11 修复轮 HEAD 50cae2a (RETEST6 全绿)
按 hunk 合入,不整文件覆盖。冲突 3 处:
- chv-guest/build.sh:取 P4 的 copy-in(cdp-broker/guard/blocklist/
chv-memory)+ cdp-broker service/enable 三件;取 R11 的 git
config(去掉 credential.helper store,层 5);tmpfiles 两边
并存(/run/chv/ask-queue + /run/chv-browser)。
- chv-guest/chv-browser.py:P4 架构保留(CDP broker 客户端 +
guard 模块);取 R11 两处仍适用的运行期修复:BROWSER_SOCK
移进 /run/chv-browser(缺陷 4,合入后的 broker 已指该路径)
与 handle_request 认 op=browser/action 透传;R11 的直连
Playwright 代码(validate_url/truncate_aria/_load_playwright/
_page snapshot)由 P4 取代,不带回。
- cloud/tests/test_chv_muse_p2.py:truncate 三测取 P4 的
GUARD.truncate_a11y(dict 树);FakeCdpBroker 取 P4 版,删
R11 的 FakePage 系列;干跑夹具取 R11 的 _fixture 调用,并
给 _fixture 补 P4 四件(否则合入后 build.sh fail-fast)。
合入内移植(R11 修复在 P4 里移了位置,不移植即静默丢失):
- chv-guest/chv-cdp-broker.py:_load_playwright 返回
sync_playwright()(原 R11 在 chv-browser.py 修过的同形
bug);回归测试由 test_p2 移植到 test_chv_muse_p4.py。
- test_chv_muse_p2.py:HOST 由模块级 eager 改懒加载(_host()):
host.py 顶层 import queue 会把 stdlib queue 缓进 sys.modules,
使同进程后加载的 test_queue_sandbox 取不到 cloud/queue.py
遮蔽而 ImportError(全量唯一失败)。
未移植(见报告):R11 的 Page.aria_snapshot() 纠正未进
chv-cdp-broker.py(仍调 accessibility.snapshot(),且 guard
的 dict 树 API 须配套改,属 P4 返工,不在本轮合入做)。
全量 cloud 套件:2522 tests OK (skipped=10),基线 a6f9597
为 2474 OK (skipped=12);+48 来自 R11 新增测试(p2 +36、
p3 +7、closeup +3、gaps +1、p4 +1 移植)。
This commit is contained in:
commit
9da74c2a78
24 files changed
+1949
-217
No files matched your search
+67
-13
@@ -14,7 +14,10 @@ socket 与 seed 文件)。它只做三件事:
|
||||
GITEA_TOKEN 持在内存里(cloud.chv.authd.CredentialVault),
|
||||
除此之外不落盘、不进日志;
|
||||
2. 在 Unix socket /run/chv-authd/authd.sock 上接受 git credential
|
||||
helper 的请求(SO_PEERCRED 只放行 root 与 chv 用户);
|
||||
helper 的请求(SO_PEERCRED 只放行 root 与 chv 用户;socket
|
||||
用 group 权限——目录 0750、socket 0660、组为第一个非 root
|
||||
放行 uid 的主组,round-11 真机修复层 3,探针用的 0666
|
||||
不上生产);
|
||||
3. 每次放凭证前先过 Sentinel 评估(cloud.chv.sentinel,主机/
|
||||
方法/路径/解析后 IP/SSRF),再按次释放(单次、300 秒 TTL),
|
||||
全程审计(审计里无值)。
|
||||
@@ -51,6 +54,46 @@ SEED_KEYS = frozenset({
|
||||
DEFAULT_ALLOWED_UIDS = (0, 1000)
|
||||
|
||||
|
||||
def _socket_group_gid(allowed_uids, socket_group=None):
|
||||
"""socket 共享组的 gid:socket_group 显式给时优先(int 即 gid,
|
||||
str 即组名);否则取第一个非 root 放行 uid 的主组。解析不出
|
||||
(uid 无 passwd 条目、组名不存在)即 None——调用方回落
|
||||
root-only 权限(目录 0700、socket 0600),绝不把 socket 放给
|
||||
全员可读(round-11 真机修复层 3:探针用 0666,生产用 group
|
||||
权限——chv 在组内,目录 0750 + socket 0660 + chgrp)。"""
|
||||
import grp
|
||||
import pwd
|
||||
if socket_group is not None:
|
||||
if isinstance(socket_group, int):
|
||||
return socket_group
|
||||
try:
|
||||
return grp.getgrnam(socket_group).gr_gid
|
||||
except (KeyError, OverflowError):
|
||||
return None
|
||||
for uid in sorted(allowed_uids or ()):
|
||||
if uid == 0:
|
||||
continue
|
||||
try:
|
||||
return pwd.getpwuid(uid).pw_gid
|
||||
except (KeyError, OverflowError):
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def _set_group_perms(path, gid, mode):
|
||||
"""目录/socket 的组权限:先 chgrp(失败只放过,不中断启动),
|
||||
再 chmod。gid 为 None 时只 chmod(root-only 模式)。"""
|
||||
if gid is not None:
|
||||
try:
|
||||
os.chown(path, -1, gid)
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
os.chmod(path, mode)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _load_seed(path):
|
||||
"""读 seed 文件(KEY=VALUE,每行一项)→ 白名单后的 dict。
|
||||
文件不存在即 FileNotFoundError(调用方 fail-fast,绝不带着
|
||||
@@ -87,13 +130,18 @@ def _operation_for_path(path):
|
||||
推断不到的一律拒绝):
|
||||
- .../git-receive-pack → push(写)
|
||||
- .../git-upload-pack → pull(读)
|
||||
- .../info/refs → pull(读,clone 也走这条)"""
|
||||
- .../info/refs → pull(读,clone 也走这条)
|
||||
- 裸 .../ → pull(读;round-11 真机修复层 4:git 智能
|
||||
HTTP 的发现请求只发裸 .git 路径,裸路径一律当 pull——
|
||||
只读默认;push 只走显式的 git-receive-pack)"""
|
||||
if path.endswith(".git/git-receive-pack"):
|
||||
return "POST", "push"
|
||||
if path.endswith(".git/git-upload-pack"):
|
||||
return "POST", "pull"
|
||||
if path.endswith(".git/info/refs"):
|
||||
return "GET", "pull"
|
||||
if path.endswith(".git"):
|
||||
return "GET", "pull"
|
||||
return None, None
|
||||
|
||||
|
||||
@@ -227,7 +275,8 @@ class AuthdServer(socketserver.ThreadingUnixStreamServer):
|
||||
allow_reuse_address = False
|
||||
|
||||
def __init__(self, socket_path, vault, policy, gitea_user,
|
||||
allowed_uids, log_path, resolve=None):
|
||||
allowed_uids, log_path, resolve=None,
|
||||
socket_group=None):
|
||||
self.vault = vault
|
||||
self.policy = policy
|
||||
self.gitea_user = gitea_user
|
||||
@@ -236,20 +285,24 @@ class AuthdServer(socketserver.ThreadingUnixStreamServer):
|
||||
# 解析器可注入(测试用假解析器;不给时 Sentinel 用真实
|
||||
# getaddrinfo)。与执行器的 sentinel_resolver 同款口径。
|
||||
self.resolve = resolve
|
||||
# round-11 真机修复(层 3):socket 用 group 权限。
|
||||
# gid 取 socket_group 显式值或第一个非 root 放行 uid 的
|
||||
# 主组;解析不出即 None,回落 root-only(0700/0600)。
|
||||
self.socket_gid = _socket_group_gid(allowed_uids, socket_group)
|
||||
directory = os.path.dirname(socket_path)
|
||||
if directory:
|
||||
os.makedirs(directory, exist_ok=True)
|
||||
try:
|
||||
os.chmod(directory, 0o700)
|
||||
except OSError:
|
||||
pass
|
||||
if self.socket_gid is not None:
|
||||
_set_group_perms(directory, self.socket_gid, 0o750)
|
||||
else:
|
||||
_set_group_perms(directory, None, 0o700)
|
||||
if os.path.exists(socket_path):
|
||||
os.unlink(socket_path)
|
||||
super().__init__(socket_path, AuthdHandler)
|
||||
try:
|
||||
os.chmod(socket_path, 0o600)
|
||||
except OSError:
|
||||
pass
|
||||
if self.socket_gid is not None:
|
||||
_set_group_perms(socket_path, self.socket_gid, 0o660)
|
||||
else:
|
||||
_set_group_perms(socket_path, None, 0o600)
|
||||
|
||||
def audit(self, record):
|
||||
_audit(self.log_path, record)
|
||||
@@ -260,7 +313,8 @@ def build_server(seed_path=DEFAULT_SEED_PATH,
|
||||
log_path=DEFAULT_LOG_PATH,
|
||||
lib_dir=DEFAULT_LIB_DIR,
|
||||
allowed_uids=None,
|
||||
resolve=None):
|
||||
resolve=None,
|
||||
socket_group=None):
|
||||
"""建服务(不启动):读 seed → 入库 → 建策略 → 建 socket 服务。
|
||||
|
||||
seed 缺必填键(GITEA_URL/GITEA_USER/GITEA_TOKEN/SYNC_REPO)
|
||||
@@ -295,7 +349,7 @@ def build_server(seed_path=DEFAULT_SEED_PATH,
|
||||
if raw.strip() else list(DEFAULT_ALLOWED_UIDS)
|
||||
server = AuthdServer(socket_path, vault, policy,
|
||||
seed["GITEA_USER"], uids, log_path,
|
||||
resolve=resolve)
|
||||
resolve=resolve, socket_group=socket_group)
|
||||
return server
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
"""chv-authd 叶子树的 stub cloud/chv/__init__.py(round-11 真机修复,层 1)。
|
||||
|
||||
真实的 cloud/chv/__init__.py import executor/orphans(进而要整套
|
||||
cloud 包),拷进 authd 的最小叶子树(/usr/local/lib/chv-authd)
|
||||
会让 chv-authd 在 import 时 ModuleNotFoundError 而 crash loop:
|
||||
先是 cloud.chv.errors 缺失,补上 errors.py 后又会在 executor
|
||||
上失败。探针 fixprobe3 的形状:叶子树里只放 errors.py + 仅导出
|
||||
ChvFailure 的 stub __init__.py——authd 只需要这个名字。
|
||||
build.sh 拷本文件作叶子树的 cloud/chv/__init__.py,不拷真实的
|
||||
包 __init__。
|
||||
"""
|
||||
from cloud.chv.errors import ChvFailure
|
||||
|
||||
__all__ = ["ChvFailure"]
|
||||
+33
-13
@@ -19,9 +19,10 @@
|
||||
#
|
||||
# Gitea 双向同步(用户工作区口径,2026-11-06 追加):镜像里加
|
||||
# git + gt 1.0.0(官方 install.sh + SHA256 校验,跟 170/172
|
||||
# 同款),配好 git user 与 credential helper 占位,不含真实
|
||||
# token;客机启动时 /usr/local/bin/chv-sync boot 按 seed 盘
|
||||
# /etc/chv/guest-env(0600)把用户选定的仓 clone 到
|
||||
# 同款),配好 git user,不含真实 token,不设 credential helper
|
||||
#(round-11 真机修复层 5:store helper 已去掉,authd helper 是
|
||||
# 唯一凭证 helper,且自身绝不落盘);客机启动时 chv-sync boot
|
||||
# 按 seed 盘 /etc/chv/guest-env(0600)把用户选定的仓 clone 到
|
||||
# ~/workspace,后续 pull/push 由用户显式同步,分叉/冲突时
|
||||
# 失败关闭(不 --force、不自动 rebase)。token 绝不进镜像、
|
||||
# 代码、日志。
|
||||
@@ -188,14 +189,18 @@ done
|
||||
#(test_chv_residual.BuildScriptMinimalAppTest 钉住),缺失时只
|
||||
# 警告、不失败——缺叶子模块的镜像里 chv-authd 起不来,chv-sync
|
||||
# 按 P3 差距表 §3.5 回落旧落盘路径并警告,绝不静默假装有 authd。
|
||||
for p3_file in authd.py git-credential-chv-authd chv-authd.service; do
|
||||
# round-11 真机修复(层 1):叶子树还须 errors.py + stub
|
||||
# __init__(authd_init_stub.py)——真实的 cloud/chv/__init__.py
|
||||
# import executor/orphans,拷进最小叶子树会让 chv-authd
|
||||
# ModuleNotFoundError 而 crash loop(探针 fixprobe3 的形状)。
|
||||
for p3_file in authd.py git-credential-chv-authd chv-authd.service authd_init_stub.py; do
|
||||
if [ ! -f "$SCRIPT_DIR/$p3_file" ]; then
|
||||
echo "缺少 $p3_file(Muse P3 客机文件):$SCRIPT_DIR" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
P3_LIBS_PRESENT=0
|
||||
if [ -f "$REPO_DIR/cloud/chv/authd.py" ] && [ -f "$REPO_DIR/cloud/chv/sentinel.py" ] && [ -f "$REPO_DIR/cloud/__init__.py" ] && [ -f "$REPO_DIR/cloud/chv/__init__.py" ]; then
|
||||
if [ -f "$REPO_DIR/cloud/chv/authd.py" ] && [ -f "$REPO_DIR/cloud/chv/sentinel.py" ] && [ -f "$REPO_DIR/cloud/chv/errors.py" ] && [ -f "$REPO_DIR/cloud/__init__.py" ] && [ -f "$REPO_DIR/cloud/chv/__init__.py" ]; then
|
||||
P3_LIBS_PRESENT=1
|
||||
else
|
||||
echo "警告:Muse P3 云端叶子模块不在仓库里(cloud/chv/authd.py 与 sentinel.py),镜像暂不含 authd 叶子模块" >&2
|
||||
@@ -324,34 +329,49 @@ VC_ARGS=(-a "$OUT/disk.raw"
|
||||
--copy-in "$SCRIPT_DIR/chv-memory.py:/usr/local/bin/"
|
||||
--run-command 'mv /usr/local/bin/chv-memory.py /usr/local/bin/chv-memory && chmod +x /usr/local/bin/chv-memory'
|
||||
|
||||
--run-command 'git config --system user.name chv && git config --system user.email chv@localhost && git config --system credential.helper store && git config --system init.defaultBranch main'
|
||||
# round-11 真机修复(层 5):镜像不再设 credential.helper store
|
||||
#(authd helper 是唯一凭证 helper);P4 的 copy-in 行保留。
|
||||
--run-command 'git config --system user.name chv && git config --system user.email chv@localhost && git config --system init.defaultBranch main'
|
||||
--run-command "$GT_INSTALL_CMD"
|
||||
--run-command '/usr/local/bin/gt version > /app/guest-versions-gt.txt'
|
||||
--run-command 'printf "ssh_deletekeys: false\nbootcmd:\n - \"[ -f /etc/ssh/ssh_host_ed25519_key ] || ssh-keygen -A\"\n" > /etc/cloud/cloud.cfg.d/99-chv-hostkey.cfg; systemctl enable ssh || true'
|
||||
--run-command 'printf "PasswordAuthentication no\nPermitRootLogin prohibit-password\nChallengeResponseAuthentication no\nKbdInteractiveAuthentication no\nPubkeyAuthentication yes\n" > /etc/ssh/sshd_config.d/99-chv.conf && mkdir -p /run/sshd && ssh-keygen -A && sshd -t && grep -q "^PasswordAuthentication no" /etc/ssh/sshd_config.d/99-chv.conf && grep -q "^PermitRootLogin prohibit-password" /etc/ssh/sshd_config.d/99-chv.conf && rm -f /etc/ssh/ssh_host_* && test -z "$(ls /etc/ssh/ssh_host_* 2>/dev/null)"'
|
||||
--run-command 'dpkg-query -W -f "\${Package} \${Version}\n" > /app/guest-versions-dpkg.txt; pip3 freeze > /app/guest-versions-pip.txt || true'
|
||||
--run-command 'useradd --system --home-dir /var/lib/chv-browser --shell /usr/sbin/nologin chv-browser && mkdir -p /var/lib/chv-browser /run/chv/shots && chown chv-browser:chv-browser /var/lib/chv-browser /run/chv/shots && chmod 0755 /run/chv /run/chv/shots'
|
||||
--run-command 'useradd --system --home-dir /var/lib/chv-browser --shell /usr/sbin/nologin chv-browser && mkdir -p /var/lib/chv-browser /run/chv/shots /run/chv-browser && chown chv-browser:chv-browser /var/lib/chv-browser /run/chv/shots /run/chv-browser && chmod 0755 /run/chv /run/chv/shots /run/chv-browser'
|
||||
--run-command 'printf "[Unit]\nDescription=CHV cell broker (Muse P2)\nAfter=network.target\n\n[Service]\nType=simple\nExecStart=/usr/bin/python3 /usr/local/bin/chv-broker\nRestart=on-failure\nRestartSec=2\n\n[Install]\nWantedBy=multi-user.target\n" > /etc/systemd/system/chv-broker.service'
|
||||
--run-command 'printf "[Unit]\nDescription=CHV browser sub-agent (Muse P2)\nAfter=network.target\n\n[Service]\nType=simple\nUser=chv-browser\nGroup=chv-browser\nExecStart=/usr/bin/python3 /usr/local/bin/chv-browser\nRestart=on-failure\nRestartSec=2\n\n[Install]\nWantedBy=multi-user.target\n" > /etc/systemd/system/chv-browser.service'
|
||||
--run-command 'printf "[Unit]\nDescription=CHV CDP broker (Muse P4)\nAfter=network.target\n\n[Service]\nType=simple\nUser=chv-browser\nGroup=chv-browser\nExecStart=/usr/bin/python3 /usr/local/bin/chv-cdp-broker\nRestart=on-failure\nRestartSec=2\n\n[Install]\nWantedBy=multi-user.target\n" > /etc/systemd/system/chv-cdp-broker.service'
|
||||
--run-command 'printf "d /run/chv 0755 root root -\nd /run/chv/shots 0755 chv-browser chv-browser -\nd /run/chv/ask-queue 0750 chv-browser chv-browser -\n" > /etc/tmpfiles.d/chv.conf && systemctl enable chv-broker chv-browser chv-cdp-broker || true')
|
||||
# round-11 真机修复(缺陷 4):tmpfiles 须登记 /run/chv-browser
|
||||
#(browser.sock 自有目录);P4 的 ask-queue 行保留。
|
||||
--run-command 'printf "d /run/chv 0755 root root -\nd /run/chv/shots 0755 chv-browser chv-browser -\nd /run/chv/ask-queue 0750 chv-browser chv-browser -\nd /run/chv-browser 0755 chv-browser chv-browser -\n" > /etc/tmpfiles.d/chv.conf && systemctl enable chv-broker chv-browser chv-cdp-broker || true')
|
||||
# P3 交接(Muse P3 的 authd/sentinel 叶子模块):文件在仓库里存在时
|
||||
# 拷入镜像(本分支已有,合入后自动生效);不存在时只警告、不失败——
|
||||
# M4 既定 build.sh 不硬要求 cloud/,见上。缺叶子模块时 chv-authd
|
||||
# 守护进程起不来,chv-sync 按差距表 §3.5 回落旧路径并警告。
|
||||
# round-11 真机修复(层 1):叶子树是 errors.py + stub __init__
|
||||
#(authd_init_stub.py),不拷真实的 cloud/chv/__init__.py(它
|
||||
# import executor/orphans,会让 authd 在最小叶子树里
|
||||
# ModuleNotFoundError 而 crash loop);cloud/__init__.py 本身只
|
||||
# 有 docstring,照常拷。
|
||||
if [ "$P3_LIBS_PRESENT" = "1" ]; then
|
||||
VC_ARGS+=(--copy-in "$REPO_DIR/cloud/chv/authd.py:/usr/local/lib/chv-authd/cloud/chv/"
|
||||
--copy-in "$REPO_DIR/cloud/chv/sentinel.py:/usr/local/lib/chv-authd/cloud/chv/"
|
||||
--copy-in "$REPO_DIR/cloud/chv/errors.py:/usr/local/lib/chv-authd/cloud/chv/"
|
||||
--copy-in "$REPO_DIR/cloud/__init__.py:/usr/local/lib/chv-authd/cloud/"
|
||||
--copy-in "$REPO_DIR/cloud/chv/__init__.py:/usr/local/lib/chv-authd/cloud/chv/")
|
||||
--copy-in "$SCRIPT_DIR/authd_init_stub.py:/usr/local/lib/chv-authd/cloud/chv/"
|
||||
--run-command 'mv /usr/local/lib/chv-authd/cloud/chv/authd_init_stub.py /usr/local/lib/chv-authd/cloud/chv/__init__.py')
|
||||
fi
|
||||
# P1 交接(Muse P1 的 vsock_notify.py):文件在仓库里存在时拷入
|
||||
# 镜像(P1 分支才有,本分支尚无);不存在时只警告、不失败——
|
||||
# 合并后自动生效,P2 不改该脚本内容。
|
||||
# 镜像并建 vsock-notify.service(自动启动,round-11 真机修复:
|
||||
# 此前只拷文件、不建服务,vsock 通道从未自动启动;服务形状按
|
||||
# vsock_notify.py 的 docstring:ExecStart=/usr/local/bin/
|
||||
# vsock-notify %H,Restart=no,After/WantedBy=multi-user.target;
|
||||
# printf 格式串里 %H 须写 %%H);不存在时只警告、不失败。
|
||||
VSOCK_NOTIFY_PRESENT=0
|
||||
if [ -f "$REPO_DIR/cloud/chv/guest/vsock_notify.py" ]; then
|
||||
VC_ARGS+=(--copy-in "$REPO_DIR/cloud/chv/guest/vsock_notify.py:/usr/local/bin/"
|
||||
--run-command 'mv /usr/local/bin/vsock_notify.py /usr/local/bin/vsock-notify && chmod +x /usr/local/bin/vsock-notify')
|
||||
--run-command 'mv /usr/local/bin/vsock_notify.py /usr/local/bin/vsock-notify && chmod +x /usr/local/bin/vsock-notify'
|
||||
--run-command 'printf "[Unit]\nDescription=CHV vsock notify (Muse P1)\nAfter=multi-user.target\n\n[Service]\nType=simple\nExecStart=/usr/local/bin/vsock-notify %%H\nRestart=no\n\n[Install]\nWantedBy=multi-user.target\n" > /etc/systemd/system/vsock-notify.service && systemctl enable vsock-notify || true')
|
||||
VSOCK_NOTIFY_PRESENT=1
|
||||
else
|
||||
echo "警告:cloud/chv/guest/vsock_notify.py 不在仓库里(P1 未合并),镜像暂不含 vsock notify" >&2
|
||||
@@ -415,7 +435,7 @@ else
|
||||
echo "警告:CHROMIUM_HEADLESS_SHELL_REV 未设,Chromium 版本只记录、不钉死(V-9 验收须设该变量重建)" >&2
|
||||
fi
|
||||
EXTRA_CONTENTS=""
|
||||
[ "$P3_LIBS_PRESENT" = "1" ] && EXTRA_CONTENTS="cloud/chv/authd.py,cloud/chv/sentinel.py"
|
||||
[ "$P3_LIBS_PRESENT" = "1" ] && EXTRA_CONTENTS="cloud/chv/authd.py,cloud/chv/sentinel.py,cloud/chv/errors.py,cloud/chv/__init__.py"
|
||||
[ "$VSOCK_NOTIFY_PRESENT" = "1" ] && EXTRA_CONTENTS="${EXTRA_CONTENTS:+$EXTRA_CONTENTS,}vsock_notify"
|
||||
python3 "$SCRIPT_DIR/buildinfo.py" "$OUT" "$TAG" "$UBUNTU_VERSION" "$CODEX_VERSION" "$REPO_COMMIT" "$UBUNTU_IMAGE_SHA256" "${PLAYWRIGHT:-1}" "${CHROMIUM_HEADLESS_SHELL_REV:-}" "$EXTRA_CONTENTS"
|
||||
fi
|
||||
|
||||
@@ -30,6 +30,20 @@ DECLARED_SOURCES = {
|
||||
"chv-cell": "repo_commit",
|
||||
"chv-broker": "repo_commit",
|
||||
"chv-browser": "repo_commit",
|
||||
# 对齐 Muse P3 + P1 vsock:authd 三件套、authd/sentinel
|
||||
# 叶子模块与 vsock 通知脚本,均是仓库内脚本,版本随
|
||||
# repo_commit。缺了它们 check 会报 contents 未覆盖(717efc4
|
||||
# 真机实测:6 项 unresolved,verify 与 check 双双失败)。
|
||||
# round-11 真机修复(层 1):叶子树新增 errors.py 与 stub
|
||||
# __init__.py(authd_init_stub.py),同款 repo_commit 登记。
|
||||
"chv-authd": "repo_commit",
|
||||
"git-credential-chv-authd": "repo_commit",
|
||||
"chv-authd.service": "repo_commit",
|
||||
"cloud/chv/authd.py": "repo_commit",
|
||||
"cloud/chv/sentinel.py": "repo_commit",
|
||||
"cloud/chv/errors.py": "repo_commit",
|
||||
"cloud/chv/__init__.py": "repo_commit",
|
||||
"vsock_notify": "repo_commit",
|
||||
}
|
||||
|
||||
|
||||
|
||||
+80
-11
@@ -5,11 +5,12 @@
|
||||
SO_PEERCRED 的最小权限 IPC。broker 只认白名单操作,凭证与浏览器
|
||||
都在单元外——单元看不见 token,只能请 broker 代办。
|
||||
|
||||
校验(双重,缺一不可才放行;cell.pid 不存在时只做 uid 检查并打日志):
|
||||
校验(双重,缺一不可才放行;无 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
|
||||
读不到时拒绝,不降级)。
|
||||
2. 对端 pid 的 pid ns 须与任一活着的单元 init 同 ns(part 4:
|
||||
并发 cell 各有 cell-<machine>.pid,旧的单文件 cell.pid 只记
|
||||
最新单元;读不到任何 pid 文件时跳过第 2 项并警告;有 pid 文
|
||||
件但对端 ns 与任一可读的单元 ns 都对不上时拒绝,不降级)。
|
||||
|
||||
操作白名单:
|
||||
ping -> {"ok": true}
|
||||
@@ -22,6 +23,7 @@ SO_PEERCRED 的最小权限 IPC。broker 只认白名单操作,凭证与浏览
|
||||
只用标准库。单连接一请求一响应(JSON 行,≤64KB);异常只断本
|
||||
连接,绝不杀死 broker 循环。
|
||||
"""
|
||||
import glob
|
||||
import grp
|
||||
import json
|
||||
import os
|
||||
@@ -31,8 +33,14 @@ import subprocess
|
||||
import sys
|
||||
|
||||
BROKER_SOCK = "/run/chv/broker.sock"
|
||||
BROWSER_SOCK = "/run/chv/browser.sock"
|
||||
# round-11 真机修复(缺陷 4):browser.sock 从 /run/chv 移到
|
||||
# chv-browser 自有目录——/run/chv 是 0755 root root,
|
||||
# chv-browser 在 bind 时 EACCES 而无限 crash loop(restart 21);
|
||||
# 放进组可写的 /run/chv 会给 chv-browser 对 broker.sock/cell.pid
|
||||
# 的 unlink 能力,所以用独立目录(0755 chv-browser 所有)。
|
||||
BROWSER_SOCK = "/run/chv-browser/browser.sock"
|
||||
CELL_PID_FILE = "/run/chv/cell.pid"
|
||||
CELL_PID_GLOB = "/run/chv/cell-*.pid"
|
||||
UID_BASE = 1000
|
||||
UID_COUNT = 65536
|
||||
WORKSPACE = "/home/chv/workspace"
|
||||
@@ -71,6 +79,38 @@ def peer_allowed(peer_uid, peer_pid, cell_pid=None, proc_root="/proc"):
|
||||
return True, "ok"
|
||||
|
||||
|
||||
def peer_allowed_multi(peer_uid, peer_pid, cell_pids, proc_root="/proc"):
|
||||
"""多 cell 版:对端 pid ns 与任一活 cell 的 init 同 ns 即放行
|
||||
(part 4 并发 cell 用;单 cell 时与 peer_allowed 等价)。
|
||||
cell_pids 为空即只做 uid 检查;全部 pid 的 ns 都读不到时
|
||||
拒绝(不降级);个别 pid 的 ns 读不到(已退出的残留文件)
|
||||
只跳过该项,不因此拒绝整个请求。"""
|
||||
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 not cell_pids:
|
||||
# part 5:reason 文案与单 cell 版 peer_allowed 对齐,保留
|
||||
# “cell.pid 缺失”字样——P2-9 驱动 grep 该串认降级日志;
|
||||
# part 4 切到 multi 版时把这里改写成“无 cell pid 文件”,
|
||||
# 该串自此再也没进过 journal,是 RETEST4
|
||||
# degraded_logged=False 的根因(不是时序、不是日志路径)。
|
||||
return True, "uid 在范围内(cell.pid 缺失,未做 pid ns 对照)"
|
||||
peer_ns = _read_ns_link(peer_pid, proc_root)
|
||||
if peer_ns is None:
|
||||
return False, "对端 pid ns 读不到,拒绝"
|
||||
readable = 0
|
||||
for cell_pid in cell_pids:
|
||||
cell_ns = _read_ns_link(cell_pid, proc_root)
|
||||
if cell_ns is None:
|
||||
continue
|
||||
readable += 1
|
||||
if peer_ns == cell_ns:
|
||||
return True, "ok"
|
||||
if readable == 0:
|
||||
return False, "cell pid 的 pid ns 均读不到,拒绝(不降级)"
|
||||
return False, "对端 pid ns 与任何单元不一致"
|
||||
|
||||
|
||||
def _read_cell_pid():
|
||||
try:
|
||||
with open(CELL_PID_FILE) as fh:
|
||||
@@ -79,6 +119,36 @@ def _read_cell_pid():
|
||||
return None
|
||||
|
||||
|
||||
def _read_cell_pids(cell_dir="/run/chv"):
|
||||
"""读全部 cell pid:每单元一份 cell-<machine>.pid + 兼容旧
|
||||
路径 cell.pid(最新单元)。去重、跳过坏内容,返回 int 列表。"""
|
||||
pids = []
|
||||
paths = sorted(glob.glob(os.path.join(cell_dir, "cell-*.pid")))
|
||||
paths.append(os.path.join(cell_dir, "cell.pid"))
|
||||
for path in paths:
|
||||
try:
|
||||
with open(path) as fh:
|
||||
pid = int(fh.read().strip())
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
if pid not in pids:
|
||||
pids.append(pid)
|
||||
return pids
|
||||
|
||||
|
||||
def _degraded_warning(reason, pid, uid):
|
||||
"""降级警告行:reason 原样进日志。serve() 在无 cell pid 文件
|
||||
时打这条;P2-9 驱动 grep 'cell.pid 缺失' 认的就是它(part 5:
|
||||
part 4 之前 serve 直接打印含该串的警告行,RETEST3 同项为
|
||||
True;part 4 切到 peer_allowed_multi 后警告行改写成
|
||||
“无 cell pid 文件”,该串再也没进过 journal——RETEST4 的
|
||||
degraded_logged=False 即由此来,不是时序、不是日志路径
|
||||
变化。reason 与警告行分开写曾让两者悄悄漂移,今后改文案
|
||||
只改 peer_allowed_multi 的 reason,警告行自动跟随)。"""
|
||||
return ("chv-broker: 警告:%s (pid=%d uid=%d)"
|
||||
% (reason, pid, uid))
|
||||
|
||||
|
||||
def _resolve_repo(repo):
|
||||
"""仓目录只允许 ~/workspace 下。repo 为空时返回 None(由
|
||||
chv-sync 自己取唯一仓)。返回解析后的绝对路径或抛 ValueError。"""
|
||||
@@ -207,17 +277,16 @@ def serve(sock_path=BROKER_SOCK, browser_sock=BROWSER_SOCK):
|
||||
continue
|
||||
try:
|
||||
pid, uid, _gid = _peer_creds(conn)
|
||||
cell_pid = _read_cell_pid()
|
||||
allowed, reason = peer_allowed(uid, pid, cell_pid)
|
||||
cell_pids = _read_cell_pids()
|
||||
allowed, reason = peer_allowed_multi(uid, pid, cell_pids)
|
||||
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)
|
||||
if not cell_pids:
|
||||
print(_degraded_warning(reason, pid, uid),
|
||||
file=sys.stderr, flush=True)
|
||||
conn.settimeout(310)
|
||||
data = b""
|
||||
while len(data) < MAX_REQUEST_BYTES:
|
||||
|
||||
@@ -10,7 +10,7 @@ Playwright/Chromium,Chromium 的所有权在单元外 CDP broker
|
||||
accessibility.snapshot,经 broker 取回),不见原始 DOM,不能
|
||||
在页面上下文跑 JS,不暴露交互操作,不开 DevTools。
|
||||
|
||||
操作(Unix socket /run/chv/browser.sock,JSON 行):
|
||||
操作(Unix socket /run/chv-browser/browser.sock,JSON 行):
|
||||
open {url} -> 经 broker 导航并返回 {title, url, a11y,
|
||||
truncated, untrusted_input, injection_flags,
|
||||
form_risk}
|
||||
@@ -40,7 +40,11 @@ import os
|
||||
import socket
|
||||
import sys
|
||||
|
||||
BROWSER_SOCK = "/run/chv/browser.sock"
|
||||
# round-11 真机修复(缺陷 4):自有目录 /run/chv-browser,
|
||||
# 0755 chv-browser 所有——/run/chv 是 0755 root root,
|
||||
# 在此 bind 会 EACCES 而无限 crash loop(见 chv-broker.py)。
|
||||
# P4 的 CDP_SOCK 保留:broker 侧仍走 /run/chv/cdp-broker.sock。
|
||||
BROWSER_SOCK = "/run/chv-browser/browser.sock"
|
||||
CDP_SOCK = "/run/chv/cdp-broker.sock"
|
||||
MAX_REQUEST_BYTES = 65536
|
||||
|
||||
@@ -186,11 +190,18 @@ class BrowserAgent:
|
||||
|
||||
|
||||
def handle_request(agent, req):
|
||||
"""处理一个请求 dict,返回响应 dict(纯逻辑,可单测)。"""
|
||||
"""处理一个请求 dict,返回响应 dict(纯逻辑,可单测)。
|
||||
op "browser" 是 broker 原样转发的形状(round-11 真机修复):
|
||||
内层 op 在 action 里;无 action 但带 url 时默认 open(驱动
|
||||
只发 url);action 同样受 FORBIDDEN_OPS 约束。"""
|
||||
guard = _load_guard()
|
||||
if not isinstance(req, dict):
|
||||
return {"ok": False, "error": "请求须是 JSON 对象"}
|
||||
op = req.get("op")
|
||||
if op == "browser":
|
||||
op = req.get("action")
|
||||
if op is None and req.get("url"):
|
||||
op = "open"
|
||||
if op in guard.FORBIDDEN_OPS:
|
||||
return {"ok": False,
|
||||
"error": "操作 %r 不暴露(无 JS/无原始 DOM/无交互)"
|
||||
|
||||
@@ -76,7 +76,14 @@ def _load_guard():
|
||||
|
||||
|
||||
def _load_playwright():
|
||||
"""懒加载 playwright;缺失时报明错(不静默假装有浏览器)。"""
|
||||
"""懒加载 playwright;缺失时报明错(不静默假装有浏览器)。
|
||||
返回调用后的上下文管理器(sync_playwright()),不是函数本身——
|
||||
round-11 复测回归(原在 chv-browser.py,P4 起 Playwright
|
||||
所有权移到本文件,同形 bug 随之移到此处;合入时移植):
|
||||
曾返回裸 sync_playwright 函数,_ensure_browser 对它调
|
||||
__enter__ 即 AttributeError,open 永远失败;单测的假工厂
|
||||
直接返回带 __enter__ 的对象,盖不住这个形状(见
|
||||
test_chv_muse_p4.py 的 test_load_playwright_returns_called_cm)。"""
|
||||
os.environ.setdefault("PLAYWRIGHT_BROWSERS_PATH",
|
||||
PLAYWRIGHT_BROWSERS_PATH)
|
||||
try:
|
||||
@@ -84,7 +91,7 @@ def _load_playwright():
|
||||
except ImportError as e:
|
||||
raise RuntimeError(
|
||||
"chv-cdp-broker: playwright 未装(%s)" % e)
|
||||
return sync_playwright
|
||||
return sync_playwright()
|
||||
|
||||
|
||||
class CdpBroker:
|
||||
|
||||
+496
-61
@@ -11,14 +11,36 @@ systemd),单元即沙箱——单元内 codex 用 danger-full-access,不
|
||||
--private-users=1000:65536 —— 单元内 uid 0 → 客机侧 chv(uid 1000)。
|
||||
效果:workspace / ~/.codex(chv 所有)单元内仍可写;host-root 0600
|
||||
文件(如 /etc/chv/guest-env)在单元内显示为 overflow uid、不可读;
|
||||
再加 --inaccessible 遮蔽作纵深防御。
|
||||
再加 --inaccessible 遮蔽作纵深防御(只遮 /usr 下路径:--volatile=yes
|
||||
下容器根是基本空的 tmpfs,/etc/chv 等在容器根里根本不存在,按构造
|
||||
即不可达,列出反而让 nspawn 解析失败而起不来,见 INACCESSIBLE_BASE)。
|
||||
|
||||
网络:--network-veth。宿主侧 ve-chv-cell(10.200.0.1/24),单元侧
|
||||
host0(10.200.0.2/24,默认路由经 10.200.0.1),客机侧 NAT 出站
|
||||
(MASQUERADE 走客机默认路由的上行口,动态发现)。单元内配网经
|
||||
nsenter 进单元 init 的 netns 做。veth 建不起来即大声失败,不静默
|
||||
回落共享网络;CHV_CELL_NETWORK=shared 是显式调试逃生口,须显式
|
||||
设置并打日志,绝不默认。
|
||||
网络:--network-veth。每次 run_cell 在锁下分配唯一身份
|
||||
(CellNet:machine chv-cell-<xx>、宿主 ve-chv-cell-<xx>、
|
||||
10.200.<xx>.0/24,宿主 .1、单元 .2),不再共用固定名与固定
|
||||
子网——固定名是并发 cell 撞毁的根因(part 4)。客机侧 NAT
|
||||
出站(MASQUERADE 走客机默认路由的上行口,动态发现)。单元内
|
||||
配网经 nsenter 进单元 init 的 netns 做。veth 建不起来即大声
|
||||
失败,不静默回落共享网络;CHV_CELL_NETWORK=shared 是显式调试
|
||||
逃生口,须显式设置并打日志,绝不默认。
|
||||
起步门(part 4):payload 在 Popen 的瞬间就开始跑,与配网全程
|
||||
并发是启动竞态的根因——快 payload 在配网完成前退出,nspawn 随
|
||||
之退出,包装器再去配网即撞墙。run_cell 把 payload 包一层
|
||||
sh 起步门(等 /run/chv/.cell-gate-<machine> 出现再 exec),
|
||||
配网完成后才开门;find_cell_init_pid 改成带超时轮询;nspawn
|
||||
已死时返回它的退出码而不是抛错。
|
||||
volatile 空根(part 4):--volatile=yes 下单元根是基本空的
|
||||
tmpfs,/app、/etc/resolv.conf、/etc/hosts、/etc/ssl/certs 都须
|
||||
显式绑进去(/app 与 CA 只读绑;resolv/hosts 是 run_cell 按客机
|
||||
有效 DNS 生成的单元专属文件,只读绑;不绑 nsswitch.conf,
|
||||
glibc 默认 files+dns 正是单元需要的)。part 6(RETEST5)纠正
|
||||
part 5 的设想:/app/work 以可写 bind 盖住只读 /app 的做法在真机
|
||||
上不成立——后挂的 bind 只有在挂载点已存在时才能盖住,挂载点
|
||||
不存在时 nspawn 在只读 /app 下连建都建不起来(全量 73 次同一
|
||||
错误,所有 cell 启动失败)。worktree 根改移出 /app:单元内
|
||||
make_worktree 改用 workspace 绑定下已可写的
|
||||
/home/chv/workspace/.chv-worktrees(经 CHV_WORKTREE_ROOT 环境
|
||||
变量传进单元),不再发任何指向 /app 之下的 bind。
|
||||
|
||||
环境透传:nspawn 不透传调用方环境,本脚本只转发白名单变量(模型
|
||||
网关与代理变量)。GITEA_TOKEN 等凭证变量不在白名单里,绝不进单元。
|
||||
@@ -34,6 +56,8 @@ nsenter 进单元 init 的 netns 做。veth 建不起来即大声失败,不静
|
||||
# 只打印配网命令表(JSON),不执行
|
||||
只用标准库。单元启动失败、配网失败一律非零退出,不静默降级。
|
||||
"""
|
||||
import fcntl
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import pwd
|
||||
@@ -41,6 +65,7 @@ import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from typing import NamedTuple
|
||||
|
||||
MACHINE = "chv-cell"
|
||||
# 单元映射:单元内 uid 0 → 客机侧 chv(uid 1000,非特权用户)。
|
||||
@@ -52,14 +77,37 @@ CELL_GID = 1000
|
||||
WORKSPACE = "/home/chv/workspace"
|
||||
CODEX_HOME = "/home/chv/.codex"
|
||||
BROKER_DIR = "/run/chv"
|
||||
# part 6(RETEST5 新发现):单元内 worktree 根(RETEST4 新发现 2
|
||||
# 的旧址是 HERE/work,即单元内 /app/work——/app 是 --bind-ro,
|
||||
# 带 repo 的默认 dispatch 撞 OSError Errno 30;part 5 把该目录以
|
||||
# 可写 bind 挂到 /app/work 上,真机证明不可行:挂载点不存在时
|
||||
# nspawn 在只读 /app 下连建都建不起来,所有 cell 启动失败)。
|
||||
# 新址是 workspace 绑定下的 .chv-worktrees:--bind=/home/chv/
|
||||
# workspace 已把整个 workspace 以可写方式挂进单元(目的路径与
|
||||
# 源相同、挂载点即 workspace 自身,挂载时已存在),worktree 根
|
||||
# 落在这里无需任何额外 bind;backing 仍在客机 workspace 下、
|
||||
# 随客机磁盘持久化(跨 cell 的 run/merge 续跑仍找得到 worktree),
|
||||
# 隔离与落盘语义不变,只是单元内路径从 /app/work/host-worktrees
|
||||
# 改为 /home/chv/workspace/.chv-worktrees/host-worktrees。
|
||||
# 该值经 --setenv=CHV_WORKTREE_ROOT 传进单元,host.py 的
|
||||
# worktree_base() 读它(与 host.py 的 CELL_WORKTREE_ROOT 同值,
|
||||
# 两处须保持一致)。
|
||||
WORKTREE_DIR = "/home/chv/workspace/.chv-worktrees"
|
||||
CELL_PID_FILE = "/run/chv/cell.pid"
|
||||
|
||||
# 单元网络(与客机 tap 网 192.168.10.0/24 不重叠的独立 /24)。
|
||||
# part 4(2026-11-07):固定名/固定子网是并发 cell 撞毁的根因——
|
||||
# 每次 run_cell 都在锁下分配唯一身份(CellNet),不再共用常量。
|
||||
# machine 名须短:nspawn 把宿主侧 veth 叫 ve-<machine>,接口名
|
||||
# 上限 15 字符;"chv-cell-%02x" 为 11 字符,ve- 前缀后 14 字符。
|
||||
CELL_NET_BASE = "10.200"
|
||||
CELL_NET = "10.200.0.0/24"
|
||||
HOST_IP = "10.200.0.1"
|
||||
CELL_IP = "10.200.0.2"
|
||||
HOST_IFACE = "ve-chv-cell"
|
||||
CELL_IFACE = "host0"
|
||||
MAX_CELL_INDEX = 250
|
||||
ALLOC_LOCK_FILE = "/run/chv/.cell-alloc.lock"
|
||||
|
||||
# Muse 点名要丢的能力 + 同批丢的危险能力(nspawn 默认集已有限,
|
||||
# 这里是显式再丢,--drop-capability 逐项写,测试逐项断言)。
|
||||
@@ -79,11 +127,78 @@ DROP_CAPABILITIES = [
|
||||
SYSCALL_FILTER = ("@system-service ~io_uring_setup "
|
||||
"~io_uring_enter ~io_uring_register")
|
||||
|
||||
# 单元内遮掉的路径:凭证与浏览器(单元内跑不起自己的浏览器,
|
||||
# 只能走 broker 的 chv-browser)。
|
||||
# 单元内遮掉的路径:浏览器(单元内跑不起自己的浏览器,
|
||||
# 只能走 broker 的 chv-browser)。只列 /usr 下的路径——
|
||||
# round-11 复测纠正(第二轮):--volatile=yes 下容器根是
|
||||
# 基本空的 tmpfs(只挂进只读 /usr),nspawn 按容器根解析
|
||||
# --inaccessible 路径,不存在的路径即启动失败;/etc/chv 与
|
||||
# /home/chv/.git-credentials 在容器根里根本不存在(无 /home、
|
||||
# 无 /etc/chv),凭证文件按构造就不可达,不再列出遮蔽。
|
||||
# 旧的两项若列回,cell 会报 Failed to resolve 而起不来。
|
||||
INACCESSIBLE_BASE = [
|
||||
"/usr/local/share/ms-playwright",
|
||||
]
|
||||
|
||||
# 只转发白名单环境变量进单元。凭证类变量(GITEA_TOKEN 等)
|
||||
# 不在白名单里,绝不进单元。
|
||||
FORWARD_ENV_ALLOWLIST = (
|
||||
"SUB2API_BASE_URL",
|
||||
"SUB2API_API_KEY",
|
||||
"XCM_API_KEY",
|
||||
"HTTP_PROXY",
|
||||
"HTTPS_PROXY",
|
||||
"NO_PROXY",
|
||||
"http_proxy",
|
||||
"https_proxy",
|
||||
"no_proxy",
|
||||
)
|
||||
|
||||
|
||||
class CellNet(NamedTuple):
|
||||
"""一次 cell 运行的唯一网络身份(锁下分配,见 allocate_cell_net)。
|
||||
machine 同时决定宿主侧 veth 名(ve-<machine>,nspawn 约定)与
|
||||
/run/chv 下的专属文件后缀(gate/resolv/hosts/pid)。"""
|
||||
index: int
|
||||
machine: str
|
||||
host_iface: str
|
||||
host_ip: str
|
||||
cell_ip: str
|
||||
subnet: str
|
||||
|
||||
|
||||
def legacy_cell_net():
|
||||
"""无身份时的旧常量(argv/network-commands 子命令与旧测试用;
|
||||
run_cell 一律走 allocate_cell_net,不用它)。"""
|
||||
return CellNet(index=0, machine=MACHINE, host_iface=HOST_IFACE,
|
||||
host_ip=HOST_IP, cell_ip=CELL_IP, subnet=CELL_NET)
|
||||
|
||||
# Muse 点名要丢的能力 + 同批丢的危险能力(nspawn 默认集已有限,
|
||||
# 这里是显式再丢,--drop-capability 逐项写,测试逐项断言)。
|
||||
DROP_CAPABILITIES = [
|
||||
"CAP_SYS_ADMIN",
|
||||
"CAP_SYS_PTRACE",
|
||||
"CAP_NET_ADMIN",
|
||||
"CAP_NET_RAW",
|
||||
"CAP_SYS_MODULE",
|
||||
"CAP_SYS_RAWIO",
|
||||
"CAP_SYS_BOOT",
|
||||
"CAP_MKNOD",
|
||||
]
|
||||
|
||||
# syscall 过滤:@system-service 集合再扣掉 io_uring 三件套
|
||||
#(Muse 点名禁用 io_uring)。
|
||||
SYSCALL_FILTER = ("@system-service ~io_uring_setup "
|
||||
"~io_uring_enter ~io_uring_register")
|
||||
|
||||
# 单元内遮掉的路径:浏览器(单元内跑不起自己的浏览器,
|
||||
# 只能走 broker 的 chv-browser)。只列 /usr 下的路径——
|
||||
# round-11 复测纠正(第二轮):--volatile=yes 下容器根是
|
||||
# 基本空的 tmpfs(只挂进只读 /usr),nspawn 按容器根解析
|
||||
# --inaccessible 路径,不存在的路径即启动失败;/etc/chv 与
|
||||
# /home/chv/.git-credentials 在容器根里根本不存在(无 /home、
|
||||
# 无 /etc/chv),凭证文件按构造就不可达,不再列出遮蔽。
|
||||
# 旧的两项若列回,cell 会报 Failed to resolve 而起不来。
|
||||
INACCESSIBLE_BASE = [
|
||||
"/etc/chv",
|
||||
"/home/chv/.git-credentials",
|
||||
"/usr/local/share/ms-playwright",
|
||||
]
|
||||
|
||||
@@ -117,9 +232,20 @@ def _playwright_package_dir():
|
||||
def build_nspawn_argv(command, network="veth",
|
||||
workspace=WORKSPACE, codex_home=CODEX_HOME,
|
||||
broker_dir=BROKER_DIR,
|
||||
playwright_package_dir=None):
|
||||
playwright_package_dir=None,
|
||||
cell_net=None, extra_ro_binds=None):
|
||||
"""产 nspawn 参数表(纯函数,可单测)。command 是单元内要跑的命令
|
||||
(list,如 ["python3", "/app/host.py", ...])。"""
|
||||
(list,如 ["python3", "/app/host.py", ...])。
|
||||
cell_net 为 None 时用旧常量身份(argv 子命令与旧测试);
|
||||
run_cell 一律传 allocate_cell_net() 的唯一身份。
|
||||
extra_ro_binds 是 [(src, dest)],只读绑进单元(如单元专属
|
||||
resolv.conf/hosts;/app 与 /etc/ssl/certs 是固定只读绑定,
|
||||
直接列在下面)。worktree 根不经 bind:WORKTREE_DIR 在已可写
|
||||
绑进单元的 workspace 之下(part 6,RETEST5),经
|
||||
--setenv=CHV_WORKTREE_ROOT 传进单元供 host.py 的
|
||||
worktree_base() 用;argv 里绝不出现指向 /app 之下(只读父
|
||||
目录)的 bind——挂载点不存在时 nspawn 在只读父目录下建不
|
||||
了挂载点,单元直接起不来。"""
|
||||
if not command:
|
||||
raise ValueError("chv-cell: 单元内命令不能为空")
|
||||
if network not in ("veth", "shared"):
|
||||
@@ -127,13 +253,26 @@ def build_nspawn_argv(command, network="veth",
|
||||
% network)
|
||||
if playwright_package_dir is None:
|
||||
playwright_package_dir = _playwright_package_dir()
|
||||
machine = cell_net.machine if cell_net is not None else MACHINE
|
||||
argv = [
|
||||
"systemd-nspawn", "--quiet",
|
||||
"--machine=%s" % MACHINE,
|
||||
"--machine=%s" % machine,
|
||||
"--directory=/",
|
||||
# round-11 真机修复:nspawn 255 拒裸 --directory=/(须
|
||||
# --ephemeral 或 --volatile),也拒裸 --no-new-privileges
|
||||
#(把它当作带必填参数的选项,吞掉下一个参数后报错)。
|
||||
# 2026-11-07 复测纠正:首轮修用的 --ephemeral 在客内
|
||||
# ext4 根盘(2.4G、已用 1.8G、可用 593M)上做整盘快照
|
||||
# 拷贝,空间不够即 ENOSPC,全部 cell 调用失败。改
|
||||
# --volatile=yes:根为基本空的 tmpfs、/usr 只读挂进,
|
||||
# 单元内除 bind 出去的目录外一律可写、用完即弃——与
|
||||
# --ephemeral 的设计意图一致,但不拷盘、不占空间。
|
||||
# --volatile=state 不行:整树只读、仅 /var 为 tmpfs,
|
||||
# codex 写 $HOME/.cache 等非 bind 路径会失败。
|
||||
"--volatile=yes",
|
||||
"--as-pid2",
|
||||
"--private-users=%d:%d" % (UID_BASE, UID_COUNT),
|
||||
"--no-new-privileges",
|
||||
"--no-new-privileges=yes",
|
||||
"--system-call-filter=%s" % SYSCALL_FILTER,
|
||||
]
|
||||
for cap in DROP_CAPABILITIES:
|
||||
@@ -144,12 +283,38 @@ def build_nspawn_argv(command, network="veth",
|
||||
"--bind=%s" % workspace,
|
||||
"--bind=%s" % codex_home,
|
||||
"--bind=%s" % broker_dir,
|
||||
# part 4(volatile 空根缺口):--volatile=yes 下单元根是
|
||||
# 基本空的 tmpfs,执行器默认在单元内跑 python3 /app/host.py、
|
||||
# codex 要做 DNS 与 TLS——/app、CA bundle、resolv.conf、
|
||||
# hosts 都须显式绑进去,否则单元内一律 ENOENT/无 DNS。
|
||||
# /app 与 /etc/ssl/certs 是镜像/系统固定路径,只读绑;
|
||||
# resolv.conf/hosts 是 run_cell 按客机有效 DNS 生成的
|
||||
# 单元专属文件,经 extra_ro_binds 传进来(见
|
||||
# prepare_cell_files:客机 /etc/resolv.conf 可能是
|
||||
# systemd-resolved stub(127.0.0.53),单元内无 resolved,
|
||||
# 直接绑 stub 即 DNS 全灭,故须生成真实服务器列表)。
|
||||
# 不绑 /etc/nsswitch.conf:客机版可能含 resolve 模块,
|
||||
# 单元内无 resolved/dbus,绑了反而可能挂起;glibc 缺
|
||||
# nsswitch.conf 时默认 files+dns,正是单元需要的。
|
||||
"--bind-ro=/app",
|
||||
# part 6(RETEST5):此处绝不再加 --bind=...:/app/work。
|
||||
# part 5 的该绑定已证不可行:/app/work 挂载点在 --bind-ro
|
||||
# 的 /app 镜像里不存在,nspawn 在只读父目录下建不了挂
|
||||
# 载点(EROFS),单元启动即失败。worktree 根已移出 /app,
|
||||
# 见 WORKTREE_DIR 与 --setenv=CHV_WORKTREE_ROOT。
|
||||
"--bind-ro=/etc/ssl/certs",
|
||||
"--tmpfs=/tmp",
|
||||
]
|
||||
for src, dest in (extra_ro_binds or []):
|
||||
argv.append("--bind-ro=%s:%s" % (src, dest))
|
||||
if network == "veth":
|
||||
argv.append("--network-veth")
|
||||
argv += [
|
||||
"--setenv=CHV_CELL=1",
|
||||
# part 6:单元内 worktree 根(host.py worktree_base() 读
|
||||
# 它;值与 WORKTREE_DIR 一致,客机侧与单元内是同一路径,
|
||||
# 因 workspace 以同名可写 bind 进单元)。
|
||||
"--setenv=CHV_WORKTREE_ROOT=%s" % WORKTREE_DIR,
|
||||
"--setenv=HOME=/home/chv",
|
||||
]
|
||||
for name in FORWARD_ENV_ALLOWLIST:
|
||||
@@ -184,32 +349,36 @@ def find_uplink_iface(run=None):
|
||||
return parse_default_route_iface(proc.stdout)
|
||||
|
||||
|
||||
def host_network_commands(uplink_iface):
|
||||
def host_network_commands(uplink_iface, net=None):
|
||||
"""客机侧配网命令表(纯函数):ve 接口起 IP + 转发 + NAT。
|
||||
调用方按顺序执行,任一失败即停。"""
|
||||
调用方按顺序执行,任一失败即停。net 为 None 时用旧常量
|
||||
(argv/network-commands 子命令与旧测试);run_cell 传
|
||||
allocate_cell_net() 的唯一身份。"""
|
||||
if not uplink_iface:
|
||||
raise ValueError("chv-cell: 上行口名为空,配不出 NAT")
|
||||
net = net or legacy_cell_net()
|
||||
return [
|
||||
["ip", "addr", "add", "%s/24" % HOST_IP, "dev", HOST_IFACE],
|
||||
["ip", "link", "set", HOST_IFACE, "up"],
|
||||
["ip", "addr", "add", "%s/24" % net.host_ip, "dev",
|
||||
net.host_iface],
|
||||
["ip", "link", "set", net.host_iface, "up"],
|
||||
["sysctl", "-w", "net.ipv4.ip_forward=1"],
|
||||
["iptables", "-t", "nat", "-A", "POSTROUTING",
|
||||
"-s", CELL_NET, "-o", uplink_iface, "-j", "MASQUERADE"],
|
||||
["iptables", "-A", "FORWARD", "-i", HOST_IFACE,
|
||||
"-s", net.subnet, "-o", uplink_iface, "-j", "MASQUERADE"],
|
||||
["iptables", "-A", "FORWARD", "-i", net.host_iface,
|
||||
"-o", uplink_iface, "-j", "ACCEPT"],
|
||||
["iptables", "-A", "FORWARD", "-i", uplink_iface,
|
||||
"-o", HOST_IFACE, "-m", "state", "--state",
|
||||
"-o", net.host_iface, "-m", "state", "--state",
|
||||
"RELATED,ESTABLISHED", "-j", "ACCEPT"],
|
||||
]
|
||||
|
||||
|
||||
def host_network_cleanup_commands(uplink_iface):
|
||||
def host_network_cleanup_commands(uplink_iface, net=None):
|
||||
"""配网清理命令表(与 host_network_commands 配对,-A 改 -D)。
|
||||
清理是 best-effort:删不掉(规则已不在)不报错。"""
|
||||
if not uplink_iface:
|
||||
raise ValueError("chv-cell: 上行口名为空,清不了 NAT")
|
||||
cmds = []
|
||||
for cmd in host_network_commands(uplink_iface):
|
||||
for cmd in host_network_commands(uplink_iface, net):
|
||||
if "iptables" not in cmd:
|
||||
continue
|
||||
cleaned = [c if c != "-A" else "-D" for c in cmd]
|
||||
@@ -217,37 +386,59 @@ def host_network_cleanup_commands(uplink_iface):
|
||||
return cmds
|
||||
|
||||
|
||||
def find_cell_init_pid(nspawn_pid, proc_root="/proc"):
|
||||
def find_cell_init_pid(nspawn_pid, proc_root="/proc", timeout=0.0,
|
||||
interval=0.1, proc_alive=None):
|
||||
"""找单元 init 的 pid:nspawn 进程的第一个子进程
|
||||
(--as-pid2 时它是 stub init,payload 是 PID 2;两者同 netns)。
|
||||
timeout=0(默认)即只查一次(旧行为);timeout>0 时带超时轮询
|
||||
(part 4 竞态修复:init 可能还没 fork,单次查找即误判)。
|
||||
proc_alive 供调用方传“nspawn 是否还活着”(如 proc.poll() is
|
||||
None 的 lambda):nspawn 已退出即立刻回 None,不再空等超时。
|
||||
找不到返回 None。"""
|
||||
try:
|
||||
task_dir = os.path.join(proc_root, str(nspawn_pid), "task")
|
||||
for tid in os.listdir(task_dir):
|
||||
children_path = os.path.join(task_dir, tid, "children")
|
||||
try:
|
||||
with open(children_path) as fh:
|
||||
children = fh.read().split()
|
||||
except OSError:
|
||||
continue
|
||||
if children:
|
||||
return int(children[0])
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
return None
|
||||
deadline = time.time() + timeout
|
||||
while True:
|
||||
found = None
|
||||
try:
|
||||
task_dir = os.path.join(proc_root, str(nspawn_pid), "task")
|
||||
for tid in os.listdir(task_dir):
|
||||
children_path = os.path.join(task_dir, tid, "children")
|
||||
try:
|
||||
with open(children_path) as fh:
|
||||
children = fh.read().split()
|
||||
except OSError:
|
||||
continue
|
||||
if children:
|
||||
try:
|
||||
found = int(children[0])
|
||||
except ValueError:
|
||||
continue
|
||||
break
|
||||
except OSError:
|
||||
# 任务目录还没出现(init 未 fork)即当作“这次没找到”,
|
||||
# 继续轮询,不直接回 None(旧代码这里直接 return None,
|
||||
# 正是单次查找误判的根因之一)。
|
||||
found = None
|
||||
if found is not None:
|
||||
return found
|
||||
if proc_alive is not None and not proc_alive():
|
||||
return None
|
||||
if time.time() >= deadline:
|
||||
return None
|
||||
time.sleep(min(interval, max(0.0, deadline - time.time())))
|
||||
|
||||
|
||||
def cell_network_commands(cell_pid):
|
||||
def cell_network_commands(cell_pid, net=None):
|
||||
"""单元内配网命令表(纯函数):nsenter 进单元 init 的 netns,
|
||||
给 host0 配 IP、起接口、加默认路由。"""
|
||||
给 host0 配 IP、起接口、加默认路由。net 为 None 时用旧常量。"""
|
||||
if not cell_pid:
|
||||
raise ValueError("chv-cell: 单元 init pid 为空,配不了单元内网络")
|
||||
net = net or legacy_cell_net()
|
||||
prefix = ["nsenter", "-t", str(cell_pid), "-n"]
|
||||
return [
|
||||
prefix + ["ip", "addr", "add", "%s/24" % CELL_IP,
|
||||
prefix + ["ip", "addr", "add", "%s/24" % net.cell_ip,
|
||||
"dev", CELL_IFACE],
|
||||
prefix + ["ip", "link", "set", CELL_IFACE, "up"],
|
||||
prefix + ["ip", "route", "add", "default", "via", HOST_IP],
|
||||
prefix + ["ip", "route", "add", "default", "via", net.host_ip],
|
||||
]
|
||||
|
||||
|
||||
@@ -264,6 +455,89 @@ def _ensure_bind_source(path, uid=CELL_UID, gid=CELL_GID):
|
||||
raise
|
||||
|
||||
|
||||
def gate_wrapper_command(command, gate_path):
|
||||
"""把单元内命令包一层起步门(纯函数):payload 先在单元内
|
||||
等 gate 文件出现再 exec 原命令。gate 文件由 run_cell 在配网
|
||||
完成后才创建(见 run_cell)——payload 与配网零同步是 RETEST3
|
||||
新缺陷 1 的根因(快 payload 在配网完成前退出,nspawn 随之
|
||||
退出,包装器再去配网即撞墙)。/usr/bin/sh 用绝对路径:
|
||||
volatile 单元根里 /bin 未必有,/usr 是只读挂进来的,必有。
|
||||
注意:$1 是 gate 路径,shift 后 "$@" 即原命令。"""
|
||||
script = ("while [ ! -e \"$1\" ]; do sleep 0.05; done; "
|
||||
"shift; exec \"$@\"")
|
||||
return ["/usr/bin/sh", "-c", script, "chv-cell-gate",
|
||||
gate_path] + list(command)
|
||||
|
||||
|
||||
def _parse_nameservers(path):
|
||||
"""读一个 resolv.conf 风格文件,返回 nameserver 列表
|
||||
(去 127.0.0.0/8 与 ::1——单元内无 resolved,stub 指过去
|
||||
即 DNS 全灭)。文件不存在或读不出即 []。"""
|
||||
servers = []
|
||||
try:
|
||||
with open(path) as fh:
|
||||
for line in fh:
|
||||
line = line.strip()
|
||||
if not line.startswith("nameserver"):
|
||||
continue
|
||||
parts = line.split()
|
||||
if len(parts) < 2:
|
||||
continue
|
||||
ip = parts[1]
|
||||
if ip.startswith("127.") or ip == "::1":
|
||||
continue
|
||||
if ip not in servers:
|
||||
servers.append(ip)
|
||||
except OSError:
|
||||
return []
|
||||
return servers
|
||||
|
||||
|
||||
def _effective_nameservers(resolv_conf="/etc/resolv.conf",
|
||||
resolved_conf="/run/systemd/resolve/resolv.conf"):
|
||||
"""客机有效 DNS:先读 /etc/resolv.conf;只剩 stub(127.0.0.53)
|
||||
或为空时读 resolved 的 resolv.conf;仍为空即大声失败。"""
|
||||
servers = _parse_nameservers(resolv_conf)
|
||||
if not servers:
|
||||
servers = _parse_nameservers(resolved_conf)
|
||||
if not servers:
|
||||
raise RuntimeError(
|
||||
"chv-cell: 客机无有效 DNS,配不出单元 resolv.conf")
|
||||
return servers
|
||||
|
||||
|
||||
def prepare_cell_files(machine, run_dir=BROKER_DIR,
|
||||
resolv_conf="/etc/resolv.conf",
|
||||
resolved_conf="/run/systemd/resolve/resolv.conf",
|
||||
hosts_file="/etc/hosts"):
|
||||
"""准备单元专属文件(run_cell 在 Popen 之前调),返回 dict:
|
||||
gate(起步门,run_cell 配网完成后才创建——注意 gate 文件本身
|
||||
在这里不创建,只给路径)、resolv(单元专属 resolv.conf,已写
|
||||
好)、hosts(单元专属 hosts,已写好)。文件都在 /run/chv 下
|
||||
(bind 进单元),0644(单元内 root 在客机侧是 chv,0600 的
|
||||
root 文件在单元内读不到;DNS/hosts 不含秘密,0644 安全)。
|
||||
/app 与 /etc/ssl/certs 是固定只读绑定,不走这里。"""
|
||||
gate = os.path.join(run_dir, ".cell-gate-%s" % machine)
|
||||
resolv = os.path.join(run_dir, "resolv.conf.%s" % machine)
|
||||
hosts = os.path.join(run_dir, "hosts.%s" % machine)
|
||||
servers = _effective_nameservers(resolv_conf, resolved_conf)
|
||||
with open(resolv, "w") as fh:
|
||||
for server in servers:
|
||||
fh.write("nameserver %s\n" % server)
|
||||
os.chmod(resolv, 0o644)
|
||||
try:
|
||||
with open(hosts_file) as src:
|
||||
content = src.read()
|
||||
except OSError:
|
||||
content = ""
|
||||
if "localhost" not in content:
|
||||
content = "127.0.0.1 localhost\n" + content
|
||||
with open(hosts, "w") as fh:
|
||||
fh.write(content)
|
||||
os.chmod(hosts, 0o644)
|
||||
return {"gate": gate, "resolv": resolv, "hosts": hosts}
|
||||
|
||||
|
||||
def _wait_for_iface(iface, timeout=10.0):
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
@@ -275,6 +549,127 @@ def _wait_for_iface(iface, timeout=10.0):
|
||||
return False
|
||||
|
||||
|
||||
def _alloc_lock(lock_path, timeout=30.0):
|
||||
"""拿分配锁(fcntl 排他锁,阻塞拿不到即超时大声失败)。
|
||||
返回锁 fd(调用方负责 close 即解锁)。"""
|
||||
fd = os.open(lock_path, os.O_CREAT | os.O_RDWR, 0o600)
|
||||
deadline = time.time() + timeout
|
||||
while True:
|
||||
try:
|
||||
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
return fd
|
||||
except BlockingIOError:
|
||||
if time.time() >= deadline:
|
||||
os.close(fd)
|
||||
raise RuntimeError(
|
||||
"chv-cell: 分配锁 %s 拿不到(超时 %.0f 秒)"
|
||||
% (lock_path, timeout))
|
||||
time.sleep(0.05)
|
||||
|
||||
|
||||
def _used_cell_indices(run):
|
||||
"""扫描已占用的 cell 下标:活着的 ve-chv-cell-<xx> 接口名、
|
||||
10.200.<N>.1/24 地址、machinectl 里仍在册的同名 machine
|
||||
(注销滞后时避让,见 RETEST3 新缺陷 1 的 veth 超时分析)。
|
||||
run 供单测注入(subprocess.run 兼容)。machinectl 查不到
|
||||
(无此命令/失败)即跳过该项,不因此失败。"""
|
||||
used = set()
|
||||
proc = run(["ip", "-o", "link", "show"], capture_output=True,
|
||||
text=True)
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError("chv-cell: 扫描已占用 cell 下标失败:"
|
||||
"ip -o link show")
|
||||
for m in re.finditer(r"\bve-chv-cell-([0-9a-f]{2})\b",
|
||||
proc.stdout or ""):
|
||||
try:
|
||||
used.add(int(m.group(1), 16))
|
||||
except ValueError:
|
||||
continue
|
||||
proc = run(["ip", "-o", "addr", "show"], capture_output=True,
|
||||
text=True)
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError("chv-cell: 扫描已占用 cell 下标失败:"
|
||||
"ip -o addr show")
|
||||
for m in re.finditer(r"\b10\.200\.(\d+)\.1/24\b",
|
||||
proc.stdout or ""):
|
||||
try:
|
||||
used.add(int(m.group(1)))
|
||||
except ValueError:
|
||||
continue
|
||||
try:
|
||||
proc = run(["machinectl", "list", "--no-legend"],
|
||||
capture_output=True, text=True)
|
||||
if proc.returncode == 0:
|
||||
for m in re.finditer(r"\bchv-cell-([0-9a-f]{2})\b",
|
||||
proc.stdout or ""):
|
||||
try:
|
||||
used.add(int(m.group(1), 16))
|
||||
except ValueError:
|
||||
continue
|
||||
except OSError:
|
||||
pass
|
||||
return used
|
||||
|
||||
|
||||
def allocate_cell_net(run=None, lock_path=ALLOC_LOCK_FILE):
|
||||
"""锁下分配唯一 cell 身份(run_cell 每调一次即分配一个)。
|
||||
取最小空闲下标 0..250;machine 名 chv-cell-<xx>(11 字符,
|
||||
ve- 前缀后 14 字符,符合接口名 15 字符上限);子网
|
||||
10.200.<idx>.0/24、宿主 10.200.<idx>.1、单元 10.200.<idx>.2。
|
||||
全部下标占满即大声失败,不静默回落固定身份。"""
|
||||
run = run or subprocess.run
|
||||
fd = _alloc_lock(lock_path)
|
||||
try:
|
||||
used = _used_cell_indices(run)
|
||||
for index in range(MAX_CELL_INDEX + 1):
|
||||
if index not in used:
|
||||
break
|
||||
else:
|
||||
raise RuntimeError(
|
||||
"chv-cell: cell 下标已占满(0..%d)" % MAX_CELL_INDEX)
|
||||
machine = "chv-cell-%02x" % index
|
||||
return CellNet(
|
||||
index=index,
|
||||
machine=machine,
|
||||
host_iface="ve-%s" % machine,
|
||||
host_ip="%s.%d.1" % (CELL_NET_BASE, index),
|
||||
cell_ip="%s.%d.2" % (CELL_NET_BASE, index),
|
||||
subnet="%s.%d.0/24" % (CELL_NET_BASE, index))
|
||||
finally:
|
||||
try:
|
||||
fcntl.flock(fd, fcntl.LOCK_UN)
|
||||
except OSError:
|
||||
pass
|
||||
os.close(fd)
|
||||
|
||||
|
||||
def _write_cell_pid_files(machine, cell_pid):
|
||||
"""写 pid 文件:每单元一份 cell-<machine>.pid + 兼容旧路径
|
||||
/run/chv/cell.pid(最新单元;P2-9 驱动与 broker 降级路径
|
||||
仍读它)。"""
|
||||
per_cell = os.path.join(BROKER_DIR, "cell-%s.pid" % machine)
|
||||
with open(per_cell, "w") as fh:
|
||||
fh.write(str(cell_pid))
|
||||
with open(CELL_PID_FILE, "w") as fh:
|
||||
fh.write(str(cell_pid))
|
||||
|
||||
|
||||
def _remove_cell_pid_files(machine, cell_pid):
|
||||
"""清 pid 文件(best-effort):每单元份必删;旧路径只在
|
||||
内容仍指向本单元时才删(并发 cell 时别删别人的指针)。"""
|
||||
for path in (os.path.join(BROKER_DIR, "cell-%s.pid" % machine),):
|
||||
try:
|
||||
os.remove(path)
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
with open(CELL_PID_FILE) as fh:
|
||||
if fh.read().strip() == str(cell_pid):
|
||||
os.remove(CELL_PID_FILE)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _run_cmds(cmds, what):
|
||||
for cmd in cmds:
|
||||
print("chv-cell: + %s" % " ".join(cmd), file=sys.stderr)
|
||||
@@ -286,7 +681,13 @@ def _run_cmds(cmds, what):
|
||||
|
||||
|
||||
def run_cell(command, network="veth"):
|
||||
"""起单元、配网、等 payload 退出、清 NAT,返回 payload 退出码。"""
|
||||
"""起单元、配网、等 payload 退出、清 NAT,返回 payload 退出码。
|
||||
part 4 顺序(RETEST3 新缺陷 1):分配唯一身份 → 准备单元专属
|
||||
文件 → Popen 带起步门的 payload(payload 在门后等着,不与配网
|
||||
并发)→ 等 veth → 客机侧配网 → 轮询找 init pid → 写 pid 文件 →
|
||||
单元内配网 → 开门放行 payload → proc.wait()。payload 已退出
|
||||
(nspawn 起不来等)时返回它的退出码而不是抛错;其余配网失败
|
||||
仍大声抛错,不静默回落共享网络。"""
|
||||
if os.geteuid() != 0:
|
||||
print("chv-cell: 须以 root 运行(nspawn + 配网都要 root)",
|
||||
file=sys.stderr)
|
||||
@@ -294,39 +695,73 @@ def run_cell(command, network="veth"):
|
||||
_ensure_bind_source(WORKSPACE)
|
||||
_ensure_bind_source(CODEX_HOME)
|
||||
_ensure_bind_source(BROKER_DIR, uid=0, gid=0)
|
||||
argv = build_nspawn_argv(command, network=network)
|
||||
print("chv-cell: 启动单元:%s" % " ".join(argv), file=sys.stderr)
|
||||
proc = subprocess.Popen(argv)
|
||||
# part 6:worktree 根目录须在 Popen 前存在,且属 chv(单元内
|
||||
# root 即客机 chv,属主不对就写不进 worktree)。它在 workspace
|
||||
# 绑定之下,进单元后 host.py 的 makedirs 也能建,这里预建只
|
||||
# 是把属主一次定对;不再是任何 bind 的源(无 /app/work 绑定)。
|
||||
_ensure_bind_source(WORKTREE_DIR)
|
||||
net = None
|
||||
files = None
|
||||
uplink = None
|
||||
cell_pid = None
|
||||
proc = None
|
||||
if network == "veth":
|
||||
net = allocate_cell_net()
|
||||
files = prepare_cell_files(net.machine)
|
||||
wrapped = gate_wrapper_command(command, files["gate"])
|
||||
else:
|
||||
wrapped = list(command)
|
||||
argv = build_nspawn_argv(
|
||||
wrapped, network=network, cell_net=net,
|
||||
extra_ro_binds=[(files["resolv"], "/etc/resolv.conf"),
|
||||
(files["hosts"], "/etc/hosts")]
|
||||
if files else None)
|
||||
print("chv-cell: 启动单元:%s" % " ".join(argv), file=sys.stderr)
|
||||
proc = subprocess.Popen(argv)
|
||||
try:
|
||||
if network == "veth":
|
||||
if not _wait_for_iface(HOST_IFACE):
|
||||
# veth 等待放宽到 20 秒:RETEST3 里连续调用在 10.2/10.3
|
||||
# 秒处超时(疑似 machine 注销滞后);唯一身份 + 起步门
|
||||
# 后配网不再与 payload 赛跑,等久一点无害。
|
||||
if not _wait_for_iface(net.host_iface, timeout=20.0):
|
||||
raise RuntimeError(
|
||||
"chv-cell: veth 接口 %s 未出现,拒绝回落共享网络"
|
||||
% HOST_IFACE)
|
||||
% net.host_iface)
|
||||
uplink = find_uplink_iface()
|
||||
if not uplink:
|
||||
raise RuntimeError(
|
||||
"chv-cell: 客机无默认路由,配不出 NAT")
|
||||
_run_cmds(host_network_commands(uplink), "客机侧配网")
|
||||
cell_pid = find_cell_init_pid(proc.pid)
|
||||
_run_cmds(host_network_commands(uplink, net), "客机侧配网")
|
||||
cell_pid = find_cell_init_pid(
|
||||
proc.pid, timeout=10.0,
|
||||
proc_alive=lambda: proc.poll() is None)
|
||||
if not cell_pid:
|
||||
raise RuntimeError("chv-cell: 找不到单元 init pid")
|
||||
with open(CELL_PID_FILE, "w") as fh:
|
||||
fh.write(str(cell_pid))
|
||||
_run_cmds(cell_network_commands(cell_pid), "单元内配网")
|
||||
_write_cell_pid_files(net.machine, cell_pid)
|
||||
_run_cmds(cell_network_commands(cell_pid, net), "单元内配网")
|
||||
# 配网完成,开门放行 payload。
|
||||
with open(files["gate"], "w") as fh:
|
||||
fh.write("")
|
||||
return proc.wait()
|
||||
except RuntimeError:
|
||||
# nspawn 已死(如 machine 名冲突、argv 错)即返回它的退出
|
||||
# 码,不把“起不来”报成配网失败;还活着才是真配网失败,大声抛。
|
||||
if proc is not None and proc.poll() is not None:
|
||||
return proc.returncode
|
||||
raise
|
||||
finally:
|
||||
if uplink:
|
||||
for cmd in host_network_cleanup_commands(uplink):
|
||||
for cmd in host_network_cleanup_commands(uplink, net):
|
||||
subprocess.run(cmd, capture_output=True)
|
||||
try:
|
||||
if os.path.exists(CELL_PID_FILE):
|
||||
os.remove(CELL_PID_FILE)
|
||||
except OSError:
|
||||
pass
|
||||
if proc.poll() is None:
|
||||
if cell_pid:
|
||||
_remove_cell_pid_files(net.machine, cell_pid)
|
||||
if files:
|
||||
for key in ("gate", "resolv", "hosts"):
|
||||
try:
|
||||
os.remove(files[key])
|
||||
except OSError:
|
||||
pass
|
||||
if proc is not None and proc.poll() is None:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=10)
|
||||
|
||||
+16
-19
@@ -14,11 +14,12 @@
|
||||
# 只给客内 authd 守护进程读)。
|
||||
# boot(root,cloud-init runcmd 调)读 guest-env,authd 在时配
|
||||
# git credential helper 走 authd 按次取凭证(只进 git 子进程
|
||||
# 内存,不落盘);authd 不在时(旧镜像)才回落到把 token 写进
|
||||
# /home/chv/.git-credentials(0600,chv 所有,git credential
|
||||
# store 标准机制)并警告。pull/push/status 以 chv 身份跑,用
|
||||
# helper 或已落盘的 credential,不再读 guest-env;token 不会
|
||||
# 出现在 git remote URL 或镜像里。
|
||||
# 内存,不落盘);authd 不在时直接失败(round-11 真机修复层 5:
|
||||
# 旧镜像的落盘回落已废止——它在真机测试里把 authd 释放的长效
|
||||
# token 写进了客内 ~/.git-credentials;authd helper 是唯一凭证
|
||||
# helper,且自身绝不落盘)。pull/push/status 以 chv 身份跑,用
|
||||
# helper,不再读 guest-env;token 不会出现在 git remote URL 或
|
||||
# 镜像里。
|
||||
#
|
||||
# 用法:
|
||||
# chv-sync boot # 启动时 clone(已 clone 则静默退出,不自动 pull)
|
||||
@@ -79,19 +80,16 @@ cmd_boot() {
|
||||
: "${GITEA_USER:?guest-env 缺 GITEA_USER}"
|
||||
: "${SYNC_REPO:?guest-env 缺 SYNC_REPO}"
|
||||
[ "$(id -u)" = "0" ] || die "boot 须以 root 运行(cloud-init runcmd)"
|
||||
local host="${GITEA_URL#https://}"
|
||||
host="${host#http://}"
|
||||
host="${host%%/*}"
|
||||
local name="${SYNC_REPO##*/}"
|
||||
local dir="$WORKSPACE/$name"
|
||||
mkdir -p "$WORKSPACE"
|
||||
chown "$SYNC_USER:$SYNC_USER" "$WORKSPACE"
|
||||
# feat-chv-muse-p3:authd 路径——socket 存在时凭证走 helper,
|
||||
# 不再把真 token 写进 .git-credentials 落盘;helper 按次向
|
||||
# authd 取凭证,只进 git 子进程内存。socket 不存在(旧镜像)
|
||||
# 时走旧路径 + 警告(过渡兼容,旧镜像退役后改为 fail-closed)。
|
||||
# authd 取凭证,只进 git 子进程内存。socket 不存在时直接失败
|
||||
#(round-11 真机修复层 5:落盘回落已废止,见下)。
|
||||
# 注意:authd 路径下 GITEA_TOKEN 不在 guest-env 里(它只进
|
||||
# authd-seed 给守护进程),所以这里不要求它;旧路径才要求。
|
||||
# authd-seed 给守护进程),所以这里不要求它。
|
||||
local authd_socket="${CHV_AUTHD_SOCKET:-/run/chv-authd/authd.sock}"
|
||||
local helper="/usr/local/bin/git-credential-chv-authd"
|
||||
if [ -S "$authd_socket" ] && [ -x "$helper" ]; then
|
||||
@@ -102,14 +100,13 @@ cmd_boot() {
|
||||
runuser -u "$SYNC_USER" -- env HOME="$USER_HOME" git config --global credential.useHttpPath true
|
||||
info "凭证走 chv-authd(不落盘)"
|
||||
else
|
||||
: "${GITEA_TOKEN:?guest-env 缺 GITEA_TOKEN(旧路径)}"
|
||||
echo "chv-sync: 警告:chv-authd 未运行,凭证走旧落盘路径(须用含 authd 的新镜像)" >&2
|
||||
# token 只落盘进用户家目录的 credential store(0600),
|
||||
# 不进 git remote URL、不进镜像、不进日志(此处不打印 token)。
|
||||
local creds="$USER_HOME/.git-credentials"
|
||||
printf 'https://%s:%s@%s\n' "$GITEA_USER" "$GITEA_TOKEN" "$host" > "$creds"
|
||||
chmod 0600 "$creds"
|
||||
chown "$SYNC_USER:$SYNC_USER" "$creds"
|
||||
# round-11 真机修复(层 5):authd 不在时直接失败,不再
|
||||
# 把真 token 写进 ~/.git-credentials 落盘——旧路径在
|
||||
# round-11 真机测试里把 authd 释放的长效 token 写进了
|
||||
# 客内磁盘(/home/chv/.git-credentials,0600)。authd
|
||||
# helper 是唯一凭证 helper,且自身绝不落盘;旧镜像退役
|
||||
# 后过渡兼容即废止,不再留落盘逃生口。
|
||||
die "chv-authd 未运行(socket 或 helper 缺失),拒绝同步:凭证绝不落盘,请用含 authd 的镜像"
|
||||
fi
|
||||
if [ -d "$dir/.git" ]; then
|
||||
info "已 clone:$dir(不自动 pull,改动由用户显式同步)"
|
||||
|
||||
@@ -51,6 +51,21 @@ DECLARED_SOURCES = {
|
||||
"chv-cell": "repo_commit",
|
||||
"chv-broker": "repo_commit",
|
||||
"chv-browser": "repo_commit",
|
||||
# 对齐 Muse P3 + P1 vsock:authd 三件套、authd/sentinel
|
||||
# 叶子模块与 vsock 通知脚本,均是仓库内脚本,版本随
|
||||
# repo_commit。缺了它们 generate 会记 6 项 unresolved,
|
||||
# verify 与 check 双双失败(717efc4 真机实测)。
|
||||
# round-11 真机修复(层 1):叶子树新增 errors.py 与 stub
|
||||
# __init__.py(authd_init_stub.py),同款 repo_commit 登记,
|
||||
# 缺了它们新镜像的 lockfile 同样会 unresolved。
|
||||
"chv-authd": "repo_commit",
|
||||
"git-credential-chv-authd": "repo_commit",
|
||||
"chv-authd.service": "repo_commit",
|
||||
"cloud/chv/authd.py": "repo_commit",
|
||||
"cloud/chv/sentinel.py": "repo_commit",
|
||||
"cloud/chv/errors.py": "repo_commit",
|
||||
"cloud/chv/__init__.py": "repo_commit",
|
||||
"vsock_notify": "repo_commit",
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user