#!/usr/bin/env python3 """Deploy webhook for dtoro/Homelab-Docs → secrets-issuance on LXC 105. Listens on 0.0.0.0:9821/deploy. Validates Gitea HMAC, runs deploy.sh. """ 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 = 9821 SECRET_PATH = "/etc/secrets-issuance-deploy/secret" DEPLOY_CMD = ["/opt/secrets-issuance/secrets-issuance/deploy/deploy.sh"] TARGET_REF = "refs/heads/main" logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") log = logging.getLogger("secrets-issuance-deploy") _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 = self.headers.get("X-Gitea-Signature", "") secret = load_secret() expected = hmac.new(secret, body, hashlib.sha256).hexdigest() if not hmac.compare_digest(expected, sig): log.warning("signature mismatch") self._reply(403, "bad signature") return try: payload = json.loads(body) except json.JSONDecodeError: self._reply(400, "bad json") return if payload.get("ref", "") != TARGET_REF: self._reply(204) return if not _deploy_lock.acquire(blocking=False): self._reply(202, "already running") return try: result = subprocess.run(DEPLOY_CMD, capture_output=True, text=True, timeout=180) if result.returncode != 0: log.error("deploy failed: %s\n%s", result.stdout, result.stderr) self._reply(500, "deploy failed") return 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())