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:
+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>
|
||||
|
||||
Reference in New Issue
Block a user