15 Commits

Author SHA1 Message Date
Daniel Redetzke 3e8dba6072 Fixes from the audit: input checks, apply order, sign-in limits
- The server endpoint must be a plain host name or IP address. It is
  written into client configs as is, so a newline could add lines such
  as PreUp, which wg-quick runs as root on the client.
- Listen addresses and the session length (1–720 hours) are checked.
  Before web settings or a restore are saved, the server tries the new
  listen addresses and certificate files, so a value it cannot start
  with is refused instead of stopping the service at the next restart.
- Kernel applies run one at a time and read the config once it is
  their turn, so an older config can no longer be applied last.
- Pending passkey sign-ins are capped: 10 per address, 1000 in total.
- Behind a local proxy, the last X-Forwarded-For entry is the client;
  earlier ones come from the client and are ignored.
- With LAN access off, peers are also kept from the IPv6 networks on
  the uplink, not only from its private IPv4 networks.
- A change that leaves no user with a password is refused, and so is a
  backup without one or from a newer version.
2026-10-05 23:09:03 +03:00
Daniel Redetzke aa4ca20296 API: the "Last apply" check is now "Kernel in sync"
GET /api/v1/status names the check Kernel in sync, the same as the
health row in the web interface, which now reads the new name directly.
The detail is unchanged: "applied <time>" or the kernel's error.
2026-10-05 22:29:47 +03:00
Daniel Redetzke 1bfede250c Health: "Last apply" reads "Kernel in sync"
The health row that shows when the config was last written to the
kernel is now called Kernel in sync, with the time since then, or Out
of sync and the kernel's error when applying failed. The API keeps the
check's name, so clients are unaffected.
2026-10-05 22:28:16 +03:00
Daniel Redetzke 8edde9f5e7 Links: arrows for moving around, ↗ for outside pages
Links to another page (All peers, Log, Add the first one) are ink with
an arrow that nudges on hover, the back link gets ←, and links that
open an outside page get ↗ and "opens in a new tab" for screen
readers, on the setup page too. Peer names look the same everywhere,
and links in text get a pale underline. Buttons and the sidebar stay
as they are.
2026-10-05 21:28:26 +03:00
Daniel Redetzke 7c36223de0 Live page: upload gets a light area too
Download and upload are both lines over light, see-through areas, so
upload no longer looks less important when it is the larger one.
2026-10-05 20:35:23 +03:00
Daniel Redetzke 2b7b41859d Live page: curved lines and speeds with one decimal
The chart draws download and upload as smooth curves that never dip
below zero or overshoot a peak, and speeds always show one decimal
from kbit/s up so the figures keep their shape as they change.
2026-10-05 20:28:56 +03:00
Daniel Redetzke ccf7b50c59 Live page: figures update without counting 2026-10-05 14:48:30 +03:00
Daniel Redetzke a82314ee94 Live page: 10-second averages for the figures and the table
The big figures, the per-peer speeds and the busiest-first order
average the last 5 steps instead of swinging with every burst, and the
figures sit in fixed-width columns so they no longer push each other
around. The charts still show every step.
2026-10-05 14:44:18 +03:00
Daniel Redetzke d83582eea1 Live page: streamed updates and a sliding chart
The server pushes each new step over server-sent events
(GET /api/v1/live/stream) the moment it is sampled, so updates no
longer arrive in uneven pairs. The chart slides left steadily between
steps instead of jumping, and the big numbers count to their new
value. Both stay still with reduced motion.
2026-10-05 14:39:35 +03:00
Daniel Redetzke 5f321f2979 Live page: online peers only 2026-10-05 14:34:08 +03:00
Daniel Redetzke 6661424daf Live page: the speed of every peer right now
A new Live page shows current download and upload per peer, updated
every 2 seconds, with the last 2 minutes as a chart and a small chart
per peer. The server reads the WireGuard counters every 2 seconds and
keeps 2 minutes in memory; GET /api/v1/live serves them, with since=
for only the newer steps. The dev simulator now adds traffic in
proportion to the time between samples.
2026-10-05 14:28:29 +03:00
Daniel Redetzke 456ae6a41e Dashboard: a range switch redraws only the traffic chart
The range buttons light up at once and fetch only the chart's stats,
like on the peer page, instead of reloading the whole dashboard.
2026-10-05 14:18:18 +03:00
Daniel Redetzke c7b4ca692e Dashboard: 24 h, 7 day and 30 day range on the traffic chart 2026-10-05 14:12:44 +03:00
Daniel Redetzke 6733bf2f3a Peer page: traffic chart opens on 24 hours 2026-10-05 14:06:05 +03:00
Daniel Redetzke e9bd3090a8 Charts: clock times and dates along the bottom
Traffic and latency charts label the x-axis with clock times every
3 hours (6 on phones, the date at midnight), or dates for the 7- and
30-day ranges. The hover readout for hourly bars shows the clock
time range instead of "3 h ago".
2026-10-05 14:01:10 +03:00
14 changed files with 1130 additions and 72 deletions
+10
View File
@@ -28,6 +28,8 @@ dependencies on the server: the binary installs, updates and removes itself.
server has a global IPv6 address.
- **Traffic history:** kept in `stats.json`, hourly for 48 h and daily for
400 days by default (Settings → Logs & history).
- **Live view:** the speed of every peer right now, updated every 2 seconds,
with the last 2 minutes as a chart. Kept in memory only.
- **Connection history:** every online session per peer, with start, duration,
address and traffic. A new session starts when a device changes networks.
Country and network operator come from the free
@@ -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/recovery-codes
GET /status GET /stats?range=24h|7d|30d|90d
GET /live?since= (speed per peer, last 2 minutes in 2-second steps)
GET /live/stream (the same as server-sent events)
GET /server PATCH /server POST /server/rotate-key GET /server/detect-ip
GET /peers POST /peers (returns the config and QR once)
GET /peers/{id} PATCH /peers/{id} DELETE /peers/{id}
@@ -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
downloaded, `up` is what it uploaded.
`GET /live` answers `{"step": 2, "size": 60, "points": [{"t": …, "peers":
{"<id>": [down, up]}}]}` with speeds in bits per second, kept only in memory.
With `since` (unix seconds) it returns only newer steps. `GET /live/stream`
sends the same messages as server-sent events: the history first, then one
message per new step.
Latency is measured by pinging the peer's tunnel address every 30 seconds. Set
it per peer with `PATCH /peers/{id}` `{"latencyCheck": "off"|"active"|"always"}`
(default `off`). `active` pings only while the device sends traffic, so idle
+132 -5
View File
@@ -3,15 +3,18 @@ package main
import (
"cmp"
"context"
"crypto/tls"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net"
"net/http"
"net/netip"
"slices"
"strings"
"syscall"
"time"
)
@@ -21,6 +24,7 @@ type App struct {
kernel Kernel
recon *Reconciler
stats *Stats
speeds *Speeds // nil in tests
auth *Auth
tls *webTLS
logPath string
@@ -28,7 +32,8 @@ type App struct {
geo *Geo // nil in tests
updates *Updater // nil in tests
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 ---
@@ -144,6 +149,8 @@ func (a *App) routes() http.Handler {
g("GET /api/v1/status", a.status)
g("GET /api/v1/stats", a.allStats)
g("GET /api/v1/live", a.liveSpeeds)
g("GET /api/v1/live/stream", a.liveStream)
g("GET /api/v1/server", a.getServer)
g("PATCH /api/v1/server", a.patchServer)
@@ -350,7 +357,7 @@ func (a *App) status(w http.ResponseWriter, r *http.Request) {
d30, u30 := sumPoints(a.stats.series(nil, "30d"))
checks := a.kernel.Checks(cfg)
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 {
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)})
}
// liveSpeeds returns each peer's speed over the last 2 minutes; with since
// (unix seconds) only the newer steps.
func (a *App) liveSpeeds(w http.ResponseWriter, r *http.Request) {
var since int64
fmt.Sscan(r.URL.Query().Get("since"), &since)
points := []SpeedPoint{}
if a.speeds != nil {
points = a.speeds.Since(since)
}
writeJSON(w, http.StatusOK, map[string]any{"step": int(speedStep / time.Second), "size": speedPoints, "points": points})
}
// liveStream sends the same data as server-sent events: the current points
// first, then each new step as soon as it is sampled. The session is checked
// again with every step, so signing out ends the stream.
func (a *App) liveStream(w http.ResponseWriter, r *http.Request) {
if a.speeds == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "live speeds are not available"})
return
}
rc := http.NewResponseController(w)
_ = rc.SetWriteDeadline(time.Time{}) // the server's write timeout would cut the stream
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("X-Accel-Buffering", "no") // nginx: do not buffer
points, ch, cancel := a.speeds.Subscribe()
defer cancel()
send := func(points []SpeedPoint) bool {
b, _ := json.Marshal(map[string]any{"step": int(speedStep / time.Second), "size": speedPoints, "points": points})
if _, err := fmt.Fprintf(w, "data: %s\n\n", b); err != nil {
return false
}
return rc.Flush() == nil
}
if !send(points) {
return
}
for {
select {
case <-r.Context().Done():
return
case <-a.speeds.Done():
return
case pt := <-ch:
if _, ok := a.auth.Authenticate(r); !ok {
return
}
if !send([]SpeedPoint{pt}) {
return
}
}
}
}
func (a *App) peerStats(w http.ResponseWriter, r *http.Request) {
cfg := a.store.Get()
if _, p := cfg.peerByID(r.PathValue("id")); p == nil {
@@ -1033,11 +1094,15 @@ func (a *App) patchSettings(w http.ResponseWriter, r *http.Request) {
b, _ := json.Marshal(w)
return string(b)
}
before := listen()
before, oldWeb := listen(), c.Web
if err := field(m, "web", &c.Web); err != nil {
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 {
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?"))
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)
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})
}
// 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
// level and log rotation. Traffic retention is read by the stats sampler.
func (a *App) applyRuntime(c *Config) {
+47 -7
View File
@@ -33,8 +33,20 @@
* { box-sizing: border-box; }
html, body { margin: 0; }
body { background: var(--ground); color: var(--ink); font-family: var(--sans); font-size: 14px; line-height: 1.45; }
a { color: var(--link); }
a:hover { color: var(--link-hover); }
a { color: var(--link); text-decoration-color: rgba(28, 92, 171, .35); text-underline-offset: 3px; }
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; }
[hidden] { display: none !important; }
.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; }
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); }
.back { font-size: 13px; margin-bottom: -8px; }
.back { margin-bottom: -8px; align-self: flex-start; }
/* cards */
.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; }
td .note { font-size: 12px; color: var(--ink-3); }
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); }
/* 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.total { background: var(--down); }
.chart .grp.on span.total { background: var(--down-strong); }
.xaxis { display: flex; justify-content: space-between; margin: 8px 0 0 56px; font-size: 11px; color: var(--axis-ink); }
.xaxis { position: relative; height: 22px; margin-left: 56px; font-size: 11px; color: var(--axis-ink); }
.xaxis span { position: absolute; top: 0; padding-top: 7px; transform: translateX(-50%); white-space: nowrap; font-variant-numeric: tabular-nums; }
.xaxis span.edge::before { content: ''; position: absolute; left: 50%; top: 0; height: 4px; border-left: 1px solid var(--axis); }
@media (max-width: 640px) { .xaxis span.minor { display: none; } }
.chart.loading { opacity: .5; }
.chart .plot { position: absolute; left: 56px; right: 0; top: 0; bottom: 1px; }
.chart .plot svg { width: 100%; height: 100%; display: block; overflow: visible; }
@@ -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 h1 { font-size: 22px; }
.setupbox p { margin: 0; color: #c9c9c3; }
.setupbox a { color: #9cc3f5; }
.setupbox a:hover { color: #fff; }
.setupbox a { color: #9cc3f5; text-decoration-color: rgba(156, 195, 245, .4); }
.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 { gap: 20px; }
.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; }
.saves { font-size: 12px; color: var(--ink-3); }
pre.log.tall { max-height: calc(100vh - 260px); min-height: 420px; }
/* live */
.livenow { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 12px 24px; margin-top: 14px; }
.livenow .k { font-size: 13px; color: var(--ink-2); }
.livenow .v { font-size: 30px; font-weight: 600; letter-spacing: -0.01em; margin-top: 4px; font-variant-numeric: tabular-nums; }
.livenow .v small { font-size: 16px; color: var(--ink-3); font-weight: 500; margin-left: 6px; }
.chart .larea { 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; } }
+317 -25
View File
@@ -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"/>',
server: '<rect x="3" y="4" width="18" height="7" rx="1.5"/><rect x="3" y="13" width="18" height="7" rx="1.5"/><path d="M7 7.5h.01M7 16.5h.01"/>',
settings: '<path d="M4 6h10M18 6h2M4 12h4M12 12h8M4 18h12"/><circle cx="16" cy="6" r="2"/><circle cx="10" cy="12" r="2"/><circle cx="18" cy="18" r="2"/>',
live: '<path d="M3 12h4l3-7 4 14 3-7h4"/>',
plus: '<path d="M12 5v14M5 12h14"/>',
key: '<circle cx="8" cy="15" r="4"/><path d="M11 12l9-9M17 6l3 3M14 9l2 2"/>',
log: '<rect x="4" y="3" width="16" height="18" rx="2"/><path d="M8 8h8M8 12h8M8 16h5"/>',
@@ -168,6 +169,14 @@
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.
function svg(tag, attrs, ...kids) {
const el = document.createElementNS('http://www.w3.org/2000/svg', tag);
@@ -384,8 +393,8 @@
function pointLabel(t, range) {
const d = new Date(t * 1000);
if (range === '24h') {
const hrs = Math.round((Date.now() - d.getTime()) / 3600000);
return hrs <= 0 ? 'This hour' : hrs + ' h ago';
const hm = (x) => x.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
return hm(d) + '–' + hm(new Date(d.getTime() + 3600000));
}
return d.toLocaleDateString(undefined, { weekday: 'short', day: 'numeric', month: 'short' });
}
@@ -430,9 +439,35 @@
h('div', { class: 'gl top' }), h('div', { class: 'gl mid' }), h('div', { class: 'gl base' }),
h('div', { class: 'yl top' }, fmtBytes(top)), h('div', { class: 'yl mid' }, fmtBytes(top / 2)),
bars),
h('div', { class: 'xaxis' },
h('span', null, pointLabel(points[0].t, range)),
h('span', null, range === '24h' ? 'now' : pointLabel(points[points.length - 1].t, range))));
timeAxis(points.map((p) => p.t), range === '24h' ? 3600 : 86400));
}
// timeAxis labels the bottom of a chart with clock times or dates. Points
// are evenly spaced buckets of step seconds. Hourly buckets get the hour
// they start at, every 3 hours (6 on narrow screens), with the date at
// midnight; daily buckets get the date under the bar, every bar for a
// week and every 7th bar, counted back from today, for a month.
function timeAxis(ts, step) {
const n = ts.length;
const ticks = [];
if (step < 86400) {
for (let i = 0; i < n; i++) {
const d = new Date(ts[i] * 1000);
if (d.getMinutes() !== 0 || d.getHours() % 3 !== 0 || i === 0) continue;
const text = d.getHours() === 0
? d.toLocaleDateString(undefined, { weekday: 'short', day: 'numeric' })
: d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
ticks.push({ at: i / n, text, minor: d.getHours() % 6 !== 0, edge: true });
}
} else {
const every = n > 10 ? 7 : 1;
for (let i = n - 1; i >= 0; i -= every) {
const d = new Date(ts[i] * 1000);
ticks.push({ at: (i + 0.5) / n, text: d.toLocaleDateString(undefined, n > 10 ? { day: 'numeric', month: 'short' } : { weekday: 'short', day: 'numeric' }) });
}
}
return h('div', { class: 'xaxis', 'aria-hidden': 'true' },
ticks.map((k) => h('span', { class: (k.minor ? 'minor' : '') + (k.edge ? ' edge' : ''), style: { left: (k.at * 100).toFixed(3) + '%' } }, k.text)));
}
// latencyChart draws the median as a line over a min–max band, one point
@@ -489,7 +524,7 @@
h('div', { class: 'gl top' }), h('div', { class: 'gl mid' }), h('div', { class: 'gl base' }),
h('div', { class: 'yl top' }, fmtMs(top)), h('div', { class: 'yl mid' }, fmtMs(top / 2)),
wrapEl),
h('div', { class: 'xaxis' }, h('span', null, '24 h ago'), h('span', null, '12 h ago'), h('span', null, 'now')));
timeAxis(points.map((p) => p.t), 300));
}
// pairDialog creates an API token and shows it once, with the pairing QR
@@ -529,7 +564,7 @@
// ---------- 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 srvBox, peerCount, verRow;
@@ -591,6 +626,7 @@
const ROUTES = [
[/^#\/?$/, '#/', viewDashboard],
[/^#\/live$/, '#/live', viewLive],
[/^#\/peers$/, '#/peers', viewPeers],
[/^#\/peers\/new$/, '#/peers', viewPeerNew],
[/^#\/peers\/([\w-]+)$/, '#/peers', viewPeer],
@@ -994,18 +1030,35 @@
// ---------- dashboard ----------
// RANGES are the time ranges offered above the traffic charts.
const RANGES = [['24h', '24 h'], ['7d', '7 days'], ['30d', '30 days']];
async function viewDashboard(wrap) {
let range = '24h';
const draw = async () => {
const [st, pl, stats, logs] = await Promise.all([
api('GET', '/status'),
api('GET', '/peers'),
api('GET', '/stats?range=24h'),
api('GET', '/stats?range=' + range),
me.isAdmin ? api('GET', '/logs?audit=1&limit=6').catch(() => null) : null,
]);
const peers = pl.peers;
const failing = st.checks.filter((c) => !c.ok);
const ifCheck = st.checks.find((c) => c.name === 'WireGuard interface');
const top = [...peers].sort((a, b) => (b.stats.down24h + b.stats.up24h) - (a.stats.down24h + a.stats.up24h)).slice(0, 6);
// A range switch fetches and redraws only the chart.
const traffic = h('div', null, chart(stats.points, range, 'total', true));
const pills = h('div', { class: 'pills', role: 'group', 'aria-label': 'Time range' });
const drawPills = () => pills.replaceChildren(...RANGES.map(([k, t]) =>
h('button', { type: 'button', class: range === k ? 'pill on' : 'pill', 'aria-pressed': String(range === k), onClick: async () => {
range = k;
drawPills();
try {
const s = await api('GET', '/stats?range=' + k);
if (range === k) traffic.replaceChildren(chart(s.points, k, 'total', true));
} catch (x) { toast(x.message, true); }
} }, t)));
drawPills();
fill(wrap,
h('div', { class: 'head' },
@@ -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('section', { class: 'card', 'aria-labelledby': 'tput' },
h('div', { class: 'cardhead' }, h('h2', { id: 'tput' }, 'Traffic, all peers · last 24 hours')),
chart(stats.points, '24h', 'total', true)),
h('div', { class: 'cardhead' }, h('h2', { id: 'tput' }, 'Traffic, all peers'), pills),
traffic),
h('div', { class: 'cols' },
h('section', { class: 'card flush' },
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' },
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('td', null, h('a', { href: '#/peers/' + p.id }, p.name)),
h('td', null, peerLink(p)),
h('td', null, badge(peerState(p))),
h('td', { class: 'num' }, fmtBytes(p.stats.down24h)),
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' },
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
? 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));
@@ -1060,6 +1113,245 @@
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) {
const parts = [l.msg.charAt(0).toUpperCase() + l.msg.slice(1)];
if (l.peer) parts.push(': ' + l.peer);
@@ -1140,7 +1432,7 @@
return hit && keep;
});
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', null, badge(peerState(p))),
h('td', { class: 'mono muted' }, p.stats.endpoint || '–',
@@ -1295,7 +1587,7 @@
drawPreview();
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', { class: 'split' }, form,
h('aside', { class: 'card aside', 'aria-labelledby': 'pv' },
@@ -1327,7 +1619,7 @@
sessMore.textContent = allSessions ? 'Show fewer' : 'Show all ' + sessions.length;
};
drawSessions();
let range = '7d';
let range = '24h';
const st = peerState(p);
const traffic = h('div');
const totals = h('div', { class: 'legend-row' });
@@ -1349,7 +1641,7 @@
};
async function drawTraffic() {
pills.replaceChildren(...[['24h', '24 h'], ['7d', '7 days'], ['30d', '30 days']].map(([k, t]) =>
pills.replaceChildren(...RANGES.map(([k, t]) =>
h('button', { type: 'button', class: range === k ? 'pill on' : 'pill', 'aria-pressed': String(range === k), onClick: () => { range = k; drawTraffic(); } }, t)));
const s = await api('GET', '/peers/' + id + '/stats?range=' + range);
const down = s.points.reduce((a, x) => a + x.down, 0), up = s.points.reduce((a, x) => a + x.up, 0);
@@ -1452,7 +1744,7 @@
};
fill(wrap,
h('a', { class: 'back', href: '#/peers' }, '← Peers'),
back('#/peers', 'Peers'),
h('div', { class: 'head' },
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)),
@@ -1503,7 +1795,7 @@
: h('p', { class: 'empty' }, 'No connections recorded yet.'),
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('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.')),
h('form', { class: 'card', onSubmit: save },
@@ -1563,8 +1855,8 @@
t.status = c.ok ? 'Present' : 'Missing';
if (/^table /.test(c.detail)) { t.raw = c.detail.replace(/ (present|missing)$/, ''); t.problem = null; }
break;
case 'Last apply':
if (c.ok) { const iso = c.detail.replace(/^applied /, ''); t.status = ago(iso); t.title = fmtStamp(iso); } else t.status = 'Failed';
case 'Kernel in sync':
if (c.ok) { const iso = c.detail.replace(/^applied /, ''); t.status = ago(iso); t.title = fmtStamp(iso); } else t.status = 'Out of sync';
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') })),
cb('nat', 'Masquerade (NAT) peer traffic to the internet'),
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'))),
h('section', { class: 'card', 'aria-labelledby': 'ky' },
@@ -1833,7 +2125,7 @@
notes ? h('div', { class: 'upnotes' },
h('div', { class: 'hd' }, h('strong', null, 'What\'s new in ' + rel.version),
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,
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,
@@ -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('pre', { class: 'code' }, cmds),
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('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 }) }),
+7 -2
View File
@@ -287,9 +287,14 @@ func remoteIP(r *http.Request) string {
host = r.RemoteAddr
}
// 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 xff := r.Header.Get("X-Forwarded-For"); xff != "" {
return strings.TrimSpace(strings.Split(xff, ",")[0])
if xff := r.Header.Values("X-Forwarded-For"); len(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
+40 -1
View File
@@ -11,6 +11,7 @@ import (
"path/filepath"
"regexp"
"slices"
"strconv"
"strings"
"sync"
"syscall"
@@ -76,8 +77,29 @@ const (
minLogFiles, maxLogFiles = 1, 100
minHourlyHours, maxHourlyHrs = 24, 24 * 31
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 {
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
@@ -364,7 +386,9 @@ func (c *Config) validate() error {
if v6.Masked() != v6 {
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")
}
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 {
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 {
case "acme":
if c.Web.TLS.Domain == "" {
@@ -586,6 +619,12 @@ func (s *Store) Update(fn func(c *Config) error) error {
s.mu.Unlock()
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 {
s.mu.Unlock()
return err
+29
View File
@@ -4,6 +4,7 @@ import (
"log/slog"
"net/netip"
"os"
"slices"
"strings"
"sync"
"time"
@@ -40,6 +41,28 @@ type Kernel interface {
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 "".
func readSysctl(path string) string {
b, err := os.ReadFile(path)
@@ -56,6 +79,10 @@ type Reconciler struct {
store *Store
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
lastErr error
lastApply time.Time
@@ -76,6 +103,8 @@ func (r *Reconciler) Kick() {
// ApplyNow applies synchronously and returns the result, so an API call can
// report kernel errors to the user.
func (r *Reconciler) ApplyNow() error {
r.applyMu.Lock()
defer r.applyMu.Unlock()
err := r.kernel.Apply(r.store.Get())
r.mu.Lock()
r.lastErr, r.lastApply = err, time.Now()
+20 -19
View File
@@ -217,7 +217,8 @@ func (k *linuxKernel) Apply(c *Config) error {
if c.Server.IPv6Enabled {
_ = 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) {
@@ -264,27 +265,27 @@ func (k *linuxKernel) Uplink(c *Config, v6 bool) string {
return l.Attrs().Name
}
// lanNetworks returns the private IPv4 networks on the uplink, used to block
// peers from the server's LAN when LAN access is off.
func lanNetworks(uplink string) []netip.Prefix {
if uplink == "" {
return nil
}
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() {
// lanNetworks returns the LAN networks on the IPv4 and IPv6 uplinks (see
// lanBlock), used to block peers from the server's LAN when LAN access is off.
func lanNetworks(uplinks ...string) []netip.Prefix {
var nets []netip.Prefix
for i, uplink := range uplinks {
if uplink == "" || slices.Contains(uplinks[:i], uplink) {
continue
}
ones, _ := a.Mask.Size()
ip, _ := netip.AddrFromSlice(a.IP.To4())
out = append(out, netip.PrefixFrom(ip, ones).Masked())
l, err := netlink.LinkByName(uplink)
if err != nil {
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
+16 -7
View File
@@ -5,8 +5,10 @@ package main
import (
"fmt"
"log/slog"
"maps"
"math/rand/v2"
"net/netip"
"slices"
"sync"
"time"
)
@@ -17,6 +19,7 @@ import (
type simKernel struct {
mu sync.Mutex
peers map[string]*PeerSample
last time.Time // previous Sample; traffic grows with the time since
}
func newKernel() (Kernel, error) {
@@ -53,17 +56,23 @@ func (k *simKernel) Apply(c *Config) error {
func (k *simKernel) Sample(string) ([]PeerSample, error) {
k.mu.Lock()
defer k.mu.Unlock()
now := time.Now()
f := 1.0
if !k.last.IsZero() {
f = now.Sub(k.last).Seconds() / 30
}
k.last = now
var out []PeerSample
i := 0
for _, p := range k.peers {
// Every third peer stays idle; the others move some data.
for i, key := range slices.Sorted(maps.Keys(k.peers)) {
p := k.peers[key]
// Every third peer stays idle; the others move some data, scaled to
// the time since the previous sample.
if i%3 != 2 {
p.TxBytes += rand.Int64N(40 << 20)
p.RxBytes += rand.Int64N(6 << 20)
p.LastHandshake = time.Now().Add(-time.Duration(rand.IntN(90)) * time.Second)
p.TxBytes += int64(float64(rand.Int64N(40<<20)) * f)
p.RxBytes += int64(float64(rand.Int64N(6<<20)) * f)
p.LastHandshake = now.Add(-time.Duration(rand.IntN(90)) * time.Second)
}
out = append(out, *p)
i++
}
return out, nil
}
+8 -2
View File
@@ -219,16 +219,22 @@ func run(configPath string) error {
var stopOnce sync.Once
shutdown := func() { stopOnce.Do(func() { close(stop) }) }
speeds := newSpeeds(store, kernel)
auth := newAuth(store)
app := &App{
store: store, kernel: kernel, recon: recon, stats: stats, auth: auth, tls: webTLS,
store: store, kernel: kernel, recon: recon, stats: stats, speeds: speeds, auth: auth, tls: webTLS,
logPath: logPath, logw: logw, geo: geo, updates: newUpdater(cfg.Updates), started: time.Now(), shutdown: shutdown,
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
wg.Add(5)
wg.Add(6)
go func() { defer wg.Done(); recon.Run(stop) }()
go func() { defer wg.Done(); stats.Run(stop) }()
go func() { defer wg.Done(); speeds.Run(stop) }()
go func() { defer wg.Done(); stats.RunPings(stop) }()
go func() { defer wg.Done(); geo.Run(stop) }()
go func() { defer wg.Done(); app.updates.Run(stop) }()
+321
View File
@@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"io"
"net"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
@@ -79,6 +80,15 @@ func TestValidate(t *testing.T) {
"bad port": func(c *Config) { c.Server.ListenPort = 70000 },
"unmasked net": func(c *Config) { c.Server.IPv4 = "10.84.12.5/24" },
"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()
mutate(cc)
@@ -86,6 +96,20 @@ func TestValidate(t *testing.T) {
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) {
@@ -1286,3 +1310,300 @@ func TestDropSecurityKeys(t *testing.T) {
t.Fatal("security key still in config.json")
}
}
func TestSpeeds(t *testing.T) {
dir := t.TempDir()
store, err := openStore(filepath.Join(dir, "config.json"))
if err != nil {
t.Fatal(err)
}
key, _ := newPrivateKey()
pub := key.PublicKey().String()
if err := store.Update(func(c *Config) error {
c.Peers = append(c.Peers, Peer{ID: "p1", Name: "phone", IPv4: serverIPv4(netip.MustParsePrefix(c.Server.IPv4)).Next().String(), PublicKey: pub, Enabled: true})
return nil
}); err != nil {
t.Fatal(err)
}
k := &fakeKernel{}
sp := newSpeeds(store, k)
t0 := time.Unix(1_800_000_000, 0)
step := func(sec int, rx, tx int64) {
k.samples = []PeerSample{{PublicKey: pub, RxBytes: rx, TxBytes: tx}}
sp.sample(t0.Add(time.Duration(sec) * time.Second))
}
_, ch, cancel := sp.Subscribe()
defer cancel()
step(0, 1000, 1000)
if n := len(sp.Since(0)); n != 0 {
t.Fatalf("first sample made %d points, want 0", n)
}
step(2, 1250, 3000) // +250 up, +2000 down in 2 s
step(4, 10, 20) // counter reset: no speed for this step
pts := sp.Since(0)
if len(pts) != 2 {
t.Fatalf("got %d points, want 2", len(pts))
}
if got, want := pts[0].Peers["p1"], [2]int64{8000, 1000}; got != want {
t.Fatalf("speed = %v, want %v (down, up in bit/s)", got, want)
}
if got := <-ch; got.T != pts[0].T {
t.Fatalf("subscriber got step %d, want %d", got.T, pts[0].T)
}
if _, ok := pts[1].Peers["p1"]; ok {
t.Fatal("a counter reset reported a speed")
}
if got := sp.Since(pts[0].T); len(got) != 1 || got[0].T != pts[1].T {
t.Fatalf("Since returned %v", got)
}
for i := 0; i < speedPoints+5; i++ {
step(6+2*i, 0, 0)
}
if n := len(sp.Since(0)); n != speedPoints {
t.Fatalf("kept %d points, want %d", n, speedPoints)
}
}
// 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)
}
}
+42 -1
View File
@@ -217,6 +217,7 @@ type ticket struct {
type ceremony struct {
userID string // "" for a passkey sign-in
ip string // lockKey of who started a passkey sign-in
data *webauthn.SessionData
expires time.Time
}
@@ -551,12 +552,52 @@ func (a *App) loginPasskeyBegin(w http.ResponseWriter, r *http.Request) {
return
}
id := randomString(24)
ip := remoteIP(r)
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()
if !ok {
writeJSON(w, http.StatusTooManyRequests, map[string]string{"error": errBusy.Error()})
return
}
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) {
id := r.URL.Query().Get("id")
ip := remoteIP(r)
+7 -3
View File
@@ -20,6 +20,10 @@
}
// 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) {
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);
@@ -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('ol', { class: 'steps' },
step(1, 'Install WireGuard',
h('p', null, h('a', { href: 'https://apps.apple.com/app/wireguard/id1441195209', rel: 'noopener' }, 'App Store'), ' · ',
h('a', { href: 'https://play.google.com/store/apps/details?id=com.wireguard.android', rel: 'noopener' }, 'Google Play'), ' · ',
h('a', { href: 'https://www.wireguard.com/install/', rel: 'noopener' }, 'Other systems'))),
h('p', null, ext('https://apps.apple.com/app/wireguard/id1441195209', 'App Store'), ' · ',
ext('https://play.google.com/store/apps/details?id=com.wireguard.android', 'Google Play'), ' · ',
ext('https://www.wireguard.com/install/', 'Other systems'))),
step(2, 'Add the profile',
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”.')),
+134
View 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)
}
}
}