Compare commits

...

4 Commits

Author SHA1 Message Date
wmantly 876ea6cfd0 Merge pull request #159 from theta42/host-form-ux-polish
Host form/list UX polish: editable hostname, created-by column, mobile tabs, more help text
2026-07-17 19:05:09 -04:00
wmantly 9100e92549 Host form/list UX polish: editable hostname, created-by column, mobile tabs, more help text
- Plain hosts can now be renamed after creation (wildcard/child/cache hosts
  stay locked, since other records reference them by name). Migrates the
  cert cache key on rename.
- Along the way, found and fixed a real bug in the vendored model-redis
  library: its rename path leaves a stray, incomplete hash behind under
  the old key when an `always`-type field (updated_on) is defined earlier
  in the schema than the primary key -- silently blocking that hostname
  from ever being reused. Worked around at the Host model level (can't
  patch node_modules).
- Host list now shows who created each host, and when.
- Host modal's tabs now scroll horizontally on narrow screens instead of
  overflowing awkwardly.
- Added missing inline help text (Target SSL, wildcard matching behavior).

Bumps to v1.1.9.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDEx8ghuZR61pqPXc6da9C
2026-07-17 19:03:26 -04:00
wmantly 9a83fb8252 Merge pull request #158 from theta42/wildcard-cert-fixes
Fix wildcard-cert gaps: attach existing host, and register wildcard's own base domain
2026-07-17 18:47:28 -04:00
wmantly 17b903e228 Fix two wildcard-cert gaps: attaching an existing host, and the wildcard's own base domain
- Host.prototype.update() had no challengeType handling (only create() did),
  so selecting "Parent Wildcard" on an existing host's edit form silently
  did nothing. Added the same wildcard-parent lookup to update(), using a
  new Host.lookUpWildcardParent() -- the existing lookUp() can't be reused
  here since an already-created host resolves to its own leaf rather than
  falling through to a sibling wildcard.

- A wildcard's issued cert covers both the base domain and *.base domain
  (altNames), but the lookup tree stores the wildcard one level below its
  base -- looking up the bare base domain landed on an empty parent node
  and found nothing. buildLookUpObj() now also stamps that parent node,
  order-independent (a real host explicitly created at that exact name
  always still wins).

Verified both fixes against a real Redis-backed Host model (not just the
mocked lookup-tree tests) -- see PR description.

Bumps to v1.1.8.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDEx8ghuZR61pqPXc6da9C
2026-07-17 18:46:09 -04:00
7 changed files with 281 additions and 13 deletions
+21 -2
View File
@@ -6,7 +6,24 @@ correspond to git tags (`vX.Y.Z`) and `nodejs/package.json`'s `version`.
## [Unreleased]
## [1.1.7] - 2026-07-16
## [1.1.9] - 2026-07-17
### Added
- The host list now shows who created each host, and when.
- Plain (non-wildcard) hosts can now be renamed after creation — the hostname field is no longer permanently locked. Wildcard hosts, wildcard children, and auto-created subdomain cache entries stay locked, since other records reference them by name.
- More inline help text on the host create/edit form (Target SSL, wildcard matching behavior).
### Fixed
- The host create/edit modal's tabs could overflow awkwardly on narrow (mobile) screens — they now scroll horizontally instead.
- Fixed a bug in the vendored `model-redis` library's record-rename path: renaming a record's primary key while another `always`-type field (e.g. `updated_on`) is defined earlier in the schema left a stray, incomplete hash behind under the old key, making that name permanently unavailable for reuse. Worked around in `Host.prototype.update()`.
Bumps to v1.1.9.
### 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 +71,9 @@ 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.9...HEAD
[1.1.9]: https://github.com/theta42/proxy/compare/v1.1.8...v1.1.9
[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
+9 -1
View File
@@ -14,6 +14,14 @@ async function getCert(host){
}
}
async function setCert(host, cert){
try{
return await client.SET(`${host}:latest`, JSON.stringify(cert));
}catch(error){
return {}
}
}
async function deleteCert(host){
try{
console.log('looking for', host);
@@ -23,4 +31,4 @@ async function deleteCert(host){
}
}
module.exports = {getCert, deleteCert};
module.exports = {getCert, setCert, deleteCert};
+96 -3
View File
@@ -2,7 +2,7 @@
const Table = require('.');
const {Domain} = require('.').models;
const {deleteCert} = require('./cert');
const {getCert, setCert, deleteCert} = require('./cert');
const ModelPs = require('../utils/model_pubsub');
const tldExtract = require('tld-extract').parse_host;
@@ -320,12 +320,66 @@ 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}`);
}
}
// Real hostname rename. model-redis's own update() (see super.update()
// below) already handles the Redis primary-key RENAME + collision
// check, and Host.buildLookUpObj() below already rebuilds the lookup
// tree afterward -- but the cert cache (models/cert.js, `${host}:latest`)
// is a separate record keyed by hostname string that the generic field
// system doesn't know about, so it doesn't move on its own. Only
// wildcard hosts (createWildcardCert) ever populate this key -- for a
// plain HTTP-01 host this is a no-op (nothing to migrate; auto-ssl
// transparently issues a fresh cert under the new name on first
// access, same as it does for any newly-created host).
let oldHost = this.host;
let renaming = data && typeof data.host === 'string' && data.host !== oldHost;
if(renaming){
let cert = await getCert(oldHost);
if(cert && Object.keys(cert).length) await setCert(data.host, cert);
}
let out = await super.update(data, ...args)
await this.bustCache(this.host);
await Host.buildLookUpObj();
if(renaming){
await deleteCert(oldHost);
// Work around a model-redis bug (as of ^1.5.0): super.update()'s
// field-application loop iterates _keyMap's definition order and
// only reassigns this[_key] (this.host) to the NEW value once it
// reaches the `host` field itself -- but `updated_on` (always:
// true, so always included) is defined BEFORE `host` in _keyMap,
// so it gets HSET while this.host is still the OLD name. Redis's
// HSET on a non-existent key (the old hash, just RENAMEd away)
// silently recreates it -- leaving a stray, incomplete hash under
// the old hostname that makes Host.exists(oldHost) wrongly return
// true forever, blocking that name from ever being reused.
await this.constructor.redisClient.DEL(`${conf.redis.prefix || ''}Host_${oldHost}`);
}
return out;
} catch(error){
throw error;
@@ -385,6 +439,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 +518,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.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "proxy-api",
"version": "1.1.7",
"version": "1.1.9",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "proxy-api",
"version": "1.1.7",
"version": "1.1.9",
"license": "MIT",
"dependencies": {
"@fortawesome/fontawesome-free": "^7.3.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "proxy-api",
"version": "1.1.7",
"version": "1.1.9",
"private": true,
"author": [
{
+119
View File
@@ -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
+33 -4
View File
@@ -39,6 +39,7 @@
// Parse the JSON object for a host to something the UI wants
function hostParseRow(host) {
host['created_on_text'] = moment(host['created_on'], "x").fromNow();
host['updated_on_text'] = moment(host['updated_on'], "x").fromNow();
host['wildcard_expires_text'] = moment(host['wildcard_expires'], "x").fromNow();
host['targetssl_text'] = host['targetssl'] ? 'https://' : 'http://';
@@ -191,6 +192,7 @@
let $f = $(form);
$f.attr('method', 'POST').attr('action', 'host').attr('evalAJAX', 'hostModalClose()');
$f.find('[name=host]').prop('disabled', false);
$('#host-rename-help').hide();
if($f.validateClear) $f.validateClear();
// A fresh host only qualifies for HTTP-01 until the name says otherwise.
@@ -247,9 +249,15 @@
hostAuthModeChanged(authMode);
hostRenderBasicAuthUsers(host, h.basicauth_users);
// The host name is the key; it can't change on edit. Wildcard hosts can
// still toggle their matching mode.
$f.find('[name=host]').prop('disabled', true);
// The host name is the Redis record's key -- renaming it is a real
// migration (see Host.prototype.update() in models/host.js), scoped
// there to plain hosts only: a wildcard's children reference it by
// name (wildcard_parent) and a cache entry's parent likewise, so
// renaming either would orphan those pointers. Keep the field locked
// for those cases; a plain host can be renamed freely.
let hostRenameable = !h.is_wildcard && !h.wildcard_parent && !h.is_cache;
$f.find('[name=host]').prop('disabled', !hostRenameable);
$('#host-rename-help').toggle(!hostRenameable);
if(h.is_wildcard){
$('#wildcard_matchAny-container').removeClass('challengeType-container');
}
@@ -423,6 +431,7 @@
<th>SSL Expire</th>
<th>Host Name</th>
<th>target</th>
<th class="hidden-xs">Created</th>
<th class="hidden-xs">Updated</th>
<th>Actions</th>
</thead>
@@ -454,6 +463,11 @@
<td>
{{{ targetssl_text }}}{{ ip }}:{{ targetPort }}
</td>
<td class="hidden-xs momentFromNow" data-date="{{ created_on }}" title="Created by {{ created_by }}">
{{ created_on_text }}
<br />
<small class="text-muted">{{ created_by }}</small>
</td>
<td class="hidden-xs momentFromNow" data-date="{{ updated_on }}" >
{{ updated_on_text }}
</td>
@@ -519,7 +533,7 @@
<div class="card-header actionMessage m-0" style="display:none"></div>
<div class="modal-body">
<ul class="nav nav-tabs" role="tablist">
<ul class="nav nav-tabs flex-nowrap overflow-x-auto" role="tablist">
<li class="nav-item"><button class="nav-link active" id="hostTab-general-btn" data-bs-toggle="tab" data-bs-target="#hostTab-general" type="button" role="tab">General</button></li>
<li class="nav-item"><button class="nav-link" id="hostTab-tls-btn" data-bs-toggle="tab" data-bs-target="#hostTab-tls" type="button" role="tab">TLS &amp; Wildcard</button></li>
<li class="nav-item"><button class="nav-link" id="hostTab-traffic-btn" data-bs-toggle="tab" data-bs-target="#hostTab-traffic" type="button" role="tab">Traffic</button></li>
@@ -542,6 +556,12 @@
for one subdomain level, <code>**.example.com</code> for any depth,
or <code>**</code> as a catch-all.
</small>
<small id="host-rename-help" class="field-help text-muted d-block" style="display:none">
Wildcard hosts, their children, and auto-created subdomain cache
entries can't be renamed here — the name is referenced elsewhere
(the wildcard's own children, or the cache entry's parent). Delete
and recreate instead.
</small>
</div>
<div class="form-group">
@@ -582,6 +602,7 @@
<input type="radio" name="targetssl" id="targetssl-true" value="true">
Proxy to HTTPS
</label></div>
<small class="field-help text-muted d-block">Whether the proxy talks to the target over HTTP or HTTPS. Independent of Incoming SSL above — clients can use HTTPS to reach the proxy while it still talks plain HTTP to the target, or vice versa.</small>
</div>
</div>
</div>
@@ -620,6 +641,14 @@
<input type="radio" name="wildcard_matchAny" id="wildcard_matchAny-true" value="true">
Match any subdomain and proxy to this host
</label></div>
<small class="field-help text-muted d-block">
"Recommended" only routes subdomains you've explicitly registered
as their own host (optionally as a "Parent Wildcard" child of this
one, to reuse this cert). "Match any" auto-creates a temporary
route to this host's target for <i>any</i> undefined subdomain the
first time it's requested — convenient, but it means every subdomain
typo or scan attempt also gets routed here.
</small>
</div>
</div>