Add TTL-based expiry for wildcard subdomain cache entries
Adopt model-redis v1.5.0 and give the on-demand is_cache Host records (and their Cached tracking records) created by Host.addCache a TTL, so they auto-expire instead of accumulating forever. Only the record hash carries the TTL, so OpenResty's direct HGETALL sees a miss once it expires and re-resolves through the lookup path. The lifetime is configurable via conf.cacheTTL (seconds, default 3600; 0 disables expiry). This also mitigates the matchAny=false "wrong host" bug: stale leftover cache entries now expire (and are still busted on parent update), so undefined subdomains stop being served by old caches. Add test/unit/wildcard_matchany.test.js covering the matchAny routing decision (defined vs undefined subdomains, mixed-policy sibling wildcards, and cache-entry behavior) and wire it into the test scripts. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -16,6 +16,11 @@ module.exports = {
|
||||
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:{
|
||||
hostScheduler:{
|
||||
|
||||
@@ -50,18 +50,25 @@ class Host extends Table{
|
||||
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({
|
||||
...parentOBJ,
|
||||
host: host,
|
||||
is_cache: true,
|
||||
is_wildcard: false,
|
||||
wildcard_parent: parentOBJ.host
|
||||
}, true);
|
||||
}, ttl);
|
||||
|
||||
await Cached.create({
|
||||
host: host,
|
||||
parent: parentOBJ.host
|
||||
});
|
||||
}, ttl);
|
||||
}catch(error){
|
||||
console.error('add cache error', {...parentOBJ, host, is_cache: true}, error);
|
||||
throw error;
|
||||
|
||||
Generated
+4
-4
@@ -23,7 +23,7 @@
|
||||
"jquery": "^3.7.1",
|
||||
"ldapts": "^8.1.2",
|
||||
"linux-sys-user": "^1.2.0",
|
||||
"model-redis": "^1.4.0",
|
||||
"model-redis": "^1.5.0",
|
||||
"moment": "^2.30.1",
|
||||
"mustache": "^4.2.0",
|
||||
"p2psub": "^0.2.0",
|
||||
@@ -1398,9 +1398,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/model-redis": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/model-redis/-/model-redis-1.4.0.tgz",
|
||||
"integrity": "sha512-mbs92tMdyPvKrP/VorXXUeMJXWV66A6MVlU3te13f8IYKl0yxyLMF6eoPzuQN7FDCtXpqUeudZe1TxZQeM7QHQ==",
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/model-redis/-/model-redis-1.5.0.tgz",
|
||||
"integrity": "sha512-eVXQQN+k3cR5aJBvPQurgr8WXYpAvVoLu6ydMWenSOJZBDVcHqeNqUCp8n5lYrxv6iZ8PlF0WQzZFmbJ1zP6/A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"redis": "^6.1.0"
|
||||
|
||||
+4
-4
@@ -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/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": "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/wildcard_matchany.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/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": {
|
||||
"node": ">=18.0.0"
|
||||
@@ -34,7 +34,7 @@
|
||||
"jquery": "^3.7.1",
|
||||
"ldapts": "^8.1.2",
|
||||
"linux-sys-user": "^1.2.0",
|
||||
"model-redis": "^1.4.0",
|
||||
"model-redis": "^1.5.0",
|
||||
"moment": "^2.30.1",
|
||||
"mustache": "^4.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