Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions public/js/settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.<gc.internal> 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');
Expand Down
17 changes: 16 additions & 1 deletion src/i18n/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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."
}
17 changes: 16 additions & 1 deletion src/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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."
}
17 changes: 6 additions & 11 deletions src/middleware/portalIdentity.js
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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.<dns.domain> (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.
Expand All @@ -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; }
}
Expand Down
4 changes: 4 additions & 0 deletions src/routes/api/settings/domains.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
46 changes: 39 additions & 7 deletions src/routes/api/settings/portal.js
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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',
});
Expand Down
Loading
Loading