feat(fail2ban): manage trusted IP addresses and networks

This commit is contained in:
Codex
2026-08-06 16:57:38 +02:00
parent 7d781d570c
commit 3a7e450b55
6 changed files with 738 additions and 23 deletions
+232 -23
View File
@@ -251,6 +251,15 @@ export function Security() {
}) })
const [f2bSavingConfig, setF2bSavingConfig] = useState(false) const [f2bSavingConfig, setF2bSavingConfig] = useState(false)
const [f2bApplyingJails, setF2bApplyingJails] = useState(false) const [f2bApplyingJails, setF2bApplyingJails] = useState(false)
const [f2bTrustedNetworks, setF2bTrustedNetworks] = useState<Array<{value: string; protected: boolean}>>([])
const [f2bDetectedIp, setF2bDetectedIp] = useState("")
const [f2bTrustedInput, setF2bTrustedInput] = useState("")
const [f2bSavingTrusted, setF2bSavingTrusted] = useState(false)
const [f2bRemovingTrusted, setF2bRemovingTrusted] = useState<string | null>(null)
const [f2bShowTrustedForm, setF2bShowTrustedForm] = useState(false)
const [f2bEditingTrusted, setF2bEditingTrusted] = useState<string | null>(null)
const [f2bTrustedEditInput, setF2bTrustedEditInput] = useState("")
const [f2bTrustedNotice, setF2bTrustedNotice] = useState<{type: "success" | "error"; text: string} | null>(null)
// SSL/HTTPS state // SSL/HTTPS state
const [sslEnabled, setSslEnabled] = useState(false) const [sslEnabled, setSslEnabled] = useState(false)
@@ -380,9 +389,10 @@ export function Security() {
const loadFail2banDetails = async () => { const loadFail2banDetails = async () => {
try { try {
setF2bDetailsLoading(true) setF2bDetailsLoading(true)
const [detailsRes, activityRes] = await Promise.all([ const [detailsRes, activityRes, trustedRes] = await Promise.all([
fetchApi("/api/security/fail2ban/details"), fetchApi("/api/security/fail2ban/details"),
fetchApi("/api/security/fail2ban/activity"), fetchApi("/api/security/fail2ban/activity"),
fetchApi("/api/security/fail2ban/trusted-networks"),
]) ])
if (detailsRes.success) { if (detailsRes.success) {
setF2bDetails({ setF2bDetails({
@@ -395,6 +405,10 @@ export function Security() {
if (activityRes.success) { if (activityRes.success) {
setF2bActivity(activityRes.events || []) setF2bActivity(activityRes.events || [])
} }
if (trustedRes.success) {
setF2bTrustedNetworks(trustedRes.entries || [])
setF2bDetectedIp(trustedRes.detected_ip || "")
}
} catch { } catch {
// Silently fail // Silently fail
} finally { } finally {
@@ -402,6 +416,86 @@ export function Security() {
} }
} }
const trustedNetworkError = (message?: string) => {
if (message?.includes("already trusted")) return st("errors.trustedNetworkExists")
if (message?.includes("Invalid IP") || message?.includes("Enter one IP")) return st("errors.invalidTrustedNetwork")
return st("errors.updateTrustedNetworksFailed")
}
const handleAddTrustedNetwork = async (value?: string) => {
const candidate = (value || f2bTrustedInput).trim()
if (!candidate) {
setF2bTrustedNotice({ type: "error", text: st("errors.invalidTrustedNetwork") })
return
}
setF2bSavingTrusted(true)
setF2bTrustedNotice(null)
try {
const data = await fetchApi("/api/security/fail2ban/trusted-networks", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ value: candidate }),
})
if (data.success) {
setF2bTrustedNotice({ type: "success", text: st("messages.trustedNetworkAdded", { value: data.value || candidate }) })
setF2bTrustedInput("")
setF2bShowTrustedForm(false)
await loadFail2banDetails()
} else {
setF2bTrustedNotice({ type: "error", text: trustedNetworkError(data.message) })
}
} catch (err) {
setF2bTrustedNotice({ type: "error", text: trustedNetworkError(err instanceof Error ? err.message : undefined) })
} finally {
setF2bSavingTrusted(false)
}
}
const handleRemoveTrustedNetwork = async (value: string) => {
setF2bRemovingTrusted(value)
setF2bTrustedNotice(null)
try {
const data = await fetchApi("/api/security/fail2ban/trusted-networks", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ value }),
})
if (data.success) {
setF2bTrustedNotice({ type: "success", text: st("messages.trustedNetworkRemoved", { value }) })
await loadFail2banDetails()
} else {
setF2bTrustedNotice({ type: "error", text: st("errors.updateTrustedNetworksFailed") })
}
} catch {
setF2bTrustedNotice({ type: "error", text: st("errors.updateTrustedNetworksFailed") })
} finally {
setF2bRemovingTrusted(null)
}
}
const handleUpdateTrustedNetwork = async () => {
if (!f2bEditingTrusted || !f2bTrustedEditInput.trim()) return
setF2bSavingTrusted(true)
setF2bTrustedNotice(null)
try {
const data = await fetchApi("/api/security/fail2ban/trusted-networks", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ old_value: f2bEditingTrusted, new_value: f2bTrustedEditInput.trim() }),
})
if (data.success) {
setF2bTrustedNotice({ type: "success", text: st("messages.trustedNetworkUpdated", { value: data.value }) })
setF2bEditingTrusted(null)
setF2bTrustedEditInput("")
await loadFail2banDetails()
}
} catch (err) {
setF2bTrustedNotice({ type: "error", text: trustedNetworkError(err instanceof Error ? err.message : undefined) })
} finally {
setF2bSavingTrusted(false)
}
}
const handleUnbanIp = async (jail: string, ip: string) => { const handleUnbanIp = async (jail: string, ip: string) => {
const key = `${jail}:${ip}` const key = `${jail}:${ip}`
setF2bUnbanning(key) setF2bUnbanning(key)
@@ -3350,32 +3444,147 @@ ${(report.sections && report.sections.length > 0) ? `
{fail2banInfo.active && f2bDetails && ( {fail2banInfo.active && f2bDetails && (
<> <>
{/* Summary stats - inline */} {/* Global Fail2Ban allowlist */}
<div className="flex items-center gap-4 flex-wrap px-3 py-2.5 bg-muted/30 rounded-lg border border-border"> <div className="rounded-lg border border-green-500/15 overflow-hidden shadow-sm">
<div className="flex items-center gap-1.5 text-sm"> <div className="flex items-start justify-between gap-3 p-3 bg-gradient-to-r from-green-500/[0.07] via-green-500/[0.03] to-transparent border-l-2 border-green-500/60">
<span className="text-muted-foreground">{st("fail2ban.jails")}:</span> <div className="flex items-start gap-2.5 min-w-0">
<span className="font-bold">{f2bDetails.jails.length}</span> <div className="w-8 h-8 rounded-md bg-green-500/10 flex items-center justify-center shrink-0">
<Network className="h-4 w-4 text-green-500" />
</div>
<div className="min-w-0">
<p className="text-sm font-semibold">{st("fail2ban.trustedNetworks.title")}</p>
<p className="text-xs text-muted-foreground mt-0.5">{st("fail2ban.trustedNetworks.description")}</p>
</div>
</div>
<Button
variant="outline"
size="sm"
onClick={() => { setF2bShowTrustedForm(!f2bShowTrustedForm); setF2bTrustedNotice(null) }}
className="h-7 text-xs text-orange-500 border-orange-500/30 hover:bg-orange-500/10 bg-transparent shrink-0"
>
<Plus className="h-3 w-3 mr-1" />
{st("fail2ban.trustedNetworks.addRule")}
</Button>
</div> </div>
<div className="w-px h-4 bg-border" />
<div className="flex items-center gap-1.5 text-sm"> {f2bTrustedNotice && (
<span className="text-muted-foreground">{st("fail2ban.bannedIps")}:</span> <div className={`mx-3 mt-3 rounded-md border px-3 py-2 text-xs ${f2bTrustedNotice.type === "success" ? "border-green-500/20 bg-green-500/10 text-green-500" : "border-red-500/20 bg-red-500/10 text-red-500"}`}>
<span className={`font-bold ${f2bDetails.jails.reduce((a, j) => a + j.currently_banned, 0) > 0 ? "text-red-500" : "text-green-500"}`}> {f2bTrustedNotice.text}
</div>
)}
{f2bShowTrustedForm && (
<div className="m-3 rounded-lg border border-border bg-muted/20 p-3 space-y-3">
<div>
<Label className="text-[10px] text-muted-foreground uppercase">{st("fail2ban.trustedNetworks.addressOrNetwork")}</Label>
<Input
value={f2bTrustedInput}
onChange={(event) => setF2bTrustedInput(event.target.value)}
onKeyDown={(event) => { if (event.key === "Enter") { event.preventDefault(); handleAddTrustedNetwork() } }}
placeholder={st("fail2ban.trustedNetworks.placeholder")}
className="h-8 font-mono text-xs mt-1"
/>
<p className="mt-1.5 text-[10px] text-muted-foreground">{st("fail2ban.trustedNetworks.example")}</p>
</div>
<div className="flex flex-wrap items-center justify-between gap-2">
{f2bDetectedIp && !f2bTrustedNetworks.some((entry) => entry.value === f2bDetectedIp) ? (
<Button variant="ghost" size="sm" onClick={() => setF2bTrustedInput(f2bDetectedIp)} className="h-7 text-xs text-blue-400">
{st("fail2ban.trustedNetworks.useCurrent", { ip: f2bDetectedIp })}
</Button>
) : <span />}
<div className="flex gap-2 ml-auto">
<Button variant="ghost" size="sm" onClick={() => { setF2bShowTrustedForm(false); setF2bTrustedInput("") }} className="h-7 text-xs text-muted-foreground">
{t("actions.cancel")}
</Button>
<Button size="sm" onClick={() => handleAddTrustedNetwork()} disabled={f2bSavingTrusted || !f2bTrustedInput.trim()} className="h-7 text-xs bg-orange-600 hover:bg-orange-700 text-white">
{f2bSavingTrusted ? <span className="h-3 w-3 animate-spin rounded-full border-2 border-white border-t-transparent mr-1" /> : <Plus className="h-3 w-3 mr-1" />}
{st("fail2ban.trustedNetworks.add")}
</Button>
</div>
</div>
</div>
)}
<div className="hidden sm:grid grid-cols-[1.1fr_0.65fr_2fr_1.5fr] gap-3 border-t border-border bg-muted/20 px-3 py-2 text-[9px] font-medium uppercase tracking-wider text-muted-foreground">
<span>{st("fail2ban.trustedNetworks.columns.status")}</span>
<span>{st("fail2ban.trustedNetworks.columns.type")}</span>
<span>{st("fail2ban.trustedNetworks.columns.address")}</span>
<span className="text-right">{st("fail2ban.trustedNetworks.columns.actions")}</span>
</div>
<div className="divide-y divide-border border-t border-border sm:border-t-0">
{f2bTrustedNetworks.map((entry) => (
<div key={entry.value} className="px-3 py-2.5 transition-colors hover:bg-muted/20">
{f2bEditingTrusted === entry.value ? (
<div className="flex flex-col sm:flex-row gap-2 items-end">
<div className="flex-1 w-full">
<Label className="text-[10px] text-muted-foreground uppercase">{st("fail2ban.trustedNetworks.addressOrNetwork")}</Label>
<Input value={f2bTrustedEditInput} onChange={(event) => setF2bTrustedEditInput(event.target.value)} className="h-8 font-mono text-xs mt-1" />
</div>
<Button variant="ghost" size="sm" onClick={() => setF2bEditingTrusted(null)} className="h-7 text-xs text-muted-foreground">{t("actions.cancel")}</Button>
<Button variant="outline" size="sm" onClick={handleUpdateTrustedNetwork} disabled={f2bSavingTrusted || !f2bTrustedEditInput.trim()} className="h-7 text-xs text-green-500 border-green-500/30 hover:bg-green-500/10 bg-transparent">
<Check className="h-3 w-3 mr-1" />{st("fail2ban.trustedNetworks.save")}
</Button>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-[1.1fr_0.65fr_2fr_1.5fr] gap-2 sm:gap-3 items-center">
<div className="flex items-center gap-2">
<span className={`inline-flex items-center rounded px-1.5 py-0.5 text-[9px] font-bold uppercase tracking-wide ${entry.protected ? "bg-slate-500/15 text-slate-400 border border-slate-500/20" : "bg-green-500/15 text-green-500 border border-green-500/20"}`}>
{entry.protected ? st("fail2ban.trustedNetworks.system") : st("fail2ban.trustedNetworks.trusted")}
</span>
</div>
<div>
<span className={`inline-flex rounded px-1.5 py-0.5 text-[9px] font-bold uppercase ${entry.value.includes("/") ? "bg-purple-500/15 text-purple-400" : "bg-blue-500/15 text-blue-400"}`}>
{entry.value.includes("/") ? "CIDR" : "IP"}
</span>
</div>
<code className="text-xs text-muted-foreground font-mono break-all">{entry.value}</code>
<div className="flex gap-2 sm:justify-end shrink-0">
{!entry.protected ? (
<>
<Button variant="outline" size="sm" onClick={() => { setF2bEditingTrusted(entry.value); setF2bTrustedEditInput(entry.value); setF2bTrustedNotice(null) }} 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" />{st("fail2ban.trustedNetworks.edit")}
</Button>
<Button variant="outline" size="sm" onClick={() => handleRemoveTrustedNetwork(entry.value)} disabled={f2bRemovingTrusted === entry.value} className="h-7 text-xs text-red-500 border-red-500/30 hover:bg-red-500/10 bg-transparent">
{f2bRemovingTrusted === entry.value ? <span className="h-3 w-3 animate-spin rounded-full border-2 border-current border-t-transparent mr-1" /> : <Trash2 className="h-3 w-3 mr-1" />}
{st("fail2ban.trustedNetworks.delete")}
</Button>
</>
) : (
<span className="inline-flex items-center text-[10px] text-muted-foreground"><Lock className="h-3 w-3 mr-1" />{st("fail2ban.trustedNetworks.required")}</span>
)}
</div>
</div>
)}
</div>
))}
</div>
<div className="flex items-start gap-2 border-t border-border bg-amber-500/5 px-3 py-2 text-[11px] text-amber-500">
<AlertTriangle className="h-3.5 w-3.5 mt-0.5 shrink-0" />
<p>{st("fail2ban.trustedNetworks.warning")}</p>
</div>
</div>
{/* Summary stats */}
<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">{f2bDetails.jails.length}</p>
<p className="text-[10px] text-muted-foreground uppercase tracking-wider">{st("fail2ban.jails")}</p>
</div>
<div className={`p-3 rounded-lg border text-center ${f2bDetails.jails.reduce((a, j) => a + j.currently_banned, 0) > 0 ? "bg-red-500/5 border-red-500/20" : "bg-muted/50 border-border"}`}>
<p className={`text-lg font-bold ${f2bDetails.jails.reduce((a, j) => a + j.currently_banned, 0) > 0 ? "text-red-500" : "text-foreground"}`}>
{f2bDetails.jails.reduce((a, j) => a + j.currently_banned, 0)} {f2bDetails.jails.reduce((a, j) => a + j.currently_banned, 0)}
</span> </p>
<p className={`text-[10px] uppercase tracking-wider ${f2bDetails.jails.reduce((a, j) => a + j.currently_banned, 0) > 0 ? "text-red-500/70" : "text-muted-foreground"}`}>{st("fail2ban.bannedIps")}</p>
</div> </div>
<div className="w-px h-4 bg-border" /> <div className="p-3 bg-orange-500/5 rounded-lg border border-orange-500/20 text-center">
<div className="flex items-center gap-1.5 text-sm"> <p className="text-lg font-bold text-orange-500">{f2bDetails.jails.reduce((a, j) => a + j.total_banned, 0)}</p>
<span className="text-muted-foreground">{st("fail2ban.totalBans")}:</span> <p className="text-[10px] text-orange-500/70 uppercase tracking-wider">{st("fail2ban.totalBans")}</p>
<span className="font-bold text-orange-500">
{f2bDetails.jails.reduce((a, j) => a + j.total_banned, 0)}
</span>
</div> </div>
<div className="w-px h-4 bg-border" /> <div className="p-3 bg-yellow-500/5 rounded-lg border border-yellow-500/20 text-center">
<div className="flex items-center gap-1.5 text-sm"> <p className="text-lg font-bold text-yellow-500">{f2bDetails.jails.reduce((a, j) => a + j.total_failed, 0)}</p>
<span className="text-muted-foreground">{st("fail2ban.failedAttempts")}:</span> <p className="text-[10px] text-yellow-500/70 uppercase tracking-wider">{st("fail2ban.failedAttempts")}</p>
<span className="font-bold text-yellow-500">
{f2bDetails.jails.reduce((a, j) => a + j.total_failed, 0)}
</span>
</div> </div>
</div> </div>
+33
View File
@@ -1502,6 +1502,9 @@
"auditFailed": "Audit failed", "auditFailed": "Audit failed",
"startAuditFailed": "Failed to start audit", "startAuditFailed": "Failed to start audit",
"updateJailConfigFailed": "Failed to save protection settings", "updateJailConfigFailed": "Failed to save protection settings",
"invalidTrustedNetwork": "Enter one valid IP address or CIDR network.",
"trustedNetworkExists": "This IP address or network is already trusted.",
"updateTrustedNetworksFailed": "Failed to update trusted IP addresses and networks.",
"ruleNeedsPortOrSource": "Add a destination port or source address first.", "ruleNeedsPortOrSource": "Add a destination port or source address first.",
"addRuleFailed": "Failed to add rule", "addRuleFailed": "Failed to add rule",
"deleteRuleFailed": "Failed to delete rule", "deleteRuleFailed": "Failed to delete rule",
@@ -1537,6 +1540,9 @@
"missingJailsApplied": "Missing protections were added.", "missingJailsApplied": "Missing protections were added.",
"auditCompleted": "Lynis audit completed.", "auditCompleted": "Lynis audit completed.",
"jailConfigUpdated": "Protection settings were saved.", "jailConfigUpdated": "Protection settings were saved.",
"trustedNetworkAdded": "{value} is now trusted by Fail2Ban.",
"trustedNetworkRemoved": "{value} was removed from trusted networks.",
"trustedNetworkUpdated": "Trusted entry was changed to {value}.",
"ruleAdded": "Firewall rule was added.", "ruleAdded": "Firewall rule was added.",
"ruleDeleted": "Firewall rule was deleted.", "ruleDeleted": "Firewall rule was deleted.",
"ruleUpdated": "Firewall rule was updated.", "ruleUpdated": "Firewall rule was updated.",
@@ -1825,6 +1831,33 @@
"maxRetries": "5", "maxRetries": "5",
"banTime": "3600", "banTime": "3600",
"findTime": "600" "findTime": "600"
},
"trustedNetworks": {
"title": "Trusted IP addresses and networks",
"description": "Fail2Ban will never block these addresses.",
"required": "Required",
"remove": "Remove from trusted networks",
"placeholder": "192.168.1.25 or 192.168.1.0/24",
"add": "Add",
"addCurrent": "Add my IP ({ip})",
"addRule": "Add address / network",
"addressOrNetwork": "IP address or CIDR network",
"example": "One address: 192.168.1.25 · Entire network: 192.168.1.0/24",
"useCurrent": "Use my current IP: {ip}",
"singleIp": "IP address",
"network": "Network",
"edit": "Edit",
"delete": "Delete",
"save": "Save changes",
"trusted": "Trusted",
"system": "System",
"columns": {
"status": "Status",
"type": "Type",
"address": "Address / network",
"actions": "Actions"
},
"warning": "Trust only addresses you control. If you trust a whole network, Fail2Ban will also ignore a compromised device in that network."
} }
}, },
"lynis": { "lynis": {
+33
View File
@@ -1502,6 +1502,9 @@
"auditFailed": "Audit zlyhal", "auditFailed": "Audit zlyhal",
"startAuditFailed": "Nepodarilo sa spustiť audit", "startAuditFailed": "Nepodarilo sa spustiť audit",
"updateJailConfigFailed": "Nepodarilo sa uložiť nastavenie ochrany", "updateJailConfigFailed": "Nepodarilo sa uložiť nastavenie ochrany",
"invalidTrustedNetwork": "Zadajte jednu platnú IP adresu alebo sieť v tvare CIDR.",
"trustedNetworkExists": "Táto IP adresa alebo sieť už je medzi dôveryhodnými.",
"updateTrustedNetworksFailed": "Nepodarilo sa upraviť dôveryhodné IP adresy a siete.",
"ruleNeedsPortOrSource": "Najprv zadajte cieľový port alebo zdrojovú adresu.", "ruleNeedsPortOrSource": "Najprv zadajte cieľový port alebo zdrojovú adresu.",
"addRuleFailed": "Nepodarilo sa pridať pravidlo", "addRuleFailed": "Nepodarilo sa pridať pravidlo",
"deleteRuleFailed": "Nepodarilo sa vymazať pravidlo", "deleteRuleFailed": "Nepodarilo sa vymazať pravidlo",
@@ -1537,6 +1540,9 @@
"missingJailsApplied": "Chýbajúce ochrany boli doplnené.", "missingJailsApplied": "Chýbajúce ochrany boli doplnené.",
"auditCompleted": "Lynis audit je hotový.", "auditCompleted": "Lynis audit je hotový.",
"jailConfigUpdated": "Nastavenie ochrany bolo uložené.", "jailConfigUpdated": "Nastavenie ochrany bolo uložené.",
"trustedNetworkAdded": "Fail2Ban už nebude blokovať {value}.",
"trustedNetworkRemoved": "{value} bola odstránená z dôveryhodných sietí.",
"trustedNetworkUpdated": "Dôveryhodná položka bola zmenená na {value}.",
"ruleAdded": "Firewall pravidlo bolo pridané.", "ruleAdded": "Firewall pravidlo bolo pridané.",
"ruleDeleted": "Firewall pravidlo bolo vymazané.", "ruleDeleted": "Firewall pravidlo bolo vymazané.",
"ruleUpdated": "Firewall pravidlo bolo upravené.", "ruleUpdated": "Firewall pravidlo bolo upravené.",
@@ -1825,6 +1831,33 @@
"maxRetries": "5", "maxRetries": "5",
"banTime": "3600", "banTime": "3600",
"findTime": "600" "findTime": "600"
},
"trustedNetworks": {
"title": "Dôveryhodné IP adresy a siete",
"description": "Tieto adresy Fail2Ban nikdy nezablokuje.",
"required": "Povinné",
"remove": "Odstrániť z dôveryhodných sietí",
"placeholder": "192.168.1.25 alebo 192.168.1.0/24",
"add": "Pridať",
"addCurrent": "Pridať moju IP ({ip})",
"addRule": "Pridať adresu / sieť",
"addressOrNetwork": "IP adresa alebo sieť v tvare CIDR",
"example": "Jedna adresa: 192.168.1.25 · Celá sieť: 192.168.1.0/24",
"useCurrent": "Použiť moju aktuálnu IP: {ip}",
"singleIp": "IP adresa",
"network": "Sieť",
"edit": "Upraviť",
"delete": "Vymazať",
"save": "Uložiť zmeny",
"trusted": "Dôveryhodná",
"system": "Systémová",
"columns": {
"status": "Stav",
"type": "Typ",
"address": "Adresa / sieť",
"actions": "Akcie"
},
"warning": "Dôverujte iba adresám, ktoré máte pod kontrolou. Pri celej sieti bude Fail2Ban ignorovať aj napadnuté zariadenie v tejto sieti."
} }
}, },
"lynis": { "lynis": {
+76
View File
@@ -5,6 +5,8 @@ ProxMenux Security Routes
Flask blueprint for firewall management and security tool detection. Flask blueprint for firewall management and security tool detection.
""" """
import ipaddress
from flask import Blueprint, jsonify, request from flask import Blueprint, jsonify, request
from jwt_middleware import require_auth from jwt_middleware import require_auth
@@ -234,6 +236,80 @@ def fail2ban_jail_config():
return jsonify({"success": False, "message": str(e)}), 500 return jsonify({"success": False, "message": str(e)}), 500
@security_bp.route('/api/security/fail2ban/trusted-networks', methods=['GET'])
@require_auth
def fail2ban_trusted_networks():
"""List global IP/CIDR addresses excluded from all Fail2Ban jails."""
if not security_manager:
return jsonify({"success": False, "message": "Security manager not available"}), 500
try:
detected_ip = request.remote_addr
try:
parsed_ip = ipaddress.ip_address(detected_ip) if detected_ip else None
if isinstance(parsed_ip, ipaddress.IPv6Address) and parsed_ip.ipv4_mapped:
parsed_ip = parsed_ip.ipv4_mapped
if not parsed_ip or parsed_ip.is_loopback:
detected_ip = None
else:
detected_ip = str(parsed_ip)
except ValueError:
detected_ip = None
return jsonify({
"success": True,
"entries": security_manager.get_fail2ban_trusted_networks(),
"detected_ip": detected_ip,
})
except Exception as e:
return jsonify({"success": False, "message": str(e)}), 500
@security_bp.route('/api/security/fail2ban/trusted-networks', methods=['POST'])
@require_auth
def fail2ban_add_trusted_network():
"""Add one global Fail2Ban IP/CIDR exclusion."""
if not security_manager:
return jsonify({"success": False, "message": "Security manager not available"}), 500
try:
data = request.json or {}
success, message, value = security_manager.add_fail2ban_trusted_network(data.get("value", ""))
status = 200 if success else 400
return jsonify({"success": success, "message": message, "value": value}), status
except Exception as e:
return jsonify({"success": False, "message": str(e)}), 500
@security_bp.route('/api/security/fail2ban/trusted-networks', methods=['DELETE'])
@require_auth
def fail2ban_remove_trusted_network():
"""Remove one user-managed global Fail2Ban IP/CIDR exclusion."""
if not security_manager:
return jsonify({"success": False, "message": "Security manager not available"}), 500
try:
data = request.json or {}
success, message = security_manager.remove_fail2ban_trusted_network(data.get("value", ""))
status = 200 if success else 400
return jsonify({"success": success, "message": message}), status
except Exception as e:
return jsonify({"success": False, "message": str(e)}), 500
@security_bp.route('/api/security/fail2ban/trusted-networks', methods=['PUT'])
@require_auth
def fail2ban_update_trusted_network():
"""Replace one user-managed global Fail2Ban IP/CIDR exclusion."""
if not security_manager:
return jsonify({"success": False, "message": "Security manager not available"}), 500
try:
data = request.json or {}
success, message, value = security_manager.update_fail2ban_trusted_network(
data.get("old_value", ""), data.get("new_value", "")
)
status = 200 if success else 400
return jsonify({"success": success, "message": message, "value": value}), status
except Exception as e:
return jsonify({"success": False, "message": str(e)}), 500
@security_bp.route('/api/security/fail2ban/apply-jails', methods=['POST']) @security_bp.route('/api/security/fail2ban/apply-jails', methods=['POST'])
@require_auth @require_auth
def fail2ban_apply_jails(): def fail2ban_apply_jails():
+192
View File
@@ -11,6 +11,8 @@ import subprocess
import re import re
import fcntl import fcntl
import threading import threading
import ipaddress
import tempfile
from contextlib import contextmanager from contextlib import contextmanager
# ================================================================= # =================================================================
@@ -80,6 +82,10 @@ def _is_pve_rule_line(stripped):
# and quote/escape tricks. See audit Tier 1 #12b. # and quote/escape tricks. See audit Tier 1 #12b.
_JAIL_NAME_RE = re.compile(r'^[A-Za-z0-9_][A-Za-z0-9_-]{0,63}$') _JAIL_NAME_RE = re.compile(r'^[A-Za-z0-9_][A-Za-z0-9_-]{0,63}$')
FAIL2BAN_TRUSTED_NETWORKS_FILE = "/etc/fail2ban/jail.d/99-proxmenux-ignore.local"
FAIL2BAN_LEGACY_GLOBAL_FILE = "/etc/fail2ban/jail.local"
_FAIL2BAN_PROTECTED_NETWORKS = ("127.0.0.0/8", "::1")
# Whitelist for the `level` argument to firewall functions. The audit flagged # Whitelist for the `level` argument to firewall functions. The audit flagged
# that an unconstrained value here could one day be extended to `vm` and become # that an unconstrained value here could one day be extended to `vm` and become
# a path traversal sink. See audit Tier 1 #12d. # a path traversal sink. See audit Tier 1 #12d.
@@ -924,6 +930,192 @@ def classify_ip(ip_address):
return "external" return "external"
def _normalise_ip_or_network(value):
"""Return a canonical IP/CIDR string, or raise ValueError."""
if not isinstance(value, str):
raise ValueError("Enter an IP address or CIDR network")
candidate = value.strip()
if not candidate or len(candidate) > 128 or any(ch.isspace() for ch in candidate):
raise ValueError("Enter one IP address or CIDR network at a time")
try:
if "/" in candidate:
parsed = ipaddress.ip_network(candidate, strict=False)
if parsed.prefixlen == 0 or parsed.is_multicast or parsed.is_unspecified:
raise ValueError
return parsed.with_prefixlen
parsed = ipaddress.ip_address(candidate)
if parsed.is_multicast or parsed.is_unspecified:
raise ValueError
return str(parsed)
except ValueError:
raise ValueError("Invalid IP address or CIDR network")
def _parse_default_ignoreip(path):
"""Read ignoreip values from the [DEFAULT] section of one config file."""
if not os.path.isfile(path):
return []
values = []
in_default = False
try:
with open(path, "r") as config_file:
for raw_line in config_file:
stripped = raw_line.strip()
if stripped.startswith("[") and stripped.endswith("]"):
in_default = stripped.upper() == "[DEFAULT]"
continue
if not in_default or not stripped or stripped.startswith(("#", ";")):
continue
match = re.match(r"^ignoreip\s*=\s*(.*)$", stripped, re.IGNORECASE)
if match:
raw_values = re.split(r"[\s,]+", match.group(1).strip())
values.extend(value for value in raw_values if value)
except OSError:
return []
return values
def _trusted_network_entries():
source = (FAIL2BAN_TRUSTED_NETWORKS_FILE
if os.path.isfile(FAIL2BAN_TRUSTED_NETWORKS_FILE)
else FAIL2BAN_LEGACY_GLOBAL_FILE)
entries = []
for value in (*_FAIL2BAN_PROTECTED_NETWORKS, *_parse_default_ignoreip(source)):
try:
normalised = _normalise_ip_or_network(value)
except ValueError:
continue
if normalised not in entries:
entries.append(normalised)
return entries
def get_fail2ban_trusted_networks():
"""Return the global Fail2Ban IP/CIDR allowlist managed by the Monitor."""
protected = {_normalise_ip_or_network(value) for value in _FAIL2BAN_PROTECTED_NETWORKS}
return [
{"value": value, "protected": value in protected}
for value in _trusted_network_entries()
]
def _write_trusted_networks(entries):
target = FAIL2BAN_TRUSTED_NETWORKS_FILE
directory = os.path.dirname(target)
os.makedirs(directory, exist_ok=True)
content = (
"# Managed by ProxMenux Monitor. Use the Security page to edit.\n"
"[DEFAULT]\n"
f"ignoreip = {' '.join(entries)}\n"
"ignoreself = true\n"
)
temp_path = None
try:
with tempfile.NamedTemporaryFile(
mode="w", dir=directory, prefix=".proxmenux-ignore-", delete=False
) as temp_file:
temp_file.write(content)
temp_path = temp_file.name
os.chmod(temp_path, 0o640)
os.replace(temp_path, target)
finally:
if temp_path and os.path.exists(temp_path):
os.unlink(temp_path)
def _save_trusted_networks(entries):
"""Persist and reload atomically; restore the previous file on failure."""
target = FAIL2BAN_TRUSTED_NETWORKS_FILE
lock_path = target + ".lock"
with _exclusive_file_lock(lock_path):
previous = None
existed = os.path.isfile(target)
if existed:
with open(target, "rb") as current_file:
previous = current_file.read()
_write_trusted_networks(entries)
rc, _, err = _run_cmd(["fail2ban-client", "reload"])
if rc == 0:
return True, "Fail2Ban trusted networks updated"
try:
if existed:
with open(target, "wb") as restore_file:
restore_file.write(previous or b"")
elif os.path.exists(target):
os.unlink(target)
_run_cmd(["fail2ban-client", "reload"])
except OSError:
pass
return False, f"Fail2Ban rejected the configuration: {err or 'reload failed'}"
def add_fail2ban_trusted_network(value):
try:
normalised = _normalise_ip_or_network(value)
except ValueError as exc:
return False, str(exc), None
entries = _trusted_network_entries()
candidate_network = ipaddress.ip_network(normalised, strict=False)
if any(
candidate_network.version == ipaddress.ip_network(entry, strict=False).version
and candidate_network.subnet_of(ipaddress.ip_network(entry, strict=False))
for entry in entries
):
return False, "This IP address or network is already trusted", normalised
entries.append(normalised)
success, message = _save_trusted_networks(entries)
return success, message, normalised
def remove_fail2ban_trusted_network(value):
try:
normalised = _normalise_ip_or_network(value)
except ValueError as exc:
return False, str(exc)
protected = {_normalise_ip_or_network(item) for item in _FAIL2BAN_PROTECTED_NETWORKS}
if normalised in protected:
return False, "Required local addresses cannot be removed"
entries = _trusted_network_entries()
if normalised not in entries:
return False, "Trusted IP address or network was not found"
entries.remove(normalised)
return _save_trusted_networks(entries)
def update_fail2ban_trusted_network(old_value, new_value):
try:
old_normalised = _normalise_ip_or_network(old_value)
new_normalised = _normalise_ip_or_network(new_value)
except ValueError as exc:
return False, str(exc), None
protected = {_normalise_ip_or_network(item) for item in _FAIL2BAN_PROTECTED_NETWORKS}
if old_normalised in protected:
return False, "Required local addresses cannot be changed", None
entries = _trusted_network_entries()
if old_normalised not in entries:
return False, "Trusted IP address or network was not found", None
other_entries = [entry for entry in entries if entry != old_normalised]
candidate_network = ipaddress.ip_network(new_normalised, strict=False)
if any(
candidate_network.version == ipaddress.ip_network(entry, strict=False).version
and candidate_network.subnet_of(ipaddress.ip_network(entry, strict=False))
for entry in other_entries
):
return False, "This IP address or network is already trusted", new_normalised
entries[entries.index(old_normalised)] = new_normalised
success, message = _save_trusted_networks(entries)
return success, message, new_normalised
def update_jail_config(jail_name, maxretry=None, bantime=None, findtime=None): def update_jail_config(jail_name, maxretry=None, bantime=None, findtime=None):
""" """
Update Fail2Ban jail configuration (maxretry, bantime, findtime). Update Fail2Ban jail configuration (maxretry, bantime, findtime).
@@ -0,0 +1,172 @@
import importlib.util
import tempfile
import unittest
from pathlib import Path
from unittest import mock
MODULE_PATH = Path(__file__).resolve().parents[1] / "security_manager.py"
SPEC = importlib.util.spec_from_file_location("security_manager_under_test", MODULE_PATH)
security_manager = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(security_manager)
class Fail2BanTrustedNetworksTests(unittest.TestCase):
def setUp(self):
self.temp_dir = tempfile.TemporaryDirectory()
self.addCleanup(self.temp_dir.cleanup)
root = Path(self.temp_dir.name)
self.managed_file = root / "jail.d" / "99-proxmenux-ignore.local"
self.legacy_file = root / "jail.local"
self.paths_patch = mock.patch.multiple(
security_manager,
FAIL2BAN_TRUSTED_NETWORKS_FILE=str(self.managed_file),
FAIL2BAN_LEGACY_GLOBAL_FILE=str(self.legacy_file),
)
self.paths_patch.start()
self.addCleanup(self.paths_patch.stop)
self.command_patch = mock.patch.object(
security_manager, "_run_cmd", return_value=(0, "OK", "")
)
self.run_command = self.command_patch.start()
self.addCleanup(self.command_patch.stop)
def write_legacy(self, content):
self.legacy_file.write_text(content)
def test_reads_and_normalises_existing_global_entries(self):
self.write_legacy(
"[DEFAULT]\nignoreip = 127.0.0.1/8, ::1 192.168.10.15 10.1.2.9/24\n"
"\n[sshd]\nenabled = true\n"
)
entries = security_manager.get_fail2ban_trusted_networks()
self.assertEqual(
[entry["value"] for entry in entries],
["127.0.0.0/8", "::1", "192.168.10.15", "10.1.2.0/24"],
)
self.assertTrue(entries[0]["protected"])
self.assertTrue(entries[1]["protected"])
self.assertFalse(entries[2]["protected"])
def test_adds_ipv4_network_and_reloads_fail2ban(self):
success, message, value = security_manager.add_fail2ban_trusted_network(
"192.168.50.123/24"
)
self.assertTrue(success, message)
self.assertEqual(value, "192.168.50.0/24")
content = self.managed_file.read_text()
self.assertIn("ignoreip = 127.0.0.0/8 ::1 192.168.50.0/24", content)
self.run_command.assert_called_once_with(["fail2ban-client", "reload"])
def test_adds_ipv6_network(self):
success, message, value = security_manager.add_fail2ban_trusted_network(
"fd12:3456:789a::42/64"
)
self.assertTrue(success, message)
self.assertEqual(value, "fd12:3456:789a::/64")
def test_rejects_invalid_or_multiple_values_without_writing(self):
for value in ("not-an-ip", "10.0.0.1 10.0.0.2", "10.0.0.0/99"):
with self.subTest(value=value):
success, _, normalised = security_manager.add_fail2ban_trusted_network(value)
self.assertFalse(success)
self.assertIsNone(normalised)
self.assertFalse(self.managed_file.exists())
self.run_command.assert_not_called()
def test_duplicate_is_rejected(self):
self.write_legacy("[DEFAULT]\nignoreip = 10.0.0.0/24\n")
success, message, value = security_manager.add_fail2ban_trusted_network("10.0.0.9/24")
self.assertFalse(success)
self.assertIn("already trusted", message)
self.assertEqual(value, "10.0.0.0/24")
self.run_command.assert_not_called()
def test_address_already_covered_by_network_is_rejected(self):
self.write_legacy("[DEFAULT]\nignoreip = 10.0.0.0/24\n")
success, message, value = security_manager.add_fail2ban_trusted_network("10.0.0.42")
self.assertFalse(success)
self.assertIn("already trusted", message)
self.assertEqual(value, "10.0.0.42")
def test_rejects_network_that_would_disable_all_bans(self):
for value in ("0.0.0.0/0", "::/0"):
with self.subTest(value=value):
success, _, normalised = security_manager.add_fail2ban_trusted_network(value)
self.assertFalse(success)
self.assertIsNone(normalised)
def test_protected_network_cannot_be_removed(self):
success, message = security_manager.remove_fail2ban_trusted_network("127.0.0.1/8")
self.assertFalse(success)
self.assertIn("cannot be removed", message)
self.run_command.assert_not_called()
def test_removes_user_network_and_keeps_other_entries(self):
self.managed_file.parent.mkdir(parents=True)
self.managed_file.write_text(
"[DEFAULT]\nignoreip = 127.0.0.0/8 ::1 10.0.0.0/24 192.168.1.5\n"
)
success, message = security_manager.remove_fail2ban_trusted_network("10.0.0.0/24")
self.assertTrue(success, message)
content = self.managed_file.read_text()
self.assertNotIn("10.0.0.0/24", content)
self.assertIn("192.168.1.5", content)
def test_updates_user_network_in_place(self):
self.managed_file.parent.mkdir(parents=True)
self.managed_file.write_text(
"[DEFAULT]\nignoreip = 127.0.0.0/8 ::1 10.0.0.0/24 192.168.1.5\n"
)
success, message, value = security_manager.update_fail2ban_trusted_network(
"10.0.0.0/24", "10.20.30.99/24"
)
self.assertTrue(success, message)
self.assertEqual(value, "10.20.30.0/24")
content = self.managed_file.read_text()
self.assertNotIn("10.0.0.0/24", content)
self.assertIn("10.20.30.0/24", content)
self.assertIn("192.168.1.5", content)
def test_protected_network_cannot_be_updated(self):
success, message, value = security_manager.update_fail2ban_trusted_network(
"::1", "::2"
)
self.assertFalse(success)
self.assertIn("cannot be changed", message)
self.assertIsNone(value)
def test_reload_failure_restores_previous_file(self):
self.managed_file.parent.mkdir(parents=True)
original = "[DEFAULT]\nignoreip = 127.0.0.0/8 ::1 10.0.0.0/24\n"
self.managed_file.write_text(original)
self.run_command.side_effect = [
(1, "", "configuration error"),
(0, "OK", ""),
]
success, message, _ = security_manager.add_fail2ban_trusted_network("192.168.1.0/24")
self.assertFalse(success)
self.assertIn("configuration error", message)
self.assertEqual(self.managed_file.read_text(), original)
self.assertEqual(self.run_command.call_count, 2)
if __name__ == "__main__":
unittest.main()