Settings in groups; the log on its own page

Settings is grouped into Access (users, sign-in, iOS app and API tokens),
Web interface (address and HTTPS, Decoy), Logs & history and Upkeep
(updates, backup). Session length moved to Sign-in and no longer asks for
a restart. Log level, log size and traffic history share one card; the
country lookup is its own switch. Each card says how it saves.

The log viewer moved to a new Log page in the sidebar, with a filter for
changes only, and the Dashboard's Log link opens it.
This commit is contained in:
Daniel Redetzke
2026-10-05 12:11:32 +03:00
parent ae95781427
commit fc85da2407
5 changed files with 125 additions and 71 deletions
+5 -4
View File
@@ -27,16 +27,17 @@ dependencies on the server: the binary installs, updates and removes itself.
- **IPv4 and IPv6:** IPv6 inside the tunnel is turned on automatically when the - **IPv4 and IPv6:** IPv6 inside the tunnel is turned on automatically when the
server has a global IPv6 address. server has a global IPv6 address.
- **Traffic history:** kept in `stats.json`, hourly for 48 h and daily for - **Traffic history:** kept in `stats.json`, hourly for 48 h and daily for
400 days by default (Settings → Data retention). 400 days by default (Settings → Logs & history).
- **Connection history:** every online session per peer, with start, duration, - **Connection history:** every online session per peer, with start, duration,
address and traffic. A new session starts when a device changes networks. address and traffic. A new session starts when a device changes networks.
Country and network operator come from the free Country and network operator come from the free
[DB-IP Lite](https://db-ip.com) databases (CC BY 4.0). GHOSTWIRE downloads [DB-IP Lite](https://db-ip.com) databases (CC BY 4.0). GHOSTWIRE downloads
them monthly (about 20 MB) and looks addresses up locally, so peer addresses them monthly (about 20 MB) and looks addresses up locally, so peer addresses
never leave the server. You can switch this off under Settings → Data never leave the server. You can switch this off under Settings → Logs &
retention. history.
- **Logs:** written to `GHOSTWIRE.jsonl`, rotated at 10 MB with 5 old files - **Logs:** written to `GHOSTWIRE.jsonl`, rotated at 10 MB with 5 old files
kept by default. Changes are marked as audit entries. kept by default, and shown on the Log page. Changes are marked as audit
entries.
- **Update notice:** once a day the server asks Gitea or GitHub (your choice - **Update notice:** once a day the server asks Gitea or GitHub (your choice
under Settings → Updates) for the latest release. A newer one shows in the under Settings → Updates) for the latest release. A newer one shows in the
sidebar, on the Dashboard and in Settings, with its release notes and the sidebar, on the Dashboard and in Settings, with its release notes and the
+10 -3
View File
@@ -1022,12 +1022,19 @@ func (a *App) patchSettings(w http.ResponseWriter, r *http.Request) {
} }
var restart bool var restart bool
err = a.store.Update(func(c *Config) error { err = a.store.Update(func(c *Config) error {
before, _ := json.Marshal(c.Web) // Session length applies to the next sign-in; everything else in
// web needs a restart.
listen := func() string {
w := c.Web
w.SessionHours = 0
b, _ := json.Marshal(w)
return string(b)
}
before := listen()
if err := field(m, "web", &c.Web); err != nil { if err := field(m, "web", &c.Web); err != nil {
return err return err
} }
after, _ := json.Marshal(c.Web) restart = listen() != before
restart = string(before) != string(after)
if err := field(m, "stats", &c.Stats); err != nil { if err := field(m, "stats", &c.Stats); err != nil {
return err return err
} }
+8
View File
@@ -370,3 +370,11 @@ dialog::backdrop { background: rgba(22, 23, 26, .55); }
.mfarow .grow { flex: 1; min-width: 0; } .mfarow .grow { flex: 1; min-width: 0; }
.dlg .secret { font-size: 15px; letter-spacing: 0.04em; overflow-wrap: anywhere; } .dlg .secret { font-size: 15px; letter-spacing: 0.04em; overflow-wrap: anywhere; }
.dlg .codes { columns: 2; font-size: 15px; line-height: 1.8; } .dlg .codes { columns: 2; font-size: 15px; line-height: 1.8; }
/* settings groups; the log page */
.group { margin-top: 20px; display: flex; flex-direction: column; gap: 2px; }
.group h2 { margin: 0; font-size: 19px; font-weight: 600; }
.group p { margin: 0; font-size: 13px; color: var(--ink-2); }
.card h3 { margin: 0; font-size: 16px; font-weight: 600; }
.saves { font-size: 12px; color: var(--ink-3); }
pre.log.tall { max-height: calc(100vh - 260px); min-height: 420px; }
+91 -64
View File
@@ -49,6 +49,7 @@
settings: '<path d="M4 6h10M18 6h2M4 12h4M12 12h8M4 18h12"/><circle cx="16" cy="6" r="2"/><circle cx="10" cy="12" r="2"/><circle cx="18" cy="18" r="2"/>', settings: '<path d="M4 6h10M18 6h2M4 12h4M12 12h8M4 18h12"/><circle cx="16" cy="6" r="2"/><circle cx="10" cy="12" r="2"/><circle cx="18" cy="18" r="2"/>',
plus: '<path d="M12 5v14M5 12h14"/>', plus: '<path d="M12 5v14M5 12h14"/>',
key: '<circle cx="8" cy="15" r="4"/><path d="M11 12l9-9M17 6l3 3M14 9l2 2"/>', key: '<circle cx="8" cy="15" r="4"/><path d="M11 12l9-9M17 6l3 3M14 9l2 2"/>',
log: '<rect x="4" y="3" width="16" height="18" rx="2"/><path d="M8 8h8M8 12h8M8 16h5"/>',
logout: '<path d="M14 4h4a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-4"/><path d="M10 16l-4-4 4-4M6 12h10"/>', logout: '<path d="M14 4h4a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-4"/><path d="M10 16l-4-4 4-4M6 12h10"/>',
}; };
@@ -528,7 +529,7 @@
// ---------- shell, router ---------- // ---------- shell, router ----------
const NAV = [['#/', 'dashboard', 'Dashboard'], ['#/peers', 'peers', 'Peers'], ['#/server', 'server', 'Server'], ['#/settings', 'settings', 'Settings']]; const NAV = [['#/', 'dashboard', 'Dashboard'], ['#/peers', 'peers', 'Peers'], ['#/server', 'server', 'Server'], ['#/log', 'log', 'Log'], ['#/settings', 'settings', 'Settings']];
let navLinks = {}; let navLinks = {};
let srvBox, peerCount, verRow; let srvBox, peerCount, verRow;
@@ -594,7 +595,8 @@
[/^#\/peers\/new$/, '#/peers', viewPeerNew], [/^#\/peers\/new$/, '#/peers', viewPeerNew],
[/^#\/peers\/([\w-]+)$/, '#/peers', viewPeer], [/^#\/peers\/([\w-]+)$/, '#/peers', viewPeer],
[/^#\/server$/, '#/server', viewServer], [/^#\/server$/, '#/server', viewServer],
[/^#\/settings(#updates)?$/, '#/settings', viewSettings], [/^#\/log$/, '#/log', viewLog],
[/^#\/settings(#\w+)?$/, '#/settings', viewSettings],
[/^#\/account$/, '#/account', viewAccount], [/^#\/account$/, '#/account', viewAccount],
]; ];
@@ -1049,7 +1051,7 @@
h('td', { class: 'num' }, fmtBytes(p.stats.up24h))))))) h('td', { class: 'num' }, fmtBytes(p.stats.up24h)))))))
: h('p', { class: 'empty' }, 'No peers yet. ', h('a', { href: '#/peers/new' }, 'Add the first one'))), : h('p', { class: 'empty' }, 'No peers yet. ', h('a', { href: '#/peers/new' }, 'Add the first one'))),
logs ? h('section', { class: 'card' }, logs ? h('section', { class: 'card' },
h('div', { class: 'cardhead' }, h('h2', null, 'Recent activity'), h('a', { href: '#/settings' }, 'Log')), h('div', { class: 'cardhead' }, h('h2', null, 'Recent activity'), h('a', { href: '#/log' }, 'Log')),
logs.lines.length 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('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)); : h('p', { class: 'empty' }, 'No changes yet.')) : null));
@@ -1707,6 +1709,36 @@
bar); bar);
} }
// ---------- log ----------
async function viewLog(wrap) {
const s = await api('GET', '/settings');
let level = 'all';
const box = h('pre', { class: 'log tall', tabindex: '0', 'aria-label': 'Log lines, newest first' });
const pills = h('div', { class: 'pills', role: 'group', 'aria-label': 'Level filter' });
let audit = false;
const draw = async () => {
pills.replaceChildren(...[['all', 'All'], ['info', 'Info'], ['warn', 'Warn'], ['error', 'Error']].map(([k, t]) =>
h('button', { type: 'button', class: level === k && !audit ? 'pill on' : 'pill', 'aria-pressed': String(level === k && !audit), onClick: () => { level = k; audit = false; draw(); } }, t)),
h('button', { type: 'button', class: audit ? 'pill on' : 'pill', 'aria-pressed': String(audit), onClick: () => { audit = true; draw(); } }, 'Changes only'));
try {
const r = await api('GET', '/logs?limit=500&level=' + level + (audit ? '&audit=1' : ''));
box.textContent = r.lines.length ? r.lines.map(fmtLogLine).join('\n') : 'No entries at this level.';
} catch (e) { box.textContent = e.message; }
};
fill(wrap,
h('div', { class: 'head' },
h('div', null, h('h1', null, 'Log'), h('p', { class: 'sub' }, h('span', { class: 'mono' }, s.logPath), ' · level ' + s.log.level + ' · rotates at ' + s.log.maxSizeMB + ' MB, keeps ' + s.log.maxFiles + ' files')),
h('div', { class: 'actions' },
h('a', { class: 'btn', href: '#/settings#logs' }, 'Log settings'),
h('a', { class: 'btn', href: '/api/v1/logs/download' }, 'Download log'))),
h('section', { class: 'card', 'aria-label': 'Log lines' },
h('div', { class: 'cardhead' }, h('span', { class: 'muted' }, 'Newest first · refreshes every 10 s'), pills),
h('div', { class: 'section' }, box)));
every(10000, draw);
await draw();
}
// ---------- updates ---------- // ---------- updates ----------
const HIDE_UPDATE = 'GHOSTWIRE.hideUpdate'; const HIDE_UPDATE = 'GHOSTWIRE.hideUpdate';
@@ -1789,7 +1821,7 @@
].join('\n') : null; ].join('\n') : null;
fill(card, fill(card,
h('div', { class: 'cardhead' }, h('div', { class: 'cardhead' },
h('h2', { id: 'upd' }, 'Updates'), h('h3', { id: 'upd' }, 'Updates'),
st.enabled ? h('span', { class: 'muted' }, st.checked ? 'Last checked ' + ago(st.checked) : 'Not checked yet') : null), st.enabled ? h('span', { class: 'muted' }, st.checked ? 'Last checked ' + ago(st.checked) : 'Not checked yet') : null),
h('div', { class: 'upvers' }, h('div', { class: 'upvers' },
h('div', { class: 'upbox' }, h('span', null, 'Running'), h('strong', { class: 'mono' }, cur)), h('div', { class: 'upbox' }, h('span', null, 'Running'), h('strong', { class: 'mono' }, cur)),
@@ -1933,7 +1965,6 @@
return; return;
} }
const [s, tk, us] = await Promise.all([api('GET', '/settings'), api('GET', '/tokens'), api('GET', '/users')]); const [s, tk, us] = await Promise.all([api('GET', '/settings'), api('GET', '/tokens'), api('GET', '/users')]);
let logLevelFilter = 'all';
// users // users
const userBody = h('tbody'); const userBody = h('tbody');
@@ -2098,20 +2129,13 @@
try { await api('DELETE', '/tokens/' + t.id); toast('Revoked ' + t.name); reloadTokens(); } catch (e) { toast(e.message, true); } try { await api('DELETE', '/tokens/' + t.id); toast('Revoked ' + t.name); reloadTokens(); } catch (e) { toast(e.message, true); }
}; };
// logs const levelSel = h('select', { id: 'lv' }, [['debug', 'Debug: everything'], ['info', 'Info'], ['warn', 'Warnings and errors'], ['error', 'Errors only']].map(([l, t]) => h('option', { value: l, selected: s.log.level === l }, t)));
const logBox = h('pre', { class: 'log', tabindex: '0', 'aria-label': 'Log lines, newest first' }); const sessSel = h('select', { id: 'st', onChange: async (e) => {
const logPills = h('div', { class: 'pills', role: 'group', 'aria-label': 'Level filter' }); try { await api('PATCH', '/settings', { web: { ...s.web, sessionHours: Number(e.target.value) } }); s.web.sessionHours = Number(e.target.value); toast('Session length saved'); } catch (x) { toast(x.message, true); }
const drawLogs = async () => { } }, [[1, '1 hour'], [12, '12 hours'], [24, '1 day'], [168, '7 days']].map(([v, t]) => h('option', { value: String(v), selected: s.web.sessionHours === v }, t)));
logPills.replaceChildren(...[['all', 'All'], ['info', 'Info'], ['warn', 'Warn'], ['error', 'Error']].map(([k, t]) => const geoBox = h('input', { type: 'checkbox', id: 'geo', checked: s.stats.geoip !== false, onChange: async (e) => {
h('button', { type: 'button', class: logLevelFilter === k ? 'pill on' : 'pill', 'aria-pressed': String(logLevelFilter === k), onClick: () => { logLevelFilter = k; drawLogs(); } }, t))); try { await api('PATCH', '/settings', { stats: { ...s.stats, geoip: e.target.checked } }); toast(e.target.checked ? 'Country lookup on' : 'Country lookup off'); } catch (x) { e.target.checked = !e.target.checked; toast(x.message, true); }
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)));
// sign-in rules // sign-in rules
const requireBox = h('input', { type: 'checkbox', id: 'rq', checked: s.signin.requireMfa, onChange: async (e) => { const requireBox = h('input', { type: 'checkbox', id: 'rq', checked: s.signin.requireMfa, onChange: async (e) => {
@@ -2161,7 +2185,6 @@
const logFiles = h('input', { id: 'rf', type: 'number', min: '1', max: '100', value: s.log.maxFiles, 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 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 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 geoStatus = s.geo && s.geo.updated ? 'Database from ' + fmtDate(s.geo.updated) + '.' : 'Not downloaded yet.';
const diskHint = h('span', { class: 'hint' }); const diskHint = h('span', { class: 'hint' });
const drawDiskHint = () => { const drawDiskHint = () => {
@@ -2180,10 +2203,10 @@
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; 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 { try {
await api('PATCH', '/settings', { await api('PATCH', '/settings', {
log: { ...s.log, maxSizeMB: next.maxSizeMB, maxFiles: next.maxFiles }, log: { ...s.log, level: levelSel.value, maxSizeMB: next.maxSizeMB, maxFiles: next.maxFiles },
stats: { hourlyHours: next.hourlyHours, dailyDays: next.dailyDays, geoip: geo.checked }, stats: { ...s.stats, hourlyHours: next.hourlyHours, dailyDays: next.dailyDays },
}); });
toast('Retention saved'); toast('Logs & history saved');
render(); render();
} catch (x) { retErr.textContent = x.message; } } catch (x) { retErr.textContent = x.message; }
}; };
@@ -2203,88 +2226,92 @@
} catch (x) { toast(x.message, true); } } catch (x) { toast(x.message, true); }
} }); } });
const groupHead = (id, title, text) => h('div', { class: 'group', id }, h('h2', null, title), h('p', null, text));
fill(wrap, fill(wrap,
h('div', null, h('h1', null, 'Settings'), h('p', { class: 'sub' }, 'Users, web interface, API access for the iOS app, logs, data retention, backups and updates')), h('div', null, h('h1', null, 'Settings'), h('p', { class: 'sub' }, 'Who can sign in, the web interface, logs and history, updates and backups')),
restartBox, restartBox,
groupHead('g-access', 'Access', 'Who can sign in, and how.'),
h('section', { class: 'card flush', 'aria-labelledby': 'usr' }, h('section', { class: 'card flush', 'aria-labelledby': 'usr' },
h('div', { class: 'cardhead' }, h('div', { class: 'cardhead' },
h('div', null, h('h2', { id: 'usr' }, 'Users'), h('p', { class: 'lead', style: { marginBottom: '0' } }, 'Everyone here is an admin. You cannot delete yourself, so one user always remains.')), h('div', null, h('h3', { id: 'usr' }, 'Users'), h('p', { class: 'lead', style: { marginBottom: '0' } }, 'Everyone here is an admin. You cannot delete yourself, so one user always remains.')),
h('button', { type: 'button', class: 'btn primary', onClick: addUser }, 'Add user')), h('button', { type: 'button', class: 'btn primary', onClick: addUser }, 'Add user')),
h('div', { class: 'tbl' }, h('table', null, h('div', { class: 'tbl' }, h('table', null,
h('thead', null, h('tr', null, h('th', null, 'User'), h('th', null, 'Status'), h('th', null, 'Two-step'), h('th', null, 'Last sign-in'), h('th', null, 'App tokens'), h('th', null, 'Created'), h('th', null, h('span', { class: 'sr' }, 'Actions')))), h('thead', null, h('tr', null, h('th', null, 'User'), h('th', null, 'Status'), h('th', null, 'Two-step'), h('th', null, 'Last sign-in'), h('th', null, 'App tokens'), h('th', null, 'Created'), h('th', null, h('span', { class: 'sr' }, 'Actions')))),
userBody))), userBody))),
h('section', { class: 'card', 'aria-labelledby': 'sgn' }, h('section', { class: 'card', 'aria-labelledby': 'sgn' },
h('h2', { id: 'sgn' }, 'Sign-in'), h('div', { class: 'cardhead' }, h('h3', { id: 'sgn' }, 'Sign-in'), h('span', { class: 'saves' }, 'Saves right away')),
h('p', { class: 'lead' }, 'Everyone sets up two-step sign-in under My account: an authenticator app or passkeys, including on a YubiKey. Changes apply immediately.'), h('p', { class: 'lead' }, 'Everyone sets up two-step sign-in under My account: an authenticator app or passkeys, including on a YubiKey.'),
h('label', { class: 'check' }, requireBox, h('span', null, 'Require two-step sign-in for everyone', h('br'), h('label', { class: 'check' }, requireBox, h('span', null, 'Require two-step sign-in for everyone', h('br'),
h('span', { class: 'hint' }, 'Users without it are asked to set it up right after their password. To help someone who lost their phone or key, use Edit → Reset two-step sign-in.')))), h('span', { class: 'hint' }, 'Users without it are asked to set it up right after their password. To help someone who lost their phone or key, use Edit → Reset two-step sign-in.'))),
h('div', { class: 'grid section' },
h('div', { class: 'field' }, h('label', { htmlFor: 'st' }, 'Stay signed in for'), sessSel, h('span', { class: 'hint' }, 'Applies to new sign-ins')),
h('div', { class: 'field' }), h('div', { class: 'field' }))),
h('section', { class: 'card', 'aria-labelledby': 'api' },
h('div', { class: 'cardhead' },
h('div', null, h('h3', { id: 'api' }, 'iOS app and 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: () => pairDialog(reloadTokens) }, '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, 'Owner'), h('th', null, 'Access'), h('th', null, 'Created'), h('th', null, 'Last used'), h('th', null, h('span', { class: 'sr' }, 'Actions')))),
tbody))),
groupHead('g-web', 'Web interface', 'Where this interface listens and what strangers see.'),
h('form', { class: 'card', onSubmit: saveWeb, 'aria-labelledby': 'web' }, h('form', { class: 'card', onSubmit: saveWeb, 'aria-labelledby': 'web' },
h('h2', { id: 'web' }, 'Web interface'), h('div', { class: 'cardhead' }, h('h3', { id: 'web' }, 'Address and HTTPS'), h('span', { class: 'saves' }, 'Save, then restart')),
h('p', { class: 'lead' }, 'Changes take effect after the service restarts.'), h('p', { class: 'lead' }, 'Usually set once during install.'),
h('div', { class: 'grid' }, 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: '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: '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: '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), 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), h('div', { class: 'section' }, fAcme, fFiles),
webErr, webErr,
h('div', { class: 'formfoot' }, h('button', { type: 'submit', class: 'btn primary' }, 'Save'))), h('div', { class: 'formfoot' }, h('button', { type: 'submit', class: 'btn primary' }, 'Save'))),
h('section', { class: 'card', 'aria-labelledby': 'dcy' }, h('section', { class: 'card', 'aria-labelledby': 'dcy' },
h('h2', { id: 'dcy' }, 'Decoy'), h('div', { class: 'cardhead' }, h('h3', { id: 'dcy' }, 'Decoy'), h('span', { class: 'saves' }, 'Saves right away')),
h('p', { class: 'lead' }, 'Shows an ordinary web server page instead of this interface. The iOS app and setup links keep working. Changes apply immediately.'), h('p', { class: 'lead' }, 'Shows an ordinary web server page instead of this interface. The iOS app and setup links keep working.'),
h('label', { class: 'check' }, decoyBox, h('span', null, 'Decoy', h('br'), h('label', { class: 'check' }, decoyBox, h('span', null, 'Decoy', h('br'),
h('span', { class: 'hint' }, 'Hides the web interface. Turn it off again in the iOS app.'))), h('span', { class: 'hint' }, 'Hides the web interface. Turn it off again in the iOS app.'))),
h('div', { class: 'grid section' }, h('div', { class: 'grid section' },
h('div', { class: 'field' }, h('label', { htmlFor: 'dp' }, 'Decoy page'), decoySel))), h('div', { class: 'field' }, h('label', { htmlFor: 'dp' }, 'Decoy page'), decoySel))),
h('section', { class: 'card', 'aria-labelledby': 'api' }, groupHead('logs', 'Logs & history', 'What the server records and for how long. The log itself is on the Log page.'),
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: () => pairDialog(reloadTokens) }, '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, 'Owner'), 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('form', { class: 'card', onSubmit: saveRetention, 'aria-labelledby': 'ret' },
h('h2', { id: 'ret' }, 'Data retention'), h('div', { class: 'cardhead' }, h('h3', { id: 'ret' }, 'Log and traffic history'), h('span', { class: 'saves' }, 'Save, no restart')),
h('p', { class: 'lead' }, 'How much log and traffic history is kept. Changes apply immediately, without a restart.'), h('p', { class: 'lead' }, 'Lower limits delete older data when you save. All-time totals are always kept.'),
h('div', { class: 'grid' }, h('div', { class: 'grid' },
h('div', { class: 'field' }, h('label', { htmlFor: 'lv' }, 'Log level'), levelSel),
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: '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: 'rf' }, 'Old log files kept'), logFiles, diskHint)),
h('div', { class: 'grid section' },
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: '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('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')),
h('label', { class: 'check section' }, geo, h('span', null, 'Show country and network of peer addresses', h('br'), h('div', { class: 'field' })),
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, retErr,
h('div', { class: 'formfoot' }, h('button', { type: 'submit', class: 'btn primary' }, 'Save retention'))), h('div', { class: 'formfoot' }, h('a', { class: 'btn', href: '#/log' }, 'Open the log'), h('button', { type: 'submit', class: 'btn primary' }, 'Save'))),
h('section', { class: 'card', 'aria-labelledby': 'geo-h' },
h('div', { class: 'cardhead' }, h('h3', { id: 'geo-h' }, 'Country and network lookup'), h('span', { class: 'saves' }, 'Saves right away')),
h('label', { class: 'check' }, geoBox, 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)))),
groupHead('g-upkeep', 'Upkeep', 'New versions and copies of your settings.'),
updatesCard(s.updates),
h('section', { class: 'card', 'aria-labelledby': 'bk' }, h('section', { class: 'card', 'aria-labelledby': 'bk' },
h('h2', { id: 'bk' }, 'Backup & restore'), h('h3', { 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('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('div', { class: 'actions' },
h('a', { class: 'btn', href: '/api/v1/backup' }, 'Download backup'), h('a', { class: 'btn', href: '/api/v1/backup' }, 'Download backup'),
h('button', { type: 'button', class: 'btn', onClick: () => restoreInput.click() }, 'Restore from file…'), h('button', { type: 'button', class: 'btn', onClick: () => restoreInput.click() }, 'Restore from file…'),
restoreInput)), restoreInput)));
const jumpTo = location.hash.split('#')[2];
updatesCard(s.updates)); if (jumpTo) document.getElementById(jumpTo)?.scrollIntoView();
await drawLogs();
if (location.hash.endsWith('#updates')) document.getElementById('updates').scrollIntoView();
} }
render(); render();
})(); })();
+11
View File
@@ -1195,6 +1195,17 @@ func TestMFA(t *testing.T) {
c3("POST", "/auth/login/recovery", map[string]string{"ticket": ticket, "code": codes[0].(string)}, 401) c3("POST", "/auth/login/recovery", map[string]string{"ticket": ticket, "code": codes[0].(string)}, 401)
c3("POST", "/auth/login/recovery", map[string]string{"ticket": ticket, "code": codes[1].(string)}, 200) c3("POST", "/auth/login/recovery", map[string]string{"ticket": ticket, "code": codes[1].(string)}, 200)
// Session length needs no restart; the listen address does.
web := store.Get().Web
web.SessionHours = 24
if r := adm("PATCH", "/settings", map[string]any{"web": web}, 200); r["restartRequired"] != false || store.Get().Web.SessionHours != 24 {
t.Fatalf("session length: %v", r)
}
web.Listen = "127.0.0.1:9443"
if r := adm("PATCH", "/settings", map[string]any{"web": web}, 200); r["restartRequired"] != true {
t.Fatalf("listen address: %v", r)
}
// Required for everyone: a user without it can only set it up. // Required for everyone: a user without it can only set it up.
adm("PATCH", "/settings", map[string]any{"signin": map[string]bool{"requireMfa": true}}, 200) adm("PATCH", "/settings", map[string]any{"signin": map[string]bool{"requireMfa": true}}, 200)
u := adm("POST", "/users", map[string]any{"username": "eve", "password": "eve's password 1", "mustChangePassword": false}, 201)["user"].(map[string]any) u := adm("POST", "/users", map[string]any{"username": "eve", "password": "eve's password 1", "mustChangePassword": false}, 201)["user"].(map[string]any)