Add per-host reverse-proxy controls (rate limit, cache, headers, IP ACL)
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>
This commit is contained in:
@@ -60,6 +60,15 @@ apt-get install -y nodejs openresty
|
||||
echo "==> Lua modules"
|
||||
luarocks install lua-resty-auto-ssl
|
||||
luarocks install luasocket
|
||||
# CIDR matcher for the per-host IP allow/deny lists (hostfeatures.lua).
|
||||
# resty.limit.req is bundled with OpenResty, so no rock is needed for it.
|
||||
luarocks install lua-resty-ipmatcher
|
||||
|
||||
echo "==> Proxy response-cache directory"
|
||||
# Must be writable by the OpenResty worker user. nginx.conf sets no `user`
|
||||
# directive, so workers run as the compiled-in default (nobody); own the dir to
|
||||
# match so proxy_cache_path can write to it.
|
||||
install -d -m 0755 -o nobody -g nogroup /var/cache/nginx/proxy
|
||||
|
||||
echo "==> Fallback SSL cert"
|
||||
install -d /etc/ssl
|
||||
@@ -91,6 +100,7 @@ link "$REPO_DIR/ops/nginx_conf/nginx.conf" /etc/openresty/nginx.conf
|
||||
link "$REPO_DIR/ops/nginx_conf/autossl.conf" /etc/openresty/autossl.conf
|
||||
link "$REPO_DIR/ops/nginx_conf/proxy.conf" /etc/openresty/sites-enabled/000-proxy
|
||||
link "$REPO_DIR/ops/nginx_conf/targetinfo.lua" /usr/local/openresty/lualib/targetinfo.lua
|
||||
link "$REPO_DIR/ops/nginx_conf/hostfeatures.lua" /usr/local/openresty/lualib/hostfeatures.lua
|
||||
link "$REPO_DIR/ops/proxy.service" /etc/systemd/system/proxy.service
|
||||
|
||||
echo "==> Node dependencies"
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
-- 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
|
||||
@@ -18,6 +18,15 @@ http {
|
||||
lua_shared_dict auto_ssl 100m;
|
||||
lua_shared_dict auto_ssl_settings 64k;
|
||||
|
||||
# Per-host rate limiting (resty.limit.req) counter storage.
|
||||
lua_shared_dict ratelimit 10m;
|
||||
|
||||
# Per-host response cache. Enabled per request via $skip_cache in proxy.conf;
|
||||
# 10m is the default TTL when the upstream doesn't send its own Cache-Control.
|
||||
proxy_cache_path /var/cache/nginx/proxy levels=1:2 keys_zone=proxycache:100m
|
||||
max_size=2g inactive=60m use_temp_path=off;
|
||||
proxy_cache_valid 200 301 302 10m;
|
||||
|
||||
resolver 8.8.4.4 8.8.8.8;
|
||||
|
||||
init_by_lua_block {
|
||||
|
||||
@@ -18,9 +18,11 @@ server {
|
||||
set $target_scheme 'http';
|
||||
set $target_port '';
|
||||
set $header_host $host;
|
||||
set $skip_cache 1;
|
||||
|
||||
access_by_lua '
|
||||
access_by_lua_block {
|
||||
local targetInfo = require "targetinfo"
|
||||
local hostfeatures = require "hostfeatures"
|
||||
local host = ngx.var.host
|
||||
local uri = ngx.var.uri
|
||||
local scheme = ngx.var.scheme
|
||||
@@ -39,16 +41,33 @@ server {
|
||||
if res["host-pass-though"] == "false" then
|
||||
ngx.var.header_host = res["ip"]
|
||||
end
|
||||
|
||||
|
||||
ngx.var.target = res["ip"]
|
||||
ngx.var.target_port = res["targetPort"]
|
||||
';
|
||||
|
||||
-- Per-host controls: IP allow/deny, rate limit, upstream headers, and
|
||||
-- the $skip_cache gate. May ngx.exit() (403/429).
|
||||
hostfeatures.access(ngx, res)
|
||||
}
|
||||
|
||||
header_filter_by_lua_block {
|
||||
require("hostfeatures").header(ngx)
|
||||
}
|
||||
|
||||
|
||||
resolver 192.168.1.1 ipv6=off; #8.8.4.4; # use Google's open DNS server
|
||||
|
||||
proxy_http_version 1.1;
|
||||
proxy_pass_request_headers on;
|
||||
|
||||
# Response cache. Opt-in per host: $skip_cache is 0 only when the Host
|
||||
# record sets respcache_enabled. Global default TTL lives in nginx.conf;
|
||||
# upstream Cache-Control (private/no-store) is still honored.
|
||||
proxy_cache proxycache;
|
||||
proxy_cache_key $scheme$host$request_uri;
|
||||
proxy_cache_bypass $skip_cache;
|
||||
proxy_no_cache $skip_cache;
|
||||
|
||||
proxy_pass $target_scheme://$target:$target_port;
|
||||
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
|
||||
Reference in New Issue
Block a user