Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ef5a2930e0 | |||
| 3482a03707 | |||
| 47762790ef | |||
| 3e8dba6072 | |||
| aa4ca20296 | |||
| 1bfede250c | |||
| 8edde9f5e7 | |||
| 7c36223de0 | |||
| 2b7b41859d | |||
| ccf7b50c59 | |||
| a82314ee94 | |||
| d83582eea1 | |||
| 5f321f2979 | |||
| 6661424daf | |||
| 456ae6a41e | |||
| c7b4ca692e | |||
| 6733bf2f3a | |||
| e9bd3090a8 | |||
| 50467535a8 | |||
| 39dde98af5 | |||
| 59c4fb5ff1 | |||
| 15d921019a | |||
| 2bc6465ea7 | |||
| ef636e8a58 | |||
| caa8774c02 | |||
| 1d3bce3da3 | |||
| ae89692ba9 | |||
| dc002b7440 | |||
| cf1e1f7f63 | |||
| 592b738f10 | |||
| e4a40ff0c3 | |||
| fbca13e528 | |||
| 3069a3006e | |||
| f5052642c6 | |||
| 00429ae484 | |||
| 1de98f785b | |||
| 0bafc3d7bc | |||
| 9d54d2d5ad | |||
| 420f1688ac | |||
| d5a36572fb | |||
| 7d875450c8 | |||
| ce4f2bc36d |
@@ -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
|
||||
dependencies on the server: the binary installs, updates and removes itself.
|
||||
|
||||
> **Coming from pivpn?** GHOSTWIRE takes over a pivpn WireGuard server in one
|
||||
> command: `sudo ./GHOSTWIRE install`. Your phones and laptops keep their
|
||||
> current configs and reconnect on their own, with nothing to re-scan or
|
||||
> re-send. See [Moving from pivpn](#moving-from-pivpn).
|
||||
|
||||

|
||||
|
||||
## Features
|
||||
|
||||
- **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
|
||||
reconciled to it, so there is no `/etc/wireguard`, no `wg-quick` and no
|
||||
`wireguard-tools`.
|
||||
@@ -27,32 +36,27 @@ dependencies on the server: the binary installs, updates and removes itself.
|
||||
- **IPv4 and IPv6:** IPv6 inside the tunnel is turned on automatically when the
|
||||
server has a global IPv6 address.
|
||||
- **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,
|
||||
address and traffic. A new session starts when a device changes networks.
|
||||
Country and network operator come from the free
|
||||
[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
|
||||
never leave the server. You can switch this off under Settings → Data
|
||||
retention.
|
||||
never leave the server. You can switch this off under Settings → Logs &
|
||||
history.
|
||||
- **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
|
||||
certificate files, or plain HTTP behind a reverse proxy.
|
||||
|
||||
## Screenshots
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
|  |  |
|
||||
| **Peers:** status, endpoint, latency and traffic at a glance | **Peer:** traffic, latency, connection history and settings |
|
||||
|  |  |
|
||||
| **Server:** health, address plan, client defaults and firewall | **Settings:** users, web interface and API tokens |
|
||||
|  |  |
|
||||
| **My account:** profile, password and your app tokens | **Sign-in** |
|
||||
|
||||
The screenshots show sample data from the built-in simulator.
|
||||
|
||||
## Security
|
||||
|
||||
- **Client private keys are never stored.** A config is shown once, as a
|
||||
@@ -65,8 +69,19 @@ The screenshots show sample data from the built-in simulator.
|
||||
`CAP_NET_ADMIN` and `CAP_NET_BIND_SERVICE`, and can write only to
|
||||
`/opt/ghostwire`.
|
||||
- **Sign-in:** one or more users, all admins. Passwords are stored as argon2id hashes.
|
||||
After 5 failed attempts, sign-in is locked for 15 minutes. Sessions use an
|
||||
After 5 failed attempts from one IP address, sign-in from it is locked for 15
|
||||
minutes; wrong two-step codes count too. Sessions use an
|
||||
HttpOnly, SameSite=Strict cookie and last 12 hours by default.
|
||||
- **Two-step sign-in:** each user can add an authenticator app (TOTP) and
|
||||
passkeys under My account. A passkey signs in on its own, without username
|
||||
and password, and also works as the second step after a password. It can live
|
||||
on the device (Touch ID, Face ID, Windows Hello), in a password manager, or on
|
||||
a YubiKey with a PIN set. Turning it on gives 10 one-time recovery codes. An
|
||||
admin can require it for everyone (Settings → Sign-in) and reset it for a user
|
||||
who lost their phone or key. Passkeys use WebAuthn and need the server's
|
||||
domain name with a trusted certificate (Let's Encrypt, certificate files, or a
|
||||
reverse proxy); on a self-signed certificate or an IP address, only the
|
||||
authenticator app is offered. API tokens never need a second step.
|
||||
- **API tokens** are stored only as hashes and can be read-only or full access.
|
||||
- `config.json` holds the server private key and is readable only by the
|
||||
service (0600).
|
||||
@@ -148,6 +163,8 @@ sudo /tmp/GHOSTWIRE install -y -domain vpn.example.net -email you@example.net -p
|
||||
| `-email` | none |
|
||||
| `-endpoint` | the domain |
|
||||
| `-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.
|
||||
`echo "$PASSWORD" | sudo ./GHOSTWIRE install -y …`. Every value is checked
|
||||
@@ -173,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
|
||||
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)
|
||||
|
||||
| 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 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 install [-domain d] [-email e] [-endpoint h] [-port p] [-import-pivpn] [-no-wait] [-y]` | Sets up and starts the service, as above. Asks for the settings no flag gave; `-y` never asks. On a pivpn server it takes over pivpn's WireGuard (see above). |
|
||||
| `GHOSTWIRE update [-force]` | Run from the new binary, e.g. `sudo /tmp/GHOSTWIRE update`. Checks that it can read the current `config.json` (nothing changes if not), backs up the config to `config.json.bak-<old version>` (keeping the newest 3 such copies), replaces the binary, updates the unit if needed and restarts. If the new version does not stay up, the old binary and config are put back and restarted. It refuses older versions without `-force`. |
|
||||
| `GHOSTWIRE 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 version` | Prints the version. |
|
||||
@@ -219,7 +260,8 @@ After editing `config.json` by hand, run `sudo systemctl reload ghostwire`.
|
||||
| File | Content |
|
||||
|---|---|
|
||||
| `GHOSTWIRE` | the program |
|
||||
| `config.json` | all settings, server key, peers, 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 |
|
||||
| `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` |
|
||||
@@ -231,19 +273,36 @@ Base path `/api/v1`. The web interface signs in with a session cookie; every
|
||||
user is an admin. Apps and scripts use `Authorization: Bearer <token>`; create
|
||||
the token under Settings → Pair iOS app. A token belongs to the user who made
|
||||
it and is revoked when that user is deleted. A read-only token may only use
|
||||
GET. Full-access tokens can do everything the web interface does except backup
|
||||
and restore. Users, passwords and API tokens need a full-access token even for
|
||||
reading.
|
||||
GET. Full-access tokens can do everything the web interface does except the
|
||||
endpoints marked "signed in": users, passwords, API tokens, the sign-in rules,
|
||||
backup and restore.
|
||||
|
||||
For a user with two-step sign-in, `POST /auth/login` answers
|
||||
`{"mfa": true, "ticket": "…", "methods": ["key", "totp", "recovery"]}`
|
||||
instead of starting a session; the ticket is good for 5 minutes, and one of
|
||||
the `/auth/login/…` steps turns it into the session. `PATCH /settings`
|
||||
`{"signin": {"requireMfa": true}}` requires two-step sign-in for every user;
|
||||
only a signed-in user can change it.
|
||||
|
||||
`POST /users` and `POST /users/{id}/reset-password` take
|
||||
`{"password": "…", "mustChangePassword": true}`; with `true` (the default) the
|
||||
user can do nothing but choose a new password at the next sign-in.
|
||||
|
||||
```
|
||||
POST /auth/login · /auth/logout GET /auth/me POST /auth/password (own password)
|
||||
GET /users POST /users PATCH /users/{id} DELETE /users/{id}
|
||||
POST /users/{id}/reset-password
|
||||
POST /auth/login · /auth/logout GET /auth/me
|
||||
signed in: POST /auth/password (own password)
|
||||
signed in: GET|POST /users · PATCH|DELETE /users/{id}
|
||||
signed in: POST /users/{id}/reset-password · /users/{id}/reset-mfa
|
||||
GET /auth/options (public: is passkey sign-in offered here)
|
||||
POST /auth/login/totp · /auth/login/recovery {"ticket", "code"}
|
||||
POST /auth/login/key/begin {"ticket"} · /auth/login/key/finish?ticket= (body: the WebAuthn credential)
|
||||
POST /auth/login/passkey/begin · /auth/login/passkey/finish?id=
|
||||
signed in: GET /auth/mfa · POST /auth/mfa/totp/setup · /auth/mfa/totp/confirm · DELETE /auth/mfa/totp
|
||||
signed in: POST /auth/mfa/keys/begin · /auth/mfa/keys/finish?name= · PATCH|DELETE /auth/mfa/keys/{id}
|
||||
signed in: POST /auth/mfa/recovery-codes
|
||||
GET /status GET /stats?range=24h|7d|30d|90d
|
||||
GET /live?since= (speed per peer, last 2 minutes in 2-second steps)
|
||||
GET /live/stream (the same as server-sent events)
|
||||
GET /server PATCH /server POST /server/rotate-key GET /server/detect-ip
|
||||
GET /peers POST /peers (returns the config and QR once)
|
||||
GET /peers/{id} PATCH /peers/{id} DELETE /peers/{id}
|
||||
@@ -251,10 +310,10 @@ POST /peers/{id}/enable | /disable | /issue-config
|
||||
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}/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 /tokens POST /tokens DELETE /tokens/{id}
|
||||
signed in: 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)
|
||||
```
|
||||
|
||||
@@ -263,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
|
||||
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
|
||||
downloaded, `up` is what it uploaded.
|
||||
|
||||
`GET /live` answers `{"step": 2, "size": 60, "points": [{"t": …, "peers":
|
||||
{"<id>": [down, up]}}]}` with speeds in bits per second, kept only in memory.
|
||||
With `since` (unix seconds) it returns only newer steps. `GET /live/stream`
|
||||
sends the same messages as server-sent events: the history first, then one
|
||||
message per new step.
|
||||
|
||||
Latency is measured by pinging the peer's tunnel address every 30 seconds. Set
|
||||
it per peer with `PATCH /peers/{id}` `{"latencyCheck": "off"|"active"|"always"}`
|
||||
(default `off`). `active` pings only while the device sends traffic, so idle
|
||||
@@ -284,12 +356,16 @@ override a drop in another table, so if ufw or firewalld is active, allow UDP
|
||||
## iOS app
|
||||
|
||||
The native iPhone app (SwiftUI, iOS 17+) lives in its own project,
|
||||
GHOSTWIRE-Companion. It does everything the web interface does except
|
||||
password, API tokens and backups. Pair it in the web interface under
|
||||
GHOSTWIRE-Companion. It manages peers, the server and the app settings and
|
||||
shows stats and logs. Users, passwords, API tokens, two-step sign-in, backup
|
||||
and restore stay in the web interface. Pair it in the web interface under
|
||||
Settings → Pair iOS app: scan the QR code, or tap "Copy pairing code" and paste
|
||||
it into the app's "Enter manually". Self-signed certificates are pinned during
|
||||
pairing.
|
||||
|
||||
The iOS app is currently in beta testing. For an invite, email
|
||||
[engineroom@redetzke.aero](mailto:engineroom@redetzke.aero).
|
||||
|
||||
## Development
|
||||
|
||||
On macOS (or any non-Linux system), `make dev` starts the app on
|
||||
|
||||
@@ -3,15 +3,18 @@ package main
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"slices"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -21,13 +24,16 @@ type App struct {
|
||||
kernel Kernel
|
||||
recon *Reconciler
|
||||
stats *Stats
|
||||
speeds *Speeds // nil in tests
|
||||
auth *Auth
|
||||
tls *webTLS
|
||||
logPath string
|
||||
logw *rotatingWriter // nil in tests
|
||||
geo *Geo // nil in tests
|
||||
updates *Updater // nil in tests
|
||||
started time.Time
|
||||
shutdown func() // graceful stop; systemd restarts the service
|
||||
webAddrs []string // the addresses the web server listens on now
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
@@ -84,6 +90,11 @@ func (a *App) guard(adminOnly bool, h http.HandlerFunc) http.HandlerFunc {
|
||||
writeJSON(w, http.StatusForbidden, map[string]string{"error": "choose a new password first", "code": "password_change_required"})
|
||||
return
|
||||
}
|
||||
if p.MFASetupRequired && r.URL.Path != "/api/v1/auth/me" && r.URL.Path != "/api/v1/auth/password" &&
|
||||
!strings.HasPrefix(r.URL.Path, "/api/v1/auth/mfa") {
|
||||
writeJSON(w, http.StatusForbidden, map[string]string{"error": "set up two-step sign-in first", "code": "mfa_setup_required"})
|
||||
return
|
||||
}
|
||||
if p.Scope == "ro" && r.Method != http.MethodGet {
|
||||
writeJSON(w, http.StatusForbidden, map[string]string{"error": "this token is read-only"})
|
||||
return
|
||||
@@ -92,17 +103,6 @@ func (a *App) guard(adminOnly bool, h http.HandlerFunc) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// fullAccess refuses read-only tokens, also for GET.
|
||||
func fullAccess(h http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if who(r).Scope == "ro" {
|
||||
writeJSON(w, http.StatusForbidden, map[string]string{"error": "this token is read-only"})
|
||||
return
|
||||
}
|
||||
h(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
// applyResult saves-then-applies: the config is already stored, so a kernel
|
||||
// error is reported but does not undo the change.
|
||||
func (a *App) apply() string {
|
||||
@@ -116,21 +116,41 @@ func (a *App) routes() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
g := func(pattern string, h http.HandlerFunc) { mux.HandleFunc(pattern, a.guard(false, h)) }
|
||||
adm := func(pattern string, h http.HandlerFunc) { mux.HandleFunc(pattern, a.guard(true, h)) }
|
||||
// full is for signed-in users and full-access tokens, even for reading.
|
||||
full := func(pattern string, h http.HandlerFunc) { mux.HandleFunc(pattern, a.guard(false, fullAccess(h))) }
|
||||
|
||||
mux.HandleFunc("POST /api/v1/auth/login", a.login)
|
||||
mux.HandleFunc("POST /api/v1/auth/logout", a.logout)
|
||||
// The second step of signing in, and signing in with a passkey alone.
|
||||
mux.HandleFunc("GET /api/v1/auth/options", a.signInOptions)
|
||||
mux.HandleFunc("POST /api/v1/auth/login/totp", a.loginTOTP)
|
||||
mux.HandleFunc("POST /api/v1/auth/login/recovery", a.loginRecovery)
|
||||
mux.HandleFunc("POST /api/v1/auth/login/key/begin", a.loginKeyBegin)
|
||||
mux.HandleFunc("POST /api/v1/auth/login/key/finish", a.loginKeyFinish)
|
||||
mux.HandleFunc("POST /api/v1/auth/login/passkey/begin", a.loginPasskeyBegin)
|
||||
mux.HandleFunc("POST /api/v1/auth/login/passkey/finish", a.loginPasskeyFinish)
|
||||
// Your own two-step sign-in. Keys and passkeys need a browser, so these
|
||||
// are for signed-in users only.
|
||||
adm("GET /api/v1/auth/mfa", a.mfaStatus)
|
||||
adm("POST /api/v1/auth/mfa/totp/setup", a.totpSetup)
|
||||
adm("POST /api/v1/auth/mfa/totp/confirm", a.totpConfirm)
|
||||
adm("DELETE /api/v1/auth/mfa/totp", a.totpRemove)
|
||||
adm("POST /api/v1/auth/mfa/keys/begin", a.keyBegin)
|
||||
adm("POST /api/v1/auth/mfa/keys/finish", a.keyFinish)
|
||||
adm("PATCH /api/v1/auth/mfa/keys/{id}", a.keyRename)
|
||||
adm("DELETE /api/v1/auth/mfa/keys/{id}", a.keyRemove)
|
||||
adm("POST /api/v1/auth/mfa/recovery-codes", a.newRecoveryCodesHandler)
|
||||
g("GET /api/v1/auth/me", a.me)
|
||||
full("POST /api/v1/auth/password", a.changePassword)
|
||||
full("GET /api/v1/users", a.listUsers)
|
||||
full("POST /api/v1/users", a.createUser)
|
||||
full("PATCH /api/v1/users/{id}", a.patchUser)
|
||||
full("POST /api/v1/users/{id}/reset-password", a.resetPassword)
|
||||
full("DELETE /api/v1/users/{id}", a.deleteUser)
|
||||
adm("POST /api/v1/auth/password", a.changePassword)
|
||||
adm("GET /api/v1/users", a.listUsers)
|
||||
adm("POST /api/v1/users", a.createUser)
|
||||
adm("PATCH /api/v1/users/{id}", a.patchUser)
|
||||
adm("POST /api/v1/users/{id}/reset-password", a.resetPassword)
|
||||
adm("DELETE /api/v1/users/{id}", a.deleteUser)
|
||||
adm("POST /api/v1/users/{id}/reset-mfa", a.resetMFA)
|
||||
|
||||
g("GET /api/v1/status", a.status)
|
||||
g("GET /api/v1/stats", a.allStats)
|
||||
g("GET /api/v1/live", a.liveSpeeds)
|
||||
g("GET /api/v1/live/stream", a.liveStream)
|
||||
|
||||
g("GET /api/v1/server", a.getServer)
|
||||
g("PATCH /api/v1/server", a.patchServer)
|
||||
@@ -157,17 +177,22 @@ func (a *App) routes() http.Handler {
|
||||
mux.HandleFunc("POST /api/v1/setup/{token}", a.setupRedeem)
|
||||
|
||||
// Full-access tokens (the iOS app) may change app settings, read logs and
|
||||
// manage users and tokens. Backups need a signed-in user.
|
||||
// restart. Users, passwords, API tokens, the sign-in rules and backups
|
||||
// need a signed-in user.
|
||||
g("GET /api/v1/settings", a.getSettings)
|
||||
g("PATCH /api/v1/settings", a.patchSettings)
|
||||
g("POST /api/v1/updates/check", a.checkUpdates)
|
||||
g("POST /api/v1/restart", a.restart)
|
||||
full("GET /api/v1/tokens", a.listTokens)
|
||||
full("POST /api/v1/tokens", a.createToken)
|
||||
full("DELETE /api/v1/tokens/{id}", a.deleteToken)
|
||||
adm("GET /api/v1/tokens", a.listTokens)
|
||||
adm("POST /api/v1/tokens", a.createToken)
|
||||
adm("DELETE /api/v1/tokens/{id}", a.deleteToken)
|
||||
g("GET /api/v1/logs", a.logs)
|
||||
g("GET /api/v1/logs/download", a.downloadLog)
|
||||
adm("GET /api/v1/backup", a.backup)
|
||||
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) {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "no such endpoint"})
|
||||
@@ -203,16 +228,22 @@ func (a *App) login(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
ip := remoteIP(r)
|
||||
id, err := a.auth.Login(in.Username, in.Password, ip)
|
||||
id, ticket, err := a.auth.Login(in.Username, in.Password, ip)
|
||||
if err != nil {
|
||||
slog.Warn("login failed", "user", in.Username, "remote", ip, "reason", err.Error())
|
||||
code := http.StatusUnauthorized
|
||||
if errors.Is(err, errLocked) {
|
||||
if errors.Is(err, errLocked) || errors.Is(err, errBusy) {
|
||||
code = http.StatusTooManyRequests
|
||||
}
|
||||
writeJSON(w, code, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if ticket != "" {
|
||||
// The password was right; the second step makes the session.
|
||||
_, u := a.auth.ticketUserID(ticket)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"mfa": true, "ticket": ticket, "methods": mfaMethods(u)})
|
||||
return
|
||||
}
|
||||
a.setSessionCookie(w, r, id)
|
||||
slog.Info("login", "audit", true, "actor", in.Username, "remote", ip)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
@@ -237,10 +268,10 @@ func (a *App) me(w http.ResponseWriter, r *http.Request) {
|
||||
p := who(r)
|
||||
out := map[string]any{
|
||||
"id": p.UserID, "name": p.Name, "isAdmin": p.IsAdmin, "scope": p.Scope,
|
||||
"mustChangePassword": p.MustChangePassword, "version": version, "session": p.Session,
|
||||
"mustChangePassword": p.MustChangePassword, "mfaSetupRequired": p.MFASetupRequired, "version": version, "session": p.Session,
|
||||
}
|
||||
if p.TokenID != "" {
|
||||
out["tokenId"] = p.TokenID // lets an app find its own token in /tokens
|
||||
if v := a.updates.Available(); v != "" {
|
||||
out["updateAvailable"] = v
|
||||
}
|
||||
if _, u := a.store.Get().userByID(p.UserID); u != nil {
|
||||
out["username"], out["note"], out["created"] = u.Username, u.Note, u.Created
|
||||
@@ -326,7 +357,7 @@ func (a *App) status(w http.ResponseWriter, r *http.Request) {
|
||||
d30, u30 := sumPoints(a.stats.series(nil, "30d"))
|
||||
checks := a.kernel.Checks(cfg)
|
||||
last, applyErr := a.recon.Status()
|
||||
ac := Check{Name: "Last apply", OK: applyErr == nil, Detail: "applied " + last.Format(time.RFC3339)}
|
||||
ac := Check{Name: "Kernel in sync", OK: applyErr == nil, Detail: "applied " + last.Format(time.RFC3339)}
|
||||
if applyErr != nil {
|
||||
ac.Detail = applyErr.Error()
|
||||
}
|
||||
@@ -374,6 +405,60 @@ func (a *App) allStats(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"range": rng, "points": a.stats.series(nil, rng)})
|
||||
}
|
||||
|
||||
// liveSpeeds returns each peer's speed over the last 2 minutes; with since
|
||||
// (unix seconds) only the newer steps.
|
||||
func (a *App) liveSpeeds(w http.ResponseWriter, r *http.Request) {
|
||||
var since int64
|
||||
fmt.Sscan(r.URL.Query().Get("since"), &since)
|
||||
points := []SpeedPoint{}
|
||||
if a.speeds != nil {
|
||||
points = a.speeds.Since(since)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"step": int(speedStep / time.Second), "size": speedPoints, "points": points})
|
||||
}
|
||||
|
||||
// liveStream sends the same data as server-sent events: the current points
|
||||
// first, then each new step as soon as it is sampled. The session is checked
|
||||
// again with every step, so signing out ends the stream.
|
||||
func (a *App) liveStream(w http.ResponseWriter, r *http.Request) {
|
||||
if a.speeds == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "live speeds are not available"})
|
||||
return
|
||||
}
|
||||
rc := http.NewResponseController(w)
|
||||
_ = rc.SetWriteDeadline(time.Time{}) // the server's write timeout would cut the stream
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("X-Accel-Buffering", "no") // nginx: do not buffer
|
||||
points, ch, cancel := a.speeds.Subscribe()
|
||||
defer cancel()
|
||||
send := func(points []SpeedPoint) bool {
|
||||
b, _ := json.Marshal(map[string]any{"step": int(speedStep / time.Second), "size": speedPoints, "points": points})
|
||||
if _, err := fmt.Fprintf(w, "data: %s\n\n", b); err != nil {
|
||||
return false
|
||||
}
|
||||
return rc.Flush() == nil
|
||||
}
|
||||
if !send(points) {
|
||||
return
|
||||
}
|
||||
for {
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
return
|
||||
case <-a.speeds.Done():
|
||||
return
|
||||
case pt := <-ch:
|
||||
if _, ok := a.auth.Authenticate(r); !ok {
|
||||
return
|
||||
}
|
||||
if !send([]SpeedPoint{pt}) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) peerStats(w http.ResponseWriter, r *http.Request) {
|
||||
cfg := a.store.Get()
|
||||
if _, p := cfg.peerByID(r.PathValue("id")); p == nil {
|
||||
@@ -475,7 +560,7 @@ func (a *App) patchServer(w http.ResponseWriter, r *http.Request) {
|
||||
err = a.store.Update(func(c *Config) error {
|
||||
s := &c.Server
|
||||
before := s.clientFacing()
|
||||
oldV4 := s.IPv4
|
||||
oldV4, oldV6 := s.IPv4, s.IPv6
|
||||
for _, f := range []struct {
|
||||
key string
|
||||
dst any
|
||||
@@ -498,6 +583,11 @@ func (a *App) patchServer(w http.ResponseWriter, r *http.Request) {
|
||||
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()
|
||||
return nil
|
||||
})
|
||||
@@ -618,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),
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -952,7 +1042,8 @@ func (a *App) issueConfig(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
// 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 != "" {
|
||||
p.PresharedKey = psk.String()
|
||||
}
|
||||
@@ -981,8 +1072,9 @@ func (a *App) getSettings(w http.ResponseWriter, r *http.Request) {
|
||||
"log": cfg.Log,
|
||||
"stats": cfg.Stats,
|
||||
"decoy": cfg.Decoy,
|
||||
"signin": cfg.SignIn,
|
||||
"geo": a.geoStatus(),
|
||||
"adminUsername": a.username(cfg, who(r).UserID), // kept for older iOS app versions
|
||||
"updates": a.updates.Status(),
|
||||
"fingerprint": a.tls.Fingerprint(),
|
||||
"logPath": a.logPath,
|
||||
})
|
||||
@@ -994,24 +1086,41 @@ func (a *App) patchSettings(w http.ResponseWriter, r *http.Request) {
|
||||
writeErr(w, err)
|
||||
return
|
||||
}
|
||||
if _, ok := m["adminUsername"]; ok {
|
||||
writeErr(w, badRequest("usernames are changed under /users"))
|
||||
if _, ok := m["signin"]; ok && !who(r).IsAdmin {
|
||||
writeJSON(w, http.StatusForbidden, map[string]string{"error": "API tokens cannot change the sign-in rules; sign in to the web interface"})
|
||||
return
|
||||
}
|
||||
var restart bool
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
after, _ := json.Marshal(c.Web)
|
||||
restart = string(before) != string(after)
|
||||
if restart = listen() != before; restart {
|
||||
if err := a.checkWebStart(oldWeb, c.Web); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := field(m, "stats", &c.Stats); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := field(m, "decoy", &c.Decoy); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := field(m, "signin", &c.SignIn); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := field(m, "updates", &c.Updates); err != nil {
|
||||
return err
|
||||
}
|
||||
return field(m, "log", &c.Log)
|
||||
})
|
||||
if err != nil {
|
||||
@@ -1141,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?"))
|
||||
return
|
||||
}
|
||||
if err := a.store.Update(func(c *Config) error { *c = in; return nil }); err != nil {
|
||||
if in.Version > configVersion {
|
||||
writeErr(w, badRequest("this backup is from a newer version of %s; update this server first", appName))
|
||||
return
|
||||
}
|
||||
in.applyDefaults()
|
||||
if !in.passwordSet() {
|
||||
writeErr(w, badRequest("this backup has no user with a password; restoring it would lock everyone out"))
|
||||
return
|
||||
}
|
||||
if err := a.store.Update(func(c *Config) error {
|
||||
old := c.Web
|
||||
*c = in
|
||||
return a.checkWebStart(old, c.Web)
|
||||
}); err != nil {
|
||||
writeErr(w, err)
|
||||
return
|
||||
}
|
||||
@@ -1149,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})
|
||||
}
|
||||
|
||||
// checkWebStart refuses web settings the service could not start with: an
|
||||
// address it cannot listen on, or certificate files it cannot read. The
|
||||
// service would stop at the next restart, and the web interface and the API
|
||||
// with it.
|
||||
func (a *App) checkWebStart(old, next WebConfig) error {
|
||||
if err := validateListen(next.Listen, "listen address", false); err != nil {
|
||||
return &userError{err.Error()}
|
||||
}
|
||||
if err := validateListen(next.HTTPListen, "HTTP listen address", true); err != nil {
|
||||
return &userError{err.Error()}
|
||||
}
|
||||
if next.TLS.Mode == "files" && next.TLS != old.TLS {
|
||||
if _, err := tls.LoadX509KeyPair(next.TLS.CertFile, next.TLS.KeyFile); err != nil {
|
||||
return badRequest("the certificate files cannot be used: %v", err)
|
||||
}
|
||||
}
|
||||
addrs := []string{next.Listen}
|
||||
if next.HTTPListen != "" && next.TLS.Mode != "off" {
|
||||
addrs = append(addrs, next.HTTPListen)
|
||||
}
|
||||
for _, addr := range addrs {
|
||||
if err := a.canListen(addr); err != nil {
|
||||
return badRequest("cannot listen on %s: %v", addr, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// canListen tries to listen on addr. An address the service listens on now,
|
||||
// or one whose port it holds, is fine: it is free again after the restart.
|
||||
func (a *App) canListen(addr string) error {
|
||||
if slices.Contains(a.webAddrs, addr) {
|
||||
return nil
|
||||
}
|
||||
ln, err := net.Listen("tcp", addr)
|
||||
if err == nil {
|
||||
return ln.Close()
|
||||
}
|
||||
if errors.Is(err, syscall.EADDRINUSE) {
|
||||
_, port, _ := net.SplitHostPort(addr)
|
||||
for _, own := range a.webAddrs {
|
||||
if _, p, _ := net.SplitHostPort(own); p == port {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// applyRuntime applies the settings that take effect without a restart: log
|
||||
// level and log rotation. Traffic retention is read by the stats sampler.
|
||||
func (a *App) applyRuntime(c *Config) {
|
||||
@@ -1157,6 +1328,17 @@ func (a *App) applyRuntime(c *Config) {
|
||||
a.logw.SetLimits(c.Log.MaxSizeMB, c.Log.MaxFiles)
|
||||
}
|
||||
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 {
|
||||
|
||||
@@ -33,8 +33,20 @@
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; }
|
||||
body { background: var(--ground); color: var(--ink); font-family: var(--sans); font-size: 14px; line-height: 1.45; }
|
||||
a { color: var(--link); }
|
||||
a:hover { color: var(--link-hover); }
|
||||
a { color: var(--link); text-decoration-color: rgba(28, 92, 171, .35); text-underline-offset: 3px; }
|
||||
a:hover { color: var(--link-hover); text-decoration-color: currentColor; }
|
||||
/* Links to another page (go, back) are ink with an arrow that nudges on
|
||||
hover; outside links (ext) keep the link colour and get ↗. */
|
||||
a.go, a.back { color: var(--ink); font-size: 13px; font-weight: 500; text-decoration: none; white-space: nowrap; }
|
||||
a.go:hover, a.back:hover { color: var(--link); }
|
||||
a.go .ar { margin-left: 4px; }
|
||||
a.back .ar { margin-right: 4px; }
|
||||
a.ext .ar { margin-left: 2px; font-size: .8em; }
|
||||
.ar { display: inline-block; transition: transform .15s; }
|
||||
a.go:hover .ar { transform: translateX(3px); }
|
||||
a.back:hover .ar { transform: translateX(-3px); }
|
||||
a.ext:hover .ar { transform: translate(2px, -2px); }
|
||||
@media (prefers-reduced-motion: reduce) { .ar { transition: none; } }
|
||||
:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; }
|
||||
[hidden] { display: none !important; }
|
||||
.mono { font-family: var(--mono); font-size: 13px; }
|
||||
@@ -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 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 .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; }
|
||||
/* 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. */
|
||||
@@ -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; }
|
||||
h1 { margin: 0; font-size: 26px; font-weight: 600; letter-spacing: -0.01em; overflow-wrap: anywhere; }
|
||||
.sub { margin: 4px 0 0; color: var(--ink-2); }
|
||||
.back { font-size: 13px; margin-bottom: -8px; }
|
||||
.back { margin-bottom: -8px; align-self: flex-start; }
|
||||
|
||||
/* cards */
|
||||
.card { background: var(--surface); border: 1px solid var(--line); border-radius: 12px; padding: 20px; min-width: 0; }
|
||||
@@ -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; }
|
||||
.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.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; }
|
||||
|
||||
/* tables */
|
||||
@@ -136,10 +154,16 @@ h1 { margin: 0; font-size: 26px; font-weight: 600; letter-spacing: -0.01em; over
|
||||
table { width: 100%; border-collapse: collapse; min-width: 720px; }
|
||||
table.narrow { min-width: 520px; }
|
||||
th { text-align: left; font-size: 12px; font-weight: 600; color: var(--ink-2); padding: 10px 12px; border-bottom: 1px solid var(--line); white-space: nowrap; }
|
||||
th .sort { display: inline-flex; align-items: center; gap: 4px; background: none; border: 0; padding: 0; font: inherit; color: inherit; cursor: pointer; }
|
||||
th .sort:hover, th .sort.on { color: var(--ink); }
|
||||
th .sort .arrow { font-size: 11px; opacity: 0.35; }
|
||||
th .sort:hover .arrow, th .sort.on .arrow { opacity: 1; }
|
||||
td { padding: 12px; border-bottom: 1px solid var(--line-2); vertical-align: middle; }
|
||||
tr:last-child td { border-bottom: 0; }
|
||||
.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||
td .note { font-size: 12px; color: var(--ink-3); }
|
||||
a.pname { color: var(--ink); font-weight: 600; text-decoration: none; }
|
||||
a.pname:hover { color: var(--link); }
|
||||
.empty { padding: 24px 12px; margin: 0; text-align: center; color: var(--ink-3); }
|
||||
|
||||
/* forms */
|
||||
@@ -167,10 +191,50 @@ fieldset { border: 0; margin: 0; padding: 0; min-width: 0; display: flex; flex-d
|
||||
.kv dt { color: var(--ink-2); }
|
||||
.kv dd { margin: 0; min-width: 0; overflow-wrap: anywhere; }
|
||||
|
||||
/* checks */
|
||||
.chk { display: flex; gap: 10px; align-items: center; padding: 10px 0; border-bottom: 1px solid var(--line-2); font-size: 13px; }
|
||||
.chk:last-child { border-bottom: 0; }
|
||||
.chk b { font-weight: 500; min-width: 160px; }
|
||||
/* 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 > span { font-size: 13px; color: var(--ink-2); }
|
||||
.hchead > span.bad { color: var(--bad-ink); font-weight: 500; }
|
||||
.hcaddrs { display: flex; flex-direction: column; gap: 12px; }
|
||||
.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 .v { margin-top: 4px; font-size: 15px; font-weight: 500; overflow-wrap: anywhere; }
|
||||
.hcaddr .v.mono { font-size: 22px; }
|
||||
.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 .v { color: var(--bad-ink); }
|
||||
.hclist { border: 1px solid var(--line); border-radius: 10px; 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); }
|
||||
.hcrow:first-child { border-top: 0; }
|
||||
.hcrow > .dot { align-self: center; }
|
||||
.hcrow .l { font-size: 13px; color: var(--ink-2); }
|
||||
.hcrow .v { display: flex; flex-wrap: wrap; align-items: baseline; gap: 2px 10px; min-width: 0; }
|
||||
.hcrow .s { font-weight: 500; }
|
||||
.hcrow .r { font-size: 12px; color: var(--ink-3); overflow-wrap: anywhere; }
|
||||
.hcrow .p { grid-column: 2 / -1; font-size: 12.5px; color: var(--bad-ink); overflow-wrap: anywhere; }
|
||||
.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 */
|
||||
.ev { display: flex; gap: 12px; padding: 10px 0; border-bottom: 1px solid var(--line-2); font-size: 13px; }
|
||||
@@ -202,7 +266,10 @@ fieldset { border: 0; margin: 0; padding: 0; min-width: 0; display: flex; flex-d
|
||||
.chart .grp span.up { background: var(--up); }
|
||||
.chart .grp span.total { background: var(--down); }
|
||||
.chart .grp.on span.total { background: var(--down-strong); }
|
||||
.xaxis { display: flex; justify-content: space-between; margin: 8px 0 0 56px; font-size: 11px; color: var(--axis-ink); }
|
||||
.xaxis { position: relative; height: 22px; margin-left: 56px; font-size: 11px; color: var(--axis-ink); }
|
||||
.xaxis span { position: absolute; top: 0; padding-top: 7px; transform: translateX(-50%); white-space: nowrap; font-variant-numeric: tabular-nums; }
|
||||
.xaxis span.edge::before { content: ''; position: absolute; left: 50%; top: 0; height: 4px; border-left: 1px solid var(--axis); }
|
||||
@media (max-width: 640px) { .xaxis span.minor { display: none; } }
|
||||
.chart.loading { opacity: .5; }
|
||||
.chart .plot { position: absolute; left: 56px; right: 0; top: 0; bottom: 1px; }
|
||||
.chart .plot svg { width: 100%; height: 100%; display: block; overflow: visible; }
|
||||
@@ -272,7 +339,6 @@ dialog::backdrop { background: rgba(22, 23, 26, .55); }
|
||||
.linkbtn { background: none; border: 0; padding: 4px; font: inherit; font-size: 13px; color: #9cc3f5; text-decoration: underline; cursor: pointer; align-self: center; }
|
||||
.linkbtn:hover { color: #fff; }
|
||||
.loginform .err-text:empty { display: none; }
|
||||
.loginfoot { margin: 0; font-family: var(--mono); font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase; color: #8d8e93; }
|
||||
.err-text { color: var(--bad-ink); font-size: 13px; margin: 0; }
|
||||
/* setup link page (setup.html): same dark look as the login page */
|
||||
.setuppage { min-height: 100vh; display: flex; justify-content: center; padding: 48px 16px; background: var(--ink); color: #f4f4f1; font-size: 15px; }
|
||||
@@ -280,8 +346,8 @@ dialog::backdrop { background: rgba(22, 23, 26, .55); }
|
||||
.setupbox { width: 100%; max-width: 400px; display: flex; flex-direction: column; gap: 24px; margin: auto 0; }
|
||||
.setupbox h1 { font-size: 22px; }
|
||||
.setupbox p { margin: 0; color: #c9c9c3; }
|
||||
.setupbox a { color: #9cc3f5; }
|
||||
.setupbox a:hover { color: #fff; }
|
||||
.setupbox a { color: #9cc3f5; text-decoration-color: rgba(156, 195, 245, .4); }
|
||||
.setupbox a:hover { color: #fff; text-decoration-color: currentColor; }
|
||||
.setupbox .center, .setupbox.center { text-align: center; display: flex; flex-direction: column; align-items: center; gap: 8px; }
|
||||
.setupbox.center { gap: 20px; }
|
||||
.setupbox .ghost { opacity: .45; }
|
||||
@@ -304,3 +370,51 @@ dialog::backdrop { background: rgba(22, 23, 26, .55); }
|
||||
.steps .btn.primary { background: #f4f4f1; border-color: #f4f4f1; color: var(--ink); font-weight: 600; }
|
||||
.steps .qr { width: 100%; height: auto; max-width: 280px; align-self: center; }
|
||||
.loading-page { padding: 40px; color: var(--ink-3); }
|
||||
|
||||
/* two-step sign-in */
|
||||
.loginalt { width: 100%; display: flex; flex-direction: column; gap: 14px; margin-top: -24px; }
|
||||
.loginalt .or, .loginform .or { display: flex; align-items: center; gap: 10px; color: #8d8e93; font-size: 12px; }
|
||||
.loginalt .or::before, .loginalt .or::after { content: ""; flex: 1; height: 1px; background: #2c2d32; }
|
||||
.loginpage .btn.altbtn { min-height: 44px; width: 100%; font-size: 15px; font-weight: 500; background: none; border-color: #3a3b41; color: #f4f4f1; margin-top: 0; }
|
||||
.loginpage .btn.altbtn:hover { background: #222328; border-color: #55565c; color: #fff; }
|
||||
.loginlinks { display: flex; flex-direction: column; align-items: center; gap: 2px; margin-top: 6px; }
|
||||
.loginform .codeinput { text-align: center; font-size: 20px; letter-spacing: 0.2em; }
|
||||
.mfalist { display: flex; flex-direction: column; }
|
||||
.mfarow { display: flex; align-items: center; gap: 8px; padding: 12px 0; border-top: 1px solid var(--line-2); }
|
||||
.mfarow:first-child { border-top: 0; padding-top: 0; }
|
||||
.mfarow .grow { flex: 1; min-width: 0; }
|
||||
.dlg .secret { font-size: 15px; letter-spacing: 0.04em; overflow-wrap: anywhere; }
|
||||
.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; } }
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -26,12 +27,23 @@ const (
|
||||
argonKeyLen = 32
|
||||
)
|
||||
|
||||
// Every argon2 run takes argonMemory (64 MiB). argonSlots caps how many run
|
||||
// at once, so a burst of sign-ins cannot run the server out of memory: two
|
||||
// slots are 128 MiB at most.
|
||||
var argonSlots = make(chan struct{}, 2)
|
||||
|
||||
func argonKey(pw, salt []byte, t, m uint32, p uint8, n uint32) []byte {
|
||||
argonSlots <- struct{}{}
|
||||
defer func() { <-argonSlots }()
|
||||
return argon2.IDKey(pw, salt, t, m, p, n)
|
||||
}
|
||||
|
||||
func hashPassword(pw string) (string, error) {
|
||||
salt := make([]byte, 16)
|
||||
if _, err := rand.Read(salt); err != nil {
|
||||
return "", err
|
||||
}
|
||||
key := argon2.IDKey([]byte(pw), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
|
||||
key := argonKey([]byte(pw), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
|
||||
b64 := base64.RawStdEncoding
|
||||
return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
|
||||
argon2.Version, argonMemory, argonTime, argonThreads, b64.EncodeToString(salt), b64.EncodeToString(key)), nil
|
||||
@@ -54,7 +66,7 @@ func verifyPassword(encoded, pw string) bool {
|
||||
if err1 != nil || err2 != nil {
|
||||
return false
|
||||
}
|
||||
got := argon2.IDKey([]byte(pw), salt, t, m, p, uint32(len(want)))
|
||||
got := argonKey([]byte(pw), salt, t, m, p, uint32(len(want)))
|
||||
return subtle.ConstantTimeCompare(got, want) == 1
|
||||
}
|
||||
|
||||
@@ -102,6 +114,9 @@ type principal struct {
|
||||
RemoteIP string
|
||||
// MustChangePassword blocks everything but changing the password.
|
||||
MustChangePassword bool
|
||||
// MFASetupRequired blocks everything but setting up two-step sign-in,
|
||||
// when it is required and the user has none.
|
||||
MFASetupRequired bool
|
||||
Session *sessionInfo // nil for API tokens
|
||||
}
|
||||
|
||||
@@ -137,36 +152,70 @@ type Auth struct {
|
||||
sessions map[string]*session
|
||||
used map[string]tokenUse
|
||||
logins map[string]tokenUse // last sign-in per user ID
|
||||
fails map[string]*failState
|
||||
fails map[string]*failState // by lockKey
|
||||
waiting int // sign-ins waiting for or running a password check
|
||||
mfa mfaState
|
||||
}
|
||||
|
||||
const (
|
||||
maxFailures = 5
|
||||
lockoutTime = 15 * time.Minute
|
||||
// maxWaiting sign-ins may wait for a password check; more are turned
|
||||
// away until the queue is shorter.
|
||||
maxWaiting = 16
|
||||
)
|
||||
|
||||
func newAuth(s *Store) *Auth {
|
||||
return &Auth{store: s, sessions: map[string]*session{}, used: map[string]tokenUse{}, logins: map[string]tokenUse{}, fails: map[string]*failState{}}
|
||||
return &Auth{store: s, sessions: map[string]*session{}, used: map[string]tokenUse{}, logins: map[string]tokenUse{}, fails: map[string]*failState{}, mfa: newMFAState()}
|
||||
}
|
||||
|
||||
func cookieName() string { return appName + "_session" }
|
||||
|
||||
var errLocked = errors.New("too many failed attempts, try again later")
|
||||
var (
|
||||
errLocked = errors.New("too many failed attempts, try again later")
|
||||
errBusy = errors.New("too many sign-ins at once, try again in a moment")
|
||||
)
|
||||
|
||||
// Login checks the credentials and returns a new session id.
|
||||
func (a *Auth) Login(user, pw, ip string) (string, error) {
|
||||
a.mu.Lock()
|
||||
f := a.fails[ip]
|
||||
if f != nil && time.Now().Before(f.until) {
|
||||
a.mu.Unlock()
|
||||
return "", errLocked
|
||||
// lockKey is what failed sign-ins are counted by: the IPv4 address, or the
|
||||
// /64 network of an IPv6 address, since one device can pick any address in
|
||||
// its /64.
|
||||
func lockKey(ip string) string {
|
||||
a, err := netip.ParseAddr(ip)
|
||||
if err != nil || a.Unmap().Is4() {
|
||||
return ip
|
||||
}
|
||||
a.mu.Unlock()
|
||||
p, _ := a.Prefix(64)
|
||||
return p.String()
|
||||
}
|
||||
|
||||
// Login checks the credentials and returns a new session id, or, for a user
|
||||
// with two-step sign-in, a ticket for the second step.
|
||||
func (a *Auth) Login(user, pw, ip string) (sessionID, ticket string, err error) {
|
||||
cfg := a.store.Get()
|
||||
if !cfg.passwordSet() {
|
||||
return "", errors.New("no password is set; run: " + appName + " passwd")
|
||||
return "", "", errors.New("no password is set; run: " + appName + " passwd")
|
||||
}
|
||||
// The attempt counts as failed before the password is checked, so
|
||||
// parallel attempts cannot get past the lockout; a right password takes
|
||||
// it back.
|
||||
a.mu.Lock()
|
||||
if a.lockedLocked(ip) {
|
||||
a.mu.Unlock()
|
||||
return "", "", errLocked
|
||||
}
|
||||
if a.waiting >= maxWaiting {
|
||||
a.mu.Unlock()
|
||||
return "", "", errBusy
|
||||
}
|
||||
a.waiting++
|
||||
undo := a.failLocked(ip)
|
||||
a.mu.Unlock()
|
||||
defer func() {
|
||||
a.mu.Lock()
|
||||
a.waiting--
|
||||
a.mu.Unlock()
|
||||
}()
|
||||
|
||||
// An unknown username costs as much time as a wrong password, so the
|
||||
// answer time does not tell which usernames exist.
|
||||
u := cfg.userByName(strings.TrimSpace(user))
|
||||
@@ -180,20 +229,15 @@ func (a *Auth) Login(user, pw, ip string) (string, error) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if !okUser || !okPw {
|
||||
if f == nil {
|
||||
f = &failState{}
|
||||
a.fails[ip] = f
|
||||
return "", "", errors.New("wrong username or password")
|
||||
}
|
||||
f.count++
|
||||
if f.count >= maxFailures {
|
||||
f.count = 0
|
||||
f.until = time.Now().Add(lockoutTime)
|
||||
undo()
|
||||
if u.hasMFA() {
|
||||
return "", a.newTicketLocked(u, ip), nil
|
||||
}
|
||||
return "", errors.New("wrong username or password")
|
||||
}
|
||||
delete(a.fails, ip)
|
||||
delete(a.fails, lockKey(ip))
|
||||
a.logins[u.ID] = tokenUse{At: time.Now(), IP: ip}
|
||||
return a.newSessionLocked(cfg, u, sessionInfo{Started: time.Now(), IP: ip}), nil
|
||||
return a.newSessionLocked(cfg, u, sessionInfo{Started: time.Now(), IP: ip}), "", nil
|
||||
}
|
||||
|
||||
// NewSession replaces a session after the user changed their password; it
|
||||
@@ -243,9 +287,14 @@ func remoteIP(r *http.Request) string {
|
||||
host = r.RemoteAddr
|
||||
}
|
||||
// Behind a local reverse proxy the real client is in X-Forwarded-For.
|
||||
// The proxy appends the address it saw, so only the last entry counts:
|
||||
// earlier ones come from the client and can be anything.
|
||||
if ip := net.ParseIP(host); ip != nil && ip.IsLoopback() {
|
||||
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
||||
return strings.TrimSpace(strings.Split(xff, ",")[0])
|
||||
if xff := r.Header.Values("X-Forwarded-For"); len(xff) > 0 {
|
||||
list := strings.Split(xff[len(xff)-1], ",")
|
||||
if last := strings.TrimSpace(list[len(list)-1]); net.ParseIP(last) != nil {
|
||||
return last
|
||||
}
|
||||
}
|
||||
}
|
||||
return host
|
||||
@@ -290,7 +339,8 @@ func (a *Auth) Authenticate(r *http.Request) (*principal, bool) {
|
||||
return nil, false
|
||||
}
|
||||
info := s.info
|
||||
return &principal{Name: u.Username, UserID: u.ID, Scope: "rw", IsAdmin: true, RemoteIP: ip, MustChangePassword: u.MustChangePassword, Session: &info}, true
|
||||
return &principal{Name: u.Username, UserID: u.ID, Scope: "rw", IsAdmin: true, RemoteIP: ip, MustChangePassword: u.MustChangePassword,
|
||||
MFASetupRequired: cfg.SignIn.RequireMFA && !u.hasMFA(), Session: &info}, true
|
||||
}
|
||||
|
||||
func (a *Auth) TokenUse(id string) *tokenUse {
|
||||
@@ -312,9 +362,21 @@ func (a *Auth) sweep() {
|
||||
delete(a.sessions, id)
|
||||
}
|
||||
}
|
||||
for ip, f := range a.fails {
|
||||
for key, f := range a.fails {
|
||||
if now.After(f.until) && f.count == 0 {
|
||||
delete(a.fails, ip)
|
||||
delete(a.fails, key)
|
||||
}
|
||||
}
|
||||
for id, t := range a.mfa.tickets {
|
||||
if now.After(t.expires) {
|
||||
delete(a.mfa.tickets, id)
|
||||
}
|
||||
}
|
||||
for _, m := range []map[string]*ceremony{a.mfa.logins, a.mfa.enrolls} {
|
||||
for id, c := range m {
|
||||
if now.After(c.expires) {
|
||||
delete(m, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ func peerAddresses(c *Config, p *Peer) []netip.Prefix {
|
||||
v4 := netip.MustParseAddr(p.IPv4)
|
||||
out := []netip.Prefix{netip.PrefixFrom(v4, 32)}
|
||||
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
|
||||
}
|
||||
@@ -77,7 +77,7 @@ func clientConfig(c *Config, p *Peer, privateKey string) string {
|
||||
addr := fmt.Sprintf("%s/%d", v4, v4net.Bits())
|
||||
if c.Server.IPv6Enabled {
|
||||
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 == "" {
|
||||
privateKey = "<the private key of this device>"
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
@@ -33,6 +34,23 @@ type Config struct {
|
||||
Log LogConfig `json:"log"`
|
||||
Stats StatsConfig `json:"stats"`
|
||||
Decoy DecoyConfig `json:"decoy"`
|
||||
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.
|
||||
type SignInConfig struct {
|
||||
// RequireMFA sends users without two-step sign-in to set it up before
|
||||
// they can do anything else. API tokens are not affected.
|
||||
RequireMFA bool `json:"requireMfa"`
|
||||
}
|
||||
|
||||
// DecoyConfig replaces the web interface with a stock web server page.
|
||||
@@ -59,8 +77,29 @@ const (
|
||||
minLogFiles, maxLogFiles = 1, 100
|
||||
minHourlyHours, maxHourlyHrs = 24, 24 * 31
|
||||
minDailyDays, maxDailyDays = 7, 3660
|
||||
minSessionHours = 1
|
||||
maxSessionHours = 30 * 24
|
||||
)
|
||||
|
||||
// validateListen checks a listen address like ":443" or "192.0.2.1:443".
|
||||
// Empty is allowed when optional (the HTTP listener is then off).
|
||||
func validateListen(addr, field string, optional bool) error {
|
||||
if addr == "" && optional {
|
||||
return nil
|
||||
}
|
||||
host, port, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s %q must look like :443 or 192.0.2.1:443", field, addr)
|
||||
}
|
||||
if n, err := strconv.Atoi(port); err != nil || n < 1 || n > 65535 {
|
||||
return fmt.Errorf("%s %q: the port must be 1–65535", field, addr)
|
||||
}
|
||||
if host != "" && host != "localhost" && checkEndpoint(host) != nil {
|
||||
return fmt.Errorf("%s %q: %q is not an IP address or host name", field, addr, host)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type WebConfig struct {
|
||||
Listen string `json:"listen"` // HTTPS (or HTTP when tls.mode is "off") listen address
|
||||
HTTPListen string `json:"httpListen"` // plain HTTP for ACME http-01 and redirects; "" disables
|
||||
@@ -92,6 +131,7 @@ type User struct {
|
||||
// the user can do nothing else until they pick their own.
|
||||
MustChangePassword bool `json:"mustChangePassword,omitempty"`
|
||||
Created time.Time `json:"created"`
|
||||
MFA *UserMFA `json:"mfa,omitempty"` // two-step sign-in, nil when never set up
|
||||
}
|
||||
|
||||
type APIToken struct {
|
||||
@@ -139,6 +179,10 @@ type Peer struct {
|
||||
PublicKey string `json:"publicKey"`
|
||||
PresharedKey string `json:"presharedKey,omitempty"`
|
||||
IPv4 string `json:"ipv4"`
|
||||
// IPv6 is set only for a peer imported from pivpn, which numbers IPv6
|
||||
// differently: its device keeps the address until the config is issued
|
||||
// here. Empty means the address mapped from IPv4 (see mapIPv6).
|
||||
IPv6 string `json:"ipv6,omitempty"`
|
||||
DNS []string `json:"dns,omitempty"` // nil = server default
|
||||
AllowedIPs []string `json:"allowedIPs,omitempty"` // nil = server default
|
||||
Keepalive *int `json:"keepalive,omitempty"` // nil = server default
|
||||
@@ -195,6 +239,9 @@ func (c *Config) applyDefaults() {
|
||||
c.Users = []User{u}
|
||||
}
|
||||
c.Admin = nil
|
||||
for i := range c.Users {
|
||||
dropSecurityKeys(&c.Users[i])
|
||||
}
|
||||
for i := range c.APITokens {
|
||||
if c.APITokens[i].UserID == "" {
|
||||
c.APITokens[i].UserID = c.Users[0].ID // tokens from before users existed
|
||||
@@ -234,6 +281,9 @@ func (c *Config) applyDefaults() {
|
||||
if c.Decoy.Page == "" {
|
||||
c.Decoy.Page = "nginx"
|
||||
}
|
||||
if c.Updates.Source == "" {
|
||||
c.Updates.Source = "gitea"
|
||||
}
|
||||
if c.APITokens == nil {
|
||||
c.APITokens = []APIToken{}
|
||||
}
|
||||
@@ -340,7 +390,9 @@ func (c *Config) validate() error {
|
||||
if v6.Masked() != v6 {
|
||||
return fmt.Errorf("IPv6 network must be the network address, e.g. %s", v6.Masked())
|
||||
}
|
||||
if s.Endpoint != "" && strings.ContainsAny(s.Endpoint, " /:") && net.ParseIP(s.Endpoint) == nil {
|
||||
// The endpoint is written into client configs as is, so it must be a
|
||||
// plain host name or IP: anything else could add lines to them.
|
||||
if s.Endpoint != "" && checkEndpoint(s.Endpoint) != nil {
|
||||
return errors.New("endpoint must be a host name or IP address without port")
|
||||
}
|
||||
if err := validateHostList(s.ClientDefaults.DNS, "DNS", false); err != nil {
|
||||
@@ -370,6 +422,18 @@ func (c *Config) validate() error {
|
||||
if _, ok := decoyPages[c.Decoy.Page]; !ok {
|
||||
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 {
|
||||
case "acme":
|
||||
if c.Web.TLS.Domain == "" {
|
||||
@@ -413,6 +477,7 @@ func (c *Config) validate() error {
|
||||
|
||||
names := map[string]bool{}
|
||||
ips := map[netip.Addr]bool{}
|
||||
ips6 := map[netip.Addr]bool{}
|
||||
keys := map[string]bool{}
|
||||
for _, p := range c.Peers {
|
||||
if err := validatePeerName(p.Name); err != nil {
|
||||
@@ -433,6 +498,17 @@ func (c *Config) validate() error {
|
||||
return fmt.Errorf("address %s is used twice", ip)
|
||||
}
|
||||
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] {
|
||||
return fmt.Errorf("peer %q: public key is used by another peer", p.Name)
|
||||
}
|
||||
@@ -559,6 +635,12 @@ func (s *Store) Update(fn func(c *Config) error) error {
|
||||
s.mu.Unlock()
|
||||
return &userError{err.Error()}
|
||||
}
|
||||
// With no user left (applyDefaults then adds an "admin" without a
|
||||
// password), nobody could sign in until someone ran "passwd" on the server.
|
||||
if old.passwordSet() && !next.passwordSet() {
|
||||
s.mu.Unlock()
|
||||
return &userError{"this would leave no user with a password, and nobody could sign in"}
|
||||
}
|
||||
if err := writeFileAtomic(s.path, next, 0o600); err != nil {
|
||||
s.mu.Unlock()
|
||||
return err
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
type decoyPage struct {
|
||||
server string // Server header, "" for none
|
||||
index func(host string) string // the front page
|
||||
indexCode int // status of the front page, 0 for 200
|
||||
error func(code int, r *http.Request) string // body for 404 and 405
|
||||
}
|
||||
|
||||
@@ -20,6 +21,10 @@ var decoyPages = map[string]decoyPage{
|
||||
"nginx": {server: nginxServer, index: func(string) string { return nginxIndex }, error: nginxError},
|
||||
"apache": {server: apacheServer, index: func(string) string { return apacheIndex }, error: apacheError},
|
||||
"soon": {index: soonIndex, error: soonError},
|
||||
// Generic pages that name no server software.
|
||||
"blank": {index: func(string) string { return "" }, error: func(int, *http.Request) string { return "" }},
|
||||
"forbidden": {index: func(string) string { return forbiddenIndex }, indexCode: http.StatusForbidden, error: soonError},
|
||||
"private": {index: func(string) string { return privateIndex }, error: soonError},
|
||||
}
|
||||
|
||||
// serveDecoy writes the decoy's answer for r. It drops the headers the web
|
||||
@@ -43,6 +48,9 @@ func serveDecoy(w http.ResponseWriter, r *http.Request, name string) {
|
||||
code, body = http.StatusMethodNotAllowed, d.error(http.StatusMethodNotAllowed, r)
|
||||
case r.URL.Path == "/" || r.URL.Path == "/index.html":
|
||||
body = d.index(hostOnly(r.Host))
|
||||
if d.indexCode != 0 {
|
||||
code = d.indexCode
|
||||
}
|
||||
default:
|
||||
code, body = http.StatusNotFound, d.error(http.StatusNotFound, r)
|
||||
}
|
||||
@@ -537,3 +545,36 @@ const apacheIndex = `<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
|
||||
const forbiddenIndex = `<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>403 Forbidden</title></head>
|
||||
<body>
|
||||
<h1>Forbidden</h1>
|
||||
<p>You don't have permission to access this resource.</p>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
|
||||
const privateIndex = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Private</title>
|
||||
<style>
|
||||
html, body { height: 100%; margin: 0; }
|
||||
body { display: flex; align-items: center; justify-content: center; background: #111; color: #999;
|
||||
font-family: Georgia, serif; text-align: center; }
|
||||
h1 { font-size: 28px; font-weight: normal; letter-spacing: 0.04em; color: #fff; margin: 0 0 10px; }
|
||||
p { margin: 0; font-size: 15px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Private server</h1>
|
||||
<p>Nothing to see here.</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
|
||||
@@ -3,6 +3,7 @@ module ghostwire
|
||||
go 1.27.1
|
||||
|
||||
require (
|
||||
github.com/go-webauthn/webauthn v0.18.2
|
||||
github.com/google/nftables v0.3.0
|
||||
github.com/oschwald/maxminddb-golang v1.13.1
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
||||
@@ -15,11 +16,20 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/fxamacker/cbor/v2 v2.9.4 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||
github.com/go-webauthn/x v0.3.1 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||
github.com/google/go-cmp v0.6.0 // indirect
|
||||
github.com/google/go-tpm v0.9.8 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/mdlayher/genetlink v1.3.2 // indirect
|
||||
github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 // indirect
|
||||
github.com/mdlayher/socket v0.5.1 // indirect
|
||||
github.com/philhofer/fwd v1.2.0 // indirect
|
||||
github.com/tinylib/msgp v1.6.4 // indirect
|
||||
github.com/vishvananda/netns v0.0.5 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
golang.org/x/sync v0.23.0 // indirect
|
||||
golang.org/x/text v0.42.0 // indirect
|
||||
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173 // indirect
|
||||
|
||||
@@ -1,9 +1,23 @@
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/fxamacker/cbor/v2 v2.9.4 h1:xwjVlxEMR3S605oUlgBjKLTTeGFciYPGYCtF/35LKGo=
|
||||
github.com/fxamacker/cbor/v2 v2.9.4/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/go-webauthn/webauthn v0.18.2 h1:0BeftmEHU7i3Dv0VFwBtidy/ba37Vcdjvqst9EYu8Sk=
|
||||
github.com/go-webauthn/webauthn v0.18.2/go.mod h1:hEXaOuLxvZ3zG9miZe3ehlyeVso9AtklXG+kTn36k+A=
|
||||
github.com/go-webauthn/x v0.3.1 h1:1ff37z3XfmTTomkhlURgGizLIDyOvPgTt2t9nlzKLRo=
|
||||
github.com/go-webauthn/x v0.3.1/go.mod h1:ZInxAynYXfBPvvm5gzKZ7geBlL23K71xASMgohHl/Rg=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
|
||||
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
|
||||
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLzH5KDR0gKc0zcktin6KSAwL7+jWKBYceddTc=
|
||||
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc=
|
||||
github.com/google/nftables v0.3.0 h1:bkyZ0cbpVeMHXOrtlFc8ISmfVqq5gPJukoYieyVmITg=
|
||||
github.com/google/nftables v0.3.0/go.mod h1:BCp9FsrbF1Fn/Yu6CLUc9GGZFw/+hsxfluNXXmxBfRM=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/mdlayher/genetlink v1.3.2 h1:KdrNKe+CTu+IbZnm/GVUMXSqBBLqcGpRDa0xkQy56gw=
|
||||
github.com/mdlayher/genetlink v1.3.2/go.mod h1:tcC3pkCrPUGIKKsCsp0B3AdaaKuHtaxoJRz3cc+528o=
|
||||
github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 h1:A1Cq6Ysb0GM0tpKMbdCXCIfBclan4oHk1Jb+Hrejirg=
|
||||
@@ -14,16 +28,24 @@ github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721 h1:RlZweED6sbSArvlE9
|
||||
github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721/go.mod h1:Ickgr2WtCLZ2MDGd4Gr0geeCH5HybhRJbonOgQpvSxc=
|
||||
github.com/oschwald/maxminddb-golang v1.13.1 h1:G3wwjdN9JmIK2o/ermkHM+98oX5fS+k5MbwsmL4MRQE=
|
||||
github.com/oschwald/maxminddb-golang v1.13.1/go.mod h1:K4pgV9N/GcK694KSTmVSDTODk4IsCNThNdTmnaBZ/F8=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
||||
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
|
||||
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
|
||||
github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
|
||||
github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
|
||||
github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0=
|
||||
github.com/vishvananda/netlink v1.3.1/go.mod h1:ARtKouGSTGchR8aMwmkzC0qiNPrrWO5JS/XMVl45+b4=
|
||||
github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY=
|
||||
github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
||||
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
||||
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
|
||||
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
|
||||
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
|
||||
@@ -42,5 +64,3 @@ golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173 h1:/jFs0duh4rdb8uI
|
||||
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173/go.mod h1:tkCQ4FQXmpAgYVh++1cq16/dH4QJtmvpRv19DWGAHSA=
|
||||
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 h1:3GDAcqdIg1ozBNLgPy4SLT84nfcBjr6rhGtXYtrkWLU=
|
||||
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10/go.mod h1:T97yPqesLiNrOYxkwmhMI0ZIlJDm+p0PMR8eRVeR5tQ=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"cmp"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -21,6 +22,7 @@ type installPlan struct {
|
||||
noEmail bool // remove an existing Let's Encrypt email
|
||||
passwordHash string // asked in the terminal; empty: asked later
|
||||
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}$`)
|
||||
@@ -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 != ""
|
||||
}
|
||||
|
||||
// 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) {
|
||||
if p.noDomain {
|
||||
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
|
||||
// the endpoint host or port changes.
|
||||
func (p installPlan) reissueCount(cur *Config, existing bool) int {
|
||||
if !existing {
|
||||
if !existing && p.pivpn == nil {
|
||||
return 0
|
||||
}
|
||||
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].")
|
||||
}
|
||||
|
||||
// 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
|
||||
fmt.Println("\nWeb interface")
|
||||
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) {
|
||||
next := cur.clone()
|
||||
if !existing {
|
||||
if !existing && p.pivpn == nil {
|
||||
next.Server.IPv4 = p.ipv4
|
||||
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)
|
||||
}
|
||||
tunnel := next.Server.IPv4
|
||||
if !existing {
|
||||
switch {
|
||||
case p.pivpn != nil:
|
||||
tunnel += " (from pivpn)"
|
||||
case !existing:
|
||||
tunnel += " (random free range)"
|
||||
}
|
||||
if next.Server.IPv6Enabled {
|
||||
@@ -395,4 +423,21 @@ func printInstallSummary(cur *Config, existing bool, p installPlan) {
|
||||
if p.passwordHash != "" {
|
||||
fmt.Printf(" Admin %s (password set)\n", next.Users[0].Username)
|
||||
}
|
||||
if pv := p.pivpn; pv != nil {
|
||||
s := next.Server
|
||||
fmt.Printf(" From pivpn server key, %s with their keys and addresses,\n", plural(len(pv.Peers), "peer"))
|
||||
fmt.Printf(" DNS %s · keepalive %d s · MTU %d\n", strings.Join(s.ClientDefaults.DNS, ", "), s.ClientDefaults.Keepalive, s.MTU)
|
||||
for _, r := range pv.Renamed {
|
||||
fmt.Printf(" Renamed %s → %s (names here: 1–32 letters, digits, . @ _ -)\n", r[0], r[1])
|
||||
}
|
||||
fmt.Printf(" Not taken client private keys in %s (never stored here)\n", pv.ClientKeys)
|
||||
}
|
||||
}
|
||||
|
||||
// plural writes "1 peer" or "5 peers".
|
||||
func plural(n int, word string) string {
|
||||
if n == 1 {
|
||||
return "1 " + word
|
||||
}
|
||||
return strconv.Itoa(n) + " " + word + "s"
|
||||
}
|
||||
|
||||
@@ -122,6 +122,15 @@ func nextFreeIPv4(c *Config) (netip.Addr, error) {
|
||||
// capacity is the number of peer addresses in the tunnel network.
|
||||
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
|
||||
// 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 {
|
||||
|
||||
@@ -3,6 +3,9 @@ package main
|
||||
import (
|
||||
"log/slog"
|
||||
"net/netip"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
@@ -38,6 +41,37 @@ type Kernel interface {
|
||||
Close() error
|
||||
}
|
||||
|
||||
// lanBlock picks, from the networks on the uplinks, the ones peers must not
|
||||
// reach while LAN access is off: private IPv4 networks, and IPv6 networks
|
||||
// except link-local, since a home LAN uses global IPv6 addresses. IPv6
|
||||
// prefixes shorter than /48 are left out: they are no LAN.
|
||||
func lanBlock(nets []netip.Prefix) []netip.Prefix {
|
||||
var out []netip.Prefix
|
||||
for _, p := range nets {
|
||||
a := p.Addr().Unmap()
|
||||
p = netip.PrefixFrom(a, min(p.Bits(), a.BitLen())).Masked()
|
||||
switch {
|
||||
case a.Is4() && !a.IsPrivate():
|
||||
continue
|
||||
case a.Is6() && (a.IsLinkLocalUnicast() || a.IsLoopback() || p.Bits() < 48):
|
||||
continue
|
||||
}
|
||||
if !slices.Contains(out, p) {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// readSysctl returns the trimmed content of a /proc/sys file, or "".
|
||||
func readSysctl(path string) string {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(b))
|
||||
}
|
||||
|
||||
// Reconciler applies the config to the kernel whenever it is triggered and
|
||||
// remembers the outcome for the health report.
|
||||
type Reconciler struct {
|
||||
@@ -45,6 +79,10 @@ type Reconciler struct {
|
||||
store *Store
|
||||
trigger chan struct{}
|
||||
|
||||
// applyMu runs one apply at a time. Each reads the config once it holds
|
||||
// the lock, so the last apply always uses the newest config.
|
||||
applyMu sync.Mutex
|
||||
|
||||
mu sync.Mutex
|
||||
lastErr error
|
||||
lastApply time.Time
|
||||
@@ -65,6 +103,8 @@ func (r *Reconciler) Kick() {
|
||||
// ApplyNow applies synchronously and returns the result, so an API call can
|
||||
// report kernel errors to the user.
|
||||
func (r *Reconciler) ApplyNow() error {
|
||||
r.applyMu.Lock()
|
||||
defer r.applyMu.Unlock()
|
||||
err := r.kernel.Apply(r.store.Get())
|
||||
r.mu.Lock()
|
||||
r.lastErr, r.lastApply = err, time.Now()
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/vishvananda/netlink"
|
||||
"golang.zx2c4.com/wireguard/wgctrl"
|
||||
@@ -217,7 +217,8 @@ func (k *linuxKernel) Apply(c *Config) error {
|
||||
if c.Server.IPv6Enabled {
|
||||
_ = os.WriteFile("/proc/sys/net/ipv6/conf/all/forwarding", []byte("1"), 0o644)
|
||||
}
|
||||
return applyFirewall(c, k.Uplink(c, false), k.Uplink(c, true), lanNetworks(k.Uplink(c, false)))
|
||||
up4, up6 := k.Uplink(c, false), k.Uplink(c, true)
|
||||
return applyFirewall(c, up4, up6, lanNetworks(up4, up6))
|
||||
}
|
||||
|
||||
func (k *linuxKernel) Sample(iface string) ([]PeerSample, error) {
|
||||
@@ -264,27 +265,27 @@ func (k *linuxKernel) Uplink(c *Config, v6 bool) string {
|
||||
return l.Attrs().Name
|
||||
}
|
||||
|
||||
// lanNetworks returns the private IPv4 networks on the uplink, used to block
|
||||
// peers from the server's LAN when LAN access is off.
|
||||
func lanNetworks(uplink string) []netip.Prefix {
|
||||
if uplink == "" {
|
||||
return nil
|
||||
// lanNetworks returns the LAN networks on the IPv4 and IPv6 uplinks (see
|
||||
// lanBlock), used to block peers from the server's LAN when LAN access is off.
|
||||
func lanNetworks(uplinks ...string) []netip.Prefix {
|
||||
var nets []netip.Prefix
|
||||
for i, uplink := range uplinks {
|
||||
if uplink == "" || slices.Contains(uplinks[:i], uplink) {
|
||||
continue
|
||||
}
|
||||
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
|
||||
}
|
||||
addrs, _ := netlink.AddrList(l, netlink.FAMILY_ALL)
|
||||
for _, a := range addrs {
|
||||
ones, _ := a.Mask.Size()
|
||||
ip, _ := netip.AddrFromSlice(a.IP.To4())
|
||||
out = append(out, netip.PrefixFrom(ip, ones).Masked())
|
||||
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
|
||||
@@ -318,14 +319,6 @@ func publicAddr(uplink string, v6 bool) (bool, string) {
|
||||
return false, "no address on " + uplink
|
||||
}
|
||||
|
||||
func readSysctl(path string) string {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(b))
|
||||
}
|
||||
|
||||
func (k *linuxKernel) Checks(c *Config) []Check {
|
||||
var out []Check
|
||||
link, err := netlink.LinkByName(c.Server.Interface)
|
||||
@@ -341,6 +334,19 @@ func (k *linuxKernel) Checks(c *Config) []Check {
|
||||
v := readSysctl("/proc/sys/net/ipv6/conf/all/forwarding")
|
||||
out = append(out, Check{"IPv6 forwarding", v == "1", "net.ipv6.conf.all.forwarding=" + v})
|
||||
}
|
||||
// With IPv6 forwarding on, accept_ra 1 means router announcements are
|
||||
// ignored: an IPv6 route learned from them expires (see sysctlConf).
|
||||
if readSysctl("/proc/sys/net/ipv6/conf/all/forwarding") == "1" {
|
||||
up := cmp.Or(k.Uplink(c, true), k.Uplink(c, false))
|
||||
if ra := readSysctl("/proc/sys/net/ipv6/conf/" + up + "/accept_ra"); up != "" && ra != "" {
|
||||
ok := ra != "1"
|
||||
detail := "net.ipv6.conf." + up + ".accept_ra=" + ra
|
||||
if !ok {
|
||||
detail += ": IPv6 from router announcements stops working; run " + appName + " update"
|
||||
}
|
||||
out = append(out, Check{"IPv6 router announcements", ok, detail})
|
||||
}
|
||||
}
|
||||
ok, detail := firewallPresent()
|
||||
out = append(out, Check{"nftables rules", ok, detail})
|
||||
up4 := k.Uplink(c, false)
|
||||
@@ -362,9 +368,11 @@ func (k *linuxKernel) Checks(c *Config) []Check {
|
||||
|
||||
func (k *linuxKernel) Down(c *Config) error {
|
||||
var errs []error
|
||||
if c.Server.Interface != "" {
|
||||
if link, err := netlink.LinkByName(c.Server.Interface); err == nil {
|
||||
errs = append(errs, netlink.LinkDel(link))
|
||||
}
|
||||
}
|
||||
errs = append(errs, removeFirewall())
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
@@ -5,8 +5,10 @@ package main
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"maps"
|
||||
"math/rand/v2"
|
||||
"net/netip"
|
||||
"slices"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
@@ -17,6 +19,7 @@ import (
|
||||
type simKernel struct {
|
||||
mu sync.Mutex
|
||||
peers map[string]*PeerSample
|
||||
last time.Time // previous Sample; traffic grows with the time since
|
||||
}
|
||||
|
||||
func newKernel() (Kernel, error) {
|
||||
@@ -53,17 +56,23 @@ func (k *simKernel) Apply(c *Config) error {
|
||||
func (k *simKernel) Sample(string) ([]PeerSample, error) {
|
||||
k.mu.Lock()
|
||||
defer k.mu.Unlock()
|
||||
now := time.Now()
|
||||
f := 1.0
|
||||
if !k.last.IsZero() {
|
||||
f = now.Sub(k.last).Seconds() / 30
|
||||
}
|
||||
k.last = now
|
||||
var out []PeerSample
|
||||
i := 0
|
||||
for _, p := range k.peers {
|
||||
// Every third peer stays idle; the others move some data.
|
||||
for i, key := range slices.Sorted(maps.Keys(k.peers)) {
|
||||
p := k.peers[key]
|
||||
// Every third peer stays idle; the others move some data, scaled to
|
||||
// the time since the previous sample.
|
||||
if i%3 != 2 {
|
||||
p.TxBytes += rand.Int64N(40 << 20)
|
||||
p.RxBytes += rand.Int64N(6 << 20)
|
||||
p.LastHandshake = time.Now().Add(-time.Duration(rand.IntN(90)) * time.Second)
|
||||
p.TxBytes += int64(float64(rand.Int64N(40<<20)) * f)
|
||||
p.RxBytes += int64(float64(rand.Int64N(6<<20)) * f)
|
||||
p.LastHandshake = now.Add(-time.Duration(rand.IntN(90)) * time.Second)
|
||||
}
|
||||
out = append(out, *p)
|
||||
i++
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -219,18 +219,25 @@ func run(configPath string) error {
|
||||
var stopOnce sync.Once
|
||||
shutdown := func() { stopOnce.Do(func() { close(stop) }) }
|
||||
|
||||
speeds := newSpeeds(store, kernel)
|
||||
auth := newAuth(store)
|
||||
app := &App{
|
||||
store: store, kernel: kernel, recon: recon, stats: stats, auth: auth, tls: webTLS,
|
||||
logPath: logPath, logw: logw, geo: geo, started: time.Now(), shutdown: shutdown,
|
||||
store: store, kernel: kernel, recon: recon, stats: stats, speeds: speeds, auth: auth, tls: webTLS,
|
||||
logPath: logPath, logw: logw, geo: geo, updates: newUpdater(cfg.Updates), started: time.Now(), shutdown: shutdown,
|
||||
webAddrs: []string{cfg.Web.Listen},
|
||||
}
|
||||
if cfg.Web.HTTPListen != "" && cfg.Web.TLS.Mode != "off" {
|
||||
app.webAddrs = append(app.webAddrs, cfg.Web.HTTPListen)
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(4)
|
||||
wg.Add(6)
|
||||
go func() { defer wg.Done(); recon.Run(stop) }()
|
||||
go func() { defer wg.Done(); stats.Run(stop) }()
|
||||
go func() { defer wg.Done(); speeds.Run(stop) }()
|
||||
go func() { defer wg.Done(); stats.RunPings(stop) }()
|
||||
go func() { defer wg.Done(); geo.Run(stop) }()
|
||||
go func() { defer wg.Done(); app.updates.Run(stop) }()
|
||||
go func() {
|
||||
t := time.NewTicker(10 * time.Minute)
|
||||
defer t.Stop()
|
||||
|
||||
@@ -6,13 +6,16 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -76,6 +79,16 @@ func TestValidate(t *testing.T) {
|
||||
"bad dns": func(c *Config) { c.Peers[0].DNS = []string{"dns.example"} },
|
||||
"bad port": func(c *Config) { c.Server.ListenPort = 70000 },
|
||||
"unmasked net": func(c *Config) { c.Server.IPv4 = "10.84.12.5/24" },
|
||||
"update source": func(c *Config) { c.Updates.Source = "sourceforge" },
|
||||
// The endpoint goes into client configs: no extra lines.
|
||||
"endpoint newline": func(c *Config) { c.Server.Endpoint = "vpn.example.net\n[Interface]\nPreUp=id;#" },
|
||||
"endpoint tab": func(c *Config) { c.Server.Endpoint = "vpn.example.net\tx" },
|
||||
"endpoint port": func(c *Config) { c.Server.Endpoint = "vpn.example.net:51820" },
|
||||
"listen": func(c *Config) { c.Web.Listen = "not-an-address" },
|
||||
"listen port": func(c *Config) { c.Web.Listen = ":70000" },
|
||||
"http listen": func(c *Config) { c.Web.HTTPListen = "80" },
|
||||
"session hours": func(c *Config) { c.Web.SessionHours = -1 },
|
||||
"session too long": func(c *Config) { c.Web.SessionHours = 100000 },
|
||||
} {
|
||||
cc := c.clone()
|
||||
mutate(cc)
|
||||
@@ -83,6 +96,20 @@ func TestValidate(t *testing.T) {
|
||||
t.Errorf("%s: expected an error", name)
|
||||
}
|
||||
}
|
||||
for _, ep := range []string{"vpn.example.net", "203.0.113.7", "2001:db8::1"} {
|
||||
cc := c.clone()
|
||||
cc.Server.Endpoint = ep
|
||||
if err := cc.validate(); err != nil {
|
||||
t.Errorf("endpoint %q rejected: %v", ep, err)
|
||||
}
|
||||
}
|
||||
for _, l := range []string{":443", "0.0.0.0:8443", "[::]:443", "localhost:8080"} {
|
||||
cc := c.clone()
|
||||
cc.Web.Listen = l
|
||||
if err := cc.validate(); err != nil {
|
||||
t.Errorf("listen %q rejected: %v", l, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientConfig(t *testing.T) {
|
||||
@@ -311,8 +338,13 @@ func TestAPI(t *testing.T) {
|
||||
secret := tok["token"].(string)
|
||||
|
||||
// Read-only token: GET works, changes are refused, admin endpoints too.
|
||||
bearer := func(method, path string, want int) {
|
||||
req, _ := http.NewRequest(method, srv.URL+"/api/v1"+path, nil)
|
||||
bearer := func(method, path string, want int, body ...any) {
|
||||
var rd io.Reader
|
||||
if len(body) > 0 {
|
||||
b, _ := json.Marshal(body[0])
|
||||
rd = bytes.NewReader(b)
|
||||
}
|
||||
req, _ := http.NewRequest(method, srv.URL+"/api/v1"+path, rd)
|
||||
req.Header.Set("Authorization", "Bearer "+secret)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
@@ -328,11 +360,45 @@ func TestAPI(t *testing.T) {
|
||||
bearer("GET", "/tokens", 403)
|
||||
bearer("GET", "/peers/"+id+"/setup", 403) // the link would set up a device
|
||||
|
||||
// A full-access token manages users and tokens, but not backups.
|
||||
// A full-access token changes settings, but users, passwords, tokens,
|
||||
// the sign-in rules and backups need a signed-in user.
|
||||
secret = call("POST", "/tokens", map[string]string{"name": "full", "scope": "rw"}, 201)["token"].(string)
|
||||
bearer("GET", "/users", 200)
|
||||
bearer("GET", "/tokens", 200)
|
||||
uid := call("GET", "/auth/me", nil, 200)["id"].(string)
|
||||
bearer("PATCH", "/settings", 200, map[string]any{"log": store.Get().Log})
|
||||
bearer("PATCH", "/settings", 403, map[string]any{"signin": map[string]bool{"requireMfa": false}})
|
||||
bearer("GET", "/users", 403)
|
||||
bearer("POST", "/users", 403, map[string]any{"username": "eve", "password": "correct horse battery"})
|
||||
bearer("POST", "/users/"+uid+"/reset-password", 403, map[string]any{"password": "correct horse battery"})
|
||||
bearer("POST", "/users/"+uid+"/reset-mfa", 403)
|
||||
bearer("POST", "/auth/password", 403, map[string]string{"current": "x", "new": "y"})
|
||||
bearer("GET", "/tokens", 403)
|
||||
bearer("POST", "/tokens", 403, map[string]string{"name": "more", "scope": "rw"})
|
||||
bearer("DELETE", "/tokens/"+tok["id"].(string), 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)
|
||||
if len(store.Get().Peers) != 0 {
|
||||
@@ -376,6 +442,92 @@ func TestUnitFile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSysctlConf(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
conf, sys := filepath.Join(dir, "conf"), filepath.Join(dir, "net")
|
||||
for name, ra := range map[string]string{"eth0": "1", "wlan0": "2", "eth1": "0", "br0": "1", "veth1": "1", "lo": "1"} {
|
||||
_ = os.MkdirAll(filepath.Join(conf, name), 0o755)
|
||||
_ = os.WriteFile(filepath.Join(conf, name, "accept_ra"), []byte(ra+"\n"), 0o644)
|
||||
}
|
||||
for _, name := range []string{"eth0", "wlan0", "eth1"} { // network cards
|
||||
_ = os.MkdirAll(filepath.Join(sys, name, "device"), 0o755)
|
||||
}
|
||||
_ = os.MkdirAll(filepath.Join(sys, "veth1"), 0o755)
|
||||
// br0 carries the default route; the lo line is the kernel's unreachable route.
|
||||
routes := filepath.Join(dir, "ipv6_route")
|
||||
_ = os.WriteFile(routes, []byte(
|
||||
"00000000000000000000000000000000 00 00000000000000000000000000000000 00 fe800000000000000000000000000001 00000400 00000001 00000000 00000003 br0\n"+
|
||||
"00000000000000000000000000000000 00 00000000000000000000000000000000 00 00000000000000000000000000000000 ffffffff 00000001 00000000 00200200 lo\n"), 0o644)
|
||||
|
||||
got := raInterfaces(conf, sys, routes)
|
||||
if want := []string{"br0", "eth0", "wlan0"}; !slices.Equal(got, want) {
|
||||
t.Fatalf("raInterfaces = %v, want %v", got, want)
|
||||
}
|
||||
c := sysctlConf(got)
|
||||
for _, want := range []string{"net.ipv6.conf.all.forwarding=1\n", "net.ipv6.conf.default.accept_ra=2\n", "net.ipv6.conf.eth0.accept_ra=2\n", "net.ipv6.conf.br0.accept_ra=2\n"} {
|
||||
if !strings.Contains(c, want) {
|
||||
t.Errorf("sysctl conf lacks %q:\n%s", want, c)
|
||||
}
|
||||
}
|
||||
if strings.Contains(c, "eth1") || strings.Contains(c, "veth1") {
|
||||
t.Errorf("sysctl conf names eth1 (accept_ra 0) or veth1 (virtual):\n%s", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginLockout(t *testing.T) {
|
||||
store, err := openStore(filepath.Join(t.TempDir(), "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 })
|
||||
a := newAuth(store)
|
||||
const right, wrong = "a long test password", "a wrong password"
|
||||
|
||||
// Ten wrong attempts at once from one /64: five are checked, the others
|
||||
// are locked out before any password check.
|
||||
var wg sync.WaitGroup
|
||||
var mu sync.Mutex
|
||||
got := map[string]int{}
|
||||
for i := range 10 {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_, _, err := a.Login("admin", wrong, fmt.Sprintf("2001:db8::%x", i+1))
|
||||
mu.Lock()
|
||||
got[err.Error()]++
|
||||
mu.Unlock()
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
if got["wrong username or password"] != 5 || got[errLocked.Error()] != 5 {
|
||||
t.Fatalf("parallel attempts: %v", got)
|
||||
}
|
||||
if _, _, err := a.Login("admin", right, "2001:db8::ffff"); !errors.Is(err, errLocked) {
|
||||
t.Fatalf("same /64: %v, want locked", err)
|
||||
}
|
||||
if _, _, err := a.Login("admin", right, "2001:db8:0:1::1"); err != nil {
|
||||
t.Fatalf("other /64: %v", err)
|
||||
}
|
||||
|
||||
// A right password takes its own attempt back. With two-step sign-in
|
||||
// the earlier failures stay, so wrong codes still lead to the lockout.
|
||||
_ = store.Update(func(c *Config) error { c.Users[0].MFA = &UserMFA{TOTPSecret: newTOTPSecret()}; return nil })
|
||||
ip := "192.0.2.7"
|
||||
for range maxFailures - 1 {
|
||||
_, _, _ = a.Login("admin", wrong, ip)
|
||||
}
|
||||
if _, tk, err := a.Login("admin", right, ip); err != nil || tk == "" {
|
||||
t.Fatalf("5th attempt, right password: ticket %q, %v", tk, err)
|
||||
}
|
||||
if _, _, err := a.Login("admin", wrong, ip); err == nil || errors.Is(err, errLocked) {
|
||||
t.Fatalf("6th attempt: %v, want wrong password", err)
|
||||
}
|
||||
if _, _, err := a.Login("admin", right, ip); !errors.Is(err, errLocked) {
|
||||
t.Fatalf("7th attempt: %v, want locked", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteIfChanged(t *testing.T) {
|
||||
p := filepath.Join(t.TempDir(), "x.conf")
|
||||
if ch, err := writeIfChanged(p, "a\n", 0o644); !ch || err != nil {
|
||||
@@ -881,7 +1033,6 @@ func TestUsers(t *testing.T) {
|
||||
if n := len(admin("GET", "/users", nil, 200)["users"].([]any)); n != 2 {
|
||||
t.Fatalf("users: %d, want 2", n)
|
||||
}
|
||||
admin("PATCH", "/settings", map[string]any{"adminUsername": "x"}, 400)
|
||||
}
|
||||
|
||||
// TestDecoy checks that the decoy hides the web interface but leaves the API
|
||||
@@ -921,8 +1072,15 @@ func TestDecoy(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
if b, _ := get("/", 200); !strings.Contains(b, "/app.js") {
|
||||
t.Fatal("web interface not served with the decoy off")
|
||||
b, _ := get("/", 200)
|
||||
if !strings.Contains(b, `"/app.js?v=`+assetHash["app.js"]+`"`) || !strings.Contains(b, `"/app.css?v=`+assetHash["app.css"]+`"`) {
|
||||
t.Fatalf("web interface not served with fingerprinted files: %q", b)
|
||||
}
|
||||
if _, h := get("/app.js?v="+assetHash["app.js"], 200); !strings.Contains(h.Get("Cache-Control"), "immutable") {
|
||||
t.Fatalf("fingerprinted app.js: %v", h)
|
||||
}
|
||||
if _, h := get("/app.js?v=old", 200); h.Get("Cache-Control") != "no-cache" {
|
||||
t.Fatalf("stale app.js cached: %v", h)
|
||||
}
|
||||
set(func(c *Config) {
|
||||
v4 := netip.MustParsePrefix(c.Server.IPv4)
|
||||
@@ -939,7 +1097,7 @@ func TestDecoy(t *testing.T) {
|
||||
t.Fatalf("%s leaks: %q", p, b)
|
||||
}
|
||||
}
|
||||
if b, _ := get("/setup/live-token", 200); !strings.Contains(b, `src="/setup/live-token/setup.js"`) {
|
||||
if b, _ := get("/setup/live-token", 200); !strings.Contains(b, `src="/setup/live-token/setup.js?v=`+assetHash["setup.js"]+`"`) {
|
||||
t.Fatalf("setup page files not under the link: %q", b)
|
||||
}
|
||||
get("/setup/live-token/app.css", 200)
|
||||
@@ -954,7 +1112,498 @@ func TestDecoy(t *testing.T) {
|
||||
if b, h := get("/", 200); !strings.Contains(b, "<p class=\"host\">127.0.0.1</p>") || h.Get("Server") != "" {
|
||||
t.Fatalf("soon decoy: %q", b)
|
||||
}
|
||||
set(func(c *Config) { c.Decoy.Page = "blank" })
|
||||
if b, _ := get("/", 200); b != "" {
|
||||
t.Fatalf("blank decoy: %q", b)
|
||||
}
|
||||
if b, _ := get("/app.js", 404); b != "" {
|
||||
t.Fatalf("blank 404: %q", b)
|
||||
}
|
||||
set(func(c *Config) { c.Decoy.Page = "forbidden" })
|
||||
if b, _ := get("/", 403); !strings.Contains(b, "Forbidden") {
|
||||
t.Fatalf("forbidden decoy: %q", b)
|
||||
}
|
||||
set(func(c *Config) { c.Decoy.Page = "private" })
|
||||
if b, _ := get("/", 200); !strings.Contains(b, "Private server") {
|
||||
t.Fatalf("private decoy: %q", b)
|
||||
}
|
||||
if err := store.Update(func(c *Config) error { c.Decoy.Page = "iis"; return nil }); err == nil {
|
||||
t.Fatal("unknown decoy page accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTOTPCode(t *testing.T) {
|
||||
// RFC 6238, appendix B (SHA-1), cut to 6 digits.
|
||||
key := []byte("12345678901234567890")
|
||||
for _, c := range []struct {
|
||||
unix int64
|
||||
want string
|
||||
}{{59, "287082"}, {1111111109, "081804"}, {1234567890, "005924"}, {2000000000, "279037"}} {
|
||||
if got := totpCode(key, uint64(c.unix/30)); got != c.want {
|
||||
t.Errorf("time %d: %s, want %s", c.unix, got, c.want)
|
||||
}
|
||||
}
|
||||
secret := b32.EncodeToString(key)
|
||||
now := time.Unix(1111111109, 0)
|
||||
if _, ok := totpMatch(secret, "081 804", now); !ok {
|
||||
t.Error("code with a space refused")
|
||||
}
|
||||
if _, ok := totpMatch(secret, "081804", now.Add(90*time.Second)); ok {
|
||||
t.Error("code three steps late accepted")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMFA signs in with an authenticator code and a recovery code, and
|
||||
// checks the "require" switch and the admin reset.
|
||||
func TestMFA(t *testing.T) {
|
||||
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())
|
||||
defer srv.Close()
|
||||
|
||||
client := func() func(method, path string, body any, want int) map[string]any {
|
||||
jar, _ := cookiejar.New(nil)
|
||||
cl := &http.Client{Jar: jar}
|
||||
return 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
|
||||
}
|
||||
}
|
||||
login := map[string]string{"username": "admin", "password": "a long test password"}
|
||||
adm := client()
|
||||
adm("POST", "/auth/login", login, 200)
|
||||
if o := adm("GET", "/auth/options", nil, 200); o["passkeys"] != false {
|
||||
t.Fatalf("passkeys offered on an IP address: %v", o)
|
||||
}
|
||||
adm("POST", "/auth/mfa/keys/begin", nil, 400)
|
||||
|
||||
// Turn on the authenticator app; the first method brings recovery codes.
|
||||
setup := adm("POST", "/auth/mfa/totp/setup", nil, 200)
|
||||
secret := setup["secret"].(string)
|
||||
if !strings.HasPrefix(setup["uri"].(string), "otpauth://totp/") || setup["qr"] == "" {
|
||||
t.Fatalf("setup: %v", setup)
|
||||
}
|
||||
adm("POST", "/auth/mfa/totp/confirm", map[string]string{"code": "000000"}, 400)
|
||||
key, _ := b32.DecodeString(secret)
|
||||
code := func(offset int) string { return totpCode(key, uint64(time.Now().Unix()/30)+uint64(offset)) }
|
||||
conf := adm("POST", "/auth/mfa/totp/confirm", map[string]string{"code": code(0)}, 200)
|
||||
codes := conf["recoveryCodes"].([]any)
|
||||
if len(codes) != recoveryCount {
|
||||
t.Fatalf("recovery codes: %v", conf)
|
||||
}
|
||||
if s := adm("GET", "/auth/mfa", nil, 200); s["totp"] != true || s["recoveryLeft"] != float64(recoveryCount) {
|
||||
t.Fatalf("status: %v", s)
|
||||
}
|
||||
|
||||
// A password alone now gives a ticket, not a session.
|
||||
c := client()
|
||||
r := c("POST", "/auth/login", login, 200)
|
||||
ticket, _ := r["ticket"].(string)
|
||||
if r["mfa"] != true || ticket == "" {
|
||||
t.Fatalf("login without second step: %v", r)
|
||||
}
|
||||
c("GET", "/peers", nil, 401)
|
||||
c("POST", "/auth/login/totp", map[string]string{"ticket": ticket, "code": "123456"}, 401)
|
||||
c("POST", "/auth/login/totp", map[string]string{"ticket": ticket, "code": code(0)}, 401) // used during setup
|
||||
c("POST", "/auth/login/totp", map[string]string{"ticket": ticket, "code": code(1)}, 200)
|
||||
c("GET", "/peers", nil, 200)
|
||||
|
||||
// A recovery code works once.
|
||||
c2 := client()
|
||||
ticket = c2("POST", "/auth/login", login, 200)["ticket"].(string)
|
||||
c2("POST", "/auth/login/recovery", map[string]string{"ticket": ticket, "code": strings.ToLower(codes[0].(string))}, 200)
|
||||
c3 := client()
|
||||
ticket = c3("POST", "/auth/login", login, 200)["ticket"].(string)
|
||||
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)
|
||||
|
||||
// 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.
|
||||
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)
|
||||
e := client()
|
||||
e("POST", "/auth/login", map[string]string{"username": "eve", "password": "eve's password 1"}, 200)
|
||||
if me := e("GET", "/auth/me", nil, 200); me["mfaSetupRequired"] != true {
|
||||
t.Fatalf("me: %v", me)
|
||||
}
|
||||
e("GET", "/peers", nil, 403)
|
||||
e("GET", "/auth/mfa", nil, 200)
|
||||
// The last method cannot be removed while it is required.
|
||||
adm("DELETE", "/auth/mfa/totp", nil, 400)
|
||||
|
||||
// An admin resets another user's two-step sign-in, not their own.
|
||||
_ = store.Update(func(c *Config) error {
|
||||
_, eu := c.userByID(u["id"].(string))
|
||||
eu.MFA = &UserMFA{TOTPSecret: newTOTPSecret(), RecoveryCodes: []string{"x"}}
|
||||
return nil
|
||||
})
|
||||
if l := adm("GET", "/users", nil, 200)["users"].([]any); l[1].(map[string]any)["mfa"].(map[string]any)["totp"] != true {
|
||||
t.Fatalf("users list: %v", l)
|
||||
}
|
||||
me := adm("GET", "/auth/me", nil, 200)
|
||||
adm("POST", "/users/"+me["id"].(string)+"/reset-mfa", nil, 400)
|
||||
adm("POST", "/users/"+u["id"].(string)+"/reset-mfa", nil, 200)
|
||||
if _, eu := store.Get().userByID(u["id"].(string)); eu.hasMFA() || len(eu.MFA.RecoveryCodes) != 0 {
|
||||
t.Fatal("reset left methods behind")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDropSecurityKeys checks that security keys from v0.3.0 are deleted on
|
||||
// load, and recovery codes with them when nothing else is left.
|
||||
func TestDropSecurityKeys(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.json")
|
||||
cfg := `{"users": [
|
||||
{"id": "a", "username": "a", "passwordHash": "x", "mfa": {"keys": [{"id": "k", "name": "YubiKey", "passkey": false}], "recoveryCodes": ["h"]}},
|
||||
{"id": "b", "username": "b", "passwordHash": "x", "mfa": {"keys": [{"id": "k1", "name": "YubiKey", "passkey": false}, {"id": "k2", "name": "Mac", "passkey": true}], "recoveryCodes": ["h"]}}
|
||||
]}`
|
||||
if err := os.WriteFile(path, []byte(cfg), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store, err := openStore(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c := store.Get()
|
||||
if a := c.Users[0].MFA; len(a.Keys) != 0 || len(a.RecoveryCodes) != 0 {
|
||||
t.Fatalf("user a kept %v", a)
|
||||
}
|
||||
if b := c.Users[1].MFA; len(b.Keys) != 1 || b.Keys[0].Name != "Mac" || len(b.RecoveryCodes) != 1 {
|
||||
t.Fatalf("user b: %v", b)
|
||||
}
|
||||
if b, _ := os.ReadFile(path); strings.Contains(string(b), "YubiKey") {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,981 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha1"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/base32"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-webauthn/webauthn/protocol"
|
||||
"github.com/go-webauthn/webauthn/webauthn"
|
||||
)
|
||||
|
||||
// Two-step sign-in for the web interface: an authenticator app (TOTP) and
|
||||
// passkeys (WebAuthn, also on a YubiKey), plus one-time recovery codes. A
|
||||
// passkey signs in on its own and also serves as the second step after a
|
||||
// password. API tokens never need a second step.
|
||||
//
|
||||
// After a correct password, a user with two-step sign-in gets a short-lived
|
||||
// ticket instead of a session; the ticket and a code or key turn into the
|
||||
// session. A passkey signs in on its own, without username and password.
|
||||
|
||||
// UserMFA is a user's two-step sign-in setup, stored in config.json.
|
||||
type UserMFA struct {
|
||||
TOTPSecret string `json:"totpSecret,omitempty"` // base32
|
||||
TOTPAdded *time.Time `json:"totpAdded,omitempty"`
|
||||
Keys []MFAKey `json:"keys,omitempty"`
|
||||
RecoveryCodes []string `json:"recoveryCodes,omitempty"` // SHA-256 of the unused codes
|
||||
Handle []byte `json:"handle,omitempty"` // WebAuthn user handle
|
||||
}
|
||||
|
||||
// MFAKey is a passkey.
|
||||
type MFAKey struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Passkey bool `json:"passkey"` // false only for security keys added by v0.3.0, which are deleted
|
||||
Created time.Time `json:"created"`
|
||||
LastUsed *time.Time `json:"lastUsed,omitempty"`
|
||||
Credential webauthn.Credential `json:"credential"`
|
||||
}
|
||||
|
||||
// dropSecurityKeys deletes the security keys v0.3.0 could add; only
|
||||
// passkeys are supported. A user left without a method loses their
|
||||
// recovery codes too.
|
||||
func dropSecurityKeys(u *User) {
|
||||
if u.MFA == nil {
|
||||
return
|
||||
}
|
||||
u.MFA.Keys = slices.DeleteFunc(u.MFA.Keys, func(k MFAKey) bool { return !k.Passkey })
|
||||
if !u.hasMFA() {
|
||||
u.MFA.RecoveryCodes = nil
|
||||
}
|
||||
}
|
||||
|
||||
func (u *User) hasMFA() bool {
|
||||
return u.MFA != nil && (u.MFA.TOTPSecret != "" || len(u.MFA.Keys) > 0)
|
||||
}
|
||||
|
||||
const (
|
||||
ticketTTL = 5 * time.Minute
|
||||
recoveryCount = 10
|
||||
totpPeriod = 30
|
||||
totpDigits = 6
|
||||
maxKeyName = 64
|
||||
)
|
||||
|
||||
// --- TOTP (RFC 6238, SHA-1, 6 digits, 30 s) ---
|
||||
|
||||
var b32 = base32.StdEncoding.WithPadding(base32.NoPadding)
|
||||
|
||||
func newTOTPSecret() string {
|
||||
b := make([]byte, 20)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return b32.EncodeToString(b)
|
||||
}
|
||||
|
||||
func totpCode(key []byte, counter uint64) string {
|
||||
var msg [8]byte
|
||||
binary.BigEndian.PutUint64(msg[:], counter)
|
||||
m := hmac.New(sha1.New, key)
|
||||
m.Write(msg[:])
|
||||
sum := m.Sum(nil)
|
||||
off := sum[len(sum)-1] & 0x0f
|
||||
v := binary.BigEndian.Uint32(sum[off:off+4]) & 0x7fffffff
|
||||
return fmt.Sprintf("%0*d", totpDigits, v%1_000_000)
|
||||
}
|
||||
|
||||
// totpMatch returns the time step the code belongs to, allowing one step of
|
||||
// clock drift either way.
|
||||
func totpMatch(secret, code string, now time.Time) (uint64, bool) {
|
||||
key, err := b32.DecodeString(strings.ToUpper(secret))
|
||||
code = strings.Map(func(r rune) rune {
|
||||
if r >= '0' && r <= '9' {
|
||||
return r
|
||||
}
|
||||
return -1
|
||||
}, code)
|
||||
if err != nil || len(code) != totpDigits {
|
||||
return 0, false
|
||||
}
|
||||
step := uint64(now.Unix() / totpPeriod)
|
||||
for _, c := range []uint64{step, step - 1, step + 1} {
|
||||
if subtle.ConstantTimeCompare([]byte(totpCode(key, c)), []byte(code)) == 1 {
|
||||
return c, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func totpURI(secret, username string) string {
|
||||
label := url.PathEscape(appName + ":" + username)
|
||||
return "otpauth://totp/" + label + "?secret=" + secret + "&issuer=" + url.QueryEscape(appName) + "&algorithm=SHA1&digits=6&period=30"
|
||||
}
|
||||
|
||||
// --- recovery codes ---
|
||||
|
||||
const recoveryAlphabet = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"
|
||||
|
||||
// newRecoveryCodes returns codes to show once and their hashes to store.
|
||||
func newRecoveryCodes() (codes, hashes []string) {
|
||||
for range recoveryCount {
|
||||
b := make([]byte, 8)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
var s strings.Builder
|
||||
for i, x := range b {
|
||||
if i == 4 {
|
||||
s.WriteByte('-')
|
||||
}
|
||||
s.WriteByte(recoveryAlphabet[int(x)%len(recoveryAlphabet)])
|
||||
}
|
||||
codes = append(codes, s.String())
|
||||
hashes = append(hashes, hashRecovery(s.String()))
|
||||
}
|
||||
return codes, hashes
|
||||
}
|
||||
|
||||
func hashRecovery(code string) string {
|
||||
norm := strings.Map(func(r rune) rune {
|
||||
if r == '-' || r == ' ' {
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, strings.ToUpper(code))
|
||||
sum := sha256.Sum256([]byte(norm))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// --- WebAuthn ---
|
||||
|
||||
// waUser adapts a User to the webauthn library.
|
||||
type waUser struct{ u *User }
|
||||
|
||||
func (w waUser) WebAuthnID() []byte { return w.u.MFA.Handle }
|
||||
func (w waUser) WebAuthnName() string { return w.u.Username }
|
||||
func (w waUser) WebAuthnDisplayName() string { return w.u.Username }
|
||||
func (w waUser) WebAuthnCredentials() []webauthn.Credential {
|
||||
var out []webauthn.Credential
|
||||
if w.u.MFA != nil {
|
||||
for _, k := range w.u.MFA.Keys {
|
||||
out = append(out, k.Credential)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// keysAvailable reports whether passkeys can work on this
|
||||
// address: WebAuthn needs a domain name (not an IP address) and a
|
||||
// certificate the browser trusts, or localhost.
|
||||
func (a *App) keysAvailable(r *http.Request) bool {
|
||||
host := hostOnly(r.Host)
|
||||
if host == "localhost" {
|
||||
return true
|
||||
}
|
||||
return host != "" && net.ParseIP(host) == nil && a.store.Get().Web.TLS.Mode != "selfsigned"
|
||||
}
|
||||
|
||||
func (a *App) webAuthn(r *http.Request) (*webauthn.WebAuthn, error) {
|
||||
if !a.keysAvailable(r) {
|
||||
return nil, badRequest("passkeys need a domain name with a trusted certificate")
|
||||
}
|
||||
scheme := "https"
|
||||
if r.TLS == nil && hostOnly(r.Host) == "localhost" {
|
||||
scheme = "http"
|
||||
}
|
||||
return webauthn.New(&webauthn.Config{
|
||||
RPID: hostOnly(r.Host), RPDisplayName: appName, RPOrigins: []string{scheme + "://" + r.Host},
|
||||
})
|
||||
}
|
||||
|
||||
// --- pending ceremonies, kept in memory ---
|
||||
|
||||
// ticket is a sign-in waiting for its second step.
|
||||
type ticket struct {
|
||||
userID string
|
||||
ip string
|
||||
expires time.Time
|
||||
fails int
|
||||
key *webauthn.SessionData // a passkey challenge, once asked for
|
||||
}
|
||||
|
||||
type ceremony struct {
|
||||
userID string // "" for a passkey sign-in
|
||||
ip string // lockKey of who started a passkey sign-in
|
||||
data *webauthn.SessionData
|
||||
expires time.Time
|
||||
}
|
||||
|
||||
type mfaState struct {
|
||||
tickets map[string]*ticket
|
||||
logins map[string]*ceremony // passkey sign-ins by id
|
||||
enrolls map[string]*ceremony // key registrations by user ID
|
||||
totpSetup map[string]string // TOTP secrets waiting for their first code, by user ID
|
||||
totpLast map[string]uint64 // last time step used per user, so a code works once
|
||||
}
|
||||
|
||||
func newMFAState() mfaState {
|
||||
return mfaState{tickets: map[string]*ticket{}, logins: map[string]*ceremony{}, enrolls: map[string]*ceremony{},
|
||||
totpSetup: map[string]string{}, totpLast: map[string]uint64{}}
|
||||
}
|
||||
|
||||
var errBadTicket = errors.New("the sign-in expired; enter your password again")
|
||||
|
||||
// failLocked counts a failed attempt from ip toward the lockout and returns
|
||||
// a function that takes it back, for an attempt counted before it was
|
||||
// checked. a.mu must be held, also when calling undo.
|
||||
func (a *Auth) failLocked(ip string) (undo func()) {
|
||||
key := lockKey(ip)
|
||||
f := a.fails[key]
|
||||
if f == nil {
|
||||
f = &failState{}
|
||||
a.fails[key] = f
|
||||
}
|
||||
f.count++
|
||||
locked := f.count >= maxFailures
|
||||
if locked {
|
||||
f.count = 0
|
||||
f.until = time.Now().Add(lockoutTime)
|
||||
}
|
||||
return func() {
|
||||
switch {
|
||||
case locked:
|
||||
f.count, f.until = maxFailures-1, time.Time{}
|
||||
case f.count > 0:
|
||||
f.count--
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Auth) lockedLocked(ip string) bool {
|
||||
f := a.fails[lockKey(ip)]
|
||||
return f != nil && time.Now().Before(f.until)
|
||||
}
|
||||
|
||||
// newTicket starts the second step for a user whose password was right.
|
||||
// a.mu must be held.
|
||||
func (a *Auth) newTicketLocked(u *User, ip string) string {
|
||||
id := randomString(32)
|
||||
a.mfa.tickets[id] = &ticket{userID: u.ID, ip: ip, expires: time.Now().Add(ticketTTL)}
|
||||
return id
|
||||
}
|
||||
|
||||
// ticketUser returns the live ticket and its user.
|
||||
func (a *Auth) ticketUser(id, ip string) (*ticket, *User, error) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if a.lockedLocked(ip) {
|
||||
return nil, nil, errLocked
|
||||
}
|
||||
t := a.mfa.tickets[id]
|
||||
if t == nil || time.Now().After(t.expires) {
|
||||
delete(a.mfa.tickets, id)
|
||||
return nil, nil, errBadTicket
|
||||
}
|
||||
_, u := a.store.Get().userByID(t.userID)
|
||||
if u == nil {
|
||||
delete(a.mfa.tickets, id)
|
||||
return nil, nil, errBadTicket
|
||||
}
|
||||
return t, u, nil
|
||||
}
|
||||
|
||||
// ticketFailed counts a wrong code; five end the ticket.
|
||||
func (a *Auth) ticketFailed(id, ip string) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
a.failLocked(ip)
|
||||
if t := a.mfa.tickets[id]; t != nil {
|
||||
t.fails++
|
||||
if t.fails >= maxFailures {
|
||||
delete(a.mfa.tickets, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// finishSignIn turns a passed second step into a session.
|
||||
func (a *Auth) finishSignIn(u *User, ip string) string {
|
||||
cfg := a.store.Get()
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
delete(a.fails, lockKey(ip))
|
||||
a.logins[u.ID] = tokenUse{At: time.Now(), IP: ip}
|
||||
return a.newSessionLocked(cfg, u, sessionInfo{Started: time.Now(), IP: ip})
|
||||
}
|
||||
|
||||
// --- sign-in endpoints (public) ---
|
||||
|
||||
func (a *App) signedIn(w http.ResponseWriter, r *http.Request, u *User, how string) {
|
||||
ip := remoteIP(r)
|
||||
a.setSessionCookie(w, r, a.auth.finishSignIn(u, ip))
|
||||
slog.Info("login", "audit", true, "actor", u.Username, "remote", ip, "method", how)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (a *App) signInFailed(w http.ResponseWriter, err error) {
|
||||
code := http.StatusUnauthorized
|
||||
if errors.Is(err, errLocked) {
|
||||
code = http.StatusTooManyRequests
|
||||
}
|
||||
writeJSON(w, code, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
// signInOptions tells the sign-in page whether to offer a passkey.
|
||||
func (a *App) signInOptions(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"passkeys": a.keysAvailable(r)})
|
||||
}
|
||||
|
||||
func (a *App) loginTOTP(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct{ Ticket, Code string }
|
||||
if err := readJSON(r, &in); err != nil {
|
||||
writeErr(w, err)
|
||||
return
|
||||
}
|
||||
ip := remoteIP(r)
|
||||
_, u, err := a.auth.ticketUser(in.Ticket, ip)
|
||||
if err != nil {
|
||||
a.signInFailed(w, err)
|
||||
return
|
||||
}
|
||||
if u.MFA == nil || u.MFA.TOTPSecret == "" || !a.auth.useTOTP(u.ID, u.MFA.TOTPSecret, in.Code) {
|
||||
a.auth.ticketFailed(in.Ticket, ip)
|
||||
slog.Warn("login failed", "user", u.Username, "remote", ip, "reason", "wrong authenticator code")
|
||||
a.signInFailed(w, errors.New("wrong code"))
|
||||
return
|
||||
}
|
||||
a.auth.dropTicket(in.Ticket)
|
||||
a.signedIn(w, r, u, "totp")
|
||||
}
|
||||
|
||||
// useTOTP checks a code and makes sure it is not used twice.
|
||||
func (a *Auth) useTOTP(userID, secret, code string) bool {
|
||||
step, ok := totpMatch(secret, code, time.Now())
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if last, seen := a.mfa.totpLast[userID]; seen && step <= last {
|
||||
return false
|
||||
}
|
||||
a.mfa.totpLast[userID] = step
|
||||
return true
|
||||
}
|
||||
|
||||
// ticketUserID returns the ticket's user without checking the lockout.
|
||||
func (a *Auth) ticketUserID(id string) (string, *User) {
|
||||
a.mu.Lock()
|
||||
t := a.mfa.tickets[id]
|
||||
a.mu.Unlock()
|
||||
if t == nil {
|
||||
return "", nil
|
||||
}
|
||||
_, u := a.store.Get().userByID(t.userID)
|
||||
return t.userID, u
|
||||
}
|
||||
|
||||
// mfaMethods lists what the second step can use: "key", "totp", "recovery".
|
||||
func mfaMethods(u *User) []string {
|
||||
out := []string{}
|
||||
if u == nil || u.MFA == nil {
|
||||
return out
|
||||
}
|
||||
if len(u.MFA.Keys) > 0 {
|
||||
out = append(out, "key")
|
||||
}
|
||||
if u.MFA.TOTPSecret != "" {
|
||||
out = append(out, "totp")
|
||||
}
|
||||
if len(u.MFA.RecoveryCodes) > 0 {
|
||||
out = append(out, "recovery")
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (a *Auth) dropTicket(id string) {
|
||||
a.mu.Lock()
|
||||
delete(a.mfa.tickets, id)
|
||||
a.mu.Unlock()
|
||||
}
|
||||
|
||||
func (a *App) loginRecovery(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct{ Ticket, Code string }
|
||||
if err := readJSON(r, &in); err != nil {
|
||||
writeErr(w, err)
|
||||
return
|
||||
}
|
||||
ip := remoteIP(r)
|
||||
_, u, err := a.auth.ticketUser(in.Ticket, ip)
|
||||
if err != nil {
|
||||
a.signInFailed(w, err)
|
||||
return
|
||||
}
|
||||
h := hashRecovery(in.Code)
|
||||
var left int
|
||||
used := false
|
||||
_ = a.store.Update(func(c *Config) error {
|
||||
_, cu := c.userByID(u.ID)
|
||||
if cu == nil || cu.MFA == nil {
|
||||
return nil
|
||||
}
|
||||
for i, x := range cu.MFA.RecoveryCodes {
|
||||
if subtle.ConstantTimeCompare([]byte(x), []byte(h)) == 1 {
|
||||
cu.MFA.RecoveryCodes = slices.Delete(cu.MFA.RecoveryCodes, i, i+1)
|
||||
used = true
|
||||
break
|
||||
}
|
||||
}
|
||||
left = len(cu.MFA.RecoveryCodes)
|
||||
return nil
|
||||
})
|
||||
if !used {
|
||||
a.auth.ticketFailed(in.Ticket, ip)
|
||||
slog.Warn("login failed", "user", u.Username, "remote", ip, "reason", "wrong recovery code")
|
||||
a.signInFailed(w, errors.New("wrong or used recovery code"))
|
||||
return
|
||||
}
|
||||
a.auth.dropTicket(in.Ticket)
|
||||
slog.Info("recovery code used", "audit", true, "actor", u.Username, "remote", ip, "left", left)
|
||||
a.signedIn(w, r, u, "recovery code")
|
||||
}
|
||||
|
||||
// loginKeyBegin asks for one of the user's passkeys, as the second step.
|
||||
func (a *App) loginKeyBegin(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct{ Ticket string }
|
||||
if err := readJSON(r, &in); err != nil {
|
||||
writeErr(w, err)
|
||||
return
|
||||
}
|
||||
t, u, err := a.auth.ticketUser(in.Ticket, remoteIP(r))
|
||||
if err != nil {
|
||||
a.signInFailed(w, err)
|
||||
return
|
||||
}
|
||||
wa, err := a.webAuthn(r)
|
||||
if err != nil {
|
||||
writeErr(w, err)
|
||||
return
|
||||
}
|
||||
if u.MFA == nil || len(u.MFA.Keys) == 0 {
|
||||
writeErr(w, badRequest("no passkey is set up"))
|
||||
return
|
||||
}
|
||||
opts, data, err := wa.BeginLogin(waUser{u}, webauthn.WithUserVerification(protocol.VerificationDiscouraged))
|
||||
if err != nil {
|
||||
writeErr(w, err)
|
||||
return
|
||||
}
|
||||
a.auth.mu.Lock()
|
||||
t.key = data
|
||||
a.auth.mu.Unlock()
|
||||
writeJSON(w, http.StatusOK, opts)
|
||||
}
|
||||
|
||||
// loginKeyFinish checks the key's answer. The ticket is in the query, the
|
||||
// body is the browser's credential.
|
||||
func (a *App) loginKeyFinish(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.URL.Query().Get("ticket")
|
||||
ip := remoteIP(r)
|
||||
t, u, err := a.auth.ticketUser(id, ip)
|
||||
if err != nil {
|
||||
a.signInFailed(w, err)
|
||||
return
|
||||
}
|
||||
wa, err := a.webAuthn(r)
|
||||
if err != nil {
|
||||
writeErr(w, err)
|
||||
return
|
||||
}
|
||||
a.auth.mu.Lock()
|
||||
data := t.key
|
||||
t.key = nil
|
||||
a.auth.mu.Unlock()
|
||||
if data == nil {
|
||||
writeErr(w, badRequest("ask for the key first"))
|
||||
return
|
||||
}
|
||||
cred, err := wa.FinishLogin(waUser{u}, *data, r)
|
||||
if err != nil {
|
||||
a.auth.ticketFailed(id, ip)
|
||||
slog.Warn("login failed", "user", u.Username, "remote", ip, "reason", "passkey: "+err.Error())
|
||||
a.signInFailed(w, errors.New("the passkey was not accepted"))
|
||||
return
|
||||
}
|
||||
a.keyUsed(u.ID, cred)
|
||||
a.auth.dropTicket(id)
|
||||
a.signedIn(w, r, u, "passkey")
|
||||
}
|
||||
|
||||
// keyUsed stores the key's new signature counter and when it was used.
|
||||
func (a *App) keyUsed(userID string, cred *webauthn.Credential) {
|
||||
now := time.Now().UTC()
|
||||
_ = a.store.Update(func(c *Config) error {
|
||||
if _, u := c.userByID(userID); u != nil && u.MFA != nil {
|
||||
for i := range u.MFA.Keys {
|
||||
if k := &u.MFA.Keys[i]; bytes.Equal(k.Credential.ID, cred.ID) {
|
||||
k.Credential.Authenticator = cred.Authenticator
|
||||
k.Credential.Flags = cred.Flags
|
||||
k.LastUsed = &now
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// loginPasskeyBegin starts a sign-in with a passkey alone.
|
||||
func (a *App) loginPasskeyBegin(w http.ResponseWriter, r *http.Request) {
|
||||
wa, err := a.webAuthn(r)
|
||||
if err != nil {
|
||||
writeErr(w, err)
|
||||
return
|
||||
}
|
||||
opts, data, err := wa.BeginDiscoverableLogin(webauthn.WithUserVerification(protocol.VerificationRequired))
|
||||
if err != nil {
|
||||
writeErr(w, err)
|
||||
return
|
||||
}
|
||||
id := randomString(24)
|
||||
ip := remoteIP(r)
|
||||
a.auth.mu.Lock()
|
||||
ok := a.auth.addPasskeyLoginLocked(id, &ceremony{data: data, ip: lockKey(ip), expires: time.Now().Add(ticketTTL)})
|
||||
a.auth.mu.Unlock()
|
||||
if !ok {
|
||||
writeJSON(w, http.StatusTooManyRequests, map[string]string{"error": errBusy.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"id": id, "options": opts})
|
||||
}
|
||||
|
||||
// Anyone can start a passkey sign-in, so the pending ones are capped: per
|
||||
// address, and in total, where the oldest makes room.
|
||||
const (
|
||||
maxPasskeyLogins = 1000
|
||||
maxPasskeyLoginsPerIP = 10
|
||||
)
|
||||
|
||||
// addPasskeyLoginLocked stores a started passkey sign-in, or reports false
|
||||
// when its address has too many pending. a.mu must be held.
|
||||
func (a *Auth) addPasskeyLoginLocked(id string, c *ceremony) bool {
|
||||
now := time.Now()
|
||||
var fromIP int
|
||||
var oldestID string
|
||||
for k, x := range a.mfa.logins {
|
||||
if now.After(x.expires) {
|
||||
delete(a.mfa.logins, k)
|
||||
continue
|
||||
}
|
||||
if x.ip == c.ip {
|
||||
fromIP++
|
||||
}
|
||||
if oldestID == "" || x.expires.Before(a.mfa.logins[oldestID].expires) {
|
||||
oldestID = k
|
||||
}
|
||||
}
|
||||
if fromIP >= maxPasskeyLoginsPerIP {
|
||||
return false
|
||||
}
|
||||
if len(a.mfa.logins) >= maxPasskeyLogins {
|
||||
delete(a.mfa.logins, oldestID)
|
||||
}
|
||||
a.mfa.logins[id] = c
|
||||
return true
|
||||
}
|
||||
|
||||
func (a *App) loginPasskeyFinish(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.URL.Query().Get("id")
|
||||
ip := remoteIP(r)
|
||||
a.auth.mu.Lock()
|
||||
cer := a.auth.mfa.logins[id]
|
||||
delete(a.auth.mfa.logins, id)
|
||||
locked := a.auth.lockedLocked(ip)
|
||||
a.auth.mu.Unlock()
|
||||
if locked {
|
||||
a.signInFailed(w, errLocked)
|
||||
return
|
||||
}
|
||||
if cer == nil || time.Now().After(cer.expires) {
|
||||
a.signInFailed(w, errors.New("the sign-in expired; try again"))
|
||||
return
|
||||
}
|
||||
wa, err := a.webAuthn(r)
|
||||
if err != nil {
|
||||
writeErr(w, err)
|
||||
return
|
||||
}
|
||||
cfg := a.store.Get()
|
||||
var found *User
|
||||
cred, err := wa.FinishDiscoverableLogin(func(rawID, handle []byte) (webauthn.User, error) {
|
||||
for i := range cfg.Users {
|
||||
u := &cfg.Users[i]
|
||||
if u.MFA != nil && len(u.MFA.Handle) > 0 && bytes.Equal(u.MFA.Handle, handle) {
|
||||
for _, k := range u.MFA.Keys {
|
||||
if bytes.Equal(k.Credential.ID, rawID) {
|
||||
found = u
|
||||
return waUser{u}, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil, errors.New("unknown passkey")
|
||||
}, *cer.data, r)
|
||||
if err != nil || found == nil {
|
||||
a.auth.mu.Lock()
|
||||
a.auth.failLocked(ip)
|
||||
a.auth.mu.Unlock()
|
||||
slog.Warn("login failed", "remote", ip, "reason", "passkey not accepted")
|
||||
a.signInFailed(w, errors.New("this passkey is not known here"))
|
||||
return
|
||||
}
|
||||
a.keyUsed(found.ID, cred)
|
||||
a.signedIn(w, r, found, "passkey")
|
||||
}
|
||||
|
||||
// --- managing your own two-step sign-in (signed-in users) ---
|
||||
|
||||
type keyView struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Created time.Time `json:"created"`
|
||||
LastUsed *time.Time `json:"lastUsed"`
|
||||
}
|
||||
|
||||
func (a *App) mfaStatus(w http.ResponseWriter, r *http.Request) {
|
||||
cfg := a.store.Get()
|
||||
_, u := cfg.userByID(who(r).UserID)
|
||||
if u == nil {
|
||||
writeErr(w, badRequest("no such user"))
|
||||
return
|
||||
}
|
||||
out := map[string]any{"totp": false, "totpAdded": nil, "keys": []keyView{}, "recoveryLeft": 0,
|
||||
"keysAvailable": a.keysAvailable(r), "required": cfg.SignIn.RequireMFA}
|
||||
if m := u.MFA; m != nil {
|
||||
keys := []keyView{}
|
||||
for _, k := range m.Keys {
|
||||
keys = append(keys, keyView{k.ID, k.Name, k.Created, k.LastUsed})
|
||||
}
|
||||
out["totp"], out["totpAdded"], out["keys"], out["recoveryLeft"] = m.TOTPSecret != "", m.TOTPAdded, keys, len(m.RecoveryCodes)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// addFirstCodes gives a user recovery codes with their first method. It
|
||||
// returns the codes to show, or nil when the user already has codes. It runs
|
||||
// inside a store update.
|
||||
func addFirstCodes(u *User) []string {
|
||||
if len(u.MFA.RecoveryCodes) > 0 {
|
||||
return nil
|
||||
}
|
||||
codes, hashes := newRecoveryCodes()
|
||||
u.MFA.RecoveryCodes = hashes
|
||||
return codes
|
||||
}
|
||||
|
||||
func (a *App) totpSetup(w http.ResponseWriter, r *http.Request) {
|
||||
p := who(r)
|
||||
secret := newTOTPSecret()
|
||||
a.auth.mu.Lock()
|
||||
a.auth.mfa.totpSetup[p.UserID] = secret
|
||||
a.auth.mu.Unlock()
|
||||
_, u := a.store.Get().userByID(p.UserID)
|
||||
if u == nil {
|
||||
writeErr(w, badRequest("no such user"))
|
||||
return
|
||||
}
|
||||
uri := totpURI(secret, u.Username)
|
||||
qr, _ := qrDataURL(uri)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"secret": secret, "uri": uri, "qr": qr})
|
||||
}
|
||||
|
||||
func (a *App) totpConfirm(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct{ Code string }
|
||||
if err := readJSON(r, &in); err != nil {
|
||||
writeErr(w, err)
|
||||
return
|
||||
}
|
||||
p := who(r)
|
||||
a.auth.mu.Lock()
|
||||
secret := a.auth.mfa.totpSetup[p.UserID]
|
||||
a.auth.mu.Unlock()
|
||||
if secret == "" {
|
||||
writeErr(w, badRequest("start the setup again"))
|
||||
return
|
||||
}
|
||||
if !a.auth.useTOTP(p.UserID, secret, in.Code) {
|
||||
writeErr(w, badRequest("wrong code; check the time on your phone and try the next one"))
|
||||
return
|
||||
}
|
||||
var codes []string
|
||||
now := time.Now().UTC()
|
||||
if err := a.store.Update(func(c *Config) error {
|
||||
_, u := c.userByID(p.UserID)
|
||||
if u == nil {
|
||||
return badRequest("no such user")
|
||||
}
|
||||
if u.MFA == nil {
|
||||
u.MFA = &UserMFA{}
|
||||
}
|
||||
u.MFA.TOTPSecret, u.MFA.TOTPAdded = secret, &now
|
||||
codes = addFirstCodes(u)
|
||||
return nil
|
||||
}); err != nil {
|
||||
writeErr(w, err)
|
||||
return
|
||||
}
|
||||
a.auth.mu.Lock()
|
||||
delete(a.auth.mfa.totpSetup, p.UserID)
|
||||
a.auth.mu.Unlock()
|
||||
a.audit(r, "authenticator app added")
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "recoveryCodes": codes})
|
||||
}
|
||||
|
||||
// lastMethodCheck refuses to remove the last method while two-step sign-in
|
||||
// is required.
|
||||
func lastMethodCheck(c *Config, u *User) error {
|
||||
if c.SignIn.RequireMFA && !u.hasMFA() {
|
||||
return badRequest("two-step sign-in is required here; add another method first")
|
||||
}
|
||||
if !u.hasMFA() && u.MFA != nil {
|
||||
u.MFA.RecoveryCodes = nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) totpRemove(w http.ResponseWriter, r *http.Request) {
|
||||
p := who(r)
|
||||
if err := a.store.Update(func(c *Config) error {
|
||||
_, u := c.userByID(p.UserID)
|
||||
if u == nil || u.MFA == nil || u.MFA.TOTPSecret == "" {
|
||||
return badRequest("no authenticator app is set up")
|
||||
}
|
||||
u.MFA.TOTPSecret, u.MFA.TOTPAdded = "", nil
|
||||
return lastMethodCheck(c, u)
|
||||
}); err != nil {
|
||||
writeErr(w, err)
|
||||
return
|
||||
}
|
||||
a.audit(r, "authenticator app removed")
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// keyBegin starts adding a passkey.
|
||||
func (a *App) keyBegin(w http.ResponseWriter, r *http.Request) {
|
||||
wa, err := a.webAuthn(r)
|
||||
if err != nil {
|
||||
writeErr(w, err)
|
||||
return
|
||||
}
|
||||
p := who(r)
|
||||
// The user handle is made once and never changes.
|
||||
if err := a.store.Update(func(c *Config) error {
|
||||
_, u := c.userByID(p.UserID)
|
||||
if u == nil {
|
||||
return badRequest("no such user")
|
||||
}
|
||||
if u.MFA == nil {
|
||||
u.MFA = &UserMFA{}
|
||||
}
|
||||
if len(u.MFA.Handle) == 0 {
|
||||
u.MFA.Handle = make([]byte, 32)
|
||||
if _, err := rand.Read(u.MFA.Handle); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
writeErr(w, err)
|
||||
return
|
||||
}
|
||||
_, u := a.store.Get().userByID(p.UserID)
|
||||
var exclude []protocol.CredentialDescriptor
|
||||
for _, k := range u.MFA.Keys {
|
||||
exclude = append(exclude, k.Credential.Descriptor())
|
||||
}
|
||||
sel := protocol.AuthenticatorSelection{ResidentKey: protocol.ResidentKeyRequirementRequired, UserVerification: protocol.VerificationRequired}
|
||||
opts, data, err := wa.BeginRegistration(waUser{u}, webauthn.WithAuthenticatorSelection(sel), webauthn.WithExclusions(exclude))
|
||||
if err != nil {
|
||||
writeErr(w, err)
|
||||
return
|
||||
}
|
||||
a.auth.mu.Lock()
|
||||
a.auth.mfa.enrolls[p.UserID] = &ceremony{userID: p.UserID, data: data, expires: time.Now().Add(ticketTTL)}
|
||||
a.auth.mu.Unlock()
|
||||
writeJSON(w, http.StatusOK, opts)
|
||||
}
|
||||
|
||||
// keyFinish stores the new key. The name is in the query, the body is the
|
||||
// browser's credential.
|
||||
func (a *App) keyFinish(w http.ResponseWriter, r *http.Request) {
|
||||
p := who(r)
|
||||
name := strings.TrimSpace(r.URL.Query().Get("name"))
|
||||
a.auth.mu.Lock()
|
||||
cer := a.auth.mfa.enrolls[p.UserID]
|
||||
delete(a.auth.mfa.enrolls, p.UserID)
|
||||
a.auth.mu.Unlock()
|
||||
if cer == nil || time.Now().After(cer.expires) {
|
||||
writeErr(w, badRequest("adding the key took too long; try again"))
|
||||
return
|
||||
}
|
||||
wa, err := a.webAuthn(r)
|
||||
if err != nil {
|
||||
writeErr(w, err)
|
||||
return
|
||||
}
|
||||
_, u := a.store.Get().userByID(p.UserID)
|
||||
if u == nil {
|
||||
writeErr(w, badRequest("no such user"))
|
||||
return
|
||||
}
|
||||
cred, err := wa.FinishRegistration(waUser{u}, *cer.data, r)
|
||||
if err != nil {
|
||||
writeErr(w, badRequest("the key was not accepted: %v", err))
|
||||
return
|
||||
}
|
||||
if name == "" {
|
||||
name = "Passkey"
|
||||
}
|
||||
if len(name) > maxKeyName {
|
||||
name = name[:maxKeyName]
|
||||
}
|
||||
var codes []string
|
||||
key := MFAKey{ID: newID(), Name: name, Passkey: true, Created: time.Now().UTC(), Credential: *cred}
|
||||
if err := a.store.Update(func(c *Config) error {
|
||||
_, u := c.userByID(p.UserID)
|
||||
if u == nil || u.MFA == nil {
|
||||
return badRequest("no such user")
|
||||
}
|
||||
u.MFA.Keys = append(u.MFA.Keys, key)
|
||||
codes = addFirstCodes(u)
|
||||
return nil
|
||||
}); err != nil {
|
||||
writeErr(w, err)
|
||||
return
|
||||
}
|
||||
a.audit(r, "passkey added", "key", name)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "recoveryCodes": codes})
|
||||
}
|
||||
|
||||
func (a *App) keyRename(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct{ Name string }
|
||||
if err := readJSON(r, &in); err != nil {
|
||||
writeErr(w, err)
|
||||
return
|
||||
}
|
||||
in.Name = strings.TrimSpace(in.Name)
|
||||
if in.Name == "" || len(in.Name) > maxKeyName {
|
||||
writeErr(w, badRequest("name must be 1–%d characters", maxKeyName))
|
||||
return
|
||||
}
|
||||
id := r.PathValue("id")
|
||||
if err := a.store.Update(func(c *Config) error {
|
||||
_, u := c.userByID(who(r).UserID)
|
||||
if u == nil || u.MFA == nil {
|
||||
return badRequest("no such key")
|
||||
}
|
||||
for i := range u.MFA.Keys {
|
||||
if u.MFA.Keys[i].ID == id {
|
||||
u.MFA.Keys[i].Name = in.Name
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return badRequest("no such key")
|
||||
}); err != nil {
|
||||
writeErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (a *App) keyRemove(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
var name string
|
||||
if err := a.store.Update(func(c *Config) error {
|
||||
_, u := c.userByID(who(r).UserID)
|
||||
if u == nil || u.MFA == nil {
|
||||
return badRequest("no such key")
|
||||
}
|
||||
i := slices.IndexFunc(u.MFA.Keys, func(k MFAKey) bool { return k.ID == id })
|
||||
if i < 0 {
|
||||
return badRequest("no such key")
|
||||
}
|
||||
name = u.MFA.Keys[i].Name
|
||||
u.MFA.Keys = slices.Delete(u.MFA.Keys, i, i+1)
|
||||
return lastMethodCheck(c, u)
|
||||
}); err != nil {
|
||||
writeErr(w, err)
|
||||
return
|
||||
}
|
||||
a.audit(r, "passkey removed", "key", name)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (a *App) newRecoveryCodesHandler(w http.ResponseWriter, r *http.Request) {
|
||||
var codes []string
|
||||
if err := a.store.Update(func(c *Config) error {
|
||||
_, u := c.userByID(who(r).UserID)
|
||||
if u == nil || !u.hasMFA() {
|
||||
return badRequest("turn on two-step sign-in first")
|
||||
}
|
||||
var hashes []string
|
||||
codes, hashes = newRecoveryCodes()
|
||||
u.MFA.RecoveryCodes = hashes
|
||||
return nil
|
||||
}); err != nil {
|
||||
writeErr(w, err)
|
||||
return
|
||||
}
|
||||
a.audit(r, "recovery codes replaced")
|
||||
writeJSON(w, http.StatusOK, map[string]any{"recoveryCodes": codes})
|
||||
}
|
||||
|
||||
// resetMFA removes another user's two-step sign-in, for a lost phone or key.
|
||||
// Their user handle stays, so passkeys they still hold are just unknown.
|
||||
func (a *App) resetMFA(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if id == who(r).UserID {
|
||||
writeErr(w, badRequest("manage your own two-step sign-in under My account"))
|
||||
return
|
||||
}
|
||||
var name string
|
||||
if err := a.store.Update(func(c *Config) error {
|
||||
_, u := c.userByID(id)
|
||||
if u == nil {
|
||||
return badRequest("no such user")
|
||||
}
|
||||
name = u.Username
|
||||
if u.MFA != nil {
|
||||
u.MFA = &UserMFA{Handle: u.MFA.Handle}
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
writeErr(w, err)
|
||||
return
|
||||
}
|
||||
a.audit(r, "two-step sign-in reset", "user", name)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// mfaSummary is what user lists show.
|
||||
func mfaSummary(u *User) map[string]any {
|
||||
out := map[string]any{"totp": false, "passkeys": 0}
|
||||
if m := u.MFA; m != nil {
|
||||
out["totp"], out["passkeys"] = m.TOTPSecret != "", len(m.Keys)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 296 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 195 KiB |
|
Before Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 147 KiB |
|
Before Width: | Height: | Size: 81 KiB |
@@ -1,7 +1,9 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"cmp"
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"flag"
|
||||
@@ -12,6 +14,7 @@ import (
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -36,9 +39,10 @@ func usage() {
|
||||
fmt.Fprintf(os.Stderr, `%s %s — WireGuard server manager
|
||||
|
||||
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.
|
||||
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]
|
||||
replace the installed binary with this one and restart
|
||||
%s uninstall [-purge] [-y]
|
||||
@@ -273,7 +277,51 @@ WantedBy=multi-user.target
|
||||
// rewrite the unit for every release.
|
||||
const unitVersion = "unit-1"
|
||||
|
||||
const sysctlConf = "net.ipv4.ip_forward=1\nnet.ipv6.conf.all.forwarding=1\n"
|
||||
// sysctlConf turns on forwarding. With IPv6 forwarding on, Linux ignores
|
||||
// router announcements unless accept_ra is 2, and a server that gets its
|
||||
// IPv6 route from them (SLAAC, e.g. a Raspberry Pi at home) loses IPv6 when
|
||||
// the route expires. So every interface in ras keeps accepting them, as
|
||||
// pivpn does for its uplink.
|
||||
func sysctlConf(ras []string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("net.ipv4.ip_forward=1\nnet.ipv6.conf.all.forwarding=1\nnet.ipv6.conf.default.accept_ra=2\n")
|
||||
for _, name := range ras {
|
||||
fmt.Fprintf(&b, "net.ipv6.conf.%s.accept_ra=2\n", name)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// raInterfaces returns the network cards and the interface of the IPv6
|
||||
// default route, except those where router announcements are switched off
|
||||
// (accept_ra 0). The directories are /proc/sys/net/ipv6/conf and
|
||||
// /sys/class/net, routes is /proc/net/ipv6_route.
|
||||
func raInterfaces(confDir, netDir, routes string) []string {
|
||||
want := map[string]bool{}
|
||||
if b, err := os.ReadFile(routes); err == nil {
|
||||
for _, line := range strings.Split(string(b), "\n") {
|
||||
f := strings.Fields(line)
|
||||
if len(f) == 10 && f[0] == strings.Repeat("0", 32) && f[1] == "00" && f[9] != "lo" {
|
||||
want[f[9]] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
entries, _ := os.ReadDir(netDir)
|
||||
for _, e := range entries {
|
||||
// Only real devices: bridges, veth and tunnels come and go.
|
||||
if _, err := os.Stat(filepath.Join(netDir, e.Name(), "device")); err == nil {
|
||||
want[e.Name()] = true
|
||||
}
|
||||
}
|
||||
var out []string
|
||||
for name := range want {
|
||||
v := readSysctl(filepath.Join(confDir, name, "accept_ra"))
|
||||
if v == "1" || v == "2" {
|
||||
out = append(out, name)
|
||||
}
|
||||
}
|
||||
slices.Sort(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// writeSystemFiles writes the unit, sysctl and module files. It reports
|
||||
// whether the unit changed (systemd must then reload).
|
||||
@@ -281,7 +329,8 @@ func writeSystemFiles() (unitChanged bool, err error) {
|
||||
if unitChanged, err = writeIfChanged(unitPath, unitFile(), 0o644); err != nil {
|
||||
return false, err
|
||||
}
|
||||
sysChanged, err := writeIfChanged(sysctlPath, sysctlConf, 0o644)
|
||||
ras := raInterfaces("/proc/sys/net/ipv6/conf", "/sys/class/net", "/proc/net/ipv6_route")
|
||||
sysChanged, err := writeIfChanged(sysctlPath, sysctlConf(ras), 0o644)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
@@ -333,6 +382,8 @@ func cmdInstall(args []string) error {
|
||||
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)")
|
||||
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)
|
||||
given := map[string]bool{}
|
||||
fs.Visit(func(f *flag.Flag) { given[f.Name] = true })
|
||||
@@ -356,14 +407,40 @@ func cmdInstall(args []string) error {
|
||||
if err := plan.check(); err != nil {
|
||||
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 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)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
@@ -407,8 +484,15 @@ func cmdInstall(args []string) error {
|
||||
}
|
||||
}
|
||||
|
||||
// Config: created with defaults (server key, the chosen subnet) if missing.
|
||||
if !existing {
|
||||
// Config: created with defaults (server key, the chosen subnet) if
|
||||
// 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)
|
||||
initial := fmt.Sprintf("{\"server\": {\"ipv4\": %q}}\n", plan.ipv4)
|
||||
if err := os.WriteFile(configFile, []byte(initial), 0o600); err != nil {
|
||||
@@ -445,17 +529,179 @@ func cmdInstall(args []string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
step("Starting %s", serviceName)
|
||||
if err := sh("systemctl", "enable", serviceName); err != nil {
|
||||
// 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
|
||||
}
|
||||
if err := restartAndVerify(); err != nil {
|
||||
switched = time.Now()
|
||||
}
|
||||
|
||||
step("Starting %s", serviceName)
|
||||
err = sh("systemctl", "enable", serviceName)
|
||||
if err == nil {
|
||||
err = restartAndVerify()
|
||||
}
|
||||
if err != nil {
|
||||
if pv != nil {
|
||||
fmt.Fprintln(os.Stderr, " ", err)
|
||||
return pivpnBack(pv, store.Get(), err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
if pv != nil {
|
||||
waitForPeers(pv, connected, switched, *noWait, interactive)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return filepath.Walk(root, func(p string, _ os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
@@ -543,7 +789,7 @@ func cmdUpdate(args []string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
backup := configFile + ".bak-" + oldVersion
|
||||
backup := newUpdateBackupPath(configFile, oldVersion, time.Now())
|
||||
step("Backing up config to %s", backup)
|
||||
if err := copyFile(configFile, backup, 0o600, uid, gid); err != nil {
|
||||
return err
|
||||
@@ -581,6 +827,11 @@ func cmdUpdate(args []string) error {
|
||||
}
|
||||
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)
|
||||
return nil
|
||||
}
|
||||
@@ -612,9 +863,11 @@ func cmdUninstall(args []string) error {
|
||||
|
||||
step("Removing the WireGuard interface and firewall table")
|
||||
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.applyDefaults()
|
||||
}
|
||||
if k, err := newKernel(); err == nil {
|
||||
if err := k.Down(c); err != nil {
|
||||
|
||||
@@ -20,6 +20,10 @@
|
||||
}
|
||||
|
||||
// Same drawing as favicon.svg.
|
||||
// ext opens an outside page in a new tab, marked with ↗ as in the app.
|
||||
const ext = (href, text) => h('a', { class: 'ext', href, target: '_blank', rel: 'noopener' }, text,
|
||||
h('span', { class: 'ar', 'aria-hidden': 'true' }, '↗'), h('span', { class: 'sr' }, ' (opens in a new tab)'));
|
||||
|
||||
function logo(size, plain) {
|
||||
const s = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
for (const [k, v] of Object.entries({ width: size, height: size, viewBox: '0 0 64 64', 'aria-hidden': 'true' })) s.setAttribute(k, v);
|
||||
@@ -108,9 +112,9 @@
|
||||
h('div', { class: 'notice' }, 'Save it now. This page can\'t be opened again: the private key exists only here and isn\'t stored anywhere.'),
|
||||
h('ol', { class: 'steps' },
|
||||
step(1, 'Install WireGuard',
|
||||
h('p', null, h('a', { href: 'https://apps.apple.com/app/wireguard/id1441195209', rel: 'noopener' }, 'App Store'), ' · ',
|
||||
h('a', { href: 'https://play.google.com/store/apps/details?id=com.wireguard.android', rel: 'noopener' }, 'Google Play'), ' · ',
|
||||
h('a', { href: 'https://www.wireguard.com/install/', rel: 'noopener' }, 'Other systems'))),
|
||||
h('p', null, ext('https://apps.apple.com/app/wireguard/id1441195209', 'App Store'), ' · ',
|
||||
ext('https://play.google.com/store/apps/details?id=com.wireguard.android', 'Google Play'), ' · ',
|
||||
ext('https://www.wireguard.com/install/', 'Other systems'))),
|
||||
step(2, 'Add the profile',
|
||||
h('button', { type: 'button', class: 'btn primary', onClick: download }, 'Download ' + file),
|
||||
h('p', null, 'Open the downloaded file with WireGuard, or in WireGuard tap + and choose “Create from file”.')),
|
||||
|
||||
@@ -260,7 +260,7 @@ func (a *App) setupRedeem(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
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 != "" {
|
||||
p.PresharedKey = psk.String()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Speeds keeps the last few minutes of each peer's speed in memory for the
|
||||
// Live page. It reads the kernel counters every speedStep, apart from the
|
||||
// traffic history in Stats, and never writes to disk.
|
||||
|
||||
const (
|
||||
speedStep = 2 * time.Second
|
||||
speedPoints = 60 // 2 minutes
|
||||
)
|
||||
|
||||
// SpeedPoint is one step: per peer ID, download and upload in bits per
|
||||
// second, from the peer's point of view.
|
||||
type SpeedPoint struct {
|
||||
T int64 `json:"t"`
|
||||
Peers map[string][2]int64 `json:"peers"`
|
||||
}
|
||||
|
||||
type Speeds struct {
|
||||
store *Store
|
||||
kernel Kernel
|
||||
|
||||
mu sync.Mutex
|
||||
last map[string][2]int64 // raw rx, tx by public key
|
||||
lastAt time.Time
|
||||
points []SpeedPoint
|
||||
subs map[chan SpeedPoint]struct{}
|
||||
done chan struct{} // closed when Run returns
|
||||
}
|
||||
|
||||
func newSpeeds(store *Store, kernel Kernel) *Speeds {
|
||||
return &Speeds{store: store, kernel: kernel, last: map[string][2]int64{}, subs: map[chan SpeedPoint]struct{}{}, done: make(chan struct{})}
|
||||
}
|
||||
|
||||
// Subscribe returns the current points and a channel that receives each new
|
||||
// one; cancel ends the subscription. A subscriber that falls behind misses
|
||||
// points rather than holding up the sampler.
|
||||
func (s *Speeds) Subscribe() (points []SpeedPoint, ch <-chan SpeedPoint, cancel func()) {
|
||||
c := make(chan SpeedPoint, 4)
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.subs[c] = struct{}{}
|
||||
return append([]SpeedPoint{}, s.points...), c, func() {
|
||||
s.mu.Lock()
|
||||
delete(s.subs, c)
|
||||
s.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// Done is closed when the sampler stops, so streams can end.
|
||||
func (s *Speeds) Done() <-chan struct{} { return s.done }
|
||||
|
||||
func (s *Speeds) sample(now time.Time) {
|
||||
cfg := s.store.Get()
|
||||
samples, err := s.kernel.Sample(cfg.Server.Interface)
|
||||
if err != nil {
|
||||
slog.Debug("speed sample failed", "err", err)
|
||||
return
|
||||
}
|
||||
idByKey := map[string]string{}
|
||||
for _, p := range cfg.Peers {
|
||||
if p.hasKey() {
|
||||
idByKey[p.PublicKey] = p.ID
|
||||
}
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
secs := now.Sub(s.lastAt).Seconds()
|
||||
first := s.lastAt.IsZero()
|
||||
cur := map[string][2]int64{}
|
||||
pt := SpeedPoint{T: now.Unix(), Peers: map[string][2]int64{}}
|
||||
for _, smp := range samples {
|
||||
cur[smp.PublicKey] = [2]int64{smp.RxBytes, smp.TxBytes}
|
||||
id := idByKey[smp.PublicKey]
|
||||
prev, ok := s.last[smp.PublicKey]
|
||||
if id == "" || !ok || first {
|
||||
continue
|
||||
}
|
||||
dRx, dTx := smp.RxBytes-prev[0], smp.TxBytes-prev[1]
|
||||
if dRx < 0 || dTx < 0 { // counters were reset
|
||||
continue
|
||||
}
|
||||
// Tx is what the server sent: the peer's download.
|
||||
pt.Peers[id] = [2]int64{int64(float64(dTx*8) / secs), int64(float64(dRx*8) / secs)}
|
||||
}
|
||||
s.last, s.lastAt = cur, now
|
||||
if first {
|
||||
return
|
||||
}
|
||||
s.points = append(s.points, pt)
|
||||
if len(s.points) > speedPoints {
|
||||
s.points = s.points[len(s.points)-speedPoints:]
|
||||
}
|
||||
for c := range s.subs {
|
||||
select {
|
||||
case c <- pt:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Since returns the points newer than the unix time t, oldest first.
|
||||
func (s *Speeds) Since(t int64) []SpeedPoint {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
out := []SpeedPoint{}
|
||||
for _, p := range s.points {
|
||||
if p.T > t {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Speeds) Run(stop <-chan struct{}) {
|
||||
defer close(s.done)
|
||||
s.sample(time.Now())
|
||||
t := time.NewTicker(speedStep)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
case now := <-t.C:
|
||||
s.sample(now)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Each update copies config.json to config.json.bak-<old version> next to
|
||||
// it, in case the new version must be rolled back. The copies hold the same
|
||||
// secrets as a backup, so the web interface lists them and can remove them,
|
||||
// and update keeps only the newest few.
|
||||
|
||||
const keepUpdateBackups = 3
|
||||
|
||||
type UpdateBackup struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
Modified time.Time `json:"modified"`
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
|
||||
// A copy that would overwrite an older one gets the time appended.
|
||||
var backupStampRe = regexp.MustCompile(`-\d{8}-\d{4}$`)
|
||||
|
||||
func updateBackupPrefix(configPath string) string { return filepath.Base(configPath) + ".bak-" }
|
||||
|
||||
// newUpdateBackupPath names the copy update makes of configPath.
|
||||
func newUpdateBackupPath(configPath, version string, now time.Time) string {
|
||||
p := configPath + ".bak-" + version
|
||||
if _, err := os.Lstat(p); err == nil {
|
||||
p += now.Format("-20060102-1504")
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// listUpdateBackups returns the copies next to configPath, newest first.
|
||||
func listUpdateBackups(configPath string) ([]UpdateBackup, error) {
|
||||
entries, err := os.ReadDir(filepath.Dir(configPath))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
prefix := updateBackupPrefix(configPath)
|
||||
out := []UpdateBackup{}
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
if !e.Type().IsRegular() || !strings.HasPrefix(name, prefix) || name == prefix {
|
||||
continue
|
||||
}
|
||||
fi, err := e.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, UpdateBackup{Name: name, Version: backupStampRe.ReplaceAllString(strings.TrimPrefix(name, prefix), ""),
|
||||
Modified: fi.ModTime(), Size: fi.Size()})
|
||||
}
|
||||
slices.SortFunc(out, func(a, b UpdateBackup) int { return b.Modified.Compare(a.Modified) })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// removeUpdateBackup deletes one copy; any other name is refused.
|
||||
func removeUpdateBackup(configPath, name string) error {
|
||||
prefix := updateBackupPrefix(configPath)
|
||||
path := filepath.Join(filepath.Dir(configPath), name)
|
||||
fi, err := os.Lstat(path)
|
||||
if !strings.HasPrefix(name, prefix) || name == prefix || strings.ContainsAny(name, `/\`) ||
|
||||
errors.Is(err, fs.ErrNotExist) || (err == nil && !fi.Mode().IsRegular()) {
|
||||
return badRequest("no copy named %q", name)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Remove(path)
|
||||
}
|
||||
|
||||
// pruneUpdateBackups keeps the newest keep copies and deletes the rest.
|
||||
func pruneUpdateBackups(configPath string, keep int) (int, error) {
|
||||
list, err := listUpdateBackups(configPath)
|
||||
if err != nil || len(list) <= keep {
|
||||
return 0, err
|
||||
}
|
||||
n := 0
|
||||
for _, b := range list[keep:] {
|
||||
if err := removeUpdateBackup(configPath, b.Name); err != nil {
|
||||
return n, err
|
||||
}
|
||||
n++
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (a *App) listUpdateBackups(w http.ResponseWriter, r *http.Request) {
|
||||
list, err := listUpdateBackups(a.store.path)
|
||||
if err != nil {
|
||||
writeErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"backups": list})
|
||||
}
|
||||
|
||||
func (a *App) removeUpdateBackup(w http.ResponseWriter, r *http.Request) {
|
||||
name := r.PathValue("name")
|
||||
if err := removeUpdateBackup(a.store.path, name); err != nil {
|
||||
writeErr(w, err)
|
||||
return
|
||||
}
|
||||
a.audit(r, "update backup removed", "file", name)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (a *App) removeUpdateBackups(w http.ResponseWriter, r *http.Request) {
|
||||
n, err := pruneUpdateBackups(a.store.path, 0)
|
||||
if err != nil {
|
||||
writeErr(w, err)
|
||||
return
|
||||
}
|
||||
a.audit(r, "update backups removed", "count", n)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "removed": n})
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestUpdateBackups(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfg := filepath.Join(dir, "config.json")
|
||||
_ = os.WriteFile(cfg, []byte("{}"), 0o600)
|
||||
now := time.Date(2026, 10, 5, 12, 9, 0, 0, time.UTC)
|
||||
|
||||
// A second copy of the same version gets the time appended instead of
|
||||
// overwriting the first.
|
||||
first := newUpdateBackupPath(cfg, "unknown", now)
|
||||
if filepath.Base(first) != "config.json.bak-unknown" {
|
||||
t.Fatalf("first copy: %s", first)
|
||||
}
|
||||
_ = os.WriteFile(first, []byte("{}"), 0o600)
|
||||
second := newUpdateBackupPath(cfg, "unknown", now)
|
||||
if filepath.Base(second) != "config.json.bak-unknown-20261005-1209" {
|
||||
t.Fatalf("second copy: %s", second)
|
||||
}
|
||||
_ = os.WriteFile(second, []byte("{}"), 0o600)
|
||||
for i, v := range []string{"v0.2.0", "v0.3.0", "v0.4.0"} {
|
||||
f := filepath.Join(dir, "config.json.bak-"+v)
|
||||
_ = os.WriteFile(f, []byte("{}"), 0o600)
|
||||
_ = os.Chtimes(f, now, now.Add(time.Duration(i+1)*time.Hour))
|
||||
}
|
||||
_ = os.Chtimes(first, now, now.Add(-2*time.Hour))
|
||||
_ = os.Chtimes(second, now, now.Add(-time.Hour))
|
||||
_ = os.Mkdir(filepath.Join(dir, "config.json.bak-dir"), 0o700) // not a file: ignored
|
||||
|
||||
list, err := listUpdateBackups(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var got []string
|
||||
for _, b := range list {
|
||||
got = append(got, b.Version)
|
||||
}
|
||||
if strings.Join(got, " ") != "v0.4.0 v0.3.0 v0.2.0 unknown unknown" {
|
||||
t.Fatalf("versions, newest first: %v", got)
|
||||
}
|
||||
|
||||
for _, bad := range []string{"config.json", "config.json.bak-", "config.json.bak-dir", "../config.json.bak-v0.4.0", "config.json.bak-v0.4.0/x"} {
|
||||
if err := removeUpdateBackup(cfg, bad); err == nil {
|
||||
t.Errorf("removed %q", bad)
|
||||
}
|
||||
}
|
||||
|
||||
if n, err := pruneUpdateBackups(cfg, keepUpdateBackups); err != nil || n != 2 {
|
||||
t.Fatalf("prune: %d, %v", n, err)
|
||||
}
|
||||
if list, _ = listUpdateBackups(cfg); len(list) != 3 || list[2].Version != "v0.2.0" {
|
||||
t.Fatalf("after prune: %v", list)
|
||||
}
|
||||
if _, err := os.Stat(cfg); err != nil {
|
||||
t.Fatal("config.json is gone")
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ type userView struct {
|
||||
LastLogin *tokenUse `json:"lastLogin"` // since the service started
|
||||
Tokens int `json:"tokens"`
|
||||
You bool `json:"you"`
|
||||
MFA map[string]any `json:"mfa"` // {"totp": bool, "passkeys": n}
|
||||
}
|
||||
|
||||
func (a *App) userView(c *Config, u *User, me string) userView {
|
||||
@@ -30,7 +31,7 @@ func (a *App) userView(c *Config, u *User, me string) userView {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return userView{u.ID, u.Username, u.Note, u.MustChangePassword, u.Created, a.auth.LastLogin(u.ID), n, u.ID == me}
|
||||
return userView{u.ID, u.Username, u.Note, u.MustChangePassword, u.Created, a.auth.LastLogin(u.ID), n, u.ID == me, mfaSummary(u)}
|
||||
}
|
||||
|
||||
// username names a user for lists, or "" if the ID is unknown.
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"embed"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
@@ -14,6 +16,39 @@ import (
|
||||
//go:embed index.html setup.html app.js setup.js app.css favicon.svg apple-touch-icon.png ShipporiMinchoB1-ExtraBold.woff2
|
||||
var webFiles embed.FS
|
||||
|
||||
// The pages load app.js, setup.js and app.css with ?v=<hash of the file>, so
|
||||
// a new binary makes browsers fetch the new files, and a fingerprinted file
|
||||
// can be cached for good.
|
||||
var (
|
||||
assetHash = map[string]string{}
|
||||
indexPage []byte
|
||||
)
|
||||
|
||||
func init() {
|
||||
for _, name := range []string{"app.js", "setup.js", "app.css"} {
|
||||
b, err := webFiles.ReadFile(name)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
sum := sha256.Sum256(b)
|
||||
assetHash[name] = hex.EncodeToString(sum[:5])
|
||||
}
|
||||
b, err := webFiles.ReadFile("index.html")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
indexPage = fingerprint(b, "/")
|
||||
}
|
||||
|
||||
// fingerprint adds ?v=<hash> to the page's references to base + file.
|
||||
func fingerprint(page []byte, base string) []byte {
|
||||
s := string(page)
|
||||
for name, h := range assetHash {
|
||||
s = strings.ReplaceAll(s, `"`+base+name+`"`, `"`+base+name+"?v="+h+`"`)
|
||||
}
|
||||
return []byte(s)
|
||||
}
|
||||
|
||||
func (a *App) webHandler() http.Handler {
|
||||
files := http.FileServerFS(webFiles)
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -22,7 +57,18 @@ func (a *App) webHandler() http.Handler {
|
||||
return
|
||||
}
|
||||
switch r.URL.Path {
|
||||
case "/", "/app.js", "/setup.js", "/app.css", "/favicon.svg", "/apple-touch-icon.png":
|
||||
case "/":
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
_, _ = w.Write(indexPage)
|
||||
case "/app.js", "/setup.js", "/app.css":
|
||||
if v := r.URL.Query().Get("v"); v != "" && v == assetHash[r.URL.Path[1:]] {
|
||||
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||
} else {
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
}
|
||||
files.ServeHTTP(w, r)
|
||||
case "/favicon.svg", "/apple-touch-icon.png":
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
files.ServeHTTP(w, r)
|
||||
case "/ShipporiMinchoB1-ExtraBold.woff2":
|
||||
@@ -66,7 +112,7 @@ func (a *App) setupPage(w http.ResponseWriter, r *http.Request) {
|
||||
page := strings.NewReplacer(`href="/`, `href="`+base, `src="/`, `src="`+base).Replace(string(b))
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_, _ = w.Write([]byte(page))
|
||||
_, _ = w.Write(fingerprint([]byte(page), base))
|
||||
}
|
||||
|
||||
func (a *App) setupAsset(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||