From 7cba3c2b212d5bcc0ff8cdfa39623939fd70f1c4 Mon Sep 17 00:00:00 2001 From: host Date: Sun, 8 Nov 2026 09:48:16 +0000 Subject: [PATCH] =?UTF-8?q?fix(chv-muse-r12fix3-2):=20worker=20socket=20?= =?UTF-8?q?=E7=BB=84=E6=9D=83=E9=99=90=20+=20allowed=5Fuids=20=E5=90=AB=20?= =?UTF-8?q?chv=EF=BC=88P6=20=E7=BC=BA=E5=8F=A3=20=E2=91=A1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R12 复测 2 钉死:即使 worker active,chv 客户端也永远连不上 socket——worker root + 目录 /run/chv-gitea 0700 root + socket 0600 root + allowed_uids=(0,) 三处锁死;chv ping 一律 EACCES (rc=2),root ping rc=0,privsep_worker_call 恒为 0,全部同步 走回落。 修法:三处配套改,只改一处仍锁死—— 1. chv-gitea-worker.py:DEFAULT_ALLOWED_UIDS=(0, 1000)(root + chv;round-11 authd 同款;环境变量 CHV_GITEA_WORKER_ALLOWED_UIDS 可覆盖);socket 用 group 权限 (目录 0750、socket 0660、组为第一个非 root 放行 uid 的主组, _socket_group_gid/_set_group_perms 与 authd 同款实现;gid 解析不出时回落 root-only 0700/0600,绝不放给全员)。 拒绝 0666 探针口径。 2. build.sh tmpfiles.d:d /run/chv-gitea 改为 0750 root 1000,与 新方案一致。gid 写数字 1000 而不用组名 chv——tmpfiles-setup 跑在 07:23:24,chv 用户由 cloud-init 在 07:23:40 才建,组名 当时解析不出;1000 是 chv 的 uid/gid(seed users 块未指定 uid/gid,cloud-init 取首个空闲 1000;authd 同款假设)。 worker 启动时再按 passwd 重新推导 gid 并重设权限,tmpfiles 行只是开机存在性保证。审计日志仍 0600 root,不动。 3. 服务单元 ReadWritePaths 不动(/run/chv-gitea 仍在列,存在性 由 tmpfiles 保证)。 注意:单元内 agent 同样映射为 chv uid 1000,故也能走到 socket, 但只能走类型化协议——操作白名单、仓库 confinement、安全扫描三 处仍在,worker 不持凭证(见 worker 模块注释)。 测试:WorkerSocketGroupPermsTest 新增 7 项——默认白名单含 (0,1000)、环境变量覆盖、gid 可推导时 0750/0660 + 组正确、显式 socket_group 时同样组权限、只有 root 时回落 0700/0600、gid 推导 函数四档、tmpfiles 行 0750 root 1000;WorkerUnitTmpfilesTest 的 /run/chv-gitea 期望改 0750。P6/round12/P2/P4 四模块 205 项全过。 --- chv-guest/build.sh | 11 ++- chv-guest/chv-gitea-worker.py | 100 ++++++++++++++++++---- cloud/tests/test_chv_muse_p6.py | 142 +++++++++++++++++++++++++++++++- docs/chv-muse-p6-gap.md | 17 ++-- 4 files changed, 243 insertions(+), 27 deletions(-) diff --git a/chv-guest/build.sh b/chv-guest/build.sh index f9d2d4e..45a26d3 100755 --- a/chv-guest/build.sh +++ b/chv-guest/build.sh @@ -407,7 +407,16 @@ VC_ARGS=(-a "$OUT/disk.raw" # 跑、早于 multi-user 的服务启动,且与 P4 的 /run/chv 同款写法, # 不新增进镜像的文件(chv.conf 本就是 build.sh 内联生成), # lockfile 无需登记。模式与 worker 审计 0600 同款口径。 - --run-command 'printf "d /run/chv 2775 root chv-browser -\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 -\nd /run/chv-gitea 0700 root root -\nf /var/log/chv-gitea-worker.log 0600 root root -\n" > /etc/tmpfiles.d/chv.conf && systemctl enable chv-broker chv-browser chv-cdp-broker || true') + # round-12 修复轮第三轮(缺口 ②):/run/chv-gitea 改为组权限 + # 0750,与 worker 新方案一致(目录 0750 + socket 0660,组为 + # chv 主组;绝不用 0666)。gid 写数字 1000 而不用组名 chv—— + # tmpfiles-setup 跑在 07:23:24,chv 用户由 cloud-init 在 + # 07:23:40 才建,组名当时解析不出;1000 是 chv 的 uid/gid + #(seed users 块未指定 uid/gid,cloud-init 取首个空闲 1000; + # authd DEFAULT_ALLOWED_UIDS 同款假设)。worker 启动时再按 + # passwd 重新推导 gid 并重设目录/socket 权限,tmpfiles 行只是 + # 开机存在性保证。 + --run-command 'printf "d /run/chv 2775 root chv-browser -\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 -\nd /run/chv-gitea 0750 root 1000 -\nf /var/log/chv-gitea-worker.log 0600 root root -\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 diff --git a/chv-guest/chv-gitea-worker.py b/chv-guest/chv-gitea-worker.py index 3df6df9..61afc16 100644 --- a/chv-guest/chv-gitea-worker.py +++ b/chv-guest/chv-gitea-worker.py @@ -9,8 +9,12 @@ chv-gitea-worker.service;P2 的 nspawn 单元落地后,codex 在单元内, socket)。它只做 Gitea 同步的 git 业务逻辑: - 在 Unix socket /run/chv-gitea/worker.sock 上接受 broker/薄 CLI - 的类型化请求(SO_PEERCRED 只放行 root;agent 在单元内只能请 - broker 代办,连不上本 socket); + 的类型化请求(SO_PEERCRED 只放行 root 与 chv;socket 用 group + 权限——目录 0750、socket 0660、组为第一个非 root 放行 uid 的主 + 组,round-12 修复轮第三轮缺口 ②,round-11 authd 同款口径,拒绝 + 0666;chv-sync 薄客户端以 chv 身份连,单元内 agent 同样映射为 + chv uid 1000,故也只能走类型化协议——操作白名单、仓库 + confinement、安全扫描三处仍在,worker 不持凭证); - 操作白名单只有 pull/push/status/ping;仓库只限 ~/workspace 下; - git 以 chv 身份跑(runuser -u chv,文件归属不变),argv 里只配 credential helper(P3 的 git-credential-chv-authd,见下),绝无 @@ -40,6 +44,10 @@ DEFAULT_SOCKET_PATH = "/run/chv-gitea/worker.sock" DEFAULT_LOG_PATH = "/var/log/chv-gitea-worker.log" WORKSPACE = "/home/chv/workspace" SYNC_USER = "chv" +# SO_PEERCRED 放行的 uid:root + chv(chv-sync 以 chv 身份跑薄客户 +# 端;round-11 authd 同款 DEFAULT_ALLOWED_UIDS)。可用环境变量 +# CHV_GITEA_WORKER_ALLOWED_UIDS 覆盖(逗号分隔)。 +DEFAULT_ALLOWED_UIDS = (0, 1000) # P3 的 credential helper(git 经它向 authd 按次取凭证)与 path # 配置(P3 修过的坑:git 默认不把 path 发给 helper)。 HELPER_PATH = "/usr/local/bin/git-credential-chv-authd" @@ -124,6 +132,45 @@ def _truncate(text, limit=8192): return text +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 authd 同款口径;0666 探针口径不上生产)。""" + 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 + + class WorkerHandler(socketserver.BaseRequestHandler): """单次请求处理(每连接一 JSON 行):鉴权 → 校验 → 跑 git → 扫描输出 → 回包。任何一步拒绝都回 {ok:false},不含值。""" @@ -259,11 +306,13 @@ class WorkerServer(socketserver.ThreadingUnixStreamServer): allow_reuse_address = False def __init__(self, socket_path, workspace, log_path, - allowed_uids=(0,), run_git=None, - helper_path=HELPER_PATH): + allowed_uids=None, run_git=None, + helper_path=HELPER_PATH, socket_group=None): self.workspace = workspace self.log_path = log_path - self.allowed_uids = frozenset(allowed_uids) + self.allowed_uids = frozenset( + allowed_uids if allowed_uids is not None + else DEFAULT_ALLOWED_UIDS) self.run_git = run_git self.helper_path = helper_path try: @@ -271,20 +320,27 @@ class WorkerServer(socketserver.ThreadingUnixStreamServer): self.cgroup_id = fh.readline().strip() except OSError: self.cgroup_id = "" + # round-12 修复轮第三轮(缺口 ②):socket 用 group 权限。 + # gid 取 socket_group 显式值或第一个非 root 放行 uid 的 + # 主组;解析不出即 None,回落 root-only(0700/0600)。 + # tmpfiles.d 的 0750 行只是开机存在性保证,worker 启动时 + # 按 passwd 重新推导并重设权限,才是最终口径。 + self.socket_gid = _socket_group_gid( + self.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, WorkerHandler) - 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) @@ -296,15 +352,23 @@ def build_server(socket_path=DEFAULT_SOCKET_PATH, lib_dir=DEFAULT_LIB_DIR, allowed_uids=None, run_git=None, - helper_path=HELPER_PATH): + helper_path=HELPER_PATH, + socket_group=None): """建服务(不启动):socket + 工作区 + 审计路径就绪即返回。 helper 缺失不在建服务时报错——建服务时只备好 socket,git - 调用时再拒绝(fail-closed,见 _serve)。""" + 调用时再拒绝(fail-closed,见 _serve)。 + allowed_uids 不给时取环境变量 CHV_GITEA_WORKER_ALLOWED_UIDS + (逗号分隔),再不给即默认 (0, 1000)(root + chv)。""" if lib_dir not in sys.path: sys.path.insert(0, lib_dir) - uids = list(allowed_uids) if allowed_uids is not None else [0] - return WorkerServer(socket_path, workspace, log_path, uids, - run_git=run_git, helper_path=helper_path) + if allowed_uids is None: + raw = os.environ.get("CHV_GITEA_WORKER_ALLOWED_UIDS") or "" + allowed_uids = ([int(p) for p in raw.split(",") if p.strip()] + if raw.strip() else list(DEFAULT_ALLOWED_UIDS)) + return WorkerServer(socket_path, workspace, log_path, + allowed_uids, run_git=run_git, + helper_path=helper_path, + socket_group=socket_group) def main(argv=None): diff --git a/cloud/tests/test_chv_muse_p6.py b/cloud/tests/test_chv_muse_p6.py index 450f5b1..d4ed525 100644 --- a/cloud/tests/test_chv_muse_p6.py +++ b/cloud/tests/test_chv_muse_p6.py @@ -844,7 +844,7 @@ class WorkerUnitTmpfilesTest(unittest.TestCase): # /run 下的目录(tmpfs,开机不存在)须有 d 行; # 文件路径(worker 首次写审计才建)须有 f 行。 self.assertEqual(entries.get("/run/chv-gitea"), - ("d", "0700")) + ("d", "0750")) self.assertEqual(entries.get("/var/log/chv-gitea-worker.log"), ("f", "0600")) for path in paths: @@ -957,6 +957,146 @@ class WorkerUnitBootRaceTest(unittest.TestCase): (REPO_ROOT / "chv-guest" / name).read_text()) +# -- (7d) worker socket 组权限 ---------------------------------------------- +class WorkerSocketGroupPermsTest(unittest.TestCase): + """round-12 修复轮第三轮(缺口 ②,P6):R12 复测 2 钉死——worker + root + 目录 0700 root + socket 0600 root + allowed_uids=(0,) + 三处锁死,chv 客户端永远 EACCES,全部同步走回落。 + + 修法:三处配套改,只改一处仍锁死——目录 0750 + socket 0660 + (组为第一个非 root 放行 uid 的主组,round-11 authd 同款口径, + 拒绝 0666)+ allowed_uids 默认含 chv(0, 1000)。gid 解析不 + 出时回落 root-only(0700/0600),绝不放给全员。 + """ + + def _build(self, tmp, allowed_uids, socket_group=None): + mod = _load_guest_worker() + helper = os.path.join(tmp, "helper") + Path(helper).write_text("#!/bin/sh\n") + os.makedirs(os.path.join(tmp, "workspace"), exist_ok=True) + server = mod.build_server( + socket_path=os.path.join(tmp, "worker.sock"), + workspace=os.path.join(tmp, "workspace"), + log_path=os.path.join(tmp, "worker.log"), + lib_dir=str(REPO_ROOT), + allowed_uids=allowed_uids, + run_git=lambda a, r: (0, "ok\n", ""), + helper_path=helper, + socket_group=socket_group) + return mod, server + + def _mode(self, path): + return oct(os.stat(path).st_mode & 0o777) + + def _first_non_root_uid(self): + # 测试跑在 root 身份下(本 VM 的 unittest 即 root), + # os.getuid() 不是可推导的非 root uid——从 passwd 里动态 + # 取第一个非 root 用户;没有即跳过(不在本机硬编 uid)。 + import pwd + + for entry in pwd.getpwall(): + if entry.pw_uid != 0: + return entry.pw_uid, entry.pw_gid + return None, None + + def test_default_allowed_uids_include_chv(self): + import unittest.mock as mock + + mod = _load_guest_worker() + with tempfile.TemporaryDirectory() as tmp: + with mock.patch.dict(os.environ): + os.environ.pop("CHV_GITEA_WORKER_ALLOWED_UIDS", None) + _, server = self._build(tmp, None) + try: + self.assertEqual(server.allowed_uids, + frozenset({0, 1000})) + finally: + server.server_close() + + def test_env_override_allowed_uids(self): + import unittest.mock as mock + + mod = _load_guest_worker() + with tempfile.TemporaryDirectory() as tmp: + with mock.patch.dict( + os.environ, + {"CHV_GITEA_WORKER_ALLOWED_UIDS": "0"}): + _, server = self._build(tmp, None) + try: + self.assertEqual(server.allowed_uids, frozenset({0})) + finally: + server.server_close() + + def test_group_perms_when_gid_resolvable(self): + uid, gid = self._first_non_root_uid() + if uid is None: + self.skipTest("passwd 里无非 root 用户,推导无从测试") + with tempfile.TemporaryDirectory() as tmp: + _, server = self._build(tmp, [0, uid]) + try: + self.assertEqual(server.socket_gid, gid) + self.assertEqual(self._mode(tmp), "0o750") + sock = os.path.join(tmp, "worker.sock") + self.assertEqual(self._mode(sock), "0o660") + self.assertEqual(os.stat(sock).st_gid, gid) + finally: + server.server_close() + + def test_group_perms_with_explicit_socket_group(self): + # socket_group 显式给组名时不走 uid 推导:allowed 只有 root + # 也能拿到组权限(0750/0660),gid 即该组。 + import grp + + with tempfile.TemporaryDirectory() as tmp: + _, server = self._build(tmp, [0], socket_group="root") + try: + self.assertEqual(server.socket_gid, + grp.getgrnam("root").gr_gid) + self.assertEqual(self._mode(tmp), "0o750") + self.assertEqual( + self._mode(os.path.join(tmp, "worker.sock")), + "0o660") + finally: + server.server_close() + + def test_root_only_fallback_when_gid_unresolvable(self): + with tempfile.TemporaryDirectory() as tmp: + _, server = self._build(tmp, [0]) + try: + self.assertIsNone(server.socket_gid) + self.assertEqual(self._mode(tmp), "0o700") + self.assertEqual( + self._mode(os.path.join(tmp, "worker.sock")), + "0o600") + finally: + server.server_close() + + def test_socket_group_gid_derivation(self): + import grp + + mod = _load_guest_worker() + uid, gid = self._first_non_root_uid() + if uid is None: + self.skipTest("passwd 里无非 root 用户,推导无从测试") + self.assertEqual(mod._socket_group_gid([0, uid]), gid) + self.assertEqual( + mod._socket_group_gid([0, uid], socket_group="root"), + grp.getgrnam("root").gr_gid) + self.assertIsNone(mod._socket_group_gid([0])) + self.assertIsNone( + mod._socket_group_gid([0, uid], + socket_group="no-such-group-xyz")) + + def test_tmpfiles_worker_dir_group_perms(self): + # tmpfiles 行须与新方案一致:0750 root + chv 的 gid。gid 写 + # 数字 1000 而不用组名——tmpfiles-setup 时 chv 用户还不存在, + # 组名解析不出;worker 启动时再按 passwd 重设。 + text = (REPO_ROOT / "chv-guest" / "build.sh").read_text() + match = re.search(r"d /run/chv-gitea (\S+) (\S+) (\S+)", text) + self.assertIsNotNone(match, "chv.conf 里找不到 /run/chv-gitea 行") + self.assertEqual(match.groups(), ("0750", "root", "1000")) + + class BuildInfoContentsTest(unittest.TestCase): """round-12 修复轮(项 2,P6):worker 三件须进 BUILDINFO contents(无条件拷入,与 P2/P4 同款钉法)。""" diff --git a/docs/chv-muse-p6-gap.md b/docs/chv-muse-p6-gap.md index 1af1be1..cfb9bb2 100644 --- a/docs/chv-muse-p6-gap.md +++ b/docs/chv-muse-p6-gap.md @@ -48,12 +48,13 @@ P3(authd/sentinel 本体——只读、不重写、不改接口)、不碰 P4 (token/key/口令模式脱敏)+ `audit_inference_request/response` (只记指纹与 verdict,不记值;失败 best-effort)。 - `chv-guest/chv-gitea-worker.py`(新,标准库守护进程):Unix socket - `/run/chv-gitea/worker.sock`(0700 目录、0600 socket);SO_PEERCRED - 只放行 root(broker 走 root 连);操作白名单 pull/push/status/ - ping;仓库只限 `~/workspace` 下(realpath + commonpath);git 以 - chv 身份跑(`runuser -u chv`,文件归属不变),argv 里只配 - credential helper、绝无 token;输出先过 `safety` 扫描 + 来源标记 - 再返回;超时 300 秒;异常只断本连接。 + `/run/chv-gitea/worker.sock`(目录 0750、socket 0660、组为 chv 主组, + round-12 修复轮第三轮缺口 ②,round-11 authd 同款口径,拒绝 + 0666);SO_PEERCRED 只放行 root 与 chv(chv-sync 薄客户端以 chv + 身份连);操作白名单 pull/push/status/ping;仓库只限 `~/workspace` + 下(realpath + commonpath);git 以 chv 身份跑(`runuser -u chv`, + 文件归属不变),argv 里只配 credential helper、绝无 token;输出先 + 过 `safety` 扫描 + 来源标记再返回;超时 300 秒;异常只断本连接。 - `chv-guest/chv-gitea-worker.service`(新):systemd 单元(root 运行, 单元外;`ProtectSystem=strict`、`PrivateTmp`、`NoNewPrivileges`、 `CapabilityBoundingSet=` 空、`RestrictAddressFamilies=AF_UNIX AF_INET @@ -168,7 +169,9 @@ Base64、零宽字符)、藏在图片/附件/二进制里的指令、需要理 - worker 的 systemd 沙箱在真机上生效(`systemctl status`、cgroup 路径、受限调用被挡的表现)。 - broker → worker → helper → authd → git 全链路在真机上跑通 - pull/push(真 Gitea、真 token 替身)。 + pull/push(真 Gitea、真 token 替身)。round-12 修复轮第三轮 + (缺口 ②)已改 socket 组权限 + allowed_uids 含 chv,待真机复测 + 3 验证 chv ping rc=0 且 privsep_worker_call > 0。 - worker 崩溃后 agent 侧只收到类型化失败、任务不炸(杀 worker 进程复现)。 - 安全扫描在真机 git 输出上的误报率抽查(真实 commit 信息样本)。