Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 32d5621cf4 | |||
| 95bb95cecd |
@@ -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
|
||||||
@@ -181,7 +182,7 @@ the running service.
|
|||||||
| Command | What it does |
|
| Command | What it does |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `GHOSTWIRE install [-domain d] [-email e] [-endpoint h] [-port p] [-y]` | Sets up and starts the service, as above. Asks for the settings no flag gave; `-y` never asks. |
|
| `GHOSTWIRE install [-domain d] [-email e] [-endpoint h] [-port p] [-y]` | Sets up and starts the service, as above. Asks for the settings no flag gave; `-y` never asks. |
|
||||||
| `GHOSTWIRE update [-force]` | Run from the new binary, e.g. `sudo /tmp/GHOSTWIRE update`. Checks that it can read the current `config.json` (nothing changes if not), backs up the config to `config.json.bak-<old version>`, replaces the binary, updates the unit if needed and restarts. If the new version does not stay up, the old binary and config are put back and restarted. It refuses older versions without `-force`. |
|
| `GHOSTWIRE update [-force]` | Run from the new binary, e.g. `sudo /tmp/GHOSTWIRE update`. Checks that it can read the current `config.json` (nothing changes if not), backs up the config to `config.json.bak-<old version>` (keeping the newest 3 such copies), replaces the binary, updates the unit if needed and restarts. If the new version does not stay up, the old binary and config are put back and restarted. It refuses older versions without `-force`. |
|
||||||
| `GHOSTWIRE uninstall [-purge] [-y]` | Stops and removes the service, `wg0` and the firewall table. `-purge` also deletes `/opt/ghostwire` and the user. |
|
| `GHOSTWIRE uninstall [-purge] [-y]` | Stops and removes the service, `wg0` and the firewall table. `-purge` also deletes `/opt/ghostwire` and the user. |
|
||||||
| `GHOSTWIRE passwd [username]` | Sets a user's password (default: the first user) and reloads the running service. The way back in if you are locked out. |
|
| `GHOSTWIRE passwd [username]` | Sets a user's password (default: the first user) and reloads the running service. The way back in if you are locked out. |
|
||||||
| `GHOSTWIRE version` | Prints the version. |
|
| `GHOSTWIRE version` | Prints the version. |
|
||||||
@@ -223,6 +224,7 @@ After editing `config.json` by hand, run `sudo systemctl reload ghostwire`.
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `GHOSTWIRE` | the program |
|
| `GHOSTWIRE` | the program |
|
||||||
| `config.json` | all settings, server key, peers, pending setup links with their PINs, user password hashes, authenticator app secrets, passkeys, recovery code and token hashes (0600) |
|
| `config.json` | all settings, server key, peers, pending setup links with their PINs, user password hashes, authenticator app secrets, passkeys, recovery code and token hashes (0600) |
|
||||||
|
| `config.json.bak-*` | copies of `config.json` made by `update`; the newest 3 are kept, and Settings → Upkeep lists and removes them |
|
||||||
| `stats.json` | traffic and connection history per peer |
|
| `stats.json` | traffic and connection history per peer |
|
||||||
| `geo-country.mmdb`, `geo-asn.mmdb` | DB-IP Lite databases for country and network lookups |
|
| `geo-country.mmdb`, `geo-asn.mmdb` | DB-IP Lite databases for country and network lookups |
|
||||||
| `GHOSTWIRE.jsonl` | log, one JSON object per line. Changes carry `"audit":true` |
|
| `GHOSTWIRE.jsonl` | log, one JSON object per line. Changes carry `"audit":true` |
|
||||||
@@ -272,6 +274,7 @@ GET /peers/{id}/setup (not read-only) DELETE /peers/{id}/setup
|
|||||||
GET /settings PATCH /settings POST /restart POST /updates/check
|
GET /settings PATCH /settings POST /restart POST /updates/check
|
||||||
GET /logs?level=&limit=&audit=1 GET /logs/download
|
GET /logs?level=&limit=&audit=1 GET /logs/download
|
||||||
signed in: GET|POST /tokens · DELETE /tokens/{id} · GET /backup · POST /restore
|
signed in: GET|POST /tokens · DELETE /tokens/{id} · GET /backup · POST /restore
|
||||||
|
signed in: GET|DELETE /update-backups · DELETE /update-backups/{name} (config copies made by update)
|
||||||
public: GET /setup/{token} · POST /setup/{token} {"pin"} (what a setup link opens)
|
public: GET /setup/{token} · POST /setup/{token} {"pin"} (what a setup link opens)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -183,6 +183,9 @@ func (a *App) routes() http.Handler {
|
|||||||
g("GET /api/v1/logs/download", a.downloadLog)
|
g("GET /api/v1/logs/download", a.downloadLog)
|
||||||
adm("GET /api/v1/backup", a.backup)
|
adm("GET /api/v1/backup", a.backup)
|
||||||
adm("POST /api/v1/restore", a.restore)
|
adm("POST /api/v1/restore", a.restore)
|
||||||
|
adm("GET /api/v1/update-backups", a.listUpdateBackups)
|
||||||
|
adm("DELETE /api/v1/update-backups", a.removeUpdateBackups)
|
||||||
|
adm("DELETE /api/v1/update-backups/{name}", a.removeUpdateBackup)
|
||||||
|
|
||||||
mux.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "no such endpoint"})
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": "no such endpoint"})
|
||||||
@@ -1022,12 +1025,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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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,14 +2203,45 @@
|
|||||||
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; }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// copies of config.json that updates leave behind
|
||||||
|
const copies = h('div', { class: 'section' });
|
||||||
|
const drawCopies = async () => {
|
||||||
|
let list;
|
||||||
|
try { list = (await api('GET', '/update-backups')).backups; } catch (x) { fill(copies, h('p', { class: 'err-text' }, x.message)); return; }
|
||||||
|
const remove = async (b) => {
|
||||||
|
if (!await confirmDialog({ title: 'Remove ' + b.name + '?', text: 'This copy of your settings from ' + b.version + ' is deleted from the server. It cannot be restored.', ok: 'Remove', danger: true })) return;
|
||||||
|
try { await api('DELETE', '/update-backups/' + encodeURIComponent(b.name)); toast('Removed ' + b.name); drawCopies(); } catch (x) { toast(x.message, true); }
|
||||||
|
};
|
||||||
|
const removeAll = async () => {
|
||||||
|
const n = list.length;
|
||||||
|
if (!await confirmDialog({ title: n === 1 ? 'Remove the copy?' : 'Remove all ' + n + ' copies?', text: 'They are deleted from the server and cannot be restored. Your current settings are not affected.', ok: 'Remove all', danger: true })) return;
|
||||||
|
try { await api('DELETE', '/update-backups'); toast(n === 1 ? 'Removed 1 copy' : 'Removed ' + n + ' copies'); drawCopies(); } catch (x) { toast(x.message, true); }
|
||||||
|
};
|
||||||
|
const total = list.reduce((t, b) => t + b.size, 0);
|
||||||
|
fill(copies,
|
||||||
|
h('div', { class: 'cardhead' },
|
||||||
|
h('div', null, h('strong', null, 'Copies made by updates'),
|
||||||
|
h('p', { class: 'lead', style: { marginBottom: '0' } }, 'Each update saves the previous config.json next to it. They hold the same secrets as a backup.')),
|
||||||
|
list.length ? h('button', { type: 'button', class: 'btn', onClick: removeAll }, 'Remove all') : null),
|
||||||
|
list.length ? h('div', { class: 'tbl section' }, h('table', { class: 'narrow' },
|
||||||
|
h('thead', null, h('tr', null, h('th', null, 'File'), h('th', null, 'From version'), h('th', null, 'Saved'), h('th', { class: 'num' }, 'Size'), h('th', null, h('span', { class: 'sr' }, 'Actions')))),
|
||||||
|
h('tbody', null, list.map((b, i) => h('tr', null,
|
||||||
|
h('td', null, h('span', { class: 'mono' }, b.name), i === 0 ? h('span', { class: 'tag plain' }, 'Newest') : null),
|
||||||
|
h('td', { class: 'mono' }, b.version),
|
||||||
|
h('td', null, fmtStamp(b.modified)),
|
||||||
|
h('td', { class: 'num' }, fmtBytes(b.size)),
|
||||||
|
h('td', { class: 'num' }, h('button', { type: 'button', class: 'btn small', onClick: () => remove(b) }, 'Remove'))))))) : h('p', { class: 'muted', style: { margin: '12px 0 0' } }, 'No copies from updates.'),
|
||||||
|
list.length ? h('p', { class: 'hint', style: { margin: '8px 0 0' } }, list.length + (list.length === 1 ? ' copy, ' : ' copies, ') + fmtBytes(total) + ' next to config.json. After each update, only the newest 3 are kept.') : null);
|
||||||
|
};
|
||||||
|
|
||||||
// backup
|
// backup
|
||||||
const restoreInput = h('input', { type: 'file', accept: 'application/json,.json', hidden: true, onChange: async (e) => {
|
const restoreInput = h('input', { type: 'file', accept: 'application/json,.json', hidden: true, onChange: async (e) => {
|
||||||
const f = e.target.files[0];
|
const f = e.target.files[0];
|
||||||
@@ -2203,88 +2257,94 @@
|
|||||||
} 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, preshared keys and authenticator app secrets.'),
|
||||||
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),
|
||||||
|
copies));
|
||||||
updatesCard(s.updates));
|
drawCopies();
|
||||||
await drawLogs();
|
const jumpTo = location.hash.split('#')[2];
|
||||||
if (location.hash.endsWith('#updates')) document.getElementById('updates').scrollIntoView();
|
if (jumpTo) document.getElementById(jumpTo)?.scrollIntoView();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
render();
|
render();
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -351,6 +351,30 @@ func TestAPI(t *testing.T) {
|
|||||||
bearer("POST", "/tokens", 403, map[string]string{"name": "more", "scope": "rw"})
|
bearer("POST", "/tokens", 403, map[string]string{"name": "more", "scope": "rw"})
|
||||||
bearer("DELETE", "/tokens/"+tok["id"].(string), 403)
|
bearer("DELETE", "/tokens/"+tok["id"].(string), 403)
|
||||||
bearer("GET", "/backup", 403)
|
bearer("GET", "/backup", 403)
|
||||||
|
bearer("GET", "/update-backups", 403)
|
||||||
|
bearer("DELETE", "/update-backups", 403)
|
||||||
|
|
||||||
|
// Config copies made by update: listed newest first, removed one by
|
||||||
|
// one or all at once; nothing else in the folder can be removed.
|
||||||
|
for i, v := range []string{"v0.3.2", "v0.4.0"} {
|
||||||
|
f := filepath.Join(dir, "config.json.bak-"+v)
|
||||||
|
_ = os.WriteFile(f, []byte("{}"), 0o600)
|
||||||
|
_ = os.Chtimes(f, time.Now(), time.Now().Add(time.Duration(i-2)*time.Hour))
|
||||||
|
}
|
||||||
|
list := call("GET", "/update-backups", nil, 200)["backups"].([]any)
|
||||||
|
if len(list) != 2 || list[0].(map[string]any)["version"] != "v0.4.0" {
|
||||||
|
t.Fatalf("update backups: %v", list)
|
||||||
|
}
|
||||||
|
call("DELETE", "/update-backups/config.json", nil, 400)
|
||||||
|
call("DELETE", "/update-backups/stats.json", nil, 400)
|
||||||
|
call("DELETE", "/update-backups/config.json.bak-v9.9.9", nil, 400)
|
||||||
|
call("DELETE", "/update-backups/config.json.bak-v0.3.2", nil, 200)
|
||||||
|
if r := call("DELETE", "/update-backups", nil, 200); r["removed"] != float64(1) {
|
||||||
|
t.Fatalf("remove all: %v", r)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, "config.json")); err != nil {
|
||||||
|
t.Fatal("config.json is gone:", err)
|
||||||
|
}
|
||||||
|
|
||||||
call("DELETE", "/peers/"+id, nil, 200)
|
call("DELETE", "/peers/"+id, nil, 200)
|
||||||
if len(store.Get().Peers) != 0 {
|
if len(store.Get().Peers) != 0 {
|
||||||
@@ -1195,6 +1219,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)
|
||||||
|
|||||||
@@ -589,7 +589,7 @@ func cmdUpdate(args []string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
backup := configFile + ".bak-" + oldVersion
|
backup := newUpdateBackupPath(configFile, oldVersion, time.Now())
|
||||||
step("Backing up config to %s", backup)
|
step("Backing up config to %s", backup)
|
||||||
if err := copyFile(configFile, backup, 0o600, uid, gid); err != nil {
|
if err := copyFile(configFile, backup, 0o600, uid, gid); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -627,6 +627,11 @@ func cmdUpdate(args []string) error {
|
|||||||
}
|
}
|
||||||
return fmt.Errorf("update failed, %s %s is running again: %w", appName, oldVersion, err)
|
return fmt.Errorf("update failed, %s %s is running again: %w", appName, oldVersion, err)
|
||||||
}
|
}
|
||||||
|
if n, err := pruneUpdateBackups(configFile, keepUpdateBackups); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, " Could not remove older config backups:", err)
|
||||||
|
} else if n > 0 {
|
||||||
|
step("Removed %d older config backups, kept the newest %d", n, keepUpdateBackups)
|
||||||
|
}
|
||||||
fmt.Printf("\nUpdated %s %s → %s.\n", appName, oldVersion, version)
|
fmt.Printf("\nUpdated %s %s → %s.\n", appName, oldVersion, version)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,9 +8,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"regexp"
|
|
||||||
"runtime"
|
"runtime"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
@@ -212,39 +210,15 @@ func fetchRelease(ctx context.Context, url string) (*Release, error) {
|
|||||||
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&r); err != nil {
|
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&r); err != nil {
|
||||||
return nil, fmt.Errorf("unreadable answer from %s: %w", req.URL.Host, err)
|
return nil, fmt.Errorf("unreadable answer from %s: %w", req.URL.Host, err)
|
||||||
}
|
}
|
||||||
if r.Draft || r.Prerelease || parseVersion(r.Tag) == nil {
|
if _, ok := compareVersions(r.Tag, r.Tag); r.Draft || r.Prerelease || !ok {
|
||||||
return nil, errors.New("the latest release is not a published version")
|
return nil, errors.New("the latest release is not a published version")
|
||||||
}
|
}
|
||||||
return &Release{Version: r.Tag, Published: r.Published, Notes: r.Body, URL: r.URL}, nil
|
return &Release{Version: r.Tag, Published: r.Published, Notes: r.Body, URL: r.URL}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var versionRe = regexp.MustCompile(`^v?(\d+)\.(\d+)\.(\d+)`)
|
|
||||||
|
|
||||||
// parseVersion reads "v0.4.0", "0.4.0" or "v0.4.0-3-gb18d16a" (a build
|
|
||||||
// after v0.4.0) as major, minor and patch, or nil.
|
|
||||||
func parseVersion(s string) []int {
|
|
||||||
m := versionRe.FindStringSubmatch(s)
|
|
||||||
if m == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
out := make([]int, 3)
|
|
||||||
for i := range out {
|
|
||||||
out[i], _ = strconv.Atoi(m[i+1])
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// newerVersion reports whether latest is a higher version than running.
|
// newerVersion reports whether latest is a higher version than running.
|
||||||
// A running version that is not a version number is never out of date.
|
// A running version that is not a version number is never out of date.
|
||||||
func newerVersion(latest, running string) bool {
|
func newerVersion(latest, running string) bool {
|
||||||
l, r := parseVersion(latest), parseVersion(running)
|
c, ok := compareVersions(latest, running)
|
||||||
if l == nil || r == nil {
|
return ok && c > 0
|
||||||
return false
|
|
||||||
}
|
|
||||||
for i := range l {
|
|
||||||
if l[i] != r[i] {
|
|
||||||
return l[i] > r[i]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Each update copies config.json to config.json.bak-<old version> next to
|
||||||
|
// it, in case the new version must be rolled back. The copies hold the same
|
||||||
|
// secrets as a backup, so the web interface lists them and can remove them,
|
||||||
|
// and update keeps only the newest few.
|
||||||
|
|
||||||
|
const keepUpdateBackups = 3
|
||||||
|
|
||||||
|
type UpdateBackup struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
Modified time.Time `json:"modified"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// A copy that would overwrite an older one gets the time appended.
|
||||||
|
var backupStampRe = regexp.MustCompile(`-\d{8}-\d{4}$`)
|
||||||
|
|
||||||
|
func updateBackupPrefix(configPath string) string { return filepath.Base(configPath) + ".bak-" }
|
||||||
|
|
||||||
|
// newUpdateBackupPath names the copy update makes of configPath.
|
||||||
|
func newUpdateBackupPath(configPath, version string, now time.Time) string {
|
||||||
|
p := configPath + ".bak-" + version
|
||||||
|
if _, err := os.Lstat(p); err == nil {
|
||||||
|
p += now.Format("-20060102-1504")
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// listUpdateBackups returns the copies next to configPath, newest first.
|
||||||
|
func listUpdateBackups(configPath string) ([]UpdateBackup, error) {
|
||||||
|
entries, err := os.ReadDir(filepath.Dir(configPath))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
prefix := updateBackupPrefix(configPath)
|
||||||
|
out := []UpdateBackup{}
|
||||||
|
for _, e := range entries {
|
||||||
|
name := e.Name()
|
||||||
|
if !e.Type().IsRegular() || !strings.HasPrefix(name, prefix) || name == prefix {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fi, err := e.Info()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, UpdateBackup{Name: name, Version: backupStampRe.ReplaceAllString(strings.TrimPrefix(name, prefix), ""),
|
||||||
|
Modified: fi.ModTime(), Size: fi.Size()})
|
||||||
|
}
|
||||||
|
slices.SortFunc(out, func(a, b UpdateBackup) int { return b.Modified.Compare(a.Modified) })
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// removeUpdateBackup deletes one copy; any other name is refused.
|
||||||
|
func removeUpdateBackup(configPath, name string) error {
|
||||||
|
prefix := updateBackupPrefix(configPath)
|
||||||
|
path := filepath.Join(filepath.Dir(configPath), name)
|
||||||
|
fi, err := os.Lstat(path)
|
||||||
|
if !strings.HasPrefix(name, prefix) || name == prefix || strings.ContainsAny(name, `/\`) ||
|
||||||
|
errors.Is(err, fs.ErrNotExist) || (err == nil && !fi.Mode().IsRegular()) {
|
||||||
|
return badRequest("no copy named %q", name)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.Remove(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// pruneUpdateBackups keeps the newest keep copies and deletes the rest.
|
||||||
|
func pruneUpdateBackups(configPath string, keep int) (int, error) {
|
||||||
|
list, err := listUpdateBackups(configPath)
|
||||||
|
if err != nil || len(list) <= keep {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
n := 0
|
||||||
|
for _, b := range list[keep:] {
|
||||||
|
if err := removeUpdateBackup(configPath, b.Name); err != nil {
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) listUpdateBackups(w http.ResponseWriter, r *http.Request) {
|
||||||
|
list, err := listUpdateBackups(a.store.path)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"backups": list})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) removeUpdateBackup(w http.ResponseWriter, r *http.Request) {
|
||||||
|
name := r.PathValue("name")
|
||||||
|
if err := removeUpdateBackup(a.store.path, name); err != nil {
|
||||||
|
writeErr(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.audit(r, "update backup removed", "file", name)
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) removeUpdateBackups(w http.ResponseWriter, r *http.Request) {
|
||||||
|
n, err := pruneUpdateBackups(a.store.path, 0)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.audit(r, "update backups removed", "count", n)
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "removed": n})
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestUpdateBackups(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
cfg := filepath.Join(dir, "config.json")
|
||||||
|
_ = os.WriteFile(cfg, []byte("{}"), 0o600)
|
||||||
|
now := time.Date(2026, 10, 5, 12, 9, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
// A second copy of the same version gets the time appended instead of
|
||||||
|
// overwriting the first.
|
||||||
|
first := newUpdateBackupPath(cfg, "unknown", now)
|
||||||
|
if filepath.Base(first) != "config.json.bak-unknown" {
|
||||||
|
t.Fatalf("first copy: %s", first)
|
||||||
|
}
|
||||||
|
_ = os.WriteFile(first, []byte("{}"), 0o600)
|
||||||
|
second := newUpdateBackupPath(cfg, "unknown", now)
|
||||||
|
if filepath.Base(second) != "config.json.bak-unknown-20261005-1209" {
|
||||||
|
t.Fatalf("second copy: %s", second)
|
||||||
|
}
|
||||||
|
_ = os.WriteFile(second, []byte("{}"), 0o600)
|
||||||
|
for i, v := range []string{"v0.2.0", "v0.3.0", "v0.4.0"} {
|
||||||
|
f := filepath.Join(dir, "config.json.bak-"+v)
|
||||||
|
_ = os.WriteFile(f, []byte("{}"), 0o600)
|
||||||
|
_ = os.Chtimes(f, now, now.Add(time.Duration(i+1)*time.Hour))
|
||||||
|
}
|
||||||
|
_ = os.Chtimes(first, now, now.Add(-2*time.Hour))
|
||||||
|
_ = os.Chtimes(second, now, now.Add(-time.Hour))
|
||||||
|
_ = os.Mkdir(filepath.Join(dir, "config.json.bak-dir"), 0o700) // not a file: ignored
|
||||||
|
|
||||||
|
list, err := listUpdateBackups(cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var got []string
|
||||||
|
for _, b := range list {
|
||||||
|
got = append(got, b.Version)
|
||||||
|
}
|
||||||
|
if strings.Join(got, " ") != "v0.4.0 v0.3.0 v0.2.0 unknown unknown" {
|
||||||
|
t.Fatalf("versions, newest first: %v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, bad := range []string{"config.json", "config.json.bak-", "config.json.bak-dir", "../config.json.bak-v0.4.0", "config.json.bak-v0.4.0/x"} {
|
||||||
|
if err := removeUpdateBackup(cfg, bad); err == nil {
|
||||||
|
t.Errorf("removed %q", bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if n, err := pruneUpdateBackups(cfg, keepUpdateBackups); err != nil || n != 2 {
|
||||||
|
t.Fatalf("prune: %d, %v", n, err)
|
||||||
|
}
|
||||||
|
if list, _ = listUpdateBackups(cfg); len(list) != 3 || list[2].Version != "v0.2.0" {
|
||||||
|
t.Fatalf("after prune: %v", list)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(cfg); err != nil {
|
||||||
|
t.Fatal("config.json is gone")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user