diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100755 index 0000000..2dd4188 --- /dev/null +++ b/scripts/deploy.sh @@ -0,0 +1,19 @@ +#!/bin/bash +# Pull the latest Caddyfile from this repo and reload caddy. +# Runs on LXC 121 as root. +set -euo pipefail + +REPO_DIR="${REPO_DIR:-/etc/caddy}" + +cd "$REPO_DIR" + +echo "[deploy] git pull" +git pull --ff-only + +echo "[deploy] caddy validate" +caddy validate --config "$REPO_DIR/Caddyfile" >/dev/null + +echo "[deploy] systemctl reload caddy" +systemctl reload caddy + +echo "[deploy] done" diff --git a/scripts/webhook/caddy-deploy-webhook.service b/scripts/webhook/caddy-deploy-webhook.service new file mode 100644 index 0000000..1ec6131 --- /dev/null +++ b/scripts/webhook/caddy-deploy-webhook.service @@ -0,0 +1,17 @@ +[Unit] +Description=Caddy-conf deploy webhook +After=network.target caddy.service + +[Service] +Type=simple +ExecStart=/usr/bin/python3 /etc/caddy/scripts/webhook/webhook.py +Restart=on-failure +RestartSec=2 +User=root +ProtectSystem=full +ProtectHome=true +NoNewPrivileges=true +PrivateTmp=true + +[Install] +WantedBy=multi-user.target diff --git a/scripts/webhook/install.sh b/scripts/webhook/install.sh new file mode 100755 index 0000000..abcff74 --- /dev/null +++ b/scripts/webhook/install.sh @@ -0,0 +1,24 @@ +#!/bin/bash +# One-shot installer for the caddy-conf deploy webhook. Run on LXC 121 as root. +set -euo pipefail + +REPO_DIR="${REPO_DIR:-/etc/caddy}" +SECRET_DIR=/etc/caddy-deploy +UNIT=caddy-deploy-webhook.service + +install -d -m 700 "$SECRET_DIR" +if [[ ! -s "$SECRET_DIR/secret" ]]; then + umask 177 + openssl rand -hex 32 > "$SECRET_DIR/secret" + echo "[install] generated new secret at $SECRET_DIR/secret" +fi + +install -m 644 "$REPO_DIR/scripts/webhook/$UNIT" "/etc/systemd/system/$UNIT" +systemctl daemon-reload +systemctl enable --now "$UNIT" +systemctl status "$UNIT" --no-pager | head -6 + +echo +echo "Webhook listening on 0.0.0.0:9797/deploy" +echo "Secret (configure this in the Gitea webhook):" +cat "$SECRET_DIR/secret" diff --git a/scripts/webhook/webhook.py b/scripts/webhook/webhook.py new file mode 100755 index 0000000..5868acb --- /dev/null +++ b/scripts/webhook/webhook.py @@ -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())