mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-02-18 16:36:27 +00:00
Update security
This commit is contained in:
@@ -11,6 +11,7 @@ import { VirtualMachines } from "./virtual-machines"
|
||||
import Hardware from "./hardware"
|
||||
import { SystemLogs } from "./system-logs"
|
||||
import { Settings } from "./settings"
|
||||
import { Security } from "./security"
|
||||
import { OnboardingCarousel } from "./onboarding-carousel"
|
||||
import { HealthStatusModal } from "./health-status-modal"
|
||||
import { ReleaseNotesModal, useVersionCheck } from "./release-notes-modal"
|
||||
@@ -31,6 +32,7 @@ import {
|
||||
FileText,
|
||||
SettingsIcon,
|
||||
Terminal,
|
||||
ShieldCheck,
|
||||
} from "lucide-react"
|
||||
import Image from "next/image"
|
||||
import { ThemeToggle } from "./theme-toggle"
|
||||
@@ -265,8 +267,10 @@ export function ProxmoxDashboard() {
|
||||
return "Terminal"
|
||||
case "logs":
|
||||
return "System Logs"
|
||||
case "settings":
|
||||
return "Settings"
|
||||
case "security":
|
||||
return "Security"
|
||||
case "settings":
|
||||
return "Settings"
|
||||
default:
|
||||
return "Navigation Menu"
|
||||
}
|
||||
@@ -416,7 +420,7 @@ export function ProxmoxDashboard() {
|
||||
>
|
||||
<div className="container mx-auto px-4 md:px-6 pt-4 md:pt-6">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-0">
|
||||
<TabsList className="hidden md:grid w-full grid-cols-8 bg-card border border-border">
|
||||
<TabsList className="hidden md:grid w-full grid-cols-9 bg-card border border-border">
|
||||
<TabsTrigger
|
||||
value="overview"
|
||||
className="data-[state=active]:bg-blue-500 data-[state=active]:text-white data-[state=active]:rounded-md"
|
||||
@@ -459,6 +463,12 @@ export function ProxmoxDashboard() {
|
||||
>
|
||||
Terminal
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="security"
|
||||
className="data-[state=active]:bg-blue-500 data-[state=active]:text-white data-[state=active]:rounded-md"
|
||||
>
|
||||
Security
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="settings"
|
||||
className="data-[state=active]:bg-blue-500 data-[state=active]:text-white data-[state=active]:rounded-md"
|
||||
@@ -588,6 +598,21 @@ export function ProxmoxDashboard() {
|
||||
<Terminal className="h-5 w-5" />
|
||||
<span>Terminal</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setActiveTab("security")
|
||||
setMobileMenuOpen(false)
|
||||
}}
|
||||
className={`w-full justify-start gap-3 ${
|
||||
activeTab === "security"
|
||||
? "bg-blue-500/10 text-blue-500 border-l-4 border-blue-500 rounded-l-none"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<ShieldCheck className="h-5 w-5" />
|
||||
<span>Security</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
@@ -640,6 +665,10 @@ export function ProxmoxDashboard() {
|
||||
<TerminalPanel key={`terminal-${componentKey}`} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="security" className="space-y-4 md:space-y-6 mt-0">
|
||||
<Security key={`security-${componentKey}`} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="settings" className="space-y-4 md:space-y-6 mt-0">
|
||||
<Settings />
|
||||
</TabsContent>
|
||||
|
||||
2096
AppImage/components/security.tsx
Normal file
2096
AppImage/components/security.tsx
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -89,6 +89,8 @@ cp "$SCRIPT_DIR/flask_terminal_routes.py" "$APP_DIR/usr/bin/" 2>/dev/null || ech
|
||||
cp "$SCRIPT_DIR/hardware_monitor.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ hardware_monitor.py not found"
|
||||
cp "$SCRIPT_DIR/proxmox_storage_monitor.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ proxmox_storage_monitor.py not found"
|
||||
cp "$SCRIPT_DIR/flask_script_runner.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ flask_script_runner.py not found"
|
||||
cp "$SCRIPT_DIR/security_manager.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ security_manager.py not found"
|
||||
cp "$SCRIPT_DIR/flask_security_routes.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ flask_security_routes.py not found"
|
||||
|
||||
echo "📋 Adding translation support..."
|
||||
cat > "$APP_DIR/usr/bin/translate_cli.py" << 'PYEOF'
|
||||
|
||||
145
AppImage/scripts/flask_security_routes.py
Normal file
145
AppImage/scripts/flask_security_routes.py
Normal file
@@ -0,0 +1,145 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
ProxMenux Security Routes
|
||||
Flask blueprint for firewall management and security tool detection.
|
||||
"""
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
|
||||
security_bp = Blueprint('security', __name__)
|
||||
|
||||
try:
|
||||
import security_manager
|
||||
except ImportError:
|
||||
security_manager = None
|
||||
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Proxmox Firewall
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
@security_bp.route('/api/security/firewall/status', methods=['GET'])
|
||||
def firewall_status():
|
||||
"""Get Proxmox firewall status, rules, and port 8008 status"""
|
||||
if not security_manager:
|
||||
return jsonify({"success": False, "message": "Security manager not available"}), 500
|
||||
try:
|
||||
status = security_manager.get_firewall_status()
|
||||
return jsonify({"success": True, **status})
|
||||
except Exception as e:
|
||||
return jsonify({"success": False, "message": str(e)}), 500
|
||||
|
||||
|
||||
@security_bp.route('/api/security/firewall/enable', methods=['POST'])
|
||||
def firewall_enable():
|
||||
"""Enable Proxmox firewall at host or cluster level"""
|
||||
if not security_manager:
|
||||
return jsonify({"success": False, "message": "Security manager not available"}), 500
|
||||
try:
|
||||
data = request.json or {}
|
||||
level = data.get("level", "host")
|
||||
success, message = security_manager.enable_firewall(level)
|
||||
return jsonify({"success": success, "message": message})
|
||||
except Exception as e:
|
||||
return jsonify({"success": False, "message": str(e)}), 500
|
||||
|
||||
|
||||
@security_bp.route('/api/security/firewall/disable', methods=['POST'])
|
||||
def firewall_disable():
|
||||
"""Disable Proxmox firewall at host or cluster level"""
|
||||
if not security_manager:
|
||||
return jsonify({"success": False, "message": "Security manager not available"}), 500
|
||||
try:
|
||||
data = request.json or {}
|
||||
level = data.get("level", "host")
|
||||
success, message = security_manager.disable_firewall(level)
|
||||
return jsonify({"success": success, "message": message})
|
||||
except Exception as e:
|
||||
return jsonify({"success": False, "message": str(e)}), 500
|
||||
|
||||
|
||||
@security_bp.route('/api/security/firewall/monitor-port', methods=['POST'])
|
||||
def firewall_add_monitor_port():
|
||||
"""Add firewall rule to allow port 8008 for ProxMenux Monitor"""
|
||||
if not security_manager:
|
||||
return jsonify({"success": False, "message": "Security manager not available"}), 500
|
||||
try:
|
||||
success, message = security_manager.add_monitor_port_rule()
|
||||
return jsonify({"success": success, "message": message})
|
||||
except Exception as e:
|
||||
return jsonify({"success": False, "message": str(e)}), 500
|
||||
|
||||
|
||||
@security_bp.route('/api/security/firewall/monitor-port', methods=['DELETE'])
|
||||
def firewall_remove_monitor_port():
|
||||
"""Remove the ProxMenux Monitor port 8008 rule"""
|
||||
if not security_manager:
|
||||
return jsonify({"success": False, "message": "Security manager not available"}), 500
|
||||
try:
|
||||
success, message = security_manager.remove_monitor_port_rule()
|
||||
return jsonify({"success": success, "message": message})
|
||||
except Exception as e:
|
||||
return jsonify({"success": False, "message": str(e)}), 500
|
||||
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Fail2Ban Detailed Management
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
@security_bp.route('/api/security/fail2ban/details', methods=['GET'])
|
||||
def fail2ban_details():
|
||||
"""Get detailed Fail2Ban info: per-jail banned IPs, stats, config"""
|
||||
if not security_manager:
|
||||
return jsonify({"success": False, "message": "Security manager not available"}), 500
|
||||
try:
|
||||
details = security_manager.get_fail2ban_details()
|
||||
return jsonify({"success": True, **details})
|
||||
except Exception as e:
|
||||
return jsonify({"success": False, "message": str(e)}), 500
|
||||
|
||||
|
||||
@security_bp.route('/api/security/fail2ban/unban', methods=['POST'])
|
||||
def fail2ban_unban():
|
||||
"""Unban a specific IP from a Fail2Ban jail"""
|
||||
if not security_manager:
|
||||
return jsonify({"success": False, "message": "Security manager not available"}), 500
|
||||
try:
|
||||
data = request.json or {}
|
||||
jail = data.get("jail", "")
|
||||
ip = data.get("ip", "")
|
||||
success, message = security_manager.unban_ip(jail, ip)
|
||||
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"""
|
||||
if not security_manager:
|
||||
return jsonify({"success": False, "message": "Security manager not available"}), 500
|
||||
try:
|
||||
events = security_manager.get_fail2ban_recent_activity()
|
||||
return jsonify({"success": True, "events": events})
|
||||
except Exception as e:
|
||||
return jsonify({"success": False, "message": str(e)}), 500
|
||||
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Security Tools Detection
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
@security_bp.route('/api/security/tools', methods=['GET'])
|
||||
def security_tools():
|
||||
"""Detect installed security tools (Fail2Ban, Lynis, etc.)"""
|
||||
if not security_manager:
|
||||
return jsonify({"success": False, "message": "Security manager not available"}), 500
|
||||
try:
|
||||
tools = security_manager.detect_security_tools()
|
||||
return jsonify({"success": True, "tools": tools})
|
||||
except Exception as e:
|
||||
return jsonify({"success": False, "message": str(e)}), 500
|
||||
@@ -43,6 +43,7 @@ from flask_terminal_routes import terminal_bp, init_terminal_routes # noqa: E40
|
||||
from flask_health_routes import health_bp # noqa: E402
|
||||
from flask_auth_routes import auth_bp # noqa: E402
|
||||
from flask_proxmenux_routes import proxmenux_bp # noqa: E402
|
||||
from flask_security_routes import security_bp # noqa: E402
|
||||
from jwt_middleware import require_auth # noqa: E402
|
||||
import auth_manager # noqa: E402
|
||||
|
||||
@@ -116,6 +117,7 @@ CORS(app) # Enable CORS for Next.js frontend
|
||||
app.register_blueprint(auth_bp)
|
||||
app.register_blueprint(health_bp)
|
||||
app.register_blueprint(proxmenux_bp)
|
||||
app.register_blueprint(security_bp)
|
||||
|
||||
# Initialize terminal / WebSocket routes
|
||||
init_terminal_routes(app)
|
||||
|
||||
648
AppImage/scripts/security_manager.py
Normal file
648
AppImage/scripts/security_manager.py
Normal file
@@ -0,0 +1,648 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
ProxMenux Security Manager
|
||||
Handles Proxmox firewall status, rules, and security tool detection.
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import subprocess
|
||||
import re
|
||||
|
||||
# =================================================================
|
||||
# Proxmox Firewall Management
|
||||
# =================================================================
|
||||
|
||||
# Proxmox firewall config paths
|
||||
CLUSTER_FW = "/etc/pve/firewall/cluster.fw"
|
||||
HOST_FW_DIR = "/etc/pve/local" # host.fw is per-node
|
||||
|
||||
def _run_cmd(cmd, timeout=10):
|
||||
"""Run a shell command and return (returncode, stdout, stderr)"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd, capture_output=True, text=True, timeout=timeout
|
||||
)
|
||||
return result.returncode, result.stdout.strip(), result.stderr.strip()
|
||||
except subprocess.TimeoutExpired:
|
||||
return -1, "", "Command timed out"
|
||||
except FileNotFoundError:
|
||||
return -1, "", f"Command not found: {cmd[0]}"
|
||||
except Exception as e:
|
||||
return -1, "", str(e)
|
||||
|
||||
|
||||
def get_firewall_status():
|
||||
"""
|
||||
Get the overall Proxmox firewall status.
|
||||
Returns dict with status info.
|
||||
"""
|
||||
result = {
|
||||
"pve_firewall_installed": False,
|
||||
"pve_firewall_active": False,
|
||||
"cluster_fw_enabled": False,
|
||||
"host_fw_enabled": False,
|
||||
"rules_count": 0,
|
||||
"rules": [],
|
||||
"monitor_port_open": False,
|
||||
}
|
||||
|
||||
# Check if pve-firewall service exists
|
||||
rc, out, _ = _run_cmd(["systemctl", "is-active", "pve-firewall"])
|
||||
result["pve_firewall_installed"] = rc == 0 or "inactive" in out or "active" in out
|
||||
result["pve_firewall_active"] = (rc == 0 and out == "active")
|
||||
|
||||
# If not installed or inactive, check if the service unit exists
|
||||
if not result["pve_firewall_installed"]:
|
||||
rc2, _, _ = _run_cmd(["systemctl", "cat", "pve-firewall"])
|
||||
result["pve_firewall_installed"] = rc2 == 0
|
||||
|
||||
# Parse cluster firewall config
|
||||
if os.path.isfile(CLUSTER_FW):
|
||||
try:
|
||||
with open(CLUSTER_FW, 'r') as f:
|
||||
content = f.read()
|
||||
# Check if firewall is enabled at cluster level
|
||||
for line in content.splitlines():
|
||||
line = line.strip()
|
||||
if line.lower().startswith("enable:"):
|
||||
val = line.split(":", 1)[1].strip()
|
||||
result["cluster_fw_enabled"] = val == "1"
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Parse host firewall config
|
||||
host_fw = os.path.join(HOST_FW_DIR, "host.fw")
|
||||
if os.path.isfile(host_fw):
|
||||
try:
|
||||
with open(host_fw, 'r') as f:
|
||||
content = f.read()
|
||||
for line in content.splitlines():
|
||||
line = line.strip()
|
||||
if line.lower().startswith("enable:"):
|
||||
val = line.split(":", 1)[1].strip()
|
||||
result["host_fw_enabled"] = val == "1"
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Get rules
|
||||
rules = _parse_firewall_rules()
|
||||
result["rules"] = rules
|
||||
result["rules_count"] = len(rules)
|
||||
|
||||
# Check if port 8008 is allowed
|
||||
for rule in rules:
|
||||
dport = str(rule.get("dport", ""))
|
||||
if "8008" in dport and rule.get("action", "").upper() == "ACCEPT":
|
||||
result["monitor_port_open"] = True
|
||||
break
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _parse_firewall_rules():
|
||||
"""Parse all firewall rules from cluster and host configs"""
|
||||
rules = []
|
||||
|
||||
for fw_file, source in [(CLUSTER_FW, "cluster"), (os.path.join(HOST_FW_DIR, "host.fw"), "host")]:
|
||||
if not os.path.isfile(fw_file):
|
||||
continue
|
||||
try:
|
||||
with open(fw_file, 'r') as f:
|
||||
content = f.read()
|
||||
|
||||
in_rules = False
|
||||
section = ""
|
||||
for line in content.splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith('#'):
|
||||
continue
|
||||
|
||||
# Detect section headers
|
||||
if line.startswith('['):
|
||||
section_match = re.match(r'\[(\w+)\]', line)
|
||||
if section_match:
|
||||
section = section_match.group(1).upper()
|
||||
in_rules = section in ("RULES", "IN", "OUT")
|
||||
continue
|
||||
|
||||
if in_rules or section in ("RULES", "IN", "OUT"):
|
||||
rule = _parse_rule_line(line, source, section)
|
||||
if rule:
|
||||
rules.append(rule)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return rules
|
||||
|
||||
|
||||
def _parse_rule_line(line, source, section):
|
||||
"""Parse a single firewall rule line"""
|
||||
# Proxmox rule format: |ACTION MACRO(params) -option value ...
|
||||
# or: IN/OUT ACTION -p proto -dport port -source addr
|
||||
parts = line.split()
|
||||
if len(parts) < 2:
|
||||
return None
|
||||
|
||||
rule = {
|
||||
"raw": line,
|
||||
"source_file": source,
|
||||
"section": section,
|
||||
}
|
||||
|
||||
idx = 0
|
||||
# Direction
|
||||
if parts[0].upper() in ("IN", "OUT"):
|
||||
rule["direction"] = parts[0].upper()
|
||||
idx = 1
|
||||
elif section in ("IN",):
|
||||
rule["direction"] = "IN"
|
||||
elif section in ("OUT",):
|
||||
rule["direction"] = "OUT"
|
||||
|
||||
if idx < len(parts):
|
||||
rule["action"] = parts[idx].upper()
|
||||
idx += 1
|
||||
|
||||
# Parse options
|
||||
while idx < len(parts):
|
||||
opt = parts[idx]
|
||||
if opt.startswith("-") and idx + 1 < len(parts):
|
||||
key = opt.lstrip("-")
|
||||
val = parts[idx + 1]
|
||||
rule[key] = val
|
||||
idx += 2
|
||||
else:
|
||||
idx += 1
|
||||
|
||||
return rule
|
||||
|
||||
|
||||
def add_monitor_port_rule():
|
||||
"""
|
||||
Add a firewall rule to allow port 8008 (ProxMenux Monitor) on the host.
|
||||
Returns (success, message)
|
||||
"""
|
||||
host_fw = os.path.join(HOST_FW_DIR, "host.fw")
|
||||
|
||||
# Check if rule already exists
|
||||
status = get_firewall_status()
|
||||
if status.get("monitor_port_open"):
|
||||
return True, "Port 8008 is already allowed in the firewall"
|
||||
|
||||
try:
|
||||
content = ""
|
||||
has_rules_section = False
|
||||
|
||||
if os.path.isfile(host_fw):
|
||||
with open(host_fw, 'r') as f:
|
||||
content = f.read()
|
||||
has_rules_section = "[RULES]" in content
|
||||
|
||||
rule_line = "IN ACCEPT -p tcp -dport 8008 -log nolog # ProxMenux Monitor"
|
||||
|
||||
if has_rules_section:
|
||||
# Add rule after [RULES] section header
|
||||
lines = content.splitlines()
|
||||
new_lines = []
|
||||
inserted = False
|
||||
for line in lines:
|
||||
new_lines.append(line)
|
||||
if not inserted and line.strip() == "[RULES]":
|
||||
new_lines.append(rule_line)
|
||||
inserted = True
|
||||
content = "\n".join(new_lines) + "\n"
|
||||
else:
|
||||
# Add [RULES] section
|
||||
if content and not content.endswith("\n"):
|
||||
content += "\n"
|
||||
content += "\n[RULES]\n"
|
||||
content += rule_line + "\n"
|
||||
|
||||
with open(host_fw, 'w') as f:
|
||||
f.write(content)
|
||||
|
||||
# Reload firewall
|
||||
_run_cmd(["pve-firewall", "reload"])
|
||||
|
||||
return True, "Firewall rule added: port 8008 (TCP) allowed for ProxMenux Monitor"
|
||||
except PermissionError:
|
||||
return False, "Permission denied. Cannot write to firewall config."
|
||||
except Exception as e:
|
||||
return False, f"Failed to add firewall rule: {str(e)}"
|
||||
|
||||
|
||||
def remove_monitor_port_rule():
|
||||
"""
|
||||
Remove the ProxMenux Monitor port 8008 rule from host firewall.
|
||||
Returns (success, message)
|
||||
"""
|
||||
host_fw = os.path.join(HOST_FW_DIR, "host.fw")
|
||||
|
||||
if not os.path.isfile(host_fw):
|
||||
return True, "No host firewall config found"
|
||||
|
||||
try:
|
||||
with open(host_fw, 'r') as f:
|
||||
lines = f.readlines()
|
||||
|
||||
new_lines = []
|
||||
removed = False
|
||||
for line in lines:
|
||||
if "8008" in line and "ProxMenux" in line:
|
||||
removed = True
|
||||
continue
|
||||
new_lines.append(line)
|
||||
|
||||
if not removed:
|
||||
return True, "No ProxMenux Monitor rule found to remove"
|
||||
|
||||
with open(host_fw, 'w') as f:
|
||||
f.writelines(new_lines)
|
||||
|
||||
_run_cmd(["pve-firewall", "reload"])
|
||||
|
||||
return True, "ProxMenux Monitor firewall rule removed"
|
||||
except Exception as e:
|
||||
return False, f"Failed to remove firewall rule: {str(e)}"
|
||||
|
||||
|
||||
def enable_firewall(level="host"):
|
||||
"""
|
||||
Enable the Proxmox firewall at host or cluster level.
|
||||
Returns (success, message)
|
||||
"""
|
||||
if level == "cluster":
|
||||
return _set_firewall_enabled(CLUSTER_FW, True)
|
||||
else:
|
||||
host_fw = os.path.join(HOST_FW_DIR, "host.fw")
|
||||
return _set_firewall_enabled(host_fw, True)
|
||||
|
||||
|
||||
def disable_firewall(level="host"):
|
||||
"""
|
||||
Disable the Proxmox firewall at host or cluster level.
|
||||
Returns (success, message)
|
||||
"""
|
||||
if level == "cluster":
|
||||
return _set_firewall_enabled(CLUSTER_FW, False)
|
||||
else:
|
||||
host_fw = os.path.join(HOST_FW_DIR, "host.fw")
|
||||
return _set_firewall_enabled(host_fw, False)
|
||||
|
||||
|
||||
def _set_firewall_enabled(fw_file, enabled):
|
||||
"""Set enable: 1 or enable: 0 in firewall config"""
|
||||
try:
|
||||
content = ""
|
||||
if os.path.isfile(fw_file):
|
||||
with open(fw_file, 'r') as f:
|
||||
content = f.read()
|
||||
|
||||
enable_val = "1" if enabled else "0"
|
||||
has_options = "[OPTIONS]" in content
|
||||
has_enable = False
|
||||
|
||||
lines = content.splitlines()
|
||||
new_lines = []
|
||||
in_options = False
|
||||
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("["):
|
||||
in_options = stripped == "[OPTIONS]"
|
||||
|
||||
if in_options and stripped.lower().startswith("enable:"):
|
||||
new_lines.append(f"enable: {enable_val}")
|
||||
has_enable = True
|
||||
else:
|
||||
new_lines.append(line)
|
||||
|
||||
if not has_enable:
|
||||
if has_options:
|
||||
# Add enable line after [OPTIONS]
|
||||
final_lines = []
|
||||
for line in new_lines:
|
||||
final_lines.append(line)
|
||||
if line.strip() == "[OPTIONS]":
|
||||
final_lines.append(f"enable: {enable_val}")
|
||||
new_lines = final_lines
|
||||
else:
|
||||
# Add [OPTIONS] section at the beginning
|
||||
new_lines.insert(0, "[OPTIONS]")
|
||||
new_lines.insert(1, f"enable: {enable_val}")
|
||||
new_lines.insert(2, "")
|
||||
|
||||
# Ensure parent directory exists
|
||||
os.makedirs(os.path.dirname(fw_file), exist_ok=True)
|
||||
|
||||
with open(fw_file, 'w') as f:
|
||||
f.write("\n".join(new_lines) + "\n")
|
||||
|
||||
# Reload or start the firewall service
|
||||
if enabled:
|
||||
_run_cmd(["systemctl", "enable", "pve-firewall"])
|
||||
_run_cmd(["systemctl", "start", "pve-firewall"])
|
||||
|
||||
_run_cmd(["pve-firewall", "reload"])
|
||||
|
||||
state = "enabled" if enabled else "disabled"
|
||||
level = "cluster" if fw_file == CLUSTER_FW else "host"
|
||||
return True, f"Firewall {state} at {level} level"
|
||||
except PermissionError:
|
||||
return False, "Permission denied. Cannot modify firewall config."
|
||||
except Exception as e:
|
||||
return False, f"Failed to modify firewall: {str(e)}"
|
||||
|
||||
|
||||
# =================================================================
|
||||
# Security Tools Detection
|
||||
# =================================================================
|
||||
|
||||
# =================================================================
|
||||
# Fail2Ban Detailed Management
|
||||
# =================================================================
|
||||
|
||||
def get_fail2ban_details():
|
||||
"""
|
||||
Get detailed Fail2Ban info: per-jail banned IPs, ban times, etc.
|
||||
Returns dict with detailed jail information.
|
||||
"""
|
||||
result = {
|
||||
"installed": False,
|
||||
"active": False,
|
||||
"version": "",
|
||||
"jails": [],
|
||||
}
|
||||
|
||||
rc, out, _ = _run_cmd(["fail2ban-client", "--version"])
|
||||
if rc != 0:
|
||||
return result
|
||||
|
||||
result["installed"] = True
|
||||
result["version"] = out.split("\n")[0].strip() if out else ""
|
||||
|
||||
rc2, out2, _ = _run_cmd(["systemctl", "is-active", "fail2ban"])
|
||||
result["active"] = (rc2 == 0 and out2 == "active")
|
||||
|
||||
if not result["active"]:
|
||||
return result
|
||||
|
||||
# Get jail list
|
||||
rc3, out3, _ = _run_cmd(["fail2ban-client", "status"])
|
||||
jail_names = []
|
||||
if rc3 == 0:
|
||||
for line in out3.splitlines():
|
||||
if "Jail list:" in line:
|
||||
jails_str = line.split(":", 1)[1].strip()
|
||||
jail_names = [j.strip() for j in jails_str.split(",") if j.strip()]
|
||||
|
||||
# Get detailed info per jail
|
||||
for jail_name in jail_names:
|
||||
jail_info = {
|
||||
"name": jail_name,
|
||||
"currently_failed": 0,
|
||||
"total_failed": 0,
|
||||
"currently_banned": 0,
|
||||
"total_banned": 0,
|
||||
"banned_ips": [],
|
||||
"findtime": "",
|
||||
"bantime": "",
|
||||
"maxretry": "",
|
||||
}
|
||||
|
||||
rc4, out4, _ = _run_cmd(["fail2ban-client", "status", jail_name])
|
||||
if rc4 == 0:
|
||||
for line in out4.splitlines():
|
||||
line = line.strip()
|
||||
if "Currently failed:" in line:
|
||||
try:
|
||||
jail_info["currently_failed"] = int(line.split(":", 1)[1].strip())
|
||||
except ValueError:
|
||||
pass
|
||||
elif "Total failed:" in line:
|
||||
try:
|
||||
jail_info["total_failed"] = int(line.split(":", 1)[1].strip())
|
||||
except ValueError:
|
||||
pass
|
||||
elif "Currently banned:" in line:
|
||||
try:
|
||||
jail_info["currently_banned"] = int(line.split(":", 1)[1].strip())
|
||||
except ValueError:
|
||||
pass
|
||||
elif "Total banned:" in line:
|
||||
try:
|
||||
jail_info["total_banned"] = int(line.split(":", 1)[1].strip())
|
||||
except ValueError:
|
||||
pass
|
||||
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()]
|
||||
|
||||
# Get jail config values
|
||||
for key in ["findtime", "bantime", "maxretry"]:
|
||||
rc5, out5, _ = _run_cmd(["fail2ban-client", "get", jail_name, key])
|
||||
if rc5 == 0 and out5:
|
||||
jail_info[key] = out5.strip()
|
||||
|
||||
result["jails"].append(jail_info)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def unban_ip(jail_name, ip_address):
|
||||
"""
|
||||
Unban a specific IP from a Fail2Ban jail.
|
||||
Returns (success, message)
|
||||
"""
|
||||
if not jail_name or not ip_address:
|
||||
return False, "Jail name and IP address are required"
|
||||
|
||||
# Validate IP format (basic check)
|
||||
if not re.match(r'^[\d.:a-fA-F]+$', ip_address):
|
||||
return False, f"Invalid IP address format: {ip_address}"
|
||||
|
||||
rc, out, err = _run_cmd(["fail2ban-client", "set", jail_name, "unbanip", ip_address])
|
||||
if rc == 0:
|
||||
return True, f"IP {ip_address} has been unbanned from jail '{jail_name}'"
|
||||
else:
|
||||
return False, f"Failed to unban IP: {err or out}"
|
||||
|
||||
|
||||
def get_fail2ban_recent_activity(lines=50):
|
||||
"""
|
||||
Get recent Fail2Ban log activity (bans and unbans).
|
||||
Returns list of recent events.
|
||||
"""
|
||||
events = []
|
||||
|
||||
log_file = "/var/log/fail2ban.log"
|
||||
if not os.path.isfile(log_file):
|
||||
return events
|
||||
|
||||
try:
|
||||
# Read last N lines using tail
|
||||
rc, out, _ = _run_cmd(["tail", f"-{lines}", log_file], timeout=5)
|
||||
if rc != 0 or not out:
|
||||
return events
|
||||
|
||||
for line in out.splitlines():
|
||||
event = None
|
||||
|
||||
# Parse ban events
|
||||
ban_match = re.search(
|
||||
r'(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})[,\d]*\s+.*\[(\w+)\]\s+Ban\s+([\d.:a-fA-F]+)',
|
||||
line
|
||||
)
|
||||
if ban_match:
|
||||
event = {
|
||||
"timestamp": ban_match.group(1),
|
||||
"jail": ban_match.group(2),
|
||||
"ip": ban_match.group(3),
|
||||
"action": "ban",
|
||||
}
|
||||
|
||||
# Parse unban events
|
||||
if not event:
|
||||
unban_match = re.search(
|
||||
r'(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})[,\d]*\s+.*\[(\w+)\]\s+Unban\s+([\d.:a-fA-F]+)',
|
||||
line
|
||||
)
|
||||
if unban_match:
|
||||
event = {
|
||||
"timestamp": unban_match.group(1),
|
||||
"jail": unban_match.group(2),
|
||||
"ip": unban_match.group(3),
|
||||
"action": "unban",
|
||||
}
|
||||
|
||||
# Parse found (failed attempt) events
|
||||
if not event:
|
||||
found_match = re.search(
|
||||
r'(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})[,\d]*\s+.*\[(\w+)\]\s+Found\s+([\d.:a-fA-F]+)',
|
||||
line
|
||||
)
|
||||
if found_match:
|
||||
event = {
|
||||
"timestamp": found_match.group(1),
|
||||
"jail": found_match.group(2),
|
||||
"ip": found_match.group(3),
|
||||
"action": "found",
|
||||
}
|
||||
|
||||
if event:
|
||||
events.append(event)
|
||||
|
||||
# Return most recent first
|
||||
events.reverse()
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return events
|
||||
|
||||
|
||||
def detect_security_tools():
|
||||
"""
|
||||
Detect installed security tools on the system.
|
||||
Returns dict with tool status info.
|
||||
"""
|
||||
tools = {}
|
||||
|
||||
# Fail2Ban
|
||||
tools["fail2ban"] = _detect_fail2ban()
|
||||
|
||||
# Lynis
|
||||
tools["lynis"] = _detect_lynis()
|
||||
|
||||
return tools
|
||||
|
||||
|
||||
def _detect_fail2ban():
|
||||
"""Detect Fail2Ban installation and status"""
|
||||
info = {
|
||||
"installed": False,
|
||||
"active": False,
|
||||
"version": "",
|
||||
"jails": [],
|
||||
"banned_ips_count": 0,
|
||||
}
|
||||
|
||||
rc, out, _ = _run_cmd(["fail2ban-client", "--version"])
|
||||
if rc == 0:
|
||||
info["installed"] = True
|
||||
info["version"] = out.split("\n")[0].strip() if out else ""
|
||||
|
||||
# Check service status
|
||||
rc2, out2, _ = _run_cmd(["systemctl", "is-active", "fail2ban"])
|
||||
info["active"] = (rc2 == 0 and out2 == "active")
|
||||
|
||||
if info["active"]:
|
||||
# Get jails
|
||||
rc3, out3, _ = _run_cmd(["fail2ban-client", "status"])
|
||||
if rc3 == 0:
|
||||
for line in out3.splitlines():
|
||||
if "Jail list:" in line:
|
||||
jails_str = line.split(":", 1)[1].strip()
|
||||
info["jails"] = [j.strip() for j in jails_str.split(",") if j.strip()]
|
||||
|
||||
# Count banned IPs across all jails
|
||||
total_banned = 0
|
||||
for jail in info["jails"]:
|
||||
rc4, out4, _ = _run_cmd(["fail2ban-client", "status", jail])
|
||||
if rc4 == 0:
|
||||
for line in out4.splitlines():
|
||||
if "Currently banned:" in line:
|
||||
try:
|
||||
count = int(line.split(":", 1)[1].strip())
|
||||
total_banned += count
|
||||
except ValueError:
|
||||
pass
|
||||
info["banned_ips_count"] = total_banned
|
||||
|
||||
return info
|
||||
|
||||
|
||||
def _detect_lynis():
|
||||
"""Detect Lynis installation and status"""
|
||||
info = {
|
||||
"installed": False,
|
||||
"version": "",
|
||||
"last_scan": None,
|
||||
"hardening_index": None,
|
||||
}
|
||||
|
||||
# Check both locations
|
||||
lynis_cmd = None
|
||||
for path in ["/usr/local/bin/lynis", "/opt/lynis/lynis", "/usr/bin/lynis"]:
|
||||
if os.path.isfile(path) and os.access(path, os.X_OK):
|
||||
lynis_cmd = path
|
||||
break
|
||||
|
||||
if lynis_cmd:
|
||||
info["installed"] = True
|
||||
rc, out, _ = _run_cmd([lynis_cmd, "show", "version"])
|
||||
if rc == 0:
|
||||
info["version"] = out.strip()
|
||||
|
||||
# Check for last scan report
|
||||
report_file = "/var/log/lynis-report.dat"
|
||||
if os.path.isfile(report_file):
|
||||
try:
|
||||
with open(report_file, 'r') as f:
|
||||
for line in f:
|
||||
if line.startswith("report_datetime_start="):
|
||||
info["last_scan"] = line.split("=", 1)[1].strip()
|
||||
elif line.startswith("hardening_index="):
|
||||
try:
|
||||
info["hardening_index"] = int(line.split("=", 1)[1].strip())
|
||||
except ValueError:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return info
|
||||
Reference in New Issue
Block a user