Files
codex-app-server/chv-guest/authd.py
T
host a90cf78d84 feat(chv): 对齐 Muse P3 收尾——authd 替身凭证 + Sentinel 出口审批
- 新测试 cloud/tests/test_chv_muse_p3.py 30 项:替身过期、真 token
  泄露面、SSRF 挡截、deny/ask 路径、authd-seed 拆分、wake 预审、
  客内 authd 守护进程、git credential helper 端到端(真 git fill)
- 8 个旧测试按新 authd 行为改写(未删测试、未放宽断言):
  GITEA_TOKEN 改走 authd-seed,build_ssh_command 不再 source 进
  codex 环境;wake 测试补 Sentinel 白名单 + 假解析器
- 7 处超 50 行函数拆到 50 行内(不改行为);补 authd/sentinel
  公共 API 注解,mypy 在改动文件上干净
- 真问题修复:git 默认不把 path 发给 helper,chv-sync boot 加
  credential.useHttpPath=true,否则 authd 路径每次都拒绝
- 全量 cloud 套件 1897 项 OK(skipped=8)

真机待验证(未做,等第三棒让出 WSL 测试机):见
docs/chv-muse-p3-gap.md §3.6 清单
2026-11-06 21:21:55 +00:00

326 lines
13 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-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 用户);
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 _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 也走这条)"""
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"
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):
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
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, AuthdHandler)
try:
os.chmod(socket_path, 0o600)
except OSError:
pass
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):
"""建服务(不启动):读 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)
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())