Merge pull request #428 from Wikid82/main

Propagate changes from main into development
This commit is contained in:
Jeremy
2025-12-18 14:02:08 -05:00
committed by GitHub
18 changed files with 1685 additions and 37 deletions

View File

@@ -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 {

View File

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

View File

@@ -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 {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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.

View File

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

View File

@@ -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
<VirtualHost *:443>
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/
</VirtualHost>
```
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=<your-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)

View File

@@ -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",
@@ -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"
}

View File

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

View File

@@ -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<ConnectionsResponse> => {
const response = await client.get('/websocket/connections');
return response.data;
};
/**
* Get aggregate WebSocket connection statistics
*/
export const getWebSocketStats = async (): Promise<ConnectionStats> => {
const response = await client.get('/websocket/stats');
return response.data;
};

View File

@@ -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 (
<Card className={className}>
<CardHeader>
<Skeleton className="h-6 w-48" />
<Skeleton className="h-4 w-64" />
</CardHeader>
<CardContent>
<div className="space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
</div>
</CardContent>
</Card>
);
}
if (!stats) {
return (
<Alert variant="warning" className={className}>
Unable to load WebSocket status
</Alert>
);
}
const hasActiveConnections = stats.total_active > 0;
return (
<Card className={className}>
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className={`p-2 rounded-lg ${hasActiveConnections ? 'bg-success/10' : 'bg-surface-muted'}`}>
{hasActiveConnections ? (
<Wifi className="w-5 h-5 text-success" />
) : (
<WifiOff className="w-5 h-5 text-content-muted" />
)}
</div>
<div>
<CardTitle className="text-lg">WebSocket Connections</CardTitle>
<CardDescription>
Real-time connection monitoring
</CardDescription>
</div>
</div>
<Badge variant={hasActiveConnections ? 'success' : 'default'}>
{stats.total_active} Active
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* Statistics Grid */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<div className="flex items-center gap-2 text-sm text-content-muted">
<Activity className="w-4 h-4" />
<span>General Logs</span>
</div>
<p className="text-2xl font-semibold">{stats.logs_connections}</p>
</div>
<div className="space-y-1">
<div className="flex items-center gap-2 text-sm text-content-muted">
<Activity className="w-4 h-4" />
<span>Security Logs</span>
</div>
<p className="text-2xl font-semibold">{stats.cerberus_connections}</p>
</div>
</div>
{/* Oldest Connection */}
{stats.oldest_connection && (
<div className="pt-3 border-t border-border">
<div className="flex items-center gap-2 text-sm text-content-muted mb-1">
<Clock className="w-4 h-4" />
<span>Oldest Connection</span>
</div>
<p className="text-sm">
{formatDistanceToNow(new Date(stats.oldest_connection), { addSuffix: true })}
</p>
</div>
)}
{/* Connection Details */}
{expanded && connections?.connections && connections.connections.length > 0 && (
<div className="pt-3 border-t border-border space-y-3">
<p className="text-sm font-medium">Active Connections</p>
<div className="space-y-2 max-h-64 overflow-y-auto">
{connections.connections.map((conn) => (
<div
key={conn.id}
className="p-3 rounded-lg bg-surface-muted space-y-2 text-xs"
>
<div className="flex items-center justify-between">
<Badge variant={conn.type === 'logs' ? 'default' : 'success'} size="sm">
{conn.type === 'logs' ? 'General' : 'Security'}
</Badge>
<span className="text-content-muted font-mono">
{conn.id.substring(0, 8)}...
</span>
</div>
{conn.remote_addr && (
<div className="flex items-center gap-2 text-content-muted">
<Globe className="w-3 h-3" />
<span>{conn.remote_addr}</span>
</div>
)}
{conn.filters && (
<div className="flex items-center gap-2 text-content-muted">
<Filter className="w-3 h-3" />
<span className="truncate">{conn.filters}</span>
</div>
)}
<div className="flex items-center gap-2 text-content-muted">
<Clock className="w-3 h-3" />
<span>
Connected {formatDistanceToNow(new Date(conn.connected_at), { addSuffix: true })}
</span>
</div>
</div>
))}
</div>
</div>
)}
{/* Toggle Details Button */}
{connections?.connections && connections.connections.length > 0 && (
<button
onClick={() => setExpanded(!expanded)}
className="w-full pt-3 text-sm text-primary hover:text-primary/80 transition-colors"
>
{expanded ? 'Hide Details' : 'Show Details'}
</button>
)}
{/* No Connections Message */}
{!hasActiveConnections && (
<div className="pt-3 text-center text-sm text-content-muted">
No active WebSocket connections
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -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(
<QueryClientProvider client={queryClient}>
<WebSocketStatusCard {...props} />
</QueryClientProvider>
);
};
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();
});
});

View File

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

View File

@@ -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() {
</Button>
</CardFooter>
</Card>
{/* WebSocket Connection Status */}
<WebSocketStatusCard showDetails={true} />
</div>
</TooltipProvider>
)