+
+
diff --git a/tests/portal_page.test.js b/tests/portal_page.test.js
new file mode 100644
index 00000000..7a42d705
--- /dev/null
+++ b/tests/portal_page.test.js
@@ -0,0 +1,33 @@
+'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 { setup, teardown } = require('./helpers/setup');
+
+let app, getDb;
+beforeEach(async () => {
+ await setup();
+ getDb = require('../src/db/connection').getDb;
+ app = require('../src/app').createApp();
+});
+afterEach(teardown);
+
+test('GET /portal renders the page with the device name for a known peer', async () => {
+ getDb().prepare(`INSERT INTO peers (name, public_key, allowed_ips, enabled, peer_type)
+ VALUES ('Marc Phone','k1','10.8.0.5/32',1,'regular')`).run();
+ const res = await supertest(app).get('/portal').set('X-GC-Portal-Peer-IP', '10.8.0.5').expect(200);
+ assert.match(res.text, /portal\.css/);
+ assert.match(res.text, /Marc Phone/);
+});
+
+test('a disabled-master portal returns 404', async () => {
+ require('../src/services/settings').set('portal.enabled', '0');
+ await supertest(app).get('/portal').set('X-GC-Portal-Peer-IP', '10.8.0.5').expect(404);
+});
+
+test('GET /portal without reserved header renders generic welcome (fail-safe)', async () => {
+ const res = await supertest(app).get('/portal').expect(200);
+ assert.match(res.text, /portal\.css/);
+});
From ad0cd5fcc59affba201331b04551678f9e8fbe46 Mon Sep 17 00:00:00 2001
From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com>
Date: Tue, 23 Jun 2026 22:42:21 +0200
Subject: [PATCH 06/16] fix(portal): nonce the no-FOUC inline head script (CSP)
---
templates/portal/portal.njk | 2 +-
tests/portal_page.test.js | 1 +
2 files changed, 2 insertions(+), 1 deletion(-)
diff --git a/templates/portal/portal.njk b/templates/portal/portal.njk
index 9ad532a5..915beeb5 100644
--- a/templates/portal/portal.njk
+++ b/templates/portal/portal.njk
@@ -6,7 +6,7 @@
{{ appName }} — {{ t('portal.title') }}
-
+
diff --git a/templates/pro/layout.njk b/templates/pro/layout.njk
index 97e20d6f..c4ea06f6 100644
--- a/templates/pro/layout.njk
+++ b/templates/pro/layout.njk
@@ -121,6 +121,7 @@
'security.lockout.remaining': {{ t('security.lockout.remaining') | dump | safe }},
'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 }},
'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/tests/portal_page.test.js b/tests/portal_page.test.js
index 4d6f402b..24af0347 100644
--- a/tests/portal_page.test.js
+++ b/tests/portal_page.test.js
@@ -32,3 +32,22 @@ test('GET /portal without reserved header renders generic welcome (fail-safe)',
const res = await supertest(app).get('/portal').expect(200);
assert.match(res.text, /portal\.css/);
});
+
+test('no untranslated portal key leaks in EN render', async () => {
+ getDb().prepare(`INSERT INTO peers (name, public_key, allowed_ips, enabled, peer_type)
+ VALUES ('Test Device','k2','10.8.0.6/32',1,'regular')`).run();
+ const res = await supertest(app).get('/portal?lang=en').set('X-GC-Portal-Peer-IP', '10.8.0.6').expect(200);
+ // portal.css and portal.js are expected; no other portal.* key should appear as-is
+ assert.doesNotMatch(res.text, /portal\.(?!css\b|js\b)[a-z_]+/i, 'untranslated portal key leaked in EN');
+ // Confirm a known EN string is rendered (device widget heading)
+ assert.match(res.text, /Device/, 'expected EN translation "Device" in EN render');
+});
+
+test('no untranslated portal key leaks in DE render', async () => {
+ getDb().prepare(`INSERT INTO peers (name, public_key, allowed_ips, enabled, peer_type)
+ VALUES ('Test Gerät','k3','10.8.0.7/32',1,'regular')`).run();
+ const res = await supertest(app).get('/portal?lang=de').set('X-GC-Portal-Peer-IP', '10.8.0.7').expect(200);
+ assert.doesNotMatch(res.text, /portal\.(?!css\b|js\b)[a-z_]+/i, 'untranslated portal key leaked in DE');
+ // Confirm a known DE string is rendered (device widget heading)
+ assert.match(res.text, /Gerät/, 'expected DE translation "Gerät" in DE render');
+});
From b65917435e40bed5f2deb6ce2060598a10c98f3b Mon Sep 17 00:00:00 2001
From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com>
Date: Tue, 23 Jun 2026 23:53:51 +0200
Subject: [PATCH 11/16] feat(portal): internal home. Caddy site +
dnsmasq name + trusted-IP guard
---
src/services/caddyConfig.js | 43 +++++++++++
src/services/dns.js | 4 ++
tests/portal_dns_caddy.test.js | 128 +++++++++++++++++++++++++++++++++
3 files changed, 175 insertions(+)
create mode 100644 tests/portal_dns_caddy.test.js
diff --git a/src/services/caddyConfig.js b/src/services/caddyConfig.js
index 90aef2a1..6e5bc7c5 100644
--- a/src/services/caddyConfig.js
+++ b/src/services/caddyConfig.js
@@ -689,6 +689,49 @@ function buildCaddyConfig(injectedRoutes, options = {}) {
}
} catch {}
+ // Home portal site — internal-only reverse proxy to the local Node app.
+ // SECURITY-CRITICAL: This is the trusted-IP control for the VPN landing
+ // portal (Task 10). The site:
+ // • Is restricted to INTERNAL_ONLY_RANGES (VPN subnet) — never externally
+ // exposed. remote_ip match is on the real TCP source; cannot be spoofed.
+ // • Strips any client-supplied X-GC-Portal-Peer-IP (prevents header forgery).
+ // • Sets X-GC-Portal-Peer-IP from {http.request.remote.host} — the real TCP
+ // source IP, NOT from any forwarded header.
+ // • Rewrites bare / to /portal so VPN clients landing on home. see
+ // the portal immediately; asset/API paths pass through unchanged.
+ const homeHost = `home.${config.dns.domain}`;
+ 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}'] },
+ },
+ },
+ },
+ ],
+ }],
+ };
+ }
+
// Group routes into a single server
const serverRoutes = [...serverRoutes_pending];
for (const [domain, srvConfig] of Object.entries(caddyRoutes)) {
diff --git a/src/services/dns.js b/src/services/dns.js
index 928c2343..1113e756 100644
--- a/src/services/dns.js
+++ b/src/services/dns.js
@@ -258,6 +258,10 @@ function renderHostsContent() {
lines.push(`${gwIp}\t${host}`);
}
+ // Portal home name — VPN clients reach the landing portal via home..
+ // Resolves to the gateway IP so the name works on any split-tunnel config.
+ lines.push(`${gwIp}\thome.${domain}`);
+
return lines.join('\n') + '\n';
}
diff --git a/tests/portal_dns_caddy.test.js b/tests/portal_dns_caddy.test.js
new file mode 100644
index 00000000..12c8818e
--- /dev/null
+++ b/tests/portal_dns_caddy.test.js
@@ -0,0 +1,128 @@
+'use strict';
+
+const crypto = require('node:crypto');
+process.env.GC_ENCRYPTION_KEY = process.env.GC_ENCRYPTION_KEY || crypto.randomBytes(32).toString('hex');
+process.env.GC_SECRET = process.env.GC_SECRET || crypto.randomBytes(32).toString('hex');
+
+const { test, before, after } = require('node:test');
+const assert = require('node:assert/strict');
+const path = require('node:path');
+const fs = require('node:fs');
+const os = require('node:os');
+
+const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gc-portal-dns-caddy-'));
+process.on('exit', () => { try { fs.rmSync(tmp, { recursive: true, force: true }); } catch {} });
+
+process.env.GC_DB_PATH = path.join(tmp, 'test.db');
+process.env.GC_DATA_DIR = tmp;
+process.env.GC_DNS_DOMAIN = 'gc.internal';
+process.env.GC_WG_GATEWAY_IP = '10.8.0.1';
+process.env.GC_WG_SUBNET = '10.8.0.0/24';
+process.env.GC_BASE_URL = 'http://localhost:3000';
+process.env.NODE_ENV = 'test';
+process.env.GC_LOG_LEVEL = 'silent';
+
+let dns, caddyConfigMod, config;
+
+before(() => {
+ require('../src/db/migrations').runMigrations();
+ dns = require('../src/services/dns');
+ caddyConfigMod = require('../src/services/caddyConfig');
+ config = require('../config/default');
+});
+
+after(() => {
+ try { require('../src/db/connection').closeDb(); } catch {}
+ try { fs.rmSync(tmp, { recursive: true, force: true }); } catch {}
+});
+
+// ─── 1. dnsmasq friendly name ────────────────────────────────────────────
+test('renderHostsContent includes a home. A-record at the gateway IP', () => {
+ const out = dns.renderHostsContent();
+ const escapedDomain = config.dns.domain.replace('.', '\\.');
+ assert.match(out, new RegExp(`home\\.${escapedDomain}`),
+ 'home. A-record missing from dnsmasq hosts output');
+ assert.ok(out.includes(config.wireguard.gatewayIp),
+ 'home A-record should use the gateway IP');
+ // The home line should map gatewayIp → home.
+ assert.ok(out.includes(`home.${config.dns.domain}`),
+ 'home A-record FQDN missing');
+});
+
+// ─── 2. Caddy site with reserved-header handling ─────────────────────────
+test('buildCaddyConfig adds an internal home. site with strip+set of reserved header', () => {
+ const cfg = caddyConfigMod.buildCaddyConfig();
+ const wantHost = `home.${config.dns.domain}`;
+ const json = JSON.stringify(cfg);
+
+ assert.ok(json.includes(wantHost),
+ `home. site missing from Caddy config (looked for ${wantHost})`);
+ assert.ok(json.includes('X-GC-Portal-Peer-IP'),
+ 'reserved header X-GC-Portal-Peer-IP handling missing from Caddy config');
+ assert.ok(json.includes('{http.request.remote.host}'),
+ 'real-IP placeholder {http.request.remote.host} missing from Caddy config');
+});
+
+// ─── 3. Internal-only (remote_ip matcher + no external exposure) ──────────
+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.
+ 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 remote_ip ranges must match config.wireguard.internalOnlyRanges
+ const remoteIpMatch = homeRoute.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 =>
+ Array.isArray(r.match) &&
+ r.match.some(m => Array.isArray(m.host) && m.host.includes(wantHost) && !m.remote_ip)
+ );
+ assert.equal(externalExposedRoutes.length, 0,
+ `home. appears in an external-exposure route (should be internal-only)`);
+});
+
+// ─── 4. Root-path rewrite to /portal ────────────────────────────────────
+test('home. site rewrites root path / to /portal without touching asset/API paths', () => {
+ const cfg = caddyConfigMod.buildCaddyConfig();
+ const wantHost = `home.${config.dns.domain}`;
+
+ const serverRoutes = cfg?.apps?.http?.servers?.srv0?.routes || [];
+ 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');
+
+ 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 =>
+ 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.equal(rewriteHandler.uri, '/portal', 'rewrite URI should be /portal');
+});
From aea9dab8ce6019b9bd5a064d61950381ec8ae0d1 Mon Sep 17 00:00:00 2001
From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com>
Date: Wed, 24 Jun 2026 00:26:46 +0200
Subject: [PATCH 12/16] fix(portal): host-gate identity (anti-forgery), TLS for
home host, CSP-safe state CSS, API config gating
---
public/css/portal.css | 32 +++++++++++++++
public/js/portal.js | 30 ++++----------
src/middleware/portalIdentity.js | 21 +++++++---
src/routes/api/portal.js | 13 ++++++-
src/services/caddyConfig.js | 17 +++++++-
templates/default/pages/settings.njk | 2 +-
templates/portal/portal.njk | 2 +
tests/portal_api.test.js | 58 ++++++++++++++++++++++++++--
tests/portal_css_smoke.test.js | 21 ++++++++++
tests/portal_dns_caddy.test.js | 36 +++++++++++++++++
tests/portal_identity.test.js | 34 +++++++++++++---
tests/portal_page.test.js | 19 +++++++--
12 files changed, 241 insertions(+), 44 deletions(-)
diff --git a/public/css/portal.css b/public/css/portal.css
index b1bd6ebf..900250b9 100644
--- a/public/css/portal.css
+++ b/public/css/portal.css
@@ -239,6 +239,38 @@ body::before{
.tiles{grid-template-columns:1fr 1fr}
}
+/* ============================================================
+ JS STATE CLASSES (loading skeleton, fallback, error, empty)
+ These were previously injected by portal.js via createElement('style'),
+ which is blocked by the page CSP (styleSrcElem = 'self' + nonce).
+ Serving them here makes them CSP-safe as a 'self' stylesheet.
+ ============================================================ */
+.card.loading{pointer-events:none}
+
+/* Shimmer animation for loading skeletons */
+@keyframes gc-shimmer{0%,100%{opacity:.38}50%{opacity:.15}}
+@media(prefers-reduced-motion:no-preference){
+ .card.loading>*:not(h2){animation:gc-shimmer 1.5s ease infinite}
+}
+
+/* Per-device data unavailable (gateway or unidentified) */
+.portal-fallback{padding:16px 0;color:var(--muted);font-size:13px;line-height:1.55}
+
+/* Error state with retry button */
+.portal-error-state{margin-top:12px;display:flex;align-items:center;gap:10px;flex-wrap:wrap;
+ padding:10px 12px;border-radius:10px;background:rgba(245,196,81,.08);border:1px solid rgba(245,196,81,.2)}
+.portal-error-msg{font-size:13px;color:var(--amber);flex:1}
+.portal-retry-btn{background:transparent;border:1px solid var(--amber);color:var(--amber);
+ padding:4px 10px;border-radius:8px;cursor:pointer;font-size:12px;font-family:var(--font-body);
+ transition:.15s}
+.portal-retry-btn:hover{background:rgba(245,196,81,.12)}
+
+/* Empty services grid placeholder */
+.portal-empty{padding:24px 0;color:var(--faint);font-size:13px;text-align:center;grid-column:1/-1}
+
+/* Reserve height for services card while tiles load */
+.c-services.loading{min-height:200px}
+
/* ============================================================
REDUCED MOTION
============================================================ */
diff --git a/public/js/portal.js b/public/js/portal.js
index 3c0e9a8b..086f4682 100644
--- a/public/js/portal.js
+++ b/public/js/portal.js
@@ -3,29 +3,10 @@
'use strict';
(function () {
- // ─── Inject minimal portal-JS CSS (loading skeleton + state elements) ──────
- (function injectCSS() {
- const s = document.createElement('style');
- s.textContent =
- '.card.loading{pointer-events:none}' +
- '@keyframes gc-shimmer{0%,100%{opacity:.38}50%{opacity:.15}}' +
- '@media(prefers-reduced-motion:no-preference){' +
- '.card.loading>*:not(h2){animation:gc-shimmer 1.5s ease infinite}' +
- '}' +
- '.portal-fallback{padding:16px 0;color:var(--muted);font-size:13px;line-height:1.55}' +
- '.portal-error-state{margin-top:12px;display:flex;align-items:center;gap:10px;flex-wrap:wrap;' +
- 'padding:10px 12px;border-radius:10px;background:rgba(245,196,81,.08);border:1px solid rgba(245,196,81,.2)}' +
- '.portal-error-msg{font-size:13px;color:var(--amber);flex:1}' +
- '.portal-retry-btn{background:transparent;border:1px solid var(--amber);color:var(--amber);' +
- 'padding:4px 10px;border-radius:8px;cursor:pointer;font-size:12px;font-family:var(--font-body);' +
- 'transition:.15s}' +
- '.portal-retry-btn:hover{background:rgba(245,196,81,.12)}' +
- '.portal-empty{padding:24px 0;color:var(--faint);font-size:13px;text-align:center;grid-column:1/-1}' +
- '.c-services.loading{min-height:200px}';
- document.head.appendChild(s);
- })();
-
// ─── Locale detection ───────────────────────────────────────────────────────
+ // NOTE: State CSS (.portal-fallback, .portal-error-state, gc-shimmer, etc.)
+ // is served via portal.css ('self') — not injected here — so it is not
+ // blocked by the page Content-Security-Policy (styleSrcElem = 'self' + nonce).
const lang = (document.documentElement.lang || 'de').slice(0, 2).toLowerCase();
const noMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
@@ -117,7 +98,10 @@
function showFallback(el) {
if (!el) return;
- el.innerHTML = '
' + (PT.fallbackGateway || '') + '
';
+ // Use the generic/neutral message — fits both gateway-identified and
+ // unidentified contexts. fallbackGateway is kept in the template i18n map
+ // for back-compat but is no longer referenced here.
+ el.innerHTML = '
' + (PT.fallbackUnknown || '') + '
';
}
function showError(card, retryFn) {
diff --git a/src/middleware/portalIdentity.js b/src/middleware/portalIdentity.js
index e0145cb2..7c616b05 100644
--- a/src/middleware/portalIdentity.js
+++ b/src/middleware/portalIdentity.js
@@ -1,6 +1,12 @@
// 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}`;
function isLoopback(addr) {
return addr === '127.0.0.1' || addr === '::1' || addr === '::ffff:127.0.0.1';
@@ -25,10 +31,15 @@ function peerFromIp(ip) {
/**
* Establish per-device identity ONLY when the request provably arrived via the
- * internal Caddy site: (a) the direct connection is from loopback (Caddy → Node),
- * and (b) the Caddy-set reserved header X-GC-Portal-Peer-IP is present.
- * Caddy strips any client-supplied copy of that header (see Task 10), so a client
- * cannot forge it; a request hitting the Node port directly (non-loopback) is rejected.
+ * 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).
+ * 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.
*/
function portalIdentity(req, _res, next) {
@@ -36,7 +47,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) {
+ if (isLoopback(direct) && headerIp && req.hostname === HOME_HOST) {
const peer = peerFromIp(headerIp);
if (peer) { req.portalPeerId = peer.id; req.portalPeerName = peer.name; }
}
diff --git a/src/routes/api/portal.js b/src/routes/api/portal.js
index 7b18bf2f..9aae5d53 100644
--- a/src/routes/api/portal.js
+++ b/src/routes/api/portal.js
@@ -6,9 +6,16 @@ const routesSvc = require('../../services/routes');
const caddyAcl = require('../../services/caddyAcl');
const { getDb } = require('../../db/connection');
const logger = require('../../utils/logger');
+const portalConfig = require('../../services/portalConfig');
const router = Router();
+// Master portal gate — 404 if the portal is disabled globally.
+router.use((req, res, next) => {
+ if (!portalConfig().enabled) return res.status(404).json({ ok: false });
+ next();
+});
+
function unidentified(res) {
return res.json({ ok: true, data: null, reason: 'unidentified' });
}
@@ -21,8 +28,10 @@ function toSQLite(date) {
router.get('/device', async (req, res) => {
try {
+ if (!portalConfig().widgets.device) return res.status(404).json({ ok: false });
if (req.portalPeerId == null) return unidentified(res);
- const all = await peers.getAll(); // async — merges live wg status
+ // Use a high limit so any identified peer is found regardless of total peer count.
+ const all = await peers.getAll({ limit: 1000000 }); // async — merges live wg status
const p = all.find(x => x.id === req.portalPeerId);
if (!p) return unidentified(res);
res.json({ ok: true, data: {
@@ -43,6 +52,7 @@ router.get('/device', async (req, res) => {
router.get('/traffic', (req, res) => {
try {
+ if (!portalConfig().widgets.traffic) return res.status(404).json({ ok: false });
if (req.portalPeerId == null) return unidentified(res);
const p = peers.getById(req.portalPeerId); // sync
if (!p) return unidentified(res);
@@ -100,6 +110,7 @@ router.get('/traffic', (req, res) => {
router.get('/services', (req, res) => {
try {
+ if (!portalConfig().widgets.services) return res.status(404).json({ ok: false });
if (req.portalPeerId == null) return unidentified(res);
const all = routesSvc.getAll().filter(r => r.enabled && r.route_type === 'http');
const visible = all.filter(r => {
diff --git a/src/services/caddyConfig.js b/src/services/caddyConfig.js
index 6e5bc7c5..7bb86abf 100644
--- a/src/services/caddyConfig.js
+++ b/src/services/caddyConfig.js
@@ -664,12 +664,18 @@ 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}`;
+
// TLS email. Split domains into public-TLD (gets real ACME) and
// internal/private suffixes (gets Caddy's internal CA). Without the
// split a single `.test`/`.local`/`.internal` route would hammer the
// Let's Encrypt rate-limit endpoint with retries every hour and
// pollute acme logs.
- const tlsConfig = buildTlsAutomation(Object.keys(caddyRoutes), config.caddy);
+ // 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);
if (tlsConfig) caddyConfig.apps.tls = tlsConfig;
// GateControl management UI route
@@ -683,6 +689,14 @@ function buildCaddyConfig(injectedRoutes, options = {}) {
handle: [{
handler: 'reverse_proxy',
upstreams: [{ dial: `127.0.0.1:${config.app.port}` }],
+ // Belt-and-suspenders: strip the portal identity header on the
+ // management-UI vhost so it cannot be used to forge peer identity
+ // even if an external request somehow reaches Node via this path.
+ headers: {
+ request: {
+ delete: ['X-GC-Portal-Peer-IP'],
+ },
+ },
}],
}],
};
@@ -699,7 +713,6 @@ function buildCaddyConfig(injectedRoutes, options = {}) {
// source IP, NOT from any forwarded header.
// • Rewrites bare / to /portal so VPN clients landing on home. see
// the portal immediately; asset/API paths pass through unchanged.
- const homeHost = `home.${config.dns.domain}`;
if (!caddyRoutes[homeHost]) {
caddyRoutes[homeHost] = {
listen: [':443', ':80'],
diff --git a/templates/default/pages/settings.njk b/templates/default/pages/settings.njk
index f9c8cc6f..60050e08 100644
--- a/templates/default/pages/settings.njk
+++ b/templates/default/pages/settings.njk
@@ -1093,7 +1093,7 @@