6092468901
Every proxied request flows through one shared OpenResty location whose behavior is chosen at request time from the host's Redis hash. Add per-host controls as new Host fields enforced in Lua rather than static nginx config (which can't key off a per-request variable): - Rate limiting: per-client-IP token bucket via resty.limit.req (ratelimit_enabled/rate/burst), backed by a new `ratelimit` shared dict. - Response caching: opt-in per host via a global proxy_cache zone gated by $skip_cache (respcache_enabled). Off by default; upstream Cache-Control still honored. - Custom/security headers: req_headers (upstream) + resp_headers (client) and hsts_enabled, applied in access/header_filter phases. - IP allow/deny CIDR lists via resty.ipmatcher (deny wins; non-empty allow is default-deny). New ops/nginx_conf/hostfeatures.lua holds the enforcement; proxy.conf's access_by_lua string becomes a block that calls it, plus a header_filter block. nodejs/utils/host_features.js is the pure, unit-tested normalize/validate layer (header/CIDR parsing, range clamping, injection-safe values) applied in routes/host.js and mirrored by the hosts.ejs edit form. install.sh gains the ipmatcher rock, the cache dir, and the hostfeatures.lua symlink. Per-host cache TTL is intentionally deferred (global default only) — see the plan's limitations. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
125 lines
3.8 KiB
Lua
125 lines
3.8 KiB
Lua
-- Per-host reverse-proxy controls, enforced from the shared location's Lua
|
|
-- phases. The Host record (a Redis hash) is resolved by targetinfo.lua and
|
|
-- passed in here as `res`; targetinfo also stashes it in ngx.ctx.targetInfo so
|
|
-- the header-filter phase can re-read it.
|
|
--
|
|
-- Fields consumed (see nodejs/models/host.js):
|
|
-- ratelimit_enabled / ratelimit_rate / ratelimit_burst
|
|
-- respcache_enabled
|
|
-- hsts_enabled
|
|
-- req_headers (JSON object) -- added to the upstream request
|
|
-- resp_headers (JSON object) -- added to the client response
|
|
-- ip_allow / ip_deny (JSON arrays of CIDRs)
|
|
|
|
local cjson = require "cjson.safe"
|
|
|
|
local M = {}
|
|
|
|
-- cjson.safe returns nil (not an error) on bad input; treat non-tables as empty.
|
|
local function decode_table(str)
|
|
if not str or str == "" then return nil end
|
|
local ok = cjson.decode(str)
|
|
if type(ok) == "table" then return ok end
|
|
return nil
|
|
end
|
|
|
|
-- resty.ipmatcher wants a plain array of CIDR strings; build a matcher or nil.
|
|
local function build_matcher(str)
|
|
local list = decode_table(str)
|
|
if not list or #list == 0 then return nil end
|
|
|
|
local ipmatcher = require "resty.ipmatcher"
|
|
local m, err = ipmatcher.new(list)
|
|
if not m then
|
|
ngx.log(ngx.ERR, "hostfeatures: bad ip list ", err)
|
|
return nil
|
|
end
|
|
return m
|
|
end
|
|
|
|
-- IP allow/deny. deny wins; a non-empty allow list is default-deny.
|
|
local function apply_ip_access(res, ip)
|
|
local deny = build_matcher(res["ip_deny"])
|
|
if deny and deny:match(ip) then
|
|
return ngx.exit(403)
|
|
end
|
|
|
|
local allow = build_matcher(res["ip_allow"])
|
|
if allow and not allow:match(ip) then
|
|
return ngx.exit(403)
|
|
end
|
|
end
|
|
|
|
-- Per-host, per-client token bucket via lua-resty-limit-traffic (bundled with
|
|
-- OpenResty). Uses the shared dict "ratelimit" declared in nginx.conf.
|
|
local function apply_ratelimit(res, host, ip)
|
|
if res["ratelimit_enabled"] ~= "true" then return end
|
|
|
|
local rate = tonumber(res["ratelimit_rate"]) or 10
|
|
local burst = tonumber(res["ratelimit_burst"]) or 0
|
|
|
|
local limit_req = require "resty.limit.req"
|
|
local lim, err = limit_req.new("ratelimit", rate, burst)
|
|
if not lim then
|
|
-- Fail open on a misconfigured limiter rather than 500 every request.
|
|
ngx.log(ngx.ERR, "hostfeatures: failed to make limiter ", err)
|
|
return
|
|
end
|
|
|
|
local delay, derr = lim:incoming(host .. ":" .. ip, true)
|
|
if not delay then
|
|
if derr == "rejected" then
|
|
return ngx.exit(429)
|
|
end
|
|
ngx.log(ngx.ERR, "hostfeatures: limiter error ", derr)
|
|
return
|
|
end
|
|
|
|
if delay > 0 then
|
|
ngx.sleep(delay)
|
|
end
|
|
end
|
|
|
|
-- Extra request headers sent to the upstream.
|
|
local function apply_req_headers(res)
|
|
local headers = decode_table(res["req_headers"])
|
|
if not headers then return end
|
|
for name, value in pairs(headers) do
|
|
ngx.req.set_header(name, value)
|
|
end
|
|
end
|
|
|
|
-- access_by_lua entry point. Runs after targetinfo.get resolved `res`.
|
|
function M.access(ngx_, res)
|
|
if not res then return end
|
|
local ip = ngx.var.remote_addr
|
|
local host = ngx.var.host
|
|
|
|
apply_ip_access(res, ip)
|
|
apply_ratelimit(res, host, ip)
|
|
apply_req_headers(res)
|
|
|
|
-- Cache gate for proxy_no_cache / proxy_cache_bypass. Opt-in per host.
|
|
ngx.var.skip_cache = (res["respcache_enabled"] == "true") and "0" or "1"
|
|
end
|
|
|
|
-- header_filter_by_lua entry point. Reads the record stashed in ngx.ctx.
|
|
function M.header(ngx_)
|
|
local res = ngx.ctx.targetInfo
|
|
if not res then return end
|
|
|
|
local headers = decode_table(res["resp_headers"])
|
|
if headers then
|
|
for name, value in pairs(headers) do
|
|
ngx.header[name] = value
|
|
end
|
|
end
|
|
|
|
if res["hsts_enabled"] == "true" then
|
|
ngx.header["Strict-Transport-Security"] =
|
|
"max-age=31536000; includeSubDomains"
|
|
end
|
|
end
|
|
|
|
return M
|