#!/usr/bin/env python3 """Tiny webhook receiver for the caddy-conf repo. Listens on 0.0.0.0:9797. Validates the X-Gitea-Signature HMAC-SHA256 header (using the secret in /etc/caddy-deploy/secret), then runs deploy.sh. Only triggers on push to master. Returns 204 on success, 400/403 on bad request, 500 on deploy failure. Runs one deploy at a time. """ from __future__ import annotations import hashlib import hmac import json import logging import os import subprocess import sys import threading from http.server import BaseHTTPRequestHandler, HTTPServer BIND_HOST = "0.0.0.0" BIND_PORT = 9797 SECRET_PATH = "/etc/caddy-deploy/secret" DEPLOY_CMD = ["/etc/caddy/scripts/deploy.sh"] TARGET_REF = "refs/heads/master" logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") log = logging.getLogger("caddy-deploy-webhook") _deploy_lock = threading.Lock() def load_secret() -> bytes: with open(SECRET_PATH, "rb") as f: return f.read().strip() class Handler(BaseHTTPRequestHandler): def log_message(self, fmt, *args): log.info("%s - %s", self.address_string(), fmt % args) def _reply(self, status: int, msg: str = "") -> None: self.send_response(status) self.send_header("Content-Type", "text/plain") self.end_headers() if msg: self.wfile.write(msg.encode()) def do_POST(self) -> None: if self.path != "/deploy": self._reply(404, "not found") return length = int(self.headers.get("Content-Length", "0")) body = self.rfile.read(length) if length else b"" sig_header = self.headers.get("X-Gitea-Signature", "") secret = load_secret() expected = hmac.new(secret, body, hashlib.sha256).hexdigest() if not hmac.compare_digest(expected, sig_header): log.warning("signature mismatch") self._reply(403, "bad signature") return try: payload = json.loads(body) except json.JSONDecodeError: self._reply(400, "bad json") return ref = payload.get("ref", "") if ref != TARGET_REF: log.info("ignoring ref %s", ref) self._reply(204) return if not _deploy_lock.acquire(blocking=False): log.info("deploy already running, skipping") self._reply(202, "already running") return try: log.info("running deploy: %s", DEPLOY_CMD) result = subprocess.run(DEPLOY_CMD, capture_output=True, text=True, timeout=120) if result.returncode != 0: log.error("deploy failed: %s\n%s", result.stdout, result.stderr) self._reply(500, "deploy failed") return log.info("deploy ok") self._reply(204) finally: _deploy_lock.release() def main() -> int: if not os.path.exists(SECRET_PATH): log.error("secret file missing: %s", SECRET_PATH) return 1 server = HTTPServer((BIND_HOST, BIND_PORT), Handler) log.info("listening on %s:%d", BIND_HOST, BIND_PORT) server.serve_forever() return 0 if __name__ == "__main__": sys.exit(main())