Implementing WebSocked Support for immediately ban-messages

This commit is contained in:
2025-12-15 20:12:41 +01:00
parent 5163e4f1f4
commit 3ad4821cb7
15 changed files with 930 additions and 139 deletions

View File

@@ -73,8 +73,12 @@ func main() {
router.Static("/static", "./pkg/web/static")
}
// Initialize WebSocket hub
wsHub := web.NewHub()
go wsHub.Run()
// Register all application routes, including the static files and templates.
web.RegisterRoutes(router)
web.RegisterRoutes(router, wsHub)
// Check if LOTR mode is active
isLOTRMode := isLOTRModeActive(settings.AlertCountries)

1
go.mod
View File

@@ -21,6 +21,7 @@ require (
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.9 // indirect

2
go.sum
View File

@@ -34,6 +34,8 @@ github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlG
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=

View File

@@ -1,132 +0,0 @@
#!/usr/bin/python3.9
"""
Fail2ban UI - A Swiss made, management interface for Fail2ban.
Copyright (C) 2025 Swissmakers GmbH
Licensed under the GNU General Public License, Version 3 (GPL-3.0)
You may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.gnu.org/licenses/gpl-3.0.en.html
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
# This file is for testing purposes only.
# (Must be copied to "/etc/fail2ban/action.d/geoip_notify.py")
#python3.9 -c "import maxminddb; print('maxminddb is installed successfully')"
import sys
import subprocess
# Manually set Python path where maxminddb is installed
sys.path.append("/usr/local/lib64/python3.9/site-packages/")
try:
import maxminddb
except ImportError:
print("Error: maxminddb module not found, even after modifying PYTHONPATH.")
sys.exit(1)
# Path to MaxMind GeoIP2 database
GEOIP_DB_PATH = "/usr/share/GeoIP/GeoLite2-Country.mmdb"
def get_country(ip):
"""
Perform a GeoIP lookup to get the country code from an IP address.
Returns the country code (e.g., "CH") or None if lookup fails.
"""
try:
with maxminddb.open_database(GEOIP_DB_PATH) as reader:
geo_data = reader.get(ip)
if geo_data and "country" in geo_data and "iso_code" in geo_data["country"]:
return geo_data["country"]["iso_code"]
except Exception as e:
print(f"GeoIP lookup failed: {e}", file=sys.stderr)
return None
def parse_placeholders(placeholder_str):
"""
Parses Fail2Ban placeholders passed as a string in "key=value" format.
Returns a dictionary.
"""
placeholders = {}
for item in placeholder_str.split(";"):
key_value = item.split("=", 1)
if len(key_value) == 2:
key, value = key_value
placeholders[key.strip()] = value.strip()
return placeholders
def send_email(placeholders):
"""
Generates and sends the email alert using sendmail.
"""
email_content = f"""Subject: [Fail2Ban] {placeholders['name']}: banned {placeholders['ip']} from {placeholders['fq-hostname']}
Date: $(LC_ALL=C date +"%a, %d %h %Y %T %z")
From: {placeholders['sendername']} <{placeholders['sender']}>
To: {placeholders['dest']}
Hi,
The IP {placeholders['ip']} has just been banned by Fail2Ban after {placeholders['failures']} attempts against {placeholders['name']}.
Here is more information about {placeholders['ip']}:
{subprocess.getoutput(placeholders['_whois_command'])}
Lines containing failures of {placeholders['ip']} (max {placeholders['grepmax']}):
{subprocess.getoutput(placeholders['_grep_logs'])}
Regards,
Fail2Ban"""
try:
subprocess.run(
["/usr/sbin/sendmail", "-f", placeholders["sender"], placeholders["dest"]],
input=email_content,
text=True,
check=True
)
print("Email sent successfully.")
except subprocess.CalledProcessError as e:
print(f"Failed to send email: {e}", file=sys.stderr)
def main(ip, allowed_countries, placeholder_str):
"""
Main function to check the IP's country and send an email if it matches the allowed list.
"""
allowed_countries = allowed_countries.split(",")
placeholders = parse_placeholders(placeholder_str)
# Perform GeoIP lookup
country = get_country(ip)
if not country:
print(f"Could not determine country for IP {ip}", file=sys.stderr)
sys.exit(1)
print(f"IP {ip} belongs to country: {country}")
# If the country is in the allowed list or "ALL" is selected, send the email
if "ALL" in allowed_countries or country in allowed_countries:
print(f"IP {ip} is in the alert countries list. Sending email...")
send_email(placeholders)
else:
print(f"IP {ip} is NOT in the alert countries list. No email sent.")
sys.exit(0) # Exit normally without error
if __name__ == "__main__":
if len(sys.argv) != 4:
print("Usage: geoip_notify.py <ip> <allowed_countries> <placeholders>", file=sys.stderr)
sys.exit(1)
ip = sys.argv[1]
allowed_countries = sys.argv[2]
placeholders = sys.argv[3]
main(ip, allowed_countries, placeholders)

View File

@@ -451,7 +451,7 @@ INSERT INTO ban_events (
server_id, server_name, jail, ip, country, hostname, failures, whois, logs, occurred_at, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
_, err := db.ExecContext(
result, err := db.ExecContext(
ctx,
query,
record.ServerID,
@@ -466,7 +466,17 @@ INSERT INTO ban_events (
record.OccurredAt.UTC(),
record.CreatedAt.UTC(),
)
return err
if err != nil {
return err
}
// Get the inserted ID
id, err := result.LastInsertId()
if err == nil {
record.ID = id
}
return nil
}
// ListBanEvents returns ban events ordered by creation date descending.

View File

@@ -46,6 +46,14 @@ import (
"github.com/swissmakers/fail2ban-ui/internal/storage"
)
// wsHub is the global WebSocket hub instance
var wsHub *Hub
// SetWebSocketHub sets the global WebSocket hub instance
func SetWebSocketHub(hub *Hub) {
wsHub = hub
}
// SummaryResponse is what we return from /api/summary
type SummaryResponse struct {
Jails []fail2ban.JailInfo `json:"jails"`
@@ -603,6 +611,11 @@ func HandleBanNotification(ctx context.Context, server config.Fail2banServer, ip
log.Printf("⚠️ Failed to record ban event: %v", err)
}
// Broadcast ban event to WebSocket clients
if wsHub != nil {
wsHub.BroadcastBanEvent(event)
}
evaluateAdvancedActions(ctx, settings, server, ip)
// Check if country is in alert list

View File

@@ -21,7 +21,9 @@ import (
)
// RegisterRoutes sets up the routes for the Fail2ban UI.
func RegisterRoutes(r *gin.Engine) {
func RegisterRoutes(r *gin.Engine, hub *Hub) {
// Set the global WebSocket hub
SetWebSocketHub(hub)
// Render the dashboard
r.GET("/", IndexHandler)
@@ -72,5 +74,8 @@ func RegisterRoutes(r *gin.Engine) {
api.GET("/events/bans", ListBanEventsHandler)
api.GET("/events/bans/stats", BanStatisticsHandler)
api.GET("/events/bans/insights", BanInsightsHandler)
// WebSocket endpoint
api.GET("/ws", WebSocketHandler(hub))
}
}

View File

@@ -156,6 +156,148 @@ mark {
background-color: #d97706;
}
.toast-ban-event {
background-color: #7f1d1d;
pointer-events: auto;
cursor: pointer;
}
.toast-ban-event:hover {
background-color: #991b1b;
transform: translateY(-2px);
box-shadow: 0 15px 20px -3px rgba(0, 0, 0, 0.15);
}
/* Backend Status Indicator */
#backendStatus {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.25rem 0.5rem;
margin-left: 5px;
border-radius: 0.25rem;
/* background-color: rgba(255, 255, 255, 0.1); */
transition: background-color 0.2s ease;
}
#backendStatus:hover {
background-color: rgba(255, 255, 255, 0.15);
}
#statusDot {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
display: inline-block;
transition: background-color 0.3s ease, box-shadow 0.3s ease;
box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.7);
animation: pulse 2s infinite;
}
#statusDot.bg-green-500 {
box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.7);
animation: pulseGreen 2s infinite;
}
#statusDot.bg-yellow-500 {
box-shadow: 0 0 0 0 rgba(234, 179, 8, 0.7);
animation: pulseYellow 2s infinite;
}
#statusDot.bg-red-500 {
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.7);
animation: pulseRed 2s infinite;
}
#statusDot.bg-gray-400 {
animation: none;
}
#statusText {
font-size: 0.75rem;
font-weight: 500;
color: rgba(255, 255, 255, 0.9);
white-space: nowrap;
}
@keyframes pulseGreen {
0% {
box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.7);
}
70% {
box-shadow: 0 0 0 4px rgba(34, 197, 94, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(34, 197, 94, 0);
}
}
@keyframes pulseYellow {
0% {
box-shadow: 0 0 0 0 rgba(234, 179, 8, 0.7);
}
70% {
box-shadow: 0 0 0 4px rgba(234, 179, 8, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(234, 179, 8, 0);
}
}
@keyframes pulseRed {
0% {
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.7);
}
70% {
box-shadow: 0 0 0 4px rgba(239, 68, 68, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0);
}
}
/* Clock Display */
#clockDisplay {
display: flex;
align-items: center;
padding: 0.25rem 0.75rem;
border-radius: 0.25rem;
background-color: rgba(255, 255, 255, 0.1);
transition: background-color 0.2s ease;
}
#clockDisplay:hover {
background-color: rgba(255, 255, 255, 0.15);
}
#clockTime {
font-family: 'Courier New', Courier, monospace;
font-size: 0.875rem;
font-weight: 600;
color: rgba(255, 255, 255, 0.95);
letter-spacing: 0.05em;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
/* Mobile responsive adjustments */
@media (max-width: 768px) {
#backendStatus {
padding: 0.125rem 0.375rem;
}
#statusText {
font-size: 0.625rem;
}
#clockDisplay {
padding: 0.125rem 0.5rem;
}
#clockTime {
font-size: 0.75rem;
}
}
#advancedMikrotikFields, #advancedPfSenseFields {
padding: 10px;
}
}

View File

@@ -18,6 +18,13 @@ function showLoading(show) {
function showToast(message, type) {
var container = document.getElementById('toast-container');
if (!container || !message) return;
// Handle ban event objects
if (typeof message === 'object' && message.type === 'ban_event') {
showBanEventToast(message.data || message);
return;
}
var toast = document.createElement('div');
var variant = type || 'info';
toast.className = 'toast toast-' + variant;
@@ -34,6 +41,60 @@ function showToast(message, type) {
}, 5000);
}
// Show toast for ban event
function showBanEventToast(event) {
var container = document.getElementById('toast-container');
if (!container || !event) return;
var toast = document.createElement('div');
toast.className = 'toast toast-ban-event';
var ip = event.ip || 'Unknown IP';
var jail = event.jail || 'Unknown Jail';
var server = event.serverName || event.serverId || 'Unknown Server';
var country = event.country || 'UNKNOWN';
toast.innerHTML = ''
+ '<div class="flex items-start gap-3">'
+ ' <div class="flex-shrink-0 mt-1">'
+ ' <i class="fas fa-shield-alt text-red-500"></i>'
+ ' </div>'
+ ' <div class="flex-1 min-w-0">'
+ ' <div class="font-semibold text-sm">New Block Detected</div>'
+ ' <div class="text-sm mt-1">'
+ ' <span class="font-mono font-semibold">' + escapeHtml(ip) + '</span>'
+ ' <span class="text-gray-500"> banned in </span>'
+ ' <span class="font-semibold">' + escapeHtml(jail) + '</span>'
+ ' </div>'
+ ' <div class="text-xs text-gray-400 mt-1">'
+ ' ' + escapeHtml(server) + ' • ' + escapeHtml(country)
+ ' </div>'
+ ' </div>'
+ '</div>';
// Add click handler to scroll to ban events table
toast.addEventListener('click', function() {
var logSection = document.getElementById('logOverviewSection');
if (logSection) {
logSection.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
});
toast.style.cursor = 'pointer';
container.appendChild(toast);
requestAnimationFrame(function() {
toast.classList.add('show');
});
setTimeout(function() {
toast.classList.remove('show');
setTimeout(function() {
toast.remove();
}, 300);
}, 5000);
}
// Escape HTML to prevent XSS
function escapeHtml(value) {
if (value === undefined || value === null) return '';

View File

@@ -74,6 +74,10 @@ function fetchBanEventsData() {
.then(function(res) { return res.json(); })
.then(function(data) {
latestBanEvents = data && data.events ? data.events : [];
// Track the last event ID to prevent duplicates from WebSocket
if (latestBanEvents.length > 0 && wsManager) {
wsManager.lastBanEventId = latestBanEvents[0].id;
}
})
.catch(function(err) {
console.error('Error fetching ban events:', err);
@@ -81,6 +85,72 @@ function fetchBanEventsData() {
});
}
// Add new ban event from WebSocket
function addBanEventFromWebSocket(event) {
// Check if event already exists (prevent duplicates)
// Only check by ID if both events have IDs
var exists = false;
if (event.id) {
exists = latestBanEvents.some(function(e) {
return e.id === event.id;
});
} else {
// If no ID, check by IP, jail, and occurredAt timestamp
exists = latestBanEvents.some(function(e) {
return e.ip === event.ip &&
e.jail === event.jail &&
e.occurredAt === event.occurredAt;
});
}
if (!exists) {
console.log('Adding new ban event from WebSocket:', event);
// Prepend to the beginning of the array
latestBanEvents.unshift(event);
// Keep only the last 200 events
if (latestBanEvents.length > 200) {
latestBanEvents = latestBanEvents.slice(0, 200);
}
// Show toast notification first
if (typeof showBanEventToast === 'function') {
showBanEventToast(event);
}
// Refresh dashboard data (summary, stats, insights) and re-render
refreshDashboardData();
} else {
console.log('Skipping duplicate ban event:', event);
}
}
// Refresh dashboard data when new ban event arrives via WebSocket
function refreshDashboardData() {
// Refresh ban statistics and insights in the background
// Also refresh summary if we have a server selected
var enabledServers = serversCache.filter(function(s) { return s.enabled; });
var summaryPromise;
if (serversCache.length && enabledServers.length && currentServerId) {
summaryPromise = fetchSummaryData();
} else {
summaryPromise = Promise.resolve();
}
Promise.all([
summaryPromise,
fetchBanStatisticsData(),
fetchBanInsightsData()
]).then(function() {
// Re-render the dashboard to show updated stats
renderDashboard();
}).catch(function(err) {
console.error('Error refreshing dashboard data:', err);
// Still re-render even if refresh fails
renderDashboard();
});
}
function fetchBanInsightsData() {
var sevenDaysAgo = new Date(Date.now() - (7 * 24 * 60 * 60 * 1000)).toISOString();
var sinceQuery = '?since=' + encodeURIComponent(sevenDaysAgo);

102
pkg/web/static/js/header.js Normal file
View File

@@ -0,0 +1,102 @@
// Header components: Clock and Backend Status Indicator
"use strict";
var clockInterval = null;
var statusUpdateCallback = null;
// Initialize clock
function initClock() {
function updateClock() {
var now = new Date();
var hours = String(now.getHours()).padStart(2, '0');
var minutes = String(now.getMinutes()).padStart(2, '0');
var seconds = String(now.getSeconds()).padStart(2, '0');
var timeString = hours + ':' + minutes + ':' + seconds;
var clockElement = document.getElementById('clockTime');
if (clockElement) {
clockElement.textContent = timeString;
}
}
// Update immediately
updateClock();
// Update every second
if (clockInterval) {
clearInterval(clockInterval);
}
clockInterval = setInterval(updateClock, 1000);
}
// Update status indicator
function updateStatusIndicator(state, text) {
var statusDot = document.getElementById('statusDot');
var statusText = document.getElementById('statusText');
if (!statusDot || !statusText) {
return;
}
// Remove all color classes
statusDot.classList.remove('bg-green-500', 'bg-yellow-500', 'bg-red-500', 'bg-gray-400');
// Set color and text based on state
switch (state) {
case 'connected':
statusDot.classList.add('bg-green-500');
statusText.textContent = text || 'Connected';
break;
case 'connecting':
case 'reconnecting':
statusDot.classList.add('bg-yellow-500');
statusText.textContent = text || 'Connecting...';
break;
case 'disconnected':
case 'error':
statusDot.classList.add('bg-red-500');
statusText.textContent = text || 'Disconnected';
break;
default:
statusDot.classList.add('bg-gray-400');
statusText.textContent = text || 'Unknown';
}
}
// Initialize status indicator
function initStatusIndicator() {
// Set initial state
updateStatusIndicator('connecting', 'Connecting...');
// Register callback with WebSocket manager when available
if (typeof wsManager !== 'undefined' && wsManager) {
wsManager.onStatusChange(function(state, text) {
updateStatusIndicator(state, text);
});
} else {
// Wait for WebSocket manager to be available
var checkInterval = setInterval(function() {
if (typeof wsManager !== 'undefined' && wsManager) {
wsManager.onStatusChange(function(state, text) {
updateStatusIndicator(state, text);
});
clearInterval(checkInterval);
}
}, 100);
}
}
// Initialize all header components
function initHeader() {
initClock();
initStatusIndicator();
}
// Cleanup on page unload
if (typeof window !== 'undefined') {
window.addEventListener('beforeunload', function() {
if (clockInterval) {
clearInterval(clockInterval);
}
});
}

View File

@@ -5,6 +5,38 @@ window.addEventListener('DOMContentLoaded', function() {
showLoading(true);
displayExternalIP();
// Initialize header components (clock and status indicator)
if (typeof initHeader === 'function') {
initHeader();
}
// Initialize WebSocket connection and register ban event handler
function registerBanEventHandler() {
if (typeof wsManager !== 'undefined' && wsManager) {
wsManager.onBanEvent(function(event) {
if (typeof addBanEventFromWebSocket === 'function') {
addBanEventFromWebSocket(event);
}
});
return true;
}
return false;
}
if (!registerBanEventHandler()) {
// Wait for WebSocket manager to be available
var wsCheckInterval = setInterval(function() {
if (registerBanEventHandler()) {
clearInterval(wsCheckInterval);
}
}, 100);
// Stop checking after 5 seconds
setTimeout(function() {
clearInterval(wsCheckInterval);
}, 5000);
}
// Check LOTR mode on page load to apply immediately
fetch('/api/settings')
.then(res => res.json())
@@ -114,4 +146,3 @@ window.addEventListener('DOMContentLoaded', function() {
}
});
});

View File

@@ -0,0 +1,205 @@
// WebSocket Manager for Fail2ban UI
// Handles real-time communication with the backend
"use strict";
class WebSocketManager {
constructor() {
this.ws = null;
this.reconnectAttempts = 0;
this.maxReconnectAttempts = Infinity;
this.reconnectDelay = 1000; // Start with 1 second
this.maxReconnectDelay = 30000; // Max 30 seconds
this.isConnecting = false;
this.isConnected = false;
this.lastBanEventId = null;
this.statusCallbacks = [];
this.banEventCallbacks = [];
// Get WebSocket URL
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const host = window.location.host;
this.wsUrl = `${protocol}//${host}/api/ws`;
this.connect();
}
connect() {
if (this.isConnecting || (this.ws && this.ws.readyState === WebSocket.OPEN)) {
return;
}
this.isConnecting = true;
this.updateStatus('connecting', 'Connecting...');
try {
this.ws = new WebSocket(this.wsUrl);
this.ws.onopen = () => {
this.isConnecting = false;
this.isConnected = true;
this.reconnectAttempts = 0;
this.reconnectDelay = 1000;
this.updateStatus('connected', 'Connected');
console.log('WebSocket connected');
};
this.ws.onmessage = (event) => {
try {
const message = JSON.parse(event.data);
this.handleMessage(message);
} catch (err) {
console.error('Error parsing WebSocket message:', err);
}
};
this.ws.onerror = (error) => {
console.error('WebSocket error:', error);
this.updateStatus('error', 'Connection error');
};
this.ws.onclose = () => {
this.isConnecting = false;
this.isConnected = false;
this.updateStatus('disconnected', 'Disconnected');
console.log('WebSocket disconnected');
// Attempt to reconnect
if (this.reconnectAttempts < this.maxReconnectAttempts) {
this.scheduleReconnect();
}
};
} catch (error) {
console.error('Error creating WebSocket connection:', error);
this.isConnecting = false;
this.updateStatus('error', 'Connection failed');
this.scheduleReconnect();
}
}
scheduleReconnect() {
this.reconnectAttempts++;
const delay = Math.min(this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1), this.maxReconnectDelay);
this.updateStatus('reconnecting', 'Reconnecting...');
setTimeout(() => {
this.connect();
}, delay);
}
handleMessage(message) {
switch (message.type) {
case 'ban_event':
this.handleBanEvent(message.data);
break;
case 'heartbeat':
this.handleHeartbeat(message);
break;
default:
console.log('Unknown message type:', message.type);
}
}
handleBanEvent(eventData) {
// Check if we've already processed this event (prevent duplicates)
// Only check if event has an ID and we have a lastBanEventId
if (eventData.id && this.lastBanEventId !== null && eventData.id <= this.lastBanEventId) {
console.log('Skipping duplicate ban event:', eventData.id);
return;
}
// Update lastBanEventId if event has an ID
if (eventData.id) {
if (this.lastBanEventId === null || eventData.id > this.lastBanEventId) {
this.lastBanEventId = eventData.id;
}
}
console.log('Processing ban event:', eventData);
// Notify all registered callbacks
this.banEventCallbacks.forEach(callback => {
try {
callback(eventData);
} catch (err) {
console.error('Error in ban event callback:', err);
}
});
}
handleHeartbeat(message) {
// Update status to show backend is healthy
if (this.isConnected) {
this.updateStatus('connected', 'Connected');
}
}
updateStatus(state, text) {
this.statusCallbacks.forEach(callback => {
try {
callback(state, text);
} catch (err) {
console.error('Error in status callback:', err);
}
});
}
onStatusChange(callback) {
this.statusCallbacks.push(callback);
}
onBanEvent(callback) {
this.banEventCallbacks.push(callback);
}
disconnect() {
if (this.ws) {
this.ws.close();
this.ws = null;
}
this.isConnected = false;
this.isConnecting = false;
}
getConnectionState() {
if (!this.ws) {
return 'disconnected';
}
switch (this.ws.readyState) {
case WebSocket.CONNECTING:
return 'connecting';
case WebSocket.OPEN:
return 'connected';
case WebSocket.CLOSING:
return 'disconnecting';
case WebSocket.CLOSED:
return 'disconnected';
default:
return 'unknown';
}
}
isHealthy() {
return this.isConnected && this.ws && this.ws.readyState === WebSocket.OPEN;
}
}
// Create global instance - initialize immediately
var wsManager = null;
// Initialize WebSocket manager
if (typeof window !== 'undefined') {
// Initialize immediately if DOM is already loaded, otherwise wait
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', function() {
if (!wsManager) {
wsManager = new WebSocketManager();
}
});
} else {
wsManager = new WebSocketManager();
}
}

View File

@@ -75,12 +75,19 @@
<div class="flex-shrink-0">
<span class="text-xl font-bold">Fail2ban UI</span>
</div>
<div id="backendStatus" class="ml-4 flex items-center gap-2">
<span class="w-2 h-2 rounded-full bg-gray-400" id="statusDot"></span>
<span id="statusText" class="text-xs">Connecting...</span>
</div>
</div>
<div class="hidden md:block">
<div class="ml-10 flex items-baseline space-x-4">
<div class="ml-10 flex items-baseline space-x-4 items-center">
<a href="#" onclick="showSection('dashboardSection')" class="px-3 py-2 rounded-md text-sm font-medium hover:bg-blue-700 transition-colors" data-i18n="nav.dashboard">Dashboard</a>
<a href="#" onclick="showSection('filterSection')" class="px-3 py-2 rounded-md text-sm font-medium hover:bg-blue-700 transition-colors" data-i18n="nav.filter_debug">Filter Debug</a>
<a href="#" onclick="showSection('settingsSection')" class="px-3 py-2 rounded-md text-sm font-medium hover:bg-blue-700 transition-colors" data-i18n="nav.settings">Settings</a>
<div id="clockDisplay" class="ml-4 text-sm font-mono">
<span id="clockTime">--:--:--</span>
</div>
</div>
</div>
<div class="md:hidden">
@@ -1163,6 +1170,8 @@
<script src="/static/js/jails.js"></script>
<script src="/static/js/settings.js"></script>
<script src="/static/js/filters.js"></script>
<script src="/static/js/websocket.js"></script>
<script src="/static/js/header.js"></script>
<script src="/static/js/lotr.js"></script>
<script src="/static/js/init.js"></script>
</body>

268
pkg/web/websocket.go Normal file
View File

@@ -0,0 +1,268 @@
// Fail2ban UI - A Swiss made, management interface for Fail2ban.
//
// Copyright (C) 2025 Swissmakers GmbH (https://swissmakers.ch)
//
// Licensed under the GNU General Public License, Version 3 (GPL-3.0)
// You may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.gnu.org/licenses/gpl-3.0.en.html
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package web
import (
"encoding/json"
"log"
"net/http"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/swissmakers/fail2ban-ui/internal/storage"
)
const (
// Time allowed to write a message to the peer
writeWait = 10 * time.Second
// Time allowed to read the next pong message from the peer
pongWait = 60 * time.Second
// Send pings to peer with this period (must be less than pongWait)
pingPeriod = (pongWait * 9) / 10
// Maximum message size allowed from peer
maxMessageSize = 512
)
var upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
CheckOrigin: func(r *http.Request) bool {
// Allow all origins for now - can be restricted in production
return true
},
}
// Client represents a WebSocket connection
type Client struct {
hub *Hub
conn *websocket.Conn
send chan []byte
}
// Hub maintains the set of active clients and broadcasts messages to them
type Hub struct {
// Registered clients
clients map[*Client]bool
// Inbound messages from clients
broadcast chan []byte
// Register requests from clients
register chan *Client
// Unregister requests from clients
unregister chan *Client
// Mutex for thread-safe operations
mu sync.RWMutex
}
// NewHub creates a new WebSocket hub
func NewHub() *Hub {
return &Hub{
clients: make(map[*Client]bool),
broadcast: make(chan []byte, 256),
register: make(chan *Client),
unregister: make(chan *Client),
}
}
// Run starts the hub's main loop
func (h *Hub) Run() {
// Start heartbeat ticker
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for {
select {
case client := <-h.register:
h.mu.Lock()
h.clients[client] = true
h.mu.Unlock()
log.Printf("WebSocket client connected. Total clients: %d", len(h.clients))
case client := <-h.unregister:
h.mu.Lock()
if _, ok := h.clients[client]; ok {
delete(h.clients, client)
close(client.send)
}
h.mu.Unlock()
log.Printf("WebSocket client disconnected. Total clients: %d", len(h.clients))
case message := <-h.broadcast:
h.mu.RLock()
for client := range h.clients {
select {
case client.send <- message:
default:
close(client.send)
delete(h.clients, client)
}
}
h.mu.RUnlock()
case <-ticker.C:
// Send heartbeat to all clients
h.sendHeartbeat()
}
}
}
// sendHeartbeat sends a heartbeat message to all connected clients
func (h *Hub) sendHeartbeat() {
message := map[string]interface{}{
"type": "heartbeat",
"time": time.Now().UTC().Unix(),
"status": "healthy",
}
data, err := json.Marshal(message)
if err != nil {
log.Printf("Error marshaling heartbeat: %v", err)
return
}
h.mu.RLock()
for client := range h.clients {
select {
case client.send <- data:
default:
close(client.send)
delete(h.clients, client)
}
}
h.mu.RUnlock()
}
// BroadcastBanEvent broadcasts a ban event to all connected clients
func (h *Hub) BroadcastBanEvent(event storage.BanEventRecord) {
message := map[string]interface{}{
"type": "ban_event",
"data": event,
}
data, err := json.Marshal(message)
if err != nil {
log.Printf("Error marshaling ban event: %v", err)
return
}
select {
case h.broadcast <- data:
default:
log.Printf("Broadcast channel full, dropping ban event")
}
}
// readPump pumps messages from the WebSocket connection to the hub
func (c *Client) readPump() {
defer func() {
c.hub.unregister <- c
c.conn.Close()
}()
c.conn.SetReadDeadline(time.Now().Add(pongWait))
c.conn.SetPongHandler(func(string) error {
c.conn.SetReadDeadline(time.Now().Add(pongWait))
return nil
})
for {
_, _, err := c.conn.ReadMessage()
if err != nil {
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
log.Printf("WebSocket error: %v", err)
}
break
}
}
}
// writePump pumps messages from the hub to the WebSocket connection
func (c *Client) writePump() {
ticker := time.NewTicker(pingPeriod)
defer func() {
ticker.Stop()
c.conn.Close()
}()
for {
select {
case message, ok := <-c.send:
c.conn.SetWriteDeadline(time.Now().Add(writeWait))
if !ok {
c.conn.WriteMessage(websocket.CloseMessage, []byte{})
return
}
w, err := c.conn.NextWriter(websocket.TextMessage)
if err != nil {
return
}
w.Write(message)
// Add queued messages to the current websocket message
n := len(c.send)
for i := 0; i < n; i++ {
w.Write([]byte{'\n'})
w.Write(<-c.send)
}
if err := w.Close(); err != nil {
return
}
case <-ticker.C:
c.conn.SetWriteDeadline(time.Now().Add(writeWait))
if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil {
return
}
}
}
}
// serveWS handles WebSocket requests from clients
func serveWS(hub *Hub, c *gin.Context) {
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
log.Printf("WebSocket upgrade error: %v", err)
return
}
client := &Client{
hub: hub,
conn: conn,
send: make(chan []byte, 256),
}
client.hub.register <- client
// Allow collection of memory referenced by the caller by doing all work in new goroutines
go client.writePump()
go client.readPump()
}
// WebSocketHandler is the Gin handler for WebSocket connections
func WebSocketHandler(hub *Hub) gin.HandlerFunc {
return func(c *gin.Context) {
serveWS(hub, c)
}
}