diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json index 6c963c6..785c99f 100644 --- a/nodejs/package-lock.json +++ b/nodejs/package-lock.json @@ -1,19 +1,19 @@ { "name": "proxy-api", - "version": "1.4.0", + "version": "1.5.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "proxy-api", - "version": "1.4.0", + "version": "1.5.3", "license": "MIT", "dependencies": { "@fortawesome/fontawesome-free": "^7.3.0", "@popperjs/core": "^2.11.8", "@simpleworkjs/app-stack": "^1.0.0", "@simpleworkjs/conf": "^1.2.0", - "@simpleworkjs/frontend": "^0.2.5", + "@simpleworkjs/frontend": "^0.2.6", "@simpleworkjs/ldap": "^1.0.0", "@simpleworkjs/oidc-client": "^1.0.0", "acme-client": "^5.4.0", @@ -310,9 +310,9 @@ } }, "node_modules/@simpleworkjs/frontend": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/@simpleworkjs/frontend/-/frontend-0.2.5.tgz", - "integrity": "sha512-PxR7UVPv3gRpdF0WsuAZplF1vYvKsEJQevVPhz9d72U+69vP/OH3tlaAXjtO/apMHfhT1viOPw2gMVOrPSxYZw==", + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/@simpleworkjs/frontend/-/frontend-0.2.6.tgz", + "integrity": "sha512-2uqvEjxyZ2LE+sfhP6rJcEMmqdViazJ3ZkitWJXInPMWF6DiEZuP5MYqBqJvfDko63CCHEt1/ChFQd7Ry85Pzg==", "license": "MIT", "engines": { "node": ">=18.0.0" diff --git a/nodejs/package.json b/nodejs/package.json index d09638c..d92ea6d 100644 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -23,7 +23,7 @@ "@popperjs/core": "^2.11.8", "@simpleworkjs/app-stack": "^1.0.0", "@simpleworkjs/conf": "^1.2.0", - "@simpleworkjs/frontend": "^0.2.5", + "@simpleworkjs/frontend": "^0.2.6", "@simpleworkjs/ldap": "^1.0.0", "@simpleworkjs/oidc-client": "^1.0.0", "acme-client": "^5.4.0", diff --git a/nodejs/routes/render.js b/nodejs/routes/render.js index 8647e92..132fd64 100644 --- a/nodejs/routes/render.js +++ b/nodejs/routes/render.js @@ -40,6 +40,15 @@ router.get('/hosts', async function(req, res, next) { res.render('hosts', {...values}); }); +// Linkable deep-link to a single host's modal, e.g. from the host modal's +// app.modal `url` option. No server-side use of :host -- the client reads +// location.pathname itself and opens the matching host's modal once the +// page's own data has loaded (same idiom sso-manager-node uses for +// /directory/:slug). +router.get('/hosts/:host', async function(req, res, next) { + res.render('hosts', {...values}); +}); + router.get('/dns', async function(req, res, next) { res.render('dns', {...values}); }); diff --git a/nodejs/views/hosts.ejs b/nodejs/views/hosts.ejs index df00c85..525d0b8 100755 --- a/nodejs/views/hosts.ejs +++ b/nodejs/views/hosts.ejs @@ -61,7 +61,7 @@ return host; } - function hostPopulate(){ + function hostPopulate(onLoaded){ app.api.get('host?detail=1&provider=1', function(error, res){ if(error) return app.messages.action(error, $.scope.hosts.$this, 'danger'); @@ -75,6 +75,8 @@ $el.removeClass('table-success'); }); }; + + if (onLoaded) onLoaded(); }); } @@ -90,10 +92,341 @@ // ----- Add / Edit modal -------------------------------------------------- - function hostModal(){ - return bootstrap.Modal.getOrCreateInstance(document.getElementById('hostModal')); + function hostModalClose(){ app.modal.close(); } + + // Built once; injected fresh into app.modal's body on every open() call + // (unlike the old always-in-DOM static modal). .actionMessage lives + // INSIDE the form (not a sibling, as it was before) because formAJAX's + // error/success target resolves via $form.closest('div.card') (which + // app.modal's plain, card-less .modal-content never matches) falling + // back to $form.find('.actionMessage') -- a descendant search. + var hostModalBodyHtml = ` +
+ + + + +
+ + +
+
+ + + + + The public hostname clients request. Use *.example.com + for one subdomain level, **.example.com for any depth, + or ** as a catch-all. + + +
+ +
+ +
+
+ Redirect plain HTTP requests to HTTPS. +
+ +
+ +
+ + + + Where matching requests are proxied. Hostname or IP only — no protocol, port, or path. +
+ +
+
+ + + +
+
+ +
+
+ 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. +
+
+ +
+ +
+ + + Add additional targets here (IP:port, one per line) to load balance across them using round-robin. The primary target above is always included. +
+
+ + +
+
+ +
+
+
+ + Options light up based on the host name: wildcard certs need a DNS + provider for the domain; child hosts reuse a parent wildcard. + +
+ +
+ +
+
+ + "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 any undefined subdomain the + first time it's requested — convenient, but it means every subdomain + typo or scan attempt also gets routed here. + +
+
+ + +
+
+ +
+
+
+
+
+ + +
+
+ + +
+
+ Token bucket per client IP; bursts above the rate are queued, then rejected with 429. + +
+ +
+ +
+
+ Cache upstream responses that declare themselves cacheable. +
+ +
+ +
+
+ Tells browsers to only use HTTPS for this host. Enable once HTTPS is confirmed working. +
+
+ + +
+
+ + + Added to each request sent to the target. One Name: value per line. +
+
+ + + Added to each response returned to the client. +
+
+ + +
+
IP access
+
+ + + If non-empty, only these sources may connect (default-deny). +
+
+ + + These sources are always blocked (deny wins over allow). +
+
+ + +
+

+ Pick one authentication method for this host — basic auth and + SSO can't both be enabled, to avoid ambiguity about which one + actually protected a request. Choose "Off" for a public host. +

+ +
+
+
+
+
+ + + + + + + + + +
+ +
+ + +
+ `; + + function openHostModal(title, submitLabel, host){ + app.modal.open({ + title: title, + size: 'lg', + bodyHtml: hostModalBodyHtml, + footer: { + // created_on/updated_on come back as redis-hash strings, not + // numbers -- moment's bare fallback parser mishandles that + // (silently "Invalid date"), same reason hostParseRow above + // uses the explicit "x" (unix ms) format token. + metaHtml: host ? app.modal.formatAudit(host, {formatDate: function(ms){ return moment(ms, 'x').format('YYYY-MM-DD HH:mm'); }}) : '', + buttonsHtml: '' + + '', + }, + url: host ? {path: '/hosts/' + encodeURIComponent(host.host)} : null, + }); + // The datalists were just rebuilt empty -- refill them (cheap re-fetch, + // no cache bug here since this function has no cache guard to skip it). + hostLoadAuthSuggestions(); } - function hostModalClose(){ hostModal().hide(); } function hostShowTab(id){ bootstrap.Tab.getOrCreateInstance(document.getElementById(id)).show(); @@ -206,20 +539,17 @@ } function hostAddOpen(){ + openHostModal('Add host', 'Add host', null); hostFormReset(); - $('#hostModalTitle').text('Add host'); - $('#hostModalSubmitText').text('Add host'); - hostModal().show(); } async function hostEditOpen(host){ - hostFormReset(); let h = $.scope.hosts.getByKey(host); + openHostModal('Edit ' + host, 'Save changes', h); + hostFormReset(); let $f = $('#hostForm'); $f.attr('method', 'PUT').attr('action', 'host/' + encodeURIComponent(host)); - $('#hostModalTitle').text('Edit ' + host); - $('#hostModalSubmitText').text('Save changes'); // Scalar fields: booleans drive the matching radio, everything else the // input with that name. Object/array fields are handled as text below. @@ -285,8 +615,6 @@ $('#challengeType-HTTP-01').prop('checked', true); } } - - hostModal().show(); } function hostDownloadCert(host, type){ @@ -341,41 +669,52 @@ } } + // Determine what Let's Encrypt challenge type the given host name can use. + // Delegated (not a direct bind against a captured selector) since + // app.modal.open() rebuilds #hostForm from scratch on every call -- a + // direct .on('keyup', ...) bind here would silently stop firing after + // the first Add/Edit. + app.modal.on('keyup', '#hostForm [name=host]', async function(){ + let $hostField = $(this); + // Reset the allowed types on start + $('#challengeType-child-container').addClass('challengeType-container'); + $('#challengeType-DNS-01-wildcard-container').addClass('challengeType-container'); + $('#wildcard_matchAny-container').addClass('challengeType-container'); + + let host = $hostField.val(); + + // If it's a wildcard, we must check the domain has a registered provider. + if(host.startsWith("*.") && await verifyWildcardRequirements(host)){ + $('#challengeType-DNS-01-wildcard-container').removeClass('challengeType-container'); + $('#wildcard_matchAny-container').removeClass('challengeType-container'); + return; + } + + // Check if a wildcard cert is available for the given host. When it is, + // make "Parent Wildcard" the default choice (it reuses an existing cert). + let wildcardParent = await hostMatchWildcard(host); + if(wildcardParent){ + $('#challengeType-child-container').removeClass('challengeType-container'); + $('#challengeType-child-relatedHost').text(wildcardParent.host); + $('#challengeType-wildcardChild').prop('checked', true); + return; + } + + // Revert the form to a valid state. + $('#challengeType-child-relatedHost').text(''); + $('#challengeType-HTTP-01').prop('checked', true); + }); + $(document).ready(function(){ - // Populate the host UI table - hostPopulate(); - hostLoadAuthSuggestions(); - - // Determine what Let's Encrypt challenge type the given host name can use. - let $hostField = $('#hostForm [name=host]'); - $hostField.on('keyup', async function(){ - // Reset the allowed types on start - $('#challengeType-child-container').addClass('challengeType-container'); - $('#challengeType-DNS-01-wildcard-container').addClass('challengeType-container'); - $('#wildcard_matchAny-container').addClass('challengeType-container'); - - let host = $hostField.val(); - - // If it's a wildcard, we must check the domain has a registered provider. - if(host.startsWith("*.") && await verifyWildcardRequirements(host)){ - $('#challengeType-DNS-01-wildcard-container').removeClass('challengeType-container'); - $('#wildcard_matchAny-container').removeClass('challengeType-container'); - return; + // Populate the host UI table, then check for a deep-linked host + // (e.g. a direct load of /hosts/some-host, or app.modal's url option + // having pushed that path) once the table's data is actually loaded. + hostPopulate(function(){ + const slug = app.modal.deepLinkSlug('/hosts'); + if (slug) { + const h = $.scope.hosts.getByKey(decodeURIComponent(slug)); + if (h) hostEditOpen(h.host); } - - // Check if a wildcard cert is available for the given host. When it is, - // make "Parent Wildcard" the default choice (it reuses an existing cert). - let wildcardParent = await hostMatchWildcard(host); - if(wildcardParent){ - $('#challengeType-child-container').removeClass('challengeType-container'); - $('#challengeType-child-relatedHost').text(wildcardParent.host); - $('#challengeType-wildcardChild').prop('checked', true); - return; - } - - // Revert the form to a valid state. - $('#challengeType-child-relatedHost').text(''); - $('#challengeType-HTTP-01').prop('checked', true); }); $.scope.hosts.take = function($el, item, list){ @@ -548,333 +887,4 @@ - - <%- include('bottom') %>