diff --git a/internal/api/security_handlers.go b/internal/api/security_handlers.go index 7bfa6e2..f16b453 100644 --- a/internal/api/security_handlers.go +++ b/internal/api/security_handlers.go @@ -1,7 +1,11 @@ package api import ( + "encoding/json" + "fmt" + "log" "net/http" + "os/exec" "strconv" "time" @@ -20,22 +24,37 @@ func (s *Server) ingestSecurityEvent(c *gin.Context) { var event security.IngestEvent if err := c.ShouldBindJSON(&event); err != nil { + clientIP := c.ClientIP() + log.Printf("Security ingest: failed to parse JSON from %s: %v", clientIP, err) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - secEvent, err := s.securityManager.IngestEvent(&event) + result, err := s.securityManager.IngestEvent(&event, s.config.Security.AutoBlockDuration) if err != nil { + log.Printf("Security ingest: failed to process event from IP %s (path=%s, method=%s): %v", + event.SourceIP, event.RequestPath, event.RequestMethod, err) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - if secEvent == nil { + if result.Event == nil { c.JSON(http.StatusOK, gin.H{"processed": false, "reason": "Event not security-relevant or IP blocked"}) return } - c.JSON(http.StatusCreated, gin.H{"processed": true, "event": secEvent}) + // If an IP was auto-blocked, notify nginx immediately + if result.AutoBlocked { + if err := s.notifyNginxBlockIP(result.BlockedIP, result.BlockTTL); err != nil { + log.Printf("Warning: failed to notify nginx about auto-blocked IP %s: %v", result.BlockedIP, err) + } + } + + c.JSON(http.StatusCreated, gin.H{ + "processed": true, + "event": result.Event, + "auto_blocked": result.AutoBlocked, + }) } // getSecurityStats returns security statistics @@ -198,6 +217,11 @@ func (s *Server) blockIP(c *gin.Context) { return } + // Notify nginx to immediately block the IP + if err := s.notifyNginxBlockIP(req.IP, req.Duration); err != nil { + log.Printf("Warning: failed to notify nginx about blocked IP %s: %v", req.IP, err) + } + c.JSON(http.StatusCreated, gin.H{"id": id, "message": "IP blocked successfully"}) } @@ -219,6 +243,11 @@ func (s *Server) unblockIP(c *gin.Context) { return } + // Notify nginx to immediately unblock the IP + if err := s.notifyNginxUnblockIP(ip); err != nil { + log.Printf("Warning: failed to notify nginx about unblocked IP %s: %v", ip, err) + } + c.JSON(http.StatusOK, gin.H{"message": "IP unblocked successfully"}) } @@ -699,6 +728,69 @@ func (s *Server) updateSecuritySettings(c *gin.Context) { c.JSON(http.StatusOK, result) } +// notifyNginxBlockIP notifies nginx to immediately add an IP to its blocked list +func (s *Server) notifyNginxBlockIP(ip string, ttlSeconds int) error { + if s.config.Nginx.ContainerName == "" { + return fmt.Errorf("nginx container name not configured") + } + + if ttlSeconds <= 0 { + ttlSeconds = 86400 * 365 // 1 year for permanent blocks + } + + payload := map[string]interface{}{ + "ip": ip, + "ttl": ttlSeconds, + } + jsonPayload, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("failed to marshal payload: %w", err) + } + + curlCmd := fmt.Sprintf( + `curl -s -X POST -H "Content-Type: application/json" -d '%s' http://127.0.0.1:8081/_internal/security/block-ip`, + string(jsonPayload), + ) + + cmd := exec.Command("docker", "exec", s.config.Nginx.ContainerName, "sh", "-c", curlCmd) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("failed to notify nginx: %s - %w", string(output), err) + } + + log.Printf("Notified nginx to block IP %s (ttl=%ds): %s", ip, ttlSeconds, string(output)) + return nil +} + +// notifyNginxUnblockIP notifies nginx to immediately remove an IP from its blocked list +func (s *Server) notifyNginxUnblockIP(ip string) error { + if s.config.Nginx.ContainerName == "" { + return fmt.Errorf("nginx container name not configured") + } + + payload := map[string]interface{}{ + "ip": ip, + } + jsonPayload, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("failed to marshal payload: %w", err) + } + + curlCmd := fmt.Sprintf( + `curl -s -X POST -H "Content-Type: application/json" -d '%s' http://127.0.0.1:8081/_internal/security/unblock-ip`, + string(jsonPayload), + ) + + cmd := exec.Command("docker", "exec", s.config.Nginx.ContainerName, "sh", "-c", curlCmd) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("failed to notify nginx: %s - %w", string(output), err) + } + + log.Printf("Notified nginx to unblock IP %s: %s", ip, string(output)) + return nil +} + // refreshSecurityScripts regenerates Lua scripts with correct agent IP and reloads nginx func (s *Server) refreshSecurityScripts(c *gin.Context) { if !s.config.Security.Enabled { diff --git a/internal/infra/manager.go b/internal/infra/manager.go index 15f5024..72f65ab 100644 --- a/internal/infra/manager.go +++ b/internal/infra/manager.go @@ -686,11 +686,41 @@ func (m *Manager) CheckSecurityHealth() *SecurityHealthCheck { result.Checks["nginx_conf_has_global_traffic_logging"] = false result.Issues = append(result.Issues, "nginx.conf does not have global traffic logging enabled") } + + // Check for IP blocking shared dict + if strings.Contains(contentStr, "lua_shared_dict blocked_ips") { + result.Checks["nginx_conf_has_blocked_ips_dict"] = true + } else { + result.Checks["nginx_conf_has_blocked_ips_dict"] = false + result.Issues = append(result.Issues, "nginx.conf missing lua_shared_dict blocked_ips for IP blocking") + result.Recommendations = append(result.Recommendations, "Use POST /api/security/refresh to regenerate nginx.conf with IP blocking support") + } + + // Check for IP blocking access check + if strings.Contains(contentStr, "access_by_lua_block") && strings.Contains(contentStr, "security.is_blocked") { + result.Checks["nginx_conf_has_ip_blocking"] = true + } else { + result.Checks["nginx_conf_has_ip_blocking"] = false + result.Issues = append(result.Issues, "nginx.conf missing access_by_lua_block for IP blocking") + result.Recommendations = append(result.Recommendations, "Use POST /api/security/refresh to regenerate nginx.conf with IP blocking") + } + + // Check for internal API server block + if strings.Contains(contentStr, "listen 127.0.0.1:8081") { + result.Checks["nginx_conf_has_internal_api"] = true + } else { + result.Checks["nginx_conf_has_internal_api"] = false + result.Issues = append(result.Issues, "nginx.conf missing internal API server for instant IP blocking") + result.Recommendations = append(result.Recommendations, "Use POST /api/security/refresh to regenerate nginx.conf with internal API") + } } else { result.Checks["nginx_conf_exists"] = false result.Checks["nginx_conf_has_lua_init"] = false result.Checks["nginx_conf_has_traffic_module"] = false result.Checks["nginx_conf_has_global_traffic_logging"] = false + result.Checks["nginx_conf_has_blocked_ips_dict"] = false + result.Checks["nginx_conf_has_ip_blocking"] = false + result.Checks["nginx_conf_has_internal_api"] = false result.Issues = append(result.Issues, "nginx.conf does not exist at "+nginxConfPath) result.Recommendations = append(result.Recommendations, "Enable realtime capture in Security settings") } @@ -815,6 +845,16 @@ func (m *Manager) CheckSecurityHealth() *SecurityHealthCheck { result.Recommendations = append(result.Recommendations, "3. Use POST /api/security/refresh to regenerate scripts with correct IP") } + + // Check 9: Internal API (port 8081) is reachable for instant IP blocking + internalAPIReachable := m.checkNginxInternalAPIReachable() + result.Checks["nginx_internal_api_reachable"] = internalAPIReachable + if !internalAPIReachable { + result.Issues = append(result.Issues, + "Nginx internal API (port 8081) is not responding - instant IP blocking will not work") + result.Recommendations = append(result.Recommendations, + "Check nginx error logs for Lua errors, ensure nginx.conf has internal server block on 127.0.0.1:8081") + } } // Determine overall status @@ -824,11 +864,15 @@ func (m *Manager) CheckSecurityHealth() *SecurityHealthCheck { "traffic_lua_exists", "traffic_lua_ip_injected", "nginx_conf_has_lua_init", + "nginx_conf_has_blocked_ips_dict", + "nginx_conf_has_ip_blocking", + "nginx_conf_has_internal_api", "nginx_container_running", "nginx_lua_module_loaded", "nginx_conf_mounted", "lua_directory_mounted", "nginx_can_reach_agent", + "nginx_internal_api_reachable", "vhosts_have_security_hook", } @@ -990,6 +1034,17 @@ func (m *Manager) checkNginxCanReachAgent(agentIP string, agentPort int) bool { return strings.Contains(string(output), "yes") } +func (m *Manager) checkNginxInternalAPIReachable() bool { + testCmd := "curl -s --connect-timeout 2 --max-time 5 -X POST http://127.0.0.1:8081/_internal/security/refresh-blocked-ips 2>/dev/null | grep -q success && echo yes || echo no" + + cmd := exec.Command("docker", "exec", m.config.Nginx.ContainerName, "sh", "-c", testCmd) + output, err := cmd.Output() + if err != nil { + return false + } + return strings.Contains(string(output), "yes") +} + // securityVolumeMounts are added when security is enabled and removed when disabled var securityVolumeMounts = []string{ "./nginx.conf:/usr/local/openresty/nginx/conf/nginx.conf:ro", diff --git a/internal/security/detector.go b/internal/security/detector.go index eb8ffd9..a474e12 100644 --- a/internal/security/detector.go +++ b/internal/security/detector.go @@ -11,38 +11,50 @@ type Detector struct { mu sync.RWMutex // Thresholds - rateWindowDuration time.Duration - rateThreshold int - autoBlockThreshold int + windowDuration time.Duration + rateThreshold int // high request rate + notFoundThreshold int // 404 responses + authFailureThreshold int // 401/403 responses + uniquePathsThreshold int // scanning many different paths + repeatedHitsThreshold int // hammering same path } type requestWindow struct { - count int - windowEnd time.Time + count int + notFoundHits int // 404 responses + authFailures int // 401/403 responses + pathHits map[string]int // path -> hit count + windowEnd time.Time } func NewDetector() *Detector { return &Detector{ - ipRequestCount: make(map[string]*requestWindow), - rateWindowDuration: time.Minute, - rateThreshold: 100, - autoBlockThreshold: 50, + ipRequestCount: make(map[string]*requestWindow), + windowDuration: 2 * time.Minute, + rateThreshold: 60, // 60 requests in 2 min + notFoundThreshold: 10, // 10 404s in 2 min + authFailureThreshold: 5, // 5 auth failures in 2 min + uniquePathsThreshold: 20, // 20 different paths in 2 min + repeatedHitsThreshold: 30, // 30 hits to same path in 2 min } } // SetThresholds configures detection thresholds -func (d *Detector) SetThresholds(rateThreshold, autoBlockThreshold int, windowDuration time.Duration) { +func (d *Detector) SetThresholds(rateThreshold, notFoundThreshold, authFailureThreshold, uniquePathsThreshold, repeatedHitsThreshold int, windowDuration time.Duration) { d.mu.Lock() defer d.mu.Unlock() d.rateThreshold = rateThreshold - d.autoBlockThreshold = autoBlockThreshold - d.rateWindowDuration = windowDuration + d.notFoundThreshold = notFoundThreshold + d.authFailureThreshold = authFailureThreshold + d.uniquePathsThreshold = uniquePathsThreshold + d.repeatedHitsThreshold = repeatedHitsThreshold + d.windowDuration = windowDuration } // Classify analyzes an incoming event and creates a SecurityEvent if it's security-relevant func (d *Detector) Classify(event *IngestEvent) *SecurityEvent { - // Track request rate - highRate := d.trackRequestRate(event.SourceIP) + // Track request behavior + highRate := d.trackRequest(event.SourceIP, event) var eventType, severity, message string @@ -118,8 +130,8 @@ func (d *Detector) Classify(event *IngestEvent) *SecurityEvent { } } -// trackRequestRate tracks request rate per IP and returns true if rate is too high -func (d *Detector) trackRequestRate(ip string) bool { +// trackRequest tracks request behavior per IP and returns true if rate is too high +func (d *Detector) trackRequest(ip string, event *IngestEvent) bool { d.mu.Lock() defer d.mu.Unlock() @@ -129,12 +141,27 @@ func (d *Detector) trackRequestRate(ip string) bool { if !exists || now.After(window.windowEnd) { d.ipRequestCount[ip] = &requestWindow{ count: 1, - windowEnd: now.Add(d.rateWindowDuration), + pathHits: make(map[string]int), + windowEnd: now.Add(d.windowDuration), } - return false + window = d.ipRequestCount[ip] + } else { + window.count++ + } + + // Track hits per path + window.pathHits[event.RequestPath]++ + + // Track 404 responses (probing for files/paths) + if event.StatusCode == 404 { + window.notFoundHits++ + } + + // Track auth failures (401, 403) + if event.StatusCode == 401 || event.StatusCode == 403 { + window.authFailures++ } - window.count++ return window.count > d.rateThreshold } @@ -150,15 +177,36 @@ func (d *Detector) ShouldAutoBlock(ip string, event *SecurityEvent) bool { return true } - // Check for repeated critical events d.mu.RLock() window, exists := d.ipRequestCount[ip] d.mu.RUnlock() - if exists && window.count > d.autoBlockThreshold && event.Severity == SeverityCritical { + if !exists { + return false + } + + // Auto-block after too many 404s (probing for files/paths) + if window.notFoundHits >= d.notFoundThreshold { return true } + // Auto-block after too many auth failures + if window.authFailures >= d.authFailureThreshold { + return true + } + + // Auto-block if trying too many unique paths (scanning) + if len(window.pathHits) >= d.uniquePathsThreshold { + return true + } + + // Auto-block if hammering same path repeatedly + for _, hits := range window.pathHits { + if hits >= d.repeatedHitsThreshold { + return true + } + } + return false } diff --git a/internal/security/manager.go b/internal/security/manager.go index 6491742..05a3105 100644 --- a/internal/security/manager.go +++ b/internal/security/manager.go @@ -37,24 +37,34 @@ func (m *Manager) Close() error { return m.db.Close() } +// IngestResult contains the result of event ingestion +type IngestResult struct { + Event *SecurityEvent + AutoBlocked bool + BlockedIP string + BlockTTL int // TTL in seconds for the block +} + // IngestEvent processes an incoming event from nginx and stores it -func (m *Manager) IngestEvent(event *IngestEvent) (*SecurityEvent, error) { +func (m *Manager) IngestEvent(event *IngestEvent, autoBlockDuration time.Duration) (*IngestResult, error) { m.mu.Lock() defer m.mu.Unlock() + result := &IngestResult{} + // Check if IP is blocked - if so, don't process blocked, err := m.db.IsIPBlocked(event.SourceIP) if err != nil { return nil, err } if blocked { - return nil, nil + return result, nil } // Classify the event secEvent := m.detector.Classify(event) if secEvent == nil { - return nil, nil + return result, nil } // Store the event @@ -63,14 +73,20 @@ func (m *Manager) IngestEvent(event *IngestEvent) (*SecurityEvent, error) { return nil, err } secEvent.ID = id + result.Event = secEvent - // Check if we should auto-block the IP (best-effort, ignore errors) + // Check if we should auto-block the IP if m.detector.ShouldAutoBlock(event.SourceIP, secEvent) { - expiresAt := time.Now().Add(24 * time.Hour) - _, _ = m.db.BlockIP(event.SourceIP, "Auto-blocked due to suspicious activity", &expiresAt, true) + expiresAt := time.Now().Add(autoBlockDuration) + _, err := m.db.BlockIP(event.SourceIP, "Auto-blocked due to suspicious activity", &expiresAt, true) + if err == nil { + result.AutoBlocked = true + result.BlockedIP = event.SourceIP + result.BlockTTL = int(autoBlockDuration.Seconds()) + } } - return secEvent, nil + return result, nil } // GetEvents retrieves events with optional filtering diff --git a/templates/infra/nginx/lua/security.lua b/templates/infra/nginx/lua/security.lua index e9116f4..50b2812 100644 --- a/templates/infra/nginx/lua/security.lua +++ b/templates/infra/nginx/lua/security.lua @@ -292,4 +292,123 @@ function _M.check_rate_limit(key, limit, window) return false end +-- Internal API handlers for immediate IP blocking + +local function json_response(status, data) + ngx.status = status + ngx.header["Content-Type"] = "application/json" + ngx.say(cjson.encode(data)) +end + +-- Handle block IP request from agent +function _M.handle_block_ip_request() + local client_ip = ngx.var.remote_addr + + if ngx.req.get_method() ~= "POST" then + ngx.log(ngx.WARN, "block-ip: method not allowed from ", client_ip) + json_response(405, {error = "Method not allowed"}) + return + end + + ngx.req.read_body() + local body = ngx.req.get_body_data() + if not body then + ngx.log(ngx.ERR, "block-ip: no body from ", client_ip) + json_response(400, {error = "No body provided"}) + return + end + + local data, err = cjson.decode(body) + if not data then + ngx.log(ngx.ERR, "block-ip: invalid JSON from ", client_ip, ": ", err, " body=", body:sub(1, 100)) + json_response(400, {error = "Invalid JSON: " .. (err or "unknown")}) + return + end + + local ip = data.ip + local ttl = data.ttl or 86400 -- default 24 hours + + if not ip then + ngx.log(ngx.ERR, "block-ip: missing IP in request from ", client_ip) + json_response(400, {error = "IP address required"}) + return + end + + local dict = ngx.shared.blocked_ips + if not dict then + ngx.log(ngx.ERR, "block-ip: shared dict not available") + json_response(500, {error = "Shared dict not available"}) + return + end + + local ok, set_err = dict:set("ip:" .. ip, true, ttl) + if not ok then + ngx.log(ngx.ERR, "block-ip: failed to set IP ", ip, " in dict: ", set_err) + json_response(500, {error = "Failed to block IP: " .. (set_err or "unknown")}) + return + end + + ngx.log(ngx.INFO, "block-ip: blocked ", ip, " for ", ttl, "s") + json_response(200, {success = true, ip = ip, ttl = ttl}) +end + +-- Handle unblock IP request from agent +function _M.handle_unblock_ip_request() + local client_ip = ngx.var.remote_addr + + if ngx.req.get_method() ~= "POST" then + ngx.log(ngx.WARN, "unblock-ip: method not allowed from ", client_ip) + json_response(405, {error = "Method not allowed"}) + return + end + + ngx.req.read_body() + local body = ngx.req.get_body_data() + if not body then + ngx.log(ngx.ERR, "unblock-ip: no body from ", client_ip) + json_response(400, {error = "No body provided"}) + return + end + + local data, err = cjson.decode(body) + if not data then + ngx.log(ngx.ERR, "unblock-ip: invalid JSON from ", client_ip, ": ", err, " body=", body:sub(1, 100)) + json_response(400, {error = "Invalid JSON: " .. (err or "unknown")}) + return + end + + local ip = data.ip + if not ip then + ngx.log(ngx.ERR, "unblock-ip: missing IP in request from ", client_ip) + json_response(400, {error = "IP address required"}) + return + end + + local dict = ngx.shared.blocked_ips + if not dict then + ngx.log(ngx.ERR, "unblock-ip: shared dict not available") + json_response(500, {error = "Shared dict not available"}) + return + end + + dict:delete("ip:" .. ip) + ngx.log(ngx.INFO, "unblock-ip: unblocked ", ip) + json_response(200, {success = true, ip = ip}) +end + +-- Handle refresh request - force full cache refresh +function _M.handle_refresh_request() + local client_ip = ngx.var.remote_addr + + if ngx.req.get_method() ~= "POST" then + ngx.log(ngx.WARN, "refresh: method not allowed from ", client_ip) + json_response(405, {error = "Method not allowed"}) + return + end + + ngx.log(ngx.INFO, "refresh: refreshing blocked IPs cache") + _M.refresh_blocked_ips() + json_response(200, {success = true, message = "Cache refreshed"}) +end + return _M diff --git a/templates/infra/nginx/nginx.lua.conf b/templates/infra/nginx/nginx.lua.conf index ddd8892..830cc66 100644 --- a/templates/infra/nginx/nginx.lua.conf +++ b/templates/infra/nginx/nginx.lua.conf @@ -73,4 +73,34 @@ http { # Include virtual hosts include /etc/nginx/conf.d/*.conf; + + # Internal API server for security operations (not exposed externally) + server { + listen 127.0.0.1:8081; + server_name localhost; + + location /_internal/security/block-ip { + content_by_lua_block { + security.handle_block_ip_request() + } + } + + location /_internal/security/unblock-ip { + content_by_lua_block { + security.handle_unblock_ip_request() + } + } + + location /_internal/security/refresh-blocked-ips { + content_by_lua_block { + security.handle_refresh_request() + } + } + + location /_internal/health { + content_by_lua_block { + ngx.say("OK") + } + } + } } diff --git a/test/e2e/nginx/lua/nginx.conf b/test/e2e/nginx/lua/nginx.conf index f26f670..82aaf67 100644 --- a/test/e2e/nginx/lua/nginx.conf +++ b/test/e2e/nginx/lua/nginx.conf @@ -56,4 +56,34 @@ http { # Default configs include /etc/nginx/conf.d/*.conf; + + # Internal API server for security operations (not exposed externally) + server { + listen 127.0.0.1:8081; + server_name localhost; + + location /_internal/security/block-ip { + content_by_lua_block { + security.handle_block_ip_request() + } + } + + location /_internal/security/unblock-ip { + content_by_lua_block { + security.handle_unblock_ip_request() + } + } + + location /_internal/security/refresh-blocked-ips { + content_by_lua_block { + security.handle_refresh_request() + } + } + + location /_internal/health { + content_by_lua_block { + ngx.say("OK") + } + } + } } diff --git a/test/e2e/nginx/lua/security.lua b/test/e2e/nginx/lua/security.lua index cb106af..95e8811 100644 --- a/test/e2e/nginx/lua/security.lua +++ b/test/e2e/nginx/lua/security.lua @@ -1,14 +1,92 @@ --- FlatRun Security Event Capture --- This script captures security-relevant events and sends them to the agent API +-- FlatRun Security Event Capture (E2E Test Version) +-- NOTE: This is a simplified version for e2e testing that uses environment variables. +-- The production template is at: templates/infra/nginx/lua/security.lua +-- Keep core functionality in sync with the template. local cjson = require "cjson.safe" local http = require "resty.http" local _M = {} --- Configuration (will be set by the agent) +-- Configuration via environment variable (test-specific) local AGENT_URL = os.getenv("FLATRUN_AGENT_URL") or "http://host.docker.internal:8080" +-- Blocked IPs cache settings +local BLOCKED_IPS_CACHE_TTL = 30 -- seconds +local BLOCKED_IPS_LAST_FETCH = "blocked_ips_last_fetch" + +-- Check if an IP is blocked (with caching) +function _M.is_blocked(ip) + if not ip then return false end + + local dict = ngx.shared.blocked_ips + if not dict then return false end + + local is_blocked = dict:get("ip:" .. ip) + if is_blocked ~= nil then + return is_blocked + end + + local last_fetch = dict:get(BLOCKED_IPS_LAST_FETCH) or 0 + local now = ngx.time() + + if now - last_fetch > BLOCKED_IPS_CACHE_TTL then + ngx.timer.at(0, function() + _M.refresh_blocked_ips() + end) + end + + return false +end + +-- Fetch blocked IPs from agent API and cache them +function _M.refresh_blocked_ips() + local dict = ngx.shared.blocked_ips + if not dict then return end + + local httpc = http.new() + httpc:set_timeout(3000) + + local res, err = httpc:request_uri(AGENT_URL .. "/api/security/blocked-ips", { + method = "GET", + }) + + if not res then + ngx.log(ngx.ERR, "Failed to fetch blocked IPs: ", err) + return + end + + if res.status ~= 200 then + ngx.log(ngx.ERR, "Blocked IPs API returned status: ", res.status) + return + end + + local data, decode_err = cjson.decode(res.body) + if not data then + ngx.log(ngx.ERR, "Failed to decode blocked IPs response: ", decode_err) + return + end + + dict:flush_all() + dict:set(BLOCKED_IPS_LAST_FETCH, ngx.time()) + + local blocked_ips = data.blocked_ips or {} + for _, entry in ipairs(blocked_ips) do + if entry.ip then + dict:set("ip:" .. entry.ip, true, BLOCKED_IPS_CACHE_TTL * 2) + end + end + + ngx.log(ngx.INFO, "Refreshed blocked IPs cache: ", #blocked_ips, " IPs") +end + +-- Initialize blocked IPs cache on worker start +function _M.init_blocked_ips() + ngx.timer.at(0, function() + _M.refresh_blocked_ips() + end) +end + -- Suspicious paths patterns local suspicious_patterns = { "%.env", @@ -177,4 +255,121 @@ function _M.check_rate_limit(key, limit, window) return false end +-- Internal API handlers for immediate IP blocking +-- NOTE: Keep in sync with templates/infra/nginx/lua/security.lua + +local function json_response(status, data) + ngx.status = status + ngx.header["Content-Type"] = "application/json" + ngx.say(cjson.encode(data)) +end + +function _M.handle_block_ip_request() + local client_ip = ngx.var.remote_addr + + if ngx.req.get_method() ~= "POST" then + ngx.log(ngx.WARN, "block-ip: method not allowed from ", client_ip) + json_response(405, {error = "Method not allowed"}) + return + end + + ngx.req.read_body() + local body = ngx.req.get_body_data() + if not body then + ngx.log(ngx.ERR, "block-ip: no body from ", client_ip) + json_response(400, {error = "No body provided"}) + return + end + + local data, err = cjson.decode(body) + if not data then + ngx.log(ngx.ERR, "block-ip: invalid JSON from ", client_ip, ": ", err, " body=", body:sub(1, 100)) + json_response(400, {error = "Invalid JSON: " .. (err or "unknown")}) + return + end + + local ip = data.ip + local ttl = data.ttl or 86400 + + if not ip then + ngx.log(ngx.ERR, "block-ip: missing IP in request from ", client_ip) + json_response(400, {error = "IP address required"}) + return + end + + local dict = ngx.shared.blocked_ips + if not dict then + ngx.log(ngx.ERR, "block-ip: shared dict not available") + json_response(500, {error = "Shared dict not available"}) + return + end + + local ok, set_err = dict:set("ip:" .. ip, true, ttl) + if not ok then + ngx.log(ngx.ERR, "block-ip: failed to set IP ", ip, " in dict: ", set_err) + json_response(500, {error = "Failed to block IP: " .. (set_err or "unknown")}) + return + end + + ngx.log(ngx.INFO, "block-ip: blocked ", ip, " for ", ttl, "s") + json_response(200, {success = true, ip = ip, ttl = ttl}) +end + +function _M.handle_unblock_ip_request() + local client_ip = ngx.var.remote_addr + + if ngx.req.get_method() ~= "POST" then + ngx.log(ngx.WARN, "unblock-ip: method not allowed from ", client_ip) + json_response(405, {error = "Method not allowed"}) + return + end + + ngx.req.read_body() + local body = ngx.req.get_body_data() + if not body then + ngx.log(ngx.ERR, "unblock-ip: no body from ", client_ip) + json_response(400, {error = "No body provided"}) + return + end + + local data, err = cjson.decode(body) + if not data then + ngx.log(ngx.ERR, "unblock-ip: invalid JSON from ", client_ip, ": ", err, " body=", body:sub(1, 100)) + json_response(400, {error = "Invalid JSON: " .. (err or "unknown")}) + return + end + + local ip = data.ip + if not ip then + ngx.log(ngx.ERR, "unblock-ip: missing IP in request from ", client_ip) + json_response(400, {error = "IP address required"}) + return + end + + local dict = ngx.shared.blocked_ips + if not dict then + ngx.log(ngx.ERR, "unblock-ip: shared dict not available") + json_response(500, {error = "Shared dict not available"}) + return + end + + dict:delete("ip:" .. ip) + ngx.log(ngx.INFO, "unblock-ip: unblocked ", ip) + json_response(200, {success = true, ip = ip}) +end + +function _M.handle_refresh_request() + local client_ip = ngx.var.remote_addr + + if ngx.req.get_method() ~= "POST" then + ngx.log(ngx.WARN, "refresh: method not allowed from ", client_ip) + json_response(405, {error = "Method not allowed"}) + return + end + + ngx.log(ngx.INFO, "refresh: refreshing blocked IPs cache") + _M.refresh_blocked_ips() + json_response(200, {success = true, message = "Cache refreshed"}) +end + return _M