Files
codex-app-server/chv-guest/chv-gitea-worker.py
T
host 7cba3c2b21 fix(chv-muse-r12fix3-2): worker socket 组权限 + allowed_uids 含 chv(P6 缺口 ②)
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 项全过。
2026-11-08 09:48:16 +00:00

398 lines
16 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-gitea-worker:Gitea 同步 privsep worker(feat-chv-muse-p6)。
对齐 Muse 官方安全文:内置连接器的业务逻辑由 systemd 沙箱化的
privsep worker 执行;每个 worker 由 cgroup 标识、有明确凭证白名单。
本进程跑在客内、运行时单元之外(root 身份的 systemd 服务,见
chv-gitea-worker.service;P2 的 nspawn 单元落地后,codex 在单元内,
本进程在单元外——单元内 root 映射为非特权用户,读不到本进程的
socket)。它只做 Gitea 同步的 git 业务逻辑:
- 在 Unix socket /run/chv-gitea/worker.sock 上接受 broker/薄 CLI
的类型化请求(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,见下),绝无
token 值;凭证由 git 经 helper 向 authd 按次取,本进程绝不持有
token;
- git 输出先过 safety 扫描 + 打上来源标记再返回;block 即阻断
(内容不往回流),flag 即标记复核。
只用标准库。cloud/chv/privsep.py 与 cloud/chv/safety.py 是叶子
模块(只 import 标准库),镜像构建时随本文件一起拷进
/usr/local/lib/chv-gitea/(见 docs/chv-muse-p6-gap.md §2 的接入
说明),本文件把该目录加进 sys.path 后 import。
"""
import argparse
import json
import os
import socket
import socketserver
import struct
import subprocess
import sys
import threading
DEFAULT_LIB_DIR = "/usr/local/lib/chv-gitea"
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"
GIT_TIMEOUT_SECONDS = 300
MAX_REQUEST_BYTES = 65536
def _audit(log_path, record):
"""审计落盘(JSON 行,0600):只含绑定与 verdict,不含值。
落盘失败只记 stderr,绝不炸 worker。"""
line = json.dumps(record, sort_keys=True)
try:
with open(log_path, "a") as fh:
fh.write(line + "\n")
except OSError as exc:
print("chv-gitea-worker: audit 落盘失败:%s" % exc,
file=sys.stderr)
print("chv-gitea-worker: %s" % line, file=sys.stderr)
def _git_base_argv():
"""git 基础 argv:只配 credential helper,不含任何凭证值。
helper 缺失时由调用方先拒绝(fail-closed),不回落读 token。"""
return ["git", "-c",
"credential.helper=%s" % HELPER_PATH,
"-c", "credential.useHttpPath=true"]
def _resolve_repo(repo, workspace):
"""解析仓目录:显式给则用它(须在 workspace 下,调用方已
校验);省略时取 workspace 下唯一的 git 仓库;0 个或多个
即 ValueError(由调用方转成类型化失败)。"""
if repo:
return repo
found = []
try:
names = sorted(os.listdir(workspace))
except OSError as exc:
raise ValueError("工作区读不到:%s" % exc)
for name in names:
path = os.path.join(workspace, name)
if os.path.isdir(os.path.join(path, ".git")):
found.append(path)
if len(found) == 1:
return found[0]
raise ValueError(
"workspace 下 git 仓库不是唯一(%d 个),请显式给仓目录"
% len(found))
def _run_git(action, repo, run_git=None):
"""跑一次 git(pull/push/status),返回 (returncode, stdout,
stderr)。run_git 可注入(测试用假 git,不跑真 git)。
git 以 chv 身份跑,文件归属不变;argv 是 list,不进 shell。"""
if run_git is not None:
return run_git(action, repo)
argv = ["runuser", "-u", SYNC_USER, "--", "env",
"HOME=/home/chv"] + _git_base_argv()
if action == "pull":
argv += ["-C", repo, "pull", "--ff-only"]
elif action == "push":
argv += ["-C", repo, "push"]
elif action == "status":
argv += ["-C", repo, "status", "-sb"]
else:
return 2, "", "未知 git 操作:%s" % action
try:
proc = subprocess.run(argv, capture_output=True, text=True,
timeout=GIT_TIMEOUT_SECONDS)
except subprocess.TimeoutExpired:
return 124, "", "git 超时(%d 秒)" % GIT_TIMEOUT_SECONDS
except OSError as exc:
return 2, "", "git 起不来:%s" % exc
return proc.returncode, proc.stdout, proc.stderr
def _truncate(text, limit=8192):
if text is None:
return ""
if len(text) > limit:
return text[:limit] + "\n…(已截断)"
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},不含值。"""
def handle(self):
server = self.server
try:
creds = self.request.getsockopt(
socket.SOL_SOCKET, socket.SO_PEERCRED,
struct.calcsize("3i"))
_pid, uid, _gid = struct.unpack("3i", creds)
except (OSError, struct.error):
self._reply({"ok": False, "error": "peercred_failed",
"stdout": "", "stderr": "",
"returncode": 2, "safety": {},
"request_id": ""})
return
if uid not in server.allowed_uids:
server.audit({"event": "privsep_peer_rejected",
"uid": uid})
self._reply({"ok": False, "error": "uid_not_allowed",
"stdout": "", "stderr": "",
"returncode": 2, "safety": {},
"request_id": ""})
return
try:
buf = b""
while b"\n" not in buf:
chunk = self.request.recv(4096)
if not chunk:
break
buf += chunk
if len(buf) > MAX_REQUEST_BYTES:
break
req = json.loads(buf.decode("utf-8"))
except (OSError, ValueError):
self._reply({"ok": False, "error": "bad_request",
"stdout": "", "stderr": "",
"returncode": 2, "safety": {},
"request_id": ""})
return
self._serve(req)
def _reply(self, obj):
try:
self.request.sendall(
(json.dumps(obj) + "\n").encode("utf-8"))
except OSError:
pass
def _serve(self, req):
server = self.server
try:
from cloud.chv.privsep import (
WorkerRequest, WorkerResponse)
request = WorkerRequest.from_dict(
req, workspace=server.workspace)
except Exception as exc:
kind = getattr(exc, "kind", "bad_request")
server.audit({"event": "privsep_request_refused",
"kind": kind})
self._reply({"ok": False, "error": kind,
"stdout": "", "stderr": "",
"returncode": 2, "safety": {},
"request_id": ""})
return
if request.op == "ping":
resp = WorkerResponse(True, "", "", 0,
{"verdict": "allow", "rules": []},
request.request_id)
self._reply(resp.to_dict())
return
if not os.path.exists(server.helper_path):
server.audit({"event": "privsep_request_refused",
"kind": "credential_helper_missing",
"op": request.op})
self._reply({"ok": False,
"error": "credential_helper_missing",
"stdout": "", "stderr": "",
"returncode": 2, "safety": {},
"request_id": request.request_id})
return
try:
repo_path = _resolve_repo(request.repo, server.workspace)
except ValueError as exc:
server.audit({"event": "privsep_request_refused",
"kind": "repo_not_unique",
"op": request.op})
self._reply({"ok": False, "error": "repo_not_unique",
"stdout": "", "stderr": str(exc),
"returncode": 2, "safety": {},
"request_id": request.request_id})
return
self._run_and_reply(request, repo_path)
def _run_and_reply(self, request, repo_path):
server = self.server
from cloud.chv.privsep import WorkerResponse
from cloud.chv.safety import (
check_context_input, mark_untrusted, redact_secrets)
code, out, err = _run_git(request.op, repo_path,
run_git=server.run_git)
source = "gitea:%s" % (request.repo or "unknown")
checked = check_context_input(
mark_untrusted(out + "\n" + err, source))
safety = checked.describe()
# 脱敏后再返回:git 输出万一含 token,不原样回流。
out = redact_secrets(out)
err = redact_secrets(err)
server.audit({"event": "privsep_worker_call",
"op": request.op, "repo": request.repo,
"returncode": code,
"safety": safety,
"cgroup": server.cgroup_id})
if checked.verdict == "block":
self._reply({"ok": False,
"error": "safety_blocked:%s" % ",".join(
f.rule for f in checked.findings),
"stdout": "", "stderr": "",
"returncode": code, "safety": safety,
"request_id": request.request_id})
return
resp = WorkerResponse(code == 0, _truncate(out),
_truncate(err), code, safety,
request.request_id)
self._reply(resp.to_dict())
class WorkerServer(socketserver.ThreadingUnixStreamServer):
"""Unix socket 服务(线程化,daemon_threads=True 避免僵尸线程)。"""
daemon_threads = True
allow_reuse_address = False
def __init__(self, socket_path, workspace, log_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 if allowed_uids is not None
else DEFAULT_ALLOWED_UIDS)
self.run_git = run_git
self.helper_path = helper_path
try:
with open("/proc/self/cgroup") as fh:
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)
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)
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)
def build_server(socket_path=DEFAULT_SOCKET_PATH,
workspace=WORKSPACE,
log_path=DEFAULT_LOG_PATH,
lib_dir=DEFAULT_LIB_DIR,
allowed_uids=None,
run_git=None,
helper_path=HELPER_PATH,
socket_group=None):
"""建服务(不启动):socket + 工作区 + 审计路径就绪即返回。
helper 缺失不在建服务时报错——建服务时只备好 socket,git
调用时再拒绝(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)
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):
"""前台运行(systemd Type=simple 直接跑本进程,不 daemonize)。"""
parser = argparse.ArgumentParser()
parser.add_argument("--socket", default=DEFAULT_SOCKET_PATH)
parser.add_argument("--workspace", default=WORKSPACE)
parser.add_argument("--log", default=DEFAULT_LOG_PATH)
parser.add_argument("--lib", default=DEFAULT_LIB_DIR)
args = parser.parse_args(argv)
server = build_server(socket_path=args.socket,
workspace=args.workspace,
log_path=args.log, lib_dir=args.lib)
print("chv-gitea-worker: listening on %s (cgroup %s)"
% (args.socket, server.cgroup_id), file=sys.stderr)
try:
server.serve_forever()
except KeyboardInterrupt:
pass
finally:
server.server_close()
return 0
if __name__ == "__main__":
raise SystemExit(main())