Merge branch 'master' into fix/porkbun-domain-sync-zoneid

This commit is contained in:
2026-07-10 23:28:42 -04:00
committed by GitHub
11 changed files with 656 additions and 8 deletions
+14 -1
View File
@@ -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',},
+3 -3
View File
@@ -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"
+4 -1
View File
@@ -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 <textarea>s so a cleared field is submitted (and
// can reset a list, e.g. the per-host IP/header controls).
&& !$(this).filter(`textarea[name="${name}"]`).length
){
continue;
}
+3
View File
@@ -3,6 +3,7 @@
const router = require('express').Router();
const {Host, Domain} = require('../models').models;
const authz = require('../middleware/authz');
const {normalizeHostFeatures} = require('../utils/host_features');
const Model = Host;
@@ -24,6 +25,7 @@ router.get('/', async function(req, res, next){
router.post('/', authz.requireDomainRole('manager', authz.resolve.hostBody), async function(req, res, next){
try{
req.body.created_by = authz.reqUsername(req);
normalizeHostFeatures(req.body);
let item = await Model.create(req.body);
return res.json({
@@ -87,6 +89,7 @@ router.get('/:item', authz.requireDomainRole('viewer', authz.resolve.hostParam),
router.put('/:item', authz.requireDomainRole('manager', authz.resolve.hostParam), async function(req, res, next){
try{
req.body.updated_by = authz.reqUsername(req);
normalizeHostFeatures(req.body);
let item = await Model.get(req.params.item);
item = await item.update(req.body);
+178
View File
@@ -0,0 +1,178 @@
'use strict';
const {describe, test} = require('node:test');
const assert = require('node:assert');
const {
parseHeaderLines, stringifyHeaders, sanitizeHeaderObject,
isValidCidr, parseCidrLines, sanitizeCidrArray, stringifyCidrs,
normalizeHostFeatures, MAX_HEADERS, MAX_CIDRS,
} = require('../../utils/host_features');
/**
* Pure normalize/validate helpers for the per-host reverse-proxy controls.
* These are the authoritative server-side validation applied in routes/host.js
* and are mirrored by the browser form code.
*/
describe('parseHeaderLines', () => {
test('parses "Name: value" lines into an object', () => {
assert.deepStrictEqual(
parseHeaderLines('X-Frame-Options: DENY\nX-A: b'),
{'X-Frame-Options': 'DENY', 'X-A': 'b'}
);
});
test('splits on the first colon only', () => {
assert.deepStrictEqual(
parseHeaderLines('X-Url: https://a.b/c'),
{'X-Url': 'https://a.b/c'}
);
});
test('skips blank lines and lines without a colon', () => {
assert.deepStrictEqual(
parseHeaderLines('\nX-A: 1\n\ngarbage\n'),
{'X-A': '1'}
);
});
test('rejects invalid header names', () => {
assert.deepStrictEqual(parseHeaderLines('Bad Name: v\nx y: z'), {});
});
test('strips CR/LF from values (no response splitting)', () => {
let out = parseHeaderLines('X-A: a\rb'); // \r inside a single line
assert.strictEqual(out['X-A'], 'ab');
});
test('handles empty/undefined input', () => {
assert.deepStrictEqual(parseHeaderLines(''), {});
assert.deepStrictEqual(parseHeaderLines(undefined), {});
assert.deepStrictEqual(parseHeaderLines(null), {});
});
test('caps the number of headers', () => {
let lines = [];
for(let i = 0; i < MAX_HEADERS + 10; i++) lines.push(`X-H${i}: ${i}`);
assert.strictEqual(Object.keys(parseHeaderLines(lines.join('\n'))).length, MAX_HEADERS);
});
test('round-trips through stringifyHeaders', () => {
let obj = {'X-A': '1', 'X-B': 'two'};
assert.deepStrictEqual(parseHeaderLines(stringifyHeaders(obj)), obj);
});
});
describe('sanitizeHeaderObject', () => {
test('drops bad names and strips CR/LF', () => {
assert.deepStrictEqual(
sanitizeHeaderObject({'X-Ok': 'v\r\nInjected: y', 'bad name': 'z'}),
{'X-Ok': 'vInjected: y'}
);
});
test('handles non-objects', () => {
assert.deepStrictEqual(sanitizeHeaderObject(null), {});
assert.deepStrictEqual(sanitizeHeaderObject('x'), {});
});
});
describe('isValidCidr', () => {
test('accepts IPv4 with and without a mask', () => {
assert.ok(isValidCidr('192.168.1.1'));
assert.ok(isValidCidr('10.0.0.0/8'));
assert.ok(isValidCidr('0.0.0.0/0'));
});
test('rejects out-of-range octets and masks', () => {
assert.ok(!isValidCidr('256.1.1.1'));
assert.ok(!isValidCidr('10.0.0.0/33'));
});
test('accepts loose IPv6, rejects junk', () => {
assert.ok(isValidCidr('::1'));
assert.ok(isValidCidr('fe80::/10'));
assert.ok(!isValidCidr('not-an-ip'));
assert.ok(!isValidCidr(''));
assert.ok(!isValidCidr(42));
});
});
describe('parseCidrLines / sanitizeCidrArray', () => {
test('splits on whitespace and commas, keeping valid entries', () => {
assert.deepStrictEqual(
parseCidrLines('10.0.0.0/8, 192.168.1.5\nbad\n '),
['10.0.0.0/8', '192.168.1.5']
);
});
test('dedupes', () => {
assert.deepStrictEqual(
sanitizeCidrArray(['1.1.1.1', '1.1.1.1', '2.2.2.2']),
['1.1.1.1', '2.2.2.2']
);
});
test('caps the list length', () => {
let arr = [];
for(let i = 0; i < MAX_CIDRS + 10; i++) arr.push(`10.0.0.${i % 256}`);
// includes dupes past .255, so just assert the cap holds
assert.ok(sanitizeCidrArray(arr).length <= MAX_CIDRS);
});
test('round-trips through stringifyCidrs', () => {
let arr = ['10.0.0.0/8', '192.168.1.5'];
assert.deepStrictEqual(parseCidrLines(stringifyCidrs(arr)), arr);
});
test('handles empty input', () => {
assert.deepStrictEqual(parseCidrLines(''), []);
assert.deepStrictEqual(sanitizeCidrArray(null), []);
});
});
describe('normalizeHostFeatures', () => {
test('coerces booleans and clamps numbers', () => {
let body = normalizeHostFeatures({
ratelimit_enabled: 'true',
respcache_enabled: 'false',
hsts_enabled: true,
ratelimit_rate: '0', // below min -> 1
ratelimit_burst: '5000000', // above max -> cap
});
assert.strictEqual(body.ratelimit_enabled, true);
assert.strictEqual(body.respcache_enabled, false);
assert.strictEqual(body.hsts_enabled, true);
assert.strictEqual(body.ratelimit_rate, 1);
assert.strictEqual(body.ratelimit_burst, 1000000);
});
test('accepts both text and object/array shapes', () => {
let fromText = normalizeHostFeatures({
resp_headers: 'X-A: 1',
ip_deny: '10.0.0.0/8\nbad',
});
assert.deepStrictEqual(fromText.resp_headers, {'X-A': '1'});
assert.deepStrictEqual(fromText.ip_deny, ['10.0.0.0/8']);
let fromObj = normalizeHostFeatures({
resp_headers: {'X-A': '1', 'bad name': 'x'},
ip_deny: ['10.0.0.0/8', 'junk'],
});
assert.deepStrictEqual(fromObj.resp_headers, {'X-A': '1'});
assert.deepStrictEqual(fromObj.ip_deny, ['10.0.0.0/8']);
});
test('only touches present keys (partial update safe)', () => {
let body = normalizeHostFeatures({host: 'a.b.com', ip: '1.2.3.4'});
assert.deepStrictEqual(body, {host: 'a.b.com', ip: '1.2.3.4'});
assert.ok(!('ip_allow' in body));
assert.ok(!('ratelimit_rate' in body));
});
test('junk numbers fall back to defaults', () => {
let body = normalizeHostFeatures({ratelimit_rate: 'abc', ratelimit_burst: 'x'});
assert.strictEqual(body.ratelimit_rate, 10);
assert.strictEqual(body.ratelimit_burst, 20);
});
});
+188
View File
@@ -0,0 +1,188 @@
'use strict';
// Pure helpers for the per-host reverse-proxy controls (rate limiting, response
// caching, custom/security headers, IP allow/deny). Shared by the server route
// (`routes/host.js`) for authoritative validation and mirrored by the browser
// form code (`public/js/app.js`) so client and server agree on the wire shape.
//
// No dependencies, no I/O — everything here is deterministic and unit-tested by
// test/unit/host_features.test.js.
const MAX_HEADERS = 50; // per direction (req/resp)
const MAX_HEADER_VALUE = 2048; // chars
const MAX_CIDRS = 200; // per list (allow/deny)
// RFC 7230 header field-name token characters.
const HEADER_NAME_RE = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
/**
* "Name: value" lines -> { Name: value }. Invalid names are dropped; CR/LF are
* stripped from values to prevent header/response splitting. First ':' splits.
*/
function parseHeaderLines(text){
let out = {};
if(text === undefined || text === null) return out;
let lines = String(text).split(/\r?\n/);
for(let line of lines){
if(!line.trim()) continue;
let idx = line.indexOf(':');
if(idx === -1) continue;
let name = line.slice(0, idx).trim();
let value = line.slice(idx + 1).trim();
if(!HEADER_NAME_RE.test(name)) continue;
value = value.replace(/[\r\n]/g, '').slice(0, MAX_HEADER_VALUE);
out[name] = value;
if(Object.keys(out).length >= MAX_HEADERS) break;
}
return out;
}
/** { Name: value } -> "Name: value" lines (for populating the edit form). */
function stringifyHeaders(obj){
if(!obj || typeof obj !== 'object') return '';
return Object.keys(obj)
.map(name => `${name}: ${obj[name]}`)
.join('\n');
}
/**
* Sanitize an already-object header map (e.g. a JSON body) the same way
* parseHeaderLines sanitizes text: valid token names only, CR/LF-stripped
* values, capped count.
*/
function sanitizeHeaderObject(obj){
let out = {};
if(!obj || typeof obj !== 'object') return out;
for(let name of Object.keys(obj)){
if(!HEADER_NAME_RE.test(name)) continue;
let value = String(obj[name]).replace(/[\r\n]/g, '').slice(0, MAX_HEADER_VALUE);
out[name] = value;
if(Object.keys(out).length >= MAX_HEADERS) break;
}
return out;
}
/** True for a plausible IPv4 or IPv6 address with an optional CIDR suffix. */
function isValidCidr(entry){
if(typeof entry !== 'string') return false;
let s = entry.trim();
if(!s) return false;
// IPv4, optional /0-32.
let m = s.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})(?:\/(\d{1,2}))?$/);
if(m){
for(let i = 1; i <= 4; i++){
if(Number(m[i]) > 255) return false;
}
if(m[5] !== undefined && Number(m[5]) > 32) return false;
return true;
}
// IPv6 (loose — resty.ipmatcher does the authoritative parse), optional /0-128.
if(/^[0-9A-Fa-f:]+(?:\/\d{1,3})?$/.test(s) && s.indexOf(':') !== -1){
let slash = s.indexOf('/');
if(slash !== -1 && Number(s.slice(slash + 1)) > 128) return false;
return true;
}
return false;
}
/** Newline/whitespace-separated text -> array of valid CIDR strings. */
function parseCidrLines(text){
if(text === undefined || text === null) return [];
return sanitizeCidrArray(String(text).split(/[\s,]+/));
}
/** array -> deduped array of valid CIDR strings, capped. */
function sanitizeCidrArray(arr){
if(!Array.isArray(arr)) return [];
let seen = new Set();
let out = [];
for(let raw of arr){
let s = String(raw).trim();
if(!s || seen.has(s)) continue;
if(!isValidCidr(s)) continue;
seen.add(s);
out.push(s);
if(out.length >= MAX_CIDRS) break;
}
return out;
}
/** array -> newline-joined text (for populating the edit form). */
function stringifyCidrs(arr){
if(!Array.isArray(arr)) return '';
return arr.join('\n');
}
function toBool(v){
return v === true || v === 'true';
}
/** Coerce a number into [min, max], falling back to `def` for junk. */
function clampNumber(v, min, max, def){
let n = Number(v);
if(!Number.isFinite(n)) return def;
n = Math.floor(n);
if(n < min) return min;
if(n > max) return max;
return n;
}
/**
* Coerce/validate only the per-host feature fields that are PRESENT in `body`,
* in place, returning it. Absent fields are left untouched so partial updates
* (PUT) don't reset unspecified controls. Accepts both the browser wire shape
* (objects/arrays) and raw text (curl users), normalizing to the stored shape.
*/
function normalizeHostFeatures(body){
if(!body || typeof body !== 'object') return body;
if('ratelimit_enabled' in body) body.ratelimit_enabled = toBool(body.ratelimit_enabled);
if('respcache_enabled' in body) body.respcache_enabled = toBool(body.respcache_enabled);
if('hsts_enabled' in body) body.hsts_enabled = toBool(body.hsts_enabled);
if('ratelimit_rate' in body) body.ratelimit_rate = clampNumber(body.ratelimit_rate, 1, 1000000, 10);
if('ratelimit_burst' in body) body.ratelimit_burst = clampNumber(body.ratelimit_burst, 0, 1000000, 20);
if('req_headers' in body){
body.req_headers = typeof body.req_headers === 'string'
? parseHeaderLines(body.req_headers)
: sanitizeHeaderObject(body.req_headers);
}
if('resp_headers' in body){
body.resp_headers = typeof body.resp_headers === 'string'
? parseHeaderLines(body.resp_headers)
: sanitizeHeaderObject(body.resp_headers);
}
if('ip_allow' in body){
body.ip_allow = typeof body.ip_allow === 'string'
? parseCidrLines(body.ip_allow)
: sanitizeCidrArray(body.ip_allow);
}
if('ip_deny' in body){
body.ip_deny = typeof body.ip_deny === 'string'
? parseCidrLines(body.ip_deny)
: sanitizeCidrArray(body.ip_deny);
}
return body;
}
module.exports = {
MAX_HEADERS, MAX_HEADER_VALUE, MAX_CIDRS,
parseHeaderLines, stringifyHeaders, sanitizeHeaderObject,
isValidCidr, parseCidrLines, sanitizeCidrArray, stringifyCidrs,
normalizeHostFeatures,
};
+100
View File
@@ -89,6 +89,16 @@
$.scope.editHost.remove(0);
}
// Mirror of utils/host_features.js stringify* helpers for populating the edit
// form's textareas. The server re-parses the posted text authoritatively.
function hostFeatureHeadersToText(obj){
if(!obj || typeof obj != 'object') return '';
return Object.keys(obj).map(function(name){ return name + ': ' + obj[name]; }).join('\n');
}
function hostFeatureCidrsToText(arr){
return Array.isArray(arr) ? arr.join('\n') : '';
}
function hostEditOpen(btn, host){
hostEditCancle();
console.log('host:', host)
@@ -110,6 +120,13 @@
}
});
// Object/array proxy-control fields render into textareas as text. Server
// (utils/host_features.js) parses the same text/shape back on save.
$(".hostEditPanel textarea[name='req_headers']").val(hostFeatureHeadersToText(host.req_headers));
$(".hostEditPanel textarea[name='resp_headers']").val(hostFeatureHeadersToText(host.resp_headers));
$(".hostEditPanel textarea[name='ip_allow']").val(hostFeatureCidrsToText(host.ip_allow));
$(".hostEditPanel textarea[name='ip_deny']").val(hostFeatureCidrsToText(host.ip_deny));
$('.hostEditPanel').scrollTo();
};
@@ -432,6 +449,89 @@
</div>
<b class="invalid-feedback"></b>
</div>
<hr />
<h6 class="text-muted">Proxy controls</h6>
<div class="form-group">
<label class="form-label">Rate limiting</label>
<div class="radio">
<label>
<input type="radio" name="ratelimit_enabled" id="ratelimit_enabled-false" value="false" checked>
Off <b>Recommended</b>
</label>
</div>
<div class="radio">
<label>
<input type="radio" name="ratelimit_enabled" id="ratelimit_enabled-true" value="true">
Limit requests per client IP
</label>
</div>
</div>
<div class="row">
<div class="col form-group">
<label for="ratelimit_rate" class="form-label">Requests / sec</label>
<input type="number" name="ratelimit_rate" class="form-control" value="10" min="1" max="1000000" />
</div>
<div class="col form-group">
<label for="ratelimit_burst" class="form-label">Burst</label>
<input type="number" name="ratelimit_burst" class="form-control" value="20" min="0" max="1000000" />
</div>
</div>
<div class="form-group">
<label class="form-label">Response caching</label>
<div class="radio">
<label>
<input type="radio" name="respcache_enabled" id="respcache_enabled-false" value="false" checked>
Off <b>Recommended</b>
</label>
</div>
<div class="radio">
<label>
<input type="radio" name="respcache_enabled" id="respcache_enabled-true" value="true">
Cache cacheable responses
</label>
</div>
</div>
<div class="form-group">
<label class="form-label">HSTS</label>
<div class="radio">
<label>
<input type="radio" name="hsts_enabled" id="hsts_enabled-false" value="false" checked>
Off
</label>
</div>
<div class="radio">
<label>
<input type="radio" name="hsts_enabled" id="hsts_enabled-true" value="true">
Send Strict-Transport-Security
</label>
</div>
</div>
<div class="form-group">
<label for="ip_allow" class="form-label">Allow IPs / CIDRs</label>
<textarea name="ip_allow" class="form-control" rows="2" placeholder="one per line; if set, only these are allowed"></textarea>
</div>
<div class="form-group">
<label for="ip_deny" class="form-label">Deny IPs / CIDRs</label>
<textarea name="ip_deny" class="form-control" rows="2" placeholder="one per line; these are blocked"></textarea>
</div>
<div class="form-group">
<label for="req_headers" class="form-label">Upstream request headers</label>
<textarea name="req_headers" class="form-control" rows="2" placeholder="Name: value, one per line"></textarea>
</div>
<div class="form-group">
<label for="resp_headers" class="form-label">Response headers</label>
<textarea name="resp_headers" class="form-control" rows="2" placeholder="Name: value, one per line"></textarea>
</div>
<hr class="buttonBreak" />
<button type="submit" class="btn btn-success">
<i class="fa-solid fa-plus"></i>
+10
View File
@@ -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"
+124
View File
@@ -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
+10
View File
@@ -18,6 +18,16 @@ 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;
# 48h is the default TTL when the upstream doesn't send its own Cache-Control.
# `inactive` must be >= the TTL or entries get evicted before they expire.
proxy_cache_path /var/cache/nginx/proxy levels=1:2 keys_zone=proxycache:100m
max_size=2g inactive=48h use_temp_path=off;
proxy_cache_valid 200 301 302 48h;
resolver 8.8.4.4 8.8.8.8;
init_by_lua_block {
+22 -3
View File
@@ -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 (48h) 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;