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

@@ -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)
}
}