diff --git a/public/css/portal.css b/public/css/portal.css
new file mode 100644
index 00000000..3c4239f1
--- /dev/null
+++ b/public/css/portal.css
@@ -0,0 +1,281 @@
+/*
+ * portal.css — GateControl VPN Landing Portal design system
+ * Own design system; does NOT share rules with app.css / pro.css / aurora.css.
+ * Two themes: dark (midnight) + light (Papier, warm).
+ *
+ * VERIFIED CONTRAST RATIOS (WCAG 2.x relative luminance, §2.1 of layout-spec)
+ * ─────────────────────────────────────────────────────────────────────────────
+ * Light "Papier" mode (the risk surface; dark passes more comfortably):
+ * --text #2c2720 on --bg #f3efe6 ≈ 12.5–14:1 PASS (normal text)
+ * --muted #6e6456 on --bg #f3efe6 ≈ 5.0:1 PASS (normal text ≥4.5:1)
+ * --faint #766b59 on --bg #f3efe6 ≈ 4.5:1 PASS (hardened from #9c917e)
+ * --teal #0e9b8a on --bg #f3efe6 ≈ 3.1:1 PASS (large/UI only ≥3:1)
+ * --coral #d9602f on --bg #f3efe6 ≈ 3.4:1 PASS (large/UI only ≥3:1)
+ *
+ * IMPORTANT: accent colours (teal/coral) are UI/large-element use ONLY.
+ * They are NEVER used for small body text — use --text/--muted for that.
+ *
+ * FONTS (self-hosted, latin subset, variable fonts)
+ * ─────────────────────────────────────────────────
+ * 2-family decision (layout-spec §4): Bricolage Grotesque (display + body) +
+ * JetBrains Mono (data/numbers). Hanken Grotesk dropped to cut payload.
+ * Files are variable-font woff2 (one file per family covers all required weights):
+ * bricolage-grotesque-latin.woff2 → weights 400–800 (75 KB)
+ * jetbrains-mono-latin.woff2 → weights 500–700 (31 KB)
+ * No subsetting performed (pyftsubset not available); full woff2 shipped as-is.
+ * Task-6 template should the critical faces.
+ */
+
+/* ============================================================
+ SELF-HOSTED FONTS
+ ============================================================ */
+/* Bricolage Grotesque — variable font, covers weights 400–800 (display + body) */
+@font-face {
+ font-family: 'Bricolage Grotesque';
+ font-style: normal;
+ font-weight: 400 800;
+ font-stretch: 100%;
+ font-display: swap;
+ /* metric-matched fallback: system-ui shares close line metrics on most platforms */
+ src: url('/fonts/bricolage-grotesque-latin.woff2') format('woff2');
+ unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC,
+ U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193,
+ U+2212, U+2215, U+FEFF, U+FFFD;
+}
+
+/* JetBrains Mono — variable font, covers weights 500–700 (data/numbers) */
+@font-face {
+ font-family: 'JetBrains Mono';
+ font-style: normal;
+ font-weight: 500 700;
+ font-stretch: 100%;
+ font-display: swap;
+ /* metric-matched fallback: ui-monospace is a close width match on macOS/iOS */
+ src: url('/fonts/jetbrains-mono-latin.woff2') format('woff2');
+ unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC,
+ U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193,
+ U+2212, U+2215, U+FEFF, U+FFFD;
+}
+
+/* ============================================================
+ ROOT VARIABLES + FONT STACKS
+ ============================================================ */
+:root{
+ --r:18px; --r-sm:12px;
+ /* 2-family system: Bricolage Grotesque for display + body; JetBrains Mono for data */
+ --font-display:"Bricolage Grotesque", ui-sans-serif, system-ui, sans-serif;
+ --font-body:"Bricolage Grotesque", ui-sans-serif, system-ui, sans-serif;
+ --font-mono:"JetBrains Mono", ui-monospace, monospace;
+}
+
+/* ============ THEMES (two modes) ============ */
+[data-theme="dark"]{
+ --bg:#0a0e14; --bg-2:#0c1219;
+ --surface:#111a24; --surface-2:#16212e; --surface-3:#1b2836;
+ --chip:rgba(255,255,255,.03);
+ --line:rgba(255,255,255,.07); --line-2:rgba(255,255,255,.12);
+ --text:#e9eff6; --muted:#90a1b3; --faint:#5f6f7e;
+ --teal:#34dcc6; --teal-dim:#1f9c8e; --coral:#ff9d6c; --coral-dim:#cf6f45;
+ --amber:#f5c451; --green:#4ade80; --green-bg:rgba(74,222,128,.10); --green-bd:rgba(74,222,128,.25);
+ --btn-text:#04201d;
+ --glow1:rgba(52,220,198,.10); --glow2:rgba(255,157,108,.08); --grain:.035;
+ --shadow:0 1px 0 rgba(255,255,255,.04) inset, 0 18px 40px -22px rgba(0,0,0,.9);
+}
+[data-theme="light"]{
+ --bg:#f3efe6; --bg-2:#ece6d9;
+ --surface:#fffdf8; --surface-2:#f8f3ea; --surface-3:#f0e9db;
+ --chip:rgba(60,45,25,.04);
+ --line:rgba(60,45,25,.10); --line-2:rgba(60,45,25,.17);
+ --text:#2c2720; --muted:#6e6456; --faint:#766b59;
+ --teal:#0e9b8a; --teal-dim:#0b7d70; --coral:#d9602f; --coral-dim:#b14d24;
+ --amber:#b5811f; --green:#15924f; --green-bg:rgba(21,146,79,.10); --green-bd:rgba(21,146,79,.28);
+ --btn-text:#ffffff;
+ --glow1:rgba(14,155,138,.10); --glow2:rgba(217,96,47,.09); --grain:.02;
+ --shadow:0 1px 0 rgba(255,255,255,.7) inset, 0 16px 34px -24px rgba(80,60,30,.5);
+}
+
+/* ============================================================
+ RESET + BODY ATMOSPHERE + GRAIN
+ ============================================================ */
+*{box-sizing:border-box}
+html,body{margin:0}
+body{
+ font-family:var(--font-body); color:var(--text); background:var(--bg);
+ -webkit-font-smoothing:antialiased; line-height:1.5; min-height:100vh;
+ transition:background-color .35s ease, color .35s ease;
+ background-image:
+ radial-gradient(1100px 600px at 82% -8%, var(--glow1), transparent 60%),
+ radial-gradient(900px 520px at 5% 8%, var(--glow2), transparent 55%),
+ linear-gradient(180deg, var(--bg), var(--bg-2));
+ background-attachment:fixed;
+}
+body::before{
+ content:""; position:fixed; inset:0; pointer-events:none; z-index:0; opacity:var(--grain);
+ background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.8' numOctaves='2'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
+}
+.wrap{position:relative; z-index:1; max-width:1120px; margin:0 auto; padding:22px 24px 56px}
+
+/* ============================================================
+ TOP BAR
+ ============================================================ */
+.topbar{display:flex; align-items:center; gap:14px; padding:6px 2px 0; flex-wrap:wrap}
+.brand{display:flex; align-items:center; gap:11px; font-family:var(--font-display); font-weight:800; letter-spacing:-.02em; font-size:18px}
+.brand .mark{width:30px;height:30px;border-radius:9px;display:grid;place-items:center;
+ background:linear-gradient(145deg,var(--teal),var(--teal-dim)); color:var(--btn-text); box-shadow:0 6px 18px -8px var(--teal-dim)}
+.brand .mark svg{width:17px;height:17px}
+.spacer{flex:1}
+
+.icon-btn{width:38px;height:38px;border-radius:11px;border:1px solid var(--line);background:var(--chip);color:var(--muted);
+ display:grid;place-items:center;cursor:pointer;transition:.18s}
+.icon-btn:hover{color:var(--text);border-color:var(--line-2);transform:translateY(-1px)}
+.icon-btn svg{width:18px;height:18px}
+[data-theme="dark"] .ic-sun{display:block} [data-theme="dark"] .ic-moon{display:none}
+[data-theme="light"] .ic-sun{display:none} [data-theme="light"] .ic-moon{display:block}
+
+.devpill{display:flex; align-items:center; gap:10px; padding:7px 12px 7px 10px; border:1px solid var(--line);
+ background:var(--chip); border-radius:999px; font-size:13px}
+.dot{width:9px;height:9px;border-radius:50%;background:var(--green);box-shadow:0 0 0 0 rgba(74,222,128,.55);animation:pulse 2.4s infinite}
+@keyframes pulse{0%{box-shadow:0 0 0 0 rgba(74,222,128,.5)}70%{box-shadow:0 0 0 7px rgba(74,222,128,0)}100%{box-shadow:0 0 0 0 rgba(74,222,128,0)}}
+.devpill .name{font-weight:600}
+.devpill .ip{font-family:var(--font-mono); color:var(--muted); font-size:12px}
+.btn-login{display:inline-flex;align-items:center;gap:8px;padding:9px 15px;border-radius:11px;cursor:pointer;
+ font-family:var(--font-body);font-weight:600;font-size:13.5px;color:var(--btn-text);border:0;
+ background:linear-gradient(145deg,var(--teal),var(--teal-dim)); box-shadow:0 10px 24px -12px var(--teal-dim); transition:transform .15s ease, filter .15s}
+.btn-login:hover{transform:translateY(-1px);filter:brightness(1.05)}
+
+/* ============================================================
+ HERO
+ ============================================================ */
+.hero{margin:30px 2px 22px}
+.hero h1{font-family:var(--font-display); font-weight:800; letter-spacing:-.025em; line-height:1.02;
+ font-size:clamp(30px,4.6vw,50px); margin:0}
+.hero h1 .hi{color:var(--teal)}
+.hero p{margin:10px 0 0; color:var(--muted); font-size:15.5px; max-width:62ch}
+
+/* ============================================================
+ WIDGET GRID + CARDS
+ Pi-hole removed → 3 cards: Device (4), Traffic (8), Services (12)
+ Delays renumbered for 3-card stagger: .04s / .12s / .20s
+ ============================================================ */
+.grid{display:grid; grid-template-columns:repeat(12,1fr); gap:18px; margin-top:6px}
+.card{grid-column:span 6; background:linear-gradient(180deg,var(--surface),var(--surface-2));
+ border:1px solid var(--line); border-radius:var(--r); padding:20px 20px 18px; box-shadow:var(--shadow);
+ position:relative; overflow:hidden;
+ opacity:0; transform:translateY(14px); animation:rise .65s cubic-bezier(.2,.7,.2,1) forwards}
+.card::after{content:"";position:absolute;inset:0 0 auto 0;height:1px;background:linear-gradient(90deg,transparent,var(--line-2),transparent)}
+@keyframes rise{to{opacity:1;transform:none}}
+
+/* 3-widget layout (Pi-hole removed) */
+.c-device{grid-column:span 4; animation-delay:.04s}
+.c-traffic{grid-column:span 8; animation-delay:.12s}
+.c-services{grid-column:span 12; animation-delay:.20s}
+
+.card h2{margin:0; font-family:var(--font-display); font-weight:700; letter-spacing:-.01em; font-size:15px;
+ display:flex; align-items:center; gap:9px; color:var(--text)}
+.card h2 .ic{width:26px;height:26px;border-radius:8px;display:grid;place-items:center;background:var(--surface-3);color:var(--teal)}
+.card h2 .ic svg{width:15px;height:15px}
+.card .sub{color:var(--faint); font-size:12px; margin-left:auto; font-weight:500}
+
+/* ============================================================
+ DEVICE STATUS CARD (.c-device)
+ ============================================================ */
+.kv{display:flex; flex-direction:column; gap:13px; margin-top:18px}
+.kv .row{display:flex; align-items:center; justify-content:space-between; gap:14px}
+.kv .k{color:var(--muted); font-size:13px}
+.kv .v{font-family:var(--font-mono); font-size:13px; color:var(--text)}
+.badge-on{display:inline-flex;align-items:center;gap:7px;font-family:var(--font-body);font-weight:600;font-size:12.5px;
+ color:var(--green); background:var(--green-bg); border:1px solid var(--green-bd); padding:3px 9px;border-radius:999px}
+.xfer{display:flex; gap:12px; margin-top:4px}
+.xfer .b{flex:1; background:var(--surface-3); border:1px solid var(--line); border-radius:var(--r-sm); padding:11px 12px}
+.xfer .b .lab{font-size:11px; color:var(--faint); text-transform:uppercase; letter-spacing:.08em; display:flex;align-items:center;gap:6px}
+.xfer .b .num{font-family:var(--font-mono); font-weight:700; font-size:18px; margin-top:4px}
+.xfer .up .num{color:var(--coral)} .xfer .down .num{color:var(--teal)}
+
+/* ============================================================
+ TRAFFIC CARD (.c-traffic)
+ ============================================================ */
+.seg{display:inline-flex; gap:2px; background:var(--surface-3); border:1px solid var(--line); border-radius:10px; padding:3px; margin-left:auto}
+.seg button{border:0; background:transparent; color:var(--muted); font-family:var(--font-body); font-weight:600; font-size:12.5px;
+ padding:5px 11px; border-radius:7px; cursor:pointer; transition:.15s}
+.seg button.on{background:var(--surface);color:var(--text); box-shadow:0 2px 8px -4px rgba(0,0,0,.35)}
+.chart{margin-top:20px; display:flex; align-items:flex-end; gap:8px; height:150px; padding-top:6px}
+.chart .col{flex:1; display:flex; flex-direction:column; justify-content:flex-end; gap:3px; height:100%}
+.chart .col .bar{width:100%; border-radius:5px 5px 2px 2px; background:linear-gradient(180deg,var(--teal),var(--teal-dim)); transition:height .5s cubic-bezier(.2,.7,.2,1)}
+.chart .col .bar.up{background:linear-gradient(180deg,var(--coral),var(--coral-dim))}
+.chart .col .lab{font-size:10.5px; color:var(--faint); text-align:center; font-family:var(--font-mono)}
+.traffic-foot{display:flex; gap:20px; margin-top:14px; border-top:1px solid var(--line); padding-top:13px; flex-wrap:wrap}
+.traffic-foot .t{font-size:12px; color:var(--muted)}
+.traffic-foot .t b{font-family:var(--font-mono); color:var(--text); font-weight:700}
+.legend{display:inline-flex;gap:7px;align-items:center;font-size:11.5px;color:var(--faint);margin-left:6px}
+.legend i{width:9px;height:9px;border-radius:3px;display:inline-block}
+
+/* ============================================================
+ SERVICES CARD (.c-services — now full-width span 12)
+ Tiles use auto-fill / minmax(200px) so they don't render as 2 giant tiles
+ at full 12-column width. Still collapses to 2-up on narrow screens.
+ ============================================================ */
+.tiles{display:grid; grid-template-columns:repeat(auto-fill, minmax(200px, 1fr)); gap:11px; margin-top:18px}
+.tile{display:flex; flex-direction:column; gap:10px; padding:14px; border-radius:var(--r-sm); cursor:pointer;
+ background:var(--surface-3); border:1px solid var(--line); text-decoration:none; color:var(--text); transition:.16s}
+.tile:hover{transform:translateY(-2px); border-color:var(--line-2); background:var(--surface-2)}
+.tile .ti{width:34px;height:34px;border-radius:10px;display:grid;place-items:center; color:#fff}
+.tile .ti svg{width:18px;height:18px}
+.tile .tn{font-weight:600; font-size:13.5px}
+.tile .td{font-size:11.5px; color:var(--faint); font-family:var(--font-mono)}
+.tile.add{align-items:center; justify-content:center; border-style:dashed; color:var(--faint); background:transparent}
+
+/* ============================================================
+ FOOTER
+ (.foot .mock removed — was the ENTWURF/mockup badge only)
+ ============================================================ */
+.foot{margin-top:34px; display:flex; align-items:center; gap:14px; color:var(--faint); font-size:12px; flex-wrap:wrap}
+
+/* ============================================================
+ RESPONSIVE
+ ============================================================ */
+@media (max-width:880px){
+ .grid > .card{grid-column:span 12 !important}
+ .devpill .ip{display:none}
+ /* Keep service tiles 2-up on narrow screens (thumb-friendly) */
+ .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
+ ============================================================ */
+@media (prefers-reduced-motion: reduce){
+ *,*::before{transition:none !important; animation:none !important}
+ .card{opacity:1; transform:none}
+}
diff --git a/public/fonts/bricolage-grotesque-latin.woff2 b/public/fonts/bricolage-grotesque-latin.woff2
new file mode 100644
index 00000000..fcc4eb1e
Binary files /dev/null and b/public/fonts/bricolage-grotesque-latin.woff2 differ
diff --git a/public/fonts/jetbrains-mono-latin.woff2 b/public/fonts/jetbrains-mono-latin.woff2
new file mode 100644
index 00000000..4d09cda4
Binary files /dev/null and b/public/fonts/jetbrains-mono-latin.woff2 differ
diff --git a/public/js/portal.js b/public/js/portal.js
new file mode 100644
index 00000000..e50136d2
--- /dev/null
+++ b/public/js/portal.js
@@ -0,0 +1,354 @@
+// public/js/portal.js — GateControl VPN Landing Portal client
+// Vanilla JS, no framework. Served as a static asset.
+'use strict';
+(function () {
+
+ // ─── 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;
+
+ // ─── Client i18n map (injected by portal.njk) ──────────────────────────────
+ var PT = {};
+ try {
+ var _pt = document.getElementById('portal-i18n');
+ if (_pt) PT = JSON.parse(_pt.textContent || '{}');
+ } catch (_) {}
+
+ // ─── Theme toggle ───────────────────────────────────────────────────────────
+ (function initTheme() {
+ const html = document.documentElement;
+ const mq = window.matchMedia('(prefers-color-scheme: dark)');
+
+ function storedTheme() {
+ try { return localStorage.getItem('gc-portal-theme'); } catch (_) { return null; }
+ }
+
+ // Follow OS until the user has manually overridden
+ mq.addEventListener('change', function (e) {
+ if (!storedTheme()) {
+ html.setAttribute('data-theme', e.matches ? 'dark' : 'light');
+ }
+ });
+
+ const btn = document.getElementById('themeBtn');
+ if (btn) {
+ btn.addEventListener('click', function () {
+ const next = html.getAttribute('data-theme') === 'dark' ? 'light' : 'dark';
+ html.setAttribute('data-theme', next);
+ try { localStorage.setItem('gc-portal-theme', next); } catch (_) {}
+ });
+ }
+ })();
+
+ // ─── Formatters ─────────────────────────────────────────────────────────────────
+ const BYTE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB'];
+ function fmtBytes(bytes) {
+ if (!bytes || bytes <= 0) return '0 B';
+ let v = Number(bytes);
+ let i = 0;
+ while (v >= 1024 && i < BYTE_UNITS.length - 1) { v /= 1024; i++; }
+ const numStr = i === 0
+ ? String(Math.round(v))
+ : new Intl.NumberFormat(lang, { minimumFractionDigits: 1, maximumFractionDigits: 1 }).format(v);
+ return numStr + ' ' + BYTE_UNITS[i];
+ }
+
+ var _rtf = new Intl.RelativeTimeFormat(lang, { numeric: 'auto' });
+ var _rtfAlways = new Intl.RelativeTimeFormat(lang, { numeric: 'always' });
+ function fmtRelTime(ts) {
+ if (!ts) return '—';
+ const now = Date.now();
+ const t = typeof ts === 'number' ? ts * 1000 : new Date(ts).getTime();
+ const s = Math.floor((now - t) / 1000);
+ if (s < 5) return _rtf.format(0, 'second');
+ if (s < 60) return _rtfAlways.format(-s, 'second');
+ const m = Math.floor(s / 60);
+ if (s < 3600) return _rtfAlways.format(-m, 'minute');
+ const h = Math.floor(s / 3600);
+ if (s < 86400) return _rtfAlways.format(-h, 'hour');
+ const d = Math.floor(s / 86400);
+ return _rtfAlways.format(-d, 'day');
+ }
+
+ var _wdFmt = new Intl.DateTimeFormat(lang, { weekday: 'short', timeZone: 'UTC' });
+ function bucketLabel(isoStr, range, idx) {
+ if (range === '30d') return 'W' + (idx + 1);
+ const d = new Date(isoStr);
+ if (range === '24h') return String(d.getUTCHours()).padStart(2, '0');
+ return _wdFmt.format(d);
+ }
+
+ function escHtml(str) {
+ return String(str)
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"');
+ }
+
+ // ─── Shared state helpers ───────────────────────────────────────────────────
+
+ function setLoading(card, on) {
+ if (!card) return;
+ card.classList.toggle('loading', on);
+ }
+
+ function showFallback(el) {
+ if (!el) return;
+ // Use the generic/neutral message — fits both gateway-identified and
+ // unidentified contexts.
+ el.replaceChildren();
+ const p = document.createElement('p');
+ p.className = 'portal-fallback';
+ p.textContent = PT.fallbackUnknown || '';
+ el.appendChild(p);
+ }
+
+ function showError(card, retryFn) {
+ if (!card) return;
+ let el = card.querySelector('.portal-error-state');
+ if (!el) {
+ el = document.createElement('div');
+ el.className = 'portal-error-state';
+ card.appendChild(el);
+ }
+ const msgSpan = document.createElement('span');
+ msgSpan.className = 'portal-error-msg';
+ msgSpan.textContent = PT.unavailable || '';
+ const retryBtn = document.createElement('button');
+ retryBtn.className = 'portal-retry-btn';
+ retryBtn.textContent = PT.retry || '';
+ el.replaceChildren(msgSpan, retryBtn);
+ retryBtn.addEventListener('click', function () {
+ el.remove();
+ retryFn();
+ });
+ }
+
+ // ─── Device widget ──────────────────────────────────────────────────────────
+ function hydrateDevice() {
+ const card = document.querySelector('.c-device');
+ if (!card) return;
+ setLoading(card, true);
+
+ fetch('/api/v1/portal/device')
+ .then(function (r) {
+ if (!r.ok) throw new Error('HTTP ' + r.status);
+ return r.json();
+ })
+ .then(function (body) {
+ setLoading(card, false);
+ if (!body.ok || body.data === null) {
+ const kv = document.getElementById('deviceKv');
+ showFallback(kv);
+ const xfer = card.querySelector('.xfer');
+ if (xfer) xfer.style.display = 'none';
+ return;
+ }
+ const d = body.data;
+ const vpnIp = (d.allowed_ips || '').split('/')[0] || '';
+
+ // Topbar pill
+ const dot = document.getElementById('devDot');
+ const nameEl = document.getElementById('devName');
+ const ipEl = document.getElementById('devIp');
+ if (dot) dot.style.background = d.isOnline ? 'var(--green)' : 'var(--muted)';
+ if (nameEl) nameEl.textContent = d.name || '';
+ if (ipEl) ipEl.textContent = vpnIp;
+
+ // Status badge
+ const statusEl = document.getElementById('deviceStatus');
+ if (statusEl) {
+ statusEl.replaceChildren();
+ if (d.isOnline) {
+ const badge = document.createElement('span');
+ badge.className = 'badge-on';
+ const dot = document.createElement('span');
+ dot.className = 'dot';
+ dot.setAttribute('style', 'animation:none');
+ badge.appendChild(dot);
+ badge.appendChild(document.createTextNode(PT.online || 'Online'));
+ statusEl.appendChild(badge);
+ } else {
+ const offSpan = document.createElement('span');
+ offSpan.setAttribute('style', 'color:var(--muted)');
+ offSpan.textContent = PT.offline || 'Offline';
+ statusEl.appendChild(offSpan);
+ }
+ }
+
+ const hs = document.getElementById('deviceHandshake');
+ if (hs) hs.textContent = fmtRelTime(d.latestHandshake);
+
+ const addr = document.getElementById('deviceAddress');
+ if (addr) addr.textContent = vpnIp || '—';
+
+ const dns = document.getElementById('deviceDns');
+ if (dns) dns.textContent = d.dns || '—';
+
+ const rxEl = document.getElementById('deviceRx');
+ if (rxEl) rxEl.textContent = fmtBytes(d.transferRx);
+
+ const txEl = document.getElementById('deviceTx');
+ if (txEl) txEl.textContent = fmtBytes(d.transferTx);
+ })
+ .catch(function () {
+ setLoading(card, false);
+ showError(card, hydrateDevice);
+ });
+ }
+
+ // ─── Traffic widget ─────────────────────────────────────────────────────────
+ let trafficData = null;
+ let activeRange = '24h';
+
+ function renderChart(range) {
+ const chart = document.getElementById('chart');
+ const tTotal = document.getElementById('tTotal');
+ const tAvg = document.getElementById('tAvg');
+ const tPeak = document.getElementById('tPeak');
+ if (!chart || !trafficData) return;
+
+ const series = trafficData.series && trafficData.series[range];
+ if (!series || !series.length) return;
+
+ const rxValues = series.map(function (b) { return b.rx; });
+ const maxRx = Math.max.apply(null, rxValues.concat([1])); // avoid /0
+ const totalRx = rxValues.reduce(function (sum, v) { return sum + v; }, 0);
+ const peakRx = Math.max.apply(null, rxValues);
+
+ chart.innerHTML = series.map(function (b, i) {
+ const dh = Math.round(b.rx / maxRx * 116) + 6;
+ const uh = Math.round(b.tx / maxRx * 116) + 4;
+ const lab = escHtml(bucketLabel(b.t, range, i));
+ return '
' +
+ '
' +
+ '
' +
+ '
' + lab + '
' +
+ '
';
+ }).join('');
+
+ function applyHeights() {
+ chart.querySelectorAll('.bar').forEach(function (bar) {
+ bar.style.height = bar.dataset.h + 'px';
+ });
+ }
+ if (noMotion) {
+ applyHeights();
+ } else {
+ requestAnimationFrame(applyHeights);
+ }
+
+ if (tTotal) tTotal.textContent = fmtBytes(totalRx);
+ if (tAvg) tAvg.textContent = series.length ? fmtBytes(Math.round(totalRx / series.length)) : '—';
+ if (tPeak) tPeak.textContent = fmtBytes(peakRx);
+ }
+
+ function hydrateTraffic() {
+ const card = document.querySelector('.c-traffic');
+ if (!card) return;
+ setLoading(card, true);
+
+ fetch('/api/v1/portal/traffic')
+ .then(function (r) {
+ if (!r.ok) throw new Error('HTTP ' + r.status);
+ return r.json();
+ })
+ .then(function (body) {
+ setLoading(card, false);
+ if (!body.ok || body.data === null) {
+ const chart = document.getElementById('chart');
+ showFallback(chart);
+ return;
+ }
+ trafficData = body.data;
+
+ // Wire range selector and confirm initial active state
+ const seg = document.getElementById('seg');
+ if (seg) {
+ seg.querySelectorAll('button').forEach(function (b) {
+ b.classList.toggle('on', b.dataset.r === activeRange);
+ });
+ seg.addEventListener('click', function (e) {
+ const btn = e.target.closest('button');
+ if (!btn) return;
+ activeRange = btn.dataset.r;
+ seg.querySelectorAll('button').forEach(function (b) {
+ b.classList.toggle('on', b.dataset.r === activeRange);
+ });
+ renderChart(activeRange);
+ });
+ }
+
+ renderChart(activeRange);
+ })
+ .catch(function () {
+ setLoading(card, false);
+ showError(card, hydrateTraffic);
+ });
+ }
+
+ // ─── Services widget ────────────────────────────────────────────────────────
+ const TILE_COLORS = [
+ 'linear-gradient(145deg,#ffb27a,#e8763f)',
+ 'linear-gradient(145deg,#7ae0d2,#28b3a2)',
+ 'linear-gradient(145deg,#9db8ff,#5b76d6)',
+ 'linear-gradient(145deg,var(--teal),var(--teal-dim))',
+ ];
+ const TILE_ICON =
+ '';
+
+ function hydrateServices() {
+ const card = document.querySelector('.c-services');
+ const tilesEl = document.getElementById('servicesTiles');
+ if (!card || !tilesEl) return;
+ setLoading(card, true);
+
+ fetch('/api/v1/portal/services')
+ .then(function (r) {
+ if (!r.ok) throw new Error('HTTP ' + r.status);
+ return r.json();
+ })
+ .then(function (body) {
+ setLoading(card, false);
+ if (!body.ok || body.data === null) {
+ showFallback(tilesEl);
+ return;
+ }
+ const services = body.data;
+ if (!services.length) {
+ const emptyDiv = document.createElement('div');
+ emptyDiv.className = 'portal-empty';
+ emptyDiv.textContent = PT.noServices || '';
+ tilesEl.replaceChildren(emptyDiv);
+ return;
+ }
+ tilesEl.innerHTML = services.map(function (s, i) {
+ const color = TILE_COLORS[i % TILE_COLORS.length];
+ return '' +
+ '' + TILE_ICON + '' +
+ '' + escHtml(s.name) + '' +
+ '' + escHtml(s.domain) + '' +
+ '';
+ }).join('');
+ })
+ .catch(function () {
+ setLoading(card, false);
+ showError(card, hydrateServices);
+ });
+ }
+
+ // ─── Boot ───────────────────────────────────────────────────────────────────
+ hydrateDevice();
+ hydrateTraffic();
+ hydrateServices();
+
+})();
diff --git a/public/js/settings.js b/public/js/settings.js
index afec0058..8751b140 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['settings.portal.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/i18n/de.json b/src/i18n/de.json
index 42083344..3d7a6b5c 100644
--- a/src/i18n/de.json
+++ b/src/i18n/de.json
@@ -1924,5 +1924,44 @@
"pihole.cfg.url_required": "URL erforderlich",
"pihole.cfg.confirm_delete": "Diese Instanz löschen?",
"pihole.cfg.dns_not_reachable": "nicht erreichbar auf Port",
- "aurora.mode_toggle": "Hell/Dunkel umschalten"
+ "aurora.mode_toggle": "Hell/Dunkel umschalten",
+
+ "portal.title": "Portal",
+ "portal.welcome": "Willkommen",
+ "portal.greeting_home": "Willkommen zu Hause",
+ "portal.greeting_sub": "Dein VPN ist aktiv — hier siehst du alles auf einen Blick.",
+ "portal.login": "Anmelden",
+ "portal.theme_toggle": "Design umschalten",
+ "portal.devpill.title": "Dein Gerät",
+ "portal.device.title": "Gerät",
+ "portal.device.status": "Status",
+ "portal.device.last_handshake": "Letzter Handshake",
+ "portal.device.vpn_address": "VPN-Adresse",
+ "portal.device.dns": "DNS",
+ "portal.device.received": "Empfangen",
+ "portal.device.sent": "Gesendet",
+ "portal.device.online": "Online",
+ "portal.device.offline": "Offline",
+ "portal.traffic.title": "Traffic",
+ "portal.traffic.legend_rx": "Empfang",
+ "portal.traffic.legend_tx": "Senden",
+ "portal.traffic.range_24h": "24 Std.",
+ "portal.traffic.range_7d": "7 Tage",
+ "portal.traffic.range_30d": "30 Tage",
+ "portal.traffic.total": "Gesamt",
+ "portal.traffic.avg_day": "Ø/Tag",
+ "portal.traffic.peak": "Spitze",
+ "portal.services.title": "Dienste",
+ "portal.services.sub": "Für dich freigegeben",
+ "portal.services.none_shared": "Noch keine Dienste freigegeben",
+ "portal.fallback.unknown": "Gerätedaten nicht verfügbar.",
+ "portal.widget.unavailable": "Nicht verfügbar",
+ "portal.retry": "↺ Erneut versuchen",
+ "settings.portal.title": "Portal",
+ "settings.portal.enabled": "Portal aktivieren",
+ "settings.portal.widgets_section": "Widgets",
+ "settings.portal.widget_device": "Gerätestatus",
+ "settings.portal.widget_traffic": "Traffic-Diagramm",
+ "settings.portal.widget_services": "Dienste",
+ "settings.portal.saved": "Portal-Einstellungen gespeichert"
}
diff --git a/src/i18n/en.json b/src/i18n/en.json
index ef5dd0ce..8c902c5e 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -1924,5 +1924,44 @@
"pihole.cfg.url_required": "URL required",
"pihole.cfg.confirm_delete": "Delete this instance?",
"pihole.cfg.dns_not_reachable": "not reachable on port",
- "aurora.mode_toggle": "Toggle light/dark"
+ "aurora.mode_toggle": "Toggle light/dark",
+
+ "portal.title": "Portal",
+ "portal.welcome": "Welcome",
+ "portal.greeting_home": "Welcome Home",
+ "portal.greeting_sub": "Your VPN is active — here's everything at a glance.",
+ "portal.login": "Login",
+ "portal.theme_toggle": "Toggle theme",
+ "portal.devpill.title": "Your device",
+ "portal.device.title": "Device",
+ "portal.device.status": "Status",
+ "portal.device.last_handshake": "Last handshake",
+ "portal.device.vpn_address": "VPN address",
+ "portal.device.dns": "DNS",
+ "portal.device.received": "Received",
+ "portal.device.sent": "Sent",
+ "portal.device.online": "Online",
+ "portal.device.offline": "Offline",
+ "portal.traffic.title": "Traffic",
+ "portal.traffic.legend_rx": "Download",
+ "portal.traffic.legend_tx": "Upload",
+ "portal.traffic.range_24h": "24 h",
+ "portal.traffic.range_7d": "7 d",
+ "portal.traffic.range_30d": "30 d",
+ "portal.traffic.total": "Total",
+ "portal.traffic.avg_day": "Avg/day",
+ "portal.traffic.peak": "Peak",
+ "portal.services.title": "Services",
+ "portal.services.sub": "Shared with you",
+ "portal.services.none_shared": "No services shared yet",
+ "portal.fallback.unknown": "Device data unavailable.",
+ "portal.widget.unavailable": "Unavailable",
+ "portal.retry": "↺ Retry",
+ "settings.portal.title": "Portal",
+ "settings.portal.enabled": "Enable portal",
+ "settings.portal.widgets_section": "Widgets",
+ "settings.portal.widget_device": "Device status",
+ "settings.portal.widget_traffic": "Traffic chart",
+ "settings.portal.widget_services": "Services",
+ "settings.portal.saved": "Portal settings saved"
}
diff --git a/src/middleware/portalIdentity.js b/src/middleware/portalIdentity.js
new file mode 100644
index 00000000..7c616b05
--- /dev/null
+++ b/src/middleware/portalIdentity.js
@@ -0,0 +1,58 @@
+// 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';
+}
+
+/** 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 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) {
+ 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 && req.hostname === HOME_HOST) {
+ 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/src/routes/api/portal.js b/src/routes/api/portal.js
new file mode 100644
index 00000000..9aae5d53
--- /dev/null
+++ b/src/routes/api/portal.js
@@ -0,0 +1,133 @@
+// 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 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' });
+}
+
+// Convert JS Date to 'YYYY-MM-DD HH:MM:SS' (UTC, no ms) for comparison
+// with SQLite's datetime('now') output format.
+function toSQLite(date) {
+ return date.toISOString().replace('T', ' ').replace(/\.\d{3}Z$/, '');
+}
+
+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);
+ // 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: {
+ 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 (!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);
+ const db = getDb();
+ const peerId = Number(req.portalPeerId);
+
+ // Period totals (back-compat)
+ 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(peerId, interval);
+ traffic[key] = { rx: row.rx, tx: row.tx };
+ }
+
+ // Time-series buckets — stable counts regardless of data density.
+ // Shape: { t: ISO-string (bucket start), rx: number, tx: number }
+ // Buckets with no data → rx:0, tx:0 (axis stability).
+ //
+ // Prepare once; called 8 + 7 + 5 = 20 times — all parameterised.
+ const bucketStmt = 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 >= ? AND recorded_at < ?
+ `);
+ function buildSeries(startMs, count, widthMs) {
+ return Array.from({ length: count }, (_, i) => {
+ const s = new Date(startMs + i * widthMs);
+ const e = new Date(startMs + (i + 1) * widthMs);
+ const row = bucketStmt.get(peerId, toSQLite(s), toSQLite(e));
+ return { t: s.toISOString(), rx: row.rx, tx: row.tx };
+ });
+ }
+ const nowMs = Date.now();
+ const H = 3600000; // 1 h in ms
+ const D = 86400000; // 1 d in ms
+ traffic.series = {
+ '24h': buildSeries(nowMs - 24 * H, 8, 3 * H), // 8 x 3 h
+ '7d': buildSeries(nowMs - 7 * D, 7, D), // 7 x 1 d
+ '30d': buildSeries(nowMs - 30 * D, 5, 6 * D), // 5 x 6 d
+ };
+
+ 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 (!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 => {
+ 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/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/routes/index.js b/src/routes/index.js
index f019eef4..c787f2f5 100644
--- a/src/routes/index.js
+++ b/src/routes/index.js
@@ -283,6 +283,22 @@ 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'));
+
+// ─── 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/src/services/caddyConfig.js b/src/services/caddyConfig.js
index 90aef2a1..2c1b8ae1 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, [homeHost]);
if (tlsConfig) caddyConfig.apps.tls = tlsConfig;
// GateControl management UI route
@@ -683,12 +689,62 @@ 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'],
+ },
+ },
}],
}],
};
}
} 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.
+ 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/caddyTlsAutomation.js b/src/services/caddyTlsAutomation.js
index 1c97912e..ec75979b 100644
--- a/src/services/caddyTlsAutomation.js
+++ b/src/services/caddyTlsAutomation.js
@@ -26,15 +26,24 @@ function isPublicDomain(domain) {
return !NON_PUBLIC_TLDS.has(tld);
}
-function buildTlsAutomation(routeDomains, caddyConfig) {
+function buildTlsAutomation(routeDomains, caddyConfig, forceInternalDomains = []) {
if (!caddyConfig || !caddyConfig.email) return null;
// Listener-only entries like ":443" land in caddyRoutes for the
// server-block setup but are not domains — skip them so they don't
// become bogus issuer subjects.
const allDomains = routeDomains.filter(d => !/^:\d+$/.test(d));
- const publicDomains = allDomains.filter(isPublicDomain);
- const privateDomains = allDomains.filter(d => !isPublicDomain(d));
+ // forceInternalDomains overrides TLD classification: these are always
+ // treated as private/internal regardless of their public-looking TLD.
+ // Deduplicate to avoid double entries if a domain appears in both lists.
+ const forcedSet = new Set(forceInternalDomains.map(d => String(d).toLowerCase()));
+ const publicDomains = allDomains.filter(
+ d => isPublicDomain(d) && !forcedSet.has(String(d).toLowerCase()),
+ );
+ const privateDomains = [
+ ...allDomains.filter(d => !isPublicDomain(d)),
+ ...allDomains.filter(d => isPublicDomain(d) && forcedSet.has(String(d).toLowerCase())),
+ ].filter((d, i, arr) => arr.indexOf(d) === i); // dedupe
const policies = [];
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/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/layout.njk b/templates/aurora/layout.njk
index 0412bbeb..692c47ef 100644
--- a/templates/aurora/layout.njk
+++ b/templates/aurora/layout.njk
@@ -126,6 +126,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/templates/aurora/pages/settings.njk b/templates/aurora/pages/settings.njk
index 9196b9d6..91ac77ff 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 ────────────────────────────────────────── #}
+
+
+
+
+
+ {{ t('settings.portal.title') }}
+
+
+
+
{{ t('settings.portal.enabled') }}
+
+
+
{{ t('settings.portal.widgets_section') }}
+
+
{{ t('settings.portal.widget_device') }}
+
+
+
+
{{ t('settings.portal.widget_traffic') }}
+
+
+
+
{{ t('settings.portal.widget_services') }}
+
+
+
+
+
+
+
+
+
+
+
{% endblock %}
{% block scripts %}
diff --git a/templates/default/layout.njk b/templates/default/layout.njk
index bfc1e2b5..04acef44 100644
--- a/templates/default/layout.njk
+++ b/templates/default/layout.njk
@@ -119,6 +119,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/templates/default/pages/settings.njk b/templates/default/pages/settings.njk
index 96719449..054b0d3e 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 ────────────────────────────────────────── #}
+
+
+
+
+ {{ t('settings.portal.title') }}
+
+
+
+
{{ t('settings.portal.enabled') }}
+
+
+
{{ t('settings.portal.widgets_section') }}
+
+
{{ t('settings.portal.widget_device') }}
+
+
+
+
{{ t('settings.portal.widget_traffic') }}
+
+
+
+
{{ t('settings.portal.widget_services') }}
+
+
+
+
+
+
+
+
+
+
+
{% endblock %}
{% block scripts %}
diff --git a/templates/portal/portal.njk b/templates/portal/portal.njk
new file mode 100644
index 00000000..9bc93dff
--- /dev/null
+++ b/templates/portal/portal.njk
@@ -0,0 +1,120 @@
+
+
+
+
+
+{{ appName }} — {{ t('portal.title') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {% 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') }}
+
+
+
+
+
+
+
+
+
+ {% endif %}
+
+ {% if widgets.services %}
+
+
+ {{ t('portal.services.title') }}{{ t('portal.services.sub') }}
+
+
+ {% endif %}
+
+
+
+
+
+
+
+
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/templates/pro/pages/settings.njk b/templates/pro/pages/settings.njk
index 24ed045b..9a4a4069 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 ────────────────────────────────────────── #}
+
+
+
+
+
+ {{ t('settings.portal.title') }}
+
+
+
+
{{ t('settings.portal.enabled') }}
+
+
+
{{ t('settings.portal.widgets_section') }}
+
+
{{ t('settings.portal.widget_device') }}
+
+
+
+
{{ t('settings.portal.widget_traffic') }}
+
+
+
+
{{ t('settings.portal.widget_services') }}
+
+
+
+
+
+
+
+
+
+
+
{% endblock %}
{% block scripts %}
diff --git a/tests/portal_api.test.js b/tests/portal_api.test.js
new file mode 100644
index 00000000..9e1871c7
--- /dev/null
+++ b/tests/portal_api.test.js
@@ -0,0 +1,249 @@
+'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);
+
+// Home vhost host header (matches default GC_DNS_DOMAIN = 'gc.internal').
+const HOME_HOST = 'home.gc.internal';
+
+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.
+ // Host must match home. for identity to be established (anti-forgery gate).
+ const res = await supertest(app).get('/api/v1/portal/device')
+ .set('X-GC-Portal-Peer-IP', '10.8.0.5')
+ .set('Host', HOME_HOST)
+ .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('non-home Host with reserved header does NOT establish identity (mgmt-vhost forgery blocked)', async () => {
+ // Regression: attacker reaches the management-UI Caddy vhost (externally reachable)
+ // and supplies the reserved header — identity must be rejected because Host is wrong.
+ 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-GC-Portal-Peer-IP', '10.8.0.5')
+ .set('Host', 'admin.example.com')
+ .expect(200);
+ assert.equal(res.body.data, null, 'must be unidentified — mgmt-vhost Host rejected');
+ assert.equal(res.body.reason, 'unidentified');
+});
+
+test('portal API returns 404 when portal master switch is off', 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();
+ require('../src/services/settings').set('portal.enabled', '0');
+ await supertest(app).get('/api/v1/portal/device')
+ .set('X-GC-Portal-Peer-IP', '10.8.0.5')
+ .set('Host', HOME_HOST)
+ .expect(404);
+});
+
+test('portal API returns 404 for a disabled widget, 200 for an enabled one', 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();
+ require('../src/services/settings').set('portal.widget.traffic', '0');
+
+ // /traffic disabled → 404
+ await supertest(app).get('/api/v1/portal/traffic')
+ .set('X-GC-Portal-Peer-IP', '10.8.0.5')
+ .set('Host', HOME_HOST)
+ .expect(404);
+
+ // /device still enabled → 200
+ const res = await supertest(app).get('/api/v1/portal/device')
+ .set('X-GC-Portal-Peer-IP', '10.8.0.5')
+ .set('Host', HOME_HOST)
+ .expect(200);
+ assert.equal(res.body.ok, true);
+});
+
+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 (3 days back)
+ 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','-1 hour'))`
+ ).run(peerId);
+
+ const res = await supertest(app).get('/api/v1/portal/traffic')
+ .set('X-GC-Portal-Peer-IP', '10.8.0.6')
+ .set('Host', HOME_HOST)
+ .expect(200);
+
+ assert.equal(res.body.ok, true);
+ const d = res.body.data;
+
+ // ── Period totals (existing assertions) ──────────────────────────────────
+ // 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);
+
+ // ── Time-series assertions (new) ─────────────────────────────────────────
+ const s = d.series;
+ assert.ok(s, 'series field present');
+
+ // Shape: correct bucket counts
+ assert.ok(Array.isArray(s['24h']), 'series.24h is array');
+ assert.equal(s['24h'].length, 8, '24h has 8 buckets (8 x 3h)');
+ assert.equal(s['7d'].length, 7, '7d has 7 buckets (7 x 1d)');
+ assert.equal(s['30d'].length, 5, '30d has 5 buckets (5 x 6d)');
+
+ // Each bucket has {t, rx, tx}
+ const sample = s['24h'][0];
+ assert.equal(typeof sample.t, 'string', 'bucket.t is string');
+ assert.equal(typeof sample.rx, 'number', 'bucket.rx is number');
+ assert.equal(typeof sample.tx, 'number', 'bucket.tx is number');
+
+ // 24h: last bucket (index 7 = now-3h..now) must contain the -1h and -2h snapshots
+ assert.equal(s['24h'][7].rx, 300, '24h last bucket rx = 100+200');
+ assert.equal(s['24h'][7].tx, 130, '24h last bucket tx = 50+80');
+
+ // 24h: all other buckets are zero (no older data in 24h window)
+ const other24h = s['24h'].slice(0, 7);
+ assert.ok(other24h.every(b => b.rx === 0), '24h buckets 0-6 all rx=0');
+ assert.ok(other24h.every(b => b.tx === 0), '24h buckets 0-6 all tx=0');
+
+ // 7d: last bucket (index 6 = now-1d..now) contains the -1h and -2h snapshots
+ assert.equal(s['7d'][6].rx, 300, '7d last-day bucket rx');
+ assert.equal(s['7d'][6].tx, 130, '7d last-day bucket tx');
+
+ // 7d: the -3d-1h snapshot falls unambiguously in bucket 3 ([now-4d, now-3d)).
+ // Seeding with an extra -1 hour ensures it never straddles the now-3d boundary
+ // regardless of sub-second timing between insert and handler execution.
+ const bucket3dIdx = s['7d'].findIndex(b => b.rx === 400);
+ assert.equal(bucket3dIdx, 3, '7d: -3d-1h snapshot is in bucket 3 (now-4d..now-3d)');
+ assert.equal(s['7d'][bucket3dIdx].tx, 150, '7d -3d snapshot bucket tx');
+
+ // 30d: last bucket (index 4 = now-6d..now) contains all three snapshots
+ // (-1h, -2h, and -3d are all well within the last 6 days)
+ assert.equal(s['30d'][4].rx, 700, '30d last bucket rx = 100+200+400');
+ assert.equal(s['30d'][4].tx, 280, '30d last bucket tx = 50+80+150');
+
+ // 30d: first 4 buckets are zero (all data is within last 6 days)
+ const other30d = s['30d'].slice(0, 4);
+ assert.ok(other30d.every(b => b.rx === 0), '30d buckets 0-3 all rx=0');
+ assert.ok(other30d.every(b => b.tx === 0), '30d buckets 0-3 all tx=0');
+});
+
+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();
+
+ // 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')
+ .set('Host', HOME_HOST)
+ .expect(200);
+
+ assert.equal(res.body.ok, true);
+ const domains = res.body.data.map(s => s.domain);
+ const domainSet = new Set(domains);
+ assert.ok(domainSet.has('open.example.com'), 'open route visible');
+ assert.ok(domainSet.has('acl.example.com'), 'ACL route visible when peer is member');
+ assert.ok(!domainSet.has('hidden.example.com'), 'ACL route NOT visible when peer is not member');
+ assert.ok(!domainSet.has('disabled.example.com'), 'disabled route not visible');
+ assert.ok(!domainSet.has('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');
+ assert.equal(open.kind, 'http');
+ assert.ok(open.id);
+ assert.equal(open.name, 'Open App');
+});
diff --git a/tests/portal_css_smoke.test.js b/tests/portal_css_smoke.test.js
new file mode 100644
index 00000000..4cade1e1
--- /dev/null
+++ b/tests/portal_css_smoke.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, beforeEach, afterEach } = require('node:test');
+const assert = require('node:assert/strict');
+const supertest = require('supertest');
+const { setup, teardown } = require('./helpers/setup');
+
+let app;
+beforeEach(async () => {
+ await setup();
+ app = require('../src/app').createApp();
+});
+afterEach(teardown);
+
+test('GET /css/portal.css returns 200', async () => {
+ await supertest(app).get('/css/portal.css').expect(200);
+});
+
+test('portal.css contains dark and light theme token blocks', async () => {
+ const res = await supertest(app).get('/css/portal.css').expect(200);
+ assert.ok(res.text.includes('[data-theme="dark"]'), 'missing dark theme block');
+ assert.ok(res.text.includes('[data-theme="light"]'), 'missing light theme block');
+});
+
+test('portal.css does NOT contain Pi-hole/donut styles (correctly trimmed)', async () => {
+ const res = await supertest(app).get('/css/portal.css').expect(200);
+ assert.ok(!res.text.includes('.donut'), 'found .donut — Pi-hole styles not fully removed');
+ assert.ok(!res.text.includes('.pi-wrap'), 'found .pi-wrap — Pi-hole styles not fully removed');
+ assert.ok(!res.text.includes('.c-pihole'), 'found .c-pihole — Pi-hole grid span not removed');
+});
+
+test('portal.css contains JS-state rules (moved from portal.js inline injector for CSP safety)', async () => {
+ const res = await supertest(app).get('/css/portal.css').expect(200);
+ // Core rules that were previously injected as a