From 609246890143e9dd3e4ab880cfda551222337631 Mon Sep 17 00:00:00 2001 From: William Mantly Date: Fri, 10 Jul 2026 22:10:55 -0400 Subject: [PATCH 1/2] Add per-host reverse-proxy controls (rate limit, cache, headers, IP ACL) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- nodejs/models/host.js | 15 +- nodejs/package.json | 6 +- nodejs/public/lib/js/app-base.js | 5 +- nodejs/routes/host.js | 3 + nodejs/test/unit/host_features.test.js | 178 +++++++++++++++++++++++ nodejs/utils/host_features.js | 188 +++++++++++++++++++++++++ nodejs/views/hosts.ejs | 100 +++++++++++++ ops/install.sh | 10 ++ ops/nginx_conf/hostfeatures.lua | 124 ++++++++++++++++ ops/nginx_conf/nginx.conf | 9 ++ ops/nginx_conf/proxy.conf | 25 +++- 11 files changed, 655 insertions(+), 8 deletions(-) create mode 100644 nodejs/test/unit/host_features.test.js create mode 100644 nodejs/utils/host_features.js create mode 100644 ops/nginx_conf/hostfeatures.lua diff --git a/nodejs/models/host.js b/nodejs/models/host.js index fc57527..bf1cef7 100755 --- a/nodejs/models/host.js +++ b/nodejs/models/host.js @@ -30,7 +30,20 @@ class Host extends Table{ 'targetssl': {isRequired: false, default: false, type: 'boolean'}, 'is_cache': {default: false, isRequired: false, type: 'boolean',}, - + + // Per-host reverse-proxy controls. Enforced in OpenResty by + // ops/nginx_conf/hostfeatures.lua, which reads these straight off the + // Redis hash. Object fields are JSON-encoded by model-redis. + 'ratelimit_enabled': {default: false, isRequired: false, type: 'boolean',}, + 'ratelimit_rate': {default: 10, isRequired: false, type: 'number', min: 1, max: 1000000}, + 'ratelimit_burst': {default: 20, isRequired: false, type: 'number', min: 0, max: 1000000}, + 'respcache_enabled': {default: false, isRequired: false, type: 'boolean',}, + 'hsts_enabled': {default: false, isRequired: false, type: 'boolean',}, + 'req_headers': {default: function(){return {}}, isRequired: false, type: 'object',}, + 'resp_headers': {default: function(){return {}}, isRequired: false, type: 'object',}, + 'ip_allow': {default: function(){return []}, isRequired: false, type: 'object',}, + 'ip_deny': {default: function(){return []}, isRequired: false, type: 'object',}, + 'is_wildcard': {default: false, isRequired: false, type: 'boolean',}, 'wildcard_status': {isRequired: false, type: 'string', min: 3, max: 500}, 'wildcard_matchAny': {default: false, isRequired: false, type: 'boolean',}, diff --git a/nodejs/package.json b/nodejs/package.json index 88669bd..b43a35c 100644 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -11,10 +11,10 @@ "scripts": { "start": "node ./bin/www", "dev": "npx nodemon --ignore public/ ./bin/www", - "test": "node --test test/unit/callback_queue.test.js test/unit/host_lookup.test.js test/unit/wildcard_matchany.test.js test/unit/roles.test.js test/unit/oidc.test.js test/unit/safe_redirect.test.js test/unit/unix_socket.test.js test/integration/dns_provider.test.js", - "test:unit": "node --test test/unit/callback_queue.test.js test/unit/host_lookup.test.js test/unit/wildcard_matchany.test.js test/unit/roles.test.js test/unit/oidc.test.js test/unit/safe_redirect.test.js test/unit/unix_socket.test.js", + "test": "node --test test/unit/callback_queue.test.js test/unit/host_lookup.test.js test/unit/wildcard_matchany.test.js test/unit/roles.test.js test/unit/oidc.test.js test/unit/safe_redirect.test.js test/unit/host_features.test.js test/unit/unix_socket.test.js test/integration/dns_provider.test.js", + "test:unit": "node --test test/unit/callback_queue.test.js test/unit/host_lookup.test.js test/unit/wildcard_matchany.test.js test/unit/roles.test.js test/unit/oidc.test.js test/unit/safe_redirect.test.js test/unit/host_features.test.js test/unit/unix_socket.test.js", "test:integration": "node --test test/integration/dns_provider.test.js", - "test:watch": "node --test --watch test/unit/callback_queue.test.js test/unit/host_lookup.test.js test/unit/wildcard_matchany.test.js test/unit/roles.test.js test/unit/oidc.test.js test/unit/safe_redirect.test.js test/unit/unix_socket.test.js test/integration/dns_provider.test.js" + "test:watch": "node --test --watch test/unit/callback_queue.test.js test/unit/host_lookup.test.js test/unit/wildcard_matchany.test.js test/unit/roles.test.js test/unit/oidc.test.js test/unit/safe_redirect.test.js test/unit/host_features.test.js test/unit/unix_socket.test.js test/integration/dns_provider.test.js" }, "engines": { "node": ">=18.0.0" diff --git a/nodejs/public/lib/js/app-base.js b/nodejs/public/lib/js/app-base.js index 3a7b153..5227e9e 100644 --- a/nodejs/public/lib/js/app-base.js +++ b/nodejs/public/lib/js/app-base.js @@ -372,8 +372,11 @@ app.util = (function(app){ for (let {name, value} of $(this).serializeArray()) { console.log(name, value) if (obj[name] === undefined) { - if (!value + if (!value && !$(this).parent().find(`[name="${name}"]`).attr('value') + // Keep empty + + +
+ + +
+ +
+ + +
+ +
+ + +
+