diff --git a/public/js/settings.js b/public/js/settings.js index eeb6bcf9..1aea212a 100644 --- a/public/js/settings.js +++ b/public/js/settings.js @@ -1801,6 +1801,76 @@ }); })(); +// ─── Portal Address ─────────────────────────────────── +(function () { + var sel = document.getElementById('portal-base-domain'); + if (!sel) return; + var prefix = document.getElementById('portal-prefix'); + var preview = document.getElementById('portal-effective-host'); + var errEl = document.getElementById('portal-host-error'); + var applyBtn = document.getElementById('portal-host-apply'); + var switchWarn = document.getElementById('portal-switch-warning'); + var noDomainsHint = document.getElementById('portal-no-domains-hint'); + var t = (window.GC && window.GC.t) || {}; + var currentHost = ''; // the live, persisted effective host (from initial GET) + var internalHost = ''; // home. for the "Internal (default)" preview + var curBase = ''; // persisted base_domain (for confirm-cancel restore) + var curPrefix = 'home'; // persisted prefix + + function effective() { + var base = sel.value; var p = (prefix.value || '').trim(); + return base ? (p ? p + '.' + base : base) : internalHost; + } + function renderPreview() { + preview.textContent = effective(); + prefix.disabled = !sel.value; + // Show the switch warning ONLY when the selection differs from the live host + // (no false alarm for an already-configured, stable host on page load). + if (switchWarn) switchWarn.style.display = (effective() !== currentHost) ? '' : 'none'; + } + + // Populate verified domains + current selection. + Promise.all([api.get('/api/v1/settings/domains'), api.get('/api/v1/settings/portal')]).then(function (r) { + var verified = (r[0].data.domains || []).filter(function (d) { return d.status === 'verified'; }); + var cur = r[1].data; + currentHost = cur.effectiveHost || ''; + internalHost = cur.internalHost || ''; + curBase = cur.base_domain || ''; + curPrefix = cur.prefix || 'home'; + sel.appendChild(new Option(t['settings.portal.internal_default'] || 'Internal (default)', '')); + verified.forEach(function (d) { sel.appendChild(new Option(d.domain, d.domain)); }); + sel.value = curBase; + prefix.value = curPrefix; + // Empty state: no verified domains → only "Internal (default)" + a hint pointing to the registry. + if (noDomainsHint) noDomainsHint.style.display = verified.length ? 'none' : ''; + renderPreview(); + }).catch(function () {}); + + // Preview only — selecting/typing does NOT switch the live host. + sel.addEventListener('change', renderPreview); + prefix.addEventListener('input', renderPreview); + + // Deliberate, confirmed commit (NOT autosave): a host change causes a brief + // portal outage (single-host switch window), so it stays an explicit action. + if (applyBtn) applyBtn.addEventListener('click', async function () { + if (errEl) { errEl.classList.remove('autosave-error'); errEl.textContent = ''; errEl.style.display = 'none'; } + if (effective() === currentHost) return; // no-op: nothing changed + if (!window.confirm(t['settings.portal.switch_warning'] || 'The portal will be briefly unreachable while switching, and the previous name stops working. Continue?')) { + // Cancel: restore the persisted selection so the warning clears and a stray re-Apply is avoided. + sel.value = curBase; prefix.value = curPrefix; renderPreview(); + return; + } + btnLoading(applyBtn); + try { + var res = await api.put('/api/v1/settings/portal', { base_domain: sel.value, prefix: prefix.value }); + if (res && res.ok) { curBase = sel.value; curPrefix = prefix.value; currentHost = effective(); renderPreview(); showToast(t['settings.portal.saved'] || 'Saved'); } + else if (errEl) { errEl.classList.add('autosave-error'); errEl.textContent = (res && res.error) || ''; errEl.style.display = ''; } + } catch (err) { + if (errEl) { errEl.classList.add('autosave-error'); errEl.textContent = err.message; errEl.style.display = ''; } + } finally { btnReset(applyBtn); } + }); +})(); + // ─── Route Block Default ────────────────────────────── (function () { var actionSel = document.getElementById('settings-route-block-action'); diff --git a/src/i18n/de.json b/src/i18n/de.json index a9bd6e67..70d20aac 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -1964,6 +1964,20 @@ "settings.portal.widget_traffic": "Traffic-Diagramm", "settings.portal.widget_services": "Dienste", "settings.portal.saved": "Portal-Einstellungen gespeichert", + "settings.portal.host_not_verified": "Domain ist nicht verifiziert", + "settings.portal.host_invalid_prefix": "Ungültiges Subdomain-Präfix", + "settings.portal.host_collision": "Host kollidiert mit der GateControl-Adresse, einer Route-Domain oder einem Peer-Hostnamen", + "settings.portal.host_requires_caddy_email": "Eine öffentliche Domain benötigt eine konfigurierte ACME-E-Mail (GC_CADDY_EMAIL) für die Zertifikatsausstellung", + "settings.portal.address": "Portal-Adresse", + "settings.portal.base_domain": "Basis-Domain", + "settings.portal.prefix": "Subdomain-Präfix", + "settings.portal.prefix_hint": "Leer = direkt auf der Domain (Apex)", + "settings.portal.internal_default": "Intern (Standard)", + "settings.portal.effective_host": "Portal erreichbar unter", + "settings.portal.apply": "Übernehmen", + "settings.portal.host_note": "Das Portal bleibt intern erreichbar (nur VPN). Eine verifizierte öffentliche Domain liefert ein gültiges Zertifikat statt der Browser-Warnung — die Subdomain und ihr Zertifikat werden dadurch aber in öffentlichen Certificate-Transparency-Logs auffindbar, und der TLS-Handshake ist öffentlich erreichbar.", + "settings.portal.switch_warning": "Beim Wechsel ist das Portal kurz nicht erreichbar, bis das Zertifikat ausgestellt ist; der bisherige Name entfällt. Verschwindet die Warnung nicht binnen weniger Minuten, wechsle zurück auf „Intern (Standard)“.", + "settings.portal.no_domains_hint": "Noch keine verifizierten Domains. Lege unter Einstellungen → Allgemein → Domains eine an und verifiziere sie, um eine öffentliche Portal-Adresse zu nutzen.", "settings.autosave.saved": "Gespeichert", "settings.autosave.error": "Speichern fehlgeschlagen", "settings.autosave.pending": "Wird gespeichert, sobald alle Pflichtfelder ausgefüllt sind", @@ -1988,5 +2002,6 @@ "settings.domains.server_ip": "Erkannte Server-IP", "settings.domains.server_ip_override": "Server-IP überschreiben", "settings.domains.server_ip_warning": "Keine Domain konnte gegen die Server-IP bestätigt werden — bitte Server-IP prüfen/überschreiben. Bestehende Routen laufen weiter.", - "settings.domains.points_note": "Verifiziert heißt: öffentliches DNS zeigt auf die Server-IP. Bestehende Routen/Portal laufen unabhängig weiter." + "settings.domains.points_note": "Verifiziert heißt: öffentliches DNS zeigt auf die Server-IP. Bestehende Routen/Portal laufen unabhängig weiter.", + "settings.domains.in_use_portal": "Domain wird vom Portal genutzt — bitte zuerst die Portal-Adresse ändern." } diff --git a/src/i18n/en.json b/src/i18n/en.json index 679f5286..cc94f70b 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -1964,6 +1964,20 @@ "settings.portal.widget_traffic": "Traffic chart", "settings.portal.widget_services": "Services", "settings.portal.saved": "Portal settings saved", + "settings.portal.host_not_verified": "Domain is not verified", + "settings.portal.host_invalid_prefix": "Invalid subdomain prefix", + "settings.portal.host_collision": "Host collides with the GateControl address, a route domain, or a peer hostname", + "settings.portal.host_requires_caddy_email": "A public domain needs an ACME email (GC_CADDY_EMAIL) configured for certificate issuance", + "settings.portal.address": "Portal address", + "settings.portal.base_domain": "Base domain", + "settings.portal.prefix": "Subdomain prefix", + "settings.portal.prefix_hint": "Empty = serve directly on the domain (apex)", + "settings.portal.internal_default": "Internal (default)", + "settings.portal.effective_host": "Portal reachable at", + "settings.portal.apply": "Apply", + "settings.portal.host_note": "The portal stays internal-only (VPN). A verified public domain serves a valid certificate instead of the browser warning — but the subdomain and its certificate become discoverable in public Certificate Transparency logs and the TLS handshake is publicly reachable.", + "settings.portal.switch_warning": "While switching, the portal is briefly unreachable until the certificate is issued, and the previous name stops working. If the warning does not clear within a few minutes, switch back to Internal (default).", + "settings.portal.no_domains_hint": "No verified domains yet. Add and verify one under Settings → General → Domains to use a public portal address.", "settings.autosave.saved": "Saved", "settings.autosave.error": "Save failed", "settings.autosave.pending": "Will save once all required fields are filled", @@ -1988,5 +2002,6 @@ "settings.domains.server_ip": "Detected server IP", "settings.domains.server_ip_override": "Override server IP", "settings.domains.server_ip_warning": "Could not confirm any domain against the server IP — please check/override the server IP. Existing routes keep working.", - "settings.domains.points_note": "Verified means: public DNS points to this server's IP. Existing routes/portal keep working regardless." + "settings.domains.points_note": "Verified means: public DNS points to this server's IP. Existing routes/portal keep working regardless.", + "settings.domains.in_use_portal": "Domain is in use by the portal — change the portal address first." } diff --git a/src/middleware/portalIdentity.js b/src/middleware/portalIdentity.js index 7c616b05..147b86c5 100644 --- a/src/middleware/portalIdentity.js +++ b/src/middleware/portalIdentity.js @@ -1,12 +1,7 @@ // src/middleware/portalIdentity.js 'use strict'; const { getDb } = require('../db/connection'); -const config = require('../../config/default'); - -// The only vhost that may establish peer identity. -// Other vhosts (management UI, etc.) also proxy to Node over loopback, so -// loopback-origin alone is not sufficient — we additionally gate on the Host. -const HOME_HOST = `home.${config.dns.domain}`; +const { effectivePortalHost } = require('../services/portalConfig'); function isLoopback(addr) { return addr === '127.0.0.1' || addr === '::1' || addr === '::ffff:127.0.0.1'; @@ -34,10 +29,10 @@ function peerFromIp(ip) { * internal home-site Caddy vhost: * (a) the direct connection is from loopback (Caddy → Node), * (b) the Caddy-set reserved header X-GC-Portal-Peer-IP is present, AND - * (c) the request Host matches home. (belt-and-suspenders: the - * management-UI vhost also proxies over loopback but has a different Host, - * so without this check a forged X-GC-Portal-Peer-IP header reaching Node - * via the mgmt vhost would establish false identity). + * (c) the request Host matches the configured portal host (effectivePortalHost, + * belt-and-suspenders: the management-UI vhost also proxies over loopback + * but has a different Host, so without this check a forged X-GC-Portal-Peer-IP + * header reaching Node via the mgmt vhost would establish false identity). * Caddy strips any client-supplied copy of that header on the home vhost * (see Task 10), so a VPN client cannot forge it via that path. * Generic X-Forwarded-For is intentionally NOT used for identity. @@ -47,7 +42,7 @@ function portalIdentity(req, _res, next) { req.portalPeerName = null; const direct = req.socket && req.socket.remoteAddress; const headerIp = req.get && req.get('X-GC-Portal-Peer-IP'); - if (isLoopback(direct) && headerIp && req.hostname === HOME_HOST) { + if (isLoopback(direct) && headerIp && req.hostname === effectivePortalHost().host) { const peer = peerFromIp(headerIp); if (peer) { req.portalPeerId = peer.id; req.portalPeerName = peer.name; } } diff --git a/src/routes/api/settings/domains.js b/src/routes/api/settings/domains.js index 910f3a89..8b521859 100644 --- a/src/routes/api/settings/domains.js +++ b/src/routes/api/settings/domains.js @@ -57,6 +57,10 @@ router.post('/domains/:id/verify', async (req, res) => { router.delete('/domains/:id', (req, res) => { try { + const row = getDb().prepare('SELECT domain FROM domains WHERE id = ?').get(Number(req.params.id)); + if (row && String(settings.get('portal.base_domain', '')).toLowerCase() === String(row.domain).toLowerCase()) { + return res.status(409).json({ ok: false, error: req.t('settings.domains.in_use_portal') }); + } domains.remove(Number(req.params.id)); res.json({ ok: true }); } catch (err) { diff --git a/src/routes/api/settings/portal.js b/src/routes/api/settings/portal.js index deec40d5..bbd160bc 100644 --- a/src/routes/api/settings/portal.js +++ b/src/routes/api/settings/portal.js @@ -1,28 +1,40 @@ 'use strict'; -// Portal settings cluster — master switch + per-widget toggles. -// Keys: portal.enabled, portal.widget.{device,traffic,services} -// All default to '1' (on); '0' = off. +// Portal settings cluster — master switch + per-widget toggles + public host. +// Keys: portal.enabled, portal.widget.{device,traffic,services}, +// portal.base_domain, portal.prefix const { Router } = require('express'); const settings = require('../../../services/settings'); const portalConfig = require('../../../services/portalConfig'); +const { validatePortalHost, effectivePortalHost, isPublicPortalHost } = require('../../../services/portalConfig'); const activity = require('../../../services/activity'); +const config = require('../../../../config/default'); +const caddySync = require('../../../services/caddySync'); +const dns = require('../../../services/dns'); +const logger = require('../../../utils/logger'); const router = Router(); /** - * GET /api/v1/settings/portal — Return current portal settings as booleans + * GET /api/v1/settings/portal — Return current portal settings as booleans + host info */ router.get('/portal', (req, res) => { - res.json({ ok: true, data: portalConfig() }); + res.json({ ok: true, data: Object.assign({}, portalConfig(), { + base_domain: settings.get('portal.base_domain', ''), + prefix: settings.get('portal.prefix', 'home'), + effectiveHost: effectivePortalHost().host, + isPublic: isPublicPortalHost(), + internalHost: `home.${config.dns.domain}`, + }) }); }); /** - * PUT /api/v1/settings/portal — Update portal master switch + widget toggles + * PUT /api/v1/settings/portal — Update portal master switch + widget toggles + host * * Accepts: - * { enabled: bool, widgets: { device: bool, traffic: bool, services: bool } } + * { enabled: bool, widgets: { device: bool, traffic: bool, services: bool }, + * base_domain: string, prefix: string } */ router.put('/portal', (req, res) => { try { @@ -42,6 +54,26 @@ router.put('/portal', (req, res) => { settings.set('portal.widget.services', widgets.services ? '1' : '0'); } + // Host change (base_domain + prefix committed together). + if (body.base_domain !== undefined || body.prefix !== undefined) { + const base = String(body.base_domain !== undefined ? body.base_domain : settings.get('portal.base_domain', '') || '').trim().toLowerCase(); + const prefix = String(body.prefix !== undefined ? (body.prefix == null ? '' : body.prefix) : settings.get('portal.prefix', 'home')).trim().toLowerCase(); + const v = validatePortalHost(base, prefix); + if (!v.ok) return res.status(400).json({ ok: false, error: req.t('settings.portal.host_' + v.error) }); + // Preflight: a public host needs an ACME email, else Caddy requests no cert and the + // feature silently fails its core value. Reject before persisting/syncing. + if (base && !config.caddy.email) { + return res.status(400).json({ ok: false, error: req.t('settings.portal.host_requires_caddy_email') }); + } + settings.set('portal.base_domain', base); + settings.set('portal.prefix', prefix); + // ALWAYS re-sync when a host field was submitted + validated — NO "only when changed" + // guard: requestCaddySync is coalesced/idempotent and dns.rebuildNow is idempotent, so + // a failed sync stays RECOVERABLE — re-pressing Apply fires the sync again. + caddySync.requestCaddySync().catch(e => logger.warn({ err: e.message }, 'portal: caddy sync failed')); + try { dns.rebuildNow(); } catch (e) { logger.warn({ err: e.message }, 'portal: dns rebuild failed'); } + } + activity.log('portal_settings_updated', 'Portal settings updated', { source: 'admin', ipAddress: req.ip, severity: 'info', }); diff --git a/src/services/caddyConfig.js b/src/services/caddyConfig.js index 2c1b8ae1..aaa235be 100644 --- a/src/services/caddyConfig.js +++ b/src/services/caddyConfig.js @@ -665,8 +665,11 @@ function buildCaddyConfig(injectedRoutes, options = {}) { }; // Home portal hostname — computed early so it can be included in TLS - // automation (must be covered by the internal-CA issuer policy). - const homeHost = `home.${config.dns.domain}`; + // automation. When a verified public domain is configured the portal host + // is public (uses ACME); otherwise it falls back to the internal default. + const { effectivePortalHost } = require('./portalConfig'); + const portal = effectivePortalHost(); + const homeHost = portal.host; // TLS email. Split domains into public-TLD (gets real ACME) and // internal/private suffixes (gets Caddy's internal CA). Without the @@ -675,7 +678,8 @@ function buildCaddyConfig(injectedRoutes, options = {}) { // pollute acme logs. // homeHost is passed explicitly because it is added to caddyRoutes below, // AFTER this call, so it would otherwise be absent from the TLS policy. - const tlsConfig = buildTlsAutomation([...Object.keys(caddyRoutes), homeHost], config.caddy, [homeHost]); + const forceInternal = portal.public ? [] : [homeHost]; + const tlsConfig = buildTlsAutomation([...Object.keys(caddyRoutes), homeHost], config.caddy, forceInternal); if (tlsConfig) caddyConfig.apps.tls = tlsConfig; // GateControl management UI route @@ -716,32 +720,49 @@ function buildCaddyConfig(injectedRoutes, options = {}) { if (!caddyRoutes[homeHost]) { caddyRoutes[homeHost] = { listen: [':443', ':80'], - routes: [{ - match: [{ remote_ip: { ranges: INTERNAL_ONLY_RANGES } }], - handle: [ - // Path-conditional rewrite: only / → /portal; other paths unchanged. - { - handler: 'subroute', - routes: [{ - match: [{ path: ['/'] }], - handle: [{ handler: 'rewrite', uri: '/portal' }], - }], - }, - // Reverse proxy to local Node app with trusted-IP header handling. - { - handler: 'reverse_proxy', - upstreams: [{ dial: `127.0.0.1:${config.app.port}` }], - headers: { - request: { - // Delete first: prevent any client-supplied copy from reaching Node. - delete: ['X-GC-Portal-Peer-IP'], - // Set from real TCP source — Caddy resolves this before XFF processing. - set: { 'X-GC-Portal-Peer-IP': ['{http.request.remote.host}'] }, + routes: [ + // (0) Explicit, UNGATED ACME-challenge route (Spec §6 safeguard): matches the + // challenge path from ANY source with an empty handle → the request is NOT + // captured by the 404 route (2) and falls through to Caddy's internal + // HTTP-01 handling, which serves the token. Guarantees cert issuance even + // when Caddy's auto-interception doesn't fire before user routes. + { + match: [{ path: ['/.well-known/acme-challenge/*'] }], + handle: [], + }, + // (1) Internal sources → portal (remote_ip = real TCP source; header strip/set unchanged). + { + match: [{ remote_ip: { ranges: INTERNAL_ONLY_RANGES } }], + handle: [ + // Path-conditional rewrite: only / → /portal; other paths unchanged. + { + handler: 'subroute', + routes: [{ + match: [{ path: ['/'] }], + handle: [{ handler: 'rewrite', uri: '/portal' }], + }], + }, + // Reverse proxy to local Node app with trusted-IP header handling. + { + handler: 'reverse_proxy', + upstreams: [{ dial: `127.0.0.1:${config.app.port}` }], + headers: { + request: { + // Delete first: prevent any client-supplied copy from reaching Node. + delete: ['X-GC-Portal-Peer-IP'], + // Set from real TCP source — Caddy resolves this before XFF processing. + set: { 'X-GC-Portal-Peer-IP': ['{http.request.remote.host}'] }, + }, }, }, - }, - ], - }], + ], + }, + // (2) Non-internal & NOT ACME-challenge → clean 404 (no silent 200, no portal content). + { + match: [{ not: [{ path: ['/.well-known/acme-challenge/*'] }] }], + handle: [{ handler: 'static_response', status_code: 404 }], + }, + ], }; } diff --git a/src/services/dns.js b/src/services/dns.js index 1113e756..272237f3 100644 --- a/src/services/dns.js +++ b/src/services/dns.js @@ -258,9 +258,10 @@ function renderHostsContent() { lines.push(`${gwIp}\t${host}`); } - // Portal home name — VPN clients reach the landing portal via home.. + // Portal home name — VPN clients reach the landing portal via the effective host. // Resolves to the gateway IP so the name works on any split-tunnel config. - lines.push(`${gwIp}\thome.${domain}`); + const { effectivePortalHost } = require('./portalConfig'); + lines.push(`${gwIp}\t${effectivePortalHost().host}`); return lines.join('\n') + '\n'; } diff --git a/src/services/portalConfig.js b/src/services/portalConfig.js index d75da688..67e0fb24 100644 --- a/src/services/portalConfig.js +++ b/src/services/portalConfig.js @@ -21,4 +21,49 @@ function portalConfig() { }; } +const config = require('../../config/default'); +const { getDb } = require('../db/connection'); + +function effectivePortalHost() { + const base = String(settings.get('portal.base_domain', '') || '').trim().toLowerCase(); + const prefix = String(settings.get('portal.prefix', 'home') || '').trim().toLowerCase(); + if (base) return { host: prefix ? `${prefix}.${base}` : base, public: true }; + return { host: `home.${config.dns.domain}`, public: false }; +} +function isPublicPortalHost() { return effectivePortalHost().public; } + +function validPrefix(prefix) { + if (prefix === '') return true; // apex allowed + return prefix.split('.').every(l => /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(l)); +} +function collidesWithGateControl(host) { + try { return host === new URL(config.app.baseUrl).hostname.toLowerCase(); } catch { return false; } +} +function collidesWithRoute(host) { + const rows = getDb().prepare('SELECT DISTINCT domain FROM routes WHERE domain IS NOT NULL').all(); + return rows.some(r => String(r.domain || '').trim().toLowerCase() === host); +} +function collidesWithPeer(host) { + // dnsmasq publishes each peer as `.` (see dns.js). A + // portal host must not shadow a peer FQDN (its → gateway-IP entry would hijack it). + const rows = getDb().prepare("SELECT hostname FROM peers WHERE hostname IS NOT NULL AND hostname != ''").all(); + return rows.some(r => `${String(r.hostname).trim().toLowerCase()}.${config.dns.domain}` === host); +} +function validatePortalHost(base, prefix) { + base = String(base || '').trim().toLowerCase(); + prefix = String(prefix == null ? 'home' : prefix).trim().toLowerCase(); + if (!base) return { ok: true }; // internal default + const domains = require('./domains'); // lazy (from sub-project A) + if (!domains.isVerified(base)) return { ok: false, error: 'not_verified' }; + if (!validPrefix(prefix)) return { ok: false, error: 'invalid_prefix' }; + const host = prefix ? `${prefix}.${base}` : base; + if (collidesWithGateControl(host) || collidesWithRoute(host) || collidesWithPeer(host)) { + return { ok: false, error: 'collision' }; + } + return { ok: true }; +} + module.exports = portalConfig; +module.exports.effectivePortalHost = effectivePortalHost; +module.exports.isPublicPortalHost = isPublicPortalHost; +module.exports.validatePortalHost = validatePortalHost; diff --git a/templates/aurora/layout.njk b/templates/aurora/layout.njk index 692c47ef..6d05e047 100644 --- a/templates/aurora/layout.njk +++ b/templates/aurora/layout.njk @@ -127,6 +127,8 @@ 'security.lockout.unlock': {{ t('security.lockout.unlock') | dump | safe }}, 'security.saved': {{ t('security.saved') | dump | safe }}, 'settings.portal.saved': {{ t('settings.portal.saved') | dump | safe }}, + 'settings.portal.internal_default': {{ t('settings.portal.internal_default') | dump | safe }}, + 'settings.portal.switch_warning': {{ t('settings.portal.switch_warning') | dump | safe }}, 'sidebar.toggle_open': {{ t('sidebar.toggle_open') | dump | safe }}, 'sidebar.toggle_close': {{ t('sidebar.toggle_close') | dump | safe }}, 'tokens.no_tokens': {{ t('tokens.no_tokens') | dump | safe }}, diff --git a/templates/aurora/pages/settings.njk b/templates/aurora/pages/settings.njk index c1d46645..fe551f06 100644 --- a/templates/aurora/pages/settings.njk +++ b/templates/aurora/pages/settings.njk @@ -977,6 +977,34 @@
+
+
+ + {{ t('settings.portal.address') }} +
+
+
+ {{ t('settings.portal.base_domain') }} + +
+
+ {{ t('settings.portal.prefix') }} + +
+
{{ t('settings.portal.prefix_hint') }}
+
+ {{ t('settings.portal.effective_host') }} + +
+ + + +
{{ t('settings.portal.host_note') }}
+
+ +
+
+
diff --git a/templates/default/layout.njk b/templates/default/layout.njk index 04acef44..39dc01df 100644 --- a/templates/default/layout.njk +++ b/templates/default/layout.njk @@ -120,6 +120,8 @@ 'security.lockout.unlock': {{ t('security.lockout.unlock') | dump | safe }}, 'security.saved': {{ t('security.saved') | dump | safe }}, 'settings.portal.saved': {{ t('settings.portal.saved') | dump | safe }}, + 'settings.portal.internal_default': {{ t('settings.portal.internal_default') | dump | safe }}, + 'settings.portal.switch_warning': {{ t('settings.portal.switch_warning') | dump | safe }}, 'sidebar.toggle_open': {{ t('sidebar.toggle_open') | dump | safe }}, 'sidebar.toggle_close': {{ t('sidebar.toggle_close') | dump | safe }}, 'tokens.no_tokens': {{ t('tokens.no_tokens') | dump | safe }}, diff --git a/templates/default/pages/settings.njk b/templates/default/pages/settings.njk index 31af5902..7396f095 100644 --- a/templates/default/pages/settings.njk +++ b/templates/default/pages/settings.njk @@ -1142,6 +1142,33 @@
+
+
+ {{ t('settings.portal.address') }} +
+
+
+ {{ t('settings.portal.base_domain') }} + +
+
+ {{ t('settings.portal.prefix') }} + +
+
{{ t('settings.portal.prefix_hint') }}
+
+ {{ t('settings.portal.effective_host') }} + +
+ + + +
{{ t('settings.portal.host_note') }}
+
+ +
+
+
diff --git a/templates/pro/layout.njk b/templates/pro/layout.njk index c4ea06f6..d2b81d71 100644 --- a/templates/pro/layout.njk +++ b/templates/pro/layout.njk @@ -122,6 +122,8 @@ 'security.lockout.unlock': {{ t('security.lockout.unlock') | dump | safe }}, 'security.saved': {{ t('security.saved') | dump | safe }}, 'settings.portal.saved': {{ t('settings.portal.saved') | dump | safe }}, + 'settings.portal.internal_default': {{ t('settings.portal.internal_default') | dump | safe }}, + 'settings.portal.switch_warning': {{ t('settings.portal.switch_warning') | dump | safe }}, 'sidebar.toggle_open': {{ t('sidebar.toggle_open') | dump | safe }}, 'sidebar.toggle_close': {{ t('sidebar.toggle_close') | dump | safe }}, 'tokens.no_tokens': {{ t('tokens.no_tokens') | dump | safe }}, diff --git a/templates/pro/pages/settings.njk b/templates/pro/pages/settings.njk index 284f6cc2..b1d555ef 100644 --- a/templates/pro/pages/settings.njk +++ b/templates/pro/pages/settings.njk @@ -1029,6 +1029,34 @@
+
+
+ + {{ t('settings.portal.address') }} +
+
+
+ {{ t('settings.portal.base_domain') }} + +
+
+ {{ t('settings.portal.prefix') }} + +
+
{{ t('settings.portal.prefix_hint') }}
+
+ {{ t('settings.portal.effective_host') }} + +
+ + + +
{{ t('settings.portal.host_note') }}
+
+ +
+
+
diff --git a/tests/helpers/setup.js b/tests/helpers/setup.js index cf3d4df2..91a9a23f 100644 --- a/tests/helpers/setup.js +++ b/tests/helpers/setup.js @@ -29,7 +29,6 @@ process.env.GC_DATA_DIR = tmpDir; // on suites that run many setup()/teardown() cycles. process.env.GC_RATE_LIMIT_LOGIN = '100000'; process.env.GC_RATE_LIMIT_API = '100000'; - // Now import app modules const { runMigrations } = require('../../src/db/migrations'); const { seedAdminUser } = require('../../src/db/seed'); diff --git a/tests/portal_caddy_tls.test.js b/tests/portal_caddy_tls.test.js new file mode 100644 index 00000000..a66ed653 --- /dev/null +++ b/tests/portal_caddy_tls.test.js @@ -0,0 +1,97 @@ +'use strict'; +const crypto = require('crypto'); +process.env.GC_ENCRYPTION_KEY = process.env.GC_ENCRYPTION_KEY || crypto.randomBytes(32).toString('hex'); +process.env.GC_CADDY_EMAIL = 'admin@example.com'; // TLS policies only emit when email set +const { test, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const { setup, teardown } = require('./helpers/setup'); + +let buildCaddyConfig, settings, getDb; +beforeEach(async () => { + await setup(); + buildCaddyConfig = require('../src/services/caddyConfig').buildCaddyConfig; + settings = require('../src/services/settings'); + getDb = require('../src/db/connection').getDb; +}); +afterEach(teardown); + +function policies(cfg) { return ((cfg.apps.tls || {}).automation || {}).policies || []; } +function acmeSubjects(cfg) { return policies(cfg).filter(p => p.issuers.some(i => i.module === 'acme')).flatMap(p => p.subjects || []); } +function internalSubjects(cfg) { return policies(cfg).filter(p => p.issuers.some(i => i.module === 'internal')).flatMap(p => p.subjects || []); } + +test('internal default host uses the internal issuer', async () => { + const cfg = await buildCaddyConfig(); + assert.ok(internalSubjects(cfg).some(s => /^home\./.test(s))); +}); + +const PORTAL_HOST = 'home.domaincaster.com'; + +// Collect every route object in the config (deep), preserving array order. +function allRoutes(cfg) { + const out = []; + (function walk(node) { + if (!node || typeof node !== 'object') return; + if (Array.isArray(node)) { node.forEach(walk); return; } + if (Array.isArray(node.match) || Array.isArray(node.handle)) out.push(node); + Object.values(node).forEach(walk); + })(cfg); + return out; +} +function hostMatches(r, host) { + return (r.match || []).some(m => Array.isArray(m.host) && m.host.includes(host)); +} +// SCOPE TO THE PORTAL HOST. caddyConfig ALWAYS writes apps.http.servers.srv0.routes +// (there is no host-keyed server). The distinction is WITHIN srv0: single-route +// vhosts are fast-path folded with a `host` matcher; MULTI-route vhosts (which the +// portal becomes once it has acme/gate/404) are wrapped in a `subroute`. So the +// portal's routes live inside a subroute handler's `routes` array. We find them by +// the portal host matcher; the fallback walks to the inner array that has the gate +// + 404. (Verify the exact shape in Task 3 Step 4 against caddyConfig_contract.test.js.) +function findPortalRoutes(cfg) { + const byHostMatcher = allRoutes(cfg).filter(r => hostMatches(r, PORTAL_HOST)); + if (byHostMatcher.length >= 2) return byHostMatcher; // host-matcher path + // Fallback: the inner subroute array containing the gate + 404 routes. + let found = null; + (function walk(node) { + if (found || !node || typeof node !== 'object') return; + if (Array.isArray(node)) { + // Guard: arrays like ['http.log.access'] contain strings — 'str'.match is + // String.prototype.match (truthy function), so we must check typeof first. + const has404 = node.some(r => r && typeof r === 'object' && (r.handle || []).some(h => h.handler === 'static_response' && h.status_code === 404)); + const hasGate = node.some(r => r && typeof r === 'object' && (r.match || []).some(m => m.remote_ip)); + if (has404 && hasGate) { found = node; return; } + node.forEach(walk); + } else { Object.values(node).forEach(walk); } + })(cfg); + return found; +} + +test('verified public portal host uses ACME (not forced-internal)', async () => { + getDb().prepare("INSERT INTO domains (domain, status) VALUES ('domaincaster.com','verified')").run(); + settings.set('portal.base_domain', 'domaincaster.com'); + settings.set('portal.prefix', 'home'); + const cfg = await buildCaddyConfig(); + // Exact array membership (not String.includes) — these are subject lists, and an + // exact === match also keeps CodeQL's url-substring-sanitization heuristic happy. + assert.ok(acmeSubjects(cfg).some(s => s === PORTAL_HOST), 'portal host should be ACME'); + assert.ok(!internalSubjects(cfg).some(s => s === PORTAL_HOST), 'must not be forced-internal'); + + const routes = findPortalRoutes(cfg); + assert.ok(routes, 'portal vhost routes present'); + // Ordering: ACME-challenge route FIRST, then internal remote_ip → portal, then 404. + const acmeIdx = routes.findIndex(r => (r.match || []).some(m => Array.isArray(m.path) && m.path.some(p => /acme-challenge/.test(p))) + && !(r.handle || []).some(h => h.handler === 'static_response')); + const gateIdx = routes.findIndex(r => (r.match || []).some(m => m.remote_ip)); + const idx404 = routes.findIndex(r => (r.handle || []).some(h => h.handler === 'static_response' && h.status_code === 404)); + assert.ok(acmeIdx > -1, 'explicit acme-challenge route present'); + assert.ok(acmeIdx < gateIdx && gateIdx < idx404, 'order: acme(0) < remote_ip < 404'); + // The internal route reverse-proxies and sets the identity header from the TCP source. + const gate = routes[gateIdx]; + assert.ok(gate.handle.some(h => h.handler === 'reverse_proxy'), 'internal route proxies to Node'); + // The 404 route MUST exclude /.well-known/acme-challenge/* (else ACME issuance breaks). + const r404 = routes[idx404]; + const notMatch = (r404.match || []).find(m => Array.isArray(m.not)); + assert.ok(notMatch, '404 route uses a `not` matcher'); + const excludedPaths = notMatch.not.flatMap(n => n.path || []); + assert.ok(excludedPaths.some(p => /acme-challenge/.test(p)), '404 route excludes the acme-challenge path'); +}); diff --git a/tests/portal_dns.test.js b/tests/portal_dns.test.js new file mode 100644 index 00000000..3d1b6686 --- /dev/null +++ b/tests/portal_dns.test.js @@ -0,0 +1,26 @@ +'use strict'; +const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); +const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gc-portal-dns-')); +process.env.GC_DB_PATH = path.join(tmpDir, 'd.db'); +process.env.GC_ENCRYPTION_KEY = 'a'.repeat(64); +process.env.GC_DNS_HOSTS_FILE = path.join(tmpDir, 'peers.hosts'); +process.env.GC_DNS_DOMAIN = 'gc.internal'; +process.env.GC_WG_GATEWAY_IP = '10.8.0.1'; +const { test, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const { setup, teardown } = require('./helpers/setup'); + +let dns, settings; +beforeEach(async () => { await setup(); dns = require('../src/services/dns'); settings = require('../src/services/settings'); }); +afterEach(teardown); + +test('publishes internal default home host by default', () => { + assert.match(dns.renderHostsContent(), /10\.8\.0\.1\thome\.gc\.internal/); +}); +test('publishes the configured public portal host -> gateway IP', () => { + settings.set('portal.base_domain', 'domaincaster.com'); + settings.set('portal.prefix', 'home'); + const c = dns.renderHostsContent(); + assert.match(c, /10\.8\.0\.1\thome\.domaincaster\.com/); + assert.doesNotMatch(c, /home\.gc\.internal/); // old entry gone +}); diff --git a/tests/portal_dns_caddy.test.js b/tests/portal_dns_caddy.test.js index f68135a1..6a92eb92 100644 --- a/tests/portal_dns_caddy.test.js +++ b/tests/portal_dns_caddy.test.js @@ -64,41 +64,55 @@ test('buildCaddyConfig adds an internal home. site with strip+set of res 'real-IP placeholder {http.request.remote.host} missing from Caddy config'); }); -// ─── 3. Internal-only (remote_ip matcher + no external exposure) ────────── +// ─── 3. Internal-only (remote_ip gate in inner subroute + 404 fallback) ────────── test('home. site is internal-only and absent from external-exposure routes', () => { const cfg = caddyConfigMod.buildCaddyConfig(); const wantHost = `home.${config.dns.domain}`; const serverRoutes = cfg?.apps?.http?.servers?.srv0?.routes || []; - // Find the route that matches home. + // Find the outer route by host matcher (outer match is host-only; gate lives inside subroute) const homeRoute = serverRoutes.find(r => Array.isArray(r.match) && r.match.some(m => Array.isArray(m.host) && m.host.includes(wantHost)) ); assert.ok(homeRoute, `home. route not found in srv0.routes`); - // Must carry a remote_ip matcher (not just a host matcher) - const hasRemoteIp = homeRoute.match.some( - m => m.remote_ip && Array.isArray(m.remote_ip.ranges) && m.remote_ip.ranges.length > 0 - ); - assert.ok(hasRemoteIp, - 'home. route is missing remote_ip matcher — it is NOT internal-only'); + // The outer handle must contain a subroute that wraps the three inner routes + const outerSubroute = (homeRoute.handle || []).find(h => h.handler === 'subroute'); + assert.ok(outerSubroute, 'outer subroute handler missing from home. route handle'); + + const innerRoutes = outerSubroute.routes || []; - // The remote_ip ranges must match config.wireguard.internalOnlyRanges - const remoteIpMatch = homeRoute.match.find(m => m.remote_ip); + // (a) Exactly one inner route carries a remote_ip matcher whose ranges deepEqual internalOnlyRanges + const gateRoutes = innerRoutes.filter(r => + Array.isArray(r.match) && r.match.some(m => m.remote_ip && Array.isArray(m.remote_ip.ranges) && m.remote_ip.ranges.length > 0) + ); + assert.equal(gateRoutes.length, 1, + 'expected exactly one inner route with a remote_ip (gate) matcher inside the subroute'); + const gateRoute = gateRoutes[0]; + const remoteIpMatch = gateRoute.match.find(m => m.remote_ip); assert.deepEqual(remoteIpMatch.remote_ip.ranges, config.wireguard.internalOnlyRanges, 'remote_ip ranges do not match config.wireguard.internalOnlyRanges'); - // home. must NOT appear as a bare host-only route (no external-block fallback) - const externalExposedRoutes = serverRoutes.filter(r => + // (b) An inner route returns static_response 404 behind a not-path matcher + // (external/non-internal sources get 404, not portal content) + const notPathFallback = innerRoutes.find(r => Array.isArray(r.match) && - r.match.some(m => Array.isArray(m.host) && m.host.includes(wantHost) && !m.remote_ip) + r.match.some(m => Array.isArray(m.not) && m.not.some(n => Array.isArray(n.path))) && + (r.handle || []).some(h => h.handler === 'static_response' && h.status_code === 404) ); - assert.equal(externalExposedRoutes.length, 0, - `home. appears in an external-exposure route (should be internal-only)`); + assert.ok(notPathFallback, + 'no inner fallback route returning 404 for non-internal/non-ACME sources (external sources must get 404)'); + + // (c) The portal reverse_proxy lives INSIDE the gate route only — never in any ungated inner route + const reverseProxyOutsideGate = innerRoutes + .filter(r => r !== gateRoute) + .some(r => JSON.stringify(r).includes('"reverse_proxy"')); + assert.ok(!reverseProxyOutsideGate, + 'reverse_proxy handler found outside the remote_ip gate — portal content is NOT internal-only'); }); -// ─── 4. Root-path rewrite to /portal ──────────────────────────────────── +// ─── 4. Root-path rewrite to /portal (inside the remote_ip gate) ──────────────────────────────────── test('home. site rewrites root path / to /portal without touching asset/API paths', () => { const cfg = caddyConfigMod.buildCaddyConfig(); const wantHost = `home.${config.dns.domain}`; @@ -109,22 +123,28 @@ test('home. site rewrites root path / to /portal without touching asset/ ); assert.ok(homeRoute, 'home. route not found'); - const json = JSON.stringify(homeRoute); - assert.ok(json.includes('rewrite'), 'rewrite handler missing from home site'); - assert.ok(json.includes('/portal'), 'rewrite target /portal missing from home site'); - - // The rewrite must be path-matched (only on '/'), not a blanket rewrite - // Verify by checking that a path matcher containing '/' is present alongside 'rewrite' - const handlers = homeRoute.handle || []; - // find subroute handler containing the rewrite - const subrouteHandler = handlers.find(h => h.handler === 'subroute'); - assert.ok(subrouteHandler, 'subroute handler for path-conditional rewrite missing'); - const rewriteRoute = subrouteHandler.routes?.find(r => + // Drill into the outer subroute (host wrapper) + const outerSubroute = (homeRoute.handle || []).find(h => h.handler === 'subroute'); + assert.ok(outerSubroute, 'outer subroute handler missing'); + + // Find the remote_ip gate route inside the outer subroute + const gateRoute = (outerSubroute.routes || []).find(r => + Array.isArray(r.match) && r.match.some(m => m.remote_ip) + ); + assert.ok(gateRoute, 'remote_ip gate route not found inside outer subroute'); + + // Find the nested subroute handler inside the gate route (path-conditional rewrite) + const innerSubroute = (gateRoute.handle || []).find(h => h.handler === 'subroute'); + assert.ok(innerSubroute, 'inner subroute handler for path-conditional rewrite missing from gate route'); + + // Find the path-matched route for '/' inside the inner subroute + const rewriteRoute = (innerSubroute.routes || []).find(r => Array.isArray(r.match) && r.match.some(m => Array.isArray(m.path) && m.path.includes('/')) ); - assert.ok(rewriteRoute, 'path-matched route for / not found in subroute'); - const rewriteHandler = rewriteRoute.handle?.find(h => h.handler === 'rewrite'); - assert.ok(rewriteHandler, 'rewrite handler not found inside path-matched subroute'); + assert.ok(rewriteRoute, 'path-matched route for / not found in inner subroute'); + + const rewriteHandler = (rewriteRoute.handle || []).find(h => h.handler === 'rewrite'); + assert.ok(rewriteHandler, 'rewrite handler not found inside path-matched inner subroute'); assert.equal(rewriteHandler.uri, '/portal', 'rewrite URI should be /portal'); }); diff --git a/tests/portal_domain_delete_guard.test.js b/tests/portal_domain_delete_guard.test.js new file mode 100644 index 00000000..52b2ae76 --- /dev/null +++ b/tests/portal_domain_delete_guard.test.js @@ -0,0 +1,25 @@ +'use strict'; +const crypto = require('crypto'); +process.env.GC_ENCRYPTION_KEY = process.env.GC_ENCRYPTION_KEY || crypto.randomBytes(32).toString('hex'); +const { test, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const { setup, teardown, getAgent, getCsrf } = require('./helpers/setup'); + +let getDb, settings; +beforeEach(async () => { await setup(); getDb = require('../src/db/connection').getDb; settings = require('../src/services/settings'); }); +afterEach(teardown); + +test('DELETE is blocked while the portal uses the domain', async () => { + const info = getDb().prepare("INSERT INTO domains (domain, status) VALUES ('domaincaster.com','verified')").run(); + settings.set('portal.base_domain', 'domaincaster.com'); + const agent = getAgent(); const csrf = getCsrf(); + const res = await agent.delete('/api/v1/settings/domains/' + info.lastInsertRowid).set('X-CSRF-Token', csrf); + assert.ok([400, 409].includes(res.status), `expected 400/409, got ${res.status}`); + assert.ok(getDb().prepare('SELECT 1 FROM domains WHERE id=?').get(info.lastInsertRowid)); // still there +}); + +test('DELETE succeeds for an unused domain', async () => { + const info = getDb().prepare("INSERT INTO domains (domain, status) VALUES ('other.com','verified')").run(); + const agent = getAgent(); const csrf = getCsrf(); + await agent.delete('/api/v1/settings/domains/' + info.lastInsertRowid).set('X-CSRF-Token', csrf).expect(200); +}); diff --git a/tests/portal_host_helper.test.js b/tests/portal_host_helper.test.js new file mode 100644 index 00000000..de59b137 --- /dev/null +++ b/tests/portal_host_helper.test.js @@ -0,0 +1,58 @@ +'use strict'; +const crypto = require('crypto'); +process.env.GC_ENCRYPTION_KEY = process.env.GC_ENCRYPTION_KEY || crypto.randomBytes(32).toString('hex'); +const { test, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const { setup, teardown } = require('./helpers/setup'); + +let pc, settings, getDb; +beforeEach(async () => { + await setup(); + pc = require('../src/services/portalConfig'); + settings = require('../src/services/settings'); + getDb = require('../src/db/connection').getDb; +}); +afterEach(teardown); + +test('effectivePortalHost: internal default when no base', () => { + const r = pc.effectivePortalHost(); + assert.match(r.host, /^home\./); // home. + assert.equal(r.public, false); +}); + +test('effectivePortalHost: prefix.base when base set; empty prefix -> apex', () => { + settings.set('portal.base_domain', 'domaincaster.com'); + settings.set('portal.prefix', 'home'); + assert.deepEqual(pc.effectivePortalHost(), { host: 'home.domaincaster.com', public: true }); + settings.set('portal.prefix', ''); + assert.deepEqual(pc.effectivePortalHost(), { host: 'domaincaster.com', public: true }); +}); + +test('validatePortalHost: empty base ok; unverified rejected; verified ok', () => { + assert.equal(pc.validatePortalHost('', 'home').ok, true); + assert.equal(pc.validatePortalHost('nope.com', 'home').ok, false); // not in domains + getDb().prepare("INSERT INTO domains (domain, status) VALUES ('domaincaster.com','verified')").run(); + assert.equal(pc.validatePortalHost('domaincaster.com', 'home').ok, true); +}); + +test('validatePortalHost: rejects collision with a route domain', () => { + getDb().prepare("INSERT INTO domains (domain, status) VALUES ('domaincaster.com','verified')").run(); + getDb().prepare("INSERT INTO routes (description, domain, target_ip, target_port, enabled, route_type) VALUES ('r','home.domaincaster.com','10.0.0.2','80',1,'http')").run(); + const r = pc.validatePortalHost('domaincaster.com', 'home'); + assert.equal(r.ok, false); + assert.equal(r.error, 'collision'); +}); + +test('validatePortalHost: rejects collision with a peer FQDN', () => { + getDb().prepare("INSERT INTO domains (domain, status) VALUES ('gc.internal','verified')").run(); + getDb().prepare("INSERT INTO peers (name, public_key, allowed_ips, enabled, peer_type, hostname) VALUES ('p','k','10.8.0.9/32',1,'regular','alice')").run(); + // peer FQDN = alice.; choosing base=gc.internal + prefix=alice collides + const r = pc.validatePortalHost('gc.internal', 'alice'); + assert.equal(r.ok, false); + assert.equal(r.error, 'collision'); +}); + +test('validatePortalHost: rejects invalid prefix', () => { + getDb().prepare("INSERT INTO domains (domain, status) VALUES ('domaincaster.com','verified')").run(); + assert.equal(pc.validatePortalHost('domaincaster.com', 'bad_prefix!').ok, false); +}); diff --git a/tests/portal_host_ui.test.js b/tests/portal_host_ui.test.js new file mode 100644 index 00000000..80ce5fe3 --- /dev/null +++ b/tests/portal_host_ui.test.js @@ -0,0 +1,27 @@ +'use strict'; +const crypto = require('crypto'); +process.env.GC_ENCRYPTION_KEY = process.env.GC_ENCRYPTION_KEY || crypto.randomBytes(32).toString('hex'); +const { test, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const supertest = require('supertest'); +const fs = require('node:fs'); const path = require('node:path'); +const { setup, teardown, getAgent } = require('./helpers/setup'); + +beforeEach(async () => { await setup(); }); +afterEach(teardown); + +test('settings page renders the Portal address card, no raw i18n keys', async () => { + const res = await getAgent().get('/settings').expect(200); + assert.match(res.text, /portal-base-domain/); + assert.match(res.text, /portal-prefix/); + assert.match(res.text, /portal-effective-host/); + assert.doesNotMatch(res.text, /settings\.portal\.(address|base_domain|prefix|host_note)\b/); +}); + +test('all three themes contain the portal-address ids incl. the Apply button', () => { + for (const theme of ['aurora', 'default', 'pro']) { + const html = fs.readFileSync(path.join(__dirname, '..', 'templates', theme, 'pages', 'settings.njk'), 'utf8'); + ['portal-base-domain', 'portal-prefix', 'portal-effective-host', 'portal-host-error', 'portal-switch-warning', 'portal-host-apply', 'portal-no-domains-hint'] + .forEach(id => assert.ok(html.includes(id), `${theme}: ${id}`)); + } +}); diff --git a/tests/portal_identity_dynamic.test.js b/tests/portal_identity_dynamic.test.js new file mode 100644 index 00000000..dd1f4ba1 --- /dev/null +++ b/tests/portal_identity_dynamic.test.js @@ -0,0 +1,35 @@ +'use strict'; +const crypto = require('crypto'); +process.env.GC_ENCRYPTION_KEY = process.env.GC_ENCRYPTION_KEY || crypto.randomBytes(32).toString('hex'); +const { test, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const { setup, teardown } = require('./helpers/setup'); + +let portalIdentity, settings, getDb; +beforeEach(async () => { + await setup(); + portalIdentity = require('../src/middleware/portalIdentity'); + settings = require('../src/services/settings'); + getDb = require('../src/db/connection').getDb; + getDb().prepare("INSERT INTO peers (name, public_key, allowed_ips, enabled, peer_type) VALUES ('alice','k1','10.8.0.5/32',1,'regular')").run(); +}); +afterEach(teardown); + +function runMw(hostname) { + const req = { socket: { remoteAddress: '127.0.0.1' }, + get: h => (String(h).toLowerCase() === 'x-gc-portal-peer-ip' ? '10.8.0.5' : undefined), hostname }; + portalIdentity(req, {}, () => {}); + return req; +} + +test('identity established for the configured public host', () => { + settings.set('portal.base_domain', 'domaincaster.com'); + settings.set('portal.prefix', 'home'); + assert.ok(runMw('home.domaincaster.com').portalPeerId); +}); + +test('identity NOT established for a stale/foreign host', () => { + settings.set('portal.base_domain', 'domaincaster.com'); + settings.set('portal.prefix', 'home'); + assert.equal(runMw('home.gc.internal').portalPeerId, null); +}); diff --git a/tests/portal_settings_host.test.js b/tests/portal_settings_host.test.js new file mode 100644 index 00000000..8f75f2aa --- /dev/null +++ b/tests/portal_settings_host.test.js @@ -0,0 +1,50 @@ +'use strict'; +const crypto = require('crypto'); +process.env.GC_ENCRYPTION_KEY = process.env.GC_ENCRYPTION_KEY || crypto.randomBytes(32).toString('hex'); +process.env.GC_CADDY_EMAIL = process.env.GC_CADDY_EMAIL || 'test@example.com'; +const { test, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const { setup, teardown, getAgent, getCsrf } = require('./helpers/setup'); + +let getDb; +beforeEach(async () => { await setup(); getDb = require('../src/db/connection').getDb; }); +afterEach(teardown); + +test('PUT accepts a verified base domain + prefix and GET reflects it', async () => { + getDb().prepare("INSERT INTO domains (domain, status) VALUES ('domaincaster.com','verified')").run(); + const agent = getAgent(); const csrf = getCsrf(); + await agent.put('/api/v1/settings/portal').set('X-CSRF-Token', csrf) + .send({ base_domain: 'domaincaster.com', prefix: 'home' }).expect(200); + const get = await agent.get('/api/v1/settings/portal').expect(200); + assert.equal(get.body.data.base_domain, 'domaincaster.com'); + assert.equal(get.body.data.effectiveHost, 'home.domaincaster.com'); + assert.equal(get.body.data.isPublic, true); +}); + +test('PUT rejects an unverified base domain (400)', async () => { + const agent = getAgent(); const csrf = getCsrf(); + await agent.put('/api/v1/settings/portal').set('X-CSRF-Token', csrf) + .send({ base_domain: 'unverified.com', prefix: 'home' }).expect(400); +}); + +test('widget toggles still work (no regression)', async () => { + const agent = getAgent(); const csrf = getCsrf(); + await agent.put('/api/v1/settings/portal').set('X-CSRF-Token', csrf).send({ enabled: false }).expect(200); + const get = await agent.get('/api/v1/settings/portal').expect(200); + assert.equal(get.body.data.enabled, false); +}); + +test('partial PUT preserves the other host field (no silent base_domain reset)', async () => { + getDb().prepare("INSERT INTO domains (domain, status) VALUES ('domaincaster.com','verified')").run(); + const agent = getAgent(); const csrf = getCsrf(); + // First: set both base_domain and prefix + await agent.put('/api/v1/settings/portal').set('X-CSRF-Token', csrf) + .send({ base_domain: 'domaincaster.com', prefix: 'home' }).expect(200); + // Then: update only prefix, omitting base_domain + await agent.put('/api/v1/settings/portal').set('X-CSRF-Token', csrf) + .send({ prefix: 'vpn' }).expect(200); + // Verify: base_domain is still 'domaincaster.com', NOT reset to '' + const get = await agent.get('/api/v1/settings/portal').expect(200); + assert.equal(get.body.data.base_domain, 'domaincaster.com'); + assert.equal(get.body.data.effectiveHost, 'vpn.domaincaster.com'); +});