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
|
- **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).
|
||||||
|
- **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,
|
- **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 +184,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 +226,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` |
|
||||||
@@ -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/keys/begin · /auth/mfa/keys/finish?name= · PATCH|DELETE /auth/mfa/keys/{id}
|
||||||
signed in: POST /auth/mfa/recovery-codes
|
signed in: POST /auth/mfa/recovery-codes
|
||||||
GET /status GET /stats?range=24h|7d|30d|90d
|
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 /server PATCH /server POST /server/rotate-key GET /server/detect-ip
|
||||||
GET /peers POST /peers (returns the config and QR once)
|
GET /peers POST /peers (returns the config and QR once)
|
||||||
GET /peers/{id} PATCH /peers/{id} DELETE /peers/{id}
|
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 /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)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -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
|
Traffic is reported from the peer's point of view: `down` is what the peer
|
||||||
downloaded, `up` is what it uploaded.
|
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
|
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"}`
|
it per peer with `PATCH /peers/{id}` `{"latencyCheck": "off"|"active"|"always"}`
|
||||||
(default `off`). `active` pings only while the device sends traffic, so idle
|
(default `off`). `active` pings only while the device sends traffic, so idle
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ type App struct {
|
|||||||
kernel Kernel
|
kernel Kernel
|
||||||
recon *Reconciler
|
recon *Reconciler
|
||||||
stats *Stats
|
stats *Stats
|
||||||
|
speeds *Speeds // nil in tests
|
||||||
auth *Auth
|
auth *Auth
|
||||||
tls *webTLS
|
tls *webTLS
|
||||||
logPath string
|
logPath string
|
||||||
@@ -144,6 +145,8 @@ func (a *App) routes() http.Handler {
|
|||||||
|
|
||||||
g("GET /api/v1/status", a.status)
|
g("GET /api/v1/status", a.status)
|
||||||
g("GET /api/v1/stats", a.allStats)
|
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("GET /api/v1/server", a.getServer)
|
||||||
g("PATCH /api/v1/server", a.patchServer)
|
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)
|
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"})
|
||||||
@@ -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)})
|
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) {
|
func (a *App) peerStats(w http.ResponseWriter, r *http.Request) {
|
||||||
cfg := a.store.Get()
|
cfg := a.store.Get()
|
||||||
if _, p := cfg.peerByID(r.PathValue("id")); p == nil {
|
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
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.up { background: var(--up); }
|
||||||
.chart .grp span.total { background: var(--down); }
|
.chart .grp span.total { background: var(--down); }
|
||||||
.chart .grp.on span.total { background: var(--down-strong); }
|
.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.loading { opacity: .5; }
|
||||||
.chart .plot { position: absolute; left: 56px; right: 0; top: 0; bottom: 1px; }
|
.chart .plot { position: absolute; left: 56px; right: 0; top: 0; bottom: 1px; }
|
||||||
.chart .plot svg { width: 100%; height: 100%; display: block; overflow: visible; }
|
.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; }
|
.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; }
|
||||||
|
|
||||||
|
/* 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"/>',
|
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"/>',
|
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"/>',
|
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"/>',
|
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"/>',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -383,8 +385,8 @@
|
|||||||
function pointLabel(t, range) {
|
function pointLabel(t, range) {
|
||||||
const d = new Date(t * 1000);
|
const d = new Date(t * 1000);
|
||||||
if (range === '24h') {
|
if (range === '24h') {
|
||||||
const hrs = Math.round((Date.now() - d.getTime()) / 3600000);
|
const hm = (x) => x.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
|
||||||
return hrs <= 0 ? 'This hour' : hrs + ' h ago';
|
return hm(d) + '–' + hm(new Date(d.getTime() + 3600000));
|
||||||
}
|
}
|
||||||
return d.toLocaleDateString(undefined, { weekday: 'short', day: 'numeric', month: 'short' });
|
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: '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)),
|
h('div', { class: 'yl top' }, fmtBytes(top)), h('div', { class: 'yl mid' }, fmtBytes(top / 2)),
|
||||||
bars),
|
bars),
|
||||||
h('div', { class: 'xaxis' },
|
timeAxis(points.map((p) => p.t), range === '24h' ? 3600 : 86400));
|
||||||
h('span', null, pointLabel(points[0].t, range)),
|
}
|
||||||
h('span', null, range === '24h' ? 'now' : pointLabel(points[points.length - 1].t, range))));
|
|
||||||
|
// 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
|
// 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: '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)),
|
h('div', { class: 'yl top' }, fmtMs(top)), h('div', { class: 'yl mid' }, fmtMs(top / 2)),
|
||||||
wrapEl),
|
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
|
// pairDialog creates an API token and shows it once, with the pairing QR
|
||||||
@@ -528,7 +556,7 @@
|
|||||||
|
|
||||||
// ---------- shell, router ----------
|
// ---------- 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 navLinks = {};
|
||||||
let srvBox, peerCount, verRow;
|
let srvBox, peerCount, verRow;
|
||||||
|
|
||||||
@@ -590,11 +618,13 @@
|
|||||||
|
|
||||||
const ROUTES = [
|
const ROUTES = [
|
||||||
[/^#\/?$/, '#/', viewDashboard],
|
[/^#\/?$/, '#/', viewDashboard],
|
||||||
|
[/^#\/live$/, '#/live', viewLive],
|
||||||
[/^#\/peers$/, '#/peers', viewPeers],
|
[/^#\/peers$/, '#/peers', viewPeers],
|
||||||
[/^#\/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],
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -992,18 +1022,35 @@
|
|||||||
|
|
||||||
// ---------- dashboard ----------
|
// ---------- 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) {
|
async function viewDashboard(wrap) {
|
||||||
|
let range = '24h';
|
||||||
const draw = async () => {
|
const draw = async () => {
|
||||||
const [st, pl, stats, logs] = await Promise.all([
|
const [st, pl, stats, logs] = await Promise.all([
|
||||||
api('GET', '/status'),
|
api('GET', '/status'),
|
||||||
api('GET', '/peers'),
|
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,
|
me.isAdmin ? api('GET', '/logs?audit=1&limit=6').catch(() => null) : null,
|
||||||
]);
|
]);
|
||||||
const peers = pl.peers;
|
const peers = pl.peers;
|
||||||
const failing = st.checks.filter((c) => !c.ok);
|
const failing = st.checks.filter((c) => !c.ok);
|
||||||
const ifCheck = st.checks.find((c) => c.name === 'WireGuard interface');
|
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);
|
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,
|
fill(wrap,
|
||||||
h('div', { class: 'head' },
|
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('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('section', { class: 'card', 'aria-labelledby': 'tput' },
|
||||||
h('div', { class: 'cardhead' }, h('h2', { id: 'tput' }, 'Traffic, all peers · last 24 hours')),
|
h('div', { class: 'cardhead' }, h('h2', { id: 'tput' }, 'Traffic, all peers'), pills),
|
||||||
chart(stats.points, '24h', 'total', true)),
|
traffic),
|
||||||
|
|
||||||
h('div', { class: 'cols' },
|
h('div', { class: 'cols' },
|
||||||
h('section', { class: 'card flush' },
|
h('section', { class: 'card flush' },
|
||||||
@@ -1049,7 +1096,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));
|
||||||
@@ -1058,6 +1105,217 @@
|
|||||||
every(30000, () => draw().catch(() => {}));
|
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) {
|
function describeAudit(l) {
|
||||||
const parts = [l.msg.charAt(0).toUpperCase() + l.msg.slice(1)];
|
const parts = [l.msg.charAt(0).toUpperCase() + l.msg.slice(1)];
|
||||||
if (l.peer) parts.push(': ' + l.peer);
|
if (l.peer) parts.push(': ' + l.peer);
|
||||||
@@ -1325,7 +1583,7 @@
|
|||||||
sessMore.textContent = allSessions ? 'Show fewer' : 'Show all ' + sessions.length;
|
sessMore.textContent = allSessions ? 'Show fewer' : 'Show all ' + sessions.length;
|
||||||
};
|
};
|
||||||
drawSessions();
|
drawSessions();
|
||||||
let range = '7d';
|
let range = '24h';
|
||||||
const st = peerState(p);
|
const st = peerState(p);
|
||||||
const traffic = h('div');
|
const traffic = h('div');
|
||||||
const totals = h('div', { class: 'legend-row' });
|
const totals = h('div', { class: 'legend-row' });
|
||||||
@@ -1347,7 +1605,7 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
async function drawTraffic() {
|
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)));
|
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 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);
|
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);
|
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 +2077,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 +2221,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 +2385,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 +2441,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 +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;
|
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 +2513,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();
|
||||||
})();
|
})();
|
||||||
|
|||||||
+16
-7
@@ -5,8 +5,10 @@ package main
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"maps"
|
||||||
"math/rand/v2"
|
"math/rand/v2"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
|
"slices"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -17,6 +19,7 @@ import (
|
|||||||
type simKernel struct {
|
type simKernel struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
peers map[string]*PeerSample
|
peers map[string]*PeerSample
|
||||||
|
last time.Time // previous Sample; traffic grows with the time since
|
||||||
}
|
}
|
||||||
|
|
||||||
func newKernel() (Kernel, error) {
|
func newKernel() (Kernel, error) {
|
||||||
@@ -53,17 +56,23 @@ func (k *simKernel) Apply(c *Config) error {
|
|||||||
func (k *simKernel) Sample(string) ([]PeerSample, error) {
|
func (k *simKernel) Sample(string) ([]PeerSample, error) {
|
||||||
k.mu.Lock()
|
k.mu.Lock()
|
||||||
defer k.mu.Unlock()
|
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
|
var out []PeerSample
|
||||||
i := 0
|
for i, key := range slices.Sorted(maps.Keys(k.peers)) {
|
||||||
for _, p := range k.peers {
|
p := k.peers[key]
|
||||||
// Every third peer stays idle; the others move some data.
|
// Every third peer stays idle; the others move some data, scaled to
|
||||||
|
// the time since the previous sample.
|
||||||
if i%3 != 2 {
|
if i%3 != 2 {
|
||||||
p.TxBytes += rand.Int64N(40 << 20)
|
p.TxBytes += int64(float64(rand.Int64N(40<<20)) * f)
|
||||||
p.RxBytes += rand.Int64N(6 << 20)
|
p.RxBytes += int64(float64(rand.Int64N(6<<20)) * f)
|
||||||
p.LastHandshake = time.Now().Add(-time.Duration(rand.IntN(90)) * time.Second)
|
p.LastHandshake = now.Add(-time.Duration(rand.IntN(90)) * time.Second)
|
||||||
}
|
}
|
||||||
out = append(out, *p)
|
out = append(out, *p)
|
||||||
i++
|
|
||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -219,16 +219,18 @@ func run(configPath string) error {
|
|||||||
var stopOnce sync.Once
|
var stopOnce sync.Once
|
||||||
shutdown := func() { stopOnce.Do(func() { close(stop) }) }
|
shutdown := func() { stopOnce.Do(func() { close(stop) }) }
|
||||||
|
|
||||||
|
speeds := newSpeeds(store, kernel)
|
||||||
auth := newAuth(store)
|
auth := newAuth(store)
|
||||||
app := &App{
|
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,
|
logPath: logPath, logw: logw, geo: geo, updates: newUpdater(cfg.Updates), started: time.Now(), shutdown: shutdown,
|
||||||
}
|
}
|
||||||
|
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
wg.Add(5)
|
wg.Add(6)
|
||||||
go func() { defer wg.Done(); recon.Run(stop) }()
|
go func() { defer wg.Done(); recon.Run(stop) }()
|
||||||
go func() { defer wg.Done(); stats.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(); stats.RunPings(stop) }()
|
||||||
go func() { defer wg.Done(); geo.Run(stop) }()
|
go func() { defer wg.Done(); geo.Run(stop) }()
|
||||||
go func() { defer wg.Done(); app.updates.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("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)
|
||||||
@@ -1251,3 +1286,56 @@ func TestDropSecurityKeys(t *testing.T) {
|
|||||||
t.Fatal("security key still in config.json")
|
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 {
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
"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