Update security

This commit is contained in:
MacRimi
2026-02-08 17:01:49 +01:00
parent 7f9da757aa
commit 7f191764be
4 changed files with 424 additions and 30 deletions

View File

@@ -8,7 +8,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/
import { 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, Flame, Bug, Search, Download, Power, PowerOff, Plus, Minus, Activity, Settings, Ban,
} 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"
@@ -100,13 +100,17 @@ export function Security() {
const [showLynisInstaller, setShowLynisInstaller] = useState(false) const [showLynisInstaller, setShowLynisInstaller] = useState(false)
// Fail2Ban detailed state // Fail2Ban detailed state
interface BannedIp {
ip: string
type: "local" | "external" | "unknown"
}
interface JailDetail { interface JailDetail {
name: string name: string
currently_failed: number currently_failed: number
total_failed: number total_failed: number
currently_banned: number currently_banned: number
total_banned: number total_banned: number
banned_ips: string[] banned_ips: BannedIp[]
findtime: string findtime: string
bantime: string bantime: string
maxretry: string maxretry: string
@@ -124,6 +128,11 @@ export function Security() {
const [f2bDetailsLoading, setF2bDetailsLoading] = useState(false) const [f2bDetailsLoading, setF2bDetailsLoading] = useState(false)
const [f2bUnbanning, setF2bUnbanning] = useState<string | null>(null) const [f2bUnbanning, setF2bUnbanning] = useState<string | null>(null)
const [f2bActiveTab, setF2bActiveTab] = useState<"jails" | "activity">("jails") const [f2bActiveTab, setF2bActiveTab] = useState<"jails" | "activity">("jails")
const [f2bEditingJail, setF2bEditingJail] = useState<string | null>(null)
const [f2bJailConfig, setF2bJailConfig] = useState<{maxretry: string; bantime: string; findtime: string; permanent: boolean}>({
maxretry: "", bantime: "", findtime: "", permanent: false,
})
const [f2bSavingConfig, setF2bSavingConfig] = useState(false)
// SSL/HTTPS state // SSL/HTTPS state
const [sslEnabled, setSslEnabled] = useState(false) const [sslEnabled, setSslEnabled] = useState(false)
@@ -233,6 +242,52 @@ export function Security() {
} }
} }
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<string, string | number> = { 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(data.message || "Jail configuration updated")
setF2bEditingJail(null)
loadFail2banDetails()
} else {
setError(data.message || "Failed to update jail config")
}
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to update jail config")
} finally {
setF2bSavingConfig(false)
}
}
// Load fail2ban details when basic info shows it's installed and active // Load fail2ban details when basic info shows it's installed and active
useEffect(() => { useEffect(() => {
if (fail2banInfo?.installed && fail2banInfo?.active) { if (fail2banInfo?.installed && fail2banInfo?.active) {
@@ -242,6 +297,7 @@ export function Security() {
const formatBanTime = (seconds: string) => { const formatBanTime = (seconds: string) => {
const s = parseInt(seconds, 10) const s = parseInt(seconds, 10)
if (s === -1) return "Permanent"
if (isNaN(s) || s <= 0) return seconds if (isNaN(s) || s <= 0) return seconds
if (s < 60) return `${s}s` if (s < 60) return `${s}s`
if (s < 3600) return `${Math.floor(s / 60)}m` if (s < 3600) return `${Math.floor(s / 60)}m`
@@ -1961,7 +2017,7 @@ export function Security() {
</div> </div>
<div> <div>
<p className="font-medium">Fail2Ban Not Installed</p> <p className="font-medium">Fail2Ban Not Installed</p>
<p className="text-sm text-muted-foreground">Protect SSH and Proxmox web interface from brute force attacks</p> <p className="text-sm text-muted-foreground">Protect SSH, Proxmox web interface, and ProxMenux Monitor from brute force attacks</p>
</div> </div>
</div> </div>
<div className="px-3 py-1 rounded-full text-sm font-medium bg-gray-500/10 text-gray-500"> <div className="px-3 py-1 rounded-full text-sm font-medium bg-gray-500/10 text-gray-500">
@@ -1977,8 +2033,10 @@ export function Security() {
<ul className="list-disc list-inside space-y-1 text-blue-300"> <ul className="list-disc list-inside space-y-1 text-blue-300">
<li>SSH protection (max 2 retries, 9h ban)</li> <li>SSH protection (max 2 retries, 9h ban)</li>
<li>Proxmox web interface protection (port 8006, max 3 retries, 1h ban)</li> <li>Proxmox web interface protection (port 8006, max 3 retries, 1h ban)</li>
<li>ProxMenux Monitor protection (port 8008 + reverse proxy, max 3 retries, 1h ban)</li>
<li>Global settings with nftables backend</li> <li>Global settings with nftables backend</li>
</ul> </ul>
<p className="text-xs text-blue-300/70 mt-1">All settings can be customized after installation. You can change retries, ban time, or set permanent bans.</p>
</div> </div>
</div> </div>
</div> </div>
@@ -2040,28 +2098,28 @@ export function Security() {
</div> </div>
</div> </div>
{/* Tab switcher */} {/* Tab switcher - redesigned with border on inactive */}
<div className="flex gap-1 p-1 bg-muted/30 rounded-lg"> <div className="flex gap-0 rounded-lg border border-border overflow-hidden">
<button <button
onClick={() => setF2bActiveTab("jails")} onClick={() => setF2bActiveTab("jails")}
className={`flex-1 px-3 py-2 rounded-md text-sm font-medium transition-all ${ className={`flex-1 px-3 py-2.5 text-sm font-medium transition-all flex items-center justify-center gap-1.5 ${
f2bActiveTab === "jails" f2bActiveTab === "jails"
? "bg-red-500 text-white shadow-sm" ? "bg-red-500 text-white"
: "text-muted-foreground hover:text-foreground" : "bg-muted/30 text-muted-foreground hover:text-foreground hover:bg-muted/50"
}`} }`}
> >
<Shield className="h-3.5 w-3.5 inline mr-1.5" /> <Shield className="h-3.5 w-3.5" />
Jails & Banned IPs Jails & Banned IPs
</button> </button>
<button <button
onClick={() => setF2bActiveTab("activity")} onClick={() => setF2bActiveTab("activity")}
className={`flex-1 px-3 py-2 rounded-md text-sm font-medium transition-all ${ className={`flex-1 px-3 py-2.5 text-sm font-medium transition-all flex items-center justify-center gap-1.5 border-l border-border ${
f2bActiveTab === "activity" f2bActiveTab === "activity"
? "bg-red-500 text-white shadow-sm" ? "bg-red-500 text-white"
: "text-muted-foreground hover:text-foreground" : "bg-muted/30 text-muted-foreground hover:text-foreground hover:bg-muted/50"
}`} }`}
> >
<Clock className="h-3.5 w-3.5 inline mr-1.5" /> <Clock className="h-3.5 w-3.5" />
Recent Activity Recent Activity
</button> </button>
</div> </div>
@@ -2076,20 +2134,133 @@ export function Security() {
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
<div className={`w-2.5 h-2.5 rounded-full ${jail.currently_banned > 0 ? "bg-red-500 animate-pulse" : "bg-green-500"}`} /> <div className={`w-2.5 h-2.5 rounded-full ${jail.currently_banned > 0 ? "bg-red-500 animate-pulse" : "bg-green-500"}`} />
<span className="font-semibold text-sm">{jail.name}</span> <span className="font-semibold text-sm">{jail.name}</span>
{parseInt(jail.bantime, 10) === -1 && (
<span className="px-1.5 py-0.5 rounded text-[10px] font-bold bg-red-500/10 text-red-500">PERMANENT BAN</span>
)}
</div> </div>
<div className="flex items-center gap-3 text-xs text-muted-foreground"> <div className="flex items-center gap-2">
<span title="Max retries before ban"> <div className="hidden sm:flex items-center gap-3 text-xs text-muted-foreground mr-2">
Retries: <span className="text-foreground font-medium">{jail.maxretry}</span> <span title="Max retries before ban">
</span> Retries: <span className="text-foreground font-medium">{jail.maxretry}</span>
<span title="Ban duration"> </span>
Ban: <span className="text-foreground font-medium">{formatBanTime(jail.bantime)}</span> <span title="Ban duration">
</span> Ban: <span className="text-foreground font-medium">{parseInt(jail.bantime, 10) === -1 ? "Permanent" : formatBanTime(jail.bantime)}</span>
<span title="Time window for counting failures"> </span>
Window: <span className="text-foreground font-medium">{formatBanTime(jail.findtime)}</span> <span title="Time window for counting failures">
</span> Window: <span className="text-foreground font-medium">{formatBanTime(jail.findtime)}</span>
</span>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => f2bEditingJail === jail.name ? setF2bEditingJail(null) : openJailConfig(jail)}
className={`h-7 w-7 p-0 ${f2bEditingJail === jail.name ? "text-red-500 bg-red-500/10" : "text-muted-foreground hover:text-foreground"}`}
title="Configure jail settings"
>
<Settings className="h-3.5 w-3.5" />
</Button>
</div> </div>
</div> </div>
{/* Jail config editor */}
{f2bEditingJail === jail.name && (
<div className="border-t border-border bg-muted/20 p-4 space-y-4">
<div className="flex items-center gap-2 mb-1">
<Settings className="h-4 w-4 text-red-500" />
<p className="text-sm font-semibold text-red-500">Configure {jail.name}</p>
</div>
<div className="grid gap-3 sm:grid-cols-3">
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground">Max Retries</Label>
<Input
type="number"
min="1"
value={f2bJailConfig.maxretry}
onChange={(e) => setF2bJailConfig({...f2bJailConfig, maxretry: e.target.value})}
className="h-9 text-sm"
placeholder="e.g. 3"
/>
<p className="text-[10px] text-muted-foreground">Failed attempts before ban</p>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground">Ban Time (seconds)</Label>
<Input
type="number"
min="60"
value={f2bJailConfig.permanent ? "" : f2bJailConfig.bantime}
onChange={(e) => setF2bJailConfig({...f2bJailConfig, bantime: e.target.value, permanent: false})}
className="h-9 text-sm"
placeholder={f2bJailConfig.permanent ? "Permanent" : "e.g. 3600 = 1h"}
disabled={f2bJailConfig.permanent}
/>
<div className="flex items-center gap-2 mt-1">
<input
type="checkbox"
id={`permanent-${jail.name}`}
checked={f2bJailConfig.permanent}
onChange={(e) => setF2bJailConfig({...f2bJailConfig, permanent: e.target.checked, bantime: ""})}
className="rounded border-border"
/>
<label htmlFor={`permanent-${jail.name}`} className="text-[10px] text-red-500 font-medium cursor-pointer">
Permanent ban (never expires)
</label>
</div>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground">Find Time (seconds)</Label>
<Input
type="number"
min="60"
value={f2bJailConfig.findtime}
onChange={(e) => setF2bJailConfig({...f2bJailConfig, findtime: e.target.value})}
className="h-9 text-sm"
placeholder="e.g. 600 = 10m"
/>
<p className="text-[10px] text-muted-foreground">Time window for counting retries</p>
</div>
</div>
<div className="bg-blue-500/10 border border-blue-500/20 rounded p-2.5 flex items-start gap-2">
<Info className="h-4 w-4 text-blue-500 flex-shrink-0 mt-0.5" />
<p className="text-[11px] text-blue-400">
Common values: 600s = 10min, 3600s = 1h, 32400s = 9h, 86400s = 24h. Set ban to permanent if you want blocked IPs to stay blocked until you manually unban them.
</p>
</div>
<div className="flex gap-2 justify-end">
<Button
variant="ghost"
size="sm"
onClick={() => setF2bEditingJail(null)}
className="text-muted-foreground"
>
Cancel
</Button>
<Button
size="sm"
disabled={f2bSavingConfig}
onClick={handleSaveJailConfig}
className="bg-red-600 hover:bg-red-700 text-white"
>
{f2bSavingConfig ? (
<div className="animate-spin h-3.5 w-3.5 border-2 border-white border-t-transparent rounded-full mr-1" />
) : (
<CheckCircle className="h-3.5 w-3.5 mr-1" />
)}
Save Configuration
</Button>
</div>
</div>
)}
{/* Mobile config summary (visible only on small screens) */}
<div className="sm:hidden flex items-center justify-around p-2 bg-muted/20 border-t border-border text-xs text-muted-foreground">
<span>Retries: <span className="text-foreground font-medium">{jail.maxretry}</span></span>
<span>Ban: <span className="text-foreground font-medium">{parseInt(jail.bantime, 10) === -1 ? "Perm" : formatBanTime(jail.bantime)}</span></span>
<span>Window: <span className="text-foreground font-medium">{formatBanTime(jail.findtime)}</span></span>
</div>
{/* Jail stats bar */} {/* Jail stats bar */}
<div className="grid grid-cols-4 gap-px bg-border"> <div className="grid grid-cols-4 gap-px bg-border">
<div className="p-2.5 bg-card text-center"> <div className="p-2.5 bg-card text-center">
@@ -2120,20 +2291,29 @@ export function Security() {
Banned IPs ({jail.banned_ips.length}) Banned IPs ({jail.banned_ips.length})
</p> </p>
<div className="space-y-1.5"> <div className="space-y-1.5">
{jail.banned_ips.map((ip) => ( {jail.banned_ips.map((entry) => (
<div key={ip} className="flex items-center justify-between px-3 py-2 bg-card rounded-md border border-red-500/20"> <div key={entry.ip} className="flex items-center justify-between px-3 py-2 bg-card rounded-md border border-red-500/20">
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
<div className="w-2 h-2 rounded-full bg-red-500" /> <div className="w-2 h-2 rounded-full bg-red-500" />
<code className="text-sm font-mono">{ip}</code> <code className="text-sm font-mono">{entry.ip}</code>
<span className={`px-1.5 py-0.5 rounded text-[9px] font-bold uppercase tracking-wider ${
entry.type === "local"
? "bg-blue-500/10 text-blue-400 border border-blue-500/20"
: entry.type === "external"
? "bg-orange-500/10 text-orange-400 border border-orange-500/20"
: "bg-gray-500/10 text-gray-400 border border-gray-500/20"
}`}>
{entry.type === "local" ? "LAN" : entry.type === "external" ? "External" : "Unknown"}
</span>
</div> </div>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => handleUnbanIp(jail.name, ip)} onClick={() => handleUnbanIp(jail.name, entry.ip)}
disabled={f2bUnbanning === `${jail.name}:${ip}`} disabled={f2bUnbanning === `${jail.name}:${entry.ip}`}
className="h-7 px-2.5 text-xs text-green-500 hover:text-green-400 hover:bg-green-500/10" className="h-7 px-2.5 text-xs text-green-500 hover:text-green-400 hover:bg-green-500/10"
> >
{f2bUnbanning === `${jail.name}:${ip}` ? ( {f2bUnbanning === `${jail.name}:${entry.ip}` ? (
<div className="animate-spin h-3 w-3 border-2 border-green-500 border-t-transparent rounded-full" /> <div className="animate-spin h-3 w-3 border-2 border-green-500 border-t-transparent rounded-full" />
) : ( ) : (
<> <>

View File

@@ -3,11 +3,31 @@ Flask Authentication Routes
Provides REST API endpoints for authentication management Provides REST API endpoints for authentication management
""" """
import logging
from flask import Blueprint, jsonify, request from flask import Blueprint, jsonify, request
import auth_manager import auth_manager
import jwt import jwt
import datetime import datetime
# Dedicated logger for auth failures (Fail2Ban reads this)
auth_logger = logging.getLogger("proxmenux-auth")
_auth_handler = logging.FileHandler("/var/log/proxmenux-auth.log")
_auth_handler.setFormatter(logging.Formatter("%(asctime)s proxmenux-auth: %(message)s"))
auth_logger.addHandler(_auth_handler)
auth_logger.setLevel(logging.WARNING)
def _get_client_ip():
"""Get the real client IP, supporting reverse proxies (X-Forwarded-For, X-Real-IP)"""
forwarded = request.headers.get("X-Forwarded-For", "")
if forwarded:
# First IP in the chain is the real client
return forwarded.split(",")[0].strip()
real_ip = request.headers.get("X-Real-IP", "")
if real_ip:
return real_ip.strip()
return request.remote_addr or "unknown"
auth_bp = Blueprint('auth', __name__) auth_bp = Blueprint('auth', __name__)
@auth_bp.route('/api/auth/status', methods=['GET']) @auth_bp.route('/api/auth/status', methods=['GET'])
@@ -139,6 +159,12 @@ def auth_login():
elif requires_totp: elif requires_totp:
return jsonify({"success": False, "requires_totp": True, "message": message}), 200 return jsonify({"success": False, "requires_totp": True, "message": message}), 200
else: else:
# Log failed auth for Fail2Ban detection
client_ip = _get_client_ip()
auth_logger.warning(
"authentication failure; rhost=%s user=%s",
client_ip, username or "unknown"
)
return jsonify({"success": False, "message": message}), 401 return jsonify({"success": False, "message": message}), 401
except Exception as e: except Exception as e:
return jsonify({"success": False, "message": str(e)}), 500 return jsonify({"success": False, "message": str(e)}), 500

View File

@@ -164,6 +164,30 @@ def fail2ban_unban():
return jsonify({"success": False, "message": str(e)}), 500 return jsonify({"success": False, "message": str(e)}), 500
@security_bp.route('/api/security/fail2ban/jail/config', methods=['PUT'])
def fail2ban_jail_config():
"""Update jail configuration (maxretry, bantime, findtime)"""
if not security_manager:
return jsonify({"success": False, "message": "Security manager not available"}), 500
try:
data = request.json or {}
jail = data.get("jail", "")
if not jail:
return jsonify({"success": False, "message": "Jail name is required"}), 400
success, message = security_manager.update_jail_config(
jail,
maxretry=data.get("maxretry"),
bantime=data.get("bantime"),
findtime=data.get("findtime"),
)
if success:
return jsonify({"success": True, "message": message})
else:
return jsonify({"success": False, "message": message}), 400
except Exception as e:
return jsonify({"success": False, "message": str(e)}), 500
@security_bp.route('/api/security/fail2ban/activity', methods=['GET']) @security_bp.route('/api/security/fail2ban/activity', methods=['GET'])
def fail2ban_activity(): def fail2ban_activity():
"""Get recent Fail2Ban log activity""" """Get recent Fail2Ban log activity"""

View File

@@ -591,7 +591,10 @@ def get_fail2ban_details():
elif "Banned IP list:" in line: elif "Banned IP list:" in line:
ips_str = line.split(":", 1)[1].strip() ips_str = line.split(":", 1)[1].strip()
if ips_str: if ips_str:
jail_info["banned_ips"] = [ip.strip() for ip in ips_str.split() if ip.strip()] raw_ips = [ip.strip() for ip in ips_str.split() if ip.strip()]
jail_info["banned_ips"] = [
{"ip": ip, "type": classify_ip(ip)} for ip in raw_ips
]
# Get jail config values # Get jail config values
for key in ["findtime", "bantime", "maxretry"]: for key in ["findtime", "bantime", "maxretry"]:
@@ -604,6 +607,167 @@ def get_fail2ban_details():
return result return result
def classify_ip(ip_address):
"""
Classify an IP address as 'local' or 'external'.
Local: 10.x.x.x, 172.16-31.x.x, 192.168.x.x, 127.x.x.x, fd00::/8, fe80::/10, ::1
"""
if not ip_address:
return "unknown"
ip = ip_address.strip()
# IPv4 private ranges
if ip.startswith("10.") or ip.startswith("127.") or ip.startswith("192.168."):
return "local"
if ip.startswith("172."):
try:
second_octet = int(ip.split(".")[1])
if 16 <= second_octet <= 31:
return "local"
except (ValueError, IndexError):
pass
# IPv6 private/link-local
ip_lower = ip.lower()
if ip_lower == "::1" or ip_lower.startswith("fd") or ip_lower.startswith("fe80"):
return "local"
return "external"
def update_jail_config(jail_name, maxretry=None, bantime=None, findtime=None):
"""
Update Fail2Ban jail configuration (maxretry, bantime, findtime).
Uses fail2ban-client set commands for live changes, and also writes
to the jail.local file for persistence.
bantime = -1 means permanent ban.
Returns (success, message)
"""
if not jail_name:
return False, "Jail name is required"
changes = []
errors = []
# Apply live changes via fail2ban-client
if maxretry is not None:
try:
val = int(maxretry)
if val < 1:
return False, "Max retries must be at least 1"
rc, _, err = _run_cmd(["fail2ban-client", "set", jail_name, "maxretry", str(val)])
if rc == 0:
changes.append(f"maxretry={val}")
else:
errors.append(f"maxretry: {err}")
except ValueError:
errors.append("maxretry must be a number")
if bantime is not None:
try:
val = int(bantime)
# -1 = permanent, otherwise must be positive
if val < -1 or val == 0:
return False, "Ban time must be positive seconds or -1 for permanent"
rc, _, err = _run_cmd(["fail2ban-client", "set", jail_name, "bantime", str(val)])
if rc == 0:
changes.append(f"bantime={val}")
else:
errors.append(f"bantime: {err}")
except ValueError:
errors.append("bantime must be a number")
if findtime is not None:
try:
val = int(findtime)
if val < 1:
return False, "Find time must be positive"
rc, _, err = _run_cmd(["fail2ban-client", "set", jail_name, "findtime", str(val)])
if rc == 0:
changes.append(f"findtime={val}")
else:
errors.append(f"findtime: {err}")
except ValueError:
errors.append("findtime must be a number")
# Also persist to jail.local so changes survive restart
if changes:
_persist_jail_config(jail_name, maxretry, bantime, findtime)
if errors:
return False, "Errors: " + "; ".join(errors)
if changes:
return True, f"Jail '{jail_name}' updated: {', '.join(changes)}"
return False, "No changes specified"
def _persist_jail_config(jail_name, maxretry=None, bantime=None, findtime=None):
"""
Write jail config changes to /etc/fail2ban/jail.local for persistence.
"""
jail_local = "/etc/fail2ban/jail.local"
try:
content = ""
if os.path.isfile(jail_local):
with open(jail_local, 'r') as f:
content = f.read()
lines = content.splitlines() if content else []
# Find or create the jail section
jail_section = f"[{jail_name}]"
section_start = -1
section_end = len(lines)
for i, line in enumerate(lines):
if line.strip() == jail_section:
section_start = i
elif section_start >= 0 and line.strip().startswith("[") and i > section_start:
section_end = i
break
# Build settings to update
settings = {}
if maxretry is not None:
settings["maxretry"] = str(int(maxretry))
if bantime is not None:
settings["bantime"] = str(int(bantime))
if findtime is not None:
settings["findtime"] = str(int(findtime))
if section_start >= 0:
# Update existing section
for key, val in settings.items():
found = False
for i in range(section_start + 1, section_end):
stripped = lines[i].strip()
if stripped.startswith(f"{key}") and "=" in stripped:
lines[i] = f"{key} = {val}"
found = True
break
if not found:
lines.insert(section_start + 1, f"{key} = {val}")
section_end += 1
else:
# Create new section
if lines and lines[-1].strip():
lines.append("")
lines.append(jail_section)
for key, val in settings.items():
lines.append(f"{key} = {val}")
with open(jail_local, 'w') as f:
f.write("\n".join(lines) + "\n")
except Exception:
pass # Best effort persistence
def unban_ip(jail_name, ip_address): def unban_ip(jail_name, ip_address):
""" """
Unban a specific IP from a Fail2Ban jail. Unban a specific IP from a Fail2Ban jail.