23 Commits

Author SHA1 Message Date
Daniel Redetzke ef5a2930e0 pivpn takeover: the reconnect wait can be skipped
In a terminal, install now says which peers it waits for and that Enter
skips the wait; Enter ends it at once and names the peers not back yet.
-no-wait skips it in scripts. Without a terminal and without -no-wait,
install still waits up to 30 s. The takeover is done before the wait
starts, so skipping it changes nothing on the server.
2026-10-06 01:04:47 +03:00
Daniel Redetzke 3482a03707 Install takes over a pivpn WireGuard server
On a server running pivpn's WireGuard, a new install offers to take it
over: the server key, port, MTU, tunnel networks, endpoint, DNS,
AllowedIPs and keepalive, and every client with its public key,
preshared key and addresses. Devices keep their configs. Clients pivpn
switched off are imported switched off, with the note "Imported from
pivpn". Client private keys in /etc/wireguard/configs are not read.

- Install notes which peers are connected, stops wg-quick@wg0, starts
  GHOSTWIRE on the same wg0 and waits up to 30 s for those peers. The
  wait only reports; idle devices reconnect when they next send.
- If the service does not stay running, install removes what it set up,
  including config.json, and starts pivpn's WireGuard again.
- Without a terminal the takeover needs -import-pivpn; install refuses
  to run next to pivpn otherwise, and the flag is refused on an
  existing install.
- Names GHOSTWIRE does not accept are renamed and listed in the
  summary. An IPv6 address that differs from the mapped one is kept on
  the peer until its config is issued again.
- uninstall without a config of its own (e.g. after a takeover was
  undone) leaves the WireGuard interface alone and removes only the
  firewall table.
- README: "Coming from pivpn?" under the intro, a Features entry and a
  "Moving from pivpn" section.

Tested end to end on Ubuntu 24.04 with pivpn aa96de7.
2026-10-06 00:54:08 +03:00
Daniel Redetzke 47762790ef Dashboard: no buttons next to the heading
The Server config and Add peer buttons are gone from the Dashboard
heading. Server is in the sidebar, Add peer is on the Peers page, and
with no peers yet the Peers card still links to adding the first one.
2026-10-05 23:41:27 +03:00
Daniel Redetzke 3e8dba6072 Fixes from the audit: input checks, apply order, sign-in limits
- The server endpoint must be a plain host name or IP address. It is
  written into client configs as is, so a newline could add lines such
  as PreUp, which wg-quick runs as root on the client.
- Listen addresses and the session length (1–720 hours) are checked.
  Before web settings or a restore are saved, the server tries the new
  listen addresses and certificate files, so a value it cannot start
  with is refused instead of stopping the service at the next restart.
- Kernel applies run one at a time and read the config once it is
  their turn, so an older config can no longer be applied last.
- Pending passkey sign-ins are capped: 10 per address, 1000 in total.
- Behind a local proxy, the last X-Forwarded-For entry is the client;
  earlier ones come from the client and are ignored.
- With LAN access off, peers are also kept from the IPv6 networks on
  the uplink, not only from its private IPv4 networks.
- A change that leaves no user with a password is refused, and so is a
  backup without one or from a newer version.
2026-10-05 23:09:03 +03:00
Daniel Redetzke aa4ca20296 API: the "Last apply" check is now "Kernel in sync"
GET /api/v1/status names the check Kernel in sync, the same as the
health row in the web interface, which now reads the new name directly.
The detail is unchanged: "applied <time>" or the kernel's error.
2026-10-05 22:29:47 +03:00
Daniel Redetzke 1bfede250c Health: "Last apply" reads "Kernel in sync"
The health row that shows when the config was last written to the
kernel is now called Kernel in sync, with the time since then, or Out
of sync and the kernel's error when applying failed. The API keeps the
check's name, so clients are unaffected.
2026-10-05 22:28:16 +03:00
Daniel Redetzke 8edde9f5e7 Links: arrows for moving around, ↗ for outside pages
Links to another page (All peers, Log, Add the first one) are ink with
an arrow that nudges on hover, the back link gets ←, and links that
open an outside page get ↗ and "opens in a new tab" for screen
readers, on the setup page too. Peer names look the same everywhere,
and links in text get a pale underline. Buttons and the sidebar stay
as they are.
2026-10-05 21:28:26 +03:00
Daniel Redetzke 7c36223de0 Live page: upload gets a light area too
Download and upload are both lines over light, see-through areas, so
upload no longer looks less important when it is the larger one.
2026-10-05 20:35:23 +03:00
Daniel Redetzke 2b7b41859d Live page: curved lines and speeds with one decimal
The chart draws download and upload as smooth curves that never dip
below zero or overshoot a peak, and speeds always show one decimal
from kbit/s up so the figures keep their shape as they change.
2026-10-05 20:28:56 +03:00
Daniel Redetzke ccf7b50c59 Live page: figures update without counting 2026-10-05 14:48:30 +03:00
Daniel Redetzke a82314ee94 Live page: 10-second averages for the figures and the table
The big figures, the per-peer speeds and the busiest-first order
average the last 5 steps instead of swinging with every burst, and the
figures sit in fixed-width columns so they no longer push each other
around. The charts still show every step.
2026-10-05 14:44:18 +03:00
Daniel Redetzke d83582eea1 Live page: streamed updates and a sliding chart
The server pushes each new step over server-sent events
(GET /api/v1/live/stream) the moment it is sampled, so updates no
longer arrive in uneven pairs. The chart slides left steadily between
steps instead of jumping, and the big numbers count to their new
value. Both stay still with reduced motion.
2026-10-05 14:39:35 +03:00
Daniel Redetzke 5f321f2979 Live page: online peers only 2026-10-05 14:34:08 +03:00
Daniel Redetzke 6661424daf Live page: the speed of every peer right now
A new Live page shows current download and upload per peer, updated
every 2 seconds, with the last 2 minutes as a chart and a small chart
per peer. The server reads the WireGuard counters every 2 seconds and
keeps 2 minutes in memory; GET /api/v1/live serves them, with since=
for only the newer steps. The dev simulator now adds traffic in
proportion to the time between samples.
2026-10-05 14:28:29 +03:00
Daniel Redetzke 456ae6a41e Dashboard: a range switch redraws only the traffic chart
The range buttons light up at once and fetch only the chart's stats,
like on the peer page, instead of reloading the whole dashboard.
2026-10-05 14:18:18 +03:00
Daniel Redetzke c7b4ca692e Dashboard: 24 h, 7 day and 30 day range on the traffic chart 2026-10-05 14:12:44 +03:00
Daniel Redetzke 6733bf2f3a Peer page: traffic chart opens on 24 hours 2026-10-05 14:06:05 +03:00
Daniel Redetzke e9bd3090a8 Charts: clock times and dates along the bottom
Traffic and latency charts label the x-axis with clock times every
3 hours (6 on phones, the date at midnight), or dates for the 7- and
30-day ranges. The hover readout for hourly bars shows the clock
time range instead of "3 h ago".
2026-10-05 14:01:10 +03:00
Daniel Redetzke 50467535a8 Remove old config copies from updates
Settings -> Upkeep -> Backup & restore lists the config.json.bak-* files
that update leaves behind and removes one or all of them; they hold the
same secrets as a backup. Removing needs a signed-in user and is logged.
After a successful update only the newest 3 copies are kept, and a copy
that would overwrite an older one (version unknown, or the same version
twice) gets the time appended. The backup card now also names preshared
keys and authenticator app secrets.

The update notice uses the existing compareVersions instead of its own.
2026-10-05 12:34:22 +03:00
Daniel Redetzke 39dde98af5 Settings in groups; the log on its own page
Settings is grouped into Access (users, sign-in, iOS app and API tokens),
Web interface (address and HTTPS), Logs & history and Upkeep
(updates, backup). Session length moved to Sign-in and no longer asks for
a restart. Log level, log size and traffic history share one card; the
country lookup is its own switch. Each card says how it saves.

The log viewer moved to a new Log page in the sidebar, with a filter for
changes only, and the Dashboard's Log link opens it.
2026-10-05 12:11:32 +03:00
Daniel Redetzke 59c4fb5ff1 Update notice: a newer release shows in the web interface
Once a day the server asks Gitea or GitHub, as picked under Settings ->
Updates, for the latest release. A newer one shows as a pill in the
sidebar, a banner on the Dashboard and in the Updates card with its
release notes and the commands to update this server. Drafts and
pre-releases are ignored, nothing about the server is sent, and the check
can be switched off. POST /updates/check checks now.
2026-10-05 11:59:27 +03:00
Daniel Redetzke 15d921019a Health card: addresses beside a list of checks
The public addresses stack on the left and the other checks are rows in
one list on the right, each with its raw setting right after the status.
Below 1000px the addresses move above the list.
2026-10-05 10:10:51 +03:00
Daniel Redetzke 2bc6465ea7 README: the iOS app is in beta testing; invites by email 2026-10-05 09:35:34 +03:00
25 changed files with 3132 additions and 211 deletions
+70 -7
View File
@@ -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).
![Dashboard with peers online, traffic of the last 24 hours, the peer list and recent activity](screenshots/dashboard.png) ![Dashboard with peers online, traffic of the last 24 hours, the peer list and recent activity](screenshots/dashboard.png)
## 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`.
@@ -27,16 +36,24 @@ dependencies on the server: the binary installs, updates and removes itself.
- **IPv4 and IPv6:** IPv6 inside the tunnel is turned on automatically when the - **IPv4 and IPv6:** IPv6 inside the tunnel is turned on automatically when the
server has a global IPv6 address. server has a global IPv6 address.
- **Traffic history:** kept in `stats.json`, hourly for 48 h and daily for - **Traffic history:** kept in `stats.json`, hourly for 48 h and daily for
400 days by default (Settings → Data retention). 400 days by default (Settings → Logs & history).
- **Live view:** the speed of every peer right now, updated every 2 seconds,
with the last 2 minutes as a chart. Kept in memory only.
- **Connection history:** every online session per peer, with start, duration, - **Connection history:** every online session per peer, with start, duration,
address and traffic. A new session starts when a device changes networks. address and traffic. A new session starts when a device changes networks.
Country and network operator come from the free Country and network operator come from the free
[DB-IP Lite](https://db-ip.com) databases (CC BY 4.0). GHOSTWIRE downloads [DB-IP Lite](https://db-ip.com) databases (CC BY 4.0). GHOSTWIRE downloads
them monthly (about 20 MB) and looks addresses up locally, so peer addresses them monthly (about 20 MB) and looks addresses up locally, so peer addresses
never leave the server. You can switch this off under Settings → Data never leave the server. You can switch this off under Settings → Logs &
retention. history.
- **Logs:** written to `GHOSTWIRE.jsonl`, rotated at 10 MB with 5 old files - **Logs:** written to `GHOSTWIRE.jsonl`, rotated at 10 MB with 5 old files
kept by default. Changes are marked as audit entries. kept by default, and shown on the Log page. Changes are marked as audit
entries.
- **Update notice:** once a day the server asks Gitea or GitHub (your choice
under Settings → Updates) for the latest release. A newer one shows in the
sidebar, on the Dashboard and in Settings, with its release notes and the
commands to update this server. Nothing about the server is sent; the check
can be switched off.
- **HTTPS built in:** Let's Encrypt, a self-signed certificate, your own - **HTTPS built in:** Let's Encrypt, a self-signed certificate, your own
certificate files, or plain HTTP behind a reverse proxy. certificate files, or plain HTTP behind a reverse proxy.
@@ -146,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
@@ -171,12 +190,36 @@ 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>`, replaces the binary, updates the unit if needed and restarts. If the new version does not stay up, the old binary and config are put back and restarted. It refuses older versions without `-force`. | | `GHOSTWIRE update [-force]` | Run from the new binary, e.g. `sudo /tmp/GHOSTWIRE update`. Checks that it can read the current `config.json` (nothing changes if not), backs up the config to `config.json.bak-<old version>` (keeping the newest 3 such copies), replaces the binary, updates the unit if needed and restarts. If the new version does not stay up, the old binary and config are put back and restarted. It refuses older versions without `-force`. |
| `GHOSTWIRE uninstall [-purge] [-y]` | Stops and removes the service, `wg0` and the firewall table. `-purge` also deletes `/opt/ghostwire` and the user. | | `GHOSTWIRE uninstall [-purge] [-y]` | Stops and removes the service, `wg0` and the firewall table. `-purge` also deletes `/opt/ghostwire` and the user. |
| `GHOSTWIRE passwd [username]` | Sets a user's password (default: the first user) and reloads the running service. The way back in if you are locked out. | | `GHOSTWIRE passwd [username]` | Sets a user's password (default: the first user) and reloads the running service. The way back in if you are locked out. |
| `GHOSTWIRE version` | Prints the version. | | `GHOSTWIRE version` | Prints the version. |
@@ -218,6 +261,7 @@ After editing `config.json` by hand, run `sudo systemctl reload ghostwire`.
|---|---| |---|---|
| `GHOSTWIRE` | the program | | `GHOSTWIRE` | the program |
| `config.json` | all settings, server key, peers, pending setup links with their PINs, user password hashes, authenticator app secrets, passkeys, recovery code and token hashes (0600) | | `config.json` | all settings, server key, peers, pending setup links with their PINs, user password hashes, authenticator app secrets, passkeys, recovery code and token hashes (0600) |
| `config.json.bak-*` | copies of `config.json` made by `update`; the newest 3 are kept, and Settings → Upkeep lists and removes them |
| `stats.json` | traffic and connection history per peer | | `stats.json` | traffic and connection history per peer |
| `geo-country.mmdb`, `geo-asn.mmdb` | DB-IP Lite databases for country and network lookups | | `geo-country.mmdb`, `geo-asn.mmdb` | DB-IP Lite databases for country and network lookups |
| `GHOSTWIRE.jsonl` | log, one JSON object per line. Changes carry `"audit":true` | | `GHOSTWIRE.jsonl` | log, one JSON object per line. Changes carry `"audit":true` |
@@ -257,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}
@@ -264,9 +310,10 @@ POST /peers/{id}/enable | /disable | /issue-config
GET /peers/{id}/stats?range=… GET /peers/{id}/sessions?limit=100 GET /peers/{id}/stats?range=… GET /peers/{id}/sessions?limit=100
GET /peers/{id}/latency (24 h, one point per 5 minutes) GET /peers/{id}/latency (24 h, one point per 5 minutes)
GET /peers/{id}/setup (not read-only) DELETE /peers/{id}/setup GET /peers/{id}/setup (not read-only) DELETE /peers/{id}/setup
GET /settings PATCH /settings POST /restart GET /settings PATCH /settings POST /restart POST /updates/check
GET /logs?level=&limit=&audit=1 GET /logs/download GET /logs?level=&limit=&audit=1 GET /logs/download
signed in: GET|POST /tokens · DELETE /tokens/{id} · GET /backup · POST /restore signed in: GET|POST /tokens · DELETE /tokens/{id} · GET /backup · POST /restore
signed in: GET|DELETE /update-backups · DELETE /update-backups/{name} (config copies made by update)
public: GET /setup/{token} · POST /setup/{token} {"pin"} (what a setup link opens) public: GET /setup/{token} · POST /setup/{token} {"pin"} (what a setup link opens)
``` ```
@@ -275,9 +322,22 @@ public: GET /setup/{token} · POST /setup/{token} {"pin"} (what a setup link o
setup link (`setup.url`, `setup.pin`, `setup.qr`) instead of a config. With a setup link (`setup.url`, `setup.pin`, `setup.qr`) instead of a config. With a
link, the peer's current keys keep working until the link is opened. link, the peer's current keys keep working until the link is opened.
`GET /settings` includes `updates`: the running and latest version,
`available`, the release notes and the download links for this server's
platform. `PATCH /settings` `{"updates": {"source": "gitea"|"github",
"check": false}}` picks the source or switches the daily check off;
`POST /updates/check` checks now. `GET /auth/me` has `updateAvailable` with
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
@@ -303,6 +363,9 @@ Settings → Pair iOS app: scan the QR code, or tap "Copy pairing code" and past
it into the app's "Enter manually". Self-signed certificates are pinned during it into the app's "Enter manually". Self-signed certificates are pinned during
pairing. pairing.
The iOS app is currently in beta testing. For an invite, email
[engineroom@redetzke.aero](mailto:engineroom@redetzke.aero).
## Development ## Development
On macOS (or any non-Linux system), `make dev` starts the app on On macOS (or any non-Linux system), `make dev` starts the app on
+172 -9
View File
@@ -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,13 +24,16 @@ 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
logw *rotatingWriter // nil in tests logw *rotatingWriter // nil in tests
geo *Geo // nil in tests geo *Geo // 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 ---
@@ -143,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)
@@ -173,6 +181,7 @@ func (a *App) routes() http.Handler {
// need a signed-in user. // need a signed-in user.
g("GET /api/v1/settings", a.getSettings) g("GET /api/v1/settings", a.getSettings)
g("PATCH /api/v1/settings", a.patchSettings) g("PATCH /api/v1/settings", a.patchSettings)
g("POST /api/v1/updates/check", a.checkUpdates)
g("POST /api/v1/restart", a.restart) g("POST /api/v1/restart", a.restart)
adm("GET /api/v1/tokens", a.listTokens) adm("GET /api/v1/tokens", a.listTokens)
adm("POST /api/v1/tokens", a.createToken) adm("POST /api/v1/tokens", a.createToken)
@@ -181,6 +190,9 @@ func (a *App) routes() http.Handler {
g("GET /api/v1/logs/download", a.downloadLog) g("GET /api/v1/logs/download", a.downloadLog)
adm("GET /api/v1/backup", a.backup) adm("GET /api/v1/backup", a.backup)
adm("POST /api/v1/restore", a.restore) adm("POST /api/v1/restore", a.restore)
adm("GET /api/v1/update-backups", a.listUpdateBackups)
adm("DELETE /api/v1/update-backups", a.removeUpdateBackups)
adm("DELETE /api/v1/update-backups/{name}", a.removeUpdateBackup)
mux.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "no such endpoint"}) writeJSON(w, http.StatusNotFound, map[string]string{"error": "no such endpoint"})
@@ -258,6 +270,9 @@ func (a *App) me(w http.ResponseWriter, r *http.Request) {
"id": p.UserID, "name": p.Name, "isAdmin": p.IsAdmin, "scope": p.Scope, "id": p.UserID, "name": p.Name, "isAdmin": p.IsAdmin, "scope": p.Scope,
"mustChangePassword": p.MustChangePassword, "mfaSetupRequired": p.MFASetupRequired, "version": version, "session": p.Session, "mustChangePassword": p.MustChangePassword, "mfaSetupRequired": p.MFASetupRequired, "version": version, "session": p.Session,
} }
if v := a.updates.Available(); v != "" {
out["updateAvailable"] = v
}
if _, u := a.store.Get().userByID(p.UserID); u != nil { if _, u := a.store.Get().userByID(p.UserID); u != nil {
out["username"], out["note"], out["created"] = u.Username, u.Note, u.Created out["username"], out["note"], out["created"] = u.Username, u.Note, u.Created
} }
@@ -342,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()
} }
@@ -390,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 {
@@ -491,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
@@ -514,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
}) })
@@ -634,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
} }
@@ -968,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()
} }
@@ -999,6 +1074,7 @@ func (a *App) getSettings(w http.ResponseWriter, r *http.Request) {
"decoy": cfg.Decoy, "decoy": cfg.Decoy,
"signin": cfg.SignIn, "signin": cfg.SignIn,
"geo": a.geoStatus(), "geo": a.geoStatus(),
"updates": a.updates.Status(),
"fingerprint": a.tls.Fingerprint(), "fingerprint": a.tls.Fingerprint(),
"logPath": a.logPath, "logPath": a.logPath,
}) })
@@ -1016,12 +1092,23 @@ func (a *App) patchSettings(w http.ResponseWriter, r *http.Request) {
} }
var restart bool var restart bool
err = a.store.Update(func(c *Config) error { err = a.store.Update(func(c *Config) error {
before, _ := json.Marshal(c.Web) // Session length applies to the next sign-in; everything else in
// web needs a restart.
listen := func() string {
w := c.Web
w.SessionHours = 0
b, _ := json.Marshal(w)
return string(b)
}
before, 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
} }
after, _ := json.Marshal(c.Web) if restart = listen() != before; restart {
restart = string(before) != string(after) 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
} }
@@ -1031,6 +1118,9 @@ func (a *App) patchSettings(w http.ResponseWriter, r *http.Request) {
if err := field(m, "signin", &c.SignIn); err != nil { if err := field(m, "signin", &c.SignIn); err != nil {
return err return err
} }
if err := field(m, "updates", &c.Updates); err != nil {
return err
}
return field(m, "log", &c.Log) return field(m, "log", &c.Log)
}) })
if err != nil { if err != nil {
@@ -1160,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
} }
@@ -1168,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) {
@@ -1176,6 +1328,17 @@ func (a *App) applyRuntime(c *Config) {
a.logw.SetLimits(c.Log.MaxSizeMB, c.Log.MaxFiles) a.logw.SetLimits(c.Log.MaxSizeMB, c.Log.MaxFiles)
} }
a.geo.SetEnabled(c.Stats.geoEnabled()) a.geo.SetEnabled(c.Stats.geoEnabled())
a.updates.Set(c.Updates)
}
// checkUpdates asks the release source now and returns what it found.
func (a *App) checkUpdates(w http.ResponseWriter, r *http.Request) {
if a.updates == nil || !a.updates.Status().Enabled {
writeErr(w, badRequest("the update check is switched off"))
return
}
a.updates.Check(r.Context())
writeJSON(w, http.StatusOK, a.updates.Status())
} }
func (a *App) geoStatus() GeoStatus { func (a *App) geoStatus() GeoStatus {
+98 -22
View File
@@ -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; }
@@ -69,7 +81,10 @@ a.brand, a.brand:hover { color: #fff; text-decoration: none; }
.side .acct strong { font-size: 14px; font-weight: 500; color: #fff; overflow: hidden; text-overflow: ellipsis; } .side .acct strong { font-size: 14px; font-weight: 500; color: #fff; overflow: hidden; text-overflow: ellipsis; }
.side .acct span span { color: #a9aaa5; } .side .acct span span { color: #a9aaa5; }
.side .avatar { width: 28px; height: 28px; border-radius: 50%; background: #3a3b41; display: grid; place-items: center; flex: none; font-size: 13px; font-weight: 600; color: #fff; } .side .avatar { width: 28px; height: 28px; border-radius: 50%; background: #3a3b41; display: grid; place-items: center; flex: none; font-size: 13px; font-weight: 600; color: #fff; }
.side .footrow { display: flex; justify-content: space-between; padding: 10px 12px 0; } .side .footrow { display: flex; justify-content: space-between; align-items: center; gap: 8px; padding: 10px 12px 0; }
.side .footrow .upd { font-size: 11.5px; font-weight: 500; color: #cfe2f8; background: #1f3550; border: 1px solid #2d4a6e; padding: 2px 8px; border-radius: 999px; text-decoration: none; white-space: nowrap; }
.side .footrow .upd:hover { color: #fff; }
.side .nav .pip { margin-left: auto; width: 7px; height: 7px; border-radius: 50%; background: #6aa6ea; }
.main { flex: 999 1 560px; min-width: 0; padding: 32px 32px 56px; } .main { flex: 999 1 560px; min-width: 0; padding: 32px 32px 56px; }
/* Beside the page (not stacked above it on a phone), the sidebar stays in /* Beside the page (not stacked above it on a phone), the sidebar stays in
place while the page scrolls, so the account link is always visible. */ place while the page scrolls, so the account link is always visible. */
@@ -83,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; }
@@ -129,6 +144,9 @@ h1 { margin: 0; font-size: 26px; font-weight: 600; letter-spacing: -0.01em; over
.tag { display: inline-block; font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 999px; background: var(--warn-bg); color: var(--warn-ink); margin-left: 6px; vertical-align: 1px; } .tag { display: inline-block; font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 999px; background: var(--warn-bg); color: var(--warn-ink); margin-left: 6px; vertical-align: 1px; }
.notice { display: flex; gap: 10px; align-items: flex-start; padding: 12px 14px; border-radius: 10px; background: var(--warn-bg); color: var(--warn-ink); font-size: 13px; } .notice { display: flex; gap: 10px; align-items: flex-start; padding: 12px 14px; border-radius: 10px; background: var(--warn-bg); color: var(--warn-ink); font-size: 13px; }
.notice.err { background: #fbefee; color: var(--bad-ink); } .notice.err { background: #fbefee; color: var(--bad-ink); }
.notice.new { background: #e8f0fa; color: #174d8f; flex-wrap: wrap; align-items: center; }
.notice.new .actions { margin-left: auto; }
.btn.ghost { background: transparent; border-color: transparent; }
.notice .btn { margin-left: auto; } .notice .btn { margin-left: auto; }
/* tables */ /* tables */
@@ -145,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 */
@@ -173,28 +191,50 @@ fieldset { border: 0; margin: 0; padding: 0; min-width: 0; display: flex; flex-d
.kv dt { color: var(--ink-2); } .kv dt { color: var(--ink-2); }
.kv dd { margin: 0; min-width: 0; overflow-wrap: anywhere; } .kv dd { margin: 0; min-width: 0; overflow-wrap: anywhere; }
/* health: public addresses, then one tile per check */ /* health: public addresses on the left, one row per check on the right */
.hcbody { display: grid; grid-template-columns: minmax(0, 5fr) minmax(0, 7fr); gap: 12px; margin-top: 16px; }
.hchead { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; } .hchead { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; }
.hchead > span { font-size: 13px; color: var(--ink-2); } .hchead > span { font-size: 13px; color: var(--ink-2); }
.hchead > span.bad { color: var(--bad-ink); font-weight: 500; } .hchead > span.bad { color: var(--bad-ink); font-weight: 500; }
.hcaddrs { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 12px; margin-top: 16px; } .hcaddrs { display: flex; flex-direction: column; gap: 12px; }
.hcaddr { background: var(--ground); border-radius: 10px; padding: 14px 16px; min-width: 0; } .hcaddr { flex: 1; display: flex; flex-direction: column; justify-content: center; background: var(--ground); border-radius: 10px; padding: 14px 18px; min-width: 0; }
.hcaddr .l { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--ink-2); } .hcaddr .l { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--ink-2); }
.hcaddr .v { margin-top: 4px; font-size: 15px; font-weight: 500; overflow-wrap: anywhere; } .hcaddr .v { margin-top: 4px; font-size: 15px; font-weight: 500; overflow-wrap: anywhere; }
.hcaddr .v.mono { font-size: 20px; } .hcaddr .v.mono { font-size: 22px; }
.hcaddr .n { font-family: var(--sans); font-size: 12px; font-weight: 400; color: var(--ink-2); } .hcaddr .n { font-family: var(--sans); font-size: 12px; font-weight: 400; color: var(--ink-2); }
.hcaddr.bad { background: #fdf6f5; box-shadow: inset 0 0 0 1px #e6b3b0; } .hcaddr.bad { background: #fdf6f5; box-shadow: inset 0 0 0 1px #e6b3b0; }
.hcaddr.bad .v { color: var(--bad-ink); } .hcaddr.bad .v { color: var(--bad-ink); }
.hctiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 12px; margin-top: 12px; } .hclist { border: 1px solid var(--line); border-radius: 10px; min-width: 0; }
.hctile { border: 1px solid var(--line); border-radius: 10px; padding: 12px 14px; display: flex; flex-direction: column; gap: 4px; min-width: 0; } .hcrow { display: grid; grid-template-columns: 8px minmax(0, 190px) minmax(0, 1fr); gap: 2px 14px; align-items: baseline; padding: 11px 16px; border-top: 1px solid var(--line-2); }
.hctile .l { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--ink-2); } .hcrow:first-child { border-top: 0; }
.hctile .l > span:first-child { flex: 1; min-width: 0; } .hcrow > .dot { align-self: center; }
.hctile .s { font-size: 15px; font-weight: 500; } .hcrow .l { font-size: 13px; color: var(--ink-2); }
.hctile .r { font-size: 11.5px; color: var(--ink-3); overflow-wrap: anywhere; } .hcrow .v { display: flex; flex-wrap: wrap; align-items: baseline; gap: 2px 10px; min-width: 0; }
.hctile .p { font-size: 12.5px; color: var(--bad-ink); overflow-wrap: anywhere; } .hcrow .s { font-weight: 500; }
.hctile.bad { border-color: #e6b3b0; background: #fdf6f5; } .hcrow .r { font-size: 12px; color: var(--ink-3); overflow-wrap: anywhere; }
.hctile.bad .s { color: var(--bad-ink); } .hcrow .p { grid-column: 2 / -1; font-size: 12.5px; color: var(--bad-ink); overflow-wrap: anywhere; }
@media (max-width: 640px) { .hctiles { grid-template-columns: repeat(2, minmax(0, 1fr)); } } .hcrow.bad { background: #fdf6f5; }
.hcrow.bad .s { color: var(--bad-ink); }
@media (max-width: 1000px) { .hcbody { grid-template-columns: minmax(0, 1fr); } }
@media (max-width: 640px) { .hcrow { grid-template-columns: 8px minmax(0, 1fr); } .hcrow .v { grid-column: 2; } }
/* updates */
.upvers { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; margin-top: 14px; }
.upbox { background: var(--ground); border-radius: 10px; padding: 12px 14px; display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.upbox > span { font-size: 12px; color: var(--ink-2); }
.upbox strong { font-size: 15px; font-weight: 500; }
.upbox strong.mono { font-size: 16px; }
.upbox.new { background: #e8f0fa; box-shadow: inset 0 0 0 1px #bcd2ee; }
.upbox.new strong { color: #174d8f; }
.uptodate { display: flex; align-items: center; gap: 10px; margin: 14px 0 0; font-weight: 500; }
.upnotes { border: 1px solid var(--line); border-radius: 10px; padding: 14px 16px; margin-top: 14px; display: flex; flex-direction: column; gap: 10px; font-size: 13px; }
.upnotes p { margin: 0; max-width: 80ch; }
.upnotes ul { margin: 0; padding-left: 18px; display: flex; flex-direction: column; gap: 6px; max-width: 80ch; }
.upnotes .hd, .upcmd .hd { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; }
.upnotes .hd a { margin-left: auto; }
.upcmd { display: flex; flex-direction: column; gap: 8px; margin-top: 14px; }
.uprow { display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap; margin-top: 16px; padding-top: 14px; border-top: 1px solid var(--line-2); }
#updates > .notice { margin-top: 14px; }
/* activity */ /* activity */
.ev { display: flex; gap: 12px; padding: 10px 0; border-bottom: 1px solid var(--line-2); font-size: 13px; } .ev { display: flex; gap: 12px; padding: 10px 0; border-bottom: 1px solid var(--line-2); font-size: 13px; }
@@ -226,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; }
@@ -303,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; }
@@ -342,3 +385,36 @@ dialog::backdrop { background: rgba(22, 23, 26, .55); }
.mfarow .grow { flex: 1; min-width: 0; } .mfarow .grow { flex: 1; min-width: 0; }
.dlg .secret { font-size: 15px; letter-spacing: 0.04em; overflow-wrap: anywhere; } .dlg .secret { font-size: 15px; letter-spacing: 0.04em; overflow-wrap: anywhere; }
.dlg .codes { columns: 2; font-size: 15px; line-height: 1.8; } .dlg .codes { columns: 2; font-size: 15px; line-height: 1.8; }
/* settings groups; the log page */
.group { margin-top: 20px; display: flex; flex-direction: column; gap: 2px; }
.group h2 { margin: 0; font-size: 19px; font-weight: 600; }
.group p { margin: 0; font-size: 13px; color: var(--ink-2); }
.card h3 { margin: 0; font-size: 16px; font-weight: 600; }
.saves { font-size: 12px; color: var(--ink-3); }
pre.log.tall { max-height: calc(100vh - 260px); min-height: 420px; }
/* live */
.livenow { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 12px 24px; margin-top: 14px; }
.livenow .k { font-size: 13px; color: var(--ink-2); }
.livenow .v { font-size: 30px; font-weight: 600; letter-spacing: -0.01em; margin-top: 4px; font-variant-numeric: tabular-nums; }
.livenow .v small { font-size: 16px; color: var(--ink-3); font-weight: 500; margin-left: 6px; }
.chart .larea { 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; } }
+582 -97
View File
@@ -47,8 +47,10 @@
peers: '<circle cx="9" cy="8" r="3.5"/><path d="M2.5 20c.8-3.5 3.4-5.5 6.5-5.5s5.7 2 6.5 5.5"/><path d="M16 4.8a3.5 3.5 0 0 1 0 6.4M18.5 14.8c1.5.8 2.6 2.6 3 5.2"/>', peers: '<circle cx="9" cy="8" r="3.5"/><path d="M2.5 20c.8-3.5 3.4-5.5 6.5-5.5s5.7 2 6.5 5.5"/><path d="M16 4.8a3.5 3.5 0 0 1 0 6.4M18.5 14.8c1.5.8 2.6 2.6 3 5.2"/>',
server: '<rect x="3" y="4" width="18" height="7" rx="1.5"/><rect x="3" y="13" width="18" height="7" rx="1.5"/><path d="M7 7.5h.01M7 16.5h.01"/>', server: '<rect x="3" y="4" width="18" height="7" rx="1.5"/><rect x="3" y="13" width="18" height="7" rx="1.5"/><path d="M7 7.5h.01M7 16.5h.01"/>',
settings: '<path d="M4 6h10M18 6h2M4 12h4M12 12h8M4 18h12"/><circle cx="16" cy="6" r="2"/><circle cx="10" cy="12" r="2"/><circle cx="18" cy="18" r="2"/>', settings: '<path d="M4 6h10M18 6h2M4 12h4M12 12h8M4 18h12"/><circle cx="16" cy="6" r="2"/><circle cx="10" cy="12" r="2"/><circle cx="18" cy="18" r="2"/>',
live: '<path d="M3 12h4l3-7 4 14 3-7h4"/>',
plus: '<path d="M12 5v14M5 12h14"/>', plus: '<path d="M12 5v14M5 12h14"/>',
key: '<circle cx="8" cy="15" r="4"/><path d="M11 12l9-9M17 6l3 3M14 9l2 2"/>', key: '<circle cx="8" cy="15" r="4"/><path d="M11 12l9-9M17 6l3 3M14 9l2 2"/>',
log: '<rect x="4" y="3" width="16" height="18" rx="2"/><path d="M8 8h8M8 12h8M8 16h5"/>',
logout: '<path d="M14 4h4a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-4"/><path d="M10 16l-4-4 4-4M6 12h10"/>', logout: '<path d="M14 4h4a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-4"/><path d="M10 16l-4-4 4-4M6 12h10"/>',
}; };
@@ -167,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);
@@ -383,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' });
} }
@@ -429,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
@@ -488,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
@@ -528,9 +564,9 @@
// ---------- shell, router ---------- // ---------- shell, router ----------
const NAV = [['#/', 'dashboard', 'Dashboard'], ['#/peers', 'peers', 'Peers'], ['#/server', 'server', 'Server'], ['#/settings', 'settings', 'Settings']]; const NAV = [['#/', 'dashboard', 'Dashboard'], ['#/live', 'live', 'Live'], ['#/peers', 'peers', 'Peers'], ['#/server', 'server', 'Server'], ['#/log', 'log', 'Log'], ['#/settings', 'settings', 'Settings']];
let navLinks = {}; let navLinks = {};
let srvBox, peerCount; let srvBox, peerCount, verRow;
function buildShell() { function buildShell() {
srvBox = h('div', { class: 'srv' }, h('span', { class: 'dot' }), h('span', null, 'Loading…')); srvBox = h('div', { class: 'srv' }, h('span', { class: 'dot' }), h('span', null, 'Loading…'));
@@ -546,13 +582,25 @@
h('span', { class: 'avatar', 'aria-hidden': 'true' }, me.name.slice(0, 1).toUpperCase()), h('span', { class: 'avatar', 'aria-hidden': 'true' }, me.name.slice(0, 1).toUpperCase()),
h('span', null, h('strong', null, me.name), h('span', null, 'My account')))), h('span', null, h('strong', null, me.name), h('span', null, 'My account')))),
h('button', { type: 'button', class: 'signout', 'aria-label': 'Sign out', onClick: logout }, icon('logout'), h('span', { class: 'tip', 'aria-hidden': 'true' }, 'Sign out'))), h('button', { type: 'button', class: 'signout', 'aria-label': 'Sign out', onClick: logout }, icon('logout'), h('span', { class: 'tip', 'aria-hidden': 'true' }, 'Sign out'))),
h('div', { class: 'footrow' }, (verRow = h('div', { class: 'footrow' }))));
h('span', null, 'v' + me.version.replace(/^v/, '')))));
main = h('main', { class: 'main', id: 'main' }); main = h('main', { class: 'main', id: 'main' });
app.replaceChildren(h('div', { class: 'shell' }, nav, main)); app.replaceChildren(h('div', { class: 'shell' }, nav, main));
drawUpdateHint();
refreshSide(); refreshSide();
} }
// drawUpdateHint shows a newer release next to the version in the sidebar
// and as a dot on Settings.
function drawUpdateHint() {
if (!verRow) return;
const v = me.updateAvailable;
fill(verRow, h('span', null, 'v' + me.version.replace(/^v/, '')),
v ? h('a', { class: 'upd', href: '#/settings#updates' }, v + ' available') : null);
const set = navLinks['#/settings'];
set.querySelectorAll('.pip, .sr').forEach((e) => e.remove());
if (v) set.append(h('span', { class: 'pip', title: 'Update available' }), h('span', { class: 'sr' }, ', update available'));
}
async function refreshSide() { async function refreshSide() {
try { try {
const s = await api('GET', '/status'); const s = await api('GET', '/status');
@@ -578,11 +626,13 @@
const ROUTES = [ const ROUTES = [
[/^#\/?$/, '#/', viewDashboard], [/^#\/?$/, '#/', viewDashboard],
[/^#\/live$/, '#/live', viewLive],
[/^#\/peers$/, '#/peers', viewPeers], [/^#\/peers$/, '#/peers', viewPeers],
[/^#\/peers\/new$/, '#/peers', viewPeerNew], [/^#\/peers\/new$/, '#/peers', viewPeerNew],
[/^#\/peers\/([\w-]+)$/, '#/peers', viewPeer], [/^#\/peers\/([\w-]+)$/, '#/peers', viewPeer],
[/^#\/server$/, '#/server', viewServer], [/^#\/server$/, '#/server', viewServer],
[/^#\/settings$/, '#/settings', viewSettings], [/^#\/log$/, '#/log', viewLog],
[/^#\/settings(#\w+)?$/, '#/settings', viewSettings],
[/^#\/account$/, '#/account', viewAccount], [/^#\/account$/, '#/account', viewAccount],
]; ];
@@ -980,31 +1030,47 @@
// ---------- 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(' · ')),
h('a', { class: 'btn small', href: '#/server' }, 'Health')) : null, h('a', { class: 'btn small', href: '#/server' }, 'Health')) : null,
updateBanner(),
h('div', { class: 'tiles' }, h('div', { class: 'tiles' },
h('div', { class: 'card tile' }, h('div', { class: 'k' }, 'Peers online'), h('div', { class: 'card tile' }, h('div', { class: 'k' }, 'Peers online'),
h('div', { class: 'v' }, String(st.peers.online), h('small', null, '/ ' + st.peers.total)), h('div', { class: 'v' }, String(st.peers.online), h('small', null, '/ ' + st.peers.total)),
@@ -1020,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: '#/settings' }, '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));
@@ -1044,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);
@@ -1124,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 || '–',
@@ -1279,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' },
@@ -1311,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' });
@@ -1333,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);
@@ -1436,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)),
@@ -1487,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 },
@@ -1511,9 +1816,9 @@
const DNS_PRESETS = [['Quad9', '9.9.9.9, 149.112.112.112']]; const DNS_PRESETS = [['Quad9', '9.9.9.9, 149.112.112.112']];
// healthParts turns the server's checks into the Health card: the public // healthParts turns the server's checks into the Health card: the public
// address per IP family (from its uplink and public address checks), then // address per IP family (from its uplink and public address checks) and,
// one tile per other check with a plain-word status, the raw setting and, // beside them, one row per other check with a plain-word status, the raw
// when it fails, what is wrong. // setting and, when it fails, what is wrong.
function healthParts(checks) { function healthParts(checks) {
const by = Object.fromEntries(checks.map((c) => [c.name, c])); const by = Object.fromEntries(checks.map((c) => [c.name, c]));
const addrs = []; const addrs = [];
@@ -1547,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;
} }
@@ -1563,14 +1868,14 @@
return h('section', { class: 'card', 'aria-labelledby': 'hc' }, return h('section', { class: 'card', 'aria-labelledby': 'hc' },
h('div', { class: 'hchead' }, h('h2', { id: 'hc' }, 'Health'), h('div', { class: 'hchead' }, h('h2', { id: 'hc' }, 'Health'),
h('span', { class: hp.failing ? 'bad' : null }, hp.failing ? hp.failing + ' of ' + hp.total + ' checks failing' : 'All ' + hp.total + ' checks pass')), h('span', { class: hp.failing ? 'bad' : null }, hp.failing ? hp.failing + ' of ' + hp.total + ' checks failing' : 'All ' + hp.total + ' checks pass')),
h('div', { class: 'hcbody' },
hp.addrs.length ? h('div', { class: 'hcaddrs' }, hp.addrs.map((a) => h('div', { class: a.ok ? 'hcaddr' : 'hcaddr bad' }, hp.addrs.length ? h('div', { class: 'hcaddrs' }, hp.addrs.map((a) => h('div', { class: a.ok ? 'hcaddr' : 'hcaddr bad' },
h('div', { class: 'l' }, dot(a.ok), a.label), h('div', { class: 'l' }, dot(a.ok), a.label),
h('div', { class: a.mono ? 'v mono' : 'v' }, a.value, a.note ? h('span', { class: 'n' }, ' ' + a.note) : null)))) : null, h('div', { class: a.mono ? 'v mono' : 'v' }, a.value, a.note ? h('span', { class: 'n' }, ' ' + a.note) : null)))) : null,
h('div', { class: 'hctiles' }, hp.tiles.map((t) => h('div', { class: t.ok ? 'hctile' : 'hctile bad', title: t.title || null }, h('div', { class: 'hclist' }, hp.tiles.map((t) => h('div', { class: t.ok ? 'hcrow' : 'hcrow bad', title: t.title || null },
h('div', { class: 'l' }, h('span', null, t.label), dot(t.ok)), dot(t.ok), h('span', { class: 'l' }, t.label),
h('div', { class: 's' }, t.status), h('span', { class: 'v' }, h('span', { class: 's' }, t.status), t.raw ? h('span', { class: 'r mono' }, t.raw) : null),
t.raw ? h('div', { class: 'r mono' }, t.raw) : null, t.problem ? h('div', { class: 'p' }, t.problem) : null)))));
t.problem ? h('div', { class: 'p' }, t.problem) : null))));
} }
async function viewServer(wrap) { async function viewServer(wrap) {
@@ -1680,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' },
@@ -1693,6 +1998,155 @@
bar); bar);
} }
// ---------- log ----------
async function viewLog(wrap) {
const s = await api('GET', '/settings');
let level = 'all';
const box = h('pre', { class: 'log tall', tabindex: '0', 'aria-label': 'Log lines, newest first' });
const pills = h('div', { class: 'pills', role: 'group', 'aria-label': 'Level filter' });
let audit = false;
const draw = async () => {
pills.replaceChildren(...[['all', 'All'], ['info', 'Info'], ['warn', 'Warn'], ['error', 'Error']].map(([k, t]) =>
h('button', { type: 'button', class: level === k && !audit ? 'pill on' : 'pill', 'aria-pressed': String(level === k && !audit), onClick: () => { level = k; audit = false; draw(); } }, t)),
h('button', { type: 'button', class: audit ? 'pill on' : 'pill', 'aria-pressed': String(audit), onClick: () => { audit = true; draw(); } }, 'Changes only'));
try {
const r = await api('GET', '/logs?limit=500&level=' + level + (audit ? '&audit=1' : ''));
box.textContent = r.lines.length ? r.lines.map(fmtLogLine).join('\n') : 'No entries at this level.';
} catch (e) { box.textContent = e.message; }
};
fill(wrap,
h('div', { class: 'head' },
h('div', null, h('h1', null, 'Log'), h('p', { class: 'sub' }, h('span', { class: 'mono' }, s.logPath), ' · level ' + s.log.level + ' · rotates at ' + s.log.maxSizeMB + ' MB, keeps ' + s.log.maxFiles + ' files')),
h('div', { class: 'actions' },
h('a', { class: 'btn', href: '#/settings#logs' }, 'Log settings'),
h('a', { class: 'btn', href: '/api/v1/logs/download' }, 'Download log'))),
h('section', { class: 'card', 'aria-label': 'Log lines' },
h('div', { class: 'cardhead' }, h('span', { class: 'muted' }, 'Newest first · refreshes every 10 s'), pills),
h('div', { class: 'section' }, box)));
every(10000, draw);
await draw();
}
// ---------- updates ----------
const HIDE_UPDATE = 'GHOSTWIRE.hideUpdate';
// updateBanner tells the Dashboard about a newer release until it is
// hidden for that version.
function updateBanner() {
const v = me.updateAvailable;
let hidden = null;
try { hidden = localStorage.getItem(HIDE_UPDATE); } catch { /* storage blocked */ }
if (!v || hidden === v) return null;
const box = h('div', { class: 'notice new' },
h('div', null, h('strong', null, 'GHOSTWIRE ' + v + ' is available. '), 'You\'re on v' + me.version.replace(/^v/, '') + '.'),
h('div', { class: 'actions' },
h('a', { class: 'btn small', href: '#/settings#updates' }, 'How to update'),
h('button', { type: 'button', class: 'btn small ghost', onClick: () => {
try { localStorage.setItem(HIDE_UPDATE, v); } catch { /* storage blocked */ }
box.remove();
} }, 'Hide until the next version')));
return box;
}
// mdInline turns **bold** and `code` into elements; everything else stays
// text.
const mdInline = (text) => text.split(/(\*\*[^*]+\*\*|`[^`]+`)/).filter(Boolean).map((t) =>
t.startsWith('**') ? h('strong', null, t.slice(2, -2)) : t.startsWith('`') ? h('code', null, t.slice(1, -1)) : t);
// releaseSummary picks the opening paragraph and the first bullet list out
// of the release notes; the full notes are a link away.
function releaseSummary(md) {
const lines = md.replace(/\r/g, '').split('\n');
const para = [];
for (const l of lines) {
if (!l.trim()) { if (para.length) break; continue; }
if (/^(#|- |\* |```|\|)/.test(l)) break;
para.push(l.trim());
}
const items = [];
let started = false;
for (const l of lines) {
const m = /^[-*] (.+)$/.exec(l);
if (m) { started = true; items.push(m[1]); } else if (started && l.trim()) break;
}
return { summary: para.join(' '), items };
}
function updatesCard(initial) {
let st = initial;
const card = h('section', { class: 'card', id: 'updates', 'aria-labelledby': 'upd' });
const setStatus = (next) => {
st = next;
me.updateAvailable = st.enabled && st.available ? st.latest.version : undefined;
drawUpdateHint();
draw();
};
const checkNow = async (btn) => {
if (btn) { btn.disabled = true; btn.textContent = 'Checking…'; }
try { setStatus(await api('POST', '/updates/check')); } catch (x) { toast(x.message, true); draw(); }
};
const save = async (updates) => {
try {
await api('PATCH', '/settings', { updates });
const s = await api('GET', '/settings');
if (s.updates.enabled) await checkNow(); else setStatus(s.updates);
} catch (x) { toast(x.message, true); draw(); }
};
const SOURCES = [['gitea', 'Gitea', 'git.redetzke.aero/Redetzke/GHOSTWIRE'], ['github', 'GitHub', 'github.com/danielredetzke/GHOSTWIRE']];
const srcName = () => SOURCES.find(([k]) => k === st.source)[1];
function draw() {
const cur = 'v' + st.current.replace(/^v/, '');
const rel = st.latest;
const notes = rel && st.available ? releaseSummary(rel.notes || '') : null;
const cmds = st.available && st.file ? [
'curl -fLO ' + st.fileUrl,
'curl -fLO ' + st.sumsUrl,
'sha256sum -c --ignore-missing SHA256SUMS',
'chmod +x ' + st.file,
'sudo ./' + st.file + ' update',
].join('\n') : null;
fill(card,
h('div', { class: 'cardhead' },
h('h3', { id: 'upd' }, 'Updates'),
st.enabled ? h('span', { class: 'muted' }, st.checked ? 'Last checked ' + ago(st.checked) : 'Not checked yet') : null),
h('div', { class: 'upvers' },
h('div', { class: 'upbox' }, h('span', null, 'Running'), h('strong', { class: 'mono' }, cur)),
rel ? h('div', { class: st.available ? 'upbox new' : 'upbox' }, h('span', null, 'Latest release'), h('strong', { class: 'mono' }, rel.version)) : null,
h('div', { class: 'upbox' }, h('span', null, 'This server'), h('strong', null, st.arch ? 'Linux · ' + st.arch : 'No release file for this platform'))),
st.enabled && st.error ? h('div', { class: 'notice err', role: 'alert' },
h('div', null, 'The last check failed: ' + st.error + '. ' + (st.lastOk ? 'Last worked ' + ago(st.lastOk) + '. ' : '') + 'Try the other source.')) : null,
rel && !st.available ? h('p', { class: 'uptodate' }, h('span', { class: 'dot ok' }), 'GHOSTWIRE is up to date.') : null,
notes ? h('div', { class: 'upnotes' },
h('div', { class: 'hd' }, h('strong', null, 'What\'s new in ' + rel.version),
h('span', { class: 'muted' }, 'Released ' + fmtDate(rel.published) + ' · from ' + srcName()),
ext(rel.url, 'Full notes on ' + srcName())),
/security/i.test(notes.summary) ? h('p', { class: 'notice' }, 'Includes security fixes.') : null,
notes.summary ? h('p', null, mdInline(notes.summary)) : null,
notes.items.length ? h('ul', null, notes.items.map((t) => h('li', null, mdInline(t)))) : null) : null,
cmds ? h('div', { class: 'upcmd' },
h('div', { class: 'hd' }, h('strong', null, 'Update this server'), h('span', { class: 'muted' }, 'Run on the server. VPN connections stay up.')),
h('pre', { class: 'code' }, cmds),
h('div', null, h('button', { type: 'button', class: 'btn small', onClick: () => copy(cmds) }, 'Copy commands'))) : null,
st.available && !st.file ? h('p', null, 'No release file is built for this platform. ', ext(rel.url, 'See the release')) : null,
h('fieldset', { class: 'section' }, h('legend', { class: 'legend' }, 'Release source'),
h('div', { class: 'grid' }, SOURCES.map(([k, name, where]) => h('label', { class: 'opt' },
h('input', { type: 'radio', name: 'upsrc', value: k, checked: st.source === k, onChange: () => save({ source: k }) }),
h('span', null, h('strong', null, name), h('br'), h('span', { class: 'hint mono' }, where))))),
h('span', { class: 'hint' }, 'Both carry the same releases and files. The check, the release notes and the download links use the source you pick.')),
h('div', { class: 'uprow' },
h('label', { class: 'check' },
h('input', { type: 'checkbox', checked: st.enabled, onChange: (e) => save({ check: e.target.checked }) }),
h('span', null, 'Check for updates once a day', h('br'),
h('span', { class: 'hint' }, 'Asks ' + new URL(st.sourceUrl).host + ' for the latest release. Nothing about this server is sent.'))),
st.enabled ? h('button', { type: 'button', class: 'btn small', onClick: (e) => checkNow(e.currentTarget) }, 'Check now') : null));
}
draw();
return card;
}
// ---------- settings ---------- // ---------- settings ----------
// ---------- my account ---------- // ---------- my account ----------
@@ -1800,7 +2254,6 @@
return; return;
} }
const [s, tk, us] = await Promise.all([api('GET', '/settings'), api('GET', '/tokens'), api('GET', '/users')]); const [s, tk, us] = await Promise.all([api('GET', '/settings'), api('GET', '/tokens'), api('GET', '/users')]);
let logLevelFilter = 'all';
// users // users
const userBody = h('tbody'); const userBody = h('tbody');
@@ -1965,20 +2418,13 @@
try { await api('DELETE', '/tokens/' + t.id); toast('Revoked ' + t.name); reloadTokens(); } catch (e) { toast(e.message, true); } try { await api('DELETE', '/tokens/' + t.id); toast('Revoked ' + t.name); reloadTokens(); } catch (e) { toast(e.message, true); }
}; };
// logs const levelSel = h('select', { id: 'lv' }, [['debug', 'Debug: everything'], ['info', 'Info'], ['warn', 'Warnings and errors'], ['error', 'Errors only']].map(([l, t]) => h('option', { value: l, selected: s.log.level === l }, t)));
const logBox = h('pre', { class: 'log', tabindex: '0', 'aria-label': 'Log lines, newest first' }); const sessSel = h('select', { id: 'st', onChange: async (e) => {
const logPills = h('div', { class: 'pills', role: 'group', 'aria-label': 'Level filter' }); try { await api('PATCH', '/settings', { web: { ...s.web, sessionHours: Number(e.target.value) } }); s.web.sessionHours = Number(e.target.value); toast('Session length saved'); } catch (x) { toast(x.message, true); }
const drawLogs = async () => { } }, [[1, '1 hour'], [12, '12 hours'], [24, '1 day'], [168, '7 days']].map(([v, t]) => h('option', { value: String(v), selected: s.web.sessionHours === v }, t)));
logPills.replaceChildren(...[['all', 'All'], ['info', 'Info'], ['warn', 'Warn'], ['error', 'Error']].map(([k, t]) => const geoBox = h('input', { type: 'checkbox', id: 'geo', checked: s.stats.geoip !== false, onChange: async (e) => {
h('button', { type: 'button', class: logLevelFilter === k ? 'pill on' : 'pill', 'aria-pressed': String(logLevelFilter === k), onClick: () => { logLevelFilter = k; drawLogs(); } }, t))); try { await api('PATCH', '/settings', { stats: { ...s.stats, geoip: e.target.checked } }); toast(e.target.checked ? 'Country lookup on' : 'Country lookup off'); } catch (x) { e.target.checked = !e.target.checked; toast(x.message, true); }
try { } });
const r = await api('GET', '/logs?limit=200&level=' + logLevelFilter);
logBox.textContent = r.lines.length ? r.lines.map(fmtLogLine).join('\n') : 'No entries at this level.';
} catch (e) { logBox.textContent = e.message; }
};
const levelSel = h('select', { id: 'lv', onChange: async (e) => {
try { await api('PATCH', '/settings', { log: { ...s.log, level: e.target.value } }); s.log.level = e.target.value; toast('Log level: ' + e.target.value); } catch (x) { toast(x.message, true); }
} }, ['debug', 'info', 'warn', 'error'].map((l) => h('option', { value: l, selected: s.log.level === l }, l)));
// sign-in rules // sign-in rules
const requireBox = h('input', { type: 'checkbox', id: 'rq', checked: s.signin.requireMfa, onChange: async (e) => { const requireBox = h('input', { type: 'checkbox', id: 'rq', checked: s.signin.requireMfa, onChange: async (e) => {
@@ -2028,7 +2474,6 @@
const logFiles = h('input', { id: 'rf', type: 'number', min: '1', max: '100', value: s.log.maxFiles, inputMode: 'numeric' }); const logFiles = h('input', { id: 'rf', type: 'number', min: '1', max: '100', value: s.log.maxFiles, inputMode: 'numeric' });
const hourly = presetSelect('rh', s.stats.hourlyHours, [[24, '1 day'], [48, '2 days'], [168, '7 days'], [336, '14 days'], [744, '31 days']], 'hours'); const hourly = presetSelect('rh', s.stats.hourlyHours, [[24, '1 day'], [48, '2 days'], [168, '7 days'], [336, '14 days'], [744, '31 days']], 'hours');
const daily = presetSelect('rd', s.stats.dailyDays, [[30, '30 days'], [90, '90 days'], [180, '6 months'], [400, '13 months'], [730, '2 years'], [1825, '5 years'], [3660, '10 years']], 'days'); const daily = presetSelect('rd', s.stats.dailyDays, [[30, '30 days'], [90, '90 days'], [180, '6 months'], [400, '13 months'], [730, '2 years'], [1825, '5 years'], [3660, '10 years']], 'days');
const geo = h('input', { type: 'checkbox', checked: s.stats.geoip !== false });
const geoStatus = s.geo && s.geo.updated ? 'Database from ' + fmtDate(s.geo.updated) + '.' : 'Not downloaded yet.'; const geoStatus = s.geo && s.geo.updated ? 'Database from ' + fmtDate(s.geo.updated) + '.' : 'Not downloaded yet.';
const diskHint = h('span', { class: 'hint' }); const diskHint = h('span', { class: 'hint' });
const drawDiskHint = () => { const drawDiskHint = () => {
@@ -2047,14 +2492,45 @@
if (shrinks && !await confirmDialog({ title: 'Delete older data?', text: 'The new limits are lower: older log files and traffic history beyond them are deleted. This cannot be undone.', ok: 'Save and delete', danger: true })) return; if (shrinks && !await confirmDialog({ title: 'Delete older data?', text: 'The new limits are lower: older log files and traffic history beyond them are deleted. This cannot be undone.', ok: 'Save and delete', danger: true })) return;
try { try {
await api('PATCH', '/settings', { await api('PATCH', '/settings', {
log: { ...s.log, maxSizeMB: next.maxSizeMB, maxFiles: next.maxFiles }, log: { ...s.log, level: levelSel.value, maxSizeMB: next.maxSizeMB, maxFiles: next.maxFiles },
stats: { hourlyHours: next.hourlyHours, dailyDays: next.dailyDays, geoip: geo.checked }, stats: { ...s.stats, hourlyHours: next.hourlyHours, dailyDays: next.dailyDays },
}); });
toast('Retention saved'); toast('Logs & history saved');
render(); render();
} catch (x) { retErr.textContent = x.message; } } catch (x) { retErr.textContent = x.message; }
}; };
// copies of config.json that updates leave behind
const copies = h('div', { class: 'section' });
const drawCopies = async () => {
let list;
try { list = (await api('GET', '/update-backups')).backups; } catch (x) { fill(copies, h('p', { class: 'err-text' }, x.message)); return; }
const remove = async (b) => {
if (!await confirmDialog({ title: 'Remove ' + b.name + '?', text: 'This copy of your settings from ' + b.version + ' is deleted from the server. It cannot be restored.', ok: 'Remove', danger: true })) return;
try { await api('DELETE', '/update-backups/' + encodeURIComponent(b.name)); toast('Removed ' + b.name); drawCopies(); } catch (x) { toast(x.message, true); }
};
const removeAll = async () => {
const n = list.length;
if (!await confirmDialog({ title: n === 1 ? 'Remove the copy?' : 'Remove all ' + n + ' copies?', text: 'They are deleted from the server and cannot be restored. Your current settings are not affected.', ok: 'Remove all', danger: true })) return;
try { await api('DELETE', '/update-backups'); toast(n === 1 ? 'Removed 1 copy' : 'Removed ' + n + ' copies'); drawCopies(); } catch (x) { toast(x.message, true); }
};
const total = list.reduce((t, b) => t + b.size, 0);
fill(copies,
h('div', { class: 'cardhead' },
h('div', null, h('strong', null, 'Copies made by updates'),
h('p', { class: 'lead', style: { marginBottom: '0' } }, 'Each update saves the previous config.json next to it. They hold the same secrets as a backup.')),
list.length ? h('button', { type: 'button', class: 'btn', onClick: removeAll }, 'Remove all') : null),
list.length ? h('div', { class: 'tbl section' }, h('table', { class: 'narrow' },
h('thead', null, h('tr', null, h('th', null, 'File'), h('th', null, 'From version'), h('th', null, 'Saved'), h('th', { class: 'num' }, 'Size'), h('th', null, h('span', { class: 'sr' }, 'Actions')))),
h('tbody', null, list.map((b, i) => h('tr', null,
h('td', null, h('span', { class: 'mono' }, b.name), i === 0 ? h('span', { class: 'tag plain' }, 'Newest') : null),
h('td', { class: 'mono' }, b.version),
h('td', null, fmtStamp(b.modified)),
h('td', { class: 'num' }, fmtBytes(b.size)),
h('td', { class: 'num' }, h('button', { type: 'button', class: 'btn small', onClick: () => remove(b) }, 'Remove'))))))) : h('p', { class: 'muted', style: { margin: '12px 0 0' } }, 'No copies from updates.'),
list.length ? h('p', { class: 'hint', style: { margin: '8px 0 0' } }, list.length + (list.length === 1 ? ' copy, ' : ' copies, ') + fmtBytes(total) + ' next to config.json. After each update, only the newest 3 are kept.') : null);
};
// backup // backup
const restoreInput = h('input', { type: 'file', accept: 'application/json,.json', hidden: true, onChange: async (e) => { const restoreInput = h('input', { type: 'file', accept: 'application/json,.json', hidden: true, onChange: async (e) => {
const f = e.target.files[0]; const f = e.target.files[0];
@@ -2070,85 +2546,94 @@
} catch (x) { toast(x.message, true); } } catch (x) { toast(x.message, true); }
} }); } });
const groupHead = (id, title, text) => h('div', { class: 'group', id }, h('h2', null, title), h('p', null, text));
fill(wrap, fill(wrap,
h('div', null, h('h1', null, 'Settings'), h('p', { class: 'sub' }, 'Users, web interface, API access for the iOS app, logs, data retention and backups')), h('div', null, h('h1', null, 'Settings'), h('p', { class: 'sub' }, 'Who can sign in, the web interface, logs and history, updates and backups')),
restartBox, restartBox,
groupHead('g-access', 'Access', 'Who can sign in, and how.'),
h('section', { class: 'card flush', 'aria-labelledby': 'usr' }, h('section', { class: 'card flush', 'aria-labelledby': 'usr' },
h('div', { class: 'cardhead' }, h('div', { class: 'cardhead' },
h('div', null, h('h2', { id: 'usr' }, 'Users'), h('p', { class: 'lead', style: { marginBottom: '0' } }, 'Everyone here is an admin. You cannot delete yourself, so one user always remains.')), h('div', null, h('h3', { id: 'usr' }, 'Users'), h('p', { class: 'lead', style: { marginBottom: '0' } }, 'Everyone here is an admin. You cannot delete yourself, so one user always remains.')),
h('button', { type: 'button', class: 'btn primary', onClick: addUser }, 'Add user')), h('button', { type: 'button', class: 'btn primary', onClick: addUser }, 'Add user')),
h('div', { class: 'tbl' }, h('table', null, h('div', { class: 'tbl' }, h('table', null,
h('thead', null, h('tr', null, h('th', null, 'User'), h('th', null, 'Status'), h('th', null, 'Two-step'), h('th', null, 'Last sign-in'), h('th', null, 'App tokens'), h('th', null, 'Created'), h('th', null, h('span', { class: 'sr' }, 'Actions')))), h('thead', null, h('tr', null, h('th', null, 'User'), h('th', null, 'Status'), h('th', null, 'Two-step'), h('th', null, 'Last sign-in'), h('th', null, 'App tokens'), h('th', null, 'Created'), h('th', null, h('span', { class: 'sr' }, 'Actions')))),
userBody))), userBody))),
h('section', { class: 'card', 'aria-labelledby': 'sgn' }, h('section', { class: 'card', 'aria-labelledby': 'sgn' },
h('h2', { id: 'sgn' }, 'Sign-in'), h('div', { class: 'cardhead' }, h('h3', { id: 'sgn' }, 'Sign-in'), h('span', { class: 'saves' }, 'Saves right away')),
h('p', { class: 'lead' }, 'Everyone sets up two-step sign-in under My account: an authenticator app or passkeys, including on a YubiKey. Changes apply immediately.'), h('p', { class: 'lead' }, 'Everyone sets up two-step sign-in under My account: an authenticator app or passkeys, including on a YubiKey.'),
h('label', { class: 'check' }, requireBox, h('span', null, 'Require two-step sign-in for everyone', h('br'), h('label', { class: 'check' }, requireBox, h('span', null, 'Require two-step sign-in for everyone', h('br'),
h('span', { class: 'hint' }, 'Users without it are asked to set it up right after their password. To help someone who lost their phone or key, use Edit → Reset two-step sign-in.')))), h('span', { class: 'hint' }, 'Users without it are asked to set it up right after their password. To help someone who lost their phone or key, use Edit → Reset two-step sign-in.'))),
h('div', { class: 'grid section' },
h('div', { class: 'field' }, h('label', { htmlFor: 'st' }, 'Stay signed in for'), sessSel, h('span', { class: 'hint' }, 'Applies to new sign-ins')),
h('div', { class: 'field' }), h('div', { class: 'field' }))),
h('section', { class: 'card', 'aria-labelledby': 'api' },
h('div', { class: 'cardhead' },
h('div', null, h('h3', { id: 'api' }, 'iOS app and API tokens'), h('p', { class: 'lead', style: { marginBottom: '0' } }, 'For the iOS app and scripts. A token appears once when you create it, and only a hash is stored.')),
h('button', { type: 'button', class: 'btn primary', onClick: () => pairDialog(reloadTokens) }, 'Pair iOS app')),
h('div', { class: 'tbl section' }, h('table', { class: 'narrow' },
h('thead', null, h('tr', null, h('th', null, 'Name'), h('th', null, 'Owner'), h('th', null, 'Access'), h('th', null, 'Created'), h('th', null, 'Last used'), h('th', null, h('span', { class: 'sr' }, 'Actions')))),
tbody))),
groupHead('g-web', 'Web interface', 'Where this interface listens and what strangers see.'),
h('form', { class: 'card', onSubmit: saveWeb, 'aria-labelledby': 'web' }, h('form', { class: 'card', onSubmit: saveWeb, 'aria-labelledby': 'web' },
h('h2', { id: 'web' }, 'Web interface'), h('div', { class: 'cardhead' }, h('h3', { id: 'web' }, 'Address and HTTPS'), h('span', { class: 'saves' }, 'Save, then restart')),
h('p', { class: 'lead' }, 'Changes take effect after the service restarts.'), h('p', { class: 'lead' }, 'Usually set once during install.'),
h('div', { class: 'grid' }, h('div', { class: 'grid' },
h('div', { class: 'field' }, h('label', { htmlFor: 'la' }, 'Listen address'), h('input', { id: 'la', class: 'mono', value: web.listen, onInput: (e) => { web.listen = e.target.value.trim(); } })), h('div', { class: 'field' }, h('label', { htmlFor: 'la' }, 'Listen address'), h('input', { id: 'la', class: 'mono', value: web.listen, onInput: (e) => { web.listen = e.target.value.trim(); } })),
h('div', { class: 'field' }, h('label', { htmlFor: 'hl' }, 'HTTP listen address'), h('input', { id: 'hl', class: 'mono', value: web.httpListen, placeholder: 'off', onInput: (e) => { web.httpListen = e.target.value.trim(); } }), h('span', { class: 'hint' }, 'Redirects to HTTPS and answers Let\'s Encrypt http-01 checks. Empty turns it off')), h('div', { class: 'field' }, h('label', { htmlFor: 'hl' }, 'HTTP listen address'), h('input', { id: 'hl', class: 'mono', value: web.httpListen, placeholder: 'off', onInput: (e) => { web.httpListen = e.target.value.trim(); } }), h('span', { class: 'hint' }, 'Redirects to HTTPS and answers Let\'s Encrypt http-01 checks. Empty turns it off')),
h('div', { class: 'field' }, h('label', { htmlFor: 'tls' }, 'HTTPS'), modeSel), h('div', { class: 'field' }, h('label', { htmlFor: 'tls' }, 'HTTPS'), modeSel),
h('div', { class: 'field' }, h('label', { htmlFor: 'st' }, 'Session length'), h('select', { id: 'st', onChange: (e) => { web.sessionHours = Number(e.target.value); } },
[[1, '1 hour'], [12, '12 hours'], [24, '1 day'], [168, '7 days']].map(([v, t]) => h('option', { value: String(v), selected: web.sessionHours === v }, t)))),
s.fingerprint ? h('div', { class: 'field' }, h('label', { htmlFor: 'fp' }, 'Certificate fingerprint (SHA-256)'), h('input', { id: 'fp', class: 'mono', value: s.fingerprint, readOnly: true }), h('span', { class: 'hint' }, 'The iOS app pins this when pairing')) : null), s.fingerprint ? h('div', { class: 'field' }, h('label', { htmlFor: 'fp' }, 'Certificate fingerprint (SHA-256)'), h('input', { id: 'fp', class: 'mono', value: s.fingerprint, readOnly: true }), h('span', { class: 'hint' }, 'The iOS app pins this when pairing')) : null),
h('div', { class: 'section' }, fAcme, fFiles), h('div', { class: 'section' }, fAcme, fFiles),
webErr, webErr,
h('div', { class: 'formfoot' }, h('button', { type: 'submit', class: 'btn primary' }, 'Save'))), h('div', { class: 'formfoot' }, h('button', { type: 'submit', class: 'btn primary' }, 'Save'))),
h('section', { class: 'card', 'aria-labelledby': 'dcy' }, h('section', { class: 'card', 'aria-labelledby': 'dcy' },
h('h2', { id: 'dcy' }, 'Decoy'), h('div', { class: 'cardhead' }, h('h3', { id: 'dcy' }, 'Decoy'), h('span', { class: 'saves' }, 'Saves right away')),
h('p', { class: 'lead' }, 'Shows an ordinary web server page instead of this interface. The iOS app and setup links keep working. Changes apply immediately.'), h('p', { class: 'lead' }, 'Shows an ordinary web server page instead of this interface. The iOS app and setup links keep working.'),
h('label', { class: 'check' }, decoyBox, h('span', null, 'Decoy', h('br'), h('label', { class: 'check' }, decoyBox, h('span', null, 'Decoy', h('br'),
h('span', { class: 'hint' }, 'Hides the web interface. Turn it off again in the iOS app.'))), h('span', { class: 'hint' }, 'Hides the web interface. Turn it off again in the iOS app.'))),
h('div', { class: 'grid section' }, h('div', { class: 'grid section' },
h('div', { class: 'field' }, h('label', { htmlFor: 'dp' }, 'Decoy page'), decoySel))), h('div', { class: 'field' }, h('label', { htmlFor: 'dp' }, 'Decoy page'), decoySel))),
h('section', { class: 'card', 'aria-labelledby': 'api' }, groupHead('logs', 'Logs & history', 'What the server records and for how long. The log itself is on the Log page.'),
h('div', { class: 'cardhead' },
h('div', null, h('h2', { id: 'api' }, 'API tokens'), h('p', { class: 'lead', style: { marginBottom: '0' } }, 'For the iOS app and scripts. A token appears once when you create it, and only a hash is stored.')),
h('button', { type: 'button', class: 'btn primary', onClick: () => pairDialog(reloadTokens) }, 'Pair iOS app')),
h('div', { class: 'tbl section' }, h('table', { class: 'narrow' },
h('thead', null, h('tr', null, h('th', null, 'Name'), h('th', null, 'Owner'), h('th', null, 'Access'), h('th', null, 'Created'), h('th', null, 'Last used'), h('th', null, h('span', { class: 'sr' }, 'Actions')))),
tbody))),
h('section', { class: 'card', 'aria-labelledby': 'lg' },
h('div', { class: 'cardhead' },
h('div', null, h('h2', { id: 'lg' }, 'Log'), h('p', { class: 'lead', style: { marginBottom: '0' } }, h('span', { class: 'mono' }, s.logPath), ' · rotates at ' + s.log.maxSizeMB + ' MB, keeps ' + s.log.maxFiles + ' files')),
logPills),
h('div', { class: 'section' }, logBox),
h('div', { class: 'grid section' },
h('div', { class: 'field' }, h('label', { htmlFor: 'lv' }, 'Log level'), levelSel),
h('div', { class: 'field', style: { justifyContent: 'flex-end' } }, h('a', { class: 'btn', href: '/api/v1/logs/download' }, 'Download log')))),
h('form', { class: 'card', onSubmit: saveRetention, 'aria-labelledby': 'ret' }, h('form', { class: 'card', onSubmit: saveRetention, 'aria-labelledby': 'ret' },
h('h2', { id: 'ret' }, 'Data retention'), h('div', { class: 'cardhead' }, h('h3', { id: 'ret' }, 'Log and traffic history'), h('span', { class: 'saves' }, 'Save, no restart')),
h('p', { class: 'lead' }, 'How much log and traffic history is kept. Changes apply immediately, without a restart.'), h('p', { class: 'lead' }, 'Lower limits delete older data when you save. All-time totals are always kept.'),
h('div', { class: 'grid' }, h('div', { class: 'grid' },
h('div', { class: 'field' }, h('label', { htmlFor: 'lv' }, 'Log level'), levelSel),
h('div', { class: 'field' }, h('label', { htmlFor: 'rs' }, 'Log file size (MB)'), logSize, h('span', { class: 'hint' }, 'The log starts a new file at this size. 1–1000')), h('div', { class: 'field' }, h('label', { htmlFor: 'rs' }, 'Log file size (MB)'), logSize, h('span', { class: 'hint' }, 'The log starts a new file at this size. 1–1000')),
h('div', { class: 'field' }, h('label', { htmlFor: 'rf' }, 'Old log files kept'), logFiles, diskHint), h('div', { class: 'field' }, h('label', { htmlFor: 'rf' }, 'Old log files kept'), logFiles, diskHint)),
h('div', { class: 'grid section' },
h('div', { class: 'field' }, h('label', { htmlFor: 'rh' }, 'Hourly traffic history'), hourly, h('span', { class: 'hint' }, 'Used by the 24-hour charts')), h('div', { class: 'field' }, h('label', { htmlFor: 'rh' }, 'Hourly traffic history'), hourly, h('span', { class: 'hint' }, 'Used by the 24-hour charts')),
h('div', { class: 'field' }, h('label', { htmlFor: 'rd' }, 'Daily traffic history'), daily, h('span', { class: 'hint' }, 'Used by the 7- and 30-day charts and the connection history. All-time totals are always kept'))), h('div', { class: 'field' }, h('label', { htmlFor: 'rd' }, 'Daily traffic history'), daily, h('span', { class: 'hint' }, 'Used by the 7- and 30-day charts and the connection history')),
h('label', { class: 'check section' }, geo, h('span', null, 'Show country and network of peer addresses', h('br'), h('div', { class: 'field' })),
h('span', { class: 'hint' }, 'Downloads the free DB-IP Lite databases (about 20 MB) once a month and looks addresses up on this server only. ' + geoStatus))),
retErr, retErr,
h('div', { class: 'formfoot' }, h('button', { type: 'submit', class: 'btn primary' }, 'Save retention'))), h('div', { class: 'formfoot' }, h('a', { class: 'btn', href: '#/log' }, 'Open the log'), h('button', { type: 'submit', class: 'btn primary' }, 'Save'))),
h('section', { class: 'card', 'aria-labelledby': 'geo-h' },
h('div', { class: 'cardhead' }, h('h3', { id: 'geo-h' }, 'Country and network lookup'), h('span', { class: 'saves' }, 'Saves right away')),
h('label', { class: 'check' }, geoBox, h('span', null, 'Show country and network of peer addresses', h('br'),
h('span', { class: 'hint' }, 'Downloads the free DB-IP Lite databases (about 20 MB) once a month and looks addresses up on this server only. ' + geoStatus)))),
groupHead('g-upkeep', 'Upkeep', 'New versions and copies of your settings.'),
updatesCard(s.updates),
h('section', { class: 'card', 'aria-labelledby': 'bk' }, h('section', { class: 'card', 'aria-labelledby': 'bk' },
h('h2', { id: 'bk' }, 'Backup & restore'), h('h3', { id: 'bk' }, 'Backup & restore'),
h('p', { class: 'lead' }, 'A backup is a copy of config.json with server key, peers, tokens and settings. Keep it safe: it contains the server\'s private key.'), h('p', { class: 'lead' }, 'A backup is a copy of config.json with server key, peers, tokens and settings. Keep it safe: it contains the server\'s private key, preshared keys and authenticator app secrets.'),
h('div', { class: 'actions' }, h('div', { class: 'actions' },
h('a', { class: 'btn', href: '/api/v1/backup' }, 'Download backup'), h('a', { class: 'btn', href: '/api/v1/backup' }, 'Download backup'),
h('button', { type: 'button', class: 'btn', onClick: () => restoreInput.click() }, 'Restore from file…'), h('button', { type: 'button', class: 'btn', onClick: () => restoreInput.click() }, 'Restore from file…'),
restoreInput))); restoreInput),
await drawLogs(); copies));
drawCopies();
const jumpTo = location.hash.split('#')[2];
if (jumpTo) document.getElementById(jumpTo)?.scrollIntoView();
} }
render(); render();
})(); })();
+7 -2
View File
@@ -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
View File
@@ -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>"
+88 -18
View File
@@ -11,6 +11,7 @@ import (
"path/filepath" "path/filepath"
"regexp" "regexp"
"slices" "slices"
"strconv"
"strings" "strings"
"sync" "sync"
"syscall" "syscall"
@@ -27,15 +28,24 @@ type Config struct {
APITokens []APIToken `json:"apiTokens"` APITokens []APIToken `json:"apiTokens"`
// Admin is the single account of config version 1; applyDefaults moves // Admin is the single account of config version 1; applyDefaults moves
// it into Users. // it into Users.
Admin *Admin `json:"admin,omitempty"` Admin *Admin `json:"admin,omitempty"`
Server Server `json:"server"` Server Server `json:"server"`
Peers []Peer `json:"peers"` Peers []Peer `json:"peers"`
Log LogConfig `json:"log"` Log LogConfig `json:"log"`
Stats StatsConfig `json:"stats"` Stats StatsConfig `json:"stats"`
Decoy DecoyConfig `json:"decoy"` Decoy DecoyConfig `json:"decoy"`
SignIn SignInConfig `json:"signin"` SignIn SignInConfig `json:"signin"`
Updates UpdatesConfig `json:"updates"`
} }
// UpdatesConfig sets the daily check for a newer release.
type UpdatesConfig struct {
Check *bool `json:"check,omitempty"` // default on
Source string `json:"source"` // gitea | github, see updateSources
}
func (c UpdatesConfig) checkEnabled() bool { return c.Check == nil || *c.Check }
// SignInConfig holds the rules for signing in to the web interface. // SignInConfig holds the rules for signing in to the web interface.
type SignInConfig struct { type SignInConfig struct {
// RequireMFA sends users without two-step sign-in to set it up before // RequireMFA sends users without two-step sign-in to set it up before
@@ -67,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
@@ -141,16 +172,20 @@ type ClientDefaults struct {
// Peer is one client. Its private key is never stored: it is shown once when // Peer is one client. Its private key is never stored: it is shown once when
// the config is issued. // the config is issued.
type Peer struct { type Peer struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Note string `json:"note"` Note string `json:"note"`
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
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"`
DNS []string `json:"dns,omitempty"` // nil = server default // IPv6 is set only for a peer imported from pivpn, which numbers IPv6
AllowedIPs []string `json:"allowedIPs,omitempty"` // nil = server default // differently: its device keeps the address until the config is issued
Keepalive *int `json:"keepalive,omitempty"` // nil = server default // here. Empty means the address mapped from IPv4 (see mapIPv6).
IPv6 string `json:"ipv6,omitempty"`
DNS []string `json:"dns,omitempty"` // nil = server default
AllowedIPs []string `json:"allowedIPs,omitempty"` // nil = server default
Keepalive *int `json:"keepalive,omitempty"` // nil = server default
// LatencyCheck says when the server pings the peer through the tunnel: // LatencyCheck says when the server pings the peer through the tunnel:
// "" (off), "active" (while the device sends traffic) or "always". // "" (off), "active" (while the device sends traffic) or "always".
LatencyCheck string `json:"latencyCheck,omitempty"` LatencyCheck string `json:"latencyCheck,omitempty"`
@@ -246,6 +281,9 @@ func (c *Config) applyDefaults() {
if c.Decoy.Page == "" { if c.Decoy.Page == "" {
c.Decoy.Page = "nginx" c.Decoy.Page = "nginx"
} }
if c.Updates.Source == "" {
c.Updates.Source = "gitea"
}
if c.APITokens == nil { if c.APITokens == nil {
c.APITokens = []APIToken{} c.APITokens = []APIToken{}
} }
@@ -352,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 {
@@ -382,6 +422,18 @@ func (c *Config) validate() error {
if _, ok := decoyPages[c.Decoy.Page]; !ok { if _, ok := decoyPages[c.Decoy.Page]; !ok {
return fmt.Errorf("unknown decoy page %q", c.Decoy.Page) return fmt.Errorf("unknown decoy page %q", c.Decoy.Page)
} }
if _, ok := updateSources[c.Updates.Source]; !ok {
return fmt.Errorf("update source must be gitea or github")
}
if err := validateListen(c.Web.Listen, "listen address", false); err != nil {
return err
}
if err := validateListen(c.Web.HTTPListen, "HTTP listen address", true); err != nil {
return err
}
if h := c.Web.SessionHours; h < minSessionHours || h > maxSessionHours {
return fmt.Errorf("session length must be %d–%d hours", minSessionHours, maxSessionHours)
}
switch c.Web.TLS.Mode { switch c.Web.TLS.Mode {
case "acme": case "acme":
if c.Web.TLS.Domain == "" { if c.Web.TLS.Domain == "" {
@@ -425,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 {
@@ -445,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)
} }
@@ -571,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
+52 -7
View File
@@ -2,6 +2,7 @@ package main
import ( import (
"bufio" "bufio"
"cmp"
"context" "context"
"errors" "errors"
"fmt" "fmt"
@@ -17,10 +18,11 @@ import (
type installPlan struct { type installPlan struct {
domain, email, endpoint string domain, email, endpoint string
port int port int
noDomain bool // turn an existing domain off (self-signed certificate) noDomain bool // turn an existing domain off (self-signed certificate)
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"
} }
+9
View File
@@ -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 {
+29
View File
@@ -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()
+24 -21
View File
@@ -217,7 +217,8 @@ func (k *linuxKernel) Apply(c *Config) error {
if c.Server.IPv6Enabled { if c.Server.IPv6Enabled {
_ = os.WriteFile("/proc/sys/net/ipv6/conf/all/forwarding", []byte("1"), 0o644) _ = os.WriteFile("/proc/sys/net/ipv6/conf/all/forwarding", []byte("1"), 0o644)
} }
return applyFirewall(c, k.Uplink(c, false), k.Uplink(c, true), lanNetworks(k.Uplink(c, false))) up4, up6 := k.Uplink(c, false), k.Uplink(c, true)
return applyFirewall(c, up4, up6, lanNetworks(up4, up6))
} }
func (k *linuxKernel) Sample(iface string) ([]PeerSample, error) { func (k *linuxKernel) Sample(iface string) ([]PeerSample, error) {
@@ -264,27 +265,27 @@ func (k *linuxKernel) Uplink(c *Config, v6 bool) string {
return l.Attrs().Name return l.Attrs().Name
} }
// lanNetworks returns the private IPv4 networks on the uplink, used to block // lanNetworks returns the LAN networks on the IPv4 and IPv6 uplinks (see
// peers from the server's LAN when LAN access is off. // lanBlock), used to block peers from the server's LAN when LAN access is off.
func lanNetworks(uplink string) []netip.Prefix { func lanNetworks(uplinks ...string) []netip.Prefix {
if uplink == "" { var nets []netip.Prefix
return nil for i, uplink := range uplinks {
} if uplink == "" || slices.Contains(uplinks[:i], uplink) {
l, err := netlink.LinkByName(uplink)
if err != nil {
return nil
}
addrs, _ := netlink.AddrList(l, netlink.FAMILY_V4)
var out []netip.Prefix
for _, a := range addrs {
if !a.IP.IsPrivate() {
continue continue
} }
ones, _ := a.Mask.Size() l, err := netlink.LinkByName(uplink)
ip, _ := netip.AddrFromSlice(a.IP.To4()) if err != nil {
out = append(out, netip.PrefixFrom(ip, ones).Masked()) continue
}
addrs, _ := netlink.AddrList(l, netlink.FAMILY_ALL)
for _, a := range addrs {
ones, _ := a.Mask.Size()
if ip, ok := netip.AddrFromSlice(a.IP); ok {
nets = append(nets, netip.PrefixFrom(ip.Unmap(), ones))
}
}
} }
return out return lanBlock(nets)
} }
// publicAddr reports the uplink's address for the health check: the first // publicAddr reports the uplink's address for the health check: the first
@@ -367,8 +368,10 @@ 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 link, err := netlink.LinkByName(c.Server.Interface); err == nil { if c.Server.Interface != "" {
errs = append(errs, netlink.LinkDel(link)) if link, err := netlink.LinkByName(c.Server.Interface); err == nil {
errs = append(errs, netlink.LinkDel(link))
}
} }
errs = append(errs, removeFirewall()) errs = append(errs, removeFirewall())
return errors.Join(errs...) return errors.Join(errs...)
+16 -7
View File
@@ -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
} }
+10 -3
View File
@@ -219,18 +219,25 @@ 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, 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(4) 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() { go func() {
t := time.NewTicker(10 * time.Minute) t := time.NewTicker(10 * time.Minute)
defer t.Stop() defer t.Stop()
+357
View File
@@ -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"
@@ -78,6 +79,16 @@ func TestValidate(t *testing.T) {
"bad dns": func(c *Config) { c.Peers[0].DNS = []string{"dns.example"} }, "bad dns": func(c *Config) { c.Peers[0].DNS = []string{"dns.example"} },
"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" },
// 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)
@@ -85,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) {
@@ -350,6 +375,30 @@ func TestAPI(t *testing.T) {
bearer("POST", "/tokens", 403, map[string]string{"name": "more", "scope": "rw"}) bearer("POST", "/tokens", 403, map[string]string{"name": "more", "scope": "rw"})
bearer("DELETE", "/tokens/"+tok["id"].(string), 403) bearer("DELETE", "/tokens/"+tok["id"].(string), 403)
bearer("GET", "/backup", 403) bearer("GET", "/backup", 403)
bearer("GET", "/update-backups", 403)
bearer("DELETE", "/update-backups", 403)
// Config copies made by update: listed newest first, removed one by
// one or all at once; nothing else in the folder can be removed.
for i, v := range []string{"v0.3.2", "v0.4.0"} {
f := filepath.Join(dir, "config.json.bak-"+v)
_ = os.WriteFile(f, []byte("{}"), 0o600)
_ = os.Chtimes(f, time.Now(), time.Now().Add(time.Duration(i-2)*time.Hour))
}
list := call("GET", "/update-backups", nil, 200)["backups"].([]any)
if len(list) != 2 || list[0].(map[string]any)["version"] != "v0.4.0" {
t.Fatalf("update backups: %v", list)
}
call("DELETE", "/update-backups/config.json", nil, 400)
call("DELETE", "/update-backups/stats.json", nil, 400)
call("DELETE", "/update-backups/config.json.bak-v9.9.9", nil, 400)
call("DELETE", "/update-backups/config.json.bak-v0.3.2", nil, 200)
if r := call("DELETE", "/update-backups", nil, 200); r["removed"] != float64(1) {
t.Fatalf("remove all: %v", r)
}
if _, err := os.Stat(filepath.Join(dir, "config.json")); err != nil {
t.Fatal("config.json is gone:", err)
}
call("DELETE", "/peers/"+id, nil, 200) call("DELETE", "/peers/"+id, nil, 200)
if len(store.Get().Peers) != 0 { if len(store.Get().Peers) != 0 {
@@ -1194,6 +1243,17 @@ func TestMFA(t *testing.T) {
c3("POST", "/auth/login/recovery", map[string]string{"ticket": ticket, "code": codes[0].(string)}, 401) c3("POST", "/auth/login/recovery", map[string]string{"ticket": ticket, "code": codes[0].(string)}, 401)
c3("POST", "/auth/login/recovery", map[string]string{"ticket": ticket, "code": codes[1].(string)}, 200) c3("POST", "/auth/login/recovery", map[string]string{"ticket": ticket, "code": codes[1].(string)}, 200)
// Session length needs no restart; the listen address does.
web := store.Get().Web
web.SessionHours = 24
if r := adm("PATCH", "/settings", map[string]any{"web": web}, 200); r["restartRequired"] != false || store.Get().Web.SessionHours != 24 {
t.Fatalf("session length: %v", r)
}
web.Listen = "127.0.0.1:9443"
if r := adm("PATCH", "/settings", map[string]any{"web": web}, 200); r["restartRequired"] != true {
t.Fatalf("listen address: %v", r)
}
// Required for everyone: a user without it can only set it up. // Required for everyone: a user without it can only set it up.
adm("PATCH", "/settings", map[string]any{"signin": map[string]bool{"requireMfa": true}}, 200) adm("PATCH", "/settings", map[string]any{"signin": map[string]bool{"requireMfa": true}}, 200)
u := adm("POST", "/users", map[string]any{"username": "eve", "password": "eve's password 1", "mustChangePassword": false}, 201)["user"].(map[string]any) u := adm("POST", "/users", map[string]any{"username": "eve", "password": "eve's password 1", "mustChangePassword": false}, 201)["user"].(map[string]any)
@@ -1250,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)
}
}
+42 -1
View File
@@ -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)
+354
View File
@@ -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
View File
@@ -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)
}
}
+218 -11
View File
@@ -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
} }
// pivpn hands over its interface: note who is connected, then stop it.
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
}
switched = time.Now()
}
step("Starting %s", serviceName) step("Starting %s", serviceName)
if err := sh("systemctl", "enable", serviceName); err != nil { 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 err := restartAndVerify(); err != nil { if pv != nil {
return err 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 {
@@ -589,7 +789,7 @@ func cmdUpdate(args []string) error {
if err != nil { if err != nil {
return err return err
} }
backup := configFile + ".bak-" + oldVersion backup := newUpdateBackupPath(configFile, oldVersion, time.Now())
step("Backing up config to %s", backup) step("Backing up config to %s", backup)
if err := copyFile(configFile, backup, 0o600, uid, gid); err != nil { if err := copyFile(configFile, backup, 0o600, uid, gid); err != nil {
return err return err
@@ -627,6 +827,11 @@ func cmdUpdate(args []string) error {
} }
return fmt.Errorf("update failed, %s %s is running again: %w", appName, oldVersion, err) return fmt.Errorf("update failed, %s %s is running again: %w", appName, oldVersion, err)
} }
if n, err := pruneUpdateBackups(configFile, keepUpdateBackups); err != nil {
fmt.Fprintln(os.Stderr, " Could not remove older config backups:", err)
} else if n > 0 {
step("Removed %d older config backups, kept the newest %d", n, keepUpdateBackups)
}
fmt.Printf("\nUpdated %s %s → %s.\n", appName, oldVersion, version) fmt.Printf("\nUpdated %s %s → %s.\n", appName, oldVersion, version)
return nil return nil
} }
@@ -658,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 {
+7 -3
View File
@@ -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
View File
@@ -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()
} }
+134
View File
@@ -0,0 +1,134 @@
package main
import (
"log/slog"
"sync"
"time"
)
// Speeds keeps the last few minutes of each peer's speed in memory for the
// Live page. It reads the kernel counters every speedStep, apart from the
// traffic history in Stats, and never writes to disk.
const (
speedStep = 2 * time.Second
speedPoints = 60 // 2 minutes
)
// SpeedPoint is one step: per peer ID, download and upload in bits per
// second, from the peer's point of view.
type SpeedPoint struct {
T int64 `json:"t"`
Peers map[string][2]int64 `json:"peers"`
}
type Speeds struct {
store *Store
kernel Kernel
mu sync.Mutex
last map[string][2]int64 // raw rx, tx by public key
lastAt time.Time
points []SpeedPoint
subs map[chan SpeedPoint]struct{}
done chan struct{} // closed when Run returns
}
func newSpeeds(store *Store, kernel Kernel) *Speeds {
return &Speeds{store: store, kernel: kernel, last: map[string][2]int64{}, subs: map[chan SpeedPoint]struct{}{}, done: make(chan struct{})}
}
// Subscribe returns the current points and a channel that receives each new
// one; cancel ends the subscription. A subscriber that falls behind misses
// points rather than holding up the sampler.
func (s *Speeds) Subscribe() (points []SpeedPoint, ch <-chan SpeedPoint, cancel func()) {
c := make(chan SpeedPoint, 4)
s.mu.Lock()
defer s.mu.Unlock()
s.subs[c] = struct{}{}
return append([]SpeedPoint{}, s.points...), c, func() {
s.mu.Lock()
delete(s.subs, c)
s.mu.Unlock()
}
}
// Done is closed when the sampler stops, so streams can end.
func (s *Speeds) Done() <-chan struct{} { return s.done }
func (s *Speeds) sample(now time.Time) {
cfg := s.store.Get()
samples, err := s.kernel.Sample(cfg.Server.Interface)
if err != nil {
slog.Debug("speed sample failed", "err", err)
return
}
idByKey := map[string]string{}
for _, p := range cfg.Peers {
if p.hasKey() {
idByKey[p.PublicKey] = p.ID
}
}
s.mu.Lock()
defer s.mu.Unlock()
secs := now.Sub(s.lastAt).Seconds()
first := s.lastAt.IsZero()
cur := map[string][2]int64{}
pt := SpeedPoint{T: now.Unix(), Peers: map[string][2]int64{}}
for _, smp := range samples {
cur[smp.PublicKey] = [2]int64{smp.RxBytes, smp.TxBytes}
id := idByKey[smp.PublicKey]
prev, ok := s.last[smp.PublicKey]
if id == "" || !ok || first {
continue
}
dRx, dTx := smp.RxBytes-prev[0], smp.TxBytes-prev[1]
if dRx < 0 || dTx < 0 { // counters were reset
continue
}
// Tx is what the server sent: the peer's download.
pt.Peers[id] = [2]int64{int64(float64(dTx*8) / secs), int64(float64(dRx*8) / secs)}
}
s.last, s.lastAt = cur, now
if first {
return
}
s.points = append(s.points, pt)
if len(s.points) > speedPoints {
s.points = s.points[len(s.points)-speedPoints:]
}
for c := range s.subs {
select {
case c <- pt:
default:
}
}
}
// Since returns the points newer than the unix time t, oldest first.
func (s *Speeds) Since(t int64) []SpeedPoint {
s.mu.Lock()
defer s.mu.Unlock()
out := []SpeedPoint{}
for _, p := range s.points {
if p.T > t {
out = append(out, p)
}
}
return out
}
func (s *Speeds) Run(stop <-chan struct{}) {
defer close(s.done)
s.sample(time.Now())
t := time.NewTicker(speedStep)
defer t.Stop()
for {
select {
case <-stop:
return
case now := <-t.C:
s.sample(now)
}
}
}
+224
View File
@@ -0,0 +1,224 @@
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"runtime"
"strings"
"sync"
"sync/atomic"
"time"
)
// The update check asks one of the two places releases are published for
// the latest one. Both carry the same tags and files.
var updateSources = map[string]struct {
Name string // shown in the web interface
API string // latest release, as JSON
Repo string // web page of the repository; downloads are under it
}{
"gitea": {"Gitea", "https://git.redetzke.aero/api/v1/repos/Redetzke/GHOSTWIRE/releases/latest", "https://git.redetzke.aero/Redetzke/GHOSTWIRE"},
"github": {"GitHub", "https://api.github.com/repos/danielredetzke/GHOSTWIRE/releases/latest", "https://github.com/danielredetzke/GHOSTWIRE"},
}
const updateCheckFreq = 24 * time.Hour
// Release is the latest published release as the source reports it.
type Release struct {
Version string `json:"version"` // tag, e.g. "v0.4.0"
Published time.Time `json:"published"`
Notes string `json:"notes"` // Markdown
URL string `json:"url"` // release page
}
// UpdateStatus is shown in the settings; Available also reaches the sidebar
// and the Dashboard through /auth/me.
type UpdateStatus struct {
Enabled bool `json:"enabled"`
Source string `json:"source"`
Current string `json:"current"`
Latest *Release `json:"latest"`
Available bool `json:"available"` // Latest is newer than Current
Checked *time.Time `json:"checked"` // last attempt
Error string `json:"error,omitempty"`
LastOK *time.Time `json:"lastOk"` // last attempt that worked
// Download links for this server's platform; empty when no release
// file is built for it.
Arch string `json:"arch"`
File string `json:"file,omitempty"`
FileURL string `json:"fileUrl,omitempty"`
SumsURL string `json:"sumsUrl,omitempty"`
SourceURL string `json:"sourceUrl"` // repository page of the source
}
type Updater struct {
enabled atomic.Bool
kick chan struct{}
fetch func(ctx context.Context, url string) (*Release, error) // replaced in tests
mu sync.Mutex
source string
latest *Release
checked *time.Time
lastOK *time.Time
err string
}
func newUpdater(c UpdatesConfig) *Updater {
u := &Updater{kick: make(chan struct{}, 1), fetch: fetchRelease, source: c.Source}
u.enabled.Store(c.checkEnabled())
return u
}
// Set applies the settings. A new source or switching the check on checks
// at once; switching it off forgets what the last check found.
func (u *Updater) Set(c UpdatesConfig) {
if u == nil {
return
}
on := c.checkEnabled()
u.mu.Lock()
changed := u.source != c.Source || u.enabled.Load() != on
if u.source != c.Source || !on {
u.latest, u.checked, u.lastOK, u.err = nil, nil, nil, ""
}
u.source = c.Source
u.enabled.Store(on)
u.mu.Unlock()
if changed && on {
select {
case u.kick <- struct{}{}:
default:
}
}
}
// Run checks once a day while the check is on.
func (u *Updater) Run(stop <-chan struct{}) {
t := time.NewTicker(updateCheckFreq)
defer t.Stop()
for {
if u.enabled.Load() {
u.Check(context.Background())
}
select {
case <-stop:
return
case <-t.C:
case <-u.kick:
}
}
}
// Check asks the source for the latest release now.
func (u *Updater) Check(ctx context.Context) {
u.mu.Lock()
source := u.source
u.mu.Unlock()
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
rel, err := u.fetch(ctx, updateSources[source].API)
now := time.Now()
u.mu.Lock()
defer u.mu.Unlock()
if u.source != source { // the source changed meanwhile; that check counts
return
}
u.checked = &now
if err != nil {
u.err = err.Error()
slog.Warn("update check failed", "source", source, "err", err)
return
}
u.latest, u.lastOK, u.err = rel, &now, ""
if newerVersion(rel.Version, version) {
slog.Info("update available", "version", rel.Version, "running", version)
}
}
func (u *Updater) Status() UpdateStatus {
if u == nil {
return UpdateStatus{Current: version}
}
u.mu.Lock()
defer u.mu.Unlock()
src := updateSources[u.source]
st := UpdateStatus{
Enabled: u.enabled.Load(), Source: u.source, Current: version, Latest: u.latest,
Checked: u.checked, Error: u.err, LastOK: u.lastOK, Arch: releaseArch(), SourceURL: src.Repo,
}
if u.latest != nil {
st.Available = newerVersion(u.latest.Version, version)
if st.Arch != "" {
st.File = fmt.Sprintf("%s-%s-linux-%s", appName, u.latest.Version, st.Arch)
base := src.Repo + "/releases/download/" + u.latest.Version + "/"
st.FileURL, st.SumsURL = base+st.File, base+"SHA256SUMS"
}
}
return st
}
// Available returns the newer release's version, or "".
func (u *Updater) Available() string {
if st := u.Status(); st.Enabled && st.Available {
return st.Latest.Version
}
return ""
}
// releaseArch names this platform the way the release files do, or "" when
// no file is built for it.
func releaseArch() string {
if runtime.GOOS != "linux" {
return ""
}
switch runtime.GOARCH {
case "amd64", "arm64":
return runtime.GOARCH
case "arm":
return "armv7"
}
return ""
}
func fetchRelease(ctx context.Context, url string) (*Release, error) {
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", appName+"/"+strings.TrimPrefix(version, "v"))
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("HTTP %d from %s", resp.StatusCode, req.URL.Host)
}
// GitHub and Gitea name these fields the same.
var r struct {
Tag string `json:"tag_name"`
Body string `json:"body"`
Published time.Time `json:"published_at"`
URL string `json:"html_url"`
Draft bool `json:"draft"`
Prerelease bool `json:"prerelease"`
}
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&r); err != nil {
return nil, fmt.Errorf("unreadable answer from %s: %w", req.URL.Host, err)
}
if _, ok := compareVersions(r.Tag, r.Tag); r.Draft || r.Prerelease || !ok {
return nil, errors.New("the latest release is not a published version")
}
return &Release{Version: r.Tag, Published: r.Published, Notes: r.Body, URL: r.URL}, nil
}
// newerVersion reports whether latest is a higher version than running.
// A running version that is not a version number is never out of date.
func newerVersion(latest, running string) bool {
c, ok := compareVersions(latest, running)
return ok && c > 0
}
+111
View File
@@ -0,0 +1,111 @@
package main
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestNewerVersion(t *testing.T) {
for _, tc := range []struct {
latest, running string
want bool
}{
{"v0.4.0", "v0.3.2", true},
{"v0.4.0", "0.3.2", true},
{"v0.10.0", "v0.9.9", true},
{"v1.0.0", "v0.99.0", true},
{"v0.4.0", "v0.4.0", false},
{"v0.4.0", "v0.4.0-3-gb18d16a", false}, // a build after the release
{"v0.3.2", "v0.4.0", false},
{"v0.4.0", "dev", false}, // not a version: never out of date
{"latest", "v0.3.2", false},
} {
if got := newerVersion(tc.latest, tc.running); got != tc.want {
t.Errorf("newerVersion(%q, %q) = %v, want %v", tc.latest, tc.running, got, tc.want)
}
}
}
func TestFetchRelease(t *testing.T) {
var body string
var status int
var ua string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ua = r.Header.Get("User-Agent")
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
}))
defer srv.Close()
status, body = 200, `{"tag_name":"v0.4.0","body":"Fixes.","published_at":"2026-10-05T06:15:28Z","html_url":"https://example.net/r/v0.4.0","draft":false,"prerelease":false}`
r, err := fetchRelease(context.Background(), srv.URL)
if err != nil {
t.Fatal(err)
}
if r.Version != "v0.4.0" || r.Notes != "Fixes." || r.URL != "https://example.net/r/v0.4.0" || r.Published.IsZero() {
t.Fatalf("release = %+v", r)
}
if !strings.HasPrefix(ua, appName+"/") {
t.Errorf("User-Agent = %q", ua)
}
status, body = 200, `{"tag_name":"v0.5.0-rc1","prerelease":true}`
if _, err := fetchRelease(context.Background(), srv.URL); err == nil {
t.Error("a pre-release was accepted")
}
status, body = 404, `{}`
if _, err := fetchRelease(context.Background(), srv.URL); err == nil || !strings.Contains(err.Error(), "404") {
t.Errorf("HTTP 404: err = %v", err)
}
}
func TestUpdater(t *testing.T) {
old := version
version = "v0.3.2"
defer func() { version = old }()
u := newUpdater(UpdatesConfig{Source: "gitea"})
var asked string
u.fetch = func(_ context.Context, url string) (*Release, error) {
asked = url
return &Release{Version: "v0.4.0"}, nil
}
u.Check(context.Background())
if asked != updateSources["gitea"].API {
t.Errorf("asked %q", asked)
}
st := u.Status()
if !st.Available || u.Available() != "v0.4.0" || st.Checked == nil || st.LastOK == nil {
t.Fatalf("status = %+v", st)
}
if st.Arch != "" {
want := "https://git.redetzke.aero/Redetzke/GHOSTWIRE/releases/download/v0.4.0/GHOSTWIRE-v0.4.0-linux-" + st.Arch
if st.FileURL != want || !strings.HasSuffix(st.SumsURL, "/v0.4.0/SHA256SUMS") {
t.Errorf("downloads = %q, %q", st.FileURL, st.SumsURL)
}
}
// A failed check keeps the last good answer and reports the error.
u.fetch = func(context.Context, string) (*Release, error) { return nil, errors.New("no route to host") }
u.Check(context.Background())
if st := u.Status(); st.Error != "no route to host" || st.Latest == nil {
t.Errorf("after a failed check: %+v", st)
}
// Another source forgets what the old one said; switching off hides it.
u.Set(UpdatesConfig{Source: "github"})
if st := u.Status(); st.Latest != nil || st.Error != "" || st.SourceURL != updateSources["github"].Repo {
t.Errorf("after changing the source: %+v", st)
}
off := false
u.fetch = func(context.Context, string) (*Release, error) { return &Release{Version: "v0.4.0"}, nil }
u.Check(context.Background())
u.Set(UpdatesConfig{Source: "github", Check: &off})
if u.Available() != "" || u.Status().Enabled {
t.Error("still reports an update with the check off")
}
}
+125
View File
@@ -0,0 +1,125 @@
package main
import (
"errors"
"io/fs"
"net/http"
"os"
"path/filepath"
"regexp"
"slices"
"strings"
"time"
)
// Each update copies config.json to config.json.bak-<old version> next to
// it, in case the new version must be rolled back. The copies hold the same
// secrets as a backup, so the web interface lists them and can remove them,
// and update keeps only the newest few.
const keepUpdateBackups = 3
type UpdateBackup struct {
Name string `json:"name"`
Version string `json:"version"`
Modified time.Time `json:"modified"`
Size int64 `json:"size"`
}
// A copy that would overwrite an older one gets the time appended.
var backupStampRe = regexp.MustCompile(`-\d{8}-\d{4}$`)
func updateBackupPrefix(configPath string) string { return filepath.Base(configPath) + ".bak-" }
// newUpdateBackupPath names the copy update makes of configPath.
func newUpdateBackupPath(configPath, version string, now time.Time) string {
p := configPath + ".bak-" + version
if _, err := os.Lstat(p); err == nil {
p += now.Format("-20060102-1504")
}
return p
}
// listUpdateBackups returns the copies next to configPath, newest first.
func listUpdateBackups(configPath string) ([]UpdateBackup, error) {
entries, err := os.ReadDir(filepath.Dir(configPath))
if err != nil {
return nil, err
}
prefix := updateBackupPrefix(configPath)
out := []UpdateBackup{}
for _, e := range entries {
name := e.Name()
if !e.Type().IsRegular() || !strings.HasPrefix(name, prefix) || name == prefix {
continue
}
fi, err := e.Info()
if err != nil {
continue
}
out = append(out, UpdateBackup{Name: name, Version: backupStampRe.ReplaceAllString(strings.TrimPrefix(name, prefix), ""),
Modified: fi.ModTime(), Size: fi.Size()})
}
slices.SortFunc(out, func(a, b UpdateBackup) int { return b.Modified.Compare(a.Modified) })
return out, nil
}
// removeUpdateBackup deletes one copy; any other name is refused.
func removeUpdateBackup(configPath, name string) error {
prefix := updateBackupPrefix(configPath)
path := filepath.Join(filepath.Dir(configPath), name)
fi, err := os.Lstat(path)
if !strings.HasPrefix(name, prefix) || name == prefix || strings.ContainsAny(name, `/\`) ||
errors.Is(err, fs.ErrNotExist) || (err == nil && !fi.Mode().IsRegular()) {
return badRequest("no copy named %q", name)
}
if err != nil {
return err
}
return os.Remove(path)
}
// pruneUpdateBackups keeps the newest keep copies and deletes the rest.
func pruneUpdateBackups(configPath string, keep int) (int, error) {
list, err := listUpdateBackups(configPath)
if err != nil || len(list) <= keep {
return 0, err
}
n := 0
for _, b := range list[keep:] {
if err := removeUpdateBackup(configPath, b.Name); err != nil {
return n, err
}
n++
}
return n, nil
}
func (a *App) listUpdateBackups(w http.ResponseWriter, r *http.Request) {
list, err := listUpdateBackups(a.store.path)
if err != nil {
writeErr(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"backups": list})
}
func (a *App) removeUpdateBackup(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
if err := removeUpdateBackup(a.store.path, name); err != nil {
writeErr(w, err)
return
}
a.audit(r, "update backup removed", "file", name)
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (a *App) removeUpdateBackups(w http.ResponseWriter, r *http.Request) {
n, err := pruneUpdateBackups(a.store.path, 0)
if err != nil {
writeErr(w, err)
return
}
a.audit(r, "update backups removed", "count", n)
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "removed": n})
}
+65
View File
@@ -0,0 +1,65 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestUpdateBackups(t *testing.T) {
dir := t.TempDir()
cfg := filepath.Join(dir, "config.json")
_ = os.WriteFile(cfg, []byte("{}"), 0o600)
now := time.Date(2026, 10, 5, 12, 9, 0, 0, time.UTC)
// A second copy of the same version gets the time appended instead of
// overwriting the first.
first := newUpdateBackupPath(cfg, "unknown", now)
if filepath.Base(first) != "config.json.bak-unknown" {
t.Fatalf("first copy: %s", first)
}
_ = os.WriteFile(first, []byte("{}"), 0o600)
second := newUpdateBackupPath(cfg, "unknown", now)
if filepath.Base(second) != "config.json.bak-unknown-20261005-1209" {
t.Fatalf("second copy: %s", second)
}
_ = os.WriteFile(second, []byte("{}"), 0o600)
for i, v := range []string{"v0.2.0", "v0.3.0", "v0.4.0"} {
f := filepath.Join(dir, "config.json.bak-"+v)
_ = os.WriteFile(f, []byte("{}"), 0o600)
_ = os.Chtimes(f, now, now.Add(time.Duration(i+1)*time.Hour))
}
_ = os.Chtimes(first, now, now.Add(-2*time.Hour))
_ = os.Chtimes(second, now, now.Add(-time.Hour))
_ = os.Mkdir(filepath.Join(dir, "config.json.bak-dir"), 0o700) // not a file: ignored
list, err := listUpdateBackups(cfg)
if err != nil {
t.Fatal(err)
}
var got []string
for _, b := range list {
got = append(got, b.Version)
}
if strings.Join(got, " ") != "v0.4.0 v0.3.0 v0.2.0 unknown unknown" {
t.Fatalf("versions, newest first: %v", got)
}
for _, bad := range []string{"config.json", "config.json.bak-", "config.json.bak-dir", "../config.json.bak-v0.4.0", "config.json.bak-v0.4.0/x"} {
if err := removeUpdateBackup(cfg, bad); err == nil {
t.Errorf("removed %q", bad)
}
}
if n, err := pruneUpdateBackups(cfg, keepUpdateBackups); err != nil || n != 2 {
t.Fatalf("prune: %d, %v", n, err)
}
if list, _ = listUpdateBackups(cfg); len(list) != 3 || list[2].Version != "v0.2.0" {
t.Fatalf("after prune: %v", list)
}
if _, err := os.Stat(cfg); err != nil {
t.Fatal("config.json is gone")
}
}