update beta ProxMenux 1.2.1.1-beta

This commit is contained in:
MacRimi
2026-05-09 18:59:59 +02:00
parent 5ed1fc44fd
commit 2f919de9e3
125 changed files with 16506 additions and 2877 deletions
+222 -39
View File
@@ -9,11 +9,54 @@ import os
import subprocess
import threading
import time
from collections import defaultdict, deque
from flask import Blueprint, jsonify, request
import auth_manager
from jwt_middleware import require_auth
import jwt
import datetime
# ─── Login rate limiter (audit Tier 3 #21) ───────────────────────────────
#
# Limits failed-login storms even on installations without Fail2Ban. Sliding
# window: 5 attempts per IP per 5 minutes. After the limit, the endpoint
# returns 429 until the oldest attempt ages out of the window. Counts ALL
# /api/auth/login POSTs (we don't know success vs failure until after auth)
# — a legitimate user has ample headroom for typos.
class _LoginRateLimiter:
def __init__(self, max_attempts=5, window_seconds=300):
self._max = max_attempts
self._window = window_seconds
self._buckets = defaultdict(deque) # ip -> deque[ts]
self._lock = threading.Lock()
def check_and_record(self, ip):
"""Returns (allowed: bool, retry_after_seconds: int)."""
if not ip:
ip = "unknown"
now = time.time()
cutoff = now - self._window
with self._lock:
bucket = self._buckets[ip]
# Drop stale entries
while bucket and bucket[0] < cutoff:
bucket.popleft()
if len(bucket) >= self._max:
# Reject; advise client when to try again.
retry = max(1, int(self._window - (now - bucket[0])))
return False, retry
bucket.append(now)
# Bound memory in pathological scans by reaping idle IPs occasionally.
if len(self._buckets) > 1024:
stale = [k for k, q in self._buckets.items() if not q or q[-1] < cutoff]
for k in stale:
self._buckets.pop(k, None)
return True, 0
_login_limiter = _LoginRateLimiter(max_attempts=5, window_seconds=300)
# Dedicated logger for auth failures (Fail2Ban reads this file)
auth_logger = logging.getLogger("proxmenux-auth")
auth_logger.setLevel(logging.WARNING)
@@ -34,15 +77,24 @@ except Exception:
pass # Syslog may not be available in all environments
# Only honor XFF when the operator has explicitly opted in via env var.
# Without this, a remote client can send `X-Forwarded-For: 1.2.3.4` to make
# each failed login look like it came from a different IP, defeating the
# Fail2Ban brute-force jail and polluting the auth log used by F2B. See
# audit Tier 3 #20.
_TRUST_PROXY = os.environ.get("PROXMENUX_TRUST_PROXY", "0") == "1"
def _get_client_ip():
"""Get the real client IP, supporting reverse proxies (X-Forwarded-For, X-Real-IP)"""
forwarded = request.headers.get("X-Forwarded-For", "")
if forwarded:
# First IP in the chain is the real client
return forwarded.split(",")[0].strip()
real_ip = request.headers.get("X-Real-IP", "")
if real_ip:
return real_ip.strip()
"""Get the real client IP. Honors XFF/X-Real-IP only when PROXMENUX_TRUST_PROXY=1."""
if _TRUST_PROXY:
forwarded = request.headers.get("X-Forwarded-For", "")
if forwarded:
# First IP in the chain is the real client
return forwarded.split(",")[0].strip()
real_ip = request.headers.get("X-Real-IP", "")
if real_ip:
return real_ip.strip()
return request.remote_addr or "unknown"
auth_bp = Blueprint('auth', __name__)
@@ -114,6 +166,7 @@ def _schedule_service_restart(delay=1.5):
@auth_bp.route('/api/ssl/configure', methods=['POST'])
@require_auth
def ssl_configure():
"""Configure SSL with Proxmox or custom certificates"""
try:
@@ -122,8 +175,19 @@ def ssl_configure():
auto_restart = data.get("auto_restart", True)
if source == "proxmox":
cert_path = auth_manager.PROXMOX_CERT_PATH
key_path = auth_manager.PROXMOX_KEY_PATH
# Sprint 11.8 / Issue #181: prefer the ACME-uploaded cert
# (pveproxy-ssl.pem) over the self-signed default (pve-ssl.pem)
# by going through the detector. detect_proxmox_certificates()
# returns the path PVE itself uses, which is what the user sees
# in the "Available" status — `ssl_configure` was hard-coding
# the self-signed default and silently downgrading the cert.
detection = auth_manager.detect_proxmox_certificates()
if detection.get("proxmox_available"):
cert_path = detection.get("proxmox_cert") or auth_manager.PROXMOX_CERT_PATH
key_path = detection.get("proxmox_key") or auth_manager.PROXMOX_KEY_PATH
else:
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", "")
@@ -131,8 +195,16 @@ def ssl_configure():
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:
# Issue #194 cross-detection: if the user already configured
# the PVE notifications webhook, the registered URL still
# points at `http://...`. Re-register it now (before the
# service restart) so PVE picks up the new https:// scheme
# the moment Flask comes back up. NO-OP when no webhook is
# registered yet.
_refresh_pve_webhook_for_ssl_change()
if auto_restart:
_schedule_service_restart()
return jsonify({
@@ -148,15 +220,21 @@ def ssl_configure():
@auth_bp.route('/api/ssl/disable', methods=['POST'])
@require_auth
def ssl_disable():
"""Disable SSL and return to HTTP"""
try:
data = request.json or {}
auto_restart = data.get("auto_restart", True)
success, message = auth_manager.disable_ssl()
if success:
# Same cross-detection as `ssl_configure`: rewrite the PVE
# webhook URL back to http:// so PVE doesn't keep posting
# to an https:// endpoint that no longer answers.
_refresh_pve_webhook_for_ssl_change()
if auto_restart:
_schedule_service_restart()
return jsonify({
@@ -171,7 +249,27 @@ def ssl_disable():
return jsonify({"success": False, "message": str(e)}), 500
def _refresh_pve_webhook_for_ssl_change():
"""Helper used by both `ssl_configure` and `ssl_disable`.
Wraps the deferred import and the try/except so an unrelated
notifications-stack hiccup never fails the SSL toggle itself.
Logs but doesn't raise on any error path.
"""
try:
from flask_notification_routes import refresh_pve_webhook_url_if_registered
result = refresh_pve_webhook_url_if_registered()
if result.get('skipped'):
return # Nothing to do — no webhook registered yet.
if result.get('error'):
print(f"[ssl] webhook refresh after SSL change had a non-fatal "
f"error: {result['error']}")
except Exception as e:
print(f"[ssl] failed to refresh PVE webhook after SSL change: {e}")
@auth_bp.route('/api/ssl/validate', methods=['POST'])
@require_auth
def ssl_validate():
"""Validate custom certificate and key file paths"""
try:
@@ -189,10 +287,21 @@ def ssl_validate():
@auth_bp.route('/api/auth/decline', methods=['POST'])
def auth_decline():
"""Decline authentication setup"""
"""Decline authentication setup.
Reachable without auth so a fresh install can opt out before any user is
created — but ONCE auth has been configured, this endpoint must reject:
otherwise an unauth attacker can `decline` post-setup and turn off the
requirement to authenticate. See audit Tier 1 #5.
"""
try:
if auth_manager.load_auth_config().get("configured", False):
return jsonify({
"success": False,
"message": "Authentication is already configured; cannot decline."
}), 403
success, message = auth_manager.decline_auth()
if success:
return jsonify({"success": True, "message": message})
else:
@@ -205,11 +314,27 @@ def auth_decline():
def auth_login():
"""Authenticate user and return JWT token"""
try:
# Application-level rate limit (5 tries per IP per 5 min). Hits BEFORE
# auth so the cost of the attempt — bcrypt-equivalent password check
# plus DB read — isn't paid by the attacker. Audit Tier 3 #21.
client_ip = _get_client_ip()
allowed, retry_after = _login_limiter.check_and_record(client_ip)
if not allowed:
auth_logger.warning(
"login rate limit exceeded; rhost=%s retry_after=%ds",
client_ip, retry_after,
)
return jsonify({
"success": False,
"message": "Too many login attempts. Please wait and try again.",
"retry_after": retry_after,
}), 429
data = request.json
username = data.get('username')
password = data.get('password')
totp_token = data.get('totp_token') # Optional 2FA token
success, token, requires_totp, message = auth_manager.authenticate(username, password, totp_token)
if success:
@@ -218,8 +343,8 @@ def auth_login():
# First step: password OK, requesting TOTP code (not a failure)
return jsonify({"success": False, "requires_totp": True, "message": message}), 200
else:
# Authentication failure (wrong password or wrong TOTP code)
client_ip = _get_client_ip()
# Authentication failure (wrong password or wrong TOTP code).
# `client_ip` was already resolved at the top for rate-limiting.
auth_logger.warning(
"authentication failure; rhost=%s user=%s",
client_ip, username or "unknown"
@@ -289,15 +414,21 @@ def auth_disable():
@auth_bp.route('/api/auth/change-password', methods=['POST'])
@require_auth
def auth_change_password():
"""Change authentication password"""
"""Change authentication password.
Accepts an optional `totp_code` in the JSON body. When the account has
2FA enabled, that code is mandatory — see auth_manager.change_password.
"""
try:
data = request.json
data = request.json or {}
old_password = data.get('old_password')
new_password = data.get('new_password')
success, message = auth_manager.change_password(old_password, new_password)
totp_code = data.get('totp_code')
success, message = auth_manager.change_password(old_password, new_password, totp_code)
if success:
return jsonify({"success": True, "message": message})
else:
@@ -308,14 +439,23 @@ def auth_change_password():
@auth_bp.route('/api/auth/skip', methods=['POST'])
def auth_skip():
"""Skip authentication setup (same as decline)"""
"""Skip authentication setup (same as decline).
Same hardening as /api/auth/decline: once auth is configured, this is
locked. See audit Tier 1 #5.
"""
try:
if auth_manager.load_auth_config().get("configured", False):
return jsonify({
"success": False,
"message": "Authentication is already configured; cannot skip."
}), 403
success, message = auth_manager.decline_auth()
if success:
# Return success with clear indication that APIs should be accessible
return jsonify({
"success": True,
"success": True,
"message": message,
"auth_declined": True # Add explicit flag for frontend
})
@@ -387,13 +527,14 @@ def totp_disable():
if not username:
return jsonify({"success": False, "message": "Unauthorized"}), 401
data = request.json
data = request.json or {}
password = data.get('password')
totp_code = data.get('totp_code')
if not password:
return jsonify({"success": False, "message": "Password required"}), 400
success, message = auth_manager.disable_totp(username, password)
success, message = auth_manager.disable_totp(username, password, totp_code)
if success:
return jsonify({"success": True, "message": message})
@@ -407,9 +548,18 @@ def totp_disable():
def generate_api_token():
"""Generate a long-lived API token for external integrations (Homepage, Home Assistant, etc.)"""
try:
# API tokens are scoped to a real authenticated user. Without
# auth configured there is no user to attach the token to —
# surface that as a 400 with a clear message rather than 401,
# so the UI can show "configure auth first" instead of bouncing
# the user to a login page that doesn't exist yet.
config = auth_manager.load_auth_config()
if not config.get("enabled", False) or config.get("declined", False):
return jsonify({"success": False, "message": "Authentication must be configured before generating API tokens"}), 400
auth_header = request.headers.get('Authorization', '')
token = auth_header.replace('Bearer ', '')
if not token:
return jsonify({"success": False, "message": "Unauthorized. Please log in first."}), 401
@@ -422,7 +572,15 @@ def generate_api_token():
password = data.get('password')
totp_token = data.get('totp_token') # Optional 2FA token
token_name = data.get('token_name', 'API Token') # Optional token description
# `scope` narrows what the token can do. Defaults to `read_only` —
# which is the safe choice for the most common integration cases
# (Homepage / Home Assistant dashboards just read metrics). Caller
# can opt into `full_admin` explicitly. Audit Tier 6 — Tokens API
# JWT 365 días sin scope.
scope = data.get('scope', 'read_only')
if scope not in ('read_only', 'full_admin'):
return jsonify({"success": False, "message": "Invalid scope (read_only|full_admin)"}), 400
if not password:
return jsonify({"success": False, "message": "Password is required"}), 400
@@ -431,12 +589,20 @@ def generate_api_token():
if success:
# Generate a long-lived token (1 year expiration)
# `auth_manager.JWT_SECRET` (capitalised constant) was removed when
# the per-install secret moved into `auth.json`; the helper
# `_get_jwt_secret()` is the public way to read it. Without this
# call the route AttributeError'd on every API-token generation.
# iss/aud match the values the verifier expects in Sprint 10E.
api_token = jwt.encode({
'username': username,
'token_name': token_name,
'exp': datetime.datetime.utcnow() + datetime.timedelta(days=365),
'iat': datetime.datetime.utcnow()
}, auth_manager.JWT_SECRET, algorithm='HS256')
'iat': datetime.datetime.utcnow(),
'iss': auth_manager.JWT_ISSUER,
'aud': auth_manager.JWT_AUDIENCE,
'scope': scope,
}, auth_manager._get_jwt_secret(), algorithm='HS256')
# Store token metadata for listing and revocation
auth_manager.store_api_token_metadata(api_token, token_name)
@@ -459,12 +625,23 @@ def generate_api_token():
@auth_bp.route('/api/auth/api-tokens', methods=['GET'])
def list_api_tokens():
"""List all generated API tokens (metadata only, no actual token values)"""
"""List all generated API tokens (metadata only, no actual token values).
When auth is not configured (fresh install) or has been declined, no
tokens can exist and the endpoint should return an empty list instead
of 401. Returning 401 here trips the frontend's `fetchApi` redirect
to `/`, which silently boots the user out of the Security page on
any host without auth set up — see bug reported 2026-05-07.
"""
try:
config = auth_manager.load_auth_config()
if not config.get("enabled", False) or config.get("declined", False):
return jsonify({"success": True, "tokens": []})
token = request.headers.get('Authorization', '').replace('Bearer ', '')
if not token or not auth_manager.verify_token(token):
return jsonify({"success": False, "message": "Unauthorized"}), 401
tokens = auth_manager.list_api_tokens()
return jsonify({"success": True, "tokens": tokens})
except Exception as e:
@@ -473,14 +650,20 @@ def list_api_tokens():
@auth_bp.route('/api/auth/api-tokens/<token_id>', methods=['DELETE'])
def revoke_api_token_route(token_id):
"""Revoke an API token by its ID"""
"""Revoke an API token by its ID."""
try:
config = auth_manager.load_auth_config()
# Without configured auth there are no tokens to revoke; surface
# that as a clean 400 instead of an unhelpful 401.
if not config.get("enabled", False) or config.get("declined", False):
return jsonify({"success": False, "message": "Authentication is not configured"}), 400
token = request.headers.get('Authorization', '').replace('Bearer ', '')
if not token or not auth_manager.verify_token(token):
return jsonify({"success": False, "message": "Unauthorized"}), 401
success, message = auth_manager.revoke_api_token(token_id)
if success:
return jsonify({"success": True, "message": message})
else: