From a93fe6f15edb43e6ddc2e85107c9eb64f4161224 Mon Sep 17 00:00:00 2001 From: aygea Date: Tue, 28 Jul 2026 14:28:16 -0700 Subject: [PATCH] Add Authorization-header + /hook proxy to webhook/deploy webhook.py: check_auth() requires Bearer token (WEBHOOK_AUTH_TOKEN), checked before HMAC signature. Returns 401 on missing auth. Dockerfile: nginx proxies /hook -> host:41798, forwarding Authorization + X-Gitea-Signature headers. Host IP via HOST_IP env + host-gateway. docker-compose.yml: extra_hosts host-gateway + HOST_IP env. deploy-webhook.sh: generates .webhook.auth token, 'auth' subcommand. Co-Authored-By: Claude --- Dockerfile | 22 +++++++++++++++--- deploy-webhook.sh | 48 +++++++++++++++++--------------------- docker-compose.yml | 11 ++++----- webhook.py | 58 +++++++++++++++++++++++++++------------------- 4 files changed, 79 insertions(+), 60 deletions(-) diff --git a/Dockerfile b/Dockerfile index 5e6b948..a22c972 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,17 +5,33 @@ WORKDIR /app COPY . . RUN python3 generate_dashboard.py -# Serve stage: nginx serves the generated static files. +# Serve stage: nginx serves static files + proxies /hook to the host webhook receiver. FROM nginx:alpine -# Replace the default nginx server block so root redirects to the dashboard RUN printf '%s\n' \ 'server {' \ ' listen 80;' \ ' server_name _;' \ ' root /usr/share/nginx/html;' \ ' index dashboard.html;' \ + ' client_max_body_size 2m;' \ ' location = / { return 302 /dashboard.html; }' \ - '}' > /etc/nginx/conf.d/default.conf + ' location /hook {' \ + ' proxy_pass http://__HOST_IP__:41798;' \ + ' proxy_set_header Host $host;' \ + ' proxy_set_header Authorization $http_authorization;' \ + ' proxy_set_header X-Gitea-Signature $http_x_gitea_signature;' \ + ' proxy_set_header X-Gitea-Event $http_x_gitea_event;' \ + ' proxy_set_header X-Gitea-Event-Type $http_x_gitea_event_type;' \ + ' proxy_set_header Content-Type $content_type;' \ + ' proxy_read_timeout 60s;' \ + ' }' \ + '}' > /etc/nginx/conf.d/default.conf.template COPY --from=build /app/dashboard.html /usr/share/nginx/html/dashboard.html COPY --from=build /app/pages /usr/share/nginx/html/pages +RUN printf '%s\n' '#!/bin/sh' 'set -e' \ + 'HOST_IP="${HOST_IP:-host.docker.internal}"' \ + 'sed "s|__HOST_IP__|${HOST_IP}|g" /etc/nginx/conf.d/default.conf.template > /etc/nginx/conf.d/default.conf' \ + 'exec nginx -g "daemon off;"' > /docker-entrypoint-hostip.sh \ + && chmod +x /docker-entrypoint-hostip.sh EXPOSE 80 +ENTRYPOINT ["/docker-entrypoint-hostip.sh"] diff --git a/deploy-webhook.sh b/deploy-webhook.sh index 65429ed..1241934 100755 --- a/deploy-webhook.sh +++ b/deploy-webhook.sh @@ -1,32 +1,27 @@ #!/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 +# ./deploy-webhook.sh install | status | secret | auth | uninstall # -# 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) +# Gitea (admin/modelTesting) → Settings → Webhooks → Add webhook (Gitea type): +# Target URL: https://llmtesting.itsaygea.com/hook +# HTTP method: POST +# POST Content Type: application/json +# Secret: $(./deploy-webhook.sh secret) +# Authorization Header: $(./deploy-webhook.sh auth) +# Trigger On: Push Events, branch filter: main set -euo pipefail cd "$(dirname "$0")" UNIT=/etc/systemd/system/llm-bench-webhook.service SECRET_FILE=.webhook.secret +AUTH_FILE=.webhook.auth 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") + [[ -f "$SECRET_FILE" ]] || { openssl rand -hex 32 > "$SECRET_FILE"; chmod 600 "$SECRET_FILE"; echo "generated HMAC secret"; } + [[ -f "$AUTH_FILE" ]] || { openssl rand -hex 24 > "$AUTH_FILE"; chmod 600 "$AUTH_FILE"; echo "generated auth token"; } + SECRET=$(cat "$SECRET_FILE"); AUTHTOK=$(cat "$AUTH_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 - ;; + echo "--- recent log ---"; sudo journalctl -u llm-bench-webhook -n 10 --no-pager 2>/dev/null || true ;; secret) cat "$SECRET_FILE" ;; + auth) cat "$AUTH_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 ;; + sudo rm -f "$UNIT"; sudo systemctl daemon-reload; echo "removed webhook service" ;; + *) echo "usage: $0 [install|status|secret|auth|uninstall]"; exit 1 ;; esac diff --git a/docker-compose.yml b/docker-compose.yml index bc068b1..b6f14e0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,13 +1,12 @@ services: benchmark: - # Builds from the repo's Dockerfile: - # stage 1 (python) runs generate_dashboard.py from data/benchmark_history.json - # stage 2 (nginx) serves dashboard.html + pages/ as static build: . image: llm-benchmark:latest container_name: llm-benchmark restart: unless-stopped ports: - - "0.0.0.0:31415:80" # bind all interfaces:31415 -> container :80 (obscure port; Netbird proxies in front) - # Re-tag the image so `docker compose up` after a code change rebuilds it. - # (compose detects Dockerfile/context changes and rebuilds automatically.) + - "0.0.0.0:31415:80" # all interfaces:31415 -> container :80 (Netbird proxies in front) + extra_hosts: + - "host.docker.internal:host-gateway" + environment: + - HOST_IP=host.docker.internal diff --git a/webhook.py b/webhook.py index d82508a..6be2840 100644 --- a/webhook.py +++ b/webhook.py @@ -2,54 +2,61 @@ """ 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) +- Listens on 0.0.0.0:PORT (Gitea posts to /hook via the dashboard nginx proxy, + or directly to http://10.0.0.22:PORT/hook) +- Auth: requires Authorization header (Bearer token) matching WEBHOOK_AUTH_TOKEN +- Signature: validates X-Gitea-Signature (HMAC-SHA256 of body) against WEBHOOK_SECRET - 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) +- One concurrent deploy at a time (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). +Security: no request data reaches the shell. The only shell string is a hardcoded +script (cd here, git fetch/reset, deploy.sh up). Ref validated == refs/heads/main. +Run via 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() +# Bearer token Gitea sends in the "Authorization Header" webhook field. +AUTH_TOKEN = os.environ.get("WEBHOOK_AUTH_TOKEN", "").strip() REF_FILTER = os.environ.get("WEBHOOK_REF", "refs/heads/main") -MAX_BODY = 2 * 1024 * 1024 # 2 MB cap +MAX_BODY = 2 * 1024 * 1024 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: +def check_auth(auth_header: str) -> bool: + if not AUTH_TOKEN: + log.warning("WEBHOOK_AUTH_TOKEN not set — accepting WITHOUT auth header (dev only)") + return True + if not auth_header: + return False + token = auth_header[7:].strip() if auth_header.lower().startswith("bearer ") else auth_header.strip() + return hmac.compare_digest(token, AUTH_TOKEN) + + +def verify(signature_hex: str, 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) + return hmac.compare_digest(hmac.new(SECRET, body, hashlib.sha256).hexdigest(), signature_hex) async def redeploy(): if _deploy_lock.locked(): - log.info("deploy already running, skipping") - return + 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, - ) + 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")) @@ -66,14 +73,12 @@ async def handle(reader, writer): headers = {} for ln in lines[1:]: if ":" in ln: - k, v = ln.split(":", 1) - headers[k.strip().lower()] = v.strip() + 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 + if not chunk: break body += chunk if path.split("?")[0] not in ("/hook", "/webhook"): @@ -81,10 +86,15 @@ async def handle(reader, writer): 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 + # 1) Authorization bearer token (checked FIRST). + if not check_auth(headers.get("authorization", "")): + log.warning("bad/missing Authorization from %s", writer.get_extra_info("peername")) + writer.write(b"HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\n\r\n"); await writer.drain(); return + # 2) HMAC signature of body. 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 - + # 3) Only pushes to the watched ref. try: ref = json.loads(body).get("ref", "") if body else "" except Exception: @@ -102,7 +112,7 @@ async def handle(reader, writer): 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) + log.info("webhook receiver listening on 0.0.0.0:%d (filter=%s auth=%s)", PORT, REF_FILTER, bool(AUTH_TOKEN)) async with server: await server.serve_forever()