mirror of https://github.com/interlegis/sapl.git
Browse Source
Replace nginx + libnginx-mod-http-geoip2 with OpenResty so that blocked IPs are rejected before reaching Gunicorn, saving worker CPU on DDoS. nginx layer (read-only, DB 1): - blocklist.lua: UA check (nginx map var), ASN check (lua-resty-maxminddb, replaces geoip2 C module), IP-prefix check (shared dict refreshed every 60s, 4-candidate O(1) lookup), pipelined GET for global IP block and per-tenant API block. Parses REDIS_URL. Fail-open on Redis error. - lua_shared_dict ip_prefix_blocked 1m: in-process prefix cache. - init_by_lua_block: opens MaxMind ASN DB once in master process. - init_worker_by_lua_block: refreshes prefix SET from Redis every 60s. Django (ratelimit.py): - _refresh_ip_prefix_blocklist: normalises entries to trailing-dot form on load so per-request checks are O(1) set membership, not iteration. - _is_ip_prefix_blocked: 4-candidate check (p1., p1.p2., p1.p2.p3., ip) against the local set; same 60s refresh cadence as before. Capacity (1,200 tenants, single Redis): - Django pool: max_connections 6 → 3 (7,200 peak connections). - nginx keepalive pool: 1 connection/worker (4,800 peak connections). - Total: ~12,200 connections — 39% headroom under maxclients 20,000. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>rate-limiter-2026
7 changed files with 222 additions and 92 deletions
@ -0,0 +1,85 @@ |
|||||
|
-- blocklist.lua: early-reject blocked IPs before reaching Gunicorn. |
||||
|
-- |
||||
|
-- Checks (in order, cheapest first): |
||||
|
-- 1. User-Agent in bot UA list — nginx map variable, no Redis |
||||
|
-- 2. ASN in datacenter deny list — lua-resty-maxminddb (MaxMind ASN DB) |
||||
|
-- 3. ngx.shared.ip_prefix_blocked membership — in-process cache refreshed every 60s |
||||
|
-- 4. GET rl:ip:{ip}:blocked — global IP block (Redis DB 1) |
||||
|
-- 5. GET rl:api:ns:{ns}:ip:{ip}:blocked — per-tenant API block (/api/ only, Redis DB 1) |
||||
|
-- |
||||
|
-- Checks 4+5 are pipelined in one Redis round trip. |
||||
|
-- On Redis failure: fail-open (request passes to Django). |
||||
|
|
||||
|
-- Parse REDIS_URL (redis://host:port or redis://host:port/db). |
||||
|
local redis_url = os.getenv("REDIS_URL") or "redis://127.0.0.1:6379" |
||||
|
local REDIS_HOST, port_str = redis_url:match("redis://([^:/]+):(%d+)") |
||||
|
if not REDIS_HOST then REDIS_HOST = redis_url:match("redis://([^:/]+)") or "127.0.0.1" end |
||||
|
local REDIS_PORT = tonumber(port_str) or 6379 |
||||
|
|
||||
|
local POD_NS = os.getenv("POD_NAMESPACE") or "" |
||||
|
local ip = ngx.var.remote_addr |
||||
|
local is_api = ngx.var.uri:sub(1, 5) == "/api/" |
||||
|
|
||||
|
local function return_429() |
||||
|
ngx.status = 429 |
||||
|
ngx.header["Retry-After"] = "300" |
||||
|
ngx.header["Content-Type"] = "application/json" |
||||
|
ngx.say('{"detail":"Too Many Requests"}') |
||||
|
return ngx.exit(429) |
||||
|
end |
||||
|
|
||||
|
-- 1. Bot UA check (nginx map variable — no I/O). |
||||
|
if ngx.var.bot_ua_blocked == "1" then return return_429() end |
||||
|
|
||||
|
-- 2. ASN check via lua-resty-maxminddb (shared DB handle opened in init_by_lua_block). |
||||
|
local BLOCKED_ASNS = { |
||||
|
[16509] = true, -- Amazon AWS |
||||
|
[14618] = true, -- Amazon AWS us-east |
||||
|
[8075] = true, -- Microsoft Azure |
||||
|
[396982]= true, -- Google Cloud |
||||
|
[20473] = true, -- Vultr |
||||
|
[24940] = true, -- Hetzner |
||||
|
[16276] = true, -- OVH |
||||
|
[36352] = true, -- ColoCrossing |
||||
|
[63949] = true, -- Linode / Akamai |
||||
|
} |
||||
|
local ok_mmdb, mmdb = pcall(require, "resty.maxminddb") |
||||
|
if ok_mmdb and mmdb.initted() then |
||||
|
local result = mmdb.lookup(ip) |
||||
|
if result and BLOCKED_ASNS[result.autonomous_system_number] then |
||||
|
return return_429() |
||||
|
end |
||||
|
end |
||||
|
|
||||
|
-- Build 4 candidates for prefix check: three trailing-dot prefixes + exact IP. |
||||
|
-- Mirrors Django's _is_ip_prefix_blocked normalisation and _refresh_ip_prefix_blocklist. |
||||
|
local parts = {} |
||||
|
for p in ip:gmatch("[^.]+") do parts[#parts+1] = p end |
||||
|
local p1 = parts[1] .. "." |
||||
|
local p2 = parts[1] .. "." .. parts[2] .. "." |
||||
|
local p3 = parts[1] .. "." .. parts[2] .. "." .. parts[3] .. "." |
||||
|
|
||||
|
-- 3. IP prefix check (in-process shared dict — no Redis I/O per request). |
||||
|
local dict = ngx.shared.ip_prefix_blocked |
||||
|
if dict:get(p1) or dict:get(p2) or dict:get(p3) or dict:get(ip) then |
||||
|
return return_429() |
||||
|
end |
||||
|
|
||||
|
-- 4+5. Pipeline both STRING block checks in one Redis round trip. |
||||
|
local red = require("resty.redis"):new() |
||||
|
red:set_timeout(200) |
||||
|
local ok = red:connect(REDIS_HOST, REDIS_PORT) |
||||
|
if not ok then return end -- fail-open |
||||
|
|
||||
|
red:select(1) |
||||
|
|
||||
|
red:init_pipeline() |
||||
|
red:get("rl:ip:" .. ip .. ":blocked") |
||||
|
red:get("rl:api:ns:" .. POD_NS .. ":ip:" .. ip .. ":blocked") |
||||
|
local res = red:commit_pipeline() |
||||
|
red:set_keepalive(10000, 1) |
||||
|
|
||||
|
if not res then return end -- fail-open on pipeline error |
||||
|
|
||||
|
if res[1] == "1" then return return_429() end |
||||
|
if is_api and res[2] == "1" then return return_429() end |
||||
Loading…
Reference in new issue