#!/usr/bin/env python3 """Tiny webhook listener that runs deploy/pull.sh when Gitea pushes main. Listens on 127.0.0.1:9528 — exposed publicly only via openresty at https://web.facere.cc/_hook/deploy. Validates the Gitea HMAC-SHA256 signature against FACERE_DEPLOY_SECRET before running anything. """ from __future__ import annotations import hmac import hashlib import json import os import subprocess import sys import threading from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer SECRET = os.environ.get("FACERE_DEPLOY_SECRET", "").encode() PULL = os.environ.get( "FACERE_DEPLOY_SCRIPT", "/home/ubuntu/repo/facere-website/deploy/pull.sh", ) LOG = os.environ.get("FACERE_DEPLOY_LOG", "/tmp/facere-deploy.log") BIND_HOST = os.environ.get("FACERE_DEPLOY_BIND", "127.0.0.1") BIND_PORT = int(os.environ.get("FACERE_DEPLOY_PORT", "9528")) _run_lock = threading.Lock() class Handler(BaseHTTPRequestHandler): def _reply(self, status: int, msg: str) -> None: body = (msg + "\n").encode() self.send_response(status) self.send_header("Content-Type", "text/plain; charset=utf-8") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) def do_GET(self) -> None: self._reply(200, "facere-deploy-hook ready") def do_POST(self) -> None: length = int(self.headers.get("Content-Length", "0") or "0") body = self.rfile.read(length) if length > 0 else b"" sig = self.headers.get("X-Gitea-Signature", "") or self.headers.get( "X-Hub-Signature-256", "" ).removeprefix("sha256=") expected = hmac.new(SECRET, body, hashlib.sha256).hexdigest() if not sig or not hmac.compare_digest(sig, expected): self._reply(401, "bad signature") return ref = "" try: ref = json.loads(body).get("ref", "") except Exception: pass if ref and ref != "refs/heads/main": self._reply(202, f"ignored ref {ref}") return if not _run_lock.acquire(blocking=False): self._reply(202, "deploy already running") return try: with open(LOG, "a") as f: f.write(f"[{self.log_date_time_string()}] webhook trigger\n") f.flush() proc = subprocess.run( [PULL], stdout=f, stderr=subprocess.STDOUT, timeout=180 ) if proc.returncode == 0: self._reply(200, "ok") else: self._reply(500, f"pull.sh exit {proc.returncode}") except subprocess.TimeoutExpired: self._reply(504, "pull.sh timeout") finally: _run_lock.release() def log_message(self, fmt: str, *args) -> None: sys.stderr.write( "[%s] %s - %s\n" % (self.log_date_time_string(), self.address_string(), fmt % args) ) def main() -> int: if not SECRET: print("FACERE_DEPLOY_SECRET is not set", file=sys.stderr) return 1 srv = ThreadingHTTPServer((BIND_HOST, BIND_PORT), Handler) print(f"facere-deploy-hook listening on {BIND_HOST}:{BIND_PORT}", flush=True) try: srv.serve_forever() except KeyboardInterrupt: srv.shutdown() return 0 if __name__ == "__main__": sys.exit(main())