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 项全过。
398 lines
16 KiB
Python
398 lines
16 KiB
Python
#!/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())
|