#!/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;agent 在单元内只能请 broker 代办,连不上本 socket); - 操作白名单只有 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" # 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 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=(0,), run_git=None, helper_path=HELPER_PATH): self.workspace = workspace self.log_path = log_path self.allowed_uids = frozenset(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 = "" directory = os.path.dirname(socket_path) if directory: os.makedirs(directory, exist_ok=True) try: os.chmod(directory, 0o700) except OSError: pass if os.path.exists(socket_path): os.unlink(socket_path) super().__init__(socket_path, WorkerHandler) try: os.chmod(socket_path, 0o600) except OSError: pass 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 + 工作区 + 审计路径就绪即返回。 helper 缺失不在建服务时报错——建服务时只备好 socket,git 调用时再拒绝(fail-closed,见 _serve)。""" 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) 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())