diff --git a/app/config/config.go b/app/config/config.go index fc93677..ef6ac8b 100644 --- a/app/config/config.go +++ b/app/config/config.go @@ -146,6 +146,7 @@ type C struct { // Connection concurrency control MaxHandlersPerConnection int `env:"ORLY_MAX_HANDLERS_PER_CONN" default:"100" usage:"max concurrent message handlers per WebSocket connection (limits goroutine growth under load)"` + MaxConnectionsPerIP int `env:"ORLY_MAX_CONN_PER_IP" default:"25" usage:"max WebSocket connections per IP address (prevents resource exhaustion, hard limit 40)"` // Adaptive rate limiting (PID-controlled) RateLimitEnabled bool `env:"ORLY_RATE_LIMIT_ENABLED" default:"true" usage:"enable adaptive PID-controlled rate limiting for database operations"` diff --git a/app/handle-websocket.go b/app/handle-websocket.go index 557da1d..2cb3bac 100644 --- a/app/handle-websocket.go +++ b/app/handle-websocket.go @@ -56,6 +56,42 @@ func (s *Server) HandleWebsocket(w http.ResponseWriter, r *http.Request) { return } whitelist: + // Extract IP from remote (strip port) + ip := remote + if idx := strings.LastIndex(remote, ":"); idx != -1 { + ip = remote[:idx] + } + + // Check per-IP connection limit (hard limit 40, default 25) + maxConnPerIP := s.Config.MaxConnectionsPerIP + if maxConnPerIP <= 0 { + maxConnPerIP = 25 + } + if maxConnPerIP > 40 { + maxConnPerIP = 40 // Hard limit + } + + s.connPerIPMu.Lock() + currentConns := s.connPerIP[ip] + if currentConns >= maxConnPerIP { + s.connPerIPMu.Unlock() + log.W.F("connection limit exceeded for IP %s: %d/%d connections", ip, currentConns, maxConnPerIP) + http.Error(w, "too many connections from your IP", http.StatusTooManyRequests) + return + } + s.connPerIP[ip]++ + s.connPerIPMu.Unlock() + + // Decrement connection count when this function returns + defer func() { + s.connPerIPMu.Lock() + s.connPerIP[ip]-- + if s.connPerIP[ip] <= 0 { + delete(s.connPerIP, ip) + } + s.connPerIPMu.Unlock() + }() + // Create an independent context for this connection // This context will be cancelled when the connection closes or server shuts down ctx, cancel := context.WithCancel(s.Ctx) diff --git a/app/main.go b/app/main.go index 2b488c4..0bde6e2 100644 --- a/app/main.go +++ b/app/main.go @@ -87,6 +87,7 @@ func Run( rateLimiter: limiter, cfg: cfg, db: db, + connPerIP: make(map[string]int), } // Initialize branding/white-label manager if enabled diff --git a/app/server.go b/app/server.go index f3a0c21..513d352 100644 --- a/app/server.go +++ b/app/server.go @@ -57,6 +57,10 @@ type Server struct { // optional reverse proxy for dev web server devProxy *httputil.ReverseProxy + // Per-IP connection tracking to prevent resource exhaustion + connPerIPMu sync.RWMutex + connPerIP map[string]int + // Challenge storage for HTTP UI authentication challengeMutex sync.RWMutex challenges map[string][]byte diff --git a/pkg/version/version b/pkg/version/version index bd40e03..c422be4 100644 --- a/pkg/version/version +++ b/pkg/version/version @@ -1 +1 @@ -v0.52.9 +v0.52.10