Merge pull request #105 from theta42/model-redis

TTL-based expiry for wildcard subdomain cache
This commit is contained in:
2026-07-10 11:08:26 -04:00
committed by GitHub
5 changed files with 185 additions and 10 deletions
+5
View File
@@ -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:{
+9 -2
View File
@@ -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;
+4 -4
View File
@@ -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
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/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",
+163
View File
@@ -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);
});
});
});