Update logs

This commit is contained in:
MacRimi
2026-02-16 09:52:33 +01:00
7 changed files with 764 additions and 101 deletions

View File

@@ -9,7 +9,8 @@ import {
Shield, Lock, User, AlertCircle, CheckCircle, Info, LogOut, Key, Copy, Eye, EyeOff, Shield, Lock, User, AlertCircle, CheckCircle, Info, LogOut, Key, Copy, Eye, EyeOff,
Trash2, RefreshCw, Clock, ShieldCheck, Globe, FileKey, AlertTriangle, Trash2, RefreshCw, Clock, ShieldCheck, Globe, FileKey, AlertTriangle,
Flame, Bug, Search, Download, Power, PowerOff, Plus, Minus, Activity, Settings, Ban, Flame, Bug, Search, Download, Power, PowerOff, Plus, Minus, Activity, Settings, Ban,
FileText, Printer, Play, BarChart3, TriangleAlert, ChevronDown, FileText, Printer, Play, BarChart3, TriangleAlert, ChevronDown, ArrowDownLeft, ArrowUpRight,
ChevronRight, Network, Zap, Pencil, Check, X,
} from "lucide-react" } from "lucide-react"
import { getApiUrl, fetchApi } from "../lib/api-config" import { getApiUrl, fetchApi } from "../lib/api-config"
import { TwoFactorSetup } from "./two-factor-setup" import { TwoFactorSetup } from "./two-factor-setup"
@@ -88,6 +89,14 @@ export function Security() {
}) })
const [addingRule, setAddingRule] = useState(false) const [addingRule, setAddingRule] = useState(false)
const [deletingRuleIdx, setDeletingRuleIdx] = useState<number | null>(null) const [deletingRuleIdx, setDeletingRuleIdx] = useState<number | null>(null)
const [expandedRuleKey, setExpandedRuleKey] = useState<string | null>(null)
const [editingRuleKey, setEditingRuleKey] = useState<string | null>(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 // Security Tools state
const [toolsLoading, setToolsLoading] = useState(true) const [toolsLoading, setToolsLoading] = useState(true)
@@ -174,6 +183,7 @@ export function Security() {
const [proxmoxCertInfo, setProxmoxCertInfo] = useState<{subject?: string; expires?: string; issuer?: string; is_self_signed?: boolean} | null>(null) const [proxmoxCertInfo, setProxmoxCertInfo] = useState<{subject?: string; expires?: string; issuer?: string; is_self_signed?: boolean} | null>(null)
const [loadingSsl, setLoadingSsl] = useState(true) const [loadingSsl, setLoadingSsl] = useState(true)
const [configuringSsl, setConfiguringSsl] = useState(false) const [configuringSsl, setConfiguringSsl] = useState(false)
const [sslRestarting, setSslRestarting] = useState(false)
const [showCustomCertForm, setShowCustomCertForm] = useState(false) const [showCustomCertForm, setShowCustomCertForm] = useState(false)
const [customCertPath, setCustomCertPath] = useState("") const [customCertPath, setCustomCertPath] = useState("")
const [customKeyPath, setCustomKeyPath] = useState("") const [customKeyPath, setCustomKeyPath] = useState("")
@@ -183,6 +193,7 @@ export function Security() {
loadApiTokens() loadApiTokens()
loadSslStatus() loadSslStatus()
loadFirewallStatus() loadFirewallStatus()
loadNetworkInterfaces()
loadSecurityTools() loadSecurityTools()
}, []) }, [])
@@ -208,6 +219,22 @@ export function Security() {
} }
} }
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 () => { const loadSecurityTools = async () => {
try { try {
setToolsLoading(true) setToolsLoading(true)
@@ -470,6 +497,51 @@ export function Security() {
} }
} }
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(data.message || "Rule updated successfully")
setEditingRuleKey(null)
loadFirewallStatus()
} else {
setError(data.message || "Failed to update rule")
}
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to update rule")
} finally {
setSavingRule(false)
}
}
const handleFirewallToggle = async (level: "host" | "cluster", enable: boolean) => { const handleFirewallToggle = async (level: "host" | "cluster", enable: boolean) => {
setFirewallAction(true) setFirewallAction(true)
setError("") setError("")
@@ -1256,13 +1328,49 @@ ${(report.sections && report.sections.length > 0) ? `
} }
} }
// 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) => { const handleEnableSsl = async (source: "proxmox" | "custom", certPath?: string, keyPath?: string) => {
setConfiguringSsl(true) setConfiguringSsl(true)
setError("") setError("")
setSuccess("") setSuccess("")
try { try {
const body: Record<string, string> = { source } const body: Record<string, string | boolean> = { source, auto_restart: true }
if (source === "custom" && certPath && keyPath) { if (source === "custom" && certPath && keyPath) {
body.cert_path = certPath body.cert_path = certPath
body.key_path = keyPath body.key_path = keyPath
@@ -1275,25 +1383,27 @@ ${(report.sections && report.sections.length > 0) ? `
}) })
if (data.success) { if (data.success) {
setSuccess(data.message || "SSL configured successfully. Restart the monitor service to apply.")
setSslEnabled(true) setSslEnabled(true)
setSslSource(source) setSslSource(source)
setShowCustomCertForm(false) setShowCustomCertForm(false)
setCustomCertPath("") setCustomCertPath("")
setCustomKeyPath("") setCustomKeyPath("")
loadSslStatus() setConfiguringSsl(false)
setSslRestarting(true)
setSuccess("SSL enabled. Restarting service and switching to HTTPS...")
await waitForServiceAndRedirect("https")
} else { } else {
setError(data.message || "Failed to configure SSL") setError(data.message || "Failed to configure SSL")
setConfiguringSsl(false)
} }
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Failed to configure SSL") setError(err instanceof Error ? err.message : "Failed to configure SSL")
} finally {
setConfiguringSsl(false) setConfiguringSsl(false)
} }
} }
const handleDisableSsl = async () => { const handleDisableSsl = async () => {
if (!confirm("Are you sure you want to disable HTTPS? The monitor will revert to HTTP after restart.")) { if (!confirm("Are you sure you want to disable HTTPS? The monitor will switch to HTTP.")) {
return return
} }
@@ -1302,21 +1412,27 @@ ${(report.sections && report.sections.length > 0) ? `
setSuccess("") setSuccess("")
try { try {
const data = await fetchApi("/api/ssl/disable", { method: "POST" }) const data = await fetchApi("/api/ssl/disable", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ auto_restart: true }),
})
if (data.success) { if (data.success) {
setSuccess(data.message || "SSL disabled. Restart the monitor service to apply.")
setSslEnabled(false) setSslEnabled(false)
setSslSource("none") setSslSource("none")
setSslCertPath("") setSslCertPath("")
setSslKeyPath("") setSslKeyPath("")
loadSslStatus() setConfiguringSsl(false)
setSslRestarting(true)
setSuccess("SSL disabled. Restarting service and switching to HTTP...")
await waitForServiceAndRedirect("http")
} else { } else {
setError(data.message || "Failed to disable SSL") setError(data.message || "Failed to disable SSL")
setConfiguringSsl(false)
} }
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Failed to disable SSL") setError(err instanceof Error ? err.message : "Failed to disable SSL")
} finally {
setConfiguringSsl(false) setConfiguringSsl(false)
} }
} }
@@ -1683,10 +1799,10 @@ ${(report.sections && report.sections.length > 0) ? `
onClick={handleDisableSsl} onClick={handleDisableSsl}
variant="outline" variant="outline"
size="sm" size="sm"
disabled={configuringSsl} disabled={configuringSsl || sslRestarting}
className="mt-2 text-red-500 border-red-500/30 hover:bg-red-500/10 bg-transparent" className="mt-2 text-red-500 border-red-500/30 hover:bg-red-500/10 bg-transparent"
> >
{configuringSsl ? "Disabling..." : "Disable HTTPS"} {configuringSsl ? "Disabling..." : sslRestarting ? "Restarting..." : "Disable HTTPS"}
</Button> </Button>
</div> </div>
)} )}
@@ -1722,7 +1838,7 @@ ${(report.sections && report.sections.length > 0) ? `
<Button <Button
onClick={() => handleEnableSsl("proxmox")} onClick={() => handleEnableSsl("proxmox")}
className="w-full bg-green-600 hover:bg-green-700 text-white" className="w-full bg-green-600 hover:bg-green-700 text-white"
disabled={configuringSsl} disabled={configuringSsl || sslRestarting}
> >
{configuringSsl ? ( {configuringSsl ? (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -1793,9 +1909,9 @@ ${(report.sections && report.sections.length > 0) ? `
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button
onClick={() => handleEnableSsl("custom", customCertPath, customKeyPath)} onClick={() => handleEnableSsl("custom", customCertPath, customKeyPath)}
className="flex-1 bg-green-600 hover:bg-green-700 text-white" className="flex-1 bg-green-600 hover:bg-green-700 text-white"
disabled={configuringSsl || !customCertPath || !customKeyPath} disabled={configuringSsl || sslRestarting || !customCertPath || !customKeyPath}
> >
{configuringSsl ? "Configuring..." : "Enable HTTPS"} {configuringSsl ? "Configuring..." : "Enable HTTPS"}
</Button> </Button>
@@ -1817,14 +1933,27 @@ ${(report.sections && report.sections.length > 0) ? `
</div> </div>
)} )}
{/* Info note about restart */} {/* Restarting overlay or info note */}
<div className="bg-blue-500/10 border border-blue-500/20 rounded-lg p-3 flex items-start gap-2"> {sslRestarting ? (
<Info className="h-5 w-5 text-blue-500 flex-shrink-0 mt-0.5" /> <div className="bg-amber-500/10 border border-amber-500/20 rounded-lg p-4 flex items-center gap-3">
<p className="text-sm text-blue-500"> <div className="h-5 w-5 border-2 border-amber-500 border-t-transparent rounded-full animate-spin flex-shrink-0" />
Changes to SSL configuration require a monitor service restart to take effect. <div>
The service will automatically use HTTPS on port 8008 when enabled. <p className="text-sm font-medium text-amber-500">
</p> Restarting monitor service...
</div> </p>
<p className="text-xs text-amber-400 mt-0.5">
The page will automatically redirect to the new address.
</p>
</div>
</div>
) : (
<div className="bg-blue-500/10 border border-blue-500/20 rounded-lg p-3 flex items-start gap-2">
<Info className="h-5 w-5 text-blue-500 flex-shrink-0 mt-0.5" />
<p className="text-sm text-blue-500">
SSL changes will automatically restart the monitor service and redirect to the new address.
</p>
</div>
)}
</> </>
)} )}
</CardContent> </CardContent>
@@ -2283,6 +2412,90 @@ ${(report.sections && report.sections.length > 0) ? `
)} )}
</div> </div>
{/* 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<string>()
firewallData.rules.forEach(r => {
if (r.dport) r.dport.split(",").forEach(p => protectedPorts.add(p.trim()))
})
return (
<div className="space-y-2">
<h3 className="text-sm font-semibold text-muted-foreground">Rules Overview</h3>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
<div className="p-3 bg-muted/50 rounded-lg border border-border text-center">
<p className="text-lg font-bold text-foreground">{total}</p>
<p className="text-[10px] text-muted-foreground uppercase tracking-wider">Total Rules</p>
</div>
<div className="p-3 bg-green-500/5 rounded-lg border border-green-500/20 text-center">
<p className="text-lg font-bold text-green-500">{acceptCount}</p>
<p className="text-[10px] text-green-500/70 uppercase tracking-wider">Accept</p>
</div>
<div className="p-3 bg-red-500/5 rounded-lg border border-red-500/20 text-center">
<p className="text-lg font-bold text-red-500">{blockCount}</p>
<p className="text-[10px] text-red-500/70 uppercase tracking-wider">Block / Reject</p>
</div>
<div className="p-3 bg-muted/50 rounded-lg border border-border text-center">
<p className="text-lg font-bold text-foreground">{protectedPorts.size}</p>
<p className="text-[10px] text-muted-foreground uppercase tracking-wider">Ports Covered</p>
</div>
</div>
{/* Visual bar */}
<div className="space-y-1.5 sm:space-y-0">
<div className="flex items-center gap-2">
<div className="flex-1 h-2 rounded-full bg-muted overflow-hidden flex">
{acceptCount > 0 && (
<div className="h-full bg-green-500 transition-all" style={{ width: `${(acceptCount / total) * 100}%` }} />
)}
{dropCount > 0 && (
<div className="h-full bg-red-500 transition-all" style={{ width: `${(dropCount / total) * 100}%` }} />
)}
{rejectCount > 0 && (
<div className="h-full bg-orange-500 transition-all" style={{ width: `${(rejectCount / total) * 100}%` }} />
)}
</div>
<div className="hidden sm:flex items-center gap-3 text-[10px] text-muted-foreground flex-shrink-0">
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-green-500" />Accept</span>
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-red-500" />Drop</span>
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-orange-500" />Reject</span>
</div>
</div>
<div className="flex sm:hidden items-center gap-3 text-[10px] text-muted-foreground">
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-green-500" />Accept</span>
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-red-500" />Drop</span>
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-orange-500" />Reject</span>
</div>
</div>
{/* Scope breakdown */}
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
<span className="flex items-center gap-1.5">
<Globe className="h-3 w-3 text-blue-400" /> Cluster: {clusterCount}
</span>
<span className="flex items-center gap-1.5">
<Shield className="h-3 w-3 text-purple-400" /> Host: {hostCount}
</span>
<span className="text-border">|</span>
<span className="flex items-center gap-1.5">
<ArrowDownLeft className="h-3 w-3" /> IN: {inCount}
</span>
<span className="flex items-center gap-1.5">
<ArrowUpRight className="h-3 w-3" /> OUT: {outCount}
</span>
</div>
</div>
)
})()}
{/* Firewall Rules */} {/* Firewall Rules */}
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -2308,6 +2521,42 @@ ${(report.sections && report.sections.length > 0) ? `
<p className="text-sm font-semibold text-orange-500">New Firewall Rule</p> <p className="text-sm font-semibold text-orange-500">New Firewall Rule</p>
</div> </div>
{/* Service Presets */}
<div className="space-y-1.5">
<p className="text-[10px] text-muted-foreground uppercase tracking-wider">Quick Presets</p>
<div className="flex flex-wrap gap-1.5">
{[
{ label: "HTTP", port: "80", proto: "tcp", comment: "HTTP Web" },
{ label: "HTTPS", port: "443", proto: "tcp", comment: "HTTPS Web" },
{ label: "SSH", port: "22", proto: "tcp", comment: "SSH Remote Access" },
{ label: "DNS", port: "53", proto: "udp", comment: "DNS" },
{ label: "SMTP", port: "25", proto: "tcp", comment: "SMTP Mail" },
{ label: "NFS", port: "2049", proto: "tcp", comment: "NFS" },
{ label: "SMB", port: "445", proto: "tcp", comment: "SMB/CIFS" },
{ label: "Ping", port: "", proto: "icmp", comment: "ICMP Ping" },
].map((preset) => (
<Button
key={preset.label}
type="button"
variant="outline"
size="sm"
onClick={() => setNewRule({
...newRule,
dport: preset.port,
protocol: preset.proto,
comment: preset.comment,
direction: "IN",
action: "ACCEPT",
})}
className="h-6 text-[10px] px-2 text-muted-foreground border-border hover:text-orange-500 hover:border-orange-500/30 bg-transparent"
>
<Zap className="h-2.5 w-2.5 mr-1" />
{preset.label}
</Button>
))}
</div>
</div>
<div className="grid gap-3 sm:grid-cols-3"> <div className="grid gap-3 sm:grid-cols-3">
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label className="text-xs text-muted-foreground">Direction</Label> <Label className="text-xs text-muted-foreground">Direction</Label>
@@ -2372,12 +2621,18 @@ ${(report.sections && report.sections.length > 0) ? `
<div className="grid gap-3 sm:grid-cols-2"> <div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label className="text-xs text-muted-foreground">Interface (optional)</Label> <Label className="text-xs text-muted-foreground">Interface (optional)</Label>
<Input <select
placeholder="e.g. vmbr0"
value={newRule.iface} value={newRule.iface}
onChange={(e) => setNewRule({...newRule, iface: e.target.value})} onChange={(e) => setNewRule({...newRule, iface: e.target.value})}
className="h-9 text-sm" className="w-full h-9 rounded-md border border-border bg-card px-3 text-sm"
/> >
<option value="">Any interface</option>
{networkInterfaces.map((iface) => (
<option key={iface.name} value={iface.name}>
{iface.name} ({iface.type}{iface.status === "up" ? ", up" : ", down"})
</option>
))}
</select>
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label className="text-xs text-muted-foreground">Apply to</Label> <Label className="text-xs text-muted-foreground">Apply to</Label>
@@ -2432,51 +2687,242 @@ ${(report.sections && report.sections.length > 0) ? `
{firewallData.rules.length > 0 ? ( {firewallData.rules.length > 0 ? (
<div className="border border-border rounded-lg overflow-hidden"> <div className="border border-border rounded-lg overflow-hidden">
{/* Table header */} {/* Table header */}
<div className="grid grid-cols-[auto_1fr_auto_auto_auto_auto_auto] gap-2 p-2.5 bg-muted/50 text-[10px] font-semibold text-muted-foreground uppercase tracking-wider"> <div className="hidden sm:grid grid-cols-[2rem_4.5rem_2rem_3rem_5rem_1fr_3.5rem_2rem] gap-2 px-3 py-2 bg-muted/50 text-[10px] font-semibold text-muted-foreground uppercase tracking-wider items-center">
<span className="w-14">Action</span> <span />
<span>Direction</span> <span>Action</span>
<span className="w-12">Proto</span> <span />
<span className="w-20">Port</span> <span>Proto</span>
<span className="w-28 hidden sm:block">Source</span> <span>Port</span>
<span className="w-14">Level</span> <span>Source</span>
<span className="w-8" /> <span>Level</span>
<span />
</div> </div>
<div className="divide-y divide-border max-h-64 overflow-y-auto"> <div className="divide-y divide-border max-h-80 overflow-y-auto">
{firewallData.rules.map((rule, idx) => ( {firewallData.rules.map((rule, idx) => {
<div key={idx} className="grid grid-cols-[auto_1fr_auto_auto_auto_auto_auto] gap-2 p-2.5 items-center hover:bg-muted/20 transition-colors"> const ruleKey = `${rule.source_file}-${rule.rule_index}`
<span className={`w-14 px-1.5 py-0.5 rounded text-[10px] font-bold text-center ${ const isExpanded = expandedRuleKey === ruleKey
rule.action === "ACCEPT" ? "bg-green-500/10 text-green-500" : const direction = rule.direction || "IN"
rule.action === "DROP" ? "bg-red-500/10 text-red-500" : const comment = rule.raw?.includes("#") ? rule.raw.split("#").slice(1).join("#").trim() : ""
rule.action === "REJECT" ? "bg-orange-500/10 text-orange-500" :
"bg-gray-500/10 text-gray-500" return (
}`}> <div key={ruleKey}>
{rule.action || "?"} {/* Main row */}
</span> <div
<span className="text-xs text-muted-foreground font-mono">{rule.direction || "IN"}</span> className="grid grid-cols-[2rem_4.5rem_1fr_2rem] sm:grid-cols-[2rem_4.5rem_2rem_3rem_5rem_1fr_3.5rem_2rem] gap-2 px-3 py-2.5 items-center hover:bg-white/5 transition-colors cursor-pointer"
<span className="w-12 text-xs text-blue-400 font-mono">{rule.p || "-"}</span> onClick={() => setExpandedRuleKey(isExpanded ? null : ruleKey)}
<span className="w-20 text-xs text-foreground font-mono">{rule.dport || "-"}</span> >
<span className="w-28 text-xs text-muted-foreground font-mono hidden sm:block truncate">{rule.source || "any"}</span> {/* Direction icon */}
<span className={`w-14 text-[10px] px-1.5 py-0.5 rounded text-center ${ <div className="flex items-center justify-center">
rule.source_file === "cluster" ? "bg-blue-500/10 text-blue-400" : "bg-purple-500/10 text-purple-400" {direction === "IN" ? (
}`}> <ArrowDownLeft className="h-4 w-4 text-blue-400" />
{rule.source_file} ) : (
</span> <ArrowUpRight className="h-4 w-4 text-amber-400" />
<Button )}
variant="ghost" </div>
size="sm" {/* Action badge */}
onClick={() => handleDeleteRule(rule.rule_index, rule.source_file)} <span className={`px-1.5 py-0.5 rounded text-[10px] font-bold text-center ${
disabled={deletingRuleIdx === rule.rule_index} rule.action === "ACCEPT" ? "bg-green-500/10 text-green-500" :
className="w-8 h-7 p-0 text-red-500/50 hover:text-red-500 hover:bg-red-500/10" rule.action === "DROP" ? "bg-red-500/10 text-red-500" :
> rule.action === "REJECT" ? "bg-orange-500/10 text-orange-500" :
{deletingRuleIdx === rule.rule_index ? ( "bg-gray-500/10 text-gray-500"
<div className="animate-spin h-3 w-3 border-2 border-red-500 border-t-transparent rounded-full" /> }`}>
) : ( {rule.action || "?"}
<Trash2 className="h-3.5 w-3.5" /> </span>
{/* Mobile: combined info on two lines */}
<div className="sm:hidden min-w-0">
<div className="flex items-center gap-1.5">
<span className="text-xs text-blue-400 font-mono flex-shrink-0">{rule.p || "*"}</span>
<span className="text-xs text-muted-foreground flex-shrink-0">:</span>
<span className="text-xs text-foreground font-mono font-medium">{rule.dport || "*"}</span>
<span className={`text-[10px] px-1 py-0 rounded flex-shrink-0 ${
rule.source_file === "cluster" ? "bg-blue-500/10 text-blue-400" : "bg-purple-500/10 text-purple-400"
}`}>{rule.source_file}</span>
</div>
{comment && (
<p className="text-[10px] text-muted-foreground truncate mt-0.5">{comment}</p>
)}
</div>
{/* Desktop: direction label */}
<span className="hidden sm:block text-xs text-muted-foreground font-mono">{direction}</span>
{/* Protocol */}
<span className="hidden sm:block text-xs text-blue-400 font-mono">{rule.p || "*"}</span>
{/* Port */}
<span className="hidden sm:block text-xs text-foreground font-mono font-medium">{rule.dport || "*"}</span>
{/* Source */}
<span className="hidden sm:block text-xs text-muted-foreground font-mono truncate">{rule.source || "any"}</span>
{/* Level badge */}
<span className={`hidden sm:block text-[10px] px-1.5 py-0.5 rounded text-center ${
rule.source_file === "cluster" ? "bg-blue-500/10 text-blue-400" : "bg-purple-500/10 text-purple-400"
}`}>
{rule.source_file}
</span>
{/* Expand/Delete */}
<div className="flex items-center justify-end">
<ChevronRight className={`h-3.5 w-3.5 text-muted-foreground transition-transform ${isExpanded ? "rotate-90" : ""}`} />
</div>
</div>
{/* Expanded details */}
{isExpanded && (
<div className="px-3 pb-3 pt-0 border-t border-border/50 bg-muted/10">
{editingRuleKey === ruleKey ? (
/* ── Inline Edit Form ── */
<div className="py-3 space-y-3">
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
<div>
<Label className="text-[10px] text-muted-foreground uppercase">Direction</Label>
<select value={editRule.direction} onChange={(e) => setEditRule({ ...editRule, direction: e.target.value })}
className="w-full h-8 text-xs rounded-md border border-border bg-background px-2 mt-0.5">
<option value="IN">IN</option>
<option value="OUT">OUT</option>
</select>
</div>
<div>
<Label className="text-[10px] text-muted-foreground uppercase">Action</Label>
<select value={editRule.action} onChange={(e) => setEditRule({ ...editRule, action: e.target.value })}
className="w-full h-8 text-xs rounded-md border border-border bg-background px-2 mt-0.5">
<option value="ACCEPT">ACCEPT</option>
<option value="DROP">DROP</option>
<option value="REJECT">REJECT</option>
</select>
</div>
<div>
<Label className="text-[10px] text-muted-foreground uppercase">Protocol</Label>
<select value={editRule.protocol} onChange={(e) => setEditRule({ ...editRule, protocol: e.target.value })}
className="w-full h-8 text-xs rounded-md border border-border bg-background px-2 mt-0.5">
<option value="tcp">TCP</option>
<option value="udp">UDP</option>
<option value="icmp">ICMP</option>
</select>
</div>
<div>
<Label className="text-[10px] text-muted-foreground uppercase">Port</Label>
<Input value={editRule.dport} onChange={(e) => setEditRule({ ...editRule, dport: e.target.value })}
placeholder="e.g. 80,443" className="h-8 text-xs mt-0.5" />
</div>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
<div>
<Label className="text-[10px] text-muted-foreground uppercase">Source</Label>
<Input value={editRule.source} onChange={(e) => setEditRule({ ...editRule, source: e.target.value })}
placeholder="IP or CIDR" className="h-8 text-xs mt-0.5" />
</div>
<div>
<Label className="text-[10px] text-muted-foreground uppercase">Interface</Label>
<select value={editRule.iface} onChange={(e) => setEditRule({ ...editRule, iface: e.target.value })}
className="w-full h-8 text-xs rounded-md border border-border bg-background px-2 mt-0.5">
<option value="">Any</option>
{networkInterfaces.map((iface) => (
<option key={iface.name} value={iface.name}>
{iface.name} ({iface.type})
</option>
))}
</select>
</div>
<div className="col-span-2 sm:col-span-1">
<Label className="text-[10px] text-muted-foreground uppercase">Comment</Label>
<Input value={editRule.comment} onChange={(e) => setEditRule({ ...editRule, comment: e.target.value })}
placeholder="Description" className="h-8 text-xs mt-0.5" />
</div>
</div>
<div className="flex items-center justify-end gap-2 pt-1">
<Button variant="ghost" size="sm"
onClick={(e) => { e.stopPropagation(); setEditingRuleKey(null) }}
className="h-7 text-xs text-muted-foreground">
<X className="h-3 w-3 mr-1" /> Cancel
</Button>
<Button variant="outline" size="sm"
onClick={(e) => { e.stopPropagation(); handleSaveEditRule(rule.rule_index, rule.source_file || "host") }}
disabled={savingRule}
className="h-7 text-xs text-green-500 border-green-500/30 hover:bg-green-500/10 bg-transparent">
{savingRule ? (
<div className="animate-spin h-3 w-3 border-2 border-green-500 border-t-transparent rounded-full mr-1" />
) : (
<Check className="h-3 w-3 mr-1" />
)}
Save Changes
</Button>
</div>
</div>
) : (
/* ── Read-only Details ── */
<>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 py-3">
<div>
<p className="text-[10px] text-muted-foreground uppercase tracking-wider mb-0.5">Direction</p>
<p className="text-xs font-medium flex items-center gap-1">
{direction === "IN" ? <ArrowDownLeft className="h-3 w-3 text-blue-400" /> : <ArrowUpRight className="h-3 w-3 text-amber-400" />}
{direction === "IN" ? "Incoming" : "Outgoing"}
</p>
</div>
<div>
<p className="text-[10px] text-muted-foreground uppercase tracking-wider mb-0.5">Protocol</p>
<p className="text-xs font-medium font-mono">{rule.p || "any"}</p>
</div>
<div>
<p className="text-[10px] text-muted-foreground uppercase tracking-wider mb-0.5">Port</p>
<p className="text-xs font-medium font-mono">{rule.dport || "any"}</p>
</div>
<div>
<p className="text-[10px] text-muted-foreground uppercase tracking-wider mb-0.5">Source</p>
<p className="text-xs font-medium font-mono">{rule.source || "any"}</p>
</div>
{rule.i && (
<div>
<p className="text-[10px] text-muted-foreground uppercase tracking-wider mb-0.5">Interface</p>
<p className="text-xs font-medium font-mono">{rule.i}</p>
</div>
)}
<div>
<p className="text-[10px] text-muted-foreground uppercase tracking-wider mb-0.5">Scope</p>
<p className="text-xs font-medium flex items-center gap-1">
{rule.source_file === "cluster" ? <Globe className="h-3 w-3 text-blue-400" /> : <Shield className="h-3 w-3 text-purple-400" />}
{rule.source_file === "cluster" ? "Cluster" : "Host"}
</p>
</div>
{comment && (
<div className="col-span-2">
<p className="text-[10px] text-muted-foreground uppercase tracking-wider mb-0.5">Comment</p>
<p className="text-xs text-muted-foreground">{comment}</p>
</div>
)}
</div>
<div className="flex items-center justify-between pt-2 border-t border-border/50">
<code className="text-[10px] text-muted-foreground/60 font-mono truncate max-w-[50%]">{rule.raw}</code>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={(e) => { e.stopPropagation(); startEditRule(rule) }}
className="h-7 text-xs text-blue-400 border-blue-400/30 hover:bg-blue-400/10 bg-transparent"
>
<Pencil className="h-3 w-3 mr-1" />
Edit
</Button>
<Button
variant="outline"
size="sm"
onClick={(e) => { e.stopPropagation(); handleDeleteRule(rule.rule_index, rule.source_file) }}
disabled={deletingRuleIdx === rule.rule_index}
className="h-7 text-xs text-red-500 border-red-500/30 hover:bg-red-500/10 bg-transparent"
>
{deletingRuleIdx === rule.rule_index ? (
<div className="animate-spin h-3 w-3 border-2 border-red-500 border-t-transparent rounded-full mr-1" />
) : (
<Trash2 className="h-3 w-3 mr-1" />
)}
Delete
</Button>
</div>
</div>
</>
)}
</div>
)} )}
</Button> </div>
</div> )
))} })}
</div> </div>
</div> </div>
) : ( ) : (

View File

@@ -158,21 +158,15 @@ export function SystemLogs() {
const fetchSystemLogs = async (): Promise<SystemLog[]> => { const fetchSystemLogs = async (): Promise<SystemLog[]> => {
try { try {
let apiUrl = "/api/logs"
const params = new URLSearchParams()
const daysAgo = dateFilter === "custom" ? Number.parseInt(customDays) : Number.parseInt(dateFilter) const daysAgo = dateFilter === "custom" ? Number.parseInt(customDays) : Number.parseInt(dateFilter)
// Clamp days to valid range
const clampedDays = Math.max(1, Math.min(daysAgo || 1, 90)) const clampedDays = Math.max(1, Math.min(daysAgo || 1, 90))
params.append("since_days", clampedDays.toString()) // Only send since_days - no limit param, so the backend returns ALL logs for the period
params.append("limit", "5000") const apiUrl = `/api/logs?since_days=${clampedDays}`
if (params.toString()) {
apiUrl += `?${params.toString()}`
}
console.log(`[v0] Fetching logs for ${clampedDays} days...`)
const data = await fetchApi(apiUrl) const data = await fetchApi(apiUrl)
const logsArray = Array.isArray(data) ? data : data.logs || [] const logsArray = Array.isArray(data) ? data : data.logs || []
console.log(`[v0] Logs: parsed=${logsArray.length}, journal_total=${data.journal_total || 'N/A'}, skipped=${data.skipped || 0} for ${clampedDays} day(s)`)
return logsArray return logsArray
} catch { } catch {
setError("Failed to load logs. Please try again.") setError("Failed to load logs. Please try again.")
@@ -550,8 +544,13 @@ export function SystemLogs() {
if (loading && logs.length === 0 && events.length === 0) { if (loading && logs.length === 0 && events.length === 0) {
return ( return (
<div className="flex items-center justify-center h-64"> <div className="flex flex-col items-center justify-center min-h-[400px] gap-4">
<RefreshCw className="h-8 w-8 animate-spin text-muted-foreground" /> <div className="relative">
<div className="h-12 w-12 rounded-full border-2 border-muted"></div>
<div className="absolute inset-0 h-12 w-12 rounded-full border-2 border-transparent border-t-primary animate-spin"></div>
</div>
<div className="text-sm font-medium text-foreground">Loading logs...</div>
<p className="text-xs text-muted-foreground">Fetching system logs and events</p>
</div> </div>
) )
} }
@@ -559,11 +558,13 @@ export function SystemLogs() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{loading && (logs.length > 0 || events.length > 0) && ( {loading && (logs.length > 0 || events.length > 0) && (
<div className="fixed inset-0 bg-background/80 backdrop-blur-sm z-50 flex items-center justify-center"> <div className="fixed inset-0 bg-background/60 backdrop-blur-sm z-50 flex items-center justify-center">
<div className="flex flex-col items-center gap-4 p-8 rounded-lg bg-card border border-border shadow-lg"> <div className="flex flex-col items-center gap-3 p-6 rounded-xl bg-card border border-border shadow-xl">
<RefreshCw className="h-12 w-12 animate-spin text-primary" /> <div className="relative">
<div className="text-lg font-medium text-foreground">Loading logs selected...</div> <div className="h-10 w-10 rounded-full border-2 border-muted"></div>
<div className="text-sm text-muted-foreground">Please wait while we fetch the logs</div> <div className="absolute inset-0 h-10 w-10 rounded-full border-2 border-transparent border-t-primary animate-spin"></div>
</div>
<div className="text-sm font-medium text-foreground">Loading logs...</div>
</div> </div>
</div> </div>
)} )}

View File

@@ -7,10 +7,17 @@ import { Badge } from "./ui/badge"
import { Cpu, MemoryStick, Thermometer, Server, Zap, AlertCircle, HardDrive, Network } from "lucide-react" import { Cpu, MemoryStick, Thermometer, Server, Zap, AlertCircle, HardDrive, Network } from "lucide-react"
import { NodeMetricsCharts } from "./node-metrics-charts" import { NodeMetricsCharts } from "./node-metrics-charts"
import { NetworkTrafficChart } from "./network-traffic-chart" import { NetworkTrafficChart } from "./network-traffic-chart"
import { TemperatureDetailModal } from "./temperature-detail-modal"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
import { fetchApi } from "../lib/api-config" import { fetchApi } from "../lib/api-config"
import { formatNetworkTraffic, getNetworkUnit } from "../lib/format-network" import { formatNetworkTraffic, getNetworkUnit } from "../lib/format-network"
import { formatStorage } from "../lib/utils" import { formatStorage } from "../lib/utils"
import { Area, AreaChart, ResponsiveContainer } from "recharts"
interface TempDataPoint {
timestamp: number
value: number
}
interface SystemData { interface SystemData {
cpu_usage: number cpu_usage: number
@@ -18,6 +25,7 @@ interface SystemData {
memory_total: number memory_total: number
memory_used: number memory_used: number
temperature: number temperature: number
temperature_sparkline?: TempDataPoint[]
uptime: string uptime: string
load_average: number[] load_average: number[]
hostname: string hostname: string
@@ -178,6 +186,7 @@ export function SystemOverview() {
const [networkTimeframe, setNetworkTimeframe] = useState("day") const [networkTimeframe, setNetworkTimeframe] = useState("day")
const [networkTotals, setNetworkTotals] = useState<{ received: number; sent: number }>({ received: 0, sent: 0 }) const [networkTotals, setNetworkTotals] = useState<{ received: number; sent: number }>({ received: 0, sent: 0 })
const [networkUnit, setNetworkUnit] = useState<"Bytes" | "Bits">("Bytes") // Added networkUnit state const [networkUnit, setNetworkUnit] = useState<"Bytes" | "Bits">("Bytes") // Added networkUnit state
const [tempModalOpen, setTempModalOpen] = useState(false)
useEffect(() => { useEffect(() => {
const fetchAllData = async () => { const fetchAllData = async () => {
@@ -458,27 +467,60 @@ export function SystemOverview() {
</CardContent> </CardContent>
</Card> </Card>
<Card className="bg-card border-border"> <Card
className={`bg-card border-border ${systemData.temperature > 0 ? "cursor-pointer hover:bg-white/5 transition-colors" : ""}`}
onClick={() => systemData.temperature > 0 && setTempModalOpen(true)}
>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Temperature</CardTitle> <CardTitle className="text-sm font-medium text-muted-foreground">Temperature</CardTitle>
<Thermometer className="h-4 w-4 text-muted-foreground" /> <Thermometer className="h-4 w-4 text-muted-foreground" />
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-xl lg:text-2xl font-bold text-foreground"> <div className="flex items-center justify-between">
{systemData.temperature === 0 ? "N/A" : `${systemData.temperature}°C`} <span className="text-xl lg:text-2xl font-bold text-foreground">
</div> {systemData.temperature === 0 ? "N/A" : `${Math.round(systemData.temperature * 10) / 10}°C`}
<div className="flex items-center mt-2"> </span>
<Badge variant="outline" className={tempStatus.color}> <Badge variant="outline" className={tempStatus.color}>
{tempStatus.status} {tempStatus.status}
</Badge> </Badge>
</div> </div>
<p className="text-xs text-muted-foreground mt-2"> {systemData.temperature > 0 && systemData.temperature_sparkline && systemData.temperature_sparkline.length > 1 ? (
{systemData.temperature === 0 ? "No sensor available" : "Live temperature reading"} <div className="mt-2 h-10">
</p> <ResponsiveContainer width="100%" height="100%">
<AreaChart data={systemData.temperature_sparkline} margin={{ top: 0, right: 0, left: 0, bottom: 0 }}>
<defs>
<linearGradient id="tempSparkGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={systemData.temperature >= 75 ? "#ef4444" : systemData.temperature >= 60 ? "#f59e0b" : "#22c55e"} stopOpacity={0.3} />
<stop offset="100%" stopColor={systemData.temperature >= 75 ? "#ef4444" : systemData.temperature >= 60 ? "#f59e0b" : "#22c55e"} stopOpacity={0} />
</linearGradient>
</defs>
<Area
type="monotone"
dataKey="value"
stroke={systemData.temperature >= 75 ? "#ef4444" : systemData.temperature >= 60 ? "#f59e0b" : "#22c55e"}
strokeWidth={1.5}
fill="url(#tempSparkGradient)"
dot={false}
isAnimationActive={false}
/>
</AreaChart>
</ResponsiveContainer>
</div>
) : (
<p className="text-xs text-muted-foreground mt-2">
{systemData.temperature === 0 ? "No sensor available" : "Collecting data..."}
</p>
)}
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
<TemperatureDetailModal
open={tempModalOpen}
onOpenChange={setTempModalOpen}
liveTemperature={systemData.temperature}
/>
<NodeMetricsCharts /> <NodeMetricsCharts />
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">

View File

@@ -4,6 +4,10 @@ Provides REST API endpoints for authentication management
""" """
import logging import logging
import os
import subprocess
import threading
import time
from flask import Blueprint, jsonify, request from flask import Blueprint, jsonify, request
import auth_manager import auth_manager
import jwt import jwt
@@ -73,12 +77,36 @@ def ssl_status():
return jsonify({"success": False, "message": str(e)}), 500 return jsonify({"success": False, "message": str(e)}), 500
def _schedule_service_restart(delay=1.5):
"""Schedule a restart of the monitor service via systemctl after a short delay.
This gives time for the HTTP response to reach the client before the process restarts."""
def _do_restart():
time.sleep(delay)
print("[ProxMenux] Restarting monitor service to apply SSL changes...")
# Use systemctl restart which properly stops and starts the service.
# This works because systemd manages proxmenux-monitor.service.
try:
subprocess.Popen(
["systemctl", "restart", "proxmenux-monitor"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL
)
except Exception as e:
print(f"[ProxMenux] Failed to restart via systemctl: {e}")
# Fallback: try to restart the process directly
os.kill(os.getpid(), 15) # SIGTERM
t = threading.Thread(target=_do_restart, daemon=True)
t.start()
@auth_bp.route('/api/ssl/configure', methods=['POST']) @auth_bp.route('/api/ssl/configure', methods=['POST'])
def ssl_configure(): def ssl_configure():
"""Configure SSL with Proxmox or custom certificates""" """Configure SSL with Proxmox or custom certificates"""
try: try:
data = request.json or {} data = request.json or {}
source = data.get("source", "proxmox") source = data.get("source", "proxmox")
auto_restart = data.get("auto_restart", True)
if source == "proxmox": if source == "proxmox":
cert_path = auth_manager.PROXMOX_CERT_PATH cert_path = auth_manager.PROXMOX_CERT_PATH
@@ -92,7 +120,14 @@ def ssl_configure():
success, message = auth_manager.configure_ssl(cert_path, key_path, source) success, message = auth_manager.configure_ssl(cert_path, key_path, source)
if success: if success:
return jsonify({"success": True, "message": message, "requires_restart": True}) if auto_restart:
_schedule_service_restart()
return jsonify({
"success": True,
"message": "SSL enabled. The service is restarting...",
"restarting": auto_restart,
"new_protocol": "https"
})
else: else:
return jsonify({"success": False, "message": message}), 400 return jsonify({"success": False, "message": message}), 400
except Exception as e: except Exception as e:
@@ -103,10 +138,20 @@ def ssl_configure():
def ssl_disable(): def ssl_disable():
"""Disable SSL and return to HTTP""" """Disable SSL and return to HTTP"""
try: try:
data = request.json or {}
auto_restart = data.get("auto_restart", True)
success, message = auth_manager.disable_ssl() success, message = auth_manager.disable_ssl()
if success: if success:
return jsonify({"success": True, "message": message, "requires_restart": True}) if auto_restart:
_schedule_service_restart()
return jsonify({
"success": True,
"message": "SSL disabled. The service is restarting...",
"restarting": auto_restart,
"new_protocol": "http"
})
else: else:
return jsonify({"success": False, "message": message}), 400 return jsonify({"success": False, "message": message}), 400
except Exception as e: except Exception as e:

View File

@@ -106,6 +106,39 @@ def firewall_delete_rule():
return jsonify({"success": False, "message": str(e)}), 500 return jsonify({"success": False, "message": str(e)}), 500
@security_bp.route('/api/security/firewall/rules/edit', methods=['PUT'])
def firewall_edit_rule():
"""Edit an existing firewall rule (delete old + insert new at same position)"""
if not security_manager:
return jsonify({"success": False, "message": "Security manager not available"}), 500
try:
data = request.json or {}
rule_index = data.get("rule_index")
level = data.get("level", "host")
new_rule = data.get("new_rule", {})
if rule_index is None:
return jsonify({"success": False, "message": "rule_index is required"}), 400
success, message = security_manager.edit_firewall_rule(
rule_index=int(rule_index),
level=level,
direction=new_rule.get("direction", "IN"),
action=new_rule.get("action", "ACCEPT"),
protocol=new_rule.get("protocol", "tcp"),
dport=new_rule.get("dport", ""),
sport=new_rule.get("sport", ""),
source=new_rule.get("source", ""),
iface=new_rule.get("iface", ""),
comment=new_rule.get("comment", ""),
)
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/firewall/monitor-port', methods=['POST']) @security_bp.route('/api/security/firewall/monitor-port', methods=['POST'])
def firewall_add_monitor_port(): def firewall_add_monitor_port():
"""Add firewall rule to allow port 8008 for ProxMenux Monitor""" """Add firewall rule to allow port 8008 for ProxMenux Monitor"""

View File

@@ -274,6 +274,96 @@ def add_firewall_rule(direction="IN", action="ACCEPT", protocol="tcp", dport="",
return False, f"Failed to add firewall rule: {str(e)}" return False, f"Failed to add firewall rule: {str(e)}"
def edit_firewall_rule(rule_index, level="host", direction="IN", action="ACCEPT",
protocol="tcp", dport="", sport="", source="", iface="", comment=""):
"""
Edit an existing firewall rule by replacing it in-place.
Deletes the old rule at rule_index and inserts the new one at the same position.
Returns (success, message)
"""
# Validate inputs
action = action.upper()
if action not in ("ACCEPT", "DROP", "REJECT"):
return False, f"Invalid action: {action}. Must be ACCEPT, DROP, or REJECT"
direction = direction.upper()
if direction not in ("IN", "OUT"):
return False, f"Invalid direction: {direction}. Must be IN or OUT"
# Build new rule line
parts = [direction, action]
if protocol:
parts.extend(["-p", protocol.lower()])
if dport:
if not re.match(r'^[\d:,]+$', dport):
return False, f"Invalid destination port: {dport}"
parts.extend(["-dport", dport])
if sport:
if not re.match(r'^[\d:,]+$', sport):
return False, f"Invalid source port: {sport}"
parts.extend(["-sport", sport])
if source:
parts.extend(["-source", source])
if iface:
parts.extend(["-i", iface])
parts.extend(["-log", "nolog"])
if comment:
safe_comment = re.sub(r'[^\w\s\-._/():]', '', comment)
parts.append(f"# {safe_comment}")
new_rule_line = " ".join(parts)
# Determine target file
if level == "cluster":
fw_file = CLUSTER_FW
else:
fw_file = os.path.join(HOST_FW_DIR, "host.fw")
if not os.path.isfile(fw_file):
return False, "Firewall config file not found"
try:
with open(fw_file, 'r') as f:
content = f.read()
lines = content.splitlines()
new_lines = []
in_rules = False
current_rule_idx = 0
replaced = False
for line in lines:
stripped = line.strip()
if stripped.startswith('['):
section_match = re.match(r'\[(\w+)\]', stripped)
if section_match:
section = section_match.group(1).upper()
in_rules = section in ("RULES", "IN", "OUT")
if in_rules and stripped and not stripped.startswith('#') and not stripped.startswith('['):
if current_rule_idx == rule_index:
# Replace the old rule with the new one
new_lines.append(new_rule_line)
replaced = True
current_rule_idx += 1
continue
current_rule_idx += 1
new_lines.append(line)
if not replaced:
return False, f"Rule index {rule_index} not found"
with open(fw_file, 'w') as f:
f.write("\n".join(new_lines) + "\n")
_run_cmd(["pve-firewall", "reload"])
return True, f"Firewall rule updated: {direction} {action} {protocol}{':' + dport if dport else ''}"
except PermissionError:
return False, "Permission denied. Cannot modify firewall config."
except Exception as e:
return False, f"Failed to edit rule: {str(e)}"
def delete_firewall_rule(rule_index, level="host"): def delete_firewall_rule(rule_index, level="host"):
""" """
Delete a firewall rule by index from host or cluster config. Delete a firewall rule by index from host or cluster config.

View File

@@ -238,8 +238,12 @@ Seal=no
Compress=yes Compress=yes
SystemMaxUse=64M SystemMaxUse=64M
RuntimeMaxUse=60M RuntimeMaxUse=60M
MaxLevelStore=warning # MaxLevelStore=info allows ProxMenux Monitor to display system logs correctly.
MaxLevelSyslog=warning # Using "warning" causes the log viewer to show nearly identical entries across
# all date ranges (1d/3d/7d) because most activity is info-level.
# It also prevents Fail2Ban from detecting SSH/Proxmox auth failures via journal.
MaxLevelStore=info
MaxLevelSyslog=info
MaxLevelKMsg=warning MaxLevelKMsg=warning
MaxLevelConsole=notice MaxLevelConsole=notice
MaxLevelWall=crit MaxLevelWall=crit
@@ -787,8 +791,10 @@ Compress=yes
SystemMaxUse=${USE_MB}M SystemMaxUse=${USE_MB}M
SystemKeepFree=${KEEP_MB}M SystemKeepFree=${KEEP_MB}M
RuntimeMaxUse=${RUNTIME_MB}M RuntimeMaxUse=${RUNTIME_MB}M
MaxLevelStore=warning # MaxLevelStore=info: required for ProxMenux Monitor log display and Fail2Ban detection.
MaxLevelSyslog=warning # Using "warning" silently discards most system logs making date filters useless.
MaxLevelStore=info
MaxLevelSyslog=info
MaxLevelKMsg=warning MaxLevelKMsg=warning
MaxLevelConsole=notice MaxLevelConsole=notice
MaxLevelWall=crit MaxLevelWall=crit