#!/usr/bin/env python3 """ Gitea Webhook Receiver for publish project auto-deploy. Receives POST from Gitea, runs git pull + docker-compose build. Usage: python3 webhook_receiver.py [--port PORT] [--secret SECRET] [--repo-path PATH] [--compose-cmd CMD] Run as systemd service on NAS host. """ import http.server import socketserver import json import subprocess import os import sys import argparse import logging from urllib.parse import urlparse, parse_qs import threading import time logging.basicConfig( level=logging.INFO, format='%(asctime)s [%(levelname)s] %(message)s', handlers=[ logging.StreamHandler(sys.stdout) ] ) logger = logging.getLogger('webhook_receiver') class WebhookHandler(http.server.BaseHTTPRequestHandler): """Handle incoming webhook POST requests from Gitea.""" # Disable logging for favicon def do_GET(self): if self.path == '/favicon.ico': self.send_response(204) self.end_headers() return if self.path == '/health': self.send_response(200) self.send_header('Content-Type', 'text/plain') self.end_headers() self.wfile.write(b'OK') return self.send_response(404) self.end_headers() def do_POST(self): parsed = urlparse(self.path) if parsed.path != '/webhook': self.send_response(404) self.end_headers() return # Validate secret secret = self.server.secret if secret: auth_header = self.headers.get('X-Gitea-Secret', '') if auth_header != secret: logger.warning('Unauthorized webhook attempt') self.send_response(401) self.send_header('Content-Type', 'application/json') self.end_headers() self.wfile.write(json.dumps({'error': 'Unauthorized'}).encode()) return # Read payload content_length = int(self.headers.get('Content-Length', 0)) payload = self.rfile.read(content_length).decode('utf-8') try: data = json.loads(payload) if payload else {} except json.JSONDecodeError: data = {} logger.info(f'Webhook received: {data.get("ref", "unknown")} from {self.client_address[0]}') # Trigger deploy in background thread = threading.Thread(target=self._deploy, args=(data,)) thread.start() self.send_response(200) self.send_header('Content-Type', 'application/json') self.end_headers() self.wfile.write(json.dumps({'status': 'deploying'}).encode()) def _deploy(self, data): """Run git pull and docker-compose up --build in the repo directory.""" repo_path = self.server.repo_path compose_cmd = self.server.compose_cmd logger.info(f'Starting deploy at {repo_path}') # Build command if compose_cmd: cmd = f'cd "{repo_path}" && {compose_cmd}' else: # Default: git pull + npm install + vite build + docker-compose up --build -d cmd = f''' cd "{repo_path}" && git pull && npm install && npm run build && docker-compose -f docker-compose.yml up --build -d ''' try: # Run with shell result = subprocess.run( cmd, shell=True, capture_output=True, text=True, timeout=600 # 10 min timeout ) if result.returncode == 0: logger.info('Deploy completed successfully') logger.info(result.stdout[-500:] if result.stdout else '') else: logger.error(f'Deploy failed: {result.stderr[-500:] if result.stderr else "unknown error"}') except subprocess.TimeoutExpired: logger.error('Deploy timed out (>10 min)') except Exception as e: logger.error(f'Deploy error: {e}') def log_message(self, format, *args): # Suppress default request logging pass class ThreadedHTTPServer(socketserver.ThreadingMixIn, http.server.HTTPServer): """Threaded HTTP server for concurrent webhook handling.""" allow_reuse_address = True def __init__(self, port, secret, repo_path, compose_cmd): super().__init__(('0.0.0.0', port), WebhookHandler) self.secret = secret or '' self.repo_path = repo_path self.compose_cmd = compose_cmd def main(): parser = argparse.ArgumentParser(description='Gitea Webhook Auto-Deploy Receiver') parser.add_argument('--port', type=int, default=5000, help='Port to listen on (default: 5000)') parser.add_argument('--secret', type=str, default='', help='X-Gitea-Secret header value for authentication') parser.add_argument('--repo-path', type=str, default='/vol1/1000/docker/publish', help='Path to the git repository on NAS') parser.add_argument('--compose-cmd', type=str, default='', help='Custom deploy command (e.g., "docker-compose up --build -d")') args = parser.parse_args() # Auto-detect repo path from script location if not set if args.repo_path == '/vol1/1000/docker/publish': script_dir = os.path.dirname(os.path.abspath(__file__)) args.repo_path = os.path.dirname(script_dir) logger.info(f'Webhook receiver starting on port {args.port}') logger.info(f'Repository path: {args.repo_path}') if args.secret: logger.info('Secret authentication: ENABLED') else: logger.warning('No secret set - any POST to /webhook will trigger deploy!') try: server = ThreadedHTTPServer(args.port, args.secret, args.repo_path, args.compose_cmd) logger.info(f'Listening on http://0.0.0.0:{args.port}/webhook') server.serve_forever() except KeyboardInterrupt: logger.info('Shutting down...') server.shutdown() if __name__ == '__main__': main()