Merge branch 'master' into deps/audit-fix
This commit is contained in:
@@ -16,6 +16,11 @@ module.exports = {
|
|||||||
prefix: 'proxy_'
|
prefix: 'proxy_'
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Lifetime, in seconds, of on-demand wildcard-subdomain cache entries
|
||||||
|
// (the is_cache Host records created by Host.addCache). They expire on
|
||||||
|
// their own via redis TTL so they stop accumulating and stale routes
|
||||||
|
// self-correct. 0 disables expiry (entries live until bustCache/clearCache).
|
||||||
|
cacheTTL: 3600,
|
||||||
|
|
||||||
service:{
|
service:{
|
||||||
hostScheduler:{
|
hostScheduler:{
|
||||||
|
|||||||
@@ -50,18 +50,25 @@ class Host extends Table{
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Give the on-demand cache entry a TTL so it auto-expires instead of
|
||||||
|
// living forever. Only the record hash carries the TTL (model-redis
|
||||||
|
// reaps the dangling index member on the next read), so OpenResty's
|
||||||
|
// direct HGETALL sees a miss once it expires and re-resolves through
|
||||||
|
// this lookup path. 0/falsy conf disables expiry.
|
||||||
|
let ttl = conf.cacheTTL > 0 ? {ttl: conf.cacheTTL} : undefined;
|
||||||
|
|
||||||
await this.create({
|
await this.create({
|
||||||
...parentOBJ,
|
...parentOBJ,
|
||||||
host: host,
|
host: host,
|
||||||
is_cache: true,
|
is_cache: true,
|
||||||
is_wildcard: false,
|
is_wildcard: false,
|
||||||
wildcard_parent: parentOBJ.host
|
wildcard_parent: parentOBJ.host
|
||||||
}, true);
|
}, ttl);
|
||||||
|
|
||||||
await Cached.create({
|
await Cached.create({
|
||||||
host: host,
|
host: host,
|
||||||
parent: parentOBJ.host
|
parent: parentOBJ.host
|
||||||
});
|
}, ttl);
|
||||||
}catch(error){
|
}catch(error){
|
||||||
console.error('add cache error', {...parentOBJ, host, is_cache: true}, error);
|
console.error('add cache error', {...parentOBJ, host, is_cache: true}, error);
|
||||||
throw error;
|
throw error;
|
||||||
|
|||||||
Generated
+4
-4
@@ -23,7 +23,7 @@
|
|||||||
"jquery": "^4.0.0",
|
"jquery": "^4.0.0",
|
||||||
"ldapts": "^8.1.8",
|
"ldapts": "^8.1.8",
|
||||||
"linux-sys-user": "^1.2.0",
|
"linux-sys-user": "^1.2.0",
|
||||||
"model-redis": "^1.4.0",
|
"model-redis": "^1.5.0",
|
||||||
"moment": "^2.30.1",
|
"moment": "^2.30.1",
|
||||||
"mustache": "^4.2.0",
|
"mustache": "^4.2.0",
|
||||||
"p2psub": "^0.2.0",
|
"p2psub": "^0.2.0",
|
||||||
@@ -1399,9 +1399,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/model-redis": {
|
"node_modules/model-redis": {
|
||||||
"version": "1.4.0",
|
"version": "1.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/model-redis/-/model-redis-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/model-redis/-/model-redis-1.5.0.tgz",
|
||||||
"integrity": "sha512-mbs92tMdyPvKrP/VorXXUeMJXWV66A6MVlU3te13f8IYKl0yxyLMF6eoPzuQN7FDCtXpqUeudZe1TxZQeM7QHQ==",
|
"integrity": "sha512-eVXQQN+k3cR5aJBvPQurgr8WXYpAvVoLu6ydMWenSOJZBDVcHqeNqUCp8n5lYrxv6iZ8PlF0WQzZFmbJ1zP6/A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"redis": "^6.1.0"
|
"redis": "^6.1.0"
|
||||||
|
|||||||
+4
-4
@@ -11,10 +11,10 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node ./bin/www",
|
"start": "node ./bin/www",
|
||||||
"dev": "npx nodemon --ignore public/ ./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/unix_socket.test.js test/integration/dns_provider.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/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/unix_socket.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/unix_socket.test.js",
|
||||||
"test:integration": "node --test test/integration/dns_provider.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/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/unix_socket.test.js test/integration/dns_provider.test.js"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
@@ -34,7 +34,7 @@
|
|||||||
"jquery": "^4.0.0",
|
"jquery": "^4.0.0",
|
||||||
"ldapts": "^8.1.8",
|
"ldapts": "^8.1.8",
|
||||||
"linux-sys-user": "^1.2.0",
|
"linux-sys-user": "^1.2.0",
|
||||||
"model-redis": "^1.4.0",
|
"model-redis": "^1.5.0",
|
||||||
"moment": "^2.30.1",
|
"moment": "^2.30.1",
|
||||||
"mustache": "^4.2.0",
|
"mustache": "^4.2.0",
|
||||||
"p2psub": "^0.2.0",
|
"p2psub": "^0.2.0",
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const {describe, test, before} = require('node:test');
|
||||||
|
const assert = require('node:assert');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests for the wildcard "match only subdomains defined here" behavior.
|
||||||
|
*
|
||||||
|
* A wildcard host can be created with wildcard_matchAny = false ("Match only
|
||||||
|
* subdomains defined here"). In that mode the wildcard cert covers the whole
|
||||||
|
* subtree for TLS, but only subdomains that are explicitly defined in redis may
|
||||||
|
* actually be proxied — an undefined subdomain must be rejected rather than
|
||||||
|
* routed to the wildcard parent.
|
||||||
|
*
|
||||||
|
* Two pieces of logic combine to produce a routing decision:
|
||||||
|
* 1. Host.lookUp(tree, host) — walks the lookup tree and returns the matched
|
||||||
|
* #record (or undefined). See models/host.js.
|
||||||
|
* 2. The host_lookup service guard — given the matched record and the
|
||||||
|
* requested domain, decides whether it may actually be served. See
|
||||||
|
* services/host_lookup.js onData().
|
||||||
|
*
|
||||||
|
* These are exercised here without a redis connection, mirroring the approach
|
||||||
|
* in host_lookup.test.js.
|
||||||
|
*/
|
||||||
|
|
||||||
|
describe('Wildcard matchAny routing', () => {
|
||||||
|
|
||||||
|
// --- helpers that mirror the real implementation -----------------------
|
||||||
|
|
||||||
|
// Faithful copy of Host.lookUp from models/host.js (including the `parent`
|
||||||
|
// tracking and the final parent['*'] fallback). Kept in sync with that
|
||||||
|
// method; the algorithm is pure so it can be unit-tested standalone.
|
||||||
|
function lookUp(lookUpObj, host){
|
||||||
|
let place = lookUpObj;
|
||||||
|
let last_resort = {};
|
||||||
|
let parent = undefined;
|
||||||
|
|
||||||
|
for(let fragment of host.split('.').reverse()){
|
||||||
|
parent = place;
|
||||||
|
if(place['**']) last_resort = place['**'];
|
||||||
|
|
||||||
|
if({...last_resort, ...place}[fragment]){
|
||||||
|
place = {...last_resort, ...place}[fragment];
|
||||||
|
}else if(place['*']){
|
||||||
|
place = place['*'];
|
||||||
|
}else if(last_resort){
|
||||||
|
place = last_resort;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if(place && place['#record']) return place['#record'];
|
||||||
|
if(parent && parent['*'] && parent['*']['#record']) return parent['*']['#record'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mirror of the host_lookup service serve decision (services/host_lookup.js
|
||||||
|
// onData): a wildcard with matchAny disabled only serves the exact wildcard
|
||||||
|
// host itself, never an inexact (undefined) subdomain. Returns the served
|
||||||
|
// host name, or null when the request must be rejected.
|
||||||
|
function serve(record, domain){
|
||||||
|
if(!record) return null;
|
||||||
|
if(record.is_wildcard && !record.wildcard_matchAny && record.host !== domain){
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return record.host;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTree(records){
|
||||||
|
const tree = {};
|
||||||
|
for(const host of Object.keys(records)){
|
||||||
|
let fragments = host.split('.');
|
||||||
|
let pointer = tree;
|
||||||
|
while(fragments.length){
|
||||||
|
let fragment = fragments.pop();
|
||||||
|
if(!pointer[fragment]) pointer[fragment] = {};
|
||||||
|
if(fragments.length === 0) pointer[fragment]['#record'] = records[host];
|
||||||
|
pointer = pointer[fragment];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tree;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolve = (records, domain) => serve(lookUp(buildTree(records), domain), domain);
|
||||||
|
|
||||||
|
// --- matchAny = false ---------------------------------------------------
|
||||||
|
|
||||||
|
describe('matchAny disabled ("only defined subdomains")', () => {
|
||||||
|
const records = {
|
||||||
|
'*.example.com': {host: '*.example.com', is_wildcard: true, wildcard_matchAny: false},
|
||||||
|
'api.example.com': {host: 'api.example.com', is_wildcard: false},
|
||||||
|
};
|
||||||
|
|
||||||
|
test('serves an explicitly defined subdomain', () => {
|
||||||
|
assert.strictEqual(resolve(records, 'api.example.com'), 'api.example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects an undefined subdomain (does not route to the wildcard)', () => {
|
||||||
|
assert.strictEqual(resolve(records, 'nope.example.com'), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects a deep undefined subdomain', () => {
|
||||||
|
assert.strictEqual(resolve(records, 'a.b.example.com'), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not serve the apex when only a wildcard is defined', () => {
|
||||||
|
assert.strictEqual(resolve(records, 'example.com'), null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- matchAny = true ----------------------------------------------------
|
||||||
|
|
||||||
|
describe('matchAny enabled', () => {
|
||||||
|
const records = {
|
||||||
|
'*.open.example.com': {host: '*.open.example.com', is_wildcard: true, wildcard_matchAny: true},
|
||||||
|
};
|
||||||
|
|
||||||
|
test('serves the wildcard for any undefined subdomain', () => {
|
||||||
|
assert.strictEqual(resolve(records, 'anything.open.example.com'), '*.open.example.com');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- sibling wildcards with mixed policies ------------------------------
|
||||||
|
|
||||||
|
describe('sibling wildcards with mixed matchAny', () => {
|
||||||
|
const records = {
|
||||||
|
'*.secure.example.com': {host: '*.secure.example.com', is_wildcard: true, wildcard_matchAny: false},
|
||||||
|
'*.open.example.com': {host: '*.open.example.com', is_wildcard: true, wildcard_matchAny: true},
|
||||||
|
};
|
||||||
|
|
||||||
|
test('the matchAny=false branch rejects undefined subdomains', () => {
|
||||||
|
assert.strictEqual(resolve(records, 'x.secure.example.com'), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the matchAny=true branch serves undefined subdomains', () => {
|
||||||
|
assert.strictEqual(resolve(records, 'x.open.example.com'), '*.open.example.com');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- cache-entry characterization --------------------------------------
|
||||||
|
|
||||||
|
describe('cached subdomain entries', () => {
|
||||||
|
// addCache() stores an on-demand subdomain as its own Host with
|
||||||
|
// is_wildcard flattened to false, so the matchAny guard (which keys off
|
||||||
|
// is_wildcard) does not apply to it — a cache entry is served directly.
|
||||||
|
// This is why such entries are given a TTL (conf.cacheTTL) and are busted
|
||||||
|
// on parent update: correctness under matchAny=false relies on stale
|
||||||
|
// cache entries expiring / being cleared, not on a serve-time guard.
|
||||||
|
const records = {
|
||||||
|
'*.example.com': {host: '*.example.com', is_wildcard: true, wildcard_matchAny: false},
|
||||||
|
'cached.example.com': {host: 'cached.example.com', is_wildcard: false, is_cache: true},
|
||||||
|
};
|
||||||
|
|
||||||
|
test('a live cache entry is served directly (regardless of parent matchAny)', () => {
|
||||||
|
assert.strictEqual(resolve(records, 'cached.example.com'), 'cached.example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('once the cache entry is gone, the undefined subdomain is rejected', () => {
|
||||||
|
const expired = {
|
||||||
|
'*.example.com': {host: '*.example.com', is_wildcard: true, wildcard_matchAny: false},
|
||||||
|
};
|
||||||
|
assert.strictEqual(resolve(expired, 'cached.example.com'), null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user