diff --git a/.gitignore b/.gitignore index 9344ed4..7b9ea1d 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,6 @@ __pycache__/ .env .env.* *.local + +# --- Webhook secret (NEVER commit) --- +.webhook.secret diff --git a/deploy-webhook.sh b/deploy-webhook.sh new file mode 100755 index 0000000..65429ed --- /dev/null +++ b/deploy-webhook.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +# Install/manage the Gitea-push webhook receiver as a systemd service. +# ./deploy-webhook.sh install -> generate secret, write unit, enable+start +# ./deploy-webhook.sh status -> show service + last logs +# ./deploy-webhook.sh secret -> print the current webhook secret (to paste into Gitea) +# ./deploy-webhook.sh uninstall -> disable+remove the service +# +# After install: in Gitea (admin/modelTesting) → Settings → Webhooks → Add webhook: +# Target URL: http://10.0.0.22:41798/hook +# HTTP method: POST +# Content type: application/json +# Secret: (output of `./deploy-webhook.sh secret`) +# Trigger on: Push events (branch: main) +set -euo pipefail +cd "$(dirname "$0")" + +UNIT=/etc/systemd/system/llm-bench-webhook.service +SECRET_FILE=.webhook.secret +PORT="${WEBHOOK_PORT:-41798}" + +case "${1:-status}" in + install) + # generate a fresh secret if none yet + if [[ ! -f "$SECRET_FILE" ]]; then + openssl rand -hex 32 > "$SECRET_FILE" + chmod 600 "$SECRET_FILE" + echo "generated new secret -> $SECRET_FILE" + fi + SECRET=$(cat "$SECRET_FILE") + sudo tee "$UNIT" >/dev/null </dev/null | head -15 || echo "not installed" + echo "--- recent log ---" + journalctl -u llm-bench-webhook -n 10 --no-pager 2>/dev/null || true + ;; + secret) cat "$SECRET_FILE" ;; + uninstall) + sudo systemctl disable --now llm-bench-webhook 2>/dev/null || true + sudo rm -f "$UNIT"; sudo systemctl daemon-reload + echo "removed webhook service" + ;; + *) echo "usage: $0 [install|status|secret|uninstall]"; exit 1 ;; +esac diff --git a/deploy.sh b/deploy.sh index c7149b1..d5e6c57 100755 --- a/deploy.sh +++ b/deploy.sh @@ -16,8 +16,10 @@ case "${1:-up}" in # --build forces a rebuild so new grades/code take effect docker compose up -d --build echo - echo "✓ dashboard up: http://$(hostname -I 2>/dev/null | awk '{print $1}' || echo localhost):8081/dashboard.html" - echo " (bind 0.0.0.0:8081 -> container :80)" + PORT=$(grep -oP '0\.0\.0\.0:\K[0-9]+' docker-compose.yml | head -1) + HOSTIP=$(hostname -I 2>/dev/null | awk '{print $1}' || echo localhost) + echo "✓ dashboard up: http://${HOSTIP}:${PORT}/dashboard.html" + echo " (bind 0.0.0.0:${PORT} -> container :80)" ;; logs) docker compose logs -f --tail=100 ;; down) docker compose down ;; diff --git a/webhook.py b/webhook.py new file mode 100644 index 0000000..d82508a --- /dev/null +++ b/webhook.py @@ -0,0 +1,110 @@ +#!/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())