From 616bd0ac9168a2ae37b4156532e5df6047f37860 Mon Sep 17 00:00:00 2001 From: MacRimi Date: Sat, 7 Feb 2026 18:36:14 +0100 Subject: [PATCH] Backend SSL config manager and API endpoints --- AppImage/components/settings.tsx | 302 +++++++++++++++++++++++++- AppImage/scripts/auth_manager.py | 197 +++++++++++++++++ AppImage/scripts/flask_auth_routes.py | 82 ++++++- AppImage/scripts/flask_server.py | 24 +- 4 files changed, 589 insertions(+), 16 deletions(-) diff --git a/AppImage/components/settings.tsx b/AppImage/components/settings.tsx index 5721c4a7..29402a03 100644 --- a/AppImage/components/settings.tsx +++ b/AppImage/components/settings.tsx @@ -5,7 +5,7 @@ import { Button } from "./ui/button" import { Input } from "./ui/input" import { Label } from "./ui/label" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card" -import { Shield, Lock, User, AlertCircle, CheckCircle, Info, LogOut, Wrench, Package, Key, Copy, Eye, EyeOff, Ruler, Trash2, RefreshCw, Clock } from 'lucide-react' +import { Shield, Lock, User, AlertCircle, CheckCircle, Info, LogOut, Wrench, Package, Key, Copy, Eye, EyeOff, Ruler, Trash2, RefreshCw, Clock, ShieldCheck, Globe, FileKey, AlertTriangle } from 'lucide-react' import { APP_VERSION } from "./release-notes-modal" import { getApiUrl, fetchApi } from "../lib/api-config" import { TwoFactorSetup } from "./two-factor-setup" @@ -71,6 +71,19 @@ export function Settings() { const [revokingTokenId, setRevokingTokenId] = useState(null) const [tokenName, setTokenName] = useState("API Token") + // SSL/HTTPS state + const [sslEnabled, setSslEnabled] = useState(false) + const [sslSource, setSslSource] = useState<"none" | "proxmox" | "custom">("none") + const [sslCertPath, setSslCertPath] = useState("") + const [sslKeyPath, setSslKeyPath] = useState("") + const [proxmoxCertAvailable, setProxmoxCertAvailable] = useState(false) + const [proxmoxCertInfo, setProxmoxCertInfo] = useState<{subject?: string; expires?: string; issuer?: string; is_self_signed?: boolean} | null>(null) + const [loadingSsl, setLoadingSsl] = useState(true) + const [configuringSsl, setConfiguringSsl] = useState(false) + const [showCustomCertForm, setShowCustomCertForm] = useState(false) + const [customCertPath, setCustomCertPath] = useState("") + const [customKeyPath, setCustomKeyPath] = useState("") + const [networkUnitSettings, setNetworkUnitSettings] = useState<"Bytes" | "Bits">("Bytes") const [loadingUnitSettings, setLoadingUnitSettings] = useState(true) @@ -78,6 +91,7 @@ export function Settings() { checkAuthStatus() loadProxmenuxTools() loadApiTokens() + loadSslStatus(setSslEnabled, setSslSource, setSslCertPath, setSslKeyPath, setProxmoxCertAvailable, setProxmoxCertInfo, setLoadingSsl) getUnitsSettings() }, []) @@ -461,6 +475,90 @@ export function Settings() { setLoadingUnitSettings(false) } + const loadSslStatus = async () => { + try { + setLoadingSsl(true) + const data = await fetchApi("/api/ssl/status") + if (data.success) { + setSslEnabled(data.ssl_enabled || false) + setSslSource(data.source || "none") + setSslCertPath(data.cert_path || "") + setSslKeyPath(data.key_path || "") + setProxmoxCertAvailable(data.proxmox_available || false) + setProxmoxCertInfo(data.cert_info || null) + } + } catch { + // Silently fail + } finally { + setLoadingSsl(false) + } + } + + const handleEnableSsl = async (source: "proxmox" | "custom", certPath?: string, keyPath?: string) => { + setConfiguringSsl(true) + setError("") + setSuccess("") + + try { + const body: Record = { source } + if (source === "custom" && certPath && keyPath) { + body.cert_path = certPath + body.key_path = keyPath + } + + const data = await fetchApi("/api/ssl/configure", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }) + + if (data.success) { + setSuccess(data.message || "SSL configured successfully. Restart the monitor service to apply.") + setSslEnabled(true) + setSslSource(source) + setShowCustomCertForm(false) + setCustomCertPath("") + setCustomKeyPath("") + loadSslStatus() + } else { + setError(data.message || "Failed to configure SSL") + } + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to configure SSL") + } finally { + setConfiguringSsl(false) + } + } + + const handleDisableSsl = async () => { + if (!confirm("Are you sure you want to disable HTTPS? The monitor will revert to HTTP after restart.")) { + return + } + + setConfiguringSsl(true) + setError("") + setSuccess("") + + try { + const data = await fetchApi("/api/ssl/disable", { method: "POST" }) + + if (data.success) { + setSuccess(data.message || "SSL disabled. Restart the monitor service to apply.") + setSslEnabled(false) + setSslSource("none") + setSslCertPath("") + setSslKeyPath("") + loadSslStatus() + } else { + setError(data.message || "Failed to disable SSL") + } + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to disable SSL") + } finally { + setConfiguringSsl(false) + } + } + return (
@@ -1075,6 +1173,208 @@ export function Settings() { )} + {/* SSL/HTTPS Configuration */} + + +
+ + SSL / HTTPS +
+ + Serve ProxMenux Monitor over HTTPS using your Proxmox host certificate or a custom certificate + +
+ + {loadingSsl ? ( +
+
+
+ ) : ( + <> + {/* Current Status */} +
+
+
+ +
+
+

+ {sslEnabled ? "HTTPS Enabled" : "HTTP (No SSL)"} +

+

+ {sslEnabled + ? `Using ${sslSource === "proxmox" ? "Proxmox host" : "custom"} certificate` + : "Monitor is served over unencrypted HTTP"} +

+
+
+
+ {sslEnabled ? "HTTPS" : "HTTP"} +
+
+ + {/* Active certificate info */} + {sslEnabled && ( +
+
+ + Active Certificate +
+
+

Cert: {sslCertPath}

+

Key: {sslKeyPath}

+
+ +
+ )} + + {/* Proxmox certificate detection */} + {!sslEnabled && proxmoxCertAvailable && ( +
+
+ +

Proxmox Host Certificate Detected

+
+ + {proxmoxCertInfo && ( +
+ {proxmoxCertInfo.subject && ( +

Subject: {proxmoxCertInfo.subject}

+ )} + {proxmoxCertInfo.issuer && ( +

Issuer: {proxmoxCertInfo.issuer}

+ )} + {proxmoxCertInfo.expires && ( +

Expires: {proxmoxCertInfo.expires}

+ )} + {proxmoxCertInfo.is_self_signed && ( +
+ + Self-signed certificate (browsers will show a security warning) +
+ )} +
+ )} + + +
+ )} + + {!sslEnabled && !proxmoxCertAvailable && ( +
+ +

+ No Proxmox host certificate detected. You can configure a custom certificate below. +

+
+ )} + + {/* Custom certificate option */} + {!sslEnabled && ( +
+ {!showCustomCertForm ? ( + + ) : ( +
+

Custom Certificate Paths

+

+ Enter the absolute paths to your SSL certificate and private key files on the Proxmox server. +

+ +
+ + setCustomCertPath(e.target.value)} + disabled={configuringSsl} + /> +
+ +
+ + setCustomKeyPath(e.target.value)} + disabled={configuringSsl} + /> +
+ +
+ + +
+
+ )} +
+ )} + + {/* Info note about restart */} +
+ +

+ Changes to SSL configuration require a monitor service restart to take effect. + The service will automatically use HTTPS on port 8008 when enabled. +

+
+ + )} + + + {/* ProxMenux Optimizations */} diff --git a/AppImage/scripts/auth_manager.py b/AppImage/scripts/auth_manager.py index c60163f5..9e71c82b 100644 --- a/AppImage/scripts/auth_manager.py +++ b/AppImage/scripts/auth_manager.py @@ -571,6 +571,203 @@ def disable_totp(username, password): return False, "Failed to disable 2FA" +# ------------------------------------------------------------------- +# SSL/HTTPS Certificate Management +# ------------------------------------------------------------------- + +SSL_CONFIG_FILE = Path(os.environ.get("PROXMENUX_SSL_CONFIG", "/etc/proxmenux/ssl_config.json")) + +# Default Proxmox certificate paths +PROXMOX_CERT_PATH = "/etc/pve/local/pve-ssl.pem" +PROXMOX_KEY_PATH = "/etc/pve/local/pve-ssl.key" + + +def load_ssl_config(): + """Load SSL configuration from file""" + if not SSL_CONFIG_FILE.exists(): + return { + "enabled": False, + "cert_path": "", + "key_path": "", + "source": "none" # "none", "proxmox", "custom" + } + + try: + with open(SSL_CONFIG_FILE, 'r') as f: + config = json.load(f) + config.setdefault("enabled", False) + config.setdefault("cert_path", "") + config.setdefault("key_path", "") + config.setdefault("source", "none") + return config + except Exception: + return { + "enabled": False, + "cert_path": "", + "key_path": "", + "source": "none" + } + + +def save_ssl_config(config): + """Save SSL configuration to file""" + try: + SSL_CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True) + with open(SSL_CONFIG_FILE, 'w') as f: + json.dump(config, f, indent=2) + return True + except Exception as e: + print(f"Error saving SSL config: {e}") + return False + + +def detect_proxmox_certificates(): + """ + Detect available Proxmox certificates. + Returns dict with detection results. + """ + result = { + "proxmox_available": False, + "proxmox_cert": PROXMOX_CERT_PATH, + "proxmox_key": PROXMOX_KEY_PATH, + "cert_info": None + } + + if os.path.isfile(PROXMOX_CERT_PATH) and os.path.isfile(PROXMOX_KEY_PATH): + result["proxmox_available"] = True + + # Try to get certificate info + try: + import subprocess + cert_output = subprocess.run( + ["openssl", "x509", "-in", PROXMOX_CERT_PATH, "-noout", "-subject", "-enddate", "-issuer"], + capture_output=True, text=True, timeout=5 + ) + if cert_output.returncode == 0: + lines = cert_output.stdout.strip().split('\n') + info = {} + for line in lines: + if line.startswith("subject="): + info["subject"] = line.replace("subject=", "").strip() + elif line.startswith("notAfter="): + info["expires"] = line.replace("notAfter=", "").strip() + elif line.startswith("issuer="): + issuer = line.replace("issuer=", "").strip() + info["issuer"] = issuer + info["is_self_signed"] = info.get("subject", "") == issuer + result["cert_info"] = info + except Exception: + pass + + return result + + +def validate_certificate_files(cert_path, key_path): + """ + Validate that cert and key files exist and are readable. + Returns (valid: bool, message: str) + """ + if not cert_path or not key_path: + return False, "Certificate and key paths are required" + + if not os.path.isfile(cert_path): + return False, f"Certificate file not found: {cert_path}" + + if not os.path.isfile(key_path): + return False, f"Key file not found: {key_path}" + + # Verify files are readable + try: + with open(cert_path, 'r') as f: + content = f.read(100) + if "BEGIN CERTIFICATE" not in content and "BEGIN TRUSTED CERTIFICATE" not in content: + return False, "Certificate file does not appear to be a valid PEM certificate" + + with open(key_path, 'r') as f: + content = f.read(100) + if "BEGIN" not in content or "KEY" not in content: + return False, "Key file does not appear to be a valid PEM key" + except PermissionError: + return False, "Cannot read certificate files. Check file permissions." + except Exception as e: + return False, f"Error reading certificate files: {str(e)}" + + # Verify cert and key match + try: + import subprocess + cert_mod = subprocess.run( + ["openssl", "x509", "-noout", "-modulus", "-in", cert_path], + capture_output=True, text=True, timeout=5 + ) + key_mod = subprocess.run( + ["openssl", "rsa", "-noout", "-modulus", "-in", key_path], + capture_output=True, text=True, timeout=5 + ) + if cert_mod.returncode == 0 and key_mod.returncode == 0: + if cert_mod.stdout.strip() != key_mod.stdout.strip(): + return False, "Certificate and key do not match" + except Exception: + pass # Non-critical, proceed anyway + + return True, "Certificate files are valid" + + +def configure_ssl(cert_path, key_path, source="custom"): + """ + Configure SSL with given certificate and key paths. + Returns (success: bool, message: str) + """ + valid, message = validate_certificate_files(cert_path, key_path) + if not valid: + return False, message + + config = { + "enabled": True, + "cert_path": cert_path, + "key_path": key_path, + "source": source + } + + if save_ssl_config(config): + return True, "SSL configured successfully. Restart the monitor service to apply changes." + else: + return False, "Failed to save SSL configuration" + + +def disable_ssl(): + """Disable SSL and return to HTTP""" + config = { + "enabled": False, + "cert_path": "", + "key_path": "", + "source": "none" + } + + if save_ssl_config(config): + return True, "SSL disabled. Restart the monitor service to apply changes." + else: + return False, "Failed to save SSL configuration" + + +def get_ssl_context(): + """ + Get SSL context for Flask if SSL is configured and enabled. + Returns tuple (cert_path, key_path) or None + """ + config = load_ssl_config() + + if not config.get("enabled"): + return None + + cert_path = config.get("cert_path", "") + key_path = config.get("key_path", "") + + if cert_path and key_path and os.path.isfile(cert_path) and os.path.isfile(key_path): + return (cert_path, key_path) + + return None + + def authenticate(username, password, totp_token=None): """ Authenticate a user with username, password, and optional TOTP diff --git a/AppImage/scripts/flask_auth_routes.py b/AppImage/scripts/flask_auth_routes.py index ab5a6b47..99ecb3e8 100644 --- a/AppImage/scripts/flask_auth_routes.py +++ b/AppImage/scripts/flask_auth_routes.py @@ -24,27 +24,91 @@ def auth_status(): return jsonify(status) except Exception as e: - return jsonify({"error": str(e)}), 500 + return jsonify({"success": False, "message": str(e)}), 500 -@auth_bp.route('/api/auth/setup', methods=['POST']) -def auth_setup(): - """Set up authentication with username and password""" +# ------------------------------------------------------------------- +# SSL/HTTPS Certificate Management +# ------------------------------------------------------------------- + +@auth_bp.route('/api/ssl/status', methods=['GET']) +def ssl_status(): + """Get current SSL configuration status and detect available certificates""" try: - data = request.json - username = data.get('username') - password = data.get('password') + config = auth_manager.load_ssl_config() + detection = auth_manager.detect_proxmox_certificates() - success, message = auth_manager.setup_auth(username, password) + return jsonify({ + "success": True, + "ssl_enabled": config.get("enabled", False), + "source": config.get("source", "none"), + "cert_path": config.get("cert_path", ""), + "key_path": config.get("key_path", ""), + "proxmox_available": detection.get("proxmox_available", False), + "proxmox_cert": detection.get("proxmox_cert", ""), + "proxmox_key": detection.get("proxmox_key", ""), + "cert_info": detection.get("cert_info") + }) + except Exception as e: + return jsonify({"success": False, "message": str(e)}), 500 + + +@auth_bp.route('/api/ssl/configure', methods=['POST']) +def ssl_configure(): + """Configure SSL with Proxmox or custom certificates""" + try: + data = request.json or {} + source = data.get("source", "proxmox") + + if source == "proxmox": + cert_path = auth_manager.PROXMOX_CERT_PATH + key_path = auth_manager.PROXMOX_KEY_PATH + elif source == "custom": + cert_path = data.get("cert_path", "") + key_path = data.get("key_path", "") + else: + return jsonify({"success": False, "message": "Invalid source. Use 'proxmox' or 'custom'."}), 400 + + success, message = auth_manager.configure_ssl(cert_path, key_path, source) if success: - return jsonify({"success": True, "message": message}) + return jsonify({"success": True, "message": message, "requires_restart": True}) else: return jsonify({"success": False, "message": message}), 400 except Exception as e: return jsonify({"success": False, "message": str(e)}), 500 +@auth_bp.route('/api/ssl/disable', methods=['POST']) +def ssl_disable(): + """Disable SSL and return to HTTP""" + try: + success, message = auth_manager.disable_ssl() + + if success: + return jsonify({"success": True, "message": message, "requires_restart": True}) + else: + return jsonify({"success": False, "message": message}), 400 + except Exception as e: + return jsonify({"success": False, "message": str(e)}), 500 + + +@auth_bp.route('/api/ssl/validate', methods=['POST']) +def ssl_validate(): + """Validate custom certificate and key file paths""" + try: + data = request.json or {} + cert_path = data.get("cert_path", "") + key_path = data.get("key_path", "") + + valid, message = auth_manager.validate_certificate_files(cert_path, key_path) + + return jsonify({"success": valid, "message": message}) + except Exception as e: + return jsonify({"success": False, "message": str(e)}), 500 + + + @auth_bp.route('/api/auth/decline', methods=['POST']) def auth_decline(): """Decline authentication setup""" diff --git a/AppImage/scripts/flask_server.py b/AppImage/scripts/flask_server.py index 782f6e36..f7f3ad24 100644 --- a/AppImage/scripts/flask_server.py +++ b/AppImage/scripts/flask_server.py @@ -6694,8 +6694,6 @@ def stream_script_logs(session_id): if __name__ == '__main__': - # API endpoints available at: /api/system, /api/system-info, /api/storage, /api/proxmox-storage, /api/network, /api/vms, /api/logs, /api/health, /api/hardware, /api/prometheus, /api/node/metrics - import sys import logging @@ -6703,11 +6701,25 @@ if __name__ == '__main__': log = logging.getLogger('werkzeug') log.setLevel(logging.ERROR) - # Silence Flask CLI banner (removes "Serving Flask app", "Debug mode", "WARNING" messages) + # Silence Flask CLI banner cli = sys.modules['flask.cli'] cli.show_server_banner = lambda *x: None - # Print only essential information - # print("API endpoints available at: /api/system, /api/system-info, /api/storage, /api/proxmox-storage, /api/network, /api/vms, /api/logs, /api/health, /api/hardware, /api/prometheus, /api/node/metrics") + # Check for SSL configuration + ssl_ctx = None + try: + ssl_ctx = auth_manager.get_ssl_context() + if ssl_ctx: + print(f"[ProxMenux] Starting with HTTPS (cert: {ssl_ctx[0]})") + else: + print("[ProxMenux] Starting with HTTP (no SSL configured)") + except Exception as e: + print(f"[ProxMenux] SSL config error, falling back to HTTP: {e}") + ssl_ctx = None - app.run(host='0.0.0.0', port=8008, debug=False) + try: + app.run(host='0.0.0.0', port=8008, debug=False, ssl_context=ssl_ctx) + except Exception as e: + if ssl_ctx: + print(f"[ProxMenux] SSL startup failed ({e}), falling back to HTTP") + app.run(host='0.0.0.0', port=8008, debug=False)