Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3e8dba6072 | |||
| aa4ca20296 | |||
| 1bfede250c | |||
| 8edde9f5e7 | |||
| 7c36223de0 | |||
| 2b7b41859d | |||
| ccf7b50c59 | |||
| a82314ee94 | |||
| d83582eea1 | |||
| 5f321f2979 | |||
| 6661424daf | |||
| 456ae6a41e | |||
| c7b4ca692e | |||
| 6733bf2f3a | |||
| e9bd3090a8 |
@@ -28,6 +28,8 @@ dependencies on the server: the binary installs, updates and removes itself.
|
|||||||
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 → Logs & history).
|
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
|
||||||
@@ -264,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}
|
||||||
@@ -293,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
|
||||||
|
|||||||
@@ -3,15 +3,18 @@ package main
|
|||||||
import (
|
import (
|
||||||
"cmp"
|
"cmp"
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -21,6 +24,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
|
||||||
@@ -28,7 +32,8 @@ type App struct {
|
|||||||
geo *Geo // nil in tests
|
geo *Geo // nil in tests
|
||||||
updates *Updater // nil in tests
|
updates *Updater // nil in tests
|
||||||
started time.Time
|
started time.Time
|
||||||
shutdown func() // graceful stop; systemd restarts the service
|
shutdown func() // graceful stop; systemd restarts the service
|
||||||
|
webAddrs []string // the addresses the web server listens on now
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- helpers ---
|
// --- helpers ---
|
||||||
@@ -144,6 +149,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)
|
||||||
@@ -350,7 +357,7 @@ func (a *App) status(w http.ResponseWriter, r *http.Request) {
|
|||||||
d30, u30 := sumPoints(a.stats.series(nil, "30d"))
|
d30, u30 := sumPoints(a.stats.series(nil, "30d"))
|
||||||
checks := a.kernel.Checks(cfg)
|
checks := a.kernel.Checks(cfg)
|
||||||
last, applyErr := a.recon.Status()
|
last, applyErr := a.recon.Status()
|
||||||
ac := Check{Name: "Last apply", OK: applyErr == nil, Detail: "applied " + last.Format(time.RFC3339)}
|
ac := Check{Name: "Kernel in sync", OK: applyErr == nil, Detail: "applied " + last.Format(time.RFC3339)}
|
||||||
if applyErr != nil {
|
if applyErr != nil {
|
||||||
ac.Detail = applyErr.Error()
|
ac.Detail = applyErr.Error()
|
||||||
}
|
}
|
||||||
@@ -398,6 +405,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 {
|
||||||
@@ -1033,11 +1094,15 @@ func (a *App) patchSettings(w http.ResponseWriter, r *http.Request) {
|
|||||||
b, _ := json.Marshal(w)
|
b, _ := json.Marshal(w)
|
||||||
return string(b)
|
return string(b)
|
||||||
}
|
}
|
||||||
before := listen()
|
before, oldWeb := listen(), c.Web
|
||||||
if err := field(m, "web", &c.Web); err != nil {
|
if err := field(m, "web", &c.Web); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
restart = listen() != before
|
if restart = listen() != before; restart {
|
||||||
|
if err := a.checkWebStart(oldWeb, c.Web); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
if err := field(m, "stats", &c.Stats); err != nil {
|
if err := field(m, "stats", &c.Stats); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -1179,7 +1244,20 @@ func (a *App) restore(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeErr(w, badRequest("this file has no server key; is it a backup of this app?"))
|
writeErr(w, badRequest("this file has no server key; is it a backup of this app?"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := a.store.Update(func(c *Config) error { *c = in; return nil }); err != nil {
|
if in.Version > configVersion {
|
||||||
|
writeErr(w, badRequest("this backup is from a newer version of %s; update this server first", appName))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
in.applyDefaults()
|
||||||
|
if !in.passwordSet() {
|
||||||
|
writeErr(w, badRequest("this backup has no user with a password; restoring it would lock everyone out"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := a.store.Update(func(c *Config) error {
|
||||||
|
old := c.Web
|
||||||
|
*c = in
|
||||||
|
return a.checkWebStart(old, c.Web)
|
||||||
|
}); err != nil {
|
||||||
writeErr(w, err)
|
writeErr(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1187,6 +1265,55 @@ func (a *App) restore(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "applyError": a.apply(), "restartRequired": true})
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "applyError": a.apply(), "restartRequired": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// checkWebStart refuses web settings the service could not start with: an
|
||||||
|
// address it cannot listen on, or certificate files it cannot read. The
|
||||||
|
// service would stop at the next restart, and the web interface and the API
|
||||||
|
// with it.
|
||||||
|
func (a *App) checkWebStart(old, next WebConfig) error {
|
||||||
|
if err := validateListen(next.Listen, "listen address", false); err != nil {
|
||||||
|
return &userError{err.Error()}
|
||||||
|
}
|
||||||
|
if err := validateListen(next.HTTPListen, "HTTP listen address", true); err != nil {
|
||||||
|
return &userError{err.Error()}
|
||||||
|
}
|
||||||
|
if next.TLS.Mode == "files" && next.TLS != old.TLS {
|
||||||
|
if _, err := tls.LoadX509KeyPair(next.TLS.CertFile, next.TLS.KeyFile); err != nil {
|
||||||
|
return badRequest("the certificate files cannot be used: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
addrs := []string{next.Listen}
|
||||||
|
if next.HTTPListen != "" && next.TLS.Mode != "off" {
|
||||||
|
addrs = append(addrs, next.HTTPListen)
|
||||||
|
}
|
||||||
|
for _, addr := range addrs {
|
||||||
|
if err := a.canListen(addr); err != nil {
|
||||||
|
return badRequest("cannot listen on %s: %v", addr, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// canListen tries to listen on addr. An address the service listens on now,
|
||||||
|
// or one whose port it holds, is fine: it is free again after the restart.
|
||||||
|
func (a *App) canListen(addr string) error {
|
||||||
|
if slices.Contains(a.webAddrs, addr) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
ln, err := net.Listen("tcp", addr)
|
||||||
|
if err == nil {
|
||||||
|
return ln.Close()
|
||||||
|
}
|
||||||
|
if errors.Is(err, syscall.EADDRINUSE) {
|
||||||
|
_, port, _ := net.SplitHostPort(addr)
|
||||||
|
for _, own := range a.webAddrs {
|
||||||
|
if _, p, _ := net.SplitHostPort(own); p == port {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// applyRuntime applies the settings that take effect without a restart: log
|
// applyRuntime applies the settings that take effect without a restart: log
|
||||||
// level and log rotation. Traffic retention is read by the stats sampler.
|
// level and log rotation. Traffic retention is read by the stats sampler.
|
||||||
func (a *App) applyRuntime(c *Config) {
|
func (a *App) applyRuntime(c *Config) {
|
||||||
|
|||||||
@@ -33,8 +33,20 @@
|
|||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
html, body { margin: 0; }
|
html, body { margin: 0; }
|
||||||
body { background: var(--ground); color: var(--ink); font-family: var(--sans); font-size: 14px; line-height: 1.45; }
|
body { background: var(--ground); color: var(--ink); font-family: var(--sans); font-size: 14px; line-height: 1.45; }
|
||||||
a { color: var(--link); }
|
a { color: var(--link); text-decoration-color: rgba(28, 92, 171, .35); text-underline-offset: 3px; }
|
||||||
a:hover { color: var(--link-hover); }
|
a:hover { color: var(--link-hover); text-decoration-color: currentColor; }
|
||||||
|
/* Links to another page (go, back) are ink with an arrow that nudges on
|
||||||
|
hover; outside links (ext) keep the link colour and get ↗. */
|
||||||
|
a.go, a.back { color: var(--ink); font-size: 13px; font-weight: 500; text-decoration: none; white-space: nowrap; }
|
||||||
|
a.go:hover, a.back:hover { color: var(--link); }
|
||||||
|
a.go .ar { margin-left: 4px; }
|
||||||
|
a.back .ar { margin-right: 4px; }
|
||||||
|
a.ext .ar { margin-left: 2px; font-size: .8em; }
|
||||||
|
.ar { display: inline-block; transition: transform .15s; }
|
||||||
|
a.go:hover .ar { transform: translateX(3px); }
|
||||||
|
a.back:hover .ar { transform: translateX(-3px); }
|
||||||
|
a.ext:hover .ar { transform: translate(2px, -2px); }
|
||||||
|
@media (prefers-reduced-motion: reduce) { .ar { transition: none; } }
|
||||||
:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; }
|
:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; }
|
||||||
[hidden] { display: none !important; }
|
[hidden] { display: none !important; }
|
||||||
.mono { font-family: var(--mono); font-size: 13px; }
|
.mono { font-family: var(--mono); font-size: 13px; }
|
||||||
@@ -86,7 +98,7 @@ a.brand, a.brand:hover { color: #fff; text-decoration: none; }
|
|||||||
.titleline { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; }
|
.titleline { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; }
|
||||||
h1 { margin: 0; font-size: 26px; font-weight: 600; letter-spacing: -0.01em; overflow-wrap: anywhere; }
|
h1 { margin: 0; font-size: 26px; font-weight: 600; letter-spacing: -0.01em; overflow-wrap: anywhere; }
|
||||||
.sub { margin: 4px 0 0; color: var(--ink-2); }
|
.sub { margin: 4px 0 0; color: var(--ink-2); }
|
||||||
.back { font-size: 13px; margin-bottom: -8px; }
|
.back { margin-bottom: -8px; align-self: flex-start; }
|
||||||
|
|
||||||
/* cards */
|
/* cards */
|
||||||
.card { background: var(--surface); border: 1px solid var(--line); border-radius: 12px; padding: 20px; min-width: 0; }
|
.card { background: var(--surface); border: 1px solid var(--line); border-radius: 12px; padding: 20px; min-width: 0; }
|
||||||
@@ -151,7 +163,7 @@ tr:last-child td { border-bottom: 0; }
|
|||||||
.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||||
td .note { font-size: 12px; color: var(--ink-3); }
|
td .note { font-size: 12px; color: var(--ink-3); }
|
||||||
a.pname { color: var(--ink); font-weight: 600; text-decoration: none; }
|
a.pname { color: var(--ink); font-weight: 600; text-decoration: none; }
|
||||||
a.pname:hover, a.pname:focus-visible { color: var(--ink); text-decoration: underline; text-underline-offset: 3px; }
|
a.pname:hover { color: var(--link); }
|
||||||
.empty { padding: 24px 12px; margin: 0; text-align: center; color: var(--ink-3); }
|
.empty { padding: 24px 12px; margin: 0; text-align: center; color: var(--ink-3); }
|
||||||
|
|
||||||
/* forms */
|
/* forms */
|
||||||
@@ -254,7 +266,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; }
|
||||||
@@ -331,8 +346,8 @@ dialog::backdrop { background: rgba(22, 23, 26, .55); }
|
|||||||
.setupbox { width: 100%; max-width: 400px; display: flex; flex-direction: column; gap: 24px; margin: auto 0; }
|
.setupbox { width: 100%; max-width: 400px; display: flex; flex-direction: column; gap: 24px; margin: auto 0; }
|
||||||
.setupbox h1 { font-size: 22px; }
|
.setupbox h1 { font-size: 22px; }
|
||||||
.setupbox p { margin: 0; color: #c9c9c3; }
|
.setupbox p { margin: 0; color: #c9c9c3; }
|
||||||
.setupbox a { color: #9cc3f5; }
|
.setupbox a { color: #9cc3f5; text-decoration-color: rgba(156, 195, 245, .4); }
|
||||||
.setupbox a:hover { color: #fff; }
|
.setupbox a:hover { color: #fff; text-decoration-color: currentColor; }
|
||||||
.setupbox .center, .setupbox.center { text-align: center; display: flex; flex-direction: column; align-items: center; gap: 8px; }
|
.setupbox .center, .setupbox.center { text-align: center; display: flex; flex-direction: column; align-items: center; gap: 8px; }
|
||||||
.setupbox.center { gap: 20px; }
|
.setupbox.center { gap: 20px; }
|
||||||
.setupbox .ghost { opacity: .45; }
|
.setupbox .ghost { opacity: .45; }
|
||||||
@@ -378,3 +393,28 @@ dialog::backdrop { background: rgba(22, 23, 26, .55); }
|
|||||||
.card h3 { margin: 0; font-size: 16px; font-weight: 600; }
|
.card h3 { margin: 0; font-size: 16px; font-weight: 600; }
|
||||||
.saves { font-size: 12px; color: var(--ink-3); }
|
.saves { font-size: 12px; color: var(--ink-3); }
|
||||||
pre.log.tall { max-height: calc(100vh - 260px); min-height: 420px; }
|
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 { opacity: .15; }
|
||||||
|
.chart .larea.down { fill: var(--down); }
|
||||||
|
.chart .larea.up { fill: var(--up); }
|
||||||
|
.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,6 +47,7 @@
|
|||||||
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"/>',
|
log: '<rect x="4" y="3" width="16" height="18" rx="2"/><path d="M8 8h8M8 12h8M8 16h5"/>',
|
||||||
@@ -168,6 +169,14 @@
|
|||||||
|
|
||||||
const badge = (st) => h('span', { class: 'badge' }, h('span', { class: st.dot }), st.label);
|
const badge = (st) => h('span', { class: 'badge' }, h('span', { class: st.dot }), st.label);
|
||||||
|
|
||||||
|
// go links to another page of the app; back returns to one. Their arrows
|
||||||
|
// nudge on hover. ext opens an outside page in a new tab, marked with ↗.
|
||||||
|
const arrow = (c) => h('span', { class: 'ar', 'aria-hidden': 'true' }, c);
|
||||||
|
const go = (href, text) => h('a', { class: 'go', href }, text, arrow('→'));
|
||||||
|
const back = (href, text) => h('a', { class: 'back', href }, arrow('←'), text);
|
||||||
|
const ext = (href, text) => h('a', { class: 'ext', href, target: '_blank', rel: 'noopener' }, text, arrow('↗'), h('span', { class: 'sr' }, ' (opens in a new tab)'));
|
||||||
|
const peerLink = (p) => h('a', { class: 'pname', href: '#/peers/' + p.id }, p.name);
|
||||||
|
|
||||||
// svg builds an SVG element; attrs are set as attributes.
|
// svg builds an SVG element; attrs are set as attributes.
|
||||||
function svg(tag, attrs, ...kids) {
|
function svg(tag, attrs, ...kids) {
|
||||||
const el = document.createElementNS('http://www.w3.org/2000/svg', tag);
|
const el = document.createElementNS('http://www.w3.org/2000/svg', tag);
|
||||||
@@ -384,8 +393,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' });
|
||||||
}
|
}
|
||||||
@@ -430,9 +439,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
|
||||||
@@ -489,7 +524,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
|
||||||
@@ -529,7 +564,7 @@
|
|||||||
|
|
||||||
// ---------- shell, router ----------
|
// ---------- shell, router ----------
|
||||||
|
|
||||||
const NAV = [['#/', 'dashboard', 'Dashboard'], ['#/peers', 'peers', 'Peers'], ['#/server', 'server', 'Server'], ['#/log', 'log', 'Log'], ['#/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;
|
||||||
|
|
||||||
@@ -591,6 +626,7 @@
|
|||||||
|
|
||||||
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],
|
||||||
@@ -994,18 +1030,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' },
|
||||||
@@ -1036,22 +1089,22 @@
|
|||||||
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' },
|
||||||
h('div', { class: 'cardhead' }, h('h2', null, 'Peers'), h('a', { href: '#/peers' }, 'All peers')),
|
h('div', { class: 'cardhead' }, h('h2', null, 'Peers'), go('#/peers', 'All peers')),
|
||||||
top.length ? h('div', { class: 'tbl' }, h('table', { class: 'narrow' },
|
top.length ? h('div', { class: 'tbl' }, h('table', { class: 'narrow' },
|
||||||
h('thead', null, h('tr', null, h('th', null, 'Name'), h('th', null, 'Status'), h('th', { class: 'num' }, 'Download, 24 h'), h('th', { class: 'num' }, 'Upload, 24 h'))),
|
h('thead', null, h('tr', null, h('th', null, 'Name'), h('th', null, 'Status'), h('th', { class: 'num' }, 'Download, 24 h'), h('th', { class: 'num' }, 'Upload, 24 h'))),
|
||||||
h('tbody', null, top.map((p) => h('tr', null,
|
h('tbody', null, top.map((p) => h('tr', null,
|
||||||
h('td', null, h('a', { href: '#/peers/' + p.id }, p.name)),
|
h('td', null, peerLink(p)),
|
||||||
h('td', null, badge(peerState(p))),
|
h('td', null, badge(peerState(p))),
|
||||||
h('td', { class: 'num' }, fmtBytes(p.stats.down24h)),
|
h('td', { class: 'num' }, fmtBytes(p.stats.down24h)),
|
||||||
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. ', go('#/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: '#/log' }, 'Log')),
|
h('div', { class: 'cardhead' }, h('h2', null, 'Recent activity'), go('#/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));
|
||||||
@@ -1060,6 +1113,245 @@
|
|||||||
every(30000, () => draw().catch(() => {}));
|
every(30000, () => draw().catch(() => {}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- live ----------
|
||||||
|
|
||||||
|
// fmtRate formats a speed in bits per second, with one decimal from
|
||||||
|
// kbit/s up so the figures keep their shape as they change.
|
||||||
|
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++; }
|
||||||
|
return (i === 0 ? String(Math.round(v)) : v.toFixed(1)) + ' ' + u[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
// curvePath draws a smooth line through the points (monotone cubic): it
|
||||||
|
// never dips below zero or rises above a peak between two steps.
|
||||||
|
function curvePath(xy) {
|
||||||
|
const n = xy.length;
|
||||||
|
if (n < 3) return 'M' + xy.map(([x, y]) => x.toFixed(1) + ',' + y.toFixed(2)).join('L');
|
||||||
|
const d = [], m = [];
|
||||||
|
for (let i = 0; i < n - 1; i++) d.push((xy[i + 1][1] - xy[i][1]) / (xy[i + 1][0] - xy[i][0]));
|
||||||
|
m[0] = d[0];
|
||||||
|
m[n - 1] = d[n - 2];
|
||||||
|
for (let i = 1; i < n - 1; i++) m[i] = d[i - 1] * d[i] <= 0 ? 0 : (d[i - 1] + d[i]) / 2;
|
||||||
|
for (let i = 0; i < n - 1; i++) {
|
||||||
|
if (d[i] === 0) { m[i] = m[i + 1] = 0; continue; }
|
||||||
|
const a = m[i] / d[i], b = m[i + 1] / d[i], q = a * a + b * b;
|
||||||
|
if (q > 9) { const t = 3 / Math.sqrt(q); m[i] = t * a * d[i]; m[i + 1] = t * b * d[i]; }
|
||||||
|
}
|
||||||
|
let p = 'M' + xy[0][0].toFixed(1) + ',' + xy[0][1].toFixed(2);
|
||||||
|
for (let i = 0; i < n - 1; i++) {
|
||||||
|
const [x0, y0] = xy[i], [x1, y1] = xy[i + 1], k = (x1 - x0) / 3;
|
||||||
|
p += 'C' + (x0 + k).toFixed(1) + ',' + (y0 + m[i] * k).toFixed(2) + ' ' + (x1 - k).toFixed(1) + ',' + (y1 - m[i + 1] * k).toFixed(2) + ' ' + x1.toFixed(1) + ',' + y1.toFixed(2);
|
||||||
|
}
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 and upload as lines over light, see-through
|
||||||
|
// areas, so neither looks less important where they overlap. 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 downArea = svg('path', { class: 'larea down' });
|
||||||
|
const upArea = svg('path', { class: 'larea up' });
|
||||||
|
const down = svg('path', { class: 'ldown' });
|
||||||
|
const up = svg('path', { class: 'lup' });
|
||||||
|
const cursor = svg('line', { class: 'cursor', x1: 0, x2: 0, y1: 0, y2: H, visibility: 'hidden' });
|
||||||
|
const g = svg('g', null, downArea, upArea, 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 line = (k) => curvePath(pts.map((p, i) => [x(i), H - p[k] / top * H]));
|
||||||
|
if (n > 1) {
|
||||||
|
const dl = line('down'), ul = line('up');
|
||||||
|
const base = 'L' + x(n - 1).toFixed(1) + ',' + H + 'L' + x(0).toFixed(1) + ',' + H + 'Z';
|
||||||
|
downArea.setAttribute('d', dl + base);
|
||||||
|
upArea.setAttribute('d', ul + base);
|
||||||
|
down.setAttribute('d', dl);
|
||||||
|
up.setAttribute('d', ul);
|
||||||
|
}
|
||||||
|
// Axis values are round: no ".0".
|
||||||
|
ylTop.textContent = fmtRate(top).replace('.0 ', ' ');
|
||||||
|
ylMid.textContent = fmtRate(top / 2).replace('.0 ', ' ');
|
||||||
|
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, peerLink(p), 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);
|
||||||
@@ -1140,7 +1432,7 @@
|
|||||||
return hit && keep;
|
return hit && keep;
|
||||||
});
|
});
|
||||||
tbody.replaceChildren(...sorted(rows).map((p) => h('tr', null,
|
tbody.replaceChildren(...sorted(rows).map((p) => h('tr', null,
|
||||||
h('td', null, h('a', { class: 'pname', href: '#/peers/' + p.id }, p.name), p.note ? h('div', { class: 'note' }, p.note) : null),
|
h('td', null, peerLink(p), p.note ? h('div', { class: 'note' }, p.note) : null),
|
||||||
h('td', { class: 'mono' }, p.ipv4),
|
h('td', { class: 'mono' }, p.ipv4),
|
||||||
h('td', null, badge(peerState(p))),
|
h('td', null, badge(peerState(p))),
|
||||||
h('td', { class: 'mono muted' }, p.stats.endpoint || '–',
|
h('td', { class: 'mono muted' }, p.stats.endpoint || '–',
|
||||||
@@ -1295,7 +1587,7 @@
|
|||||||
drawPreview();
|
drawPreview();
|
||||||
|
|
||||||
fill(wrap,
|
fill(wrap,
|
||||||
h('a', { class: 'back', href: '#/peers' }, '← Peers'),
|
back('#/peers', 'Peers'),
|
||||||
h('div', null, h('h1', null, 'Add peer'), h('p', { class: 'sub' }, 'Assigns the next free address and adds the peer to ' + srv.interface + ' without a restart.')),
|
h('div', null, h('h1', null, 'Add peer'), h('p', { class: 'sub' }, 'Assigns the next free address and adds the peer to ' + srv.interface + ' without a restart.')),
|
||||||
h('div', { class: 'split' }, form,
|
h('div', { class: 'split' }, form,
|
||||||
h('aside', { class: 'card aside', 'aria-labelledby': 'pv' },
|
h('aside', { class: 'card aside', 'aria-labelledby': 'pv' },
|
||||||
@@ -1327,7 +1619,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' });
|
||||||
@@ -1349,7 +1641,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);
|
||||||
@@ -1452,7 +1744,7 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
fill(wrap,
|
fill(wrap,
|
||||||
h('a', { class: 'back', href: '#/peers' }, '← Peers'),
|
back('#/peers', 'Peers'),
|
||||||
h('div', { class: 'head' },
|
h('div', { class: 'head' },
|
||||||
h('div', null,
|
h('div', null,
|
||||||
h('div', { class: 'titleline' }, h('h1', null, p.name), badge(st.key === 'online' ? { ...st, label: 'Online · handshake ' + ago(p.stats.lastHandshake) } : st)),
|
h('div', { class: 'titleline' }, h('h1', null, p.name), badge(st.key === 'online' ? { ...st, label: 'Online · handshake ' + ago(p.stats.lastHandshake) } : st)),
|
||||||
@@ -1503,7 +1795,7 @@
|
|||||||
: h('p', { class: 'empty' }, 'No connections recorded yet.'),
|
: h('p', { class: 'empty' }, 'No connections recorded yet.'),
|
||||||
sessions.length > SHORT ? h('div', { style: { margin: '8px 12px 0' } }, sessMore) : null,
|
sessions.length > SHORT ? h('div', { style: { margin: '8px 12px 0' } }, sessMore) : null,
|
||||||
h('p', { class: 'hint', style: { margin: '4px 12px 12px' } }, 'Country and network: ',
|
h('p', { class: 'hint', style: { margin: '4px 12px 12px' } }, 'Country and network: ',
|
||||||
h('a', { href: 'https://db-ip.com', target: '_blank', rel: 'noopener' }, 'IP Geolocation by DB-IP'),
|
ext('https://db-ip.com', 'IP Geolocation by DB-IP'),
|
||||||
'. Kept as long as the daily traffic history.')),
|
'. Kept as long as the daily traffic history.')),
|
||||||
|
|
||||||
h('form', { class: 'card', onSubmit: save },
|
h('form', { class: 'card', onSubmit: save },
|
||||||
@@ -1563,8 +1855,8 @@
|
|||||||
t.status = c.ok ? 'Present' : 'Missing';
|
t.status = c.ok ? 'Present' : 'Missing';
|
||||||
if (/^table /.test(c.detail)) { t.raw = c.detail.replace(/ (present|missing)$/, ''); t.problem = null; }
|
if (/^table /.test(c.detail)) { t.raw = c.detail.replace(/ (present|missing)$/, ''); t.problem = null; }
|
||||||
break;
|
break;
|
||||||
case 'Last apply':
|
case 'Kernel in sync':
|
||||||
if (c.ok) { const iso = c.detail.replace(/^applied /, ''); t.status = ago(iso); t.title = fmtStamp(iso); } else t.status = 'Failed';
|
if (c.ok) { const iso = c.detail.replace(/^applied /, ''); t.status = ago(iso); t.title = fmtStamp(iso); } else t.status = 'Out of sync';
|
||||||
break;
|
break;
|
||||||
case 'Latency check': t.status = c.ok ? 'Tunnel ping works' : 'Failing'; break;
|
case 'Latency check': t.status = c.ok ? 'Tunnel ping works' : 'Failing'; break;
|
||||||
}
|
}
|
||||||
@@ -1696,7 +1988,7 @@
|
|||||||
fieldEl('up6', 'IPv6 uplink interface', h('input', { id: 'up6', class: 'mono', value: draft.uplinkV6, placeholder: 'auto: ' + (srv.detectedUplinkV6 || 'none found'), onInput: str('uplinkV6') })),
|
fieldEl('up6', 'IPv6 uplink interface', h('input', { id: 'up6', class: 'mono', value: draft.uplinkV6, placeholder: 'auto: ' + (srv.detectedUplinkV6 || 'none found'), onInput: str('uplinkV6') })),
|
||||||
cb('nat', 'Masquerade (NAT) peer traffic to the internet'),
|
cb('nat', 'Masquerade (NAT) peer traffic to the internet'),
|
||||||
cb('peerToPeer', 'Allow peers to reach each other'),
|
cb('peerToPeer', 'Allow peers to reach each other'),
|
||||||
cb('lanAccess', 'Allow peers to reach the server\'s LAN', 'Private networks on the uplink interface'),
|
cb('lanAccess', 'Allow peers to reach the server\'s LAN', 'Private IPv4 and the IPv6 networks on the uplink interface'),
|
||||||
cb('openPort', 'Accept UDP ' + draft.listenPort + ' in the input chain'))),
|
cb('openPort', 'Accept UDP ' + draft.listenPort + ' in the input chain'))),
|
||||||
|
|
||||||
h('section', { class: 'card', 'aria-labelledby': 'ky' },
|
h('section', { class: 'card', 'aria-labelledby': 'ky' },
|
||||||
@@ -1833,7 +2125,7 @@
|
|||||||
notes ? h('div', { class: 'upnotes' },
|
notes ? h('div', { class: 'upnotes' },
|
||||||
h('div', { class: 'hd' }, h('strong', null, 'What\'s new in ' + rel.version),
|
h('div', { class: 'hd' }, h('strong', null, 'What\'s new in ' + rel.version),
|
||||||
h('span', { class: 'muted' }, 'Released ' + fmtDate(rel.published) + ' · from ' + srcName()),
|
h('span', { class: 'muted' }, 'Released ' + fmtDate(rel.published) + ' · from ' + srcName()),
|
||||||
h('a', { href: rel.url, target: '_blank', rel: 'noopener' }, 'Full notes on ' + srcName())),
|
ext(rel.url, 'Full notes on ' + srcName())),
|
||||||
/security/i.test(notes.summary) ? h('p', { class: 'notice' }, 'Includes security fixes.') : null,
|
/security/i.test(notes.summary) ? h('p', { class: 'notice' }, 'Includes security fixes.') : null,
|
||||||
notes.summary ? h('p', null, mdInline(notes.summary)) : null,
|
notes.summary ? h('p', null, mdInline(notes.summary)) : null,
|
||||||
notes.items.length ? h('ul', null, notes.items.map((t) => h('li', null, mdInline(t)))) : null) : null,
|
notes.items.length ? h('ul', null, notes.items.map((t) => h('li', null, mdInline(t)))) : null) : null,
|
||||||
@@ -1841,7 +2133,7 @@
|
|||||||
h('div', { class: 'hd' }, h('strong', null, 'Update this server'), h('span', { class: 'muted' }, 'Run on the server. VPN connections stay up.')),
|
h('div', { class: 'hd' }, h('strong', null, 'Update this server'), h('span', { class: 'muted' }, 'Run on the server. VPN connections stay up.')),
|
||||||
h('pre', { class: 'code' }, cmds),
|
h('pre', { class: 'code' }, cmds),
|
||||||
h('div', null, h('button', { type: 'button', class: 'btn small', onClick: () => copy(cmds) }, 'Copy commands'))) : null,
|
h('div', null, h('button', { type: 'button', class: 'btn small', onClick: () => copy(cmds) }, 'Copy commands'))) : null,
|
||||||
st.available && !st.file ? h('p', null, 'No release file is built for this platform. ', h('a', { href: rel.url, target: '_blank', rel: 'noopener' }, 'See the release')) : null,
|
st.available && !st.file ? h('p', null, 'No release file is built for this platform. ', ext(rel.url, 'See the release')) : null,
|
||||||
h('fieldset', { class: 'section' }, h('legend', { class: 'legend' }, 'Release source'),
|
h('fieldset', { class: 'section' }, h('legend', { class: 'legend' }, 'Release source'),
|
||||||
h('div', { class: 'grid' }, SOURCES.map(([k, name, where]) => h('label', { class: 'opt' },
|
h('div', { class: 'grid' }, SOURCES.map(([k, name, where]) => h('label', { class: 'opt' },
|
||||||
h('input', { type: 'radio', name: 'upsrc', value: k, checked: st.source === k, onChange: () => save({ source: k }) }),
|
h('input', { type: 'radio', name: 'upsrc', value: k, checked: st.source === k, onChange: () => save({ source: k }) }),
|
||||||
|
|||||||
@@ -287,9 +287,14 @@ func remoteIP(r *http.Request) string {
|
|||||||
host = r.RemoteAddr
|
host = r.RemoteAddr
|
||||||
}
|
}
|
||||||
// Behind a local reverse proxy the real client is in X-Forwarded-For.
|
// Behind a local reverse proxy the real client is in X-Forwarded-For.
|
||||||
|
// The proxy appends the address it saw, so only the last entry counts:
|
||||||
|
// earlier ones come from the client and can be anything.
|
||||||
if ip := net.ParseIP(host); ip != nil && ip.IsLoopback() {
|
if ip := net.ParseIP(host); ip != nil && ip.IsLoopback() {
|
||||||
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
if xff := r.Header.Values("X-Forwarded-For"); len(xff) > 0 {
|
||||||
return strings.TrimSpace(strings.Split(xff, ",")[0])
|
list := strings.Split(xff[len(xff)-1], ",")
|
||||||
|
if last := strings.TrimSpace(list[len(list)-1]); net.ParseIP(last) != nil {
|
||||||
|
return last
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return host
|
return host
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"slices"
|
"slices"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"syscall"
|
"syscall"
|
||||||
@@ -76,8 +77,29 @@ const (
|
|||||||
minLogFiles, maxLogFiles = 1, 100
|
minLogFiles, maxLogFiles = 1, 100
|
||||||
minHourlyHours, maxHourlyHrs = 24, 24 * 31
|
minHourlyHours, maxHourlyHrs = 24, 24 * 31
|
||||||
minDailyDays, maxDailyDays = 7, 3660
|
minDailyDays, maxDailyDays = 7, 3660
|
||||||
|
minSessionHours = 1
|
||||||
|
maxSessionHours = 30 * 24
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// validateListen checks a listen address like ":443" or "192.0.2.1:443".
|
||||||
|
// Empty is allowed when optional (the HTTP listener is then off).
|
||||||
|
func validateListen(addr, field string, optional bool) error {
|
||||||
|
if addr == "" && optional {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
host, port, err := net.SplitHostPort(addr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%s %q must look like :443 or 192.0.2.1:443", field, addr)
|
||||||
|
}
|
||||||
|
if n, err := strconv.Atoi(port); err != nil || n < 1 || n > 65535 {
|
||||||
|
return fmt.Errorf("%s %q: the port must be 1–65535", field, addr)
|
||||||
|
}
|
||||||
|
if host != "" && host != "localhost" && checkEndpoint(host) != nil {
|
||||||
|
return fmt.Errorf("%s %q: %q is not an IP address or host name", field, addr, host)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
type WebConfig struct {
|
type WebConfig struct {
|
||||||
Listen string `json:"listen"` // HTTPS (or HTTP when tls.mode is "off") listen address
|
Listen string `json:"listen"` // HTTPS (or HTTP when tls.mode is "off") listen address
|
||||||
HTTPListen string `json:"httpListen"` // plain HTTP for ACME http-01 and redirects; "" disables
|
HTTPListen string `json:"httpListen"` // plain HTTP for ACME http-01 and redirects; "" disables
|
||||||
@@ -364,7 +386,9 @@ func (c *Config) validate() error {
|
|||||||
if v6.Masked() != v6 {
|
if v6.Masked() != v6 {
|
||||||
return fmt.Errorf("IPv6 network must be the network address, e.g. %s", v6.Masked())
|
return fmt.Errorf("IPv6 network must be the network address, e.g. %s", v6.Masked())
|
||||||
}
|
}
|
||||||
if s.Endpoint != "" && strings.ContainsAny(s.Endpoint, " /:") && net.ParseIP(s.Endpoint) == nil {
|
// The endpoint is written into client configs as is, so it must be a
|
||||||
|
// plain host name or IP: anything else could add lines to them.
|
||||||
|
if s.Endpoint != "" && checkEndpoint(s.Endpoint) != nil {
|
||||||
return errors.New("endpoint must be a host name or IP address without port")
|
return errors.New("endpoint must be a host name or IP address without port")
|
||||||
}
|
}
|
||||||
if err := validateHostList(s.ClientDefaults.DNS, "DNS", false); err != nil {
|
if err := validateHostList(s.ClientDefaults.DNS, "DNS", false); err != nil {
|
||||||
@@ -397,6 +421,15 @@ func (c *Config) validate() error {
|
|||||||
if _, ok := updateSources[c.Updates.Source]; !ok {
|
if _, ok := updateSources[c.Updates.Source]; !ok {
|
||||||
return fmt.Errorf("update source must be gitea or github")
|
return fmt.Errorf("update source must be gitea or github")
|
||||||
}
|
}
|
||||||
|
if err := validateListen(c.Web.Listen, "listen address", false); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := validateListen(c.Web.HTTPListen, "HTTP listen address", true); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if h := c.Web.SessionHours; h < minSessionHours || h > maxSessionHours {
|
||||||
|
return fmt.Errorf("session length must be %d–%d hours", minSessionHours, maxSessionHours)
|
||||||
|
}
|
||||||
switch c.Web.TLS.Mode {
|
switch c.Web.TLS.Mode {
|
||||||
case "acme":
|
case "acme":
|
||||||
if c.Web.TLS.Domain == "" {
|
if c.Web.TLS.Domain == "" {
|
||||||
@@ -586,6 +619,12 @@ func (s *Store) Update(fn func(c *Config) error) error {
|
|||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
return &userError{err.Error()}
|
return &userError{err.Error()}
|
||||||
}
|
}
|
||||||
|
// With no user left (applyDefaults then adds an "admin" without a
|
||||||
|
// password), nobody could sign in until someone ran "passwd" on the server.
|
||||||
|
if old.passwordSet() && !next.passwordSet() {
|
||||||
|
s.mu.Unlock()
|
||||||
|
return &userError{"this would leave no user with a password, and nobody could sign in"}
|
||||||
|
}
|
||||||
if err := writeFileAtomic(s.path, next, 0o600); err != nil {
|
if err := writeFileAtomic(s.path, next, 0o600); err != nil {
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"os"
|
"os"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -40,6 +41,28 @@ type Kernel interface {
|
|||||||
Close() error
|
Close() error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// lanBlock picks, from the networks on the uplinks, the ones peers must not
|
||||||
|
// reach while LAN access is off: private IPv4 networks, and IPv6 networks
|
||||||
|
// except link-local, since a home LAN uses global IPv6 addresses. IPv6
|
||||||
|
// prefixes shorter than /48 are left out: they are no LAN.
|
||||||
|
func lanBlock(nets []netip.Prefix) []netip.Prefix {
|
||||||
|
var out []netip.Prefix
|
||||||
|
for _, p := range nets {
|
||||||
|
a := p.Addr().Unmap()
|
||||||
|
p = netip.PrefixFrom(a, min(p.Bits(), a.BitLen())).Masked()
|
||||||
|
switch {
|
||||||
|
case a.Is4() && !a.IsPrivate():
|
||||||
|
continue
|
||||||
|
case a.Is6() && (a.IsLinkLocalUnicast() || a.IsLoopback() || p.Bits() < 48):
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !slices.Contains(out, p) {
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// readSysctl returns the trimmed content of a /proc/sys file, or "".
|
// readSysctl returns the trimmed content of a /proc/sys file, or "".
|
||||||
func readSysctl(path string) string {
|
func readSysctl(path string) string {
|
||||||
b, err := os.ReadFile(path)
|
b, err := os.ReadFile(path)
|
||||||
@@ -56,6 +79,10 @@ type Reconciler struct {
|
|||||||
store *Store
|
store *Store
|
||||||
trigger chan struct{}
|
trigger chan struct{}
|
||||||
|
|
||||||
|
// applyMu runs one apply at a time. Each reads the config once it holds
|
||||||
|
// the lock, so the last apply always uses the newest config.
|
||||||
|
applyMu sync.Mutex
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
lastErr error
|
lastErr error
|
||||||
lastApply time.Time
|
lastApply time.Time
|
||||||
@@ -76,6 +103,8 @@ func (r *Reconciler) Kick() {
|
|||||||
// ApplyNow applies synchronously and returns the result, so an API call can
|
// ApplyNow applies synchronously and returns the result, so an API call can
|
||||||
// report kernel errors to the user.
|
// report kernel errors to the user.
|
||||||
func (r *Reconciler) ApplyNow() error {
|
func (r *Reconciler) ApplyNow() error {
|
||||||
|
r.applyMu.Lock()
|
||||||
|
defer r.applyMu.Unlock()
|
||||||
err := r.kernel.Apply(r.store.Get())
|
err := r.kernel.Apply(r.store.Get())
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
r.lastErr, r.lastApply = err, time.Now()
|
r.lastErr, r.lastApply = err, time.Now()
|
||||||
|
|||||||
+20
-19
@@ -217,7 +217,8 @@ func (k *linuxKernel) Apply(c *Config) error {
|
|||||||
if c.Server.IPv6Enabled {
|
if c.Server.IPv6Enabled {
|
||||||
_ = os.WriteFile("/proc/sys/net/ipv6/conf/all/forwarding", []byte("1"), 0o644)
|
_ = os.WriteFile("/proc/sys/net/ipv6/conf/all/forwarding", []byte("1"), 0o644)
|
||||||
}
|
}
|
||||||
return applyFirewall(c, k.Uplink(c, false), k.Uplink(c, true), lanNetworks(k.Uplink(c, false)))
|
up4, up6 := k.Uplink(c, false), k.Uplink(c, true)
|
||||||
|
return applyFirewall(c, up4, up6, lanNetworks(up4, up6))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (k *linuxKernel) Sample(iface string) ([]PeerSample, error) {
|
func (k *linuxKernel) Sample(iface string) ([]PeerSample, error) {
|
||||||
@@ -264,27 +265,27 @@ func (k *linuxKernel) Uplink(c *Config, v6 bool) string {
|
|||||||
return l.Attrs().Name
|
return l.Attrs().Name
|
||||||
}
|
}
|
||||||
|
|
||||||
// lanNetworks returns the private IPv4 networks on the uplink, used to block
|
// lanNetworks returns the LAN networks on the IPv4 and IPv6 uplinks (see
|
||||||
// peers from the server's LAN when LAN access is off.
|
// lanBlock), used to block peers from the server's LAN when LAN access is off.
|
||||||
func lanNetworks(uplink string) []netip.Prefix {
|
func lanNetworks(uplinks ...string) []netip.Prefix {
|
||||||
if uplink == "" {
|
var nets []netip.Prefix
|
||||||
return nil
|
for i, uplink := range uplinks {
|
||||||
}
|
if uplink == "" || slices.Contains(uplinks[:i], uplink) {
|
||||||
l, err := netlink.LinkByName(uplink)
|
|
||||||
if err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
addrs, _ := netlink.AddrList(l, netlink.FAMILY_V4)
|
|
||||||
var out []netip.Prefix
|
|
||||||
for _, a := range addrs {
|
|
||||||
if !a.IP.IsPrivate() {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
ones, _ := a.Mask.Size()
|
l, err := netlink.LinkByName(uplink)
|
||||||
ip, _ := netip.AddrFromSlice(a.IP.To4())
|
if err != nil {
|
||||||
out = append(out, netip.PrefixFrom(ip, ones).Masked())
|
continue
|
||||||
|
}
|
||||||
|
addrs, _ := netlink.AddrList(l, netlink.FAMILY_ALL)
|
||||||
|
for _, a := range addrs {
|
||||||
|
ones, _ := a.Mask.Size()
|
||||||
|
if ip, ok := netip.AddrFromSlice(a.IP); ok {
|
||||||
|
nets = append(nets, netip.PrefixFrom(ip.Unmap(), ones))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return out
|
return lanBlock(nets)
|
||||||
}
|
}
|
||||||
|
|
||||||
// publicAddr reports the uplink's address for the health check: the first
|
// publicAddr reports the uplink's address for the health check: the first
|
||||||
|
|||||||
+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,22 @@ 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,
|
||||||
|
webAddrs: []string{cfg.Web.Listen},
|
||||||
|
}
|
||||||
|
if cfg.Web.HTTPListen != "" && cfg.Web.TLS.Mode != "off" {
|
||||||
|
app.webAddrs = append(app.webAddrs, cfg.Web.HTTPListen)
|
||||||
}
|
}
|
||||||
|
|
||||||
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) }()
|
||||||
|
|||||||
+321
@@ -6,6 +6,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/cookiejar"
|
"net/http/cookiejar"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@@ -79,6 +80,15 @@ func TestValidate(t *testing.T) {
|
|||||||
"bad port": func(c *Config) { c.Server.ListenPort = 70000 },
|
"bad port": func(c *Config) { c.Server.ListenPort = 70000 },
|
||||||
"unmasked net": func(c *Config) { c.Server.IPv4 = "10.84.12.5/24" },
|
"unmasked net": func(c *Config) { c.Server.IPv4 = "10.84.12.5/24" },
|
||||||
"update source": func(c *Config) { c.Updates.Source = "sourceforge" },
|
"update source": func(c *Config) { c.Updates.Source = "sourceforge" },
|
||||||
|
// The endpoint goes into client configs: no extra lines.
|
||||||
|
"endpoint newline": func(c *Config) { c.Server.Endpoint = "vpn.example.net\n[Interface]\nPreUp=id;#" },
|
||||||
|
"endpoint tab": func(c *Config) { c.Server.Endpoint = "vpn.example.net\tx" },
|
||||||
|
"endpoint port": func(c *Config) { c.Server.Endpoint = "vpn.example.net:51820" },
|
||||||
|
"listen": func(c *Config) { c.Web.Listen = "not-an-address" },
|
||||||
|
"listen port": func(c *Config) { c.Web.Listen = ":70000" },
|
||||||
|
"http listen": func(c *Config) { c.Web.HTTPListen = "80" },
|
||||||
|
"session hours": func(c *Config) { c.Web.SessionHours = -1 },
|
||||||
|
"session too long": func(c *Config) { c.Web.SessionHours = 100000 },
|
||||||
} {
|
} {
|
||||||
cc := c.clone()
|
cc := c.clone()
|
||||||
mutate(cc)
|
mutate(cc)
|
||||||
@@ -86,6 +96,20 @@ func TestValidate(t *testing.T) {
|
|||||||
t.Errorf("%s: expected an error", name)
|
t.Errorf("%s: expected an error", name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for _, ep := range []string{"vpn.example.net", "203.0.113.7", "2001:db8::1"} {
|
||||||
|
cc := c.clone()
|
||||||
|
cc.Server.Endpoint = ep
|
||||||
|
if err := cc.validate(); err != nil {
|
||||||
|
t.Errorf("endpoint %q rejected: %v", ep, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, l := range []string{":443", "0.0.0.0:8443", "[::]:443", "localhost:8080"} {
|
||||||
|
cc := c.clone()
|
||||||
|
cc.Web.Listen = l
|
||||||
|
if err := cc.validate(); err != nil {
|
||||||
|
t.Errorf("listen %q rejected: %v", l, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestClientConfig(t *testing.T) {
|
func TestClientConfig(t *testing.T) {
|
||||||
@@ -1286,3 +1310,300 @@ 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// signedInApp starts the API with a signed-in admin and returns the app
|
||||||
|
// and a call function.
|
||||||
|
func signedInApp(t *testing.T) (*App, func(method, path string, body any, want int) map[string]any) {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
store, err := openStore(filepath.Join(dir, "config.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
hash, _ := hashPassword("a long test password")
|
||||||
|
_ = store.Update(func(c *Config) error { c.Users[0].PasswordHash = hash; return nil })
|
||||||
|
k := &fakeKernel{}
|
||||||
|
st, _ := openStats(filepath.Join(dir, "stats.json"), store, k)
|
||||||
|
app := &App{store: store, kernel: k, recon: newReconciler(k, store), stats: st, auth: newAuth(store),
|
||||||
|
tls: &webTLS{}, logPath: filepath.Join(dir, "log.jsonl"), started: time.Now(), shutdown: func() {}}
|
||||||
|
srv := httptest.NewServer(app.routes())
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
jar, _ := cookiejar.New(nil)
|
||||||
|
cl := &http.Client{Jar: jar}
|
||||||
|
call := func(method, path string, body any, want int) map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
var rd io.Reader
|
||||||
|
if body != nil {
|
||||||
|
b, _ := json.Marshal(body)
|
||||||
|
rd = bytes.NewReader(b)
|
||||||
|
}
|
||||||
|
req, _ := http.NewRequest(method, srv.URL+"/api/v1"+path, rd)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp, err := cl.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
var out map[string]any
|
||||||
|
_ = json.NewDecoder(resp.Body).Decode(&out)
|
||||||
|
if resp.StatusCode != want {
|
||||||
|
t.Fatalf("%s %s: status %d, want %d: %v", method, path, resp.StatusCode, want, out)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
call("POST", "/auth/login", map[string]string{"username": "admin", "password": "a long test password"}, 200)
|
||||||
|
return app, call
|
||||||
|
}
|
||||||
|
|
||||||
|
// Web settings the service could not start with are refused before they
|
||||||
|
// are saved: a restart would otherwise take the web interface and the API
|
||||||
|
// down for good.
|
||||||
|
func TestWebSettingsCheck(t *testing.T) {
|
||||||
|
app, call := signedInApp(t)
|
||||||
|
web := func(change func(w *WebConfig)) map[string]any {
|
||||||
|
w := app.store.Get().Web
|
||||||
|
w.HTTPListen = ""
|
||||||
|
change(&w)
|
||||||
|
return map[string]any{"web": w}
|
||||||
|
}
|
||||||
|
call("PATCH", "/settings", web(func(w *WebConfig) { w.Listen = "not-an-address" }), 400)
|
||||||
|
call("PATCH", "/settings", web(func(w *WebConfig) { w.SessionHours = -1 }), 400)
|
||||||
|
call("PATCH", "/settings", web(func(w *WebConfig) {
|
||||||
|
w.TLS = TLSConfig{Mode: "files", CertFile: "/nonexistent/cert.pem", KeyFile: "/nonexistent/key.pem"}
|
||||||
|
}), 400)
|
||||||
|
|
||||||
|
// A port another program holds is refused; a free one is saved.
|
||||||
|
busy, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer busy.Close()
|
||||||
|
call("PATCH", "/settings", web(func(w *WebConfig) { w.Listen = busy.Addr().String() }), 400)
|
||||||
|
free, _ := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
addr := free.Addr().String()
|
||||||
|
free.Close()
|
||||||
|
call("PATCH", "/settings", web(func(w *WebConfig) { w.Listen = addr; w.TLS = TLSConfig{Mode: "off"} }), 200)
|
||||||
|
if app.store.Get().Web.Listen != addr {
|
||||||
|
t.Fatal("valid listen address not saved")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The address the service listens on now is in use by itself: fine.
|
||||||
|
app.webAddrs = []string{busy.Addr().String()}
|
||||||
|
call("PATCH", "/settings", web(func(w *WebConfig) { w.Listen = busy.Addr().String() }), 200)
|
||||||
|
|
||||||
|
// Restore runs the same check.
|
||||||
|
backup := app.store.Get()
|
||||||
|
backup.Web.Listen = "not-an-address"
|
||||||
|
call("POST", "/restore", backup, 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRemoteIP(t *testing.T) {
|
||||||
|
for _, c := range []struct {
|
||||||
|
remote string
|
||||||
|
xff []string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"203.0.113.5:1234", nil, "203.0.113.5"},
|
||||||
|
{"203.0.113.5:1234", []string{"198.51.100.1"}, "203.0.113.5"}, // not from a local proxy
|
||||||
|
{"127.0.0.1:1234", []string{"198.51.100.1"}, "198.51.100.1"},
|
||||||
|
// The client sent its own header; the proxy appended the real address.
|
||||||
|
{"127.0.0.1:1234", []string{"1.2.3.4, 198.51.100.1"}, "198.51.100.1"},
|
||||||
|
{"127.0.0.1:1234", []string{"1.2.3.4", "198.51.100.1"}, "198.51.100.1"},
|
||||||
|
{"127.0.0.1:1234", []string{"garbage"}, "127.0.0.1"},
|
||||||
|
} {
|
||||||
|
r := httptest.NewRequest("GET", "/", nil)
|
||||||
|
r.RemoteAddr = c.remote
|
||||||
|
for _, v := range c.xff {
|
||||||
|
r.Header.Add("X-Forwarded-For", v)
|
||||||
|
}
|
||||||
|
if got := remoteIP(r); got != c.want {
|
||||||
|
t.Errorf("%s %v: got %s, want %s", c.remote, c.xff, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Anyone can start a passkey sign-in, so pending ones are capped per
|
||||||
|
// address and in total.
|
||||||
|
func TestPasskeyLoginCap(t *testing.T) {
|
||||||
|
a := newAuth(nil)
|
||||||
|
start := func(id, ip string, expires time.Time) bool {
|
||||||
|
a.mu.Lock()
|
||||||
|
defer a.mu.Unlock()
|
||||||
|
return a.addPasskeyLoginLocked(id, &ceremony{ip: lockKey(ip), expires: expires})
|
||||||
|
}
|
||||||
|
later := time.Now().Add(ticketTTL)
|
||||||
|
for i := range maxPasskeyLoginsPerIP {
|
||||||
|
if !start(fmt.Sprint("a", i), "198.51.100.1", later) {
|
||||||
|
t.Fatalf("sign-in %d refused", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if start("a-more", "198.51.100.1", later) {
|
||||||
|
t.Fatal("too many sign-ins from one address accepted")
|
||||||
|
}
|
||||||
|
if !start("b0", "198.51.100.2", later) {
|
||||||
|
t.Fatal("another address refused")
|
||||||
|
}
|
||||||
|
// Expired ones make room again.
|
||||||
|
a.mfa.logins = map[string]*ceremony{}
|
||||||
|
start("old", "198.51.100.3", time.Now().Add(-time.Second))
|
||||||
|
if !start("new", "198.51.100.3", later) || len(a.mfa.logins) != 1 {
|
||||||
|
t.Fatalf("expired sign-in not dropped: %d pending", len(a.mfa.logins))
|
||||||
|
}
|
||||||
|
// In total, the oldest makes room.
|
||||||
|
a.mfa.logins = map[string]*ceremony{}
|
||||||
|
for i := range maxPasskeyLogins {
|
||||||
|
start(fmt.Sprint("c", i), fmt.Sprintf("10.0.%d.%d", i/250, i%250), later.Add(time.Duration(i)*time.Millisecond))
|
||||||
|
}
|
||||||
|
start("last", "192.0.2.1", later.Add(time.Hour))
|
||||||
|
if _, ok := a.mfa.logins["c0"]; ok || len(a.mfa.logins) != maxPasskeyLogins {
|
||||||
|
t.Fatalf("cap not kept: %d pending, oldest kept %v", len(a.mfa.logins), ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLanBlock(t *testing.T) {
|
||||||
|
got := lanBlock([]netip.Prefix{
|
||||||
|
netip.MustParsePrefix("192.168.1.20/24"),
|
||||||
|
netip.MustParsePrefix("203.0.113.9/24"), // public IPv4: not a LAN
|
||||||
|
netip.MustParsePrefix("2001:db8:1:2::20/64"),
|
||||||
|
netip.MustParsePrefix("fd00:1:2:3::20/64"),
|
||||||
|
netip.MustParsePrefix("fe80::1/64"),
|
||||||
|
netip.MustParsePrefix("2001:db8::1/32"), // no LAN
|
||||||
|
netip.MustParsePrefix("192.168.1.30/24"), // same network twice
|
||||||
|
})
|
||||||
|
want := []netip.Prefix{
|
||||||
|
netip.MustParsePrefix("192.168.1.0/24"),
|
||||||
|
netip.MustParsePrefix("2001:db8:1:2::/64"),
|
||||||
|
netip.MustParsePrefix("fd00:1:2:3::/64"),
|
||||||
|
}
|
||||||
|
if !slices.Equal(got, want) {
|
||||||
|
t.Fatalf("got %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A change that would leave no user with a password is refused: restoring
|
||||||
|
// a backup without users, or the last users deleting each other.
|
||||||
|
func TestNoUserLeftWithPassword(t *testing.T) {
|
||||||
|
app, call := signedInApp(t)
|
||||||
|
if err := app.store.Update(func(c *Config) error { c.Users = nil; return nil }); err == nil {
|
||||||
|
t.Fatal("removing every user was accepted")
|
||||||
|
}
|
||||||
|
if !app.store.Get().passwordSet() {
|
||||||
|
t.Fatal("password lost")
|
||||||
|
}
|
||||||
|
|
||||||
|
backup := app.store.Get()
|
||||||
|
backup.Users, backup.APITokens = nil, nil
|
||||||
|
call("POST", "/restore", backup, 400)
|
||||||
|
backup = app.store.Get()
|
||||||
|
backup.Version = configVersion + 1
|
||||||
|
call("POST", "/restore", backup, 400)
|
||||||
|
call("POST", "/restore", app.store.Get(), 200)
|
||||||
|
if !app.store.Get().passwordSet() {
|
||||||
|
t.Fatal("password lost")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// slowKernel records the configs it applied; the first apply takes a while.
|
||||||
|
type slowKernel struct {
|
||||||
|
fakeKernel
|
||||||
|
mu sync.Mutex
|
||||||
|
calls int
|
||||||
|
applied []string // peer names, per apply
|
||||||
|
}
|
||||||
|
|
||||||
|
func (k *slowKernel) Apply(c *Config) error {
|
||||||
|
k.mu.Lock()
|
||||||
|
k.calls++
|
||||||
|
first := k.calls == 1
|
||||||
|
k.mu.Unlock()
|
||||||
|
if first {
|
||||||
|
time.Sleep(200 * time.Millisecond)
|
||||||
|
}
|
||||||
|
var names []string
|
||||||
|
for _, p := range c.Peers {
|
||||||
|
names = append(names, p.Name)
|
||||||
|
}
|
||||||
|
k.mu.Lock()
|
||||||
|
k.applied = append(k.applied, strings.Join(names, ","))
|
||||||
|
k.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Applies run one at a time, so a slow apply of an older config cannot
|
||||||
|
// finish after the newest one and undo it in the kernel.
|
||||||
|
func TestApplyOrder(t *testing.T) {
|
||||||
|
store, err := openStore(filepath.Join(t.TempDir(), "config.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
k := &slowKernel{}
|
||||||
|
r := newReconciler(k, store)
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(1)
|
||||||
|
go func() { defer wg.Done(); _ = r.ApplyNow() }() // the old config, slowly
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
if err := store.Update(func(c *Config) error {
|
||||||
|
c.Peers = append(c.Peers, Peer{ID: newID(), Name: "phone", IPv4: serverIPv4(netip.MustParsePrefix(c.Server.IPv4)).Next().String()})
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_ = r.ApplyNow()
|
||||||
|
wg.Wait()
|
||||||
|
if last := k.applied[len(k.applied)-1]; last != "phone" {
|
||||||
|
t.Fatalf("the kernel ended with %q, not the newest config; applies: %q", last, k.applied)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -217,6 +217,7 @@ type ticket struct {
|
|||||||
|
|
||||||
type ceremony struct {
|
type ceremony struct {
|
||||||
userID string // "" for a passkey sign-in
|
userID string // "" for a passkey sign-in
|
||||||
|
ip string // lockKey of who started a passkey sign-in
|
||||||
data *webauthn.SessionData
|
data *webauthn.SessionData
|
||||||
expires time.Time
|
expires time.Time
|
||||||
}
|
}
|
||||||
@@ -551,12 +552,52 @@ func (a *App) loginPasskeyBegin(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
id := randomString(24)
|
id := randomString(24)
|
||||||
|
ip := remoteIP(r)
|
||||||
a.auth.mu.Lock()
|
a.auth.mu.Lock()
|
||||||
a.auth.mfa.logins[id] = &ceremony{data: data, expires: time.Now().Add(ticketTTL)}
|
ok := a.auth.addPasskeyLoginLocked(id, &ceremony{data: data, ip: lockKey(ip), expires: time.Now().Add(ticketTTL)})
|
||||||
a.auth.mu.Unlock()
|
a.auth.mu.Unlock()
|
||||||
|
if !ok {
|
||||||
|
writeJSON(w, http.StatusTooManyRequests, map[string]string{"error": errBusy.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"id": id, "options": opts})
|
writeJSON(w, http.StatusOK, map[string]any{"id": id, "options": opts})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Anyone can start a passkey sign-in, so the pending ones are capped: per
|
||||||
|
// address, and in total, where the oldest makes room.
|
||||||
|
const (
|
||||||
|
maxPasskeyLogins = 1000
|
||||||
|
maxPasskeyLoginsPerIP = 10
|
||||||
|
)
|
||||||
|
|
||||||
|
// addPasskeyLoginLocked stores a started passkey sign-in, or reports false
|
||||||
|
// when its address has too many pending. a.mu must be held.
|
||||||
|
func (a *Auth) addPasskeyLoginLocked(id string, c *ceremony) bool {
|
||||||
|
now := time.Now()
|
||||||
|
var fromIP int
|
||||||
|
var oldestID string
|
||||||
|
for k, x := range a.mfa.logins {
|
||||||
|
if now.After(x.expires) {
|
||||||
|
delete(a.mfa.logins, k)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if x.ip == c.ip {
|
||||||
|
fromIP++
|
||||||
|
}
|
||||||
|
if oldestID == "" || x.expires.Before(a.mfa.logins[oldestID].expires) {
|
||||||
|
oldestID = k
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if fromIP >= maxPasskeyLoginsPerIP {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if len(a.mfa.logins) >= maxPasskeyLogins {
|
||||||
|
delete(a.mfa.logins, oldestID)
|
||||||
|
}
|
||||||
|
a.mfa.logins[id] = c
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) loginPasskeyFinish(w http.ResponseWriter, r *http.Request) {
|
func (a *App) loginPasskeyFinish(w http.ResponseWriter, r *http.Request) {
|
||||||
id := r.URL.Query().Get("id")
|
id := r.URL.Query().Get("id")
|
||||||
ip := remoteIP(r)
|
ip := remoteIP(r)
|
||||||
|
|||||||
@@ -20,6 +20,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Same drawing as favicon.svg.
|
// Same drawing as favicon.svg.
|
||||||
|
// ext opens an outside page in a new tab, marked with ↗ as in the app.
|
||||||
|
const ext = (href, text) => h('a', { class: 'ext', href, target: '_blank', rel: 'noopener' }, text,
|
||||||
|
h('span', { class: 'ar', 'aria-hidden': 'true' }, '↗'), h('span', { class: 'sr' }, ' (opens in a new tab)'));
|
||||||
|
|
||||||
function logo(size, plain) {
|
function logo(size, plain) {
|
||||||
const s = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
const s = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||||
for (const [k, v] of Object.entries({ width: size, height: size, viewBox: '0 0 64 64', 'aria-hidden': 'true' })) s.setAttribute(k, v);
|
for (const [k, v] of Object.entries({ width: size, height: size, viewBox: '0 0 64 64', 'aria-hidden': 'true' })) s.setAttribute(k, v);
|
||||||
@@ -108,9 +112,9 @@
|
|||||||
h('div', { class: 'notice' }, 'Save it now. This page can\'t be opened again: the private key exists only here and isn\'t stored anywhere.'),
|
h('div', { class: 'notice' }, 'Save it now. This page can\'t be opened again: the private key exists only here and isn\'t stored anywhere.'),
|
||||||
h('ol', { class: 'steps' },
|
h('ol', { class: 'steps' },
|
||||||
step(1, 'Install WireGuard',
|
step(1, 'Install WireGuard',
|
||||||
h('p', null, h('a', { href: 'https://apps.apple.com/app/wireguard/id1441195209', rel: 'noopener' }, 'App Store'), ' · ',
|
h('p', null, ext('https://apps.apple.com/app/wireguard/id1441195209', 'App Store'), ' · ',
|
||||||
h('a', { href: 'https://play.google.com/store/apps/details?id=com.wireguard.android', rel: 'noopener' }, 'Google Play'), ' · ',
|
ext('https://play.google.com/store/apps/details?id=com.wireguard.android', 'Google Play'), ' · ',
|
||||||
h('a', { href: 'https://www.wireguard.com/install/', rel: 'noopener' }, 'Other systems'))),
|
ext('https://www.wireguard.com/install/', 'Other systems'))),
|
||||||
step(2, 'Add the profile',
|
step(2, 'Add the profile',
|
||||||
h('button', { type: 'button', class: 'btn primary', onClick: download }, 'Download ' + file),
|
h('button', { type: 'button', class: 'btn primary', onClick: download }, 'Download ' + file),
|
||||||
h('p', null, 'Open the downloaded file with WireGuard, or in WireGuard tap + and choose “Create from file”.')),
|
h('p', null, 'Open the downloaded file with WireGuard, or in WireGuard tap + and choose “Create from file”.')),
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user