- build.sh:BLOG_GIT_URL 环境变量(默认公网,同机构建可指内网 http://127.0.0.1:13020);每次构建 remote set-url 后 fetch—— 2026-10-26 事故:ESA 边缘对 git.wait4x.online 522 时公网 fetch set -e 静默死,staging 冻结 150s+ 无痕 - webhook_receiver.py:构建输出落盘 ~/docker_apps/blog/build.log (capture_output 丢弃=黑洞,本次事故"构建为何没跑"零证据); 与运行副本双同步纪律维持 - 宿主 unit 补 BLOG_GIT_URL 内网通道(配置在 unit 不在仓——主机 特定值不污染仓库默认) 验证: 手动构建实测 stamp=19b2390+robots.txt 352B 上线;18743 测试 床恢复 200;smoke 对 staging 软门容忍符合 T1 转硬门前设计 Spec: none (发布链通道与可观测性修复,事故驱动) Co-authored-by: GLM-5.3
107 lines
4.2 KiB
Python
107 lines
4.2 KiB
Python
#!/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:
|
||
# 输出落盘:capture_output 丢弃是调试黑洞(2026-10-26 ESA 522
|
||
# 事件中构建静默死、staging 冻结无任何可见痕迹)
|
||
log = Path.home() / "docker_apps/blog/build.log"
|
||
with open(log, "wb") as fh:
|
||
subprocess.run(
|
||
["bash", str(build_script())],
|
||
stdout=fh, stderr=subprocess.STDOUT, 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()
|