diff --git a/backend/internal/api/handlers/cerberus_logs_ws.go b/backend/internal/api/handlers/cerberus_logs_ws.go index 62a2df1b..2157ede8 100644 --- a/backend/internal/api/handlers/cerberus_logs_ws.go +++ b/backend/internal/api/handlers/cerberus_logs_ws.go @@ -16,11 +16,15 @@ import ( // CerberusLogsHandler handles WebSocket connections for streaming security logs. type CerberusLogsHandler struct { watcher *services.LogWatcher + tracker *services.WebSocketTracker } // NewCerberusLogsHandler creates a new handler for Cerberus security log streaming. -func NewCerberusLogsHandler(watcher *services.LogWatcher) *CerberusLogsHandler { - return &CerberusLogsHandler{watcher: watcher} +func NewCerberusLogsHandler(watcher *services.LogWatcher, tracker *services.WebSocketTracker) *CerberusLogsHandler { + return &CerberusLogsHandler{ + watcher: watcher, + tracker: tracker, + } } // LiveLogs handles WebSocket connections for Cerberus security log streaming. @@ -52,6 +56,22 @@ func (h *CerberusLogsHandler) LiveLogs(c *gin.Context) { subscriberID := uuid.New().String() logger.Log().WithField("subscriber_id", subscriberID).Info("Cerberus logs WebSocket connected") + // Register connection with tracker if available + if h.tracker != nil { + filters := c.Request.URL.RawQuery + connInfo := &services.ConnectionInfo{ + ID: subscriberID, + Type: "cerberus", + ConnectedAt: time.Now(), + LastActivityAt: time.Now(), + RemoteAddr: c.Request.RemoteAddr, + UserAgent: c.Request.UserAgent(), + Filters: filters, + } + h.tracker.Register(connInfo) + defer h.tracker.Unregister(subscriberID) + } + // Parse query filters sourceFilter := strings.ToLower(c.Query("source")) // waf, crowdsec, ratelimit, acl, normal levelFilter := strings.ToLower(c.Query("level")) // info, warn, error @@ -117,6 +137,11 @@ func (h *CerberusLogsHandler) LiveLogs(c *gin.Context) { return } + // Update activity timestamp + if h.tracker != nil { + h.tracker.UpdateActivity(subscriberID) + } + case <-ticker.C: // Send ping to keep connection alive if err := conn.WriteMessage(websocket.PingMessage, []byte{}); err != nil { diff --git a/backend/internal/api/handlers/cerberus_logs_ws_test.go b/backend/internal/api/handlers/cerberus_logs_ws_test.go index 281e732d..6ab8229f 100644 --- a/backend/internal/api/handlers/cerberus_logs_ws_test.go +++ b/backend/internal/api/handlers/cerberus_logs_ws_test.go @@ -29,10 +29,12 @@ func TestCerberusLogsHandler_NewHandler(t *testing.T) { t.Parallel() watcher := services.NewLogWatcher("/tmp/test.log") - handler := NewCerberusLogsHandler(watcher) + tracker := services.NewWebSocketTracker() + handler := NewCerberusLogsHandler(watcher, tracker) assert.NotNil(t, handler) assert.Equal(t, watcher, handler.watcher) + assert.Equal(t, tracker, handler.tracker) } // TestCerberusLogsHandler_SuccessfulConnection verifies WebSocket upgrade. @@ -51,7 +53,7 @@ func TestCerberusLogsHandler_SuccessfulConnection(t *testing.T) { require.NoError(t, err) defer watcher.Stop() - handler := NewCerberusLogsHandler(watcher) + handler := NewCerberusLogsHandler(watcher, nil) // Create test server router := gin.New() @@ -88,7 +90,7 @@ func TestCerberusLogsHandler_ReceiveLogEntries(t *testing.T) { require.NoError(t, err) defer watcher.Stop() - handler := NewCerberusLogsHandler(watcher) + handler := NewCerberusLogsHandler(watcher, nil) // Create test server router := gin.New() @@ -157,7 +159,7 @@ func TestCerberusLogsHandler_SourceFilter(t *testing.T) { require.NoError(t, err) defer watcher.Stop() - handler := NewCerberusLogsHandler(watcher) + handler := NewCerberusLogsHandler(watcher, nil) router := gin.New() router.GET("/ws", handler.LiveLogs) @@ -236,7 +238,7 @@ func TestCerberusLogsHandler_BlockedOnlyFilter(t *testing.T) { require.NoError(t, err) defer watcher.Stop() - handler := NewCerberusLogsHandler(watcher) + handler := NewCerberusLogsHandler(watcher, nil) router := gin.New() router.GET("/ws", handler.LiveLogs) @@ -313,7 +315,7 @@ func TestCerberusLogsHandler_IPFilter(t *testing.T) { require.NoError(t, err) defer watcher.Stop() - handler := NewCerberusLogsHandler(watcher) + handler := NewCerberusLogsHandler(watcher, nil) router := gin.New() router.GET("/ws", handler.LiveLogs) @@ -388,7 +390,7 @@ func TestCerberusLogsHandler_ClientDisconnect(t *testing.T) { require.NoError(t, err) defer watcher.Stop() - handler := NewCerberusLogsHandler(watcher) + handler := NewCerberusLogsHandler(watcher, nil) router := gin.New() router.GET("/ws", handler.LiveLogs) @@ -424,7 +426,7 @@ func TestCerberusLogsHandler_MultipleClients(t *testing.T) { require.NoError(t, err) defer watcher.Stop() - handler := NewCerberusLogsHandler(watcher) + handler := NewCerberusLogsHandler(watcher, nil) router := gin.New() router.GET("/ws", handler.LiveLogs) @@ -486,7 +488,7 @@ func TestCerberusLogsHandler_UpgradeFailure(t *testing.T) { t.Parallel() watcher := services.NewLogWatcher("/tmp/test.log") - handler := NewCerberusLogsHandler(watcher) + handler := NewCerberusLogsHandler(watcher, nil) router := gin.New() router.GET("/ws", handler.LiveLogs) diff --git a/backend/internal/api/handlers/logs_ws.go b/backend/internal/api/handlers/logs_ws.go index 47608f5d..ecb880db 100644 --- a/backend/internal/api/handlers/logs_ws.go +++ b/backend/internal/api/handlers/logs_ws.go @@ -10,6 +10,7 @@ import ( "github.com/gorilla/websocket" "github.com/Wikid82/charon/backend/internal/logger" + "github.com/Wikid82/charon/backend/internal/services" ) var upgrader = websocket.Upgrader{ @@ -31,8 +32,26 @@ type LogEntry struct { Fields map[string]interface{} `json:"fields"` } +// LogsWSHandler handles WebSocket connections for live log streaming. +type LogsWSHandler struct { + tracker *services.WebSocketTracker +} + +// NewLogsWSHandler creates a new handler for log streaming. +func NewLogsWSHandler(tracker *services.WebSocketTracker) *LogsWSHandler { + return &LogsWSHandler{tracker: tracker} +} + // LogsWebSocketHandler handles WebSocket connections for live log streaming. +// DEPRECATED: Use NewLogsWSHandler().HandleWebSocket instead. Kept for backward compatibility. func LogsWebSocketHandler(c *gin.Context) { + // For backward compatibility, create a nil tracker if called directly + handler := NewLogsWSHandler(nil) + handler.HandleWebSocket(c) +} + +// HandleWebSocket handles WebSocket connections for live log streaming. +func (h *LogsWSHandler) HandleWebSocket(c *gin.Context) { logger.Log().Info("WebSocket connection attempt received") // Upgrade HTTP connection to WebSocket @@ -52,6 +71,22 @@ func LogsWebSocketHandler(c *gin.Context) { logger.Log().WithField("subscriber_id", subscriberID).Info("WebSocket connection established successfully") + // Register connection with tracker if available + if h.tracker != nil { + filters := c.Request.URL.RawQuery + connInfo := &services.ConnectionInfo{ + ID: subscriberID, + Type: "logs", + ConnectedAt: time.Now(), + LastActivityAt: time.Now(), + RemoteAddr: c.Request.RemoteAddr, + UserAgent: c.Request.UserAgent(), + Filters: filters, + } + h.tracker.Register(connInfo) + defer h.tracker.Unregister(subscriberID) + } + // Parse query parameters for filtering levelFilter := strings.ToLower(c.Query("level")) sourceFilter := strings.ToLower(c.Query("source")) @@ -115,6 +150,11 @@ func LogsWebSocketHandler(c *gin.Context) { return } + // Update activity timestamp + if h.tracker != nil { + h.tracker.UpdateActivity(subscriberID) + } + case <-ticker.C: // Send ping to keep connection alive if err := conn.WriteMessage(websocket.PingMessage, []byte{}); err != nil { diff --git a/backend/internal/api/handlers/websocket_status_handler.go b/backend/internal/api/handlers/websocket_status_handler.go new file mode 100644 index 00000000..dfa7d9d0 --- /dev/null +++ b/backend/internal/api/handlers/websocket_status_handler.go @@ -0,0 +1,34 @@ +package handlers + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "github.com/Wikid82/charon/backend/internal/services" +) + +// WebSocketStatusHandler provides endpoints for WebSocket connection monitoring. +type WebSocketStatusHandler struct { + tracker *services.WebSocketTracker +} + +// NewWebSocketStatusHandler creates a new handler for WebSocket status monitoring. +func NewWebSocketStatusHandler(tracker *services.WebSocketTracker) *WebSocketStatusHandler { + return &WebSocketStatusHandler{tracker: tracker} +} + +// GetConnections returns a list of all active WebSocket connections. +func (h *WebSocketStatusHandler) GetConnections(c *gin.Context) { + connections := h.tracker.GetAllConnections() + c.JSON(http.StatusOK, gin.H{ + "connections": connections, + "count": len(connections), + }) +} + +// GetStats returns aggregate statistics about WebSocket connections. +func (h *WebSocketStatusHandler) GetStats(c *gin.Context) { + stats := h.tracker.GetStats() + c.JSON(http.StatusOK, stats) +} diff --git a/backend/internal/api/handlers/websocket_status_handler_test.go b/backend/internal/api/handlers/websocket_status_handler_test.go new file mode 100644 index 00000000..b0fa8abc --- /dev/null +++ b/backend/internal/api/handlers/websocket_status_handler_test.go @@ -0,0 +1,169 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/Wikid82/charon/backend/internal/services" +) + +func TestWebSocketStatusHandler_GetConnections(t *testing.T) { + gin.SetMode(gin.TestMode) + + tracker := services.NewWebSocketTracker() + handler := NewWebSocketStatusHandler(tracker) + + // Register test connections + conn1 := &services.ConnectionInfo{ + ID: "conn-1", + Type: "logs", + ConnectedAt: time.Now(), + LastActivityAt: time.Now(), + RemoteAddr: "192.168.1.1:12345", + UserAgent: "Mozilla/5.0", + Filters: "level=error", + } + conn2 := &services.ConnectionInfo{ + ID: "conn-2", + Type: "cerberus", + ConnectedAt: time.Now(), + LastActivityAt: time.Now(), + RemoteAddr: "192.168.1.2:54321", + UserAgent: "Chrome/90.0", + Filters: "source=waf", + } + + tracker.Register(conn1) + tracker.Register(conn2) + + // Create test request + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/websocket/connections", nil) + + // Call handler + handler.GetConnections(c) + + // Verify response + assert.Equal(t, http.StatusOK, w.Code) + + var response map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &response) + require.NoError(t, err) + + assert.Equal(t, float64(2), response["count"]) + connections, ok := response["connections"].([]interface{}) + require.True(t, ok) + assert.Len(t, connections, 2) +} + +func TestWebSocketStatusHandler_GetConnectionsEmpty(t *testing.T) { + gin.SetMode(gin.TestMode) + + tracker := services.NewWebSocketTracker() + handler := NewWebSocketStatusHandler(tracker) + + // Create test request + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/websocket/connections", nil) + + // Call handler + handler.GetConnections(c) + + // Verify response + assert.Equal(t, http.StatusOK, w.Code) + + var response map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &response) + require.NoError(t, err) + + assert.Equal(t, float64(0), response["count"]) + connections, ok := response["connections"].([]interface{}) + require.True(t, ok) + assert.Len(t, connections, 0) +} + +func TestWebSocketStatusHandler_GetStats(t *testing.T) { + gin.SetMode(gin.TestMode) + + tracker := services.NewWebSocketTracker() + handler := NewWebSocketStatusHandler(tracker) + + // Register test connections + conn1 := &services.ConnectionInfo{ + ID: "conn-1", + Type: "logs", + ConnectedAt: time.Now(), + } + conn2 := &services.ConnectionInfo{ + ID: "conn-2", + Type: "logs", + ConnectedAt: time.Now(), + } + conn3 := &services.ConnectionInfo{ + ID: "conn-3", + Type: "cerberus", + ConnectedAt: time.Now(), + } + + tracker.Register(conn1) + tracker.Register(conn2) + tracker.Register(conn3) + + // Create test request + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/websocket/stats", nil) + + // Call handler + handler.GetStats(c) + + // Verify response + assert.Equal(t, http.StatusOK, w.Code) + + var stats services.ConnectionStats + err := json.Unmarshal(w.Body.Bytes(), &stats) + require.NoError(t, err) + + assert.Equal(t, 3, stats.TotalActive) + assert.Equal(t, 2, stats.LogsConnections) + assert.Equal(t, 1, stats.CerberusConnections) + assert.NotNil(t, stats.OldestConnection) + assert.False(t, stats.LastUpdated.IsZero()) +} + +func TestWebSocketStatusHandler_GetStatsEmpty(t *testing.T) { + gin.SetMode(gin.TestMode) + + tracker := services.NewWebSocketTracker() + handler := NewWebSocketStatusHandler(tracker) + + // Create test request + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/websocket/stats", nil) + + // Call handler + handler.GetStats(c) + + // Verify response + assert.Equal(t, http.StatusOK, w.Code) + + var stats services.ConnectionStats + err := json.Unmarshal(w.Body.Bytes(), &stats) + require.NoError(t, err) + + assert.Equal(t, 0, stats.TotalActive) + assert.Equal(t, 0, stats.LogsConnections) + assert.Equal(t, 0, stats.CerberusConnections) + assert.Nil(t, stats.OldestConnection) + assert.False(t, stats.LastUpdated.IsZero()) +} diff --git a/backend/internal/api/routes/routes.go b/backend/internal/api/routes/routes.go index 12374854..4371865b 100644 --- a/backend/internal/api/routes/routes.go +++ b/backend/internal/api/routes/routes.go @@ -119,6 +119,10 @@ func Register(router *gin.Engine, db *gorm.DB, cfg config.Config) error { logService := services.NewLogService(&cfg) logsHandler := handlers.NewLogsHandler(logService) + // WebSocket tracker for connection monitoring + wsTracker := services.NewWebSocketTracker() + wsStatusHandler := handlers.NewWebSocketStatusHandler(wsTracker) + // Notification Service (needed for multiple handlers) notificationService := services.NewNotificationService(db) @@ -160,7 +164,14 @@ func Register(router *gin.Engine, db *gorm.DB, cfg config.Config) error { protected.GET("/logs", logsHandler.List) protected.GET("/logs/:filename", logsHandler.Read) protected.GET("/logs/:filename/download", logsHandler.Download) - protected.GET("/logs/live", handlers.LogsWebSocketHandler) + + // WebSocket endpoints + logsWSHandler := handlers.NewLogsWSHandler(wsTracker) + protected.GET("/logs/live", logsWSHandler.HandleWebSocket) + + // WebSocket status monitoring + protected.GET("/websocket/connections", wsStatusHandler.GetConnections) + protected.GET("/websocket/stats", wsStatusHandler.GetStats) // Security Notification Settings securityNotificationService := services.NewSecurityNotificationService(db) @@ -395,7 +406,7 @@ func Register(router *gin.Engine, db *gorm.DB, cfg config.Config) error { if err := logWatcher.Start(context.Background()); err != nil { logger.Log().WithError(err).Error("Failed to start security log watcher") } - cerberusLogsHandler := handlers.NewCerberusLogsHandler(logWatcher) + cerberusLogsHandler := handlers.NewCerberusLogsHandler(logWatcher, wsTracker) protected.GET("/cerberus/logs/ws", cerberusLogsHandler.LiveLogs) // Access Lists diff --git a/backend/internal/services/websocket_tracker.go b/backend/internal/services/websocket_tracker.go new file mode 100644 index 00000000..1a2ed7ad --- /dev/null +++ b/backend/internal/services/websocket_tracker.go @@ -0,0 +1,140 @@ +// Package services provides business logic services for the application. +package services + +import ( + "sync" + "time" + + "github.com/Wikid82/charon/backend/internal/logger" +) + +// ConnectionInfo tracks information about a single WebSocket connection. +type ConnectionInfo struct { + ID string `json:"id"` + Type string `json:"type"` // "logs" or "cerberus" + ConnectedAt time.Time `json:"connected_at"` + LastActivityAt time.Time `json:"last_activity_at"` + RemoteAddr string `json:"remote_addr,omitempty"` + UserAgent string `json:"user_agent,omitempty"` + Filters string `json:"filters,omitempty"` // Query parameters used for filtering +} + +// ConnectionStats provides aggregate statistics about WebSocket connections. +type ConnectionStats struct { + TotalActive int `json:"total_active"` + LogsConnections int `json:"logs_connections"` + CerberusConnections int `json:"cerberus_connections"` + OldestConnection *time.Time `json:"oldest_connection,omitempty"` + LastUpdated time.Time `json:"last_updated"` +} + +// WebSocketTracker tracks active WebSocket connections and provides statistics. +type WebSocketTracker struct { + mu sync.RWMutex + connections map[string]*ConnectionInfo +} + +// NewWebSocketTracker creates a new WebSocket connection tracker. +func NewWebSocketTracker() *WebSocketTracker { + return &WebSocketTracker{ + connections: make(map[string]*ConnectionInfo), + } +} + +// Register adds a new WebSocket connection to tracking. +func (t *WebSocketTracker) Register(conn *ConnectionInfo) { + t.mu.Lock() + defer t.mu.Unlock() + + t.connections[conn.ID] = conn + logger.Log().WithField("connection_id", conn.ID). + WithField("type", conn.Type). + WithField("remote_addr", conn.RemoteAddr). + Debug("WebSocket connection registered") +} + +// Unregister removes a WebSocket connection from tracking. +func (t *WebSocketTracker) Unregister(connectionID string) { + t.mu.Lock() + defer t.mu.Unlock() + + if conn, exists := t.connections[connectionID]; exists { + duration := time.Since(conn.ConnectedAt) + logger.Log().WithField("connection_id", connectionID). + WithField("type", conn.Type). + WithField("duration", duration.String()). + Debug("WebSocket connection unregistered") + delete(t.connections, connectionID) + } +} + +// UpdateActivity updates the last activity timestamp for a connection. +func (t *WebSocketTracker) UpdateActivity(connectionID string) { + t.mu.Lock() + defer t.mu.Unlock() + + if conn, exists := t.connections[connectionID]; exists { + conn.LastActivityAt = time.Now() + } +} + +// GetConnection retrieves information about a specific connection. +func (t *WebSocketTracker) GetConnection(connectionID string) (*ConnectionInfo, bool) { + t.mu.RLock() + defer t.mu.RUnlock() + + conn, exists := t.connections[connectionID] + return conn, exists +} + +// GetAllConnections returns a slice of all active connections. +func (t *WebSocketTracker) GetAllConnections() []*ConnectionInfo { + t.mu.RLock() + defer t.mu.RUnlock() + + connections := make([]*ConnectionInfo, 0, len(t.connections)) + for _, conn := range t.connections { + // Create a copy to avoid race conditions + connCopy := *conn + connections = append(connections, &connCopy) + } + return connections +} + +// GetStats returns aggregate statistics about WebSocket connections. +func (t *WebSocketTracker) GetStats() *ConnectionStats { + t.mu.RLock() + defer t.mu.RUnlock() + + stats := &ConnectionStats{ + TotalActive: len(t.connections), + LogsConnections: 0, + CerberusConnections: 0, + LastUpdated: time.Now(), + } + + var oldestTime *time.Time + for _, conn := range t.connections { + switch conn.Type { + case "logs": + stats.LogsConnections++ + case "cerberus": + stats.CerberusConnections++ + } + + if oldestTime == nil || conn.ConnectedAt.Before(*oldestTime) { + t := conn.ConnectedAt + oldestTime = &t + } + } + + stats.OldestConnection = oldestTime + return stats +} + +// GetCount returns the total number of active connections. +func (t *WebSocketTracker) GetCount() int { + t.mu.RLock() + defer t.mu.RUnlock() + return len(t.connections) +} diff --git a/backend/internal/services/websocket_tracker_test.go b/backend/internal/services/websocket_tracker_test.go new file mode 100644 index 00000000..11562b64 --- /dev/null +++ b/backend/internal/services/websocket_tracker_test.go @@ -0,0 +1,225 @@ +package services + +import ( + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewWebSocketTracker(t *testing.T) { + tracker := NewWebSocketTracker() + assert.NotNil(t, tracker) + assert.NotNil(t, tracker.connections) + assert.Equal(t, 0, tracker.GetCount()) +} + +func TestWebSocketTracker_Register(t *testing.T) { + tracker := NewWebSocketTracker() + + conn := &ConnectionInfo{ + ID: "test-conn-1", + Type: "logs", + ConnectedAt: time.Now(), + LastActivityAt: time.Now(), + RemoteAddr: "192.168.1.1:12345", + UserAgent: "Mozilla/5.0", + Filters: "level=error", + } + + tracker.Register(conn) + assert.Equal(t, 1, tracker.GetCount()) + + // Verify the connection is retrievable + retrieved, exists := tracker.GetConnection("test-conn-1") + assert.True(t, exists) + assert.Equal(t, conn.ID, retrieved.ID) + assert.Equal(t, conn.Type, retrieved.Type) +} + +func TestWebSocketTracker_Unregister(t *testing.T) { + tracker := NewWebSocketTracker() + + conn := &ConnectionInfo{ + ID: "test-conn-1", + Type: "cerberus", + ConnectedAt: time.Now(), + } + + tracker.Register(conn) + assert.Equal(t, 1, tracker.GetCount()) + + tracker.Unregister("test-conn-1") + assert.Equal(t, 0, tracker.GetCount()) + + // Verify the connection is no longer retrievable + _, exists := tracker.GetConnection("test-conn-1") + assert.False(t, exists) +} + +func TestWebSocketTracker_UnregisterNonExistent(t *testing.T) { + tracker := NewWebSocketTracker() + + // Should not panic + tracker.Unregister("non-existent-id") + assert.Equal(t, 0, tracker.GetCount()) +} + +func TestWebSocketTracker_UpdateActivity(t *testing.T) { + tracker := NewWebSocketTracker() + + initialTime := time.Now().Add(-1 * time.Hour) + conn := &ConnectionInfo{ + ID: "test-conn-1", + Type: "logs", + ConnectedAt: initialTime, + LastActivityAt: initialTime, + } + + tracker.Register(conn) + + // Wait a moment to ensure time difference + time.Sleep(10 * time.Millisecond) + + tracker.UpdateActivity("test-conn-1") + + retrieved, exists := tracker.GetConnection("test-conn-1") + require.True(t, exists) + assert.True(t, retrieved.LastActivityAt.After(initialTime)) +} + +func TestWebSocketTracker_UpdateActivityNonExistent(t *testing.T) { + tracker := NewWebSocketTracker() + + // Should not panic + tracker.UpdateActivity("non-existent-id") +} + +func TestWebSocketTracker_GetAllConnections(t *testing.T) { + tracker := NewWebSocketTracker() + + conn1 := &ConnectionInfo{ + ID: "conn-1", + Type: "logs", + ConnectedAt: time.Now(), + } + conn2 := &ConnectionInfo{ + ID: "conn-2", + Type: "cerberus", + ConnectedAt: time.Now(), + } + + tracker.Register(conn1) + tracker.Register(conn2) + + connections := tracker.GetAllConnections() + assert.Equal(t, 2, len(connections)) + + // Verify both connections are present (order may vary) + ids := make(map[string]bool) + for _, conn := range connections { + ids[conn.ID] = true + } + assert.True(t, ids["conn-1"]) + assert.True(t, ids["conn-2"]) +} + +func TestWebSocketTracker_GetStats(t *testing.T) { + tracker := NewWebSocketTracker() + + now := time.Now() + oldestTime := now.Add(-10 * time.Minute) + + conn1 := &ConnectionInfo{ + ID: "conn-1", + Type: "logs", + ConnectedAt: now, + } + conn2 := &ConnectionInfo{ + ID: "conn-2", + Type: "cerberus", + ConnectedAt: oldestTime, + } + conn3 := &ConnectionInfo{ + ID: "conn-3", + Type: "logs", + ConnectedAt: now.Add(-5 * time.Minute), + } + + tracker.Register(conn1) + tracker.Register(conn2) + tracker.Register(conn3) + + stats := tracker.GetStats() + assert.Equal(t, 3, stats.TotalActive) + assert.Equal(t, 2, stats.LogsConnections) + assert.Equal(t, 1, stats.CerberusConnections) + assert.NotNil(t, stats.OldestConnection) + assert.True(t, stats.OldestConnection.Equal(oldestTime)) + assert.False(t, stats.LastUpdated.IsZero()) +} + +func TestWebSocketTracker_GetStatsEmpty(t *testing.T) { + tracker := NewWebSocketTracker() + + stats := tracker.GetStats() + assert.Equal(t, 0, stats.TotalActive) + assert.Equal(t, 0, stats.LogsConnections) + assert.Equal(t, 0, stats.CerberusConnections) + assert.Nil(t, stats.OldestConnection) + assert.False(t, stats.LastUpdated.IsZero()) +} + +func TestWebSocketTracker_ConcurrentAccess(t *testing.T) { + tracker := NewWebSocketTracker() + + // Test concurrent registration + done := make(chan bool) + for i := 0; i < 10; i++ { + go func(id int) { + conn := &ConnectionInfo{ + ID: fmt.Sprintf("conn-%d", id), + Type: "logs", + ConnectedAt: time.Now(), + } + tracker.Register(conn) + done <- true + }(i) + } + + // Wait for all goroutines to complete + for i := 0; i < 10; i++ { + <-done + } + + assert.Equal(t, 10, tracker.GetCount()) + + // Test concurrent read + for i := 0; i < 10; i++ { + go func() { + _ = tracker.GetAllConnections() + _ = tracker.GetStats() + done <- true + }() + } + + for i := 0; i < 10; i++ { + <-done + } + + // Test concurrent unregister + for i := 0; i < 10; i++ { + go func(id int) { + tracker.Unregister(fmt.Sprintf("conn-%d", id)) + done <- true + }(i) + } + + for i := 0; i < 10; i++ { + <-done + } + + assert.Equal(t, 0, tracker.GetCount()) +} diff --git a/docs/features.md b/docs/features.md index 349bbcc4..e1cb0936 100644 --- a/docs/features.md +++ b/docs/features.md @@ -545,6 +545,39 @@ Uses WebSocket technology to stream logs with zero delay. - `?source=waf` — Only WAF-related events - `?source=cerberus` — All Cerberus security events +### WebSocket Connection Monitoring + +**What it does:** Tracks and displays all active WebSocket connections in real-time, helping you troubleshoot connection issues and monitor system health. + +**What you see:** + +- Total active WebSocket connections +- Breakdown by connection type (General Logs vs Security Logs) +- Oldest connection age +- Detailed connection information: + - Connection ID and type + - Remote address (client IP) + - Active filters being used + - Connection duration + +**Where to find it:** System Settings → WebSocket Connections card + +**API Endpoints:** Programmatically access WebSocket statistics: + +- `GET /api/v1/websocket/stats` — Aggregate connection statistics +- `GET /api/v1/websocket/connections` — Detailed list of all active connections + +**Use cases:** + +- **Troubleshooting:** Verify WebSocket connections are working when live logs aren't updating +- **Monitoring:** Track how many users are viewing live logs in real-time +- **Debugging:** Identify connection issues with proxy/load balancer configurations +- **Capacity Planning:** Understand WebSocket connection patterns and usage + +**Auto-refresh:** The status card automatically updates every 5 seconds to show current connection state. + +**See also:** [WebSocket Troubleshooting Guide](troubleshooting/websocket.md) for help resolving connection issues. + ### Notification System **What it does:** Sends alerts when security events match your configured criteria. diff --git a/docs/live-logs-guide.md b/docs/live-logs-guide.md index 69ad8d0d..18e43349 100644 --- a/docs/live-logs-guide.md +++ b/docs/live-logs-guide.md @@ -595,6 +595,7 @@ ws.onmessage = (event) => { - **[Security Guide](https://wikid82.github.io/charon/security)** \u2014 Learn about Cerberus features - **[API Documentation](https://wikid82.github.io/charon/api)** \u2014 Full API reference - **[Features Overview](https://wikid82.github.io/charon/features)** \u2014 See all Charon capabilities +- **[WebSocket Troubleshooting](troubleshooting/websocket.md)** — Fix WebSocket connection issues - **[Troubleshooting](https://wikid82.github.io/charon/troubleshooting)** \u2014 Common issues and solutions --- diff --git a/docs/troubleshooting/websocket.md b/docs/troubleshooting/websocket.md new file mode 100644 index 00000000..824e4043 --- /dev/null +++ b/docs/troubleshooting/websocket.md @@ -0,0 +1,364 @@ +# Troubleshooting WebSocket Issues + +WebSocket connections are used in Charon for real-time features like live log streaming. If you're experiencing issues with WebSocket connections (e.g., logs not updating in real-time), this guide will help you diagnose and resolve the problem. + +## Quick Diagnostics + +### Check WebSocket Connection Status + +1. Go to **System Settings** in the Charon UI +2. Scroll to the **WebSocket Connections** card +3. Check if there are active connections displayed + +The WebSocket status card shows: +- Total number of active WebSocket connections +- Breakdown by type (General Logs vs Security Logs) +- Oldest connection age +- Detailed connection info (when expanded) + +### Browser Console Check + +Open your browser's Developer Tools (F12) and check the Console tab for: +- WebSocket connection errors +- Connection refused messages +- Authentication failures +- CORS errors + +## Common Issues and Solutions + +### 1. Proxy/Load Balancer Configuration + +**Symptom:** WebSocket connections fail to establish or disconnect immediately. + +**Cause:** If running Charon behind a reverse proxy (Nginx, Apache, HAProxy, or load balancer), the proxy might be terminating WebSocket connections or not forwarding the upgrade request properly. + +**Solution:** + +#### Nginx Configuration + +```nginx +location /api/v1/logs/live { + proxy_pass http://charon:8080; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # Increase timeouts for long-lived connections + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; +} + +location /api/v1/cerberus/logs/ws { + proxy_pass http://charon:8080; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # Increase timeouts for long-lived connections + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; +} +``` + +Key requirements: +- `proxy_http_version 1.1` — Required for WebSocket support +- `Upgrade` and `Connection` headers — Required for WebSocket upgrade +- Long `proxy_read_timeout` — Prevents connection from timing out + +#### Apache Configuration + +```apache + + ServerName charon.example.com + + # Enable WebSocket proxy + ProxyRequests Off + ProxyPreserveHost On + + # WebSocket endpoints + ProxyPass /api/v1/logs/live ws://localhost:8080/api/v1/logs/live retry=0 timeout=3600 + ProxyPassReverse /api/v1/logs/live ws://localhost:8080/api/v1/logs/live + + ProxyPass /api/v1/cerberus/logs/ws ws://localhost:8080/api/v1/cerberus/logs/ws retry=0 timeout=3600 + ProxyPassReverse /api/v1/cerberus/logs/ws ws://localhost:8080/api/v1/cerberus/logs/ws + + # Regular HTTP endpoints + ProxyPass / http://localhost:8080/ + ProxyPassReverse / http://localhost:8080/ + +``` + +Required modules: +```bash +a2enmod proxy proxy_http proxy_wstunnel +``` + +### 2. Network Timeouts + +**Symptom:** WebSocket connections work initially but disconnect after some idle time. + +**Cause:** Intermediate network infrastructure (firewalls, load balancers, NAT devices) may have idle timeout settings shorter than the WebSocket keepalive interval. + +**Solution:** + +Charon sends WebSocket ping frames every 30 seconds to keep connections alive. If you're still experiencing timeouts: + +1. **Check proxy timeout settings** (see above) +2. **Check firewall idle timeout:** + ```bash + # Linux iptables + iptables -L -v -n | grep ESTABLISHED + + # If timeout is too short, increase it: + iptables -A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT + echo 3600 > /proc/sys/net/netfilter/nf_conntrack_tcp_timeout_established + ``` + +3. **Check load balancer settings:** + - AWS ALB/ELB: Set idle timeout to 3600 seconds + - GCP Load Balancer: Set timeout to 1 hour + - Azure Load Balancer: Set idle timeout to maximum + +### 3. HTTPS Certificate Errors (Docker) + +**Symptom:** WebSocket connections fail with TLS/certificate errors, especially in Docker environments. + +**Cause:** Missing CA certificates in the Docker container, or self-signed certificates not trusted by the browser. + +**Solution:** + +#### Install CA Certificates (Docker) + +Add to your Dockerfile: +```dockerfile +RUN apt-get update && apt-get install -y ca-certificates && update-ca-certificates +``` + +Or for existing containers: +```bash +docker exec -it charon apt-get update && apt-get install -y ca-certificates +``` + +#### For Self-Signed Certificates (Development Only) + +**Warning:** This compromises security. Only use in development environments. + +Set environment variable: +```bash +docker run -e FF_IGNORE_CERT_ERRORS=1 charon:latest +``` + +Or in docker-compose.yml: +```yaml +services: + charon: + environment: + - FF_IGNORE_CERT_ERRORS=1 +``` + +#### Better Solution: Use Valid Certificates + +1. Use Let's Encrypt (free, automated) +2. Use a trusted CA certificate +3. Import your self-signed cert into the browser's trust store + +### 4. Firewall Settings + +**Symptom:** WebSocket connections fail or time out. + +**Cause:** Firewall blocking WebSocket traffic on ports 80/443. + +**Solution:** + +#### Linux (iptables) + +Allow WebSocket traffic: +```bash +# Allow HTTP/HTTPS +iptables -A INPUT -p tcp --dport 80 -j ACCEPT +iptables -A INPUT -p tcp --dport 443 -j ACCEPT + +# Allow established connections (for WebSocket) +iptables -A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT + +# Save rules +iptables-save > /etc/iptables/rules.v4 +``` + +#### Docker + +Ensure ports are exposed: +```yaml +services: + charon: + ports: + - "8080:8080" + - "443:443" +``` + +#### Cloud Providers + +- **AWS:** Add inbound rules to Security Group for ports 80/443 +- **GCP:** Add firewall rules for ports 80/443 +- **Azure:** Add Network Security Group rules for ports 80/443 + +### 5. Connection Stability / Packet Loss + +**Symptom:** Frequent WebSocket disconnections and reconnections. + +**Cause:** Unstable network with packet loss prevents WebSocket connections from staying open. + +**Solution:** + +#### Check Network Stability + +```bash +# Ping test +ping -c 100 charon.example.com + +# Check packet loss (should be < 1%) +mtr charon.example.com +``` + +#### Enable Connection Retry (Client-Side) + +The Charon frontend automatically handles reconnection for security logs but not general logs. If you need more robust reconnection: + +1. Monitor the WebSocket status in System Settings +2. Refresh the page if connections are frequently dropping +3. Consider using a more stable network connection +4. Check if VPN or proxy is causing issues + +### 6. Browser Compatibility + +**Symptom:** WebSocket connections don't work in certain browsers. + +**Cause:** Very old browsers don't support WebSocket protocol. + +**Supported Browsers:** +- Chrome 16+ ✅ +- Firefox 11+ ✅ +- Safari 7+ ✅ +- Edge (all versions) ✅ +- IE 10+ ⚠️ (deprecated, use Edge) + +**Solution:** Update to a modern browser. + +### 7. CORS Issues + +**Symptom:** Browser console shows CORS errors with WebSocket connections. + +**Cause:** Cross-origin WebSocket connection blocked by browser security policy. + +**Solution:** + +WebSocket connections should be same-origin (from the same domain as the Charon UI). If you're accessing Charon from a different domain: + +1. **Preferred:** Access Charon UI from the same domain +2. **Alternative:** Configure CORS in Charon (if supported) +3. **Development Only:** Use browser extension to disable CORS (NOT for production) + +### 8. Authentication Issues + +**Symptom:** WebSocket connection fails with 401 Unauthorized. + +**Cause:** Authentication token not being sent with WebSocket connection. + +**Solution:** + +Charon WebSocket endpoints support three authentication methods: + +1. **HttpOnly Cookie** (automatic) — Used by default when accessing UI from browser +2. **Query Parameter** — `?token=` +3. **Authorization Header** — Not supported for browser WebSocket connections + +If you're accessing WebSocket from a script or tool: +```javascript +const ws = new WebSocket('wss://charon.example.com/api/v1/logs/live?token=YOUR_TOKEN'); +``` + +## Monitoring WebSocket Connections + +### Using the System Settings UI + +1. Navigate to **System Settings** in Charon +2. View the **WebSocket Connections** card +3. Expand details to see: + - Connection ID + - Connection type (General/Security) + - Remote address + - Active filters + - Connection duration + +### Using the API + +Check WebSocket statistics programmatically: + +```bash +# Get connection statistics +curl -H "Authorization: Bearer YOUR_TOKEN" \ + https://charon.example.com/api/v1/websocket/stats + +# Get detailed connection list +curl -H "Authorization: Bearer YOUR_TOKEN" \ + https://charon.example.com/api/v1/websocket/connections +``` + +Response example: +```json +{ + "total_active": 2, + "logs_connections": 1, + "cerberus_connections": 1, + "oldest_connection": "2024-01-15T10:30:00Z", + "last_updated": "2024-01-15T11:00:00Z" +} +``` + +### Using Browser DevTools + +1. Open DevTools (F12) +2. Go to **Network** tab +3. Filter by **WS** (WebSocket) +4. Look for connections to: + - `/api/v1/logs/live` + - `/api/v1/cerberus/logs/ws` + +Check: +- Status should be `101 Switching Protocols` +- Messages tab shows incoming log entries +- No errors in Frames tab + +## Still Having Issues? + +If none of the above solutions work: + +1. **Check Charon logs:** + ```bash + docker logs charon | grep -i websocket + ``` + +2. **Enable debug logging** (if available) + +3. **Report an issue on GitHub:** + - [Charon Issues](https://github.com/Wikid82/charon/issues) + - Include: + - Charon version + - Browser and version + - Proxy/load balancer configuration + - Error messages from browser console + - Charon server logs + +## See Also + +- [Live Logs Guide](../live-logs-guide.md) +- [Security Documentation](../security.md) +- [API Documentation](../api.md) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5f5c5bb0..05fe9c4c 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -24,7 +24,7 @@ "react-dom": "^19.2.3", "react-hook-form": "^7.68.0", "react-hot-toast": "^2.6.0", - "react-router-dom": "^7.10.1", + "react-router-dom": "^7.11.0", "tailwind-merge": "^3.4.0", "tldts": "^7.0.19" }, @@ -163,7 +163,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -523,7 +522,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -570,7 +568,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -3262,7 +3259,8 @@ "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", - "dev": true + "dev": true, + "peer": true }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -3350,7 +3348,6 @@ "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -3361,7 +3358,6 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -3401,7 +3397,6 @@ "integrity": "sha512-6/cmF2piao+f6wSxUsJLZjck7OQsYyRtcOZS02k7XINSNlz93v6emM8WutDQSXnroG2xwYlEVHJI+cPA7CPM3Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.50.0", "@typescript-eslint/types": "8.50.0", @@ -3782,7 +3777,6 @@ "integrity": "sha512-rkoPH+RqWopVxDnCBE/ysIdfQ2A7j1eDmW8tCxxrR9nnFBa9jKf86VgsSAzxBd1x+ny0GC4JgiD3SNfRHv3pOg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/utils": "4.0.16", "fflate": "^0.8.2", @@ -3818,7 +3812,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4049,7 +4042,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -4252,8 +4244,7 @@ "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "peer": true + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==" }, "node_modules/data-urls": { "version": "6.0.0", @@ -4342,7 +4333,8 @@ "version": "0.5.16", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", - "dev": true + "dev": true, + "peer": true }, "node_modules/dunder-proto": { "version": "1.0.1", @@ -4506,7 +4498,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -5399,7 +5390,6 @@ "integrity": "sha512-GtldT42B8+jefDUC4yUKAvsaOrH7PDHmZxZXNgF2xMmymjUbRYJvpAybZAKEmXDGTM0mCsz8duOa4vTm5AY2Kg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@acemir/cssom": "^0.9.28", "@asamuzakjp/dom-selector": "^6.7.6", @@ -5846,6 +5836,7 @@ "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, + "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -6259,7 +6250,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -6289,6 +6279,7 @@ "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -6303,6 +6294,7 @@ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, + "peer": true, "engines": { "node": ">=8" } @@ -6312,6 +6304,7 @@ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, + "peer": true, "engines": { "node": ">=10" }, @@ -6359,7 +6352,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -6369,7 +6361,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -6414,7 +6405,8 @@ "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", - "dev": true + "dev": true, + "peer": true }, "node_modules/react-refresh": { "version": "0.18.0", @@ -6474,9 +6466,9 @@ } }, "node_modules/react-router": { - "version": "7.10.1", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.10.1.tgz", - "integrity": "sha512-gHL89dRa3kwlUYtRQ+m8NmxGI6CgqN+k4XyGjwcFoQwwCWF6xXpOCUlDovkXClS0d0XJN/5q7kc5W3kiFEd0Yw==", + "version": "7.11.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.11.0.tgz", + "integrity": "sha512-uI4JkMmjbWCZc01WVP2cH7ZfSzH91JAZUDd7/nIprDgWxBV1TkkmLToFh7EbMTcMak8URFRa2YoBL/W8GWnCTQ==", "license": "MIT", "dependencies": { "cookie": "^1.0.1", @@ -6496,12 +6488,12 @@ } }, "node_modules/react-router-dom": { - "version": "7.10.1", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.10.1.tgz", - "integrity": "sha512-JNBANI6ChGVjA5bwsUIwJk7LHKmqB4JYnYfzFwyp2t12Izva11elds2jx7Yfoup2zssedntwU0oZ5DEmk5Sdaw==", + "version": "7.11.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.11.0.tgz", + "integrity": "sha512-e49Ir/kMGRzFOOrYQBdoitq3ULigw4lKbAyKusnvtDu2t4dBX4AGYPrzNvorXmVuOyeakai6FUPW5MmibvVG8g==", "license": "MIT", "dependencies": { - "react-router": "7.10.1" + "react-router": "7.11.0" }, "engines": { "node": ">=20.0.0" @@ -6969,7 +6961,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -7007,7 +6998,8 @@ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/update-browserslist-db": { "version": "1.2.2", @@ -7098,7 +7090,6 @@ "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -7174,7 +7165,6 @@ "integrity": "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/expect": "4.0.16", "@vitest/mocker": "4.0.16", @@ -7412,7 +7402,6 @@ "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/frontend/package.json b/frontend/package.json index cc7b36c3..9cabac50 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -43,7 +43,7 @@ "react-dom": "^19.2.3", "react-hook-form": "^7.68.0", "react-hot-toast": "^2.6.0", - "react-router-dom": "^7.10.1", + "react-router-dom": "^7.11.0", "tailwind-merge": "^3.4.0", "tldts": "^7.0.19" }, diff --git a/frontend/src/api/__tests__/websocket.test.ts b/frontend/src/api/__tests__/websocket.test.ts new file mode 100644 index 00000000..cc3cf9b6 --- /dev/null +++ b/frontend/src/api/__tests__/websocket.test.ts @@ -0,0 +1,112 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { getWebSocketConnections, getWebSocketStats } from '../websocket'; +import client from '../client'; + +vi.mock('../client'); + +describe('WebSocket API', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('getWebSocketConnections', () => { + it('should fetch WebSocket connections', async () => { + const mockResponse = { + connections: [ + { + id: 'test-conn-1', + type: 'logs', + connected_at: '2024-01-15T10:00:00Z', + last_activity_at: '2024-01-15T10:05:00Z', + remote_addr: '192.168.1.1:12345', + user_agent: 'Mozilla/5.0', + filters: 'level=error', + }, + { + id: 'test-conn-2', + type: 'cerberus', + connected_at: '2024-01-15T10:02:00Z', + last_activity_at: '2024-01-15T10:06:00Z', + remote_addr: '192.168.1.2:54321', + user_agent: 'Chrome/90.0', + filters: 'source=waf', + }, + ], + count: 2, + }; + + vi.mocked(client.get).mockResolvedValue({ data: mockResponse }); + + const result = await getWebSocketConnections(); + + expect(client.get).toHaveBeenCalledWith('/websocket/connections'); + expect(result).toEqual(mockResponse); + expect(result.count).toBe(2); + expect(result.connections).toHaveLength(2); + }); + + it('should handle empty connections', async () => { + const mockResponse = { + connections: [], + count: 0, + }; + + vi.mocked(client.get).mockResolvedValue({ data: mockResponse }); + + const result = await getWebSocketConnections(); + + expect(result.connections).toHaveLength(0); + expect(result.count).toBe(0); + }); + + it('should handle API errors', async () => { + vi.mocked(client.get).mockRejectedValue(new Error('Network error')); + + await expect(getWebSocketConnections()).rejects.toThrow('Network error'); + }); + }); + + describe('getWebSocketStats', () => { + it('should fetch WebSocket statistics', async () => { + const mockResponse = { + total_active: 3, + logs_connections: 2, + cerberus_connections: 1, + oldest_connection: '2024-01-15T09:55:00Z', + last_updated: '2024-01-15T10:10:00Z', + }; + + vi.mocked(client.get).mockResolvedValue({ data: mockResponse }); + + const result = await getWebSocketStats(); + + expect(client.get).toHaveBeenCalledWith('/websocket/stats'); + expect(result).toEqual(mockResponse); + expect(result.total_active).toBe(3); + expect(result.logs_connections).toBe(2); + expect(result.cerberus_connections).toBe(1); + }); + + it('should handle stats with no connections', async () => { + const mockResponse = { + total_active: 0, + logs_connections: 0, + cerberus_connections: 0, + last_updated: '2024-01-15T10:10:00Z', + }; + + vi.mocked(client.get).mockResolvedValue({ data: mockResponse }); + + const result = await getWebSocketStats(); + + expect(result.total_active).toBe(0); + expect(result.oldest_connection).toBeUndefined(); + }); + + it('should handle API errors', async () => { + vi.mocked(client.get).mockRejectedValue(new Error('Server error')); + + await expect(getWebSocketStats()).rejects.toThrow('Server error'); + }); + }); +}); diff --git a/frontend/src/api/websocket.ts b/frontend/src/api/websocket.ts new file mode 100644 index 00000000..11fadedb --- /dev/null +++ b/frontend/src/api/websocket.ts @@ -0,0 +1,40 @@ +import client from './client'; + +export interface ConnectionInfo { + id: string; + type: 'logs' | 'cerberus'; + connected_at: string; + last_activity_at: string; + remote_addr?: string; + user_agent?: string; + filters?: string; +} + +export interface ConnectionStats { + total_active: number; + logs_connections: number; + cerberus_connections: number; + oldest_connection?: string; + last_updated: string; +} + +export interface ConnectionsResponse { + connections: ConnectionInfo[]; + count: number; +} + +/** + * Get all active WebSocket connections + */ +export const getWebSocketConnections = async (): Promise => { + const response = await client.get('/websocket/connections'); + return response.data; +}; + +/** + * Get aggregate WebSocket connection statistics + */ +export const getWebSocketStats = async (): Promise => { + const response = await client.get('/websocket/stats'); + return response.data; +}; diff --git a/frontend/src/components/WebSocketStatusCard.tsx b/frontend/src/components/WebSocketStatusCard.tsx new file mode 100644 index 00000000..e8b37415 --- /dev/null +++ b/frontend/src/components/WebSocketStatusCard.tsx @@ -0,0 +1,175 @@ +import { useState } from 'react'; +import { Wifi, WifiOff, Activity, Clock, Filter, Globe } from 'lucide-react'; +import { useWebSocketConnections, useWebSocketStats } from '../hooks/useWebSocketStatus'; +import { + Card, + CardHeader, + CardTitle, + CardDescription, + CardContent, + Badge, + Skeleton, + Alert, +} from './ui'; +import { formatDistanceToNow } from 'date-fns'; + +interface WebSocketStatusCardProps { + className?: string; + showDetails?: boolean; +} + +/** + * Component to display WebSocket connection status and statistics + */ +export function WebSocketStatusCard({ className = '', showDetails = false }: WebSocketStatusCardProps) { + const [expanded, setExpanded] = useState(showDetails); + const { data: connections, isLoading: connectionsLoading } = useWebSocketConnections(); + const { data: stats, isLoading: statsLoading } = useWebSocketStats(); + + const isLoading = connectionsLoading || statsLoading; + + if (isLoading) { + return ( + + + + + + + + + + + + + ); + } + + if (!stats) { + return ( + + Unable to load WebSocket status + + ); + } + + const hasActiveConnections = stats.total_active > 0; + + return ( + + + + + + {hasActiveConnections ? ( + + ) : ( + + )} + + + WebSocket Connections + + Real-time connection monitoring + + + + + {stats.total_active} Active + + + + + {/* Statistics Grid */} + + + + + General Logs + + {stats.logs_connections} + + + + + Security Logs + + {stats.cerberus_connections} + + + + {/* Oldest Connection */} + {stats.oldest_connection && ( + + + + Oldest Connection + + + {formatDistanceToNow(new Date(stats.oldest_connection), { addSuffix: true })} + + + )} + + {/* Connection Details */} + {expanded && connections?.connections && connections.connections.length > 0 && ( + + Active Connections + + {connections.connections.map((conn) => ( + + + + {conn.type === 'logs' ? 'General' : 'Security'} + + + {conn.id.substring(0, 8)}... + + + {conn.remote_addr && ( + + + {conn.remote_addr} + + )} + {conn.filters && ( + + + {conn.filters} + + )} + + + + Connected {formatDistanceToNow(new Date(conn.connected_at), { addSuffix: true })} + + + + ))} + + + )} + + {/* Toggle Details Button */} + {connections?.connections && connections.connections.length > 0 && ( + setExpanded(!expanded)} + className="w-full pt-3 text-sm text-primary hover:text-primary/80 transition-colors" + > + {expanded ? 'Hide Details' : 'Show Details'} + + )} + + {/* No Connections Message */} + {!hasActiveConnections && ( + + No active WebSocket connections + + )} + + + ); +} diff --git a/frontend/src/components/__tests__/WebSocketStatusCard.test.tsx b/frontend/src/components/__tests__/WebSocketStatusCard.test.tsx new file mode 100644 index 00000000..53bfbe5f --- /dev/null +++ b/frontend/src/components/__tests__/WebSocketStatusCard.test.tsx @@ -0,0 +1,260 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { WebSocketStatusCard } from '../WebSocketStatusCard'; +import * as websocketApi from '../../api/websocket'; + +// Mock the API functions +vi.mock('../../api/websocket'); + +// Mock date-fns to avoid timezone issues in tests +vi.mock('date-fns', () => ({ + formatDistanceToNow: vi.fn(() => '5 minutes ago'), +})); + +describe('WebSocketStatusCard', () => { + let queryClient: QueryClient; + + beforeEach(() => { + queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + vi.clearAllMocks(); + }); + + const renderComponent = (props = {}) => { + return render( + + + + ); + }; + + it('should render loading state', () => { + vi.mocked(websocketApi.getWebSocketConnections).mockReturnValue( + new Promise(() => {}) // Never resolves + ); + vi.mocked(websocketApi.getWebSocketStats).mockReturnValue( + new Promise(() => {}) // Never resolves + ); + + renderComponent(); + + // Loading state shows skeleton elements + expect(screen.getAllByRole('generic').length).toBeGreaterThan(0); + }); + + it('should render with no active connections', async () => { + vi.mocked(websocketApi.getWebSocketConnections).mockResolvedValue({ + connections: [], + count: 0, + }); + vi.mocked(websocketApi.getWebSocketStats).mockResolvedValue({ + total_active: 0, + logs_connections: 0, + cerberus_connections: 0, + last_updated: '2024-01-15T10:10:00Z', + }); + + renderComponent(); + + await waitFor(() => { + expect(screen.getByText('WebSocket Connections')).toBeInTheDocument(); + }); + + expect(screen.getByText('0 Active')).toBeInTheDocument(); + expect(screen.getByText('No active WebSocket connections')).toBeInTheDocument(); + }); + + it('should render with active connections', async () => { + const mockConnections = [ + { + id: 'conn-1', + type: 'logs' as const, + connected_at: '2024-01-15T10:00:00Z', + last_activity_at: '2024-01-15T10:05:00Z', + remote_addr: '192.168.1.1:12345', + filters: 'level=error', + }, + { + id: 'conn-2', + type: 'cerberus' as const, + connected_at: '2024-01-15T10:02:00Z', + last_activity_at: '2024-01-15T10:06:00Z', + remote_addr: '192.168.1.2:54321', + filters: 'source=waf', + }, + ]; + + vi.mocked(websocketApi.getWebSocketConnections).mockResolvedValue({ + connections: mockConnections, + count: 2, + }); + vi.mocked(websocketApi.getWebSocketStats).mockResolvedValue({ + total_active: 2, + logs_connections: 1, + cerberus_connections: 1, + oldest_connection: '2024-01-15T10:00:00Z', + last_updated: '2024-01-15T10:10:00Z', + }); + + renderComponent(); + + await waitFor(() => { + expect(screen.getByText('WebSocket Connections')).toBeInTheDocument(); + }); + + expect(screen.getByText('2 Active')).toBeInTheDocument(); + expect(screen.getByText('General Logs')).toBeInTheDocument(); + expect(screen.getByText('Security Logs')).toBeInTheDocument(); + // Use getAllByText since we have two "1" values + const ones = screen.getAllByText('1'); + expect(ones).toHaveLength(2); + }); + + it('should show details when expanded', async () => { + const mockConnections = [ + { + id: 'conn-123', + type: 'logs' as const, + connected_at: '2024-01-15T10:00:00Z', + last_activity_at: '2024-01-15T10:05:00Z', + remote_addr: '192.168.1.1:12345', + filters: 'level=error', + }, + ]; + + vi.mocked(websocketApi.getWebSocketConnections).mockResolvedValue({ + connections: mockConnections, + count: 1, + }); + vi.mocked(websocketApi.getWebSocketStats).mockResolvedValue({ + total_active: 1, + logs_connections: 1, + cerberus_connections: 0, + last_updated: '2024-01-15T10:10:00Z', + }); + + renderComponent({ showDetails: true }); + + await waitFor(() => { + expect(screen.getByText('WebSocket Connections')).toBeInTheDocument(); + }); + + // Check for connection details + expect(screen.getByText('Active Connections')).toBeInTheDocument(); + expect(screen.getByText(/conn-123/i)).toBeInTheDocument(); + expect(screen.getByText('192.168.1.1:12345')).toBeInTheDocument(); + expect(screen.getByText('level=error')).toBeInTheDocument(); + }); + + it('should toggle details on button click', async () => { + const user = userEvent.setup(); + const mockConnections = [ + { + id: 'conn-1', + type: 'logs' as const, + connected_at: '2024-01-15T10:00:00Z', + last_activity_at: '2024-01-15T10:05:00Z', + }, + ]; + + vi.mocked(websocketApi.getWebSocketConnections).mockResolvedValue({ + connections: mockConnections, + count: 1, + }); + vi.mocked(websocketApi.getWebSocketStats).mockResolvedValue({ + total_active: 1, + logs_connections: 1, + cerberus_connections: 0, + last_updated: '2024-01-15T10:10:00Z', + }); + + renderComponent(); + + await waitFor(() => { + expect(screen.getByText('Show Details')).toBeInTheDocument(); + }); + + // Initially hidden + expect(screen.queryByText('Active Connections')).not.toBeInTheDocument(); + + // Click to show + await user.click(screen.getByText('Show Details')); + + await waitFor(() => { + expect(screen.getByText('Active Connections')).toBeInTheDocument(); + }); + + // Click to hide + await user.click(screen.getByText('Hide Details')); + + await waitFor(() => { + expect(screen.queryByText('Active Connections')).not.toBeInTheDocument(); + }); + }); + + it('should handle API errors gracefully', async () => { + vi.mocked(websocketApi.getWebSocketConnections).mockRejectedValue( + new Error('API Error') + ); + vi.mocked(websocketApi.getWebSocketStats).mockRejectedValue( + new Error('API Error') + ); + + renderComponent(); + + await waitFor(() => { + expect(screen.getByText('Unable to load WebSocket status')).toBeInTheDocument(); + }); + }); + + it('should display oldest connection when available', async () => { + vi.mocked(websocketApi.getWebSocketConnections).mockResolvedValue({ + connections: [], + count: 1, + }); + vi.mocked(websocketApi.getWebSocketStats).mockResolvedValue({ + total_active: 1, + logs_connections: 1, + cerberus_connections: 0, + oldest_connection: '2024-01-15T09:55:00Z', + last_updated: '2024-01-15T10:10:00Z', + }); + + renderComponent(); + + await waitFor(() => { + expect(screen.getByText('Oldest Connection')).toBeInTheDocument(); + }); + + expect(screen.getByText('5 minutes ago')).toBeInTheDocument(); + }); + + it('should apply custom className', async () => { + vi.mocked(websocketApi.getWebSocketConnections).mockResolvedValue({ + connections: [], + count: 0, + }); + vi.mocked(websocketApi.getWebSocketStats).mockResolvedValue({ + total_active: 0, + logs_connections: 0, + cerberus_connections: 0, + last_updated: '2024-01-15T10:10:00Z', + }); + + const { container } = renderComponent({ className: 'custom-class' }); + + await waitFor(() => { + expect(screen.getByText('WebSocket Connections')).toBeInTheDocument(); + }); + + const card = container.querySelector('.custom-class'); + expect(card).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/hooks/useWebSocketStatus.ts b/frontend/src/hooks/useWebSocketStatus.ts new file mode 100644 index 00000000..574ef74f --- /dev/null +++ b/frontend/src/hooks/useWebSocketStatus.ts @@ -0,0 +1,24 @@ +import { useQuery } from '@tanstack/react-query'; +import { getWebSocketConnections, getWebSocketStats } from '../api/websocket'; + +/** + * Hook to fetch and manage WebSocket connection data + */ +export const useWebSocketConnections = () => { + return useQuery({ + queryKey: ['websocket', 'connections'], + queryFn: getWebSocketConnections, + refetchInterval: 5000, // Refresh every 5 seconds + }); +}; + +/** + * Hook to fetch and manage WebSocket statistics + */ +export const useWebSocketStats = () => { + return useQuery({ + queryKey: ['websocket', 'stats'], + queryFn: getWebSocketStats, + refetchInterval: 5000, // Refresh every 5 seconds + }); +}; diff --git a/frontend/src/pages/SystemSettings.tsx b/frontend/src/pages/SystemSettings.tsx index 1aea57e6..3c53a9ca 100644 --- a/frontend/src/pages/SystemSettings.tsx +++ b/frontend/src/pages/SystemSettings.tsx @@ -16,6 +16,7 @@ import { getFeatureFlags, updateFeatureFlags } from '../api/featureFlags' import client from '../api/client' import { Server, RefreshCw, Save, Activity, Info, ExternalLink } from 'lucide-react' import { ConfigReloadOverlay } from '../components/LoadingStates' +import { WebSocketStatusCard } from '../components/WebSocketStatusCard' interface HealthResponse { status: string @@ -410,6 +411,9 @@ export default function SystemSettings() { + + {/* WebSocket Connection Status */} + )
{stats.logs_connections}
{stats.cerberus_connections}
+ {formatDistanceToNow(new Date(stats.oldest_connection), { addSuffix: true })} +
Active Connections