diff --git a/cmd/server/main.go b/cmd/server/main.go index e70b934..ed6b785 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -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) diff --git a/go.mod b/go.mod index a51c213..3fad333 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index c766f63..e9c8f32 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/geoip_notify.py b/internal/geoip_notify.py deleted file mode 100644 index d9cf4d5..0000000 --- a/internal/geoip_notify.py +++ /dev/null @@ -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 ", file=sys.stderr) - sys.exit(1) - - ip = sys.argv[1] - allowed_countries = sys.argv[2] - placeholders = sys.argv[3] - - main(ip, allowed_countries, placeholders) diff --git a/internal/storage/storage.go b/internal/storage/storage.go index 5de48a6..57f9f6b 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -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. diff --git a/pkg/web/handlers.go b/pkg/web/handlers.go index b72299d..09451fa 100644 --- a/pkg/web/handlers.go +++ b/pkg/web/handlers.go @@ -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 diff --git a/pkg/web/routes.go b/pkg/web/routes.go index 32c5617..276f9db 100644 --- a/pkg/web/routes.go +++ b/pkg/web/routes.go @@ -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)) } } diff --git a/pkg/web/static/fail2ban-ui.css b/pkg/web/static/fail2ban-ui.css index 50d2157..ae4d1b7 100644 --- a/pkg/web/static/fail2ban-ui.css +++ b/pkg/web/static/fail2ban-ui.css @@ -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; -} \ No newline at end of file +} diff --git a/pkg/web/static/js/core.js b/pkg/web/static/js/core.js index 1e7585f..33f7b98 100644 --- a/pkg/web/static/js/core.js +++ b/pkg/web/static/js/core.js @@ -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 = '' + + '
' + + '
' + + ' ' + + '
' + + '
' + + '
New Block Detected
' + + '
' + + ' ' + escapeHtml(ip) + '' + + ' banned in ' + + ' ' + escapeHtml(jail) + '' + + '
' + + '
' + + ' ' + escapeHtml(server) + ' • ' + escapeHtml(country) + + '
' + + '
' + + '
'; + + // 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 ''; diff --git a/pkg/web/static/js/dashboard.js b/pkg/web/static/js/dashboard.js index 7ff2d51..52649f8 100644 --- a/pkg/web/static/js/dashboard.js +++ b/pkg/web/static/js/dashboard.js @@ -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); diff --git a/pkg/web/static/js/header.js b/pkg/web/static/js/header.js new file mode 100644 index 0000000..0b5b9f7 --- /dev/null +++ b/pkg/web/static/js/header.js @@ -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); + } + }); +} diff --git a/pkg/web/static/js/init.js b/pkg/web/static/js/init.js index 9a7e28f..20f1f0c 100644 --- a/pkg/web/static/js/init.js +++ b/pkg/web/static/js/init.js @@ -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() { } }); }); - diff --git a/pkg/web/static/js/websocket.js b/pkg/web/static/js/websocket.js new file mode 100644 index 0000000..5eba662 --- /dev/null +++ b/pkg/web/static/js/websocket.js @@ -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(); + } +} + diff --git a/pkg/web/templates/index.html b/pkg/web/templates/index.html index 1fa5e22..d00d7af 100644 --- a/pkg/web/templates/index.html +++ b/pkg/web/templates/index.html @@ -75,12 +75,19 @@
Fail2ban UI
+
+ + Connecting... +