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 清单
This commit is contained in:
1 parent
06c3e172fe
commit
a90cf78d84
15 files changed
+2370
-93
No files matched your search
@@ -0,0 +1,325 @@
|
||||
#!/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())
|
||||
Reference in new issue
Block a user