Add Gitea-push webhook for auto-deploy

webhook.py: HMAC-signed receiver (X-Gitea-Signature), validates ref==main,
  one-concurrent-deploy lock, no request data reaches shell.
deploy-webhook.sh: installs llm-bench-webhook systemd service (runs as
  aygea, in docker group), generates + stores secret in .webhook.secret.
deploy.sh: port read from compose (now 31415).

Installed on mewtwo: listening 0.0.0.0:41798, enabled for boot.
Gitea webhook target: http://10.0.0.22:41798/hook

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-07-28 14:20:03 -07:00
co-authored by Claude
parent 42291f61b8
commit b97658e067
4 changed files with 189 additions and 2 deletions
+3
View File
@@ -18,3 +18,6 @@ __pycache__/
.env .env
.env.* .env.*
*.local *.local
# --- Webhook secret (NEVER commit) ---
.webhook.secret
+72
View File
@@ -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 <<EOF
[Unit]
Description=LLM Benchmark — Gitea push webhook receiver
After=network-online.target docker.service
Wants=network-online.target
[Service]
Type=simple
User=aygea
Group=aygea
SupplementaryGroups=docker
WorkingDirectory=$(pwd)
Environment=WEBHOOK_PORT=${PORT}
Environment=WEBHOOK_SECRET=${SECRET}
Environment=WEBHOOK_REF=refs/heads/main
Environment=HOME=/home/aygea
ExecStart=/usr/bin/python3 $(pwd)/webhook.py
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable --now llm-bench-webhook
echo
echo "✓ webhook service installed and started on 0.0.0.0:${PORT}"
echo " Gitea webhook URL: http://10.0.0.22:${PORT}/hook"
echo " Secret: $(./deploy-webhook.sh secret)"
;;
status)
systemctl status llm-bench-webhook --no-pager -l 2>/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
+4 -2
View File
@@ -16,8 +16,10 @@ case "${1:-up}" in
# --build forces a rebuild so new grades/code take effect # --build forces a rebuild so new grades/code take effect
docker compose up -d --build docker compose up -d --build
echo echo
echo "✓ dashboard up: http://$(hostname -I 2>/dev/null | awk '{print $1}' || echo localhost):8081/dashboard.html" PORT=$(grep -oP '0\.0\.0\.0:\K[0-9]+' docker-compose.yml | head -1)
echo " (bind 0.0.0.0:8081 -> container :80)" 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 ;; logs) docker compose logs -f --tail=100 ;;
down) docker compose down ;; down) docker compose down ;;
+110
View File
@@ -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())