Files
mc-webui/scripts/updater/updater.py
MarekWo df852a1a80 feat: Add remote update capability from web GUI
Adds webhook-based update system that allows triggering updates
directly from the mc-webui menu. Includes:
- Webhook server (updater.py) on port 5050
- Systemd service and install script
- API proxy endpoints for container-to-host communication
- Update modal with progress tracking and auto-reload

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 16:58:05 +01:00

229 lines
6.8 KiB
Python
Executable File

#!/usr/bin/env python3
"""
mc-webui Update Webhook Server
A simple HTTP server that listens for update requests and executes
the update script. Designed to run as a systemd service on the host.
Security:
- Listens only on localhost (127.0.0.1)
- Simple token-based authentication (optional)
Endpoints:
- GET /health - Check if webhook is running
- POST /update - Trigger update (returns immediately, runs in background)
- GET /status - Check if update is in progress
"""
import os
import sys
import json
import subprocess
import threading
import time
from http.server import HTTPServer, BaseHTTPRequestHandler
from urllib.parse import urlparse, parse_qs
# Configuration
HOST = '0.0.0.0' # Listen on all interfaces (Docker needs this)
PORT = 5050
MCWEBUI_DIR = os.environ.get('MCWEBUI_DIR', os.path.expanduser('~/mc-webui'))
UPDATE_SCRIPT = os.path.join(MCWEBUI_DIR, 'scripts', 'update.sh')
AUTH_TOKEN = os.environ.get('UPDATER_TOKEN', '') # Optional token
# Global state
update_in_progress = False
last_update_result = None
last_update_time = None
class UpdateHandler(BaseHTTPRequestHandler):
"""HTTP request handler for update webhook."""
def log_message(self, format, *args):
"""Override to use custom logging format."""
print(f"[{self.log_date_time_string()}] {args[0]}")
def send_json(self, data, status=200):
"""Send JSON response."""
self.send_response(status)
self.send_header('Content-Type', 'application/json')
self.send_header('Access-Control-Allow-Origin', '*')
self.end_headers()
self.wfile.write(json.dumps(data).encode())
def check_auth(self):
"""Check authorization token if configured."""
if not AUTH_TOKEN:
return True
auth_header = self.headers.get('Authorization', '')
if auth_header.startswith('Bearer '):
token = auth_header[7:]
return token == AUTH_TOKEN
# Also check query parameter
parsed = urlparse(self.path)
params = parse_qs(parsed.query)
token = params.get('token', [''])[0]
return token == AUTH_TOKEN
def do_OPTIONS(self):
"""Handle CORS preflight."""
self.send_response(200)
self.send_header('Access-Control-Allow-Origin', '*')
self.send_header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
self.send_header('Access-Control-Allow-Headers', 'Authorization, Content-Type')
self.end_headers()
def do_GET(self):
"""Handle GET requests."""
parsed = urlparse(self.path)
path = parsed.path
if path == '/health':
self.handle_health()
elif path == '/status':
self.handle_status()
else:
self.send_json({'error': 'Not found'}, 404)
def do_POST(self):
"""Handle POST requests."""
parsed = urlparse(self.path)
path = parsed.path
if path == '/update':
self.handle_update()
else:
self.send_json({'error': 'Not found'}, 404)
def handle_health(self):
"""Health check endpoint."""
self.send_json({
'status': 'ok',
'service': 'mc-webui-updater',
'update_in_progress': update_in_progress,
'mcwebui_dir': MCWEBUI_DIR
})
def handle_status(self):
"""Get update status."""
self.send_json({
'update_in_progress': update_in_progress,
'last_update_result': last_update_result,
'last_update_time': last_update_time
})
def handle_update(self):
"""Trigger update."""
global update_in_progress
if not self.check_auth():
self.send_json({'error': 'Unauthorized'}, 401)
return
if update_in_progress:
self.send_json({
'success': False,
'error': 'Update already in progress'
}, 409)
return
if not os.path.exists(UPDATE_SCRIPT):
self.send_json({
'success': False,
'error': f'Update script not found: {UPDATE_SCRIPT}'
}, 500)
return
# Start update in background thread
update_in_progress = True
thread = threading.Thread(target=run_update, daemon=True)
thread.start()
self.send_json({
'success': True,
'message': 'Update started',
'note': 'Server will restart. Poll /health to detect completion.'
})
def run_update():
"""Run update script in background."""
global update_in_progress, last_update_result, last_update_time
try:
print(f"[UPDATE] Starting update from {UPDATE_SCRIPT}")
# Run the update script
result = subprocess.run(
['/bin/bash', UPDATE_SCRIPT],
cwd=MCWEBUI_DIR,
capture_output=True,
text=True,
timeout=300 # 5 minute timeout
)
last_update_result = {
'success': result.returncode == 0,
'returncode': result.returncode,
'stdout': result.stdout[-2000:] if result.stdout else '', # Last 2000 chars
'stderr': result.stderr[-500:] if result.stderr else ''
}
last_update_time = time.strftime('%Y-%m-%d %H:%M:%S')
if result.returncode == 0:
print(f"[UPDATE] Update completed successfully")
else:
print(f"[UPDATE] Update failed with code {result.returncode}")
print(f"[UPDATE] stderr: {result.stderr}")
except subprocess.TimeoutExpired:
last_update_result = {
'success': False,
'error': 'Update timed out after 5 minutes'
}
last_update_time = time.strftime('%Y-%m-%d %H:%M:%S')
print("[UPDATE] Update timed out")
except Exception as e:
last_update_result = {
'success': False,
'error': str(e)
}
last_update_time = time.strftime('%Y-%m-%d %H:%M:%S')
print(f"[UPDATE] Update error: {e}")
finally:
update_in_progress = False
def main():
"""Main entry point."""
print(f"mc-webui Update Webhook Server")
print(f" Listening on: {HOST}:{PORT}")
print(f" mc-webui dir: {MCWEBUI_DIR}")
print(f" Update script: {UPDATE_SCRIPT}")
print(f" Auth token: {'configured' if AUTH_TOKEN else 'disabled'}")
print()
if not os.path.exists(MCWEBUI_DIR):
print(f"WARNING: mc-webui directory not found: {MCWEBUI_DIR}")
if not os.path.exists(UPDATE_SCRIPT):
print(f"WARNING: Update script not found: {UPDATE_SCRIPT}")
server = HTTPServer((HOST, PORT), UpdateHandler)
try:
print(f"Server started. Press Ctrl+C to stop.")
server.serve_forever()
except KeyboardInterrupt:
print("\nShutting down...")
server.shutdown()
if __name__ == '__main__':
main()