Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e5df0d3370 | |||
| fcd73169e0 | |||
| 876ea6cfd0 | |||
| 9100e92549 |
+21
-2
@@ -6,7 +6,24 @@ correspond to git tags (`vX.Y.Z`) and `nodejs/package.json`'s `version`.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.1.8] - 2026-07-17
|
||||
## [1.1.10] - 2026-07-17
|
||||
|
||||
### Added
|
||||
- A help icon (❓) in the top-right header now deep-links to the doc most relevant to the current page (falls back to the docs index elsewhere).
|
||||
- The in-app docs viewer (`/docs`) is now searchable — a simple line-substring search over the same local doc set, no new dependency, still works with no internet access.
|
||||
|
||||
Bumps to v1.1.10.
|
||||
|
||||
### 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 +77,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.8...HEAD
|
||||
[Unreleased]: https://github.com/theta42/proxy/compare/v1.1.10...HEAD
|
||||
[1.1.10]: https://github.com/theta42/proxy/compare/v1.1.9...v1.1.10
|
||||
[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
|
||||
|
||||
@@ -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};
|
||||
|
||||
+34
-1
@@ -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;
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "proxy-api",
|
||||
"version": "1.1.8",
|
||||
"version": "1.1.10",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "proxy-api",
|
||||
"version": "1.1.8",
|
||||
"version": "1.1.10",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "^7.3.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "proxy-api",
|
||||
"version": "1.1.8",
|
||||
"version": "1.1.10",
|
||||
"private": true,
|
||||
"author": [
|
||||
{
|
||||
|
||||
@@ -60,6 +60,30 @@ router.get('/', function(req, res) {
|
||||
res.render('docs_index', {...values, docs: docList});
|
||||
});
|
||||
|
||||
// Plain, dependency-free line-substring search over the same allowlisted
|
||||
// doc set -- no separate index to build/maintain, no new dependency, and it
|
||||
// keeps working with no internet access (same reasoning as the rest of this
|
||||
// route). Must be registered before the /:slug catch-all below, or "search"
|
||||
// would be treated as a (nonexistent) doc slug and 404.
|
||||
router.get('/search', function(req, res) {
|
||||
const q = (req.query.q || '').trim();
|
||||
if (!q) return res.json({results: []});
|
||||
const qLower = q.toLowerCase();
|
||||
|
||||
const results = [];
|
||||
for (const [slug, doc] of Object.entries(DOCS)) {
|
||||
try {
|
||||
const content = fs.readFileSync(doc.file, 'utf8');
|
||||
const matchLine = content.split('\n').find(line => line.toLowerCase().includes(qLower));
|
||||
if (matchLine) {
|
||||
results.push({slug, title: doc.title, snippet: matchLine.trim().slice(0, 200)});
|
||||
}
|
||||
} catch (error) { /* unreadable doc file -- skip it */ }
|
||||
}
|
||||
|
||||
res.json({results});
|
||||
});
|
||||
|
||||
router.get('/:slug', function(req, res, next) {
|
||||
const doc = DOCS[req.params.slug];
|
||||
if (!doc) return next({status: 404, message: 'Doc not found'});
|
||||
|
||||
@@ -11,7 +11,12 @@
|
||||
A local copy of this project's documentation, readable from the
|
||||
running app -- no internet access required.
|
||||
</p>
|
||||
<ul class="list-group">
|
||||
<div class="input-group mb-3">
|
||||
<span class="input-group-text"><i class="fa-solid fa-magnifying-glass"></i></span>
|
||||
<input type="search" id="docs-search-input" class="form-control" placeholder="Search the docs…" oninput="docsSearch(this.value)">
|
||||
</div>
|
||||
<div id="docs-search-results" style="display:none"></div>
|
||||
<ul id="docs-list" class="list-group">
|
||||
<% docs.forEach(function(doc){ %>
|
||||
<li class="list-group-item">
|
||||
<a href="/docs/<%= doc.slug %>"><%= doc.title %></a>
|
||||
@@ -22,5 +27,41 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script type="text/javascript">
|
||||
var docsSearchTimer;
|
||||
function docsSearch(q){
|
||||
clearTimeout(docsSearchTimer);
|
||||
docsSearchTimer = setTimeout(function(){ docsSearchRun(q); }, 200);
|
||||
}
|
||||
function docsSearchRun(q){
|
||||
q = (q || '').trim();
|
||||
var $results = $('#docs-search-results');
|
||||
var $list = $('#docs-list');
|
||||
if(!q){
|
||||
$results.hide().empty();
|
||||
$list.show();
|
||||
return;
|
||||
}
|
||||
// Not app.api.get() -- routes/docs.js is mounted at /docs directly,
|
||||
// not under /api, unlike the rest of this app's endpoints.
|
||||
$.getJSON('/docs/search', {q: q}, function(data){
|
||||
$list.hide();
|
||||
$results.empty().show();
|
||||
var hits = (data && data.results) || [];
|
||||
if(!hits.length){
|
||||
$results.append($('<p class="text-muted"></p>').text('No results for "' + q + '".'));
|
||||
return;
|
||||
}
|
||||
var $ul = $('<ul class="list-group"></ul>');
|
||||
hits.forEach(function(hit){
|
||||
var $li = $('<li class="list-group-item"></li>');
|
||||
$('<a></a>').attr('href', '/docs/' + hit.slug).text(hit.title).appendTo($li);
|
||||
$('<div class="text-muted small"></div>').text(hit.snippet).appendTo($li);
|
||||
$ul.append($li);
|
||||
});
|
||||
$results.append($ul);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<%- include('bottom') %>
|
||||
|
||||
+33
-4
@@ -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 & 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>
|
||||
|
||||
|
||||
@@ -62,6 +62,9 @@
|
||||
</li>
|
||||
</ul>
|
||||
<div class="form-inline mt-2 mt-md-0">
|
||||
<a id="cl-help" class="nav-link text-light me-3" href="/docs" title="Help">
|
||||
<i class="fa-solid fa-circle-question"></i>
|
||||
</a>
|
||||
<a id="cl-username" class="navbar-text text-light me-3" href="/profile" style="display: none;">
|
||||
<i class="fa-solid fa-user me-1"></i><span id="cl-username-text"></span>
|
||||
</a>
|
||||
@@ -102,6 +105,21 @@
|
||||
sessionStorage.setItem('update-banner-dismissed', '1');
|
||||
}
|
||||
|
||||
// Deep-link the header help icon to whichever doc is most relevant to
|
||||
// the current page. No server-side "current section" local exists
|
||||
// (every res.render() call shares one values object, see
|
||||
// routes/render.js), so this follows the same client-side
|
||||
// path-matching convention already used for the top-nav active-link
|
||||
// highlighting just below. Unmapped pages fall back to the docs
|
||||
// index (already /docs, the anchor's default).
|
||||
var HELP_DOCS_BY_PATH = {
|
||||
'/hosts': 'installation',
|
||||
'/dns': 'installation',
|
||||
'/users': 'architecture',
|
||||
'/permissions': 'architecture',
|
||||
'/groups': 'architecture',
|
||||
};
|
||||
|
||||
$(document).ready(function(){
|
||||
|
||||
// Set the correct link to active in the top nav bar
|
||||
@@ -113,6 +131,9 @@
|
||||
}
|
||||
})
|
||||
|
||||
let helpSlug = HELP_DOCS_BY_PATH[window.location.pathname.toLocaleLowerCase()];
|
||||
if(helpSlug) $('#cl-help').attr('href', '/docs/' + helpSlug);
|
||||
|
||||
// Set the correct login/logout button, and reveal admin-only nav
|
||||
// items for global admins.
|
||||
app.auth.isLoggedIn(function(error, data){
|
||||
|
||||
Reference in New Issue
Block a user