#!/usr/bin/env python3 """gitea webhook 接收器(Phase 4 T3):127.0.0.1/桥网关 :13021。 安全模型: - /hook 仅接受 HMAC-SHA256 通过的 push(main) 事件(密钥宿主 0600 文件); - /healthz 无副作用探活; - 监听 0.0.0.0 是为 gitea 容器经桥网关(172.22.0.1)回连——宿主在 NAT 后、 ESA 只代理 80/443 vhost,该端口不对公网;HMAC 是硬门槛。 触发链:gitea push(main) → 本接收器验签 → build.sh → www-staging 更新。 部署形态:systemd 运行副本 ~/docker_apps/blog/webhook_receiver.py——改本文件 必须同步两份(仓内源 + 运行副本)并 restart 单元,漂移即事故。 常驻方式:systemd --user(blog-webhook.service);reboot 自启需一次性 `sudo loginctl enable-linger wait4x`(已列入上线检查单)。 """ import hashlib import hmac import json import subprocess import threading from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path PORT = 13021 SECRET_FILE = Path.home() / "docker_apps/gitea/webhook-secret.txt" BUILD_REPO = Path.home() / "docker_apps/blog/build-repo" BUILD_LOCK = threading.Lock() # 构建脚本 = 构建仓自己的 origin/main 版本(架构修正 2026-10-25:此前指向 # 工作树副本,车道在途半成品状态会断生产链——20:31 事故实锤)。构建仓缺失时 # 首克引导(blog 仓公开,匿名 clone)。 def build_script() -> Path: s = BUILD_REPO / "infra/deploy/build.sh" if not s.exists(): subprocess.run( ["git", "clone", "https://git.wait4x.online/wait4x/blog.git", str(BUILD_REPO)], check=True, capture_output=True, ) return s def read_secret() -> bytes: return SECRET_FILE.read_text().strip().encode() class Handler(BaseHTTPRequestHandler): def _json(self, code: int, obj: dict) -> None: body = json.dumps(obj).encode() self.send_response(code) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) def do_GET(self) -> None: if self.path == "/healthz": self._json(200, {"ok": True, "service": "blog-webhook"}) else: self._json(404, {"error": "not found"}) def do_POST(self) -> None: if self.path != "/hook": self._json(404, {"error": "not found"}) return length = int(self.headers.get("Content-Length", 0)) payload = self.rfile.read(length) sig = self.headers.get("X-Gitea-Signature", "") expected = hmac.new(read_secret(), payload, hashlib.sha256).hexdigest() if not hmac.compare_digest(sig, expected): self._json(401, {"error": "bad signature"}) return try: ref = json.loads(payload).get("ref", "") except json.JSONDecodeError: self._json(400, {"error": "bad json"}) return if not ref.endswith("/main"): self._json(200, {"ignored": ref}) return if BUILD_LOCK.locked(): self._json(202, {"queued": False, "reason": "build in progress"}) return threading.Thread(target=self._run_build, daemon=True).start() self._json(202, {"queued": True}) def _run_build(self) -> None: with BUILD_LOCK: subprocess.run( ["bash", str(build_script())], capture_output=True, timeout=1200, cwd=str(BUILD_REPO), ) def log_message(self, fmt: str, *args: object) -> None: # 静默常规访问日志(健康探针会刷屏);错误仍走默认 stderr if "healthz" in (fmt % args if args else fmt): return super().log_message(fmt, *args) if __name__ == "__main__": ThreadingHTTPServer(("0.0.0.0", PORT), Handler).serve_forever()