Merge pull request #158 from theta42/wildcard-cert-fixes
Fix wildcard-cert gaps: attach existing host, and register wildcard's own base domain
This commit is contained in:
+9
-2
@@ -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
|
||||
|
||||
+62
-2
@@ -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.
|
||||
|
||||
Generated
+2
-2
@@ -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",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "proxy-api",
|
||||
"version": "1.1.7",
|
||||
"version": "1.1.8",
|
||||
"private": true,
|
||||
"author": [
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user