Merge branch 'master' into fix/porkbun-domain-sync-zoneid
This commit is contained in:
+14
-1
@@ -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
@@ -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"
|
||||
|
||||
@@ -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,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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user