diff --git a/AppImage/components/proxmox-dashboard.tsx b/AppImage/components/proxmox-dashboard.tsx index b97aaf4a..20794015 100644 --- a/AppImage/components/proxmox-dashboard.tsx +++ b/AppImage/components/proxmox-dashboard.tsx @@ -11,6 +11,7 @@ import { VirtualMachines } from "./virtual-machines" import Hardware from "./hardware" import { SystemLogs } from "./system-logs" import { Settings } from "./settings" +import { Security } from "./security" import { OnboardingCarousel } from "./onboarding-carousel" import { HealthStatusModal } from "./health-status-modal" import { ReleaseNotesModal, useVersionCheck } from "./release-notes-modal" @@ -31,6 +32,7 @@ import { FileText, SettingsIcon, Terminal, + ShieldCheck, } from "lucide-react" import Image from "next/image" import { ThemeToggle } from "./theme-toggle" @@ -265,8 +267,10 @@ export function ProxmoxDashboard() { return "Terminal" case "logs": return "System Logs" - case "settings": - return "Settings" + case "security": + return "Security" + case "settings": + return "Settings" default: return "Navigation Menu" } @@ -416,7 +420,7 @@ export function ProxmoxDashboard() { >
- + Terminal + + Security + Terminal + +
+ )} + + {!authEnabled && showSetupForm && ( +
+

Setup Authentication

+ +
+ +
+ + setUsername(e.target.value)} + className="pl-10" + disabled={loading} + /> +
+
+ +
+ +
+ + setPassword(e.target.value)} + className="pl-10" + disabled={loading} + /> +
+
+ +
+ +
+ + setConfirmPassword(e.target.value)} + className="pl-10" + disabled={loading} + /> +
+
+ +
+ + +
+
+ )} + + {authEnabled && ( +
+ + + {!showChangePassword && ( + + )} + + {showChangePassword && ( +
+

Change Password

+ +
+ +
+ + setCurrentPassword(e.target.value)} + className="pl-10" + disabled={loading} + /> +
+
+ +
+ +
+ + setNewPassword(e.target.value)} + className="pl-10" + disabled={loading} + /> +
+
+ +
+ +
+ + setConfirmNewPassword(e.target.value)} + className="pl-10" + disabled={loading} + /> +
+
+ +
+ + +
+
+ )} + + {!totpEnabled && ( +
+
+ +
+

Two-Factor Authentication (2FA)

+

+ Add an extra layer of security by requiring a code from your authenticator app in addition to + your password. +

+
+
+ + +
+ )} + + {totpEnabled && ( +
+
+ +

2FA is enabled

+
+ + {!show2FADisable && ( + + )} + + {show2FADisable && ( +
+

Disable Two-Factor Authentication

+

Enter your password to confirm

+ +
+ +
+ + setDisable2FAPassword(e.target.value)} + className="pl-10" + disabled={loading} + /> +
+
+ +
+ + +
+
+ )} +
+ )} + + +
+ )} + + + + {/* 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. +

+
+ + )} + + + + {/* API Access Tokens */} + {authEnabled && ( + + +
+ + API Access Tokens +
+ + Generate long-lived API tokens for external integrations like Homepage and Home Assistant + +
+ + {error && ( +
+ +

{error}

+
+ )} + + {success && ( +
+ +

{success}

+
+ )} + +
+
+ +
+

About API Tokens

+
    +
  • Tokens are valid for 1 year
  • +
  • Use them to access APIs from external services
  • +
  • {'Include in Authorization header: Bearer YOUR_TOKEN'}
  • +
  • See README.md for complete integration examples
  • +
+
+
+
+ + {!showApiTokenSection && !apiToken && ( + + )} + + {showApiTokenSection && !apiToken && ( +
+

Generate API Token

+

+ Enter your credentials to generate a new long-lived API token +

+ +
+ +
+ + setTokenName(e.target.value)} + className="pl-10" + disabled={generatingToken} + /> +
+
+ +
+ +
+ + setTokenPassword(e.target.value)} + className="pl-10" + disabled={generatingToken} + /> +
+
+ + {totpEnabled && ( +
+ +
+ + setTokenTotpCode(e.target.value)} + className="pl-10" + maxLength={6} + disabled={generatingToken} + /> +
+
+ )} + +
+ + +
+
+ )} + + {apiToken && ( +
+
+ +

Your API Token

+
+ +
+ +
+

+ Important: Save this token now! +

+

+ {"You won't be able to see it again. Store it securely."} +

+
+
+ +
+ +
+ +
+ + +
+
+ {tokenCopied && ( +

+ + Copied to clipboard! +

+ )} +
+ +
+

How to use this token:

+
+

# Add to request headers:

+

{'Authorization: Bearer YOUR_TOKEN_HERE'}

+
+

+ See the README documentation for complete integration examples with Homepage and Home Assistant. +

+
+ + +
+ )} + + {/* Existing Tokens List */} + {!loadingTokens && existingTokens.length > 0 && ( +
+
+

Active Tokens

+ +
+ +
+ {existingTokens.map((token) => ( +
+
+
+ +
+
+

{token.name}

+
+ {token.token_prefix} + + + {token.created_at + ? new Date(token.created_at).toLocaleDateString() + : "Unknown"} + +
+
+
+ +
+ ))} +
+
+ )} + + {loadingTokens && ( +
+
+ Loading tokens... +
+ )} + + {!loadingTokens && existingTokens.length === 0 && !showApiTokenSection && !apiToken && ( +
+ No API tokens created yet +
+ )} + + + )} + + {/* Proxmox Firewall */} + + +
+ + Proxmox Firewall +
+ + Manage the Proxmox VE built-in firewall at cluster and host level + +
+ + {firewallLoading ? ( +
+
+
+ ) : !firewallData?.pve_firewall_installed ? ( +
+ +
+

Proxmox Firewall Not Detected

+

+ The pve-firewall service was not found on this system. It should be included with Proxmox VE by default. +

+
+
+ ) : ( + <> + {/* Firewall Status Overview */} +
+ {/* Cluster Firewall */} +
+
+
+ +
+
+

Cluster Firewall

+

+ {firewallData.cluster_fw_enabled ? "Active" : "Disabled"} +

+
+
+ +
+ + {/* Host Firewall */} +
+
+
+ +
+
+

Host Firewall

+

+ {firewallData.host_fw_enabled ? "Active" : "Disabled"} +

+
+
+ +
+
+ + {/* ProxMenux Monitor Port 8008 */} +
+
+
+ +
+
+

ProxMenux Monitor Port (8008/TCP)

+

+ {firewallData.monitor_port_open + ? "Port 8008 is allowed in the firewall" + : "Port 8008 is not configured in the firewall"} +

+
+
+ +
+ + {!firewallData.monitor_port_open && (firewallData.cluster_fw_enabled || firewallData.host_fw_enabled) && ( +
+ +

+ The firewall is active but port 8008 is not allowed. ProxMenux Monitor may be inaccessible from other devices. Add the rule above to fix this. +

+
+ )} + + {/* Active Rules */} + {firewallData.rules.length > 0 && ( +
+
+

+ Active Rules ({firewallData.rules_count}) +

+ +
+
+ {firewallData.rules.map((rule, idx) => ( +
+ + {rule.action || "?"} + + {rule.direction || "IN"} + {rule.p && {rule.p}} + {rule.dport && :{rule.dport}} + {rule.source_file} +
+ ))} +
+
+ )} + +
+ +

+ For advanced firewall configuration (IP sets, security groups, per-VM rules), use the Proxmox web interface at port 8006. +

+
+ + )} + + + + {/* Fail2Ban */} + + +
+
+ + Fail2Ban +
+ {fail2banInfo?.installed && fail2banInfo?.active && ( + + )} +
+ + Intrusion prevention system that bans IPs after repeated failed login attempts + +
+ + {toolsLoading ? ( +
+
+
+ ) : !fail2banInfo?.installed ? ( + /* --- NOT INSTALLED --- */ +
+
+
+
+ +
+
+

Fail2Ban Not Installed

+

Protect SSH and Proxmox web interface from brute force attacks

+
+
+
+ Not Installed +
+
+ +
+
+ +
+

What Fail2Ban will configure:

+
    +
  • SSH protection (max 2 retries, 9h ban)
  • +
  • Proxmox web interface protection (port 8006, max 3 retries, 1h ban)
  • +
  • Global settings with nftables backend
  • +
+
+
+
+ + +
+ ) : ( + /* --- INSTALLED --- */ +
+ {/* Status bar */} +
+
+
+ +
+
+

Fail2Ban {fail2banInfo.version}

+

+ {fail2banInfo.active ? "Service is running" : "Service is not running"} +

+
+
+
+ {fail2banInfo.active ? "Active" : "Inactive"} +
+
+ + {fail2banInfo.active && f2bDetails && ( + <> + {/* Summary stats */} +
+
+

Jails

+

{f2bDetails.jails.length}

+
+
+

Banned IPs

+

a + j.currently_banned, 0) > 0 ? "text-red-500" : "text-green-500"}`}> + {f2bDetails.jails.reduce((a, j) => a + j.currently_banned, 0)} +

+
+
+

Total Bans

+

+ {f2bDetails.jails.reduce((a, j) => a + j.total_banned, 0)} +

+
+
+

Failed Attempts

+

+ {f2bDetails.jails.reduce((a, j) => a + j.total_failed, 0)} +

+
+
+ + {/* Tab switcher */} +
+ + +
+ + {/* JAILS TAB */} + {f2bActiveTab === "jails" && ( +
+ {f2bDetails.jails.map((jail) => ( +
+ {/* Jail header */} +
+
+
0 ? "bg-red-500 animate-pulse" : "bg-green-500"}`} /> + {jail.name} +
+
+ + Retries: {jail.maxretry} + + + Ban: {formatBanTime(jail.bantime)} + + + Window: {formatBanTime(jail.findtime)} + +
+
+ + {/* Jail stats bar */} +
+
+

Banned

+

0 ? "text-red-500" : "text-green-500"}`}> + {jail.currently_banned} +

+
+
+

Total Bans

+

{jail.total_banned}

+
+
+

Failed Now

+

{jail.currently_failed}

+
+
+

Total Failed

+

{jail.total_failed}

+
+
+ + {/* Banned IPs list */} + {jail.banned_ips.length > 0 && ( +
+
+

+ Banned IPs ({jail.banned_ips.length}) +

+
+ {jail.banned_ips.map((ip) => ( +
+
+
+ {ip} +
+ +
+ ))} +
+
+
+ )} + + {jail.currently_banned === 0 && ( +
+

No IPs currently banned in this jail

+
+ )} +
+ ))} + + {f2bDetails.jails.length === 0 && ( +
+ No jails configured +
+ )} +
+ )} + + {/* ACTIVITY TAB */} + {f2bActiveTab === "activity" && ( +
+ {f2bActivity.length === 0 ? ( +
+ No recent activity in the Fail2Ban log +
+ ) : ( + f2bActivity.map((event, idx) => ( +
+
+
+ {event.action} +
+ {event.ip} + {event.jail} + {event.timestamp} +
+ )) + )} +
+ )} + + )} + + {fail2banInfo.active && !f2bDetails && f2bDetailsLoading && ( +
+
+
+ )} +
+ )} + + + + {/* Lynis */} + + +
+ + Lynis Security Audit +
+ + System security auditing tool that performs comprehensive security scans + +
+ + {toolsLoading ? ( +
+
+
+ ) : !lynisInfo?.installed ? ( +
+
+
+
+ +
+
+

Lynis Not Installed

+

Comprehensive security auditing and hardening tool

+
+
+
+ Not Installed +
+
+ +
+
+ +
+

Lynis features:

+
    +
  • System hardening scoring (0-100)
  • +
  • Vulnerability detection and suggestions
  • +
  • Compliance checking (PCI-DSS, HIPAA, etc.)
  • +
  • Installed from latest GitHub source
  • +
+
+
+
+ + +
+ ) : ( +
+ {/* Status */} +
+
+
+ +
+
+

Lynis {lynisInfo.version}

+

Security auditing tool installed

+
+
+
+ Installed +
+
+ + {/* Last Scan Info */} +
+
+

Last Scan

+

+ {lynisInfo.last_scan || "No scan performed yet"} +

+
+
+

Hardening Index

+

= 70 ? "text-green-500" : + lynisInfo.hardening_index >= 50 ? "text-yellow-500" : + "text-red-500" + }`}> + {lynisInfo.hardening_index !== null ? `${lynisInfo.hardening_index}/100` : "N/A"} +

+
+
+ +
+ +

+ Run audits from the Proxmox terminal with: lynis audit system +

+
+
+ )} + + + + {/* Script Terminal Modals */} + { + setShowFail2banInstaller(false) + loadSecurityTools() + }} + scriptPath="/usr/local/share/proxmenux/scripts/security/fail2ban_installer.sh" + scriptName="fail2ban_installer" + params={{ EXECUTION_MODE: "web" }} + title="Fail2Ban Installation" + description="Installing and configuring Fail2Ban for SSH and Proxmox protection..." + /> + { + setShowLynisInstaller(false) + loadSecurityTools() + }} + scriptPath="/usr/local/share/proxmenux/scripts/security/lynis_installer.sh" + scriptName="lynis_installer" + params={{ EXECUTION_MODE: "web" }} + title="Lynis Installation" + description="Installing Lynis security auditing tool from GitHub..." + /> + + setShow2FASetup(false)} + onSuccess={() => { + setSuccess("2FA enabled successfully!") + checkAuthStatus() + }} + /> +
+ ) +} diff --git a/AppImage/components/settings.tsx b/AppImage/components/settings.tsx index 29402a03..73625767 100644 --- a/AppImage/components/settings.tsx +++ b/AppImage/components/settings.tsx @@ -1,16 +1,11 @@ "use client" import { useState, useEffect } from "react" -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, 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" +import { Wrench, Package, Ruler } from "lucide-react" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select" import { getNetworkUnit } from "../lib/format-network" +import { fetchApi } from "../lib/api-config" interface ProxMenuxTool { key: string @@ -18,99 +13,20 @@ interface ProxMenuxTool { enabled: boolean } -interface ApiTokenEntry { - id: string - name: string - token_prefix: string - created_at: string - expires_at: string - revoked: boolean -} - export function Settings() { - const [authEnabled, setAuthEnabled] = useState(false) - const [totpEnabled, setTotpEnabled] = useState(false) - const [loading, setLoading] = useState(false) - const [error, setError] = useState("") - const [success, setSuccess] = useState("") - - // Setup form state - const [showSetupForm, setShowSetupForm] = useState(false) - const [username, setUsername] = useState("") - const [password, setPassword] = useState("") - const [confirmPassword, setConfirmPassword] = useState("") - - // Change password form state - const [showChangePassword, setShowChangePassword] = useState(false) - const [currentPassword, setCurrentPassword] = useState("") - const [newPassword, setNewPassword] = useState("") - const [confirmNewPassword, setConfirmNewPassword] = useState("") - - const [show2FASetup, setShow2FASetup] = useState(false) - const [show2FADisable, setShow2FADisable] = useState(false) - const [disable2FAPassword, setDisable2FAPassword] = useState("") - const [proxmenuxTools, setProxmenuxTools] = useState([]) const [loadingTools, setLoadingTools] = useState(true) - const [expandedVersions, setExpandedVersions] = useState>({ - [APP_VERSION]: true, // Current version expanded by default - }) - - // API Token state management - const [showApiTokenSection, setShowApiTokenSection] = useState(false) - const [apiToken, setApiToken] = useState("") - const [apiTokenVisible, setApiTokenVisible] = useState(false) - const [tokenPassword, setTokenPassword] = useState("") - const [tokenTotpCode, setTokenTotpCode] = useState("") - const [generatingToken, setGeneratingToken] = useState(false) - const [tokenCopied, setTokenCopied] = useState(false) - - // Token list state - const [existingTokens, setExistingTokens] = useState([]) - const [loadingTokens, setLoadingTokens] = useState(false) - 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) useEffect(() => { - checkAuthStatus() loadProxmenuxTools() - loadApiTokens() - loadSslStatus(setSslEnabled, setSslSource, setSslCertPath, setSslKeyPath, setProxmoxCertAvailable, setProxmoxCertInfo, setLoadingSsl) getUnitsSettings() }, []) - const checkAuthStatus = async () => { - try { - const response = await fetch(getApiUrl("/api/auth/status")) - const data = await response.json() - setAuthEnabled(data.auth_enabled || false) - setTotpEnabled(data.totp_enabled || false) // Get 2FA status - } catch (err) { - console.error("Failed to check auth status:", err) - } - } - const loadProxmenuxTools = async () => { try { - const response = await fetch(getApiUrl("/api/proxmenux/installed-tools")) - const data = await response.json() - + const data = await fetchApi("/api/proxmenux/installed-tools") if (data.success) { setProxmenuxTools(data.installed_tools || []) } @@ -121,347 +37,13 @@ export function Settings() { } } - const handleEnableAuth = async () => { - setError("") - setSuccess("") - - if (!username || !password) { - setError("Please fill in all fields") - return - } - - if (password !== confirmPassword) { - setError("Passwords do not match") - return - } - - if (password.length < 6) { - setError("Password must be at least 6 characters") - return - } - - setLoading(true) - - try { - const response = await fetch(getApiUrl("/api/auth/setup"), { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - username, - password, - enable_auth: true, - }), - }) - - const data = await response.json() - - if (!response.ok) { - throw new Error(data.error || "Failed to enable authentication") - } - - // Save token - localStorage.setItem("proxmenux-auth-token", data.token) - localStorage.setItem("proxmenux-auth-setup-complete", "true") - - setSuccess("Authentication enabled successfully!") - setAuthEnabled(true) - setShowSetupForm(false) - setUsername("") - setPassword("") - setConfirmPassword("") - } catch (err) { - setError(err instanceof Error ? err.message : "Failed to enable authentication") - } finally { - setLoading(false) - } - } - - const handleDisableAuth = async () => { - if ( - !confirm( - "Are you sure you want to disable authentication? This will remove password protection from your dashboard.", - ) - ) { - return - } - - setLoading(true) - setError("") - setSuccess("") - - try { - const token = localStorage.getItem("proxmenux-auth-token") - const response = await fetch(getApiUrl("/api/auth/disable"), { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - }) - - const data = await response.json() - - if (!response.ok) { - throw new Error(data.message || "Failed to disable authentication") - } - - localStorage.removeItem("proxmenux-auth-token") - localStorage.removeItem("proxmenux-auth-setup-complete") - - setSuccess("Authentication disabled successfully! Reloading...") - - setTimeout(() => { - window.location.reload() - }, 1000) - } catch (err) { - setError(err instanceof Error ? err.message : "Failed to disable authentication. Please try again.") - } finally { - setLoading(false) - } - } - - const handleChangePassword = async () => { - setError("") - setSuccess("") - - if (!currentPassword || !newPassword) { - setError("Please fill in all fields") - return - } - - if (newPassword !== confirmNewPassword) { - setError("New passwords do not match") - return - } - - if (newPassword.length < 6) { - setError("Password must be at least 6 characters") - return - } - - setLoading(true) - - try { - const response = await fetch(getApiUrl("/api/auth/change-password"), { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${localStorage.getItem("proxmenux-auth-token")}`, - }, - body: JSON.stringify({ - current_password: currentPassword, - new_password: newPassword, - }), - }) - - const data = await response.json() - - if (!response.ok) { - throw new Error(data.error || "Failed to change password") - } - - // Update token if provided - if (data.token) { - localStorage.setItem("proxmenux-auth-token", data.token) - } - - setSuccess("Password changed successfully!") - setShowChangePassword(false) - setCurrentPassword("") - setNewPassword("") - setConfirmNewPassword("") - } catch (err) { - setError(err instanceof Error ? err.message : "Failed to change password") - } finally { - setLoading(false) - } - } - - const handleDisable2FA = async () => { - setError("") - setSuccess("") - - if (!disable2FAPassword) { - setError("Please enter your password") - return - } - - setLoading(true) - - try { - const token = localStorage.getItem("proxmenux-auth-token") - const response = await fetch(getApiUrl("/api/auth/totp/disable"), { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify({ password: disable2FAPassword }), - }) - - const data = await response.json() - - if (!response.ok) { - throw new Error(data.message || "Failed to disable 2FA") - } - - setSuccess("2FA disabled successfully!") - setTotpEnabled(false) - setShow2FADisable(false) - setDisable2FAPassword("") - checkAuthStatus() - } catch (err) { - setError(err instanceof Error ? err.message : "Failed to disable 2FA") - } finally { - setLoading(false) - } - } - - const handleLogout = () => { - localStorage.removeItem("proxmenux-auth-token") - localStorage.removeItem("proxmenux-auth-setup-complete") - window.location.reload() - } - - const loadApiTokens = async () => { - try { - setLoadingTokens(true) - const data = await fetchApi("/api/auth/api-tokens") - if (data.success) { - setExistingTokens(data.tokens || []) - } - } catch { - // Silently fail - tokens section is optional - } finally { - setLoadingTokens(false) - } - } - - const handleRevokeToken = async (tokenId: string) => { - if (!confirm("Are you sure you want to revoke this token? Any integration using it will stop working immediately.")) { - return - } - - setRevokingTokenId(tokenId) - setError("") - setSuccess("") - - try { - const data = await fetchApi(`/api/auth/api-tokens/${tokenId}`, { - method: "DELETE", - }) - - if (data.success) { - setSuccess("Token revoked successfully") - setExistingTokens((prev) => prev.filter((t) => t.id !== tokenId)) - } else { - setError(data.message || "Failed to revoke token") - } - } catch (err) { - setError(err instanceof Error ? err.message : "Failed to revoke token") - } finally { - setRevokingTokenId(null) - } - } - - const handleGenerateApiToken = async () => { - setError("") - setSuccess("") - - if (!tokenPassword) { - setError("Please enter your password") - return - } - - if (totpEnabled && !tokenTotpCode) { - setError("Please enter your 2FA code") - return - } - - setGeneratingToken(true) - - try { - const data = await fetchApi("/api/auth/generate-api-token", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - password: tokenPassword, - totp_token: totpEnabled ? tokenTotpCode : undefined, - token_name: tokenName || "API Token", - }), - }) - - if (!data.success) { - setError(data.message || data.error || "Failed to generate API token") - return - } - - if (!data.token) { - setError("No token received from server") - return - } - - setApiToken(data.token) - setSuccess("API token generated successfully! Make sure to copy it now as you won't be able to see it again.") - setTokenPassword("") - setTokenTotpCode("") - setTokenName("API Token") - loadApiTokens() - } catch (err) { - setError(err instanceof Error ? err.message : "Failed to generate API token. Please try again.") - } finally { - setGeneratingToken(false) - } - } - - const copyToClipboard = async (text: string) => { - try { - if (navigator.clipboard && typeof navigator.clipboard.writeText === "function") { - await navigator.clipboard.writeText(text) - } else { - // Fallback for non-secure contexts (HTTP on local network) - const textarea = document.createElement("textarea") - textarea.value = text - textarea.style.position = "fixed" - textarea.style.left = "-9999px" - textarea.style.top = "-9999px" - textarea.style.opacity = "0" - document.body.appendChild(textarea) - textarea.focus() - textarea.select() - document.execCommand("copy") - document.body.removeChild(textarea) - } - return true - } catch { - return false - } - } - - const copyApiToken = async () => { - const ok = await copyToClipboard(apiToken) - if (ok) { - setTokenCopied(true) - setTimeout(() => setTokenCopied(false), 2000) - } - } - - const toggleVersion = (version: string) => { - setExpandedVersions((prev) => ({ - ...prev, - [version]: !prev[version], - })) - } - const changeNetworkUnit = (unit: string) => { const networkUnit = unit as "Bytes" | "Bits" localStorage.setItem("proxmenux-network-unit", networkUnit) setNetworkUnitSettings(networkUnit) - - // Dispatch custom event to notify other components + window.dispatchEvent(new CustomEvent("networkUnitChanged", { detail: networkUnit })) - - // Also dispatch storage event for backward compatibility + window.dispatchEvent(new StorageEvent("storage", { key: "proxmenux-network-unit", newValue: networkUnit, @@ -475,391 +57,13 @@ 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 (

Settings

-

Manage your dashboard security and preferences

+

Manage your dashboard preferences

- {/* Authentication Settings */} - - -
- - Authentication -
- Protect your dashboard with username and password authentication -
- - {error && ( -
- -

{error}

-
- )} - - {success && ( -
- -

{success}

-
- )} - -
-
-
- -
-
-

Authentication Status

-

- {authEnabled ? "Password protection is enabled" : "No password protection"} -

-
-
-
- {authEnabled ? "Enabled" : "Disabled"} -
-
- - {!authEnabled && !showSetupForm && ( -
-
- -

- Enable authentication to protect your dashboard when accessing from non-private networks. -

-
- -
- )} - - {!authEnabled && showSetupForm && ( -
-

Setup Authentication

- -
- -
- - setUsername(e.target.value)} - className="pl-10" - disabled={loading} - /> -
-
- -
- -
- - setPassword(e.target.value)} - className="pl-10" - disabled={loading} - /> -
-
- -
- -
- - setConfirmPassword(e.target.value)} - className="pl-10" - disabled={loading} - /> -
-
- -
- - -
-
- )} - - {authEnabled && ( -
- - - {!showChangePassword && ( - - )} - - {showChangePassword && ( -
-

Change Password

- -
- -
- - setCurrentPassword(e.target.value)} - className="pl-10" - disabled={loading} - /> -
-
- -
- -
- - setNewPassword(e.target.value)} - className="pl-10" - disabled={loading} - /> -
-
- -
- -
- - setConfirmNewPassword(e.target.value)} - className="pl-10" - disabled={loading} - /> -
-
- -
- - -
-
- )} - - {!totpEnabled && ( -
-
- -
-

Two-Factor Authentication (2FA)

-

- Add an extra layer of security by requiring a code from your authenticator app in addition to - your password. -

-
-
- - -
- )} - - {totpEnabled && ( -
-
- -

2FA is enabled

-
- - {!show2FADisable && ( - - )} - - {show2FADisable && ( -
-

Disable Two-Factor Authentication

-

Enter your password to confirm

- -
- -
- - setDisable2FAPassword(e.target.value)} - className="pl-10" - disabled={loading} - /> -
-
- -
- - -
-
- )} -
- )} - - -
- )} -
-
- {/* Network Units Settings */} @@ -891,490 +95,6 @@ export function Settings() { - {/* API Access Tokens */} - {authEnabled && ( - - -
- - API Access Tokens -
- - Generate long-lived API tokens for external integrations like Homepage and Home Assistant - -
- - {error && ( -
- -

{error}

-
- )} - - {success && ( -
- -

{success}

-
- )} - -
-
- -
-

About API Tokens

-
    -
  • Tokens are valid for 1 year
  • -
  • Use them to access APIs from external services
  • -
  • Include in Authorization header: Bearer YOUR_TOKEN
  • -
  • See README.md for complete integration examples
  • -
-
-
-
- - {!showApiTokenSection && !apiToken && ( - - )} - - {showApiTokenSection && !apiToken && ( -
-

Generate API Token

-

- Enter your credentials to generate a new long-lived API token -

- -
- -
- - setTokenName(e.target.value)} - className="pl-10" - disabled={generatingToken} - /> -
-
- -
- -
- - setTokenPassword(e.target.value)} - className="pl-10" - disabled={generatingToken} - /> -
-
- - {totpEnabled && ( -
- -
- - setTokenTotpCode(e.target.value)} - className="pl-10" - maxLength={6} - disabled={generatingToken} - /> -
-
- )} - -
- - -
-
- )} - - {apiToken && ( -
-
- -

Your API Token

-
- -
- -
-

- ⚠️ Important: Save this token now! -

-

- You won't be able to see it again. Store it securely. -

-
-
- -
- -
- -
- - -
-
- {tokenCopied && ( -

- - Copied to clipboard! -

- )} -
- -
-

How to use this token:

-
-

# Add to request headers:

-

Authorization: Bearer YOUR_TOKEN_HERE

-
-

- See the README documentation for complete integration examples with Homepage and Home Assistant. -

-
- - -
- )} - - {/* Existing Tokens List */} - {!loadingTokens && existingTokens.length > 0 && ( -
-
-

Active Tokens

- -
- -
- {existingTokens.map((token) => ( -
-
-
- -
-
-

{token.name}

-
- {token.token_prefix} - - - {token.created_at - ? new Date(token.created_at).toLocaleDateString() - : "Unknown"} - -
-
-
- -
- ))} -
-
- )} - - {loadingTokens && ( -
-
- Loading tokens... -
- )} - - {!loadingTokens && existingTokens.length === 0 && !showApiTokenSection && !apiToken && ( -
- No API tokens created yet -
- )} - - - )} - - {/* 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 */} @@ -1416,15 +136,6 @@ export function Settings() { )} - - setShow2FASetup(false)} - onSuccess={() => { - setSuccess("2FA enabled successfully!") - checkAuthStatus() - }} - />
) } diff --git a/AppImage/scripts/build_appimage.sh b/AppImage/scripts/build_appimage.sh index b2c450e6..447dd60e 100644 --- a/AppImage/scripts/build_appimage.sh +++ b/AppImage/scripts/build_appimage.sh @@ -89,6 +89,8 @@ cp "$SCRIPT_DIR/flask_terminal_routes.py" "$APP_DIR/usr/bin/" 2>/dev/null || ech cp "$SCRIPT_DIR/hardware_monitor.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ hardware_monitor.py not found" cp "$SCRIPT_DIR/proxmox_storage_monitor.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ proxmox_storage_monitor.py not found" cp "$SCRIPT_DIR/flask_script_runner.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ flask_script_runner.py not found" +cp "$SCRIPT_DIR/security_manager.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ security_manager.py not found" +cp "$SCRIPT_DIR/flask_security_routes.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ flask_security_routes.py not found" echo "📋 Adding translation support..." cat > "$APP_DIR/usr/bin/translate_cli.py" << 'PYEOF' diff --git a/AppImage/scripts/flask_security_routes.py b/AppImage/scripts/flask_security_routes.py new file mode 100644 index 00000000..1462cbf7 --- /dev/null +++ b/AppImage/scripts/flask_security_routes.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +ProxMenux Security Routes +Flask blueprint for firewall management and security tool detection. +""" + +from flask import Blueprint, jsonify, request + +security_bp = Blueprint('security', __name__) + +try: + import security_manager +except ImportError: + security_manager = None + + +# ------------------------------------------------------------------- +# Proxmox Firewall +# ------------------------------------------------------------------- + +@security_bp.route('/api/security/firewall/status', methods=['GET']) +def firewall_status(): + """Get Proxmox firewall status, rules, and port 8008 status""" + if not security_manager: + return jsonify({"success": False, "message": "Security manager not available"}), 500 + try: + status = security_manager.get_firewall_status() + return jsonify({"success": True, **status}) + except Exception as e: + return jsonify({"success": False, "message": str(e)}), 500 + + +@security_bp.route('/api/security/firewall/enable', methods=['POST']) +def firewall_enable(): + """Enable Proxmox firewall at host or cluster level""" + if not security_manager: + return jsonify({"success": False, "message": "Security manager not available"}), 500 + try: + data = request.json or {} + level = data.get("level", "host") + success, message = security_manager.enable_firewall(level) + return jsonify({"success": success, "message": message}) + except Exception as e: + return jsonify({"success": False, "message": str(e)}), 500 + + +@security_bp.route('/api/security/firewall/disable', methods=['POST']) +def firewall_disable(): + """Disable Proxmox firewall at host or cluster level""" + if not security_manager: + return jsonify({"success": False, "message": "Security manager not available"}), 500 + try: + data = request.json or {} + level = data.get("level", "host") + success, message = security_manager.disable_firewall(level) + return jsonify({"success": success, "message": message}) + except Exception as e: + return jsonify({"success": False, "message": str(e)}), 500 + + +@security_bp.route('/api/security/firewall/monitor-port', methods=['POST']) +def firewall_add_monitor_port(): + """Add firewall rule to allow port 8008 for ProxMenux Monitor""" + if not security_manager: + return jsonify({"success": False, "message": "Security manager not available"}), 500 + try: + success, message = security_manager.add_monitor_port_rule() + return jsonify({"success": success, "message": message}) + except Exception as e: + return jsonify({"success": False, "message": str(e)}), 500 + + +@security_bp.route('/api/security/firewall/monitor-port', methods=['DELETE']) +def firewall_remove_monitor_port(): + """Remove the ProxMenux Monitor port 8008 rule""" + if not security_manager: + return jsonify({"success": False, "message": "Security manager not available"}), 500 + try: + success, message = security_manager.remove_monitor_port_rule() + return jsonify({"success": success, "message": message}) + except Exception as e: + return jsonify({"success": False, "message": str(e)}), 500 + + +# ------------------------------------------------------------------- +# Fail2Ban Detailed Management +# ------------------------------------------------------------------- + +@security_bp.route('/api/security/fail2ban/details', methods=['GET']) +def fail2ban_details(): + """Get detailed Fail2Ban info: per-jail banned IPs, stats, config""" + if not security_manager: + return jsonify({"success": False, "message": "Security manager not available"}), 500 + try: + details = security_manager.get_fail2ban_details() + return jsonify({"success": True, **details}) + except Exception as e: + return jsonify({"success": False, "message": str(e)}), 500 + + +@security_bp.route('/api/security/fail2ban/unban', methods=['POST']) +def fail2ban_unban(): + """Unban a specific IP from a Fail2Ban jail""" + if not security_manager: + return jsonify({"success": False, "message": "Security manager not available"}), 500 + try: + data = request.json or {} + jail = data.get("jail", "") + ip = data.get("ip", "") + success, message = security_manager.unban_ip(jail, ip) + 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 + + +@security_bp.route('/api/security/fail2ban/activity', methods=['GET']) +def fail2ban_activity(): + """Get recent Fail2Ban log activity""" + if not security_manager: + return jsonify({"success": False, "message": "Security manager not available"}), 500 + try: + events = security_manager.get_fail2ban_recent_activity() + return jsonify({"success": True, "events": events}) + except Exception as e: + return jsonify({"success": False, "message": str(e)}), 500 + + +# ------------------------------------------------------------------- +# Security Tools Detection +# ------------------------------------------------------------------- + +@security_bp.route('/api/security/tools', methods=['GET']) +def security_tools(): + """Detect installed security tools (Fail2Ban, Lynis, etc.)""" + if not security_manager: + return jsonify({"success": False, "message": "Security manager not available"}), 500 + try: + tools = security_manager.detect_security_tools() + return jsonify({"success": True, "tools": tools}) + except Exception as e: + return jsonify({"success": False, "message": str(e)}), 500 diff --git a/AppImage/scripts/flask_server.py b/AppImage/scripts/flask_server.py index 94d40c3f..05b3f4d5 100644 --- a/AppImage/scripts/flask_server.py +++ b/AppImage/scripts/flask_server.py @@ -43,6 +43,7 @@ from flask_terminal_routes import terminal_bp, init_terminal_routes # noqa: E40 from flask_health_routes import health_bp # noqa: E402 from flask_auth_routes import auth_bp # noqa: E402 from flask_proxmenux_routes import proxmenux_bp # noqa: E402 +from flask_security_routes import security_bp # noqa: E402 from jwt_middleware import require_auth # noqa: E402 import auth_manager # noqa: E402 @@ -116,6 +117,7 @@ CORS(app) # Enable CORS for Next.js frontend app.register_blueprint(auth_bp) app.register_blueprint(health_bp) app.register_blueprint(proxmenux_bp) +app.register_blueprint(security_bp) # Initialize terminal / WebSocket routes init_terminal_routes(app) diff --git a/AppImage/scripts/security_manager.py b/AppImage/scripts/security_manager.py new file mode 100644 index 00000000..2634517a --- /dev/null +++ b/AppImage/scripts/security_manager.py @@ -0,0 +1,648 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +ProxMenux Security Manager +Handles Proxmox firewall status, rules, and security tool detection. +""" + +import os +import json +import subprocess +import re + +# ================================================================= +# Proxmox Firewall Management +# ================================================================= + +# Proxmox firewall config paths +CLUSTER_FW = "/etc/pve/firewall/cluster.fw" +HOST_FW_DIR = "/etc/pve/local" # host.fw is per-node + +def _run_cmd(cmd, timeout=10): + """Run a shell command and return (returncode, stdout, stderr)""" + try: + result = subprocess.run( + cmd, capture_output=True, text=True, timeout=timeout + ) + return result.returncode, result.stdout.strip(), result.stderr.strip() + except subprocess.TimeoutExpired: + return -1, "", "Command timed out" + except FileNotFoundError: + return -1, "", f"Command not found: {cmd[0]}" + except Exception as e: + return -1, "", str(e) + + +def get_firewall_status(): + """ + Get the overall Proxmox firewall status. + Returns dict with status info. + """ + result = { + "pve_firewall_installed": False, + "pve_firewall_active": False, + "cluster_fw_enabled": False, + "host_fw_enabled": False, + "rules_count": 0, + "rules": [], + "monitor_port_open": False, + } + + # Check if pve-firewall service exists + rc, out, _ = _run_cmd(["systemctl", "is-active", "pve-firewall"]) + result["pve_firewall_installed"] = rc == 0 or "inactive" in out or "active" in out + result["pve_firewall_active"] = (rc == 0 and out == "active") + + # If not installed or inactive, check if the service unit exists + if not result["pve_firewall_installed"]: + rc2, _, _ = _run_cmd(["systemctl", "cat", "pve-firewall"]) + result["pve_firewall_installed"] = rc2 == 0 + + # Parse cluster firewall config + if os.path.isfile(CLUSTER_FW): + try: + with open(CLUSTER_FW, 'r') as f: + content = f.read() + # Check if firewall is enabled at cluster level + for line in content.splitlines(): + line = line.strip() + if line.lower().startswith("enable:"): + val = line.split(":", 1)[1].strip() + result["cluster_fw_enabled"] = val == "1" + break + except Exception: + pass + + # Parse host firewall config + host_fw = os.path.join(HOST_FW_DIR, "host.fw") + if os.path.isfile(host_fw): + try: + with open(host_fw, 'r') as f: + content = f.read() + for line in content.splitlines(): + line = line.strip() + if line.lower().startswith("enable:"): + val = line.split(":", 1)[1].strip() + result["host_fw_enabled"] = val == "1" + break + except Exception: + pass + + # Get rules + rules = _parse_firewall_rules() + result["rules"] = rules + result["rules_count"] = len(rules) + + # Check if port 8008 is allowed + for rule in rules: + dport = str(rule.get("dport", "")) + if "8008" in dport and rule.get("action", "").upper() == "ACCEPT": + result["monitor_port_open"] = True + break + + return result + + +def _parse_firewall_rules(): + """Parse all firewall rules from cluster and host configs""" + rules = [] + + for fw_file, source in [(CLUSTER_FW, "cluster"), (os.path.join(HOST_FW_DIR, "host.fw"), "host")]: + if not os.path.isfile(fw_file): + continue + try: + with open(fw_file, 'r') as f: + content = f.read() + + in_rules = False + section = "" + for line in content.splitlines(): + line = line.strip() + if not line or line.startswith('#'): + continue + + # Detect section headers + if line.startswith('['): + section_match = re.match(r'\[(\w+)\]', line) + if section_match: + section = section_match.group(1).upper() + in_rules = section in ("RULES", "IN", "OUT") + continue + + if in_rules or section in ("RULES", "IN", "OUT"): + rule = _parse_rule_line(line, source, section) + if rule: + rules.append(rule) + except Exception: + pass + + return rules + + +def _parse_rule_line(line, source, section): + """Parse a single firewall rule line""" + # Proxmox rule format: |ACTION MACRO(params) -option value ... + # or: IN/OUT ACTION -p proto -dport port -source addr + parts = line.split() + if len(parts) < 2: + return None + + rule = { + "raw": line, + "source_file": source, + "section": section, + } + + idx = 0 + # Direction + if parts[0].upper() in ("IN", "OUT"): + rule["direction"] = parts[0].upper() + idx = 1 + elif section in ("IN",): + rule["direction"] = "IN" + elif section in ("OUT",): + rule["direction"] = "OUT" + + if idx < len(parts): + rule["action"] = parts[idx].upper() + idx += 1 + + # Parse options + while idx < len(parts): + opt = parts[idx] + if opt.startswith("-") and idx + 1 < len(parts): + key = opt.lstrip("-") + val = parts[idx + 1] + rule[key] = val + idx += 2 + else: + idx += 1 + + return rule + + +def add_monitor_port_rule(): + """ + Add a firewall rule to allow port 8008 (ProxMenux Monitor) on the host. + Returns (success, message) + """ + host_fw = os.path.join(HOST_FW_DIR, "host.fw") + + # Check if rule already exists + status = get_firewall_status() + if status.get("monitor_port_open"): + return True, "Port 8008 is already allowed in the firewall" + + try: + content = "" + has_rules_section = False + + if os.path.isfile(host_fw): + with open(host_fw, 'r') as f: + content = f.read() + has_rules_section = "[RULES]" in content + + rule_line = "IN ACCEPT -p tcp -dport 8008 -log nolog # ProxMenux Monitor" + + if has_rules_section: + # Add rule after [RULES] section header + lines = content.splitlines() + new_lines = [] + inserted = False + for line in lines: + new_lines.append(line) + if not inserted and line.strip() == "[RULES]": + new_lines.append(rule_line) + inserted = True + content = "\n".join(new_lines) + "\n" + else: + # Add [RULES] section + if content and not content.endswith("\n"): + content += "\n" + content += "\n[RULES]\n" + content += rule_line + "\n" + + with open(host_fw, 'w') as f: + f.write(content) + + # Reload firewall + _run_cmd(["pve-firewall", "reload"]) + + return True, "Firewall rule added: port 8008 (TCP) allowed for ProxMenux Monitor" + except PermissionError: + return False, "Permission denied. Cannot write to firewall config." + except Exception as e: + return False, f"Failed to add firewall rule: {str(e)}" + + +def remove_monitor_port_rule(): + """ + Remove the ProxMenux Monitor port 8008 rule from host firewall. + Returns (success, message) + """ + host_fw = os.path.join(HOST_FW_DIR, "host.fw") + + if not os.path.isfile(host_fw): + return True, "No host firewall config found" + + try: + with open(host_fw, 'r') as f: + lines = f.readlines() + + new_lines = [] + removed = False + for line in lines: + if "8008" in line and "ProxMenux" in line: + removed = True + continue + new_lines.append(line) + + if not removed: + return True, "No ProxMenux Monitor rule found to remove" + + with open(host_fw, 'w') as f: + f.writelines(new_lines) + + _run_cmd(["pve-firewall", "reload"]) + + return True, "ProxMenux Monitor firewall rule removed" + except Exception as e: + return False, f"Failed to remove firewall rule: {str(e)}" + + +def enable_firewall(level="host"): + """ + Enable the Proxmox firewall at host or cluster level. + Returns (success, message) + """ + if level == "cluster": + return _set_firewall_enabled(CLUSTER_FW, True) + else: + host_fw = os.path.join(HOST_FW_DIR, "host.fw") + return _set_firewall_enabled(host_fw, True) + + +def disable_firewall(level="host"): + """ + Disable the Proxmox firewall at host or cluster level. + Returns (success, message) + """ + if level == "cluster": + return _set_firewall_enabled(CLUSTER_FW, False) + else: + host_fw = os.path.join(HOST_FW_DIR, "host.fw") + return _set_firewall_enabled(host_fw, False) + + +def _set_firewall_enabled(fw_file, enabled): + """Set enable: 1 or enable: 0 in firewall config""" + try: + content = "" + if os.path.isfile(fw_file): + with open(fw_file, 'r') as f: + content = f.read() + + enable_val = "1" if enabled else "0" + has_options = "[OPTIONS]" in content + has_enable = False + + lines = content.splitlines() + new_lines = [] + in_options = False + + for line in lines: + stripped = line.strip() + if stripped.startswith("["): + in_options = stripped == "[OPTIONS]" + + if in_options and stripped.lower().startswith("enable:"): + new_lines.append(f"enable: {enable_val}") + has_enable = True + else: + new_lines.append(line) + + if not has_enable: + if has_options: + # Add enable line after [OPTIONS] + final_lines = [] + for line in new_lines: + final_lines.append(line) + if line.strip() == "[OPTIONS]": + final_lines.append(f"enable: {enable_val}") + new_lines = final_lines + else: + # Add [OPTIONS] section at the beginning + new_lines.insert(0, "[OPTIONS]") + new_lines.insert(1, f"enable: {enable_val}") + new_lines.insert(2, "") + + # Ensure parent directory exists + os.makedirs(os.path.dirname(fw_file), exist_ok=True) + + with open(fw_file, 'w') as f: + f.write("\n".join(new_lines) + "\n") + + # Reload or start the firewall service + if enabled: + _run_cmd(["systemctl", "enable", "pve-firewall"]) + _run_cmd(["systemctl", "start", "pve-firewall"]) + + _run_cmd(["pve-firewall", "reload"]) + + state = "enabled" if enabled else "disabled" + level = "cluster" if fw_file == CLUSTER_FW else "host" + return True, f"Firewall {state} at {level} level" + except PermissionError: + return False, "Permission denied. Cannot modify firewall config." + except Exception as e: + return False, f"Failed to modify firewall: {str(e)}" + + +# ================================================================= +# Security Tools Detection +# ================================================================= + +# ================================================================= +# Fail2Ban Detailed Management +# ================================================================= + +def get_fail2ban_details(): + """ + Get detailed Fail2Ban info: per-jail banned IPs, ban times, etc. + Returns dict with detailed jail information. + """ + result = { + "installed": False, + "active": False, + "version": "", + "jails": [], + } + + rc, out, _ = _run_cmd(["fail2ban-client", "--version"]) + if rc != 0: + return result + + result["installed"] = True + result["version"] = out.split("\n")[0].strip() if out else "" + + rc2, out2, _ = _run_cmd(["systemctl", "is-active", "fail2ban"]) + result["active"] = (rc2 == 0 and out2 == "active") + + if not result["active"]: + return result + + # Get jail list + rc3, out3, _ = _run_cmd(["fail2ban-client", "status"]) + jail_names = [] + if rc3 == 0: + for line in out3.splitlines(): + if "Jail list:" in line: + jails_str = line.split(":", 1)[1].strip() + jail_names = [j.strip() for j in jails_str.split(",") if j.strip()] + + # Get detailed info per jail + for jail_name in jail_names: + jail_info = { + "name": jail_name, + "currently_failed": 0, + "total_failed": 0, + "currently_banned": 0, + "total_banned": 0, + "banned_ips": [], + "findtime": "", + "bantime": "", + "maxretry": "", + } + + rc4, out4, _ = _run_cmd(["fail2ban-client", "status", jail_name]) + if rc4 == 0: + for line in out4.splitlines(): + line = line.strip() + if "Currently failed:" in line: + try: + jail_info["currently_failed"] = int(line.split(":", 1)[1].strip()) + except ValueError: + pass + elif "Total failed:" in line: + try: + jail_info["total_failed"] = int(line.split(":", 1)[1].strip()) + except ValueError: + pass + elif "Currently banned:" in line: + try: + jail_info["currently_banned"] = int(line.split(":", 1)[1].strip()) + except ValueError: + pass + elif "Total banned:" in line: + try: + jail_info["total_banned"] = int(line.split(":", 1)[1].strip()) + except ValueError: + pass + elif "Banned IP list:" in line: + ips_str = line.split(":", 1)[1].strip() + if ips_str: + jail_info["banned_ips"] = [ip.strip() for ip in ips_str.split() if ip.strip()] + + # Get jail config values + for key in ["findtime", "bantime", "maxretry"]: + rc5, out5, _ = _run_cmd(["fail2ban-client", "get", jail_name, key]) + if rc5 == 0 and out5: + jail_info[key] = out5.strip() + + result["jails"].append(jail_info) + + return result + + +def unban_ip(jail_name, ip_address): + """ + Unban a specific IP from a Fail2Ban jail. + Returns (success, message) + """ + if not jail_name or not ip_address: + return False, "Jail name and IP address are required" + + # Validate IP format (basic check) + if not re.match(r'^[\d.:a-fA-F]+$', ip_address): + return False, f"Invalid IP address format: {ip_address}" + + rc, out, err = _run_cmd(["fail2ban-client", "set", jail_name, "unbanip", ip_address]) + if rc == 0: + return True, f"IP {ip_address} has been unbanned from jail '{jail_name}'" + else: + return False, f"Failed to unban IP: {err or out}" + + +def get_fail2ban_recent_activity(lines=50): + """ + Get recent Fail2Ban log activity (bans and unbans). + Returns list of recent events. + """ + events = [] + + log_file = "/var/log/fail2ban.log" + if not os.path.isfile(log_file): + return events + + try: + # Read last N lines using tail + rc, out, _ = _run_cmd(["tail", f"-{lines}", log_file], timeout=5) + if rc != 0 or not out: + return events + + for line in out.splitlines(): + event = None + + # Parse ban events + ban_match = re.search( + r'(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})[,\d]*\s+.*\[(\w+)\]\s+Ban\s+([\d.:a-fA-F]+)', + line + ) + if ban_match: + event = { + "timestamp": ban_match.group(1), + "jail": ban_match.group(2), + "ip": ban_match.group(3), + "action": "ban", + } + + # Parse unban events + if not event: + unban_match = re.search( + r'(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})[,\d]*\s+.*\[(\w+)\]\s+Unban\s+([\d.:a-fA-F]+)', + line + ) + if unban_match: + event = { + "timestamp": unban_match.group(1), + "jail": unban_match.group(2), + "ip": unban_match.group(3), + "action": "unban", + } + + # Parse found (failed attempt) events + if not event: + found_match = re.search( + r'(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})[,\d]*\s+.*\[(\w+)\]\s+Found\s+([\d.:a-fA-F]+)', + line + ) + if found_match: + event = { + "timestamp": found_match.group(1), + "jail": found_match.group(2), + "ip": found_match.group(3), + "action": "found", + } + + if event: + events.append(event) + + # Return most recent first + events.reverse() + + except Exception: + pass + + return events + + +def detect_security_tools(): + """ + Detect installed security tools on the system. + Returns dict with tool status info. + """ + tools = {} + + # Fail2Ban + tools["fail2ban"] = _detect_fail2ban() + + # Lynis + tools["lynis"] = _detect_lynis() + + return tools + + +def _detect_fail2ban(): + """Detect Fail2Ban installation and status""" + info = { + "installed": False, + "active": False, + "version": "", + "jails": [], + "banned_ips_count": 0, + } + + rc, out, _ = _run_cmd(["fail2ban-client", "--version"]) + if rc == 0: + info["installed"] = True + info["version"] = out.split("\n")[0].strip() if out else "" + + # Check service status + rc2, out2, _ = _run_cmd(["systemctl", "is-active", "fail2ban"]) + info["active"] = (rc2 == 0 and out2 == "active") + + if info["active"]: + # Get jails + rc3, out3, _ = _run_cmd(["fail2ban-client", "status"]) + if rc3 == 0: + for line in out3.splitlines(): + if "Jail list:" in line: + jails_str = line.split(":", 1)[1].strip() + info["jails"] = [j.strip() for j in jails_str.split(",") if j.strip()] + + # Count banned IPs across all jails + total_banned = 0 + for jail in info["jails"]: + rc4, out4, _ = _run_cmd(["fail2ban-client", "status", jail]) + if rc4 == 0: + for line in out4.splitlines(): + if "Currently banned:" in line: + try: + count = int(line.split(":", 1)[1].strip()) + total_banned += count + except ValueError: + pass + info["banned_ips_count"] = total_banned + + return info + + +def _detect_lynis(): + """Detect Lynis installation and status""" + info = { + "installed": False, + "version": "", + "last_scan": None, + "hardening_index": None, + } + + # Check both locations + lynis_cmd = None + for path in ["/usr/local/bin/lynis", "/opt/lynis/lynis", "/usr/bin/lynis"]: + if os.path.isfile(path) and os.access(path, os.X_OK): + lynis_cmd = path + break + + if lynis_cmd: + info["installed"] = True + rc, out, _ = _run_cmd([lynis_cmd, "show", "version"]) + if rc == 0: + info["version"] = out.strip() + + # Check for last scan report + report_file = "/var/log/lynis-report.dat" + if os.path.isfile(report_file): + try: + with open(report_file, 'r') as f: + for line in f: + if line.startswith("report_datetime_start="): + info["last_scan"] = line.split("=", 1)[1].strip() + elif line.startswith("hardening_index="): + try: + info["hardening_index"] = int(line.split("=", 1)[1].strip()) + except ValueError: + pass + except Exception: + pass + + return info