#!/usr/bin/env python3 """chv-authd:客内凭证守护进程(feat-chv-muse-p3)。 对齐 Muse 官方安全文:authd 保管凭证(含 token,存于用户 VM 而非集中基础设施),代理只见替身令牌;凭证存入运行时单元之外 的安全凭证存储,代理不可见,仅在需要时注入。 本进程跑在客内、运行时单元之外(root 身份的 systemd 服务, 见 chv-authd.service;P2 的 nspawn 单元落地后,codex 在单元内, 本进程在单元外——单元内 root 映射为非特权用户,读不到本进程的 socket 与 seed 文件)。它只做三件事: 1. 启动时读 /etc/chv/authd-seed(0600 root,seed 投递),把 GITEA_TOKEN 持在内存里(cloud.chv.authd.CredentialVault), 除此之外不落盘、不进日志; 2. 在 Unix socket /run/chv-authd/authd.sock 上接受 git credential 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), 全程审计(审计里无值)。 只用标准库。cloud/chv/authd.py 与 cloud/chv/sentinel.py 是叶子 模块(只 import 标准库),镜像构建时随本文件一起拷进 /usr/local/lib/chv-authd/(见 docs/chv-muse-p3-gap.md §2 的接入 说明),本文件把该目录加进 sys.path 后 import。 """ import argparse import json import os import socket import socketserver import struct import sys import threading DEFAULT_LIB_DIR = "/usr/local/lib/chv-authd" DEFAULT_SEED_PATH = "/etc/chv/authd-seed" DEFAULT_SOCKET_PATH = "/run/chv-authd/authd.sock" DEFAULT_LOG_PATH = "/var/log/chv-authd.log" # seed 文件里允许的键(与 cloud.chv.gitea_sync.GITEA_SYNC_KEYS # 同款白名单;未知键一律丢掉,调用方拼错的键绝不静默进保险库)。 SEED_KEYS = frozenset({ "GITEA_URL", "GITEA_USER", "GITEA_TOKEN", "SYNC_REPO", "SYNC_BRANCH", "GITEA_EMAIL", }) # SO_PEERCRED 放行的 uid:root + chv(git 以 chv 身份跑 helper)。 # 可用环境变量 CHV_AUTHD_ALLOWED_UIDS 覆盖(逗号分隔)。 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,绝不带着 “无凭证”的 authd 继续跑、让 git 静默拿不到凭证)。""" values = {} with open(path) as fh: for line in fh: line = line.strip() if not line or line.startswith("#") or "=" not in line: continue key, value = line.split("=", 1) key = key.strip() if key in SEED_KEYS and value.strip(): values[key] = value.strip() return values def _audit(log_path, record): """审计落盘(JSON 行,0600):只含绑定与 verdict,不含值。 落盘失败只记 stderr,绝不炸守护进程(审计失败不改变放行 决策是 worker 层的既定语义,见 executor._emit)。""" 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-authd: audit 落盘失败:%s" % exc, file=sys.stderr) print("chv-authd: %s" % line, file=sys.stderr) def _operation_for_path(path): """git 智能 HTTP 路径 → 操作(helper 不知道 git 操作,守护 进程按路径推断;Sentinel 的 L7 规则已把路径限定在这三条内, 推断不到的一律拒绝): - .../git-receive-pack → push(写) - .../git-upload-pack → pull(读) - .../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 def _repo_from_path(path): """从 git 路径取仓库名:/admin/repo.git/info/refs → admin/repo。 取不出即 None(调用方拒绝)。""" parts = [p for p in (path or "").split("/") if p] if len(parts) < 2: return None repo = parts[1] if not repo.endswith(".git"): return None return "%s/%s" % (parts[0], repo[:-len(".git")]) class AuthdHandler(socketserver.BaseRequestHandler): """单次请求处理(每连接一 JSON 行):鉴权 → Sentinel 评估 → 按次释放 → 回包。任何一步拒绝都回 {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"}) return if uid not in server.allowed_uids: server.audit({"event": "authd_peer_rejected", "uid": uid}) self._reply({"ok": False, "error": "uid_not_allowed"}) return try: buf = b"" while b"\n" not in buf: chunk = self.request.recv(4096) if not chunk: break buf += chunk if len(buf) > 65536: break req = json.loads(buf.decode("utf-8")) except (OSError, ValueError): self._reply({"ok": False, "error": "bad_request"}) 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 connector = req.get("connector") host = (req.get("host") or "").lower() path = req.get("path") or "/" if connector != "gitea" or not host or not path: server.audit({"event": "authd_credential_refused", "kind": "bad_request_fields", "connector": connector}) self._reply({"ok": False, "error": "bad_request_fields"}) return method, operation = _operation_for_path(path) if method is None: server.audit({"event": "authd_credential_refused", "kind": "path_not_allowed", "connector": connector, "host": host, "path": path}) self._reply({"ok": False, "error": "path_not_allowed"}) return repo = _repo_from_path(path) if repo is None: server.audit({"event": "authd_credential_refused", "kind": "repo_unparseable", "connector": connector, "host": host}) self._reply({"ok": False, "error": "repo_unparseable"}) return # Sentinel 评估:主机/方法/路径/解析后 IP/SSRF。 from cloud.chv.sentinel import EgressRequest scheme = "https" port = 443 egress = EgressRequest(connector, method, scheme, host, port, path) decision = server.policy.evaluate( egress, resolve=getattr(server, "resolve", None)) if decision.verdict != "allow": record = decision.audit(egress) server.audit(record) self._reply({"ok": False, "error": "sentinel_" + decision.verdict, "reasons": decision.reasons}) return server.audit(decision.audit(egress)) # 按次释放:绑定须与入库作用域一致(主机/仓库),单次有效。 try: release = server.vault.release( connector, "GITEA_TOKEN", host, repo, operation) except Exception as exc: # AuthdRefusal:kind 在异常上 kind = getattr(exc, "kind", "refused") server.audit({"event": "authd_credential_refused", "kind": kind, "connector": connector, "host": host, "repo": repo, "operation": operation}) self._reply({"ok": False, "error": kind}) return server.audit(server.vault.audit_release(release)) try: value = server.vault.consume(release.release_id) except Exception as exc: kind = getattr(exc, "kind", "refused") server.audit({"event": "authd_credential_refused", "kind": kind, "connector": connector, "host": host, "repo": repo, "operation": operation}) self._reply({"ok": False, "error": kind}) return self._reply({"ok": True, "username": server.gitea_user, "password": value, "expires_at": release.expires_at}) class AuthdServer(socketserver.ThreadingUnixStreamServer): """Unix socket 服务(线程化,daemon_threads=True 避免僵尸线程)。""" daemon_threads = True allow_reuse_address = False def __init__(self, socket_path, vault, policy, gitea_user, allowed_uids, log_path, resolve=None, socket_group=None): self.vault = vault self.policy = policy self.gitea_user = gitea_user self.allowed_uids = frozenset(allowed_uids) self.log_path = log_path # 解析器可注入(测试用假解析器;不给时 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) 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) 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(seed_path=DEFAULT_SEED_PATH, socket_path=DEFAULT_SOCKET_PATH, log_path=DEFAULT_LOG_PATH, lib_dir=DEFAULT_LIB_DIR, allowed_uids=None, resolve=None, socket_group=None): """建服务(不启动):读 seed → 入库 → 建策略 → 建 socket 服务。 seed 缺必填键(GITEA_URL/GITEA_USER/GITEA_TOKEN/SYNC_REPO) 即 ValueError(只含缺失键名,不含值);无 seed 文件即 FileNotFoundError——authd 绝不带着“无凭证”启动。""" if lib_dir not in sys.path: sys.path.insert(0, lib_dir) from cloud.chv.authd import CredentialVault from cloud.chv.sentinel import SentinelPolicy seed = _load_seed(seed_path) missing = sorted({"GITEA_URL", "GITEA_USER", "GITEA_TOKEN", "SYNC_REPO"} - set(seed)) if missing: raise ValueError( "authd seed 不完整,缺失:%s" % ",".join(missing)) from urllib.parse import urlparse gitea_host = (urlparse(seed["GITEA_URL"]).hostname or "").lower() if not gitea_host: raise ValueError("authd seed 的 GITEA_URL 主机解析不出") vault = CredentialVault() vault.store("gitea", "GITEA_TOKEN", seed["GITEA_TOKEN"], host=gitea_host, repo=seed["SYNC_REPO"]) extra = [h.strip().lower() for h in (os.environ.get("CHV_SENTINEL_ALLOW_HOSTS") or "").split(",") if h.strip()] policy = SentinelPolicy( allow_hosts={gitea_host} | set(extra)) uids = allowed_uids if uids is None: raw = os.environ.get("CHV_AUTHD_ALLOWED_UIDS") or "" uids = [int(p) for p in raw.split(",") if p.strip()] \ if raw.strip() else list(DEFAULT_ALLOWED_UIDS) server = AuthdServer(socket_path, vault, policy, seed["GITEA_USER"], uids, log_path, resolve=resolve, socket_group=socket_group) return server def main(argv=None): """前台运行(systemd Type=simple 直接跑本进程,不 daemonize)。""" parser = argparse.ArgumentParser() parser.add_argument("--seed", default=DEFAULT_SEED_PATH) parser.add_argument("--socket", default=DEFAULT_SOCKET_PATH) parser.add_argument("--log", default=DEFAULT_LOG_PATH) parser.add_argument("--lib", default=DEFAULT_LIB_DIR) args = parser.parse_args(argv) server = build_server(seed_path=args.seed, socket_path=args.socket, log_path=args.log, lib_dir=args.lib) print("chv-authd: listening on %s" % args.socket, file=sys.stderr) try: server.serve_forever() except KeyboardInterrupt: pass finally: server.server_close() return 0 if __name__ == "__main__": sys.exit(main())