34 Commits

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

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

The log viewer moved to a new Log page in the sidebar, with a filter for
changes only, and the Dashboard's Log link opens it.
2026-10-05 12:11:32 +03:00
Daniel Redetzke 59c4fb5ff1 Update notice: a newer release shows in the web interface
Once a day the server asks Gitea or GitHub, as picked under Settings ->
Updates, for the latest release. A newer one shows as a pill in the
sidebar, a banner on the Dashboard and in the Updates card with its
release notes and the commands to update this server. Drafts and
pre-releases are ignored, nothing about the server is sent, and the check
can be switched off. POST /updates/check checks now.
2026-10-05 11:59:27 +03:00
Daniel Redetzke 15d921019a Health card: addresses beside a list of checks
The public addresses stack on the left and the other checks are rows in
one list on the right, each with its raw setting right after the status.
Below 1000px the addresses move above the list.
2026-10-05 10:10:51 +03:00
Daniel Redetzke 2bc6465ea7 README: the iOS app is in beta testing; invites by email 2026-10-05 09:35:34 +03:00
Daniel Redetzke ef636e8a58 Health card: public addresses up top, checks as tiles
The public IPv4 and IPv6 addresses, with their uplink, lead the card.
Every other check is a tile with a plain-word status, the raw setting
and, when it fails, what is wrong. The header counts passing or failing
checks.
2026-10-05 09:09:44 +03:00
Daniel Redetzke caa8774c02 Revert "Send adminUsername in /settings again"
This reverts commit c846345a1c.
2026-10-05 08:54:04 +03:00
Daniel Redetzke 1d3bce3da3 Send adminUsername in /settings again
iOS app builds before Companion 2c9cc1c cannot decode /settings without
it, and App Review still tests such builds. A test keeps it in place.
2026-10-05 08:51:19 +03:00
Daniel Redetzke ae89692ba9 API tokens no longer manage users, passwords or tokens
A full-access token could create a user or reset a password, sign in as
that user and so reach backups and two-step sign-in settings. Users,
passwords, API tokens and the sign-in rules in PATCH /settings now need
a signed-in user again. /auth/me no longer returns tokenId, and
/settings no longer returns adminUsername.
2026-10-05 08:41:18 +03:00
Daniel Redetzke dc002b7440 Show peer names in plain ink instead of underlined links 2026-10-05 01:57:00 +03:00
Daniel Redetzke cf1e1f7f63 Show dialogs again when an extension moves them
Bitwarden moves elements around in <body>. A moved dialog stayed open but
fell out of the top layer to the bottom of the page, so a confirmation
seemed to vanish and its checkbox stayed ticked unsaved.
2026-10-05 01:45:32 +03:00
Daniel Redetzke 592b738f10 Cap concurrent password checks and count attempts before checking
Every argon2 run takes 64 MiB and nothing limited how many ran at once,
so parallel sign-in attempts could run the server out of memory (8 at
once used about 600 MB). At most two now run at once; at most 16
sign-ins wait for one, more get HTTP 429. 30 parallel sign-ins peaked
at 275 MB.

A sign-in attempt now counts toward the lockout before its password is
checked, so parallel attempts cannot get past it; a right password
takes its own attempt back. IPv6 addresses are locked out by /64.
2026-10-05 00:23:05 +03:00
Daniel Redetzke e4a40ff0c3 Keep IPv6 router announcements working with forwarding on
With net.ipv6.conf.all.forwarding=1, Linux ignores router announcements
unless accept_ra is 2, so a server that gets its IPv6 route by SLAAC
(e.g. a Raspberry Pi at home) lost IPv6 once the route expired.

The sysctl file now also sets accept_ra=2 for the default and for every
network card and the IPv6 default-route interface, except where
accept_ra is 0. "update" rewrites the file, which fixes existing
installs. A new health check warns while the uplink still has
accept_ra=1.
2026-10-05 00:10:13 +03:00
Daniel Redetzke fbca13e528 README: dashboard screenshot from v0.3.1 2026-10-04 23:01:17 +03:00
Daniel Redetzke 3069a3006e README: bring iOS app, tokens, config.json and lockout up to date 2026-10-04 22:54:48 +03:00
Daniel Redetzke f5052642c6 README: keep only the dashboard screenshot 2026-10-04 22:50:13 +03:00
Daniel Redetzke 00429ae484 Delete stored security keys 2026-10-04 22:35:19 +03:00
Daniel Redetzke 1de98f785b Passkeys only: drop adding security keys 2026-10-04 22:13:31 +03:00
Daniel Redetzke 0bafc3d7bc Japanese hover label on the passkey button 2026-10-04 22:03:12 +03:00
27 changed files with 2569 additions and 349 deletions
+61 -41
View File
@@ -27,32 +27,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 - **IPv4 and IPv6:** IPv6 inside the tunnel is turned on automatically when the
server has a global IPv6 address. server has a global IPv6 address.
- **Traffic history:** kept in `stats.json`, hourly for 48 h and daily for - **Traffic history:** kept in `stats.json`, hourly for 48 h and daily for
400 days by default (Settings → Data retention). 400 days by default (Settings → Logs & history).
- **Live view:** the speed of every peer right now, updated every 2 seconds,
with the last 2 minutes as a chart. Kept in memory only.
- **Connection history:** every online session per peer, with start, duration, - **Connection history:** every online session per peer, with start, duration,
address and traffic. A new session starts when a device changes networks. address and traffic. A new session starts when a device changes networks.
Country and network operator come from the free Country and network operator come from the free
[DB-IP Lite](https://db-ip.com) databases (CC BY 4.0). GHOSTWIRE downloads [DB-IP Lite](https://db-ip.com) databases (CC BY 4.0). GHOSTWIRE downloads
them monthly (about 20 MB) and looks addresses up locally, so peer addresses them monthly (about 20 MB) and looks addresses up locally, so peer addresses
never leave the server. You can switch this off under Settings → Data never leave the server. You can switch this off under Settings → Logs &
retention. history.
- **Logs:** written to `GHOSTWIRE.jsonl`, rotated at 10 MB with 5 old files - **Logs:** written to `GHOSTWIRE.jsonl`, rotated at 10 MB with 5 old files
kept by default. Changes are marked as audit entries. kept by default, and shown on the Log page. Changes are marked as audit
entries.
- **Update notice:** once a day the server asks Gitea or GitHub (your choice
under Settings → Updates) for the latest release. A newer one shows in the
sidebar, on the Dashboard and in Settings, with its release notes and the
commands to update this server. Nothing about the server is sent; the check
can be switched off.
- **HTTPS built in:** Let's Encrypt, a self-signed certificate, your own - **HTTPS built in:** Let's Encrypt, a self-signed certificate, your own
certificate files, or plain HTTP behind a reverse proxy. certificate files, or plain HTTP behind a reverse proxy.
## Screenshots
| | |
|---|---|
| ![Peers list with status, endpoint, latency sparklines and 30-day traffic](screenshots/peers.png) | ![Peer page with traffic and latency charts, connection details and history](screenshots/peer.png) |
| **Peers:** status, endpoint, latency and traffic at a glance | **Peer:** traffic, latency, connection history and settings |
| ![Server page with health checks, interface, endpoint, client defaults and firewall](screenshots/server.png) | ![Settings with users, web interface and API tokens](screenshots/settings.png) |
| **Server:** health, address plan, client defaults and firewall | **Settings:** users, web interface and API tokens |
| ![My account page with profile, password and own app tokens](screenshots/account.png) | ![Sign-in page](screenshots/login.png) |
| **My account:** profile, password and your app tokens | **Sign-in** |
The screenshots show sample data from the built-in simulator.
## Security ## Security
- **Client private keys are never stored.** A config is shown once, as a - **Client private keys are never stored.** A config is shown once, as a
@@ -65,16 +60,19 @@ The screenshots show sample data from the built-in simulator.
`CAP_NET_ADMIN` and `CAP_NET_BIND_SERVICE`, and can write only to `CAP_NET_ADMIN` and `CAP_NET_BIND_SERVICE`, and can write only to
`/opt/ghostwire`. `/opt/ghostwire`.
- **Sign-in:** one or more users, all admins. Passwords are stored as argon2id hashes. - **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. HttpOnly, SameSite=Strict cookie and last 12 hours by default.
- **Two-step sign-in:** each user can add an authenticator app (TOTP), security - **Two-step sign-in:** each user can add an authenticator app (TOTP) and
keys such as a YubiKey, and passkeys that sign in without a password, under passkeys under My account. A passkey signs in on its own, without username
My account. Turning it on gives 10 one-time recovery codes. An admin can and password, and also works as the second step after a password. It can live
require it for everyone (Settings → Sign-in) and reset it for a user who lost on the device (Touch ID, Face ID, Windows Hello), in a password manager, or on
their phone or key. Security keys and passkeys use WebAuthn and need the a YubiKey with a PIN set. Turning it on gives 10 one-time recovery codes. An
server's domain name with a trusted certificate (Let's Encrypt, certificate admin can require it for everyone (Settings → Sign-in) and reset it for a user
files, or a reverse proxy); on a self-signed certificate or an IP address, who lost their phone or key. Passkeys use WebAuthn and need the server's
only the authenticator app is offered. API tokens never need a second step. 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. - **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 - `config.json` holds the server private key and is readable only by the
service (0600). service (0600).
@@ -186,7 +184,7 @@ the running service.
| Command | What it does | | Command | What it does |
|---|---| |---|---|
| `GHOSTWIRE install [-domain d] [-email e] [-endpoint h] [-port p] [-y]` | Sets up and starts the service, as above. Asks for the settings no flag gave; `-y` never asks. | | `GHOSTWIRE install [-domain d] [-email e] [-endpoint h] [-port p] [-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 update [-force]` | Run from the new binary, e.g. `sudo /tmp/GHOSTWIRE update`. Checks that it can read the current `config.json` (nothing changes if not), backs up the config to `config.json.bak-<old version>` (keeping the newest 3 such copies), replaces the binary, updates the unit if needed and restarts. If the new version does not stay up, the old binary and config are put back and restarted. It refuses older versions without `-force`. |
| `GHOSTWIRE uninstall [-purge] [-y]` | Stops and removes the service, `wg0` and the firewall table. `-purge` also deletes `/opt/ghostwire` and the user. | | `GHOSTWIRE uninstall [-purge] [-y]` | Stops and removes the service, `wg0` and the firewall table. `-purge` also deletes `/opt/ghostwire` and the user. |
| `GHOSTWIRE passwd [username]` | Sets a user's password (default: the first user) and reloads the running service. The way back in if you are locked out. | | `GHOSTWIRE passwd [username]` | Sets a user's password (default: the first user) and reloads the running service. The way back in if you are locked out. |
| `GHOSTWIRE version` | Prints the version. | | `GHOSTWIRE version` | Prints the version. |
@@ -227,7 +225,8 @@ After editing `config.json` by hand, run `sudo systemctl reload ghostwire`.
| File | Content | | File | Content |
|---|---| |---|---|
| `GHOSTWIRE` | the program | | `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 | | `stats.json` | traffic and connection history per peer |
| `geo-country.mmdb`, `geo-asn.mmdb` | DB-IP Lite databases for country and network lookups | | `geo-country.mmdb`, `geo-asn.mmdb` | DB-IP Lite databases for country and network lookups |
| `GHOSTWIRE.jsonl` | log, one JSON object per line. Changes carry `"audit":true` | | `GHOSTWIRE.jsonl` | log, one JSON object per line. Changes carry `"audit":true` |
@@ -239,32 +238,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 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 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 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 GET. Full-access tokens can do everything the web interface does except the
and restore. Users, passwords and API tokens need a full-access token even for endpoints marked "signed in": users, passwords, API tokens, the sign-in rules,
reading. backup and restore.
For a user with two-step sign-in, `POST /auth/login` answers For a user with two-step sign-in, `POST /auth/login` answers
`{"mfa": true, "ticket": "…", "methods": ["key", "totp", "recovery"]}` `{"mfa": true, "ticket": "…", "methods": ["key", "totp", "recovery"]}`
instead of starting a session; the ticket is good for 5 minutes, and one of 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` the `/auth/login/…` steps turns it into the session. `PATCH /settings`
`{"signin": {"requireMfa": true}}` requires two-step sign-in for every user. `{"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 `POST /users` and `POST /users/{id}/reset-password` take
`{"password": "…", "mustChangePassword": true}`; with `true` (the default) the `{"password": "…", "mustChangePassword": true}`; with `true` (the default) the
user can do nothing but choose a new password at the next sign-in. 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) POST /auth/login · /auth/logout GET /auth/me
GET /users POST /users PATCH /users/{id} DELETE /users/{id} signed in: POST /auth/password (own password)
POST /users/{id}/reset-password POST /users/{id}/reset-mfa 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) GET /auth/options (public: is passkey sign-in offered here)
POST /auth/login/totp · /auth/login/recovery {"ticket", "code"} 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/key/begin {"ticket"} · /auth/login/key/finish?ticket= (body: the WebAuthn credential)
POST /auth/login/passkey/begin · /auth/login/passkey/finish?id= 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: GET /auth/mfa · POST /auth/mfa/totp/setup · /auth/mfa/totp/confirm · DELETE /auth/mfa/totp
signed in: POST /auth/mfa/keys/begin {"passkey"} · /auth/mfa/keys/finish?name= · PATCH|DELETE /auth/mfa/keys/{id} signed in: POST /auth/mfa/keys/begin · /auth/mfa/keys/finish?name= · PATCH|DELETE /auth/mfa/keys/{id}
signed in: POST /auth/mfa/recovery-codes signed in: POST /auth/mfa/recovery-codes
GET /status GET /stats?range=24h|7d|30d|90d GET /status GET /stats?range=24h|7d|30d|90d
GET /live?since= (speed per peer, last 2 minutes in 2-second steps)
GET /live/stream (the same as server-sent events)
GET /server PATCH /server POST /server/rotate-key GET /server/detect-ip GET /server PATCH /server POST /server/rotate-key GET /server/detect-ip
GET /peers POST /peers (returns the config and QR once) GET /peers POST /peers (returns the config and QR once)
GET /peers/{id} PATCH /peers/{id} DELETE /peers/{id} GET /peers/{id} PATCH /peers/{id} DELETE /peers/{id}
@@ -272,10 +275,10 @@ POST /peers/{id}/enable | /disable | /issue-config
GET /peers/{id}/stats?range=… GET /peers/{id}/sessions?limit=100 GET /peers/{id}/stats?range=… GET /peers/{id}/sessions?limit=100
GET /peers/{id}/latency (24 h, one point per 5 minutes) GET /peers/{id}/latency (24 h, one point per 5 minutes)
GET /peers/{id}/setup (not read-only) DELETE /peers/{id}/setup GET /peers/{id}/setup (not read-only) DELETE /peers/{id}/setup
GET /settings PATCH /settings POST /restart GET /settings PATCH /settings POST /restart POST /updates/check
GET /logs?level=&limit=&audit=1 GET /logs/download GET /logs?level=&limit=&audit=1 GET /logs/download
GET /tokens POST /tokens DELETE /tokens/{id} signed in: GET|POST /tokens · DELETE /tokens/{id} · GET /backup · POST /restore
signed in: GET /backup · POST /restore signed in: GET|DELETE /update-backups · DELETE /update-backups/{name} (config copies made by update)
public: GET /setup/{token} · POST /setup/{token} {"pin"} (what a setup link opens) public: GET /setup/{token} · POST /setup/{token} {"pin"} (what a setup link opens)
``` ```
@@ -284,9 +287,22 @@ public: GET /setup/{token} · POST /setup/{token} {"pin"} (what a setup link o
setup link (`setup.url`, `setup.pin`, `setup.qr`) instead of a config. With a setup link (`setup.url`, `setup.pin`, `setup.qr`) instead of a config. With a
link, the peer's current keys keep working until the link is opened. link, the peer's current keys keep working until the link is opened.
`GET /settings` includes `updates`: the running and latest version,
`available`, the release notes and the download links for this server's
platform. `PATCH /settings` `{"updates": {"source": "gitea"|"github",
"check": false}}` picks the source or switches the daily check off;
`POST /updates/check` checks now. `GET /auth/me` has `updateAvailable` with
the newer version while there is one.
Traffic is reported from the peer's point of view: `down` is what the peer Traffic is reported from the peer's point of view: `down` is what the peer
downloaded, `up` is what it uploaded. downloaded, `up` is what it uploaded.
`GET /live` answers `{"step": 2, "size": 60, "points": [{"t": …, "peers":
{"<id>": [down, up]}}]}` with speeds in bits per second, kept only in memory.
With `since` (unix seconds) it returns only newer steps. `GET /live/stream`
sends the same messages as server-sent events: the history first, then one
message per new step.
Latency is measured by pinging the peer's tunnel address every 30 seconds. Set Latency is measured by pinging the peer's tunnel address every 30 seconds. Set
it per peer with `PATCH /peers/{id}` `{"latencyCheck": "off"|"active"|"always"}` it per peer with `PATCH /peers/{id}` `{"latencyCheck": "off"|"active"|"always"}`
(default `off`). `active` pings only while the device sends traffic, so idle (default `off`). `active` pings only while the device sends traffic, so idle
@@ -305,12 +321,16 @@ override a drop in another table, so if ufw or firewalld is active, allow UDP
## iOS app ## iOS app
The native iPhone app (SwiftUI, iOS 17+) lives in its own project, The native iPhone app (SwiftUI, iOS 17+) lives in its own project,
GHOSTWIRE-Companion. It does everything the web interface does except GHOSTWIRE-Companion. It manages peers, the server and the app settings and
password, API tokens and backups. Pair it in the web interface under 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 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 it into the app's "Enter manually". Self-signed certificates are pinned during
pairing. pairing.
The iOS app is currently in beta testing. For an invite, email
[engineroom@redetzke.aero](mailto:engineroom@redetzke.aero).
## Development ## Development
On macOS (or any non-Linux system), `make dev` starts the app on On macOS (or any non-Linux system), `make dev` starts the app on
+176 -35
View File
@@ -3,15 +3,18 @@ package main
import ( import (
"cmp" "cmp"
"context" "context"
"crypto/tls"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"io" "io"
"log/slog" "log/slog"
"net"
"net/http" "net/http"
"net/netip" "net/netip"
"slices" "slices"
"strings" "strings"
"syscall"
"time" "time"
) )
@@ -21,13 +24,16 @@ type App struct {
kernel Kernel kernel Kernel
recon *Reconciler recon *Reconciler
stats *Stats stats *Stats
speeds *Speeds // nil in tests
auth *Auth auth *Auth
tls *webTLS tls *webTLS
logPath string logPath string
logw *rotatingWriter // nil in tests logw *rotatingWriter // nil in tests
geo *Geo // nil in tests geo *Geo // nil in tests
updates *Updater // nil in tests
started time.Time started time.Time
shutdown func() // graceful stop; systemd restarts the service shutdown func() // graceful stop; systemd restarts the service
webAddrs []string // the addresses the web server listens on now
} }
// --- helpers --- // --- helpers ---
@@ -97,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 // applyResult saves-then-applies: the config is already stored, so a kernel
// error is reported but does not undo the change. // error is reported but does not undo the change.
func (a *App) apply() string { func (a *App) apply() string {
@@ -121,8 +116,6 @@ func (a *App) routes() http.Handler {
mux := http.NewServeMux() mux := http.NewServeMux()
g := func(pattern string, h http.HandlerFunc) { mux.HandleFunc(pattern, a.guard(false, h)) } 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)) } 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/login", a.login)
mux.HandleFunc("POST /api/v1/auth/logout", a.logout) mux.HandleFunc("POST /api/v1/auth/logout", a.logout)
@@ -146,16 +139,18 @@ func (a *App) routes() http.Handler {
adm("DELETE /api/v1/auth/mfa/keys/{id}", a.keyRemove) adm("DELETE /api/v1/auth/mfa/keys/{id}", a.keyRemove)
adm("POST /api/v1/auth/mfa/recovery-codes", a.newRecoveryCodesHandler) adm("POST /api/v1/auth/mfa/recovery-codes", a.newRecoveryCodesHandler)
g("GET /api/v1/auth/me", a.me) g("GET /api/v1/auth/me", a.me)
full("POST /api/v1/auth/password", a.changePassword) adm("POST /api/v1/auth/password", a.changePassword)
full("GET /api/v1/users", a.listUsers) adm("GET /api/v1/users", a.listUsers)
full("POST /api/v1/users", a.createUser) adm("POST /api/v1/users", a.createUser)
full("PATCH /api/v1/users/{id}", a.patchUser) adm("PATCH /api/v1/users/{id}", a.patchUser)
full("POST /api/v1/users/{id}/reset-password", a.resetPassword) adm("POST /api/v1/users/{id}/reset-password", a.resetPassword)
full("DELETE /api/v1/users/{id}", a.deleteUser) adm("DELETE /api/v1/users/{id}", a.deleteUser)
full("POST /api/v1/users/{id}/reset-mfa", a.resetMFA) adm("POST /api/v1/users/{id}/reset-mfa", a.resetMFA)
g("GET /api/v1/status", a.status) g("GET /api/v1/status", a.status)
g("GET /api/v1/stats", a.allStats) g("GET /api/v1/stats", a.allStats)
g("GET /api/v1/live", a.liveSpeeds)
g("GET /api/v1/live/stream", a.liveStream)
g("GET /api/v1/server", a.getServer) g("GET /api/v1/server", a.getServer)
g("PATCH /api/v1/server", a.patchServer) g("PATCH /api/v1/server", a.patchServer)
@@ -182,17 +177,22 @@ func (a *App) routes() http.Handler {
mux.HandleFunc("POST /api/v1/setup/{token}", a.setupRedeem) mux.HandleFunc("POST /api/v1/setup/{token}", a.setupRedeem)
// Full-access tokens (the iOS app) may change app settings, read logs and // 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("GET /api/v1/settings", a.getSettings)
g("PATCH /api/v1/settings", a.patchSettings) g("PATCH /api/v1/settings", a.patchSettings)
g("POST /api/v1/updates/check", a.checkUpdates)
g("POST /api/v1/restart", a.restart) g("POST /api/v1/restart", a.restart)
full("GET /api/v1/tokens", a.listTokens) adm("GET /api/v1/tokens", a.listTokens)
full("POST /api/v1/tokens", a.createToken) adm("POST /api/v1/tokens", a.createToken)
full("DELETE /api/v1/tokens/{id}", a.deleteToken) adm("DELETE /api/v1/tokens/{id}", a.deleteToken)
g("GET /api/v1/logs", a.logs) g("GET /api/v1/logs", a.logs)
g("GET /api/v1/logs/download", a.downloadLog) g("GET /api/v1/logs/download", a.downloadLog)
adm("GET /api/v1/backup", a.backup) adm("GET /api/v1/backup", a.backup)
adm("POST /api/v1/restore", a.restore) adm("POST /api/v1/restore", a.restore)
adm("GET /api/v1/update-backups", a.listUpdateBackups)
adm("DELETE /api/v1/update-backups", a.removeUpdateBackups)
adm("DELETE /api/v1/update-backups/{name}", a.removeUpdateBackup)
mux.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "no such endpoint"}) writeJSON(w, http.StatusNotFound, map[string]string{"error": "no such endpoint"})
@@ -232,7 +232,7 @@ func (a *App) login(w http.ResponseWriter, r *http.Request) {
if err != nil { if err != nil {
slog.Warn("login failed", "user", in.Username, "remote", ip, "reason", err.Error()) slog.Warn("login failed", "user", in.Username, "remote", ip, "reason", err.Error())
code := http.StatusUnauthorized code := http.StatusUnauthorized
if errors.Is(err, errLocked) { if errors.Is(err, errLocked) || errors.Is(err, errBusy) {
code = http.StatusTooManyRequests code = http.StatusTooManyRequests
} }
writeJSON(w, code, map[string]string{"error": err.Error()}) writeJSON(w, code, map[string]string{"error": err.Error()})
@@ -270,8 +270,8 @@ func (a *App) me(w http.ResponseWriter, r *http.Request) {
"id": p.UserID, "name": p.Name, "isAdmin": p.IsAdmin, "scope": p.Scope, "id": p.UserID, "name": p.Name, "isAdmin": p.IsAdmin, "scope": p.Scope,
"mustChangePassword": p.MustChangePassword, "mfaSetupRequired": p.MFASetupRequired, "version": version, "session": p.Session, "mustChangePassword": p.MustChangePassword, "mfaSetupRequired": p.MFASetupRequired, "version": version, "session": p.Session,
} }
if p.TokenID != "" { if v := a.updates.Available(); v != "" {
out["tokenId"] = p.TokenID // lets an app find its own token in /tokens out["updateAvailable"] = v
} }
if _, u := a.store.Get().userByID(p.UserID); u != nil { if _, u := a.store.Get().userByID(p.UserID); u != nil {
out["username"], out["note"], out["created"] = u.Username, u.Note, u.Created out["username"], out["note"], out["created"] = u.Username, u.Note, u.Created
@@ -357,7 +357,7 @@ func (a *App) status(w http.ResponseWriter, r *http.Request) {
d30, u30 := sumPoints(a.stats.series(nil, "30d")) d30, u30 := sumPoints(a.stats.series(nil, "30d"))
checks := a.kernel.Checks(cfg) checks := a.kernel.Checks(cfg)
last, applyErr := a.recon.Status() last, applyErr := a.recon.Status()
ac := Check{Name: "Last apply", OK: applyErr == nil, Detail: "applied " + last.Format(time.RFC3339)} ac := Check{Name: "Kernel in sync", OK: applyErr == nil, Detail: "applied " + last.Format(time.RFC3339)}
if applyErr != nil { if applyErr != nil {
ac.Detail = applyErr.Error() ac.Detail = applyErr.Error()
} }
@@ -405,6 +405,60 @@ func (a *App) allStats(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"range": rng, "points": a.stats.series(nil, rng)}) writeJSON(w, http.StatusOK, map[string]any{"range": rng, "points": a.stats.series(nil, rng)})
} }
// liveSpeeds returns each peer's speed over the last 2 minutes; with since
// (unix seconds) only the newer steps.
func (a *App) liveSpeeds(w http.ResponseWriter, r *http.Request) {
var since int64
fmt.Sscan(r.URL.Query().Get("since"), &since)
points := []SpeedPoint{}
if a.speeds != nil {
points = a.speeds.Since(since)
}
writeJSON(w, http.StatusOK, map[string]any{"step": int(speedStep / time.Second), "size": speedPoints, "points": points})
}
// liveStream sends the same data as server-sent events: the current points
// first, then each new step as soon as it is sampled. The session is checked
// again with every step, so signing out ends the stream.
func (a *App) liveStream(w http.ResponseWriter, r *http.Request) {
if a.speeds == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "live speeds are not available"})
return
}
rc := http.NewResponseController(w)
_ = rc.SetWriteDeadline(time.Time{}) // the server's write timeout would cut the stream
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("X-Accel-Buffering", "no") // nginx: do not buffer
points, ch, cancel := a.speeds.Subscribe()
defer cancel()
send := func(points []SpeedPoint) bool {
b, _ := json.Marshal(map[string]any{"step": int(speedStep / time.Second), "size": speedPoints, "points": points})
if _, err := fmt.Fprintf(w, "data: %s\n\n", b); err != nil {
return false
}
return rc.Flush() == nil
}
if !send(points) {
return
}
for {
select {
case <-r.Context().Done():
return
case <-a.speeds.Done():
return
case pt := <-ch:
if _, ok := a.auth.Authenticate(r); !ok {
return
}
if !send([]SpeedPoint{pt}) {
return
}
}
}
}
func (a *App) peerStats(w http.ResponseWriter, r *http.Request) { func (a *App) peerStats(w http.ResponseWriter, r *http.Request) {
cfg := a.store.Get() cfg := a.store.Get()
if _, p := cfg.peerByID(r.PathValue("id")); p == nil { if _, p := cfg.peerByID(r.PathValue("id")); p == nil {
@@ -1014,7 +1068,7 @@ func (a *App) getSettings(w http.ResponseWriter, r *http.Request) {
"decoy": cfg.Decoy, "decoy": cfg.Decoy,
"signin": cfg.SignIn, "signin": cfg.SignIn,
"geo": a.geoStatus(), "geo": a.geoStatus(),
"adminUsername": a.username(cfg, who(r).UserID), // kept for older iOS app versions "updates": a.updates.Status(),
"fingerprint": a.tls.Fingerprint(), "fingerprint": a.tls.Fingerprint(),
"logPath": a.logPath, "logPath": a.logPath,
}) })
@@ -1026,18 +1080,29 @@ func (a *App) patchSettings(w http.ResponseWriter, r *http.Request) {
writeErr(w, err) writeErr(w, err)
return return
} }
if _, ok := m["adminUsername"]; ok { if _, ok := m["signin"]; ok && !who(r).IsAdmin {
writeErr(w, badRequest("usernames are changed under /users")) writeJSON(w, http.StatusForbidden, map[string]string{"error": "API tokens cannot change the sign-in rules; sign in to the web interface"})
return return
} }
var restart bool var restart bool
err = a.store.Update(func(c *Config) error { err = a.store.Update(func(c *Config) error {
before, _ := json.Marshal(c.Web) // Session length applies to the next sign-in; everything else in
// web needs a restart.
listen := func() string {
w := c.Web
w.SessionHours = 0
b, _ := json.Marshal(w)
return string(b)
}
before, oldWeb := listen(), c.Web
if err := field(m, "web", &c.Web); err != nil { if err := field(m, "web", &c.Web); err != nil {
return err return err
} }
after, _ := json.Marshal(c.Web) if restart = listen() != before; restart {
restart = string(before) != string(after) if err := a.checkWebStart(oldWeb, c.Web); err != nil {
return err
}
}
if err := field(m, "stats", &c.Stats); err != nil { if err := field(m, "stats", &c.Stats); err != nil {
return err return err
} }
@@ -1047,6 +1112,9 @@ func (a *App) patchSettings(w http.ResponseWriter, r *http.Request) {
if err := field(m, "signin", &c.SignIn); err != nil { if err := field(m, "signin", &c.SignIn); err != nil {
return err return err
} }
if err := field(m, "updates", &c.Updates); err != nil {
return err
}
return field(m, "log", &c.Log) return field(m, "log", &c.Log)
}) })
if err != nil { if err != nil {
@@ -1176,7 +1244,20 @@ func (a *App) restore(w http.ResponseWriter, r *http.Request) {
writeErr(w, badRequest("this file has no server key; is it a backup of this app?")) writeErr(w, badRequest("this file has no server key; is it a backup of this app?"))
return return
} }
if err := a.store.Update(func(c *Config) error { *c = in; return nil }); err != nil { if in.Version > configVersion {
writeErr(w, badRequest("this backup is from a newer version of %s; update this server first", appName))
return
}
in.applyDefaults()
if !in.passwordSet() {
writeErr(w, badRequest("this backup has no user with a password; restoring it would lock everyone out"))
return
}
if err := a.store.Update(func(c *Config) error {
old := c.Web
*c = in
return a.checkWebStart(old, c.Web)
}); err != nil {
writeErr(w, err) writeErr(w, err)
return return
} }
@@ -1184,6 +1265,55 @@ func (a *App) restore(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "applyError": a.apply(), "restartRequired": true}) writeJSON(w, http.StatusOK, map[string]any{"ok": true, "applyError": a.apply(), "restartRequired": true})
} }
// checkWebStart refuses web settings the service could not start with: an
// address it cannot listen on, or certificate files it cannot read. The
// service would stop at the next restart, and the web interface and the API
// with it.
func (a *App) checkWebStart(old, next WebConfig) error {
if err := validateListen(next.Listen, "listen address", false); err != nil {
return &userError{err.Error()}
}
if err := validateListen(next.HTTPListen, "HTTP listen address", true); err != nil {
return &userError{err.Error()}
}
if next.TLS.Mode == "files" && next.TLS != old.TLS {
if _, err := tls.LoadX509KeyPair(next.TLS.CertFile, next.TLS.KeyFile); err != nil {
return badRequest("the certificate files cannot be used: %v", err)
}
}
addrs := []string{next.Listen}
if next.HTTPListen != "" && next.TLS.Mode != "off" {
addrs = append(addrs, next.HTTPListen)
}
for _, addr := range addrs {
if err := a.canListen(addr); err != nil {
return badRequest("cannot listen on %s: %v", addr, err)
}
}
return nil
}
// canListen tries to listen on addr. An address the service listens on now,
// or one whose port it holds, is fine: it is free again after the restart.
func (a *App) canListen(addr string) error {
if slices.Contains(a.webAddrs, addr) {
return nil
}
ln, err := net.Listen("tcp", addr)
if err == nil {
return ln.Close()
}
if errors.Is(err, syscall.EADDRINUSE) {
_, port, _ := net.SplitHostPort(addr)
for _, own := range a.webAddrs {
if _, p, _ := net.SplitHostPort(own); p == port {
return nil
}
}
}
return err
}
// applyRuntime applies the settings that take effect without a restart: log // applyRuntime applies the settings that take effect without a restart: log
// level and log rotation. Traffic retention is read by the stats sampler. // level and log rotation. Traffic retention is read by the stats sampler.
func (a *App) applyRuntime(c *Config) { func (a *App) applyRuntime(c *Config) {
@@ -1192,6 +1322,17 @@ func (a *App) applyRuntime(c *Config) {
a.logw.SetLimits(c.Log.MaxSizeMB, c.Log.MaxFiles) a.logw.SetLimits(c.Log.MaxSizeMB, c.Log.MaxFiles)
} }
a.geo.SetEnabled(c.Stats.geoEnabled()) a.geo.SetEnabled(c.Stats.geoEnabled())
a.updates.Set(c.Updates)
}
// checkUpdates asks the release source now and returns what it found.
func (a *App) checkUpdates(w http.ResponseWriter, r *http.Request) {
if a.updates == nil || !a.updates.Status().Enabled {
writeErr(w, badRequest("the update check is switched off"))
return
}
a.updates.Check(r.Context())
writeJSON(w, http.StatusOK, a.updates.Status())
} }
func (a *App) geoStatus() GeoStatus { func (a *App) geoStatus() GeoStatus {
+107 -11
View File
@@ -33,8 +33,20 @@
* { box-sizing: border-box; } * { box-sizing: border-box; }
html, body { margin: 0; } html, body { margin: 0; }
body { background: var(--ground); color: var(--ink); font-family: var(--sans); font-size: 14px; line-height: 1.45; } body { background: var(--ground); color: var(--ink); font-family: var(--sans); font-size: 14px; line-height: 1.45; }
a { color: var(--link); } a { color: var(--link); text-decoration-color: rgba(28, 92, 171, .35); text-underline-offset: 3px; }
a:hover { color: var(--link-hover); } a:hover { color: var(--link-hover); text-decoration-color: currentColor; }
/* Links to another page (go, back) are ink with an arrow that nudges on
hover; outside links (ext) keep the link colour and get ↗. */
a.go, a.back { color: var(--ink); font-size: 13px; font-weight: 500; text-decoration: none; white-space: nowrap; }
a.go:hover, a.back:hover { color: var(--link); }
a.go .ar { margin-left: 4px; }
a.back .ar { margin-right: 4px; }
a.ext .ar { margin-left: 2px; font-size: .8em; }
.ar { display: inline-block; transition: transform .15s; }
a.go:hover .ar { transform: translateX(3px); }
a.back:hover .ar { transform: translateX(-3px); }
a.ext:hover .ar { transform: translate(2px, -2px); }
@media (prefers-reduced-motion: reduce) { .ar { transition: none; } }
:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; } :focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; }
[hidden] { display: none !important; } [hidden] { display: none !important; }
.mono { font-family: var(--mono); font-size: 13px; } .mono { font-family: var(--mono); font-size: 13px; }
@@ -69,7 +81,10 @@ a.brand, a.brand:hover { color: #fff; text-decoration: none; }
.side .acct strong { font-size: 14px; font-weight: 500; color: #fff; overflow: hidden; text-overflow: ellipsis; } .side .acct strong { font-size: 14px; font-weight: 500; color: #fff; overflow: hidden; text-overflow: ellipsis; }
.side .acct span span { color: #a9aaa5; } .side .acct span span { color: #a9aaa5; }
.side .avatar { width: 28px; height: 28px; border-radius: 50%; background: #3a3b41; display: grid; place-items: center; flex: none; font-size: 13px; font-weight: 600; color: #fff; } .side .avatar { width: 28px; height: 28px; border-radius: 50%; background: #3a3b41; display: grid; place-items: center; flex: none; font-size: 13px; font-weight: 600; color: #fff; }
.side .footrow { display: flex; justify-content: space-between; padding: 10px 12px 0; } .side .footrow { display: flex; justify-content: space-between; align-items: center; gap: 8px; padding: 10px 12px 0; }
.side .footrow .upd { font-size: 11.5px; font-weight: 500; color: #cfe2f8; background: #1f3550; border: 1px solid #2d4a6e; padding: 2px 8px; border-radius: 999px; text-decoration: none; white-space: nowrap; }
.side .footrow .upd:hover { color: #fff; }
.side .nav .pip { margin-left: auto; width: 7px; height: 7px; border-radius: 50%; background: #6aa6ea; }
.main { flex: 999 1 560px; min-width: 0; padding: 32px 32px 56px; } .main { flex: 999 1 560px; min-width: 0; padding: 32px 32px 56px; }
/* Beside the page (not stacked above it on a phone), the sidebar stays in /* Beside the page (not stacked above it on a phone), the sidebar stays in
place while the page scrolls, so the account link is always visible. */ place while the page scrolls, so the account link is always visible. */
@@ -83,7 +98,7 @@ a.brand, a.brand:hover { color: #fff; text-decoration: none; }
.titleline { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; } .titleline { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; }
h1 { margin: 0; font-size: 26px; font-weight: 600; letter-spacing: -0.01em; overflow-wrap: anywhere; } h1 { margin: 0; font-size: 26px; font-weight: 600; letter-spacing: -0.01em; overflow-wrap: anywhere; }
.sub { margin: 4px 0 0; color: var(--ink-2); } .sub { margin: 4px 0 0; color: var(--ink-2); }
.back { font-size: 13px; margin-bottom: -8px; } .back { margin-bottom: -8px; align-self: flex-start; }
/* cards */ /* cards */
.card { background: var(--surface); border: 1px solid var(--line); border-radius: 12px; padding: 20px; min-width: 0; } .card { background: var(--surface); border: 1px solid var(--line); border-radius: 12px; padding: 20px; min-width: 0; }
@@ -129,6 +144,9 @@ h1 { margin: 0; font-size: 26px; font-weight: 600; letter-spacing: -0.01em; over
.tag { display: inline-block; font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 999px; background: var(--warn-bg); color: var(--warn-ink); margin-left: 6px; vertical-align: 1px; } .tag { display: inline-block; font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 999px; background: var(--warn-bg); color: var(--warn-ink); margin-left: 6px; vertical-align: 1px; }
.notice { display: flex; gap: 10px; align-items: flex-start; padding: 12px 14px; border-radius: 10px; background: var(--warn-bg); color: var(--warn-ink); font-size: 13px; } .notice { display: flex; gap: 10px; align-items: flex-start; padding: 12px 14px; border-radius: 10px; background: var(--warn-bg); color: var(--warn-ink); font-size: 13px; }
.notice.err { background: #fbefee; color: var(--bad-ink); } .notice.err { background: #fbefee; color: var(--bad-ink); }
.notice.new { background: #e8f0fa; color: #174d8f; flex-wrap: wrap; align-items: center; }
.notice.new .actions { margin-left: auto; }
.btn.ghost { background: transparent; border-color: transparent; }
.notice .btn { margin-left: auto; } .notice .btn { margin-left: auto; }
/* tables */ /* tables */
@@ -144,6 +162,8 @@ td { padding: 12px; border-bottom: 1px solid var(--line-2); vertical-align: midd
tr:last-child td { border-bottom: 0; } tr:last-child td { border-bottom: 0; }
.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; } .num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
td .note { font-size: 12px; color: var(--ink-3); } td .note { font-size: 12px; color: var(--ink-3); }
a.pname { color: var(--ink); font-weight: 600; text-decoration: none; }
a.pname:hover { color: var(--link); }
.empty { padding: 24px 12px; margin: 0; text-align: center; color: var(--ink-3); } .empty { padding: 24px 12px; margin: 0; text-align: center; color: var(--ink-3); }
/* forms */ /* forms */
@@ -171,10 +191,50 @@ fieldset { border: 0; margin: 0; padding: 0; min-width: 0; display: flex; flex-d
.kv dt { color: var(--ink-2); } .kv dt { color: var(--ink-2); }
.kv dd { margin: 0; min-width: 0; overflow-wrap: anywhere; } .kv dd { margin: 0; min-width: 0; overflow-wrap: anywhere; }
/* checks */ /* health: public addresses on the left, one row per check on the right */
.chk { display: flex; gap: 10px; align-items: center; padding: 10px 0; border-bottom: 1px solid var(--line-2); font-size: 13px; } .hcbody { display: grid; grid-template-columns: minmax(0, 5fr) minmax(0, 7fr); gap: 12px; margin-top: 16px; }
.chk:last-child { border-bottom: 0; } .hchead { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; }
.chk b { font-weight: 500; min-width: 160px; } .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 */ /* activity */
.ev { display: flex; gap: 12px; padding: 10px 0; border-bottom: 1px solid var(--line-2); font-size: 13px; } .ev { display: flex; gap: 12px; padding: 10px 0; border-bottom: 1px solid var(--line-2); font-size: 13px; }
@@ -206,7 +266,10 @@ fieldset { border: 0; margin: 0; padding: 0; min-width: 0; display: flex; flex-d
.chart .grp span.up { background: var(--up); } .chart .grp span.up { background: var(--up); }
.chart .grp span.total { background: var(--down); } .chart .grp span.total { background: var(--down); }
.chart .grp.on span.total { background: var(--down-strong); } .chart .grp.on span.total { background: var(--down-strong); }
.xaxis { display: flex; justify-content: space-between; margin: 8px 0 0 56px; font-size: 11px; color: var(--axis-ink); } .xaxis { position: relative; height: 22px; margin-left: 56px; font-size: 11px; color: var(--axis-ink); }
.xaxis span { position: absolute; top: 0; padding-top: 7px; transform: translateX(-50%); white-space: nowrap; font-variant-numeric: tabular-nums; }
.xaxis span.edge::before { content: ''; position: absolute; left: 50%; top: 0; height: 4px; border-left: 1px solid var(--axis); }
@media (max-width: 640px) { .xaxis span.minor { display: none; } }
.chart.loading { opacity: .5; } .chart.loading { opacity: .5; }
.chart .plot { position: absolute; left: 56px; right: 0; top: 0; bottom: 1px; } .chart .plot { position: absolute; left: 56px; right: 0; top: 0; bottom: 1px; }
.chart .plot svg { width: 100%; height: 100%; display: block; overflow: visible; } .chart .plot svg { width: 100%; height: 100%; display: block; overflow: visible; }
@@ -283,8 +346,8 @@ dialog::backdrop { background: rgba(22, 23, 26, .55); }
.setupbox { width: 100%; max-width: 400px; display: flex; flex-direction: column; gap: 24px; margin: auto 0; } .setupbox { width: 100%; max-width: 400px; display: flex; flex-direction: column; gap: 24px; margin: auto 0; }
.setupbox h1 { font-size: 22px; } .setupbox h1 { font-size: 22px; }
.setupbox p { margin: 0; color: #c9c9c3; } .setupbox p { margin: 0; color: #c9c9c3; }
.setupbox a { color: #9cc3f5; } .setupbox a { color: #9cc3f5; text-decoration-color: rgba(156, 195, 245, .4); }
.setupbox a:hover { color: #fff; } .setupbox a:hover { color: #fff; text-decoration-color: currentColor; }
.setupbox .center, .setupbox.center { text-align: center; display: flex; flex-direction: column; align-items: center; gap: 8px; } .setupbox .center, .setupbox.center { text-align: center; display: flex; flex-direction: column; align-items: center; gap: 8px; }
.setupbox.center { gap: 20px; } .setupbox.center { gap: 20px; }
.setupbox .ghost { opacity: .45; } .setupbox .ghost { opacity: .45; }
@@ -322,3 +385,36 @@ dialog::backdrop { background: rgba(22, 23, 26, .55); }
.mfarow .grow { flex: 1; min-width: 0; } .mfarow .grow { flex: 1; min-width: 0; }
.dlg .secret { font-size: 15px; letter-spacing: 0.04em; overflow-wrap: anywhere; } .dlg .secret { font-size: 15px; letter-spacing: 0.04em; overflow-wrap: anywhere; }
.dlg .codes { columns: 2; font-size: 15px; line-height: 1.8; } .dlg .codes { columns: 2; font-size: 15px; line-height: 1.8; }
/* settings groups; the log page */
.group { margin-top: 20px; display: flex; flex-direction: column; gap: 2px; }
.group h2 { margin: 0; font-size: 19px; font-weight: 600; }
.group p { margin: 0; font-size: 13px; color: var(--ink-2); }
.card h3 { margin: 0; font-size: 16px; font-weight: 600; }
.saves { font-size: 12px; color: var(--ink-3); }
pre.log.tall { max-height: calc(100vh - 260px); min-height: 420px; }
/* live */
.livenow { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 12px 24px; margin-top: 14px; }
.livenow .k { font-size: 13px; color: var(--ink-2); }
.livenow .v { font-size: 30px; font-weight: 600; letter-spacing: -0.01em; margin-top: 4px; font-variant-numeric: tabular-nums; }
.livenow .v small { font-size: 16px; color: var(--ink-3); font-weight: 500; margin-left: 6px; }
.chart .larea { opacity: .15; }
.chart .larea.down { fill: var(--down); }
.chart .larea.up { fill: var(--up); }
.chart .ldown, .chart .lup { fill: none; stroke-width: 1.75; stroke-linejoin: round; stroke-linecap: round; vector-effect: non-scaling-stroke; }
.chart .ldown { stroke: var(--down); }
.chart .lup { stroke: var(--up); }
.chart .plot.live svg { overflow: hidden; }
.xaxis.lx span:first-child { transform: none; }
.xaxis.lx span:last-child { transform: translateX(-100%); }
.spark.wide { width: 96px; }
.spark .fill { fill: var(--down); opacity: .15; stroke: none; }
tr.idle td { color: var(--ink-3); }
tr.idle .spark polyline { stroke: var(--ink-3); }
tr.idle .spark .fill { fill: var(--ink-3); }
td .mono, td.num .mono { font-variant-numeric: tabular-nums; }
.livesub { display: flex; align-items: center; gap: 6px; }
.dot.pulse { animation: pulse 2s ease-in-out infinite; }
@keyframes pulse { 50% { opacity: .35; } }
@media (prefers-reduced-motion: reduce) { .dot.pulse { animation: none; } }
+670 -115
View File
File diff suppressed because it is too large Load Diff
+67 -26
View File
@@ -10,6 +10,7 @@ import (
"fmt" "fmt"
"net" "net"
"net/http" "net/http"
"net/netip"
"strings" "strings"
"sync" "sync"
"time" "time"
@@ -26,12 +27,23 @@ const (
argonKeyLen = 32 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) { func hashPassword(pw string) (string, error) {
salt := make([]byte, 16) salt := make([]byte, 16)
if _, err := rand.Read(salt); err != nil { if _, err := rand.Read(salt); err != nil {
return "", err return "", err
} }
key := argon2.IDKey([]byte(pw), salt, argonTime, argonMemory, argonThreads, argonKeyLen) key := argonKey([]byte(pw), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
b64 := base64.RawStdEncoding b64 := base64.RawStdEncoding
return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s", 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 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 { if err1 != nil || err2 != nil {
return false 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 return subtle.ConstantTimeCompare(got, want) == 1
} }
@@ -140,13 +152,17 @@ type Auth struct {
sessions map[string]*session sessions map[string]*session
used map[string]tokenUse used map[string]tokenUse
logins map[string]tokenUse // last sign-in per user ID 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 mfa mfaState
} }
const ( const (
maxFailures = 5 maxFailures = 5
lockoutTime = 15 * time.Minute 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 { func newAuth(s *Store) *Auth {
@@ -155,23 +171,51 @@ func newAuth(s *Store) *Auth {
func cookieName() string { return appName + "_session" } 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")
)
// 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
}
p, _ := a.Prefix(64)
return p.String()
}
// Login checks the credentials and returns a new session id, or, for a user // 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. // with two-step sign-in, a ticket for the second step.
func (a *Auth) Login(user, pw, ip string) (sessionID, ticket string, err error) { func (a *Auth) Login(user, pw, ip string) (sessionID, ticket string, err error) {
a.mu.Lock()
f := a.fails[ip]
if f != nil && time.Now().Before(f.until) {
a.mu.Unlock()
return "", "", errLocked
}
a.mu.Unlock()
cfg := a.store.Get() cfg := a.store.Get()
if !cfg.passwordSet() { 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 // An unknown username costs as much time as a wrong password, so the
// answer time does not tell which usernames exist. // answer time does not tell which usernames exist.
u := cfg.userByName(strings.TrimSpace(user)) u := cfg.userByName(strings.TrimSpace(user))
@@ -185,21 +229,13 @@ func (a *Auth) Login(user, pw, ip string) (sessionID, ticket string, err error)
a.mu.Lock() a.mu.Lock()
defer a.mu.Unlock() defer a.mu.Unlock()
if !okUser || !okPw { if !okUser || !okPw {
if f == nil {
f = &failState{}
a.fails[ip] = f
}
f.count++
if f.count >= maxFailures {
f.count = 0
f.until = time.Now().Add(lockoutTime)
}
return "", "", errors.New("wrong username or password") return "", "", errors.New("wrong username or password")
} }
undo()
if u.hasMFA() { if u.hasMFA() {
return "", a.newTicketLocked(u, ip), nil return "", a.newTicketLocked(u, ip), nil
} }
delete(a.fails, ip) delete(a.fails, lockKey(ip))
a.logins[u.ID] = tokenUse{At: time.Now(), IP: 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
} }
@@ -251,9 +287,14 @@ func remoteIP(r *http.Request) string {
host = r.RemoteAddr host = r.RemoteAddr
} }
// Behind a local reverse proxy the real client is in X-Forwarded-For. // Behind a local reverse proxy the real client is in X-Forwarded-For.
// The proxy appends the address it saw, so only the last entry counts:
// earlier ones come from the client and can be anything.
if ip := net.ParseIP(host); ip != nil && ip.IsLoopback() { if ip := net.ParseIP(host); ip != nil && ip.IsLoopback() {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" { if xff := r.Header.Values("X-Forwarded-For"); len(xff) > 0 {
return strings.TrimSpace(strings.Split(xff, ",")[0]) list := strings.Split(xff[len(xff)-1], ",")
if last := strings.TrimSpace(list[len(list)-1]); net.ParseIP(last) != nil {
return last
}
} }
} }
return host return host
@@ -321,9 +362,9 @@ func (a *Auth) sweep() {
delete(a.sessions, id) delete(a.sessions, id)
} }
} }
for ip, f := range a.fails { for key, f := range a.fails {
if now.After(f.until) && f.count == 0 { if now.After(f.until) && f.count == 0 {
delete(a.fails, ip) delete(a.fails, key)
} }
} }
for id, t := range a.mfa.tickets { for id, t := range a.mfa.tickets {
+58 -1
View File
@@ -11,6 +11,7 @@ import (
"path/filepath" "path/filepath"
"regexp" "regexp"
"slices" "slices"
"strconv"
"strings" "strings"
"sync" "sync"
"syscall" "syscall"
@@ -34,8 +35,17 @@ type Config struct {
Stats StatsConfig `json:"stats"` Stats StatsConfig `json:"stats"`
Decoy DecoyConfig `json:"decoy"` Decoy DecoyConfig `json:"decoy"`
SignIn SignInConfig `json:"signin"` SignIn SignInConfig `json:"signin"`
Updates UpdatesConfig `json:"updates"`
} }
// UpdatesConfig sets the daily check for a newer release.
type UpdatesConfig struct {
Check *bool `json:"check,omitempty"` // default on
Source string `json:"source"` // gitea | github, see updateSources
}
func (c UpdatesConfig) checkEnabled() bool { return c.Check == nil || *c.Check }
// SignInConfig holds the rules for signing in to the web interface. // SignInConfig holds the rules for signing in to the web interface.
type SignInConfig struct { type SignInConfig struct {
// RequireMFA sends users without two-step sign-in to set it up before // RequireMFA sends users without two-step sign-in to set it up before
@@ -67,8 +77,29 @@ const (
minLogFiles, maxLogFiles = 1, 100 minLogFiles, maxLogFiles = 1, 100
minHourlyHours, maxHourlyHrs = 24, 24 * 31 minHourlyHours, maxHourlyHrs = 24, 24 * 31
minDailyDays, maxDailyDays = 7, 3660 minDailyDays, maxDailyDays = 7, 3660
minSessionHours = 1
maxSessionHours = 30 * 24
) )
// validateListen checks a listen address like ":443" or "192.0.2.1:443".
// Empty is allowed when optional (the HTTP listener is then off).
func validateListen(addr, field string, optional bool) error {
if addr == "" && optional {
return nil
}
host, port, err := net.SplitHostPort(addr)
if err != nil {
return fmt.Errorf("%s %q must look like :443 or 192.0.2.1:443", field, addr)
}
if n, err := strconv.Atoi(port); err != nil || n < 1 || n > 65535 {
return fmt.Errorf("%s %q: the port must be 1–65535", field, addr)
}
if host != "" && host != "localhost" && checkEndpoint(host) != nil {
return fmt.Errorf("%s %q: %q is not an IP address or host name", field, addr, host)
}
return nil
}
type WebConfig struct { type WebConfig struct {
Listen string `json:"listen"` // HTTPS (or HTTP when tls.mode is "off") listen address Listen string `json:"listen"` // HTTPS (or HTTP when tls.mode is "off") listen address
HTTPListen string `json:"httpListen"` // plain HTTP for ACME http-01 and redirects; "" disables HTTPListen string `json:"httpListen"` // plain HTTP for ACME http-01 and redirects; "" disables
@@ -204,6 +235,9 @@ func (c *Config) applyDefaults() {
c.Users = []User{u} c.Users = []User{u}
} }
c.Admin = nil c.Admin = nil
for i := range c.Users {
dropSecurityKeys(&c.Users[i])
}
for i := range c.APITokens { for i := range c.APITokens {
if c.APITokens[i].UserID == "" { if c.APITokens[i].UserID == "" {
c.APITokens[i].UserID = c.Users[0].ID // tokens from before users existed c.APITokens[i].UserID = c.Users[0].ID // tokens from before users existed
@@ -243,6 +277,9 @@ func (c *Config) applyDefaults() {
if c.Decoy.Page == "" { if c.Decoy.Page == "" {
c.Decoy.Page = "nginx" c.Decoy.Page = "nginx"
} }
if c.Updates.Source == "" {
c.Updates.Source = "gitea"
}
if c.APITokens == nil { if c.APITokens == nil {
c.APITokens = []APIToken{} c.APITokens = []APIToken{}
} }
@@ -349,7 +386,9 @@ func (c *Config) validate() error {
if v6.Masked() != v6 { if v6.Masked() != v6 {
return fmt.Errorf("IPv6 network must be the network address, e.g. %s", v6.Masked()) return fmt.Errorf("IPv6 network must be the network address, e.g. %s", v6.Masked())
} }
if s.Endpoint != "" && strings.ContainsAny(s.Endpoint, " /:") && net.ParseIP(s.Endpoint) == nil { // The endpoint is written into client configs as is, so it must be a
// plain host name or IP: anything else could add lines to them.
if s.Endpoint != "" && checkEndpoint(s.Endpoint) != nil {
return errors.New("endpoint must be a host name or IP address without port") return errors.New("endpoint must be a host name or IP address without port")
} }
if err := validateHostList(s.ClientDefaults.DNS, "DNS", false); err != nil { if err := validateHostList(s.ClientDefaults.DNS, "DNS", false); err != nil {
@@ -379,6 +418,18 @@ func (c *Config) validate() error {
if _, ok := decoyPages[c.Decoy.Page]; !ok { if _, ok := decoyPages[c.Decoy.Page]; !ok {
return fmt.Errorf("unknown decoy page %q", c.Decoy.Page) return fmt.Errorf("unknown decoy page %q", c.Decoy.Page)
} }
if _, ok := updateSources[c.Updates.Source]; !ok {
return fmt.Errorf("update source must be gitea or github")
}
if err := validateListen(c.Web.Listen, "listen address", false); err != nil {
return err
}
if err := validateListen(c.Web.HTTPListen, "HTTP listen address", true); err != nil {
return err
}
if h := c.Web.SessionHours; h < minSessionHours || h > maxSessionHours {
return fmt.Errorf("session length must be %d–%d hours", minSessionHours, maxSessionHours)
}
switch c.Web.TLS.Mode { switch c.Web.TLS.Mode {
case "acme": case "acme":
if c.Web.TLS.Domain == "" { if c.Web.TLS.Domain == "" {
@@ -568,6 +619,12 @@ func (s *Store) Update(fn func(c *Config) error) error {
s.mu.Unlock() s.mu.Unlock()
return &userError{err.Error()} return &userError{err.Error()}
} }
// With no user left (applyDefaults then adds an "admin" without a
// password), nobody could sign in until someone ran "passwd" on the server.
if old.passwordSet() && !next.passwordSet() {
s.mu.Unlock()
return &userError{"this would leave no user with a password, and nobody could sign in"}
}
if err := writeFileAtomic(s.path, next, 0o600); err != nil { if err := writeFileAtomic(s.path, next, 0o600); err != nil {
s.mu.Unlock() s.mu.Unlock()
return err return err
+40
View File
@@ -3,6 +3,9 @@ package main
import ( import (
"log/slog" "log/slog"
"net/netip" "net/netip"
"os"
"slices"
"strings"
"sync" "sync"
"time" "time"
) )
@@ -38,6 +41,37 @@ type Kernel interface {
Close() error Close() error
} }
// lanBlock picks, from the networks on the uplinks, the ones peers must not
// reach while LAN access is off: private IPv4 networks, and IPv6 networks
// except link-local, since a home LAN uses global IPv6 addresses. IPv6
// prefixes shorter than /48 are left out: they are no LAN.
func lanBlock(nets []netip.Prefix) []netip.Prefix {
var out []netip.Prefix
for _, p := range nets {
a := p.Addr().Unmap()
p = netip.PrefixFrom(a, min(p.Bits(), a.BitLen())).Masked()
switch {
case a.Is4() && !a.IsPrivate():
continue
case a.Is6() && (a.IsLinkLocalUnicast() || a.IsLoopback() || p.Bits() < 48):
continue
}
if !slices.Contains(out, p) {
out = append(out, p)
}
}
return out
}
// readSysctl returns the trimmed content of a /proc/sys file, or "".
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 // Reconciler applies the config to the kernel whenever it is triggered and
// remembers the outcome for the health report. // remembers the outcome for the health report.
type Reconciler struct { type Reconciler struct {
@@ -45,6 +79,10 @@ type Reconciler struct {
store *Store store *Store
trigger chan struct{} trigger chan struct{}
// applyMu runs one apply at a time. Each reads the config once it holds
// the lock, so the last apply always uses the newest config.
applyMu sync.Mutex
mu sync.Mutex mu sync.Mutex
lastErr error lastErr error
lastApply time.Time lastApply time.Time
@@ -65,6 +103,8 @@ func (r *Reconciler) Kick() {
// ApplyNow applies synchronously and returns the result, so an API call can // ApplyNow applies synchronously and returns the result, so an API call can
// report kernel errors to the user. // report kernel errors to the user.
func (r *Reconciler) ApplyNow() error { func (r *Reconciler) ApplyNow() error {
r.applyMu.Lock()
defer r.applyMu.Unlock()
err := r.kernel.Apply(r.store.Get()) err := r.kernel.Apply(r.store.Get())
r.mu.Lock() r.mu.Lock()
r.lastErr, r.lastApply = err, time.Now() r.lastErr, r.lastApply = err, time.Now()
+30 -24
View File
@@ -3,13 +3,13 @@
package main package main
import ( import (
"cmp"
"errors" "errors"
"fmt" "fmt"
"net" "net"
"net/netip" "net/netip"
"os" "os"
"slices" "slices"
"strings"
"github.com/vishvananda/netlink" "github.com/vishvananda/netlink"
"golang.zx2c4.com/wireguard/wgctrl" "golang.zx2c4.com/wireguard/wgctrl"
@@ -217,7 +217,8 @@ func (k *linuxKernel) Apply(c *Config) error {
if c.Server.IPv6Enabled { if c.Server.IPv6Enabled {
_ = os.WriteFile("/proc/sys/net/ipv6/conf/all/forwarding", []byte("1"), 0o644) _ = os.WriteFile("/proc/sys/net/ipv6/conf/all/forwarding", []byte("1"), 0o644)
} }
return applyFirewall(c, k.Uplink(c, false), k.Uplink(c, true), lanNetworks(k.Uplink(c, false))) up4, up6 := k.Uplink(c, false), k.Uplink(c, true)
return applyFirewall(c, up4, up6, lanNetworks(up4, up6))
} }
func (k *linuxKernel) Sample(iface string) ([]PeerSample, error) { func (k *linuxKernel) Sample(iface string) ([]PeerSample, error) {
@@ -264,27 +265,27 @@ func (k *linuxKernel) Uplink(c *Config, v6 bool) string {
return l.Attrs().Name return l.Attrs().Name
} }
// lanNetworks returns the private IPv4 networks on the uplink, used to block // lanNetworks returns the LAN networks on the IPv4 and IPv6 uplinks (see
// peers from the server's LAN when LAN access is off. // lanBlock), used to block peers from the server's LAN when LAN access is off.
func lanNetworks(uplink string) []netip.Prefix { func lanNetworks(uplinks ...string) []netip.Prefix {
if uplink == "" { var nets []netip.Prefix
return nil for i, uplink := range uplinks {
if uplink == "" || slices.Contains(uplinks[:i], uplink) {
continue
} }
l, err := netlink.LinkByName(uplink) l, err := netlink.LinkByName(uplink)
if err != nil { if err != nil {
return nil
}
addrs, _ := netlink.AddrList(l, netlink.FAMILY_V4)
var out []netip.Prefix
for _, a := range addrs {
if !a.IP.IsPrivate() {
continue continue
} }
addrs, _ := netlink.AddrList(l, netlink.FAMILY_ALL)
for _, a := range addrs {
ones, _ := a.Mask.Size() ones, _ := a.Mask.Size()
ip, _ := netip.AddrFromSlice(a.IP.To4()) if ip, ok := netip.AddrFromSlice(a.IP); ok {
out = append(out, netip.PrefixFrom(ip, ones).Masked()) nets = append(nets, netip.PrefixFrom(ip.Unmap(), ones))
} }
return out }
}
return lanBlock(nets)
} }
// publicAddr reports the uplink's address for the health check: the first // publicAddr reports the uplink's address for the health check: the first
@@ -318,14 +319,6 @@ func publicAddr(uplink string, v6 bool) (bool, string) {
return false, "no address on " + uplink 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 { func (k *linuxKernel) Checks(c *Config) []Check {
var out []Check var out []Check
link, err := netlink.LinkByName(c.Server.Interface) 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") v := readSysctl("/proc/sys/net/ipv6/conf/all/forwarding")
out = append(out, Check{"IPv6 forwarding", v == "1", "net.ipv6.conf.all.forwarding=" + v}) 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() ok, detail := firewallPresent()
out = append(out, Check{"nftables rules", ok, detail}) out = append(out, Check{"nftables rules", ok, detail})
up4 := k.Uplink(c, false) up4 := k.Uplink(c, false)
+16 -7
View File
@@ -5,8 +5,10 @@ package main
import ( import (
"fmt" "fmt"
"log/slog" "log/slog"
"maps"
"math/rand/v2" "math/rand/v2"
"net/netip" "net/netip"
"slices"
"sync" "sync"
"time" "time"
) )
@@ -17,6 +19,7 @@ import (
type simKernel struct { type simKernel struct {
mu sync.Mutex mu sync.Mutex
peers map[string]*PeerSample peers map[string]*PeerSample
last time.Time // previous Sample; traffic grows with the time since
} }
func newKernel() (Kernel, error) { func newKernel() (Kernel, error) {
@@ -53,17 +56,23 @@ func (k *simKernel) Apply(c *Config) error {
func (k *simKernel) Sample(string) ([]PeerSample, error) { func (k *simKernel) Sample(string) ([]PeerSample, error) {
k.mu.Lock() k.mu.Lock()
defer k.mu.Unlock() defer k.mu.Unlock()
now := time.Now()
f := 1.0
if !k.last.IsZero() {
f = now.Sub(k.last).Seconds() / 30
}
k.last = now
var out []PeerSample var out []PeerSample
i := 0 for i, key := range slices.Sorted(maps.Keys(k.peers)) {
for _, p := range k.peers { p := k.peers[key]
// Every third peer stays idle; the others move some data. // Every third peer stays idle; the others move some data, scaled to
// the time since the previous sample.
if i%3 != 2 { if i%3 != 2 {
p.TxBytes += rand.Int64N(40 << 20) p.TxBytes += int64(float64(rand.Int64N(40<<20)) * f)
p.RxBytes += rand.Int64N(6 << 20) p.RxBytes += int64(float64(rand.Int64N(6<<20)) * f)
p.LastHandshake = time.Now().Add(-time.Duration(rand.IntN(90)) * time.Second) p.LastHandshake = now.Add(-time.Duration(rand.IntN(90)) * time.Second)
} }
out = append(out, *p) out = append(out, *p)
i++
} }
return out, nil return out, nil
} }
+10 -3
View File
@@ -219,18 +219,25 @@ func run(configPath string) error {
var stopOnce sync.Once var stopOnce sync.Once
shutdown := func() { stopOnce.Do(func() { close(stop) }) } shutdown := func() { stopOnce.Do(func() { close(stop) }) }
speeds := newSpeeds(store, kernel)
auth := newAuth(store) auth := newAuth(store)
app := &App{ app := &App{
store: store, kernel: kernel, recon: recon, stats: stats, auth: auth, tls: webTLS, store: store, kernel: kernel, recon: recon, stats: stats, speeds: speeds, auth: auth, tls: webTLS,
logPath: logPath, logw: logw, geo: geo, started: time.Now(), shutdown: shutdown, logPath: logPath, logw: logw, geo: geo, updates: newUpdater(cfg.Updates), started: time.Now(), shutdown: shutdown,
webAddrs: []string{cfg.Web.Listen},
}
if cfg.Web.HTTPListen != "" && cfg.Web.TLS.Mode != "off" {
app.webAddrs = append(app.webAddrs, cfg.Web.HTTPListen)
} }
var wg sync.WaitGroup var wg sync.WaitGroup
wg.Add(4) wg.Add(6)
go func() { defer wg.Done(); recon.Run(stop) }() go func() { defer wg.Done(); recon.Run(stop) }()
go func() { defer wg.Done(); stats.Run(stop) }() go func() { defer wg.Done(); stats.Run(stop) }()
go func() { defer wg.Done(); speeds.Run(stop) }()
go func() { defer wg.Done(); stats.RunPings(stop) }() go func() { defer wg.Done(); stats.RunPings(stop) }()
go func() { defer wg.Done(); geo.Run(stop) }() go func() { defer wg.Done(); geo.Run(stop) }()
go func() { defer wg.Done(); app.updates.Run(stop) }()
go func() { go func() {
t := time.NewTicker(10 * time.Minute) t := time.NewTicker(10 * time.Minute)
defer t.Stop() defer t.Stop()
+493 -7
View File
@@ -6,13 +6,16 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"net"
"net/http" "net/http"
"net/http/cookiejar" "net/http/cookiejar"
"net/http/httptest" "net/http/httptest"
"net/netip" "net/netip"
"os" "os"
"path/filepath" "path/filepath"
"slices"
"strings" "strings"
"sync"
"testing" "testing"
"time" "time"
) )
@@ -76,6 +79,16 @@ func TestValidate(t *testing.T) {
"bad dns": func(c *Config) { c.Peers[0].DNS = []string{"dns.example"} }, "bad dns": func(c *Config) { c.Peers[0].DNS = []string{"dns.example"} },
"bad port": func(c *Config) { c.Server.ListenPort = 70000 }, "bad port": func(c *Config) { c.Server.ListenPort = 70000 },
"unmasked net": func(c *Config) { c.Server.IPv4 = "10.84.12.5/24" }, "unmasked net": func(c *Config) { c.Server.IPv4 = "10.84.12.5/24" },
"update source": func(c *Config) { c.Updates.Source = "sourceforge" },
// The endpoint goes into client configs: no extra lines.
"endpoint newline": func(c *Config) { c.Server.Endpoint = "vpn.example.net\n[Interface]\nPreUp=id;#" },
"endpoint tab": func(c *Config) { c.Server.Endpoint = "vpn.example.net\tx" },
"endpoint port": func(c *Config) { c.Server.Endpoint = "vpn.example.net:51820" },
"listen": func(c *Config) { c.Web.Listen = "not-an-address" },
"listen port": func(c *Config) { c.Web.Listen = ":70000" },
"http listen": func(c *Config) { c.Web.HTTPListen = "80" },
"session hours": func(c *Config) { c.Web.SessionHours = -1 },
"session too long": func(c *Config) { c.Web.SessionHours = 100000 },
} { } {
cc := c.clone() cc := c.clone()
mutate(cc) mutate(cc)
@@ -83,6 +96,20 @@ func TestValidate(t *testing.T) {
t.Errorf("%s: expected an error", name) t.Errorf("%s: expected an error", name)
} }
} }
for _, ep := range []string{"vpn.example.net", "203.0.113.7", "2001:db8::1"} {
cc := c.clone()
cc.Server.Endpoint = ep
if err := cc.validate(); err != nil {
t.Errorf("endpoint %q rejected: %v", ep, err)
}
}
for _, l := range []string{":443", "0.0.0.0:8443", "[::]:443", "localhost:8080"} {
cc := c.clone()
cc.Web.Listen = l
if err := cc.validate(); err != nil {
t.Errorf("listen %q rejected: %v", l, err)
}
}
} }
func TestClientConfig(t *testing.T) { func TestClientConfig(t *testing.T) {
@@ -311,8 +338,13 @@ func TestAPI(t *testing.T) {
secret := tok["token"].(string) secret := tok["token"].(string)
// Read-only token: GET works, changes are refused, admin endpoints too. // Read-only token: GET works, changes are refused, admin endpoints too.
bearer := func(method, path string, want int) { bearer := func(method, path string, want int, body ...any) {
req, _ := http.NewRequest(method, srv.URL+"/api/v1"+path, nil) 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) req.Header.Set("Authorization", "Bearer "+secret)
resp, err := http.DefaultClient.Do(req) resp, err := http.DefaultClient.Do(req)
if err != nil { if err != nil {
@@ -328,11 +360,45 @@ func TestAPI(t *testing.T) {
bearer("GET", "/tokens", 403) bearer("GET", "/tokens", 403)
bearer("GET", "/peers/"+id+"/setup", 403) // the link would set up a device 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) secret = call("POST", "/tokens", map[string]string{"name": "full", "scope": "rw"}, 201)["token"].(string)
bearer("GET", "/users", 200) uid := call("GET", "/auth/me", nil, 200)["id"].(string)
bearer("GET", "/tokens", 200) 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", "/backup", 403)
bearer("GET", "/update-backups", 403)
bearer("DELETE", "/update-backups", 403)
// Config copies made by update: listed newest first, removed one by
// one or all at once; nothing else in the folder can be removed.
for i, v := range []string{"v0.3.2", "v0.4.0"} {
f := filepath.Join(dir, "config.json.bak-"+v)
_ = os.WriteFile(f, []byte("{}"), 0o600)
_ = os.Chtimes(f, time.Now(), time.Now().Add(time.Duration(i-2)*time.Hour))
}
list := call("GET", "/update-backups", nil, 200)["backups"].([]any)
if len(list) != 2 || list[0].(map[string]any)["version"] != "v0.4.0" {
t.Fatalf("update backups: %v", list)
}
call("DELETE", "/update-backups/config.json", nil, 400)
call("DELETE", "/update-backups/stats.json", nil, 400)
call("DELETE", "/update-backups/config.json.bak-v9.9.9", nil, 400)
call("DELETE", "/update-backups/config.json.bak-v0.3.2", nil, 200)
if r := call("DELETE", "/update-backups", nil, 200); r["removed"] != float64(1) {
t.Fatalf("remove all: %v", r)
}
if _, err := os.Stat(filepath.Join(dir, "config.json")); err != nil {
t.Fatal("config.json is gone:", err)
}
call("DELETE", "/peers/"+id, nil, 200) call("DELETE", "/peers/"+id, nil, 200)
if len(store.Get().Peers) != 0 { if len(store.Get().Peers) != 0 {
@@ -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) { func TestWriteIfChanged(t *testing.T) {
p := filepath.Join(t.TempDir(), "x.conf") p := filepath.Join(t.TempDir(), "x.conf")
if ch, err := writeIfChanged(p, "a\n", 0o644); !ch || err != nil { 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 { if n := len(admin("GET", "/users", nil, 200)["users"].([]any)); n != 2 {
t.Fatalf("users: %d, want 2", n) 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 // TestDecoy checks that the decoy hides the web interface but leaves the API
@@ -1050,7 +1201,7 @@ func TestMFA(t *testing.T) {
if o := adm("GET", "/auth/options", nil, 200); o["passkeys"] != false { if o := adm("GET", "/auth/options", nil, 200); o["passkeys"] != false {
t.Fatalf("passkeys offered on an IP address: %v", o) t.Fatalf("passkeys offered on an IP address: %v", o)
} }
adm("POST", "/auth/mfa/keys/begin", map[string]bool{"passkey": true}, 400) adm("POST", "/auth/mfa/keys/begin", nil, 400)
// Turn on the authenticator app; the first method brings recovery codes. // Turn on the authenticator app; the first method brings recovery codes.
setup := adm("POST", "/auth/mfa/totp/setup", nil, 200) setup := adm("POST", "/auth/mfa/totp/setup", nil, 200)
@@ -1092,6 +1243,17 @@ func TestMFA(t *testing.T) {
c3("POST", "/auth/login/recovery", map[string]string{"ticket": ticket, "code": codes[0].(string)}, 401) c3("POST", "/auth/login/recovery", map[string]string{"ticket": ticket, "code": codes[0].(string)}, 401)
c3("POST", "/auth/login/recovery", map[string]string{"ticket": ticket, "code": codes[1].(string)}, 200) c3("POST", "/auth/login/recovery", map[string]string{"ticket": ticket, "code": codes[1].(string)}, 200)
// Session length needs no restart; the listen address does.
web := store.Get().Web
web.SessionHours = 24
if r := adm("PATCH", "/settings", map[string]any{"web": web}, 200); r["restartRequired"] != false || store.Get().Web.SessionHours != 24 {
t.Fatalf("session length: %v", r)
}
web.Listen = "127.0.0.1:9443"
if r := adm("PATCH", "/settings", map[string]any{"web": web}, 200); r["restartRequired"] != true {
t.Fatalf("listen address: %v", r)
}
// Required for everyone: a user without it can only set it up. // Required for everyone: a user without it can only set it up.
adm("PATCH", "/settings", map[string]any{"signin": map[string]bool{"requireMfa": true}}, 200) adm("PATCH", "/settings", map[string]any{"signin": map[string]bool{"requireMfa": true}}, 200)
u := adm("POST", "/users", map[string]any{"username": "eve", "password": "eve's password 1", "mustChangePassword": false}, 201)["user"].(map[string]any) u := adm("POST", "/users", map[string]any{"username": "eve", "password": "eve's password 1", "mustChangePassword": false}, 201)["user"].(map[string]any)
@@ -1121,3 +1283,327 @@ func TestMFA(t *testing.T) {
t.Fatal("reset left methods behind") 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)
}
}
+99 -51
View File
@@ -24,9 +24,10 @@ import (
"github.com/go-webauthn/webauthn/webauthn" "github.com/go-webauthn/webauthn/webauthn"
) )
// Two-step sign-in for the web interface: an authenticator app (TOTP), // Two-step sign-in for the web interface: an authenticator app (TOTP) and
// security keys such as a YubiKey and passkeys (both WebAuthn), plus // passkeys (WebAuthn, also on a YubiKey), plus one-time recovery codes. A
// one-time recovery codes. API tokens never need a second step. // 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 // 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 // ticket instead of a session; the ticket and a code or key turn into the
@@ -41,16 +42,29 @@ type UserMFA struct {
Handle []byte `json:"handle,omitempty"` // WebAuthn user handle Handle []byte `json:"handle,omitempty"` // WebAuthn user handle
} }
// MFAKey is a security key or passkey. // MFAKey is a passkey.
type MFAKey struct { type MFAKey struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Passkey bool `json:"passkey"` // discoverable: signs in without a password Passkey bool `json:"passkey"` // false only for security keys added by v0.3.0, which are deleted
Created time.Time `json:"created"` Created time.Time `json:"created"`
LastUsed *time.Time `json:"lastUsed,omitempty"` LastUsed *time.Time `json:"lastUsed,omitempty"`
Credential webauthn.Credential `json:"credential"` 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 { func (u *User) hasMFA() bool {
return u.MFA != nil && (u.MFA.TOTPSecret != "" || len(u.MFA.Keys) > 0) return u.MFA != nil && (u.MFA.TOTPSecret != "" || len(u.MFA.Keys) > 0)
} }
@@ -166,7 +180,7 @@ func (w waUser) WebAuthnCredentials() []webauthn.Credential {
return out return out
} }
// keysAvailable reports whether security keys and passkeys can work on this // keysAvailable reports whether passkeys can work on this
// address: WebAuthn needs a domain name (not an IP address) and a // address: WebAuthn needs a domain name (not an IP address) and a
// certificate the browser trusts, or localhost. // certificate the browser trusts, or localhost.
func (a *App) keysAvailable(r *http.Request) bool { func (a *App) keysAvailable(r *http.Request) bool {
@@ -179,7 +193,7 @@ func (a *App) keysAvailable(r *http.Request) bool {
func (a *App) webAuthn(r *http.Request) (*webauthn.WebAuthn, error) { func (a *App) webAuthn(r *http.Request) (*webauthn.WebAuthn, error) {
if !a.keysAvailable(r) { if !a.keysAvailable(r) {
return nil, badRequest("security keys and passkeys need a domain name with a trusted certificate") return nil, badRequest("passkeys need a domain name with a trusted certificate")
} }
scheme := "https" scheme := "https"
if r.TLS == nil && hostOnly(r.Host) == "localhost" { if r.TLS == nil && hostOnly(r.Host) == "localhost" {
@@ -198,12 +212,12 @@ type ticket struct {
ip string ip string
expires time.Time expires time.Time
fails int fails int
key *webauthn.SessionData // a security key challenge, once asked for key *webauthn.SessionData // a passkey challenge, once asked for
} }
type ceremony struct { type ceremony struct {
userID string // "" for a passkey sign-in userID string // "" for a passkey sign-in
passkey bool ip string // lockKey of who started a passkey sign-in
data *webauthn.SessionData data *webauthn.SessionData
expires time.Time expires time.Time
} }
@@ -223,23 +237,34 @@ func newMFAState() mfaState {
var errBadTicket = errors.New("the sign-in expired; enter your password again") var errBadTicket = errors.New("the sign-in expired; enter your password again")
// failLocked counts a failed attempt from ip toward the lockout. a.mu must // failLocked counts a failed attempt from ip toward the lockout and returns
// be held. // a function that takes it back, for an attempt counted before it was
func (a *Auth) failLocked(ip string) { // checked. a.mu must be held, also when calling undo.
f := a.fails[ip] func (a *Auth) failLocked(ip string) (undo func()) {
key := lockKey(ip)
f := a.fails[key]
if f == nil { if f == nil {
f = &failState{} f = &failState{}
a.fails[ip] = f a.fails[key] = f
} }
f.count++ f.count++
if f.count >= maxFailures { locked := f.count >= maxFailures
if locked {
f.count = 0 f.count = 0
f.until = time.Now().Add(lockoutTime) 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 { func (a *Auth) lockedLocked(ip string) bool {
f := a.fails[ip] f := a.fails[lockKey(ip)]
return f != nil && time.Now().Before(f.until) return f != nil && time.Now().Before(f.until)
} }
@@ -289,7 +314,7 @@ func (a *Auth) finishSignIn(u *User, ip string) string {
cfg := a.store.Get() cfg := a.store.Get()
a.mu.Lock() a.mu.Lock()
defer a.mu.Unlock() defer a.mu.Unlock()
delete(a.fails, ip) delete(a.fails, lockKey(ip))
a.logins[u.ID] = tokenUse{At: time.Now(), IP: ip} a.logins[u.ID] = tokenUse{At: time.Now(), IP: ip}
return a.newSessionLocked(cfg, u, sessionInfo{Started: time.Now(), IP: ip}) return a.newSessionLocked(cfg, u, sessionInfo{Started: time.Now(), IP: ip})
} }
@@ -430,7 +455,7 @@ func (a *App) loginRecovery(w http.ResponseWriter, r *http.Request) {
a.signedIn(w, r, u, "recovery code") a.signedIn(w, r, u, "recovery code")
} }
// loginKeyBegin asks for one of the user's security keys or passkeys. // loginKeyBegin asks for one of the user's passkeys, as the second step.
func (a *App) loginKeyBegin(w http.ResponseWriter, r *http.Request) { func (a *App) loginKeyBegin(w http.ResponseWriter, r *http.Request) {
var in struct{ Ticket string } var in struct{ Ticket string }
if err := readJSON(r, &in); err != nil { if err := readJSON(r, &in); err != nil {
@@ -448,7 +473,7 @@ func (a *App) loginKeyBegin(w http.ResponseWriter, r *http.Request) {
return return
} }
if u.MFA == nil || len(u.MFA.Keys) == 0 { if u.MFA == nil || len(u.MFA.Keys) == 0 {
writeErr(w, badRequest("no security key is set up")) writeErr(w, badRequest("no passkey is set up"))
return return
} }
opts, data, err := wa.BeginLogin(waUser{u}, webauthn.WithUserVerification(protocol.VerificationDiscouraged)) opts, data, err := wa.BeginLogin(waUser{u}, webauthn.WithUserVerification(protocol.VerificationDiscouraged))
@@ -488,13 +513,13 @@ func (a *App) loginKeyFinish(w http.ResponseWriter, r *http.Request) {
cred, err := wa.FinishLogin(waUser{u}, *data, r) cred, err := wa.FinishLogin(waUser{u}, *data, r)
if err != nil { if err != nil {
a.auth.ticketFailed(id, ip) a.auth.ticketFailed(id, ip)
slog.Warn("login failed", "user", u.Username, "remote", ip, "reason", "security key: "+err.Error()) slog.Warn("login failed", "user", u.Username, "remote", ip, "reason", "passkey: "+err.Error())
a.signInFailed(w, errors.New("the security key was not accepted")) a.signInFailed(w, errors.New("the passkey was not accepted"))
return return
} }
a.keyUsed(u.ID, cred) a.keyUsed(u.ID, cred)
a.auth.dropTicket(id) a.auth.dropTicket(id)
a.signedIn(w, r, u, "security key") a.signedIn(w, r, u, "passkey")
} }
// keyUsed stores the key's new signature counter and when it was used. // keyUsed stores the key's new signature counter and when it was used.
@@ -527,12 +552,52 @@ func (a *App) loginPasskeyBegin(w http.ResponseWriter, r *http.Request) {
return return
} }
id := randomString(24) id := randomString(24)
ip := remoteIP(r)
a.auth.mu.Lock() a.auth.mu.Lock()
a.auth.mfa.logins[id] = &ceremony{data: data, expires: time.Now().Add(ticketTTL)} ok := a.auth.addPasskeyLoginLocked(id, &ceremony{data: data, ip: lockKey(ip), expires: time.Now().Add(ticketTTL)})
a.auth.mu.Unlock() a.auth.mu.Unlock()
if !ok {
writeJSON(w, http.StatusTooManyRequests, map[string]string{"error": errBusy.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{"id": id, "options": opts}) writeJSON(w, http.StatusOK, map[string]any{"id": id, "options": opts})
} }
// Anyone can start a passkey sign-in, so the pending ones are capped: per
// address, and in total, where the oldest makes room.
const (
maxPasskeyLogins = 1000
maxPasskeyLoginsPerIP = 10
)
// addPasskeyLoginLocked stores a started passkey sign-in, or reports false
// when its address has too many pending. a.mu must be held.
func (a *Auth) addPasskeyLoginLocked(id string, c *ceremony) bool {
now := time.Now()
var fromIP int
var oldestID string
for k, x := range a.mfa.logins {
if now.After(x.expires) {
delete(a.mfa.logins, k)
continue
}
if x.ip == c.ip {
fromIP++
}
if oldestID == "" || x.expires.Before(a.mfa.logins[oldestID].expires) {
oldestID = k
}
}
if fromIP >= maxPasskeyLoginsPerIP {
return false
}
if len(a.mfa.logins) >= maxPasskeyLogins {
delete(a.mfa.logins, oldestID)
}
a.mfa.logins[id] = c
return true
}
func (a *App) loginPasskeyFinish(w http.ResponseWriter, r *http.Request) { func (a *App) loginPasskeyFinish(w http.ResponseWriter, r *http.Request) {
id := r.URL.Query().Get("id") id := r.URL.Query().Get("id")
ip := remoteIP(r) ip := remoteIP(r)
@@ -561,7 +626,7 @@ func (a *App) loginPasskeyFinish(w http.ResponseWriter, r *http.Request) {
u := &cfg.Users[i] u := &cfg.Users[i]
if u.MFA != nil && len(u.MFA.Handle) > 0 && bytes.Equal(u.MFA.Handle, handle) { if u.MFA != nil && len(u.MFA.Handle) > 0 && bytes.Equal(u.MFA.Handle, handle) {
for _, k := range u.MFA.Keys { for _, k := range u.MFA.Keys {
if k.Passkey && bytes.Equal(k.Credential.ID, rawID) { if bytes.Equal(k.Credential.ID, rawID) {
found = u found = u
return waUser{u}, nil return waUser{u}, nil
} }
@@ -587,7 +652,6 @@ func (a *App) loginPasskeyFinish(w http.ResponseWriter, r *http.Request) {
type keyView struct { type keyView struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Passkey bool `json:"passkey"`
Created time.Time `json:"created"` Created time.Time `json:"created"`
LastUsed *time.Time `json:"lastUsed"` LastUsed *time.Time `json:"lastUsed"`
} }
@@ -604,7 +668,7 @@ func (a *App) mfaStatus(w http.ResponseWriter, r *http.Request) {
if m := u.MFA; m != nil { if m := u.MFA; m != nil {
keys := []keyView{} keys := []keyView{}
for _, k := range m.Keys { for _, k := range m.Keys {
keys = append(keys, keyView{k.ID, k.Name, k.Passkey, k.Created, k.LastUsed}) 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) out["totp"], out["totpAdded"], out["keys"], out["recoveryLeft"] = m.TOTPSecret != "", m.TOTPAdded, keys, len(m.RecoveryCodes)
} }
@@ -710,13 +774,8 @@ func (a *App) totpRemove(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"ok": true}) writeJSON(w, http.StatusOK, map[string]any{"ok": true})
} }
// keyBegin starts adding a security key ({"passkey": false}) or a passkey. // keyBegin starts adding a passkey.
func (a *App) keyBegin(w http.ResponseWriter, r *http.Request) { func (a *App) keyBegin(w http.ResponseWriter, r *http.Request) {
var in struct{ Passkey bool }
if err := readJSON(r, &in); err != nil {
writeErr(w, err)
return
}
wa, err := a.webAuthn(r) wa, err := a.webAuthn(r)
if err != nil { if err != nil {
writeErr(w, err) writeErr(w, err)
@@ -748,17 +807,14 @@ func (a *App) keyBegin(w http.ResponseWriter, r *http.Request) {
for _, k := range u.MFA.Keys { for _, k := range u.MFA.Keys {
exclude = append(exclude, k.Credential.Descriptor()) exclude = append(exclude, k.Credential.Descriptor())
} }
sel := protocol.AuthenticatorSelection{ResidentKey: protocol.ResidentKeyRequirementDiscouraged, UserVerification: protocol.VerificationDiscouraged} sel := protocol.AuthenticatorSelection{ResidentKey: protocol.ResidentKeyRequirementRequired, UserVerification: protocol.VerificationRequired}
if in.Passkey {
sel = protocol.AuthenticatorSelection{ResidentKey: protocol.ResidentKeyRequirementRequired, UserVerification: protocol.VerificationRequired}
}
opts, data, err := wa.BeginRegistration(waUser{u}, webauthn.WithAuthenticatorSelection(sel), webauthn.WithExclusions(exclude)) opts, data, err := wa.BeginRegistration(waUser{u}, webauthn.WithAuthenticatorSelection(sel), webauthn.WithExclusions(exclude))
if err != nil { if err != nil {
writeErr(w, err) writeErr(w, err)
return return
} }
a.auth.mu.Lock() a.auth.mu.Lock()
a.auth.mfa.enrolls[p.UserID] = &ceremony{userID: p.UserID, passkey: in.Passkey, data: data, expires: time.Now().Add(ticketTTL)} a.auth.mfa.enrolls[p.UserID] = &ceremony{userID: p.UserID, data: data, expires: time.Now().Add(ticketTTL)}
a.auth.mu.Unlock() a.auth.mu.Unlock()
writeJSON(w, http.StatusOK, opts) writeJSON(w, http.StatusOK, opts)
} }
@@ -792,13 +848,13 @@ func (a *App) keyFinish(w http.ResponseWriter, r *http.Request) {
return return
} }
if name == "" { if name == "" {
name = map[bool]string{false: "Security key", true: "Passkey"}[cer.passkey] name = "Passkey"
} }
if len(name) > maxKeyName { if len(name) > maxKeyName {
name = name[:maxKeyName] name = name[:maxKeyName]
} }
var codes []string var codes []string
key := MFAKey{ID: newID(), Name: name, Passkey: cer.passkey, Created: time.Now().UTC(), Credential: *cred} key := MFAKey{ID: newID(), Name: name, Passkey: true, Created: time.Now().UTC(), Credential: *cred}
if err := a.store.Update(func(c *Config) error { if err := a.store.Update(func(c *Config) error {
_, u := c.userByID(p.UserID) _, u := c.userByID(p.UserID)
if u == nil || u.MFA == nil { if u == nil || u.MFA == nil {
@@ -811,7 +867,7 @@ func (a *App) keyFinish(w http.ResponseWriter, r *http.Request) {
writeErr(w, err) writeErr(w, err)
return return
} }
a.audit(r, map[bool]string{false: "security key added", true: "passkey added"}[cer.passkey], "key", name) a.audit(r, "passkey added", "key", name)
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "recoveryCodes": codes}) writeJSON(w, http.StatusOK, map[string]any{"ok": true, "recoveryCodes": codes})
} }
@@ -865,7 +921,7 @@ func (a *App) keyRemove(w http.ResponseWriter, r *http.Request) {
writeErr(w, err) writeErr(w, err)
return return
} }
a.audit(r, "security key removed", "key", name) a.audit(r, "passkey removed", "key", name)
writeJSON(w, http.StatusOK, map[string]any{"ok": true}) writeJSON(w, http.StatusOK, map[string]any{"ok": true})
} }
@@ -917,17 +973,9 @@ func (a *App) resetMFA(w http.ResponseWriter, r *http.Request) {
// mfaSummary is what user lists show. // mfaSummary is what user lists show.
func mfaSummary(u *User) map[string]any { func mfaSummary(u *User) map[string]any {
out := map[string]any{"totp": false, "keys": 0, "passkeys": 0} out := map[string]any{"totp": false, "passkeys": 0}
if m := u.MFA; m != nil { if m := u.MFA; m != nil {
keys, passkeys := 0, 0 out["totp"], out["passkeys"] = m.TOTPSecret != "", len(m.Keys)
for _, k := range m.Keys {
if k.Passkey {
passkeys++
} else {
keys++
}
}
out["totp"], out["keys"], out["passkeys"] = m.TOTPSecret != "", keys, passkeys
} }
return out return out
} }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 296 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

+54 -3
View File
@@ -12,6 +12,7 @@ import (
"os/user" "os/user"
"path/filepath" "path/filepath"
"runtime" "runtime"
"slices"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@@ -273,7 +274,51 @@ WantedBy=multi-user.target
// rewrite the unit for every release. // rewrite the unit for every release.
const unitVersion = "unit-1" 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 // writeSystemFiles writes the unit, sysctl and module files. It reports
// whether the unit changed (systemd must then reload). // whether the unit changed (systemd must then reload).
@@ -281,7 +326,8 @@ func writeSystemFiles() (unitChanged bool, err error) {
if unitChanged, err = writeIfChanged(unitPath, unitFile(), 0o644); err != nil { if unitChanged, err = writeIfChanged(unitPath, unitFile(), 0o644); err != nil {
return false, err 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 { if err != nil {
return false, err return false, err
} }
@@ -543,7 +589,7 @@ func cmdUpdate(args []string) error {
if err != nil { if err != nil {
return err return err
} }
backup := configFile + ".bak-" + oldVersion backup := newUpdateBackupPath(configFile, oldVersion, time.Now())
step("Backing up config to %s", backup) step("Backing up config to %s", backup)
if err := copyFile(configFile, backup, 0o600, uid, gid); err != nil { if err := copyFile(configFile, backup, 0o600, uid, gid); err != nil {
return err return err
@@ -581,6 +627,11 @@ func cmdUpdate(args []string) error {
} }
return fmt.Errorf("update failed, %s %s is running again: %w", appName, oldVersion, err) return fmt.Errorf("update failed, %s %s is running again: %w", appName, oldVersion, err)
} }
if n, err := pruneUpdateBackups(configFile, keepUpdateBackups); err != nil {
fmt.Fprintln(os.Stderr, " Could not remove older config backups:", err)
} else if n > 0 {
step("Removed %d older config backups, kept the newest %d", n, keepUpdateBackups)
}
fmt.Printf("\nUpdated %s %s → %s.\n", appName, oldVersion, version) fmt.Printf("\nUpdated %s %s → %s.\n", appName, oldVersion, version)
return nil return nil
} }
+7 -3
View File
@@ -20,6 +20,10 @@
} }
// Same drawing as favicon.svg. // Same drawing as favicon.svg.
// ext opens an outside page in a new tab, marked with ↗ as in the app.
const ext = (href, text) => h('a', { class: 'ext', href, target: '_blank', rel: 'noopener' }, text,
h('span', { class: 'ar', 'aria-hidden': 'true' }, '↗'), h('span', { class: 'sr' }, ' (opens in a new tab)'));
function logo(size, plain) { function logo(size, plain) {
const s = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); const s = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
for (const [k, v] of Object.entries({ width: size, height: size, viewBox: '0 0 64 64', 'aria-hidden': 'true' })) s.setAttribute(k, v); for (const [k, v] of Object.entries({ width: size, height: size, viewBox: '0 0 64 64', 'aria-hidden': 'true' })) s.setAttribute(k, v);
@@ -108,9 +112,9 @@
h('div', { class: 'notice' }, 'Save it now. This page can\'t be opened again: the private key exists only here and isn\'t stored anywhere.'), h('div', { class: 'notice' }, 'Save it now. This page can\'t be opened again: the private key exists only here and isn\'t stored anywhere.'),
h('ol', { class: 'steps' }, h('ol', { class: 'steps' },
step(1, 'Install WireGuard', step(1, 'Install WireGuard',
h('p', null, h('a', { href: 'https://apps.apple.com/app/wireguard/id1441195209', rel: 'noopener' }, 'App Store'), ' · ', h('p', null, ext('https://apps.apple.com/app/wireguard/id1441195209', 'App Store'), ' · ',
h('a', { href: 'https://play.google.com/store/apps/details?id=com.wireguard.android', rel: 'noopener' }, 'Google Play'), ' · ', ext('https://play.google.com/store/apps/details?id=com.wireguard.android', 'Google Play'), ' · ',
h('a', { href: 'https://www.wireguard.com/install/', rel: 'noopener' }, 'Other systems'))), ext('https://www.wireguard.com/install/', 'Other systems'))),
step(2, 'Add the profile', step(2, 'Add the profile',
h('button', { type: 'button', class: 'btn primary', onClick: download }, 'Download ' + file), h('button', { type: 'button', class: 'btn primary', onClick: download }, 'Download ' + file),
h('p', null, 'Open the downloaded file with WireGuard, or in WireGuard tap + and choose “Create from file”.')), h('p', null, 'Open the downloaded file with WireGuard, or in WireGuard tap + and choose “Create from file”.')),
+134
View File
@@ -0,0 +1,134 @@
package main
import (
"log/slog"
"sync"
"time"
)
// Speeds keeps the last few minutes of each peer's speed in memory for the
// Live page. It reads the kernel counters every speedStep, apart from the
// traffic history in Stats, and never writes to disk.
const (
speedStep = 2 * time.Second
speedPoints = 60 // 2 minutes
)
// SpeedPoint is one step: per peer ID, download and upload in bits per
// second, from the peer's point of view.
type SpeedPoint struct {
T int64 `json:"t"`
Peers map[string][2]int64 `json:"peers"`
}
type Speeds struct {
store *Store
kernel Kernel
mu sync.Mutex
last map[string][2]int64 // raw rx, tx by public key
lastAt time.Time
points []SpeedPoint
subs map[chan SpeedPoint]struct{}
done chan struct{} // closed when Run returns
}
func newSpeeds(store *Store, kernel Kernel) *Speeds {
return &Speeds{store: store, kernel: kernel, last: map[string][2]int64{}, subs: map[chan SpeedPoint]struct{}{}, done: make(chan struct{})}
}
// Subscribe returns the current points and a channel that receives each new
// one; cancel ends the subscription. A subscriber that falls behind misses
// points rather than holding up the sampler.
func (s *Speeds) Subscribe() (points []SpeedPoint, ch <-chan SpeedPoint, cancel func()) {
c := make(chan SpeedPoint, 4)
s.mu.Lock()
defer s.mu.Unlock()
s.subs[c] = struct{}{}
return append([]SpeedPoint{}, s.points...), c, func() {
s.mu.Lock()
delete(s.subs, c)
s.mu.Unlock()
}
}
// Done is closed when the sampler stops, so streams can end.
func (s *Speeds) Done() <-chan struct{} { return s.done }
func (s *Speeds) sample(now time.Time) {
cfg := s.store.Get()
samples, err := s.kernel.Sample(cfg.Server.Interface)
if err != nil {
slog.Debug("speed sample failed", "err", err)
return
}
idByKey := map[string]string{}
for _, p := range cfg.Peers {
if p.hasKey() {
idByKey[p.PublicKey] = p.ID
}
}
s.mu.Lock()
defer s.mu.Unlock()
secs := now.Sub(s.lastAt).Seconds()
first := s.lastAt.IsZero()
cur := map[string][2]int64{}
pt := SpeedPoint{T: now.Unix(), Peers: map[string][2]int64{}}
for _, smp := range samples {
cur[smp.PublicKey] = [2]int64{smp.RxBytes, smp.TxBytes}
id := idByKey[smp.PublicKey]
prev, ok := s.last[smp.PublicKey]
if id == "" || !ok || first {
continue
}
dRx, dTx := smp.RxBytes-prev[0], smp.TxBytes-prev[1]
if dRx < 0 || dTx < 0 { // counters were reset
continue
}
// Tx is what the server sent: the peer's download.
pt.Peers[id] = [2]int64{int64(float64(dTx*8) / secs), int64(float64(dRx*8) / secs)}
}
s.last, s.lastAt = cur, now
if first {
return
}
s.points = append(s.points, pt)
if len(s.points) > speedPoints {
s.points = s.points[len(s.points)-speedPoints:]
}
for c := range s.subs {
select {
case c <- pt:
default:
}
}
}
// Since returns the points newer than the unix time t, oldest first.
func (s *Speeds) Since(t int64) []SpeedPoint {
s.mu.Lock()
defer s.mu.Unlock()
out := []SpeedPoint{}
for _, p := range s.points {
if p.T > t {
out = append(out, p)
}
}
return out
}
func (s *Speeds) Run(stop <-chan struct{}) {
defer close(s.done)
s.sample(time.Now())
t := time.NewTicker(speedStep)
defer t.Stop()
for {
select {
case <-stop:
return
case now := <-t.C:
s.sample(now)
}
}
}
+224
View File
@@ -0,0 +1,224 @@
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"runtime"
"strings"
"sync"
"sync/atomic"
"time"
)
// The update check asks one of the two places releases are published for
// the latest one. Both carry the same tags and files.
var updateSources = map[string]struct {
Name string // shown in the web interface
API string // latest release, as JSON
Repo string // web page of the repository; downloads are under it
}{
"gitea": {"Gitea", "https://git.redetzke.aero/api/v1/repos/Redetzke/GHOSTWIRE/releases/latest", "https://git.redetzke.aero/Redetzke/GHOSTWIRE"},
"github": {"GitHub", "https://api.github.com/repos/danielredetzke/GHOSTWIRE/releases/latest", "https://github.com/danielredetzke/GHOSTWIRE"},
}
const updateCheckFreq = 24 * time.Hour
// Release is the latest published release as the source reports it.
type Release struct {
Version string `json:"version"` // tag, e.g. "v0.4.0"
Published time.Time `json:"published"`
Notes string `json:"notes"` // Markdown
URL string `json:"url"` // release page
}
// UpdateStatus is shown in the settings; Available also reaches the sidebar
// and the Dashboard through /auth/me.
type UpdateStatus struct {
Enabled bool `json:"enabled"`
Source string `json:"source"`
Current string `json:"current"`
Latest *Release `json:"latest"`
Available bool `json:"available"` // Latest is newer than Current
Checked *time.Time `json:"checked"` // last attempt
Error string `json:"error,omitempty"`
LastOK *time.Time `json:"lastOk"` // last attempt that worked
// Download links for this server's platform; empty when no release
// file is built for it.
Arch string `json:"arch"`
File string `json:"file,omitempty"`
FileURL string `json:"fileUrl,omitempty"`
SumsURL string `json:"sumsUrl,omitempty"`
SourceURL string `json:"sourceUrl"` // repository page of the source
}
type Updater struct {
enabled atomic.Bool
kick chan struct{}
fetch func(ctx context.Context, url string) (*Release, error) // replaced in tests
mu sync.Mutex
source string
latest *Release
checked *time.Time
lastOK *time.Time
err string
}
func newUpdater(c UpdatesConfig) *Updater {
u := &Updater{kick: make(chan struct{}, 1), fetch: fetchRelease, source: c.Source}
u.enabled.Store(c.checkEnabled())
return u
}
// Set applies the settings. A new source or switching the check on checks
// at once; switching it off forgets what the last check found.
func (u *Updater) Set(c UpdatesConfig) {
if u == nil {
return
}
on := c.checkEnabled()
u.mu.Lock()
changed := u.source != c.Source || u.enabled.Load() != on
if u.source != c.Source || !on {
u.latest, u.checked, u.lastOK, u.err = nil, nil, nil, ""
}
u.source = c.Source
u.enabled.Store(on)
u.mu.Unlock()
if changed && on {
select {
case u.kick <- struct{}{}:
default:
}
}
}
// Run checks once a day while the check is on.
func (u *Updater) Run(stop <-chan struct{}) {
t := time.NewTicker(updateCheckFreq)
defer t.Stop()
for {
if u.enabled.Load() {
u.Check(context.Background())
}
select {
case <-stop:
return
case <-t.C:
case <-u.kick:
}
}
}
// Check asks the source for the latest release now.
func (u *Updater) Check(ctx context.Context) {
u.mu.Lock()
source := u.source
u.mu.Unlock()
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
rel, err := u.fetch(ctx, updateSources[source].API)
now := time.Now()
u.mu.Lock()
defer u.mu.Unlock()
if u.source != source { // the source changed meanwhile; that check counts
return
}
u.checked = &now
if err != nil {
u.err = err.Error()
slog.Warn("update check failed", "source", source, "err", err)
return
}
u.latest, u.lastOK, u.err = rel, &now, ""
if newerVersion(rel.Version, version) {
slog.Info("update available", "version", rel.Version, "running", version)
}
}
func (u *Updater) Status() UpdateStatus {
if u == nil {
return UpdateStatus{Current: version}
}
u.mu.Lock()
defer u.mu.Unlock()
src := updateSources[u.source]
st := UpdateStatus{
Enabled: u.enabled.Load(), Source: u.source, Current: version, Latest: u.latest,
Checked: u.checked, Error: u.err, LastOK: u.lastOK, Arch: releaseArch(), SourceURL: src.Repo,
}
if u.latest != nil {
st.Available = newerVersion(u.latest.Version, version)
if st.Arch != "" {
st.File = fmt.Sprintf("%s-%s-linux-%s", appName, u.latest.Version, st.Arch)
base := src.Repo + "/releases/download/" + u.latest.Version + "/"
st.FileURL, st.SumsURL = base+st.File, base+"SHA256SUMS"
}
}
return st
}
// Available returns the newer release's version, or "".
func (u *Updater) Available() string {
if st := u.Status(); st.Enabled && st.Available {
return st.Latest.Version
}
return ""
}
// releaseArch names this platform the way the release files do, or "" when
// no file is built for it.
func releaseArch() string {
if runtime.GOOS != "linux" {
return ""
}
switch runtime.GOARCH {
case "amd64", "arm64":
return runtime.GOARCH
case "arm":
return "armv7"
}
return ""
}
func fetchRelease(ctx context.Context, url string) (*Release, error) {
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", appName+"/"+strings.TrimPrefix(version, "v"))
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("HTTP %d from %s", resp.StatusCode, req.URL.Host)
}
// GitHub and Gitea name these fields the same.
var r struct {
Tag string `json:"tag_name"`
Body string `json:"body"`
Published time.Time `json:"published_at"`
URL string `json:"html_url"`
Draft bool `json:"draft"`
Prerelease bool `json:"prerelease"`
}
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&r); err != nil {
return nil, fmt.Errorf("unreadable answer from %s: %w", req.URL.Host, err)
}
if _, ok := compareVersions(r.Tag, r.Tag); r.Draft || r.Prerelease || !ok {
return nil, errors.New("the latest release is not a published version")
}
return &Release{Version: r.Tag, Published: r.Published, Notes: r.Body, URL: r.URL}, nil
}
// newerVersion reports whether latest is a higher version than running.
// A running version that is not a version number is never out of date.
func newerVersion(latest, running string) bool {
c, ok := compareVersions(latest, running)
return ok && c > 0
}
+111
View File
@@ -0,0 +1,111 @@
package main
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestNewerVersion(t *testing.T) {
for _, tc := range []struct {
latest, running string
want bool
}{
{"v0.4.0", "v0.3.2", true},
{"v0.4.0", "0.3.2", true},
{"v0.10.0", "v0.9.9", true},
{"v1.0.0", "v0.99.0", true},
{"v0.4.0", "v0.4.0", false},
{"v0.4.0", "v0.4.0-3-gb18d16a", false}, // a build after the release
{"v0.3.2", "v0.4.0", false},
{"v0.4.0", "dev", false}, // not a version: never out of date
{"latest", "v0.3.2", false},
} {
if got := newerVersion(tc.latest, tc.running); got != tc.want {
t.Errorf("newerVersion(%q, %q) = %v, want %v", tc.latest, tc.running, got, tc.want)
}
}
}
func TestFetchRelease(t *testing.T) {
var body string
var status int
var ua string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ua = r.Header.Get("User-Agent")
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
}))
defer srv.Close()
status, body = 200, `{"tag_name":"v0.4.0","body":"Fixes.","published_at":"2026-10-05T06:15:28Z","html_url":"https://example.net/r/v0.4.0","draft":false,"prerelease":false}`
r, err := fetchRelease(context.Background(), srv.URL)
if err != nil {
t.Fatal(err)
}
if r.Version != "v0.4.0" || r.Notes != "Fixes." || r.URL != "https://example.net/r/v0.4.0" || r.Published.IsZero() {
t.Fatalf("release = %+v", r)
}
if !strings.HasPrefix(ua, appName+"/") {
t.Errorf("User-Agent = %q", ua)
}
status, body = 200, `{"tag_name":"v0.5.0-rc1","prerelease":true}`
if _, err := fetchRelease(context.Background(), srv.URL); err == nil {
t.Error("a pre-release was accepted")
}
status, body = 404, `{}`
if _, err := fetchRelease(context.Background(), srv.URL); err == nil || !strings.Contains(err.Error(), "404") {
t.Errorf("HTTP 404: err = %v", err)
}
}
func TestUpdater(t *testing.T) {
old := version
version = "v0.3.2"
defer func() { version = old }()
u := newUpdater(UpdatesConfig{Source: "gitea"})
var asked string
u.fetch = func(_ context.Context, url string) (*Release, error) {
asked = url
return &Release{Version: "v0.4.0"}, nil
}
u.Check(context.Background())
if asked != updateSources["gitea"].API {
t.Errorf("asked %q", asked)
}
st := u.Status()
if !st.Available || u.Available() != "v0.4.0" || st.Checked == nil || st.LastOK == nil {
t.Fatalf("status = %+v", st)
}
if st.Arch != "" {
want := "https://git.redetzke.aero/Redetzke/GHOSTWIRE/releases/download/v0.4.0/GHOSTWIRE-v0.4.0-linux-" + st.Arch
if st.FileURL != want || !strings.HasSuffix(st.SumsURL, "/v0.4.0/SHA256SUMS") {
t.Errorf("downloads = %q, %q", st.FileURL, st.SumsURL)
}
}
// A failed check keeps the last good answer and reports the error.
u.fetch = func(context.Context, string) (*Release, error) { return nil, errors.New("no route to host") }
u.Check(context.Background())
if st := u.Status(); st.Error != "no route to host" || st.Latest == nil {
t.Errorf("after a failed check: %+v", st)
}
// Another source forgets what the old one said; switching off hides it.
u.Set(UpdatesConfig{Source: "github"})
if st := u.Status(); st.Latest != nil || st.Error != "" || st.SourceURL != updateSources["github"].Repo {
t.Errorf("after changing the source: %+v", st)
}
off := false
u.fetch = func(context.Context, string) (*Release, error) { return &Release{Version: "v0.4.0"}, nil }
u.Check(context.Background())
u.Set(UpdatesConfig{Source: "github", Check: &off})
if u.Available() != "" || u.Status().Enabled {
t.Error("still reports an update with the check off")
}
}
+125
View File
@@ -0,0 +1,125 @@
package main
import (
"errors"
"io/fs"
"net/http"
"os"
"path/filepath"
"regexp"
"slices"
"strings"
"time"
)
// Each update copies config.json to config.json.bak-<old version> next to
// it, in case the new version must be rolled back. The copies hold the same
// secrets as a backup, so the web interface lists them and can remove them,
// and update keeps only the newest few.
const keepUpdateBackups = 3
type UpdateBackup struct {
Name string `json:"name"`
Version string `json:"version"`
Modified time.Time `json:"modified"`
Size int64 `json:"size"`
}
// A copy that would overwrite an older one gets the time appended.
var backupStampRe = regexp.MustCompile(`-\d{8}-\d{4}$`)
func updateBackupPrefix(configPath string) string { return filepath.Base(configPath) + ".bak-" }
// newUpdateBackupPath names the copy update makes of configPath.
func newUpdateBackupPath(configPath, version string, now time.Time) string {
p := configPath + ".bak-" + version
if _, err := os.Lstat(p); err == nil {
p += now.Format("-20060102-1504")
}
return p
}
// listUpdateBackups returns the copies next to configPath, newest first.
func listUpdateBackups(configPath string) ([]UpdateBackup, error) {
entries, err := os.ReadDir(filepath.Dir(configPath))
if err != nil {
return nil, err
}
prefix := updateBackupPrefix(configPath)
out := []UpdateBackup{}
for _, e := range entries {
name := e.Name()
if !e.Type().IsRegular() || !strings.HasPrefix(name, prefix) || name == prefix {
continue
}
fi, err := e.Info()
if err != nil {
continue
}
out = append(out, UpdateBackup{Name: name, Version: backupStampRe.ReplaceAllString(strings.TrimPrefix(name, prefix), ""),
Modified: fi.ModTime(), Size: fi.Size()})
}
slices.SortFunc(out, func(a, b UpdateBackup) int { return b.Modified.Compare(a.Modified) })
return out, nil
}
// removeUpdateBackup deletes one copy; any other name is refused.
func removeUpdateBackup(configPath, name string) error {
prefix := updateBackupPrefix(configPath)
path := filepath.Join(filepath.Dir(configPath), name)
fi, err := os.Lstat(path)
if !strings.HasPrefix(name, prefix) || name == prefix || strings.ContainsAny(name, `/\`) ||
errors.Is(err, fs.ErrNotExist) || (err == nil && !fi.Mode().IsRegular()) {
return badRequest("no copy named %q", name)
}
if err != nil {
return err
}
return os.Remove(path)
}
// pruneUpdateBackups keeps the newest keep copies and deletes the rest.
func pruneUpdateBackups(configPath string, keep int) (int, error) {
list, err := listUpdateBackups(configPath)
if err != nil || len(list) <= keep {
return 0, err
}
n := 0
for _, b := range list[keep:] {
if err := removeUpdateBackup(configPath, b.Name); err != nil {
return n, err
}
n++
}
return n, nil
}
func (a *App) listUpdateBackups(w http.ResponseWriter, r *http.Request) {
list, err := listUpdateBackups(a.store.path)
if err != nil {
writeErr(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"backups": list})
}
func (a *App) removeUpdateBackup(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
if err := removeUpdateBackup(a.store.path, name); err != nil {
writeErr(w, err)
return
}
a.audit(r, "update backup removed", "file", name)
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (a *App) removeUpdateBackups(w http.ResponseWriter, r *http.Request) {
n, err := pruneUpdateBackups(a.store.path, 0)
if err != nil {
writeErr(w, err)
return
}
a.audit(r, "update backups removed", "count", n)
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "removed": n})
}
+65
View File
@@ -0,0 +1,65 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestUpdateBackups(t *testing.T) {
dir := t.TempDir()
cfg := filepath.Join(dir, "config.json")
_ = os.WriteFile(cfg, []byte("{}"), 0o600)
now := time.Date(2026, 10, 5, 12, 9, 0, 0, time.UTC)
// A second copy of the same version gets the time appended instead of
// overwriting the first.
first := newUpdateBackupPath(cfg, "unknown", now)
if filepath.Base(first) != "config.json.bak-unknown" {
t.Fatalf("first copy: %s", first)
}
_ = os.WriteFile(first, []byte("{}"), 0o600)
second := newUpdateBackupPath(cfg, "unknown", now)
if filepath.Base(second) != "config.json.bak-unknown-20261005-1209" {
t.Fatalf("second copy: %s", second)
}
_ = os.WriteFile(second, []byte("{}"), 0o600)
for i, v := range []string{"v0.2.0", "v0.3.0", "v0.4.0"} {
f := filepath.Join(dir, "config.json.bak-"+v)
_ = os.WriteFile(f, []byte("{}"), 0o600)
_ = os.Chtimes(f, now, now.Add(time.Duration(i+1)*time.Hour))
}
_ = os.Chtimes(first, now, now.Add(-2*time.Hour))
_ = os.Chtimes(second, now, now.Add(-time.Hour))
_ = os.Mkdir(filepath.Join(dir, "config.json.bak-dir"), 0o700) // not a file: ignored
list, err := listUpdateBackups(cfg)
if err != nil {
t.Fatal(err)
}
var got []string
for _, b := range list {
got = append(got, b.Version)
}
if strings.Join(got, " ") != "v0.4.0 v0.3.0 v0.2.0 unknown unknown" {
t.Fatalf("versions, newest first: %v", got)
}
for _, bad := range []string{"config.json", "config.json.bak-", "config.json.bak-dir", "../config.json.bak-v0.4.0", "config.json.bak-v0.4.0/x"} {
if err := removeUpdateBackup(cfg, bad); err == nil {
t.Errorf("removed %q", bad)
}
}
if n, err := pruneUpdateBackups(cfg, keepUpdateBackups); err != nil || n != 2 {
t.Fatalf("prune: %d, %v", n, err)
}
if list, _ = listUpdateBackups(cfg); len(list) != 3 || list[2].Version != "v0.2.0" {
t.Fatalf("after prune: %v", list)
}
if _, err := os.Stat(cfg); err != nil {
t.Fatal("config.json is gone")
}
}
+1 -1
View File
@@ -21,7 +21,7 @@ type userView struct {
LastLogin *tokenUse `json:"lastLogin"` // since the service started LastLogin *tokenUse `json:"lastLogin"` // since the service started
Tokens int `json:"tokens"` Tokens int `json:"tokens"`
You bool `json:"you"` You bool `json:"you"`
MFA map[string]any `json:"mfa"` // {"totp": bool, "keys": n, "passkeys": n} MFA map[string]any `json:"mfa"` // {"totp": bool, "passkeys": n}
} }
func (a *App) userView(c *Config, u *User, me string) userView { func (a *App) userView(c *Config, u *User, me string) userView {