14 Commits

Author SHA1 Message Date
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
21 changed files with 996 additions and 142 deletions
+32 -27
View File
@@ -37,22 +37,14 @@ dependencies on the server: the binary installs, updates and removes itself.
retention.
- **Logs:** written to `GHOSTWIRE.jsonl`, rotated at 10 MB with 5 old files
kept by default. 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,7 +57,8 @@ The screenshots show sample data from the built-in simulator.
`CAP_NET_ADMIN` and `CAP_NET_BIND_SERVICE`, and can write only to
`/opt/ghostwire`.
- **Sign-in:** one or more users, all admins. Passwords are stored as argon2id hashes.
After 5 failed attempts, sign-in is locked for 15 minutes. Sessions use an
After 5 failed attempts from one IP address, sign-in from it is locked for 15
minutes; wrong two-step codes count too. Sessions use an
HttpOnly, SameSite=Strict cookie and last 12 hours by default.
- **Two-step sign-in:** each user can add an authenticator app (TOTP) and
passkeys under My account. A passkey signs in on its own, without username
@@ -229,7 +222,7 @@ 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) |
| `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` |
@@ -241,24 +234,26 @@ 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)
@@ -274,10 +269,9 @@ 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
public: GET /setup/{token} · POST /setup/{token} {"pin"} (what a setup link opens)
```
@@ -286,6 +280,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.
@@ -307,12 +308,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
+34 -30
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,13 +170,15 @@ 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)
@@ -232,7 +222,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 +260,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
@@ -1014,7 +1004,7 @@ func (a *App) getSettings(w http.ResponseWriter, r *http.Request) {
"decoy": cfg.Decoy,
"signin": cfg.SignIn,
"geo": a.geoStatus(),
"adminUsername": a.username(cfg, who(r).UserID), // kept for older iOS app versions
"updates": a.updates.Status(),
"fingerprint": a.tls.Fingerprint(),
"logPath": a.logPath,
})
@@ -1026,8 +1016,8 @@ 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
@@ -1047,6 +1037,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 +1185,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 {
+53 -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; }
+221 -13
View File
@@ -257,13 +257,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;
}
@@ -517,7 +530,7 @@
const NAV = [['#/', 'dashboard', 'Dashboard'], ['#/peers', 'peers', 'Peers'], ['#/server', 'server', 'Server'], ['#/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…'));
@@ -533,13 +546,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');
@@ -569,7 +594,7 @@
[/^#\/peers\/new$/, '#/peers', viewPeerNew],
[/^#\/peers\/([\w-]+)$/, '#/peers', viewPeer],
[/^#\/server$/, '#/server', viewServer],
[/^#\/settings$/, '#/settings', viewSettings],
[/^#\/settings(#updates)?$/, '#/settings', viewSettings],
[/^#\/account$/, '#/account', viewAccount],
];
@@ -992,6 +1017,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)),
@@ -1111,7 +1138,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 || '–',
@@ -1497,6 +1524,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));
@@ -1567,11 +1657,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'),
@@ -1621,6 +1707,125 @@
bar);
}
// ---------- 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('h2', { 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 ----------
@@ -1999,7 +2204,7 @@
} });
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' }, 'Users, web interface, API access for the iOS app, logs, data retention, backups and updates')),
restartBox,
h('section', { class: 'card flush', 'aria-labelledby': 'usr' },
@@ -2074,8 +2279,11 @@
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)));
restoreInput)),
updatesCard(s.updates));
await drawLogs();
if (location.hash.endsWith('#updates')) document.getElementById('updates').scrollIntoView();
}
render();
+60 -24
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
}
@@ -140,13 +152,17 @@ type Auth struct {
sessions map[string]*session
used map[string]tokenUse
logins map[string]tokenUse // last sign-in per user ID
fails map[string]*failState
fails map[string]*failState // by lockKey
waiting int // sign-ins waiting for or running a password check
mfa mfaState
}
const (
maxFailures = 5
lockoutTime = 15 * time.Minute
// maxWaiting sign-ins may wait for a password check; more are turned
// away until the queue is shorter.
maxWaiting = 16
)
func newAuth(s *Store) *Auth {
@@ -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 {
+15
View File
@@ -34,8 +34,17 @@ type Config struct {
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
@@ -246,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{}
}
@@ -382,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()
+109 -6
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,10 +336,20 @@ 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)
call("DELETE", "/peers/"+id, nil, 200)
@@ -376,6 +394,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 +985,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
+19 -8
View File
@@ -236,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)
}
@@ -302,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})
}
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

+48 -2
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
}
+250
View File
@@ -0,0 +1,250 @@
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"regexp"
"runtime"
"strconv"
"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 r.Draft || r.Prerelease || parseVersion(r.Tag) == nil {
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
}
var versionRe = regexp.MustCompile(`^v?(\d+)\.(\d+)\.(\d+)`)
// parseVersion reads "v0.4.0", "0.4.0" or "v0.4.0-3-gb18d16a" (a build
// after v0.4.0) as major, minor and patch, or nil.
func parseVersion(s string) []int {
m := versionRe.FindStringSubmatch(s)
if m == nil {
return nil
}
out := make([]int, 3)
for i := range out {
out[i], _ = strconv.Atoi(m[i+1])
}
return out
}
// 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 {
l, r := parseVersion(latest), parseVersion(running)
if l == nil || r == nil {
return false
}
for i := range l {
if l[i] != r[i] {
return l[i] > r[i]
}
}
return false
}
+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")
}
}