Files
codex-app-server/chv-guest/authd.py
T
host 9f593d62ef fix(chv-muse-r11fix): authd 层 3 socket 用 group 权限、层 4 裸 .git 当 pull
层 3:AuthdServer 不再 0700/0600 一刀切。socket 共享组取显式
socket_group 或第一个非 root 放行 uid 的主组(build_server 透
传;生产默认 allowed_uids=(0,1000) 派生 chv 主组),目录 0750
+ socket 0660 + chgrp;解析不出(uid 无 passwd 条目)回落
root-only,绝不放 0666(探针 fixprobe8 用的 0666 不上生产)。
层 4:_operation_for_path 加裸 .git → GET/pull;Sentinel
gitea 规则加 (GET, suffix, .git),POST 裸 .git 仍拒。语义已定:
裸 .git 一律当 pull(只读默认),push 只走显式 git-receive-pack。
测试:Sentinel 允许 GET/拒绝 POST 裸 .git、helper 端到端裸路径
取凭证、组 gid 派生与回落、显式组落地 0750/0660。
2026-11-07 03:58:45 +00:00

380 lines
15 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 用户;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())