mirror of
https://github.com/swissmakers/fail2ban-ui.git
synced 2026-04-11 13:47:05 +02:00
Implementing WebSocked Support for immediately ban-messages
This commit is contained in:
@@ -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
1
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
|
||||
|
||||
2
go.sum
2
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=
|
||||
|
||||
@@ -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)
|
||||
@@ -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,9 +466,19 @@ INSERT INTO ban_events (
|
||||
record.OccurredAt.UTC(),
|
||||
record.CreatedAt.UTC(),
|
||||
)
|
||||
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.
|
||||
func ListBanEvents(ctx context.Context, serverID string, limit int, since time.Time) ([]BanEventRecord, error) {
|
||||
if db == nil {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 '';
|
||||
|
||||
@@ -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
102
pkg/web/static/js/header.js
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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() {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
205
pkg/web/static/js/websocket.js
Normal file
205
pkg/web/static/js/websocket.js
Normal 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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
268
pkg/web/websocket.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user