"use client" import { useState, useEffect, useRef } 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, Key, Copy, Eye, EyeOff, Trash2, RefreshCw, Clock, ShieldCheck, Globe, FileKey, AlertTriangle, Flame, Bug, Search, Download, Power, PowerOff, Plus, Minus, Activity, Settings, Ban, FileText, Printer, Play, BarChart3, TriangleAlert, ChevronDown, ArrowDownLeft, ArrowUpRight, ChevronRight, Network, Zap, Pencil, Check, X, ExternalLink, } from "lucide-react" import { getApiUrl, fetchApi } from "../lib/api-config" import { TwoFactorSetup } from "./two-factor-setup" import { ScriptTerminalModal } from "./script-terminal-modal" import { SecureGatewaySetup } from "./secure-gateway-setup" import { useI18n } from "../lib/i18n/provider" interface ApiTokenEntry { id: string name: string token_prefix: string created_at: string expires_at: string revoked: boolean /** Backend flag: `true` when JWT verifies under the current jwt_secret, * `false` when the secret has been rotated since this token was minted * (token returns 401 even though it looks stored), `null` for legacy * rows that pre-date the tracking field. */ valid?: boolean | null /** Human reason populated when `valid === false`. */ invalidation_reason?: string } // Replaces the previous `password.length < 6` check. Bumped the minimum // floor and require at least 3 of the 4 character categories so a brute- // force on the password hash isn't trivial. Also screens the few obvious // strings that real users still type. Server-side enforces the same floor // in auth_manager.setup_auth. const _OBVIOUS_PASSWORDS = new Set([ "password", "password1", "password123", "12345678", "123456789", "1234567890", "qwerty", "qwertyuiop", "letmein", "welcome", "admin", "administrator", "root", "proxmox", "proxmenux", "changeme", "abcdefgh", ]) function validatePasswordStrength(pw: string, t: (key: string) => string): string | null { if (pw.length < 10) { return t("securityPage.errors.passwordMinLength") } const categories = [ /[a-z]/.test(pw), /[A-Z]/.test(pw), /\d/.test(pw), /[^A-Za-z0-9]/.test(pw), ].filter(Boolean).length if (categories < 3) { return t("securityPage.errors.passwordComplexity") } if (_OBVIOUS_PASSWORDS.has(pw.toLowerCase())) { return t("securityPage.errors.passwordCommon") } return null } export function Security() { const { language, t } = useI18n() const st = (key: string, params?: Record) => t(`securityPage.${key}`, params) const interfaceTypeLabel = (type: string) => ["physical", "bridge", "bond", "vlan", "virtual"].includes(type) ? t(`network.interfaceTypes.${type}`) : type const authErrorText = (message: unknown, fallbackKey: string) => { const raw = typeof message === "string" ? message : "" const normalized = raw.toLowerCase() if (normalized.includes("authentication is already configured")) { return st("errors.authAlreadyConfigured") } if (normalized.includes("invalid 2fa code")) { return st("errors.invalid2faCode") } if (normalized.includes("invalid password")) { return st("errors.invalidPassword") } return raw || st(fallbackKey) } 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 [disable2FATotpCode, setDisable2FATotpCode] = useState("") // 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("") // Proxmox Firewall state const [firewallLoading, setFirewallLoading] = useState(true) const [firewallData, setFirewallData] = useState<{ pve_firewall_installed: boolean pve_firewall_active: boolean cluster_fw_enabled: boolean host_fw_enabled: boolean rules_count: number rules: Array<{ raw: string; direction?: string; action?: string; dport?: string; p?: string; source?: string; source_file?: string; section?: string; rule_index: number }> monitor_port_open: boolean } | null>(null) const [firewallAction, setFirewallAction] = useState(false) const [showAddRule, setShowAddRule] = useState(false) const [newRule, setNewRule] = useState({ direction: "IN", action: "ACCEPT", protocol: "tcp", dport: "", sport: "", source: "", iface: "", comment: "", level: "host", }) const [addingRule, setAddingRule] = useState(false) const [deletingRuleIdx, setDeletingRuleIdx] = useState(null) const [expandedRuleKey, setExpandedRuleKey] = useState(null) const [editingRuleKey, setEditingRuleKey] = useState(null) const [editRule, setEditRule] = useState({ direction: "IN", action: "ACCEPT", protocol: "tcp", dport: "", sport: "", source: "", iface: "", comment: "", level: "host", }) const [savingRule, setSavingRule] = useState(false) const [networkInterfaces, setNetworkInterfaces] = useState<{name: string, type: string, status: string}[]>([]) // Security Tools state const [toolsLoading, setToolsLoading] = useState(true) const [fail2banInfo, setFail2banInfo] = useState<{ installed: boolean; active: boolean; version: string; jails: string[]; banned_ips_count: number } | null>(null) const [lynisInfo, setLynisInfo] = useState<{ installed: boolean; version: string; last_scan: string | null; hardening_index: number | null } | null>(null) const [showFail2banInstaller, setShowFail2banInstaller] = useState(false) const [showLynisInstaller, setShowLynisInstaller] = useState(false) const [uninstallingFail2ban, setUninstallingFail2ban] = useState(false) const [uninstallingLynis, setUninstallingLynis] = useState(false) const [showFail2banUninstallConfirm, setShowFail2banUninstallConfirm] = useState(false) const [showLynisUninstallConfirm, setShowLynisUninstallConfirm] = useState(false) // Lynis audit state interface LynisWarning { test_id: string; severity: string; description: string; solution: string; proxmox_context?: string; proxmox_expected?: boolean; proxmox_severity?: string } interface LynisSuggestion { test_id: string; description: string; solution: string; details: string; proxmox_context?: string; proxmox_expected?: boolean; proxmox_severity?: string } interface LynisCheck { name: string; status: string; detail?: string } interface LynisSection { name: string; checks: LynisCheck[] } interface LynisReport { datetime_start: string; datetime_end: string; lynis_version: string os_name: string; os_version: string; os_fullname: string; hostname: string hardening_index: number | null; tests_performed: number warnings: LynisWarning[]; suggestions: LynisSuggestion[] categories: Record installed_packages: number; kernel_version: string firewall_active: boolean; malware_scanner: boolean sections: LynisSection[] proxmox_adjusted_score?: number | null proxmox_expected_warnings?: number proxmox_expected_suggestions?: number proxmox_context_applied?: boolean is_complete?: boolean parse_issue?: string } const [lynisAuditRunning, setLynisAuditRunning] = useState(false) const [lynisReport, setLynisReport] = useState(null) const [lynisReportLoading, setLynisReportLoading] = useState(false) const [lynisShowReport, setLynisShowReport] = useState(false) const [lynisActiveTab, setLynisActiveTab] = useState<"overview" | "warnings" | "suggestions" | "checks">("overview") // Tracks the active Lynis poll so a component unmount mid-audit clears // the setInterval. Without this the timer kept firing every 3s and // calling setState on an unmounted component, which logs a React // warning and leaks the closure. const lynisPollRef = useRef | null>(null) useEffect(() => () => { if (lynisPollRef.current) { clearInterval(lynisPollRef.current) lynisPollRef.current = null } }, []) // Fail2Ban detailed state interface BannedIp { ip: string type: "local" | "external" | "unknown" } interface JailDetail { name: string currently_failed: number total_failed: number currently_banned: number total_banned: number banned_ips: BannedIp[] findtime: string bantime: string maxretry: string } interface F2bEvent { timestamp: string jail: string ip: string action: "ban" | "unban" | "found" } const [f2bDetails, setF2bDetails] = useState<{ installed: boolean; active: boolean; version: string; jails: JailDetail[] } | null>(null) const [f2bActivity, setF2bActivity] = useState([]) const [f2bDetailsLoading, setF2bDetailsLoading] = useState(false) const [f2bUnbanning, setF2bUnbanning] = useState(null) const [f2bActiveTab, setF2bActiveTab] = useState<"jails" | "activity">("jails") const [f2bEditingJail, setF2bEditingJail] = useState(null) const [f2bJailConfig, setF2bJailConfig] = useState<{maxretry: string; bantime: string; findtime: string; permanent: boolean}>({ maxretry: "", bantime: "", findtime: "", permanent: false, }) const [f2bSavingConfig, setF2bSavingConfig] = useState(false) const [f2bApplyingJails, setF2bApplyingJails] = useState(false) // 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 [sslRestarting, setSslRestarting] = useState(false) const [showCustomCertForm, setShowCustomCertForm] = useState(false) const [customCertPath, setCustomCertPath] = useState("") const [customKeyPath, setCustomKeyPath] = useState("") useEffect(() => { checkAuthStatus() loadApiTokens() loadSslStatus() loadFirewallStatus() loadNetworkInterfaces() loadSecurityTools() }, []) const loadFirewallStatus = async () => { try { setFirewallLoading(true) const data = await fetchApi("/api/security/firewall/status") if (data.success) { setFirewallData({ pve_firewall_installed: data.pve_firewall_installed, pve_firewall_active: data.pve_firewall_active, cluster_fw_enabled: data.cluster_fw_enabled, host_fw_enabled: data.host_fw_enabled, rules_count: data.rules_count, rules: data.rules || [], monitor_port_open: data.monitor_port_open, }) } } catch (err) { // Was a silent catch — left the user staring at "0 firewall rules" when // the request 401'd or the backend was down. At minimum surface the // failure in the browser console so devtools shows what went wrong. console.error("[security] Failed to load firewall status:", err) } finally { setFirewallLoading(false) } } const loadNetworkInterfaces = async () => { try { const data = await fetchApi("/api/network") // The API returns interfaces in separate arrays: physical_interfaces, bridge_interfaces, etc. // The generic "interfaces" array only holds uncategorized types and is usually empty. const all = [ ...(data.physical_interfaces || []), ...(data.bridge_interfaces || []), ...(data.interfaces || []), ].sort((a: any, b: any) => a.name.localeCompare(b.name)) setNetworkInterfaces(all) } catch { // Silently fail - select will just show "Any interface" } } const loadSecurityTools = async () => { try { setToolsLoading(true) const data = await fetchApi("/api/security/tools") if (data.success && data.tools) { setFail2banInfo(data.tools.fail2ban || null) setLynisInfo(data.tools.lynis || null) } } catch (err) { console.error("[security] Failed to load security tools (fail2ban/lynis):", err) } finally { setToolsLoading(false) } } const handleUninstallFail2ban = async () => { setUninstallingFail2ban(true) setError("") setSuccess("") setShowFail2banUninstallConfirm(false) try { const data = await fetchApi("/api/security/fail2ban/uninstall", { method: "POST", }) if (data.success) { setSuccess(st("messages.fail2banUninstalled")) loadSecurityTools() setF2bDetails(null) } else { setError(data.message || st("errors.fail2banUninstallFailed")) } } catch (err) { setError(err instanceof Error ? err.message : st("errors.fail2banUninstallFailed")) } finally { setUninstallingFail2ban(false) } } const handleUninstallLynis = async () => { setUninstallingLynis(true) setError("") setSuccess("") setShowLynisUninstallConfirm(false) try { const data = await fetchApi("/api/security/lynis/uninstall", { method: "POST", }) if (data.success) { setSuccess(st("messages.lynisUninstalled")) loadSecurityTools() setLynisReport(null) } else { setError(data.message || st("errors.lynisUninstallFailed")) } } catch (err) { setError(err instanceof Error ? err.message : st("errors.lynisUninstallFailed")) } finally { setUninstallingLynis(false) } } const loadFail2banDetails = async () => { try { setF2bDetailsLoading(true) const [detailsRes, activityRes] = await Promise.all([ fetchApi("/api/security/fail2ban/details"), fetchApi("/api/security/fail2ban/activity"), ]) if (detailsRes.success) { setF2bDetails({ installed: detailsRes.installed, active: detailsRes.active, version: detailsRes.version, jails: detailsRes.jails || [], }) } if (activityRes.success) { setF2bActivity(activityRes.events || []) } } catch { // Silently fail } finally { setF2bDetailsLoading(false) } } const handleUnbanIp = async (jail: string, ip: string) => { const key = `${jail}:${ip}` setF2bUnbanning(key) setError("") setSuccess("") try { const data = await fetchApi("/api/security/fail2ban/unban", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ jail, ip }), }) if (data.success) { setSuccess(st("messages.ipUnbanned", { ip, jail: fail2banProtectionLabel(jail) })) loadFail2banDetails() loadSecurityTools() } else { setError(data.message || st("errors.unbanIpFailed")) } } catch (err) { setError(err instanceof Error ? err.message : st("errors.unbanIpFailed")) } finally { setF2bUnbanning(null) } } const handleApplyMissingJails = async () => { setF2bApplyingJails(true) setError("") setSuccess("") try { const data = await fetchApi("/api/security/fail2ban/apply-jails", { method: "POST", }) if (data.success) { setSuccess(st("messages.missingJailsApplied")) // Reload to see the new jails await loadFail2banDetails() loadSecurityTools() } else { setError(data.message || st("errors.applyMissingJailsFailed")) } } catch (err) { setError(err instanceof Error ? err.message : st("errors.applyMissingJailsFailed")) } finally { setF2bApplyingJails(false) } } // --- Lynis audit handlers --- const handleRunLynisAudit = async () => { setLynisAuditRunning(true) setError("") setSuccess("") try { const data = await fetchApi("/api/security/lynis/run", { method: "POST" }) if (data.success) { // Poll for completion. Stash the interval id in a ref so the // component unmount cleanup (above) can clear it if the user // navigates away while the audit is still running. if (lynisPollRef.current) clearInterval(lynisPollRef.current) lynisPollRef.current = setInterval(async () => { try { const status = await fetchApi("/api/security/lynis/status") if (!status.running) { if (lynisPollRef.current) { clearInterval(lynisPollRef.current) lynisPollRef.current = null } setLynisAuditRunning(false) if (status.progress === "completed") { setSuccess(st("messages.auditCompleted")) loadSecurityTools() loadLynisReport() } else { setError(status.progress || st("errors.auditFailed")) } } } catch { if (lynisPollRef.current) { clearInterval(lynisPollRef.current) lynisPollRef.current = null } setLynisAuditRunning(false) } }, 3000) } else { setError(data.message || st("errors.startAuditFailed")) setLynisAuditRunning(false) } } catch (err) { setError(err instanceof Error ? err.message : st("errors.startAuditFailed")) setLynisAuditRunning(false) } } const loadLynisReport = async () => { setLynisReportLoading(true) try { const data = await fetchApi("/api/security/lynis/report") if (data.success && data.report) { setLynisReport(data.report) } } catch (err) { console.error("[security] Failed to load Lynis report:", err) } finally { setLynisReportLoading(false) } } // Load report on mount if lynis is installed useEffect(() => { if (lynisInfo?.installed && lynisInfo?.last_scan) { loadLynisReport() } }, [lynisInfo?.installed, lynisInfo?.last_scan]) const openJailConfig = (jail: JailDetail) => { const bt = parseInt(jail.bantime, 10) const isPermanent = bt === -1 setF2bEditingJail(jail.name) setF2bJailConfig({ maxretry: jail.maxretry, bantime: isPermanent ? "" : jail.bantime, findtime: jail.findtime, permanent: isPermanent, }) } const handleSaveJailConfig = async () => { if (!f2bEditingJail) return setF2bSavingConfig(true) setError("") setSuccess("") try { const payload: Record = { jail: f2bEditingJail } if (f2bJailConfig.maxretry) payload.maxretry = parseInt(f2bJailConfig.maxretry, 10) if (f2bJailConfig.permanent) { payload.bantime = -1 } else if (f2bJailConfig.bantime) { payload.bantime = parseInt(f2bJailConfig.bantime, 10) } if (f2bJailConfig.findtime) payload.findtime = parseInt(f2bJailConfig.findtime, 10) const data = await fetchApi("/api/security/fail2ban/jail/config", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }) if (data.success) { setSuccess(st("messages.jailConfigUpdated")) setF2bEditingJail(null) loadFail2banDetails() } else { setError(data.message || st("errors.updateJailConfigFailed")) } } catch (err) { setError(err instanceof Error ? err.message : st("errors.updateJailConfigFailed")) } finally { setF2bSavingConfig(false) } } // Load fail2ban details when basic info shows it's installed and active useEffect(() => { if (fail2banInfo?.installed && fail2banInfo?.active) { loadFail2banDetails() } }, [fail2banInfo?.installed, fail2banInfo?.active]) const formatBanTime = (seconds: string) => { const s = parseInt(seconds, 10) if (s === -1) return st("values.permanent") if (isNaN(s) || s <= 0) return seconds if (s < 60) return `${s}s` if (s < 3600) return `${Math.floor(s / 60)}m` if (s < 86400) return `${Math.floor(s / 3600)}h` return `${Math.floor(s / 86400)}d` } const fail2banProtectionLabel = (name: string) => { const normalized = name.toLowerCase() if (normalized === "sshd" || normalized === "proxmox" || normalized === "proxmenux") { return st(`fail2ban.jailLabels.${normalized}`) } return name } const fail2banProtectionDescription = (name: string) => { const normalized = name.toLowerCase() if (normalized === "sshd" || normalized === "proxmox" || normalized === "proxmenux") { return st(`fail2ban.jailDescriptions.${normalized}`) } return "" } const fail2banActivityLabel = (action: string) => { const normalized = action.toLowerCase() if (normalized === "ban") return st("fail2ban.activity.ban") if (normalized === "unban") return st("fail2ban.activity.unban") if (normalized === "fail") return st("fail2ban.activity.fail") return action } const handleAddRule = async () => { if (!newRule.dport && !newRule.source) { setError(st("errors.ruleNeedsPortOrSource")) return } setAddingRule(true) setError("") setSuccess("") try { const data = await fetchApi("/api/security/firewall/rules", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(newRule), }) if (data.success) { setSuccess(st("messages.ruleAdded")) setShowAddRule(false) setNewRule({ direction: "IN", action: "ACCEPT", protocol: "tcp", dport: "", sport: "", source: "", iface: "", comment: "", level: "host" }) loadFirewallStatus() } else { setError(data.message || st("errors.addRuleFailed")) } } catch (err) { setError(err instanceof Error ? err.message : st("errors.addRuleFailed")) } finally { setAddingRule(false) } } const handleDeleteRule = async (ruleIndex: number, level: string) => { setDeletingRuleIdx(ruleIndex) setError("") setSuccess("") try { const data = await fetchApi("/api/security/firewall/rules", { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ rule_index: ruleIndex, level }), }) if (data.success) { setSuccess(st("messages.ruleDeleted")) loadFirewallStatus() } else { setError(data.message || st("errors.deleteRuleFailed")) } } catch (err) { setError(err instanceof Error ? err.message : st("errors.deleteRuleFailed")) } finally { setDeletingRuleIdx(null) } } const startEditRule = (rule: any) => { const ruleKey = `${rule.source_file}-${rule.rule_index}` const comment = rule.raw?.includes("#") ? rule.raw.split("#").slice(1).join("#").trim() : "" setEditingRuleKey(ruleKey) setEditRule({ direction: rule.direction || "IN", action: rule.action || "ACCEPT", protocol: rule.p || "tcp", dport: rule.dport || "", sport: "", source: rule.source || "", iface: rule.i || "", comment, level: rule.source_file || "host", }) } const handleSaveEditRule = async (oldRuleIndex: number, oldLevel: string) => { setSavingRule(true) setError("") setSuccess("") try { const data = await fetchApi("/api/security/firewall/rules/edit", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ rule_index: oldRuleIndex, level: oldLevel, new_rule: editRule, }), }) if (data.success) { setSuccess(st("messages.ruleUpdated")) setEditingRuleKey(null) loadFirewallStatus() } else { setError(data.message || st("errors.updateRuleFailed")) } } catch (err) { setError(err instanceof Error ? err.message : st("errors.updateRuleFailed")) } finally { setSavingRule(false) } } const handleFirewallToggle = async (level: "host" | "cluster", enable: boolean) => { setFirewallAction(true) setError("") setSuccess("") try { const endpoint = enable ? "/api/security/firewall/enable" : "/api/security/firewall/disable" const data = await fetchApi(endpoint, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ level }), }) if (data.success) { setSuccess(st("messages.firewallUpdated", { state: enable ? st("values.enabledLower") : st("values.disabledLower"), level: level === "cluster" ? st("values.clusterLower") : st("values.hostLower"), })) loadFirewallStatus() } else { setError(data.message || st("errors.updateFirewallFailed")) } } catch (err) { setError(err instanceof Error ? err.message : st("errors.updateFirewallFailed")) } finally { setFirewallAction(false) } } const handleMonitorPortToggle = async (add: boolean) => { setFirewallAction(true) setError("") setSuccess("") try { const data = await fetchApi("/api/security/firewall/monitor-port", { method: add ? "POST" : "DELETE", }) if (data.success) { setSuccess(st(add ? "messages.monitorPortAdded" : "messages.monitorPortRemoved")) loadFirewallStatus() } else { setError(data.message || st("errors.updateMonitorPortFailed")) } } catch (err) { setError(err instanceof Error ? err.message : st("errors.updateMonitorPortFailed")) } finally { setFirewallAction(false) } } const checkAuthStatus = async () => { try { const response = await fetch(getApiUrl("/api/auth/status")) // Check if response is valid JSON before parsing if (!response.ok) return const contentType = response.headers.get("content-type") if (!contentType || !contentType.includes("application/json")) return const data = await response.json() setAuthEnabled(data.auth_enabled || false) setTotpEnabled(data.totp_enabled || false) } catch { // API not available (preview environment) } } const handleEnableAuth = async () => { setError("") setSuccess("") if (!username || !password) { setError(st("errors.fillAllFields")) return } if (password !== confirmPassword) { setError(st("errors.passwordsDoNotMatch")) return } const pwError = validatePasswordStrength(password, t) if (pwError) { setError(pwError) 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(authErrorText(data.error || data.message, "errors.enableAuthFailed")) } localStorage.setItem("proxmenux-auth-token", data.token) localStorage.setItem("proxmenux-auth-setup-complete", "true") setSuccess(st("messages.authEnabled")) setAuthEnabled(true) setShowSetupForm(false) setUsername("") setPassword("") setConfirmPassword("") } catch (err) { setError(err instanceof Error ? err.message : st("errors.enableAuthFailed")) } finally { setLoading(false) } } const handleDisableAuth = async () => { if ( !confirm( st("confirm.disableAuth"), ) ) { 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(authErrorText(data.message || data.error, "errors.disableAuthFailed")) } localStorage.removeItem("proxmenux-auth-token") localStorage.removeItem("proxmenux-auth-setup-complete") setSuccess(st("messages.authDisabledReloading")) setTimeout(() => { window.location.reload() }, 1000) } catch (err) { setError(err instanceof Error ? err.message : st("errors.disableAuthRetry")) } finally { setLoading(false) } } const handleChangePassword = async () => { setError("") setSuccess("") if (!currentPassword || !newPassword) { setError(st("errors.fillAllFields")) return } if (newPassword !== confirmNewPassword) { setError(st("errors.newPasswordsDoNotMatch")) return } const pwError = validatePasswordStrength(newPassword, t) if (pwError) { setError(pwError) 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(authErrorText(data.error || data.message, "errors.changePasswordFailed")) } if (data.token) { localStorage.setItem("proxmenux-auth-token", data.token) } setSuccess(st("messages.passwordChanged")) setShowChangePassword(false) setCurrentPassword("") setNewPassword("") setConfirmNewPassword("") } catch (err) { setError(err instanceof Error ? err.message : st("errors.changePasswordFailed")) } finally { setLoading(false) } } const handleDisable2FA = async () => { setError("") setSuccess("") if (!disable2FAPassword) { setError(st("errors.enterPassword")) return } // Mirror backend hardening (auth_manager.disable_totp): turning 2FA off must // require the second factor — otherwise an attacker who phished the password // could strip the protection. Accepts a 6-digit TOTP code or a backup code. if (!disable2FATotpCode) { setError(st("errors.enter2faOrBackup")) 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, totp_code: disable2FATotpCode.trim(), }), }) const data = await response.json() if (!response.ok) { throw new Error(authErrorText(data.message || data.error, "errors.disable2faFailed")) } setSuccess(st("messages.twoFactorDisabled")) setTotpEnabled(false) setShow2FADisable(false) setDisable2FAPassword("") setDisable2FATotpCode("") checkAuthStatus() } catch (err) { setError(err instanceof Error ? err.message : st("errors.disable2faFailed")) } finally { setLoading(false) } } // handleLogout removed: the session-end action lives in the header's // AvatarMenu now (Fase 1, v1.2.2). See `components/avatar-menu.tsx`. const loadApiTokens = async () => { try { setLoadingTokens(true) const data = await fetchApi("/api/auth/api-tokens") if (data.success) { setExistingTokens(data.tokens || []) } } catch (err) { console.error("[security] Failed to load API tokens:", err) } finally { setLoadingTokens(false) } } const handleRevokeToken = async (tokenId: string) => { if (!confirm(st("confirm.revokeToken"))) { return } setRevokingTokenId(tokenId) setError("") setSuccess("") try { const data = await fetchApi(`/api/auth/api-tokens/${tokenId}`, { method: "DELETE", }) if (data.success) { setSuccess(st("messages.tokenRevoked")) setExistingTokens((prev) => prev.filter((t) => t.id !== tokenId)) } else { setError(data.message || st("errors.revokeTokenFailed")) } } catch (err) { setError(err instanceof Error ? err.message : st("errors.revokeTokenFailed")) } finally { setRevokingTokenId(null) } } const handleGenerateApiToken = async () => { setError("") setSuccess("") if (!tokenPassword) { setError(st("errors.enterPassword")) return } if (totpEnabled && !tokenTotpCode) { setError(st("errors.enter2fa")) 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 || st("apiTokens.defaultName"), }), }) if (!data.success) { setError(authErrorText(data.message || data.error, "errors.generateTokenFailed")) return } if (!data.token) { setError(st("errors.noTokenReceived")) return } setApiToken(data.token) setSuccess(st("messages.apiTokenGenerated")) setTokenPassword("") setTokenTotpCode("") setTokenName("") loadApiTokens() } catch (err) { setError(err instanceof Error ? err.message : st("errors.generateTokenRetry")) } finally { setGeneratingToken(false) } } const copyToClipboard = async (text: string) => { // Preferred path (HTTPS / localhost). On plain HTTP the Promise rejects, // so we catch and fall through to the textarea fallback. try { if (navigator.clipboard && window.isSecureContext) { await navigator.clipboard.writeText(text) return true } } catch { // fall through to execCommand fallback } try { const textarea = document.createElement("textarea") textarea.value = text textarea.style.position = "fixed" textarea.style.left = "-9999px" textarea.style.top = "-9999px" textarea.style.opacity = "0" textarea.readOnly = true document.body.appendChild(textarea) textarea.focus() textarea.select() const ok = document.execCommand("copy") document.body.removeChild(textarea) return ok } catch { return false } } const copyApiToken = async () => { const ok = await copyToClipboard(apiToken) if (ok) { setTokenCopied(true) setTimeout(() => setTokenCopied(false), 2000) } } const isNumber = (value: unknown): value is number => ( typeof value === "number" && Number.isFinite(value) ) const getLynisScoreState = (report: LynisReport | null | undefined, fallbackScore?: number | null) => { const rawScore = report ? report.hardening_index : fallbackScore const adjustedScore = report?.proxmox_adjusted_score const reportHasScore = isNumber(rawScore) || isNumber(adjustedScore) const reportComplete = report ? report.is_complete !== false && report.tests_performed > 0 && reportHasScore : isNumber(fallbackScore) const displayScore = reportComplete ? (isNumber(adjustedScore) ? adjustedScore : rawScore) : null const hasAdjustment = reportComplete && isNumber(adjustedScore) && isNumber(rawScore) && adjustedScore !== rawScore return { rawScore, adjustedScore, displayScore, reportComplete, hasAdjustment } } const getActionableCount = (total: number, expected = 0) => Math.max(0, total - expected) const getPluralForm = (count: number) => { const value = Math.abs(count) if (language === "sk") { if (value === 1) return "one" if (value >= 2 && value <= 4) return "few" return "many" } return value === 1 ? "one" : "many" } const lynisCountText = ( key: "tests" | "warnings" | "suggestions" | "testsExecuted" | "actionableWarnings" | "actionableSuggestions", count: number, ) => st(`lynis.counts.${key}.${getPluralForm(count)}`, { count }) const generatePrintableReport = (report: LynisReport) => { // Escape user/server-controlled strings before they land in the printable // HTML. Without this, any Lynis check name / description / solution that // contained `
${st("lynis.report.brandTitle")} ${st("lynis.report.reviewHint")}
ProxMenux

${st("lynis.report.title")}

${st("lynis.report.subtitle")}

${st("lynis.report.date")}: ${esc(now)}
${st("lynis.report.auditor")}: Lynis ${esc(report.lynis_version || "")}
ID: PMXA-${Date.now().toString(36).toUpperCase()}
1. ${st("lynis.report.executiveSummary")}
${displayScore ?? "N/A"}
${scoreLabel}

${st("lynis.report.hardeningAssessment")}${hasAdjustment ? ` ${st("lynis.proxmoxAdjustedParen")}` : ""}

${reportComplete ? `

${st("lynis.report.auditOf")} ${esc(report.hostname || t("common.unknown"))} ${st("lynis.report.running")} ${esc(report.os_fullname || `${report.os_name} ${report.os_version}`.trim() || st("lynis.report.unknownOs"))} (Proxmox VE). ${lynisCountText("testsExecuted", report.tests_performed)} ${actionableWarnings > 0 ? `${lynisCountText("actionableWarnings", actionableWarnings)}` : `${st("lynis.report.noActionableWarnings")}`} ${st("lynis.report.and")} ${lynisCountText("actionableSuggestions", actionableSuggestions)}. ${totalExpected > 0 ? `${st("lynis.report.expectedBehavior", { count: totalExpected })}` : ""}

` : `

${st("lynis.report.incompleteDescription")}

`} ${hasAdjustment ? `
${st("lynis.report.lynisRaw")}: ${rawScore}/100 ${st("lynis.report.pveAdjusted")}: ${displayScore}/100
${st("lynis.report.rangeCritical")}${st("lynis.report.rangeModerate")}${st("lynis.report.rangeGood")}100
` : ""}
2. ${st("lynis.report.systemInformation")}
${st("lynis.hostname")}
${esc(report.hostname || "N/A")}
${st("lynis.report.operatingSystem")}
${esc(report.os_fullname || `${report.os_name} ${report.os_version}`.trim() || "N/A")}
${esc(st("lynis.kernel"))}
${esc(report.kernel_version || "N/A")}
${st("lynis.report.lynisVersion")}
${esc(report.lynis_version || "N/A")}
${st("lynis.report.reportDate")}
${esc(report.datetime_start ? report.datetime_start.replace("T", " ").substring(0, 16) : "N/A")}
${st("lynis.report.testsPerformed")}
${reportComplete ? report.tests_performed : "N/A"}
3. ${st("lynis.report.securityPosture")}
${displayScore ?? "N/A"}${displayScore == null ? "" : `/100`}
${st("lynis.report.proxmoxScoreWithLabel", { label: scoreLabel })}
${hasAdjustment ? `
${st("lynis.report.lynisRaw")}: ${rawScore}
` : ""}
${actionableWarnings}
${st("lynis.report.actionableWarningsLabel")}
${(report.proxmox_expected_warnings ?? 0) > 0 ? `
${st("lynis.pveExpectedPlus", { count: report.proxmox_expected_warnings ?? 0 })}
` : ""}
${actionableSuggestions}
${st("lynis.report.actionableSuggestionsLabel")}
${(report.proxmox_expected_suggestions ?? 0) > 0 ? `
${st("lynis.pveExpectedPlus", { count: report.proxmox_expected_suggestions ?? 0 })}
` : ""}
${reportComplete ? report.tests_performed : "N/A"}
${st("lynis.report.testsPerformed")}
${st("lynis.firewall")}
${report.firewall_active ? st("values.active") : st("values.inactive")}
${st("lynis.malwareScanner")}
${report.malware_scanner ? st("values.installed") : st("lynis.malwareScannerNotInstalled")}
${st("lynis.packages")}
${esc(report.installed_packages || "N/A")}
4. ${st("lynis.warnings")} (${report.warnings.length}${(report.proxmox_expected_warnings ?? 0) > 0 ? ` - ${st("lynis.actionableCount", { count: actionableWarnings })}` : ""})

${st("lynis.report.warningsDescription")}

${report.warnings.length === 0 ? `
${st("lynis.report.noWarningsDetected")}
` : report.warnings.map((w, i) => `
#${i + 1} ${esc(w.test_id)} ${w.proxmox_expected ? `${st("lynis.pveExpected")}` : ''} ${!w.proxmox_expected && w.proxmox_severity === "low" ? `${st("lynis.lowRisk")}` : ''} ${!w.proxmox_expected && !w.proxmox_severity && w.severity ? `${esc(w.severity)}` : ""}
${esc(w.description)}
${w.proxmox_context ? `
Proxmox: ${esc(w.proxmox_context)}
` : ""} ${w.solution ? `
${st("lynis.report.recommendation")}: ${esc(w.solution)}
` : ""}
`).join("")}
5. ${st("lynis.suggestions")} (${report.suggestions.length}${(report.proxmox_expected_suggestions ?? 0) > 0 ? ` - ${st("lynis.actionableCount", { count: actionableSuggestions })}` : ""})

${st("lynis.report.suggestionsDescription")}${(report.proxmox_expected_suggestions ?? 0) > 0 ? ` ${st("lynis.report.expectedBehavior", { count: report.proxmox_expected_suggestions ?? 0 })}` : ""}

${report.suggestions.length === 0 ? `
${st("lynis.noSuggestions")}
` : report.suggestions.map((s, i) => `
#${i + 1} ${esc(s.test_id)} ${s.proxmox_expected ? `${st("lynis.pveExpected")}` : ''} ${!s.proxmox_expected && s.proxmox_severity === "low" ? `${st("lynis.lowPriority")}` : ''}
${esc(s.description)}
${s.proxmox_context ? `
Proxmox: ${esc(s.proxmox_context)}
` : ""} ${s.solution ? `
${st("lynis.report.recommendation")}: ${esc(s.solution)}
` : ""} ${s.details ? `
${esc(s.details)}
` : ""}
`).join("")}
${(report.sections && report.sections.length > 0) ? `
6. ${st("lynis.report.detailedChecks")} (${st("lynis.report.categoriesCount", { count: report.sections.length })})

${st("lynis.report.detailedChecksDescription")}

${report.sections.map((section, sIdx) => `
${sIdx + 1} ${esc(section.name)} ${st("lynis.checksCount", { count: section.checks.length })}
${section.checks.map(check => { const st = check.status.toUpperCase() const isWarn = ["WARNING", "UNSAFE", "WEAK", "DIFFERENT", "DISABLED"].includes(st) const isSugg = ["SUGGESTION", "PARTIALLY HARDENED", "MEDIUM", "NON DEFAULT"].includes(st) const isOk = ["OK", "FOUND", "DONE", "ENABLED", "ACTIVE", "YES", "HARDENED", "PROTECTED"].includes(st) const color = isWarn ? "#dc2626" : isSugg ? "#ca8a04" : isOk ? "#16a34a" : "#64748b" const cls = isWarn ? ' class="warn"' : isSugg ? ' class="sugg"' : "" return ` ` }).join("")}
${st("lynis.report.check")}${st("lynis.report.status")}
${esc(check.name)}${check.detail ? ` (${esc(check.detail)})` : ""} ${esc(check.status)}
`).join("")}
` : ""} ` } 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 (err) { console.error("[security] Failed to load SSL status:", err) } finally { setLoadingSsl(false) } } // Wait for the monitor service to come back on the new protocol, then redirect const waitForServiceAndRedirect = async (newProtocol: "https" | "http") => { const host = window.location.hostname const port = window.location.port || "8008" const newUrl = `${newProtocol}://${host}:${port}${window.location.pathname}` // Wait for service to restart (try up to 30 seconds) const maxAttempts = 15 for (let i = 0; i < maxAttempts; i++) { await new Promise(r => setTimeout(r, 2000)) try { const controller = new AbortController() const timeout = setTimeout(() => controller.abort(), 3000) const resp = await fetch(`${newProtocol}://${host}:${port}/api/ssl/status`, { signal: controller.signal, // For self-signed certs, we need to handle rejection mode: "no-cors" }).catch(() => null) clearTimeout(timeout) // For HTTPS with self-signed certs, even a failed CORS request means the server is up if (resp || newProtocol === "https") { // Give it one more second to fully stabilize await new Promise(r => setTimeout(r, 1000)) window.location.href = newUrl return } } catch { // Server not ready yet, keep waiting } } // Fallback: redirect anyway after timeout window.location.href = newUrl } const handleEnableSsl = async (source: "proxmox" | "custom", certPath?: string, keyPath?: string) => { setConfiguringSsl(true) setError("") setSuccess("") try { const body: Record = { source, auto_restart: true } 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) { setSslEnabled(true) setSslSource(source) setShowCustomCertForm(false) setCustomCertPath("") setCustomKeyPath("") setConfiguringSsl(false) setSslRestarting(true) setSuccess(st("messages.sslEnabledRestarting")) await waitForServiceAndRedirect("https") } else { setError(data.message || st("errors.configureSslFailed")) setConfiguringSsl(false) } } catch (err) { setError(err instanceof Error ? err.message : st("errors.configureSslFailed")) setConfiguringSsl(false) } } const handleDisableSsl = async () => { if (!confirm(st("confirm.disableHttps"))) { return } setConfiguringSsl(true) setError("") setSuccess("") try { const data = await fetchApi("/api/ssl/disable", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ auto_restart: true }), }) if (data.success) { setSslEnabled(false) setSslSource("none") setSslCertPath("") setSslKeyPath("") setConfiguringSsl(false) setSslRestarting(true) setSuccess(st("messages.sslDisabledRestarting")) await waitForServiceAndRedirect("http") } else { setError(data.message || st("errors.disableSslFailed")) setConfiguringSsl(false) } } catch (err) { setError(err instanceof Error ? err.message : st("errors.disableSslFailed")) setConfiguringSsl(false) } } return (

{st("title")}

{st("description")}

{/* ── ProxMenux Monitor Security Group ── */}

ProxMenux Monitor

{/* Authentication Settings */}
{st("auth.title")}
{st("auth.description")}
{error && (

{error}

)} {success && (

{success}

)}

{st("auth.statusTitle")}

{authEnabled ? st("auth.passwordEnabled") : st("auth.noPasswordProtection")}

{authEnabled ? st("values.enabled") : st("values.disabled")}
{!authEnabled && !showSetupForm && (

{st("auth.enableHint")}

)} {!authEnabled && showSetupForm && (

{st("auth.setupTitle")}

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 && (
{/* Logout moved to the header AvatarMenu (Fase 1, v1.2.2) so the session-end action lives in one consistent place on every page. The Security panel keeps the actions that affect the *account* itself (password, 2FA, disable auth), not the session. */} {!showChangePassword && ( )} {showChangePassword && (

{st("auth.changePassword")}

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 && (

{st("twoFactor.title")}

{st("twoFactor.hint")}

)} {totpEnabled && (

{st("twoFactor.enabled")}

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

{st("twoFactor.disableTitle")}

{st("twoFactor.disableDescription")}

setDisable2FAPassword(e.target.value)} className="pl-10" disabled={loading} />
setDisable2FATotpCode(e.target.value)} disabled={loading} />
)}
)}
)}
{/* SSL/HTTPS Configuration */}
{st("ssl.title")}
{st("ssl.description")}
{loadingSsl ? (
) : ( <> {/* Current Status */}

{sslEnabled ? st("ssl.httpsEnabled") : st("ssl.httpNoSsl")}

{sslEnabled ? st("ssl.usingCertificate", { source: sslSource === "proxmox" ? st("ssl.proxmoxHost") : st("ssl.custom") }) : st("ssl.unencryptedHttp")}

{sslEnabled ? "HTTPS" : "HTTP"}
{/* Active certificate info */} {sslEnabled && (
{st("ssl.activeCertificate")}

{st("ssl.cert")}: {sslCertPath}

{st("ssl.key")}: {sslKeyPath}

)} {/* Proxmox certificate detection */} {!sslEnabled && proxmoxCertAvailable && (

{st("ssl.proxmoxCertDetected")}

{proxmoxCertInfo && (
{proxmoxCertInfo.subject && (

{st("ssl.subject")}: {proxmoxCertInfo.subject}

)} {proxmoxCertInfo.issuer && (

{st("ssl.issuer")}: {proxmoxCertInfo.issuer}

)} {proxmoxCertInfo.expires && (

{st("ssl.expires")}: {proxmoxCertInfo.expires}

)} {proxmoxCertInfo.is_self_signed && (
{st("ssl.selfSignedWarning")}
)}
)}
)} {!sslEnabled && !proxmoxCertAvailable && (

{st("ssl.noProxmoxCertificate")}

)} {/* Custom certificate option */} {!sslEnabled && (
{!showCustomCertForm ? ( ) : (

{st("ssl.customPaths")}

{st("ssl.customPathsDescription")}

setCustomCertPath(e.target.value)} disabled={configuringSsl} />
setCustomKeyPath(e.target.value)} disabled={configuringSsl} />
)}
)} {/* Restarting overlay or info note */} {sslRestarting ? (

{st("ssl.restartTitle")}

{st("ssl.restartDescription")}

) : (

{st("ssl.changesRestart")}

)} )} {/* API Access Tokens */} {authEnabled && (
{st("apiTokens.title")}
{st("apiTokens.description")}
{error && (

{error}

)} {success && (

{success}

)}

{st("apiTokens.aboutTitle")}

  • {st("apiTokens.validFor")}
  • {st("apiTokens.externalServices")}
  • {st("apiTokens.authorizationHeader")}
  • {st("apiTokens.seeGuideBefore")}{" "} {st("apiTokens.integrationsGuide")} {" "} {st("apiTokens.seeGuideAfter")}
{!showApiTokenSection && !apiToken && ( )} {showApiTokenSection && !apiToken && (

{st("apiTokens.generateTitle")}

{st("apiTokens.generateDescription")}

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 && (

{st("apiTokens.yourToken")}

{st("apiTokens.saveTokenNow")}

{st("apiTokens.tokenOnlyShownOnce")}

{tokenCopied && (

{st("apiTokens.copied")}

)}

{st("apiTokens.howToUse")}

# {st("apiTokens.addToHeaders")}

{st("apiTokens.authorizationHeaderExample")}

{st("apiTokens.readmeExamples")}

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

{st("apiTokens.activeTokens")}

{existingTokens.map((token) => { // `valid === false` → JWT signature broken by a // jwt_secret rotation, every request returns 401 // even though the entry still appears here. The // operator needs to revoke and regenerate. const isInvalid = token.valid === false const isLegacy = token.valid === null || token.valid === undefined const containerClass = isInvalid ? "flex items-center justify-between p-3 bg-red-500/5 rounded-lg border border-red-500/30" : "flex items-center justify-between p-3 bg-muted/50 rounded-lg border border-border" return (

{token.name}

{isInvalid && ( {st("apiTokens.invalidRegenerate")} )} {isLegacy && ( {st("apiTokens.legacy")} )}
{token.token_prefix} {token.created_at ? new Date(token.created_at).toLocaleDateString() : t("common.unknown")}
{isInvalid && token.invalidation_reason && (

{token.invalidation_reason}

)}
) })}
)} {loadingTokens && (
{st("apiTokens.loading")}
)} {!loadingTokens && existingTokens.length === 0 && !showApiTokenSection && !apiToken && (
{st("apiTokens.empty")}
)} )} {/* ── Proxmox VE Security Group ── */}

Proxmox VE

{/* Proxmox Firewall */}
{st("firewall.title")}
{firewallData?.pve_firewall_installed && ( )}
{st("firewall.description")}
{firewallLoading ? (
) : !firewallData?.pve_firewall_installed ? (

{st("firewall.notDetectedTitle")}

{st("firewall.notDetectedDescription")}

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

{st("firewall.clusterTitle")}

{firewallData.cluster_fw_enabled ? st("firewall.clusterActive") : st("firewall.clusterDisabled")}

{/* Host Firewall */}

{st("firewall.hostTitle")}

{firewallData.host_fw_enabled ? st("firewall.hostActive") : st("values.disabled")}

{!firewallData.cluster_fw_enabled && (

{st("firewall.clusterRequiredHint")}

)} {/* Quick Presets */}

{st("firewall.quickAccessRules")}

{/* Monitor Port 8008 */}

ProxMenux Monitor

{st("firewall.port8008")}

{/* Proxmox Web UI hint */}

Proxmox Web UI

{st("firewall.port8006AlwaysAllowed")}

{st("firewall.builtIn")}
{!firewallData.monitor_port_open && (firewallData.cluster_fw_enabled || firewallData.host_fw_enabled) && (

{st("firewall.monitorPortWarning")}

)}
{/* Rules Summary Dashboard */} {firewallData.rules.length > 0 && (() => { const acceptCount = firewallData.rules.filter(r => r.action === "ACCEPT").length const dropCount = firewallData.rules.filter(r => r.action === "DROP").length const rejectCount = firewallData.rules.filter(r => r.action === "REJECT").length const blockCount = dropCount + rejectCount const total = firewallData.rules.length const clusterCount = firewallData.rules.filter(r => r.source_file === "cluster").length const hostCount = firewallData.rules.filter(r => r.source_file === "host").length const inCount = firewallData.rules.filter(r => (r.direction || "IN") === "IN").length const outCount = firewallData.rules.filter(r => r.direction === "OUT").length // Collect unique protected ports const protectedPorts = new Set() firewallData.rules.forEach(r => { if (r.dport) r.dport.split(",").forEach(p => protectedPorts.add(p.trim())) }) return (

{st("firewall.rulesOverview")}

{total}

{st("firewall.totalRules")}

{acceptCount}

{st("firewall.accept")}

{blockCount}

{st("firewall.blockReject")}

{protectedPorts.size}

{st("firewall.portsCovered")}

{/* Visual bar */}
{acceptCount > 0 && (
)} {dropCount > 0 && (
)} {rejectCount > 0 && (
)}
{st("firewall.accept")} {st("firewall.drop")} {st("firewall.reject")}
{st("firewall.accept")} {st("firewall.drop")} {st("firewall.reject")}
{/* Scope breakdown */}
{st("firewall.cluster")}: {clusterCount} {st("firewall.host")}: {hostCount} | IN: {inCount} OUT: {outCount}
) })()} {/* Firewall Rules */}

{st("firewall.rules", { count: firewallData.rules_count })}

{/* Add Rule Form */} {showAddRule && (

{st("firewall.newRule")}

{/* Service Presets */}

{st("firewall.quickPresets")}

{[ { label: "HTTP", port: "80", proto: "tcp", comment: st("firewall.presets.httpWeb") }, { label: "HTTPS", port: "443", proto: "tcp", comment: st("firewall.presets.httpsWeb") }, { label: "SSH", port: "22", proto: "tcp", comment: st("firewall.presets.sshRemoteAccess") }, { label: "DNS", port: "53", proto: "udp", comment: "DNS" }, { label: "SMTP", port: "25", proto: "tcp", comment: st("firewall.presets.smtpMail") }, { label: "NFS", port: "2049", proto: "tcp", comment: "NFS" }, { label: "SMB", port: "445", proto: "tcp", comment: "SMB/CIFS" }, { label: "Ping", port: "", proto: "icmp", comment: st("firewall.presets.icmpPing") }, ].map((preset) => ( ))}
setNewRule({...newRule, dport: e.target.value})} className="h-9 text-sm" />

{st("firewall.destinationPortHint")}

setNewRule({...newRule, source: e.target.value})} className="h-9 text-sm" />

{st("firewall.sourceAddressHint")}

setNewRule({...newRule, comment: e.target.value})} className="h-9 text-sm" />
)} {/* Rules List */} {firewallData.rules.length > 0 ? (
{/* Table header */}
{st("firewall.fields.action")} {st("firewall.fields.proto")} {st("firewall.fields.port")} {st("firewall.fields.source")} {st("firewall.fields.level")}
{firewallData.rules.map((rule, idx) => { const ruleKey = `${rule.source_file}-${rule.rule_index}` const isExpanded = expandedRuleKey === ruleKey const direction = rule.direction || "IN" const comment = rule.raw?.includes("#") ? rule.raw.split("#").slice(1).join("#").trim() : "" return (
{/* Main row */}
setExpandedRuleKey(isExpanded ? null : ruleKey)} > {/* Direction icon */}
{direction === "IN" ? ( ) : ( )}
{/* Action badge */} {rule.action || "?"} {/* Mobile: combined info on two lines */}
{rule.p || "*"} : {rule.dport || "*"} {rule.source_file}
{comment && (

{comment}

)}
{/* Desktop: direction label */} {direction} {/* Protocol */} {rule.p || "*"} {/* Port */} {rule.dport || "*"} {/* Source */} {rule.source || "any"} {/* Level badge */} {rule.source_file} {/* Expand/Delete */}
{/* Expanded details */} {isExpanded && (
{editingRuleKey === ruleKey ? ( /* ── Inline Edit Form ── */
setEditRule({ ...editRule, dport: e.target.value })} placeholder={st("firewall.placeholders.shortPort")} className="h-8 text-xs mt-0.5" />
setEditRule({ ...editRule, source: e.target.value })} placeholder={st("firewall.placeholders.ipOrCidr")} className="h-8 text-xs mt-0.5" />
setEditRule({ ...editRule, comment: e.target.value })} placeholder={st("firewall.placeholders.description")} className="h-8 text-xs mt-0.5" />
) : ( /* ── Read-only Details ── */ <>

{st("firewall.fields.direction")}

{direction === "IN" ? : } {direction === "IN" ? st("firewall.incoming") : st("firewall.outgoing")}

{st("firewall.fields.protocol")}

{rule.p || st("firewall.anyLower")}

{st("firewall.fields.port")}

{rule.dport || st("firewall.anyLower")}

{st("firewall.fields.source")}

{rule.source || st("firewall.anyLower")}

{rule.i && (

{st("firewall.fields.interface")}

{rule.i}

)}

{st("firewall.fields.scope")}

{rule.source_file === "cluster" ? : } {rule.source_file === "cluster" ? st("firewall.cluster") : st("firewall.host")}

{comment && (

{st("firewall.fields.comment")}

{comment}

)}
{rule.raw}
)}
)}
) })}
) : (

{st("firewall.noRules")}

{st("firewall.noRulesHint")}

)}
)} {/* Secure Gateway */} {/* Fail2Ban */}
Fail2Ban
{fail2banInfo?.installed && (
{fail2banInfo?.active && ( )}
)}
{st("fail2ban.description")}
{toolsLoading ? (
) : !fail2banInfo?.installed ? ( /* --- NOT INSTALLED --- */

{st("fail2ban.notInstalled")}

{st("fail2ban.notInstalledDescription")}

{st("fail2ban.configureTitle")}

  • {st("fail2ban.configureSsh")}
  • {st("fail2ban.configureProxmox")}
  • {st("fail2ban.configureMonitor")}
  • {st("fail2ban.configureGlobal")}

{st("fail2ban.customizeAfterInstall")}

) : ( /* --- INSTALLED --- */
{/* Status bar */}

{fail2banInfo.version}

{fail2banInfo.active ? st("values.serviceRunning") : st("values.serviceNotRunning")}

{fail2banInfo.active ? st("values.active") : st("values.inactive")}
{fail2banInfo.active && f2bDetails && ( <> {/* Summary stats - inline */}
{st("fail2ban.jails")}: {f2bDetails.jails.length}
{st("fail2ban.bannedIps")}: a + j.currently_banned, 0) > 0 ? "text-red-500" : "text-green-500"}`}> {f2bDetails.jails.reduce((a, j) => a + j.currently_banned, 0)}
{st("fail2ban.totalBans")}: {f2bDetails.jails.reduce((a, j) => a + j.total_banned, 0)}
{st("fail2ban.failedAttempts")}: {f2bDetails.jails.reduce((a, j) => a + j.total_failed, 0)}
{/* Missing protections warning */} {(() => { const expectedJails = ["sshd", "proxmox", "proxmenux"] const currentNames = f2bDetails.jails.map(j => j.name.toLowerCase()) const missing = expectedJails.filter(j => !currentNames.includes(j)) if (missing.length === 0) return null return (

{st("fail2ban.missingProtectionsTitle")}

{st("fail2ban.missingProtectionsBefore")}{" "} {missing.map(j => fail2banProtectionLabel(j)).join(", ")}

) })()} {/* Tab switcher */}
{/* PROTECTIONS TAB */} {f2bActiveTab === "jails" && (
{f2bDetails.jails.map((jail) => (
{/* Protection header */}
0 ? "bg-red-500 animate-pulse" : "bg-green-500"}`} /> {fail2banProtectionLabel(jail.name)} {fail2banProtectionLabel(jail.name) !== jail.name && ( {jail.name} )} {fail2banProtectionDescription(jail.name)} {parseInt(jail.bantime, 10) === -1 && ( {st("fail2ban.permanentBan")} )}
{st("fail2ban.retries")}: {jail.maxretry} {st("fail2ban.ban")}: {parseInt(jail.bantime, 10) === -1 ? st("values.permanent") : formatBanTime(jail.bantime)} {st("fail2ban.window")}: {formatBanTime(jail.findtime)}
{/* Protection config editor */} {f2bEditingJail === jail.name && (

{st("fail2ban.configureJail", { jail: fail2banProtectionLabel(jail.name) })}

setF2bJailConfig({...f2bJailConfig, maxretry: e.target.value})} className="h-9 text-sm" placeholder={st("fail2ban.placeholders.maxRetries")} />

{st("fail2ban.failedAttemptsBeforeBan")}

setF2bJailConfig({...f2bJailConfig, bantime: e.target.value, permanent: false})} className="h-9 text-sm" placeholder={f2bJailConfig.permanent ? st("values.permanent") : st("fail2ban.placeholders.banTime")} disabled={f2bJailConfig.permanent} />
setF2bJailConfig({...f2bJailConfig, permanent: e.target.checked, bantime: ""})} className="rounded border-border" />
setF2bJailConfig({...f2bJailConfig, findtime: e.target.value})} className="h-9 text-sm" placeholder={st("fail2ban.placeholders.findTime")} />

{st("fail2ban.timeWindowHint")}

{st("fail2ban.commonValuesHint")}

)} {/* Mobile config summary (visible only on small screens) */}
{st("fail2ban.retries")}: {jail.maxretry} {st("fail2ban.ban")}: {parseInt(jail.bantime, 10) === -1 ? st("values.perm") : formatBanTime(jail.bantime)} {st("fail2ban.window")}: {formatBanTime(jail.findtime)}
{/* Protection stats - inline */}
{st("fail2ban.banned")}: 0 ? "text-red-500" : "text-green-500"}`}> {jail.currently_banned}
{st("fail2ban.totalBans")}: {jail.total_banned}
{st("fail2ban.failedNow")}: {jail.currently_failed}
{st("fail2ban.totalFailed")}: {jail.total_failed}
{/* Blocked IPs list */} {jail.banned_ips.length > 0 && (

{st("fail2ban.bannedIpsWithCount", { count: jail.banned_ips.length })}

{jail.banned_ips.map((entry) => (
{entry.ip} {entry.type === "local" ? "LAN" : entry.type === "external" ? st("values.external") : t("common.unknown")}
))}
)} {jail.currently_banned === 0 && (

{st("fail2ban.noBannedIps")}

)}
))} {f2bDetails.jails.length === 0 && (
{st("fail2ban.noJails")}
)}
)} {/* ACTIVITY TAB */} {f2bActiveTab === "activity" && (
{f2bActivity.length === 0 ? (
{st("fail2ban.noActivity")}
) : ( f2bActivity.map((event, idx) => (
{fail2banActivityLabel(event.action)}
{event.ip} {fail2banProtectionLabel(event.jail)} {event.timestamp}
)) )}
)} )} {fail2banInfo.active && !f2bDetails && f2bDetailsLoading && (
)}
)} {/* Lynis */}
{st("lynis.title")}
{lynisInfo?.installed && ( )}
{st("lynis.description")}
{toolsLoading ? (
) : !lynisInfo?.installed ? (

{st("lynis.notInstalled")}

{st("lynis.notInstalledDescription")}

{st("lynis.featuresTitle")}

  • {st("lynis.featureScoring")}
  • {st("lynis.featureVulnerabilities")}
  • {st("lynis.featureCompliance")}
  • {st("lynis.featureGithub")}
) : (
{/* Status bar */}

Lynis {lynisInfo.version}

{st("lynis.installedDescription")}

{st("values.installed")}
{/* Summary stats */}

{st("lynis.lastScan")}

{lynisInfo.last_scan ? lynisInfo.last_scan.replace("T", " ").substring(0, 16) : st("values.never")}

{st("lynis.hardeningIndex")}

{(() => { const { rawScore, adjustedScore: adjScore, displayScore, reportComplete, hasAdjustment } = getLynisScoreState(lynisReport, lynisInfo.hardening_index) const scoreColorClass = displayScore === null || displayScore === undefined ? "text-muted-foreground" : displayScore >= 70 ? "text-green-500" : displayScore >= 50 ? "text-yellow-500" : "text-red-500" return (

{displayScore !== null && displayScore !== undefined ? displayScore : "—"}

{hasAdjustment && (

{st("lynis.scoreBreakdown", { raw: rawScore ?? "N/A", adjusted: adjScore ?? "N/A" })}

)} {!reportComplete && lynisReport && (

{st("lynis.reportIncompleteShort")}

)}
) })()}

{st("lynis.warnings")}

{(() => { if (!lynisReport) return

-

const total = lynisReport.warnings.length const expected = lynisReport.proxmox_expected_warnings ?? 0 const real = getActionableCount(total, expected) return (

0 ? "text-red-500" : total > 0 ? "text-yellow-500" : "text-green-500"}`}> {real > 0 ? real : total}

{expected > 0 && (

{st("lynis.pveExpectedPlus", { count: expected })}

)}
) })()}

{st("lynis.suggestions")}

{(() => { if (!lynisReport) return

-

const total = lynisReport.suggestions.length const expected = lynisReport.proxmox_expected_suggestions ?? 0 const real = getActionableCount(total, expected) return (

0 ? "text-yellow-500" : "text-green-500"}`}> {real > 0 ? real : total}

{expected > 0 && (

{st("lynis.pveExpectedPlus", { count: expected })}

)}
) })()}
{/* Hardening bar */} {(() => { const { rawScore, displayScore, reportComplete, hasAdjustment } = getLynisScoreState(lynisReport, lynisInfo.hardening_index) if (!reportComplete || displayScore === null || displayScore === undefined || rawScore === null || rawScore === undefined) { if (!lynisReport) return null return (

{st("lynis.reportIncompleteTitle")}

{st("lynis.reportIncompleteDescription")}

) } return (
{st("lynis.securityHardeningScore")} {hasAdjustment && {st("lynis.proxmoxAdjustedParen")}} = 70 ? "text-green-500" : displayScore >= 50 ? "text-yellow-500" : "text-red-500" }`}> {displayScore}/100
{hasAdjustment ? (
{/* Raw score bar (dimmed) */}
{/* Adjusted score bar */}
= 70 ? "bg-green-500" : displayScore >= 50 ? "bg-yellow-500" : "bg-red-500" }`} style={{ width: `${displayScore}%` }} />
) : (
= 70 ? "bg-green-500" : displayScore >= 50 ? "bg-yellow-500" : "bg-red-500" }`} style={{ width: `${displayScore}%` }} />
)}
{st("lynis.scoreCritical")} {st("lynis.scoreModerate")} {st("lynis.scoreGood")}
{hasAdjustment && (

{st("lynis.rawScorePrefix")} {rawScore}/100 | {st("lynis.expectedFindings", { count: (lynisReport?.proxmox_expected_warnings ?? 0) + (lynisReport?.proxmox_expected_suggestions ?? 0) })}

)}
) })()} {/* Running indicator */} {lynisAuditRunning && (

{st("lynis.auditInProgress")}

{st("lynis.auditInProgressDescription")}

)} {/* Reports list */} {lynisReport && (

{st("lynis.auditReports")}

{/* Report row - clickable to expand */}
) })()} {/* Delete button separated with divider to prevent accidental clicks */}
{/* Expanded report details */} {lynisShowReport && (
{/* System info strip */}

{st("lynis.hostname")}

{lynisReport.hostname || "N/A"}

OS

{lynisReport.os_fullname || `${lynisReport.os_name} ${lynisReport.os_version}`.trim() || "N/A"}

{st("lynis.kernel")}

{lynisReport.kernel_version || "N/A"}

{st("lynis.tests")}

{lynisReport.tests_performed}

{/* Report tabs - responsive with shorter labels on mobile */}
{(["overview", "checks", "warnings", "suggestions"] as const).map((tab) => ( ))}
{/* Overview tab */} {lynisActiveTab === "overview" && (

{st("lynis.packages")}

{lynisReport.installed_packages || "N/A"}

{st("lynis.firewall")}

{lynisReport.firewall_active ? st("values.active") : st("values.inactive")}

{st("lynis.malwareScanner")}

{lynisReport.malware_scanner ? st("values.installed") : st("lynis.malwareScannerNotInstalled")}

{/* Security checklist */}

{st("lynis.quickStatus")}

{(() => { const { displayScore, reportComplete } = getLynisScoreState(lynisReport) const adjScore = displayScore ?? 0 const realWarnings = getActionableCount(lynisReport.warnings.length, lynisReport.proxmox_expected_warnings ?? 0) return [ { label: st("lynis.firewall"), ok: lynisReport.firewall_active, passText: st("values.active"), failText: st("values.inactive"), }, { label: st("lynis.malwareScanner"), ok: lynisReport.malware_scanner, passText: st("values.installed"), failText: st("values.notInstalled"), isWarning: true, }, { label: st("lynis.warnings"), ok: realWarnings <= 0, passText: lynisReport.warnings.length === 0 ? st("values.none") : st("lynis.allPveExpected", { count: lynisReport.warnings.length }), failText: st("lynis.actionableCount", { count: realWarnings }) + (lynisReport.proxmox_expected_warnings ? ` ${st("lynis.expectedWarningsSuffix", { count: lynisReport.proxmox_expected_warnings })}` : ""), isWarning: realWarnings > 0 && realWarnings <= 5, }, { label: st("lynis.hardeningScorePve"), ok: reportComplete && adjScore >= 70, passText: `${adjScore}/100`, failText: reportComplete ? `${adjScore}/100 (< 70)` : st("lynis.reportIncompleteShort"), isWarning: !reportComplete || adjScore >= 50, }, ].map((item) => { const color = item.ok ? "green" : item.isWarning ? "yellow" : "red" return (
{item.label} {item.ok ? item.passText : item.failText}
)}) })()}
)} {/* Checks tab */} {lynisActiveTab === "checks" && (
{(!lynisReport.sections || lynisReport.sections.length === 0) ? (
{st("lynis.noCheckDetails")}
) : (
{lynisReport.sections.map((section, sIdx) => (
{sIdx + 1} {section.name} {st("lynis.checksCount", { count: section.checks.length })}
{section.checks.map((check, cIdx) => { const st = check.status.toUpperCase() const isOk = ["OK", "FOUND", "DONE", "ENABLED", "ACTIVE", "YES", "HARDENED", "PROTECTED", "NONE", "NOT FOUND", "NOT RUNNING", "NOT ACTIVE", "NOT ENABLED", "DEFAULT", "NO"].includes(st) const isWarn = ["WARNING", "UNSAFE", "WEAK", "DIFFERENT", "DISABLED"].includes(st) const isSugg = ["SUGGESTION", "PARTIALLY HARDENED", "MEDIUM", "NON DEFAULT"].includes(st) const dotColor = isWarn ? "bg-red-500" : isSugg ? "bg-yellow-500" : isOk ? "bg-green-500" : "bg-muted-foreground" const textColor = isWarn ? "text-red-500" : isSugg ? "text-yellow-500" : isOk ? "text-green-500" : "text-muted-foreground" return (
{check.name} {check.detail && {check.detail}} {check.status}
) })}
))}
)}
)} {/* Warnings tab */} {lynisActiveTab === "warnings" && (
{lynisReport.warnings.length === 0 ? (
{st("lynis.noWarnings")}
) : (
{lynisReport.warnings.map((w, idx) => (
{w.test_id} {w.proxmox_expected && ( {st("lynis.pveExpected")} )} {!w.proxmox_expected && w.proxmox_severity === "low" && ( {st("lynis.lowRisk")} )} {!w.proxmox_expected && !w.proxmox_severity && w.severity && ( {w.severity} )}

{w.description}

{w.proxmox_context && (

Proxmox: {w.proxmox_context}

)} {w.solution && (

{st("lynis.solution")}: {w.solution}

)}
))}
)}
)} {/* Suggestions tab */} {lynisActiveTab === "suggestions" && (
{lynisReport.suggestions.length === 0 ? (
{st("lynis.noSuggestions")}
) : (
{lynisReport.suggestions.map((s, idx) => (
{s.test_id} {s.proxmox_expected && ( {st("lynis.pveExpected")} )} {!s.proxmox_expected && s.proxmox_severity === "low" && ( {st("lynis.lowPriority")} )}

{s.description}

{s.proxmox_context && (

Proxmox: {s.proxmox_context}

)} {s.solution && (

{st("lynis.solution")}: {s.solution}

)} {s.details && (

{s.details}

)}
))}
)}
)}
)}
)} {/* Run audit button - at the bottom */}
)} {/* Script Terminal Modals */} { setShowFail2banInstaller(false) loadSecurityTools() }} scriptPath="/usr/local/share/proxmenux/scripts/security/fail2ban_installer.sh" scriptName="fail2ban_installer" params={{ EXECUTION_MODE: "web" }} title={st("fail2ban.installationTitle")} description={st("fail2ban.installationDescription")} /> { setShowLynisInstaller(false) loadSecurityTools() }} scriptPath="/usr/local/share/proxmenux/scripts/security/lynis_installer.sh" scriptName="lynis_installer" params={{ EXECUTION_MODE: "web" }} title={st("lynis.installationTitle")} description={st("lynis.installationDescription")} /> {/* Uninstall Confirmation Dialogs */} {showFail2banUninstallConfirm && (

{st("fail2ban.uninstallConfirmTitle")}

{st("confirm.cannotBeUndone")}

{st("fail2ban.uninstallConfirmDescription")}

  • {st("fail2ban.removeSshJail")}
  • {st("fail2ban.removeProxmoxProtection")}
  • {st("fail2ban.removeMonitorProtection")}
  • {st("fail2ban.removeCustomJails")}
  • {st("fail2ban.removeAuthLogger")}
)} {showLynisUninstallConfirm && (

{st("lynis.uninstallConfirmTitle")}

{st("confirm.cannotBeUndone")}

{st("lynis.uninstallConfirmDescription")}

  • {st("lynis.removeInstallation")}
  • {st("lynis.removeWrapper")}
  • {st("lynis.removeReports")}
)} setShow2FASetup(false)} onSuccess={() => { setSuccess(st("messages.twoFactorEnabled")) checkAuthStatus() }} />
) }