Backend SSL config manager and API endpoints

This commit is contained in:
MacRimi
2026-02-07 18:36:14 +01:00
parent 108a169e7c
commit 616bd0ac91
4 changed files with 589 additions and 16 deletions

View File

@@ -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<string | null>(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<string, string> = { 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 (
<div className="space-y-6">
<div>
@@ -1075,6 +1173,208 @@ export function Settings() {
</Card>
)}
{/* SSL/HTTPS Configuration */}
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<ShieldCheck className="h-5 w-5 text-green-500" />
<CardTitle>SSL / HTTPS</CardTitle>
</div>
<CardDescription>
Serve ProxMenux Monitor over HTTPS using your Proxmox host certificate or a custom certificate
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{loadingSsl ? (
<div className="flex items-center justify-center py-8">
<div className="animate-spin h-8 w-8 border-4 border-green-500 border-t-transparent rounded-full" />
</div>
) : (
<>
{/* Current Status */}
<div className="flex items-center justify-between p-4 bg-muted/50 rounded-lg">
<div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${sslEnabled ? "bg-green-500/10" : "bg-gray-500/10"}`}>
<Globe className={`h-5 w-5 ${sslEnabled ? "text-green-500" : "text-gray-500"}`} />
</div>
<div>
<p className="font-medium">
{sslEnabled ? "HTTPS Enabled" : "HTTP (No SSL)"}
</p>
<p className="text-sm text-muted-foreground">
{sslEnabled
? `Using ${sslSource === "proxmox" ? "Proxmox host" : "custom"} certificate`
: "Monitor is served over unencrypted HTTP"}
</p>
</div>
</div>
<div className={`px-3 py-1 rounded-full text-sm font-medium ${sslEnabled ? "bg-green-500/10 text-green-500" : "bg-gray-500/10 text-gray-500"}`}>
{sslEnabled ? "HTTPS" : "HTTP"}
</div>
</div>
{/* Active certificate info */}
{sslEnabled && (
<div className="space-y-2 p-3 bg-green-500/5 border border-green-500/20 rounded-lg">
<div className="flex items-center gap-2 text-sm font-medium text-green-500">
<FileKey className="h-4 w-4" />
Active Certificate
</div>
<div className="grid gap-1 text-sm text-muted-foreground">
<p><span className="font-medium text-foreground">Cert:</span> <code className="text-xs">{sslCertPath}</code></p>
<p><span className="font-medium text-foreground">Key:</span> <code className="text-xs">{sslKeyPath}</code></p>
</div>
<Button
onClick={handleDisableSsl}
variant="outline"
size="sm"
disabled={configuringSsl}
className="mt-2 text-red-500 border-red-500/30 hover:bg-red-500/10 bg-transparent"
>
{configuringSsl ? "Disabling..." : "Disable HTTPS"}
</Button>
</div>
)}
{/* Proxmox certificate detection */}
{!sslEnabled && proxmoxCertAvailable && (
<div className="space-y-3 p-4 border border-border rounded-lg">
<div className="flex items-center gap-2">
<ShieldCheck className="h-4 w-4 text-green-500" />
<h3 className="font-semibold text-sm">Proxmox Host Certificate Detected</h3>
</div>
{proxmoxCertInfo && (
<div className="grid gap-1 text-sm text-muted-foreground bg-muted/50 p-3 rounded">
{proxmoxCertInfo.subject && (
<p><span className="font-medium text-foreground">Subject:</span> {proxmoxCertInfo.subject}</p>
)}
{proxmoxCertInfo.issuer && (
<p><span className="font-medium text-foreground">Issuer:</span> {proxmoxCertInfo.issuer}</p>
)}
{proxmoxCertInfo.expires && (
<p><span className="font-medium text-foreground">Expires:</span> {proxmoxCertInfo.expires}</p>
)}
{proxmoxCertInfo.is_self_signed && (
<div className="flex items-center gap-1.5 mt-1 text-yellow-500">
<AlertTriangle className="h-3.5 w-3.5" />
<span className="text-xs">Self-signed certificate (browsers will show a security warning)</span>
</div>
)}
</div>
)}
<Button
onClick={() => handleEnableSsl("proxmox")}
className="w-full bg-green-600 hover:bg-green-700 text-white"
disabled={configuringSsl}
>
{configuringSsl ? (
<div className="flex items-center gap-2">
<div className="animate-spin h-4 w-4 border-2 border-white border-t-transparent rounded-full" />
Configuring...
</div>
) : (
<>
<ShieldCheck className="h-4 w-4 mr-2" />
Use Proxmox Certificate
</>
)}
</Button>
</div>
)}
{!sslEnabled && !proxmoxCertAvailable && (
<div className="bg-yellow-500/10 border border-yellow-500/20 rounded-lg p-3 flex items-start gap-2">
<AlertTriangle className="h-5 w-5 text-yellow-500 flex-shrink-0 mt-0.5" />
<p className="text-sm text-yellow-500">
No Proxmox host certificate detected. You can configure a custom certificate below.
</p>
</div>
)}
{/* Custom certificate option */}
{!sslEnabled && (
<div className="space-y-3">
{!showCustomCertForm ? (
<Button
onClick={() => setShowCustomCertForm(true)}
variant="outline"
className="w-full"
>
<FileKey className="h-4 w-4 mr-2" />
Use Custom Certificate
</Button>
) : (
<div className="space-y-4 border border-border rounded-lg p-4">
<h3 className="font-semibold text-sm">Custom Certificate Paths</h3>
<p className="text-xs text-muted-foreground">
Enter the absolute paths to your SSL certificate and private key files on the Proxmox server.
</p>
<div className="space-y-2">
<Label htmlFor="ssl-cert-path">Certificate Path (.pem / .crt)</Label>
<Input
id="ssl-cert-path"
type="text"
placeholder="/etc/ssl/certs/mydomain.pem"
value={customCertPath}
onChange={(e) => setCustomCertPath(e.target.value)}
disabled={configuringSsl}
/>
</div>
<div className="space-y-2">
<Label htmlFor="ssl-key-path">Private Key Path (.key / .pem)</Label>
<Input
id="ssl-key-path"
type="text"
placeholder="/etc/ssl/private/mydomain.key"
value={customKeyPath}
onChange={(e) => setCustomKeyPath(e.target.value)}
disabled={configuringSsl}
/>
</div>
<div className="flex gap-2">
<Button
onClick={() => handleEnableSsl("custom", customCertPath, customKeyPath)}
className="flex-1 bg-green-600 hover:bg-green-700 text-white"
disabled={configuringSsl || !customCertPath || !customKeyPath}
>
{configuringSsl ? "Configuring..." : "Enable HTTPS"}
</Button>
<Button
onClick={() => {
setShowCustomCertForm(false)
setCustomCertPath("")
setCustomKeyPath("")
}}
variant="outline"
className="flex-1"
disabled={configuringSsl}
>
Cancel
</Button>
</div>
</div>
)}
</div>
)}
{/* Info note about restart */}
<div className="bg-blue-500/10 border border-blue-500/20 rounded-lg p-3 flex items-start gap-2">
<Info className="h-5 w-5 text-blue-500 flex-shrink-0 mt-0.5" />
<p className="text-sm text-blue-500">
Changes to SSL configuration require a monitor service restart to take effect.
The service will automatically use HTTPS on port 8008 when enabled.
</p>
</div>
</>
)}
</CardContent>
</Card>
{/* ProxMenux Optimizations */}
<Card>
<CardHeader>

View File

@@ -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

View File

@@ -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"""

View File

@@ -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)