Updae AppImage

This commit is contained in:
MacRimi
2025-11-04 21:02:56 +01:00
parent a8311923fb
commit f0a62191ea
4 changed files with 403 additions and 294 deletions

View File

@@ -311,11 +311,6 @@ export function ProxmoxDashboard() {
try {
const token = localStorage.getItem("proxmenux-auth-token")
const hasDeclined = localStorage.getItem("proxmenux-auth-declined") === "true"
console.log("[v0] Token in localStorage:", token ? "EXISTS" : "NOT FOUND")
console.log("[v0] Has declined flag:", hasDeclined)
const headers: HeadersInit = { "Content-Type": "application/json" }
if (token) {
@@ -325,40 +320,23 @@ export function ProxmoxDashboard() {
const apiUrl = getApiUrl("/api/auth/status")
console.log("[v0] Auth status API URL:", apiUrl)
const response = await fetch(apiUrl, {
headers,
})
const response = await fetch(apiUrl, { headers })
const data = await response.json()
console.log("[v0] Auth status response data:", JSON.stringify(data, null, 2))
setAuthRequired(data.auth_enabled)
setIsAuthenticated(data.authenticated)
// If auth is not configured and user hasn't declined, show the modal
const shouldShowModal = !data.auth_configured && !hasDeclined
console.log("[v0] Auth configured:", data.auth_configured)
console.log("[v0] Should show modal:", shouldShowModal)
setAuthDeclined(!shouldShowModal)
// auth_configured will be true if user either set up auth OR skipped it
const shouldShowModal = !data.auth_configured
setAuthDeclined(data.auth_configured) // If configured (either way), don't show modal
setAuthChecked(true)
console.log("[v0] Final auth state:", {
authRequired: data.auth_enabled,
isAuthenticated: data.authenticated,
authConfigured: data.auth_configured,
hasDeclined: hasDeclined,
shouldShowModal: shouldShowModal,
authDeclined: !shouldShowModal,
})
console.log("[v0] ===== AUTH CHECK END =====")
if (data.authenticated && token) {
setupTokenRefresh()
}
} catch (error) {
console.error("[v0] Failed to check auth status:", error)
console.log("[v0] ===== AUTH CHECK FAILED =====")
setAuthDeclined(false)
setAuthChecked(true)
}

View File

@@ -0,0 +1,277 @@
"""
Authentication Manager Module
Handles all authentication-related operations including:
- Loading/saving auth configuration
- Password hashing and verification
- JWT token generation and validation
- Auth status checking
"""
import os
import json
import hashlib
from datetime import datetime, timedelta
from pathlib import Path
try:
import jwt
JWT_AVAILABLE = True
except ImportError:
JWT_AVAILABLE = False
print("Warning: PyJWT not available. Authentication features will be limited.")
# Configuration
CONFIG_DIR = Path.home() / ".config" / "proxmenux-monitor"
AUTH_CONFIG_FILE = CONFIG_DIR / "auth.json"
JWT_SECRET = "proxmenux-monitor-secret-key-change-in-production"
JWT_ALGORITHM = "HS256"
TOKEN_EXPIRATION_HOURS = 24
def ensure_config_dir():
"""Ensure the configuration directory exists"""
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
def load_auth_config():
"""
Load authentication configuration from file
Returns dict with structure:
{
"enabled": bool,
"username": str,
"password_hash": str,
"declined": bool, # True if user explicitly declined auth
"configured": bool # True if auth has been set up (enabled or declined)
}
"""
if not AUTH_CONFIG_FILE.exists():
return {
"enabled": False,
"username": None,
"password_hash": None,
"declined": False,
"configured": False
}
try:
with open(AUTH_CONFIG_FILE, 'r') as f:
config = json.load(f)
# Ensure all required fields exist
config.setdefault("declined", False)
config.setdefault("configured", config.get("enabled", False) or config.get("declined", False))
return config
except Exception as e:
print(f"Error loading auth config: {e}")
return {
"enabled": False,
"username": None,
"password_hash": None,
"declined": False,
"configured": False
}
def save_auth_config(config):
"""Save authentication configuration to file"""
ensure_config_dir()
try:
with open(AUTH_CONFIG_FILE, 'w') as f:
json.dump(config, f, indent=2)
return True
except Exception as e:
print(f"Error saving auth config: {e}")
return False
def hash_password(password):
"""Hash a password using SHA-256"""
return hashlib.sha256(password.encode()).hexdigest()
def verify_password(password, password_hash):
"""Verify a password against its hash"""
return hash_password(password) == password_hash
def generate_token(username):
"""Generate a JWT token for the given username"""
if not JWT_AVAILABLE:
return None
payload = {
'username': username,
'exp': datetime.utcnow() + timedelta(hours=TOKEN_EXPIRATION_HOURS),
'iat': datetime.utcnow()
}
try:
token = jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
return token
except Exception as e:
print(f"Error generating token: {e}")
return None
def verify_token(token):
"""
Verify a JWT token
Returns username if valid, None otherwise
"""
if not JWT_AVAILABLE or not token:
return None
try:
payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
return payload.get('username')
except jwt.ExpiredSignatureError:
print("Token has expired")
return None
except jwt.InvalidTokenError as e:
print(f"Invalid token: {e}")
return None
def get_auth_status():
"""
Get current authentication status
Returns dict with:
{
"enabled": bool,
"configured": bool,
"declined": bool,
"username": str or None
}
"""
config = load_auth_config()
return {
"enabled": config.get("enabled", False),
"configured": config.get("configured", False),
"declined": config.get("declined", False),
"username": config.get("username") if config.get("enabled") else None
}
def setup_auth(username, password):
"""
Set up authentication with username and password
Returns (success: bool, message: str)
"""
if not username or not password:
return False, "Username and password are required"
if len(password) < 6:
return False, "Password must be at least 6 characters"
config = {
"enabled": True,
"username": username,
"password_hash": hash_password(password),
"declined": False,
"configured": True
}
if save_auth_config(config):
return True, "Authentication configured successfully"
else:
return False, "Failed to save authentication configuration"
def decline_auth():
"""
Mark authentication as declined by user
Returns (success: bool, message: str)
"""
config = load_auth_config()
config["enabled"] = False
config["declined"] = True
config["configured"] = True
config["username"] = None
config["password_hash"] = None
if save_auth_config(config):
return True, "Authentication declined"
else:
return False, "Failed to save configuration"
def disable_auth():
"""
Disable authentication (different from decline - can be re-enabled)
Returns (success: bool, message: str)
"""
config = load_auth_config()
config["enabled"] = False
# Keep configured=True and don't set declined=True
# This allows re-enabling without showing the setup modal again
if save_auth_config(config):
return True, "Authentication disabled"
else:
return False, "Failed to save configuration"
def enable_auth():
"""
Enable authentication (must already be configured)
Returns (success: bool, message: str)
"""
config = load_auth_config()
if not config.get("username") or not config.get("password_hash"):
return False, "Authentication not configured. Please set up username and password first."
config["enabled"] = True
config["declined"] = False
if save_auth_config(config):
return True, "Authentication enabled"
else:
return False, "Failed to save configuration"
def change_password(old_password, new_password):
"""
Change the authentication password
Returns (success: bool, message: str)
"""
config = load_auth_config()
if not config.get("enabled"):
return False, "Authentication is not enabled"
if not verify_password(old_password, config.get("password_hash", "")):
return False, "Current password is incorrect"
if len(new_password) < 6:
return False, "New password must be at least 6 characters"
config["password_hash"] = hash_password(new_password)
if save_auth_config(config):
return True, "Password changed successfully"
else:
return False, "Failed to save new password"
def authenticate(username, password):
"""
Authenticate a user with username and password
Returns (success: bool, token: str or None, message: str)
"""
config = load_auth_config()
if not config.get("enabled"):
return False, None, "Authentication is not enabled"
if username != config.get("username"):
return False, None, "Invalid username or password"
if not verify_password(password, config.get("password_hash", "")):
return False, None, "Invalid username or password"
token = generate_token(username)
if token:
return True, token, "Authentication successful"
else:
return False, None, "Failed to generate authentication token"

View File

@@ -0,0 +1,116 @@
"""
Flask Authentication Routes
Provides REST API endpoints for authentication management
"""
from flask import jsonify, request
import auth_manager
def register_auth_routes(app):
"""Register authentication routes with the Flask app"""
@app.route('/api/auth/status', methods=['GET'])
def auth_status():
"""Get current authentication status"""
try:
status = auth_manager.get_auth_status()
return jsonify(status)
except Exception as e:
return jsonify({"error": str(e)}), 500
@app.route('/api/auth/setup', methods=['POST'])
def auth_setup():
"""Set up authentication with username and password"""
try:
data = request.json
username = data.get('username')
password = data.get('password')
success, message = auth_manager.setup_auth(username, password)
if success:
return jsonify({"success": True, "message": message})
else:
return jsonify({"success": False, "message": message}), 400
except Exception as e:
return jsonify({"success": False, "message": str(e)}), 500
@app.route('/api/auth/decline', methods=['POST'])
def auth_decline():
"""Decline authentication setup"""
try:
success, message = auth_manager.decline_auth()
if success:
return jsonify({"success": True, "message": message})
else:
return jsonify({"success": False, "message": message}), 400
except Exception as e:
return jsonify({"success": False, "message": str(e)}), 500
@app.route('/api/auth/login', methods=['POST'])
def auth_login():
"""Authenticate user and return JWT token"""
try:
data = request.json
username = data.get('username')
password = data.get('password')
success, token, message = auth_manager.authenticate(username, password)
if success:
return jsonify({"success": True, "token": token, "message": message})
else:
return jsonify({"success": False, "message": message}), 401
except Exception as e:
return jsonify({"success": False, "message": str(e)}), 500
@app.route('/api/auth/enable', methods=['POST'])
def auth_enable():
"""Enable authentication"""
try:
success, message = auth_manager.enable_auth()
if success:
return jsonify({"success": True, "message": message})
else:
return jsonify({"success": False, "message": message}), 400
except Exception as e:
return jsonify({"success": False, "message": str(e)}), 500
@app.route('/api/auth/disable', methods=['POST'])
def auth_disable():
"""Disable authentication"""
try:
success, message = auth_manager.disable_auth()
if success:
return jsonify({"success": True, "message": message})
else:
return jsonify({"success": False, "message": message}), 400
except Exception as e:
return jsonify({"success": False, "message": str(e)}), 500
@app.route('/api/auth/change-password', methods=['POST'])
def auth_change_password():
"""Change authentication password"""
try:
data = request.json
old_password = data.get('old_password')
new_password = data.get('new_password')
success, message = auth_manager.change_password(old_password, new_password)
if success:
return jsonify({"success": True, "message": message})
else:
return jsonify({"success": False, "message": message}), 400
except Exception as e:
return jsonify({"success": False, "message": str(e)}), 500

View File

@@ -28,275 +28,13 @@ import jwt
from functools import wraps
from pathlib import Path
from flask_auth_routes import auth_bp
app = Flask(__name__)
CORS(app) # Enable CORS for Next.js frontend
# Authentication configuration
AUTH_CONFIG_DIR = Path.home() / ".config" / "proxmenux-monitor"
AUTH_CONFIG_FILE = AUTH_CONFIG_DIR / "auth.json"
JWT_SECRET = secrets.token_hex(32) # Generate a random secret for JWT
SESSION_TIMEOUT = 30 * 60 # 30 minutes in seconds
app.register_blueprint(auth_bp)
# Ensure config directory exists
AUTH_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
def hash_password(password: str) -> str:
"""Hash a password using SHA-256"""
return hashlib.sha256(password.encode()).hexdigest()
def load_auth_config():
"""Load authentication configuration from file"""
if not AUTH_CONFIG_FILE.exists():
return {} # Return empty dict if file doesn't exist
try:
with open(AUTH_CONFIG_FILE, 'r') as f:
return json.load(f)
except (json.JSONDecodeError, FileNotFoundError): # Handle potential errors
return {} # Return empty dict on error
def save_auth_config(config):
"""Save authentication configuration to file"""
with open(AUTH_CONFIG_FILE, 'w') as f:
json.dump(config, f, indent=2)
def require_auth(f):
"""Decorator to require authentication for endpoints"""
@wraps(f)
def decorated_function(*args, **kwargs):
auth_config = load_auth_config()
# If auth is not enabled, allow access
if not auth_config.get("auth_enabled", False):
return f(*args, **kwargs)
# Check for Authorization header
auth_header = request.headers.get('Authorization')
if not auth_header or not auth_header.startswith('Bearer '):
return jsonify({"error": "Authentication required"}), 401
token = auth_header.split(' ')[1]
try:
# Verify JWT token
payload = jwt.decode(token, JWT_SECRET, algorithms=['HS256'])
# Check if token is expired
if time.time() > payload.get('exp', 0):
return jsonify({"error": "Token expired"}), 401
return f(*args, **kwargs)
except jwt.InvalidTokenError:
return jsonify({"error": "Invalid token"}), 401
return decorated_function
# Authentication endpoints
@app.route('/api/auth/status', methods=['GET'])
def auth_status():
"""Check if authentication is enabled and if current session is valid"""
try:
auth_config = load_auth_config()
is_configured = auth_config is not None and len(auth_config) > 0
is_enabled = auth_config.get("auth_enabled", False) if is_configured else False
# Check if user has valid token
is_authenticated = False
if is_enabled:
auth_header = request.headers.get('Authorization')
if auth_header and auth_header.startswith('Bearer '):
token = auth_header.split(' ')[1]
try:
payload = jwt.decode(token, JWT_SECRET, algorithms=['HS256'])
if time.time() <= payload.get('exp', 0):
is_authenticated = True
except:
pass
return jsonify({
"auth_enabled": is_enabled,
"auth_configured": is_configured, # New field to indicate if auth has been set up
"authenticated": is_authenticated or not is_enabled
})
except Exception as e:
return jsonify({"error": str(e)}), 500
@app.route('/api/auth/setup', methods=['POST'])
def auth_setup():
"""Setup authentication for the first time"""
try:
data = request.get_json()
username = data.get('username', '').strip()
password = data.get('password', '').strip()
if not username or not password:
return jsonify({"error": "Username and password are required"}), 400
if len(password) < 6:
return jsonify({"error": "Password must be at least 6 characters"}), 400
# Hash password and save config
password_hash = hash_password(password)
auth_config = {
"auth_enabled": True,
"username": username,
"password_hash": password_hash,
"created_at": datetime.now().isoformat()
}
save_auth_config(auth_config)
# Generate JWT token
token = jwt.encode({
'username': username,
'exp': time.time() + SESSION_TIMEOUT
}, JWT_SECRET, algorithm='HS256')
return jsonify({
"success": True,
"token": token
})
except Exception as e:
return jsonify({"error": str(e)}), 500
@app.route('/api/auth/skip', methods=['POST'])
def auth_skip():
"""Skip authentication setup"""
try:
auth_config = {
"auth_enabled": False,
"skipped": True,
"skipped_at": datetime.now().isoformat()
}
save_auth_config(auth_config)
return jsonify({"success": True})
except Exception as e:
return jsonify({"error": str(e)}), 500
@app.route('/api/auth/login', methods=['POST'])
def auth_login():
"""Login with username and password"""
try:
data = request.get_json()
username = data.get('username', '').strip()
password = data.get('password', '').strip()
if not username or not password:
return jsonify({"error": "Username and password are required"}), 400
# Load auth config
auth_config = load_auth_config()
if not auth_config or not auth_config.get("auth_enabled", False):
return jsonify({"error": "Authentication is not enabled"}), 400
# Verify credentials
stored_username = auth_config.get("username", "")
stored_password_hash = auth_config.get("password_hash", "")
if username != stored_username or hash_password(password) != stored_password_hash:
return jsonify({"error": "Invalid username or password"}), 401
# Generate JWT token
token = jwt.encode({
'username': username,
'exp': time.time() + SESSION_TIMEOUT
}, JWT_SECRET, algorithm='HS256')
return jsonify({
"success": True,
"token": token
})
except Exception as e:
return jsonify({"error": str(e)}), 500
@app.route('/api/auth/refresh', methods=['POST'])
def auth_refresh():
"""Refresh JWT token"""
try:
auth_header = request.headers.get('Authorization')
if not auth_header or not auth_header.startswith('Bearer '):
return jsonify({"error": "No token provided"}), 401
token = auth_header.split(' ')[1]
try:
# Verify current token
payload = jwt.decode(token, JWT_SECRET, algorithms=['HS256'])
username = payload.get('username')
# Generate new token
new_token = jwt.encode({
'username': username,
'exp': time.time() + SESSION_TIMEOUT
}, JWT_SECRET, algorithm='HS256')
return jsonify({
"success": True,
"token": new_token
})
except jwt.InvalidTokenError:
return jsonify({"error": "Invalid token"}), 401
except Exception as e:
return jsonify({"error": str(e)}), 500
@app.route('/api/auth/logout', methods=['POST'])
@require_auth
def auth_logout():
"""Logout (client should delete token)"""
return jsonify({"success": True})
@app.route('/api/auth/disable', methods=['POST'])
@require_auth
def auth_disable():
"""Disable authentication"""
try:
auth_config = {
"auth_enabled": False,
"disabled_at": datetime.now().isoformat()
}
save_auth_config(auth_config)
return jsonify({"success": True})
except Exception as e:
return jsonify({"error": str(e)}), 500
@app.route('/api/auth/change-password', methods=['POST'])
@require_auth
def auth_change_password():
"""Change password"""
try:
data = request.get_json()
current_password = data.get('current_password', '').strip()
new_password = data.get('new_password', '').strip()
if not current_password or not new_password:
return jsonify({"error": "Current and new password are required"}), 400
if len(new_password) < 6:
return jsonify({"error": "New password must be at least 6 characters"}), 400
# Load auth config
auth_config = load_auth_config()
# Verify current password
stored_password_hash = auth_config.get("password_hash", "")
if hash_password(current_password) != stored_password_hash:
return jsonify({"error": "Current password is incorrect"}), 401
# Update password
auth_config["password_hash"] = hash_password(new_password)
auth_config["updated_at"] = datetime.now().isoformat()
save_auth_config(auth_config)
return jsonify({"success": True})
except Exception as e:
return jsonify({"error": str(e)}), 500
# app = Flask(__name__)
# CORS(app) # Enable CORS for Next.js frontend
@@ -2465,7 +2203,7 @@ def get_proxmox_vms():
# print(f"[v0] Error getting VM/LXC info: {e}")
pass
return {
'error': f'Unable to access VM information: {str(e)}',
'error': 'Unable to access VM information: {str(e)}',
'vms': []
}
except Exception as e:
@@ -3567,7 +3305,7 @@ def get_detailed_gpu_info(gpu):
gfx_clock = clocks['GFX_SCLK']
if 'value' in gfx_clock:
detailed_info['clock_graphics'] = f"{gfx_clock['value']} MHz"
# print(f"[v0] Graphics Clock: {detailed_info['clock_graphics']}", flush=True)
# print(f"[v0] Graphics Clock: {detailed_info['clock_graphics']} MHz", flush=True)
pass
data_retrieved = True
@@ -4380,7 +4118,7 @@ def get_hardware_info():
# print(f"[v0] Error getting storage info: {e}")
pass
# Graphics Cards (from lspci - will be duplicated by new PCI device listing, but kept for now)
# Graphics Cards
try:
# Try nvidia-smi first
result = subprocess.run(['nvidia-smi', '--query-gpu=name,memory.total,memory.used,temperature.gpu,power.draw,utilization.gpu,utilization.memory,clocks.graphics,clocks.memory', '--format=csv,noheader,nounits'],