diff --git a/CHANGELOG.md b/CHANGELOG.md index dda9e87..347aa9a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,13 @@ correspond to git tags (`vX.Y.Z`) and `nodejs/package.json`'s `version`. ## [Unreleased] -## [1.1.7] - 2026-07-16 +## [1.1.8] - 2026-07-17 + +### Fixed +- **Couldn't attach an existing host to a parent wildcard.** The host edit form's "Parent Wildcard" option submitted correctly, but `Host.prototype.update()` had no `challengeType` handling at all (only `Host.create()` did) — selecting it and saving silently did nothing. Added the same wildcard-parent lookup to `update()`. +- **Couldn't register a wildcard's own base domain as a host.** A wildcard cert's `altNames` already cover both the base domain and `*.base domain`, but the lookup tree stores the wildcard one level below its base domain, and a lookup for the bare base domain landed on that empty parent node and found nothing — even though the already-issued cert covers it. `buildLookUpObj()` now also stamps the parent node so this resolves correctly, without re-issuing or duplicating the cert. + +Both required a corrected lookup: attaching an *existing* host (which already has its own tree leaf) needed a new `Host.lookUpWildcardParent()` that checks the sibling wildcard slot instead of resolving to the host's own record. ### Changed - Redesigned the GitHub Pages docs site to match the app's own look (dark navbar/footer, Bootstrap 5, Font Awesome) instead of the generic `jekyll-theme-cayman` theme, added a real cross-page nav, SEO (`jekyll-seo-tag` + `jekyll-sitemap`, per-page descriptions, OG/Twitter tags, sitemap.xml, robots.txt), and mobile-responsive layout. @@ -54,7 +60,8 @@ First tagged release. Establishes the `vX.Y.Z` tag convention that the in-app up - Standalone backup script (`ops/backup.sh`) for deployments not using theta-env's orchestrator — snapshots Redis and `./config`, with retention. - Admin-only in-app banner that checks GitHub releases every 24h and surfaces available updates. -[Unreleased]: https://github.com/theta42/proxy/compare/v1.1.7...HEAD +[Unreleased]: https://github.com/theta42/proxy/compare/v1.1.8...HEAD +[1.1.8]: https://github.com/theta42/proxy/compare/v1.1.7...v1.1.8 [1.1.7]: https://github.com/theta42/proxy/compare/v1.1.6...v1.1.7 [1.1.6]: https://github.com/theta42/proxy/compare/v1.1.5...v1.1.6 [1.1.5]: https://github.com/theta42/proxy/compare/v1.1.4...v1.1.5 diff --git a/nodejs/models/host.js b/nodejs/models/host.js index 445bf06..ccfebc0 100755 --- a/nodejs/models/host.js +++ b/nodejs/models/host.js @@ -320,9 +320,30 @@ class Host extends Table{ } } - async update(...args){ + async update(data, ...args){ try{ - let out = await super.update(...args) + // Mirror Host.create()'s challengeType handling (lines above) so an + // existing HTTP-01 host can be attached to a parent wildcard's cert + // after creation -- previously this was silently dropped since only + // create() understood challengeType, leaving no way to convert an + // existing host onto a wildcard once one was issued. + if(data && data.challengeType === 'wildcardChild'){ + // Not Host.lookUp() -- this.host already has its own leaf in the + // tree (it already exists), so a plain lookUp() would just find + // itself. lookUpWildcardParent() checks the sibling "*" slot + // instead. See its comment for why create()'s own wildcardChild + // branch doesn't need this (a host being newly created hasn't + // claimed its own leaf yet, so plain lookUp() already falls + // through to the wildcard correctly there). + let parentHost = Host.lookUpWildcardParent(this.host); + if(parentHost && parentHost.is_wildcard){ + data.wildcard_parent = parentHost.host; + }else{ + throw new Error(`No parent wild card for ${this.host}`); + } + } + + let out = await super.update(data, ...args) await this.bustCache(this.host); await Host.buildLookUpObj(); @@ -385,6 +406,25 @@ class Host extends Table{ // #record denotes a leaf node on this tree. if(fragments.length === 0){ pointer[fragment]['#record'] = await this.get(host) + + // A single-level wildcard's issued cert also covers its own + // base domain (createWildcardCert requests altNames: + // [domain, *.domain] -- see utils/letsencrypt.js), but the + // base domain sits one level ABOVE the wildcard's own leaf + // in this tree (e.g. "*.cool.mysite.com" is a child of the + // node for "cool.mysite.com"). Without this, looking up the + // bare base domain when it has no host of its own falls + // through to nothing, even though the already-issued cert + // covers it. `pointer` here is still that parent node + // (reassigned to the child only below) -- stamp it too, but + // only if a real, explicitly-created host at that exact + // name hasn't already claimed this leaf (order-independent: + // this only ever fills a gap -- a real host's own pass + // through this loop always overwrites #record + // unconditionally when it's finalized, see above). + if(fragment === '*' && !pointer['#record']){ + pointer['#record'] = pointer[fragment]['#record']; + } } // Advance the pointer to the next level of the tree. @@ -445,6 +485,26 @@ class Host extends Table{ if(parent && parent['*'] && parent['*']['#record']) return parent['*']['#record']; } + // Find the wildcard covering @host as its own base domain (e.g. + // "*.cool.mysite.com" for host="cool.mysite.com"), regardless of whether + // @host is already registered as its own host. Unlike lookUp(), which + // walks to and returns @host's own exact-match leaf when one exists, this + // walks to that exact position and looks one level deeper at its "*" + // child -- the sibling wildcard slot -- so it still finds the parent + // wildcard even when @host already has its own (non-wildcard) record. + // Used when attaching an already-created host to a wildcard after the + // fact (see update() below); Host.create()'s own wildcardChild handling + // can keep using plain lookUp() since a host being newly created hasn't + // claimed its own leaf yet. + static lookUpWildcardParent(host){ + let place = this.lookUpObj; + for(let fragment of host.split('.').reverse()){ + if(!place[fragment]) return undefined; + place = place[fragment]; + } + if(place['*'] && place['*']['#record']) return place['*']['#record']; + } + static async lookUpReady(){ /* Wait for the lookup tree to be built. diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json index 67e212f..8d454d9 100644 --- a/nodejs/package-lock.json +++ b/nodejs/package-lock.json @@ -1,12 +1,12 @@ { "name": "proxy-api", - "version": "1.1.7", + "version": "1.1.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "proxy-api", - "version": "1.1.7", + "version": "1.1.8", "license": "MIT", "dependencies": { "@fortawesome/fontawesome-free": "^7.3.0", diff --git a/nodejs/package.json b/nodejs/package.json index 2623323..b4fee40 100644 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -1,6 +1,6 @@ { "name": "proxy-api", - "version": "1.1.7", + "version": "1.1.8", "private": true, "author": [ { diff --git a/nodejs/test/unit/host_lookup.test.js b/nodejs/test/unit/host_lookup.test.js index 0ba8138..9b7b097 100644 --- a/nodejs/test/unit/host_lookup.test.js +++ b/nodejs/test/unit/host_lookup.test.js @@ -136,6 +136,125 @@ describe('Host Lookup Algorithm', () => { }); }); +/** + * Tests for the wildcard's-own-base-domain fix: a single-level wildcard's + * issued cert also covers its own base domain (altNames: [domain, *.domain], + * see utils/letsencrypt.js), but that base domain sits one tree level ABOVE + * the wildcard's own leaf. buildLookUpObj() now also stamps that parent + * node's #record, and lookUpWildcardParent() finds it even when the base + * domain is ALSO separately registered as its own plain host (the "attach an + * existing host to a parent wildcard" case, unlike lookUp() which would just + * resolve to that host's own record). + */ +describe('Host wildcard base-domain lookup', () => { + + let Host; + + before(async () => { + Host = createMockHostClassWithWildcardParentFix(); + }); + + test('lookUp finds the wildcard record for its own bare base domain when no plain host exists', async () => { + await populateTree(Host, ['*.cool.mysite.com']); + const result = Host.lookUp('cool.mysite.com'); + assert.ok(result, 'Should find a match'); + assert.strictEqual(result.host, '*.cool.mysite.com'); + }); + + test('lookUp still prefers an explicitly-created plain host over the wildcard, regardless of population order', async () => { + await populateTree(Host, ['*.cool.mysite.com', 'cool.mysite.com']); + assert.strictEqual(Host.lookUp('cool.mysite.com').host, 'cool.mysite.com'); + + await populateTree(Host, ['cool.mysite.com', '*.cool.mysite.com']); + assert.strictEqual(Host.lookUp('cool.mysite.com').host, 'cool.mysite.com'); + }); + + test('lookUpWildcardParent finds the wildcard even when the base domain already has its own plain host', async () => { + await populateTree(Host, ['*.cool.mysite.com', 'cool.mysite.com']); + const result = Host.lookUpWildcardParent('cool.mysite.com'); + assert.ok(result, 'Should find the sibling wildcard'); + assert.strictEqual(result.host, '*.cool.mysite.com'); + }); + + test('lookUpWildcardParent returns undefined when there is no wildcard sibling', async () => { + await populateTree(Host, ['cool.mysite.com']); + assert.strictEqual(Host.lookUpWildcardParent('cool.mysite.com'), undefined); + }); + + test('lookUpWildcardParent returns undefined for an unrelated host', async () => { + await populateTree(Host, ['*.cool.mysite.com']); + assert.strictEqual(Host.lookUpWildcardParent('other.example.com'), undefined); + }); +}); + +/** + * Same mock shape as createMockHostClass() above, plus the parent-record + * stamp in the tree-population loop and the lookUpWildcardParent() method -- + * both copied from the real implementation in models/host.js. + */ +function createMockHostClassWithWildcardParentFix() { + return class MockHost { + static lookUpObj = {}; + + static lookUp(host) { + let place = this.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']; + } + + static lookUpWildcardParent(host) { + let place = this.lookUpObj; + for(let fragment of host.split('.').reverse()){ + if(!place[fragment]) return undefined; + place = place[fragment]; + } + if(place['*'] && place['*']['#record']) return place['*']['#record']; + } + }; +} + +async function populateTree(Host, hosts) { + Host.lookUpObj = {}; + + for(let host of hosts){ + let fragments = host.split('.'); + let pointer = Host.lookUpObj; + + while(fragments.length){ + let fragment = fragments.pop(); + + if(!pointer[fragment]){ + pointer[fragment] = {}; + } + + if(fragments.length === 0){ + pointer[fragment]['#record'] = {host}; + + if(fragment === '*' && !pointer['#record']){ + pointer['#record'] = pointer[fragment]['#record']; + } + } + + pointer = pointer[fragment]; + } + } +} + /** * Creates a mock Host class with just the lookUp functionality * This allows us to test the algorithm without Redis dependencies