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 <noreply@anthropic.com>
This commit is contained in:
+19
-3
@@ -5,17 +5,33 @@ WORKDIR /app
|
|||||||
COPY . .
|
COPY . .
|
||||||
RUN python3 generate_dashboard.py
|
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
|
FROM nginx:alpine
|
||||||
# Replace the default nginx server block so root redirects to the dashboard
|
|
||||||
RUN printf '%s\n' \
|
RUN printf '%s\n' \
|
||||||
'server {' \
|
'server {' \
|
||||||
' listen 80;' \
|
' listen 80;' \
|
||||||
' server_name _;' \
|
' server_name _;' \
|
||||||
' root /usr/share/nginx/html;' \
|
' root /usr/share/nginx/html;' \
|
||||||
' index dashboard.html;' \
|
' index dashboard.html;' \
|
||||||
|
' client_max_body_size 2m;' \
|
||||||
' location = / { return 302 /dashboard.html; }' \
|
' 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/dashboard.html /usr/share/nginx/html/dashboard.html
|
||||||
COPY --from=build /app/pages /usr/share/nginx/html/pages
|
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
|
EXPOSE 80
|
||||||
|
ENTRYPOINT ["/docker-entrypoint-hostip.sh"]
|
||||||
|
|||||||
+19
-25
@@ -1,32 +1,27 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Install/manage the Gitea-push webhook receiver as a systemd service.
|
# Install/manage the Gitea-push webhook receiver as a systemd service.
|
||||||
# ./deploy-webhook.sh install -> generate secret, write unit, enable+start
|
# ./deploy-webhook.sh install | status | secret | auth | uninstall
|
||||||
# ./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:
|
# Gitea (admin/modelTesting) → Settings → Webhooks → Add webhook (Gitea type):
|
||||||
# Target URL: http://10.0.0.22:41798/hook
|
# Target URL: https://llmtesting.itsaygea.com/hook
|
||||||
# HTTP method: POST
|
# HTTP method: POST
|
||||||
# Content type: application/json
|
# POST Content Type: application/json
|
||||||
# Secret: (output of `./deploy-webhook.sh secret`)
|
# Secret: $(./deploy-webhook.sh secret)
|
||||||
# Trigger on: Push events (branch: main)
|
# Authorization Header: $(./deploy-webhook.sh auth)
|
||||||
|
# Trigger On: Push Events, branch filter: main
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
cd "$(dirname "$0")"
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
UNIT=/etc/systemd/system/llm-bench-webhook.service
|
UNIT=/etc/systemd/system/llm-bench-webhook.service
|
||||||
SECRET_FILE=.webhook.secret
|
SECRET_FILE=.webhook.secret
|
||||||
|
AUTH_FILE=.webhook.auth
|
||||||
PORT="${WEBHOOK_PORT:-41798}"
|
PORT="${WEBHOOK_PORT:-41798}"
|
||||||
|
|
||||||
case "${1:-status}" in
|
case "${1:-status}" in
|
||||||
install)
|
install)
|
||||||
# generate a fresh secret if none yet
|
[[ -f "$SECRET_FILE" ]] || { openssl rand -hex 32 > "$SECRET_FILE"; chmod 600 "$SECRET_FILE"; echo "generated HMAC secret"; }
|
||||||
if [[ ! -f "$SECRET_FILE" ]]; then
|
[[ -f "$AUTH_FILE" ]] || { openssl rand -hex 24 > "$AUTH_FILE"; chmod 600 "$AUTH_FILE"; echo "generated auth token"; }
|
||||||
openssl rand -hex 32 > "$SECRET_FILE"
|
SECRET=$(cat "$SECRET_FILE"); AUTHTOK=$(cat "$AUTH_FILE")
|
||||||
chmod 600 "$SECRET_FILE"
|
|
||||||
echo "generated new secret -> $SECRET_FILE"
|
|
||||||
fi
|
|
||||||
SECRET=$(cat "$SECRET_FILE")
|
|
||||||
sudo tee "$UNIT" >/dev/null <<EOF
|
sudo tee "$UNIT" >/dev/null <<EOF
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=LLM Benchmark — Gitea push webhook receiver
|
Description=LLM Benchmark — Gitea push webhook receiver
|
||||||
@@ -41,6 +36,7 @@ SupplementaryGroups=docker
|
|||||||
WorkingDirectory=$(pwd)
|
WorkingDirectory=$(pwd)
|
||||||
Environment=WEBHOOK_PORT=${PORT}
|
Environment=WEBHOOK_PORT=${PORT}
|
||||||
Environment=WEBHOOK_SECRET=${SECRET}
|
Environment=WEBHOOK_SECRET=${SECRET}
|
||||||
|
Environment=WEBHOOK_AUTH_TOKEN=${AUTHTOK}
|
||||||
Environment=WEBHOOK_REF=refs/heads/main
|
Environment=WEBHOOK_REF=refs/heads/main
|
||||||
Environment=HOME=/home/aygea
|
Environment=HOME=/home/aygea
|
||||||
ExecStart=/usr/bin/python3 $(pwd)/webhook.py
|
ExecStart=/usr/bin/python3 $(pwd)/webhook.py
|
||||||
@@ -53,20 +49,18 @@ EOF
|
|||||||
sudo systemctl daemon-reload
|
sudo systemctl daemon-reload
|
||||||
sudo systemctl enable --now llm-bench-webhook
|
sudo systemctl enable --now llm-bench-webhook
|
||||||
echo
|
echo
|
||||||
echo "✓ webhook service installed and started on 0.0.0.0:${PORT}"
|
echo "✓ webhook service on 0.0.0.0:${PORT}"
|
||||||
echo " Gitea webhook URL: http://10.0.0.22:${PORT}/hook"
|
echo " Gitea URL: https://llmtesting.itsaygea.com/hook"
|
||||||
|
echo " Authorization Header: $(./deploy-webhook.sh auth)"
|
||||||
echo " Secret: $(./deploy-webhook.sh secret)"
|
echo " Secret: $(./deploy-webhook.sh secret)"
|
||||||
;;
|
;;
|
||||||
status)
|
status)
|
||||||
systemctl status llm-bench-webhook --no-pager -l 2>/dev/null | head -15 || echo "not installed"
|
systemctl status llm-bench-webhook --no-pager -l 2>/dev/null | head -15 || echo "not installed"
|
||||||
echo "--- recent log ---"
|
echo "--- recent log ---"; sudo journalctl -u llm-bench-webhook -n 10 --no-pager 2>/dev/null || true ;;
|
||||||
journalctl -u llm-bench-webhook -n 10 --no-pager 2>/dev/null || true
|
|
||||||
;;
|
|
||||||
secret) cat "$SECRET_FILE" ;;
|
secret) cat "$SECRET_FILE" ;;
|
||||||
|
auth) cat "$AUTH_FILE" ;;
|
||||||
uninstall)
|
uninstall)
|
||||||
sudo systemctl disable --now llm-bench-webhook 2>/dev/null || true
|
sudo systemctl disable --now llm-bench-webhook 2>/dev/null || true
|
||||||
sudo rm -f "$UNIT"; sudo systemctl daemon-reload
|
sudo rm -f "$UNIT"; sudo systemctl daemon-reload; echo "removed webhook service" ;;
|
||||||
echo "removed webhook service"
|
*) echo "usage: $0 [install|status|secret|auth|uninstall]"; exit 1 ;;
|
||||||
;;
|
|
||||||
*) echo "usage: $0 [install|status|secret|uninstall]"; exit 1 ;;
|
|
||||||
esac
|
esac
|
||||||
|
|||||||
+5
-6
@@ -1,13 +1,12 @@
|
|||||||
services:
|
services:
|
||||||
benchmark:
|
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: .
|
build: .
|
||||||
image: llm-benchmark:latest
|
image: llm-benchmark:latest
|
||||||
container_name: llm-benchmark
|
container_name: llm-benchmark
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "0.0.0.0:31415:80" # bind all interfaces:31415 -> container :80 (obscure port; Netbird proxies in front)
|
- "0.0.0.0:31415:80" # all interfaces:31415 -> container :80 (Netbird proxies in front)
|
||||||
# Re-tag the image so `docker compose up` after a code change rebuilds it.
|
extra_hosts:
|
||||||
# (compose detects Dockerfile/context changes and rebuilds automatically.)
|
- "host.docker.internal:host-gateway"
|
||||||
|
environment:
|
||||||
|
- HOST_IP=host.docker.internal
|
||||||
|
|||||||
+34
-24
@@ -2,54 +2,61 @@
|
|||||||
"""
|
"""
|
||||||
Gitea push webhook receiver for the benchmark dashboard.
|
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)
|
- Listens on 0.0.0.0:PORT (Gitea posts to /hook via the dashboard nginx proxy,
|
||||||
- Validates the shared secret via the X-Gitea-Signature header (HMAC-SHA256 of the body)
|
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
|
- 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:
|
Security: no request data reaches the shell. The only shell string is a hardcoded
|
||||||
- No request data reaches the shell. The only string passed to the shell is a
|
script (cd here, git fetch/reset, deploy.sh up). Ref validated == refs/heads/main.
|
||||||
hardcoded script (cd to this file's own dir, git fetch/reset, deploy.sh up).
|
Run via systemd unit llm-bench-webhook.service (see deploy-webhook.sh).
|
||||||
- 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
|
import asyncio, hmac, hashlib, os, json, logging
|
||||||
from http import HTTPStatus
|
|
||||||
|
|
||||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
PORT = int(os.environ.get("WEBHOOK_PORT", "41798"))
|
PORT = int(os.environ.get("WEBHOOK_PORT", "41798"))
|
||||||
SECRET = os.environ.get("WEBHOOK_SECRET", "").encode()
|
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")
|
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")
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||||
log = logging.getLogger("webhook")
|
log = logging.getLogger("webhook")
|
||||||
_deploy_lock = asyncio.Lock()
|
_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"
|
_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:
|
if not SECRET:
|
||||||
log.warning("WEBHOOK_SECRET not set — accepting WITHOUT signature check (dev only)")
|
log.warning("WEBHOOK_SECRET not set — accepting WITHOUT signature check (dev only)")
|
||||||
return True
|
return True
|
||||||
if not signature_hex:
|
if not signature_hex:
|
||||||
return False
|
return False
|
||||||
mac = hmac.new(SECRET, body, hashlib.sha256).hexdigest()
|
return hmac.compare_digest(hmac.new(SECRET, body, hashlib.sha256).hexdigest(), signature_hex)
|
||||||
return hmac.compare_digest(mac, signature_hex)
|
|
||||||
|
|
||||||
|
|
||||||
async def redeploy():
|
async def redeploy():
|
||||||
if _deploy_lock.locked():
|
if _deploy_lock.locked():
|
||||||
log.info("deploy already running, skipping")
|
log.info("deploy already running, skipping"); return
|
||||||
return
|
|
||||||
async with _deploy_lock:
|
async with _deploy_lock:
|
||||||
log.info("starting redeploy")
|
log.info("starting redeploy")
|
||||||
proc = await asyncio.create_subprocess_exec(
|
proc = await asyncio.create_subprocess_exec(
|
||||||
"bash", "-lc", _DEPLOY_CMD,
|
"bash", "-lc", _DEPLOY_CMD,
|
||||||
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT,
|
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT)
|
||||||
)
|
|
||||||
out, _ = await proc.communicate()
|
out, _ = await proc.communicate()
|
||||||
log.info("redeploy exit=%s\n%s", proc.returncode, (out or b"").decode(errors="replace"))
|
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 = {}
|
headers = {}
|
||||||
for ln in lines[1:]:
|
for ln in lines[1:]:
|
||||||
if ":" in ln:
|
if ":" in ln:
|
||||||
k, v = ln.split(":", 1)
|
k, v = ln.split(":", 1); headers[k.strip().lower()] = v.strip()
|
||||||
headers[k.strip().lower()] = v.strip()
|
|
||||||
cl = int(headers.get("content-length", "0") or 0)
|
cl = int(headers.get("content-length", "0") or 0)
|
||||||
body = body_start
|
body = body_start
|
||||||
while len(body) < cl and len(body) < MAX_BODY:
|
while len(body) < cl and len(body) < MAX_BODY:
|
||||||
chunk = await reader.read(min(65536, cl - len(body)))
|
chunk = await reader.read(min(65536, cl - len(body)))
|
||||||
if not chunk:
|
if not chunk: break
|
||||||
break
|
|
||||||
body += chunk
|
body += chunk
|
||||||
|
|
||||||
if path.split("?")[0] not in ("/hook", "/webhook"):
|
if path.split("?")[0] not in ("/hook", "/webhook"):
|
||||||
@@ -81,10 +86,15 @@ async def handle(reader, writer):
|
|||||||
if method != "POST":
|
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
|
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):
|
if not verify(headers.get("x-gitea-signature", ""), body):
|
||||||
log.warning("bad signature from %s", writer.get_extra_info("peername"))
|
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
|
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:
|
try:
|
||||||
ref = json.loads(body).get("ref", "") if body else ""
|
ref = json.loads(body).get("ref", "") if body else ""
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -102,7 +112,7 @@ async def handle(reader, writer):
|
|||||||
|
|
||||||
async def main():
|
async def main():
|
||||||
server = await asyncio.start_server(handle, "0.0.0.0", PORT)
|
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:
|
async with server:
|
||||||
await server.serve_forever()
|
await server.serve_forever()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user