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 {
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" />
) : (
<>

View File

@@ -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

View File

@@ -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"""

View File

@@ -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.