#!/usr/bin/env python3 """ Gitea push webhook receiver for the benchmark dashboard. - Listens on 0.0.0.0:PORT (obscure port; Gitea calls http://10.0.0.22:PORT/hook) - Validates the shared secret via the X-Gitea-Signature header (HMAC-SHA256 of the body) - On a valid push to `main`, runs: git fetch + reset to origin/main + ./deploy.sh up - One concurrent deploy at a time (a lock prevents overlapping rebuilds) Security notes: - No request data reaches the shell. The only string passed to the shell is a hardcoded script (cd to this file's own dir, git fetch/reset, deploy.sh up). - The pushed ref is validated against a fixed constant (refs/heads/main) before deploy. Run via the systemd unit llm-bench-webhook.service (see deploy-webhook.sh). """ import asyncio, hmac, hashlib, os, json, logging from http import HTTPStatus HERE = os.path.dirname(os.path.abspath(__file__)) PORT = int(os.environ.get("WEBHOOK_PORT", "41798")) SECRET = os.environ.get("WEBHOOK_SECRET", "").encode() REF_FILTER = os.environ.get("WEBHOOK_REF", "refs/heads/main") MAX_BODY = 2 * 1024 * 1024 # 2 MB cap logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") log = logging.getLogger("webhook") _deploy_lock = asyncio.Lock() # Hardcoded deploy script — NO request data interpolated into it. _DEPLOY_CMD = "cd " + HERE + " && git fetch origin && git reset --hard origin/main && ./deploy.sh up" def verify(signature_hex, body: bytes) -> bool: if not SECRET: log.warning("WEBHOOK_SECRET not set — accepting WITHOUT signature check (dev only)") return True if not signature_hex: return False mac = hmac.new(SECRET, body, hashlib.sha256).hexdigest() return hmac.compare_digest(mac, signature_hex) async def redeploy(): if _deploy_lock.locked(): log.info("deploy already running, skipping") return async with _deploy_lock: log.info("starting redeploy") proc = await asyncio.create_subprocess_exec( "bash", "-lc", _DEPLOY_CMD, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT, ) out, _ = await proc.communicate() log.info("redeploy exit=%s\n%s", proc.returncode, (out or b"").decode(errors="replace")) async def handle(reader, writer): try: req = await reader.readuntil(b"\r\n\r\n") except Exception: writer.close(); return try: head, _, body_start = req.partition(b"\r\n\r\n") lines = head.decode("latin1").split("\r\n") method, path, _ = lines[0].split(" ", 2) headers = {} for ln in lines[1:]: if ":" in ln: k, v = ln.split(":", 1) headers[k.strip().lower()] = v.strip() cl = int(headers.get("content-length", "0") or 0) body = body_start while len(body) < cl and len(body) < MAX_BODY: chunk = await reader.read(min(65536, cl - len(body))) if not chunk: break body += chunk if path.split("?")[0] not in ("/hook", "/webhook"): writer.write(b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n"); await writer.drain(); return if method != "POST": writer.write(b"HTTP/1.1 405 Method Not Allowed\r\nContent-Length: 0\r\n\r\n"); await writer.drain(); return if not verify(headers.get("x-gitea-signature", ""), body): log.warning("bad signature from %s", writer.get_extra_info("peername")) writer.write(b"HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\n\r\n"); await writer.drain(); return try: ref = json.loads(body).get("ref", "") if body else "" except Exception: ref = "" if ref and ref != REF_FILTER: writer.write(b'HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: 17\r\n\r\nignored: wrong ref') await writer.drain(); return asyncio.create_task(redeploy()) writer.write(b'HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: 12\r\n\r\ndeploying...\n') await writer.drain() finally: writer.close() async def main(): server = await asyncio.start_server(handle, "0.0.0.0", PORT) log.info("webhook receiver listening on 0.0.0.0:%d (filter=%s)", PORT, REF_FILTER) async with server: await server.serve_forever() if __name__ == "__main__": asyncio.run(main())