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 ────────────────────────────────────────── #} + + {% 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 ────────────────────────────────────────── #} + + {% 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') }} + + + + + + + + + +
+ + +
+
+ + {{ 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 %} + +
+ +
+ {{ appName }} +
+ +
+ + 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 ────────────────────────────────────────── #} + + {% 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