diff --git a/README.md b/README.md
index 43f5173..45056bb 100644
--- a/README.md
+++ b/README.md
@@ -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
server has a global IPv6 address.
- **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,
address and traffic. A new session starts when a device changes networks.
Country and network operator come from the free
[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
- never leave the server. You can switch this off under Settings → Data
- retention.
+ never leave the server. You can switch this off under Settings → Logs &
+ history.
- **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
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
diff --git a/api.go b/api.go
index 2a0d077..8f4e31c 100644
--- a/api.go
+++ b/api.go
@@ -1022,12 +1022,19 @@ func (a *App) patchSettings(w http.ResponseWriter, r *http.Request) {
}
var restart bool
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 {
return err
}
- after, _ := json.Marshal(c.Web)
- restart = string(before) != string(after)
+ restart = listen() != before
if err := field(m, "stats", &c.Stats); err != nil {
return err
}
diff --git a/app.css b/app.css
index a0ceb07..2ebeac1 100644
--- a/app.css
+++ b/app.css
@@ -370,3 +370,11 @@ dialog::backdrop { background: rgba(22, 23, 26, .55); }
.mfarow .grow { flex: 1; min-width: 0; }
.dlg .secret { font-size: 15px; letter-spacing: 0.04em; overflow-wrap: anywhere; }
.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; }
diff --git a/app.js b/app.js
index 0491787..a3d767a 100644
--- a/app.js
+++ b/app.js
@@ -49,6 +49,7 @@
settings: '',
plus: '',
key: '',
+ log: '',
logout: '',
};
@@ -528,7 +529,7 @@
// ---------- 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 srvBox, peerCount, verRow;
@@ -594,7 +595,8 @@
[/^#\/peers\/new$/, '#/peers', viewPeerNew],
[/^#\/peers\/([\w-]+)$/, '#/peers', viewPeer],
[/^#\/server$/, '#/server', viewServer],
- [/^#\/settings(#updates)?$/, '#/settings', viewSettings],
+ [/^#\/log$/, '#/log', viewLog],
+ [/^#\/settings(#\w+)?$/, '#/settings', viewSettings],
[/^#\/account$/, '#/account', viewAccount],
];
@@ -1049,7 +1051,7 @@
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')),
+ h('div', { class: 'cardhead' }, h('h2', null, 'Recent activity'), h('a', { href: '#/log' }, '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));
@@ -1707,6 +1709,36 @@
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 ----------
const HIDE_UPDATE = 'GHOSTWIRE.hideUpdate';
@@ -1789,7 +1821,7 @@
].join('\n') : null;
fill(card,
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),
h('div', { class: 'upvers' },
h('div', { class: 'upbox' }, h('span', null, 'Running'), h('strong', { class: 'mono' }, cur)),
@@ -1933,7 +1965,6 @@
return;
}
const [s, tk, us] = await Promise.all([api('GET', '/settings'), api('GET', '/tokens'), api('GET', '/users')]);
- let logLevelFilter = 'all';
// users
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); }
};
- // 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)));
+ 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 sessSel = h('select', { id: 'st', onChange: async (e) => {
+ 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); }
+ } }, [[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)));
+ const geoBox = h('input', { type: 'checkbox', id: 'geo', checked: s.stats.geoip !== false, onChange: async (e) => {
+ 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); }
+ } });
// sign-in rules
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 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 = () => {
@@ -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;
try {
await api('PATCH', '/settings', {
- log: { ...s.log, maxSizeMB: next.maxSizeMB, maxFiles: next.maxFiles },
- stats: { hourlyHours: next.hourlyHours, dailyDays: next.dailyDays, geoip: geo.checked },
+ log: { ...s.log, level: levelSel.value, maxSizeMB: next.maxSizeMB, maxFiles: next.maxFiles },
+ stats: { ...s.stats, hourlyHours: next.hourlyHours, dailyDays: next.dailyDays },
});
- toast('Retention saved');
+ toast('Logs & history saved');
render();
} catch (x) { retErr.textContent = x.message; }
};
@@ -2203,88 +2226,92 @@
} 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,
- 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,
+ groupHead('g-access', 'Access', 'Who can sign in, and how.'),
h('section', { class: 'card flush', 'aria-labelledby': 'usr' },
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('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')))),
userBody))),
h('section', { class: 'card', 'aria-labelledby': 'sgn' },
- h('h2', { id: 'sgn' }, 'Sign-in'),
- 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('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.'),
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('h2', { id: 'web' }, 'Web interface'),
- h('p', { class: 'lead' }, 'Changes take effect after the service restarts.'),
+ h('div', { class: 'cardhead' }, h('h3', { id: 'web' }, 'Address and HTTPS'), h('span', { class: 'saves' }, 'Save, then restart')),
+ h('p', { class: 'lead' }, 'Usually set once during install.'),
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': 'dcy' },
- h('h2', { id: 'dcy' }, 'Decoy'),
- 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('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.'),
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('div', { class: 'grid section' },
h('div', { class: 'field' }, h('label', { htmlFor: 'dp' }, 'Decoy page'), decoySel))),
- 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: () => 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')))),
-
+ groupHead('logs', 'Logs & history', 'What the server records and for how long. The log itself is on the Log page.'),
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: 'cardhead' }, h('h3', { id: 'ret' }, 'Log and traffic history'), h('span', { class: 'saves' }, 'Save, no 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: '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: '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: '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))),
+ 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('div', { class: 'field' })),
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('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('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)),
-
- updatesCard(s.updates));
- await drawLogs();
- if (location.hash.endsWith('#updates')) document.getElementById('updates').scrollIntoView();
+ restoreInput)));
+ const jumpTo = location.hash.split('#')[2];
+ if (jumpTo) document.getElementById(jumpTo)?.scrollIntoView();
}
+
render();
})();
diff --git a/main_test.go b/main_test.go
index f5d90e7..d333318 100644
--- a/main_test.go
+++ b/main_test.go
@@ -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[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.
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)