Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ef5a2930e0 | |||
| 3482a03707 | |||
| 47762790ef | |||
| 3e8dba6072 | |||
| aa4ca20296 | |||
| 1bfede250c | |||
| 8edde9f5e7 | |||
| 7c36223de0 | |||
| 2b7b41859d | |||
| ccf7b50c59 | |||
| a82314ee94 | |||
| d83582eea1 | |||
| 5f321f2979 | |||
| 6661424daf | |||
| 456ae6a41e | |||
| c7b4ca692e | |||
| 6733bf2f3a | |||
| e9bd3090a8 |
@@ -12,10 +12,19 @@ remove), hands out client configs as a download or QR code, and records traffic
|
|||||||
and connection history per peer. There are no install scripts and no
|
and connection history per peer. There are no install scripts and no
|
||||||
dependencies on the server: the binary installs, updates and removes itself.
|
dependencies on the server: the binary installs, updates and removes itself.
|
||||||
|
|
||||||
|
> **Coming from pivpn?** GHOSTWIRE takes over a pivpn WireGuard server in one
|
||||||
|
> command: `sudo ./GHOSTWIRE install`. Your phones and laptops keep their
|
||||||
|
> current configs and reconnect on their own, with nothing to re-scan or
|
||||||
|
> re-send. See [Moving from pivpn](#moving-from-pivpn).
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
|
- **pivpn takeover:** install finds a pivpn WireGuard server and takes over
|
||||||
|
its key, networks and every client with its keys and addresses, so devices
|
||||||
|
keep working without new configs. pivpn comes back by itself if the switch
|
||||||
|
fails. [Details](#moving-from-pivpn).
|
||||||
- **One file of state:** everything lives in `config.json`. The kernel is
|
- **One file of state:** everything lives in `config.json`. The kernel is
|
||||||
reconciled to it, so there is no `/etc/wireguard`, no `wg-quick` and no
|
reconciled to it, so there is no `/etc/wireguard`, no `wg-quick` and no
|
||||||
`wireguard-tools`.
|
`wireguard-tools`.
|
||||||
@@ -28,6 +37,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
|
||||||
@@ -152,6 +163,8 @@ sudo /tmp/GHOSTWIRE install -y -domain vpn.example.net -email you@example.net -p
|
|||||||
| `-email` | none |
|
| `-email` | none |
|
||||||
| `-endpoint` | the domain |
|
| `-endpoint` | the domain |
|
||||||
| `-port` | 51820, or the current port when already installed |
|
| `-port` | 51820, or the current port when already installed |
|
||||||
|
| `-import-pivpn` | off: see [Moving from pivpn](#moving-from-pivpn) |
|
||||||
|
| `-no-wait` | off: after a pivpn takeover, don't wait for devices to reconnect |
|
||||||
|
|
||||||
The admin password is then read from standard input, e.g.
|
The admin password is then read from standard input, e.g.
|
||||||
`echo "$PASSWORD" | sudo ./GHOSTWIRE install -y …`. Every value is checked
|
`echo "$PASSWORD" | sudo ./GHOSTWIRE install -y …`. Every value is checked
|
||||||
@@ -177,11 +190,35 @@ Then open `https://vpn.example.net` and sign in as `admin`. Add more users
|
|||||||
under Settings → Users. Root is needed only for the commands below, never for
|
under Settings → Users. Root is needed only for the commands below, never for
|
||||||
the running service.
|
the running service.
|
||||||
|
|
||||||
|
## Moving from pivpn
|
||||||
|
|
||||||
|
On a server that runs pivpn's WireGuard, a new install offers to take it
|
||||||
|
over. Devices keep their current config: GHOSTWIRE takes pivpn's server key,
|
||||||
|
port, MTU, tunnel networks (IPv4 and IPv6), endpoint, DNS, AllowedIPs and
|
||||||
|
keepalive, and every client with its public key, preshared key and addresses.
|
||||||
|
Clients pivpn switched off are imported switched off, with the note
|
||||||
|
"Imported from pivpn". Client private keys, which pivpn keeps in
|
||||||
|
`/etc/wireguard/configs`, are not read or stored.
|
||||||
|
|
||||||
|
After the summary, install notes which peers are connected, stops pivpn's
|
||||||
|
WireGuard (`systemctl disable --now wg-quick@wg0`), starts GHOSTWIRE on the
|
||||||
|
same `wg0` and waits up to 30 s for those peers to come back. Devices that
|
||||||
|
send traffic reconnect after about 15 s; an idle device reconnects the next
|
||||||
|
time it sends something. The wait only reports: Enter skips it, and so does
|
||||||
|
`-no-wait` in scripts. If the service does not stay running, install puts
|
||||||
|
pivpn back as it was.
|
||||||
|
|
||||||
|
Without a terminal, the takeover needs `-import-pivpn`; install refuses to
|
||||||
|
run next to pivpn otherwise. pivpn's files stay as they were. Manage peers in
|
||||||
|
GHOSTWIRE from then on, delete `/etc/wireguard/configs` once everything works,
|
||||||
|
and don't run `pivpn uninstall`, which removes WireGuard packages. To go back
|
||||||
|
to pivpn: `GHOSTWIRE uninstall`, then `systemctl enable --now wg-quick@wg0`.
|
||||||
|
|
||||||
## Commands (as root)
|
## Commands (as root)
|
||||||
|
|
||||||
| Command | What it does |
|
| Command | What it does |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `GHOSTWIRE install [-domain d] [-email e] [-endpoint h] [-port p] [-y]` | Sets up and starts the service, as above. Asks for the settings no flag gave; `-y` never asks. |
|
| `GHOSTWIRE install [-domain d] [-email e] [-endpoint h] [-port p] [-import-pivpn] [-no-wait] [-y]` | Sets up and starts the service, as above. Asks for the settings no flag gave; `-y` never asks. On a pivpn server it takes over pivpn's WireGuard (see above). |
|
||||||
| `GHOSTWIRE update [-force]` | Run from the new binary, e.g. `sudo /tmp/GHOSTWIRE update`. Checks that it can read the current `config.json` (nothing changes if not), backs up the config to `config.json.bak-<old version>` (keeping the newest 3 such copies), replaces the binary, updates the unit if needed and restarts. If the new version does not stay up, the old binary and config are put back and restarted. It refuses older versions without `-force`. |
|
| `GHOSTWIRE update [-force]` | Run from the new binary, e.g. `sudo /tmp/GHOSTWIRE update`. Checks that it can read the current `config.json` (nothing changes if not), backs up the config to `config.json.bak-<old version>` (keeping the newest 3 such copies), replaces the binary, updates the unit if needed and restarts. If the new version does not stay up, the old binary and config are put back and restarted. It refuses older versions without `-force`. |
|
||||||
| `GHOSTWIRE uninstall [-purge] [-y]` | Stops and removes the service, `wg0` and the firewall table. `-purge` also deletes `/opt/ghostwire` and the user. |
|
| `GHOSTWIRE uninstall [-purge] [-y]` | Stops and removes the service, `wg0` and the firewall table. `-purge` also deletes `/opt/ghostwire` and the user. |
|
||||||
| `GHOSTWIRE passwd [username]` | Sets a user's password (default: the first user) and reloads the running service. The way back in if you are locked out. |
|
| `GHOSTWIRE passwd [username]` | Sets a user's password (default: the first user) and reloads the running service. The way back in if you are locked out. |
|
||||||
@@ -264,6 +301,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 +332,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
|
||||||
@@ -29,6 +33,7 @@ type App struct {
|
|||||||
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 {
|
||||||
@@ -499,7 +560,7 @@ func (a *App) patchServer(w http.ResponseWriter, r *http.Request) {
|
|||||||
err = a.store.Update(func(c *Config) error {
|
err = a.store.Update(func(c *Config) error {
|
||||||
s := &c.Server
|
s := &c.Server
|
||||||
before := s.clientFacing()
|
before := s.clientFacing()
|
||||||
oldV4 := s.IPv4
|
oldV4, oldV6 := s.IPv4, s.IPv6
|
||||||
for _, f := range []struct {
|
for _, f := range []struct {
|
||||||
key string
|
key string
|
||||||
dst any
|
dst any
|
||||||
@@ -522,6 +583,11 @@ func (a *App) patchServer(w http.ResponseWriter, r *http.Request) {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if s.IPv6 != oldV6 {
|
||||||
|
for i := range c.Peers {
|
||||||
|
c.Peers[i].IPv6 = "" // pivpn's addresses are in the old network
|
||||||
|
}
|
||||||
|
}
|
||||||
reissue = before != s.clientFacing()
|
reissue = before != s.clientFacing()
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
@@ -642,7 +708,7 @@ func (a *App) peerView(c *Config, p *Peer) peerView {
|
|||||||
Created: p.Created, ConfigIssued: p.ConfigIssued, Setup: viewSetup(p.Setup), Stats: a.stats.Summary(p.ID),
|
Created: p.Created, ConfigIssued: p.ConfigIssued, Setup: viewSetup(p.Setup), Stats: a.stats.Summary(p.ID),
|
||||||
}
|
}
|
||||||
if c.Server.IPv6Enabled {
|
if c.Server.IPv6Enabled {
|
||||||
v.IPv6 = mapIPv6(netip.MustParsePrefix(c.Server.IPv6), netip.MustParseAddr(p.IPv4)).String()
|
v.IPv6 = peerIPv6(c, p).String()
|
||||||
}
|
}
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
@@ -976,7 +1042,8 @@ func (a *App) issueConfig(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
// A config issued here replaces any pending link.
|
// A config issued here replaces any pending link.
|
||||||
p.PublicKey, p.ConfigIssued, p.Setup, name = pub, &now, nil, p.Name
|
// The new config gets the mapped IPv6 address.
|
||||||
|
p.PublicKey, p.ConfigIssued, p.Setup, p.IPv6, name = pub, &now, nil, "", p.Name
|
||||||
if p.PresharedKey != "" {
|
if p.PresharedKey != "" {
|
||||||
p.PresharedKey = psk.String()
|
p.PresharedKey = psk.String()
|
||||||
}
|
}
|
||||||
@@ -1033,11 +1100,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 +1250,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 +1271,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,26 +1030,40 @@
|
|||||||
|
|
||||||
// ---------- 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' },
|
||||||
h('div', null, h('h1', null, 'Dashboard'),
|
h('div', null, h('h1', null, 'Dashboard'),
|
||||||
h('p', { class: 'sub' }, 'Endpoint ', h('span', { class: 'mono' }, st.endpoint), ' · network ', h('span', { class: 'mono' }, st.ipv4))),
|
h('p', { class: 'sub' }, 'Endpoint ', h('span', { class: 'mono' }, st.endpoint), ' · network ', h('span', { class: 'mono' }, st.ipv4)))),
|
||||||
h('div', { class: 'actions' },
|
|
||||||
h('a', { class: 'btn', href: '#/server' }, 'Server config'),
|
|
||||||
h('a', { class: 'btn primary', href: '#/peers/new' }, icon('plus', 16, 2), 'Add peer'))),
|
|
||||||
|
|
||||||
failing.length ? h('div', { class: 'notice err', role: 'alert' },
|
failing.length ? h('div', { class: 'notice err', role: 'alert' },
|
||||||
h('div', null, h('strong', null, 'Needs attention: '), failing.map((c) => c.name + ' (' + c.detail + ')').join(' · ')),
|
h('div', null, h('strong', null, 'Needs attention: '), failing.map((c) => c.name + ' (' + c.detail + ')').join(' · ')),
|
||||||
@@ -1036,22 +1086,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 +1110,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 +1429,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 +1584,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 +1616,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 +1638,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 +1741,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 +1792,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 +1852,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 +1985,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 +2122,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 +2130,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
|
||||||
|
|||||||
+2
-2
@@ -52,7 +52,7 @@ func peerAddresses(c *Config, p *Peer) []netip.Prefix {
|
|||||||
v4 := netip.MustParseAddr(p.IPv4)
|
v4 := netip.MustParseAddr(p.IPv4)
|
||||||
out := []netip.Prefix{netip.PrefixFrom(v4, 32)}
|
out := []netip.Prefix{netip.PrefixFrom(v4, 32)}
|
||||||
if c.Server.IPv6Enabled {
|
if c.Server.IPv6Enabled {
|
||||||
out = append(out, netip.PrefixFrom(mapIPv6(netip.MustParsePrefix(c.Server.IPv6), v4), 128))
|
out = append(out, netip.PrefixFrom(peerIPv6(c, p), 128))
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
@@ -77,7 +77,7 @@ func clientConfig(c *Config, p *Peer, privateKey string) string {
|
|||||||
addr := fmt.Sprintf("%s/%d", v4, v4net.Bits())
|
addr := fmt.Sprintf("%s/%d", v4, v4net.Bits())
|
||||||
if c.Server.IPv6Enabled {
|
if c.Server.IPv6Enabled {
|
||||||
v6net := netip.MustParsePrefix(c.Server.IPv6)
|
v6net := netip.MustParsePrefix(c.Server.IPv6)
|
||||||
addr += fmt.Sprintf(",%s/%d", mapIPv6(v6net, v4), v6net.Bits())
|
addr += fmt.Sprintf(",%s/%d", peerIPv6(c, p), v6net.Bits())
|
||||||
}
|
}
|
||||||
if privateKey == "" {
|
if privateKey == "" {
|
||||||
privateKey = "<the private key of this device>"
|
privateKey = "<the private key of this device>"
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -157,6 +179,10 @@ type Peer struct {
|
|||||||
PublicKey string `json:"publicKey"`
|
PublicKey string `json:"publicKey"`
|
||||||
PresharedKey string `json:"presharedKey,omitempty"`
|
PresharedKey string `json:"presharedKey,omitempty"`
|
||||||
IPv4 string `json:"ipv4"`
|
IPv4 string `json:"ipv4"`
|
||||||
|
// IPv6 is set only for a peer imported from pivpn, which numbers IPv6
|
||||||
|
// differently: its device keeps the address until the config is issued
|
||||||
|
// here. Empty means the address mapped from IPv4 (see mapIPv6).
|
||||||
|
IPv6 string `json:"ipv6,omitempty"`
|
||||||
DNS []string `json:"dns,omitempty"` // nil = server default
|
DNS []string `json:"dns,omitempty"` // nil = server default
|
||||||
AllowedIPs []string `json:"allowedIPs,omitempty"` // nil = server default
|
AllowedIPs []string `json:"allowedIPs,omitempty"` // nil = server default
|
||||||
Keepalive *int `json:"keepalive,omitempty"` // nil = server default
|
Keepalive *int `json:"keepalive,omitempty"` // nil = server default
|
||||||
@@ -364,7 +390,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 +425,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 == "" {
|
||||||
@@ -440,6 +477,7 @@ func (c *Config) validate() error {
|
|||||||
|
|
||||||
names := map[string]bool{}
|
names := map[string]bool{}
|
||||||
ips := map[netip.Addr]bool{}
|
ips := map[netip.Addr]bool{}
|
||||||
|
ips6 := map[netip.Addr]bool{}
|
||||||
keys := map[string]bool{}
|
keys := map[string]bool{}
|
||||||
for _, p := range c.Peers {
|
for _, p := range c.Peers {
|
||||||
if err := validatePeerName(p.Name); err != nil {
|
if err := validatePeerName(p.Name); err != nil {
|
||||||
@@ -460,6 +498,17 @@ func (c *Config) validate() error {
|
|||||||
return fmt.Errorf("address %s is used twice", ip)
|
return fmt.Errorf("address %s is used twice", ip)
|
||||||
}
|
}
|
||||||
ips[ip] = true
|
ips[ip] = true
|
||||||
|
if p.IPv6 != "" {
|
||||||
|
a, err := netip.ParseAddr(p.IPv6)
|
||||||
|
if err != nil || !a.Is6() || !v6.Contains(a) || a == v6.Addr() {
|
||||||
|
return fmt.Errorf("peer %q: IPv6 address %s is outside %s", p.Name, p.IPv6, v6)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if a := peerIPv6(c, &p); ips6[a] {
|
||||||
|
return fmt.Errorf("IPv6 address %s is used twice", a)
|
||||||
|
} else {
|
||||||
|
ips6[a] = true
|
||||||
|
}
|
||||||
if p.hasKey() && keys[p.PublicKey] {
|
if p.hasKey() && keys[p.PublicKey] {
|
||||||
return fmt.Errorf("peer %q: public key is used by another peer", p.Name)
|
return fmt.Errorf("peer %q: public key is used by another peer", p.Name)
|
||||||
}
|
}
|
||||||
@@ -586,6 +635,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
|
||||||
|
|||||||
+48
-3
@@ -2,6 +2,7 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
|
"cmp"
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -21,6 +22,7 @@ type installPlan struct {
|
|||||||
noEmail bool // remove an existing Let's Encrypt email
|
noEmail bool // remove an existing Let's Encrypt email
|
||||||
passwordHash string // asked in the terminal; empty: asked later
|
passwordHash string // asked in the terminal; empty: asked later
|
||||||
ipv4 string // tunnel network of a new install
|
ipv4 string // tunnel network of a new install
|
||||||
|
pivpn *pivpnSetup // pivpn's WireGuard server to take over
|
||||||
}
|
}
|
||||||
|
|
||||||
var domainRe = regexp.MustCompile(`^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,63}$`)
|
var domainRe = regexp.MustCompile(`^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,63}$`)
|
||||||
@@ -80,6 +82,10 @@ func (p installPlan) changes() bool {
|
|||||||
return p.domain != "" || p.email != "" || p.endpoint != "" || p.port != 0 || p.noDomain || p.noEmail || p.passwordHash != ""
|
return p.domain != "" || p.email != "" || p.endpoint != "" || p.port != 0 || p.noDomain || p.noEmail || p.passwordHash != ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// errPivpnDeclined ends an install whose admin keeps pivpn: both would use
|
||||||
|
// the same interface.
|
||||||
|
var errPivpnDeclined = errors.New("install cancelled; nothing was changed. GHOSTWIRE and pivpn cannot both run the WireGuard interface: remove pivpn first, or install again and take it over")
|
||||||
|
|
||||||
func (p installPlan) apply(c *Config) {
|
func (p installPlan) apply(c *Config) {
|
||||||
if p.noDomain {
|
if p.noDomain {
|
||||||
if c.Web.TLS.Mode == "acme" {
|
if c.Web.TLS.Mode == "acme" {
|
||||||
@@ -113,7 +119,7 @@ func (p installPlan) apply(c *Config) {
|
|||||||
// reissueCount is the number of devices whose config stops working because
|
// reissueCount is the number of devices whose config stops working because
|
||||||
// the endpoint host or port changes.
|
// the endpoint host or port changes.
|
||||||
func (p installPlan) reissueCount(cur *Config, existing bool) int {
|
func (p installPlan) reissueCount(cur *Config, existing bool) int {
|
||||||
if !existing {
|
if !existing && p.pivpn == nil {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
next := cur.clone()
|
next := cur.clone()
|
||||||
@@ -189,6 +195,25 @@ func askInstall(in io.Reader, cur *Config, existing bool, given map[string]bool,
|
|||||||
fmt.Println("Press Enter to accept the value in [brackets].")
|
fmt.Println("Press Enter to accept the value in [brackets].")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// pivpn: taken over first, so its settings become the defaults below.
|
||||||
|
if p.pivpn != nil && !given["import-pivpn"] {
|
||||||
|
s := cur.Server
|
||||||
|
nets := s.IPv4
|
||||||
|
if s.IPv6Enabled {
|
||||||
|
nets += " + " + s.IPv6
|
||||||
|
}
|
||||||
|
fmt.Println("\npivpn found")
|
||||||
|
fmt.Printf(" %s · %s · UDP %d · %s\n", s.Interface, nets, s.ListenPort, cmp.Or(s.Endpoint, "no endpoint"))
|
||||||
|
fmt.Printf(" %s: %s\n", plural(len(p.pivpn.Peers), "client"), cmp.Or(p.pivpn.names(), "none"))
|
||||||
|
ok, err := pr.confirm(" Take over this WireGuard server? The devices keep working without new configs.")
|
||||||
|
if err != nil {
|
||||||
|
return p, err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return p, errPivpnDeclined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Web interface
|
// Web interface
|
||||||
fmt.Println("\nWeb interface")
|
fmt.Println("\nWeb interface")
|
||||||
curDomain := ""
|
curDomain := ""
|
||||||
@@ -328,7 +353,7 @@ func askInstall(in io.Reader, cur *Config, existing bool, given map[string]bool,
|
|||||||
|
|
||||||
func printInstallSummary(cur *Config, existing bool, p installPlan) {
|
func printInstallSummary(cur *Config, existing bool, p installPlan) {
|
||||||
next := cur.clone()
|
next := cur.clone()
|
||||||
if !existing {
|
if !existing && p.pivpn == nil {
|
||||||
next.Server.IPv4 = p.ipv4
|
next.Server.IPv4 = p.ipv4
|
||||||
next.Server.IPv6Enabled = hasGlobalIPv6()
|
next.Server.IPv6Enabled = hasGlobalIPv6()
|
||||||
}
|
}
|
||||||
@@ -366,7 +391,10 @@ func printInstallSummary(cur *Config, existing bool, p installPlan) {
|
|||||||
ep += fmt.Sprintf(" (was %s — %d existing device(s) need a new config)", endpointString(cur), n)
|
ep += fmt.Sprintf(" (was %s — %d existing device(s) need a new config)", endpointString(cur), n)
|
||||||
}
|
}
|
||||||
tunnel := next.Server.IPv4
|
tunnel := next.Server.IPv4
|
||||||
if !existing {
|
switch {
|
||||||
|
case p.pivpn != nil:
|
||||||
|
tunnel += " (from pivpn)"
|
||||||
|
case !existing:
|
||||||
tunnel += " (random free range)"
|
tunnel += " (random free range)"
|
||||||
}
|
}
|
||||||
if next.Server.IPv6Enabled {
|
if next.Server.IPv6Enabled {
|
||||||
@@ -395,4 +423,21 @@ func printInstallSummary(cur *Config, existing bool, p installPlan) {
|
|||||||
if p.passwordHash != "" {
|
if p.passwordHash != "" {
|
||||||
fmt.Printf(" Admin %s (password set)\n", next.Users[0].Username)
|
fmt.Printf(" Admin %s (password set)\n", next.Users[0].Username)
|
||||||
}
|
}
|
||||||
|
if pv := p.pivpn; pv != nil {
|
||||||
|
s := next.Server
|
||||||
|
fmt.Printf(" From pivpn server key, %s with their keys and addresses,\n", plural(len(pv.Peers), "peer"))
|
||||||
|
fmt.Printf(" DNS %s · keepalive %d s · MTU %d\n", strings.Join(s.ClientDefaults.DNS, ", "), s.ClientDefaults.Keepalive, s.MTU)
|
||||||
|
for _, r := range pv.Renamed {
|
||||||
|
fmt.Printf(" Renamed %s → %s (names here: 1–32 letters, digits, . @ _ -)\n", r[0], r[1])
|
||||||
|
}
|
||||||
|
fmt.Printf(" Not taken client private keys in %s (never stored here)\n", pv.ClientKeys)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// plural writes "1 peer" or "5 peers".
|
||||||
|
func plural(n int, word string) string {
|
||||||
|
if n == 1 {
|
||||||
|
return "1 " + word
|
||||||
|
}
|
||||||
|
return strconv.Itoa(n) + " " + word + "s"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -122,6 +122,15 @@ func nextFreeIPv4(c *Config) (netip.Addr, error) {
|
|||||||
// capacity is the number of peer addresses in the tunnel network.
|
// capacity is the number of peer addresses in the tunnel network.
|
||||||
func capacity(n netip.Prefix) int { return 1<<(32-n.Bits()) - 3 }
|
func capacity(n netip.Prefix) int { return 1<<(32-n.Bits()) - 3 }
|
||||||
|
|
||||||
|
// peerIPv6 is the peer's IPv6 tunnel address: the one kept from pivpn, or
|
||||||
|
// the one mapped from its IPv4 address.
|
||||||
|
func peerIPv6(c *Config, p *Peer) netip.Addr {
|
||||||
|
if a, err := netip.ParseAddr(p.IPv6); err == nil {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return mapIPv6(netip.MustParsePrefix(c.Server.IPv6), netip.MustParseAddr(p.IPv4))
|
||||||
|
}
|
||||||
|
|
||||||
// mapIPv6 puts the 32 bits of an IPv4 address into the low bits of the IPv6
|
// mapIPv6 puts the 32 bits of an IPv4 address into the low bits of the IPv6
|
||||||
// network: 10.84.12.8 in fd11:5ee:bad:c0de::/64 becomes fd11:5ee:bad:c0de::a54:c08.
|
// network: 10.84.12.8 in fd11:5ee:bad:c0de::/64 becomes fd11:5ee:bad:c0de::a54:c08.
|
||||||
func mapIPv6(v6net netip.Prefix, v4 netip.Addr) netip.Addr {
|
func mapIPv6(v6net netip.Prefix, v4 netip.Addr) netip.Addr {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
+18
-15
@@ -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) {
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
l, err := netlink.LinkByName(uplink)
|
l, err := netlink.LinkByName(uplink)
|
||||||
if err != nil {
|
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
|
||||||
}
|
}
|
||||||
|
addrs, _ := netlink.AddrList(l, netlink.FAMILY_ALL)
|
||||||
|
for _, a := range addrs {
|
||||||
ones, _ := a.Mask.Size()
|
ones, _ := a.Mask.Size()
|
||||||
ip, _ := netip.AddrFromSlice(a.IP.To4())
|
if ip, ok := netip.AddrFromSlice(a.IP); ok {
|
||||||
out = append(out, netip.PrefixFrom(ip, ones).Masked())
|
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
|
||||||
@@ -367,9 +368,11 @@ func (k *linuxKernel) Checks(c *Config) []Check {
|
|||||||
|
|
||||||
func (k *linuxKernel) Down(c *Config) error {
|
func (k *linuxKernel) Down(c *Config) error {
|
||||||
var errs []error
|
var errs []error
|
||||||
|
if c.Server.Interface != "" {
|
||||||
if link, err := netlink.LinkByName(c.Server.Interface); err == nil {
|
if link, err := netlink.LinkByName(c.Server.Interface); err == nil {
|
||||||
errs = append(errs, netlink.LinkDel(link))
|
errs = append(errs, netlink.LinkDel(link))
|
||||||
}
|
}
|
||||||
|
}
|
||||||
errs = append(errs, removeFirewall())
|
errs = append(errs, removeFirewall())
|
||||||
return errors.Join(errs...)
|
return errors.Join(errs...)
|
||||||
}
|
}
|
||||||
|
|||||||
+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)
|
||||||
|
|||||||
@@ -0,0 +1,354 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"cmp"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/netip"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A new install can take over a WireGuard server set up by pivpn: the
|
||||||
|
// server key, the network and every client with its public key, preshared
|
||||||
|
// key and addresses, so the devices keep their configs. pivpn keeps the
|
||||||
|
// client private keys in /etc/wireguard/configs; they are not read.
|
||||||
|
|
||||||
|
const (
|
||||||
|
pivpnSetupVars = "etc/pivpn/wireguard/setupVars.conf"
|
||||||
|
pivpnNote = "Imported from pivpn"
|
||||||
|
)
|
||||||
|
|
||||||
|
// pivpnSetup is what install takes over from pivpn.
|
||||||
|
type pivpnSetup struct {
|
||||||
|
Dev string // the interface, wg0
|
||||||
|
Server Server
|
||||||
|
Peers []Peer
|
||||||
|
Renamed [][2]string // pivpn name, name here
|
||||||
|
ClientKeys string // where pivpn keeps the client configs with private keys
|
||||||
|
}
|
||||||
|
|
||||||
|
// readPivpn reads pivpn's WireGuard setup under root ("/" on a server). It
|
||||||
|
// returns nil and no error when pivpn's WireGuard is not installed.
|
||||||
|
func readPivpn(root string) (*pivpnSetup, error) {
|
||||||
|
vars, err := readSetupVars(filepath.Join(root, pivpnSetupVars))
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s := &pivpnSetup{Dev: cmp.Or(vars["pivpnDEV"], "wg0")}
|
||||||
|
if checkIfName(s.Dev) != nil {
|
||||||
|
return nil, fmt.Errorf("pivpn: interface name %q is not usable", s.Dev)
|
||||||
|
}
|
||||||
|
confPath := filepath.Join(root, "etc/wireguard", s.Dev+".conf")
|
||||||
|
conf, err := parseWgConf(confPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("pivpn: %w", err)
|
||||||
|
}
|
||||||
|
s.ClientKeys = "/etc/wireguard/configs"
|
||||||
|
created := readClientsTxt(filepath.Join(root, "etc/wireguard/configs/clients.txt"))
|
||||||
|
|
||||||
|
// Server
|
||||||
|
srv := &s.Server
|
||||||
|
srv.Interface = s.Dev
|
||||||
|
if _, err := wgtypes.ParseKey(conf.privateKey); err != nil {
|
||||||
|
return nil, fmt.Errorf("pivpn: %s: the server key is missing or invalid", confPath)
|
||||||
|
}
|
||||||
|
srv.PrivateKey = conf.privateKey
|
||||||
|
srv.KeyCreated = fileTime(filepath.Join(root, "etc/wireguard/keys/server_priv"), confPath)
|
||||||
|
if srv.ListenPort = conf.listenPort; srv.ListenPort == 0 {
|
||||||
|
srv.ListenPort, _ = strconv.Atoi(vars["pivpnPORT"])
|
||||||
|
}
|
||||||
|
if srv.MTU = conf.mtu; srv.MTU == 0 {
|
||||||
|
srv.MTU, _ = strconv.Atoi(vars["pivpnMTU"])
|
||||||
|
}
|
||||||
|
for _, a := range conf.address {
|
||||||
|
if a.Addr().Is4() {
|
||||||
|
srv.IPv4 = a.Masked().String()
|
||||||
|
} else {
|
||||||
|
srv.IPv6, srv.IPv6Enabled = a.Masked().String(), true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if srv.IPv4 == "" {
|
||||||
|
return nil, fmt.Errorf("pivpn: %s has no IPv4 Address line", confPath)
|
||||||
|
}
|
||||||
|
if h := vars["pivpnHOST"]; checkEndpoint(h) == nil {
|
||||||
|
srv.Endpoint = h
|
||||||
|
}
|
||||||
|
srv.NAT, srv.PeerToPeer, srv.OpenPort = true, true, true
|
||||||
|
for _, k := range []string{"pivpnDNS1", "pivpnDNS2"} {
|
||||||
|
if a, err := netip.ParseAddr(vars[k]); err == nil {
|
||||||
|
srv.ClientDefaults.DNS = append(srv.ClientDefaults.DNS, a.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, v := range strings.Split(vars["ALLOWED_IPS"], ",") {
|
||||||
|
if p, err := netip.ParsePrefix(strings.TrimSpace(v)); err == nil {
|
||||||
|
srv.ClientDefaults.AllowedIPs = append(srv.ClientDefaults.AllowedIPs, p.Masked().String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
srv.ClientDefaults.Keepalive, _ = strconv.Atoi(vars["pivpnPERSISTENTKEEPALIVE"])
|
||||||
|
|
||||||
|
// Clients
|
||||||
|
v6net, _ := netip.ParsePrefix(srv.IPv6)
|
||||||
|
taken := map[string]bool{}
|
||||||
|
for _, cl := range conf.clients {
|
||||||
|
pub, err := wgtypes.ParseKey(cl.publicKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("pivpn: client %q has no valid public key", cl.name)
|
||||||
|
}
|
||||||
|
p := Peer{ID: newID(), Name: cl.name, Note: pivpnNote, Enabled: !cl.disabled, PublicKey: pub.String()}
|
||||||
|
if cl.presharedKey != "" {
|
||||||
|
psk, err := wgtypes.ParseKey(cl.presharedKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("pivpn: client %q has an invalid preshared key", cl.name)
|
||||||
|
}
|
||||||
|
p.PresharedKey = psk.String()
|
||||||
|
}
|
||||||
|
for _, a := range cl.allowedIPs {
|
||||||
|
switch {
|
||||||
|
case a.Addr().Is4() && p.IPv4 == "":
|
||||||
|
p.IPv4 = a.Addr().String()
|
||||||
|
case a.Addr().Is6() && p.IPv6 == "" && v6net.IsValid() && v6net.Contains(a.Addr()):
|
||||||
|
p.IPv6 = a.Addr().String()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if p.IPv4 == "" {
|
||||||
|
return nil, fmt.Errorf("pivpn: client %q has no IPv4 address", cl.name)
|
||||||
|
}
|
||||||
|
// Keep pivpn's IPv6 address only where it differs from the mapped one.
|
||||||
|
if v4, err := netip.ParseAddr(p.IPv4); err == nil && v6net.IsValid() && p.IPv6 == mapIPv6(v6net, v4).String() {
|
||||||
|
p.IPv6 = ""
|
||||||
|
}
|
||||||
|
t, ok := created[cl.name]
|
||||||
|
if !ok {
|
||||||
|
t = srv.KeyCreated
|
||||||
|
}
|
||||||
|
t = t.UTC()
|
||||||
|
p.Created, p.ConfigIssued = t, &t
|
||||||
|
if name := usableName(cl.name, taken); name != cl.name {
|
||||||
|
s.Renamed = append(s.Renamed, [2]string{cl.name, name})
|
||||||
|
p.Name = name
|
||||||
|
}
|
||||||
|
taken[strings.ToLower(p.Name)] = true
|
||||||
|
s.Peers = append(s.Peers, p)
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// apply puts the pivpn setup into a fresh config.
|
||||||
|
func (s *pivpnSetup) apply(c *Config) {
|
||||||
|
cd := c.Server.ClientDefaults
|
||||||
|
c.Server = s.Server
|
||||||
|
// Settings pivpn left empty keep GHOSTWIRE's defaults.
|
||||||
|
if c.Server.ClientDefaults.DNS == nil {
|
||||||
|
c.Server.ClientDefaults.DNS = cd.DNS
|
||||||
|
}
|
||||||
|
if c.Server.ClientDefaults.AllowedIPs == nil {
|
||||||
|
c.Server.ClientDefaults.AllowedIPs = cd.AllowedIPs
|
||||||
|
}
|
||||||
|
if c.Server.IPv6 == "" {
|
||||||
|
// IPv4-only pivpn: IPv6 stays off, with the network GHOSTWIRE
|
||||||
|
// would pick, ready for when it is switched on.
|
||||||
|
c.Server.IPv6 = "fd11:5ee:bad:c0de::/64"
|
||||||
|
}
|
||||||
|
c.Peers = slices.Clone(s.Peers)
|
||||||
|
c.applyDefaults()
|
||||||
|
}
|
||||||
|
|
||||||
|
// names lists the clients for the takeover question.
|
||||||
|
func (s *pivpnSetup) names() string {
|
||||||
|
var out []string
|
||||||
|
for _, p := range s.Peers {
|
||||||
|
n := p.Name
|
||||||
|
if !p.Enabled {
|
||||||
|
n += " (off)"
|
||||||
|
}
|
||||||
|
out = append(out, n)
|
||||||
|
}
|
||||||
|
return strings.Join(out, ", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkIfName(n string) error {
|
||||||
|
if n == "" || len(n) > 15 || strings.ContainsAny(n, "/ \t") {
|
||||||
|
return errors.New("bad interface name")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// usableName turns a pivpn client name into one GHOSTWIRE accepts and that
|
||||||
|
// is not taken yet.
|
||||||
|
func usableName(name string, taken map[string]bool) string {
|
||||||
|
b := []rune{}
|
||||||
|
for _, r := range name {
|
||||||
|
if r < 128 && (r == '.' || r == '@' || r == '_' || r == '-' || r >= '0' && r <= '9' || r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z') {
|
||||||
|
b = append(b, r)
|
||||||
|
} else {
|
||||||
|
b = append(b, '-')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
n := strings.TrimLeft(string(b), "-.")
|
||||||
|
if n == "" || strings.Trim(n, "0123456789") == "" || n == "server" {
|
||||||
|
n = "peer-" + n
|
||||||
|
}
|
||||||
|
n = strings.TrimRight(n, "-")
|
||||||
|
if len(n) > 32 {
|
||||||
|
n = n[:32]
|
||||||
|
}
|
||||||
|
base := n
|
||||||
|
for i := 1; taken[strings.ToLower(n)] || validatePeerName(n) != nil; i++ {
|
||||||
|
suffix := "-" + strconv.Itoa(i)
|
||||||
|
n = base
|
||||||
|
if len(n)+len(suffix) > 32 {
|
||||||
|
n = n[:32-len(suffix)]
|
||||||
|
}
|
||||||
|
n += suffix
|
||||||
|
if i > 1000 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// readSetupVars reads pivpn's KEY=VALUE file; values may be quoted.
|
||||||
|
func readSetupVars(path string) (map[string]string, error) {
|
||||||
|
b, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := map[string]string{}
|
||||||
|
for _, line := range strings.Split(string(b), "\n") {
|
||||||
|
k, v, ok := strings.Cut(strings.TrimSpace(line), "=")
|
||||||
|
if !ok || strings.HasPrefix(k, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
v = strings.TrimSpace(v)
|
||||||
|
if len(v) >= 2 && (v[0] == '"' || v[0] == '\'') && v[len(v)-1] == v[0] {
|
||||||
|
v = v[1 : len(v)-1]
|
||||||
|
}
|
||||||
|
out[strings.TrimSpace(k)] = v
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type wgClient struct {
|
||||||
|
name, publicKey, presharedKey string
|
||||||
|
allowedIPs []netip.Prefix
|
||||||
|
disabled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type wgConf struct {
|
||||||
|
privateKey string
|
||||||
|
listenPort, mtu int
|
||||||
|
address []netip.Prefix
|
||||||
|
clients []wgClient
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseWgConf reads pivpn's wg0.conf: the [Interface] section, then one
|
||||||
|
// "### begin NAME ###" … "### end NAME ###" block per client. pivpn turns a
|
||||||
|
// client off by prefixing each line of its block with "#[disabled] ".
|
||||||
|
func parseWgConf(path string) (*wgConf, error) {
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
c := &wgConf{}
|
||||||
|
var cur *wgClient
|
||||||
|
sc := bufio.NewScanner(f)
|
||||||
|
for sc.Scan() {
|
||||||
|
line := strings.TrimSpace(sc.Text())
|
||||||
|
disabled := false
|
||||||
|
if rest, ok := strings.CutPrefix(line, "#[disabled]"); ok {
|
||||||
|
line, disabled = strings.TrimSpace(rest), true
|
||||||
|
}
|
||||||
|
if name, ok := strings.CutPrefix(line, "### begin "); ok {
|
||||||
|
c.clients = append(c.clients, wgClient{name: strings.TrimSpace(strings.TrimSuffix(name, "###"))})
|
||||||
|
cur = &c.clients[len(c.clients)-1]
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(line, "### end ") {
|
||||||
|
cur = nil
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, "[") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
k, v, ok := strings.Cut(line, "=")
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
k, v = strings.ToLower(strings.TrimSpace(k)), strings.TrimSpace(v)
|
||||||
|
if cur != nil {
|
||||||
|
cur.disabled = cur.disabled || disabled
|
||||||
|
switch k {
|
||||||
|
case "publickey":
|
||||||
|
cur.publicKey = v
|
||||||
|
case "presharedkey":
|
||||||
|
cur.presharedKey = v
|
||||||
|
case "allowedips":
|
||||||
|
cur.allowedIPs = parsePrefixes(v)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch k {
|
||||||
|
case "privatekey":
|
||||||
|
c.privateKey = v
|
||||||
|
case "listenport":
|
||||||
|
c.listenPort, _ = strconv.Atoi(v)
|
||||||
|
case "mtu":
|
||||||
|
c.mtu, _ = strconv.Atoi(v)
|
||||||
|
case "address":
|
||||||
|
c.address = parsePrefixes(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return c, sc.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func parsePrefixes(v string) []netip.Prefix {
|
||||||
|
var out []netip.Prefix
|
||||||
|
for _, s := range strings.Split(v, ",") {
|
||||||
|
if p, err := netip.ParsePrefix(strings.TrimSpace(s)); err == nil {
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// readClientsTxt returns when each client was created: clients.txt has
|
||||||
|
// "NAME PUBLICKEY UNIXTIME" per line.
|
||||||
|
func readClientsTxt(path string) map[string]time.Time {
|
||||||
|
out := map[string]time.Time{}
|
||||||
|
b, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
for _, line := range strings.Split(string(b), "\n") {
|
||||||
|
f := strings.Fields(line)
|
||||||
|
if len(f) < 3 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if n, err := strconv.ParseInt(f[2], 10, 64); err == nil && n > 0 {
|
||||||
|
out[f[0]] = time.Unix(n, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// fileTime is the modification time of the first file that exists.
|
||||||
|
func fileTime(paths ...string) time.Time {
|
||||||
|
for _, p := range paths {
|
||||||
|
if st, err := os.Stat(p); err == nil {
|
||||||
|
return st.ModTime().UTC()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return time.Now().UTC()
|
||||||
|
}
|
||||||
+335
@@ -0,0 +1,335 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/netip"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||||
|
)
|
||||||
|
|
||||||
|
// pivpnFixture writes a pivpn WireGuard layout under a temp root, in the
|
||||||
|
// format pivpn v4 writes (checked against a real install, Ubuntu 24.04).
|
||||||
|
type pivpnClient struct {
|
||||||
|
name, v6 string
|
||||||
|
ipv4 string
|
||||||
|
psk bool
|
||||||
|
disabled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func pivpnFixture(t *testing.T, ipv6 bool, clients []pivpnClient) (root string, serverKey wgtypes.Key, pubs map[string]string) {
|
||||||
|
t.Helper()
|
||||||
|
root = t.TempDir()
|
||||||
|
must := func(err error) {
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, d := range []string{"etc/pivpn/wireguard", "etc/wireguard/configs", "etc/wireguard/keys"} {
|
||||||
|
must(os.MkdirAll(filepath.Join(root, d), 0o755))
|
||||||
|
}
|
||||||
|
v6 := "0"
|
||||||
|
if ipv6 {
|
||||||
|
v6 = "1"
|
||||||
|
}
|
||||||
|
vars := `USING_UFW=0
|
||||||
|
IPv4dev=eth0
|
||||||
|
VPN=wireguard
|
||||||
|
pivpnPORT=51820
|
||||||
|
pivpnDNS1=9.9.9.9
|
||||||
|
pivpnDNS2=149.112.112.112
|
||||||
|
pivpnHOST=vpn.example.net
|
||||||
|
pivpnPROTO=udp
|
||||||
|
pivpnMTU=1420
|
||||||
|
pivpnPERSISTENTKEEPALIVE=25
|
||||||
|
pivpnDEV=wg0
|
||||||
|
pivpnNET=10.6.0.0
|
||||||
|
subnetClass=24
|
||||||
|
pivpnenableipv6=` + v6 + `
|
||||||
|
pivpnNETv6="fd11:5ee:bad:c0de::"
|
||||||
|
subnetClassv6=64
|
||||||
|
ALLOWED_IPS="0.0.0.0/0, ::0/0"
|
||||||
|
INSTALLED_PACKAGES=(wireguard-tools qrencode)
|
||||||
|
`
|
||||||
|
must(os.WriteFile(filepath.Join(root, pivpnSetupVars), []byte(vars), 0o644))
|
||||||
|
serverKey, _ = wgtypes.GeneratePrivateKey()
|
||||||
|
var conf, txt strings.Builder
|
||||||
|
addr := "10.6.0.1/24"
|
||||||
|
if ipv6 {
|
||||||
|
addr += ",fd11:5ee:bad:c0de::a06:1/64"
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&conf, "[Interface]\nPrivateKey = %s\nAddress = %s\nMTU = 1420\nListenPort = 51820\n", serverKey, addr)
|
||||||
|
pubs = map[string]string{}
|
||||||
|
for _, c := range clients {
|
||||||
|
k, _ := wgtypes.GeneratePrivateKey()
|
||||||
|
pubs[c.name] = k.PublicKey().String()
|
||||||
|
var b strings.Builder
|
||||||
|
fmt.Fprintf(&b, "### begin %s ###\n[Peer]\nPublicKey = %s\n", c.name, k.PublicKey())
|
||||||
|
if c.psk {
|
||||||
|
psk, _ := wgtypes.GenerateKey()
|
||||||
|
fmt.Fprintf(&b, "PresharedKey = %s\n", psk)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, "AllowedIPs = %s/32", c.ipv4)
|
||||||
|
if ipv6 {
|
||||||
|
fmt.Fprintf(&b, ",%s/128", c.v6)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, "\n### end %s ###\n", c.name)
|
||||||
|
block := b.String()
|
||||||
|
if c.disabled {
|
||||||
|
block = "#[disabled] " + strings.ReplaceAll(strings.TrimSuffix(block, "\n"), "\n", "\n#[disabled] ") + "\n"
|
||||||
|
}
|
||||||
|
conf.WriteString(block)
|
||||||
|
fmt.Fprintf(&txt, "%s %s 1700000000 167116802\n", c.name, k.PublicKey())
|
||||||
|
}
|
||||||
|
must(os.WriteFile(filepath.Join(root, "etc/wireguard/wg0.conf"), []byte(conf.String()), 0o644))
|
||||||
|
must(os.WriteFile(filepath.Join(root, "etc/wireguard/configs/clients.txt"), []byte(txt.String()), 0o644))
|
||||||
|
return root, serverKey, pubs
|
||||||
|
}
|
||||||
|
|
||||||
|
func importedConfig(t *testing.T, s *pivpnSetup) *Config {
|
||||||
|
t.Helper()
|
||||||
|
c := &Config{}
|
||||||
|
c.applyDefaults()
|
||||||
|
s.apply(c)
|
||||||
|
if err := c.validate(); err != nil {
|
||||||
|
t.Fatalf("imported config does not validate: %v", err)
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPivpnImport(t *testing.T) {
|
||||||
|
// No pivpn: nothing to import, no error.
|
||||||
|
if s, err := readPivpn(t.TempDir()); s != nil || err != nil {
|
||||||
|
t.Fatalf("empty root: %v %v", s, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
clients := []pivpnClient{
|
||||||
|
{name: "iphone-alex", ipv4: "10.6.0.2", v6: "fd11:5ee:bad:c0de::a06:2", psk: true},
|
||||||
|
{name: "nas-office", ipv4: "10.6.0.5", v6: "fd11:5ee:bad:c0de::a06:5", psk: false},
|
||||||
|
{name: "phone-guest", ipv4: "10.6.0.6", v6: "fd11:5ee:bad:c0de::a06:6", psk: true, disabled: true},
|
||||||
|
// Hand-edited: an IPv6 address that is not the mapped one.
|
||||||
|
{name: "old-laptop", ipv4: "10.6.0.7", v6: "fd11:5ee:bad:c0de::7", psk: true},
|
||||||
|
}
|
||||||
|
root, key, pubs := pivpnFixture(t, true, clients)
|
||||||
|
s, err := readPivpn(root)
|
||||||
|
if err != nil || s == nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
c := importedConfig(t, s)
|
||||||
|
srv := c.Server
|
||||||
|
if srv.PrivateKey != key.String() || srv.ListenPort != 51820 || srv.MTU != 1420 || srv.Interface != "wg0" ||
|
||||||
|
srv.IPv4 != "10.6.0.0/24" || srv.IPv6 != "fd11:5ee:bad:c0de::/64" || !srv.IPv6Enabled ||
|
||||||
|
srv.Endpoint != "vpn.example.net" || !srv.NAT || !srv.PeerToPeer || !srv.OpenPort {
|
||||||
|
t.Fatalf("server: %+v", srv)
|
||||||
|
}
|
||||||
|
cd := srv.ClientDefaults
|
||||||
|
if strings.Join(cd.DNS, ",") != "9.9.9.9,149.112.112.112" || strings.Join(cd.AllowedIPs, ",") != "0.0.0.0/0,::/0" || cd.Keepalive != 25 {
|
||||||
|
t.Fatalf("client defaults: %+v", cd)
|
||||||
|
}
|
||||||
|
if len(c.Peers) != 4 {
|
||||||
|
t.Fatalf("want 4 peers, got %d", len(c.Peers))
|
||||||
|
}
|
||||||
|
by := map[string]*Peer{}
|
||||||
|
for i := range c.Peers {
|
||||||
|
by[c.Peers[i].Name] = &c.Peers[i]
|
||||||
|
}
|
||||||
|
ph := by["iphone-alex"]
|
||||||
|
if ph.PublicKey != pubs["iphone-alex"] || ph.PresharedKey == "" || ph.IPv4 != "10.6.0.2" || ph.IPv6 != "" ||
|
||||||
|
!ph.Enabled || ph.Note != pivpnNote || !ph.Created.Equal(time.Unix(1700000000, 0)) || ph.ConfigIssued == nil {
|
||||||
|
t.Fatalf("iphone-alex: %+v", ph)
|
||||||
|
}
|
||||||
|
if by["nas-office"].PresharedKey != "" {
|
||||||
|
t.Error("nas-office had no preshared key")
|
||||||
|
}
|
||||||
|
if by["phone-guest"].Enabled {
|
||||||
|
t.Error("a #[disabled] client must be imported switched off")
|
||||||
|
}
|
||||||
|
if by["old-laptop"].IPv6 != "fd11:5ee:bad:c0de::7" {
|
||||||
|
t.Errorf("a non-mapped IPv6 address must be kept, got %q", by["old-laptop"].IPv6)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each device's own pivpn config keeps working: the server accepts its
|
||||||
|
// key, preshared key and both addresses.
|
||||||
|
for _, cl := range clients {
|
||||||
|
p := by[cl.name]
|
||||||
|
got := []string{}
|
||||||
|
for _, a := range peerAddresses(c, p) {
|
||||||
|
got = append(got, a.String())
|
||||||
|
}
|
||||||
|
want := cl.ipv4 + "/32 " + cl.v6 + "/128"
|
||||||
|
if strings.Join(got, " ") != want {
|
||||||
|
t.Errorf("%s: server allows %v, the device uses %s", cl.name, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A config issued here gets the mapped address and drops the kept one.
|
||||||
|
conf := clientConfig(c, by["old-laptop"], "")
|
||||||
|
if !strings.Contains(conf, "fd11:5ee:bad:c0de::7/64") {
|
||||||
|
t.Errorf("config before re-issue should keep pivpn's address:\n%s", conf)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IPv4-only pivpn.
|
||||||
|
root4, _, _ := pivpnFixture(t, false, clients[:1])
|
||||||
|
s4, err := readPivpn(root4)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
c4 := importedConfig(t, s4)
|
||||||
|
if c4.Server.IPv6Enabled || c4.Peers[0].IPv6 != "" {
|
||||||
|
t.Fatalf("IPv4-only import: %+v %+v", c4.Server, c4.Peers[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
// A wg0.conf without clients imports the server alone.
|
||||||
|
root0, _, _ := pivpnFixture(t, true, nil)
|
||||||
|
s0, err := readPivpn(root0)
|
||||||
|
if err != nil || len(importedConfig(t, s0).Peers) != 0 {
|
||||||
|
t.Fatalf("no clients: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Broken files are refused before anything changes.
|
||||||
|
broken := func(edit func(string) string) error {
|
||||||
|
r, _, _ := pivpnFixture(t, true, clients[:1])
|
||||||
|
p := filepath.Join(r, "etc/wireguard/wg0.conf")
|
||||||
|
b, _ := os.ReadFile(p)
|
||||||
|
_ = os.WriteFile(p, []byte(edit(string(b))), 0o644)
|
||||||
|
_, err := readPivpn(r)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if broken(func(s string) string { return strings.Replace(s, "PrivateKey = ", "PrivateKey = x", 1) }) == nil {
|
||||||
|
t.Error("a bad server key must be refused")
|
||||||
|
}
|
||||||
|
if broken(func(s string) string { return strings.Replace(s, "\nPublicKey = ", "\nPublicKey = x", 1) }) == nil {
|
||||||
|
t.Error("a bad client key must be refused")
|
||||||
|
}
|
||||||
|
if err := broken(func(s string) string { return strings.Replace(s, "AllowedIPs = 10.6.0.2/32,", "AllowedIPs = ", 1) }); err == nil {
|
||||||
|
t.Error("a client without IPv4 must be refused")
|
||||||
|
}
|
||||||
|
r, _, _ := pivpnFixture(t, true, nil)
|
||||||
|
_ = os.Remove(filepath.Join(r, "etc/wireguard/wg0.conf"))
|
||||||
|
if _, err := readPivpn(r); err == nil || errors.Is(err, os.ErrNotExist) && !strings.Contains(err.Error(), "pivpn") {
|
||||||
|
t.Errorf("a missing wg0.conf must be an error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPivpnNames(t *testing.T) {
|
||||||
|
taken := map[string]bool{"phone": true}
|
||||||
|
for in, want := range map[string]string{
|
||||||
|
"iphone-alex": "iphone-alex",
|
||||||
|
"phone": "phone-1",
|
||||||
|
"server": "peer-server",
|
||||||
|
"12345": "peer-12345",
|
||||||
|
"-dash": "dash",
|
||||||
|
"a-very-long-client-name-from-pivpn-2025": "a-very-long-client-name-from-piv",
|
||||||
|
"Ümlaut": "mlaut",
|
||||||
|
} {
|
||||||
|
if got := usableName(in, taken); got != want || validatePeerName(got) != nil {
|
||||||
|
t.Errorf("usableName(%q) = %q, want %q", in, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two pivpn names that become the same here are both kept, renamed.
|
||||||
|
root, _, _ := pivpnFixture(t, true, []pivpnClient{
|
||||||
|
{name: "Phone", ipv4: "10.6.0.2", v6: "fd11:5ee:bad:c0de::a06:2"},
|
||||||
|
{name: "phone", ipv4: "10.6.0.3", v6: "fd11:5ee:bad:c0de::a06:3"},
|
||||||
|
})
|
||||||
|
s, err := readPivpn(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
c := importedConfig(t, s)
|
||||||
|
if c.Peers[0].Name != "Phone" || c.Peers[1].Name != "phone-1" || len(s.Renamed) != 1 || s.Renamed[0] != [2]string{"phone", "phone-1"} {
|
||||||
|
t.Fatalf("renames: %v %v", []string{c.Peers[0].Name, c.Peers[1].Name}, s.Renamed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPivpnInstallQuestion(t *testing.T) {
|
||||||
|
root, _, _ := pivpnFixture(t, true, []pivpnClient{
|
||||||
|
{name: "iphone-alex", ipv4: "10.6.0.2", v6: "fd11:5ee:bad:c0de::a06:2", psk: true},
|
||||||
|
})
|
||||||
|
s, err := readPivpn(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cur := importedConfig(t, s)
|
||||||
|
hash, _ := hashPassword("a long test password")
|
||||||
|
cur.Users[0].PasswordHash = hash
|
||||||
|
|
||||||
|
// Yes, then Enter keeps pivpn's endpoint and port: no device needs a new config.
|
||||||
|
p, err := askInstall(strings.NewReader("y\n\n\n\ny\n"), cur, false, map[string]bool{}, installPlan{pivpn: s})
|
||||||
|
if err != nil || p.endpoint != "" || p.port != 0 || p.reissueCount(cur, false) != 0 {
|
||||||
|
t.Fatalf("Enter should keep pivpn's settings: %+v %v", p, err)
|
||||||
|
}
|
||||||
|
// A new port means the device needs a new config.
|
||||||
|
p, err = askInstall(strings.NewReader("y\n\n\n51900\ny\n"), cur, false, map[string]bool{}, installPlan{pivpn: s})
|
||||||
|
if err != nil || p.reissueCount(cur, false) != 1 {
|
||||||
|
t.Fatalf("a new port should need a new config: %+v %v", p, err)
|
||||||
|
}
|
||||||
|
// No: nothing changes, and install explains why.
|
||||||
|
if _, err := askInstall(strings.NewReader("n\n"), cur, false, map[string]bool{}, installPlan{pivpn: s}); !errors.Is(err, errPivpnDeclined) {
|
||||||
|
t.Fatalf("want errPivpnDeclined, got %v", err)
|
||||||
|
}
|
||||||
|
// -import-pivpn answers the question.
|
||||||
|
if _, err := askInstall(strings.NewReader("\n\n\ny\n"), cur, false, map[string]bool{"import-pivpn": true}, installPlan{pivpn: s}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPeerIPv6Kept(t *testing.T) {
|
||||||
|
c := testConfig(t)
|
||||||
|
c.Server.IPv6Enabled = true
|
||||||
|
c.Peers = []Peer{{ID: "a", Name: "a", IPv4: "10.84.12.2", PublicKey: "k1", IPv6: "fd11:5ee:bad:c0de::2"}}
|
||||||
|
if err := c.validate(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, bad := range []string{"10.84.12.9", "fd00::2", "fd11:5ee:bad:c0de::", "not an address"} {
|
||||||
|
c.Peers[0].IPv6 = bad
|
||||||
|
if c.validate() == nil {
|
||||||
|
t.Errorf("IPv6 %q should be refused", bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Two peers on the same IPv6 address.
|
||||||
|
c.Peers[0].IPv6 = mapIPv6(netip.MustParsePrefix(c.Server.IPv6), netip.MustParseAddr("10.84.12.3")).String()
|
||||||
|
c.Peers = append(c.Peers, Peer{ID: "b", Name: "b", IPv4: "10.84.12.3", PublicKey: "k2"})
|
||||||
|
if c.validate() == nil {
|
||||||
|
t.Error("an IPv6 address used twice should be refused")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPivpnWaitBack(t *testing.T) {
|
||||||
|
pv := &pivpnSetup{Peers: []Peer{{Name: "a", PublicKey: "ka"}, {Name: "b", PublicKey: "kb"}}}
|
||||||
|
since := time.Now()
|
||||||
|
after := since.Add(time.Second)
|
||||||
|
sample := func(back ...string) func() ([]PeerSample, error) {
|
||||||
|
return func() ([]PeerSample, error) {
|
||||||
|
var out []PeerSample
|
||||||
|
for _, k := range back {
|
||||||
|
out = append(out, PeerSample{PublicKey: k, LastHandshake: after})
|
||||||
|
}
|
||||||
|
// A handshake from before the switch does not count.
|
||||||
|
return append(out, PeerSample{PublicKey: "kb", LastHandshake: since.Add(-time.Minute)}), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Everyone back: returns at once.
|
||||||
|
start := time.Now()
|
||||||
|
back, skipped := waitBack(pv, []string{"a", "b"}, sample("ka", "kb"), since, time.Minute, time.Millisecond, nil)
|
||||||
|
if len(back) != 2 || skipped || time.Since(start) > time.Second {
|
||||||
|
t.Fatalf("all back: %v %v", back, skipped)
|
||||||
|
}
|
||||||
|
// One missing: waits for the timeout.
|
||||||
|
back, skipped = waitBack(pv, []string{"a", "b"}, sample("ka"), since, 50*time.Millisecond, 5*time.Millisecond, nil)
|
||||||
|
if len(back) != 1 || back[0] != "a" || skipped {
|
||||||
|
t.Fatalf("timeout: %v %v", back, skipped)
|
||||||
|
}
|
||||||
|
// Enter: returns at once, marked skipped.
|
||||||
|
skip := make(chan struct{})
|
||||||
|
close(skip)
|
||||||
|
start = time.Now()
|
||||||
|
back, skipped = waitBack(pv, []string{"a", "b"}, sample("ka"), time.Now(), time.Minute, time.Second, skip)
|
||||||
|
if !skipped || time.Since(start) > time.Second {
|
||||||
|
t.Fatalf("skip: %v %v", back, skipped)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"cmp"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"errors"
|
"errors"
|
||||||
"flag"
|
"flag"
|
||||||
@@ -37,9 +39,10 @@ func usage() {
|
|||||||
fmt.Fprintf(os.Stderr, `%s %s — WireGuard server manager
|
fmt.Fprintf(os.Stderr, `%s %s — WireGuard server manager
|
||||||
|
|
||||||
Usage (as root):
|
Usage (as root):
|
||||||
%s install [-domain vpn.example.net] [-email you@example.net] [-endpoint host] [-port 51820] [-y]
|
%s install [-domain vpn.example.net] [-email you@example.net] [-endpoint host] [-port 51820] [-import-pivpn] [-no-wait] [-y]
|
||||||
set up user, folder, config, sysctls and systemd service; start it.
|
set up user, folder, config, sysctls and systemd service; start it.
|
||||||
In a terminal it asks for the settings no flag gave; -y never asks
|
In a terminal it asks for the settings no flag gave; -y never asks.
|
||||||
|
On a pivpn server it offers to take over pivpn's WireGuard and clients
|
||||||
%s update [-force]
|
%s update [-force]
|
||||||
replace the installed binary with this one and restart
|
replace the installed binary with this one and restart
|
||||||
%s uninstall [-purge] [-y]
|
%s uninstall [-purge] [-y]
|
||||||
@@ -379,6 +382,8 @@ func cmdInstall(args []string) error {
|
|||||||
endpoint := fs.String("endpoint", "", "host or IP clients connect to (default: the domain)")
|
endpoint := fs.String("endpoint", "", "host or IP clients connect to (default: the domain)")
|
||||||
port := fs.Int("port", 0, "UDP port WireGuard listens on (default: 51820, or the current port when already installed)")
|
port := fs.Int("port", 0, "UDP port WireGuard listens on (default: 51820, or the current port when already installed)")
|
||||||
yes := fs.Bool("y", false, "do not ask; use the flags and defaults")
|
yes := fs.Bool("y", false, "do not ask; use the flags and defaults")
|
||||||
|
importPivpn := fs.Bool("import-pivpn", false, "take over pivpn's WireGuard server and clients (new installs only)")
|
||||||
|
noWait := fs.Bool("no-wait", false, "after a pivpn takeover, do not wait for connected devices to come back")
|
||||||
_ = fs.Parse(args)
|
_ = fs.Parse(args)
|
||||||
given := map[string]bool{}
|
given := map[string]bool{}
|
||||||
fs.Visit(func(f *flag.Flag) { given[f.Name] = true })
|
fs.Visit(func(f *flag.Flag) { given[f.Name] = true })
|
||||||
@@ -402,14 +407,40 @@ func cmdInstall(args []string) error {
|
|||||||
if err := plan.check(); err != nil {
|
if err := plan.check(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
interactive := !*yes && term.IsTerminal(int(os.Stdin.Fd()))
|
||||||
|
|
||||||
|
// pivpn: a new install takes over its WireGuard server, or stops, since
|
||||||
|
// both would run the same interface.
|
||||||
|
var pv *pivpnSetup
|
||||||
if !existing {
|
if !existing {
|
||||||
|
if pv, err = readPivpn("/"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case *importPivpn && existing:
|
||||||
|
return fmt.Errorf("-import-pivpn works only on a new install, and %s exists", configFile)
|
||||||
|
case *importPivpn && pv == nil:
|
||||||
|
return fmt.Errorf("-import-pivpn: pivpn's WireGuard setup was not found (/%s)", pivpnSetupVars)
|
||||||
|
case pv != nil && !interactive && !*importPivpn:
|
||||||
|
return fmt.Errorf("pivpn runs WireGuard on %s here; add -import-pivpn to take it over, or remove pivpn first", pv.Dev)
|
||||||
|
}
|
||||||
|
if pv != nil {
|
||||||
|
pv.apply(cur)
|
||||||
|
if err := cur.validate(); err != nil {
|
||||||
|
return fmt.Errorf("pivpn's setup cannot be taken over, nothing changed: %w", err)
|
||||||
|
}
|
||||||
|
plan.pivpn = pv
|
||||||
|
}
|
||||||
|
|
||||||
|
if !existing && pv == nil {
|
||||||
n, err := randomSubnet(24)
|
n, err := randomSubnet(24)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
plan.ipv4 = n.String()
|
plan.ipv4 = n.String()
|
||||||
}
|
}
|
||||||
if !*yes && term.IsTerminal(int(os.Stdin.Fd())) {
|
if interactive {
|
||||||
if plan, err = askInstall(os.Stdin, cur, existing, given, plan); err != nil {
|
if plan, err = askInstall(os.Stdin, cur, existing, given, plan); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -453,8 +484,15 @@ func cmdInstall(args []string) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Config: created with defaults (server key, the chosen subnet) if missing.
|
// Config: created with defaults (server key, the chosen subnet) if
|
||||||
if !existing {
|
// missing, or with everything taken over from pivpn.
|
||||||
|
switch {
|
||||||
|
case pv != nil:
|
||||||
|
step("Creating %s from pivpn (%s)", configFile, plural(len(pv.Peers), "peer"))
|
||||||
|
if err := writeFileAtomic(configFile, cur, 0o600); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
case !existing:
|
||||||
step("Creating %s", configFile)
|
step("Creating %s", configFile)
|
||||||
initial := fmt.Sprintf("{\"server\": {\"ipv4\": %q}}\n", plan.ipv4)
|
initial := fmt.Sprintf("{\"server\": {\"ipv4\": %q}}\n", plan.ipv4)
|
||||||
if err := os.WriteFile(configFile, []byte(initial), 0o600); err != nil {
|
if err := os.WriteFile(configFile, []byte(initial), 0o600); err != nil {
|
||||||
@@ -491,17 +529,179 @@ func cmdInstall(args []string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
step("Starting %s", serviceName)
|
// pivpn hands over its interface: note who is connected, then stop it.
|
||||||
if err := sh("systemctl", "enable", serviceName); err != nil {
|
var connected []string
|
||||||
|
var switched time.Time
|
||||||
|
if pv != nil {
|
||||||
|
connected = pivpnConnected(pv)
|
||||||
|
step("Peers connected to pivpn right now: %s", cmp.Or(strings.Join(connected, ", "), "none"))
|
||||||
|
step("Stopping pivpn's WireGuard (systemctl disable --now wg-quick@%s)", pv.Dev)
|
||||||
|
if err := sh("systemctl", "disable", "--now", "wg-quick@"+pv.Dev); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := restartAndVerify(); err != nil {
|
switched = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
step("Starting %s", serviceName)
|
||||||
|
err = sh("systemctl", "enable", serviceName)
|
||||||
|
if err == nil {
|
||||||
|
err = restartAndVerify()
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
if pv != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, " ", err)
|
||||||
|
return pivpnBack(pv, store.Get(), err)
|
||||||
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if pv != nil {
|
||||||
|
waitForPeers(pv, connected, switched, *noWait, interactive)
|
||||||
|
}
|
||||||
printWhereToGo(store.Get())
|
printWhereToGo(store.Get())
|
||||||
|
if pv != nil {
|
||||||
|
fmt.Printf("\npivpn is still installed but no longer runs %s. Manage the peers here from now on.\n", pv.Dev)
|
||||||
|
fmt.Printf("Its files in /etc/wireguard and /etc/pivpn are untouched, including the client\n")
|
||||||
|
fmt.Printf("configs with private keys. Once everything works, delete %s.\n", pv.ClientKeys)
|
||||||
|
fmt.Printf("Don't run \"pivpn uninstall\": it removes WireGuard packages and firewall rules.\n")
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// pivpnConnected names the peers with a handshake in the last 3 minutes.
|
||||||
|
func pivpnConnected(pv *pivpnSetup) []string {
|
||||||
|
k, err := newKernel()
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
defer k.Close()
|
||||||
|
samples, err := k.Sample(pv.Dev)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var out []string
|
||||||
|
for _, p := range pv.Peers {
|
||||||
|
for _, s := range samples {
|
||||||
|
if s.PublicKey == p.PublicKey && !s.LastHandshake.IsZero() && time.Since(s.LastHandshake) < onlineWindow {
|
||||||
|
out = append(out, p.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitForPeers waits up to 30 s for the peers that were connected to pivpn
|
||||||
|
// to make a handshake with the new service. It only reports: a device that
|
||||||
|
// is idle may take minutes to send its next packet. Enter in a terminal
|
||||||
|
// skips the rest of the wait; -no-wait skips it entirely.
|
||||||
|
func waitForPeers(pv *pivpnSetup, names []string, since time.Time, noWait, interactive bool) {
|
||||||
|
switch {
|
||||||
|
case len(names) == 0:
|
||||||
|
step("No peer was connected before the switch; devices connect when they come back online.")
|
||||||
|
return
|
||||||
|
case noWait:
|
||||||
|
step("Not waiting for %s (-no-wait).", strings.Join(names, ", "))
|
||||||
|
fmt.Printf(" %s\n", onlineLater(len(names)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
k, err := newKernel()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer k.Close()
|
||||||
|
var skip <-chan struct{}
|
||||||
|
hint := ""
|
||||||
|
if interactive {
|
||||||
|
ch := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
_, _ = bufio.NewReader(os.Stdin).ReadString('\n')
|
||||||
|
close(ch)
|
||||||
|
}()
|
||||||
|
skip, hint = ch, " (Enter skips)"
|
||||||
|
}
|
||||||
|
step("Waiting up to 30 s for %s to come back%s", strings.Join(names, ", "), hint)
|
||||||
|
back, skipped := waitBack(pv, names, func() ([]PeerSample, error) { return k.Sample(pv.Dev) }, since, 30*time.Second, 2*time.Second, skip)
|
||||||
|
secs := int(time.Since(since).Round(time.Second).Seconds())
|
||||||
|
var missing []string
|
||||||
|
for _, n := range names {
|
||||||
|
if !slices.Contains(back, n) {
|
||||||
|
missing = append(missing, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case len(missing) == 0:
|
||||||
|
step("%d of %d peers that were connected before are back (after %d s)", len(back), len(names), secs)
|
||||||
|
return
|
||||||
|
case skipped:
|
||||||
|
step("Skipped after %d s: %d of %d back so far", secs, len(back), len(names))
|
||||||
|
fmt.Printf(" %s not back yet.\n %s\n", strings.Join(missing, ", "), onlineLater(len(missing)))
|
||||||
|
default:
|
||||||
|
step("%d of %d are back after %d s", len(back), len(names), secs)
|
||||||
|
fmt.Printf(" %s not back yet. A device that is idle can take a few minutes to send its next\n", strings.Join(missing, ", "))
|
||||||
|
fmt.Printf(" packet. %s\n", onlineLater(len(missing)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitBack polls the kernel until every named peer made a handshake after
|
||||||
|
// since, the timeout passes or skip is closed. It returns the peers that are
|
||||||
|
// back and whether the wait was skipped.
|
||||||
|
func waitBack(pv *pivpnSetup, names []string, sample func() ([]PeerSample, error), since time.Time, timeout, every time.Duration, skip <-chan struct{}) (back []string, skipped bool) {
|
||||||
|
key := map[string]string{}
|
||||||
|
for _, p := range pv.Peers {
|
||||||
|
key[p.Name] = p.PublicKey
|
||||||
|
}
|
||||||
|
deadline := time.After(time.Until(since.Add(timeout)))
|
||||||
|
tick := time.NewTicker(every)
|
||||||
|
defer tick.Stop()
|
||||||
|
for {
|
||||||
|
back = back[:0]
|
||||||
|
if samples, err := sample(); err == nil {
|
||||||
|
for _, n := range names {
|
||||||
|
for _, s := range samples {
|
||||||
|
if s.PublicKey == key[n] && s.LastHandshake.After(since) {
|
||||||
|
back = append(back, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(back) == len(names) {
|
||||||
|
return back, false
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-skip:
|
||||||
|
return back, true
|
||||||
|
case <-deadline:
|
||||||
|
return back, false
|
||||||
|
case <-tick.C:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// onlineLater tells where peers that are not back yet will show up.
|
||||||
|
func onlineLater(n int) string {
|
||||||
|
if n == 1 {
|
||||||
|
return "It shows as online on the Peers page once it is back."
|
||||||
|
}
|
||||||
|
return "They show as online on the Peers page once they are back."
|
||||||
|
}
|
||||||
|
|
||||||
|
// pivpnBack undoes the takeover after the service failed to start: it stops
|
||||||
|
// the service, removes its interface, firewall table and the config it was
|
||||||
|
// given, and starts pivpn's WireGuard again. Without the config, the next
|
||||||
|
// install offers the takeover again instead of fighting pivpn for wg0.
|
||||||
|
func pivpnBack(pv *pivpnSetup, c *Config, cause error) error {
|
||||||
|
_ = sh("systemctl", "disable", "--now", serviceName)
|
||||||
|
if k, err := newKernel(); err == nil {
|
||||||
|
_ = k.Down(c)
|
||||||
|
k.Close()
|
||||||
|
}
|
||||||
|
_ = os.Remove(configFile)
|
||||||
|
step("Starting pivpn's WireGuard again (systemctl enable --now wg-quick@%s)", pv.Dev)
|
||||||
|
if err := sh("systemctl", "enable", "--now", "wg-quick@"+pv.Dev); err != nil {
|
||||||
|
return fmt.Errorf("install failed, and starting pivpn's WireGuard again failed too: %v (original error: %w)", err, cause)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("install failed; pivpn runs %s as before: %w", pv.Dev, cause)
|
||||||
|
}
|
||||||
|
|
||||||
func chownTree(root string, uid, gid int) error {
|
func chownTree(root string, uid, gid int) error {
|
||||||
return filepath.Walk(root, func(p string, _ os.FileInfo, err error) error {
|
return filepath.Walk(root, func(p string, _ os.FileInfo, err error) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -663,9 +863,11 @@ func cmdUninstall(args []string) error {
|
|||||||
|
|
||||||
step("Removing the WireGuard interface and firewall table")
|
step("Removing the WireGuard interface and firewall table")
|
||||||
c, err := loadConfigFile(configFile)
|
c, err := loadConfigFile(configFile)
|
||||||
if err != nil {
|
if _, statErr := os.Stat(configFile); err != nil || statErr != nil {
|
||||||
|
// Without a config of ours, e.g. after a pivpn takeover was undone,
|
||||||
|
// the interface may belong to someone else: only the firewall table
|
||||||
|
// goes.
|
||||||
c = &Config{}
|
c = &Config{}
|
||||||
c.applyDefaults()
|
|
||||||
}
|
}
|
||||||
if k, err := newKernel(); err == nil {
|
if k, err := newKernel(); err == nil {
|
||||||
if err := k.Down(c); err != nil {
|
if err := k.Down(c); err != nil {
|
||||||
|
|||||||
@@ -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”.')),
|
||||||
|
|||||||
+1
-1
@@ -260,7 +260,7 @@ func (a *App) setupRedeem(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
id, hadKey = p.ID, p.hasKey()
|
id, hadKey = p.ID, p.hasKey()
|
||||||
p.PublicKey, p.ConfigIssued, p.Setup = k.PublicKey().String(), &now, nil
|
p.PublicKey, p.ConfigIssued, p.Setup, p.IPv6 = k.PublicKey().String(), &now, nil, ""
|
||||||
if p.PresharedKey != "" {
|
if p.PresharedKey != "" {
|
||||||
p.PresharedKey = psk.String()
|
p.PresharedKey = psk.String()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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