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>
121 lines
5.1 KiB
Python
121 lines
5.1 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Gitea push webhook receiver for the benchmark dashboard.
|
|
|
|
- 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 (lock prevents overlapping rebuilds)
|
|
|
|
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
|
|
|
|
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
|
|
|
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
|
log = logging.getLogger("webhook")
|
|
_deploy_lock = asyncio.Lock()
|
|
|
|
_DEPLOY_CMD = "cd " + HERE + " && git fetch origin && git reset --hard origin/main && ./deploy.sh up"
|
|
|
|
|
|
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
|
|
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
|
|
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
|
|
|
|
# 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:
|
|
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 auth=%s)", PORT, REF_FILTER, bool(AUTH_TOKEN))
|
|
async with server:
|
|
await server.serve_forever()
|
|
|
|
if __name__ == "__main__":
|
|
asyncio.run(main())
|