mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-02-18 16:36:27 +00:00
Update security
This commit is contained in:
@@ -8,7 +8,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/
|
||||
import {
|
||||
Shield, Lock, User, AlertCircle, CheckCircle, Info, LogOut, Key, Copy, Eye, EyeOff,
|
||||
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"
|
||||
import { getApiUrl, fetchApi } from "../lib/api-config"
|
||||
import { TwoFactorSetup } from "./two-factor-setup"
|
||||
@@ -100,13 +100,17 @@ export function Security() {
|
||||
const [showLynisInstaller, setShowLynisInstaller] = useState(false)
|
||||
|
||||
// Fail2Ban detailed state
|
||||
interface BannedIp {
|
||||
ip: string
|
||||
type: "local" | "external" | "unknown"
|
||||
}
|
||||
interface JailDetail {
|
||||
name: string
|
||||
currently_failed: number
|
||||
total_failed: number
|
||||
currently_banned: number
|
||||
total_banned: number
|
||||
banned_ips: string[]
|
||||
banned_ips: BannedIp[]
|
||||
findtime: string
|
||||
bantime: string
|
||||
maxretry: string
|
||||
@@ -124,6 +128,11 @@ export function Security() {
|
||||
const [f2bDetailsLoading, setF2bDetailsLoading] = useState(false)
|
||||
const [f2bUnbanning, setF2bUnbanning] = useState<string | null>(null)
|
||||
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
|
||||
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
|
||||
useEffect(() => {
|
||||
if (fail2banInfo?.installed && fail2banInfo?.active) {
|
||||
@@ -242,6 +297,7 @@ export function Security() {
|
||||
|
||||
const formatBanTime = (seconds: string) => {
|
||||
const s = parseInt(seconds, 10)
|
||||
if (s === -1) return "Permanent"
|
||||
if (isNaN(s) || s <= 0) return seconds
|
||||
if (s < 60) return `${s}s`
|
||||
if (s < 3600) return `${Math.floor(s / 60)}m`
|
||||
@@ -1961,7 +2017,7 @@ export function Security() {
|
||||
</div>
|
||||
<div>
|
||||
<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 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">
|
||||
<li>SSH protection (max 2 retries, 9h 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>
|
||||
</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>
|
||||
@@ -2040,28 +2098,28 @@ export function Security() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab switcher */}
|
||||
<div className="flex gap-1 p-1 bg-muted/30 rounded-lg">
|
||||
{/* Tab switcher - redesigned with border on inactive */}
|
||||
<div className="flex gap-0 rounded-lg border border-border overflow-hidden">
|
||||
<button
|
||||
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"
|
||||
? "bg-red-500 text-white shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
? "bg-red-500 text-white"
|
||||
: "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
|
||||
</button>
|
||||
<button
|
||||
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"
|
||||
? "bg-red-500 text-white shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
? "bg-red-500 text-white"
|
||||
: "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
|
||||
</button>
|
||||
</div>
|
||||
@@ -2076,20 +2134,133 @@ export function Security() {
|
||||
<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"}`} />
|
||||
<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 className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<span title="Max retries before ban">
|
||||
Retries: <span className="text-foreground font-medium">{jail.maxretry}</span>
|
||||
</span>
|
||||
<span title="Ban duration">
|
||||
Ban: <span className="text-foreground font-medium">{formatBanTime(jail.bantime)}</span>
|
||||
</span>
|
||||
<span title="Time window for counting failures">
|
||||
Window: <span className="text-foreground font-medium">{formatBanTime(jail.findtime)}</span>
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="hidden sm:flex items-center gap-3 text-xs text-muted-foreground mr-2">
|
||||
<span title="Max retries before ban">
|
||||
Retries: <span className="text-foreground font-medium">{jail.maxretry}</span>
|
||||
</span>
|
||||
<span title="Ban duration">
|
||||
Ban: <span className="text-foreground font-medium">{parseInt(jail.bantime, 10) === -1 ? "Permanent" : formatBanTime(jail.bantime)}</span>
|
||||
</span>
|
||||
<span title="Time window for counting failures">
|
||||
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>
|
||||
|
||||
{/* 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 */}
|
||||
<div className="grid grid-cols-4 gap-px bg-border">
|
||||
<div className="p-2.5 bg-card text-center">
|
||||
@@ -2120,20 +2291,29 @@ export function Security() {
|
||||
Banned IPs ({jail.banned_ips.length})
|
||||
</p>
|
||||
<div className="space-y-1.5">
|
||||
{jail.banned_ips.map((ip) => (
|
||||
<div key={ip} className="flex items-center justify-between px-3 py-2 bg-card rounded-md border border-red-500/20">
|
||||
{jail.banned_ips.map((entry) => (
|
||||
<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="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>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleUnbanIp(jail.name, ip)}
|
||||
disabled={f2bUnbanning === `${jail.name}:${ip}`}
|
||||
onClick={() => handleUnbanIp(jail.name, entry.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"
|
||||
>
|
||||
{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" />
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -3,11 +3,31 @@ Flask Authentication Routes
|
||||
Provides REST API endpoints for authentication management
|
||||
"""
|
||||
|
||||
import logging
|
||||
from flask import Blueprint, jsonify, request
|
||||
import auth_manager
|
||||
import jwt
|
||||
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.route('/api/auth/status', methods=['GET'])
|
||||
@@ -139,6 +159,12 @@ def auth_login():
|
||||
elif requires_totp:
|
||||
return jsonify({"success": False, "requires_totp": True, "message": message}), 200
|
||||
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
|
||||
except Exception as e:
|
||||
return jsonify({"success": False, "message": str(e)}), 500
|
||||
|
||||
@@ -164,6 +164,30 @@ def fail2ban_unban():
|
||||
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'])
|
||||
def fail2ban_activity():
|
||||
"""Get recent Fail2Ban log activity"""
|
||||
|
||||
@@ -591,7 +591,10 @@ def get_fail2ban_details():
|
||||
elif "Banned IP list:" in line:
|
||||
ips_str = line.split(":", 1)[1].strip()
|
||||
if ips_str:
|
||||
jail_info["banned_ips"] = [ip.strip() for ip in ips_str.split() if ip.strip()]
|
||||
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
|
||||
for key in ["findtime", "bantime", "maxretry"]:
|
||||
@@ -604,6 +607,167 @@ def get_fail2ban_details():
|
||||
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):
|
||||
"""
|
||||
Unban a specific IP from a Fail2Ban jail.
|
||||
|
||||
Reference in New Issue
Block a user