diff --git a/CHANGELOG.md b/CHANGELOG.md index 347aa9a..8ffbe74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,18 @@ correspond to git tags (`vX.Y.Z`) and `nodejs/package.json`'s `version`. ## [Unreleased] -## [1.1.8] - 2026-07-17 +## [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()`. @@ -60,7 +71,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.8...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 diff --git a/nodejs/models/cert.js b/nodejs/models/cert.js index 6544982..3d7b62d 100644 --- a/nodejs/models/cert.js +++ b/nodejs/models/cert.js @@ -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}; diff --git a/nodejs/models/host.js b/nodejs/models/host.js index ccfebc0..12c52fb 100755 --- a/nodejs/models/host.js +++ b/nodejs/models/host.js @@ -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; @@ -343,10 +343,43 @@ class Host extends Table{ } } + // 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; diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json index 8d454d9..eb91aa7 100644 --- a/nodejs/package-lock.json +++ b/nodejs/package-lock.json @@ -1,12 +1,12 @@ { "name": "proxy-api", - "version": "1.1.8", + "version": "1.1.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "proxy-api", - "version": "1.1.8", + "version": "1.1.9", "license": "MIT", "dependencies": { "@fortawesome/fontawesome-free": "^7.3.0", diff --git a/nodejs/package.json b/nodejs/package.json index b4fee40..ed91936 100644 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -1,6 +1,6 @@ { "name": "proxy-api", - "version": "1.1.8", + "version": "1.1.9", "private": true, "author": [ { diff --git a/nodejs/views/hosts.ejs b/nodejs/views/hosts.ejs index e983403..4103f44 100755 --- a/nodejs/views/hosts.ejs +++ b/nodejs/views/hosts.ejs @@ -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 @@