diff --git a/nodejs/conf/base.js b/nodejs/conf/base.js index a04d514..d670d31 100644 --- a/nodejs/conf/base.js +++ b/nodejs/conf/base.js @@ -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:{ diff --git a/nodejs/models/host.js b/nodejs/models/host.js index b3030d4..fc57527 100755 --- a/nodejs/models/host.js +++ b/nodejs/models/host.js @@ -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; diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json index 17e2bd4..d3a7ebd 100644 --- a/nodejs/package-lock.json +++ b/nodejs/package-lock.json @@ -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" diff --git a/nodejs/package.json b/nodejs/package.json index c4645c5..9b57fe1 100644 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -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", diff --git a/nodejs/test/unit/wildcard_matchany.test.js b/nodejs/test/unit/wildcard_matchany.test.js new file mode 100644 index 0000000..08487c1 --- /dev/null +++ b/nodejs/test/unit/wildcard_matchany.test.js @@ -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); + }); + }); +});