merge(chv-muse-round11): 合入 P3 authd 替身凭证 + Sentinel 出口审批 (a90cf78)
冲突解决(3 处,均为并集保留,双方改动无一丢弃): - cloud/chv/executor.py _copy_config_fields:P1 的 balloon/ vsock/vsock_cid 与 P3 的 sentinel_allow_hosts 合并进同一元组 - cloud/tests/test_chv_arch_maint_fe.py EXPECTED_FIELDS:P1 三字段 + P3 sentinel_allow_hosts;len 断言 41/39 → 42(并集) - cloud/chv/config.py 自动合入:P3 sentinel_allow_hosts(网络 片段内)+ P1 _balloon_vsock_fragment 均保留;仅更新 build_chv_config docstring 注明字段集含 P3 字段
This commit is contained in:
commit
304707b372
15 files changed
+2376
-95
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())
|
||||
@@ -0,0 +1,22 @@
|
||||
[Unit]
|
||||
Description=CHV authd (credential broker for chv-sync)
|
||||
# 注意时序:本服务须在 chv-sync boot 之前就绪。cloud-final 的
|
||||
# runcmd(99-chv-sync.cfg)在 chv-sync boot 之前显式
|
||||
# `systemctl start chv-authd`;这里不依赖 After=cloud-final
|
||||
#(cloud-final 跑完才 After 得到它,太晚)。enable 后开机自启
|
||||
# 只是兜底,首启顺序以 runcmd 的显式 start 为准。
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
# authd 跑在运行时单元之外(root 身份):它是凭证的唯一持有者,
|
||||
# 单元内的 agent 只能经 Unix socket + SO_PEERCRED 向它要凭证,
|
||||
# 读不到它的内存与 seed 文件(P2 单元隔离落地后)。
|
||||
ExecStart=/usr/bin/python3 /usr/local/bin/chv-authd
|
||||
Restart=on-failure
|
||||
RestartSec=2
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
+36
-19
@@ -7,19 +7,18 @@
|
||||
# 的规则:分叉或冲突时失败关闭,不强制覆盖、不自动 rebase、
|
||||
# 不静默丢提交。
|
||||
#
|
||||
# 凭证绝不进镜像。启动时由 seed 盘投递 /etc/chv/guest-env
|
||||
# (0600,root 所有),格式(KEY=VALUE,每行一项):
|
||||
# GITEA_URL=https://gitea.tose.top
|
||||
# GITEA_USER=<Gitea 用户名>
|
||||
# GITEA_TOKEN=<专用 API token,仅此次测试/此次 VM 有效>
|
||||
# SYNC_REPO=<owner/repo>
|
||||
# SYNC_BRANCH=<可选,默认分支>
|
||||
# GITEA_EMAIL=<可选,commit 身份邮箱;不给则留镜像占位>
|
||||
# boot(root,cloud-init runcmd 调)读 guest-env,把 token 写进
|
||||
# 凭证绝不进镜像。启动时由 seed 盘投递两份文件(0600,root 所有):
|
||||
# /etc/chv/guest-env(非密钥同步变量:GITEA_URL/GITEA_USER/
|
||||
# SYNC_REPO/SYNC_BRANCH/GITEA_EMAIL)与
|
||||
# /etc/chv/authd-seed(feat-chv-muse-p3:只装 GITEA_TOKEN,
|
||||
# 只给客内 authd 守护进程读)。
|
||||
# boot(root,cloud-init runcmd 调)读 guest-env,authd 在时配
|
||||
# git credential helper 走 authd 按次取凭证(只进 git 子进程
|
||||
# 内存,不落盘);authd 不在时(旧镜像)才回落到把 token 写进
|
||||
# /home/chv/.git-credentials(0600,chv 所有,git credential
|
||||
# store 标准机制),再以 chv 身份 clone 到 ~/workspace/<仓名>。
|
||||
# pull/push/status 以 chv 身份跑,只用已落盘的 credential,不
|
||||
# 再读 guest-env;token 不会出现在 git remote URL 或镜像里。
|
||||
# store 标准机制)并警告。pull/push/status 以 chv 身份跑,用
|
||||
# helper 或已落盘的 credential,不再读 guest-env;token 不会
|
||||
# 出现在 git remote URL 或镜像里。
|
||||
#
|
||||
# 用法:
|
||||
# chv-sync boot # 启动时 clone(已 clone 则静默退出,不自动 pull)
|
||||
@@ -64,7 +63,6 @@ cmd_boot() {
|
||||
set -a; . "$GUEST_ENV"; set +a
|
||||
: "${GITEA_URL:?guest-env 缺 GITEA_URL}"
|
||||
: "${GITEA_USER:?guest-env 缺 GITEA_USER}"
|
||||
: "${GITEA_TOKEN:?guest-env 缺 GITEA_TOKEN}"
|
||||
: "${SYNC_REPO:?guest-env 缺 SYNC_REPO}"
|
||||
[ "$(id -u)" = "0" ] || die "boot 须以 root 运行(cloud-init runcmd)"
|
||||
local host="${GITEA_URL#https://}"
|
||||
@@ -74,12 +72,31 @@ cmd_boot() {
|
||||
local dir="$WORKSPACE/$name"
|
||||
mkdir -p "$WORKSPACE"
|
||||
chown "$SYNC_USER:$SYNC_USER" "$WORKSPACE"
|
||||
# token 只落盘进用户家目录的 credential store(0600),
|
||||
# 不进 git remote URL、不进镜像、不进日志(此处不打印 token)。
|
||||
local creds="$USER_HOME/.git-credentials"
|
||||
printf 'https://%s:%s@%s\n' "$GITEA_USER" "$GITEA_TOKEN" "$host" > "$creds"
|
||||
chmod 0600 "$creds"
|
||||
chown "$SYNC_USER:$SYNC_USER" "$creds"
|
||||
# feat-chv-muse-p3:authd 路径——socket 存在时凭证走 helper,
|
||||
# 不再把真 token 写进 .git-credentials 落盘;helper 按次向
|
||||
# authd 取凭证,只进 git 子进程内存。socket 不存在(旧镜像)
|
||||
# 时走旧路径 + 警告(过渡兼容,旧镜像退役后改为 fail-closed)。
|
||||
# 注意:authd 路径下 GITEA_TOKEN 不在 guest-env 里(它只进
|
||||
# authd-seed 给守护进程),所以这里不要求它;旧路径才要求。
|
||||
local authd_socket="${CHV_AUTHD_SOCKET:-/run/chv-authd/authd.sock}"
|
||||
local helper="/usr/local/bin/git-credential-chv-authd"
|
||||
if [ -S "$authd_socket" ] && [ -x "$helper" ]; then
|
||||
runuser -u "$SYNC_USER" -- env HOME="$USER_HOME" git config --global credential.helper "$helper"
|
||||
# helper 按路径推断仓库与操作(Sentinel L7 绑定),git
|
||||
# 默认不把 path 发给 helper(须 credential.useHttpPath),
|
||||
# 不开它每次都会因“请求不完整”拒绝——新测试抓到的真问题。
|
||||
runuser -u "$SYNC_USER" -- env HOME="$USER_HOME" git config --global credential.useHttpPath true
|
||||
info "凭证走 chv-authd(不落盘)"
|
||||
else
|
||||
: "${GITEA_TOKEN:?guest-env 缺 GITEA_TOKEN(旧路径)}"
|
||||
echo "chv-sync: 警告:chv-authd 未运行,凭证走旧落盘路径(须用含 authd 的新镜像)" >&2
|
||||
# token 只落盘进用户家目录的 credential store(0600),
|
||||
# 不进 git remote URL、不进镜像、不进日志(此处不打印 token)。
|
||||
local creds="$USER_HOME/.git-credentials"
|
||||
printf 'https://%s:%s@%s\n' "$GITEA_USER" "$GITEA_TOKEN" "$host" > "$creds"
|
||||
chmod 0600 "$creds"
|
||||
chown "$SYNC_USER:$SYNC_USER" "$creds"
|
||||
fi
|
||||
if [ -d "$dir/.git" ]; then
|
||||
info "已 clone:$dir(不自动 pull,改动由用户显式同步)"
|
||||
exit 0
|
||||
|
||||
Executable
+107
@@ -0,0 +1,107 @@
|
||||
#!/usr/bin/env python3
|
||||
"""git credential helper:向 chv-authd 按次取凭证(feat-chv-muse-p3)。
|
||||
|
||||
git credential 协议:git 把 `protocol/host/path`(每行 `key=value`,
|
||||
空行结束)写进本进程 stdin;本进程向 authd 的 Unix socket 发
|
||||
一次 JSON 请求,拿到用户名/口令后按协议回给 git(属性 + 空行)。
|
||||
|
||||
口令只进 git 子进程内存:本进程不写文件、不打日志(拒绝时只
|
||||
报 kind,不含值);`password_expiry_utc` 按释放的 expires_at
|
||||
回给 git,git 在该时间后重新问 helper(不缓存过期凭证)。
|
||||
authd 拒绝 / socket 不通时本进程 exit 1——git 操作随之失败
|
||||
(fail-closed,绝不回落成“无凭证也照推”)。
|
||||
|
||||
用法(chv-sync boot 配好后 git 自动调,不用手工跑):
|
||||
git config --global credential.helper /usr/local/bin/git-credential-chv-authd
|
||||
git config --global credential.useHttpPath true
|
||||
(后者不可少:git 默认不把 path 发给 helper,没有 path 本进程
|
||||
无法推断仓库与操作,会直接拒绝。)
|
||||
socket 路径可用 CHV_AUTHD_SOCKET 覆盖(测试用 /tmp 下的假 socket)。
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import sys
|
||||
|
||||
DEFAULT_SOCKET_PATH = "/run/chv-authd/authd.sock"
|
||||
SOCKET_TIMEOUT_SECONDS = 5
|
||||
|
||||
|
||||
def _read_credential_request():
|
||||
"""读 git credential 协议的请求属性(空行结束)→ dict。"""
|
||||
attrs = {}
|
||||
for line in sys.stdin:
|
||||
line = line.rstrip("\n")
|
||||
if not line:
|
||||
break
|
||||
if "=" in line:
|
||||
key, value = line.split("=", 1)
|
||||
attrs[key.strip()] = value.strip()
|
||||
return attrs
|
||||
|
||||
|
||||
def _ask_authd(socket_path, host, path):
|
||||
"""向 authd 要一次凭证:返回 (username, password, expires_at)。
|
||||
拒绝/通信失败即 RuntimeError(只含 kind,不含值)。"""
|
||||
req = {"connector": "gitea", "host": host, "path": path}
|
||||
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
sock.settimeout(SOCKET_TIMEOUT_SECONDS)
|
||||
try:
|
||||
sock.connect(socket_path)
|
||||
sock.sendall((json.dumps(req) + "\n").encode("utf-8"))
|
||||
chunks = []
|
||||
while True:
|
||||
data = sock.recv(65536)
|
||||
if not data:
|
||||
break
|
||||
chunks.append(data)
|
||||
if b"\n" in data:
|
||||
break
|
||||
except OSError as exc:
|
||||
raise RuntimeError("authd_unreachable:%s" % exc)
|
||||
finally:
|
||||
sock.close()
|
||||
try:
|
||||
resp = json.loads(b"".join(chunks).decode("utf-8"))
|
||||
except ValueError:
|
||||
raise RuntimeError("authd_bad_response")
|
||||
if not resp.get("ok"):
|
||||
raise RuntimeError("authd_refused:%s"
|
||||
% resp.get("error", "refused"))
|
||||
return (resp["username"], resp["password"],
|
||||
resp.get("expires_at"))
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
"""helper 入口:读协议 → 问 authd → 回属性。拒绝即 exit 1。"""
|
||||
attrs = _read_credential_request()
|
||||
host = attrs.get("host", "")
|
||||
path = attrs.get("path", "")
|
||||
if attrs.get("protocol") != "https" or not host or not path:
|
||||
print("git-credential-chv-authd: 请求不完整(须 protocol=https "
|
||||
"+ host + path),拒绝", file=sys.stderr)
|
||||
return 1
|
||||
if not path.startswith("/"):
|
||||
path = "/" + path
|
||||
# git 的 info/refs 路径带 ?service=... 查询串:Sentinel 的
|
||||
# L7 规则按干净路径匹配,这里先去掉查询串再问 authd。
|
||||
path = path.split("?", 1)[0]
|
||||
socket_path = os.environ.get("CHV_AUTHD_SOCKET") \
|
||||
or DEFAULT_SOCKET_PATH
|
||||
try:
|
||||
username, password, expires_at = _ask_authd(
|
||||
socket_path, host, path)
|
||||
except RuntimeError as exc:
|
||||
print("git-credential-chv-authd: %s" % exc, file=sys.stderr)
|
||||
return 1
|
||||
print("username=%s" % username)
|
||||
print("password=%s" % password)
|
||||
if expires_at:
|
||||
print("password_expiry_utc=%d" % int(expires_at))
|
||||
print()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in new issue
Block a user