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:
19
scripts/deploy.sh
Executable file
19
scripts/deploy.sh
Executable file
@@ -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"
|
||||
17
scripts/webhook/caddy-deploy-webhook.service
Normal file
17
scripts/webhook/caddy-deploy-webhook.service
Normal file
@@ -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
|
||||
24
scripts/webhook/install.sh
Executable file
24
scripts/webhook/install.sh
Executable file
@@ -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"
|
||||
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