19 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
24 changed files with 1450 additions and 288 deletions
+51 -41
View File
@@ -27,32 +27,25 @@ dependencies on the server: the binary installs, updates and removes itself.
- **IPv4 and IPv6:** IPv6 inside the tunnel is turned on automatically when the
server has a global IPv6 address.
- **Traffic history:** kept in `stats.json`, hourly for 48 h and daily for
400 days by default (Settings → Data retention).
400 days by default (Settings → Logs & history).
- **Connection history:** every online session per peer, with start, duration,
address and traffic. A new session starts when a device changes networks.
Country and network operator come from the free
[DB-IP Lite](https://db-ip.com) databases (CC BY 4.0). GHOSTWIRE downloads
them monthly (about 20 MB) and looks addresses up locally, so peer addresses
never leave the server. You can switch this off under Settings → Data
retention.
never leave the server. You can switch this off under Settings → Logs &
history.
- **Logs:** written to `GHOSTWIRE.jsonl`, rotated at 10 MB with 5 old files
kept by default. Changes are marked as audit entries.
kept by default, and shown on the Log page. Changes are marked as audit
entries.
- **Update notice:** once a day the server asks Gitea or GitHub (your choice
under Settings → Updates) for the latest release. A newer one shows in the
sidebar, on the Dashboard and in Settings, with its release notes and the
commands to update this server. Nothing about the server is sent; the check
can be switched off.
- **HTTPS built in:** Let's Encrypt, a self-signed certificate, your own
certificate files, or plain HTTP behind a reverse proxy.
## Screenshots
| | |
|---|---|
| ![Peers list with status, endpoint, latency sparklines and 30-day traffic](screenshots/peers.png) | ![Peer page with traffic and latency charts, connection details and history](screenshots/peer.png) |
| **Peers:** status, endpoint, latency and traffic at a glance | **Peer:** traffic, latency, connection history and settings |
| ![Server page with health checks, interface, endpoint, client defaults and firewall](screenshots/server.png) | ![Settings with users, web interface and API tokens](screenshots/settings.png) |
| **Server:** health, address plan, client defaults and firewall | **Settings:** users, web interface and API tokens |
| ![My account page with profile, password and own app tokens](screenshots/account.png) | ![Sign-in page](screenshots/login.png) |
| **My account:** profile, password and your app tokens | **Sign-in** |
The screenshots show sample data from the built-in simulator.
## Security
- **Client private keys are never stored.** A config is shown once, as a
@@ -65,16 +58,19 @@ The screenshots show sample data from the built-in simulator.
`CAP_NET_ADMIN` and `CAP_NET_BIND_SERVICE`, and can write only to
`/opt/ghostwire`.
- **Sign-in:** one or more users, all admins. Passwords are stored as argon2id hashes.
After 5 failed attempts, sign-in is locked for 15 minutes. Sessions use an
After 5 failed attempts from one IP address, sign-in from it is locked for 15
minutes; wrong two-step codes count too. Sessions use an
HttpOnly, SameSite=Strict cookie and last 12 hours by default.
- **Two-step sign-in:** each user can add an authenticator app (TOTP), security
keys such as a YubiKey, and passkeys that sign in without a password, under
My account. 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. Security keys and 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.
- **Two-step sign-in:** each user can add an authenticator app (TOTP) and
passkeys under My account. A passkey signs in on its own, without username
and password, and also works as the second step after a password. It can live
on the device (Touch ID, Face ID, Windows Hello), in a password manager, or on
a YubiKey with a PIN set. Turning it on gives 10 one-time recovery codes. An
admin can require it for everyone (Settings → Sign-in) and reset it for a user
who lost their phone or key. Passkeys use WebAuthn and need the server's
domain name with a trusted certificate (Let's Encrypt, certificate files, or a
reverse proxy); on a self-signed certificate or an IP address, only the
authenticator app is offered. API tokens never need a second step.
- **API tokens** are stored only as hashes and can be read-only or full access.
- `config.json` holds the server private key and is readable only by the
service (0600).
@@ -186,7 +182,7 @@ the running service.
| Command | What it does |
|---|---|
| `GHOSTWIRE install [-domain d] [-email e] [-endpoint h] [-port p] [-y]` | Sets up and starts the service, as above. Asks for the settings no flag gave; `-y` never asks. |
| `GHOSTWIRE update [-force]` | Run from the new binary, e.g. `sudo /tmp/GHOSTWIRE update`. Checks that it can read the current `config.json` (nothing changes if not), backs up the config to `config.json.bak-<old version>`, replaces the binary, updates the unit if needed and restarts. If the new version does not stay up, the old binary and config are put back and restarted. It refuses older versions without `-force`. |
| `GHOSTWIRE update [-force]` | Run from the new binary, e.g. `sudo /tmp/GHOSTWIRE update`. Checks that it can read the current `config.json` (nothing changes if not), backs up the config to `config.json.bak-<old version>` (keeping the newest 3 such copies), replaces the binary, updates the unit if needed and restarts. If the new version does not stay up, the old binary and config are put back and restarted. It refuses older versions without `-force`. |
| `GHOSTWIRE uninstall [-purge] [-y]` | Stops and removes the service, `wg0` and the firewall table. `-purge` also deletes `/opt/ghostwire` and the user. |
| `GHOSTWIRE passwd [username]` | Sets a user's password (default: the first user) and reloads the running service. The way back in if you are locked out. |
| `GHOSTWIRE version` | Prints the version. |
@@ -227,7 +223,8 @@ After editing `config.json` by hand, run `sudo systemctl reload ghostwire`.
| File | Content |
|---|---|
| `GHOSTWIRE` | the program |
| `config.json` | all settings, server key, peers, token hashes (0600) |
| `config.json` | all settings, server key, peers, pending setup links with their PINs, user password hashes, authenticator app secrets, passkeys, recovery code and token hashes (0600) |
| `config.json.bak-*` | copies of `config.json` made by `update`; the newest 3 are kept, and Settings → Upkeep lists and removes them |
| `stats.json` | traffic and connection history per peer |
| `geo-country.mmdb`, `geo-asn.mmdb` | DB-IP Lite databases for country and network lookups |
| `GHOSTWIRE.jsonl` | log, one JSON object per line. Changes carry `"audit":true` |
@@ -239,30 +236,32 @@ Base path `/api/v1`. The web interface signs in with a session cookie; every
user is an admin. Apps and scripts use `Authorization: Bearer <token>`; create
the token under Settings → Pair iOS app. A token belongs to the user who made
it and is revoked when that user is deleted. A read-only token may only use
GET. Full-access tokens can do everything the web interface does except backup
and restore. Users, passwords and API tokens need a full-access token even for
reading.
GET. Full-access tokens can do everything the web interface does except the
endpoints marked "signed in": users, passwords, API tokens, the sign-in rules,
backup and restore.
For a user with two-step sign-in, `POST /auth/login` answers
`{"mfa": true, "ticket": "…", "methods": ["key", "totp", "recovery"]}`
instead of starting a session; the ticket is good for 5 minutes, and one of
the `/auth/login/…` steps turns it into the session. `PATCH /settings`
`{"signin": {"requireMfa": true}}` requires two-step sign-in for every user.
`{"signin": {"requireMfa": true}}` requires two-step sign-in for every user;
only a signed-in user can change it.
`POST /users` and `POST /users/{id}/reset-password` take
`{"password": "…", "mustChangePassword": true}`; with `true` (the default) the
user can do nothing but choose a new password at the next sign-in.
```
POST /auth/login · /auth/logout GET /auth/me POST /auth/password (own password)
GET /users POST /users PATCH /users/{id} DELETE /users/{id}
POST /users/{id}/reset-password POST /users/{id}/reset-mfa
POST /auth/login · /auth/logout GET /auth/me
signed in: POST /auth/password (own password)
signed in: GET|POST /users · PATCH|DELETE /users/{id}
signed in: POST /users/{id}/reset-password · /users/{id}/reset-mfa
GET /auth/options (public: is passkey sign-in offered here)
POST /auth/login/totp · /auth/login/recovery {"ticket", "code"}
POST /auth/login/key/begin {"ticket"} · /auth/login/key/finish?ticket= (body: the WebAuthn credential)
POST /auth/login/passkey/begin · /auth/login/passkey/finish?id=
signed in: GET /auth/mfa · POST /auth/mfa/totp/setup · /auth/mfa/totp/confirm · DELETE /auth/mfa/totp
signed in: POST /auth/mfa/keys/begin {"passkey"} · /auth/mfa/keys/finish?name= · PATCH|DELETE /auth/mfa/keys/{id}
signed in: POST /auth/mfa/keys/begin · /auth/mfa/keys/finish?name= · PATCH|DELETE /auth/mfa/keys/{id}
signed in: POST /auth/mfa/recovery-codes
GET /status GET /stats?range=24h|7d|30d|90d
GET /server PATCH /server POST /server/rotate-key GET /server/detect-ip
@@ -272,10 +271,10 @@ POST /peers/{id}/enable | /disable | /issue-config
GET /peers/{id}/stats?range=… GET /peers/{id}/sessions?limit=100
GET /peers/{id}/latency (24 h, one point per 5 minutes)
GET /peers/{id}/setup (not read-only) DELETE /peers/{id}/setup
GET /settings PATCH /settings POST /restart
GET /settings PATCH /settings POST /restart POST /updates/check
GET /logs?level=&limit=&audit=1 GET /logs/download
GET /tokens POST /tokens DELETE /tokens/{id}
signed in: GET /backup · POST /restore
signed in: GET|POST /tokens · DELETE /tokens/{id} · GET /backup · POST /restore
signed in: GET|DELETE /update-backups · DELETE /update-backups/{name} (config copies made by update)
public: GET /setup/{token} · POST /setup/{token} {"pin"} (what a setup link opens)
```
@@ -284,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
link, the peer's current keys keep working until the link is opened.
`GET /settings` includes `updates`: the running and latest version,
`available`, the release notes and the download links for this server's
platform. `PATCH /settings` `{"updates": {"source": "gitea"|"github",
"check": false}}` picks the source or switches the daily check off;
`POST /updates/check` checks now. `GET /auth/me` has `updateAvailable` with
the newer version while there is one.
Traffic is reported from the peer's point of view: `down` is what the peer
downloaded, `up` is what it uploaded.
@@ -305,12 +311,16 @@ override a drop in another table, so if ufw or firewalld is active, allow UDP
## iOS app
The native iPhone app (SwiftUI, iOS 17+) lives in its own project,
GHOSTWIRE-Companion. It does everything the web interface does except
password, API tokens and backups. Pair it in the web interface under
GHOSTWIRE-Companion. It manages peers, the server and the app settings and
shows stats and logs. Users, passwords, API tokens, two-step sign-in, backup
and restore stay in the web interface. Pair it in the web interface under
Settings → Pair iOS app: scan the QR code, or tap "Copy pairing code" and paste
it into the app's "Enter manually". Self-signed certificates are pinned during
pairing.
The iOS app is currently in beta testing. For an invite, email
[engineroom@redetzke.aero](mailto:engineroom@redetzke.aero).
## Development
On macOS (or any non-Linux system), `make dev` starts the app on
+55 -41
View File
@@ -26,6 +26,7 @@ type App struct {
logPath string
logw *rotatingWriter // nil in tests
geo *Geo // nil in tests
updates *Updater // nil in tests
started time.Time
shutdown func() // graceful stop; systemd restarts the service
}
@@ -97,17 +98,6 @@ func (a *App) guard(adminOnly bool, h http.HandlerFunc) http.HandlerFunc {
}
}
// fullAccess refuses read-only tokens, also for GET.
func fullAccess(h http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if who(r).Scope == "ro" {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "this token is read-only"})
return
}
h(w, r)
}
}
// applyResult saves-then-applies: the config is already stored, so a kernel
// error is reported but does not undo the change.
func (a *App) apply() string {
@@ -121,8 +111,6 @@ func (a *App) routes() http.Handler {
mux := http.NewServeMux()
g := func(pattern string, h http.HandlerFunc) { mux.HandleFunc(pattern, a.guard(false, h)) }
adm := func(pattern string, h http.HandlerFunc) { mux.HandleFunc(pattern, a.guard(true, h)) }
// full is for signed-in users and full-access tokens, even for reading.
full := func(pattern string, h http.HandlerFunc) { mux.HandleFunc(pattern, a.guard(false, fullAccess(h))) }
mux.HandleFunc("POST /api/v1/auth/login", a.login)
mux.HandleFunc("POST /api/v1/auth/logout", a.logout)
@@ -146,13 +134,13 @@ func (a *App) routes() http.Handler {
adm("DELETE /api/v1/auth/mfa/keys/{id}", a.keyRemove)
adm("POST /api/v1/auth/mfa/recovery-codes", a.newRecoveryCodesHandler)
g("GET /api/v1/auth/me", a.me)
full("POST /api/v1/auth/password", a.changePassword)
full("GET /api/v1/users", a.listUsers)
full("POST /api/v1/users", a.createUser)
full("PATCH /api/v1/users/{id}", a.patchUser)
full("POST /api/v1/users/{id}/reset-password", a.resetPassword)
full("DELETE /api/v1/users/{id}", a.deleteUser)
full("POST /api/v1/users/{id}/reset-mfa", a.resetMFA)
adm("POST /api/v1/auth/password", a.changePassword)
adm("GET /api/v1/users", a.listUsers)
adm("POST /api/v1/users", a.createUser)
adm("PATCH /api/v1/users/{id}", a.patchUser)
adm("POST /api/v1/users/{id}/reset-password", a.resetPassword)
adm("DELETE /api/v1/users/{id}", a.deleteUser)
adm("POST /api/v1/users/{id}/reset-mfa", a.resetMFA)
g("GET /api/v1/status", a.status)
g("GET /api/v1/stats", a.allStats)
@@ -182,17 +170,22 @@ func (a *App) routes() http.Handler {
mux.HandleFunc("POST /api/v1/setup/{token}", a.setupRedeem)
// Full-access tokens (the iOS app) may change app settings, read logs and
// manage users and tokens. Backups need a signed-in user.
// restart. Users, passwords, API tokens, the sign-in rules and backups
// need a signed-in user.
g("GET /api/v1/settings", a.getSettings)
g("PATCH /api/v1/settings", a.patchSettings)
g("POST /api/v1/updates/check", a.checkUpdates)
g("POST /api/v1/restart", a.restart)
full("GET /api/v1/tokens", a.listTokens)
full("POST /api/v1/tokens", a.createToken)
full("DELETE /api/v1/tokens/{id}", a.deleteToken)
adm("GET /api/v1/tokens", a.listTokens)
adm("POST /api/v1/tokens", a.createToken)
adm("DELETE /api/v1/tokens/{id}", a.deleteToken)
g("GET /api/v1/logs", a.logs)
g("GET /api/v1/logs/download", a.downloadLog)
adm("GET /api/v1/backup", a.backup)
adm("POST /api/v1/restore", a.restore)
adm("GET /api/v1/update-backups", a.listUpdateBackups)
adm("DELETE /api/v1/update-backups", a.removeUpdateBackups)
adm("DELETE /api/v1/update-backups/{name}", a.removeUpdateBackup)
mux.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "no such endpoint"})
@@ -232,7 +225,7 @@ func (a *App) login(w http.ResponseWriter, r *http.Request) {
if err != nil {
slog.Warn("login failed", "user", in.Username, "remote", ip, "reason", err.Error())
code := http.StatusUnauthorized
if errors.Is(err, errLocked) {
if errors.Is(err, errLocked) || errors.Is(err, errBusy) {
code = http.StatusTooManyRequests
}
writeJSON(w, code, map[string]string{"error": err.Error()})
@@ -270,8 +263,8 @@ func (a *App) me(w http.ResponseWriter, r *http.Request) {
"id": p.UserID, "name": p.Name, "isAdmin": p.IsAdmin, "scope": p.Scope,
"mustChangePassword": p.MustChangePassword, "mfaSetupRequired": p.MFASetupRequired, "version": version, "session": p.Session,
}
if p.TokenID != "" {
out["tokenId"] = p.TokenID // lets an app find its own token in /tokens
if v := a.updates.Available(); v != "" {
out["updateAvailable"] = v
}
if _, u := a.store.Get().userByID(p.UserID); u != nil {
out["username"], out["note"], out["created"] = u.Username, u.Note, u.Created
@@ -1008,15 +1001,15 @@ func (a *App) issueConfig(w http.ResponseWriter, r *http.Request) {
func (a *App) getSettings(w http.ResponseWriter, r *http.Request) {
cfg := a.store.Get()
writeJSON(w, http.StatusOK, map[string]any{
"web": cfg.Web,
"log": cfg.Log,
"stats": cfg.Stats,
"decoy": cfg.Decoy,
"signin": cfg.SignIn,
"geo": a.geoStatus(),
"adminUsername": a.username(cfg, who(r).UserID), // kept for older iOS app versions
"fingerprint": a.tls.Fingerprint(),
"logPath": a.logPath,
"web": cfg.Web,
"log": cfg.Log,
"stats": cfg.Stats,
"decoy": cfg.Decoy,
"signin": cfg.SignIn,
"geo": a.geoStatus(),
"updates": a.updates.Status(),
"fingerprint": a.tls.Fingerprint(),
"logPath": a.logPath,
})
}
@@ -1026,18 +1019,25 @@ func (a *App) patchSettings(w http.ResponseWriter, r *http.Request) {
writeErr(w, err)
return
}
if _, ok := m["adminUsername"]; ok {
writeErr(w, badRequest("usernames are changed under /users"))
if _, ok := m["signin"]; ok && !who(r).IsAdmin {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "API tokens cannot change the sign-in rules; sign in to the web interface"})
return
}
var restart bool
err = a.store.Update(func(c *Config) error {
before, _ := json.Marshal(c.Web)
// Session length applies to the next sign-in; everything else in
// web needs a restart.
listen := func() string {
w := c.Web
w.SessionHours = 0
b, _ := json.Marshal(w)
return string(b)
}
before := listen()
if err := field(m, "web", &c.Web); err != nil {
return err
}
after, _ := json.Marshal(c.Web)
restart = string(before) != string(after)
restart = listen() != before
if err := field(m, "stats", &c.Stats); err != nil {
return err
}
@@ -1047,6 +1047,9 @@ func (a *App) patchSettings(w http.ResponseWriter, r *http.Request) {
if err := field(m, "signin", &c.SignIn); err != nil {
return err
}
if err := field(m, "updates", &c.Updates); err != nil {
return err
}
return field(m, "log", &c.Log)
})
if err != nil {
@@ -1192,6 +1195,17 @@ func (a *App) applyRuntime(c *Config) {
a.logw.SetLimits(c.Log.MaxSizeMB, c.Log.MaxFiles)
}
a.geo.SetEnabled(c.Stats.geoEnabled())
a.updates.Set(c.Updates)
}
// checkUpdates asks the release source now and returns what it found.
func (a *App) checkUpdates(w http.ResponseWriter, r *http.Request) {
if a.updates == nil || !a.updates.Status().Enabled {
writeErr(w, badRequest("the update check is switched off"))
return
}
a.updates.Check(r.Context())
writeJSON(w, http.StatusOK, a.updates.Status())
}
func (a *App) geoStatus() GeoStatus {
+61 -5
View File
@@ -69,7 +69,10 @@ a.brand, a.brand:hover { color: #fff; text-decoration: none; }
.side .acct strong { font-size: 14px; font-weight: 500; color: #fff; overflow: hidden; text-overflow: ellipsis; }
.side .acct span span { color: #a9aaa5; }
.side .avatar { width: 28px; height: 28px; border-radius: 50%; background: #3a3b41; display: grid; place-items: center; flex: none; font-size: 13px; font-weight: 600; color: #fff; }
.side .footrow { display: flex; justify-content: space-between; padding: 10px 12px 0; }
.side .footrow { display: flex; justify-content: space-between; align-items: center; gap: 8px; padding: 10px 12px 0; }
.side .footrow .upd { font-size: 11.5px; font-weight: 500; color: #cfe2f8; background: #1f3550; border: 1px solid #2d4a6e; padding: 2px 8px; border-radius: 999px; text-decoration: none; white-space: nowrap; }
.side .footrow .upd:hover { color: #fff; }
.side .nav .pip { margin-left: auto; width: 7px; height: 7px; border-radius: 50%; background: #6aa6ea; }
.main { flex: 999 1 560px; min-width: 0; padding: 32px 32px 56px; }
/* Beside the page (not stacked above it on a phone), the sidebar stays in
place while the page scrolls, so the account link is always visible. */
@@ -129,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; }
.notice { display: flex; gap: 10px; align-items: flex-start; padding: 12px 14px; border-radius: 10px; background: var(--warn-bg); color: var(--warn-ink); font-size: 13px; }
.notice.err { background: #fbefee; color: var(--bad-ink); }
.notice.new { background: #e8f0fa; color: #174d8f; flex-wrap: wrap; align-items: center; }
.notice.new .actions { margin-left: auto; }
.btn.ghost { background: transparent; border-color: transparent; }
.notice .btn { margin-left: auto; }
/* tables */
@@ -144,6 +150,8 @@ td { padding: 12px; border-bottom: 1px solid var(--line-2); vertical-align: midd
tr:last-child td { border-bottom: 0; }
.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
td .note { font-size: 12px; color: var(--ink-3); }
a.pname { color: var(--ink); font-weight: 600; text-decoration: none; }
a.pname:hover, 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); }
/* forms */
@@ -171,10 +179,50 @@ fieldset { border: 0; margin: 0; padding: 0; min-width: 0; display: flex; flex-d
.kv dt { color: var(--ink-2); }
.kv dd { margin: 0; min-width: 0; overflow-wrap: anywhere; }
/* checks */
.chk { display: flex; gap: 10px; align-items: center; padding: 10px 0; border-bottom: 1px solid var(--line-2); font-size: 13px; }
.chk:last-child { border-bottom: 0; }
.chk b { font-weight: 500; min-width: 160px; }
/* health: public addresses on the left, one row per check on the right */
.hcbody { display: grid; grid-template-columns: minmax(0, 5fr) minmax(0, 7fr); gap: 12px; margin-top: 16px; }
.hchead { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; }
.hchead > span { font-size: 13px; color: var(--ink-2); }
.hchead > span.bad { color: var(--bad-ink); font-weight: 500; }
.hcaddrs { display: flex; flex-direction: column; gap: 12px; }
.hcaddr { flex: 1; display: flex; flex-direction: column; justify-content: center; background: var(--ground); border-radius: 10px; padding: 14px 18px; min-width: 0; }
.hcaddr .l { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--ink-2); }
.hcaddr .v { margin-top: 4px; font-size: 15px; font-weight: 500; overflow-wrap: anywhere; }
.hcaddr .v.mono { font-size: 22px; }
.hcaddr .n { font-family: var(--sans); font-size: 12px; font-weight: 400; color: var(--ink-2); }
.hcaddr.bad { background: #fdf6f5; box-shadow: inset 0 0 0 1px #e6b3b0; }
.hcaddr.bad .v { color: var(--bad-ink); }
.hclist { border: 1px solid var(--line); border-radius: 10px; min-width: 0; }
.hcrow { display: grid; grid-template-columns: 8px minmax(0, 190px) minmax(0, 1fr); gap: 2px 14px; align-items: baseline; padding: 11px 16px; border-top: 1px solid var(--line-2); }
.hcrow:first-child { border-top: 0; }
.hcrow > .dot { align-self: center; }
.hcrow .l { font-size: 13px; color: var(--ink-2); }
.hcrow .v { display: flex; flex-wrap: wrap; align-items: baseline; gap: 2px 10px; min-width: 0; }
.hcrow .s { font-weight: 500; }
.hcrow .r { font-size: 12px; color: var(--ink-3); overflow-wrap: anywhere; }
.hcrow .p { grid-column: 2 / -1; font-size: 12.5px; color: var(--bad-ink); overflow-wrap: anywhere; }
.hcrow.bad { background: #fdf6f5; }
.hcrow.bad .s { color: var(--bad-ink); }
@media (max-width: 1000px) { .hcbody { grid-template-columns: minmax(0, 1fr); } }
@media (max-width: 640px) { .hcrow { grid-template-columns: 8px minmax(0, 1fr); } .hcrow .v { grid-column: 2; } }
/* updates */
.upvers { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; margin-top: 14px; }
.upbox { background: var(--ground); border-radius: 10px; padding: 12px 14px; display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.upbox > span { font-size: 12px; color: var(--ink-2); }
.upbox strong { font-size: 15px; font-weight: 500; }
.upbox strong.mono { font-size: 16px; }
.upbox.new { background: #e8f0fa; box-shadow: inset 0 0 0 1px #bcd2ee; }
.upbox.new strong { color: #174d8f; }
.uptodate { display: flex; align-items: center; gap: 10px; margin: 14px 0 0; font-weight: 500; }
.upnotes { border: 1px solid var(--line); border-radius: 10px; padding: 14px 16px; margin-top: 14px; display: flex; flex-direction: column; gap: 10px; font-size: 13px; }
.upnotes p { margin: 0; max-width: 80ch; }
.upnotes ul { margin: 0; padding-left: 18px; display: flex; flex-direction: column; gap: 6px; max-width: 80ch; }
.upnotes .hd, .upcmd .hd { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; }
.upnotes .hd a { margin-left: auto; }
.upcmd { display: flex; flex-direction: column; gap: 8px; margin-top: 14px; }
.uprow { display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap; margin-top: 16px; padding-top: 14px; border-top: 1px solid var(--line-2); }
#updates > .notice { margin-top: 14px; }
/* activity */
.ev { display: flex; gap: 12px; padding: 10px 0; border-bottom: 1px solid var(--line-2); font-size: 13px; }
@@ -322,3 +370,11 @@ dialog::backdrop { background: rgba(22, 23, 26, .55); }
.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; }
+360 -97
View File
@@ -49,6 +49,7 @@
settings: '<path d="M4 6h10M18 6h2M4 12h4M12 12h8M4 18h12"/><circle cx="16" cy="6" r="2"/><circle cx="10" cy="12" r="2"/><circle cx="18" cy="18" r="2"/>',
plus: '<path d="M12 5v14M5 12h14"/>',
key: '<circle cx="8" cy="15" r="4"/><path d="M11 12l9-9M17 6l3 3M14 9l2 2"/>',
log: '<rect x="4" y="3" width="16" height="18" rx="2"/><path d="M8 8h8M8 12h8M8 16h5"/>',
logout: '<path d="M14 4h4a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-4"/><path d="M10 16l-4-4 4-4M6 12h10"/>',
};
@@ -144,7 +145,6 @@
if (!m) return '';
const parts = [];
if (m.totp) parts.push('App');
if (m.keys) parts.push(m.keys === 1 ? '1 key' : m.keys + ' keys');
if (m.passkeys) parts.push(m.passkeys === 1 ? '1 passkey' : m.passkeys + ' passkeys');
return parts.join(', ');
}
@@ -258,13 +258,26 @@
else if (okMsg) toast(okMsg);
}
// dialog shows a modal dialog. Extensions such as Bitwarden move elements
// around in <body>; a moved dialog stays open but drops out of the top
// layer to the bottom of the page, so it is shown as a modal again. That
// goes through close(), whose close event arrives after the dialog is open
// again and is kept from the listeners added by callers.
function dialog(build) {
const d = h('dialog');
const close = () => d.close();
d.addEventListener('close', () => d.remove());
const moved = new MutationObserver(() => {
if (d.open && d.isConnected && !d.matches(':modal')) { d.close(); d.showModal(); }
});
d.addEventListener('close', (e) => {
if (d.open) { e.stopImmediatePropagation(); return; }
moved.disconnect();
d.remove();
});
d.append(build(close));
document.body.append(d);
d.showModal();
moved.observe(document.body, { childList: true, subtree: true });
return d;
}
@@ -516,9 +529,9 @@
// ---------- shell, router ----------
const NAV = [['#/', 'dashboard', 'Dashboard'], ['#/peers', 'peers', 'Peers'], ['#/server', 'server', 'Server'], ['#/settings', 'settings', 'Settings']];
const NAV = [['#/', 'dashboard', 'Dashboard'], ['#/peers', 'peers', 'Peers'], ['#/server', 'server', 'Server'], ['#/log', 'log', 'Log'], ['#/settings', 'settings', 'Settings']];
let navLinks = {};
let srvBox, peerCount;
let srvBox, peerCount, verRow;
function buildShell() {
srvBox = h('div', { class: 'srv' }, h('span', { class: 'dot' }), h('span', null, 'Loading…'));
@@ -534,13 +547,25 @@
h('span', { class: 'avatar', 'aria-hidden': 'true' }, me.name.slice(0, 1).toUpperCase()),
h('span', null, h('strong', null, me.name), h('span', null, 'My account')))),
h('button', { type: 'button', class: 'signout', 'aria-label': 'Sign out', onClick: logout }, icon('logout'), h('span', { class: 'tip', 'aria-hidden': 'true' }, 'Sign out'))),
h('div', { class: 'footrow' },
h('span', null, 'v' + me.version.replace(/^v/, '')))));
(verRow = h('div', { class: 'footrow' }))));
main = h('main', { class: 'main', id: 'main' });
app.replaceChildren(h('div', { class: 'shell' }, nav, main));
drawUpdateHint();
refreshSide();
}
// drawUpdateHint shows a newer release next to the version in the sidebar
// and as a dot on Settings.
function drawUpdateHint() {
if (!verRow) return;
const v = me.updateAvailable;
fill(verRow, h('span', null, 'v' + me.version.replace(/^v/, '')),
v ? h('a', { class: 'upd', href: '#/settings#updates' }, v + ' available') : null);
const set = navLinks['#/settings'];
set.querySelectorAll('.pip, .sr').forEach((e) => e.remove());
if (v) set.append(h('span', { class: 'pip', title: 'Update available' }), h('span', { class: 'sr' }, ', update available'));
}
async function refreshSide() {
try {
const s = await api('GET', '/status');
@@ -570,7 +595,8 @@
[/^#\/peers\/new$/, '#/peers', viewPeerNew],
[/^#\/peers\/([\w-]+)$/, '#/peers', viewPeer],
[/^#\/server$/, '#/server', viewServer],
[/^#\/settings$/, '#/settings', viewSettings],
[/^#\/log$/, '#/log', viewLog],
[/^#\/settings(#\w+)?$/, '#/settings', viewSettings],
[/^#\/account$/, '#/account', viewAccount],
];
@@ -636,7 +662,7 @@
// allows it.
const passkeyRow = h('div', { class: 'loginalt', hidden: true },
h('div', { class: 'or' }, 'or'),
h('button', { type: 'button', class: 'btn altbtn', onClick: async () => {
h('button', { type: 'button', class: 'btn altbtn signin', onClick: async () => {
err.textContent = '';
try {
const b = await api('POST', '/auth/login/passkey/begin');
@@ -644,7 +670,7 @@
await api('POST', '/auth/login/passkey/finish?id=' + encodeURIComponent(b.id), cred);
await signedIn();
} catch (x) { err.textContent = keyError(x); }
} }, icon('key', 18), 'Sign in with a passkey'));
} }, icon('key', 18), h('span', { class: 'en' }, 'Sign in with a passkey'), h('span', { class: 'ja', lang: 'ja', 'aria-hidden': 'true' }, 'パスキーでサインイン')));
if (window.PublicKeyCredential) {
api('GET', '/auth/options').then((o) => { passkeyRow.hidden = !o.passkeys; }).catch(() => {});
}
@@ -706,7 +732,7 @@
function keyError(x) {
if (x && x.name === 'NotAllowedError') return 'Cancelled or timed out. Try again.';
if (x && x.name === 'InvalidStateError') return 'This key is already set up for your account.';
if (x && x.name === 'SecurityError') return 'Security keys need this site on its domain name with a trusted certificate.';
if (x && x.name === 'SecurityError') return 'Passkeys need this site on its domain name with a trusted certificate.';
return x.message;
}
@@ -720,11 +746,11 @@
let mode = canKey ? 'key' : methods.includes('totp') ? 'totp' : 'recovery';
const box = h('div', { class: 'loginform' });
const TITLES = {
key: ['Use your security key', 'Insert your key and touch it, or use the passkey on this device.'],
key: ['Use your passkey', 'Confirm with Touch ID, Face ID, Windows Hello or your password manager, or insert your YubiKey and touch it.'],
totp: ['Enter the code', 'The 6-digit code from your authenticator app.'],
recovery: ['Use a recovery code', 'One of the codes you saved when you set up two-step sign-in. Each works once.'],
};
const LINKS = { key: 'Use a security key instead', totp: 'Use an authenticator code instead', recovery: 'Use a recovery code' };
const LINKS = { key: 'Use a passkey instead', totp: 'Use an authenticator code instead', recovery: 'Use a recovery code' };
const head = h('div', { class: 'logintext' });
const draw = () => {
const err = h('p', { class: 'err-text', role: 'alert' });
@@ -733,7 +759,7 @@
.map((m) => h('button', { type: 'button', class: 'linkbtn', onClick: () => { mode = m; draw(); } }, LINKS[m]));
const foot = h('div', { class: 'loginlinks' }, others, h('button', { type: 'button', class: 'linkbtn', onClick: showLogin }, 'Start over'));
if (mode === 'key') {
const btn = h('button', { type: 'button', class: 'btn primary' }, 'Use security key');
const btn = h('button', { type: 'button', class: 'btn primary' }, 'Use passkey');
const go = async () => {
err.textContent = '';
btn.disabled = true;
@@ -788,8 +814,7 @@
h('p', null, 'This server asks for a second step after the password. Add one to continue.')),
h('div', { class: 'loginform' },
h('button', { type: 'button', class: 'btn primary', onClick: () => addTOTP(done) }, 'Use an authenticator app'),
keys ? h('button', { type: 'button', class: 'btn altbtn', onClick: () => addKey(false, done) }, 'Use a security key') : null,
keys ? h('button', { type: 'button', class: 'btn altbtn', onClick: () => addKey(true, done) }, 'Use a passkey') : null,
keys ? h('button', { type: 'button', class: 'btn altbtn', onClick: () => addPasskey(done) }, 'Use a passkey') : null,
h('div', { class: 'loginlinks' }, h('button', { type: 'button', class: 'linkbtn', onClick: logout }, 'Sign out'))))));
}
@@ -841,30 +866,28 @@
code.focus();
}
// addKey adds a security key, or with passkey a passkey that also signs
// in without a password.
function addKey(passkey, onDone) {
const nm = h('input', { id: 'kn', value: passkey ? 'Passkey' : 'YubiKey', autocomplete: 'off', maxLength: 64 });
// addPasskey adds a passkey. It signs in on its own, and also serves as
// the second step after a password.
function addPasskey(onDone) {
const nm = h('input', { id: 'kn', value: 'Passkey', autocomplete: 'off', maxLength: 64 });
const e = h('p', { class: 'err-text', role: 'alert' });
const btn = h('button', { type: 'submit', class: 'btn primary' }, passkey ? 'Add passkey' : 'Add security key');
const btn = h('button', { type: 'submit', class: 'btn primary' }, 'Add passkey');
dialog((close) => h('form', { class: 'dlg', onSubmit: async (ev) => {
ev.preventDefault();
e.textContent = '';
btn.disabled = true;
try {
const opts = await api('POST', '/auth/mfa/keys/begin', { passkey });
const opts = await api('POST', '/auth/mfa/keys/begin');
const cred = await webauthnCreate(opts);
const res = await api('POST', '/auth/mfa/keys/finish?name=' + encodeURIComponent(nm.value.trim()), cred);
close();
toast((passkey ? 'Passkey' : 'Security key') + ' added');
toast('Passkey added');
afterAdd(res, onDone);
} catch (x) { e.textContent = keyError(x); btn.disabled = false; }
} },
h('h2', null, passkey ? 'Add a passkey' : 'Add a security key'),
h('p', null, passkey
? 'A passkey signs you in on its own, without username and password. It can live in your password manager, on this device (Touch ID, Face ID, Windows Hello) or on a YubiKey.'
: 'A YubiKey or other FIDO2 key, asked for after your password. Have it ready: your browser asks you to insert and touch it.'),
h('div', { class: 'field' }, h('label', { htmlFor: 'kn' }, 'Name'), nm, h('span', { class: 'hint' }, 'So you can tell your keys apart')),
h('h2', null, 'Add a passkey'),
h('p', null, 'A passkey signs you in on its own, without username and password, and also works as the second step after your password. It can live on this device (Touch ID, Face ID, Windows Hello), in your password manager, or on a YubiKey with a PIN set.'),
h('div', { class: 'field' }, h('label', { htmlFor: 'kn' }, 'Name'), nm, h('span', { class: 'hint' }, 'So you can tell your passkeys apart, for example "MacBook" or "YubiKey"')),
e,
h('div', { class: 'foot' }, h('button', { type: 'button', class: 'btn', onClick: close }, 'Cancel'), btn)));
nm.select();
@@ -910,7 +933,7 @@
}
for (const k of s.keys) {
rows.push(h('div', { class: 'mfarow' }, h('div', { class: 'grow' }, h('strong', null, k.name),
h('div', { class: 'hint' }, (k.passkey ? 'Passkey' : 'Security key') + ' · added ' + fmtDate(k.created) + ' · ' + (k.lastUsed ? 'last used ' + ago(k.lastUsed) : 'not used yet'))),
h('div', { class: 'hint' }, 'Added ' + fmtDate(k.created) + ' · ' + (k.lastUsed ? 'last used ' + ago(k.lastUsed) : 'not used yet'))),
h('button', { type: 'button', class: 'btn small', onClick: () => renameKey(k) }, 'Rename'),
h('button', { type: 'button', class: 'btn danger small', onClick: () => removeKey(k) }, 'Remove')));
}
@@ -922,9 +945,8 @@
rows.length ? h('div', { class: 'mfalist' }, rows) : h('div', { class: 'notice' }, s.required ? 'Two-step sign-in is required on this server.' : 'Two-step sign-in is off for your account.'),
h('div', { class: 'actions section' },
s.totp ? null : h('button', { type: 'button', class: 'btn primary', onClick: () => addTOTP(draw) }, 'Add authenticator app'),
keys ? h('button', { type: 'button', class: 'btn', onClick: () => addKey(false, draw) }, 'Add security key') : null,
keys ? h('button', { type: 'button', class: 'btn', onClick: () => addKey(true, draw) }, 'Add passkey') : null),
keys ? null : h('p', { class: 'hint section' }, 'Security keys and passkeys need this site on its domain name with a trusted certificate (Let\'s Encrypt or certificate files).'),
keys ? h('button', { type: 'button', class: 'btn', onClick: () => addPasskey(draw) }, 'Add passkey') : null),
keys ? null : h('p', { class: 'hint section' }, 'Passkeys need this site on its domain name with a trusted certificate (Let\'s Encrypt or certificate files).'),
].filter(Boolean));
};
draw();
@@ -997,6 +1019,8 @@
h('div', null, h('strong', null, 'Needs attention: '), failing.map((c) => c.name + ' (' + c.detail + ')').join(' · ')),
h('a', { class: 'btn small', href: '#/server' }, 'Health')) : null,
updateBanner(),
h('div', { class: 'tiles' },
h('div', { class: 'card tile' }, h('div', { class: 'k' }, 'Peers online'),
h('div', { class: 'v' }, String(st.peers.online), h('small', null, '/ ' + st.peers.total)),
@@ -1027,7 +1051,7 @@
h('td', { class: 'num' }, fmtBytes(p.stats.up24h)))))))
: h('p', { class: 'empty' }, 'No peers yet. ', h('a', { href: '#/peers/new' }, 'Add the first one'))),
logs ? h('section', { class: 'card' },
h('div', { class: 'cardhead' }, h('h2', null, 'Recent activity'), h('a', { href: '#/settings' }, 'Log')),
h('div', { class: 'cardhead' }, h('h2', null, 'Recent activity'), h('a', { href: '#/log' }, 'Log')),
logs.lines.length
? h('div', null, logs.lines.map((l) => h('div', { class: 'ev' }, h('time', { datetime: l.time }, fmtWhen(l.time)), h('span', null, describeAudit(l)))))
: h('p', { class: 'empty' }, 'No changes yet.')) : null));
@@ -1116,7 +1140,7 @@
return hit && keep;
});
tbody.replaceChildren(...sorted(rows).map((p) => h('tr', null,
h('td', null, h('a', { href: '#/peers/' + p.id }, h('strong', null, p.name)), p.note ? h('div', { class: 'note' }, p.note) : null),
h('td', null, h('a', { class: 'pname', href: '#/peers/' + p.id }, p.name), p.note ? h('div', { class: 'note' }, p.note) : null),
h('td', { class: 'mono' }, p.ipv4),
h('td', null, badge(peerState(p))),
h('td', { class: 'mono muted' }, p.stats.endpoint || '–',
@@ -1502,6 +1526,69 @@
const DNS_PRESETS = [['Quad9', '9.9.9.9, 149.112.112.112']];
// healthParts turns the server's checks into the Health card: the public
// address per IP family (from its uplink and public address checks) and,
// beside them, one row per other check with a plain-word status, the raw
// setting and, when it fails, what is wrong.
function healthParts(checks) {
const by = Object.fromEntries(checks.map((c) => [c.name, c]));
const addrs = [];
for (const fam of ['IPv4', 'IPv6']) {
const up = by[fam + ' uplink'], pub = by['Public ' + fam];
if (!up) continue;
const m = pub && pub.ok ? /^(\S+) \((.+)\)$/.exec(pub.detail) : null;
addrs.push({
label: 'Public ' + fam + (up.ok ? ' · ' + up.detail : ''),
ok: up.ok && (!pub || pub.ok),
value: m ? m[1] : (pub ? pub.detail : up.detail),
note: m ? m[2] : null,
mono: !!(pub && pub.ok),
});
}
const sysctl = (d) => d.replace(/^net\.ipv[46]\.(conf\.)?/, '');
const tiles = checks.filter((c) => !/^(IPv[46] uplink|Public IPv[46])$/.test(c.name)).map((c) => {
const t = { label: c.name, ok: c.ok, status: c.ok ? 'OK' : 'Problem', raw: null, problem: c.ok ? null : c.detail };
switch (c.name) {
case 'WireGuard interface': t.status = c.detail; t.problem = null; break;
case 'IPv4 forwarding': case 'IPv6 forwarding': t.status = c.ok ? 'On' : 'Off'; t.raw = sysctl(c.detail); t.problem = null; break;
case 'IPv6 router announcements': {
const [setting, ...why] = c.detail.split(': ');
t.label = 'Router announcements';
t.status = !c.ok ? 'Ignored' : setting.endsWith('=0') ? 'Not used' : 'Accepted';
t.raw = sysctl(setting);
t.problem = c.ok || !why.length ? null : why.join(': ');
break;
}
case 'nftables rules':
t.status = c.ok ? 'Present' : 'Missing';
if (/^table /.test(c.detail)) { t.raw = c.detail.replace(/ (present|missing)$/, ''); t.problem = null; }
break;
case 'Last apply':
if (c.ok) { const iso = c.detail.replace(/^applied /, ''); t.status = ago(iso); t.title = fmtStamp(iso); } else t.status = 'Failed';
break;
case 'Latency check': t.status = c.ok ? 'Tunnel ping works' : 'Failing'; break;
}
return t;
});
return { addrs, tiles, failing: checks.filter((c) => !c.ok).length, total: checks.length };
}
function healthCard(checks) {
const hp = healthParts(checks);
const dot = (ok) => [h('span', { class: ok ? 'dot ok' : 'dot bad' }), h('span', { class: 'sr' }, ok ? 'OK: ' : 'Problem: ')];
return h('section', { class: 'card', 'aria-labelledby': 'hc' },
h('div', { class: 'hchead' }, h('h2', { id: 'hc' }, 'Health'),
h('span', { class: hp.failing ? 'bad' : null }, hp.failing ? hp.failing + ' of ' + hp.total + ' checks failing' : 'All ' + hp.total + ' checks pass')),
h('div', { class: 'hcbody' },
hp.addrs.length ? h('div', { class: 'hcaddrs' }, hp.addrs.map((a) => h('div', { class: a.ok ? 'hcaddr' : 'hcaddr bad' },
h('div', { class: 'l' }, dot(a.ok), a.label),
h('div', { class: a.mono ? 'v mono' : 'v' }, a.value, a.note ? h('span', { class: 'n' }, ' ' + a.note) : null)))) : null,
h('div', { class: 'hclist' }, hp.tiles.map((t) => h('div', { class: t.ok ? 'hcrow' : 'hcrow bad', title: t.title || null },
dot(t.ok), h('span', { class: 'l' }, t.label),
h('span', { class: 'v' }, h('span', { class: 's' }, t.status), t.raw ? h('span', { class: 'r mono' }, t.raw) : null),
t.problem ? h('div', { class: 'p' }, t.problem) : null)))));
}
async function viewServer(wrap) {
const [srv, st] = await Promise.all([api('GET', '/server'), api('GET', '/status')]);
const orig = JSON.parse(JSON.stringify(srv));
@@ -1572,11 +1659,7 @@
fill(wrap,
h('div', null, h('h1', null, 'Server'), h('p', { class: 'sub' }, 'WireGuard interface, address plan, client defaults and firewall')),
result,
h('section', { class: 'card', 'aria-labelledby': 'hc' },
h('h2', { id: 'hc' }, 'Health'),
h('div', { style: { marginTop: '8px' } }, st.checks.map((c) => h('div', { class: 'chk' },
h('span', { class: c.ok ? 'dot ok' : 'dot bad' }), h('span', { class: 'sr' }, c.ok ? 'OK: ' : 'Problem: '),
h('b', null, c.name), h('span', { class: c.ok ? 'muted' : null }, c.detail))))),
healthCard(st.checks),
h('section', { class: 'card', 'aria-labelledby': 'if' },
h('h2', { id: 'if' }, 'Interface'),
@@ -1626,6 +1709,155 @@
bar);
}
// ---------- log ----------
async function viewLog(wrap) {
const s = await api('GET', '/settings');
let level = 'all';
const box = h('pre', { class: 'log tall', tabindex: '0', 'aria-label': 'Log lines, newest first' });
const pills = h('div', { class: 'pills', role: 'group', 'aria-label': 'Level filter' });
let audit = false;
const draw = async () => {
pills.replaceChildren(...[['all', 'All'], ['info', 'Info'], ['warn', 'Warn'], ['error', 'Error']].map(([k, t]) =>
h('button', { type: 'button', class: level === k && !audit ? 'pill on' : 'pill', 'aria-pressed': String(level === k && !audit), onClick: () => { level = k; audit = false; draw(); } }, t)),
h('button', { type: 'button', class: audit ? 'pill on' : 'pill', 'aria-pressed': String(audit), onClick: () => { audit = true; draw(); } }, 'Changes only'));
try {
const r = await api('GET', '/logs?limit=500&level=' + level + (audit ? '&audit=1' : ''));
box.textContent = r.lines.length ? r.lines.map(fmtLogLine).join('\n') : 'No entries at this level.';
} catch (e) { box.textContent = e.message; }
};
fill(wrap,
h('div', { class: 'head' },
h('div', null, h('h1', null, 'Log'), h('p', { class: 'sub' }, h('span', { class: 'mono' }, s.logPath), ' · level ' + s.log.level + ' · rotates at ' + s.log.maxSizeMB + ' MB, keeps ' + s.log.maxFiles + ' files')),
h('div', { class: 'actions' },
h('a', { class: 'btn', href: '#/settings#logs' }, 'Log settings'),
h('a', { class: 'btn', href: '/api/v1/logs/download' }, 'Download log'))),
h('section', { class: 'card', 'aria-label': 'Log lines' },
h('div', { class: 'cardhead' }, h('span', { class: 'muted' }, 'Newest first · refreshes every 10 s'), pills),
h('div', { class: 'section' }, box)));
every(10000, draw);
await draw();
}
// ---------- updates ----------
const HIDE_UPDATE = 'GHOSTWIRE.hideUpdate';
// updateBanner tells the Dashboard about a newer release until it is
// hidden for that version.
function updateBanner() {
const v = me.updateAvailable;
let hidden = null;
try { hidden = localStorage.getItem(HIDE_UPDATE); } catch { /* storage blocked */ }
if (!v || hidden === v) return null;
const box = h('div', { class: 'notice new' },
h('div', null, h('strong', null, 'GHOSTWIRE ' + v + ' is available. '), 'You\'re on v' + me.version.replace(/^v/, '') + '.'),
h('div', { class: 'actions' },
h('a', { class: 'btn small', href: '#/settings#updates' }, 'How to update'),
h('button', { type: 'button', class: 'btn small ghost', onClick: () => {
try { localStorage.setItem(HIDE_UPDATE, v); } catch { /* storage blocked */ }
box.remove();
} }, 'Hide until the next version')));
return box;
}
// mdInline turns **bold** and `code` into elements; everything else stays
// text.
const mdInline = (text) => text.split(/(\*\*[^*]+\*\*|`[^`]+`)/).filter(Boolean).map((t) =>
t.startsWith('**') ? h('strong', null, t.slice(2, -2)) : t.startsWith('`') ? h('code', null, t.slice(1, -1)) : t);
// releaseSummary picks the opening paragraph and the first bullet list out
// of the release notes; the full notes are a link away.
function releaseSummary(md) {
const lines = md.replace(/\r/g, '').split('\n');
const para = [];
for (const l of lines) {
if (!l.trim()) { if (para.length) break; continue; }
if (/^(#|- |\* |```|\|)/.test(l)) break;
para.push(l.trim());
}
const items = [];
let started = false;
for (const l of lines) {
const m = /^[-*] (.+)$/.exec(l);
if (m) { started = true; items.push(m[1]); } else if (started && l.trim()) break;
}
return { summary: para.join(' '), items };
}
function updatesCard(initial) {
let st = initial;
const card = h('section', { class: 'card', id: 'updates', 'aria-labelledby': 'upd' });
const setStatus = (next) => {
st = next;
me.updateAvailable = st.enabled && st.available ? st.latest.version : undefined;
drawUpdateHint();
draw();
};
const checkNow = async (btn) => {
if (btn) { btn.disabled = true; btn.textContent = 'Checking…'; }
try { setStatus(await api('POST', '/updates/check')); } catch (x) { toast(x.message, true); draw(); }
};
const save = async (updates) => {
try {
await api('PATCH', '/settings', { updates });
const s = await api('GET', '/settings');
if (s.updates.enabled) await checkNow(); else setStatus(s.updates);
} catch (x) { toast(x.message, true); draw(); }
};
const SOURCES = [['gitea', 'Gitea', 'git.redetzke.aero/Redetzke/GHOSTWIRE'], ['github', 'GitHub', 'github.com/danielredetzke/GHOSTWIRE']];
const srcName = () => SOURCES.find(([k]) => k === st.source)[1];
function draw() {
const cur = 'v' + st.current.replace(/^v/, '');
const rel = st.latest;
const notes = rel && st.available ? releaseSummary(rel.notes || '') : null;
const cmds = st.available && st.file ? [
'curl -fLO ' + st.fileUrl,
'curl -fLO ' + st.sumsUrl,
'sha256sum -c --ignore-missing SHA256SUMS',
'chmod +x ' + st.file,
'sudo ./' + st.file + ' update',
].join('\n') : null;
fill(card,
h('div', { class: 'cardhead' },
h('h3', { id: 'upd' }, 'Updates'),
st.enabled ? h('span', { class: 'muted' }, st.checked ? 'Last checked ' + ago(st.checked) : 'Not checked yet') : null),
h('div', { class: 'upvers' },
h('div', { class: 'upbox' }, h('span', null, 'Running'), h('strong', { class: 'mono' }, cur)),
rel ? h('div', { class: st.available ? 'upbox new' : 'upbox' }, h('span', null, 'Latest release'), h('strong', { class: 'mono' }, rel.version)) : null,
h('div', { class: 'upbox' }, h('span', null, 'This server'), h('strong', null, st.arch ? 'Linux · ' + st.arch : 'No release file for this platform'))),
st.enabled && st.error ? h('div', { class: 'notice err', role: 'alert' },
h('div', null, 'The last check failed: ' + st.error + '. ' + (st.lastOk ? 'Last worked ' + ago(st.lastOk) + '. ' : '') + 'Try the other source.')) : null,
rel && !st.available ? h('p', { class: 'uptodate' }, h('span', { class: 'dot ok' }), 'GHOSTWIRE is up to date.') : null,
notes ? h('div', { class: 'upnotes' },
h('div', { class: 'hd' }, h('strong', null, 'What\'s new in ' + rel.version),
h('span', { class: 'muted' }, 'Released ' + fmtDate(rel.published) + ' · from ' + srcName()),
h('a', { href: rel.url, target: '_blank', rel: 'noopener' }, 'Full notes on ' + srcName())),
/security/i.test(notes.summary) ? h('p', { class: 'notice' }, 'Includes security fixes.') : null,
notes.summary ? h('p', null, mdInline(notes.summary)) : null,
notes.items.length ? h('ul', null, notes.items.map((t) => h('li', null, mdInline(t)))) : null) : null,
cmds ? h('div', { class: 'upcmd' },
h('div', { class: 'hd' }, h('strong', null, 'Update this server'), h('span', { class: 'muted' }, 'Run on the server. VPN connections stay up.')),
h('pre', { class: 'code' }, cmds),
h('div', null, h('button', { type: 'button', class: 'btn small', onClick: () => copy(cmds) }, 'Copy commands'))) : null,
st.available && !st.file ? h('p', null, 'No release file is built for this platform. ', h('a', { href: rel.url, target: '_blank', rel: 'noopener' }, 'See the release')) : null,
h('fieldset', { class: 'section' }, h('legend', { class: 'legend' }, 'Release source'),
h('div', { class: 'grid' }, SOURCES.map(([k, name, where]) => h('label', { class: 'opt' },
h('input', { type: 'radio', name: 'upsrc', value: k, checked: st.source === k, onChange: () => save({ source: k }) }),
h('span', null, h('strong', null, name), h('br'), h('span', { class: 'hint mono' }, where))))),
h('span', { class: 'hint' }, 'Both carry the same releases and files. The check, the release notes and the download links use the source you pick.')),
h('div', { class: 'uprow' },
h('label', { class: 'check' },
h('input', { type: 'checkbox', checked: st.enabled, onChange: (e) => save({ check: e.target.checked }) }),
h('span', null, 'Check for updates once a day', h('br'),
h('span', { class: 'hint' }, 'Asks ' + new URL(st.sourceUrl).host + ' for the latest release. Nothing about this server is sent.'))),
st.enabled ? h('button', { type: 'button', class: 'btn small', onClick: (e) => checkNow(e.currentTarget) }, 'Check now') : null));
}
draw();
return card;
}
// ---------- settings ----------
// ---------- my account ----------
@@ -1733,7 +1965,6 @@
return;
}
const [s, tk, us] = await Promise.all([api('GET', '/settings'), api('GET', '/tokens'), api('GET', '/users')]);
let logLevelFilter = 'all';
// users
const userBody = h('tbody');
@@ -1839,7 +2070,7 @@
h('div', { class: 'foot' }, h('button', { type: 'button', class: 'btn', onClick: close }, 'Cancel'), h('button', { type: 'submit', class: 'btn primary' }, 'Reset password'))));
};
const resetMFA = async (u) => {
if (!await confirmDialog({ title: 'Reset two-step sign-in for ' + u.username + '?', text: 'Their authenticator app, security keys, passkeys and recovery codes are removed. They sign in with their password and can set it up again.', ok: 'Reset', danger: true })) return;
if (!await confirmDialog({ title: 'Reset two-step sign-in for ' + u.username + '?', text: 'Their authenticator app, passkeys and recovery codes are removed. They sign in with their password and can set it up again.', ok: 'Reset', danger: true })) return;
try { await api('POST', '/users/' + u.id + '/reset-mfa'); toast('Two-step sign-in reset for ' + u.username); reloadUsers(); } catch (x) { toast(x.message, true); }
};
const deleteUser = async (u) => {
@@ -1898,20 +2129,13 @@
try { await api('DELETE', '/tokens/' + t.id); toast('Revoked ' + t.name); reloadTokens(); } catch (e) { toast(e.message, true); }
};
// logs
const logBox = h('pre', { class: 'log', tabindex: '0', 'aria-label': 'Log lines, newest first' });
const logPills = h('div', { class: 'pills', role: 'group', 'aria-label': 'Level filter' });
const drawLogs = async () => {
logPills.replaceChildren(...[['all', 'All'], ['info', 'Info'], ['warn', 'Warn'], ['error', 'Error']].map(([k, t]) =>
h('button', { type: 'button', class: logLevelFilter === k ? 'pill on' : 'pill', 'aria-pressed': String(logLevelFilter === k), onClick: () => { logLevelFilter = k; drawLogs(); } }, t)));
try {
const r = await api('GET', '/logs?limit=200&level=' + logLevelFilter);
logBox.textContent = r.lines.length ? r.lines.map(fmtLogLine).join('\n') : 'No entries at this level.';
} catch (e) { logBox.textContent = e.message; }
};
const levelSel = h('select', { id: 'lv', onChange: async (e) => {
try { await api('PATCH', '/settings', { log: { ...s.log, level: e.target.value } }); s.log.level = e.target.value; toast('Log level: ' + e.target.value); } catch (x) { toast(x.message, true); }
} }, ['debug', 'info', 'warn', 'error'].map((l) => h('option', { value: l, selected: s.log.level === l }, l)));
const levelSel = h('select', { id: 'lv' }, [['debug', 'Debug: everything'], ['info', 'Info'], ['warn', 'Warnings and errors'], ['error', 'Errors only']].map(([l, t]) => h('option', { value: l, selected: s.log.level === l }, t)));
const sessSel = h('select', { id: 'st', onChange: async (e) => {
try { await api('PATCH', '/settings', { web: { ...s.web, sessionHours: Number(e.target.value) } }); s.web.sessionHours = Number(e.target.value); toast('Session length saved'); } catch (x) { toast(x.message, true); }
} }, [[1, '1 hour'], [12, '12 hours'], [24, '1 day'], [168, '7 days']].map(([v, t]) => h('option', { value: String(v), selected: s.web.sessionHours === v }, t)));
const geoBox = h('input', { type: 'checkbox', id: 'geo', checked: s.stats.geoip !== false, onChange: async (e) => {
try { await api('PATCH', '/settings', { stats: { ...s.stats, geoip: e.target.checked } }); toast(e.target.checked ? 'Country lookup on' : 'Country lookup off'); } catch (x) { e.target.checked = !e.target.checked; toast(x.message, true); }
} });
// sign-in rules
const requireBox = h('input', { type: 'checkbox', id: 'rq', checked: s.signin.requireMfa, onChange: async (e) => {
@@ -1961,7 +2185,6 @@
const logFiles = h('input', { id: 'rf', type: 'number', min: '1', max: '100', value: s.log.maxFiles, inputMode: 'numeric' });
const hourly = presetSelect('rh', s.stats.hourlyHours, [[24, '1 day'], [48, '2 days'], [168, '7 days'], [336, '14 days'], [744, '31 days']], 'hours');
const daily = presetSelect('rd', s.stats.dailyDays, [[30, '30 days'], [90, '90 days'], [180, '6 months'], [400, '13 months'], [730, '2 years'], [1825, '5 years'], [3660, '10 years']], 'days');
const geo = h('input', { type: 'checkbox', checked: s.stats.geoip !== false });
const geoStatus = s.geo && s.geo.updated ? 'Database from ' + fmtDate(s.geo.updated) + '.' : 'Not downloaded yet.';
const diskHint = h('span', { class: 'hint' });
const drawDiskHint = () => {
@@ -1980,14 +2203,45 @@
if (shrinks && !await confirmDialog({ title: 'Delete older data?', text: 'The new limits are lower: older log files and traffic history beyond them are deleted. This cannot be undone.', ok: 'Save and delete', danger: true })) return;
try {
await api('PATCH', '/settings', {
log: { ...s.log, maxSizeMB: next.maxSizeMB, maxFiles: next.maxFiles },
stats: { hourlyHours: next.hourlyHours, dailyDays: next.dailyDays, geoip: geo.checked },
log: { ...s.log, level: levelSel.value, maxSizeMB: next.maxSizeMB, maxFiles: next.maxFiles },
stats: { ...s.stats, hourlyHours: next.hourlyHours, dailyDays: next.dailyDays },
});
toast('Retention saved');
toast('Logs & history saved');
render();
} catch (x) { retErr.textContent = x.message; }
};
// copies of config.json that updates leave behind
const copies = h('div', { class: 'section' });
const drawCopies = async () => {
let list;
try { list = (await api('GET', '/update-backups')).backups; } catch (x) { fill(copies, h('p', { class: 'err-text' }, x.message)); return; }
const remove = async (b) => {
if (!await confirmDialog({ title: 'Remove ' + b.name + '?', text: 'This copy of your settings from ' + b.version + ' is deleted from the server. It cannot be restored.', ok: 'Remove', danger: true })) return;
try { await api('DELETE', '/update-backups/' + encodeURIComponent(b.name)); toast('Removed ' + b.name); drawCopies(); } catch (x) { toast(x.message, true); }
};
const removeAll = async () => {
const n = list.length;
if (!await confirmDialog({ title: n === 1 ? 'Remove the copy?' : 'Remove all ' + n + ' copies?', text: 'They are deleted from the server and cannot be restored. Your current settings are not affected.', ok: 'Remove all', danger: true })) return;
try { await api('DELETE', '/update-backups'); toast(n === 1 ? 'Removed 1 copy' : 'Removed ' + n + ' copies'); drawCopies(); } catch (x) { toast(x.message, true); }
};
const total = list.reduce((t, b) => t + b.size, 0);
fill(copies,
h('div', { class: 'cardhead' },
h('div', null, h('strong', null, 'Copies made by updates'),
h('p', { class: 'lead', style: { marginBottom: '0' } }, 'Each update saves the previous config.json next to it. They hold the same secrets as a backup.')),
list.length ? h('button', { type: 'button', class: 'btn', onClick: removeAll }, 'Remove all') : null),
list.length ? h('div', { class: 'tbl section' }, h('table', { class: 'narrow' },
h('thead', null, h('tr', null, h('th', null, 'File'), h('th', null, 'From version'), h('th', null, 'Saved'), h('th', { class: 'num' }, 'Size'), h('th', null, h('span', { class: 'sr' }, 'Actions')))),
h('tbody', null, list.map((b, i) => h('tr', null,
h('td', null, h('span', { class: 'mono' }, b.name), i === 0 ? h('span', { class: 'tag plain' }, 'Newest') : null),
h('td', { class: 'mono' }, b.version),
h('td', null, fmtStamp(b.modified)),
h('td', { class: 'num' }, fmtBytes(b.size)),
h('td', { class: 'num' }, h('button', { type: 'button', class: 'btn small', onClick: () => remove(b) }, 'Remove'))))))) : h('p', { class: 'muted', style: { margin: '12px 0 0' } }, 'No copies from updates.'),
list.length ? h('p', { class: 'hint', style: { margin: '8px 0 0' } }, list.length + (list.length === 1 ? ' copy, ' : ' copies, ') + fmtBytes(total) + ' next to config.json. After each update, only the newest 3 are kept.') : null);
};
// backup
const restoreInput = h('input', { type: 'file', accept: 'application/json,.json', hidden: true, onChange: async (e) => {
const f = e.target.files[0];
@@ -2003,85 +2257,94 @@
} catch (x) { toast(x.message, true); }
} });
const groupHead = (id, title, text) => h('div', { class: 'group', id }, h('h2', null, title), h('p', null, text));
fill(wrap,
h('div', null, h('h1', null, 'Settings'), h('p', { class: 'sub' }, 'Users, web interface, API access for the iOS app, logs, data retention and backups')),
h('div', null, h('h1', null, 'Settings'), h('p', { class: 'sub' }, 'Who can sign in, the web interface, logs and history, updates and backups')),
restartBox,
groupHead('g-access', 'Access', 'Who can sign in, and how.'),
h('section', { class: 'card flush', 'aria-labelledby': 'usr' },
h('div', { class: 'cardhead' },
h('div', null, h('h2', { id: 'usr' }, 'Users'), h('p', { class: 'lead', style: { marginBottom: '0' } }, 'Everyone here is an admin. You cannot delete yourself, so one user always remains.')),
h('div', null, h('h3', { id: 'usr' }, 'Users'), h('p', { class: 'lead', style: { marginBottom: '0' } }, 'Everyone here is an admin. You cannot delete yourself, so one user always remains.')),
h('button', { type: 'button', class: 'btn primary', onClick: addUser }, 'Add user')),
h('div', { class: 'tbl' }, h('table', null,
h('thead', null, h('tr', null, h('th', null, 'User'), h('th', null, 'Status'), h('th', null, 'Two-step'), h('th', null, 'Last sign-in'), h('th', null, 'App tokens'), h('th', null, 'Created'), h('th', null, h('span', { class: 'sr' }, 'Actions')))),
userBody))),
h('section', { class: 'card', 'aria-labelledby': 'sgn' },
h('h2', { id: 'sgn' }, 'Sign-in'),
h('p', { class: 'lead' }, 'Everyone sets up two-step sign-in under My account: an authenticator app, security keys such as a YubiKey, or passkeys. Changes apply immediately.'),
h('div', { class: 'cardhead' }, h('h3', { id: 'sgn' }, 'Sign-in'), h('span', { class: 'saves' }, 'Saves right away')),
h('p', { class: 'lead' }, 'Everyone sets up two-step sign-in under My account: an authenticator app or passkeys, including on a YubiKey.'),
h('label', { class: 'check' }, requireBox, h('span', null, 'Require two-step sign-in for everyone', h('br'),
h('span', { class: 'hint' }, 'Users without it are asked to set it up right after their password. To help someone who lost their phone or key, use Edit → Reset two-step sign-in.')))),
h('span', { class: 'hint' }, 'Users without it are asked to set it up right after their password. To help someone who lost their phone or key, use Edit → Reset two-step sign-in.'))),
h('div', { class: 'grid section' },
h('div', { class: 'field' }, h('label', { htmlFor: 'st' }, 'Stay signed in for'), sessSel, h('span', { class: 'hint' }, 'Applies to new sign-ins')),
h('div', { class: 'field' }), h('div', { class: 'field' }))),
h('section', { class: 'card', 'aria-labelledby': 'api' },
h('div', { class: 'cardhead' },
h('div', null, h('h3', { id: 'api' }, 'iOS app and API tokens'), h('p', { class: 'lead', style: { marginBottom: '0' } }, 'For the iOS app and scripts. A token appears once when you create it, and only a hash is stored.')),
h('button', { type: 'button', class: 'btn primary', onClick: () => pairDialog(reloadTokens) }, 'Pair iOS app')),
h('div', { class: 'tbl section' }, h('table', { class: 'narrow' },
h('thead', null, h('tr', null, h('th', null, 'Name'), h('th', null, 'Owner'), h('th', null, 'Access'), h('th', null, 'Created'), h('th', null, 'Last used'), h('th', null, h('span', { class: 'sr' }, 'Actions')))),
tbody))),
groupHead('g-web', 'Web interface', 'Where this interface listens and what strangers see.'),
h('form', { class: 'card', onSubmit: saveWeb, 'aria-labelledby': 'web' },
h('h2', { id: 'web' }, 'Web interface'),
h('p', { class: 'lead' }, 'Changes take effect after the service restarts.'),
h('div', { class: 'cardhead' }, h('h3', { id: 'web' }, 'Address and HTTPS'), h('span', { class: 'saves' }, 'Save, then restart')),
h('p', { class: 'lead' }, 'Usually set once during install.'),
h('div', { class: 'grid' },
h('div', { class: 'field' }, h('label', { htmlFor: 'la' }, 'Listen address'), h('input', { id: 'la', class: 'mono', value: web.listen, onInput: (e) => { web.listen = e.target.value.trim(); } })),
h('div', { class: 'field' }, h('label', { htmlFor: 'hl' }, 'HTTP listen address'), h('input', { id: 'hl', class: 'mono', value: web.httpListen, placeholder: 'off', onInput: (e) => { web.httpListen = e.target.value.trim(); } }), h('span', { class: 'hint' }, 'Redirects to HTTPS and answers Let\'s Encrypt http-01 checks. Empty turns it off')),
h('div', { class: 'field' }, h('label', { htmlFor: 'tls' }, 'HTTPS'), modeSel),
h('div', { class: 'field' }, h('label', { htmlFor: 'st' }, 'Session length'), h('select', { id: 'st', onChange: (e) => { web.sessionHours = Number(e.target.value); } },
[[1, '1 hour'], [12, '12 hours'], [24, '1 day'], [168, '7 days']].map(([v, t]) => h('option', { value: String(v), selected: web.sessionHours === v }, t)))),
s.fingerprint ? h('div', { class: 'field' }, h('label', { htmlFor: 'fp' }, 'Certificate fingerprint (SHA-256)'), h('input', { id: 'fp', class: 'mono', value: s.fingerprint, readOnly: true }), h('span', { class: 'hint' }, 'The iOS app pins this when pairing')) : null),
h('div', { class: 'section' }, fAcme, fFiles),
webErr,
h('div', { class: 'formfoot' }, h('button', { type: 'submit', class: 'btn primary' }, 'Save'))),
h('section', { class: 'card', 'aria-labelledby': 'dcy' },
h('h2', { id: 'dcy' }, 'Decoy'),
h('p', { class: 'lead' }, 'Shows an ordinary web server page instead of this interface. The iOS app and setup links keep working. Changes apply immediately.'),
h('div', { class: 'cardhead' }, h('h3', { id: 'dcy' }, 'Decoy'), h('span', { class: 'saves' }, 'Saves right away')),
h('p', { class: 'lead' }, 'Shows an ordinary web server page instead of this interface. The iOS app and setup links keep working.'),
h('label', { class: 'check' }, decoyBox, h('span', null, 'Decoy', h('br'),
h('span', { class: 'hint' }, 'Hides the web interface. Turn it off again in the iOS app.'))),
h('div', { class: 'grid section' },
h('div', { class: 'field' }, h('label', { htmlFor: 'dp' }, 'Decoy page'), decoySel))),
h('section', { class: 'card', 'aria-labelledby': 'api' },
h('div', { class: 'cardhead' },
h('div', null, h('h2', { id: 'api' }, 'API tokens'), h('p', { class: 'lead', style: { marginBottom: '0' } }, 'For the iOS app and scripts. A token appears once when you create it, and only a hash is stored.')),
h('button', { type: 'button', class: 'btn primary', onClick: () => pairDialog(reloadTokens) }, 'Pair iOS app')),
h('div', { class: 'tbl section' }, h('table', { class: 'narrow' },
h('thead', null, h('tr', null, h('th', null, 'Name'), h('th', null, 'Owner'), h('th', null, 'Access'), h('th', null, 'Created'), h('th', null, 'Last used'), h('th', null, h('span', { class: 'sr' }, 'Actions')))),
tbody))),
h('section', { class: 'card', 'aria-labelledby': 'lg' },
h('div', { class: 'cardhead' },
h('div', null, h('h2', { id: 'lg' }, 'Log'), h('p', { class: 'lead', style: { marginBottom: '0' } }, h('span', { class: 'mono' }, s.logPath), ' · rotates at ' + s.log.maxSizeMB + ' MB, keeps ' + s.log.maxFiles + ' files')),
logPills),
h('div', { class: 'section' }, logBox),
h('div', { class: 'grid section' },
h('div', { class: 'field' }, h('label', { htmlFor: 'lv' }, 'Log level'), levelSel),
h('div', { class: 'field', style: { justifyContent: 'flex-end' } }, h('a', { class: 'btn', href: '/api/v1/logs/download' }, 'Download log')))),
groupHead('logs', 'Logs & history', 'What the server records and for how long. The log itself is on the Log page.'),
h('form', { class: 'card', onSubmit: saveRetention, 'aria-labelledby': 'ret' },
h('h2', { id: 'ret' }, 'Data retention'),
h('p', { class: 'lead' }, 'How much log and traffic history is kept. Changes apply immediately, without a restart.'),
h('div', { class: 'cardhead' }, h('h3', { id: 'ret' }, 'Log and traffic history'), h('span', { class: 'saves' }, 'Save, no restart')),
h('p', { class: 'lead' }, 'Lower limits delete older data when you save. All-time totals are always kept.'),
h('div', { class: 'grid' },
h('div', { class: 'field' }, h('label', { htmlFor: 'lv' }, 'Log level'), levelSel),
h('div', { class: 'field' }, h('label', { htmlFor: 'rs' }, 'Log file size (MB)'), logSize, h('span', { class: 'hint' }, 'The log starts a new file at this size. 1–1000')),
h('div', { class: 'field' }, h('label', { htmlFor: 'rf' }, 'Old log files kept'), logFiles, diskHint),
h('div', { class: 'field' }, h('label', { htmlFor: 'rf' }, 'Old log files kept'), logFiles, diskHint)),
h('div', { class: 'grid section' },
h('div', { class: 'field' }, h('label', { htmlFor: 'rh' }, 'Hourly traffic history'), hourly, h('span', { class: 'hint' }, 'Used by the 24-hour charts')),
h('div', { class: 'field' }, h('label', { htmlFor: 'rd' }, 'Daily traffic history'), daily, h('span', { class: 'hint' }, 'Used by the 7- and 30-day charts and the connection history. All-time totals are always kept'))),
h('label', { class: 'check section' }, geo, h('span', null, 'Show country and network of peer addresses', h('br'),
h('span', { class: 'hint' }, 'Downloads the free DB-IP Lite databases (about 20 MB) once a month and looks addresses up on this server only. ' + geoStatus))),
h('div', { class: 'field' }, h('label', { htmlFor: 'rd' }, 'Daily traffic history'), daily, h('span', { class: 'hint' }, 'Used by the 7- and 30-day charts and the connection history')),
h('div', { class: 'field' })),
retErr,
h('div', { class: 'formfoot' }, h('button', { type: 'submit', class: 'btn primary' }, 'Save retention'))),
h('div', { class: 'formfoot' }, h('a', { class: 'btn', href: '#/log' }, 'Open the log'), h('button', { type: 'submit', class: 'btn primary' }, 'Save'))),
h('section', { class: 'card', 'aria-labelledby': 'geo-h' },
h('div', { class: 'cardhead' }, h('h3', { id: 'geo-h' }, 'Country and network lookup'), h('span', { class: 'saves' }, 'Saves right away')),
h('label', { class: 'check' }, geoBox, h('span', null, 'Show country and network of peer addresses', h('br'),
h('span', { class: 'hint' }, 'Downloads the free DB-IP Lite databases (about 20 MB) once a month and looks addresses up on this server only. ' + geoStatus)))),
groupHead('g-upkeep', 'Upkeep', 'New versions and copies of your settings.'),
updatesCard(s.updates),
h('section', { class: 'card', 'aria-labelledby': 'bk' },
h('h2', { id: 'bk' }, 'Backup & restore'),
h('p', { class: 'lead' }, 'A backup is a copy of config.json with server key, peers, tokens and settings. Keep it safe: it contains the server\'s private key.'),
h('h3', { id: 'bk' }, 'Backup & restore'),
h('p', { class: 'lead' }, 'A backup is a copy of config.json with server key, peers, tokens and settings. Keep it safe: it contains the server\'s private key, preshared keys and authenticator app secrets.'),
h('div', { class: 'actions' },
h('a', { class: 'btn', href: '/api/v1/backup' }, 'Download backup'),
h('button', { type: 'button', class: 'btn', onClick: () => restoreInput.click() }, 'Restore from file…'),
restoreInput)));
await drawLogs();
restoreInput),
copies));
drawCopies();
const jumpTo = location.hash.split('#')[2];
if (jumpTo) document.getElementById(jumpTo)?.scrollIntoView();
}
render();
})();
+61 -25
View File
@@ -10,6 +10,7 @@ import (
"fmt"
"net"
"net/http"
"net/netip"
"strings"
"sync"
"time"
@@ -26,12 +27,23 @@ const (
argonKeyLen = 32
)
// Every argon2 run takes argonMemory (64 MiB). argonSlots caps how many run
// at once, so a burst of sign-ins cannot run the server out of memory: two
// slots are 128 MiB at most.
var argonSlots = make(chan struct{}, 2)
func argonKey(pw, salt []byte, t, m uint32, p uint8, n uint32) []byte {
argonSlots <- struct{}{}
defer func() { <-argonSlots }()
return argon2.IDKey(pw, salt, t, m, p, n)
}
func hashPassword(pw string) (string, error) {
salt := make([]byte, 16)
if _, err := rand.Read(salt); err != nil {
return "", err
}
key := argon2.IDKey([]byte(pw), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
key := argonKey([]byte(pw), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
b64 := base64.RawStdEncoding
return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
argon2.Version, argonMemory, argonTime, argonThreads, b64.EncodeToString(salt), b64.EncodeToString(key)), nil
@@ -54,7 +66,7 @@ func verifyPassword(encoded, pw string) bool {
if err1 != nil || err2 != nil {
return false
}
got := argon2.IDKey([]byte(pw), salt, t, m, p, uint32(len(want)))
got := argonKey([]byte(pw), salt, t, m, p, uint32(len(want)))
return subtle.ConstantTimeCompare(got, want) == 1
}
@@ -139,14 +151,18 @@ type Auth struct {
mu sync.Mutex
sessions map[string]*session
used map[string]tokenUse
logins map[string]tokenUse // last sign-in per user ID
fails map[string]*failState
logins map[string]tokenUse // last sign-in per user ID
fails map[string]*failState // by lockKey
waiting int // sign-ins waiting for or running a password check
mfa mfaState
}
const (
maxFailures = 5
lockoutTime = 15 * time.Minute
// maxWaiting sign-ins may wait for a password check; more are turned
// away until the queue is shorter.
maxWaiting = 16
)
func newAuth(s *Store) *Auth {
@@ -155,23 +171,51 @@ func newAuth(s *Store) *Auth {
func cookieName() string { return appName + "_session" }
var errLocked = errors.New("too many failed attempts, try again later")
var (
errLocked = errors.New("too many failed attempts, try again later")
errBusy = errors.New("too many sign-ins at once, try again in a moment")
)
// lockKey is what failed sign-ins are counted by: the IPv4 address, or the
// /64 network of an IPv6 address, since one device can pick any address in
// its /64.
func lockKey(ip string) string {
a, err := netip.ParseAddr(ip)
if err != nil || a.Unmap().Is4() {
return ip
}
p, _ := a.Prefix(64)
return p.String()
}
// Login checks the credentials and returns a new session id, or, for a user
// with two-step sign-in, a ticket for the second step.
func (a *Auth) Login(user, pw, ip string) (sessionID, ticket string, err error) {
a.mu.Lock()
f := a.fails[ip]
if f != nil && time.Now().Before(f.until) {
a.mu.Unlock()
return "", "", errLocked
}
a.mu.Unlock()
cfg := a.store.Get()
if !cfg.passwordSet() {
return "", "", errors.New("no password is set; run: " + appName + " passwd")
}
// The attempt counts as failed before the password is checked, so
// parallel attempts cannot get past the lockout; a right password takes
// it back.
a.mu.Lock()
if a.lockedLocked(ip) {
a.mu.Unlock()
return "", "", errLocked
}
if a.waiting >= maxWaiting {
a.mu.Unlock()
return "", "", errBusy
}
a.waiting++
undo := a.failLocked(ip)
a.mu.Unlock()
defer func() {
a.mu.Lock()
a.waiting--
a.mu.Unlock()
}()
// An unknown username costs as much time as a wrong password, so the
// answer time does not tell which usernames exist.
u := cfg.userByName(strings.TrimSpace(user))
@@ -185,21 +229,13 @@ func (a *Auth) Login(user, pw, ip string) (sessionID, ticket string, err error)
a.mu.Lock()
defer a.mu.Unlock()
if !okUser || !okPw {
if f == nil {
f = &failState{}
a.fails[ip] = f
}
f.count++
if f.count >= maxFailures {
f.count = 0
f.until = time.Now().Add(lockoutTime)
}
return "", "", errors.New("wrong username or password")
}
undo()
if u.hasMFA() {
return "", a.newTicketLocked(u, ip), nil
}
delete(a.fails, ip)
delete(a.fails, lockKey(ip))
a.logins[u.ID] = tokenUse{At: time.Now(), IP: ip}
return a.newSessionLocked(cfg, u, sessionInfo{Started: time.Now(), IP: ip}), "", nil
}
@@ -321,9 +357,9 @@ func (a *Auth) sweep() {
delete(a.sessions, id)
}
}
for ip, f := range a.fails {
for key, f := range a.fails {
if now.After(f.until) && f.count == 0 {
delete(a.fails, ip)
delete(a.fails, key)
}
}
for id, t := range a.mfa.tickets {
+25 -7
View File
@@ -27,15 +27,24 @@ type Config struct {
APITokens []APIToken `json:"apiTokens"`
// Admin is the single account of config version 1; applyDefaults moves
// it into Users.
Admin *Admin `json:"admin,omitempty"`
Server Server `json:"server"`
Peers []Peer `json:"peers"`
Log LogConfig `json:"log"`
Stats StatsConfig `json:"stats"`
Decoy DecoyConfig `json:"decoy"`
SignIn SignInConfig `json:"signin"`
Admin *Admin `json:"admin,omitempty"`
Server Server `json:"server"`
Peers []Peer `json:"peers"`
Log LogConfig `json:"log"`
Stats StatsConfig `json:"stats"`
Decoy DecoyConfig `json:"decoy"`
SignIn SignInConfig `json:"signin"`
Updates UpdatesConfig `json:"updates"`
}
// UpdatesConfig sets the daily check for a newer release.
type UpdatesConfig struct {
Check *bool `json:"check,omitempty"` // default on
Source string `json:"source"` // gitea | github, see updateSources
}
func (c UpdatesConfig) checkEnabled() bool { return c.Check == nil || *c.Check }
// SignInConfig holds the rules for signing in to the web interface.
type SignInConfig struct {
// RequireMFA sends users without two-step sign-in to set it up before
@@ -204,6 +213,9 @@ func (c *Config) applyDefaults() {
c.Users = []User{u}
}
c.Admin = nil
for i := range c.Users {
dropSecurityKeys(&c.Users[i])
}
for i := range c.APITokens {
if c.APITokens[i].UserID == "" {
c.APITokens[i].UserID = c.Users[0].ID // tokens from before users existed
@@ -243,6 +255,9 @@ func (c *Config) applyDefaults() {
if c.Decoy.Page == "" {
c.Decoy.Page = "nginx"
}
if c.Updates.Source == "" {
c.Updates.Source = "gitea"
}
if c.APITokens == nil {
c.APITokens = []APIToken{}
}
@@ -379,6 +394,9 @@ func (c *Config) validate() error {
if _, ok := decoyPages[c.Decoy.Page]; !ok {
return fmt.Errorf("unknown decoy page %q", c.Decoy.Page)
}
if _, ok := updateSources[c.Updates.Source]; !ok {
return fmt.Errorf("update source must be gitea or github")
}
switch c.Web.TLS.Mode {
case "acme":
if c.Web.TLS.Domain == "" {
+11
View File
@@ -3,6 +3,8 @@ package main
import (
"log/slog"
"net/netip"
"os"
"strings"
"sync"
"time"
)
@@ -38,6 +40,15 @@ type Kernel interface {
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
// remembers the outcome for the health report.
type Reconciler struct {
+14 -9
View File
@@ -3,13 +3,13 @@
package main
import (
"cmp"
"errors"
"fmt"
"net"
"net/netip"
"os"
"slices"
"strings"
"github.com/vishvananda/netlink"
"golang.zx2c4.com/wireguard/wgctrl"
@@ -318,14 +318,6 @@ func publicAddr(uplink string, v6 bool) (bool, string) {
return false, "no address on " + uplink
}
func readSysctl(path string) string {
b, err := os.ReadFile(path)
if err != nil {
return ""
}
return strings.TrimSpace(string(b))
}
func (k *linuxKernel) Checks(c *Config) []Check {
var out []Check
link, err := netlink.LinkByName(c.Server.Interface)
@@ -341,6 +333,19 @@ func (k *linuxKernel) Checks(c *Config) []Check {
v := readSysctl("/proc/sys/net/ipv6/conf/all/forwarding")
out = append(out, Check{"IPv6 forwarding", v == "1", "net.ipv6.conf.all.forwarding=" + v})
}
// With IPv6 forwarding on, accept_ra 1 means router announcements are
// ignored: an IPv6 route learned from them expires (see sysctlConf).
if readSysctl("/proc/sys/net/ipv6/conf/all/forwarding") == "1" {
up := cmp.Or(k.Uplink(c, true), k.Uplink(c, false))
if ra := readSysctl("/proc/sys/net/ipv6/conf/" + up + "/accept_ra"); up != "" && ra != "" {
ok := ra != "1"
detail := "net.ipv6.conf." + up + ".accept_ra=" + ra
if !ok {
detail += ": IPv6 from router announcements stops working; run " + appName + " update"
}
out = append(out, Check{"IPv6 router announcements", ok, detail})
}
}
ok, detail := firewallPresent()
out = append(out, Check{"nftables rules", ok, detail})
up4 := k.Uplink(c, false)
+3 -2
View File
@@ -222,15 +222,16 @@ func run(configPath string) error {
auth := newAuth(store)
app := &App{
store: store, kernel: kernel, recon: recon, stats: stats, auth: auth, tls: webTLS,
logPath: logPath, logw: logw, geo: geo, started: time.Now(), shutdown: shutdown,
logPath: logPath, logw: logw, geo: geo, updates: newUpdater(cfg.Updates), started: time.Now(), shutdown: shutdown,
}
var wg sync.WaitGroup
wg.Add(4)
wg.Add(5)
go func() { defer wg.Done(); recon.Run(stop) }()
go func() { defer wg.Done(); stats.Run(stop) }()
go func() { defer wg.Done(); stats.RunPings(stop) }()
go func() { defer wg.Done(); geo.Run(stop) }()
go func() { defer wg.Done(); app.updates.Run(stop) }()
go func() {
t := time.NewTicker(10 * time.Minute)
defer t.Stop()
+172 -7
View File
@@ -12,7 +12,9 @@ import (
"net/netip"
"os"
"path/filepath"
"slices"
"strings"
"sync"
"testing"
"time"
)
@@ -76,6 +78,7 @@ func TestValidate(t *testing.T) {
"bad dns": func(c *Config) { c.Peers[0].DNS = []string{"dns.example"} },
"bad port": func(c *Config) { c.Server.ListenPort = 70000 },
"unmasked net": func(c *Config) { c.Server.IPv4 = "10.84.12.5/24" },
"update source": func(c *Config) { c.Updates.Source = "sourceforge" },
} {
cc := c.clone()
mutate(cc)
@@ -311,8 +314,13 @@ func TestAPI(t *testing.T) {
secret := tok["token"].(string)
// Read-only token: GET works, changes are refused, admin endpoints too.
bearer := func(method, path string, want int) {
req, _ := http.NewRequest(method, srv.URL+"/api/v1"+path, nil)
bearer := func(method, path string, want int, body ...any) {
var rd io.Reader
if len(body) > 0 {
b, _ := json.Marshal(body[0])
rd = bytes.NewReader(b)
}
req, _ := http.NewRequest(method, srv.URL+"/api/v1"+path, rd)
req.Header.Set("Authorization", "Bearer "+secret)
resp, err := http.DefaultClient.Do(req)
if err != nil {
@@ -328,11 +336,45 @@ func TestAPI(t *testing.T) {
bearer("GET", "/tokens", 403)
bearer("GET", "/peers/"+id+"/setup", 403) // the link would set up a device
// A full-access token manages users and tokens, but not backups.
// A full-access token changes settings, but users, passwords, tokens,
// the sign-in rules and backups need a signed-in user.
secret = call("POST", "/tokens", map[string]string{"name": "full", "scope": "rw"}, 201)["token"].(string)
bearer("GET", "/users", 200)
bearer("GET", "/tokens", 200)
uid := call("GET", "/auth/me", nil, 200)["id"].(string)
bearer("PATCH", "/settings", 200, map[string]any{"log": store.Get().Log})
bearer("PATCH", "/settings", 403, map[string]any{"signin": map[string]bool{"requireMfa": false}})
bearer("GET", "/users", 403)
bearer("POST", "/users", 403, map[string]any{"username": "eve", "password": "correct horse battery"})
bearer("POST", "/users/"+uid+"/reset-password", 403, map[string]any{"password": "correct horse battery"})
bearer("POST", "/users/"+uid+"/reset-mfa", 403)
bearer("POST", "/auth/password", 403, map[string]string{"current": "x", "new": "y"})
bearer("GET", "/tokens", 403)
bearer("POST", "/tokens", 403, map[string]string{"name": "more", "scope": "rw"})
bearer("DELETE", "/tokens/"+tok["id"].(string), 403)
bearer("GET", "/backup", 403)
bearer("GET", "/update-backups", 403)
bearer("DELETE", "/update-backups", 403)
// Config copies made by update: listed newest first, removed one by
// one or all at once; nothing else in the folder can be removed.
for i, v := range []string{"v0.3.2", "v0.4.0"} {
f := filepath.Join(dir, "config.json.bak-"+v)
_ = os.WriteFile(f, []byte("{}"), 0o600)
_ = os.Chtimes(f, time.Now(), time.Now().Add(time.Duration(i-2)*time.Hour))
}
list := call("GET", "/update-backups", nil, 200)["backups"].([]any)
if len(list) != 2 || list[0].(map[string]any)["version"] != "v0.4.0" {
t.Fatalf("update backups: %v", list)
}
call("DELETE", "/update-backups/config.json", nil, 400)
call("DELETE", "/update-backups/stats.json", nil, 400)
call("DELETE", "/update-backups/config.json.bak-v9.9.9", nil, 400)
call("DELETE", "/update-backups/config.json.bak-v0.3.2", nil, 200)
if r := call("DELETE", "/update-backups", nil, 200); r["removed"] != float64(1) {
t.Fatalf("remove all: %v", r)
}
if _, err := os.Stat(filepath.Join(dir, "config.json")); err != nil {
t.Fatal("config.json is gone:", err)
}
call("DELETE", "/peers/"+id, nil, 200)
if len(store.Get().Peers) != 0 {
@@ -376,6 +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) {
p := filepath.Join(t.TempDir(), "x.conf")
if ch, err := writeIfChanged(p, "a\n", 0o644); !ch || err != nil {
@@ -881,7 +1009,6 @@ func TestUsers(t *testing.T) {
if n := len(admin("GET", "/users", nil, 200)["users"].([]any)); n != 2 {
t.Fatalf("users: %d, want 2", n)
}
admin("PATCH", "/settings", map[string]any{"adminUsername": "x"}, 400)
}
// TestDecoy checks that the decoy hides the web interface but leaves the API
@@ -1050,7 +1177,7 @@ func TestMFA(t *testing.T) {
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", map[string]bool{"passkey": true}, 400)
adm("POST", "/auth/mfa/keys/begin", nil, 400)
// Turn on the authenticator app; the first method brings recovery codes.
setup := adm("POST", "/auth/mfa/totp/setup", nil, 200)
@@ -1092,6 +1219,17 @@ func TestMFA(t *testing.T) {
c3("POST", "/auth/login/recovery", map[string]string{"ticket": ticket, "code": codes[0].(string)}, 401)
c3("POST", "/auth/login/recovery", map[string]string{"ticket": ticket, "code": codes[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)
@@ -1121,3 +1259,30 @@ func TestMFA(t *testing.T) {
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")
}
}
+57 -50
View File
@@ -24,9 +24,10 @@ import (
"github.com/go-webauthn/webauthn/webauthn"
)
// Two-step sign-in for the web interface: an authenticator app (TOTP),
// security keys such as a YubiKey and passkeys (both WebAuthn), plus
// one-time recovery codes. API tokens never need a second step.
// 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
@@ -41,16 +42,29 @@ type UserMFA struct {
Handle []byte `json:"handle,omitempty"` // WebAuthn user handle
}
// MFAKey is a security key or passkey.
// MFAKey is a passkey.
type MFAKey struct {
ID string `json:"id"`
Name string `json:"name"`
Passkey bool `json:"passkey"` // discoverable: signs in without a password
Passkey bool `json:"passkey"` // false only for security keys added by v0.3.0, which are deleted
Created time.Time `json:"created"`
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)
}
@@ -166,7 +180,7 @@ func (w waUser) WebAuthnCredentials() []webauthn.Credential {
return out
}
// keysAvailable reports whether security keys and passkeys can work on this
// keysAvailable reports whether passkeys can work on this
// address: WebAuthn needs a domain name (not an IP address) and a
// certificate the browser trusts, or localhost.
func (a *App) keysAvailable(r *http.Request) bool {
@@ -179,7 +193,7 @@ func (a *App) keysAvailable(r *http.Request) bool {
func (a *App) webAuthn(r *http.Request) (*webauthn.WebAuthn, error) {
if !a.keysAvailable(r) {
return nil, badRequest("security keys and passkeys need a domain name with a trusted certificate")
return nil, badRequest("passkeys need a domain name with a trusted certificate")
}
scheme := "https"
if r.TLS == nil && hostOnly(r.Host) == "localhost" {
@@ -198,12 +212,11 @@ type ticket struct {
ip string
expires time.Time
fails int
key *webauthn.SessionData // a security key challenge, once asked for
key *webauthn.SessionData // a passkey challenge, once asked for
}
type ceremony struct {
userID string // "" for a passkey sign-in
passkey bool
data *webauthn.SessionData
expires time.Time
}
@@ -223,23 +236,34 @@ func newMFAState() mfaState {
var errBadTicket = errors.New("the sign-in expired; enter your password again")
// failLocked counts a failed attempt from ip toward the lockout. a.mu must
// be held.
func (a *Auth) failLocked(ip string) {
f := a.fails[ip]
// 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[ip] = f
a.fails[key] = f
}
f.count++
if f.count >= maxFailures {
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[ip]
f := a.fails[lockKey(ip)]
return f != nil && time.Now().Before(f.until)
}
@@ -289,7 +313,7 @@ func (a *Auth) finishSignIn(u *User, ip string) string {
cfg := a.store.Get()
a.mu.Lock()
defer a.mu.Unlock()
delete(a.fails, ip)
delete(a.fails, lockKey(ip))
a.logins[u.ID] = tokenUse{At: time.Now(), IP: ip}
return a.newSessionLocked(cfg, u, sessionInfo{Started: time.Now(), IP: ip})
}
@@ -430,7 +454,7 @@ func (a *App) loginRecovery(w http.ResponseWriter, r *http.Request) {
a.signedIn(w, r, u, "recovery code")
}
// loginKeyBegin asks for one of the user's security keys or passkeys.
// loginKeyBegin asks for one of the user's passkeys, as the second step.
func (a *App) loginKeyBegin(w http.ResponseWriter, r *http.Request) {
var in struct{ Ticket string }
if err := readJSON(r, &in); err != nil {
@@ -448,7 +472,7 @@ func (a *App) loginKeyBegin(w http.ResponseWriter, r *http.Request) {
return
}
if u.MFA == nil || len(u.MFA.Keys) == 0 {
writeErr(w, badRequest("no security key is set up"))
writeErr(w, badRequest("no passkey is set up"))
return
}
opts, data, err := wa.BeginLogin(waUser{u}, webauthn.WithUserVerification(protocol.VerificationDiscouraged))
@@ -488,13 +512,13 @@ func (a *App) loginKeyFinish(w http.ResponseWriter, r *http.Request) {
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", "security key: "+err.Error())
a.signInFailed(w, errors.New("the security key was not accepted"))
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, "security key")
a.signedIn(w, r, u, "passkey")
}
// keyUsed stores the key's new signature counter and when it was used.
@@ -561,7 +585,7 @@ func (a *App) loginPasskeyFinish(w http.ResponseWriter, r *http.Request) {
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 k.Passkey && bytes.Equal(k.Credential.ID, rawID) {
if bytes.Equal(k.Credential.ID, rawID) {
found = u
return waUser{u}, nil
}
@@ -587,7 +611,6 @@ func (a *App) loginPasskeyFinish(w http.ResponseWriter, r *http.Request) {
type keyView struct {
ID string `json:"id"`
Name string `json:"name"`
Passkey bool `json:"passkey"`
Created time.Time `json:"created"`
LastUsed *time.Time `json:"lastUsed"`
}
@@ -604,7 +627,7 @@ func (a *App) mfaStatus(w http.ResponseWriter, r *http.Request) {
if m := u.MFA; m != nil {
keys := []keyView{}
for _, k := range m.Keys {
keys = append(keys, keyView{k.ID, k.Name, k.Passkey, k.Created, k.LastUsed})
keys = append(keys, keyView{k.ID, k.Name, k.Created, k.LastUsed})
}
out["totp"], out["totpAdded"], out["keys"], out["recoveryLeft"] = m.TOTPSecret != "", m.TOTPAdded, keys, len(m.RecoveryCodes)
}
@@ -710,13 +733,8 @@ func (a *App) totpRemove(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
// keyBegin starts adding a security key ({"passkey": false}) or a passkey.
// keyBegin starts adding a passkey.
func (a *App) keyBegin(w http.ResponseWriter, r *http.Request) {
var in struct{ Passkey bool }
if err := readJSON(r, &in); err != nil {
writeErr(w, err)
return
}
wa, err := a.webAuthn(r)
if err != nil {
writeErr(w, err)
@@ -748,17 +766,14 @@ func (a *App) keyBegin(w http.ResponseWriter, r *http.Request) {
for _, k := range u.MFA.Keys {
exclude = append(exclude, k.Credential.Descriptor())
}
sel := protocol.AuthenticatorSelection{ResidentKey: protocol.ResidentKeyRequirementDiscouraged, UserVerification: protocol.VerificationDiscouraged}
if in.Passkey {
sel = protocol.AuthenticatorSelection{ResidentKey: protocol.ResidentKeyRequirementRequired, UserVerification: protocol.VerificationRequired}
}
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, passkey: in.Passkey, data: data, expires: time.Now().Add(ticketTTL)}
a.auth.mfa.enrolls[p.UserID] = &ceremony{userID: p.UserID, data: data, expires: time.Now().Add(ticketTTL)}
a.auth.mu.Unlock()
writeJSON(w, http.StatusOK, opts)
}
@@ -792,13 +807,13 @@ func (a *App) keyFinish(w http.ResponseWriter, r *http.Request) {
return
}
if name == "" {
name = map[bool]string{false: "Security key", true: "Passkey"}[cer.passkey]
name = "Passkey"
}
if len(name) > maxKeyName {
name = name[:maxKeyName]
}
var codes []string
key := MFAKey{ID: newID(), Name: name, Passkey: cer.passkey, Created: time.Now().UTC(), Credential: *cred}
key := MFAKey{ID: newID(), Name: name, Passkey: true, Created: time.Now().UTC(), Credential: *cred}
if err := a.store.Update(func(c *Config) error {
_, u := c.userByID(p.UserID)
if u == nil || u.MFA == nil {
@@ -811,7 +826,7 @@ func (a *App) keyFinish(w http.ResponseWriter, r *http.Request) {
writeErr(w, err)
return
}
a.audit(r, map[bool]string{false: "security key added", true: "passkey added"}[cer.passkey], "key", name)
a.audit(r, "passkey added", "key", name)
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "recoveryCodes": codes})
}
@@ -865,7 +880,7 @@ func (a *App) keyRemove(w http.ResponseWriter, r *http.Request) {
writeErr(w, err)
return
}
a.audit(r, "security key removed", "key", name)
a.audit(r, "passkey removed", "key", name)
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
@@ -917,17 +932,9 @@ func (a *App) resetMFA(w http.ResponseWriter, r *http.Request) {
// mfaSummary is what user lists show.
func mfaSummary(u *User) map[string]any {
out := map[string]any{"totp": false, "keys": 0, "passkeys": 0}
out := map[string]any{"totp": false, "passkeys": 0}
if m := u.MFA; m != nil {
keys, passkeys := 0, 0
for _, k := range m.Keys {
if k.Passkey {
passkeys++
} else {
keys++
}
}
out["totp"], out["keys"], out["passkeys"] = m.TOTPSecret != "", keys, passkeys
out["totp"], out["passkeys"] = m.TOTPSecret != "", len(m.Keys)
}
return out
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 296 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

+54 -3
View File
@@ -12,6 +12,7 @@ import (
"os/user"
"path/filepath"
"runtime"
"slices"
"strconv"
"strings"
"time"
@@ -273,7 +274,51 @@ WantedBy=multi-user.target
// rewrite the unit for every release.
const unitVersion = "unit-1"
const sysctlConf = "net.ipv4.ip_forward=1\nnet.ipv6.conf.all.forwarding=1\n"
// sysctlConf turns on forwarding. With IPv6 forwarding on, Linux ignores
// router announcements unless accept_ra is 2, and a server that gets its
// IPv6 route from them (SLAAC, e.g. a Raspberry Pi at home) loses IPv6 when
// the route expires. So every interface in ras keeps accepting them, as
// pivpn does for its uplink.
func sysctlConf(ras []string) string {
var b strings.Builder
b.WriteString("net.ipv4.ip_forward=1\nnet.ipv6.conf.all.forwarding=1\nnet.ipv6.conf.default.accept_ra=2\n")
for _, name := range ras {
fmt.Fprintf(&b, "net.ipv6.conf.%s.accept_ra=2\n", name)
}
return b.String()
}
// raInterfaces returns the network cards and the interface of the IPv6
// default route, except those where router announcements are switched off
// (accept_ra 0). The directories are /proc/sys/net/ipv6/conf and
// /sys/class/net, routes is /proc/net/ipv6_route.
func raInterfaces(confDir, netDir, routes string) []string {
want := map[string]bool{}
if b, err := os.ReadFile(routes); err == nil {
for _, line := range strings.Split(string(b), "\n") {
f := strings.Fields(line)
if len(f) == 10 && f[0] == strings.Repeat("0", 32) && f[1] == "00" && f[9] != "lo" {
want[f[9]] = true
}
}
}
entries, _ := os.ReadDir(netDir)
for _, e := range entries {
// Only real devices: bridges, veth and tunnels come and go.
if _, err := os.Stat(filepath.Join(netDir, e.Name(), "device")); err == nil {
want[e.Name()] = true
}
}
var out []string
for name := range want {
v := readSysctl(filepath.Join(confDir, name, "accept_ra"))
if v == "1" || v == "2" {
out = append(out, name)
}
}
slices.Sort(out)
return out
}
// writeSystemFiles writes the unit, sysctl and module files. It reports
// whether the unit changed (systemd must then reload).
@@ -281,7 +326,8 @@ func writeSystemFiles() (unitChanged bool, err error) {
if unitChanged, err = writeIfChanged(unitPath, unitFile(), 0o644); err != nil {
return false, err
}
sysChanged, err := writeIfChanged(sysctlPath, sysctlConf, 0o644)
ras := raInterfaces("/proc/sys/net/ipv6/conf", "/sys/class/net", "/proc/net/ipv6_route")
sysChanged, err := writeIfChanged(sysctlPath, sysctlConf(ras), 0o644)
if err != nil {
return false, err
}
@@ -543,7 +589,7 @@ func cmdUpdate(args []string) error {
if err != nil {
return err
}
backup := configFile + ".bak-" + oldVersion
backup := newUpdateBackupPath(configFile, oldVersion, time.Now())
step("Backing up config to %s", backup)
if err := copyFile(configFile, backup, 0o600, uid, gid); err != nil {
return err
@@ -581,6 +627,11 @@ func cmdUpdate(args []string) error {
}
return fmt.Errorf("update failed, %s %s is running again: %w", appName, oldVersion, err)
}
if n, err := pruneUpdateBackups(configFile, keepUpdateBackups); err != nil {
fmt.Fprintln(os.Stderr, " Could not remove older config backups:", err)
} else if n > 0 {
step("Removed %d older config backups, kept the newest %d", n, keepUpdateBackups)
}
fmt.Printf("\nUpdated %s %s → %s.\n", appName, oldVersion, version)
return nil
}
+224
View File
@@ -0,0 +1,224 @@
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"runtime"
"strings"
"sync"
"sync/atomic"
"time"
)
// The update check asks one of the two places releases are published for
// the latest one. Both carry the same tags and files.
var updateSources = map[string]struct {
Name string // shown in the web interface
API string // latest release, as JSON
Repo string // web page of the repository; downloads are under it
}{
"gitea": {"Gitea", "https://git.redetzke.aero/api/v1/repos/Redetzke/GHOSTWIRE/releases/latest", "https://git.redetzke.aero/Redetzke/GHOSTWIRE"},
"github": {"GitHub", "https://api.github.com/repos/danielredetzke/GHOSTWIRE/releases/latest", "https://github.com/danielredetzke/GHOSTWIRE"},
}
const updateCheckFreq = 24 * time.Hour
// Release is the latest published release as the source reports it.
type Release struct {
Version string `json:"version"` // tag, e.g. "v0.4.0"
Published time.Time `json:"published"`
Notes string `json:"notes"` // Markdown
URL string `json:"url"` // release page
}
// UpdateStatus is shown in the settings; Available also reaches the sidebar
// and the Dashboard through /auth/me.
type UpdateStatus struct {
Enabled bool `json:"enabled"`
Source string `json:"source"`
Current string `json:"current"`
Latest *Release `json:"latest"`
Available bool `json:"available"` // Latest is newer than Current
Checked *time.Time `json:"checked"` // last attempt
Error string `json:"error,omitempty"`
LastOK *time.Time `json:"lastOk"` // last attempt that worked
// Download links for this server's platform; empty when no release
// file is built for it.
Arch string `json:"arch"`
File string `json:"file,omitempty"`
FileURL string `json:"fileUrl,omitempty"`
SumsURL string `json:"sumsUrl,omitempty"`
SourceURL string `json:"sourceUrl"` // repository page of the source
}
type Updater struct {
enabled atomic.Bool
kick chan struct{}
fetch func(ctx context.Context, url string) (*Release, error) // replaced in tests
mu sync.Mutex
source string
latest *Release
checked *time.Time
lastOK *time.Time
err string
}
func newUpdater(c UpdatesConfig) *Updater {
u := &Updater{kick: make(chan struct{}, 1), fetch: fetchRelease, source: c.Source}
u.enabled.Store(c.checkEnabled())
return u
}
// Set applies the settings. A new source or switching the check on checks
// at once; switching it off forgets what the last check found.
func (u *Updater) Set(c UpdatesConfig) {
if u == nil {
return
}
on := c.checkEnabled()
u.mu.Lock()
changed := u.source != c.Source || u.enabled.Load() != on
if u.source != c.Source || !on {
u.latest, u.checked, u.lastOK, u.err = nil, nil, nil, ""
}
u.source = c.Source
u.enabled.Store(on)
u.mu.Unlock()
if changed && on {
select {
case u.kick <- struct{}{}:
default:
}
}
}
// Run checks once a day while the check is on.
func (u *Updater) Run(stop <-chan struct{}) {
t := time.NewTicker(updateCheckFreq)
defer t.Stop()
for {
if u.enabled.Load() {
u.Check(context.Background())
}
select {
case <-stop:
return
case <-t.C:
case <-u.kick:
}
}
}
// Check asks the source for the latest release now.
func (u *Updater) Check(ctx context.Context) {
u.mu.Lock()
source := u.source
u.mu.Unlock()
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
rel, err := u.fetch(ctx, updateSources[source].API)
now := time.Now()
u.mu.Lock()
defer u.mu.Unlock()
if u.source != source { // the source changed meanwhile; that check counts
return
}
u.checked = &now
if err != nil {
u.err = err.Error()
slog.Warn("update check failed", "source", source, "err", err)
return
}
u.latest, u.lastOK, u.err = rel, &now, ""
if newerVersion(rel.Version, version) {
slog.Info("update available", "version", rel.Version, "running", version)
}
}
func (u *Updater) Status() UpdateStatus {
if u == nil {
return UpdateStatus{Current: version}
}
u.mu.Lock()
defer u.mu.Unlock()
src := updateSources[u.source]
st := UpdateStatus{
Enabled: u.enabled.Load(), Source: u.source, Current: version, Latest: u.latest,
Checked: u.checked, Error: u.err, LastOK: u.lastOK, Arch: releaseArch(), SourceURL: src.Repo,
}
if u.latest != nil {
st.Available = newerVersion(u.latest.Version, version)
if st.Arch != "" {
st.File = fmt.Sprintf("%s-%s-linux-%s", appName, u.latest.Version, st.Arch)
base := src.Repo + "/releases/download/" + u.latest.Version + "/"
st.FileURL, st.SumsURL = base+st.File, base+"SHA256SUMS"
}
}
return st
}
// Available returns the newer release's version, or "".
func (u *Updater) Available() string {
if st := u.Status(); st.Enabled && st.Available {
return st.Latest.Version
}
return ""
}
// releaseArch names this platform the way the release files do, or "" when
// no file is built for it.
func releaseArch() string {
if runtime.GOOS != "linux" {
return ""
}
switch runtime.GOARCH {
case "amd64", "arm64":
return runtime.GOARCH
case "arm":
return "armv7"
}
return ""
}
func fetchRelease(ctx context.Context, url string) (*Release, error) {
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", appName+"/"+strings.TrimPrefix(version, "v"))
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("HTTP %d from %s", resp.StatusCode, req.URL.Host)
}
// GitHub and Gitea name these fields the same.
var r struct {
Tag string `json:"tag_name"`
Body string `json:"body"`
Published time.Time `json:"published_at"`
URL string `json:"html_url"`
Draft bool `json:"draft"`
Prerelease bool `json:"prerelease"`
}
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&r); err != nil {
return nil, fmt.Errorf("unreadable answer from %s: %w", req.URL.Host, err)
}
if _, ok := compareVersions(r.Tag, r.Tag); r.Draft || r.Prerelease || !ok {
return nil, errors.New("the latest release is not a published version")
}
return &Release{Version: r.Tag, Published: r.Published, Notes: r.Body, URL: r.URL}, nil
}
// newerVersion reports whether latest is a higher version than running.
// A running version that is not a version number is never out of date.
func newerVersion(latest, running string) bool {
c, ok := compareVersions(latest, running)
return ok && c > 0
}
+111
View File
@@ -0,0 +1,111 @@
package main
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestNewerVersion(t *testing.T) {
for _, tc := range []struct {
latest, running string
want bool
}{
{"v0.4.0", "v0.3.2", true},
{"v0.4.0", "0.3.2", true},
{"v0.10.0", "v0.9.9", true},
{"v1.0.0", "v0.99.0", true},
{"v0.4.0", "v0.4.0", false},
{"v0.4.0", "v0.4.0-3-gb18d16a", false}, // a build after the release
{"v0.3.2", "v0.4.0", false},
{"v0.4.0", "dev", false}, // not a version: never out of date
{"latest", "v0.3.2", false},
} {
if got := newerVersion(tc.latest, tc.running); got != tc.want {
t.Errorf("newerVersion(%q, %q) = %v, want %v", tc.latest, tc.running, got, tc.want)
}
}
}
func TestFetchRelease(t *testing.T) {
var body string
var status int
var ua string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ua = r.Header.Get("User-Agent")
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
}))
defer srv.Close()
status, body = 200, `{"tag_name":"v0.4.0","body":"Fixes.","published_at":"2026-10-05T06:15:28Z","html_url":"https://example.net/r/v0.4.0","draft":false,"prerelease":false}`
r, err := fetchRelease(context.Background(), srv.URL)
if err != nil {
t.Fatal(err)
}
if r.Version != "v0.4.0" || r.Notes != "Fixes." || r.URL != "https://example.net/r/v0.4.0" || r.Published.IsZero() {
t.Fatalf("release = %+v", r)
}
if !strings.HasPrefix(ua, appName+"/") {
t.Errorf("User-Agent = %q", ua)
}
status, body = 200, `{"tag_name":"v0.5.0-rc1","prerelease":true}`
if _, err := fetchRelease(context.Background(), srv.URL); err == nil {
t.Error("a pre-release was accepted")
}
status, body = 404, `{}`
if _, err := fetchRelease(context.Background(), srv.URL); err == nil || !strings.Contains(err.Error(), "404") {
t.Errorf("HTTP 404: err = %v", err)
}
}
func TestUpdater(t *testing.T) {
old := version
version = "v0.3.2"
defer func() { version = old }()
u := newUpdater(UpdatesConfig{Source: "gitea"})
var asked string
u.fetch = func(_ context.Context, url string) (*Release, error) {
asked = url
return &Release{Version: "v0.4.0"}, nil
}
u.Check(context.Background())
if asked != updateSources["gitea"].API {
t.Errorf("asked %q", asked)
}
st := u.Status()
if !st.Available || u.Available() != "v0.4.0" || st.Checked == nil || st.LastOK == nil {
t.Fatalf("status = %+v", st)
}
if st.Arch != "" {
want := "https://git.redetzke.aero/Redetzke/GHOSTWIRE/releases/download/v0.4.0/GHOSTWIRE-v0.4.0-linux-" + st.Arch
if st.FileURL != want || !strings.HasSuffix(st.SumsURL, "/v0.4.0/SHA256SUMS") {
t.Errorf("downloads = %q, %q", st.FileURL, st.SumsURL)
}
}
// A failed check keeps the last good answer and reports the error.
u.fetch = func(context.Context, string) (*Release, error) { return nil, errors.New("no route to host") }
u.Check(context.Background())
if st := u.Status(); st.Error != "no route to host" || st.Latest == nil {
t.Errorf("after a failed check: %+v", st)
}
// Another source forgets what the old one said; switching off hides it.
u.Set(UpdatesConfig{Source: "github"})
if st := u.Status(); st.Latest != nil || st.Error != "" || st.SourceURL != updateSources["github"].Repo {
t.Errorf("after changing the source: %+v", st)
}
off := false
u.fetch = func(context.Context, string) (*Release, error) { return &Release{Version: "v0.4.0"}, nil }
u.Check(context.Background())
u.Set(UpdatesConfig{Source: "github", Check: &off})
if u.Available() != "" || u.Status().Enabled {
t.Error("still reports an update with the check off")
}
}
+125
View File
@@ -0,0 +1,125 @@
package main
import (
"errors"
"io/fs"
"net/http"
"os"
"path/filepath"
"regexp"
"slices"
"strings"
"time"
)
// Each update copies config.json to config.json.bak-<old version> next to
// it, in case the new version must be rolled back. The copies hold the same
// secrets as a backup, so the web interface lists them and can remove them,
// and update keeps only the newest few.
const keepUpdateBackups = 3
type UpdateBackup struct {
Name string `json:"name"`
Version string `json:"version"`
Modified time.Time `json:"modified"`
Size int64 `json:"size"`
}
// A copy that would overwrite an older one gets the time appended.
var backupStampRe = regexp.MustCompile(`-\d{8}-\d{4}$`)
func updateBackupPrefix(configPath string) string { return filepath.Base(configPath) + ".bak-" }
// newUpdateBackupPath names the copy update makes of configPath.
func newUpdateBackupPath(configPath, version string, now time.Time) string {
p := configPath + ".bak-" + version
if _, err := os.Lstat(p); err == nil {
p += now.Format("-20060102-1504")
}
return p
}
// listUpdateBackups returns the copies next to configPath, newest first.
func listUpdateBackups(configPath string) ([]UpdateBackup, error) {
entries, err := os.ReadDir(filepath.Dir(configPath))
if err != nil {
return nil, err
}
prefix := updateBackupPrefix(configPath)
out := []UpdateBackup{}
for _, e := range entries {
name := e.Name()
if !e.Type().IsRegular() || !strings.HasPrefix(name, prefix) || name == prefix {
continue
}
fi, err := e.Info()
if err != nil {
continue
}
out = append(out, UpdateBackup{Name: name, Version: backupStampRe.ReplaceAllString(strings.TrimPrefix(name, prefix), ""),
Modified: fi.ModTime(), Size: fi.Size()})
}
slices.SortFunc(out, func(a, b UpdateBackup) int { return b.Modified.Compare(a.Modified) })
return out, nil
}
// removeUpdateBackup deletes one copy; any other name is refused.
func removeUpdateBackup(configPath, name string) error {
prefix := updateBackupPrefix(configPath)
path := filepath.Join(filepath.Dir(configPath), name)
fi, err := os.Lstat(path)
if !strings.HasPrefix(name, prefix) || name == prefix || strings.ContainsAny(name, `/\`) ||
errors.Is(err, fs.ErrNotExist) || (err == nil && !fi.Mode().IsRegular()) {
return badRequest("no copy named %q", name)
}
if err != nil {
return err
}
return os.Remove(path)
}
// pruneUpdateBackups keeps the newest keep copies and deletes the rest.
func pruneUpdateBackups(configPath string, keep int) (int, error) {
list, err := listUpdateBackups(configPath)
if err != nil || len(list) <= keep {
return 0, err
}
n := 0
for _, b := range list[keep:] {
if err := removeUpdateBackup(configPath, b.Name); err != nil {
return n, err
}
n++
}
return n, nil
}
func (a *App) listUpdateBackups(w http.ResponseWriter, r *http.Request) {
list, err := listUpdateBackups(a.store.path)
if err != nil {
writeErr(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"backups": list})
}
func (a *App) removeUpdateBackup(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
if err := removeUpdateBackup(a.store.path, name); err != nil {
writeErr(w, err)
return
}
a.audit(r, "update backup removed", "file", name)
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (a *App) removeUpdateBackups(w http.ResponseWriter, r *http.Request) {
n, err := pruneUpdateBackups(a.store.path, 0)
if err != nil {
writeErr(w, err)
return
}
a.audit(r, "update backups removed", "count", n)
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "removed": n})
}
+65
View File
@@ -0,0 +1,65 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestUpdateBackups(t *testing.T) {
dir := t.TempDir()
cfg := filepath.Join(dir, "config.json")
_ = os.WriteFile(cfg, []byte("{}"), 0o600)
now := time.Date(2026, 10, 5, 12, 9, 0, 0, time.UTC)
// A second copy of the same version gets the time appended instead of
// overwriting the first.
first := newUpdateBackupPath(cfg, "unknown", now)
if filepath.Base(first) != "config.json.bak-unknown" {
t.Fatalf("first copy: %s", first)
}
_ = os.WriteFile(first, []byte("{}"), 0o600)
second := newUpdateBackupPath(cfg, "unknown", now)
if filepath.Base(second) != "config.json.bak-unknown-20261005-1209" {
t.Fatalf("second copy: %s", second)
}
_ = os.WriteFile(second, []byte("{}"), 0o600)
for i, v := range []string{"v0.2.0", "v0.3.0", "v0.4.0"} {
f := filepath.Join(dir, "config.json.bak-"+v)
_ = os.WriteFile(f, []byte("{}"), 0o600)
_ = os.Chtimes(f, now, now.Add(time.Duration(i+1)*time.Hour))
}
_ = os.Chtimes(first, now, now.Add(-2*time.Hour))
_ = os.Chtimes(second, now, now.Add(-time.Hour))
_ = os.Mkdir(filepath.Join(dir, "config.json.bak-dir"), 0o700) // not a file: ignored
list, err := listUpdateBackups(cfg)
if err != nil {
t.Fatal(err)
}
var got []string
for _, b := range list {
got = append(got, b.Version)
}
if strings.Join(got, " ") != "v0.4.0 v0.3.0 v0.2.0 unknown unknown" {
t.Fatalf("versions, newest first: %v", got)
}
for _, bad := range []string{"config.json", "config.json.bak-", "config.json.bak-dir", "../config.json.bak-v0.4.0", "config.json.bak-v0.4.0/x"} {
if err := removeUpdateBackup(cfg, bad); err == nil {
t.Errorf("removed %q", bad)
}
}
if n, err := pruneUpdateBackups(cfg, keepUpdateBackups); err != nil || n != 2 {
t.Fatalf("prune: %d, %v", n, err)
}
if list, _ = listUpdateBackups(cfg); len(list) != 3 || list[2].Version != "v0.2.0" {
t.Fatalf("after prune: %v", list)
}
if _, err := os.Stat(cfg); err != nil {
t.Fatal("config.json is gone")
}
}
+1 -1
View File
@@ -21,7 +21,7 @@ type userView struct {
LastLogin *tokenUse `json:"lastLogin"` // since the service started
Tokens int `json:"tokens"`
You bool `json:"you"`
MFA map[string]any `json:"mfa"` // {"totp": bool, "keys": n, "passkeys": n}
MFA map[string]any `json:"mfa"` // {"totp": bool, "passkeys": n}
}
func (a *App) userView(c *Config, u *User, me string) userView {