896 lines
22 KiB
Go
896 lines
22 KiB
Go
// FILE: logwisp/src/internal/limit/net.go
|
|
package limit
|
|
|
|
import (
|
|
"context"
|
|
"net"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"logwisp/src/internal/config"
|
|
|
|
"github.com/lixenwraith/log"
|
|
)
|
|
|
|
// DenialReason indicates why a network request was denied.
|
|
type DenialReason string
|
|
|
|
// ** THIS PROGRAM IS IPV4 ONLY !!**
|
|
const (
|
|
// IPv4Only is the enforcement message for IPv6 rejection
|
|
IPv4Only = "IPv4-only (IPv6 not supported)"
|
|
)
|
|
|
|
const (
|
|
ReasonAllowed DenialReason = ""
|
|
ReasonBlacklisted DenialReason = "IP denied by blacklist"
|
|
ReasonNotWhitelisted DenialReason = "IP not in whitelist"
|
|
ReasonRateLimited DenialReason = "Rate limit exceeded"
|
|
ReasonConnectionLimited DenialReason = "Connection limit exceeded"
|
|
ReasonInvalidIP DenialReason = "Invalid IP address"
|
|
)
|
|
|
|
// NetLimiter manages network-level limiting including ACLs, rate limits, and connection counts.
|
|
type NetLimiter struct {
|
|
config *config.NetLimitConfig
|
|
logger *log.Logger
|
|
|
|
// IP Access Control Lists
|
|
ipWhitelist []*net.IPNet
|
|
ipBlacklist []*net.IPNet
|
|
|
|
// Per-IP limiters
|
|
ipLimiters map[string]*ipLimiter
|
|
ipMu sync.RWMutex
|
|
|
|
// Global limiter for the transport
|
|
globalLimiter *TokenBucket
|
|
|
|
// Connection tracking
|
|
ipConnections map[string]*connTracker
|
|
userConnections map[string]*connTracker
|
|
tokenConnections map[string]*connTracker
|
|
totalConnections atomic.Int64
|
|
connMu sync.RWMutex
|
|
|
|
// Statistics
|
|
totalRequests atomic.Uint64
|
|
blockedByBlacklist atomic.Uint64
|
|
blockedByWhitelist atomic.Uint64
|
|
blockedByRateLimit atomic.Uint64
|
|
blockedByConnLimit atomic.Uint64
|
|
blockedByInvalidIP atomic.Uint64
|
|
uniqueIPs atomic.Uint64
|
|
|
|
// Cleanup
|
|
lastCleanup time.Time
|
|
cleanupMu sync.Mutex
|
|
cleanupActive atomic.Bool
|
|
|
|
// Lifecycle management
|
|
ctx context.Context
|
|
cancel context.CancelFunc
|
|
cleanupDone chan struct{}
|
|
}
|
|
|
|
// ipLimiter holds the rate limiting and activity state for a single IP address.
|
|
type ipLimiter struct {
|
|
bucket *TokenBucket
|
|
lastSeen time.Time
|
|
connections atomic.Int64
|
|
}
|
|
|
|
// connTracker tracks active connections and their last activity.
|
|
type connTracker struct {
|
|
connections atomic.Int64
|
|
lastSeen time.Time
|
|
mu sync.Mutex
|
|
}
|
|
|
|
// NewNetLimiter creates a new network limiter from configuration.
|
|
func NewNetLimiter(cfg *config.NetLimitConfig, logger *log.Logger) *NetLimiter {
|
|
if cfg == nil {
|
|
return nil
|
|
}
|
|
|
|
// Return nil only if nothing is configured
|
|
hasACL := len(cfg.IPWhitelist) > 0 || len(cfg.IPBlacklist) > 0
|
|
hasRateLimit := cfg.Enabled
|
|
|
|
if !hasACL && !hasRateLimit {
|
|
return nil
|
|
}
|
|
|
|
if logger == nil {
|
|
panic("netlimit.New: logger cannot be nil")
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
|
|
l := &NetLimiter{
|
|
config: cfg,
|
|
logger: logger,
|
|
ipWhitelist: make([]*net.IPNet, 0),
|
|
ipBlacklist: make([]*net.IPNet, 0),
|
|
ipLimiters: make(map[string]*ipLimiter),
|
|
ipConnections: make(map[string]*connTracker),
|
|
userConnections: make(map[string]*connTracker),
|
|
tokenConnections: make(map[string]*connTracker),
|
|
lastCleanup: time.Now(),
|
|
ctx: ctx,
|
|
cancel: cancel,
|
|
cleanupDone: make(chan struct{}),
|
|
}
|
|
|
|
// Parse IP lists
|
|
l.parseIPLists()
|
|
|
|
// Start cleanup goroutine only if rate limiting is enabled
|
|
if cfg.Enabled {
|
|
go l.cleanupLoop()
|
|
}
|
|
|
|
logger.Info("msg", "Net limiter initialized",
|
|
"component", "netlimit",
|
|
"acl_enabled", hasACL,
|
|
"rate_limiting", cfg.Enabled,
|
|
"whitelist_rules", len(l.ipWhitelist),
|
|
"blacklist_rules", len(l.ipBlacklist),
|
|
"requests_per_second", cfg.RequestsPerSecond,
|
|
"burst_size", cfg.BurstSize,
|
|
"max_connections_per_ip", cfg.MaxConnectionsPerIP,
|
|
"max_connections_total", cfg.MaxConnectionsTotal)
|
|
|
|
return l
|
|
}
|
|
|
|
// Shutdown gracefully stops the net limiter's background cleanup processes.
|
|
func (l *NetLimiter) Shutdown() {
|
|
if l == nil {
|
|
return
|
|
}
|
|
|
|
l.logger.Info("msg", "Shutting down net limiter", "component", "netlimit")
|
|
|
|
// Cancel context to stop cleanup goroutine
|
|
l.cancel()
|
|
|
|
// Wait for cleanup goroutine to finish
|
|
select {
|
|
case <-l.cleanupDone:
|
|
l.logger.Debug("msg", "Cleanup goroutine stopped", "component", "netlimit")
|
|
case <-time.After(2 * time.Second):
|
|
l.logger.Warn("msg", "Cleanup goroutine shutdown timeout", "component", "netlimit")
|
|
}
|
|
}
|
|
|
|
// CheckHTTP checks if an incoming HTTP request is allowed based on all configured limits.
|
|
func (l *NetLimiter) CheckHTTP(remoteAddr string) (allowed bool, statusCode int64, message string) {
|
|
if l == nil {
|
|
return true, 0, ""
|
|
}
|
|
|
|
l.totalRequests.Add(1)
|
|
|
|
// Parse IP address
|
|
ipStr, _, err := net.SplitHostPort(remoteAddr)
|
|
if err != nil {
|
|
l.logger.Warn("msg", "Failed to parse remote addr",
|
|
"component", "netlimit",
|
|
"remote_addr", remoteAddr,
|
|
"error", err)
|
|
return true, 0, ""
|
|
}
|
|
|
|
ip := net.ParseIP(ipStr)
|
|
if ip == nil {
|
|
l.blockedByInvalidIP.Add(1)
|
|
l.logger.Warn("msg", "Failed to parse IP",
|
|
"component", "netlimit",
|
|
"ip", ipStr)
|
|
return false, 403, string(ReasonInvalidIP)
|
|
}
|
|
|
|
// Reject IPv6 connections
|
|
if !isIPv4(ip) {
|
|
l.blockedByInvalidIP.Add(1)
|
|
l.logger.Warn("msg", "IPv6 connection rejected",
|
|
"component", "netlimit",
|
|
"ip", ipStr,
|
|
"reason", IPv4Only)
|
|
return false, 403, IPv4Only
|
|
}
|
|
|
|
// Normalize to IPv4 representation
|
|
ip = ip.To4()
|
|
|
|
// Check IP access control
|
|
if reason := l.checkIPAccess(ip); reason != ReasonAllowed {
|
|
return false, 403, string(reason)
|
|
}
|
|
|
|
// If rate limiting is not enabled, allow
|
|
if !l.config.Enabled {
|
|
return true, 0, ""
|
|
}
|
|
|
|
// Check connection limits
|
|
if l.config.MaxConnectionsPerIP > 0 {
|
|
l.connMu.RLock()
|
|
tracker, exists := l.ipConnections[ipStr]
|
|
l.connMu.RUnlock()
|
|
|
|
if exists && tracker.connections.Load() >= l.config.MaxConnectionsPerIP {
|
|
l.blockedByConnLimit.Add(1)
|
|
statusCode = l.config.ResponseCode
|
|
if statusCode == 0 {
|
|
statusCode = 429
|
|
}
|
|
return false, statusCode, string(ReasonConnectionLimited)
|
|
}
|
|
}
|
|
|
|
// Check rate limit
|
|
if !l.checkIPLimit(ipStr) {
|
|
l.blockedByRateLimit.Add(1)
|
|
statusCode = l.config.ResponseCode
|
|
if statusCode == 0 {
|
|
statusCode = 429
|
|
}
|
|
message = l.config.ResponseMessage
|
|
if message == "" {
|
|
message = string(ReasonRateLimited)
|
|
}
|
|
return false, statusCode, message
|
|
}
|
|
|
|
return true, 0, ""
|
|
}
|
|
|
|
// CheckTCP checks if an incoming TCP connection is allowed based on ACLs and rate limits.
|
|
func (l *NetLimiter) CheckTCP(remoteAddr net.Addr) bool {
|
|
if l == nil {
|
|
return true
|
|
}
|
|
|
|
l.totalRequests.Add(1)
|
|
|
|
// Extract IP from TCP addr
|
|
tcpAddr, ok := remoteAddr.(*net.TCPAddr)
|
|
if !ok {
|
|
l.blockedByInvalidIP.Add(1)
|
|
return false
|
|
}
|
|
|
|
// Reject IPv6 connections
|
|
if !isIPv4(tcpAddr.IP) {
|
|
l.blockedByInvalidIP.Add(1)
|
|
l.logger.Warn("msg", "IPv6 TCP connection rejected",
|
|
"component", "netlimit",
|
|
"ip", tcpAddr.IP.String(),
|
|
"reason", IPv4Only)
|
|
return false
|
|
}
|
|
|
|
// Normalize to IPv4 representation
|
|
ip := tcpAddr.IP.To4()
|
|
|
|
// Check IP access control
|
|
if reason := l.checkIPAccess(ip); reason != ReasonAllowed {
|
|
return false
|
|
}
|
|
|
|
// If rate limiting is not enabled, allow
|
|
if !l.config.Enabled {
|
|
return true
|
|
}
|
|
|
|
// Check rate limit
|
|
ipStr := tcpAddr.IP.String()
|
|
if !l.checkIPLimit(ipStr) {
|
|
l.blockedByRateLimit.Add(1)
|
|
return false
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// AddConnection tracks a new connection from a specific remote address (for HTTP).
|
|
func (l *NetLimiter) AddConnection(remoteAddr string) {
|
|
if l == nil {
|
|
return
|
|
}
|
|
|
|
ip, _, err := net.SplitHostPort(remoteAddr)
|
|
if err != nil {
|
|
l.logger.Warn("msg", "Failed to parse remote address in AddConnection",
|
|
"component", "netlimit",
|
|
"remote_addr", remoteAddr,
|
|
"error", err)
|
|
return
|
|
}
|
|
|
|
// IP validation
|
|
parsedIP := net.ParseIP(ip)
|
|
if parsedIP == nil {
|
|
l.logger.Warn("msg", "Failed to parse IP in AddConnection",
|
|
"component", "netlimit",
|
|
"ip", ip)
|
|
return
|
|
}
|
|
|
|
// Only supporting ipv4
|
|
if !isIPv4(parsedIP) {
|
|
return
|
|
}
|
|
|
|
l.connMu.Lock()
|
|
tracker, exists := l.ipConnections[ip]
|
|
if !exists {
|
|
// Create new tracker with timestamp
|
|
tracker = &connTracker{
|
|
lastSeen: time.Now(),
|
|
}
|
|
l.ipConnections[ip] = tracker
|
|
}
|
|
l.connMu.Unlock()
|
|
|
|
newCount := tracker.connections.Add(1)
|
|
// Update activity timestamp
|
|
tracker.mu.Lock()
|
|
tracker.lastSeen = time.Now()
|
|
tracker.mu.Unlock()
|
|
|
|
l.logger.Debug("msg", "Connection added",
|
|
"ip", ip,
|
|
"connections", newCount)
|
|
}
|
|
|
|
// RemoveConnection removes a tracked connection (for HTTP).
|
|
func (l *NetLimiter) RemoveConnection(remoteAddr string) {
|
|
if l == nil {
|
|
return
|
|
}
|
|
|
|
ip, _, err := net.SplitHostPort(remoteAddr)
|
|
if err != nil {
|
|
l.logger.Warn("msg", "Failed to parse remote address in RemoveConnection",
|
|
"component", "netlimit",
|
|
"remote_addr", remoteAddr,
|
|
"error", err)
|
|
return
|
|
}
|
|
|
|
// IP validation
|
|
parsedIP := net.ParseIP(ip)
|
|
if parsedIP == nil {
|
|
l.logger.Warn("msg", "Failed to parse IP in RemoveConnection",
|
|
"component", "netlimit",
|
|
"ip", ip)
|
|
return
|
|
}
|
|
|
|
// Only supporting ipv4
|
|
if !isIPv4(parsedIP) {
|
|
return
|
|
}
|
|
|
|
l.connMu.RLock()
|
|
tracker, exists := l.ipConnections[ip]
|
|
l.connMu.RUnlock()
|
|
|
|
if exists {
|
|
newCount := tracker.connections.Add(-1)
|
|
l.logger.Debug("msg", "Connection removed",
|
|
"ip", ip,
|
|
"connections", newCount)
|
|
|
|
if newCount <= 0 {
|
|
// Clean up if no more connections
|
|
l.connMu.Lock()
|
|
if tracker.connections.Load() <= 0 {
|
|
delete(l.ipConnections, ip)
|
|
}
|
|
l.connMu.Unlock()
|
|
}
|
|
}
|
|
}
|
|
|
|
// TrackConnection checks connection limits and tracks a new connection (for TCP).
|
|
func (l *NetLimiter) TrackConnection(ip string, user string, token string) bool {
|
|
if l == nil {
|
|
return true
|
|
}
|
|
|
|
l.connMu.Lock()
|
|
defer l.connMu.Unlock()
|
|
|
|
// Check total connections limit (0 = disabled)
|
|
if l.config.MaxConnectionsTotal > 0 {
|
|
currentTotal := l.totalConnections.Load()
|
|
if currentTotal >= l.config.MaxConnectionsTotal {
|
|
l.blockedByConnLimit.Add(1)
|
|
l.logger.Debug("msg", "TCP connection blocked by total limit",
|
|
"component", "netlimit",
|
|
"current_total", currentTotal,
|
|
"max_connections_total", l.config.MaxConnectionsTotal)
|
|
return false
|
|
}
|
|
}
|
|
|
|
// Check per-IP connection limit (0 = disabled)
|
|
if l.config.MaxConnectionsPerIP > 0 && ip != "" {
|
|
tracker, exists := l.ipConnections[ip]
|
|
if !exists {
|
|
tracker = &connTracker{lastSeen: time.Now()}
|
|
l.ipConnections[ip] = tracker
|
|
}
|
|
if tracker.connections.Load() >= l.config.MaxConnectionsPerIP {
|
|
l.blockedByConnLimit.Add(1)
|
|
l.logger.Debug("msg", "TCP connection blocked by IP limit",
|
|
"component", "netlimit",
|
|
"ip", ip,
|
|
"current", tracker.connections.Load(),
|
|
"max", l.config.MaxConnectionsPerIP)
|
|
return false
|
|
}
|
|
}
|
|
|
|
// All checks passed, increment counters
|
|
l.totalConnections.Add(1)
|
|
|
|
if ip != "" && l.config.MaxConnectionsPerIP > 0 {
|
|
if tracker, exists := l.ipConnections[ip]; exists {
|
|
tracker.connections.Add(1)
|
|
tracker.mu.Lock()
|
|
tracker.lastSeen = time.Now()
|
|
tracker.mu.Unlock()
|
|
}
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// ReleaseConnection decrements connection counters when a connection is closed (for TCP).
|
|
func (l *NetLimiter) ReleaseConnection(ip string, user string, token string) {
|
|
if l == nil {
|
|
return
|
|
}
|
|
|
|
l.connMu.Lock()
|
|
defer l.connMu.Unlock()
|
|
|
|
// Decrement total
|
|
if l.totalConnections.Load() > 0 {
|
|
l.totalConnections.Add(-1)
|
|
}
|
|
|
|
// Decrement IP counter
|
|
if ip != "" {
|
|
if tracker, exists := l.ipConnections[ip]; exists {
|
|
if tracker.connections.Load() > 0 {
|
|
tracker.connections.Add(-1)
|
|
}
|
|
tracker.mu.Lock()
|
|
tracker.lastSeen = time.Now()
|
|
tracker.mu.Unlock()
|
|
}
|
|
}
|
|
|
|
// Decrement user counter
|
|
if user != "" {
|
|
if tracker, exists := l.userConnections[user]; exists {
|
|
if tracker.connections.Load() > 0 {
|
|
tracker.connections.Add(-1)
|
|
}
|
|
tracker.mu.Lock()
|
|
tracker.lastSeen = time.Now()
|
|
tracker.mu.Unlock()
|
|
}
|
|
}
|
|
|
|
// Decrement token counter
|
|
if token != "" {
|
|
if tracker, exists := l.tokenConnections[token]; exists {
|
|
if tracker.connections.Load() > 0 {
|
|
tracker.connections.Add(-1)
|
|
}
|
|
tracker.mu.Lock()
|
|
tracker.lastSeen = time.Now()
|
|
tracker.mu.Unlock()
|
|
}
|
|
}
|
|
}
|
|
|
|
// GetStats returns a map of the net limiter's current statistics.
|
|
func (l *NetLimiter) GetStats() map[string]any {
|
|
if l == nil {
|
|
return map[string]any{"enabled": false}
|
|
}
|
|
|
|
// Get active rate limiters count
|
|
l.ipMu.RLock()
|
|
activeIPs := len(l.ipLimiters)
|
|
l.ipMu.RUnlock()
|
|
|
|
// Get connection tracker counts and calculate total active connections
|
|
l.connMu.RLock()
|
|
ipConnTrackers := len(l.ipConnections)
|
|
userConnTrackers := len(l.userConnections)
|
|
tokenConnTrackers := len(l.tokenConnections)
|
|
|
|
// Calculate actual connection count by summing all IP connections
|
|
// Potentially more accurate than totalConnections counter which might drift
|
|
// TODO: test and refactor if they match
|
|
actualIPConnections := 0
|
|
for _, tracker := range l.ipConnections {
|
|
actualIPConnections += int(tracker.connections.Load())
|
|
}
|
|
|
|
actualUserConnections := 0
|
|
for _, tracker := range l.userConnections {
|
|
actualUserConnections += int(tracker.connections.Load())
|
|
}
|
|
|
|
actualTokenConnections := 0
|
|
for _, tracker := range l.tokenConnections {
|
|
actualTokenConnections += int(tracker.connections.Load())
|
|
}
|
|
|
|
// Use the counter for total (should match actualIPConnections in most cases)
|
|
totalConns := l.totalConnections.Load()
|
|
l.connMu.RUnlock()
|
|
|
|
// Calculate total blocked
|
|
totalBlocked := l.blockedByBlacklist.Load() +
|
|
l.blockedByWhitelist.Load() +
|
|
l.blockedByRateLimit.Load() +
|
|
l.blockedByConnLimit.Load() +
|
|
l.blockedByInvalidIP.Load()
|
|
|
|
return map[string]any{
|
|
"enabled": true,
|
|
"total_requests": l.totalRequests.Load(),
|
|
"total_blocked": totalBlocked,
|
|
"blocked_breakdown": map[string]uint64{
|
|
"blacklist": l.blockedByBlacklist.Load(),
|
|
"whitelist": l.blockedByWhitelist.Load(),
|
|
"rate_limit": l.blockedByRateLimit.Load(),
|
|
"conn_limit": l.blockedByConnLimit.Load(),
|
|
"invalid_ip": l.blockedByInvalidIP.Load(),
|
|
},
|
|
"rate_limiting": map[string]any{
|
|
"enabled": l.config.Enabled,
|
|
"requests_per_second": l.config.RequestsPerSecond,
|
|
"burst_size": l.config.BurstSize,
|
|
"active_ip_limiters": activeIPs, // IPs being rate-limited
|
|
},
|
|
"access_control": map[string]any{
|
|
"whitelist_rules": len(l.ipWhitelist),
|
|
"blacklist_rules": len(l.ipBlacklist),
|
|
},
|
|
"connections": map[string]any{
|
|
// Actual counts
|
|
"total_active": totalConns, // Counter-based total
|
|
"active_ip_connections": actualIPConnections, // Sum of all IP connections
|
|
"active_user_connections": actualUserConnections, // Sum of all user connections
|
|
"active_token_connections": actualTokenConnections, // Sum of all token connections
|
|
|
|
// Tracker counts (number of unique IPs/users/tokens being tracked)
|
|
"tracked_ips": ipConnTrackers,
|
|
"tracked_users": userConnTrackers,
|
|
"tracked_tokens": tokenConnTrackers,
|
|
|
|
// Configuration limits (0 = disabled)
|
|
"limit_per_ip": l.config.MaxConnectionsPerIP,
|
|
"limit_total": l.config.MaxConnectionsTotal,
|
|
},
|
|
}
|
|
}
|
|
|
|
// cleanupLoop runs a periodic cleanup of stale limiter and tracker entries.
|
|
func (l *NetLimiter) cleanupLoop() {
|
|
defer close(l.cleanupDone)
|
|
|
|
ticker := time.NewTicker(1 * time.Minute)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-l.ctx.Done():
|
|
// Exit when context is cancelled
|
|
l.logger.Debug("msg", "Cleanup loop stopping", "component", "netlimit")
|
|
return
|
|
case <-ticker.C:
|
|
l.cleanup()
|
|
}
|
|
}
|
|
}
|
|
|
|
// maybeCleanup triggers an asynchronous cleanup if enough time has passed since the last one.
|
|
func (l *NetLimiter) maybeCleanup() {
|
|
l.cleanupMu.Lock()
|
|
|
|
// Check if enough time has passed
|
|
if time.Since(l.lastCleanup) < 30*time.Second {
|
|
l.cleanupMu.Unlock()
|
|
return
|
|
}
|
|
|
|
// Check if cleanup already running
|
|
if !l.cleanupActive.CompareAndSwap(false, true) {
|
|
l.cleanupMu.Unlock()
|
|
return
|
|
}
|
|
|
|
l.lastCleanup = time.Now()
|
|
l.cleanupMu.Unlock()
|
|
|
|
// Run cleanup async
|
|
go func() {
|
|
defer l.cleanupActive.Store(false)
|
|
l.cleanup()
|
|
}()
|
|
}
|
|
|
|
// cleanup removes stale IP limiters and connection trackers from memory.
|
|
func (l *NetLimiter) cleanup() {
|
|
staleTimeout := 5 * time.Minute
|
|
now := time.Now()
|
|
|
|
l.ipMu.Lock()
|
|
defer l.ipMu.Unlock()
|
|
|
|
// Clean up rate limiters
|
|
l.ipMu.Lock()
|
|
cleaned := 0
|
|
for ip, lim := range l.ipLimiters {
|
|
if now.Sub(lim.lastSeen) > staleTimeout {
|
|
delete(l.ipLimiters, ip)
|
|
cleaned++
|
|
}
|
|
}
|
|
l.ipMu.Unlock()
|
|
|
|
if cleaned > 0 {
|
|
l.logger.Debug("msg", "Cleaned up stale IP limiters",
|
|
"component", "netlimit",
|
|
"cleaned", cleaned,
|
|
"remaining", len(l.ipLimiters))
|
|
}
|
|
|
|
// Clean up stale connection trackers
|
|
l.connMu.Lock()
|
|
|
|
// Clean IP connections
|
|
ipCleaned := 0
|
|
for ip, tracker := range l.ipConnections {
|
|
tracker.mu.Lock()
|
|
lastSeen := tracker.lastSeen
|
|
tracker.mu.Unlock()
|
|
|
|
if now.Sub(lastSeen) > staleTimeout && tracker.connections.Load() <= 0 {
|
|
delete(l.ipConnections, ip)
|
|
ipCleaned++
|
|
}
|
|
}
|
|
|
|
// Clean user connections
|
|
userCleaned := 0
|
|
for user, tracker := range l.userConnections {
|
|
tracker.mu.Lock()
|
|
lastSeen := tracker.lastSeen
|
|
tracker.mu.Unlock()
|
|
|
|
if now.Sub(lastSeen) > staleTimeout && tracker.connections.Load() <= 0 {
|
|
delete(l.userConnections, user)
|
|
userCleaned++
|
|
}
|
|
}
|
|
|
|
// Clean token connections
|
|
tokenCleaned := 0
|
|
for token, tracker := range l.tokenConnections {
|
|
tracker.mu.Lock()
|
|
lastSeen := tracker.lastSeen
|
|
tracker.mu.Unlock()
|
|
|
|
if now.Sub(lastSeen) > staleTimeout && tracker.connections.Load() <= 0 {
|
|
delete(l.tokenConnections, token)
|
|
tokenCleaned++
|
|
}
|
|
}
|
|
|
|
l.connMu.Unlock()
|
|
|
|
if ipCleaned > 0 || userCleaned > 0 || tokenCleaned > 0 {
|
|
l.logger.Debug("msg", "Cleaned up stale connection trackers",
|
|
"component", "netlimit",
|
|
"ip_cleaned", ipCleaned,
|
|
"user_cleaned", userCleaned,
|
|
"token_cleaned", tokenCleaned,
|
|
"ip_remaining", len(l.ipConnections),
|
|
"user_remaining", len(l.userConnections),
|
|
"token_remaining", len(l.tokenConnections))
|
|
}
|
|
}
|
|
|
|
// checkIPAccess verifies if an IP address is permitted by the configured ACLs.
|
|
func (l *NetLimiter) checkIPAccess(ip net.IP) DenialReason {
|
|
// 1. Check blacklist first (deny takes precedence)
|
|
for _, ipNet := range l.ipBlacklist {
|
|
if ipNet.Contains(ip) {
|
|
l.blockedByBlacklist.Add(1)
|
|
l.logger.Debug("msg", "IP denied by blacklist",
|
|
"component", "netlimit",
|
|
"ip", ip.String(),
|
|
"rule", ipNet.String())
|
|
return ReasonBlacklisted
|
|
}
|
|
}
|
|
|
|
// 2. If whitelist is configured, IP must be in it
|
|
if len(l.ipWhitelist) > 0 {
|
|
for _, ipNet := range l.ipWhitelist {
|
|
if ipNet.Contains(ip) {
|
|
l.logger.Debug("msg", "IP allowed by whitelist",
|
|
"component", "netlimit",
|
|
"ip", ip.String(),
|
|
"rule", ipNet.String())
|
|
return ReasonAllowed
|
|
}
|
|
}
|
|
l.blockedByWhitelist.Add(1)
|
|
l.logger.Debug("msg", "IP not in whitelist",
|
|
"component", "netlimit",
|
|
"ip", ip.String())
|
|
return ReasonNotWhitelisted
|
|
}
|
|
|
|
return ReasonAllowed
|
|
}
|
|
|
|
// checkIPLimit enforces the requests-per-second limit for a given IP address.
|
|
func (l *NetLimiter) checkIPLimit(ip string) bool {
|
|
// Validate IP format
|
|
parsedIP := net.ParseIP(ip)
|
|
if parsedIP == nil || !isIPv4(parsedIP) {
|
|
l.logger.Warn("msg", "Invalid or non-IPv4 address in rate limiter",
|
|
"component", "netlimit",
|
|
"ip", ip)
|
|
return false
|
|
}
|
|
|
|
// Maybe run cleanup
|
|
l.maybeCleanup()
|
|
|
|
// IP limit
|
|
l.ipMu.Lock()
|
|
lim, exists := l.ipLimiters[ip]
|
|
if !exists {
|
|
// Create new limiter for this IP
|
|
lim = &ipLimiter{
|
|
bucket: NewTokenBucket(
|
|
float64(l.config.BurstSize),
|
|
l.config.RequestsPerSecond,
|
|
),
|
|
lastSeen: time.Now(),
|
|
}
|
|
l.ipLimiters[ip] = lim
|
|
l.uniqueIPs.Add(1)
|
|
|
|
l.logger.Debug("msg", "Created new IP limiter",
|
|
"ip", ip,
|
|
"total_ips", l.uniqueIPs.Load())
|
|
} else {
|
|
lim.lastSeen = time.Now()
|
|
}
|
|
l.ipMu.Unlock()
|
|
|
|
// Rate limit check
|
|
allowed := lim.bucket.Allow()
|
|
if !allowed {
|
|
l.blockedByRateLimit.Add(1)
|
|
}
|
|
|
|
return allowed
|
|
}
|
|
|
|
// parseIPLists converts the string-based IP rules from the config into parsed net.IPNet objects.
|
|
func (l *NetLimiter) parseIPLists() {
|
|
// Parse whitelist
|
|
for _, entry := range l.config.IPWhitelist {
|
|
if ipNet := l.parseIPEntry(entry, "whitelist"); ipNet != nil {
|
|
l.ipWhitelist = append(l.ipWhitelist, ipNet)
|
|
}
|
|
}
|
|
|
|
// Parse blacklist
|
|
for _, entry := range l.config.IPBlacklist {
|
|
if ipNet := l.parseIPEntry(entry, "blacklist"); ipNet != nil {
|
|
l.ipBlacklist = append(l.ipBlacklist, ipNet)
|
|
}
|
|
}
|
|
}
|
|
|
|
// parseIPEntry parses a single IP address or CIDR notation string into a net.IPNet object.
|
|
func (l *NetLimiter) parseIPEntry(entry, listType string) *net.IPNet {
|
|
// Handle single IP
|
|
if !strings.Contains(entry, "/") {
|
|
ip := net.ParseIP(entry)
|
|
if ip == nil {
|
|
l.logger.Warn("msg", "Invalid IP entry",
|
|
"component", "netlimit",
|
|
"list", listType,
|
|
"entry", entry)
|
|
return nil
|
|
}
|
|
|
|
// Reject IPv6
|
|
if ip.To4() == nil {
|
|
l.logger.Warn("msg", "IPv6 address rejected",
|
|
"component", "netlimit",
|
|
"list", listType,
|
|
"entry", entry,
|
|
"reason", IPv4Only)
|
|
return nil
|
|
}
|
|
|
|
return &net.IPNet{IP: ip.To4(), Mask: net.CIDRMask(32, 32)}
|
|
}
|
|
|
|
// Parse CIDR
|
|
ipAddr, ipNet, err := net.ParseCIDR(entry)
|
|
if err != nil {
|
|
l.logger.Warn("msg", "Invalid CIDR entry",
|
|
"component", "netlimit",
|
|
"list", listType,
|
|
"entry", entry,
|
|
"error", err)
|
|
return nil
|
|
}
|
|
|
|
// Reject IPv6 CIDR
|
|
if ipAddr.To4() == nil {
|
|
l.logger.Warn("msg", "IPv6 CIDR rejected",
|
|
"component", "netlimit",
|
|
"list", listType,
|
|
"entry", entry,
|
|
"reason", IPv4Only)
|
|
return nil
|
|
}
|
|
|
|
// Ensure mask is IPv4
|
|
_, bits := ipNet.Mask.Size()
|
|
if bits != 32 {
|
|
l.logger.Warn("msg", "Non-IPv4 CIDR mask rejected",
|
|
"component", "netlimit",
|
|
"list", listType,
|
|
"entry", entry,
|
|
"mask_bits", bits,
|
|
"reason", IPv4Only)
|
|
return nil
|
|
}
|
|
|
|
return &net.IPNet{IP: ipAddr.To4(), Mask: ipNet.Mask}
|
|
}
|
|
|
|
// updateConnectionActivity updates the last seen timestamp for a connection tracker.
|
|
func (l *NetLimiter) updateConnectionActivity(ip string) {
|
|
l.connMu.RLock()
|
|
tracker, exists := l.ipConnections[ip]
|
|
l.connMu.RUnlock()
|
|
|
|
if exists {
|
|
tracker.mu.Lock()
|
|
tracker.lastSeen = time.Now()
|
|
tracker.mu.Unlock()
|
|
}
|
|
}
|
|
|
|
// isIPv4 is a helper function to check if a net.IP is an IPv4 address.
|
|
func isIPv4(ip net.IP) bool {
|
|
return ip.To4() != nil
|
|
} |