层 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。
380 lines
15 KiB
Python
380 lines
15 KiB
Python
#!/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())
|