Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 813c3e8bd7 | |||
| 8d97f6c61e | |||
| dbd1808803 | |||
| 5850f3976e | |||
| e01b73d677 | |||
| c78824b0e9 | |||
| 37a6e3cda8 | |||
| beb02a5ca2 | |||
| dec89d003c | |||
| 32d5621cf4 | |||
| 95bb95cecd |
@@ -27,16 +27,19 @@ 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).
|
||||
- **Live view:** the speed of every peer right now, updated every 2 seconds,
|
||||
with the last 2 minutes as a chart. Kept in memory only.
|
||||
- **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
|
||||
@@ -181,7 +184,7 @@ the running service.
|
||||
| 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 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 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. |
|
||||
@@ -223,6 +226,7 @@ After editing `config.json` by hand, run `sudo systemctl reload ghostwire`.
|
||||
|---|---|
|
||||
| `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.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 |
|
||||
| `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` |
|
||||
@@ -262,6 +266,8 @@ signed in: GET /auth/mfa · POST /auth/mfa/totp/setup · /auth/mfa/totp/confirm
|
||||
signed in: POST /auth/mfa/keys/begin · /auth/mfa/keys/finish?name= · PATCH|DELETE /auth/mfa/keys/{id}
|
||||
signed in: POST /auth/mfa/recovery-codes
|
||||
GET /status GET /stats?range=24h|7d|30d|90d
|
||||
GET /live?since= (speed per peer, last 2 minutes in 2-second steps)
|
||||
GET /live/stream (the same as server-sent events)
|
||||
GET /server PATCH /server POST /server/rotate-key GET /server/detect-ip
|
||||
GET /peers POST /peers (returns the config and QR once)
|
||||
GET /peers/{id} PATCH /peers/{id} DELETE /peers/{id}
|
||||
@@ -272,6 +278,7 @@ GET /peers/{id}/setup (not read-only) DELETE /peers/{id}/setup
|
||||
GET /settings PATCH /settings POST /restart POST /updates/check
|
||||
GET /logs?level=&limit=&audit=1 GET /logs/download
|
||||
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)
|
||||
```
|
||||
|
||||
@@ -290,6 +297,12 @@ the newer version while there is one.
|
||||
Traffic is reported from the peer's point of view: `down` is what the peer
|
||||
downloaded, `up` is what it uploaded.
|
||||
|
||||
`GET /live` answers `{"step": 2, "size": 60, "points": [{"t": …, "peers":
|
||||
{"<id>": [down, up]}}]}` with speeds in bits per second, kept only in memory.
|
||||
With `since` (unix seconds) it returns only newer steps. `GET /live/stream`
|
||||
sends the same messages as server-sent events: the history first, then one
|
||||
message per new step.
|
||||
|
||||
Latency is measured by pinging the peer's tunnel address every 30 seconds. Set
|
||||
it per peer with `PATCH /peers/{id}` `{"latencyCheck": "off"|"active"|"always"}`
|
||||
(default `off`). `active` pings only while the device sends traffic, so idle
|
||||
|
||||
@@ -21,6 +21,7 @@ type App struct {
|
||||
kernel Kernel
|
||||
recon *Reconciler
|
||||
stats *Stats
|
||||
speeds *Speeds // nil in tests
|
||||
auth *Auth
|
||||
tls *webTLS
|
||||
logPath string
|
||||
@@ -144,6 +145,8 @@ func (a *App) routes() http.Handler {
|
||||
|
||||
g("GET /api/v1/status", a.status)
|
||||
g("GET /api/v1/stats", a.allStats)
|
||||
g("GET /api/v1/live", a.liveSpeeds)
|
||||
g("GET /api/v1/live/stream", a.liveStream)
|
||||
|
||||
g("GET /api/v1/server", a.getServer)
|
||||
g("PATCH /api/v1/server", a.patchServer)
|
||||
@@ -183,6 +186,9 @@ func (a *App) routes() http.Handler {
|
||||
g("GET /api/v1/logs/download", a.downloadLog)
|
||||
adm("GET /api/v1/backup", a.backup)
|
||||
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) {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "no such endpoint"})
|
||||
@@ -395,6 +401,60 @@ func (a *App) allStats(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"range": rng, "points": a.stats.series(nil, rng)})
|
||||
}
|
||||
|
||||
// liveSpeeds returns each peer's speed over the last 2 minutes; with since
|
||||
// (unix seconds) only the newer steps.
|
||||
func (a *App) liveSpeeds(w http.ResponseWriter, r *http.Request) {
|
||||
var since int64
|
||||
fmt.Sscan(r.URL.Query().Get("since"), &since)
|
||||
points := []SpeedPoint{}
|
||||
if a.speeds != nil {
|
||||
points = a.speeds.Since(since)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"step": int(speedStep / time.Second), "size": speedPoints, "points": points})
|
||||
}
|
||||
|
||||
// liveStream sends the same data as server-sent events: the current points
|
||||
// first, then each new step as soon as it is sampled. The session is checked
|
||||
// again with every step, so signing out ends the stream.
|
||||
func (a *App) liveStream(w http.ResponseWriter, r *http.Request) {
|
||||
if a.speeds == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "live speeds are not available"})
|
||||
return
|
||||
}
|
||||
rc := http.NewResponseController(w)
|
||||
_ = rc.SetWriteDeadline(time.Time{}) // the server's write timeout would cut the stream
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("X-Accel-Buffering", "no") // nginx: do not buffer
|
||||
points, ch, cancel := a.speeds.Subscribe()
|
||||
defer cancel()
|
||||
send := func(points []SpeedPoint) bool {
|
||||
b, _ := json.Marshal(map[string]any{"step": int(speedStep / time.Second), "size": speedPoints, "points": points})
|
||||
if _, err := fmt.Fprintf(w, "data: %s\n\n", b); err != nil {
|
||||
return false
|
||||
}
|
||||
return rc.Flush() == nil
|
||||
}
|
||||
if !send(points) {
|
||||
return
|
||||
}
|
||||
for {
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
return
|
||||
case <-a.speeds.Done():
|
||||
return
|
||||
case pt := <-ch:
|
||||
if _, ok := a.auth.Authenticate(r); !ok {
|
||||
return
|
||||
}
|
||||
if !send([]SpeedPoint{pt}) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) peerStats(w http.ResponseWriter, r *http.Request) {
|
||||
cfg := a.store.Get()
|
||||
if _, p := cfg.peerByID(r.PathValue("id")); p == nil {
|
||||
@@ -1022,12 +1082,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
|
||||
}
|
||||
|
||||
@@ -254,7 +254,10 @@ fieldset { border: 0; margin: 0; padding: 0; min-width: 0; display: flex; flex-d
|
||||
.chart .grp span.up { background: var(--up); }
|
||||
.chart .grp span.total { background: var(--down); }
|
||||
.chart .grp.on span.total { background: var(--down-strong); }
|
||||
.xaxis { display: flex; justify-content: space-between; margin: 8px 0 0 56px; font-size: 11px; color: var(--axis-ink); }
|
||||
.xaxis { position: relative; height: 22px; margin-left: 56px; font-size: 11px; color: var(--axis-ink); }
|
||||
.xaxis span { position: absolute; top: 0; padding-top: 7px; transform: translateX(-50%); white-space: nowrap; font-variant-numeric: tabular-nums; }
|
||||
.xaxis span.edge::before { content: ''; position: absolute; left: 50%; top: 0; height: 4px; border-left: 1px solid var(--axis); }
|
||||
@media (max-width: 640px) { .xaxis span.minor { display: none; } }
|
||||
.chart.loading { opacity: .5; }
|
||||
.chart .plot { position: absolute; left: 56px; right: 0; top: 0; bottom: 1px; }
|
||||
.chart .plot svg { width: 100%; height: 100%; display: block; overflow: visible; }
|
||||
@@ -370,3 +373,34 @@ 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; }
|
||||
|
||||
/* live */
|
||||
.livenow { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 12px 24px; margin-top: 14px; }
|
||||
.livenow .k { font-size: 13px; color: var(--ink-2); }
|
||||
.livenow .v { font-size: 30px; font-weight: 600; letter-spacing: -0.01em; margin-top: 4px; font-variant-numeric: tabular-nums; }
|
||||
.livenow .v small { font-size: 16px; color: var(--ink-3); font-weight: 500; margin-left: 6px; }
|
||||
.chart .larea { fill: var(--down); opacity: .15; }
|
||||
.chart .ldown, .chart .lup { fill: none; stroke-width: 1.75; stroke-linejoin: round; stroke-linecap: round; vector-effect: non-scaling-stroke; }
|
||||
.chart .ldown { stroke: var(--down); }
|
||||
.chart .lup { stroke: var(--up); }
|
||||
.chart .plot.live svg { overflow: hidden; }
|
||||
.xaxis.lx span:first-child { transform: none; }
|
||||
.xaxis.lx span:last-child { transform: translateX(-100%); }
|
||||
.spark.wide { width: 96px; }
|
||||
.spark .fill { fill: var(--down); opacity: .15; stroke: none; }
|
||||
tr.idle td { color: var(--ink-3); }
|
||||
tr.idle .spark polyline { stroke: var(--ink-3); }
|
||||
tr.idle .spark .fill { fill: var(--ink-3); }
|
||||
td .mono, td.num .mono { font-variant-numeric: tabular-nums; }
|
||||
.livesub { display: flex; align-items: center; gap: 6px; }
|
||||
.dot.pulse { animation: pulse 2s ease-in-out infinite; }
|
||||
@keyframes pulse { 50% { opacity: .35; } }
|
||||
@media (prefers-reduced-motion: reduce) { .dot.pulse { animation: none; } }
|
||||
|
||||
@@ -47,8 +47,10 @@
|
||||
peers: '<circle cx="9" cy="8" r="3.5"/><path d="M2.5 20c.8-3.5 3.4-5.5 6.5-5.5s5.7 2 6.5 5.5"/><path d="M16 4.8a3.5 3.5 0 0 1 0 6.4M18.5 14.8c1.5.8 2.6 2.6 3 5.2"/>',
|
||||
server: '<rect x="3" y="4" width="18" height="7" rx="1.5"/><rect x="3" y="13" width="18" height="7" rx="1.5"/><path d="M7 7.5h.01M7 16.5h.01"/>',
|
||||
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"/>',
|
||||
live: '<path d="M3 12h4l3-7 4 14 3-7h4"/>',
|
||||
plus: '<path d="M12 5v14M5 12h14"/>',
|
||||
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"/>',
|
||||
};
|
||||
|
||||
@@ -383,8 +385,8 @@
|
||||
function pointLabel(t, range) {
|
||||
const d = new Date(t * 1000);
|
||||
if (range === '24h') {
|
||||
const hrs = Math.round((Date.now() - d.getTime()) / 3600000);
|
||||
return hrs <= 0 ? 'This hour' : hrs + ' h ago';
|
||||
const hm = (x) => x.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
|
||||
return hm(d) + '–' + hm(new Date(d.getTime() + 3600000));
|
||||
}
|
||||
return d.toLocaleDateString(undefined, { weekday: 'short', day: 'numeric', month: 'short' });
|
||||
}
|
||||
@@ -429,9 +431,35 @@
|
||||
h('div', { class: 'gl top' }), h('div', { class: 'gl mid' }), h('div', { class: 'gl base' }),
|
||||
h('div', { class: 'yl top' }, fmtBytes(top)), h('div', { class: 'yl mid' }, fmtBytes(top / 2)),
|
||||
bars),
|
||||
h('div', { class: 'xaxis' },
|
||||
h('span', null, pointLabel(points[0].t, range)),
|
||||
h('span', null, range === '24h' ? 'now' : pointLabel(points[points.length - 1].t, range))));
|
||||
timeAxis(points.map((p) => p.t), range === '24h' ? 3600 : 86400));
|
||||
}
|
||||
|
||||
// timeAxis labels the bottom of a chart with clock times or dates. Points
|
||||
// are evenly spaced buckets of step seconds. Hourly buckets get the hour
|
||||
// they start at, every 3 hours (6 on narrow screens), with the date at
|
||||
// midnight; daily buckets get the date under the bar, every bar for a
|
||||
// week and every 7th bar, counted back from today, for a month.
|
||||
function timeAxis(ts, step) {
|
||||
const n = ts.length;
|
||||
const ticks = [];
|
||||
if (step < 86400) {
|
||||
for (let i = 0; i < n; i++) {
|
||||
const d = new Date(ts[i] * 1000);
|
||||
if (d.getMinutes() !== 0 || d.getHours() % 3 !== 0 || i === 0) continue;
|
||||
const text = d.getHours() === 0
|
||||
? d.toLocaleDateString(undefined, { weekday: 'short', day: 'numeric' })
|
||||
: d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
|
||||
ticks.push({ at: i / n, text, minor: d.getHours() % 6 !== 0, edge: true });
|
||||
}
|
||||
} else {
|
||||
const every = n > 10 ? 7 : 1;
|
||||
for (let i = n - 1; i >= 0; i -= every) {
|
||||
const d = new Date(ts[i] * 1000);
|
||||
ticks.push({ at: (i + 0.5) / n, text: d.toLocaleDateString(undefined, n > 10 ? { day: 'numeric', month: 'short' } : { weekday: 'short', day: 'numeric' }) });
|
||||
}
|
||||
}
|
||||
return h('div', { class: 'xaxis', 'aria-hidden': 'true' },
|
||||
ticks.map((k) => h('span', { class: (k.minor ? 'minor' : '') + (k.edge ? ' edge' : ''), style: { left: (k.at * 100).toFixed(3) + '%' } }, k.text)));
|
||||
}
|
||||
|
||||
// latencyChart draws the median as a line over a min–max band, one point
|
||||
@@ -488,7 +516,7 @@
|
||||
h('div', { class: 'gl top' }), h('div', { class: 'gl mid' }), h('div', { class: 'gl base' }),
|
||||
h('div', { class: 'yl top' }, fmtMs(top)), h('div', { class: 'yl mid' }, fmtMs(top / 2)),
|
||||
wrapEl),
|
||||
h('div', { class: 'xaxis' }, h('span', null, '24 h ago'), h('span', null, '12 h ago'), h('span', null, 'now')));
|
||||
timeAxis(points.map((p) => p.t), 300));
|
||||
}
|
||||
|
||||
// pairDialog creates an API token and shows it once, with the pairing QR
|
||||
@@ -528,7 +556,7 @@
|
||||
|
||||
// ---------- shell, router ----------
|
||||
|
||||
const NAV = [['#/', 'dashboard', 'Dashboard'], ['#/peers', 'peers', 'Peers'], ['#/server', 'server', 'Server'], ['#/settings', 'settings', 'Settings']];
|
||||
const NAV = [['#/', 'dashboard', 'Dashboard'], ['#/live', 'live', 'Live'], ['#/peers', 'peers', 'Peers'], ['#/server', 'server', 'Server'], ['#/log', 'log', 'Log'], ['#/settings', 'settings', 'Settings']];
|
||||
let navLinks = {};
|
||||
let srvBox, peerCount, verRow;
|
||||
|
||||
@@ -590,11 +618,13 @@
|
||||
|
||||
const ROUTES = [
|
||||
[/^#\/?$/, '#/', viewDashboard],
|
||||
[/^#\/live$/, '#/live', viewLive],
|
||||
[/^#\/peers$/, '#/peers', viewPeers],
|
||||
[/^#\/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],
|
||||
];
|
||||
|
||||
@@ -992,18 +1022,35 @@
|
||||
|
||||
// ---------- dashboard ----------
|
||||
|
||||
// RANGES are the time ranges offered above the traffic charts.
|
||||
const RANGES = [['24h', '24 h'], ['7d', '7 days'], ['30d', '30 days']];
|
||||
|
||||
async function viewDashboard(wrap) {
|
||||
let range = '24h';
|
||||
const draw = async () => {
|
||||
const [st, pl, stats, logs] = await Promise.all([
|
||||
api('GET', '/status'),
|
||||
api('GET', '/peers'),
|
||||
api('GET', '/stats?range=24h'),
|
||||
api('GET', '/stats?range=' + range),
|
||||
me.isAdmin ? api('GET', '/logs?audit=1&limit=6').catch(() => null) : null,
|
||||
]);
|
||||
const peers = pl.peers;
|
||||
const failing = st.checks.filter((c) => !c.ok);
|
||||
const ifCheck = st.checks.find((c) => c.name === 'WireGuard interface');
|
||||
const top = [...peers].sort((a, b) => (b.stats.down24h + b.stats.up24h) - (a.stats.down24h + a.stats.up24h)).slice(0, 6);
|
||||
// A range switch fetches and redraws only the chart.
|
||||
const traffic = h('div', null, chart(stats.points, range, 'total', true));
|
||||
const pills = h('div', { class: 'pills', role: 'group', 'aria-label': 'Time range' });
|
||||
const drawPills = () => pills.replaceChildren(...RANGES.map(([k, t]) =>
|
||||
h('button', { type: 'button', class: range === k ? 'pill on' : 'pill', 'aria-pressed': String(range === k), onClick: async () => {
|
||||
range = k;
|
||||
drawPills();
|
||||
try {
|
||||
const s = await api('GET', '/stats?range=' + k);
|
||||
if (range === k) traffic.replaceChildren(chart(s.points, k, 'total', true));
|
||||
} catch (x) { toast(x.message, true); }
|
||||
} }, t)));
|
||||
drawPills();
|
||||
|
||||
fill(wrap,
|
||||
h('div', { class: 'head' },
|
||||
@@ -1034,8 +1081,8 @@
|
||||
h('div', { class: 's' }, 'Service up ' + ago(st.started).replace(' ago', '') + ' · ' + (st.healthy ? 'all checks pass' : failing.length + ' check(s) failing')))),
|
||||
|
||||
h('section', { class: 'card', 'aria-labelledby': 'tput' },
|
||||
h('div', { class: 'cardhead' }, h('h2', { id: 'tput' }, 'Traffic, all peers · last 24 hours')),
|
||||
chart(stats.points, '24h', 'total', true)),
|
||||
h('div', { class: 'cardhead' }, h('h2', { id: 'tput' }, 'Traffic, all peers'), pills),
|
||||
traffic),
|
||||
|
||||
h('div', { class: 'cols' },
|
||||
h('section', { class: 'card flush' },
|
||||
@@ -1049,7 +1096,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));
|
||||
@@ -1058,6 +1105,217 @@
|
||||
every(30000, () => draw().catch(() => {}));
|
||||
}
|
||||
|
||||
// ---------- live ----------
|
||||
|
||||
// fmtRate formats a speed in bits per second.
|
||||
function fmtRate(bps) {
|
||||
const u = ['bit/s', 'kbit/s', 'Mbit/s', 'Gbit/s'];
|
||||
let i = 0, v = bps;
|
||||
while (v >= 1000 && i < u.length - 1) { v /= 1000; i++; }
|
||||
const s = i === 0 ? String(Math.round(v)) : v < 10 ? v.toFixed(1) : String(Math.round(v));
|
||||
return s + ' ' + u[i];
|
||||
}
|
||||
|
||||
// Below this a peer counts as idle: keepalives and background chatter.
|
||||
const IDLE_BPS = 2000;
|
||||
|
||||
// The figures and the table average the last few steps so they do not
|
||||
// swing with every burst; the charts show each step.
|
||||
const AVG_STEPS = 5;
|
||||
|
||||
const calm = () => window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
// liveChart draws download as a filled area and upload as a line. It is
|
||||
// built once and updated in place: each new step enters just beyond the
|
||||
// right edge and the chart slides left by one step over the step's length,
|
||||
// so it moves steadily instead of jumping. Hover shows the values at a
|
||||
// moment.
|
||||
function liveChart() {
|
||||
const W = 1000, H = 100;
|
||||
let pts = [], size = 60, step = 2, off = 0, dx = W / 59, t0 = 0, moving = false;
|
||||
const area = svg('polygon', { class: 'larea' });
|
||||
const down = svg('polyline', { class: 'ldown' });
|
||||
const up = svg('polyline', { class: 'lup' });
|
||||
const cursor = svg('line', { class: 'cursor', x1: 0, x2: 0, y1: 0, y2: H, visibility: 'hidden' });
|
||||
const g = svg('g', null, area, down, up, cursor);
|
||||
const plot = svg('svg', { viewBox: '0 0 ' + W + ' ' + H, preserveAspectRatio: 'none', 'aria-hidden': 'true' }, g);
|
||||
const ylTop = h('div', { class: 'yl top' }), ylMid = h('div', { class: 'yl mid' });
|
||||
const at = (p) => new Date(p.t * 1000).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
const idle = () => {
|
||||
const peak = pts.reduce((m, p) => p.down + p.up > m.down + m.up ? p : m, { down: 0, up: 0 });
|
||||
return peak.t
|
||||
? ['Peak ', h('strong', null, fmtRate(peak.down + peak.up)), ' at ' + at(peak) + ' · hover the chart to see a moment.']
|
||||
: ['No traffic in the last 2 minutes.'];
|
||||
};
|
||||
const readout = h('div', { class: 'readout' });
|
||||
let hoverT = null;
|
||||
const x = (i) => off + i * dx;
|
||||
const show = (i) => {
|
||||
const p = pts[i];
|
||||
hoverT = p.t;
|
||||
cursor.setAttribute('x1', x(i).toFixed(1)); cursor.setAttribute('x2', x(i).toFixed(1)); cursor.setAttribute('visibility', 'visible');
|
||||
readout.replaceChildren(at(p), ' · Download ', h('strong', null, fmtRate(p.down)), ' · Upload ', h('strong', null, fmtRate(p.up)));
|
||||
};
|
||||
const plotEl = h('div', { class: 'plot live', role: 'img', 'aria-label': 'Speed of all peers over the last 2 minutes',
|
||||
onMousemove: (e) => {
|
||||
if (!pts.length) return;
|
||||
const r = plotEl.getBoundingClientRect();
|
||||
const shift = moving ? Math.min(1, (performance.now() - t0) / (step * 1000)) * dx : 0;
|
||||
const xv = (e.clientX - r.left) / r.width * W + shift;
|
||||
show(Math.max(0, Math.min(pts.length - 1, Math.round((xv - off) / dx))));
|
||||
},
|
||||
onMouseleave: () => { hoverT = null; cursor.setAttribute('visibility', 'hidden'); readout.replaceChildren(...idle()); } }, plot);
|
||||
const el = h('div', null,
|
||||
readout,
|
||||
h('div', { class: 'chart small' },
|
||||
h('div', { class: 'gl top' }), h('div', { class: 'gl mid' }), h('div', { class: 'gl base' }),
|
||||
ylTop, ylMid, plotEl),
|
||||
h('div', { class: 'xaxis lx' }, h('span', { style: { left: '0%' } }, '2 min ago'), h('span', { style: { left: '50%' } }, '1 min ago'), h('span', { style: { left: '100%' } }, 'now')));
|
||||
|
||||
// update draws points; slide is true for a new step arriving live.
|
||||
const update = (points, sz, st, slide) => {
|
||||
pts = points; size = sz; step = st; dx = W / (size - 1);
|
||||
moving = slide && !calm();
|
||||
const n = pts.length;
|
||||
// The newest point sits at the right edge, or one step beyond it
|
||||
// while sliding in.
|
||||
off = W - (n - 1) * dx + (moving ? dx : 0);
|
||||
const top = niceTop(Math.max(0, ...pts.map((p) => Math.max(p.down, p.up))) || 1e6);
|
||||
const y = (v) => (H - v / top * H).toFixed(2);
|
||||
const line = (k) => pts.map((p, i) => x(i).toFixed(1) + ',' + y(p[k])).join(' ');
|
||||
if (n > 1) {
|
||||
area.setAttribute('points', x(0).toFixed(1) + ',' + H + ' ' + line('down') + ' ' + x(n - 1).toFixed(1) + ',' + H);
|
||||
down.setAttribute('points', line('down'));
|
||||
up.setAttribute('points', line('up'));
|
||||
}
|
||||
ylTop.textContent = fmtRate(top);
|
||||
ylMid.textContent = fmtRate(top / 2);
|
||||
g.style.transition = 'none';
|
||||
g.style.transform = 'translateX(0)';
|
||||
if (moving) {
|
||||
g.getBoundingClientRect(); // start the slide from 0
|
||||
t0 = performance.now();
|
||||
g.style.transition = 'transform ' + step + 's linear';
|
||||
g.style.transform = 'translateX(' + (-dx).toFixed(2) + 'px)';
|
||||
}
|
||||
const i = hoverT == null ? -1 : pts.findIndex((p) => p.t === hoverT);
|
||||
if (i >= 0) show(i);
|
||||
else { hoverT = null; cursor.setAttribute('visibility', 'hidden'); readout.replaceChildren(...idle()); }
|
||||
};
|
||||
return { el, update };
|
||||
}
|
||||
|
||||
// rateSpark draws a peer's total speed over the same window, scaled to its
|
||||
// own peak.
|
||||
function rateSpark(vals) {
|
||||
const s = svg('svg', { class: 'spark wide', viewBox: '0 0 96 18', preserveAspectRatio: 'none', 'aria-hidden': 'true' });
|
||||
const top = Math.max(...vals, IDLE_BPS * 4);
|
||||
const n = vals.length;
|
||||
if (n < 2) return s;
|
||||
const pts = vals.map((v, i) => (i / (n - 1) * 96).toFixed(1) + ',' + (17 - v / top * 15).toFixed(1));
|
||||
s.append(svg('polygon', { class: 'fill', points: '0,18 ' + pts.join(' ') + ' 96,18' }), svg('polyline', { points: pts.join(' ') }));
|
||||
return s;
|
||||
}
|
||||
|
||||
// viewLive shows the speed of every peer right now. The server streams its
|
||||
// short in-memory history (the last 2 minutes in 2-second steps) and then
|
||||
// each new step as it is sampled.
|
||||
async function viewLive(wrap) {
|
||||
let peers = (await api('GET', '/peers')).peers;
|
||||
let live = { step: 2, size: 60, points: [] };
|
||||
let paused = false, es = null, retry = 0;
|
||||
const down = h('div', { class: 'v' }), up = h('div', { class: 'v' }), active = h('div', { class: 'v' });
|
||||
const totals = h('div', { class: 'livenow' },
|
||||
h('div', null, h('div', { class: 'k' }, h('span', { class: 'key down' }), 'Download'), down),
|
||||
h('div', null, h('div', { class: 'k' }, h('span', { class: 'key up' }), 'Upload'), up),
|
||||
h('div', null, h('div', { class: 'k' }, 'Active peers'), active));
|
||||
const lc = liveChart();
|
||||
const rows = h('div');
|
||||
const pauseBtn = h('button', { type: 'button', class: 'btn', onClick: () => {
|
||||
paused = !paused;
|
||||
pauseBtn.textContent = paused ? 'Resume' : 'Pause';
|
||||
sub.replaceChildren(...subText());
|
||||
if (paused) close(); else open();
|
||||
} }, 'Pause');
|
||||
const subText = () => paused
|
||||
? ['Paused · the chart keeps the moment you paused']
|
||||
: [h('span', { class: 'dot ok pulse' }), ' Updated every ' + live.step + ' s · figures are ' + AVG_STEPS * live.step + '-second averages'];
|
||||
const sub = h('p', { class: 'sub livesub' }, subText());
|
||||
|
||||
const draw = (slide) => {
|
||||
const pts = live.points;
|
||||
const sumAt = (p) => Object.values(p.peers).reduce((a, [d, u]) => ({ down: a.down + d, up: a.up + u }), { down: 0, up: 0 });
|
||||
const series = pts.map((p) => ({ t: p.t, ...sumAt(p) }));
|
||||
const recent = pts.slice(-AVG_STEPS);
|
||||
const avg = (f) => recent.length ? recent.reduce((a, p) => a + f(p), 0) / recent.length : 0;
|
||||
const last = {};
|
||||
for (const p of peers) last[p.id] = [avg((x) => (x.peers[p.id] || [0, 0])[0]), avg((x) => (x.peers[p.id] || [0, 0])[1])];
|
||||
down.textContent = fmtRate(avg((p) => sumAt(p).down));
|
||||
up.textContent = fmtRate(avg((p) => sumAt(p).up));
|
||||
active.replaceChildren(String(peers.filter((p) => { const r = last[p.id]; return r && r[0] + r[1] >= IDLE_BPS; }).length),
|
||||
h('small', null, '/ ' + peers.filter((p) => p.stats.online).length + ' online'));
|
||||
lc.update(series, live.size, live.step, slide);
|
||||
|
||||
const online = peers.filter((p) => p.stats.online)
|
||||
.map((p) => ({ p, r: last[p.id] || [0, 0], hist: pts.map((x) => { const v = x.peers[p.id]; return v ? v[0] + v[1] : 0; }) }))
|
||||
.sort((a, b) => (b.r[0] + b.r[1]) - (a.r[0] + a.r[1]) || a.p.name.localeCompare(b.p.name));
|
||||
const rate = (v, idle) => idle ? h('span', { class: 'muted' }, '–') : h('span', { class: 'mono' }, fmtRate(v));
|
||||
rows.replaceChildren(
|
||||
online.length ? h('div', { class: 'tbl' }, h('table', { class: 'narrow' },
|
||||
h('thead', null, h('tr', null, h('th', null, 'Peer'), h('th', null, 'Last 2 minutes'), h('th', { class: 'num' }, 'Download'), h('th', { class: 'num' }, 'Upload'), h('th', null, 'Endpoint'))),
|
||||
h('tbody', null, online.map(({ p, r, hist }) => {
|
||||
const idle = r[0] + r[1] < IDLE_BPS;
|
||||
return h('tr', { class: idle ? 'idle' : null },
|
||||
h('td', null, h('a', { href: '#/peers/' + p.id }, p.name), idle ? h('span', { class: 'tag plain' }, 'idle') : null),
|
||||
h('td', null, rateSpark(hist)),
|
||||
h('td', { class: 'num' }, rate(r[0], idle)),
|
||||
h('td', { class: 'num' }, rate(r[1], idle)),
|
||||
h('td', null, h('span', { class: 'mono' }, p.stats.endpoint ? p.stats.endpoint.replace(/:\d+$/, '') : '–'),
|
||||
p.stats.location && p.stats.location.country ? h('span', { class: 'cc', title: fmtLocation(p.stats.location) }, p.stats.location.country) : null));
|
||||
})))) : h('p', { class: 'empty' }, 'No peer is online.'));
|
||||
};
|
||||
|
||||
// The first message of a stream is the whole history; later ones carry
|
||||
// one new step each. One step more than the server keeps is held, so
|
||||
// the chart's left edge stays filled while it slides.
|
||||
function open() {
|
||||
if (es || paused || document.hidden || !wrap.isConnected) return;
|
||||
let first = true;
|
||||
es = new EventSource('/api/v1/live/stream');
|
||||
es.onmessage = (e) => {
|
||||
retry = 0;
|
||||
const m = JSON.parse(e.data);
|
||||
live = { step: m.step, size: m.size, points: first ? m.points : live.points.concat(m.points).slice(-m.size - 1) };
|
||||
draw(!first);
|
||||
first = false;
|
||||
};
|
||||
es.onerror = () => {
|
||||
if (es.readyState !== EventSource.CLOSED) { first = true; return; } // the browser reconnects
|
||||
close();
|
||||
// Refused, e.g. signed out: api() shows the sign-in page on 401.
|
||||
api('GET', '/status').then(() => { if (wrap.isConnected) setTimeout(open, Math.min(30000, 2000 * 2 ** retry++)); }).catch(() => {});
|
||||
};
|
||||
}
|
||||
function close() { if (es) { es.close(); es = null; } }
|
||||
const onVis = () => { if (document.hidden) close(); else open(); };
|
||||
document.addEventListener('visibilitychange', onVis);
|
||||
cleanups.push(() => { close(); document.removeEventListener('visibilitychange', onVis); });
|
||||
|
||||
fill(wrap,
|
||||
h('div', { class: 'head' },
|
||||
h('div', null, h('h1', null, 'Live'), sub),
|
||||
h('div', { class: 'actions' }, pauseBtn)),
|
||||
h('section', { class: 'card', 'aria-labelledby': 'lv' },
|
||||
h('div', { class: 'cardhead' }, h('h2', { id: 'lv' }, 'All peers · right now')),
|
||||
totals, lc.el),
|
||||
h('section', { class: 'card flush', 'aria-labelledby': 'lp' },
|
||||
h('div', { class: 'cardhead' }, h('h2', { id: 'lp' }, 'Peers'), h('span', { class: 'hint' }, 'Busiest first')),
|
||||
rows));
|
||||
draw(false);
|
||||
open();
|
||||
every(15000, async () => { try { peers = (await api('GET', '/peers')).peers; } catch { /* keep last */ } });
|
||||
}
|
||||
|
||||
function describeAudit(l) {
|
||||
const parts = [l.msg.charAt(0).toUpperCase() + l.msg.slice(1)];
|
||||
if (l.peer) parts.push(': ' + l.peer);
|
||||
@@ -1325,7 +1583,7 @@
|
||||
sessMore.textContent = allSessions ? 'Show fewer' : 'Show all ' + sessions.length;
|
||||
};
|
||||
drawSessions();
|
||||
let range = '7d';
|
||||
let range = '24h';
|
||||
const st = peerState(p);
|
||||
const traffic = h('div');
|
||||
const totals = h('div', { class: 'legend-row' });
|
||||
@@ -1347,7 +1605,7 @@
|
||||
};
|
||||
|
||||
async function drawTraffic() {
|
||||
pills.replaceChildren(...[['24h', '24 h'], ['7d', '7 days'], ['30d', '30 days']].map(([k, t]) =>
|
||||
pills.replaceChildren(...RANGES.map(([k, t]) =>
|
||||
h('button', { type: 'button', class: range === k ? 'pill on' : 'pill', 'aria-pressed': String(range === k), onClick: () => { range = k; drawTraffic(); } }, t)));
|
||||
const s = await api('GET', '/peers/' + id + '/stats?range=' + range);
|
||||
const down = s.points.reduce((a, x) => a + x.down, 0), up = s.points.reduce((a, x) => a + x.up, 0);
|
||||
@@ -1707,6 +1965,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 +2077,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 +2221,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 +2385,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 +2441,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,14 +2459,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;
|
||||
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; }
|
||||
};
|
||||
|
||||
// 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
|
||||
const restoreInput = h('input', { type: 'file', accept: 'application/json,.json', hidden: true, onChange: async (e) => {
|
||||
const f = e.target.files[0];
|
||||
@@ -2203,88 +2513,94 @@
|
||||
} 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('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('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, preshared keys and authenticator app secrets.'),
|
||||
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),
|
||||
copies));
|
||||
drawCopies();
|
||||
const jumpTo = location.hash.split('#')[2];
|
||||
if (jumpTo) document.getElementById(jumpTo)?.scrollIntoView();
|
||||
}
|
||||
|
||||
|
||||
render();
|
||||
})();
|
||||
|
||||
+16
-7
@@ -5,8 +5,10 @@ package main
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"maps"
|
||||
"math/rand/v2"
|
||||
"net/netip"
|
||||
"slices"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
@@ -17,6 +19,7 @@ import (
|
||||
type simKernel struct {
|
||||
mu sync.Mutex
|
||||
peers map[string]*PeerSample
|
||||
last time.Time // previous Sample; traffic grows with the time since
|
||||
}
|
||||
|
||||
func newKernel() (Kernel, error) {
|
||||
@@ -53,17 +56,23 @@ func (k *simKernel) Apply(c *Config) error {
|
||||
func (k *simKernel) Sample(string) ([]PeerSample, error) {
|
||||
k.mu.Lock()
|
||||
defer k.mu.Unlock()
|
||||
now := time.Now()
|
||||
f := 1.0
|
||||
if !k.last.IsZero() {
|
||||
f = now.Sub(k.last).Seconds() / 30
|
||||
}
|
||||
k.last = now
|
||||
var out []PeerSample
|
||||
i := 0
|
||||
for _, p := range k.peers {
|
||||
// Every third peer stays idle; the others move some data.
|
||||
for i, key := range slices.Sorted(maps.Keys(k.peers)) {
|
||||
p := k.peers[key]
|
||||
// Every third peer stays idle; the others move some data, scaled to
|
||||
// the time since the previous sample.
|
||||
if i%3 != 2 {
|
||||
p.TxBytes += rand.Int64N(40 << 20)
|
||||
p.RxBytes += rand.Int64N(6 << 20)
|
||||
p.LastHandshake = time.Now().Add(-time.Duration(rand.IntN(90)) * time.Second)
|
||||
p.TxBytes += int64(float64(rand.Int64N(40<<20)) * f)
|
||||
p.RxBytes += int64(float64(rand.Int64N(6<<20)) * f)
|
||||
p.LastHandshake = now.Add(-time.Duration(rand.IntN(90)) * time.Second)
|
||||
}
|
||||
out = append(out, *p)
|
||||
i++
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -219,16 +219,18 @@ func run(configPath string) error {
|
||||
var stopOnce sync.Once
|
||||
shutdown := func() { stopOnce.Do(func() { close(stop) }) }
|
||||
|
||||
speeds := newSpeeds(store, kernel)
|
||||
auth := newAuth(store)
|
||||
app := &App{
|
||||
store: store, kernel: kernel, recon: recon, stats: stats, auth: auth, tls: webTLS,
|
||||
store: store, kernel: kernel, recon: recon, stats: stats, speeds: speeds, auth: auth, tls: webTLS,
|
||||
logPath: logPath, logw: logw, geo: geo, updates: newUpdater(cfg.Updates), started: time.Now(), shutdown: shutdown,
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(5)
|
||||
wg.Add(6)
|
||||
go func() { defer wg.Done(); recon.Run(stop) }()
|
||||
go func() { defer wg.Done(); stats.Run(stop) }()
|
||||
go func() { defer wg.Done(); speeds.Run(stop) }()
|
||||
go func() { defer wg.Done(); stats.RunPings(stop) }()
|
||||
go func() { defer wg.Done(); geo.Run(stop) }()
|
||||
go func() { defer wg.Done(); app.updates.Run(stop) }()
|
||||
|
||||
@@ -351,6 +351,30 @@ func TestAPI(t *testing.T) {
|
||||
bearer("POST", "/tokens", 403, map[string]string{"name": "more", "scope": "rw"})
|
||||
bearer("DELETE", "/tokens/"+tok["id"].(string), 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)
|
||||
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[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)
|
||||
@@ -1251,3 +1286,56 @@ func TestDropSecurityKeys(t *testing.T) {
|
||||
t.Fatal("security key still in config.json")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpeeds(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
store, err := openStore(filepath.Join(dir, "config.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
key, _ := newPrivateKey()
|
||||
pub := key.PublicKey().String()
|
||||
if err := store.Update(func(c *Config) error {
|
||||
c.Peers = append(c.Peers, Peer{ID: "p1", Name: "phone", IPv4: serverIPv4(netip.MustParsePrefix(c.Server.IPv4)).Next().String(), PublicKey: pub, Enabled: true})
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
k := &fakeKernel{}
|
||||
sp := newSpeeds(store, k)
|
||||
t0 := time.Unix(1_800_000_000, 0)
|
||||
step := func(sec int, rx, tx int64) {
|
||||
k.samples = []PeerSample{{PublicKey: pub, RxBytes: rx, TxBytes: tx}}
|
||||
sp.sample(t0.Add(time.Duration(sec) * time.Second))
|
||||
}
|
||||
_, ch, cancel := sp.Subscribe()
|
||||
defer cancel()
|
||||
step(0, 1000, 1000)
|
||||
if n := len(sp.Since(0)); n != 0 {
|
||||
t.Fatalf("first sample made %d points, want 0", n)
|
||||
}
|
||||
step(2, 1250, 3000) // +250 up, +2000 down in 2 s
|
||||
step(4, 10, 20) // counter reset: no speed for this step
|
||||
pts := sp.Since(0)
|
||||
if len(pts) != 2 {
|
||||
t.Fatalf("got %d points, want 2", len(pts))
|
||||
}
|
||||
if got, want := pts[0].Peers["p1"], [2]int64{8000, 1000}; got != want {
|
||||
t.Fatalf("speed = %v, want %v (down, up in bit/s)", got, want)
|
||||
}
|
||||
if got := <-ch; got.T != pts[0].T {
|
||||
t.Fatalf("subscriber got step %d, want %d", got.T, pts[0].T)
|
||||
}
|
||||
if _, ok := pts[1].Peers["p1"]; ok {
|
||||
t.Fatal("a counter reset reported a speed")
|
||||
}
|
||||
if got := sp.Since(pts[0].T); len(got) != 1 || got[0].T != pts[1].T {
|
||||
t.Fatalf("Since returned %v", got)
|
||||
}
|
||||
for i := 0; i < speedPoints+5; i++ {
|
||||
step(6+2*i, 0, 0)
|
||||
}
|
||||
if n := len(sp.Since(0)); n != speedPoints {
|
||||
t.Fatalf("kept %d points, want %d", n, speedPoints)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -589,7 +589,7 @@ func cmdUpdate(args []string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
backup := configFile + ".bak-" + oldVersion
|
||||
backup := newUpdateBackupPath(configFile, oldVersion, time.Now())
|
||||
step("Backing up config to %s", backup)
|
||||
if err := copyFile(configFile, backup, 0o600, uid, gid); err != nil {
|
||||
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)
|
||||
}
|
||||
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)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Speeds keeps the last few minutes of each peer's speed in memory for the
|
||||
// Live page. It reads the kernel counters every speedStep, apart from the
|
||||
// traffic history in Stats, and never writes to disk.
|
||||
|
||||
const (
|
||||
speedStep = 2 * time.Second
|
||||
speedPoints = 60 // 2 minutes
|
||||
)
|
||||
|
||||
// SpeedPoint is one step: per peer ID, download and upload in bits per
|
||||
// second, from the peer's point of view.
|
||||
type SpeedPoint struct {
|
||||
T int64 `json:"t"`
|
||||
Peers map[string][2]int64 `json:"peers"`
|
||||
}
|
||||
|
||||
type Speeds struct {
|
||||
store *Store
|
||||
kernel Kernel
|
||||
|
||||
mu sync.Mutex
|
||||
last map[string][2]int64 // raw rx, tx by public key
|
||||
lastAt time.Time
|
||||
points []SpeedPoint
|
||||
subs map[chan SpeedPoint]struct{}
|
||||
done chan struct{} // closed when Run returns
|
||||
}
|
||||
|
||||
func newSpeeds(store *Store, kernel Kernel) *Speeds {
|
||||
return &Speeds{store: store, kernel: kernel, last: map[string][2]int64{}, subs: map[chan SpeedPoint]struct{}{}, done: make(chan struct{})}
|
||||
}
|
||||
|
||||
// Subscribe returns the current points and a channel that receives each new
|
||||
// one; cancel ends the subscription. A subscriber that falls behind misses
|
||||
// points rather than holding up the sampler.
|
||||
func (s *Speeds) Subscribe() (points []SpeedPoint, ch <-chan SpeedPoint, cancel func()) {
|
||||
c := make(chan SpeedPoint, 4)
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.subs[c] = struct{}{}
|
||||
return append([]SpeedPoint{}, s.points...), c, func() {
|
||||
s.mu.Lock()
|
||||
delete(s.subs, c)
|
||||
s.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// Done is closed when the sampler stops, so streams can end.
|
||||
func (s *Speeds) Done() <-chan struct{} { return s.done }
|
||||
|
||||
func (s *Speeds) sample(now time.Time) {
|
||||
cfg := s.store.Get()
|
||||
samples, err := s.kernel.Sample(cfg.Server.Interface)
|
||||
if err != nil {
|
||||
slog.Debug("speed sample failed", "err", err)
|
||||
return
|
||||
}
|
||||
idByKey := map[string]string{}
|
||||
for _, p := range cfg.Peers {
|
||||
if p.hasKey() {
|
||||
idByKey[p.PublicKey] = p.ID
|
||||
}
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
secs := now.Sub(s.lastAt).Seconds()
|
||||
first := s.lastAt.IsZero()
|
||||
cur := map[string][2]int64{}
|
||||
pt := SpeedPoint{T: now.Unix(), Peers: map[string][2]int64{}}
|
||||
for _, smp := range samples {
|
||||
cur[smp.PublicKey] = [2]int64{smp.RxBytes, smp.TxBytes}
|
||||
id := idByKey[smp.PublicKey]
|
||||
prev, ok := s.last[smp.PublicKey]
|
||||
if id == "" || !ok || first {
|
||||
continue
|
||||
}
|
||||
dRx, dTx := smp.RxBytes-prev[0], smp.TxBytes-prev[1]
|
||||
if dRx < 0 || dTx < 0 { // counters were reset
|
||||
continue
|
||||
}
|
||||
// Tx is what the server sent: the peer's download.
|
||||
pt.Peers[id] = [2]int64{int64(float64(dTx*8) / secs), int64(float64(dRx*8) / secs)}
|
||||
}
|
||||
s.last, s.lastAt = cur, now
|
||||
if first {
|
||||
return
|
||||
}
|
||||
s.points = append(s.points, pt)
|
||||
if len(s.points) > speedPoints {
|
||||
s.points = s.points[len(s.points)-speedPoints:]
|
||||
}
|
||||
for c := range s.subs {
|
||||
select {
|
||||
case c <- pt:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Since returns the points newer than the unix time t, oldest first.
|
||||
func (s *Speeds) Since(t int64) []SpeedPoint {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
out := []SpeedPoint{}
|
||||
for _, p := range s.points {
|
||||
if p.T > t {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Speeds) Run(stop <-chan struct{}) {
|
||||
defer close(s.done)
|
||||
s.sample(time.Now())
|
||||
t := time.NewTicker(speedStep)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
case now := <-t.C:
|
||||
s.sample(now)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,9 +8,7 @@ import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"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 {
|
||||
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 &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.
|
||||
// A running version that is not a version number is never out of date.
|
||||
func newerVersion(latest, running string) bool {
|
||||
l, r := parseVersion(latest), parseVersion(running)
|
||||
if l == nil || r == nil {
|
||||
return false
|
||||
}
|
||||
for i := range l {
|
||||
if l[i] != r[i] {
|
||||
return l[i] > r[i]
|
||||
}
|
||||
}
|
||||
return false
|
||||
c, ok := compareVersions(latest, running)
|
||||
return ok && c > 0
|
||||
}
|
||||
|
||||
@@ -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