30 Commits

Author SHA1 Message Date
Daniel Redetzke 32d5621cf4 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 95bb95cecd 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 0861047952 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 ac572e165a 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 dbeaa645c0 README: the iOS app is in beta testing; invites by email 2026-10-05 09:35:34 +03:00
Daniel Redetzke 52be000731 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 5dd19185bb Revert "Send adminUsername in /settings again"
This reverts commit c846345a1c.
2026-10-05 08:54:04 +03:00
Daniel Redetzke dcc3f91740 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 c79ea08f19 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 d749fe5f99 Show peer names in plain ink instead of underlined links 2026-10-05 01:57:00 +03:00
Daniel Redetzke 5797f152ea 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 0a0efd9115 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 9ecc188269 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 ebd3ceacd7 README: dashboard screenshot from v0.3.1 2026-10-04 23:01:17 +03:00
Daniel Redetzke c218a9665b README: bring iOS app, tokens, config.json and lockout up to date 2026-10-04 22:54:48 +03:00
Daniel Redetzke 436485d627 README: keep only the dashboard screenshot 2026-10-04 22:50:13 +03:00
Daniel Redetzke 490d055cec Delete stored security keys 2026-10-04 22:35:19 +03:00
Daniel Redetzke 56978b28e9 Passkeys only: drop adding security keys 2026-10-04 22:13:31 +03:00
Daniel Redetzke 2cbe344d74 Japanese hover label on the passkey button 2026-10-04 22:03:12 +03:00
Daniel Redetzke 3e44022b0b Two-step sign-in: authenticator app, security keys and passkeys 2026-10-04 21:55:53 +03:00
Daniel Redetzke 1e8175cad4 Drop the tagline from the sign-in page 2026-10-04 21:02:37 +03:00
Daniel Redetzke f5e0da5ccd Fingerprint app.js, setup.js and app.css 2026-10-04 20:56:35 +03:00
Daniel Redetzke 542ee98a85 Sortable peers table, shorter connection history 2026-10-04 20:53:56 +03:00
Daniel Redetzke 82228faeba More web interface pages 2026-10-04 20:47:12 +03:00
Daniel Redetzke 6afef85b2b Full-access tokens manage users, passwords and tokens 2026-10-04 20:37:46 +03:00
Daniel Redetzke 207f3f4172 Sign out from an icon in the account row 2026-10-04 20:21:00 +03:00
Daniel Redetzke bbbef00329 Web interface setting 2026-10-04 20:16:57 +03:00
Daniel Redetzke 606b3fe89f Keep the sidebar in view on long pages
Beside the page, the sidebar now stays in place while the page scrolls,
so the account link and Sign out are always visible; it scrolls by
itself in very short windows. On a phone it still stacks above the page.
Also show the version with one 'v': release builds already start with it.
2026-10-04 16:36:30 +03:00
Daniel Redetzke c7340d011a README: screenshots of the web interface
Seven pages captured from a demo instance with sample data from the
simulator: dashboard (under the introduction), peers, peer, server,
settings, my account and sign-in. Images live in screenshots/.
2026-10-04 16:31:01 +03:00
Daniel Redetzke 9220ff54aa README: show the Hannya logo at the top 2026-10-04 16:14:54 +03:00
22 changed files with 3836 additions and 208 deletions
+66 -14
View File
@@ -1,3 +1,7 @@
<p align="center">
<img src="favicon.svg" width="120" height="120" alt="GHOSTWIRE logo: the Hannya mask">
</p>
# GHOSTWIRE # GHOSTWIRE
**ゴーストワイヤー** · A self-hosted WireGuard server manager in a single Go **ゴーストワイヤー** · A self-hosted WireGuard server manager in a single Go
@@ -8,6 +12,8 @@ remove), hands out client configs as a download or QR code, and records traffic
and connection history per peer. There are no install scripts and no and connection history per peer. There are no install scripts and no
dependencies on the server: the binary installs, updates and removes itself. dependencies on the server: the binary installs, updates and removes itself.
![Dashboard with peers online, traffic of the last 24 hours, the peer list and recent activity](screenshots/dashboard.png)
## Features ## Features
- **One file of state:** everything lives in `config.json`. The kernel is - **One file of state:** everything lives in `config.json`. The kernel is
@@ -21,16 +27,22 @@ 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).
- **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.
@@ -46,8 +58,19 @@ dependencies on the server: the binary installs, updates and removes itself.
`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) and
passkeys under My account. A passkey signs in on its own, without username
and password, and also works as the second step after a password. It can live
on the device (Touch ID, Face ID, Windows Hello), in a password manager, or on
a YubiKey with a PIN set. Turning it on gives 10 one-time recovery codes. An
admin can require it for everyone (Settings → Sign-in) and reset it for a user
who lost their phone or key. Passkeys use WebAuthn and need the server's
domain name with a trusted certificate (Let's Encrypt, certificate files, or a
reverse proxy); on a self-signed certificate or an IP address, only the
authenticator app is offered. API tokens never need a second step.
- **API tokens** are stored only as hashes and can be read-only or full access. - **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).
@@ -159,7 +182,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. |
@@ -200,7 +223,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` |
@@ -213,16 +237,32 @@ 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 the GET. Full-access tokens can do everything the web interface does except the
endpoints marked "signed in": users, passwords, API tokens, backup and restore. endpoints marked "signed in": users, passwords, API tokens, the sign-in rules,
backup and restore.
For a user with two-step sign-in, `POST /auth/login` answers
`{"mfa": true, "ticket": "…", "methods": ["key", "totp", "recovery"]}`
instead of starting a session; the ticket is good for 5 minutes, and one of
the `/auth/login/…` steps turns it into the session. `PATCH /settings`
`{"signin": {"requireMfa": true}}` requires two-step sign-in for every user;
only a signed-in user can change it.
`POST /users` and `POST /users/{id}/reset-password` take `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 signed in: GET|POST /users · PATCH|DELETE /users/{id}
signed in: POST /users/{id}/reset-password · /users/{id}/reset-mfa
GET /auth/options (public: is passkey sign-in offered here)
POST /auth/login/totp · /auth/login/recovery {"ticket", "code"}
POST /auth/login/key/begin {"ticket"} · /auth/login/key/finish?ticket= (body: the WebAuthn credential)
POST /auth/login/passkey/begin · /auth/login/passkey/finish?id=
signed in: GET /auth/mfa · POST /auth/mfa/totp/setup · /auth/mfa/totp/confirm · DELETE /auth/mfa/totp
signed in: POST /auth/mfa/keys/begin · /auth/mfa/keys/finish?name= · PATCH|DELETE /auth/mfa/keys/{id}
signed in: POST /auth/mfa/recovery-codes
GET /status GET /stats?range=24h|7d|30d|90d GET /status GET /stats?range=24h|7d|30d|90d
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)
@@ -231,9 +271,10 @@ POST /peers/{id}/enable | /disable | /issue-config
GET /peers/{id}/stats?range=… GET /peers/{id}/sessions?limit=100 GET /peers/{id}/stats?range=… GET /peers/{id}/sessions?limit=100
GET /peers/{id}/latency (24 h, one point per 5 minutes) GET /peers/{id}/latency (24 h, one point per 5 minutes)
GET /peers/{id}/setup (not read-only) DELETE /peers/{id}/setup GET /peers/{id}/setup (not read-only) DELETE /peers/{id}/setup
GET /settings PATCH /settings POST /restart GET /settings PATCH /settings POST /restart POST /updates/check
GET /logs?level=&limit=&audit=1 GET /logs/download GET /logs?level=&limit=&audit=1 GET /logs/download
signed in: GET|POST /tokens · DELETE /tokens/{id} · GET /backup · POST /restore signed in: GET|POST /tokens · DELETE /tokens/{id} · GET /backup · POST /restore
signed in: GET|DELETE /update-backups · DELETE /update-backups/{name} (config copies made by update)
public: GET /setup/{token} · POST /setup/{token} {"pin"} (what a setup link opens) public: GET /setup/{token} · POST /setup/{token} {"pin"} (what a setup link opens)
``` ```
@@ -242,6 +283,13 @@ 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.
@@ -263,12 +311,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
+83 -13
View File
@@ -26,6 +26,7 @@ type App struct {
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
} }
@@ -84,6 +85,11 @@ func (a *App) guard(adminOnly bool, h http.HandlerFunc) http.HandlerFunc {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "choose a new password first", "code": "password_change_required"}) writeJSON(w, http.StatusForbidden, map[string]string{"error": "choose a new password first", "code": "password_change_required"})
return return
} }
if p.MFASetupRequired && r.URL.Path != "/api/v1/auth/me" && r.URL.Path != "/api/v1/auth/password" &&
!strings.HasPrefix(r.URL.Path, "/api/v1/auth/mfa") {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "set up two-step sign-in first", "code": "mfa_setup_required"})
return
}
if p.Scope == "ro" && r.Method != http.MethodGet { if p.Scope == "ro" && r.Method != http.MethodGet {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "this token is read-only"}) writeJSON(w, http.StatusForbidden, map[string]string{"error": "this token is read-only"})
return return
@@ -108,6 +114,25 @@ func (a *App) routes() http.Handler {
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)
// The second step of signing in, and signing in with a passkey alone.
mux.HandleFunc("GET /api/v1/auth/options", a.signInOptions)
mux.HandleFunc("POST /api/v1/auth/login/totp", a.loginTOTP)
mux.HandleFunc("POST /api/v1/auth/login/recovery", a.loginRecovery)
mux.HandleFunc("POST /api/v1/auth/login/key/begin", a.loginKeyBegin)
mux.HandleFunc("POST /api/v1/auth/login/key/finish", a.loginKeyFinish)
mux.HandleFunc("POST /api/v1/auth/login/passkey/begin", a.loginPasskeyBegin)
mux.HandleFunc("POST /api/v1/auth/login/passkey/finish", a.loginPasskeyFinish)
// Your own two-step sign-in. Keys and passkeys need a browser, so these
// are for signed-in users only.
adm("GET /api/v1/auth/mfa", a.mfaStatus)
adm("POST /api/v1/auth/mfa/totp/setup", a.totpSetup)
adm("POST /api/v1/auth/mfa/totp/confirm", a.totpConfirm)
adm("DELETE /api/v1/auth/mfa/totp", a.totpRemove)
adm("POST /api/v1/auth/mfa/keys/begin", a.keyBegin)
adm("POST /api/v1/auth/mfa/keys/finish", a.keyFinish)
adm("PATCH /api/v1/auth/mfa/keys/{id}", a.keyRename)
adm("DELETE /api/v1/auth/mfa/keys/{id}", a.keyRemove)
adm("POST /api/v1/auth/mfa/recovery-codes", a.newRecoveryCodesHandler)
g("GET /api/v1/auth/me", a.me) g("GET /api/v1/auth/me", a.me)
adm("POST /api/v1/auth/password", a.changePassword) adm("POST /api/v1/auth/password", a.changePassword)
adm("GET /api/v1/users", a.listUsers) adm("GET /api/v1/users", a.listUsers)
@@ -115,6 +140,7 @@ func (a *App) routes() http.Handler {
adm("PATCH /api/v1/users/{id}", a.patchUser) adm("PATCH /api/v1/users/{id}", a.patchUser)
adm("POST /api/v1/users/{id}/reset-password", a.resetPassword) adm("POST /api/v1/users/{id}/reset-password", a.resetPassword)
adm("DELETE /api/v1/users/{id}", a.deleteUser) adm("DELETE /api/v1/users/{id}", a.deleteUser)
adm("POST /api/v1/users/{id}/reset-mfa", a.resetMFA)
g("GET /api/v1/status", a.status) g("GET /api/v1/status", a.status)
g("GET /api/v1/stats", a.allStats) g("GET /api/v1/stats", a.allStats)
@@ -143,10 +169,12 @@ func (a *App) routes() http.Handler {
mux.HandleFunc("GET /api/v1/setup/{token}", a.setupInfo) mux.HandleFunc("GET /api/v1/setup/{token}", a.setupInfo)
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 and read logs. // Full-access tokens (the iOS app) may change app settings, read logs and
// Users, passwords, tokens and 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)
adm("GET /api/v1/tokens", a.listTokens) adm("GET /api/v1/tokens", a.listTokens)
adm("POST /api/v1/tokens", a.createToken) adm("POST /api/v1/tokens", a.createToken)
@@ -155,12 +183,16 @@ func (a *App) routes() http.Handler {
g("GET /api/v1/logs/download", a.downloadLog) g("GET /api/v1/logs/download", a.downloadLog)
adm("GET /api/v1/backup", a.backup) adm("GET /api/v1/backup", a.backup)
adm("POST /api/v1/restore", a.restore) adm("POST /api/v1/restore", a.restore)
adm("GET /api/v1/update-backups", a.listUpdateBackups)
adm("DELETE /api/v1/update-backups", a.removeUpdateBackups)
adm("DELETE /api/v1/update-backups/{name}", a.removeUpdateBackup)
mux.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "no such endpoint"}) writeJSON(w, http.StatusNotFound, map[string]string{"error": "no such endpoint"})
}) })
mux.HandleFunc("GET /setup/{token}", setupPage) mux.HandleFunc("GET /setup/{token}", a.setupPage)
mux.Handle("/", webHandler()) mux.HandleFunc("GET /setup/{token}/{file}", a.setupAsset)
mux.Handle("/", a.webHandler())
csrf := http.NewCrossOriginProtection() csrf := http.NewCrossOriginProtection()
return securityHeaders(csrf.Handler(mux)) return securityHeaders(csrf.Handler(mux))
@@ -189,16 +221,22 @@ func (a *App) login(w http.ResponseWriter, r *http.Request) {
return return
} }
ip := remoteIP(r) ip := remoteIP(r)
id, err := a.auth.Login(in.Username, in.Password, ip) id, ticket, err := a.auth.Login(in.Username, in.Password, ip)
if err != nil { 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()})
return return
} }
if ticket != "" {
// The password was right; the second step makes the session.
_, u := a.auth.ticketUserID(ticket)
writeJSON(w, http.StatusOK, map[string]any{"mfa": true, "ticket": ticket, "methods": mfaMethods(u)})
return
}
a.setSessionCookie(w, r, id) a.setSessionCookie(w, r, id)
slog.Info("login", "audit", true, "actor", in.Username, "remote", ip) slog.Info("login", "audit", true, "actor", in.Username, "remote", ip)
writeJSON(w, http.StatusOK, map[string]any{"ok": true}) writeJSON(w, http.StatusOK, map[string]any{"ok": true})
@@ -223,7 +261,10 @@ func (a *App) me(w http.ResponseWriter, r *http.Request) {
p := who(r) p := who(r)
out := map[string]any{ out := map[string]any{
"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, "version": version, "session": p.Session, "mustChangePassword": p.MustChangePassword, "mfaSetupRequired": p.MFASetupRequired, "version": version, "session": p.Session,
}
if v := a.updates.Available(); v != "" {
out["updateAvailable"] = v
} }
if _, u := a.store.Get().userByID(p.UserID); u != nil { if _, u := a.store.Get().userByID(p.UserID); u != nil {
out["username"], out["note"], out["created"] = u.Username, u.Note, u.Created out["username"], out["note"], out["created"] = u.Username, u.Note, u.Created
@@ -963,8 +1004,10 @@ func (a *App) getSettings(w http.ResponseWriter, r *http.Request) {
"web": cfg.Web, "web": cfg.Web,
"log": cfg.Log, "log": cfg.Log,
"stats": cfg.Stats, "stats": cfg.Stats,
"decoy": cfg.Decoy,
"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,
}) })
@@ -976,21 +1019,37 @@ 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 := listen()
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) restart = listen() != before
restart = string(before) != string(after)
if err := field(m, "stats", &c.Stats); err != nil { if err := field(m, "stats", &c.Stats); err != nil {
return err return err
} }
if err := field(m, "decoy", &c.Decoy); err != nil {
return err
}
if err := field(m, "signin", &c.SignIn); err != nil {
return err
}
if err := field(m, "updates", &c.Updates); err != nil {
return err
}
return field(m, "log", &c.Log) return field(m, "log", &c.Log)
}) })
if err != nil { if err != nil {
@@ -1136,6 +1195,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 {
+90 -8
View File
@@ -28,7 +28,7 @@
--brand: "Shippori Mincho B1", "Hiragino Mincho ProN", "Yu Mincho", serif; --brand: "Shippori Mincho B1", "Hiragino Mincho ProN", "Yu Mincho", serif;
} }
@font-face { font-family: "Shippori Mincho B1"; font-weight: 800; font-display: swap; src: url("/ShipporiMinchoB1-ExtraBold.woff2") format("woff2"); } @font-face { font-family: "Shippori Mincho B1"; font-weight: 800; font-display: swap; src: url("ShipporiMinchoB1-ExtraBold.woff2") format("woff2"); }
* { box-sizing: border-box; } * { box-sizing: border-box; }
html, body { margin: 0; } html, body { margin: 0; }
@@ -56,7 +56,12 @@ a.brand, a.brand:hover { color: #fff; text-decoration: none; }
.side a.nav.on { background: #2a2b31; color: #fff; } .side a.nav.on { background: #2a2b31; color: #fff; }
.side .count { margin-left: auto; font-size: 12px; color: #8d8e93; } .side .count { margin-left: auto; font-size: 12px; color: #8d8e93; }
.side .foot { margin-top: auto; padding-top: 16px; border-top: 1px solid #2c2d32; display: flex; flex-direction: column; gap: 2px; font-size: 12px; color: #8d8e93; } .side .foot { margin-top: auto; padding-top: 16px; border-top: 1px solid #2c2d32; display: flex; flex-direction: column; gap: 2px; font-size: 12px; color: #8d8e93; }
.side .foot button { background: none; border: 0; padding: 0; font: inherit; color: #c9c9c3; text-decoration: underline; cursor: pointer; } .side .acctrow { display: flex; align-items: center; gap: 4px; }
.side .acctrow .acct { flex: 1; min-width: 0; }
.side .signout { position: relative; flex: none; width: 40px; height: 40px; display: grid; place-items: center; border: 0; border-radius: 8px; background: none; color: #8d8e93; cursor: pointer; }
.side .signout:hover { background: #222328; color: #fff; }
.side .signout .tip { position: absolute; bottom: calc(100% + 6px); right: 0; padding: 3px 8px; border-radius: 5px; background: #000; color: #fff; font-size: 12px; white-space: nowrap; opacity: 0; pointer-events: none; transition: opacity 0.12s; }
.side .signout:hover .tip, .side .signout:focus-visible .tip { opacity: 1; }
.side .acct { display: flex; align-items: center; gap: 12px; min-height: 52px; padding: 0 12px; border-radius: 8px; color: #c9c9c3; text-decoration: none; } .side .acct { display: flex; align-items: center; gap: 12px; min-height: 52px; padding: 0 12px; border-radius: 8px; color: #c9c9c3; text-decoration: none; }
.side .acct:hover { background: #222328; color: #fff; } .side .acct:hover { background: #222328; color: #fff; }
.side .acct.on { background: #2a2b31; color: #fff; } .side .acct.on { background: #2a2b31; color: #fff; }
@@ -64,8 +69,14 @@ 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
place while the page scrolls, so the account link is always visible. */
@media (min-width: 800px) { .side { position: sticky; top: 0; height: 100vh; height: 100dvh; overflow-y: auto; } }
.wrap { max-width: 1120px; margin: 0 auto; display: flex; flex-direction: column; gap: 20px; } .wrap { max-width: 1120px; margin: 0 auto; display: flex; flex-direction: column; gap: 20px; }
@media (max-width: 640px) { .main { padding: 20px 16px 40px; } } @media (max-width: 640px) { .main { padding: 20px 16px 40px; } }
@@ -121,6 +132,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 */
@@ -128,10 +142,16 @@ h1 { margin: 0; font-size: 26px; font-weight: 600; letter-spacing: -0.01em; over
table { width: 100%; border-collapse: collapse; min-width: 720px; } table { width: 100%; border-collapse: collapse; min-width: 720px; }
table.narrow { min-width: 520px; } table.narrow { min-width: 520px; }
th { text-align: left; font-size: 12px; font-weight: 600; color: var(--ink-2); padding: 10px 12px; border-bottom: 1px solid var(--line); white-space: nowrap; } th { text-align: left; font-size: 12px; font-weight: 600; color: var(--ink-2); padding: 10px 12px; border-bottom: 1px solid var(--line); white-space: nowrap; }
th .sort { display: inline-flex; align-items: center; gap: 4px; background: none; border: 0; padding: 0; font: inherit; color: inherit; cursor: pointer; }
th .sort:hover, th .sort.on { color: var(--ink); }
th .sort .arrow { font-size: 11px; opacity: 0.35; }
th .sort:hover .arrow, th .sort.on .arrow { opacity: 1; }
td { padding: 12px; border-bottom: 1px solid var(--line-2); vertical-align: middle; } td { padding: 12px; border-bottom: 1px solid var(--line-2); vertical-align: middle; }
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, a.pname:focus-visible { color: var(--ink); text-decoration: underline; text-underline-offset: 3px; }
.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 */
@@ -159,10 +179,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; }
@@ -264,7 +324,6 @@ dialog::backdrop { background: rgba(22, 23, 26, .55); }
.linkbtn { background: none; border: 0; padding: 4px; font: inherit; font-size: 13px; color: #9cc3f5; text-decoration: underline; cursor: pointer; align-self: center; } .linkbtn { background: none; border: 0; padding: 4px; font: inherit; font-size: 13px; color: #9cc3f5; text-decoration: underline; cursor: pointer; align-self: center; }
.linkbtn:hover { color: #fff; } .linkbtn:hover { color: #fff; }
.loginform .err-text:empty { display: none; } .loginform .err-text:empty { display: none; }
.loginfoot { margin: 0; font-family: var(--mono); font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase; color: #8d8e93; }
.err-text { color: var(--bad-ink); font-size: 13px; margin: 0; } .err-text { color: var(--bad-ink); font-size: 13px; margin: 0; }
/* setup link page (setup.html): same dark look as the login page */ /* setup link page (setup.html): same dark look as the login page */
.setuppage { min-height: 100vh; display: flex; justify-content: center; padding: 48px 16px; background: var(--ink); color: #f4f4f1; font-size: 15px; } .setuppage { min-height: 100vh; display: flex; justify-content: center; padding: 48px 16px; background: var(--ink); color: #f4f4f1; font-size: 15px; }
@@ -296,3 +355,26 @@ dialog::backdrop { background: rgba(22, 23, 26, .55); }
.steps .btn.primary { background: #f4f4f1; border-color: #f4f4f1; color: var(--ink); font-weight: 600; } .steps .btn.primary { background: #f4f4f1; border-color: #f4f4f1; color: var(--ink); font-weight: 600; }
.steps .qr { width: 100%; height: auto; max-width: 280px; align-self: center; } .steps .qr { width: 100%; height: auto; max-width: 280px; align-self: center; }
.loading-page { padding: 40px; color: var(--ink-3); } .loading-page { padding: 40px; color: var(--ink-3); }
/* two-step sign-in */
.loginalt { width: 100%; display: flex; flex-direction: column; gap: 14px; margin-top: -24px; }
.loginalt .or, .loginform .or { display: flex; align-items: center; gap: 10px; color: #8d8e93; font-size: 12px; }
.loginalt .or::before, .loginalt .or::after { content: ""; flex: 1; height: 1px; background: #2c2d32; }
.loginpage .btn.altbtn { min-height: 44px; width: 100%; font-size: 15px; font-weight: 500; background: none; border-color: #3a3b41; color: #f4f4f1; margin-top: 0; }
.loginpage .btn.altbtn:hover { background: #222328; border-color: #55565c; color: #fff; }
.loginlinks { display: flex; flex-direction: column; align-items: center; gap: 2px; margin-top: 6px; }
.loginform .codeinput { text-align: center; font-size: 20px; letter-spacing: 0.2em; }
.mfalist { display: flex; flex-direction: column; }
.mfarow { display: flex; align-items: center; gap: 8px; padding: 12px 0; border-top: 1px solid var(--line-2); }
.mfarow:first-child { border-top: 0; padding-top: 0; }
.mfarow .grow { flex: 1; min-width: 0; }
.dlg .secret { font-size: 15px; letter-spacing: 0.04em; overflow-wrap: anywhere; }
.dlg .codes { columns: 2; font-size: 15px; line-height: 1.8; }
/* settings groups; the log page */
.group { margin-top: 20px; display: flex; flex-direction: column; gap: 2px; }
.group h2 { margin: 0; font-size: 19px; font-weight: 600; }
.group p { margin: 0; font-size: 13px; color: var(--ink-2); }
.card h3 { margin: 0; font-size: 16px; font-weight: 600; }
.saves { font-size: 12px; color: var(--ink-3); }
pre.log.tall { max-height: calc(100vh - 260px); min-height: 420px; }
+774 -89
View File
File diff suppressed because it is too large Load Diff
+85 -28
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
} }
@@ -102,6 +114,9 @@ type principal struct {
RemoteIP string RemoteIP string
// MustChangePassword blocks everything but changing the password. // MustChangePassword blocks everything but changing the password.
MustChangePassword bool MustChangePassword bool
// MFASetupRequired blocks everything but setting up two-step sign-in,
// when it is required and the user has none.
MFASetupRequired bool
Session *sessionInfo // nil for API tokens Session *sessionInfo // nil for API tokens
} }
@@ -137,36 +152,70 @@ 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
} }
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 {
return &Auth{store: s, sessions: map[string]*session{}, used: map[string]tokenUse{}, logins: map[string]tokenUse{}, fails: map[string]*failState{}} return &Auth{store: s, sessions: map[string]*session{}, used: map[string]tokenUse{}, logins: map[string]tokenUse{}, fails: map[string]*failState{}, mfa: newMFAState()}
} }
func cookieName() string { return appName + "_session" } func cookieName() string { return appName + "_session" }
var errLocked = errors.New("too many failed attempts, try again later") var (
errLocked = errors.New("too many failed attempts, try again later")
errBusy = errors.New("too many sign-ins at once, try again in a moment")
)
// Login checks the credentials and returns a new session id. // lockKey is what failed sign-ins are counted by: the IPv4 address, or the
func (a *Auth) Login(user, pw, ip string) (string, error) { // /64 network of an IPv6 address, since one device can pick any address in
a.mu.Lock() // its /64.
f := a.fails[ip] func lockKey(ip string) string {
if f != nil && time.Now().Before(f.until) { a, err := netip.ParseAddr(ip)
a.mu.Unlock() if err != nil || a.Unmap().Is4() {
return "", errLocked return ip
}
p, _ := a.Prefix(64)
return p.String()
} }
a.mu.Unlock()
// Login checks the credentials and returns a new session id, or, for a user
// with two-step sign-in, a ticket for the second step.
func (a *Auth) Login(user, pw, ip string) (sessionID, ticket string, err error) {
cfg := a.store.Get() 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))
@@ -180,20 +229,15 @@ func (a *Auth) Login(user, pw, ip string) (string, error) {
a.mu.Lock() a.mu.Lock()
defer a.mu.Unlock() defer a.mu.Unlock()
if !okUser || !okPw { if !okUser || !okPw {
if f == nil { return "", "", errors.New("wrong username or password")
f = &failState{}
a.fails[ip] = f
} }
f.count++ undo()
if f.count >= maxFailures { if u.hasMFA() {
f.count = 0 return "", a.newTicketLocked(u, ip), nil
f.until = time.Now().Add(lockoutTime)
} }
return "", errors.New("wrong username or password") delete(a.fails, lockKey(ip))
}
delete(a.fails, 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
} }
// NewSession replaces a session after the user changed their password; it // NewSession replaces a session after the user changed their password; it
@@ -290,7 +334,8 @@ func (a *Auth) Authenticate(r *http.Request) (*principal, bool) {
return nil, false return nil, false
} }
info := s.info info := s.info
return &principal{Name: u.Username, UserID: u.ID, Scope: "rw", IsAdmin: true, RemoteIP: ip, MustChangePassword: u.MustChangePassword, Session: &info}, true return &principal{Name: u.Username, UserID: u.ID, Scope: "rw", IsAdmin: true, RemoteIP: ip, MustChangePassword: u.MustChangePassword,
MFASetupRequired: cfg.SignIn.RequireMFA && !u.hasMFA(), Session: &info}, true
} }
func (a *Auth) TokenUse(id string) *tokenUse { func (a *Auth) TokenUse(id string) *tokenUse {
@@ -312,9 +357,21 @@ 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 {
if now.After(t.expires) {
delete(a.mfa.tickets, id)
}
}
for _, m := range []map[string]*ceremony{a.mfa.logins, a.mfa.enrolls} {
for id, c := range m {
if now.After(c.expires) {
delete(m, id)
}
} }
} }
} }
+41
View File
@@ -32,6 +32,31 @@ type Config struct {
Peers []Peer `json:"peers"` Peers []Peer `json:"peers"`
Log LogConfig `json:"log"` Log LogConfig `json:"log"`
Stats StatsConfig `json:"stats"` Stats StatsConfig `json:"stats"`
Decoy DecoyConfig `json:"decoy"`
SignIn SignInConfig `json:"signin"`
Updates UpdatesConfig `json:"updates"`
}
// UpdatesConfig sets the daily check for a newer release.
type UpdatesConfig struct {
Check *bool `json:"check,omitempty"` // default on
Source string `json:"source"` // gitea | github, see updateSources
}
func (c UpdatesConfig) checkEnabled() bool { return c.Check == nil || *c.Check }
// SignInConfig holds the rules for signing in to the web interface.
type SignInConfig struct {
// RequireMFA sends users without two-step sign-in to set it up before
// they can do anything else. API tokens are not affected.
RequireMFA bool `json:"requireMfa"`
}
// DecoyConfig replaces the web interface with a stock web server page.
// The API keeps working, so the iOS app can turn it off again.
type DecoyConfig struct {
Enabled bool `json:"enabled"`
Page string `json:"page"` // nginx | apache | soon
} }
// StatsConfig sets how long traffic history is kept in stats.json. // StatsConfig sets how long traffic history is kept in stats.json.
@@ -84,6 +109,7 @@ type User struct {
// the user can do nothing else until they pick their own. // the user can do nothing else until they pick their own.
MustChangePassword bool `json:"mustChangePassword,omitempty"` MustChangePassword bool `json:"mustChangePassword,omitempty"`
Created time.Time `json:"created"` Created time.Time `json:"created"`
MFA *UserMFA `json:"mfa,omitempty"` // two-step sign-in, nil when never set up
} }
type APIToken struct { type APIToken struct {
@@ -187,6 +213,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
@@ -223,6 +252,12 @@ func (c *Config) applyDefaults() {
if c.Stats.DailyDays == 0 { if c.Stats.DailyDays == 0 {
c.Stats.DailyDays = 400 c.Stats.DailyDays = 400
} }
if c.Decoy.Page == "" {
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{}
} }
@@ -356,6 +391,12 @@ func (c *Config) validate() error {
} else if st.DailyDays < minDailyDays || st.DailyDays > maxDailyDays { } else if st.DailyDays < minDailyDays || st.DailyDays > maxDailyDays {
return fmt.Errorf("daily traffic history must be %d–%d days", minDailyDays, maxDailyDays) return fmt.Errorf("daily traffic history must be %d–%d days", minDailyDays, maxDailyDays)
} }
if _, ok := decoyPages[c.Decoy.Page]; !ok {
return fmt.Errorf("unknown decoy page %q", c.Decoy.Page)
}
if _, ok := updateSources[c.Updates.Source]; !ok {
return fmt.Errorf("update source must be gitea or github")
}
switch c.Web.TLS.Mode { switch c.Web.TLS.Mode {
case "acme": case "acme":
if c.Web.TLS.Domain == "" { if c.Web.TLS.Domain == "" {
+580
View File
@@ -0,0 +1,580 @@
package main
import (
"html"
"net"
"net/http"
"strings"
)
// A decoy answers every web path like a freshly installed web server: the
// front page is its stock welcome page and everything else is its stock
// error page. Only /api/v1 and live setup links get past it.
type decoyPage struct {
server string // Server header, "" for none
index func(host string) string // the front page
indexCode int // status of the front page, 0 for 200
error func(code int, r *http.Request) string // body for 404 and 405
}
var decoyPages = map[string]decoyPage{
"nginx": {server: nginxServer, index: func(string) string { return nginxIndex }, error: nginxError},
"apache": {server: apacheServer, index: func(string) string { return apacheIndex }, error: apacheError},
"soon": {index: soonIndex, error: soonError},
// Generic pages that name no server software.
"blank": {index: func(string) string { return "" }, error: func(int, *http.Request) string { return "" }},
"forbidden": {index: func(string) string { return forbiddenIndex }, indexCode: http.StatusForbidden, error: soonError},
"private": {index: func(string) string { return privateIndex }, error: soonError},
}
// serveDecoy writes the decoy's answer for r. It drops the headers the web
// interface adds, since a stock server sends none of them.
func serveDecoy(w http.ResponseWriter, r *http.Request, name string) {
d, ok := decoyPages[name]
if !ok {
d = decoyPages["nginx"]
}
h := w.Header()
for _, k := range []string{"Content-Security-Policy", "X-Content-Type-Options", "Referrer-Policy", "X-Frame-Options", "Strict-Transport-Security", "Cache-Control"} {
h.Del(k)
}
if d.server != "" {
h.Set("Server", d.server)
}
h.Set("Content-Type", "text/html")
code, body := http.StatusOK, ""
switch {
case r.Method != http.MethodGet && r.Method != http.MethodHead:
code, body = http.StatusMethodNotAllowed, d.error(http.StatusMethodNotAllowed, r)
case r.URL.Path == "/" || r.URL.Path == "/index.html":
body = d.index(hostOnly(r.Host))
if d.indexCode != 0 {
code = d.indexCode
}
default:
code, body = http.StatusNotFound, d.error(http.StatusNotFound, r)
}
w.WriteHeader(code)
if r.Method != http.MethodHead {
_, _ = w.Write([]byte(body))
}
}
func hostOnly(hostport string) string {
if h, _, err := net.SplitHostPort(hostport); err == nil {
return h
}
return hostport
}
func hostPort(r *http.Request) string {
if _, p, err := net.SplitHostPort(r.Host); err == nil {
return p
}
if r.TLS != nil {
return "443"
}
return "80"
}
const nginxServer = "nginx/1.24.0 (Ubuntu)"
const nginxIndex = `<!DOCTYPE html>
<html>
<head>
<title>Welcome to nginx!</title>
<style>
html { color-scheme: light dark; }
body { width: 35em; margin: 0 auto;
font-family: Tahoma, Verdana, Arial, sans-serif; }
</style>
</head>
<body>
<h1>Welcome to nginx!</h1>
<p>If you see this page, the nginx web server is successfully installed and
working. Further configuration is required.</p>
<p>For online documentation and support please refer to
<a href="http://nginx.org/">nginx.org</a>.<br/>
Commercial support is available at
<a href="http://nginx.com/">nginx.com</a>.</p>
<p><em>Thank you for using nginx.</em></p>
</body>
</html>
`
func nginxError(code int, _ *http.Request) string {
status := statusLine(code)
return "<html>\r\n<head><title>" + status + "</title></head>\r\n<body>\r\n<center><h1>" + status +
"</h1></center>\r\n<hr><center>" + nginxServer + "</center>\r\n</body>\r\n</html>\r\n"
}
const apacheServer = "Apache/2.4.58 (Ubuntu)"
func apacheError(code int, r *http.Request) string {
msg := "<p>The requested URL was not found on this server.</p>"
if code == http.StatusMethodNotAllowed {
msg = "<p>The requested method " + html.EscapeString(r.Method) + " is not allowed for this URL.</p>"
}
return "<!DOCTYPE HTML PUBLIC \"-//IETF//DTD HTML 2.0//EN\">\n<html><head>\n<title>" + statusLine(code) +
"</title>\n</head><body>\n<h1>" + http.StatusText(code) + "</h1>\n" + msg + "\n<hr>\n<address>" + apacheServer +
" Server at " + html.EscapeString(hostOnly(r.Host)) + " Port " + hostPort(r) + "</address>\n</body></html>\n"
}
func soonIndex(host string) string {
return strings.ReplaceAll(soonTemplate, "{{host}}", html.EscapeString(host))
}
func soonError(code int, _ *http.Request) string {
status := statusLine(code)
return "<!DOCTYPE html>\n<html>\n<head><title>" + status + "</title></head>\n<body>\n<h1>" + status + "</h1>\n</body>\n</html>\n"
}
func statusLine(code int) string {
if code == http.StatusMethodNotAllowed {
return "405 Not Allowed" // nginx's wording, also fine for the others
}
return "404 Not Found"
}
const soonTemplate = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Coming soon</title>
<style>
html, body { height: 100%; margin: 0; }
body { display: flex; align-items: center; justify-content: center; background: #f7f7f7; color: #444;
font-family: Helvetica, Arial, sans-serif; text-align: center; }
h1 { font-size: 28px; font-weight: 600; color: #222; margin: 0 0 10px; }
p { margin: 0 0 6px; }
.host { font-size: 13px; color: #888; margin-top: 18px; }
</style>
</head>
<body>
<main>
<h1>Coming soon</h1>
<p>This site is under construction.</p>
<p class="host">{{host}}</p>
</main>
</body>
</html>
`
const apacheIndex = `<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<title>Apache2 Ubuntu Default Page: It works</title>
<style type="text/css" media="screen">
* {
margin: 0px 0px 0px 0px;
padding: 0px 0px 0px 0px;
}
body, html {
padding: 3px 3px 3px 3px;
background-color: #D8DBE2;
font-family: Ubuntu, Verdana, sans-serif;
font-size: 11pt;
text-align: center;
}
div.main_page {
position: relative;
display: table;
width: 800px;
margin-bottom: 3px;
margin-left: auto;
margin-right: auto;
padding: 0px 0px 0px 0px;
border-width: 2px;
border-color: #212738;
border-style: solid;
background-color: #FFFFFF;
text-align: center;
}
div.page_header {
height: 180px;
width: 100%;
background-color: #F5F6F7;
}
div.page_header span {
margin: 15px 0px 0px 50px;
font-size: 180%;
font-weight: bold;
}
div.page_header img {
margin: 3px 0px 0px 40px;
border: 0px 0px 0px;
}
div.banner {
padding: 9px 6px 9px 6px;
background-color: #E9510E;
color: #FFFFFF;
font-weight: bold;
font-size: 112%;
text-align: center;
position: absolute;
left: 40%;
bottom: 30px;
width: 20%;
}
div.table_of_contents {
clear: left;
min-width: 200px;
margin: 3px 3px 3px 3px;
background-color: #FFFFFF;
text-align: left;
}
div.table_of_contents_item {
clear: left;
width: 100%;
margin: 4px 0px 0px 0px;
background-color: #FFFFFF;
color: #000000;
text-align: left;
}
div.table_of_contents_item a {
margin: 6px 0px 0px 6px;
}
div.content_section {
margin: 3px 3px 3px 3px;
background-color: #FFFFFF;
text-align: left;
}
div.content_section_text {
padding: 4px 8px 4px 8px;
color: #000000;
font-size: 100%;
}
div.content_section_text pre {
margin: 8px 0px 8px 0px;
padding: 8px 8px 8px 8px;
border-width: 1px;
border-style: dotted;
border-color: #000000;
background-color: #F5F6F7;
font-style: italic;
}
div.content_section_text p {
margin-bottom: 6px;
}
div.content_section_text ul, div.content_section_text li {
padding: 4px 8px 4px 16px;
}
div.section_header {
padding: 3px 6px 3px 6px;
background-color: #8E9CB2;
color: #FFFFFF;
font-weight: bold;
font-size: 112%;
text-align: center;
}
div.section_header_red {
background-color: #CD214F;
}
div.section_header_grey {
background-color: #9F9386;
}
.floating_element {
position: relative;
float: left;
}
div.table_of_contents_item a,
div.content_section_text a {
text-decoration: none;
font-weight: bold;
}
div.table_of_contents_item a:link,
div.table_of_contents_item a:visited,
div.table_of_contents_item a:active {
color: #000000;
}
div.table_of_contents_item a:hover {
background-color: #000000;
color: #FFFFFF;
}
div.content_section_text a:link,
div.content_section_text a:visited,
div.content_section_text a:active {
background-color: #DCDFE6;
color: #000000;
}
div.content_section_text a:hover {
background-color: #000000;
color: #DCDFE6;
}
div.validator {
}
</style>
</head>
<body>
<div class="main_page">
<div class="page_header floating_element">
<span class="floating_element">
Apache2 Default Page
</span>
</div>
<!-- <div class="table_of_contents floating_element">
<div class="section_header section_header_grey">
TABLE OF CONTENTS
</div>
<div class="table_of_contents_item floating_element">
<a href="#about">About</a>
</div>
<div class="table_of_contents_item floating_element">
<a href="#changes">Changes</a>
</div>
<div class="table_of_contents_item floating_element">
<a href="#scope">Scope</a>
</div>
<div class="table_of_contents_item floating_element">
<a href="#files">Config files</a>
</div>
</div>
-->
<div class="content_section floating_element">
<div class="section_header section_header_red">
<div id="about"></div>
It works!
</div>
<div class="content_section_text">
<p>
This is the default welcome page used to test the correct
operation of the Apache2 server after installation on Ubuntu systems.
It is based on the equivalent page on Debian, from which the Ubuntu Apache
packaging is derived.
If you can read this page, it means that the Apache HTTP server installed at
this site is working properly. You should <b>replace this file</b> (located at
<tt>/var/www/html/index.html</tt>) before continuing to operate your HTTP server.
</p>
<p>
If you are a normal user of this web site and don't know what this page is
about, this probably means that the site is currently unavailable due to
maintenance.
If the problem persists, please contact the site's administrator.
</p>
</div>
<div class="section_header">
<div id="changes"></div>
Configuration Overview
</div>
<div class="content_section_text">
<p>
Ubuntu's Apache2 default configuration is different from the
upstream default configuration, and split into several files optimized for
interaction with Ubuntu tools. The configuration system is
<b>fully documented in
/usr/share/doc/apache2/README.Debian.gz</b>. Refer to this for the full
documentation. Documentation for the web server itself can be
found by accessing the <a href="/manual">manual</a> if the <tt>apache2-doc</tt>
package was installed on this server.
</p>
<p>
The configuration layout for an Apache2 web server installation on Ubuntu systems is as follows:
</p>
<pre>
/etc/apache2/
|-- apache2.conf
| ` + "`" + `-- ports.conf
|-- mods-enabled
| |-- *.load
| ` + "`" + `-- *.conf
|-- conf-enabled
| ` + "`" + `-- *.conf
|-- sites-enabled
| ` + "`" + `-- *.conf
</pre>
<ul>
<li>
<tt>apache2.conf</tt> is the main configuration
file. It puts the pieces together by including all remaining configuration
files when starting up the web server.
</li>
<li>
<tt>ports.conf</tt> is always included from the
main configuration file. It is used to determine the listening ports for
incoming connections, and this file can be customized anytime.
</li>
<li>
Configuration files in the <tt>mods-enabled/</tt>,
<tt>conf-enabled/</tt> and <tt>sites-enabled/</tt> directories contain
particular configuration snippets which manage modules, global configuration
fragments, or virtual host configurations, respectively.
</li>
<li>
They are activated by symlinking available
configuration files from their respective
*-available/ counterparts. These should be managed
by using our helpers
<tt>
a2enmod,
a2dismod,
</tt>
<tt>
a2ensite,
a2dissite,
</tt>
and
<tt>
a2enconf,
a2disconf
</tt>. See their respective man pages for detailed information.
</li>
<li>
The binary is called apache2 and is managed using systemd, so to
start/stop the service use <tt>systemctl start apache2</tt> and
<tt>systemctl stop apache2</tt>, and use <tt>systemctl status apache2</tt>
and <tt>journalctl -u apache2</tt> to check status. <tt>system</tt>
and <tt>apache2ctl</tt> can also be used for service management if
desired.
<b>Calling <tt>/usr/bin/apache2</tt> directly will not work</b> with the
default configuration.
</li>
</ul>
</div>
<div class="section_header">
<div id="docroot"></div>
Document Roots
</div>
<div class="content_section_text">
<p>
By default, Ubuntu does not allow access through the web browser to
<em>any</em> file outside of those located in <tt>/var/www</tt>,
<a href="http://httpd.apache.org/docs/2.4/mod/mod_userdir.html" rel="nofollow">public_html</a>
directories (when enabled) and <tt>/usr/share</tt> (for web
applications). If your site is using a web document root
located elsewhere (such as in <tt>/srv</tt>) you may need to whitelist your
document root directory in <tt>/etc/apache2/apache2.conf</tt>.
</p>
<p>
The default Ubuntu document root is <tt>/var/www/html</tt>. You
can make your own virtual hosts under /var/www.
</p>
</div>
<div class="section_header">
<div id="bugs"></div>
Reporting Problems
</div>
<div class="content_section_text">
<p>
Please use the <tt>ubuntu-bug</tt> tool to report bugs in the
Apache2 package with Ubuntu. However, check <a
href="https://bugs.launchpad.net/ubuntu/+source/apache2"
rel="nofollow">existing bug reports</a> before reporting a new bug.
</p>
<p>
Please report bugs specific to modules (such as PHP and others)
to their respective packages, not to the web server itself.
</p>
</div>
</div>
</div>
<div class="validator">
</div>
</body>
</html>
`
const forbiddenIndex = `<!DOCTYPE html>
<html>
<head><title>403 Forbidden</title></head>
<body>
<h1>Forbidden</h1>
<p>You don't have permission to access this resource.</p>
</body>
</html>
`
const privateIndex = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Private</title>
<style>
html, body { height: 100%; margin: 0; }
body { display: flex; align-items: center; justify-content: center; background: #111; color: #999;
font-family: Georgia, serif; text-align: center; }
h1 { font-size: 28px; font-weight: normal; letter-spacing: 0.04em; color: #fff; margin: 0 0 10px; }
p { margin: 0; font-size: 15px; }
</style>
</head>
<body>
<main>
<h1>Private server</h1>
<p>Nothing to see here.</p>
</main>
</body>
</html>
`
+10
View File
@@ -3,6 +3,7 @@ module ghostwire
go 1.27.1 go 1.27.1
require ( require (
github.com/go-webauthn/webauthn v0.18.2
github.com/google/nftables v0.3.0 github.com/google/nftables v0.3.0
github.com/oschwald/maxminddb-golang v1.13.1 github.com/oschwald/maxminddb-golang v1.13.1
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
@@ -15,11 +16,20 @@ require (
) )
require ( require (
github.com/fxamacker/cbor/v2 v2.9.4 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/go-webauthn/x v0.3.1 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/google/go-cmp v0.6.0 // indirect github.com/google/go-cmp v0.6.0 // indirect
github.com/google/go-tpm v0.9.8 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mdlayher/genetlink v1.3.2 // indirect github.com/mdlayher/genetlink v1.3.2 // indirect
github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 // indirect github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 // indirect
github.com/mdlayher/socket v0.5.1 // indirect github.com/mdlayher/socket v0.5.1 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/tinylib/msgp v1.6.4 // indirect
github.com/vishvananda/netns v0.0.5 // indirect github.com/vishvananda/netns v0.0.5 // indirect
github.com/x448/float16 v0.8.4 // indirect
golang.org/x/sync v0.23.0 // indirect golang.org/x/sync v0.23.0 // indirect
golang.org/x/text v0.42.0 // indirect golang.org/x/text v0.42.0 // indirect
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173 // indirect golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173 // indirect
+28 -8
View File
@@ -1,9 +1,23 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/fxamacker/cbor/v2 v2.9.4 h1:xwjVlxEMR3S605oUlgBjKLTTeGFciYPGYCtF/35LKGo=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/fxamacker/cbor/v2 v2.9.4/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-webauthn/webauthn v0.18.2 h1:0BeftmEHU7i3Dv0VFwBtidy/ba37Vcdjvqst9EYu8Sk=
github.com/go-webauthn/webauthn v0.18.2/go.mod h1:hEXaOuLxvZ3zG9miZe3ehlyeVso9AtklXG+kTn36k+A=
github.com/go-webauthn/x v0.3.1 h1:1ff37z3XfmTTomkhlURgGizLIDyOvPgTt2t9nlzKLRo=
github.com/go-webauthn/x v0.3.1/go.mod h1:ZInxAynYXfBPvvm5gzKZ7geBlL23K71xASMgohHl/Rg=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLzH5KDR0gKc0zcktin6KSAwL7+jWKBYceddTc=
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc=
github.com/google/nftables v0.3.0 h1:bkyZ0cbpVeMHXOrtlFc8ISmfVqq5gPJukoYieyVmITg= github.com/google/nftables v0.3.0 h1:bkyZ0cbpVeMHXOrtlFc8ISmfVqq5gPJukoYieyVmITg=
github.com/google/nftables v0.3.0/go.mod h1:BCp9FsrbF1Fn/Yu6CLUc9GGZFw/+hsxfluNXXmxBfRM= github.com/google/nftables v0.3.0/go.mod h1:BCp9FsrbF1Fn/Yu6CLUc9GGZFw/+hsxfluNXXmxBfRM=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/mdlayher/genetlink v1.3.2 h1:KdrNKe+CTu+IbZnm/GVUMXSqBBLqcGpRDa0xkQy56gw= github.com/mdlayher/genetlink v1.3.2 h1:KdrNKe+CTu+IbZnm/GVUMXSqBBLqcGpRDa0xkQy56gw=
github.com/mdlayher/genetlink v1.3.2/go.mod h1:tcC3pkCrPUGIKKsCsp0B3AdaaKuHtaxoJRz3cc+528o= github.com/mdlayher/genetlink v1.3.2/go.mod h1:tcC3pkCrPUGIKKsCsp0B3AdaaKuHtaxoJRz3cc+528o=
github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 h1:A1Cq6Ysb0GM0tpKMbdCXCIfBclan4oHk1Jb+Hrejirg= github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 h1:A1Cq6Ysb0GM0tpKMbdCXCIfBclan4oHk1Jb+Hrejirg=
@@ -14,16 +28,24 @@ github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721 h1:RlZweED6sbSArvlE9
github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721/go.mod h1:Ickgr2WtCLZ2MDGd4Gr0geeCH5HybhRJbonOgQpvSxc= github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721/go.mod h1:Ickgr2WtCLZ2MDGd4Gr0geeCH5HybhRJbonOgQpvSxc=
github.com/oschwald/maxminddb-golang v1.13.1 h1:G3wwjdN9JmIK2o/ermkHM+98oX5fS+k5MbwsmL4MRQE= github.com/oschwald/maxminddb-golang v1.13.1 h1:G3wwjdN9JmIK2o/ermkHM+98oX5fS+k5MbwsmL4MRQE=
github.com/oschwald/maxminddb-golang v1.13.1/go.mod h1:K4pgV9N/GcK694KSTmVSDTODk4IsCNThNdTmnaBZ/F8= github.com/oschwald/maxminddb-golang v1.13.1/go.mod h1:K4pgV9N/GcK694KSTmVSDTODk4IsCNThNdTmnaBZ/F8=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0= github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0=
github.com/vishvananda/netlink v1.3.1/go.mod h1:ARtKouGSTGchR8aMwmkzC0qiNPrrWO5JS/XMVl45+b4= github.com/vishvananda/netlink v1.3.1/go.mod h1:ARtKouGSTGchR8aMwmkzC0qiNPrrWO5JS/XMVl45+b4=
github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY= github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY=
github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
@@ -42,5 +64,3 @@ golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173 h1:/jFs0duh4rdb8uI
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173/go.mod h1:tkCQ4FQXmpAgYVh++1cq16/dH4QJtmvpRv19DWGAHSA= golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173/go.mod h1:tkCQ4FQXmpAgYVh++1cq16/dH4QJtmvpRv19DWGAHSA=
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 h1:3GDAcqdIg1ozBNLgPy4SLT84nfcBjr6rhGtXYtrkWLU= golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 h1:3GDAcqdIg1ozBNLgPy4SLT84nfcBjr6rhGtXYtrkWLU=
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10/go.mod h1:T97yPqesLiNrOYxkwmhMI0ZIlJDm+p0PMR8eRVeR5tQ= golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10/go.mod h1:T97yPqesLiNrOYxkwmhMI0ZIlJDm+p0PMR8eRVeR5tQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+11
View File
@@ -3,6 +3,8 @@ package main
import ( import (
"log/slog" "log/slog"
"net/netip" "net/netip"
"os"
"strings"
"sync" "sync"
"time" "time"
) )
@@ -38,6 +40,15 @@ type Kernel interface {
Close() error Close() error
} }
// 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 {
+14 -9
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"
@@ -318,14 +318,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 +333,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)
+3 -2
View File
@@ -222,15 +222,16 @@ func run(configPath string) error {
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, 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,
} }
var wg sync.WaitGroup var wg sync.WaitGroup
wg.Add(4) wg.Add(5)
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(); 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()
+412 -3
View File
@@ -12,7 +12,9 @@ import (
"net/netip" "net/netip"
"os" "os"
"path/filepath" "path/filepath"
"slices"
"strings" "strings"
"sync"
"testing" "testing"
"time" "time"
) )
@@ -76,6 +78,7 @@ 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" },
} { } {
cc := c.clone() cc := c.clone()
mutate(cc) mutate(cc)
@@ -311,8 +314,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,6 +336,46 @@ 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 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)
uid := call("GET", "/auth/me", nil, 200)["id"].(string)
bearer("PATCH", "/settings", 200, map[string]any{"log": store.Get().Log})
bearer("PATCH", "/settings", 403, map[string]any{"signin": map[string]bool{"requireMfa": false}})
bearer("GET", "/users", 403)
bearer("POST", "/users", 403, map[string]any{"username": "eve", "password": "correct horse battery"})
bearer("POST", "/users/"+uid+"/reset-password", 403, map[string]any{"password": "correct horse battery"})
bearer("POST", "/users/"+uid+"/reset-mfa", 403)
bearer("POST", "/auth/password", 403, map[string]string{"current": "x", "new": "y"})
bearer("GET", "/tokens", 403)
bearer("POST", "/tokens", 403, map[string]string{"name": "more", "scope": "rw"})
bearer("DELETE", "/tokens/"+tok["id"].(string), 403)
bearer("GET", "/backup", 403)
bearer("GET", "/update-backups", 403)
bearer("DELETE", "/update-backups", 403)
// Config copies made by update: listed newest first, removed one by
// one or all at once; nothing else in the folder can be removed.
for i, v := range []string{"v0.3.2", "v0.4.0"} {
f := filepath.Join(dir, "config.json.bak-"+v)
_ = os.WriteFile(f, []byte("{}"), 0o600)
_ = os.Chtimes(f, time.Now(), time.Now().Add(time.Duration(i-2)*time.Hour))
}
list := call("GET", "/update-backups", nil, 200)["backups"].([]any)
if len(list) != 2 || list[0].(map[string]any)["version"] != "v0.4.0" {
t.Fatalf("update backups: %v", list)
}
call("DELETE", "/update-backups/config.json", nil, 400)
call("DELETE", "/update-backups/stats.json", nil, 400)
call("DELETE", "/update-backups/config.json.bak-v9.9.9", nil, 400)
call("DELETE", "/update-backups/config.json.bak-v0.3.2", nil, 200)
if r := call("DELETE", "/update-backups", nil, 200); r["removed"] != float64(1) {
t.Fatalf("remove all: %v", r)
}
if _, err := os.Stat(filepath.Join(dir, "config.json")); err != nil {
t.Fatal("config.json is gone:", err)
}
call("DELETE", "/peers/"+id, nil, 200) call("DELETE", "/peers/"+id, nil, 200)
if len(store.Get().Peers) != 0 { if len(store.Get().Peers) != 0 {
t.Fatal("peer not deleted") t.Fatal("peer not deleted")
@@ -370,6 +418,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 {
@@ -875,5 +1009,280 @@ 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
// and live setup links alone.
func TestDecoy(t *testing.T) {
dir := t.TempDir()
store, err := openStore(filepath.Join(dir, "config.json"))
if err != nil {
t.Fatal(err)
}
if store.Get().Decoy.Page != "nginx" {
t.Fatalf("default decoy page %q", store.Get().Decoy.Page)
}
k := &fakeKernel{}
st, _ := openStats(filepath.Join(dir, "stats.json"), store, k)
app := &App{store: store, kernel: k, recon: newReconciler(k, store), stats: st, auth: newAuth(store),
tls: &webTLS{}, logPath: filepath.Join(dir, "log.jsonl"), started: time.Now(), shutdown: func() {}}
srv := httptest.NewServer(app.routes())
defer srv.Close()
get := func(path string, want int) (string, http.Header) {
t.Helper()
resp, err := http.Get(srv.URL + path)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
if resp.StatusCode != want {
t.Fatalf("GET %s: status %d, want %d", path, resp.StatusCode, want)
}
return string(b), resp.Header
}
set := func(fn func(c *Config)) {
if err := store.Update(func(c *Config) error { fn(c); return nil }); err != nil {
t.Fatal(err)
}
}
b, _ := get("/", 200)
if !strings.Contains(b, `"/app.js?v=`+assetHash["app.js"]+`"`) || !strings.Contains(b, `"/app.css?v=`+assetHash["app.css"]+`"`) {
t.Fatalf("web interface not served with fingerprinted files: %q", b)
}
if _, h := get("/app.js?v="+assetHash["app.js"], 200); !strings.Contains(h.Get("Cache-Control"), "immutable") {
t.Fatalf("fingerprinted app.js: %v", h)
}
if _, h := get("/app.js?v=old", 200); h.Get("Cache-Control") != "no-cache" {
t.Fatalf("stale app.js cached: %v", h)
}
set(func(c *Config) {
v4 := netip.MustParsePrefix(c.Server.IPv4)
c.Peers = append(c.Peers, Peer{ID: "p1", Name: "phone", IPv4: v4.Addr().Next().Next().Next().String(), Setup: &SetupLink{Token: "live-token", Expires: time.Now().Add(time.Hour)}})
c.Decoy.Enabled = true
})
b, h := get("/", 200)
if !strings.Contains(b, "Welcome to nginx!") || h.Get("Server") != nginxServer || h.Get("Content-Security-Policy") != "" {
t.Fatalf("nginx decoy: %q %v", b, h)
}
for _, p := range []string{"/app.js", "/app.css", "/favicon.svg", "/ShipporiMinchoB1-ExtraBold.woff2", "/setup/wrong", "/setup/wrong/app.css", "/setup/live-token/app.js"} {
if b, _ := get(p, 404); strings.Contains(b, "GHOSTWIRE") || !strings.Contains(b, "404 Not Found") {
t.Fatalf("%s leaks: %q", p, b)
}
}
if b, _ := get("/setup/live-token", 200); !strings.Contains(b, `src="/setup/live-token/setup.js?v=`+assetHash["setup.js"]+`"`) {
t.Fatalf("setup page files not under the link: %q", b)
}
get("/setup/live-token/app.css", 200)
get("/api/v1/setup/live-token", 200)
get("/api/v1/status", 401)
set(func(c *Config) { c.Decoy.Page = "apache" })
if b, _ := get("/nope", 404); !strings.Contains(b, "Apache/2.4.58 (Ubuntu) Server at 127.0.0.1 Port") {
t.Fatalf("apache 404: %q", b)
}
set(func(c *Config) { c.Decoy.Page = "soon" })
if b, h := get("/", 200); !strings.Contains(b, "<p class=\"host\">127.0.0.1</p>") || h.Get("Server") != "" {
t.Fatalf("soon decoy: %q", b)
}
set(func(c *Config) { c.Decoy.Page = "blank" })
if b, _ := get("/", 200); b != "" {
t.Fatalf("blank decoy: %q", b)
}
if b, _ := get("/app.js", 404); b != "" {
t.Fatalf("blank 404: %q", b)
}
set(func(c *Config) { c.Decoy.Page = "forbidden" })
if b, _ := get("/", 403); !strings.Contains(b, "Forbidden") {
t.Fatalf("forbidden decoy: %q", b)
}
set(func(c *Config) { c.Decoy.Page = "private" })
if b, _ := get("/", 200); !strings.Contains(b, "Private server") {
t.Fatalf("private decoy: %q", b)
}
if err := store.Update(func(c *Config) error { c.Decoy.Page = "iis"; return nil }); err == nil {
t.Fatal("unknown decoy page accepted")
}
}
func TestTOTPCode(t *testing.T) {
// RFC 6238, appendix B (SHA-1), cut to 6 digits.
key := []byte("12345678901234567890")
for _, c := range []struct {
unix int64
want string
}{{59, "287082"}, {1111111109, "081804"}, {1234567890, "005924"}, {2000000000, "279037"}} {
if got := totpCode(key, uint64(c.unix/30)); got != c.want {
t.Errorf("time %d: %s, want %s", c.unix, got, c.want)
}
}
secret := b32.EncodeToString(key)
now := time.Unix(1111111109, 0)
if _, ok := totpMatch(secret, "081 804", now); !ok {
t.Error("code with a space refused")
}
if _, ok := totpMatch(secret, "081804", now.Add(90*time.Second)); ok {
t.Error("code three steps late accepted")
}
}
// TestMFA signs in with an authenticator code and a recovery code, and
// checks the "require" switch and the admin reset.
func TestMFA(t *testing.T) {
dir := t.TempDir()
store, err := openStore(filepath.Join(dir, "config.json"))
if err != nil {
t.Fatal(err)
}
hash, _ := hashPassword("a long test password")
_ = store.Update(func(c *Config) error { c.Users[0].PasswordHash = hash; return nil })
k := &fakeKernel{}
st, _ := openStats(filepath.Join(dir, "stats.json"), store, k)
app := &App{store: store, kernel: k, recon: newReconciler(k, store), stats: st, auth: newAuth(store),
tls: &webTLS{}, logPath: filepath.Join(dir, "log.jsonl"), started: time.Now(), shutdown: func() {}}
srv := httptest.NewServer(app.routes())
defer srv.Close()
client := func() func(method, path string, body any, want int) map[string]any {
jar, _ := cookiejar.New(nil)
cl := &http.Client{Jar: jar}
return func(method, path string, body any, want int) map[string]any {
t.Helper()
var rd io.Reader
if body != nil {
b, _ := json.Marshal(body)
rd = bytes.NewReader(b)
}
req, _ := http.NewRequest(method, srv.URL+"/api/v1"+path, rd)
req.Header.Set("Content-Type", "application/json")
resp, err := cl.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var out map[string]any
_ = json.NewDecoder(resp.Body).Decode(&out)
if resp.StatusCode != want {
t.Fatalf("%s %s: status %d, want %d: %v", method, path, resp.StatusCode, want, out)
}
return out
}
}
login := map[string]string{"username": "admin", "password": "a long test password"}
adm := client()
adm("POST", "/auth/login", login, 200)
if o := adm("GET", "/auth/options", nil, 200); o["passkeys"] != false {
t.Fatalf("passkeys offered on an IP address: %v", o)
}
adm("POST", "/auth/mfa/keys/begin", nil, 400)
// Turn on the authenticator app; the first method brings recovery codes.
setup := adm("POST", "/auth/mfa/totp/setup", nil, 200)
secret := setup["secret"].(string)
if !strings.HasPrefix(setup["uri"].(string), "otpauth://totp/") || setup["qr"] == "" {
t.Fatalf("setup: %v", setup)
}
adm("POST", "/auth/mfa/totp/confirm", map[string]string{"code": "000000"}, 400)
key, _ := b32.DecodeString(secret)
code := func(offset int) string { return totpCode(key, uint64(time.Now().Unix()/30)+uint64(offset)) }
conf := adm("POST", "/auth/mfa/totp/confirm", map[string]string{"code": code(0)}, 200)
codes := conf["recoveryCodes"].([]any)
if len(codes) != recoveryCount {
t.Fatalf("recovery codes: %v", conf)
}
if s := adm("GET", "/auth/mfa", nil, 200); s["totp"] != true || s["recoveryLeft"] != float64(recoveryCount) {
t.Fatalf("status: %v", s)
}
// A password alone now gives a ticket, not a session.
c := client()
r := c("POST", "/auth/login", login, 200)
ticket, _ := r["ticket"].(string)
if r["mfa"] != true || ticket == "" {
t.Fatalf("login without second step: %v", r)
}
c("GET", "/peers", nil, 401)
c("POST", "/auth/login/totp", map[string]string{"ticket": ticket, "code": "123456"}, 401)
c("POST", "/auth/login/totp", map[string]string{"ticket": ticket, "code": code(0)}, 401) // used during setup
c("POST", "/auth/login/totp", map[string]string{"ticket": ticket, "code": code(1)}, 200)
c("GET", "/peers", nil, 200)
// A recovery code works once.
c2 := client()
ticket = c2("POST", "/auth/login", login, 200)["ticket"].(string)
c2("POST", "/auth/login/recovery", map[string]string{"ticket": ticket, "code": strings.ToLower(codes[0].(string))}, 200)
c3 := client()
ticket = c3("POST", "/auth/login", login, 200)["ticket"].(string)
c3("POST", "/auth/login/recovery", map[string]string{"ticket": ticket, "code": codes[0].(string)}, 401)
c3("POST", "/auth/login/recovery", map[string]string{"ticket": ticket, "code": codes[1].(string)}, 200)
// Session length needs no restart; the listen address does.
web := store.Get().Web
web.SessionHours = 24
if r := adm("PATCH", "/settings", map[string]any{"web": web}, 200); r["restartRequired"] != false || store.Get().Web.SessionHours != 24 {
t.Fatalf("session length: %v", r)
}
web.Listen = "127.0.0.1:9443"
if r := adm("PATCH", "/settings", map[string]any{"web": web}, 200); r["restartRequired"] != true {
t.Fatalf("listen address: %v", r)
}
// Required for everyone: a user without it can only set it up.
adm("PATCH", "/settings", map[string]any{"signin": map[string]bool{"requireMfa": true}}, 200)
u := adm("POST", "/users", map[string]any{"username": "eve", "password": "eve's password 1", "mustChangePassword": false}, 201)["user"].(map[string]any)
e := client()
e("POST", "/auth/login", map[string]string{"username": "eve", "password": "eve's password 1"}, 200)
if me := e("GET", "/auth/me", nil, 200); me["mfaSetupRequired"] != true {
t.Fatalf("me: %v", me)
}
e("GET", "/peers", nil, 403)
e("GET", "/auth/mfa", nil, 200)
// The last method cannot be removed while it is required.
adm("DELETE", "/auth/mfa/totp", nil, 400)
// An admin resets another user's two-step sign-in, not their own.
_ = store.Update(func(c *Config) error {
_, eu := c.userByID(u["id"].(string))
eu.MFA = &UserMFA{TOTPSecret: newTOTPSecret(), RecoveryCodes: []string{"x"}}
return nil
})
if l := adm("GET", "/users", nil, 200)["users"].([]any); l[1].(map[string]any)["mfa"].(map[string]any)["totp"] != true {
t.Fatalf("users list: %v", l)
}
me := adm("GET", "/auth/me", nil, 200)
adm("POST", "/users/"+me["id"].(string)+"/reset-mfa", nil, 400)
adm("POST", "/users/"+u["id"].(string)+"/reset-mfa", nil, 200)
if _, eu := store.Get().userByID(u["id"].(string)); eu.hasMFA() || len(eu.MFA.RecoveryCodes) != 0 {
t.Fatal("reset left methods behind")
}
}
// TestDropSecurityKeys checks that security keys from v0.3.0 are deleted on
// load, and recovery codes with them when nothing else is left.
func TestDropSecurityKeys(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
cfg := `{"users": [
{"id": "a", "username": "a", "passwordHash": "x", "mfa": {"keys": [{"id": "k", "name": "YubiKey", "passkey": false}], "recoveryCodes": ["h"]}},
{"id": "b", "username": "b", "passwordHash": "x", "mfa": {"keys": [{"id": "k1", "name": "YubiKey", "passkey": false}, {"id": "k2", "name": "Mac", "passkey": true}], "recoveryCodes": ["h"]}}
]}`
if err := os.WriteFile(path, []byte(cfg), 0o600); err != nil {
t.Fatal(err)
}
store, err := openStore(path)
if err != nil {
t.Fatal(err)
}
c := store.Get()
if a := c.Users[0].MFA; len(a.Keys) != 0 || len(a.RecoveryCodes) != 0 {
t.Fatalf("user a kept %v", a)
}
if b := c.Users[1].MFA; len(b.Keys) != 1 || b.Keys[0].Name != "Mac" || len(b.RecoveryCodes) != 1 {
t.Fatalf("user b: %v", b)
}
if b, _ := os.ReadFile(path); strings.Contains(string(b), "YubiKey") {
t.Fatal("security key still in config.json")
}
} }
+940
View File
@@ -0,0 +1,940 @@
package main
import (
"bytes"
"crypto/hmac"
"crypto/rand"
"crypto/sha1"
"crypto/sha256"
"crypto/subtle"
"encoding/base32"
"encoding/binary"
"encoding/hex"
"errors"
"fmt"
"log/slog"
"net"
"net/http"
"net/url"
"slices"
"strings"
"time"
"github.com/go-webauthn/webauthn/protocol"
"github.com/go-webauthn/webauthn/webauthn"
)
// Two-step sign-in for the web interface: an authenticator app (TOTP) and
// passkeys (WebAuthn, also on a YubiKey), plus one-time recovery codes. A
// passkey signs in on its own and also serves as the second step after a
// password. API tokens never need a second step.
//
// After a correct password, a user with two-step sign-in gets a short-lived
// ticket instead of a session; the ticket and a code or key turn into the
// session. A passkey signs in on its own, without username and password.
// UserMFA is a user's two-step sign-in setup, stored in config.json.
type UserMFA struct {
TOTPSecret string `json:"totpSecret,omitempty"` // base32
TOTPAdded *time.Time `json:"totpAdded,omitempty"`
Keys []MFAKey `json:"keys,omitempty"`
RecoveryCodes []string `json:"recoveryCodes,omitempty"` // SHA-256 of the unused codes
Handle []byte `json:"handle,omitempty"` // WebAuthn user handle
}
// MFAKey is a passkey.
type MFAKey struct {
ID string `json:"id"`
Name string `json:"name"`
Passkey bool `json:"passkey"` // false only for security keys added by v0.3.0, which are deleted
Created time.Time `json:"created"`
LastUsed *time.Time `json:"lastUsed,omitempty"`
Credential webauthn.Credential `json:"credential"`
}
// dropSecurityKeys deletes the security keys v0.3.0 could add; only
// passkeys are supported. A user left without a method loses their
// recovery codes too.
func dropSecurityKeys(u *User) {
if u.MFA == nil {
return
}
u.MFA.Keys = slices.DeleteFunc(u.MFA.Keys, func(k MFAKey) bool { return !k.Passkey })
if !u.hasMFA() {
u.MFA.RecoveryCodes = nil
}
}
func (u *User) hasMFA() bool {
return u.MFA != nil && (u.MFA.TOTPSecret != "" || len(u.MFA.Keys) > 0)
}
const (
ticketTTL = 5 * time.Minute
recoveryCount = 10
totpPeriod = 30
totpDigits = 6
maxKeyName = 64
)
// --- TOTP (RFC 6238, SHA-1, 6 digits, 30 s) ---
var b32 = base32.StdEncoding.WithPadding(base32.NoPadding)
func newTOTPSecret() string {
b := make([]byte, 20)
if _, err := rand.Read(b); err != nil {
panic(err)
}
return b32.EncodeToString(b)
}
func totpCode(key []byte, counter uint64) string {
var msg [8]byte
binary.BigEndian.PutUint64(msg[:], counter)
m := hmac.New(sha1.New, key)
m.Write(msg[:])
sum := m.Sum(nil)
off := sum[len(sum)-1] & 0x0f
v := binary.BigEndian.Uint32(sum[off:off+4]) & 0x7fffffff
return fmt.Sprintf("%0*d", totpDigits, v%1_000_000)
}
// totpMatch returns the time step the code belongs to, allowing one step of
// clock drift either way.
func totpMatch(secret, code string, now time.Time) (uint64, bool) {
key, err := b32.DecodeString(strings.ToUpper(secret))
code = strings.Map(func(r rune) rune {
if r >= '0' && r <= '9' {
return r
}
return -1
}, code)
if err != nil || len(code) != totpDigits {
return 0, false
}
step := uint64(now.Unix() / totpPeriod)
for _, c := range []uint64{step, step - 1, step + 1} {
if subtle.ConstantTimeCompare([]byte(totpCode(key, c)), []byte(code)) == 1 {
return c, true
}
}
return 0, false
}
func totpURI(secret, username string) string {
label := url.PathEscape(appName + ":" + username)
return "otpauth://totp/" + label + "?secret=" + secret + "&issuer=" + url.QueryEscape(appName) + "&algorithm=SHA1&digits=6&period=30"
}
// --- recovery codes ---
const recoveryAlphabet = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"
// newRecoveryCodes returns codes to show once and their hashes to store.
func newRecoveryCodes() (codes, hashes []string) {
for range recoveryCount {
b := make([]byte, 8)
if _, err := rand.Read(b); err != nil {
panic(err)
}
var s strings.Builder
for i, x := range b {
if i == 4 {
s.WriteByte('-')
}
s.WriteByte(recoveryAlphabet[int(x)%len(recoveryAlphabet)])
}
codes = append(codes, s.String())
hashes = append(hashes, hashRecovery(s.String()))
}
return codes, hashes
}
func hashRecovery(code string) string {
norm := strings.Map(func(r rune) rune {
if r == '-' || r == ' ' {
return -1
}
return r
}, strings.ToUpper(code))
sum := sha256.Sum256([]byte(norm))
return hex.EncodeToString(sum[:])
}
// --- WebAuthn ---
// waUser adapts a User to the webauthn library.
type waUser struct{ u *User }
func (w waUser) WebAuthnID() []byte { return w.u.MFA.Handle }
func (w waUser) WebAuthnName() string { return w.u.Username }
func (w waUser) WebAuthnDisplayName() string { return w.u.Username }
func (w waUser) WebAuthnCredentials() []webauthn.Credential {
var out []webauthn.Credential
if w.u.MFA != nil {
for _, k := range w.u.MFA.Keys {
out = append(out, k.Credential)
}
}
return out
}
// keysAvailable reports whether passkeys can work on this
// address: WebAuthn needs a domain name (not an IP address) and a
// certificate the browser trusts, or localhost.
func (a *App) keysAvailable(r *http.Request) bool {
host := hostOnly(r.Host)
if host == "localhost" {
return true
}
return host != "" && net.ParseIP(host) == nil && a.store.Get().Web.TLS.Mode != "selfsigned"
}
func (a *App) webAuthn(r *http.Request) (*webauthn.WebAuthn, error) {
if !a.keysAvailable(r) {
return nil, badRequest("passkeys need a domain name with a trusted certificate")
}
scheme := "https"
if r.TLS == nil && hostOnly(r.Host) == "localhost" {
scheme = "http"
}
return webauthn.New(&webauthn.Config{
RPID: hostOnly(r.Host), RPDisplayName: appName, RPOrigins: []string{scheme + "://" + r.Host},
})
}
// --- pending ceremonies, kept in memory ---
// ticket is a sign-in waiting for its second step.
type ticket struct {
userID string
ip string
expires time.Time
fails int
key *webauthn.SessionData // a passkey challenge, once asked for
}
type ceremony struct {
userID string // "" for a passkey sign-in
data *webauthn.SessionData
expires time.Time
}
type mfaState struct {
tickets map[string]*ticket
logins map[string]*ceremony // passkey sign-ins by id
enrolls map[string]*ceremony // key registrations by user ID
totpSetup map[string]string // TOTP secrets waiting for their first code, by user ID
totpLast map[string]uint64 // last time step used per user, so a code works once
}
func newMFAState() mfaState {
return mfaState{tickets: map[string]*ticket{}, logins: map[string]*ceremony{}, enrolls: map[string]*ceremony{},
totpSetup: map[string]string{}, totpLast: map[string]uint64{}}
}
var errBadTicket = errors.New("the sign-in expired; enter your password again")
// failLocked counts a failed attempt from ip toward the lockout and returns
// a function that takes it back, for an attempt counted before it was
// checked. a.mu must be held, also when calling undo.
func (a *Auth) failLocked(ip string) (undo func()) {
key := lockKey(ip)
f := a.fails[key]
if f == nil {
f = &failState{}
a.fails[key] = f
}
f.count++
locked := f.count >= maxFailures
if locked {
f.count = 0
f.until = time.Now().Add(lockoutTime)
}
return func() {
switch {
case locked:
f.count, f.until = maxFailures-1, time.Time{}
case f.count > 0:
f.count--
}
}
}
func (a *Auth) lockedLocked(ip string) bool {
f := a.fails[lockKey(ip)]
return f != nil && time.Now().Before(f.until)
}
// newTicket starts the second step for a user whose password was right.
// a.mu must be held.
func (a *Auth) newTicketLocked(u *User, ip string) string {
id := randomString(32)
a.mfa.tickets[id] = &ticket{userID: u.ID, ip: ip, expires: time.Now().Add(ticketTTL)}
return id
}
// ticketUser returns the live ticket and its user.
func (a *Auth) ticketUser(id, ip string) (*ticket, *User, error) {
a.mu.Lock()
defer a.mu.Unlock()
if a.lockedLocked(ip) {
return nil, nil, errLocked
}
t := a.mfa.tickets[id]
if t == nil || time.Now().After(t.expires) {
delete(a.mfa.tickets, id)
return nil, nil, errBadTicket
}
_, u := a.store.Get().userByID(t.userID)
if u == nil {
delete(a.mfa.tickets, id)
return nil, nil, errBadTicket
}
return t, u, nil
}
// ticketFailed counts a wrong code; five end the ticket.
func (a *Auth) ticketFailed(id, ip string) {
a.mu.Lock()
defer a.mu.Unlock()
a.failLocked(ip)
if t := a.mfa.tickets[id]; t != nil {
t.fails++
if t.fails >= maxFailures {
delete(a.mfa.tickets, id)
}
}
}
// finishSignIn turns a passed second step into a session.
func (a *Auth) finishSignIn(u *User, ip string) string {
cfg := a.store.Get()
a.mu.Lock()
defer a.mu.Unlock()
delete(a.fails, lockKey(ip))
a.logins[u.ID] = tokenUse{At: time.Now(), IP: ip}
return a.newSessionLocked(cfg, u, sessionInfo{Started: time.Now(), IP: ip})
}
// --- sign-in endpoints (public) ---
func (a *App) signedIn(w http.ResponseWriter, r *http.Request, u *User, how string) {
ip := remoteIP(r)
a.setSessionCookie(w, r, a.auth.finishSignIn(u, ip))
slog.Info("login", "audit", true, "actor", u.Username, "remote", ip, "method", how)
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (a *App) signInFailed(w http.ResponseWriter, err error) {
code := http.StatusUnauthorized
if errors.Is(err, errLocked) {
code = http.StatusTooManyRequests
}
writeJSON(w, code, map[string]string{"error": err.Error()})
}
// signInOptions tells the sign-in page whether to offer a passkey.
func (a *App) signInOptions(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"passkeys": a.keysAvailable(r)})
}
func (a *App) loginTOTP(w http.ResponseWriter, r *http.Request) {
var in struct{ Ticket, Code string }
if err := readJSON(r, &in); err != nil {
writeErr(w, err)
return
}
ip := remoteIP(r)
_, u, err := a.auth.ticketUser(in.Ticket, ip)
if err != nil {
a.signInFailed(w, err)
return
}
if u.MFA == nil || u.MFA.TOTPSecret == "" || !a.auth.useTOTP(u.ID, u.MFA.TOTPSecret, in.Code) {
a.auth.ticketFailed(in.Ticket, ip)
slog.Warn("login failed", "user", u.Username, "remote", ip, "reason", "wrong authenticator code")
a.signInFailed(w, errors.New("wrong code"))
return
}
a.auth.dropTicket(in.Ticket)
a.signedIn(w, r, u, "totp")
}
// useTOTP checks a code and makes sure it is not used twice.
func (a *Auth) useTOTP(userID, secret, code string) bool {
step, ok := totpMatch(secret, code, time.Now())
if !ok {
return false
}
a.mu.Lock()
defer a.mu.Unlock()
if last, seen := a.mfa.totpLast[userID]; seen && step <= last {
return false
}
a.mfa.totpLast[userID] = step
return true
}
// ticketUserID returns the ticket's user without checking the lockout.
func (a *Auth) ticketUserID(id string) (string, *User) {
a.mu.Lock()
t := a.mfa.tickets[id]
a.mu.Unlock()
if t == nil {
return "", nil
}
_, u := a.store.Get().userByID(t.userID)
return t.userID, u
}
// mfaMethods lists what the second step can use: "key", "totp", "recovery".
func mfaMethods(u *User) []string {
out := []string{}
if u == nil || u.MFA == nil {
return out
}
if len(u.MFA.Keys) > 0 {
out = append(out, "key")
}
if u.MFA.TOTPSecret != "" {
out = append(out, "totp")
}
if len(u.MFA.RecoveryCodes) > 0 {
out = append(out, "recovery")
}
return out
}
func (a *Auth) dropTicket(id string) {
a.mu.Lock()
delete(a.mfa.tickets, id)
a.mu.Unlock()
}
func (a *App) loginRecovery(w http.ResponseWriter, r *http.Request) {
var in struct{ Ticket, Code string }
if err := readJSON(r, &in); err != nil {
writeErr(w, err)
return
}
ip := remoteIP(r)
_, u, err := a.auth.ticketUser(in.Ticket, ip)
if err != nil {
a.signInFailed(w, err)
return
}
h := hashRecovery(in.Code)
var left int
used := false
_ = a.store.Update(func(c *Config) error {
_, cu := c.userByID(u.ID)
if cu == nil || cu.MFA == nil {
return nil
}
for i, x := range cu.MFA.RecoveryCodes {
if subtle.ConstantTimeCompare([]byte(x), []byte(h)) == 1 {
cu.MFA.RecoveryCodes = slices.Delete(cu.MFA.RecoveryCodes, i, i+1)
used = true
break
}
}
left = len(cu.MFA.RecoveryCodes)
return nil
})
if !used {
a.auth.ticketFailed(in.Ticket, ip)
slog.Warn("login failed", "user", u.Username, "remote", ip, "reason", "wrong recovery code")
a.signInFailed(w, errors.New("wrong or used recovery code"))
return
}
a.auth.dropTicket(in.Ticket)
slog.Info("recovery code used", "audit", true, "actor", u.Username, "remote", ip, "left", left)
a.signedIn(w, r, u, "recovery code")
}
// loginKeyBegin asks for one of the user's passkeys, as the second step.
func (a *App) loginKeyBegin(w http.ResponseWriter, r *http.Request) {
var in struct{ Ticket string }
if err := readJSON(r, &in); err != nil {
writeErr(w, err)
return
}
t, u, err := a.auth.ticketUser(in.Ticket, remoteIP(r))
if err != nil {
a.signInFailed(w, err)
return
}
wa, err := a.webAuthn(r)
if err != nil {
writeErr(w, err)
return
}
if u.MFA == nil || len(u.MFA.Keys) == 0 {
writeErr(w, badRequest("no passkey is set up"))
return
}
opts, data, err := wa.BeginLogin(waUser{u}, webauthn.WithUserVerification(protocol.VerificationDiscouraged))
if err != nil {
writeErr(w, err)
return
}
a.auth.mu.Lock()
t.key = data
a.auth.mu.Unlock()
writeJSON(w, http.StatusOK, opts)
}
// loginKeyFinish checks the key's answer. The ticket is in the query, the
// body is the browser's credential.
func (a *App) loginKeyFinish(w http.ResponseWriter, r *http.Request) {
id := r.URL.Query().Get("ticket")
ip := remoteIP(r)
t, u, err := a.auth.ticketUser(id, ip)
if err != nil {
a.signInFailed(w, err)
return
}
wa, err := a.webAuthn(r)
if err != nil {
writeErr(w, err)
return
}
a.auth.mu.Lock()
data := t.key
t.key = nil
a.auth.mu.Unlock()
if data == nil {
writeErr(w, badRequest("ask for the key first"))
return
}
cred, err := wa.FinishLogin(waUser{u}, *data, r)
if err != nil {
a.auth.ticketFailed(id, ip)
slog.Warn("login failed", "user", u.Username, "remote", ip, "reason", "passkey: "+err.Error())
a.signInFailed(w, errors.New("the passkey was not accepted"))
return
}
a.keyUsed(u.ID, cred)
a.auth.dropTicket(id)
a.signedIn(w, r, u, "passkey")
}
// keyUsed stores the key's new signature counter and when it was used.
func (a *App) keyUsed(userID string, cred *webauthn.Credential) {
now := time.Now().UTC()
_ = a.store.Update(func(c *Config) error {
if _, u := c.userByID(userID); u != nil && u.MFA != nil {
for i := range u.MFA.Keys {
if k := &u.MFA.Keys[i]; bytes.Equal(k.Credential.ID, cred.ID) {
k.Credential.Authenticator = cred.Authenticator
k.Credential.Flags = cred.Flags
k.LastUsed = &now
}
}
}
return nil
})
}
// loginPasskeyBegin starts a sign-in with a passkey alone.
func (a *App) loginPasskeyBegin(w http.ResponseWriter, r *http.Request) {
wa, err := a.webAuthn(r)
if err != nil {
writeErr(w, err)
return
}
opts, data, err := wa.BeginDiscoverableLogin(webauthn.WithUserVerification(protocol.VerificationRequired))
if err != nil {
writeErr(w, err)
return
}
id := randomString(24)
a.auth.mu.Lock()
a.auth.mfa.logins[id] = &ceremony{data: data, expires: time.Now().Add(ticketTTL)}
a.auth.mu.Unlock()
writeJSON(w, http.StatusOK, map[string]any{"id": id, "options": opts})
}
func (a *App) loginPasskeyFinish(w http.ResponseWriter, r *http.Request) {
id := r.URL.Query().Get("id")
ip := remoteIP(r)
a.auth.mu.Lock()
cer := a.auth.mfa.logins[id]
delete(a.auth.mfa.logins, id)
locked := a.auth.lockedLocked(ip)
a.auth.mu.Unlock()
if locked {
a.signInFailed(w, errLocked)
return
}
if cer == nil || time.Now().After(cer.expires) {
a.signInFailed(w, errors.New("the sign-in expired; try again"))
return
}
wa, err := a.webAuthn(r)
if err != nil {
writeErr(w, err)
return
}
cfg := a.store.Get()
var found *User
cred, err := wa.FinishDiscoverableLogin(func(rawID, handle []byte) (webauthn.User, error) {
for i := range cfg.Users {
u := &cfg.Users[i]
if u.MFA != nil && len(u.MFA.Handle) > 0 && bytes.Equal(u.MFA.Handle, handle) {
for _, k := range u.MFA.Keys {
if bytes.Equal(k.Credential.ID, rawID) {
found = u
return waUser{u}, nil
}
}
}
}
return nil, errors.New("unknown passkey")
}, *cer.data, r)
if err != nil || found == nil {
a.auth.mu.Lock()
a.auth.failLocked(ip)
a.auth.mu.Unlock()
slog.Warn("login failed", "remote", ip, "reason", "passkey not accepted")
a.signInFailed(w, errors.New("this passkey is not known here"))
return
}
a.keyUsed(found.ID, cred)
a.signedIn(w, r, found, "passkey")
}
// --- managing your own two-step sign-in (signed-in users) ---
type keyView struct {
ID string `json:"id"`
Name string `json:"name"`
Created time.Time `json:"created"`
LastUsed *time.Time `json:"lastUsed"`
}
func (a *App) mfaStatus(w http.ResponseWriter, r *http.Request) {
cfg := a.store.Get()
_, u := cfg.userByID(who(r).UserID)
if u == nil {
writeErr(w, badRequest("no such user"))
return
}
out := map[string]any{"totp": false, "totpAdded": nil, "keys": []keyView{}, "recoveryLeft": 0,
"keysAvailable": a.keysAvailable(r), "required": cfg.SignIn.RequireMFA}
if m := u.MFA; m != nil {
keys := []keyView{}
for _, k := range m.Keys {
keys = append(keys, keyView{k.ID, k.Name, k.Created, k.LastUsed})
}
out["totp"], out["totpAdded"], out["keys"], out["recoveryLeft"] = m.TOTPSecret != "", m.TOTPAdded, keys, len(m.RecoveryCodes)
}
writeJSON(w, http.StatusOK, out)
}
// addFirstCodes gives a user recovery codes with their first method. It
// returns the codes to show, or nil when the user already has codes. It runs
// inside a store update.
func addFirstCodes(u *User) []string {
if len(u.MFA.RecoveryCodes) > 0 {
return nil
}
codes, hashes := newRecoveryCodes()
u.MFA.RecoveryCodes = hashes
return codes
}
func (a *App) totpSetup(w http.ResponseWriter, r *http.Request) {
p := who(r)
secret := newTOTPSecret()
a.auth.mu.Lock()
a.auth.mfa.totpSetup[p.UserID] = secret
a.auth.mu.Unlock()
_, u := a.store.Get().userByID(p.UserID)
if u == nil {
writeErr(w, badRequest("no such user"))
return
}
uri := totpURI(secret, u.Username)
qr, _ := qrDataURL(uri)
writeJSON(w, http.StatusOK, map[string]any{"secret": secret, "uri": uri, "qr": qr})
}
func (a *App) totpConfirm(w http.ResponseWriter, r *http.Request) {
var in struct{ Code string }
if err := readJSON(r, &in); err != nil {
writeErr(w, err)
return
}
p := who(r)
a.auth.mu.Lock()
secret := a.auth.mfa.totpSetup[p.UserID]
a.auth.mu.Unlock()
if secret == "" {
writeErr(w, badRequest("start the setup again"))
return
}
if !a.auth.useTOTP(p.UserID, secret, in.Code) {
writeErr(w, badRequest("wrong code; check the time on your phone and try the next one"))
return
}
var codes []string
now := time.Now().UTC()
if err := a.store.Update(func(c *Config) error {
_, u := c.userByID(p.UserID)
if u == nil {
return badRequest("no such user")
}
if u.MFA == nil {
u.MFA = &UserMFA{}
}
u.MFA.TOTPSecret, u.MFA.TOTPAdded = secret, &now
codes = addFirstCodes(u)
return nil
}); err != nil {
writeErr(w, err)
return
}
a.auth.mu.Lock()
delete(a.auth.mfa.totpSetup, p.UserID)
a.auth.mu.Unlock()
a.audit(r, "authenticator app added")
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "recoveryCodes": codes})
}
// lastMethodCheck refuses to remove the last method while two-step sign-in
// is required.
func lastMethodCheck(c *Config, u *User) error {
if c.SignIn.RequireMFA && !u.hasMFA() {
return badRequest("two-step sign-in is required here; add another method first")
}
if !u.hasMFA() && u.MFA != nil {
u.MFA.RecoveryCodes = nil
}
return nil
}
func (a *App) totpRemove(w http.ResponseWriter, r *http.Request) {
p := who(r)
if err := a.store.Update(func(c *Config) error {
_, u := c.userByID(p.UserID)
if u == nil || u.MFA == nil || u.MFA.TOTPSecret == "" {
return badRequest("no authenticator app is set up")
}
u.MFA.TOTPSecret, u.MFA.TOTPAdded = "", nil
return lastMethodCheck(c, u)
}); err != nil {
writeErr(w, err)
return
}
a.audit(r, "authenticator app removed")
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
// keyBegin starts adding a passkey.
func (a *App) keyBegin(w http.ResponseWriter, r *http.Request) {
wa, err := a.webAuthn(r)
if err != nil {
writeErr(w, err)
return
}
p := who(r)
// The user handle is made once and never changes.
if err := a.store.Update(func(c *Config) error {
_, u := c.userByID(p.UserID)
if u == nil {
return badRequest("no such user")
}
if u.MFA == nil {
u.MFA = &UserMFA{}
}
if len(u.MFA.Handle) == 0 {
u.MFA.Handle = make([]byte, 32)
if _, err := rand.Read(u.MFA.Handle); err != nil {
return err
}
}
return nil
}); err != nil {
writeErr(w, err)
return
}
_, u := a.store.Get().userByID(p.UserID)
var exclude []protocol.CredentialDescriptor
for _, k := range u.MFA.Keys {
exclude = append(exclude, k.Credential.Descriptor())
}
sel := protocol.AuthenticatorSelection{ResidentKey: protocol.ResidentKeyRequirementRequired, UserVerification: protocol.VerificationRequired}
opts, data, err := wa.BeginRegistration(waUser{u}, webauthn.WithAuthenticatorSelection(sel), webauthn.WithExclusions(exclude))
if err != nil {
writeErr(w, err)
return
}
a.auth.mu.Lock()
a.auth.mfa.enrolls[p.UserID] = &ceremony{userID: p.UserID, data: data, expires: time.Now().Add(ticketTTL)}
a.auth.mu.Unlock()
writeJSON(w, http.StatusOK, opts)
}
// keyFinish stores the new key. The name is in the query, the body is the
// browser's credential.
func (a *App) keyFinish(w http.ResponseWriter, r *http.Request) {
p := who(r)
name := strings.TrimSpace(r.URL.Query().Get("name"))
a.auth.mu.Lock()
cer := a.auth.mfa.enrolls[p.UserID]
delete(a.auth.mfa.enrolls, p.UserID)
a.auth.mu.Unlock()
if cer == nil || time.Now().After(cer.expires) {
writeErr(w, badRequest("adding the key took too long; try again"))
return
}
wa, err := a.webAuthn(r)
if err != nil {
writeErr(w, err)
return
}
_, u := a.store.Get().userByID(p.UserID)
if u == nil {
writeErr(w, badRequest("no such user"))
return
}
cred, err := wa.FinishRegistration(waUser{u}, *cer.data, r)
if err != nil {
writeErr(w, badRequest("the key was not accepted: %v", err))
return
}
if name == "" {
name = "Passkey"
}
if len(name) > maxKeyName {
name = name[:maxKeyName]
}
var codes []string
key := MFAKey{ID: newID(), Name: name, Passkey: true, Created: time.Now().UTC(), Credential: *cred}
if err := a.store.Update(func(c *Config) error {
_, u := c.userByID(p.UserID)
if u == nil || u.MFA == nil {
return badRequest("no such user")
}
u.MFA.Keys = append(u.MFA.Keys, key)
codes = addFirstCodes(u)
return nil
}); err != nil {
writeErr(w, err)
return
}
a.audit(r, "passkey added", "key", name)
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "recoveryCodes": codes})
}
func (a *App) keyRename(w http.ResponseWriter, r *http.Request) {
var in struct{ Name string }
if err := readJSON(r, &in); err != nil {
writeErr(w, err)
return
}
in.Name = strings.TrimSpace(in.Name)
if in.Name == "" || len(in.Name) > maxKeyName {
writeErr(w, badRequest("name must be 1–%d characters", maxKeyName))
return
}
id := r.PathValue("id")
if err := a.store.Update(func(c *Config) error {
_, u := c.userByID(who(r).UserID)
if u == nil || u.MFA == nil {
return badRequest("no such key")
}
for i := range u.MFA.Keys {
if u.MFA.Keys[i].ID == id {
u.MFA.Keys[i].Name = in.Name
return nil
}
}
return badRequest("no such key")
}); err != nil {
writeErr(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (a *App) keyRemove(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
var name string
if err := a.store.Update(func(c *Config) error {
_, u := c.userByID(who(r).UserID)
if u == nil || u.MFA == nil {
return badRequest("no such key")
}
i := slices.IndexFunc(u.MFA.Keys, func(k MFAKey) bool { return k.ID == id })
if i < 0 {
return badRequest("no such key")
}
name = u.MFA.Keys[i].Name
u.MFA.Keys = slices.Delete(u.MFA.Keys, i, i+1)
return lastMethodCheck(c, u)
}); err != nil {
writeErr(w, err)
return
}
a.audit(r, "passkey removed", "key", name)
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (a *App) newRecoveryCodesHandler(w http.ResponseWriter, r *http.Request) {
var codes []string
if err := a.store.Update(func(c *Config) error {
_, u := c.userByID(who(r).UserID)
if u == nil || !u.hasMFA() {
return badRequest("turn on two-step sign-in first")
}
var hashes []string
codes, hashes = newRecoveryCodes()
u.MFA.RecoveryCodes = hashes
return nil
}); err != nil {
writeErr(w, err)
return
}
a.audit(r, "recovery codes replaced")
writeJSON(w, http.StatusOK, map[string]any{"recoveryCodes": codes})
}
// resetMFA removes another user's two-step sign-in, for a lost phone or key.
// Their user handle stays, so passkeys they still hold are just unknown.
func (a *App) resetMFA(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if id == who(r).UserID {
writeErr(w, badRequest("manage your own two-step sign-in under My account"))
return
}
var name string
if err := a.store.Update(func(c *Config) error {
_, u := c.userByID(id)
if u == nil {
return badRequest("no such user")
}
name = u.Username
if u.MFA != nil {
u.MFA = &UserMFA{Handle: u.MFA.Handle}
}
return nil
}); err != nil {
writeErr(w, err)
return
}
a.audit(r, "two-step sign-in reset", "user", name)
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
// mfaSummary is what user lists show.
func mfaSummary(u *User) map[string]any {
out := map[string]any{"totp": false, "passkeys": 0}
if m := u.MFA; m != nil {
out["totp"], out["passkeys"] = m.TOTPSecret != "", len(m.Keys)
}
return out
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 296 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
} }
+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")
}
}
+2 -1
View File
@@ -21,6 +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, "passkeys": n}
} }
func (a *App) userView(c *Config, u *User, me string) userView { func (a *App) userView(c *Config, u *User, me string) userView {
@@ -30,7 +31,7 @@ func (a *App) userView(c *Config, u *User, me string) userView {
n++ n++
} }
} }
return userView{u.ID, u.Username, u.Note, u.MustChangePassword, u.Created, a.auth.LastLogin(u.ID), n, u.ID == me} return userView{u.ID, u.Username, u.Note, u.MustChangePassword, u.Created, a.auth.LastLogin(u.ID), n, u.ID == me, mfaSummary(u)}
} }
// username names a user for lists, or "" if the ID is unknown. // username names a user for lists, or "" if the ID is unknown.
+93 -5
View File
@@ -1,8 +1,13 @@
package main package main
import ( import (
"crypto/sha256"
"embed" "embed"
"encoding/hex"
"net/http" "net/http"
"net/url"
"strings"
"time"
) )
// The web UI and its icons are built into the binary. The UI talks only to // The web UI and its icons are built into the binary. The UI talks only to
@@ -11,11 +16,59 @@ import (
//go:embed index.html setup.html app.js setup.js app.css favicon.svg apple-touch-icon.png ShipporiMinchoB1-ExtraBold.woff2 //go:embed index.html setup.html app.js setup.js app.css favicon.svg apple-touch-icon.png ShipporiMinchoB1-ExtraBold.woff2
var webFiles embed.FS var webFiles embed.FS
func webHandler() http.Handler { // The pages load app.js, setup.js and app.css with ?v=<hash of the file>, so
// a new binary makes browsers fetch the new files, and a fingerprinted file
// can be cached for good.
var (
assetHash = map[string]string{}
indexPage []byte
)
func init() {
for _, name := range []string{"app.js", "setup.js", "app.css"} {
b, err := webFiles.ReadFile(name)
if err != nil {
panic(err)
}
sum := sha256.Sum256(b)
assetHash[name] = hex.EncodeToString(sum[:5])
}
b, err := webFiles.ReadFile("index.html")
if err != nil {
panic(err)
}
indexPage = fingerprint(b, "/")
}
// fingerprint adds ?v=<hash> to the page's references to base + file.
func fingerprint(page []byte, base string) []byte {
s := string(page)
for name, h := range assetHash {
s = strings.ReplaceAll(s, `"`+base+name+`"`, `"`+base+name+"?v="+h+`"`)
}
return []byte(s)
}
func (a *App) webHandler() http.Handler {
files := http.FileServerFS(webFiles) files := http.FileServerFS(webFiles)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if d := a.store.Get().Decoy; d.Enabled {
serveDecoy(w, r, d.Page)
return
}
switch r.URL.Path { switch r.URL.Path {
case "/", "/app.js", "/setup.js", "/app.css", "/favicon.svg", "/apple-touch-icon.png": case "/":
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache")
_, _ = w.Write(indexPage)
case "/app.js", "/setup.js", "/app.css":
if v := r.URL.Query().Get("v"); v != "" && v == assetHash[r.URL.Path[1:]] {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
} else {
w.Header().Set("Cache-Control", "no-cache")
}
files.ServeHTTP(w, r)
case "/favicon.svg", "/apple-touch-icon.png":
w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Cache-Control", "no-cache")
files.ServeHTTP(w, r) files.ServeHTTP(w, r)
case "/ShipporiMinchoB1-ExtraBold.woff2": case "/ShipporiMinchoB1-ExtraBold.woff2":
@@ -30,15 +83,50 @@ func webHandler() http.Handler {
}) })
} }
// setupAllowed reports whether the setup page and its files may be served for
// this token. With the decoy on, only a live setup link gets past the decoy.
func (a *App) setupAllowed(token string) bool {
cfg := a.store.Get()
if !cfg.Decoy.Enabled {
return true
}
p := cfg.peerByToken(token)
return p != nil && !p.Setup.expired(time.Now())
}
// setupPage serves the page a setup link opens. The token stays in the URL; // setupPage serves the page a setup link opens. The token stays in the URL;
// setup.js reads it from there and talks to /api/v1/setup. // setup.js reads it from there and talks to /api/v1/setup. The page loads its
func setupPage(w http.ResponseWriter, r *http.Request) { // files from under the link, so they work while the decoy hides the root.
func (a *App) setupPage(w http.ResponseWriter, r *http.Request) {
token := r.PathValue("token")
if !a.setupAllowed(token) {
serveDecoy(w, r, a.store.Get().Decoy.Page)
return
}
b, err := webFiles.ReadFile("setup.html") b, err := webFiles.ReadFile("setup.html")
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
} }
base := "/setup/" + url.PathEscape(token) + "/"
page := strings.NewReplacer(`href="/`, `href="`+base, `src="/`, `src="`+base).Replace(string(b))
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store") w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(b) _, _ = w.Write(fingerprint([]byte(page), base))
}
func (a *App) setupAsset(w http.ResponseWriter, r *http.Request) {
file := r.PathValue("file")
switch file {
case "setup.js", "app.css", "favicon.svg", "apple-touch-icon.png", "ShipporiMinchoB1-ExtraBold.woff2":
default:
a.webHandler().ServeHTTP(w, r)
return
}
if !a.setupAllowed(r.PathValue("token")) {
serveDecoy(w, r, a.store.Get().Decoy.Page)
return
}
w.Header().Set("Cache-Control", "no-store")
http.ServeFileFS(w, r, webFiles, file)
} }