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>
This commit is contained in:
MarekWo
2026-01-21 16:58:05 +01:00
parent 976d9c6822
commit df852a1a80
7 changed files with 724 additions and 2 deletions

View File

@@ -194,6 +194,43 @@ git checkout main
./scripts/update.sh
```
### Remote updates from web GUI (optional)
You can enable one-click updates directly from the mc-webui menu. This requires installing a small webhook service on the host machine.
**Install the updater service:**
```bash
cd ~/mc-webui
sudo ./scripts/updater/install.sh
```
The installer will:
- Create a systemd service `mc-webui-updater`
- Start a webhook server on port 5050 (localhost only)
- Enable automatic startup on boot
**Usage:**
1. Click the refresh button (↻) next to the version in the menu
2. If an update is available, an "Update" button appears
3. Click "Update" to trigger the update remotely
4. The app will automatically reload when the update completes
**Useful commands:**
```bash
# Check service status
systemctl status mc-webui-updater
# View logs
journalctl -u mc-webui-updater -f
# Uninstall
sudo ~/mc-webui/scripts/updater/install.sh --uninstall
```
**Security note:** The webhook listens only on localhost. The Docker container connects to it via the Docker bridge network.
---
## Gallery

View File

@@ -2125,6 +2125,131 @@ def check_update():
}), 500
# =============================================================================
# Remote Update (via host webhook)
# =============================================================================
# Updater webhook URL - tries multiple addresses for Docker compatibility
UPDATER_URLS = [
'http://host.docker.internal:5050', # Docker Desktop (Mac/Windows)
'http://172.17.0.1:5050', # Docker default bridge gateway (Linux)
'http://127.0.0.1:5050', # Localhost fallback
]
def get_updater_url():
"""Find working updater webhook URL."""
for url in UPDATER_URLS:
try:
response = requests.get(f"{url}/health", timeout=2)
if response.status_code == 200:
return url
except requests.RequestException:
continue
return None
@api_bp.route('/updater/status', methods=['GET'])
def updater_status():
"""
Check if the update webhook is available on the host.
Returns:
JSON with updater status:
{
"success": true,
"available": true,
"url": "http://172.17.0.1:5050",
"update_in_progress": false
}
"""
try:
url = get_updater_url()
if not url:
return jsonify({
'success': True,
'available': False,
'message': 'Update webhook not installed or not running'
}), 200
# Get detailed status from webhook
response = requests.get(f"{url}/health", timeout=5)
data = response.json()
return jsonify({
'success': True,
'available': True,
'url': url,
'update_in_progress': data.get('update_in_progress', False),
'mcwebui_dir': data.get('mcwebui_dir', '')
}), 200
except Exception as e:
logger.error(f"Error checking updater status: {e}")
return jsonify({
'success': False,
'available': False,
'error': str(e)
}), 200
@api_bp.route('/updater/trigger', methods=['POST'])
def updater_trigger():
"""
Trigger remote update via host webhook.
This will:
1. Call the webhook to start update.sh
2. The server will restart (containers rebuilt)
3. Frontend should poll /api/version to detect completion
Returns:
JSON with result:
{
"success": true,
"message": "Update started"
}
"""
try:
url = get_updater_url()
if not url:
return jsonify({
'success': False,
'error': 'Update webhook not available. Install it first.'
}), 503
# Trigger update
response = requests.post(f"{url}/update", timeout=10)
data = response.json()
if response.status_code == 200 and data.get('success'):
return jsonify({
'success': True,
'message': 'Update started. Server will restart shortly.'
}), 200
else:
return jsonify({
'success': False,
'error': data.get('error', 'Unknown error')
}), response.status_code
except requests.Timeout:
# Timeout might mean the update started and server is restarting
return jsonify({
'success': True,
'message': 'Update may have started (request timed out)'
}), 200
except Exception as e:
logger.error(f"Error triggering update: {e}")
return jsonify({
'success': False,
'error': str(e)
}), 500
@api_bp.route('/read_status/mark_read', methods=['POST'])
def mark_read_api():
"""

View File

@@ -1349,8 +1349,16 @@ async function checkForAppUpdates() {
if (data.success) {
if (data.update_available) {
// Update available - show green with link
versionText.innerHTML = `${data.current_version} <a href="${data.github_url}" target="_blank" class="text-success" title="Update available: ${data.latest_date}+${data.latest_commit}"><i class="bi bi-arrow-up-circle-fill"></i> Update available</a>`;
// Check if remote update is available
const updaterStatus = await fetch('/api/updater/status').then(r => r.json()).catch(() => ({ available: false }));
if (updaterStatus.available) {
// Show "Update Now" link that opens modal
versionText.innerHTML = `${data.current_version} <a href="#" onclick="openUpdateModal('${data.latest_date}+${data.latest_commit}'); return false;" class="text-success" title="Click to update"><i class="bi bi-arrow-up-circle-fill"></i> Update now</a>`;
} else {
// Show link to GitHub (no remote update available)
versionText.innerHTML = `${data.current_version} <a href="${data.github_url}" target="_blank" class="text-success" title="Update available: ${data.latest_date}+${data.latest_commit}"><i class="bi bi-arrow-up-circle-fill"></i> Update available</a>`;
}
icon.className = 'bi bi-check-circle-fill text-success';
showNotification(`Update available: ${data.latest_date}+${data.latest_commit}`, 'success');
} else {
@@ -1382,6 +1390,137 @@ async function checkForAppUpdates() {
}
}
// Store update info for modal
let pendingUpdateVersion = null;
/**
* Open update modal and prepare for remote update
*/
function openUpdateModal(newVersion) {
pendingUpdateVersion = newVersion;
// Close offcanvas menu
const offcanvas = bootstrap.Offcanvas.getInstance(document.getElementById('mainMenu'));
if (offcanvas) offcanvas.hide();
// Reset modal state
document.getElementById('updateStatus').classList.remove('d-none');
document.getElementById('updateProgress').classList.add('d-none');
document.getElementById('updateResult').classList.add('d-none');
document.getElementById('updateCancelBtn').classList.remove('d-none');
document.getElementById('updateConfirmBtn').classList.remove('d-none');
document.getElementById('updateReloadBtn').classList.add('d-none');
document.getElementById('updateMessage').textContent = `New version available: ${newVersion}`;
// Hide spinner, show message
document.querySelector('#updateStatus .spinner-border').classList.add('d-none');
// Setup confirm button
document.getElementById('updateConfirmBtn').onclick = performRemoteUpdate;
// Show modal
const modal = new bootstrap.Modal(document.getElementById('updateModal'));
modal.show();
}
/**
* Perform remote update via webhook
*/
async function performRemoteUpdate() {
const currentVersion = document.getElementById('versionText')?.textContent?.split(' ')[0] || '';
// Show progress state
document.getElementById('updateStatus').classList.add('d-none');
document.getElementById('updateProgress').classList.remove('d-none');
document.getElementById('updateCancelBtn').classList.add('d-none');
document.getElementById('updateConfirmBtn').classList.add('d-none');
document.getElementById('updateProgressMessage').textContent = 'Starting update...';
try {
// Trigger update
const response = await fetch('/api/updater/trigger', { method: 'POST' });
const data = await response.json();
if (!data.success) {
showUpdateResult(false, data.error || 'Failed to start update');
return;
}
document.getElementById('updateProgressMessage').textContent = 'Update started. Waiting for server to restart...';
// Poll for server to come back up with new version
let attempts = 0;
const maxAttempts = 60; // 2 minutes max
const pollInterval = 2000; // 2 seconds
const pollForCompletion = async () => {
attempts++;
try {
const versionResponse = await fetch('/api/version', {
cache: 'no-store',
headers: { 'Cache-Control': 'no-cache' }
});
if (versionResponse.ok) {
const versionData = await versionResponse.json();
const newVersion = versionData.version;
// Check if version changed
if (newVersion !== currentVersion) {
showUpdateResult(true, `Updated to ${newVersion}`);
return;
}
}
} catch (e) {
// Server not responding yet - this is expected during restart
document.getElementById('updateProgressMessage').textContent =
`Rebuilding containers... (${attempts}/${maxAttempts})`;
}
if (attempts < maxAttempts) {
setTimeout(pollForCompletion, pollInterval);
} else {
showUpdateResult(false, 'Update timed out. Please check server manually.');
}
};
// Start polling after a short delay
setTimeout(pollForCompletion, 3000);
} catch (error) {
console.error('Update error:', error);
showUpdateResult(false, 'Network error during update');
}
}
/**
* Show update result in modal
*/
function showUpdateResult(success, message) {
document.getElementById('updateProgress').classList.add('d-none');
document.getElementById('updateResult').classList.remove('d-none');
const icon = document.getElementById('updateResultIcon');
const msg = document.getElementById('updateResultMessage');
if (success) {
icon.className = 'bi bi-check-circle-fill text-success fs-1 mb-3 d-block';
msg.className = 'mb-0 text-success';
document.getElementById('updateReloadBtn').classList.remove('d-none');
} else {
icon.className = 'bi bi-x-circle-fill text-danger fs-1 mb-3 d-block';
msg.className = 'mb-0 text-danger';
document.getElementById('updateCancelBtn').classList.remove('d-none');
document.getElementById('updateCancelBtn').textContent = 'Close';
}
msg.textContent = message;
}
// Make openUpdateModal globally accessible
window.openUpdateModal = openUpdateModal;
/**
* Scroll to bottom of messages
*/

View File

@@ -330,6 +330,41 @@
</div>
</div>
<!-- Update Modal -->
<div class="modal fade" id="updateModal" tabindex="-1" data-bs-backdrop="static" data-bs-keyboard="false">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-cloud-download"></i> Update mc-webui</h5>
</div>
<div class="modal-body text-center py-4">
<div id="updateStatus">
<div class="spinner-border text-primary mb-3" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<p id="updateMessage" class="mb-0">Checking for updates...</p>
</div>
<div id="updateProgress" class="d-none">
<div class="progress mb-3" style="height: 8px;">
<div class="progress-bar progress-bar-striped progress-bar-animated"
role="progressbar" style="width: 100%"></div>
</div>
<p id="updateProgressMessage" class="text-muted mb-0">Updating...</p>
</div>
<div id="updateResult" class="d-none">
<i id="updateResultIcon" class="bi fs-1 mb-3 d-block"></i>
<p id="updateResultMessage" class="mb-0"></p>
</div>
</div>
<div class="modal-footer justify-content-center" id="updateFooter">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal" id="updateCancelBtn">Cancel</button>
<button type="button" class="btn btn-primary d-none" id="updateConfirmBtn">Update Now</button>
<button type="button" class="btn btn-primary d-none" id="updateReloadBtn" onclick="location.reload()">Reload Page</button>
</div>
</div>
</div>
</div>
<!-- Toast container for notifications -->
<div class="toast-container position-fixed top-0 start-0 p-3">
<div id="notificationToast" class="toast" role="alert">

137
scripts/updater/install.sh Executable file
View File

@@ -0,0 +1,137 @@
#!/bin/bash
#
# mc-webui Update Webhook Installer
#
# This script installs the update webhook service that allows
# remote updates from the mc-webui GUI.
#
# Usage: sudo ./install.sh [--uninstall]
#
set -e
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
info() { echo -e "${GREEN}[INFO]${NC} $1"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
error() { echo -e "${RED}[ERROR]${NC} $1"; exit 1; }
# Check if running as root
if [ "$EUID" -ne 0 ]; then
error "Please run as root: sudo $0"
fi
# Get the directory where this script is located
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
MCWEBUI_DIR="$(dirname "$(dirname "$SCRIPT_DIR")")"
# Detect the user who owns mc-webui directory
MCWEBUI_USER=$(stat -c '%U' "$MCWEBUI_DIR")
MCWEBUI_GROUP=$(stat -c '%G' "$MCWEBUI_DIR")
SERVICE_NAME="mc-webui-updater"
SERVICE_FILE="/etc/systemd/system/${SERVICE_NAME}.service"
# Uninstall
if [ "$1" == "--uninstall" ]; then
info "Uninstalling ${SERVICE_NAME}..."
if systemctl is-active --quiet "$SERVICE_NAME"; then
systemctl stop "$SERVICE_NAME"
info "Service stopped"
fi
if systemctl is-enabled --quiet "$SERVICE_NAME" 2>/dev/null; then
systemctl disable "$SERVICE_NAME"
info "Service disabled"
fi
if [ -f "$SERVICE_FILE" ]; then
rm "$SERVICE_FILE"
systemctl daemon-reload
info "Service file removed"
fi
echo -e "${GREEN}Uninstallation complete!${NC}"
exit 0
fi
# Install
info "Installing ${SERVICE_NAME}..."
info " mc-webui directory: $MCWEBUI_DIR"
info " mc-webui user: $MCWEBUI_USER"
# Check if updater.py exists
if [ ! -f "$SCRIPT_DIR/updater.py" ]; then
error "updater.py not found in $SCRIPT_DIR"
fi
# Check if update.sh exists
if [ ! -f "$MCWEBUI_DIR/scripts/update.sh" ]; then
error "update.sh not found in $MCWEBUI_DIR/scripts/"
fi
# Create service file with correct paths
info "Creating systemd service file..."
cat > "$SERVICE_FILE" << EOF
[Unit]
Description=mc-webui Update Webhook Server
Documentation=https://github.com/MarekWo/mc-webui
After=network.target docker.service
[Service]
Type=simple
User=root
Environment=MCWEBUI_DIR=${MCWEBUI_DIR}
Environment=UPDATER_TOKEN=
ExecStart=/usr/bin/python3 ${SCRIPT_DIR}/updater.py
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
EOF
info "Reloading systemd..."
systemctl daemon-reload
info "Enabling service..."
systemctl enable "$SERVICE_NAME"
info "Starting service..."
systemctl start "$SERVICE_NAME"
# Wait a moment for service to start
sleep 2
# Check if service is running
if systemctl is-active --quiet "$SERVICE_NAME"; then
info "Service is running!"
# Test health endpoint
if command -v curl &> /dev/null; then
HEALTH=$(curl -s http://127.0.0.1:5050/health 2>/dev/null || echo "")
if echo "$HEALTH" | grep -q '"status":"ok"'; then
info "Health check passed!"
else
warn "Health check failed - service may still be starting"
fi
fi
else
error "Service failed to start. Check: journalctl -u $SERVICE_NAME"
fi
echo ""
echo -e "${GREEN}Installation complete!${NC}"
echo ""
echo "The update webhook is now running on port 5050."
echo "You can now use the 'Update' button in mc-webui GUI."
echo ""
echo "Useful commands:"
echo " systemctl status $SERVICE_NAME # Check status"
echo " journalctl -u $SERVICE_NAME -f # View logs"
echo " sudo $0 --uninstall # Uninstall"

View File

@@ -0,0 +1,21 @@
[Unit]
Description=mc-webui Update Webhook Server
Documentation=https://github.com/MarekWo/mc-webui
After=network.target docker.service
[Service]
Type=simple
User=root
Environment=MCWEBUI_DIR=/home/marek/mc-webui
Environment=UPDATER_TOKEN=
ExecStart=/usr/bin/python3 /home/marek/mc-webui/scripts/updater/updater.py
Restart=always
RestartSec=5
# Security hardening
NoNewPrivileges=false
ProtectSystem=false
ProtectHome=false
[Install]
WantedBy=multi-user.target

228
scripts/updater/updater.py Executable file
View File

@@ -0,0 +1,228 @@
#!/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()