Add deploy.sh + webhook receiver
deploy.sh runs on LXC 121: git pull, caddy validate, systemctl reload. webhook.py is a small HTTP receiver on :9797/deploy that verifies the gitea HMAC-SHA256 signature and triggers deploy.sh. install.sh provisions /etc/caddy-deploy/secret and the systemd unit.
This commit is contained in:
106
scripts/webhook/webhook.py
Executable file
106
scripts/webhook/webhook.py
Executable file
@@ -0,0 +1,106 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tiny webhook receiver for the caddy-conf repo.
|
||||
|
||||
Listens on 0.0.0.0:9797. Validates the X-Gitea-Signature HMAC-SHA256 header
|
||||
(using the secret in /etc/caddy-deploy/secret), then runs deploy.sh.
|
||||
|
||||
Only triggers on push to master. Returns 204 on success, 400/403 on bad
|
||||
request, 500 on deploy failure. Runs one deploy at a time.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
|
||||
BIND_HOST = "0.0.0.0"
|
||||
BIND_PORT = 9797
|
||||
SECRET_PATH = "/etc/caddy-deploy/secret"
|
||||
DEPLOY_CMD = ["/etc/caddy/scripts/deploy.sh"]
|
||||
TARGET_REF = "refs/heads/master"
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||
log = logging.getLogger("caddy-deploy-webhook")
|
||||
|
||||
_deploy_lock = threading.Lock()
|
||||
|
||||
|
||||
def load_secret() -> bytes:
|
||||
with open(SECRET_PATH, "rb") as f:
|
||||
return f.read().strip()
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def log_message(self, fmt, *args):
|
||||
log.info("%s - %s", self.address_string(), fmt % args)
|
||||
|
||||
def _reply(self, status: int, msg: str = "") -> None:
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "text/plain")
|
||||
self.end_headers()
|
||||
if msg:
|
||||
self.wfile.write(msg.encode())
|
||||
|
||||
def do_POST(self) -> None:
|
||||
if self.path != "/deploy":
|
||||
self._reply(404, "not found")
|
||||
return
|
||||
|
||||
length = int(self.headers.get("Content-Length", "0"))
|
||||
body = self.rfile.read(length) if length else b""
|
||||
|
||||
sig_header = self.headers.get("X-Gitea-Signature", "")
|
||||
secret = load_secret()
|
||||
expected = hmac.new(secret, body, hashlib.sha256).hexdigest()
|
||||
if not hmac.compare_digest(expected, sig_header):
|
||||
log.warning("signature mismatch")
|
||||
self._reply(403, "bad signature")
|
||||
return
|
||||
|
||||
try:
|
||||
payload = json.loads(body)
|
||||
except json.JSONDecodeError:
|
||||
self._reply(400, "bad json")
|
||||
return
|
||||
|
||||
ref = payload.get("ref", "")
|
||||
if ref != TARGET_REF:
|
||||
log.info("ignoring ref %s", ref)
|
||||
self._reply(204)
|
||||
return
|
||||
|
||||
if not _deploy_lock.acquire(blocking=False):
|
||||
log.info("deploy already running, skipping")
|
||||
self._reply(202, "already running")
|
||||
return
|
||||
try:
|
||||
log.info("running deploy: %s", DEPLOY_CMD)
|
||||
result = subprocess.run(DEPLOY_CMD, capture_output=True, text=True, timeout=120)
|
||||
if result.returncode != 0:
|
||||
log.error("deploy failed: %s\n%s", result.stdout, result.stderr)
|
||||
self._reply(500, "deploy failed")
|
||||
return
|
||||
log.info("deploy ok")
|
||||
self._reply(204)
|
||||
finally:
|
||||
_deploy_lock.release()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if not os.path.exists(SECRET_PATH):
|
||||
log.error("secret file missing: %s", SECRET_PATH)
|
||||
return 1
|
||||
server = HTTPServer((BIND_HOST, BIND_PORT), Handler)
|
||||
log.info("listening on %s:%d", BIND_HOST, BIND_PORT)
|
||||
server.serve_forever()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user