From 36fffd65e0f316dae83a75f3c19da1c8545da625 Mon Sep 17 00:00:00 2001 From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com> Date: Tue, 23 Jun 2026 21:55:53 +0200 Subject: [PATCH 01/16] =?UTF-8?q?feat(portal):=20source-IP=E2=86=92direct-?= =?UTF-8?q?peer=20identity=20middleware?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/middleware/portalIdentity.js | 47 +++++++++++++++++++ tests/portal_identity.test.js | 77 ++++++++++++++++++++++++++++++++ 2 files changed, 124 insertions(+) create mode 100644 src/middleware/portalIdentity.js create mode 100644 tests/portal_identity.test.js diff --git a/src/middleware/portalIdentity.js b/src/middleware/portalIdentity.js new file mode 100644 index 00000000..e0145cb2 --- /dev/null +++ b/src/middleware/portalIdentity.js @@ -0,0 +1,47 @@ +// src/middleware/portalIdentity.js +'use strict'; +const { getDb } = require('../db/connection'); + +function isLoopback(addr) { + return addr === '127.0.0.1' || addr === '::1' || addr === '::ffff:127.0.0.1'; +} + +/** Find a direct, enabled peer whose allowed_ips contains the /32 `ip`. */ +function peerFromIp(ip) { + if (!ip || typeof ip !== 'string') return null; + const v4 = ip.startsWith('::ffff:') ? ip.slice(7) : ip; + const db = getDb(); + // allowed_ips may be a comma-separated list; match any /32 entry robustly in JS. + const rows = db.prepare(` + SELECT id, name, allowed_ips FROM peers + WHERE enabled = 1 AND peer_type != 'gateway' + `).all(); + for (const r of rows) { + const entries = String(r.allowed_ips || '').split(',').map(s => s.trim().split('/')[0]); + if (entries.includes(v4)) return { id: r.id, name: r.name }; + } + return null; +} + +/** + * 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. + * Generic X-Forwarded-For is intentionally NOT used for identity. + */ +function portalIdentity(req, _res, next) { + req.portalPeerId = null; + 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) { + const peer = peerFromIp(headerIp); + if (peer) { req.portalPeerId = peer.id; req.portalPeerName = peer.name; } + } + next(); +} + +module.exports = portalIdentity; +module.exports.peerFromIp = peerFromIp; diff --git a/tests/portal_identity.test.js b/tests/portal_identity.test.js new file mode 100644 index 00000000..4c1d93ee --- /dev/null +++ b/tests/portal_identity.test.js @@ -0,0 +1,77 @@ +'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, peers, getDb; +beforeEach(async () => { + await setup(); + portalIdentity = require('../src/middleware/portalIdentity'); + peers = require('../src/services/peers'); + getDb = require('../src/db/connection').getDb; +}); +afterEach(teardown); + +function runMw(ip) { + // Simulate a request that arrived via the internal Caddy site: + // connection from loopback + the Caddy-set reserved header. + const req = { + socket: { remoteAddress: '127.0.0.1' }, + get: (h) => (String(h).toLowerCase() === 'x-gc-portal-peer-ip' ? ip : undefined), + }; + let called = false; + portalIdentity(req, {}, () => { called = true; }); + return { req, called }; +} + +test('maps a direct peer tunnel IP to its peer id', () => { + const db = getDb(); + db.prepare(`INSERT INTO peers (name, public_key, allowed_ips, enabled, peer_type) + VALUES ('alice','k1','10.8.0.5/32',1,'regular')`).run(); + const { req, called } = runMw('10.8.0.5'); + assert.equal(called, true); + assert.ok(req.portalPeerId, 'expected a peer id'); +}); + +test('returns null for an unknown source IP', () => { + const { req } = runMw('10.8.0.99'); + assert.equal(req.portalPeerId, null); +}); + +test('returns null when the IP belongs to a gateway peer (fail-safe)', () => { + const db = getDb(); + db.prepare(`INSERT INTO peers (name, public_key, allowed_ips, enabled, peer_type) + VALUES ('gw','k2','10.8.0.9/32',1,'gateway')`).run(); + const { req } = runMw('10.8.0.9'); + assert.equal(req.portalPeerId, null); +}); + +test('returns null for a disabled peer', () => { + const db = getDb(); + db.prepare(`INSERT INTO peers (name, public_key, allowed_ips, enabled, peer_type) + VALUES ('bob','k3','10.8.0.6/32',0,'regular')`).run(); + const { req } = runMw('10.8.0.6'); + assert.equal(req.portalPeerId, null); +}); + +test('returns null when the reserved header is absent (not via internal site)', () => { + const db = getDb(); + db.prepare(`INSERT INTO peers (name, public_key, allowed_ips, enabled, peer_type) + VALUES ('alice','k1','10.8.0.5/32',1,'regular')`).run(); + const req = { socket: { remoteAddress: '127.0.0.1' }, get: () => undefined }; + portalIdentity(req, {}, () => {}); + assert.equal(req.portalPeerId, null); +}); + +test('returns null when the connection is NOT from loopback (direct-to-Node forgery)', () => { + const db = getDb(); + db.prepare(`INSERT INTO peers (name, public_key, allowed_ips, enabled, peer_type) + VALUES ('alice','k1','10.8.0.5/32',1,'regular')`).run(); + // Attacker hits the Node port directly over the tunnel with a forged header. + const req = { socket: { remoteAddress: '10.8.0.99' }, + get: (h) => (String(h).toLowerCase() === 'x-gc-portal-peer-ip' ? '10.8.0.5' : undefined) }; + portalIdentity(req, {}, () => {}); + assert.equal(req.portalPeerId, null); +}); From 64ffcb153d45522d4683a5a5838e83cb31dd3cc9 Mon Sep 17 00:00:00 2001 From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com> Date: Tue, 23 Jun 2026 22:08:03 +0200 Subject: [PATCH 02/16] feat(portal): source-IP-scoped portal data API (/api/v1/portal/*) --- src/routes/api/portal.js | 85 +++++++++++++++++++++++ src/routes/index.js | 4 ++ tests/portal_api.test.js | 141 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 230 insertions(+) create mode 100644 src/routes/api/portal.js create mode 100644 tests/portal_api.test.js diff --git a/src/routes/api/portal.js b/src/routes/api/portal.js new file mode 100644 index 00000000..be879c72 --- /dev/null +++ b/src/routes/api/portal.js @@ -0,0 +1,85 @@ +// src/routes/api/portal.js +'use strict'; +const { Router } = require('express'); +const peers = require('../../services/peers'); +const routesSvc = require('../../services/routes'); +const caddyAcl = require('../../services/caddyAcl'); +const { getDb } = require('../../db/connection'); +const logger = require('../../utils/logger'); + +const router = Router(); + +function unidentified(res) { + return res.json({ ok: true, data: null, reason: 'unidentified' }); +} + +router.get('/device', async (req, res) => { + try { + if (req.portalPeerId == null) return unidentified(res); + const all = await peers.getAll(); // async — merges live wg status + const p = all.find(x => x.id === req.portalPeerId); + if (!p) return unidentified(res); + res.json({ ok: true, data: { + id: p.id, + name: p.name, + isOnline: p.isOnline, + latestHandshake: p.latestHandshake, + transferRx: p.transferRx, + transferTx: p.transferTx, + allowed_ips: p.allowed_ips, + dns: p.dns, + } }); + } catch (err) { + logger.error({ error: err.message }, 'portal /device failed'); + return unidentified(res); + } +}); + +router.get('/traffic', (req, res) => { + try { + if (req.portalPeerId == null) return unidentified(res); + const p = peers.getById(req.portalPeerId); // sync + if (!p) return unidentified(res); + const db = getDb(); + const periods = [ + ['last24h', '-24 hours'], + ['last7d', '-7 days'], + ['last30d', '-30 days'], + ]; + const traffic = { total: { rx: p.total_rx || 0, tx: p.total_tx || 0 } }; + for (const [key, interval] of periods) { + const row = db.prepare(` + SELECT COALESCE(SUM(download_bytes),0) rx, COALESCE(SUM(upload_bytes),0) tx + FROM peer_traffic_snapshots WHERE peer_id = ? AND recorded_at >= datetime('now', ?) + `).get(Number(req.portalPeerId), interval); + traffic[key] = { rx: row.rx, tx: row.tx }; + } + res.json({ ok: true, data: traffic }); + } catch (err) { + logger.error({ error: err.message }, 'portal /traffic failed'); + return unidentified(res); + } +}); + +router.get('/services', (req, res) => { + try { + if (req.portalPeerId == null) return unidentified(res); + const all = routesSvc.getAll().filter(r => r.enabled); + const visible = all.filter(r => { + if (!r.acl_enabled) return true; // open route — always reachable + const aclPeers = caddyAcl.getAclPeers(r.id) || []; + return aclPeers.some(p => p.peer_id === req.portalPeerId); + }).map(r => ({ + id: r.id, + name: r.description || r.domain, + domain: r.domain, + kind: 'http', + })); + res.json({ ok: true, data: visible }); + } catch (err) { + logger.error({ error: err.message }, 'portal /services failed'); + return unidentified(res); + } +}); + +module.exports = router; diff --git a/src/routes/index.js b/src/routes/index.js index f019eef4..db92267b 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -283,6 +283,10 @@ router.use('/api/v1/gateway', apiLimiter, require('./api/gateway')); // ─── Real-time event stream (SSE) — session-authed, bypasses apiLimiter ── router.get('/api/v1/events', requireAuth, require('./api/events')); +// ─── Portal API (source-IP identity, no session auth) ────────── +const portalIdentity = require('../middleware/portalIdentity'); +router.use('/api/v1/portal', apiLimiter, portalIdentity, require('./api/portal')); + // ─── API routes ──────────────────────────────────── router.use('/api/v1', requireAuth, apiLimiter, require('./api')); diff --git a/tests/portal_api.test.js b/tests/portal_api.test.js new file mode 100644 index 00000000..7632878a --- /dev/null +++ b/tests/portal_api.test.js @@ -0,0 +1,141 @@ +'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 /api/v1/portal/device returns the calling peer (via reserved header)', async () => { + getDb().prepare(`INSERT INTO peers (name, public_key, allowed_ips, enabled, peer_type) + VALUES ('alice','k1','10.8.0.5/32',1,'regular')`).run(); + // supertest connects from loopback (like Caddy); the reserved header carries the peer IP. + const res = await supertest(app).get('/api/v1/portal/device') + .set('X-GC-Portal-Peer-IP', '10.8.0.5').expect(200); + assert.equal(res.body.ok, true); + assert.equal(res.body.data.name, 'alice'); +}); + +test('a generic X-Forwarded-For does NOT establish identity (only the reserved header does)', async () => { + getDb().prepare(`INSERT INTO peers (name, public_key, allowed_ips, enabled, peer_type) + VALUES ('alice','k1','10.8.0.5/32',1,'regular')`).run(); + const res = await supertest(app).get('/api/v1/portal/device') + .set('X-Forwarded-For', '10.8.0.5').expect(200); + assert.equal(res.body.data, null); + assert.equal(res.body.reason, 'unidentified'); +}); + +test('portal endpoints never require a token and never 500 on unknown IP', async () => { + const res = await supertest(app).get('/api/v1/portal/device') + .set('X-GC-Portal-Peer-IP', '10.8.0.250').expect(200); + assert.equal(res.body.ok, true); + assert.equal(res.body.data, null); + assert.equal(res.body.reason, 'unidentified'); +}); + +test('GET /api/v1/portal/traffic returns period buckets for the calling peer', async () => { + const db = getDb(); + const { lastInsertRowid: peerId } = db.prepare( + `INSERT INTO peers (name, public_key, allowed_ips, enabled, peer_type, total_rx, total_tx) + VALUES ('bob','k2','10.8.0.6/32',1,'regular',500,300)` + ).run(); + + // Insert snapshots: two in the last 24 h, one older (7d) + db.prepare( + `INSERT INTO peer_traffic_snapshots (peer_id, download_bytes, upload_bytes, recorded_at) + VALUES (?,100,50,datetime('now','-1 hours'))` + ).run(peerId); + db.prepare( + `INSERT INTO peer_traffic_snapshots (peer_id, download_bytes, upload_bytes, recorded_at) + VALUES (?,200,80,datetime('now','-2 hours'))` + ).run(peerId); + db.prepare( + `INSERT INTO peer_traffic_snapshots (peer_id, download_bytes, upload_bytes, recorded_at) + VALUES (?,400,150,datetime('now','-3 days'))` + ).run(peerId); + + const res = await supertest(app).get('/api/v1/portal/traffic') + .set('X-GC-Portal-Peer-IP', '10.8.0.6').expect(200); + + assert.equal(res.body.ok, true); + const d = res.body.data; + // Total comes from peers.total_rx / total_tx + assert.equal(d.total.rx, 500); + assert.equal(d.total.tx, 300); + // last24h: only the two recent rows + assert.equal(d.last24h.rx, 300); // 100 + 200 + assert.equal(d.last24h.tx, 130); // 50 + 80 + // last7d: all three rows + assert.equal(d.last7d.rx, 700); // 100 + 200 + 400 + assert.equal(d.last7d.tx, 280); // 50 + 80 + 150 + // last30d: all three rows + assert.equal(d.last30d.rx, 700); + assert.equal(d.last30d.tx, 280); +}); + +test('GET /api/v1/portal/services returns only visible routes for the calling peer', async () => { + const db = getDb(); + const { lastInsertRowid: peerId } = db.prepare( + `INSERT INTO peers (name, public_key, allowed_ips, enabled, peer_type) + VALUES ('carol','k3','10.8.0.7/32',1,'regular')` + ).run(); + + // Open route (no ACL) — should be visible + db.prepare( + `INSERT INTO routes (domain, description, target_ip, target_port, enabled, acl_enabled) + VALUES ('open.example.com','Open App','10.0.0.1',80,1,0)` + ).run(); + + // ACL-restricted route: carol IS on the ACL — should be visible + const { lastInsertRowid: aclRouteId } = db.prepare( + `INSERT INTO routes (domain, description, target_ip, target_port, enabled, acl_enabled) + VALUES ('acl.example.com','Restricted App','10.0.0.2',80,1,1)` + ).run(); + db.prepare( + `INSERT INTO route_peer_acl (route_id, peer_id) VALUES (?,?)` + ).run(aclRouteId, peerId); + + // ACL-restricted route: carol is NOT on the ACL — should NOT be visible + const { lastInsertRowid: hiddenRouteId } = db.prepare( + `INSERT INTO routes (domain, description, target_ip, target_port, enabled, acl_enabled) + VALUES ('hidden.example.com','Hidden App','10.0.0.3',80,1,1)` + ).run(); + // Insert some OTHER peer on the hidden route ACL + const { lastInsertRowid: otherId } = db.prepare( + `INSERT INTO peers (name, public_key, allowed_ips, enabled, peer_type) + VALUES ('dave','k4','10.8.0.8/32',1,'regular')` + ).run(); + db.prepare( + `INSERT INTO route_peer_acl (route_id, peer_id) VALUES (?,?)` + ).run(hiddenRouteId, otherId); + + // Disabled route — should NOT be visible + db.prepare( + `INSERT INTO routes (domain, description, target_ip, target_port, enabled, acl_enabled) + VALUES ('disabled.example.com','Disabled App','10.0.0.4',80,0,0)` + ).run(); + + const res = await supertest(app).get('/api/v1/portal/services') + .set('X-GC-Portal-Peer-IP', '10.8.0.7').expect(200); + + assert.equal(res.body.ok, true); + const domains = res.body.data.map(s => s.domain); + assert.ok(domains.includes('open.example.com'), 'open route visible'); + assert.ok(domains.includes('acl.example.com'), 'ACL route visible when peer is member'); + assert.ok(!domains.includes('hidden.example.com'), 'ACL route NOT visible when peer is not member'); + assert.ok(!domains.includes('disabled.example.com'), 'disabled route not visible'); + + // Each item has required shape + const open = res.body.data.find(s => s.domain === 'open.example.com'); + assert.equal(open.kind, 'http'); + assert.ok(open.id); + assert.equal(open.name, 'Open App'); +}); From 439dc711456ccb2bd10322b1f47bab4162b73460 Mon Sep 17 00:00:00 2001 From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com> Date: Tue, 23 Jun 2026 22:19:41 +0200 Subject: [PATCH 03/16] fix(portal): restrict /services to HTTP routes (exclude L4/RDP) --- src/routes/api/portal.js | 2 +- tests/portal_api.test.js | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/routes/api/portal.js b/src/routes/api/portal.js index be879c72..77c96b31 100644 --- a/src/routes/api/portal.js +++ b/src/routes/api/portal.js @@ -64,7 +64,7 @@ router.get('/traffic', (req, res) => { router.get('/services', (req, res) => { try { if (req.portalPeerId == null) return unidentified(res); - const all = routesSvc.getAll().filter(r => r.enabled); + const all = routesSvc.getAll().filter(r => r.enabled && r.route_type === 'http'); const visible = all.filter(r => { if (!r.acl_enabled) return true; // open route — always reachable const aclPeers = caddyAcl.getAclPeers(r.id) || []; diff --git a/tests/portal_api.test.js b/tests/portal_api.test.js index 7632878a..f3f91890 100644 --- a/tests/portal_api.test.js +++ b/tests/portal_api.test.js @@ -123,6 +123,12 @@ test('GET /api/v1/portal/services returns only visible routes for the calling pe VALUES ('disabled.example.com','Disabled App','10.0.0.4',80,0,0)` ).run(); + // Enabled L4 route (open ACL) — should NOT be visible (route_type filter) + db.prepare( + `INSERT INTO routes (domain, description, target_ip, target_port, enabled, acl_enabled, route_type) + VALUES ('l4.example.com','L4 App','10.0.0.5',443,1,0,'l4')` + ).run(); + const res = await supertest(app).get('/api/v1/portal/services') .set('X-GC-Portal-Peer-IP', '10.8.0.7').expect(200); @@ -132,6 +138,7 @@ test('GET /api/v1/portal/services returns only visible routes for the calling pe assert.ok(domains.includes('acl.example.com'), 'ACL route visible when peer is member'); assert.ok(!domains.includes('hidden.example.com'), 'ACL route NOT visible when peer is not member'); assert.ok(!domains.includes('disabled.example.com'), 'disabled route not visible'); + assert.ok(!domains.includes('l4.example.com'), 'L4 route NOT visible (excluded by route_type filter)'); // Each item has required shape const open = res.body.data.find(s => s.domain === 'open.example.com'); From fbdfe33f3bbf6255101e7c6828d1f7551ea0002d Mon Sep 17 00:00:00 2001 From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com> Date: Tue, 23 Jun 2026 22:29:15 +0200 Subject: [PATCH 04/16] feat(portal): settings toggles (master + per-widget) --- public/js/settings.js | 55 ++++++++++++++++++++++++++++ src/routes/api/settings/index.js | 1 + src/routes/api/settings/portal.js | 55 ++++++++++++++++++++++++++++ src/services/portalConfig.js | 24 ++++++++++++ templates/aurora/pages/settings.njk | 37 +++++++++++++++++++ templates/default/pages/settings.njk | 36 ++++++++++++++++++ templates/pro/pages/settings.njk | 37 +++++++++++++++++++ tests/portal_settings.test.js | 52 ++++++++++++++++++++++++++ 8 files changed, 297 insertions(+) create mode 100644 src/routes/api/settings/portal.js create mode 100644 src/services/portalConfig.js create mode 100644 tests/portal_settings.test.js diff --git a/public/js/settings.js b/public/js/settings.js index afec0058..ee42676b 100644 --- a/public/js/settings.js +++ b/public/js/settings.js @@ -1666,6 +1666,61 @@ loadPihole(); })(); +// ─── Portal Settings ───────────────────────────────── +(function () { + var enabledToggle = document.getElementById('portal-enabled'); + var widgetDevice = document.getElementById('portal-widget-device'); + var widgetTraffic = document.getElementById('portal-widget-traffic'); + var widgetServices = document.getElementById('portal-widget-services'); + var saveBtn = document.getElementById('btn-portal-save'); + if (!enabledToggle) return; + + [enabledToggle, widgetDevice, widgetTraffic, widgetServices].forEach(function (el) { + if (el) el.addEventListener('click', function () { el.classList.toggle('on'); }); + }); + + function setToggle(el, val) { + if (!el) return; + if (val) el.classList.add('on'); else el.classList.remove('on'); + } + + api.get('/api/v1/settings/portal').then(function (data) { + if (!data.ok) return; + var d = data.data; + setToggle(enabledToggle, d.enabled); + setToggle(widgetDevice, d.widgets && d.widgets.device); + setToggle(widgetTraffic, d.widgets && d.widgets.traffic); + setToggle(widgetServices, d.widgets && d.widgets.services); + }).catch(function (err) { + console.error('Failed to load portal settings:', err); + }); + + if (saveBtn) { + saveBtn.addEventListener('click', async function () { + btnLoading(saveBtn); + try { + var data = await api.put('/api/v1/settings/portal', { + enabled: enabledToggle.classList.contains('on'), + widgets: { + device: widgetDevice ? widgetDevice.classList.contains('on') : true, + traffic: widgetTraffic ? widgetTraffic.classList.contains('on') : true, + services: widgetServices ? widgetServices.classList.contains('on') : true, + }, + }); + if (data.ok) { + showMessage('portal-message', GC.t['security.saved'] || 'Settings saved', 'success'); + } else { + showMessage('portal-message', data.error || 'Failed', 'error'); + } + } catch (err) { + showMessage('portal-message', err.message, 'error'); + } finally { + btnReset(saveBtn); + } + }); + } +})(); + // ─── Route Block Default ────────────────────────────── (function () { var actionSel = document.getElementById('settings-route-block-action'); diff --git a/src/routes/api/settings/index.js b/src/routes/api/settings/index.js index fc6872cd..63e9fc7f 100644 --- a/src/routes/api/settings/index.js +++ b/src/routes/api/settings/index.js @@ -45,5 +45,6 @@ router.use('/', require('./network')); router.use('/', require('./observability')); router.use('/', require('./gateway')); router.use('/', require('./pihole')); +router.use('/', require('./portal')); module.exports = router; diff --git a/src/routes/api/settings/portal.js b/src/routes/api/settings/portal.js new file mode 100644 index 00000000..deec40d5 --- /dev/null +++ b/src/routes/api/settings/portal.js @@ -0,0 +1,55 @@ +'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. + +const { Router } = require('express'); +const settings = require('../../../services/settings'); +const portalConfig = require('../../../services/portalConfig'); +const activity = require('../../../services/activity'); + +const router = Router(); + +/** + * GET /api/v1/settings/portal — Return current portal settings as booleans + */ +router.get('/portal', (req, res) => { + res.json({ ok: true, data: portalConfig() }); +}); + +/** + * PUT /api/v1/settings/portal — Update portal master switch + widget toggles + * + * Accepts: + * { enabled: bool, widgets: { device: bool, traffic: bool, services: bool } } + */ +router.put('/portal', (req, res) => { + try { + const body = req.body || {}; + const widgets = body.widgets || {}; + + if (body.enabled !== undefined) { + settings.set('portal.enabled', body.enabled ? '1' : '0'); + } + if (widgets.device !== undefined) { + settings.set('portal.widget.device', widgets.device ? '1' : '0'); + } + if (widgets.traffic !== undefined) { + settings.set('portal.widget.traffic', widgets.traffic ? '1' : '0'); + } + if (widgets.services !== undefined) { + settings.set('portal.widget.services', widgets.services ? '1' : '0'); + } + + activity.log('portal_settings_updated', 'Portal settings updated', { + source: 'admin', ipAddress: req.ip, severity: 'info', + }); + + res.json({ ok: true }); + } catch (err) { + res.status(500).json({ ok: false, error: req.t('common.error') }); + } +}); + +module.exports = router; diff --git a/src/services/portalConfig.js b/src/services/portalConfig.js new file mode 100644 index 00000000..d75da688 --- /dev/null +++ b/src/services/portalConfig.js @@ -0,0 +1,24 @@ +'use strict'; + +const settings = require('./settings'); + +/** + * Returns the current VPN landing portal configuration derived from settings. + * All values default to enabled ('1') unless explicitly set to '0'. + * + * @returns {{ enabled: boolean, widgets: { device: boolean, traffic: boolean, services: boolean } }} + */ +const on = (key) => settings.get(key, '1') !== '0'; + +function portalConfig() { + return { + enabled: on('portal.enabled'), + widgets: { + device: on('portal.widget.device'), + traffic: on('portal.widget.traffic'), + services: on('portal.widget.services'), + }, + }; +} + +module.exports = portalConfig; diff --git a/templates/aurora/pages/settings.njk b/templates/aurora/pages/settings.njk index 9196b9d6..351d4dc8 100644 --- a/templates/aurora/pages/settings.njk +++ b/templates/aurora/pages/settings.njk @@ -26,6 +26,7 @@
{{ t('settings.tab_license') }}
{{ t('settings.tab_split_tunnel') }}
{% if license.features.pihole_integration %}
{{ t('settings.tab_pihole') }}
{% endif %} +
{{ t('settings.portal.title') }}
{{ t('settings.tab_general') }}
{{ t('settings.tab_security') }}
@@ -36,6 +37,7 @@
{{ t('settings.tab_license') }}
{{ t('settings.tab_split_tunnel') }}
{% if license.features.pihole_integration %}
{{ t('settings.tab_pihole') }}
{% endif %} +
{{ t('settings.portal.title') }}
{# ─── General Tab ─────────────────────────────────────── #} @@ -921,6 +923,41 @@ {% endif %} +{# ─── Portal Tab ────────────────────────────────────────── #} + + {% endblock %} {% block scripts %} diff --git a/templates/default/pages/settings.njk b/templates/default/pages/settings.njk index 96719449..f9c8cc6f 100644 --- a/templates/default/pages/settings.njk +++ b/templates/default/pages/settings.njk @@ -146,6 +146,7 @@
{{ t('settings.tab_license') }}
{{ t('settings.tab_split_tunnel') }}
{% if license.features.pihole_integration %}
{{ t('settings.tab_pihole') }}
{% endif %} +
{{ t('settings.portal.title') }}
{{ t('settings.tab_general') }}
{{ t('settings.tab_security') }}
@@ -156,6 +157,7 @@
{{ t('settings.tab_license') }}
{{ t('settings.tab_split_tunnel') }}
{% if license.features.pihole_integration %}
{{ t('settings.tab_pihole') }}
{% endif %} +
{{ t('settings.portal.title') }}
{# ─── General Tab ─────────────────────────────────────── #} @@ -1087,6 +1089,40 @@ {% endif %} +{# ─── Portal Tab ────────────────────────────────────────── #} + + {% endblock %} {% block scripts %} diff --git a/templates/pro/pages/settings.njk b/templates/pro/pages/settings.njk index 24ed045b..9ec659c3 100644 --- a/templates/pro/pages/settings.njk +++ b/templates/pro/pages/settings.njk @@ -25,6 +25,7 @@
{{ t('settings.tab_license') }}
{{ t('settings.tab_split_tunnel') }}
{% if license.features.pihole_integration %}
{{ t('settings.tab_pihole') }}
{% endif %} +
{{ t('settings.portal.title') }}
{{ t('settings.tab_general') }}
{{ t('settings.tab_security') }}
@@ -35,6 +36,7 @@
{{ t('settings.tab_license') }}
{{ t('settings.tab_split_tunnel') }}
{% if license.features.pihole_integration %}
{{ t('settings.tab_pihole') }}
{% endif %} +
{{ t('settings.portal.title') }}
{# ─── General Tab ─────────────────────────────────────── #} @@ -975,6 +977,41 @@ {% endif %} +{# ─── Portal Tab ────────────────────────────────────────── #} + + {% endblock %} {% block scripts %} diff --git a/tests/portal_settings.test.js b/tests/portal_settings.test.js new file mode 100644 index 00000000..dab93e11 --- /dev/null +++ b/tests/portal_settings.test.js @@ -0,0 +1,52 @@ +'use strict'; +const crypto = require('crypto'); +process.env.GC_ENCRYPTION_KEY = process.env.GC_ENCRYPTION_KEY || crypto.randomBytes(32).toString('hex'); + +const { test, before, after } = require('node:test'); +const assert = require('node:assert/strict'); +const { setup, teardown, getAgent, getCsrf } = require('./helpers/setup'); + +let portalCfg, settings, agent, csrf; + +before(async () => { + const ctx = await setup(); + agent = ctx.agent; + csrf = ctx.csrfToken; + settings = require('../src/services/settings'); + portalCfg = require('../src/services/portalConfig'); +}); +after(teardown); + +// ── Part A: unit tests ───────────────────────────────────────────────────── + +test('defaults: enabled, all widgets on', () => { + const c = portalCfg(); + assert.equal(c.enabled, true); + assert.deepEqual(c.widgets, { device: true, traffic: true, services: true }); +}); + +test('a disabled widget is reflected', () => { + settings.set('portal.widget.traffic', '0'); + assert.equal(portalCfg().widgets.traffic, false); +}); + +test('master off is reflected', () => { + settings.set('portal.enabled', '0'); + assert.equal(portalCfg().enabled, false); +}); + +// ── Part B: supertest round-trip ─────────────────────────────────────────── + +test('PUT /api/settings/portal then GET reflects the change', async () => { + const put = await agent + .put('/api/v1/settings/portal') + .set('X-CSRF-Token', csrf) + .send({ enabled: true, widgets: { device: true, traffic: true, services: false } }) + .expect(200); + assert.equal(put.body.ok, true); + + const get = await agent.get('/api/v1/settings/portal').expect(200); + assert.equal(get.body.ok, true); + assert.equal(get.body.data.widgets.services, false); + assert.equal(portalCfg().widgets.services, false); +}); From 4d7d917b5e1d13e5020dd212126d1ba6279cba7a Mon Sep 17 00:00:00 2001 From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com> Date: Tue, 23 Jun 2026 22:40:04 +0200 Subject: [PATCH 05/16] feat(portal): server-rendered portal page route + template --- src/routes/index.js | 12 ++++ templates/portal/portal.njk | 110 ++++++++++++++++++++++++++++++++++++ tests/portal_page.test.js | 33 +++++++++++ 3 files changed, 155 insertions(+) create mode 100644 templates/portal/portal.njk create mode 100644 tests/portal_page.test.js diff --git a/src/routes/index.js b/src/routes/index.js index db92267b..c787f2f5 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -287,6 +287,18 @@ router.get('/api/v1/events', requireAuth, require('./api/events')); const portalIdentity = require('../middleware/portalIdentity'); router.use('/api/v1/portal', apiLimiter, portalIdentity, require('./api/portal')); +// ─── Portal page (source-IP identity, no session auth) ───────── +const portalConfig = require('../services/portalConfig'); +router.get('/portal', portalIdentity, (req, res) => { + const cfg = portalConfig(); + if (!cfg.enabled) return res.sendStatus(404); + res.render('portal/portal.njk', { + widgets: cfg.widgets, + deviceName: req.portalPeerName, // null → generic welcome + identified: req.portalPeerId != null, + }); +}); + // ─── API routes ──────────────────────────────────── router.use('/api/v1', requireAuth, apiLimiter, require('./api')); diff --git a/templates/portal/portal.njk b/templates/portal/portal.njk new file mode 100644 index 00000000..9ad532a5 --- /dev/null +++ b/templates/portal/portal.njk @@ -0,0 +1,110 @@ + + + + + +{{ appName }} — {{ t('portal.title') }} + + + + + + +
+ + +
+
+ + {{ appName }} +
+
+ + {% if identified %} +
+ + {{ deviceName }} + +
+ {% endif %} + +
+ + +
+

{% if identified %}{{ t('portal.greeting_home') }}{% else %}{{ t('portal.welcome') }}{% endif %}

+

{{ t('portal.greeting_sub') }}

+
+ + +
+ + {% if widgets.device %} + +
+

{{ t('portal.device.title') }}

+
+
{{ t('portal.device.status') }}
+
{{ t('portal.device.last_handshake') }}
+
{{ t('portal.device.vpn_address') }}
+
{{ t('portal.device.dns') }}
+
+
+
{{ t('portal.device.received') }}
+
{{ t('portal.device.sent') }}
+
+
+ {% endif %} + + {% if widgets.traffic %} + +
+

{{ t('portal.traffic.title') }} + {{ t('portal.traffic.legend_rx') }}{{ t('portal.traffic.legend_tx') }} + + + + + +

+
+
+ {{ t('portal.traffic.total') }}: + {{ t('portal.traffic.avg_day') }}: + {{ t('portal.traffic.peak') }}: +
+
+ {% endif %} + + {% if widgets.services %} + +
+

{{ t('portal.services.title') }}{{ t('portal.services.sub') }}

+
+
+ {% endif %} + +
+ + + +
+ + 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 @@