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

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