'use strict'; // Web UI for the WireGuard manager. Plain JavaScript, no build step. All // data comes from /api/v1. Text from the server is always inserted as text // nodes, never as HTML. (() => { const APP = 'GHOSTWIRE'; const app = document.getElementById('app'); let me = null; let main = null; let cleanups = []; // ---------- DOM helpers ---------- const PROPS = new Set(['value', 'checked', 'disabled', 'selected', 'hidden', 'readOnly', 'htmlFor', 'type', 'name', 'id', 'href', 'src', 'alt', 'download', 'placeholder', 'autocomplete', 'inputMode', 'min', 'max', 'rows', 'accept', 'required', 'title']); function h(tag, props, ...kids) { const el = document.createElement(tag); for (const [k, v] of Object.entries(props || {})) { if (v == null || v === false) continue; if (k === 'class') el.className = v; else if (k === 'style') Object.assign(el.style, v); else if (k.startsWith('on')) el.addEventListener(k.slice(2).toLowerCase(), v); else if (PROPS.has(k)) el[k] = v; else el.setAttribute(k, v === true ? '' : v); } add(el, kids); return el; } function add(el, kids) { for (const k of kids.flat(Infinity)) { if (k == null || k === false) continue; el.append(k instanceof Node ? k : String(k)); } return el; } // fill replaces the children of el; null and false parts are skipped // (replaceChildren would print them as text). function fill(el, ...kids) { el.replaceChildren(); return add(el, kids); } const ICONS = { dashboard: '', peers: '', server: '', settings: '', plus: '', }; // The Kamon logo: a crest ring around the ghost. Same drawing as favicon.svg. const LOGO = '' + '' + ''; function logo(size) { const s = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); for (const [k, v] of Object.entries({ width: size, height: size, viewBox: '0 0 64 64', 'aria-hidden': 'true' })) s.setAttribute(k, v); s.innerHTML = LOGO; return s; } // brand is the logo lockup: mark, wordmark and the katakana reading. // With href it is a link (the sidebar's goes to the start page). const brand = (size, href) => h(href ? 'a' : 'div', href ? { class: 'brand', href } : { class: 'brand' }, logo(size), h('div', null, h('div', { class: 'wm' }, APP), h('div', { class: 'kana', lang: 'ja' }, 'ゴーストワイヤー'))); // Icons are constant markup from ICONS, never data. function icon(name, size = 18, width = 1.7) { const s = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); for (const [k, v] of Object.entries({ width: size, height: size, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', 'stroke-width': width, 'stroke-linecap': 'round', 'aria-hidden': 'true' })) s.setAttribute(k, v); s.innerHTML = ICONS[name]; return s; } // ---------- formatting ---------- function fmtBytes(n) { if (n == null) return '–'; const u = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']; let i = 0, v = n; while (v >= 1000 && i < u.length - 1) { v /= 1000; i++; } const s = i === 0 ? String(v) : v < 10 ? v.toFixed(2) : v < 100 ? v.toFixed(1) : String(Math.round(v)); return s + ' ' + u[i]; } function ago(iso) { if (!iso) return 'never'; const s = Math.max(0, (Date.now() - Date.parse(iso)) / 1000); if (s < 60) return Math.round(s) + ' s ago'; if (s < 3600) return Math.round(s / 60) + ' min ago'; if (s < 86400) return Math.round(s / 3600) + ' h ago'; return Math.round(s / 86400) + ' d ago'; } const fmtDate = (iso) => iso ? new Date(iso).toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' }) : '–'; function fmtWhen(iso) { const d = new Date(iso), now = new Date(); if (d.toDateString() === now.toDateString()) return d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }); const y = new Date(now); y.setDate(now.getDate() - 1); if (d.toDateString() === y.toDateString()) return 'Yest.'; return d.toLocaleDateString(undefined, { day: 'numeric', month: 'short' }); } const list = (s) => s.split(',').map((x) => x.trim()).filter(Boolean); const sameList = (a, b) => JSON.stringify(a || null) === JSON.stringify(b || null); function peerState(p) { if (!p.enabled) return { key: 'disabled', label: 'Disabled', dot: 'dot bad' }; if (!p.publicKey) { if (p.setup && !p.setup.expired) return { key: 'setup', label: 'Waiting for setup', dot: 'dot warn' }; return { key: 'nokey', label: p.setup ? 'Setup link expired' : 'No config yet', dot: 'dot off' }; } if (p.stats.online) return { key: 'online', label: 'Online · ' + ago(p.stats.lastHandshake), dot: 'dot ok' }; if (p.stats.lastHandshake) return { key: 'offline', label: 'Offline · ' + ago(p.stats.lastHandshake), dot: 'dot' }; return { key: 'never', label: 'Never connected', dot: 'dot off' }; } // fmtLogLine turns a JSON log record into "2026-10-03 13:46:25 INFO login actor=admin ...". function fmtLogLine(l) { const t = new Date(l.time); const pad = (n) => String(n).padStart(2, '0'); const ts = t.getFullYear() + '-' + pad(t.getMonth() + 1) + '-' + pad(t.getDate()) + ' ' + pad(t.getHours()) + ':' + pad(t.getMinutes()) + ':' + pad(t.getSeconds()); const rest = Object.entries(l).filter(([k]) => !['time', 'level', 'msg', 'audit'].includes(k)) .map(([k, v]) => k + '=' + (typeof v === 'string' ? v : JSON.stringify(v))).join(' '); return ts + ' ' + String(l.level).padEnd(5) + ' ' + l.msg + (rest ? ' ' + rest : ''); } // "Germany · Deutsche Telekom AG", "Local network" or "". function fmtLocation(g) { if (!g) return ''; return [g.countryName || g.country, g.network].filter(Boolean).join(' · '); } function fmtDuration(sec) { if (sec < 60) return 'under 1 min'; const m = Math.round(sec / 60); if (m < 60) return m + ' min'; const h = Math.floor(m / 60); if (h < 48) return h + ' h ' + (m % 60) + ' min'; return Math.round(h / 24) + ' days'; } const fmtStamp = (iso) => new Date(iso).toLocaleString(undefined, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }); const badge = (st) => h('span', { class: 'badge' }, h('span', { class: st.dot }), st.label); // svg builds an SVG element; attrs are set as attributes. function svg(tag, attrs, ...kids) { const el = document.createElementNS('http://www.w3.org/2000/svg', tag); for (const [k, v] of Object.entries(attrs || {})) el.setAttribute(k, v); add(el, kids); return el; } const fmtMs = (ms) => (ms < 10 ? ms.toFixed(1) : String(Math.round(ms))) + ' ms'; // A latency value is stale when the server stopped pinging, e.g. because // the device went idle; it is then shown greyed out. const latStale = (l) => Date.now() - Date.parse(l.at) > 3 * 30000; // latState describes a peer's latency for the lists: null when there is // nothing to show. function latState(p) { if (!p.enabled || !p.publicKey) return null; if (p.latencyCheck === 'off') return { note: 'Check off', title: 'The latency check is off for this peer' }; const l = p.stats.latency; const idle = p.latencyCheck === 'active' ? 'Pinged only while the device sends traffic' : 'Not measured yet'; if (!l) return { note: '–', title: idle }; const stale = latStale(l); const when = stale ? ' · measured ' + ago(l.at) : ''; if (l.ms == null) return { note: 'No ping reply', stale, title: 'The device does not answer ping. Windows blocks it in its firewall by default.' + when }; return { ms: l.ms, spark: l.spark, stale, title: 'Median of the last 5 minutes · ' + fmtMs(l.min) + '–' + fmtMs(l.max) + ' · ' + l.loss + ' % loss' + when }; } // sparkline draws the medians of the last hour; gaps are skipped. function sparkline(vals) { const pts = (vals || []).map((v, i) => [i, v]).filter(([, v]) => v != null); const s = svg('svg', { class: 'spark', viewBox: '0 0 56 18', 'aria-hidden': 'true' }); if (pts.length < 2) return s; const lo = Math.min(...pts.map(([, v]) => v)), hi = Math.max(...pts.map(([, v]) => v)); const span = Math.max(hi - lo, hi * 0.2, 1); const xy = ([i, v]) => [(i / (vals.length - 1) * 54 + 1).toFixed(1), (16 - (v - lo) / span * 14).toFixed(1)]; const [ex, ey] = xy(pts[pts.length - 1]); s.append(svg('polyline', { points: pts.map((p) => xy(p).join(',')).join(' ') }), svg('circle', { cx: ex, cy: ey, r: 2 })); return s; } function latCell(p) { const st = latState(p); if (!st) return h('td', { class: 'num muted' }, '–'); if (st.ms == null) return h('td', { class: 'num' }, h('span', { class: st.stale ? 'latnote stale' : 'latnote', title: st.title }, st.note)); return h('td', { class: 'num' }, h('span', { class: st.stale ? 'lat stale' : 'lat', title: st.title }, sparkline(st.spark), h('span', { class: 'mono' }, fmtMs(st.ms)))); } // ---------- API ---------- async function api(method, path, body) { const opt = { method, credentials: 'same-origin', headers: {} }; if (body !== undefined) { opt.headers['Content-Type'] = 'application/json'; opt.body = JSON.stringify(body); } const r = await fetch('/api/v1' + path, opt); let data = {}; try { data = await r.json(); } catch { /* empty body */ } if (r.status === 401 && path !== '/auth/login' && path !== '/auth/me') { me = null; showLogin(); throw new Error('Signed out'); } if (!r.ok) throw new Error(data.error || r.statusText); return data; } // ---------- toasts & dialogs ---------- function toast(msg, err) { const t = h('div', { class: err ? 'toast err' : 'toast' }, msg); document.getElementById('toasts').append(t); setTimeout(() => t.remove(), err ? 8000 : 4000); } // applied reports a kernel error that happened after a successful save. function applied(res, okMsg) { if (res && res.applyError) toast('Saved, but applying to WireGuard failed: ' + res.applyError, true); else if (okMsg) toast(okMsg); } function dialog(build) { const d = h('dialog'); const close = () => d.close(); d.addEventListener('close', () => d.remove()); d.append(build(close)); document.body.append(d); d.showModal(); return d; } function confirmDialog({ title, text, ok = 'Confirm', danger = false }) { return new Promise((resolve) => { let result = false; const d = dialog((close) => h('div', { class: 'dlg' }, h('h2', null, title), h('p', null, text), h('div', { class: 'foot' }, h('button', { type: 'button', class: 'btn', onClick: close }, 'Cancel'), h('button', { type: 'button', class: danger ? 'btn danger' : 'btn primary', onClick: () => { result = true; close(); } }, ok)))); d.addEventListener('close', () => resolve(result)); }); } function download(name, text, type = 'text/plain') { const url = URL.createObjectURL(new Blob([text], { type })); const a = h('a', { href: url, download: name }); document.body.append(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 1000); } async function copy(text) { try { await navigator.clipboard.writeText(text); toast('Copied'); } catch { toast('Copy failed: your browser blocked clipboard access', true); } } // configDialog shows a freshly issued client config. It is the only time // the private key exists anywhere. function configDialog(res, onClose) { const name = res.peer.name; const d = dialog((close) => h('div', { class: 'dlg' }, h('h2', null, 'Config for ' + name), res.includesPrivateKey ? h('div', { class: 'notice' }, 'This is the only time the private key is shown. Scan or download it now: it is not stored on the server.') : h('div', { class: 'notice' }, 'The device keeps its own private key. Put it into the PrivateKey line.'), h('div', { class: 'qrrow' }, res.qr ? h('img', { class: 'qr', src: res.qr, alt: 'QR code of the client config for ' + name }) : null, h('div', { class: 'col' }, h('button', { type: 'button', class: 'btn', onClick: () => download(name + '.conf', res.config) }, 'Download ' + name + '.conf'), h('button', { type: 'button', class: 'btn', onClick: () => copy(res.config) }, 'Copy config'))), h('pre', { class: 'code' }, res.config), h('div', { class: 'foot' }, h('button', { type: 'button', class: 'btn primary', onClick: close }, 'Done')))); d.addEventListener('close', () => { applied(res); if (onClose) onClose(); }); } // linkDialog shows a setup link with its PIN, to send to the device's owner. function linkDialog(name, setup, onClose) { const share = navigator.share ? h('button', { type: 'button', class: 'btn', onClick: () => navigator.share({ title: 'VPN setup for ' + name, url: setup.url }).catch(() => {}) }, 'Share…') : null; const d = dialog((close) => h('div', { class: 'dlg' }, h('h2', null, 'Setup link for ' + name), h('div', { class: 'notice' }, setup.pin ? 'Anyone with this link and the PIN can set up this peer once. Send the PIN separately, e.g. by phone or another messenger.' : 'Anyone with this link can set up this peer once. Send it only to the device\'s owner.'), h('div', { class: 'field' }, h('label', { htmlFor: 'sl' }, 'Link'), h('div', { class: 'row' }, h('input', { id: 'sl', class: 'mono', value: setup.url, readOnly: true, onFocus: (e) => e.target.select() }), h('button', { type: 'button', class: 'btn primary', onClick: () => copy(setup.url) }, 'Copy link'), share)), h('div', { class: 'qrrow' }, h('img', { class: 'qr small', src: setup.qr, alt: 'QR code of the setup link for ' + name }), h('dl', { class: 'kv grow' }, setup.pin ? [h('dt', null, 'PIN'), h('dd', { class: 'pinrow' }, h('span', { class: 'pinval' }, setup.pin), h('button', { type: 'button', class: 'btn small', onClick: () => copy(setup.pin) }, 'Copy'))] : null, h('dt', null, 'Valid until'), h('dd', null, fmtStamp(setup.expires)), h('dt', null, 'Uses'), h('dd', null, 'Once. Then the link stops working.'))), h('p', { class: 'hint' }, 'The QR code holds only the link, not the config. Until the link is used, you can copy it again or revoke it on the peer\'s page.'), h('div', { class: 'foot' }, h('button', { type: 'button', class: 'btn primary', onClick: close }, 'Done')))); d.addEventListener('close', () => { if (onClose) onClose(); }); } // handover is the "Show it here" / "Send a setup link" choice used when a // config is created or issued again. function handover({ showHint, linkHint, onChange }) { let mode = 'show'; const hours = h('select', { id: 'lh' }, [[1, '1 hour'], [24, '24 hours'], [168, '7 days']].map(([v, t]) => h('option', { value: String(v), selected: v === 24 }, t))); const pin = h('input', { type: 'checkbox', checked: true }); const more = h('div', { class: 'linkopts', hidden: true }, h('div', { class: 'field' }, h('label', { htmlFor: 'lh' }, 'Link valid for'), hours), h('label', { class: 'check' }, pin, h('span', null, 'Require a PIN', h('br'), h('span', { class: 'hint' }, 'Send it by another channel than the link')))); const opt = (v, title, hint) => h('label', { class: 'opt' }, h('input', { type: 'radio', name: 'handover', value: v, checked: v === mode, onChange: () => { mode = v; more.hidden = v !== 'link'; if (onChange) onChange(); } }), h('span', null, h('strong', null, title), h('br'), h('span', { class: 'hint' }, hint))); return { el: h('fieldset', null, h('legend', { class: 'legend' }, 'Hand over the config'), opt('show', 'Show it here', showHint), opt('link', 'Send a setup link', linkHint), more), link: () => mode === 'link', body: () => mode === 'link' ? { delivery: 'link', linkHours: Number(hours.value), linkPIN: pin.checked } : {}, }; } // ---------- chart ---------- function niceTop(max) { if (max <= 0) return 1e6; const e = Math.pow(10, Math.floor(Math.log10(max))); for (const m of [1, 2, 5, 10]) if (m * e >= max) return m * e; return 10 * e; } function pointLabel(t, range) { const d = new Date(t * 1000); if (range === '24h') { const hrs = Math.round((Date.now() - d.getTime()) / 3600000); return hrs <= 0 ? 'This hour' : hrs + ' h ago'; } return d.toLocaleDateString(undefined, { weekday: 'short', day: 'numeric', month: 'short' }); } // chart draws bars for each point: two per point (download, upload) in // "pair" mode, one stacked total in "total" mode. Hover or focus shows the // values in the readout line above. function chart(points, range, mode, small) { const H = small ? 178 : 198; const vals = points.map((p) => mode === 'pair' ? Math.max(p.down, p.up) : p.down + p.up); const top = niceTop(Math.max(0, ...vals)); const px = (v) => v > 0 ? Math.max(2, Math.round(v / top * H)) + 'px' : '0px'; let peakIdx = vals.indexOf(Math.max(...vals)); const describe = (i) => { const p = points[i]; return mode === 'pair' ? [pointLabel(p.t, range), ' · Download ', h('strong', null, fmtBytes(p.down)), ' · Upload ', h('strong', null, fmtBytes(p.up))] : [h('strong', null, fmtBytes(p.down + p.up)), ' · ', pointLabel(p.t, range)]; }; const idle = () => mode === 'pair' || vals[peakIdx] === 0 ? ['Hover or focus a bar to see its values.'] : [h('strong', null, fmtBytes(vals[peakIdx])), ' · peak, ', pointLabel(points[peakIdx].t, range)]; const readout = h('div', { class: 'readout' }, idle()); const groups = points.map((p, i) => { const spans = mode === 'pair' ? [h('span', { class: 'down', style: { height: px(p.down) } }), h('span', { class: 'up', style: { height: px(p.up) } })] : [h('span', { class: 'total', style: { height: px(p.down + p.up) } })]; const label = pointLabel(p.t, range) + ': download ' + fmtBytes(p.down) + ', upload ' + fmtBytes(p.up); const show = () => { groups.forEach((g) => g.classList.remove('on')); g.classList.add('on'); readout.replaceChildren(...describe(i)); }; const g = h('button', { type: 'button', class: 'grp', 'aria-label': label, onMouseenter: show, onFocus: show }, spans); return g; }); const reset = () => { groups.forEach((g) => g.classList.remove('on')); readout.replaceChildren(...idle()); }; const bars = h('div', { class: 'bars', onMouseleave: reset, onFocusout: reset }, groups); return h('div', null, readout, h('div', { class: small ? 'chart small' : 'chart' }, h('div', { class: 'gl top' }), h('div', { class: 'gl mid' }), h('div', { class: 'gl base' }), h('div', { class: 'yl top' }, fmtBytes(top)), h('div', { class: 'yl mid' }, fmtBytes(top / 2)), bars), h('div', { class: 'xaxis' }, h('span', null, pointLabel(points[0].t, range)), h('span', null, range === '24h' ? 'now' : pointLabel(points[points.length - 1].t, range)))); } // latencyChart draws the median as a line over a min–max band, one point // per 5 minutes; steps without replies leave a gap. Hover shows the values. function latencyChart(points) { const ok = (p) => p.sent > p.lost; const peak = Math.max(0, ...points.filter(ok).map((p) => p.max)); const top = peak > 0 ? niceTop(peak) : 100; const n = points.length, W = 1000, H = 100; const x = (i) => (i + 0.5) / n * W, y = (v) => (H - v / top * H).toFixed(2); const segs = []; points.forEach((p, i) => { if (!ok(p)) return; const last = segs[segs.length - 1]; if (last && last[last.length - 1] === i - 1) last.push(i); else segs.push([i]); }); const half = W / n * 0.4; const shapes = segs.flatMap((seg) => { // A lone point gets a short flat stretch so it stays visible. const xs = seg.length > 1 ? seg.map(x) : [x(seg[0]) - half, x(seg[0]) + half]; const at = (k) => points[seg[Math.min(k, seg.length - 1)]]; const upper = xs.map((xv, k) => xv.toFixed(1) + ',' + y(at(k).max)); const lower = xs.map((xv, k) => xv.toFixed(1) + ',' + y(at(k).min)).reverse(); return [ svg('polygon', { class: 'band', points: upper.concat(lower).join(' ') }), svg('polyline', { class: 'med', points: xs.map((xv, k) => xv.toFixed(1) + ',' + y(at(k).med)).join(' ') }), ]; }); const cursor = svg('line', { class: 'cursor', x1: 0, x2: 0, y1: 0, y2: H, visibility: 'hidden' }); const plot = svg('svg', { viewBox: '0 0 ' + W + ' ' + H, preserveAspectRatio: 'none', 'aria-hidden': 'true' }, shapes, cursor); const label = (p) => { const d = new Date(p.t * 1000); return d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }); }; const withData = points.filter(ok); const idle = () => withData.length ? ['Median over 24 hours ', h('strong', null, fmtMs(withData.map((p) => p.med).sort((a, b) => a - b)[withData.length >> 1])), ' · hover the chart to see a time.'] : ['No measurements in the last 24 hours.']; const readout = h('div', { class: 'readout' }, idle()); const wrapEl = h('div', { class: 'plot', role: 'img', 'aria-label': 'Latency over the last 24 hours', onMousemove: (e) => { const r = wrapEl.getBoundingClientRect(); const i = Math.max(0, Math.min(n - 1, Math.floor((e.clientX - r.left) / r.width * n))); const p = points[i]; cursor.setAttribute('x1', x(i)); cursor.setAttribute('x2', x(i)); cursor.setAttribute('visibility', 'visible'); readout.replaceChildren(...(ok(p) ? [label(p), ' · median ', h('strong', null, fmtMs(p.med)), ' · ' + fmtMs(p.min) + '–' + fmtMs(p.max) + ' · ' + Math.round(p.lost * 100 / p.sent) + ' % loss'] : [label(p), p.sent ? ' · no reply' : ' · not measured'])); }, onMouseleave: () => { cursor.setAttribute('visibility', 'hidden'); readout.replaceChildren(...idle()); } }, plot); return h('div', null, readout, h('div', { class: 'chart small' }, h('div', { class: 'gl top' }), h('div', { class: 'gl mid' }), h('div', { class: 'gl base' }), h('div', { class: 'yl top' }, fmtMs(top)), h('div', { class: 'yl mid' }, fmtMs(top / 2)), wrapEl), h('div', { class: 'xaxis' }, h('span', null, '24 h ago'), h('span', null, '12 h ago'), h('span', null, 'now'))); } // ---------- shell, router ---------- const NAV = [['#/', 'dashboard', 'Dashboard'], ['#/peers', 'peers', 'Peers'], ['#/server', 'server', 'Server'], ['#/settings', 'settings', 'Settings']]; let navLinks = {}; let srvBox, peerCount; function buildShell() { srvBox = h('div', { class: 'srv' }, h('span', { class: 'dot' }), h('span', null, 'Loading…')); peerCount = h('span', { class: 'count' }); navLinks = {}; const nav = h('nav', { class: 'side', 'aria-label': 'Main' }, brand(34, '#/'), srvBox, NAV.map(([href, ic, label]) => (navLinks[href] = h('a', { class: 'nav', href }, icon(ic), label, ic === 'peers' ? peerCount : null))), h('div', { class: 'foot' }, 'Signed in as ', me.name, ' · ', h('button', { type: 'button', onClick: logout }, 'Sign out'), h('br'), 'v' + me.version)); main = h('main', { class: 'main', id: 'main' }); app.replaceChildren(h('div', { class: 'shell' }, nav, main)); refreshSide(); } async function refreshSide() { try { const s = await api('GET', '/status'); const up = s.checks.find((c) => c.name === 'WireGuard interface'); const ok = up && up.ok; srvBox.replaceChildren( h('span', { class: s.healthy ? 'dot ok' : 'dot bad' }), h('span', null, h('strong', null, s.interface + (ok ? ' up' : ' down')), ' · ' + s.listenPort + '/udp', s.healthy ? '' : ' · check health')); peerCount.textContent = s.peers.total; } catch { /* shown on next refresh */ } } async function logout() { try { await api('POST', '/auth/logout'); } catch { /* ignore */ } me = null; showLogin(); } function every(ms, fn) { const id = setInterval(() => { if (!document.hidden) fn(); }, ms); cleanups.push(() => clearInterval(id)); } const ROUTES = [ [/^#\/?$/, '#/', viewDashboard], [/^#\/peers$/, '#/peers', viewPeers], [/^#\/peers\/new$/, '#/peers', viewPeerNew], [/^#\/peers\/([\w-]+)$/, '#/peers', viewPeer], [/^#\/server$/, '#/server', viewServer], [/^#\/settings$/, '#/settings', viewSettings], ]; async function render() { cleanups.forEach((f) => f()); cleanups = []; if (!me) { try { me = await api('GET', '/auth/me'); } catch { showLogin(); return; } } if (!main || !main.isConnected) buildShell(); every(30000, refreshSide); const hash = location.hash || '#/'; const route = ROUTES.find(([re]) => re.test(hash)) || ROUTES[0]; for (const [href, a] of Object.entries(navLinks)) { a.classList.toggle('on', href === route[1]); if (href === route[1]) a.setAttribute('aria-current', 'page'); else a.removeAttribute('aria-current'); } const args = hash.match(route[0]).slice(1); const wrap = h('div', { class: 'wrap' }, h('p', { class: 'muted' }, 'Loading…')); main.replaceChildren(wrap); window.scrollTo(0, 0); try { await route[2](wrap, ...args); } catch (e) { if (e.message !== 'Signed out') fill(wrap, h('div', { class: 'notice err' }, e.message)); } } window.addEventListener('hashchange', render); // ---------- login ---------- function showLogin() { cleanups.forEach((f) => f()); cleanups = []; main = null; const err = h('p', { class: 'err-text', role: 'alert' }); const user = h('input', { id: 'u', autocomplete: 'username', value: 'admin', required: true }); const pw = h('input', { id: 'p', type: 'password', autocomplete: 'current-password', required: true }); // On hover the label turns into its Japanese reading; screen readers keep "Sign in". const btn = h('button', { type: 'submit', class: 'btn primary signin' }, h('span', { class: 'en' }, 'Sign in'), h('span', { class: 'ja', lang: 'ja', 'aria-hidden': 'true' }, 'サインイン')); const form = h('form', { class: 'loginform', onSubmit: async (e) => { e.preventDefault(); err.textContent = ''; btn.disabled = true; try { await api('POST', '/auth/login', { username: user.value, password: pw.value }); me = await api('GET', '/auth/me'); render(); } catch (x) { err.textContent = x.message; btn.disabled = false; pw.select(); } } }, h('div', { class: 'field' }, h('label', { htmlFor: 'u' }, 'Username'), user), h('div', { class: 'field' }, h('label', { htmlFor: 'p' }, 'Password'), pw), err, btn); app.replaceChildren(h('div', { class: 'loginpage' }, h('div', { class: 'loginbox' }, brand(72), form), h('p', { class: 'loginfoot' }, 'WireGuard server manager'))); pw.focus(); } // ---------- dashboard ---------- async function viewDashboard(wrap) { const draw = async () => { const [st, pl, stats, logs] = await Promise.all([ api('GET', '/status'), api('GET', '/peers'), api('GET', '/stats?range=24h'), me.isAdmin ? api('GET', '/logs?audit=1&limit=6').catch(() => null) : null, ]); const peers = pl.peers; const failing = st.checks.filter((c) => !c.ok); const ifCheck = st.checks.find((c) => c.name === 'WireGuard interface'); const top = [...peers].sort((a, b) => (b.stats.down24h + b.stats.up24h) - (a.stats.down24h + a.stats.up24h)).slice(0, 6); fill(wrap, h('div', { class: 'head' }, h('div', null, h('h1', null, 'Dashboard'), h('p', { class: 'sub' }, 'Endpoint ', h('span', { class: 'mono' }, st.endpoint), ' · network ', h('span', { class: 'mono' }, st.ipv4))), h('div', { class: 'actions' }, h('a', { class: 'btn', href: '#/server' }, 'Server config'), h('a', { class: 'btn primary', href: '#/peers/new' }, icon('plus', 16, 2), 'Add peer'))), failing.length ? h('div', { class: 'notice err', role: 'alert' }, h('div', null, h('strong', null, 'Needs attention: '), failing.map((c) => c.name + ' (' + c.detail + ')').join(' · ')), h('a', { class: 'btn small', href: '#/server' }, 'Health')) : null, h('div', { class: 'tiles' }, h('div', { class: 'card tile' }, h('div', { class: 'k' }, 'Peers online'), h('div', { class: 'v' }, String(st.peers.online), h('small', null, '/ ' + st.peers.total)), h('div', { class: 's' }, st.peers.disabled + ' disabled · ' + st.peers.never + ' never connected')), h('div', { class: 'card tile' }, h('div', { class: 'k' }, 'Traffic, last 24 h'), h('div', { class: 'v' }, fmtBytes(st.traffic24h.down + st.traffic24h.up)), h('div', { class: 's' }, 'Download ' + fmtBytes(st.traffic24h.down) + ' · Upload ' + fmtBytes(st.traffic24h.up))), h('div', { class: 'card tile' }, h('div', { class: 'k' }, 'Traffic, last 30 days'), h('div', { class: 'v' }, fmtBytes(st.traffic30d.down + st.traffic30d.up)), h('div', { class: 's' }, st.topPeer30d ? 'Top peer: ' + st.topPeer30d : 'No traffic yet')), h('div', { class: 'card tile' }, h('div', { class: 'k' }, 'Interface'), h('div', { class: 'v' }, h('span', { class: ifCheck && ifCheck.ok ? 'dot ok big' : 'dot bad big' }), ifCheck && ifCheck.ok ? 'Up' : 'Down'), h('div', { class: 's' }, 'Service up ' + ago(st.started).replace(' ago', '') + ' · ' + (st.healthy ? 'all checks pass' : failing.length + ' check(s) failing')))), h('section', { class: 'card', 'aria-labelledby': 'tput' }, h('div', { class: 'cardhead' }, h('h2', { id: 'tput' }, 'Traffic, all peers · last 24 hours')), chart(stats.points, '24h', 'total', true)), h('div', { class: 'cols' }, h('section', { class: 'card flush' }, h('div', { class: 'cardhead' }, h('h2', null, 'Peers'), h('a', { href: '#/peers' }, 'All peers')), top.length ? h('div', { class: 'tbl' }, h('table', { class: 'narrow' }, h('thead', null, h('tr', null, h('th', null, 'Name'), h('th', null, 'Status'), h('th', { class: 'num' }, 'Download, 24 h'), h('th', { class: 'num' }, 'Upload, 24 h'))), h('tbody', null, top.map((p) => h('tr', null, h('td', null, h('a', { href: '#/peers/' + p.id }, p.name)), h('td', null, badge(peerState(p))), h('td', { class: 'num' }, fmtBytes(p.stats.down24h)), h('td', { class: 'num' }, fmtBytes(p.stats.up24h))))))) : h('p', { class: 'empty' }, 'No peers yet. ', h('a', { href: '#/peers/new' }, 'Add the first one'))), logs ? h('section', { class: 'card' }, h('div', { class: 'cardhead' }, h('h2', null, 'Recent activity'), h('a', { href: '#/settings' }, 'Log')), logs.lines.length ? h('div', null, logs.lines.map((l) => h('div', { class: 'ev' }, h('time', { datetime: l.time }, fmtWhen(l.time)), h('span', null, describeAudit(l))))) : h('p', { class: 'empty' }, 'No changes yet.')) : null)); }; await draw(); every(30000, () => draw().catch(() => {})); } function describeAudit(l) { const parts = [l.msg.charAt(0).toUpperCase() + l.msg.slice(1)]; if (l.peer) parts.push(': ' + l.peer); if (l.token) parts.push(': ' + l.token); if (l.fields && l.fields.length) parts.push(' (' + l.fields.join(', ') + ')'); parts.push(' · ' + (l.actor || '')); return parts.join(''); } // ---------- peers ---------- async function viewPeers(wrap) { let q = '', filter = 'all', data = await api('GET', '/peers'); const tbody = h('tbody'); const empty = h('p', { class: 'empty', hidden: true }, 'No peers match this filter.'); const sub = h('p', { class: 'sub' }); const pills = h('div', { class: 'pills', role: 'group', 'aria-label': 'Status filter' }); const drawPills = () => pills.replaceChildren(...[['all', 'All'], ['online', 'Online'], ['offline', 'Offline'], ['disabled', 'Disabled']].map(([k, label]) => h('button', { type: 'button', class: filter === k ? 'pill on' : 'pill', 'aria-pressed': String(filter === k), onClick: () => { filter = k; drawPills(); drawRows(); } }, label))); const toggle = async (p, on) => { try { const res = await api('POST', '/peers/' + p.id + (on ? '/enable' : '/disable')); applied(res, (on ? 'Enabled ' : 'Disabled ') + p.name); data = await api('GET', '/peers'); drawRows(); } catch (e) { toast(e.message, true); drawRows(); } }; const drawRows = () => { sub.replaceChildren(data.peers.length + ' of ' + data.capacity + ' addresses in ', h('span', { class: 'mono' }, data.network), ' used'); const rows = data.peers.filter((p) => { const st = peerState(p).key; const hit = !q || (p.name + ' ' + p.ipv4 + ' ' + p.note).toLowerCase().includes(q); const keep = filter === 'all' || filter === st || (filter === 'offline' && ['offline', 'never', 'setup', 'nokey'].includes(st)); return hit && keep; }); tbody.replaceChildren(...rows.map((p) => h('tr', null, h('td', null, h('a', { href: '#/peers/' + p.id }, h('strong', null, p.name)), p.note ? h('div', { class: 'note' }, p.note) : null), h('td', { class: 'mono' }, p.ipv4), h('td', null, badge(peerState(p))), h('td', { class: 'mono muted' }, p.stats.endpoint || '–', p.stats.location && p.stats.location.country ? h('span', { class: 'cc', title: fmtLocation(p.stats.location) }, p.stats.location.country) : null), latCell(p), h('td', { class: 'num' }, fmtBytes(p.stats.down30d)), h('td', { class: 'num' }, fmtBytes(p.stats.up30d)), h('td', null, h('input', { type: 'checkbox', class: 'sw', checked: p.enabled, 'aria-label': (p.enabled ? 'Disable ' : 'Enable ') + p.name, onChange: (e) => toggle(p, e.target.checked) })), h('td', { class: 'num' }, h('a', { class: 'btn small', href: '#/peers/' + p.id }, 'Manage'))))); empty.hidden = rows.length > 0; }; drawPills(); drawRows(); fill(wrap, h('div', { class: 'head' }, h('div', null, h('h1', null, 'Peers'), sub), h('div', { class: 'actions' }, h('a', { class: 'btn primary', href: '#/peers/new' }, icon('plus', 16, 2), 'Add peer'))), h('div', { class: 'row', style: { flexWrap: 'wrap', alignItems: 'center', gap: '10px' } }, h('label', { htmlFor: 'q', class: 'sr' }, 'Search peers'), h('input', { id: 'q', type: 'search', placeholder: 'Search name, address or note', style: { flex: '1 1 260px', maxWidth: '360px' }, onInput: (e) => { q = e.target.value.toLowerCase(); drawRows(); } }), pills), h('section', { class: 'card flush' }, h('div', { class: 'tbl' }, h('table', null, h('thead', null, h('tr', null, ['Name', 'Address', 'Status', 'Endpoint'].map((t) => h('th', null, t)), h('th', { class: 'num' }, 'Latency'), h('th', { class: 'num' }, 'Download, 30 d'), h('th', { class: 'num' }, 'Upload, 30 d'), h('th', null, 'Enabled'), h('th', null, h('span', { class: 'sr' }, 'Actions')))), tbody), empty)), h('p', { class: 'muted', style: { margin: '0', fontSize: '13px' } }, 'Online means a handshake in the last 3 minutes. Latency is the round trip from the server through the tunnel to the device and back, median of the last 5 minutes; turn it on in a peer\'s settings. Download and Upload are measured from the peer\'s side. Changes apply live without disconnecting other peers.')); every(15000, async () => { try { data = await api('GET', '/peers'); drawRows(); } catch { /* keep last */ } }); } // ---------- shared peer form pieces ---------- // choice builds a select with "server default" plus options, and a text // input that appears for "custom". function choice({ id, label, hint, options, value, customValue, placeholder, mono = true, onChange }) { const input = h('input', { id: id + '-c', class: mono ? 'mono' : null, value: customValue || '', placeholder, hidden: value !== 'custom', 'aria-label': label + ' (custom)' }); const sel = h('select', { id }, options.map(([v, t]) => h('option', { value: v, selected: v === value }, t))); const sync = () => { input.hidden = sel.value !== 'custom'; if (onChange) onChange(); }; sel.addEventListener('change', sync); input.addEventListener('input', () => onChange && onChange()); const el = h('div', { class: 'field' }, h('label', { htmlFor: id }, label), sel, input, hint ? h('span', { class: 'hint' }, hint) : null); return { el, get: () => sel.value, custom: () => input.value }; } function serverNets(srv) { const nets = [srv.ipv4]; if (srv.ipv6Enabled) nets.push(srv.ipv6); return nets; } // Reads the DNS / AllowedIPs / keepalive choices into API values; null // means "use the server default". function overrides(dns, allowed, ka, srv) { const out = {}; out.dns = dns.get() === 'default' ? null : list(dns.custom()); const a = allowed.get(); out.allowedIPs = a === 'default' ? null : a === 'vpn' ? serverNets(srv) : list(allowed.custom()); const k = ka.get(); out.keepalive = k === 'default' ? null : k === 'off' ? 0 : parseInt(ka.custom(), 10); if (k === 'custom' && !(out.keepalive >= 0)) throw new Error('Keepalive must be a number of seconds'); return out; } function overrideChoices(srv, p) { const d = srv.clientDefaults; const allowedMode = !p || p.allowedIPs == null ? 'default' : sameList(p.allowedIPs, serverNets(srv)) ? 'vpn' : 'custom'; return { dns: { options: [['default', 'Server default · ' + (d.dns.join(', ') || 'none')], ['custom', 'Custom…']], value: !p || p.dns == null ? 'default' : 'custom', customValue: p && p.dns ? p.dns.join(', ') : '' }, allowed: { options: [['default', 'Server default · ' + d.allowedIPs.join(', ')], ['vpn', 'Only the VPN network'], ['custom', 'Custom…']], value: allowedMode, customValue: p && p.allowedIPs ? p.allowedIPs.join(', ') : '' }, ka: { options: [['default', 'Server default · ' + (d.keepalive ? d.keepalive + ' s' : 'off')], ['off', 'Off'], ['custom', 'Custom…']], value: !p || p.keepalive == null ? 'default' : p.keepalive === 0 ? 'off' : 'custom', customValue: p && p.keepalive ? String(p.keepalive) : '' }, }; } // ---------- add peer ---------- async function viewPeerNew(wrap) { const srv = await api('GET', '/server'); const err = h('p', { class: 'err-text', role: 'alert' }); const name = h('input', { id: 'n', autocomplete: 'off', required: true }); const note = h('input', { id: 'no' }); const ip = h('input', { id: 'ip', class: 'mono', placeholder: 'Next free address' }); const psk = h('input', { type: 'checkbox', checked: true }); const preview = h('pre', { class: 'code' }); const ch = overrideChoices(srv, null); const update = () => drawPreview(); const dns = choice({ id: 'dns', label: 'DNS', ...ch.dns, placeholder: '9.9.9.9, 149.112.112.112', onChange: update }); const allowed = choice({ id: 'ai', label: 'Route through the VPN (AllowedIPs)', ...ch.allowed, placeholder: '10.0.0.0/24, 192.168.1.0/24', hint: 'Used in the client config', onChange: update }); const ka = choice({ id: 'ka', label: 'Persistent keepalive', ...ch.ka, placeholder: 'Seconds', hint: 'Keeps the tunnel open behind NAT', onChange: update }); const ho = handover({ showHint: 'QR code and download right after you click Create. Best when the device is next to you.', linkHint: 'A one-time link you send to the device\'s owner. Keys are made when the link is opened and never stored.', onChange: update, }); const qrBox = h('div', { class: 'ph' }); function drawPreview() { const link = ho.link(); submit.textContent = link ? 'Create peer and link' : 'Create peer'; qrBox.replaceChildren(link ? 'Setup link' : 'QR code', h('br'), 'after creation'); aside.textContent = link ? 'With a setup link, keys are created when the recipient opens it. Until then the peer is inactive.' : 'Keys are created when you click Create peer. Then the config can be downloaded or scanned once.'; let o; try { o = overrides(dns, allowed, ka, srv); } catch { o = {}; } const d = srv.clientDefaults; const lines = ['[Interface]', 'PrivateKey = ' + (link ? '‹made when the link is opened›' : '‹generated on create›'), 'Address = ' + (ip.value || '‹next free›') + '/' + srv.ipv4.split('/')[1] + (srv.ipv6Enabled ? ',‹mapped IPv6›' : '')]; const dnsList = o.dns === undefined || o.dns === null ? d.dns : o.dns; if (dnsList.length) lines.push('DNS = ' + dnsList.join(', ')); lines.push('', '[Peer]', 'PublicKey = ' + srv.publicKey); if (psk.checked) lines.push('PresharedKey = ' + (link ? '‹made when the link is opened›' : '‹generated on create›')); lines.push('Endpoint = ' + (srv.endpoint || '‹set the endpoint in Server›') + ':' + (srv.endpointPort || srv.listenPort)); lines.push('AllowedIPs = ' + ((o.allowedIPs == null ? d.allowedIPs : o.allowedIPs).join(', '))); const k = o.keepalive == null ? d.keepalive : o.keepalive; if (k > 0) lines.push('PersistentKeepalive = ' + k); preview.textContent = lines.join('\n'); } const aside = h('p', { class: 'lead' }); const submit = h('button', { type: 'submit', class: 'btn primary' }, 'Create peer'); const form = h('form', { class: 'card grow', onSubmit: async (e) => { e.preventDefault(); err.textContent = ''; let body; try { body = { name: name.value.trim(), note: note.value.trim(), ipv4: ip.value.trim(), presharedKey: psk.checked, ...overrides(dns, allowed, ka, srv), ...ho.body() }; } catch (x) { err.textContent = x.message; return; } submit.disabled = true; try { const res = await api('POST', '/peers', body); const done = () => { location.hash = '#/peers/' + res.peer.id; }; if (res.setup) linkDialog(res.peer.name, res.setup, done); else configDialog(res, done); } catch (x) { err.textContent = x.message; submit.disabled = false; } } }, h('div', { class: 'grid' }, h('div', { class: 'field' }, h('label', { htmlFor: 'n' }, 'Name'), name, h('span', { class: 'hint' }, 'Letters, numbers, . _ @ - · max 32 · unique')), h('div', { class: 'field' }, h('label', { htmlFor: 'no' }, 'Note ', h('span', { class: 'hint' }, '(optional)')), note), h('div', { class: 'field' }, h('label', { htmlFor: 'ip' }, 'IPv4 address'), ip, h('span', { class: 'hint' }, 'Leave empty for the next free address in ' + srv.ipv4)), srv.ipv6Enabled ? h('div', { class: 'field' }, h('label', null, 'IPv6 address'), h('input', { class: 'mono', readOnly: true, value: 'Derived from the IPv4 address' })) : null), h('div', { class: 'grid section' }, allowed.el, dns.el, ka.el), h('div', { class: 'section' }, h('label', { class: 'check' }, psk, 'Add a preshared key')), h('div', { class: 'section' }, ho.el), err, h('div', { class: 'formfoot' }, h('a', { class: 'btn', href: '#/peers' }, 'Cancel'), submit)); for (const el of [ip, psk]) el.addEventListener('input', drawPreview); psk.addEventListener('change', drawPreview); drawPreview(); fill(wrap, h('a', { class: 'back', href: '#/peers' }, '← Peers'), h('div', null, h('h1', null, 'Add peer'), h('p', { class: 'sub' }, 'Assigns the next free address and adds the peer to ' + srv.interface + ' without a restart.')), h('div', { class: 'split' }, form, h('aside', { class: 'card aside', 'aria-labelledby': 'pv' }, h('h2', { id: 'pv' }, 'Client config preview'), aside, preview, h('div', { class: 'qrrow section' }, qrBox)))); name.focus(); } // ---------- peer detail ---------- async function viewPeer(wrap, id) { const [p, srv, sess] = await Promise.all([api('GET', '/peers/' + id), api('GET', '/server'), api('GET', '/peers/' + id + '/sessions?limit=50')]); const sessions = sess.sessions; let range = '7d'; const st = peerState(p); const traffic = h('div'); const totals = h('div', { class: 'legend-row' }); const pills = h('div', { class: 'pills', role: 'group', 'aria-label': 'Time range' }); const showLat = p.latencyCheck !== 'off' || p.stats.latency; const latBox = h('div'); async function drawLatency() { if (!showLat) return; try { latBox.replaceChildren(latencyChart((await api('GET', '/peers/' + id + '/latency')).points)); } catch (e) { latBox.replaceChildren(h('p', { class: 'err-text' }, e.message)); } } const latText = () => { const l = p.stats.latency; if (p.latencyCheck === 'off' && !l) return h('span', { class: 'muted' }, 'Check off'); if (!l) return h('span', { class: 'muted' }, p.latencyCheck === 'active' ? 'Not measured yet · pinged only while the device sends traffic' : 'Not measured yet'); const when = latStale(l) || p.latencyCheck === 'off' ? h('span', { class: 'muted' }, ' · measured ' + ago(l.at)) : null; if (l.ms == null) return [h('span', null, 'No ping reply'), when]; return [h('span', { class: 'mono' }, fmtMs(l.ms)), h('span', { class: 'muted' }, ' median · ' + fmtMs(l.min) + '–' + fmtMs(l.max) + ' · ' + l.loss + ' % loss'), when]; }; async function drawTraffic() { pills.replaceChildren(...[['24h', '24 h'], ['7d', '7 days'], ['30d', '30 days']].map(([k, t]) => h('button', { type: 'button', class: range === k ? 'pill on' : 'pill', 'aria-pressed': String(range === k), onClick: () => { range = k; drawTraffic(); } }, t))); const s = await api('GET', '/peers/' + id + '/stats?range=' + range); const down = s.points.reduce((a, x) => a + x.down, 0), up = s.points.reduce((a, x) => a + x.up, 0); totals.replaceChildren( h('span', null, h('span', { class: 'key down' }), 'Download ', h('strong', null, fmtBytes(down))), h('span', null, h('span', { class: 'key up' }), 'Upload ', h('strong', null, fmtBytes(up)))); traffic.replaceChildren(chart(s.points, range, 'pair', false)); } const toggle = async () => { try { const res = await api('POST', '/peers/' + id + (p.enabled ? '/disable' : '/enable')); applied(res, (p.enabled ? 'Disabled ' : 'Enabled ') + p.name); render(); } catch (e) { toast(e.message, true); } }; const del = async () => { if (!await confirmDialog({ title: 'Delete ' + p.name + '?', text: 'The device loses access immediately. Its traffic history is deleted too. This cannot be undone.', ok: 'Delete peer', danger: true })) return; try { const res = await api('DELETE', '/peers/' + id); applied(res, 'Deleted ' + p.name); location.hash = '#/peers'; } catch (e) { toast(e.message, true); } }; const reissue = () => { const ho = handover({ showHint: 'New keys now; QR code and download on this screen', linkHint: p.publicKey ? 'The current config keeps working until the link is opened' : 'A one-time link you send to the device\'s owner', }); const e = h('p', { class: 'err-text', role: 'alert' }); dialog((close) => h('form', { class: 'dlg', onSubmit: async (ev) => { ev.preventDefault(); e.textContent = ''; try { const res = await api('POST', '/peers/' + id + '/issue-config', ho.body()); close(); if (res.setup) linkDialog(p.name, res.setup, render); else configDialog(res, render); } catch (x) { e.textContent = x.message; } } }, h('h2', null, (p.publicKey ? 'Issue a new config for ' : 'Issue a config for ') + p.name + '?'), p.publicKey ? h('p', null, 'New keys are created. The device that uses the current config stops working once it is replaced.') : null, p.setup ? h('p', null, 'This replaces the current setup link.') : null, ho.el, e, h('div', { class: 'foot' }, h('button', { type: 'button', class: 'btn', onClick: close }, 'Cancel'), h('button', { type: 'submit', class: 'btn primary' }, 'Continue')))); }; const showLink = async () => { try { linkDialog(p.name, await api('GET', '/peers/' + id + '/setup')); } catch (e) { toast(e.message, true); } }; const copyLink = async () => { try { copy((await api('GET', '/peers/' + id + '/setup')).url); } catch (e) { toast(e.message, true); } }; const revoke = async () => { if (!await confirmDialog({ title: 'Revoke the setup link?', text: 'The link stops working immediately.', ok: 'Revoke link', danger: true })) return; try { await api('DELETE', '/peers/' + id + '/setup'); toast('Setup link revoked'); render(); } catch (e) { toast(e.message, true); } }; const setupCard = () => { const su = p.setup; if (!su) return null; return h('section', { class: 'card', 'aria-labelledby': 'sl' }, h('div', { class: 'cardhead' }, h('h2', { id: 'sl' }, 'Setup link'), h('span', { class: 'hint' }, su.expired ? 'Expired' : 'Not opened yet')), h('dl', { class: 'kv' }, h('dt', null, su.expired ? 'Expired' : 'Expires'), h('dd', null, fmtStamp(su.expires)), h('dt', null, 'PIN'), h('dd', null, su.pinRequired ? 'Required · ' + su.pinFails + ' of 5 wrong tries' : 'Not required'), p.publicKey ? [h('dt', null, 'Current config'), h('dd', null, 'Keeps working until the link is opened')] : null), h('div', { class: 'actions section' }, su.expired ? [h('button', { type: 'button', class: 'btn primary', onClick: reissue }, 'New link…'), h('button', { type: 'button', class: 'btn', onClick: revoke }, 'Remove')] : [h('button', { type: 'button', class: 'btn primary', onClick: copyLink }, 'Copy link'), h('button', { type: 'button', class: 'btn', onClick: showLink }, su.pinRequired ? 'Show link & PIN' : 'Show link'), h('button', { type: 'button', class: 'btn danger', onClick: revoke }, 'Revoke')])); }; // settings form const err = h('p', { class: 'err-text', role: 'alert' }); const name = h('input', { id: 'n', value: p.name }); const note = h('input', { id: 'no', value: p.note }); const ip = h('input', { id: 'ip', class: 'mono', value: p.ipv4 }); const ch = overrideChoices(srv, p); const dns = choice({ id: 'dns', label: 'DNS', ...ch.dns, placeholder: '9.9.9.9' }); const allowed = choice({ id: 'ai', label: 'Client AllowedIPs', ...ch.allowed, hint: 'Used in the client config', placeholder: '0.0.0.0/0, ::/0' }); const ka = choice({ id: 'ka', label: 'Persistent keepalive', ...ch.ka, placeholder: 'Seconds' }); const LAT_HINTS = { off: 'The server never pings this peer.', active: 'Pings every 30 s while the device sends traffic. An idle device is left alone.', always: 'Pings every 30 s, even when idle. This keeps the tunnel up, so the peer always shows as Online. Best for servers and routers.', }; const latHint = h('span', { class: 'hint' }, LAT_HINTS[p.latencyCheck]); const lc = h('select', { id: 'lc', onChange: () => { latHint.textContent = LAT_HINTS[lc.value]; } }, [['off', 'Off'], ['active', 'While the device is active'], ['always', 'Always']].map(([v, t]) => h('option', { value: v, selected: v === p.latencyCheck }, t))); const save = async (e) => { e.preventDefault(); err.textContent = ''; try { const body = { name: name.value, note: note.value, ipv4: ip.value.trim(), latencyCheck: lc.value, ...overrides(dns, allowed, ka, srv) }; const res = await api('PATCH', '/peers/' + id, body); applied(res, 'Saved'); render(); } catch (x) { err.textContent = x.message; } }; fill(wrap, h('a', { class: 'back', href: '#/peers' }, '← Peers'), h('div', { class: 'head' }, h('div', null, h('div', { class: 'titleline' }, h('h1', null, p.name), badge(st.key === 'online' ? { ...st, label: 'Online · handshake ' + ago(p.stats.lastHandshake) } : st)), h('p', { class: 'sub' }, (p.note ? p.note + ' · ' : '') + 'created ' + fmtDate(p.created))), h('div', { class: 'actions' }, h('button', { type: 'button', class: 'btn', onClick: toggle }, p.enabled ? 'Disable' : 'Enable'), h('button', { type: 'button', class: 'btn danger', onClick: del }, 'Delete'))), setupCard(), h('section', { class: 'card', 'aria-labelledby': 'traffic' }, h('div', { class: 'cardhead' }, h('div', null, h('h2', { id: 'traffic' }, 'Traffic'), totals), pills), traffic), showLat ? h('section', { class: 'card', 'aria-labelledby': 'lat' }, h('div', { class: 'cardhead' }, h('h2', { id: 'lat' }, 'Latency · last 24 hours'), h('div', { class: 'legend-row', style: { marginTop: '0' } }, h('span', null, h('span', { class: 'key down' }), 'Median'), h('span', null, h('span', { class: 'key band' }), 'Min–max'))), latBox) : null, h('div', { class: 'cols' }, h('section', { class: 'card' }, h('h2', null, 'Connection'), h('dl', { class: 'kv' }, h('dt', null, 'Tunnel address'), h('dd', { class: 'mono' }, p.ipv4 + '/32', p.ipv6 ? [h('br'), p.ipv6 + '/128'] : null), h('dt', null, 'Endpoint'), h('dd', { class: 'mono' }, p.stats.endpoint || '–'), h('dt', null, 'Location'), h('dd', null, fmtLocation(p.stats.location) || '–'), h('dt', null, 'Latest handshake'), h('dd', null, ago(p.stats.lastHandshake)), h('dt', null, 'Latency'), h('dd', null, latText()), h('dt', null, 'Public key'), h('dd', { class: 'mono' }, p.publicKey || '–'), h('dt', null, 'Preshared key'), h('dd', null, p.hasPresharedKey ? 'Set' : 'None'), h('dt', null, 'All-time traffic'), h('dd', null, 'Download ' + fmtBytes(p.stats.downTotal) + ' · Upload ' + fmtBytes(p.stats.upTotal)))), h('section', { class: 'card' }, h('h2', null, 'Client configuration'), h('p', { class: 'lead' }, 'This server doesn\'t keep the peer\'s private key. To set up a device again, issue a new config. The old one stops working.'), h('div', { class: 'actions' }, h('button', { type: 'button', class: 'btn', onClick: reissue }, p.publicKey ? 'Issue new config…' : 'Issue config…')), h('p', { class: 'hint', style: { margin: '12px 0 0' } }, p.configIssued ? 'Last issued ' + fmtDate(p.configIssued) + '.' : 'No config issued yet.'))), h('section', { class: 'card flush', 'aria-labelledby': 'hist' }, h('div', { class: 'cardhead' }, h('h2', { id: 'hist' }, 'Connection history'), h('span', { class: 'hint' }, 'Newest first · a new row starts when the device changes networks')), sessions.length ? h('div', { class: 'tbl' }, h('table', null, h('thead', null, h('tr', null, h('th', null, 'Started'), h('th', null, 'Duration'), h('th', null, 'From'), h('th', null, 'Address'), h('th', { class: 'num' }, 'Download'), h('th', { class: 'num' }, 'Upload'))), h('tbody', null, sessions.map((se) => h('tr', null, h('td', null, fmtStamp(se.start)), h('td', null, se.open ? [h('span', { class: 'badge' }, h('span', { class: 'dot ok' }), 'Online now'), ' ', fmtDuration(se.seconds)] : fmtDuration(se.seconds)), h('td', null, fmtLocation(se.geo) || h('span', { class: 'muted' }, 'Unknown')), h('td', { class: 'mono muted' }, se.ip), h('td', { class: 'num' }, fmtBytes(se.down)), h('td', { class: 'num' }, fmtBytes(se.up))))))) : h('p', { class: 'empty' }, 'No connections recorded yet.'), h('p', { class: 'hint', style: { margin: '4px 12px 12px' } }, 'Country and network: ', h('a', { href: 'https://db-ip.com', target: '_blank', rel: 'noopener' }, 'IP Geolocation by DB-IP'), '. Kept as long as the daily traffic history.')), h('form', { class: 'card', onSubmit: save }, h('h2', null, 'Settings'), h('p', { class: 'lead' }, 'Name, address and latency check changes apply immediately. DNS, AllowedIPs and keepalive are part of the client config: they take effect after the config is issued again.'), h('div', { class: 'grid' }, h('div', { class: 'field' }, h('label', { htmlFor: 'n' }, 'Name'), name, h('span', { class: 'hint' }, 'Letters, numbers, . _ @ - · max 32')), h('div', { class: 'field' }, h('label', { htmlFor: 'no' }, 'Note'), note), h('div', { class: 'field' }, h('label', { htmlFor: 'ip' }, 'IPv4 address'), ip, h('span', { class: 'hint' }, 'Changing it requires a new client config')), ka.el, allowed.el, dns.el, h('div', { class: 'field' }, h('label', { htmlFor: 'lc' }, 'Latency check'), lc, latHint)), err, h('div', { class: 'formfoot' }, h('button', { type: 'button', class: 'btn', onClick: render }, 'Cancel'), h('button', { type: 'submit', class: 'btn primary' }, 'Save changes')))); await Promise.all([drawTraffic(), drawLatency()]); } // ---------- server ---------- const DNS_PRESETS = [['Quad9', '9.9.9.9, 149.112.112.112']]; async function viewServer(wrap) { const [srv, st] = await Promise.all([api('GET', '/server'), api('GET', '/status')]); const orig = JSON.parse(JSON.stringify(srv)); const draft = JSON.parse(JSON.stringify(srv)); const LABELS = { listenPort: 'Listen port', mtu: 'MTU', ipv4: 'IPv4 network', ipv6: 'IPv6 network', ipv6Enabled: 'IPv6', endpoint: 'Endpoint host', endpointPort: 'Endpoint port', uplinkV4: 'IPv4 uplink', uplinkV6: 'IPv6 uplink', nat: 'NAT', peerToPeer: 'Peer-to-peer', lanAccess: 'LAN access', openPort: 'Open port', clientDefaults: 'Client defaults', }; const DISRUPTIVE = ['listenPort', 'ipv4', 'ipv6', 'ipv6Enabled']; const bar = h('div', { class: 'applybar', role: 'region', 'aria-label': 'Pending changes', hidden: true }); const result = h('div'); const changed = () => Object.keys(LABELS).filter((k) => JSON.stringify(draft[k]) !== JSON.stringify(orig[k])); const drawBar = () => { const c = changed(); bar.hidden = c.length === 0; if (!c.length) return; const disrupt = c.some((k) => DISRUPTIVE.includes(k)); bar.replaceChildren( h('span', null, c.length + ' unsaved change' + (c.length > 1 ? 's' : '') + ': ' + c.map((k) => LABELS[k]).join(', '), disrupt ? [' · ', h('strong', null, 'connected peers drop and need new configs')] : null), h('div', { class: 'actions' }, h('button', { type: 'button', class: 'btn', onClick: render }, 'Discard'), h('button', { type: 'button', class: 'btn primary', onClick: apply }, 'Apply'))); }; async function apply() { const body = {}; for (const k of changed()) body[k] = draft[k]; try { const res = await api('PATCH', '/server', body); await render(); applied(res, 'Server settings applied'); if (res.reissueNeeded) toast('Existing devices need a new config: endpoint, port or addresses changed.', false); } catch (e) { toast(e.message, true); } } const num = (k) => (e) => { draft[k] = e.target.value === '' ? 0 : Number(e.target.value); drawBar(); }; const str = (k) => (e) => { draft[k] = e.target.value.trim(); drawBar(); }; const bool = (k) => (e) => { draft[k] = e.target.checked; drawBar(); }; const cd = (k, parse) => (e) => { draft.clientDefaults = { ...draft.clientDefaults, [k]: parse(e.target.value) }; drawBar(); }; const restartTag = () => h('span', { class: 'tag' }, 'drops peers'); const fieldEl = (id, label, input, hint) => h('div', { class: 'field' }, h('label', { htmlFor: id }, label), input, hint ? h('span', { class: 'hint' }, hint) : null); const cb = (k, label, hint) => h('label', { class: 'check' }, h('input', { type: 'checkbox', checked: draft[k], onChange: bool(k) }), h('span', null, label, hint ? [h('br'), h('span', { class: 'hint' }, hint)] : null)); const host = h('input', { id: 'host', class: 'mono', value: draft.endpoint, placeholder: 'vpn.example.net', onInput: str('endpoint') }); const detectHint = h('span', { class: 'hint' }, 'A DNS name keeps configs valid when your IP changes'); const detect = async () => { try { const r = await api('GET', '/server/detect-ip'); detectHint.textContent = 'Detected public IP: ' + r.ip; if (!host.value) { host.value = r.ip; draft.endpoint = r.ip; drawBar(); } } catch (e) { toast(e.message, true); } }; const dnsInput = h('input', { id: 'dns', class: 'mono', value: draft.clientDefaults.dns.join(', '), onInput: cd('dns', list) }); const curDNS = draft.clientDefaults.dns.join(', '); const preset = h('select', { id: 'dp', onChange: (e) => { if (e.target.value) { dnsInput.value = e.target.value; dnsInput.dispatchEvent(new Event('input')); } } }, DNS_PRESETS.map(([n, v]) => h('option', { value: v, selected: v === curDNS }, n)), h('option', { value: '', selected: !DNS_PRESETS.some(([, v]) => v === curDNS) }, 'Custom')); const rotate = async () => { if (!await confirmDialog({ title: 'Rotate the server key?', text: 'Every client config stops working until it is issued again. Use this only if the server key may have leaked.', ok: 'Rotate key', danger: true })) return; try { const res = await api('POST', '/server/rotate-key'); applied(res, 'Server key rotated'); render(); } catch (e) { toast(e.message, true); } }; fill(wrap, h('div', null, h('h1', null, 'Server'), h('p', { class: 'sub' }, 'WireGuard interface, address plan, client defaults and firewall')), result, h('section', { class: 'card', 'aria-labelledby': 'hc' }, h('h2', { id: 'hc' }, 'Health'), h('div', { style: { marginTop: '8px' } }, st.checks.map((c) => h('div', { class: 'chk' }, h('span', { class: c.ok ? 'dot ok' : 'dot bad' }), h('span', { class: 'sr' }, c.ok ? 'OK: ' : 'Problem: '), h('b', null, c.name), h('span', { class: c.ok ? 'muted' : null }, c.detail))))), h('section', { class: 'card', 'aria-labelledby': 'if' }, h('h2', { id: 'if' }, 'Interface'), h('p', { class: 'lead' }, 'Fields marked ', restartTag(), ' change what every client config contains: connected peers drop and need a new config.'), h('div', { class: 'grid' }, fieldEl('ifn', 'Interface name', h('input', { id: 'ifn', class: 'mono', value: draft.interface, readOnly: true })), fieldEl('pt', ['Listen port', restartTag()], h('input', { id: 'pt', class: 'mono', inputMode: 'numeric', value: draft.listenPort, onInput: num('listenPort') }), 'UDP, 1–65535'), fieldEl('mtu', 'MTU', h('input', { id: 'mtu', class: 'mono', inputMode: 'numeric', value: draft.mtu, onInput: num('mtu') }), '1420 suits most links. Try 1280 on PPPoE or mobile'), fieldEl('v4', ['IPv4 network', restartTag()], h('input', { id: 'v4', class: 'mono', value: draft.ipv4, onInput: str('ipv4') }), 'The server uses the first address. Peers keep their host number'), fieldEl('v6', ['IPv6 network', restartTag()], h('input', { id: 'v6', class: 'mono', value: draft.ipv6, onInput: str('ipv6') })), h('div', { class: 'field', style: { justifyContent: 'flex-end' } }, cb('ipv6Enabled', 'Enable IPv6 in the tunnel')))), h('section', { class: 'card', 'aria-labelledby': 'ep' }, h('h2', { id: 'ep' }, 'Public endpoint'), h('p', { class: 'lead' }, 'Where clients connect.'), h('div', { class: 'grid' }, h('div', { class: 'field' }, h('label', { htmlFor: 'host' }, 'Host'), h('div', { class: 'row' }, host, h('button', { type: 'button', class: 'btn', onClick: detect }, 'Detect IP')), detectHint), fieldEl('ept', 'Port seen by clients', h('input', { id: 'ept', class: 'mono', inputMode: 'numeric', value: draft.endpointPort || '', placeholder: String(draft.listenPort), onInput: num('endpointPort') }), 'Leave empty unless a router forwards a different port'))), h('section', { class: 'card', 'aria-labelledby': 'cd' }, h('h2', { id: 'cd' }, 'Client defaults'), h('p', { class: 'lead' }, 'Used for new client configs and for peers set to "Server default". Existing devices pick up changes only after their config is issued again.'), h('div', { class: 'grid' }, fieldEl('dp', 'DNS provider', preset), fieldEl('dns', 'DNS servers', dnsInput), fieldEl('al', 'AllowedIPs', h('input', { id: 'al', class: 'mono', value: draft.clientDefaults.allowedIPs.join(', '), onInput: cd('allowedIPs', list) }), '0.0.0.0/0, ::/0 is a full tunnel. Keep ::/0 even without server IPv6 to prevent leaks'), fieldEl('ka', 'Persistent keepalive', h('input', { id: 'ka', class: 'mono', inputMode: 'numeric', value: draft.clientDefaults.keepalive, onInput: cd('keepalive', (v) => Number(v) || 0) }), 'Seconds. 0 turns it off'))), h('section', { class: 'card', 'aria-labelledby': 'fw' }, h('h2', { id: 'fw' }, 'Routing & firewall'), h('p', { class: 'lead' }, 'Rules live in their own nftables table. Other firewall rules aren\'t touched; if you also run ufw or firewalld, allow UDP ' + draft.listenPort + ' there.'), h('div', { class: 'grid' }, fieldEl('up4', 'IPv4 uplink interface', h('input', { id: 'up4', class: 'mono', value: draft.uplinkV4, placeholder: 'auto: ' + (srv.detectedUplinkV4 || 'none found'), onInput: str('uplinkV4') })), fieldEl('up6', 'IPv6 uplink interface', h('input', { id: 'up6', class: 'mono', value: draft.uplinkV6, placeholder: 'auto: ' + (srv.detectedUplinkV6 || 'none found'), onInput: str('uplinkV6') })), cb('nat', 'Masquerade (NAT) peer traffic to the internet'), cb('peerToPeer', 'Allow peers to reach each other'), cb('lanAccess', 'Allow peers to reach the server\'s LAN', 'Private networks on the uplink interface'), cb('openPort', 'Accept UDP ' + draft.listenPort + ' in the input chain'))), h('section', { class: 'card', 'aria-labelledby': 'ky' }, h('h2', { id: 'ky' }, 'Server key'), h('dl', { class: 'kv' }, h('dt', null, 'Public key'), h('dd', { class: 'mono' }, srv.publicKey), h('dt', null, 'Created'), h('dd', null, fmtDate(srv.keyCreated))), h('div', { class: 'section' }, h('button', { type: 'button', class: 'btn danger', onClick: rotate }, 'Rotate server key…')), h('p', { class: 'hint' }, 'Every client config stops working until it\'s issued again.')), bar); } // ---------- settings ---------- async function viewSettings(wrap) { if (!me.isAdmin) { fill(wrap, h('h1', null, 'Settings'), h('div', { class: 'notice' }, 'Only the admin account can change settings. API tokens cannot.')); return; } const [s, tk] = await Promise.all([api('GET', '/settings'), api('GET', '/tokens')]); let logLevelFilter = 'all'; // admin account const uname = h('input', { id: 'u', value: s.adminUsername, autocomplete: 'username' }); const cur = h('input', { id: 'pc', type: 'password', autocomplete: 'current-password' }); const p1 = h('input', { id: 'p1', type: 'password', autocomplete: 'new-password', placeholder: 'At least 12 characters' }); const p2 = h('input', { id: 'p2', type: 'password', autocomplete: 'new-password' }); const accErr = h('p', { class: 'err-text', role: 'alert' }); const savePw = async (e) => { e.preventDefault(); accErr.textContent = ''; try { if (uname.value.trim() !== s.adminUsername) { await api('PATCH', '/settings', { adminUsername: uname.value.trim() }); toast('Username saved'); } if (p1.value || p2.value || cur.value) { if (p1.value !== p2.value) throw new Error('The new passwords do not match'); await api('POST', '/auth/password', { current: cur.value, new: p1.value }); toast('Password changed. Please sign in again.'); me = null; showLogin(); } } catch (x) { accErr.textContent = x.message; } }; // web interface const web = JSON.parse(JSON.stringify(s.web)); const restartBox = h('div'); const webErr = h('p', { class: 'err-text', role: 'alert' }); const modeSel = h('select', { id: 'tls' }, [['acme', 'Let\'s Encrypt (automatic)'], ['selfsigned', 'Self-signed certificate (generated)'], ['files', 'Certificate files'], ['off', 'Off: behind a reverse proxy']].map(([v, t]) => h('option', { value: v, selected: web.tls.mode === v }, t))); const fAcme = h('div', { class: 'grid' }, h('div', { class: 'field' }, h('label', { htmlFor: 'dom' }, 'Domain'), h('input', { id: 'dom', class: 'mono', value: web.tls.domain || '', placeholder: 'vpn.example.net', onInput: (e) => { web.tls.domain = e.target.value.trim(); } }), h('span', { class: 'hint' }, 'Must point to this server. Port 443 (and 80 if set) must be reachable from the internet')), h('div', { class: 'field' }, h('label', { htmlFor: 'em' }, 'Email for Let\'s Encrypt'), h('input', { id: 'em', type: 'email', value: web.tls.email || '', onInput: (e) => { web.tls.email = e.target.value.trim(); } }), h('span', { class: 'hint' }, 'Optional. Used for expiry warnings')), h('label', { class: 'check' }, h('input', { type: 'checkbox', checked: !!web.tls.staging, onChange: (e) => { web.tls.staging = e.target.checked; } }), h('span', null, 'Use the staging CA', h('br'), h('span', { class: 'hint' }, 'For testing: certificates are not trusted by browsers')))); const fFiles = h('div', { class: 'grid' }, h('div', { class: 'field' }, h('label', { htmlFor: 'cf' }, 'Certificate file'), h('input', { id: 'cf', class: 'mono', value: web.tls.certFile || '', placeholder: '/etc/ssl/vpn/fullchain.pem', onInput: (e) => { web.tls.certFile = e.target.value.trim(); } })), h('div', { class: 'field' }, h('label', { htmlFor: 'kf' }, 'Key file'), h('input', { id: 'kf', class: 'mono', value: web.tls.keyFile || '', placeholder: '/etc/ssl/vpn/privkey.pem', onInput: (e) => { web.tls.keyFile = e.target.value.trim(); } }), h('span', { class: 'hint' }, 'Readable by the service user. Reloaded when the file changes'))); const syncMode = () => { web.tls.mode = modeSel.value; fAcme.hidden = modeSel.value !== 'acme'; fFiles.hidden = modeSel.value !== 'files'; }; modeSel.addEventListener('change', syncMode); syncMode(); const saveWeb = async (e) => { e.preventDefault(); webErr.textContent = ''; try { const res = await api('PATCH', '/settings', { web }); if (res.restartRequired) { restartBox.replaceChildren(h('div', { class: 'notice' }, 'Saved. The web interface uses the new settings after a restart.', h('button', { type: 'button', class: 'btn small', onClick: restartNow }, 'Restart now'))); } else toast('Saved'); } catch (x) { webErr.textContent = x.message; } }; const restartNow = async () => { try { await api('POST', '/restart'); } catch { /* the connection may drop */ } toast('Restarting… the page reloads in a few seconds.'); setTimeout(() => location.reload(), 4000); }; // tokens const tbody = h('tbody'); const drawTokens = (tokens) => tbody.replaceChildren(...(tokens.length ? tokens.map((t) => h('tr', null, h('td', null, t.name), h('td', null, h('span', { class: 'badge' }, t.scope === 'ro' ? 'Read only' : 'Full access')), h('td', null, fmtDate(t.created)), h('td', null, t.lastUsed ? ago(t.lastUsed.at) + ' · ' + t.lastUsed.ip : 'Not since restart'), h('td', { class: 'num' }, h('button', { type: 'button', class: 'btn danger small', onClick: () => revoke(t) }, 'Revoke')))) : [h('tr', null, h('td', { colspan: '5', class: 'muted' }, 'No tokens yet.'))])); drawTokens(tk.tokens); const reloadTokens = async () => drawTokens((await api('GET', '/tokens')).tokens); const revoke = async (t) => { if (!await confirmDialog({ title: 'Revoke ' + t.name + '?', text: 'Apps using this token are signed out immediately.', ok: 'Revoke', danger: true })) return; try { await api('DELETE', '/tokens/' + t.id); toast('Revoked ' + t.name); reloadTokens(); } catch (e) { toast(e.message, true); } }; const newToken = () => { const nm = h('input', { id: 'tn', value: 'iPhone app' }); const sc = h('select', { id: 'ts' }, h('option', { value: 'rw' }, 'Full access'), h('option', { value: 'ro' }, 'Read only')); const e = h('p', { class: 'err-text' }); const d = dialog((close) => { const body = h('form', { class: 'dlg', onSubmit: async (ev) => { ev.preventDefault(); try { const r = await api('POST', '/tokens', { name: nm.value, scope: sc.value }); body.replaceChildren( h('h2', null, 'Scan with the iOS app'), h('div', { class: 'notice' }, 'The token is shown only now. Only a hash is stored on the server.'), h('div', { class: 'qrrow' }, h('img', { class: 'qr', src: r.qr, alt: 'Pairing QR code' }), h('div', { class: 'col' }, h('button', { type: 'button', class: 'btn', onClick: () => copy(r.pairing) }, 'Copy pairing code'), h('button', { type: 'button', class: 'btn', onClick: () => copy(r.token) }, 'Copy token'))), h('pre', { class: 'code' }, r.token), h('div', { class: 'foot' }, h('button', { type: 'button', class: 'btn primary', onClick: close }, 'Done'))); reloadTokens(); } catch (x) { e.textContent = x.message; } } }, h('h2', null, 'New API token'), h('p', null, 'For the iOS app or scripts. The pairing QR code holds the server address, the token and the certificate fingerprint.'), h('div', { class: 'field' }, h('label', { htmlFor: 'tn' }, 'Name'), nm), h('div', { class: 'field' }, h('label', { htmlFor: 'ts' }, 'Access'), sc), e, h('div', { class: 'foot' }, h('button', { type: 'button', class: 'btn', onClick: close }, 'Cancel'), h('button', { type: 'submit', class: 'btn primary' }, 'Create token'))); return body; }); return d; }; // logs const logBox = h('pre', { class: 'log', tabindex: '0', 'aria-label': 'Log lines, newest first' }); const logPills = h('div', { class: 'pills', role: 'group', 'aria-label': 'Level filter' }); const drawLogs = async () => { logPills.replaceChildren(...[['all', 'All'], ['info', 'Info'], ['warn', 'Warn'], ['error', 'Error']].map(([k, t]) => h('button', { type: 'button', class: logLevelFilter === k ? 'pill on' : 'pill', 'aria-pressed': String(logLevelFilter === k), onClick: () => { logLevelFilter = k; drawLogs(); } }, t))); try { const r = await api('GET', '/logs?limit=200&level=' + logLevelFilter); logBox.textContent = r.lines.length ? r.lines.map(fmtLogLine).join('\n') : 'No entries at this level.'; } catch (e) { logBox.textContent = e.message; } }; const levelSel = h('select', { id: 'lv', onChange: async (e) => { try { await api('PATCH', '/settings', { log: { ...s.log, level: e.target.value } }); s.log.level = e.target.value; toast('Log level: ' + e.target.value); } catch (x) { toast(x.message, true); } } }, ['debug', 'info', 'warn', 'error'].map((l) => h('option', { value: l, selected: s.log.level === l }, l))); // data retention const presetSelect = (id, value, presets, unit) => { const opts = presets.some(([v]) => v === value) ? presets : [...presets, [value, value + ' ' + unit]].sort((a, b) => a[0] - b[0]); return h('select', { id }, opts.map(([v, t]) => h('option', { value: String(v), selected: v === value }, t))); }; const logSize = h('input', { id: 'rs', type: 'number', min: '1', max: '1000', value: s.log.maxSizeMB, inputMode: 'numeric' }); const logFiles = h('input', { id: 'rf', type: 'number', min: '1', max: '100', value: s.log.maxFiles, inputMode: 'numeric' }); const hourly = presetSelect('rh', s.stats.hourlyHours, [[24, '1 day'], [48, '2 days'], [168, '7 days'], [336, '14 days'], [744, '31 days']], 'hours'); const daily = presetSelect('rd', s.stats.dailyDays, [[30, '30 days'], [90, '90 days'], [180, '6 months'], [400, '13 months'], [730, '2 years'], [1825, '5 years'], [3660, '10 years']], 'days'); const geo = h('input', { type: 'checkbox', checked: s.stats.geoip !== false }); const geoStatus = s.geo && s.geo.updated ? 'Database from ' + fmtDate(s.geo.updated) + '.' : 'Not downloaded yet.'; const diskHint = h('span', { class: 'hint' }); const drawDiskHint = () => { const mb = Number(logSize.value) * (Number(logFiles.value) + 1); diskHint.textContent = mb > 0 ? 'The log uses up to ' + mb + ' MB on disk (current file plus kept files).' : ''; }; logSize.addEventListener('input', drawDiskHint); logFiles.addEventListener('input', drawDiskHint); drawDiskHint(); const retErr = h('p', { class: 'err-text', role: 'alert' }); const saveRetention = async (e) => { e.preventDefault(); retErr.textContent = ''; const next = { maxSizeMB: Number(logSize.value), maxFiles: Number(logFiles.value), hourlyHours: Number(hourly.value), dailyDays: Number(daily.value) }; const shrinks = next.maxFiles < s.log.maxFiles || next.hourlyHours < s.stats.hourlyHours || next.dailyDays < s.stats.dailyDays; if (shrinks && !await confirmDialog({ title: 'Delete older data?', text: 'The new limits are lower: older log files and traffic history beyond them are deleted. This cannot be undone.', ok: 'Save and delete', danger: true })) return; try { await api('PATCH', '/settings', { log: { ...s.log, maxSizeMB: next.maxSizeMB, maxFiles: next.maxFiles }, stats: { hourlyHours: next.hourlyHours, dailyDays: next.dailyDays, geoip: geo.checked }, }); toast('Retention saved'); render(); } catch (x) { retErr.textContent = x.message; } }; // backup const restoreInput = h('input', { type: 'file', accept: 'application/json,.json', hidden: true, onChange: async (e) => { const f = e.target.files[0]; e.target.value = ''; if (!f) return; let cfg; try { cfg = JSON.parse(await f.text()); } catch { toast('This file is not valid JSON', true); return; } if (!await confirmDialog({ title: 'Restore this backup?', text: 'All current settings, peers and tokens are replaced by the ones in ' + f.name + '.', ok: 'Restore', danger: true })) return; try { const res = await api('POST', '/restore', cfg); applied(res, 'Backup restored'); restartBox.replaceChildren(h('div', { class: 'notice' }, 'Backup restored. Restart to apply web interface settings.', h('button', { type: 'button', class: 'btn small', onClick: restartNow }, 'Restart now'))); } catch (x) { toast(x.message, true); } } }); fill(wrap, h('div', null, h('h1', null, 'Settings'), h('p', { class: 'sub' }, 'Web interface, API access for the iOS app, logs, data retention and backups')), restartBox, h('form', { class: 'card', onSubmit: savePw, 'aria-labelledby': 'acc' }, h('h2', { id: 'acc' }, 'Admin account'), h('p', { class: 'lead' }, 'One admin account. The iOS app signs in with an API token, not this password.'), h('div', { class: 'grid' }, h('div', { class: 'field' }, h('label', { htmlFor: 'u' }, 'Username'), uname), h('div', { class: 'field' }, h('label', { htmlFor: 'pc' }, 'Current password'), cur), h('div', { class: 'field' }, h('label', { htmlFor: 'p1' }, 'New password'), p1), h('div', { class: 'field' }, h('label', { htmlFor: 'p2' }, 'Confirm new password'), p2)), accErr, h('div', { class: 'formfoot' }, h('button', { type: 'submit', class: 'btn' }, 'Save account'))), h('form', { class: 'card', onSubmit: saveWeb, 'aria-labelledby': 'web' }, h('h2', { id: 'web' }, 'Web interface'), h('p', { class: 'lead' }, 'Changes take effect after the service restarts.'), h('div', { class: 'grid' }, h('div', { class: 'field' }, h('label', { htmlFor: 'la' }, 'Listen address'), h('input', { id: 'la', class: 'mono', value: web.listen, onInput: (e) => { web.listen = e.target.value.trim(); } })), h('div', { class: 'field' }, h('label', { htmlFor: 'hl' }, 'HTTP listen address'), h('input', { id: 'hl', class: 'mono', value: web.httpListen, placeholder: 'off', onInput: (e) => { web.httpListen = e.target.value.trim(); } }), h('span', { class: 'hint' }, 'Redirects to HTTPS and answers Let\'s Encrypt http-01 checks. Empty turns it off')), h('div', { class: 'field' }, h('label', { htmlFor: 'tls' }, 'HTTPS'), modeSel), h('div', { class: 'field' }, h('label', { htmlFor: 'st' }, 'Session length'), h('select', { id: 'st', onChange: (e) => { web.sessionHours = Number(e.target.value); } }, [[1, '1 hour'], [12, '12 hours'], [24, '1 day'], [168, '7 days']].map(([v, t]) => h('option', { value: String(v), selected: web.sessionHours === v }, t)))), s.fingerprint ? h('div', { class: 'field' }, h('label', { htmlFor: 'fp' }, 'Certificate fingerprint (SHA-256)'), h('input', { id: 'fp', class: 'mono', value: s.fingerprint, readOnly: true }), h('span', { class: 'hint' }, 'The iOS app pins this when pairing')) : null), h('div', { class: 'section' }, fAcme, fFiles), webErr, h('div', { class: 'formfoot' }, h('button', { type: 'submit', class: 'btn primary' }, 'Save'))), h('section', { class: 'card', 'aria-labelledby': 'api' }, h('div', { class: 'cardhead' }, h('div', null, h('h2', { id: 'api' }, 'API tokens'), h('p', { class: 'lead', style: { marginBottom: '0' } }, 'For the iOS app and scripts. A token appears once when you create it, and only a hash is stored.')), h('button', { type: 'button', class: 'btn primary', onClick: newToken }, 'Pair iOS app')), h('div', { class: 'tbl section' }, h('table', { class: 'narrow' }, h('thead', null, h('tr', null, h('th', null, 'Name'), h('th', null, 'Access'), h('th', null, 'Created'), h('th', null, 'Last used'), h('th', null, h('span', { class: 'sr' }, 'Actions')))), tbody))), h('section', { class: 'card', 'aria-labelledby': 'lg' }, h('div', { class: 'cardhead' }, h('div', null, h('h2', { id: 'lg' }, 'Log'), h('p', { class: 'lead', style: { marginBottom: '0' } }, h('span', { class: 'mono' }, s.logPath), ' · rotates at ' + s.log.maxSizeMB + ' MB, keeps ' + s.log.maxFiles + ' files')), logPills), h('div', { class: 'section' }, logBox), h('div', { class: 'grid section' }, h('div', { class: 'field' }, h('label', { htmlFor: 'lv' }, 'Log level'), levelSel), h('div', { class: 'field', style: { justifyContent: 'flex-end' } }, h('a', { class: 'btn', href: '/api/v1/logs/download' }, 'Download log')))), h('form', { class: 'card', onSubmit: saveRetention, 'aria-labelledby': 'ret' }, h('h2', { id: 'ret' }, 'Data retention'), h('p', { class: 'lead' }, 'How much log and traffic history is kept. Changes apply immediately, without a restart.'), h('div', { class: 'grid' }, h('div', { class: 'field' }, h('label', { htmlFor: 'rs' }, 'Log file size (MB)'), logSize, h('span', { class: 'hint' }, 'The log starts a new file at this size. 1–1000')), h('div', { class: 'field' }, h('label', { htmlFor: 'rf' }, 'Old log files kept'), logFiles, diskHint), h('div', { class: 'field' }, h('label', { htmlFor: 'rh' }, 'Hourly traffic history'), hourly, h('span', { class: 'hint' }, 'Used by the 24-hour charts')), h('div', { class: 'field' }, h('label', { htmlFor: 'rd' }, 'Daily traffic history'), daily, h('span', { class: 'hint' }, 'Used by the 7- and 30-day charts and the connection history. All-time totals are always kept'))), h('label', { class: 'check section' }, geo, h('span', null, 'Show country and network of peer addresses', h('br'), h('span', { class: 'hint' }, 'Downloads the free DB-IP Lite databases (about 20 MB) once a month and looks addresses up on this server only. ' + geoStatus))), retErr, h('div', { class: 'formfoot' }, h('button', { type: 'submit', class: 'btn primary' }, 'Save retention'))), h('section', { class: 'card', 'aria-labelledby': 'bk' }, h('h2', { id: 'bk' }, 'Backup & restore'), h('p', { class: 'lead' }, 'A backup is a copy of config.json with server key, peers, tokens and settings. Keep it safe: it contains the server\'s private key.'), h('div', { class: 'actions' }, h('a', { class: 'btn', href: '/api/v1/backup' }, 'Download backup'), h('button', { type: 'button', class: 'btn', onClick: () => restoreInput.click() }, 'Restore from file…'), restoreInput))); await drawLogs(); } render(); })();