Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0861047952 | |||
| ac572e165a | |||
| dbeaa645c0 | |||
| 52be000731 | |||
| 5dd19185bb | |||
| dcc3f91740 | |||
| c79ea08f19 | |||
| d749fe5f99 | |||
| 5797f152ea | |||
| 0a0efd9115 |
@@ -37,6 +37,11 @@ dependencies on the server: the binary installs, updates and removes itself.
|
|||||||
retention.
|
retention.
|
||||||
- **Logs:** written to `GHOSTWIRE.jsonl`, rotated at 10 MB with 5 old files
|
- **Logs:** written to `GHOSTWIRE.jsonl`, rotated at 10 MB with 5 old files
|
||||||
kept by default. Changes are marked as audit entries.
|
kept by default. Changes are marked as audit entries.
|
||||||
|
- **Update notice:** once a day the server asks Gitea or GitHub (your choice
|
||||||
|
under Settings → Updates) for the latest release. A newer one shows in the
|
||||||
|
sidebar, on the Dashboard and in Settings, with its release notes and the
|
||||||
|
commands to update this server. Nothing about the server is sent; the check
|
||||||
|
can be switched off.
|
||||||
- **HTTPS built in:** Let's Encrypt, a self-signed certificate, your own
|
- **HTTPS built in:** Let's Encrypt, a self-signed certificate, your own
|
||||||
certificate files, or plain HTTP behind a reverse proxy.
|
certificate files, or plain HTTP behind a reverse proxy.
|
||||||
|
|
||||||
@@ -227,27 +232,28 @@ After editing `config.json` by hand, run `sudo systemctl reload ghostwire`.
|
|||||||
|
|
||||||
Base path `/api/v1`. The web interface signs in with a session cookie; every
|
Base path `/api/v1`. The web interface signs in with a session cookie; every
|
||||||
user is an admin. Apps and scripts use `Authorization: Bearer <token>`; create
|
user is an admin. Apps and scripts use `Authorization: Bearer <token>`; create
|
||||||
the token under Settings → Pair iOS app, or in the iOS app under Settings →
|
the token under Settings → Pair iOS app. A token belongs to the user who made
|
||||||
Access → API tokens. A token belongs to the user who made
|
|
||||||
it and is revoked when that user is deleted. A read-only token may only use
|
it and is revoked when that user is deleted. A read-only token may only use
|
||||||
GET. Full-access tokens can do everything the web interface does except backup
|
GET. Full-access tokens can do everything the web interface does except the
|
||||||
and restore. Users, passwords and API tokens need a full-access token even for
|
endpoints marked "signed in": users, passwords, API tokens, the sign-in rules,
|
||||||
reading.
|
backup and restore.
|
||||||
|
|
||||||
For a user with two-step sign-in, `POST /auth/login` answers
|
For a user with two-step sign-in, `POST /auth/login` answers
|
||||||
`{"mfa": true, "ticket": "…", "methods": ["key", "totp", "recovery"]}`
|
`{"mfa": true, "ticket": "…", "methods": ["key", "totp", "recovery"]}`
|
||||||
instead of starting a session; the ticket is good for 5 minutes, and one of
|
instead of starting a session; the ticket is good for 5 minutes, and one of
|
||||||
the `/auth/login/…` steps turns it into the session. `PATCH /settings`
|
the `/auth/login/…` steps turns it into the session. `PATCH /settings`
|
||||||
`{"signin": {"requireMfa": true}}` requires two-step sign-in for every user.
|
`{"signin": {"requireMfa": true}}` requires two-step sign-in for every user;
|
||||||
|
only a signed-in user can change it.
|
||||||
|
|
||||||
`POST /users` and `POST /users/{id}/reset-password` take
|
`POST /users` and `POST /users/{id}/reset-password` take
|
||||||
`{"password": "…", "mustChangePassword": true}`; with `true` (the default) the
|
`{"password": "…", "mustChangePassword": true}`; with `true` (the default) the
|
||||||
user can do nothing but choose a new password at the next sign-in.
|
user can do nothing but choose a new password at the next sign-in.
|
||||||
|
|
||||||
```
|
```
|
||||||
POST /auth/login · /auth/logout GET /auth/me POST /auth/password (own password)
|
POST /auth/login · /auth/logout GET /auth/me
|
||||||
GET /users POST /users PATCH /users/{id} DELETE /users/{id}
|
signed in: POST /auth/password (own password)
|
||||||
POST /users/{id}/reset-password POST /users/{id}/reset-mfa
|
signed in: GET|POST /users · PATCH|DELETE /users/{id}
|
||||||
|
signed in: POST /users/{id}/reset-password · /users/{id}/reset-mfa
|
||||||
GET /auth/options (public: is passkey sign-in offered here)
|
GET /auth/options (public: is passkey sign-in offered here)
|
||||||
POST /auth/login/totp · /auth/login/recovery {"ticket", "code"}
|
POST /auth/login/totp · /auth/login/recovery {"ticket", "code"}
|
||||||
POST /auth/login/key/begin {"ticket"} · /auth/login/key/finish?ticket= (body: the WebAuthn credential)
|
POST /auth/login/key/begin {"ticket"} · /auth/login/key/finish?ticket= (body: the WebAuthn credential)
|
||||||
@@ -263,10 +269,9 @@ POST /peers/{id}/enable | /disable | /issue-config
|
|||||||
GET /peers/{id}/stats?range=… GET /peers/{id}/sessions?limit=100
|
GET /peers/{id}/stats?range=… GET /peers/{id}/sessions?limit=100
|
||||||
GET /peers/{id}/latency (24 h, one point per 5 minutes)
|
GET /peers/{id}/latency (24 h, one point per 5 minutes)
|
||||||
GET /peers/{id}/setup (not read-only) DELETE /peers/{id}/setup
|
GET /peers/{id}/setup (not read-only) DELETE /peers/{id}/setup
|
||||||
GET /settings PATCH /settings POST /restart
|
GET /settings PATCH /settings POST /restart POST /updates/check
|
||||||
GET /logs?level=&limit=&audit=1 GET /logs/download
|
GET /logs?level=&limit=&audit=1 GET /logs/download
|
||||||
GET /tokens POST /tokens DELETE /tokens/{id}
|
signed in: GET|POST /tokens · DELETE /tokens/{id} · GET /backup · POST /restore
|
||||||
signed in: GET /backup · POST /restore
|
|
||||||
public: GET /setup/{token} · POST /setup/{token} {"pin"} (what a setup link opens)
|
public: GET /setup/{token} · POST /setup/{token} {"pin"} (what a setup link opens)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -275,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
|
setup link (`setup.url`, `setup.pin`, `setup.qr`) instead of a config. With a
|
||||||
link, the peer's current keys keep working until the link is opened.
|
link, the peer's current keys keep working until the link is opened.
|
||||||
|
|
||||||
|
`GET /settings` includes `updates`: the running and latest version,
|
||||||
|
`available`, the release notes and the download links for this server's
|
||||||
|
platform. `PATCH /settings` `{"updates": {"source": "gitea"|"github",
|
||||||
|
"check": false}}` picks the source or switches the daily check off;
|
||||||
|
`POST /updates/check` checks now. `GET /auth/me` has `updateAvailable` with
|
||||||
|
the newer version while there is one.
|
||||||
|
|
||||||
Traffic is reported from the peer's point of view: `down` is what the peer
|
Traffic is reported from the peer's point of view: `down` is what the peer
|
||||||
downloaded, `up` is what it uploaded.
|
downloaded, `up` is what it uploaded.
|
||||||
|
|
||||||
@@ -296,13 +308,16 @@ override a drop in another table, so if ufw or firewalld is active, allow UDP
|
|||||||
## iOS app
|
## iOS app
|
||||||
|
|
||||||
The native iPhone app (SwiftUI, iOS 17+) lives in its own project,
|
The native iPhone app (SwiftUI, iOS 17+) lives in its own project,
|
||||||
GHOSTWIRE-Companion. It does everything the web interface does except
|
GHOSTWIRE-Companion. It manages peers, the server and the app settings and
|
||||||
backup and restore, and adding an authenticator app or passkeys for two-step
|
shows stats and logs. Users, passwords, API tokens, two-step sign-in, backup
|
||||||
sign-in. Pair it in the web interface under
|
and restore stay in the web interface. Pair it in the web interface under
|
||||||
Settings → Pair iOS app: scan the QR code, or tap "Copy pairing code" and paste
|
Settings → Pair iOS app: scan the QR code, or tap "Copy pairing code" and paste
|
||||||
it into the app's "Enter manually". Self-signed certificates are pinned during
|
it into the app's "Enter manually". Self-signed certificates are pinned during
|
||||||
pairing.
|
pairing.
|
||||||
|
|
||||||
|
The iOS app is currently in beta testing. For an invite, email
|
||||||
|
[engineroom@redetzke.aero](mailto:engineroom@redetzke.aero).
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
On macOS (or any non-Linux system), `make dev` starts the app on
|
On macOS (or any non-Linux system), `make dev` starts the app on
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ type App struct {
|
|||||||
logPath string
|
logPath string
|
||||||
logw *rotatingWriter // nil in tests
|
logw *rotatingWriter // nil in tests
|
||||||
geo *Geo // nil in tests
|
geo *Geo // nil in tests
|
||||||
|
updates *Updater // nil in tests
|
||||||
started time.Time
|
started time.Time
|
||||||
shutdown func() // graceful stop; systemd restarts the service
|
shutdown func() // graceful stop; systemd restarts the service
|
||||||
}
|
}
|
||||||
@@ -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
|
// applyResult saves-then-applies: the config is already stored, so a kernel
|
||||||
// error is reported but does not undo the change.
|
// error is reported but does not undo the change.
|
||||||
func (a *App) apply() string {
|
func (a *App) apply() string {
|
||||||
@@ -121,8 +111,6 @@ func (a *App) routes() http.Handler {
|
|||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
g := func(pattern string, h http.HandlerFunc) { mux.HandleFunc(pattern, a.guard(false, h)) }
|
g := func(pattern string, h http.HandlerFunc) { mux.HandleFunc(pattern, a.guard(false, h)) }
|
||||||
adm := func(pattern string, h http.HandlerFunc) { mux.HandleFunc(pattern, a.guard(true, h)) }
|
adm := func(pattern string, h http.HandlerFunc) { mux.HandleFunc(pattern, a.guard(true, h)) }
|
||||||
// full is for signed-in users and full-access tokens, even for reading.
|
|
||||||
full := func(pattern string, h http.HandlerFunc) { mux.HandleFunc(pattern, a.guard(false, fullAccess(h))) }
|
|
||||||
|
|
||||||
mux.HandleFunc("POST /api/v1/auth/login", a.login)
|
mux.HandleFunc("POST /api/v1/auth/login", a.login)
|
||||||
mux.HandleFunc("POST /api/v1/auth/logout", a.logout)
|
mux.HandleFunc("POST /api/v1/auth/logout", a.logout)
|
||||||
@@ -146,13 +134,13 @@ func (a *App) routes() http.Handler {
|
|||||||
adm("DELETE /api/v1/auth/mfa/keys/{id}", a.keyRemove)
|
adm("DELETE /api/v1/auth/mfa/keys/{id}", a.keyRemove)
|
||||||
adm("POST /api/v1/auth/mfa/recovery-codes", a.newRecoveryCodesHandler)
|
adm("POST /api/v1/auth/mfa/recovery-codes", a.newRecoveryCodesHandler)
|
||||||
g("GET /api/v1/auth/me", a.me)
|
g("GET /api/v1/auth/me", a.me)
|
||||||
full("POST /api/v1/auth/password", a.changePassword)
|
adm("POST /api/v1/auth/password", a.changePassword)
|
||||||
full("GET /api/v1/users", a.listUsers)
|
adm("GET /api/v1/users", a.listUsers)
|
||||||
full("POST /api/v1/users", a.createUser)
|
adm("POST /api/v1/users", a.createUser)
|
||||||
full("PATCH /api/v1/users/{id}", a.patchUser)
|
adm("PATCH /api/v1/users/{id}", a.patchUser)
|
||||||
full("POST /api/v1/users/{id}/reset-password", a.resetPassword)
|
adm("POST /api/v1/users/{id}/reset-password", a.resetPassword)
|
||||||
full("DELETE /api/v1/users/{id}", a.deleteUser)
|
adm("DELETE /api/v1/users/{id}", a.deleteUser)
|
||||||
full("POST /api/v1/users/{id}/reset-mfa", a.resetMFA)
|
adm("POST /api/v1/users/{id}/reset-mfa", a.resetMFA)
|
||||||
|
|
||||||
g("GET /api/v1/status", a.status)
|
g("GET /api/v1/status", a.status)
|
||||||
g("GET /api/v1/stats", a.allStats)
|
g("GET /api/v1/stats", a.allStats)
|
||||||
@@ -182,13 +170,15 @@ func (a *App) routes() http.Handler {
|
|||||||
mux.HandleFunc("POST /api/v1/setup/{token}", a.setupRedeem)
|
mux.HandleFunc("POST /api/v1/setup/{token}", a.setupRedeem)
|
||||||
|
|
||||||
// Full-access tokens (the iOS app) may change app settings, read logs and
|
// Full-access tokens (the iOS app) may change app settings, read logs and
|
||||||
// manage users and tokens. Backups need a signed-in user.
|
// restart. Users, passwords, API tokens, the sign-in rules and backups
|
||||||
|
// need a signed-in user.
|
||||||
g("GET /api/v1/settings", a.getSettings)
|
g("GET /api/v1/settings", a.getSettings)
|
||||||
g("PATCH /api/v1/settings", a.patchSettings)
|
g("PATCH /api/v1/settings", a.patchSettings)
|
||||||
|
g("POST /api/v1/updates/check", a.checkUpdates)
|
||||||
g("POST /api/v1/restart", a.restart)
|
g("POST /api/v1/restart", a.restart)
|
||||||
full("GET /api/v1/tokens", a.listTokens)
|
adm("GET /api/v1/tokens", a.listTokens)
|
||||||
full("POST /api/v1/tokens", a.createToken)
|
adm("POST /api/v1/tokens", a.createToken)
|
||||||
full("DELETE /api/v1/tokens/{id}", a.deleteToken)
|
adm("DELETE /api/v1/tokens/{id}", a.deleteToken)
|
||||||
g("GET /api/v1/logs", a.logs)
|
g("GET /api/v1/logs", a.logs)
|
||||||
g("GET /api/v1/logs/download", a.downloadLog)
|
g("GET /api/v1/logs/download", a.downloadLog)
|
||||||
adm("GET /api/v1/backup", a.backup)
|
adm("GET /api/v1/backup", a.backup)
|
||||||
@@ -232,7 +222,7 @@ func (a *App) login(w http.ResponseWriter, r *http.Request) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Warn("login failed", "user", in.Username, "remote", ip, "reason", err.Error())
|
slog.Warn("login failed", "user", in.Username, "remote", ip, "reason", err.Error())
|
||||||
code := http.StatusUnauthorized
|
code := http.StatusUnauthorized
|
||||||
if errors.Is(err, errLocked) {
|
if errors.Is(err, errLocked) || errors.Is(err, errBusy) {
|
||||||
code = http.StatusTooManyRequests
|
code = http.StatusTooManyRequests
|
||||||
}
|
}
|
||||||
writeJSON(w, code, map[string]string{"error": err.Error()})
|
writeJSON(w, code, map[string]string{"error": err.Error()})
|
||||||
@@ -270,8 +260,8 @@ func (a *App) me(w http.ResponseWriter, r *http.Request) {
|
|||||||
"id": p.UserID, "name": p.Name, "isAdmin": p.IsAdmin, "scope": p.Scope,
|
"id": p.UserID, "name": p.Name, "isAdmin": p.IsAdmin, "scope": p.Scope,
|
||||||
"mustChangePassword": p.MustChangePassword, "mfaSetupRequired": p.MFASetupRequired, "version": version, "session": p.Session,
|
"mustChangePassword": p.MustChangePassword, "mfaSetupRequired": p.MFASetupRequired, "version": version, "session": p.Session,
|
||||||
}
|
}
|
||||||
if p.TokenID != "" {
|
if v := a.updates.Available(); v != "" {
|
||||||
out["tokenId"] = p.TokenID // lets an app find its own token in /tokens
|
out["updateAvailable"] = v
|
||||||
}
|
}
|
||||||
if _, u := a.store.Get().userByID(p.UserID); u != nil {
|
if _, u := a.store.Get().userByID(p.UserID); u != nil {
|
||||||
out["username"], out["note"], out["created"] = u.Username, u.Note, u.Created
|
out["username"], out["note"], out["created"] = u.Username, u.Note, u.Created
|
||||||
@@ -1014,7 +1004,7 @@ func (a *App) getSettings(w http.ResponseWriter, r *http.Request) {
|
|||||||
"decoy": cfg.Decoy,
|
"decoy": cfg.Decoy,
|
||||||
"signin": cfg.SignIn,
|
"signin": cfg.SignIn,
|
||||||
"geo": a.geoStatus(),
|
"geo": a.geoStatus(),
|
||||||
"adminUsername": a.username(cfg, who(r).UserID), // kept for older iOS app versions
|
"updates": a.updates.Status(),
|
||||||
"fingerprint": a.tls.Fingerprint(),
|
"fingerprint": a.tls.Fingerprint(),
|
||||||
"logPath": a.logPath,
|
"logPath": a.logPath,
|
||||||
})
|
})
|
||||||
@@ -1026,8 +1016,8 @@ func (a *App) patchSettings(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeErr(w, err)
|
writeErr(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if _, ok := m["adminUsername"]; ok {
|
if _, ok := m["signin"]; ok && !who(r).IsAdmin {
|
||||||
writeErr(w, badRequest("usernames are changed under /users"))
|
writeJSON(w, http.StatusForbidden, map[string]string{"error": "API tokens cannot change the sign-in rules; sign in to the web interface"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var restart bool
|
var restart bool
|
||||||
@@ -1047,6 +1037,9 @@ func (a *App) patchSettings(w http.ResponseWriter, r *http.Request) {
|
|||||||
if err := field(m, "signin", &c.SignIn); err != nil {
|
if err := field(m, "signin", &c.SignIn); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := field(m, "updates", &c.Updates); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
return field(m, "log", &c.Log)
|
return field(m, "log", &c.Log)
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1192,6 +1185,17 @@ func (a *App) applyRuntime(c *Config) {
|
|||||||
a.logw.SetLimits(c.Log.MaxSizeMB, c.Log.MaxFiles)
|
a.logw.SetLimits(c.Log.MaxSizeMB, c.Log.MaxFiles)
|
||||||
}
|
}
|
||||||
a.geo.SetEnabled(c.Stats.geoEnabled())
|
a.geo.SetEnabled(c.Stats.geoEnabled())
|
||||||
|
a.updates.Set(c.Updates)
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkUpdates asks the release source now and returns what it found.
|
||||||
|
func (a *App) checkUpdates(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if a.updates == nil || !a.updates.Status().Enabled {
|
||||||
|
writeErr(w, badRequest("the update check is switched off"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.updates.Check(r.Context())
|
||||||
|
writeJSON(w, http.StatusOK, a.updates.Status())
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) geoStatus() GeoStatus {
|
func (a *App) geoStatus() GeoStatus {
|
||||||
|
|||||||
@@ -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 strong { font-size: 14px; font-weight: 500; color: #fff; overflow: hidden; text-overflow: ellipsis; }
|
||||||
.side .acct span span { color: #a9aaa5; }
|
.side .acct span span { color: #a9aaa5; }
|
||||||
.side .avatar { width: 28px; height: 28px; border-radius: 50%; background: #3a3b41; display: grid; place-items: center; flex: none; font-size: 13px; font-weight: 600; color: #fff; }
|
.side .avatar { width: 28px; height: 28px; border-radius: 50%; background: #3a3b41; display: grid; place-items: center; flex: none; font-size: 13px; font-weight: 600; color: #fff; }
|
||||||
.side .footrow { display: flex; justify-content: space-between; padding: 10px 12px 0; }
|
.side .footrow { display: flex; justify-content: space-between; align-items: center; gap: 8px; padding: 10px 12px 0; }
|
||||||
|
.side .footrow .upd { font-size: 11.5px; font-weight: 500; color: #cfe2f8; background: #1f3550; border: 1px solid #2d4a6e; padding: 2px 8px; border-radius: 999px; text-decoration: none; white-space: nowrap; }
|
||||||
|
.side .footrow .upd:hover { color: #fff; }
|
||||||
|
.side .nav .pip { margin-left: auto; width: 7px; height: 7px; border-radius: 50%; background: #6aa6ea; }
|
||||||
.main { flex: 999 1 560px; min-width: 0; padding: 32px 32px 56px; }
|
.main { flex: 999 1 560px; min-width: 0; padding: 32px 32px 56px; }
|
||||||
/* Beside the page (not stacked above it on a phone), the sidebar stays in
|
/* Beside the page (not stacked above it on a phone), the sidebar stays in
|
||||||
place while the page scrolls, so the account link is always visible. */
|
place while the page scrolls, so the account link is always visible. */
|
||||||
@@ -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; }
|
.tag { display: inline-block; font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 999px; background: var(--warn-bg); color: var(--warn-ink); margin-left: 6px; vertical-align: 1px; }
|
||||||
.notice { display: flex; gap: 10px; align-items: flex-start; padding: 12px 14px; border-radius: 10px; background: var(--warn-bg); color: var(--warn-ink); font-size: 13px; }
|
.notice { display: flex; gap: 10px; align-items: flex-start; padding: 12px 14px; border-radius: 10px; background: var(--warn-bg); color: var(--warn-ink); font-size: 13px; }
|
||||||
.notice.err { background: #fbefee; color: var(--bad-ink); }
|
.notice.err { background: #fbefee; color: var(--bad-ink); }
|
||||||
|
.notice.new { background: #e8f0fa; color: #174d8f; flex-wrap: wrap; align-items: center; }
|
||||||
|
.notice.new .actions { margin-left: auto; }
|
||||||
|
.btn.ghost { background: transparent; border-color: transparent; }
|
||||||
.notice .btn { margin-left: auto; }
|
.notice .btn { margin-left: auto; }
|
||||||
|
|
||||||
/* tables */
|
/* tables */
|
||||||
@@ -144,6 +150,8 @@ td { padding: 12px; border-bottom: 1px solid var(--line-2); vertical-align: midd
|
|||||||
tr:last-child td { border-bottom: 0; }
|
tr:last-child td { border-bottom: 0; }
|
||||||
.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||||
td .note { font-size: 12px; color: var(--ink-3); }
|
td .note { font-size: 12px; color: var(--ink-3); }
|
||||||
|
a.pname { color: var(--ink); font-weight: 600; text-decoration: none; }
|
||||||
|
a.pname:hover, a.pname:focus-visible { color: var(--ink); text-decoration: underline; text-underline-offset: 3px; }
|
||||||
.empty { padding: 24px 12px; margin: 0; text-align: center; color: var(--ink-3); }
|
.empty { padding: 24px 12px; margin: 0; text-align: center; color: var(--ink-3); }
|
||||||
|
|
||||||
/* forms */
|
/* forms */
|
||||||
@@ -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 dt { color: var(--ink-2); }
|
||||||
.kv dd { margin: 0; min-width: 0; overflow-wrap: anywhere; }
|
.kv dd { margin: 0; min-width: 0; overflow-wrap: anywhere; }
|
||||||
|
|
||||||
/* checks */
|
/* health: public addresses on the left, one row per check on the right */
|
||||||
.chk { display: flex; gap: 10px; align-items: center; padding: 10px 0; border-bottom: 1px solid var(--line-2); font-size: 13px; }
|
.hcbody { display: grid; grid-template-columns: minmax(0, 5fr) minmax(0, 7fr); gap: 12px; margin-top: 16px; }
|
||||||
.chk:last-child { border-bottom: 0; }
|
.hchead { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; }
|
||||||
.chk b { font-weight: 500; min-width: 160px; }
|
.hchead > span { font-size: 13px; color: var(--ink-2); }
|
||||||
|
.hchead > span.bad { color: var(--bad-ink); font-weight: 500; }
|
||||||
|
.hcaddrs { display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
.hcaddr { flex: 1; display: flex; flex-direction: column; justify-content: center; background: var(--ground); border-radius: 10px; padding: 14px 18px; min-width: 0; }
|
||||||
|
.hcaddr .l { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--ink-2); }
|
||||||
|
.hcaddr .v { margin-top: 4px; font-size: 15px; font-weight: 500; overflow-wrap: anywhere; }
|
||||||
|
.hcaddr .v.mono { font-size: 22px; }
|
||||||
|
.hcaddr .n { font-family: var(--sans); font-size: 12px; font-weight: 400; color: var(--ink-2); }
|
||||||
|
.hcaddr.bad { background: #fdf6f5; box-shadow: inset 0 0 0 1px #e6b3b0; }
|
||||||
|
.hcaddr.bad .v { color: var(--bad-ink); }
|
||||||
|
.hclist { border: 1px solid var(--line); border-radius: 10px; min-width: 0; }
|
||||||
|
.hcrow { display: grid; grid-template-columns: 8px minmax(0, 190px) minmax(0, 1fr); gap: 2px 14px; align-items: baseline; padding: 11px 16px; border-top: 1px solid var(--line-2); }
|
||||||
|
.hcrow:first-child { border-top: 0; }
|
||||||
|
.hcrow > .dot { align-self: center; }
|
||||||
|
.hcrow .l { font-size: 13px; color: var(--ink-2); }
|
||||||
|
.hcrow .v { display: flex; flex-wrap: wrap; align-items: baseline; gap: 2px 10px; min-width: 0; }
|
||||||
|
.hcrow .s { font-weight: 500; }
|
||||||
|
.hcrow .r { font-size: 12px; color: var(--ink-3); overflow-wrap: anywhere; }
|
||||||
|
.hcrow .p { grid-column: 2 / -1; font-size: 12.5px; color: var(--bad-ink); overflow-wrap: anywhere; }
|
||||||
|
.hcrow.bad { background: #fdf6f5; }
|
||||||
|
.hcrow.bad .s { color: var(--bad-ink); }
|
||||||
|
@media (max-width: 1000px) { .hcbody { grid-template-columns: minmax(0, 1fr); } }
|
||||||
|
@media (max-width: 640px) { .hcrow { grid-template-columns: 8px minmax(0, 1fr); } .hcrow .v { grid-column: 2; } }
|
||||||
|
|
||||||
|
/* updates */
|
||||||
|
.upvers { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; margin-top: 14px; }
|
||||||
|
.upbox { background: var(--ground); border-radius: 10px; padding: 12px 14px; display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
||||||
|
.upbox > span { font-size: 12px; color: var(--ink-2); }
|
||||||
|
.upbox strong { font-size: 15px; font-weight: 500; }
|
||||||
|
.upbox strong.mono { font-size: 16px; }
|
||||||
|
.upbox.new { background: #e8f0fa; box-shadow: inset 0 0 0 1px #bcd2ee; }
|
||||||
|
.upbox.new strong { color: #174d8f; }
|
||||||
|
.uptodate { display: flex; align-items: center; gap: 10px; margin: 14px 0 0; font-weight: 500; }
|
||||||
|
.upnotes { border: 1px solid var(--line); border-radius: 10px; padding: 14px 16px; margin-top: 14px; display: flex; flex-direction: column; gap: 10px; font-size: 13px; }
|
||||||
|
.upnotes p { margin: 0; max-width: 80ch; }
|
||||||
|
.upnotes ul { margin: 0; padding-left: 18px; display: flex; flex-direction: column; gap: 6px; max-width: 80ch; }
|
||||||
|
.upnotes .hd, .upcmd .hd { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; }
|
||||||
|
.upnotes .hd a { margin-left: auto; }
|
||||||
|
.upcmd { display: flex; flex-direction: column; gap: 8px; margin-top: 14px; }
|
||||||
|
.uprow { display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap; margin-top: 16px; padding-top: 14px; border-top: 1px solid var(--line-2); }
|
||||||
|
#updates > .notice { margin-top: 14px; }
|
||||||
|
|
||||||
/* activity */
|
/* activity */
|
||||||
.ev { display: flex; gap: 12px; padding: 10px 0; border-bottom: 1px solid var(--line-2); font-size: 13px; }
|
.ev { display: flex; gap: 12px; padding: 10px 0; border-bottom: 1px solid var(--line-2); font-size: 13px; }
|
||||||
|
|||||||
@@ -257,13 +257,26 @@
|
|||||||
else if (okMsg) toast(okMsg);
|
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) {
|
function dialog(build) {
|
||||||
const d = h('dialog');
|
const d = h('dialog');
|
||||||
const close = () => d.close();
|
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));
|
d.append(build(close));
|
||||||
document.body.append(d);
|
document.body.append(d);
|
||||||
d.showModal();
|
d.showModal();
|
||||||
|
moved.observe(document.body, { childList: true, subtree: true });
|
||||||
return d;
|
return d;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -517,7 +530,7 @@
|
|||||||
|
|
||||||
const NAV = [['#/', 'dashboard', 'Dashboard'], ['#/peers', 'peers', 'Peers'], ['#/server', 'server', 'Server'], ['#/settings', 'settings', 'Settings']];
|
const NAV = [['#/', 'dashboard', 'Dashboard'], ['#/peers', 'peers', 'Peers'], ['#/server', 'server', 'Server'], ['#/settings', 'settings', 'Settings']];
|
||||||
let navLinks = {};
|
let navLinks = {};
|
||||||
let srvBox, peerCount;
|
let srvBox, peerCount, verRow;
|
||||||
|
|
||||||
function buildShell() {
|
function buildShell() {
|
||||||
srvBox = h('div', { class: 'srv' }, h('span', { class: 'dot' }), h('span', null, 'Loading…'));
|
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', { 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('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('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' },
|
(verRow = h('div', { class: 'footrow' }))));
|
||||||
h('span', null, 'v' + me.version.replace(/^v/, '')))));
|
|
||||||
main = h('main', { class: 'main', id: 'main' });
|
main = h('main', { class: 'main', id: 'main' });
|
||||||
app.replaceChildren(h('div', { class: 'shell' }, nav, main));
|
app.replaceChildren(h('div', { class: 'shell' }, nav, main));
|
||||||
|
drawUpdateHint();
|
||||||
refreshSide();
|
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() {
|
async function refreshSide() {
|
||||||
try {
|
try {
|
||||||
const s = await api('GET', '/status');
|
const s = await api('GET', '/status');
|
||||||
@@ -569,7 +594,7 @@
|
|||||||
[/^#\/peers\/new$/, '#/peers', viewPeerNew],
|
[/^#\/peers\/new$/, '#/peers', viewPeerNew],
|
||||||
[/^#\/peers\/([\w-]+)$/, '#/peers', viewPeer],
|
[/^#\/peers\/([\w-]+)$/, '#/peers', viewPeer],
|
||||||
[/^#\/server$/, '#/server', viewServer],
|
[/^#\/server$/, '#/server', viewServer],
|
||||||
[/^#\/settings$/, '#/settings', viewSettings],
|
[/^#\/settings(#updates)?$/, '#/settings', viewSettings],
|
||||||
[/^#\/account$/, '#/account', viewAccount],
|
[/^#\/account$/, '#/account', viewAccount],
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -992,6 +1017,8 @@
|
|||||||
h('div', null, h('strong', null, 'Needs attention: '), failing.map((c) => c.name + ' (' + c.detail + ')').join(' · ')),
|
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,
|
h('a', { class: 'btn small', href: '#/server' }, 'Health')) : null,
|
||||||
|
|
||||||
|
updateBanner(),
|
||||||
|
|
||||||
h('div', { class: 'tiles' },
|
h('div', { class: 'tiles' },
|
||||||
h('div', { class: 'card tile' }, h('div', { class: 'k' }, 'Peers online'),
|
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)),
|
h('div', { class: 'v' }, String(st.peers.online), h('small', null, '/ ' + st.peers.total)),
|
||||||
@@ -1111,7 +1138,7 @@
|
|||||||
return hit && keep;
|
return hit && keep;
|
||||||
});
|
});
|
||||||
tbody.replaceChildren(...sorted(rows).map((p) => h('tr', null,
|
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', { class: 'mono' }, p.ipv4),
|
||||||
h('td', null, badge(peerState(p))),
|
h('td', null, badge(peerState(p))),
|
||||||
h('td', { class: 'mono muted' }, p.stats.endpoint || '–',
|
h('td', { class: 'mono muted' }, p.stats.endpoint || '–',
|
||||||
@@ -1497,6 +1524,69 @@
|
|||||||
|
|
||||||
const DNS_PRESETS = [['Quad9', '9.9.9.9, 149.112.112.112']];
|
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) {
|
async function viewServer(wrap) {
|
||||||
const [srv, st] = await Promise.all([api('GET', '/server'), api('GET', '/status')]);
|
const [srv, st] = await Promise.all([api('GET', '/server'), api('GET', '/status')]);
|
||||||
const orig = JSON.parse(JSON.stringify(srv));
|
const orig = JSON.parse(JSON.stringify(srv));
|
||||||
@@ -1567,11 +1657,7 @@
|
|||||||
fill(wrap,
|
fill(wrap,
|
||||||
h('div', null, h('h1', null, 'Server'), h('p', { class: 'sub' }, 'WireGuard interface, address plan, client defaults and firewall')),
|
h('div', null, h('h1', null, 'Server'), h('p', { class: 'sub' }, 'WireGuard interface, address plan, client defaults and firewall')),
|
||||||
result,
|
result,
|
||||||
h('section', { class: 'card', 'aria-labelledby': 'hc' },
|
healthCard(st.checks),
|
||||||
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))))),
|
|
||||||
|
|
||||||
h('section', { class: 'card', 'aria-labelledby': 'if' },
|
h('section', { class: 'card', 'aria-labelledby': 'if' },
|
||||||
h('h2', { id: 'if' }, 'Interface'),
|
h('h2', { id: 'if' }, 'Interface'),
|
||||||
@@ -1621,6 +1707,125 @@
|
|||||||
bar);
|
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 ----------
|
// ---------- settings ----------
|
||||||
|
|
||||||
// ---------- my account ----------
|
// ---------- my account ----------
|
||||||
@@ -1999,7 +2204,7 @@
|
|||||||
} });
|
} });
|
||||||
|
|
||||||
fill(wrap,
|
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,
|
restartBox,
|
||||||
|
|
||||||
h('section', { class: 'card flush', 'aria-labelledby': 'usr' },
|
h('section', { class: 'card flush', 'aria-labelledby': 'usr' },
|
||||||
@@ -2074,8 +2279,11 @@
|
|||||||
h('div', { class: 'actions' },
|
h('div', { class: 'actions' },
|
||||||
h('a', { class: 'btn', href: '/api/v1/backup' }, 'Download backup'),
|
h('a', { class: 'btn', href: '/api/v1/backup' }, 'Download backup'),
|
||||||
h('button', { type: 'button', class: 'btn', onClick: () => restoreInput.click() }, 'Restore from file…'),
|
h('button', { type: 'button', class: 'btn', onClick: () => restoreInput.click() }, 'Restore from file…'),
|
||||||
restoreInput)));
|
restoreInput)),
|
||||||
|
|
||||||
|
updatesCard(s.updates));
|
||||||
await drawLogs();
|
await drawLogs();
|
||||||
|
if (location.hash.endsWith('#updates')) document.getElementById('updates').scrollIntoView();
|
||||||
}
|
}
|
||||||
|
|
||||||
render();
|
render();
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -26,12 +27,23 @@ const (
|
|||||||
argonKeyLen = 32
|
argonKeyLen = 32
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Every argon2 run takes argonMemory (64 MiB). argonSlots caps how many run
|
||||||
|
// at once, so a burst of sign-ins cannot run the server out of memory: two
|
||||||
|
// slots are 128 MiB at most.
|
||||||
|
var argonSlots = make(chan struct{}, 2)
|
||||||
|
|
||||||
|
func argonKey(pw, salt []byte, t, m uint32, p uint8, n uint32) []byte {
|
||||||
|
argonSlots <- struct{}{}
|
||||||
|
defer func() { <-argonSlots }()
|
||||||
|
return argon2.IDKey(pw, salt, t, m, p, n)
|
||||||
|
}
|
||||||
|
|
||||||
func hashPassword(pw string) (string, error) {
|
func hashPassword(pw string) (string, error) {
|
||||||
salt := make([]byte, 16)
|
salt := make([]byte, 16)
|
||||||
if _, err := rand.Read(salt); err != nil {
|
if _, err := rand.Read(salt); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
key := argon2.IDKey([]byte(pw), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
|
key := argonKey([]byte(pw), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
|
||||||
b64 := base64.RawStdEncoding
|
b64 := base64.RawStdEncoding
|
||||||
return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
|
return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
|
||||||
argon2.Version, argonMemory, argonTime, argonThreads, b64.EncodeToString(salt), b64.EncodeToString(key)), nil
|
argon2.Version, argonMemory, argonTime, argonThreads, b64.EncodeToString(salt), b64.EncodeToString(key)), nil
|
||||||
@@ -54,7 +66,7 @@ func verifyPassword(encoded, pw string) bool {
|
|||||||
if err1 != nil || err2 != nil {
|
if err1 != nil || err2 != nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
got := argon2.IDKey([]byte(pw), salt, t, m, p, uint32(len(want)))
|
got := argonKey([]byte(pw), salt, t, m, p, uint32(len(want)))
|
||||||
return subtle.ConstantTimeCompare(got, want) == 1
|
return subtle.ConstantTimeCompare(got, want) == 1
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,13 +152,17 @@ type Auth struct {
|
|||||||
sessions map[string]*session
|
sessions map[string]*session
|
||||||
used map[string]tokenUse
|
used map[string]tokenUse
|
||||||
logins map[string]tokenUse // last sign-in per user ID
|
logins map[string]tokenUse // last sign-in per user ID
|
||||||
fails map[string]*failState
|
fails map[string]*failState // by lockKey
|
||||||
|
waiting int // sign-ins waiting for or running a password check
|
||||||
mfa mfaState
|
mfa mfaState
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
maxFailures = 5
|
maxFailures = 5
|
||||||
lockoutTime = 15 * time.Minute
|
lockoutTime = 15 * time.Minute
|
||||||
|
// maxWaiting sign-ins may wait for a password check; more are turned
|
||||||
|
// away until the queue is shorter.
|
||||||
|
maxWaiting = 16
|
||||||
)
|
)
|
||||||
|
|
||||||
func newAuth(s *Store) *Auth {
|
func newAuth(s *Store) *Auth {
|
||||||
@@ -155,23 +171,51 @@ func newAuth(s *Store) *Auth {
|
|||||||
|
|
||||||
func cookieName() string { return appName + "_session" }
|
func cookieName() string { return appName + "_session" }
|
||||||
|
|
||||||
var errLocked = errors.New("too many failed attempts, try again later")
|
var (
|
||||||
|
errLocked = errors.New("too many failed attempts, try again later")
|
||||||
|
errBusy = errors.New("too many sign-ins at once, try again in a moment")
|
||||||
|
)
|
||||||
|
|
||||||
|
// lockKey is what failed sign-ins are counted by: the IPv4 address, or the
|
||||||
|
// /64 network of an IPv6 address, since one device can pick any address in
|
||||||
|
// its /64.
|
||||||
|
func lockKey(ip string) string {
|
||||||
|
a, err := netip.ParseAddr(ip)
|
||||||
|
if err != nil || a.Unmap().Is4() {
|
||||||
|
return ip
|
||||||
|
}
|
||||||
|
p, _ := a.Prefix(64)
|
||||||
|
return p.String()
|
||||||
|
}
|
||||||
|
|
||||||
// Login checks the credentials and returns a new session id, or, for a user
|
// Login checks the credentials and returns a new session id, or, for a user
|
||||||
// with two-step sign-in, a ticket for the second step.
|
// with two-step sign-in, a ticket for the second step.
|
||||||
func (a *Auth) Login(user, pw, ip string) (sessionID, ticket string, err error) {
|
func (a *Auth) Login(user, pw, ip string) (sessionID, ticket string, err error) {
|
||||||
a.mu.Lock()
|
|
||||||
f := a.fails[ip]
|
|
||||||
if f != nil && time.Now().Before(f.until) {
|
|
||||||
a.mu.Unlock()
|
|
||||||
return "", "", errLocked
|
|
||||||
}
|
|
||||||
a.mu.Unlock()
|
|
||||||
|
|
||||||
cfg := a.store.Get()
|
cfg := a.store.Get()
|
||||||
if !cfg.passwordSet() {
|
if !cfg.passwordSet() {
|
||||||
return "", "", errors.New("no password is set; run: " + appName + " passwd")
|
return "", "", errors.New("no password is set; run: " + appName + " passwd")
|
||||||
}
|
}
|
||||||
|
// The attempt counts as failed before the password is checked, so
|
||||||
|
// parallel attempts cannot get past the lockout; a right password takes
|
||||||
|
// it back.
|
||||||
|
a.mu.Lock()
|
||||||
|
if a.lockedLocked(ip) {
|
||||||
|
a.mu.Unlock()
|
||||||
|
return "", "", errLocked
|
||||||
|
}
|
||||||
|
if a.waiting >= maxWaiting {
|
||||||
|
a.mu.Unlock()
|
||||||
|
return "", "", errBusy
|
||||||
|
}
|
||||||
|
a.waiting++
|
||||||
|
undo := a.failLocked(ip)
|
||||||
|
a.mu.Unlock()
|
||||||
|
defer func() {
|
||||||
|
a.mu.Lock()
|
||||||
|
a.waiting--
|
||||||
|
a.mu.Unlock()
|
||||||
|
}()
|
||||||
|
|
||||||
// An unknown username costs as much time as a wrong password, so the
|
// An unknown username costs as much time as a wrong password, so the
|
||||||
// answer time does not tell which usernames exist.
|
// answer time does not tell which usernames exist.
|
||||||
u := cfg.userByName(strings.TrimSpace(user))
|
u := cfg.userByName(strings.TrimSpace(user))
|
||||||
@@ -185,21 +229,13 @@ func (a *Auth) Login(user, pw, ip string) (sessionID, ticket string, err error)
|
|||||||
a.mu.Lock()
|
a.mu.Lock()
|
||||||
defer a.mu.Unlock()
|
defer a.mu.Unlock()
|
||||||
if !okUser || !okPw {
|
if !okUser || !okPw {
|
||||||
if f == nil {
|
|
||||||
f = &failState{}
|
|
||||||
a.fails[ip] = f
|
|
||||||
}
|
|
||||||
f.count++
|
|
||||||
if f.count >= maxFailures {
|
|
||||||
f.count = 0
|
|
||||||
f.until = time.Now().Add(lockoutTime)
|
|
||||||
}
|
|
||||||
return "", "", errors.New("wrong username or password")
|
return "", "", errors.New("wrong username or password")
|
||||||
}
|
}
|
||||||
|
undo()
|
||||||
if u.hasMFA() {
|
if u.hasMFA() {
|
||||||
return "", a.newTicketLocked(u, ip), nil
|
return "", a.newTicketLocked(u, ip), nil
|
||||||
}
|
}
|
||||||
delete(a.fails, ip)
|
delete(a.fails, lockKey(ip))
|
||||||
a.logins[u.ID] = tokenUse{At: time.Now(), IP: ip}
|
a.logins[u.ID] = tokenUse{At: time.Now(), IP: ip}
|
||||||
return a.newSessionLocked(cfg, u, sessionInfo{Started: time.Now(), IP: ip}), "", nil
|
return a.newSessionLocked(cfg, u, sessionInfo{Started: time.Now(), IP: ip}), "", nil
|
||||||
}
|
}
|
||||||
@@ -321,9 +357,9 @@ func (a *Auth) sweep() {
|
|||||||
delete(a.sessions, id)
|
delete(a.sessions, id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for ip, f := range a.fails {
|
for key, f := range a.fails {
|
||||||
if now.After(f.until) && f.count == 0 {
|
if now.After(f.until) && f.count == 0 {
|
||||||
delete(a.fails, ip)
|
delete(a.fails, key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for id, t := range a.mfa.tickets {
|
for id, t := range a.mfa.tickets {
|
||||||
|
|||||||
@@ -34,8 +34,17 @@ type Config struct {
|
|||||||
Stats StatsConfig `json:"stats"`
|
Stats StatsConfig `json:"stats"`
|
||||||
Decoy DecoyConfig `json:"decoy"`
|
Decoy DecoyConfig `json:"decoy"`
|
||||||
SignIn SignInConfig `json:"signin"`
|
SignIn SignInConfig `json:"signin"`
|
||||||
|
Updates UpdatesConfig `json:"updates"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdatesConfig sets the daily check for a newer release.
|
||||||
|
type UpdatesConfig struct {
|
||||||
|
Check *bool `json:"check,omitempty"` // default on
|
||||||
|
Source string `json:"source"` // gitea | github, see updateSources
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c UpdatesConfig) checkEnabled() bool { return c.Check == nil || *c.Check }
|
||||||
|
|
||||||
// SignInConfig holds the rules for signing in to the web interface.
|
// SignInConfig holds the rules for signing in to the web interface.
|
||||||
type SignInConfig struct {
|
type SignInConfig struct {
|
||||||
// RequireMFA sends users without two-step sign-in to set it up before
|
// RequireMFA sends users without two-step sign-in to set it up before
|
||||||
@@ -246,6 +255,9 @@ func (c *Config) applyDefaults() {
|
|||||||
if c.Decoy.Page == "" {
|
if c.Decoy.Page == "" {
|
||||||
c.Decoy.Page = "nginx"
|
c.Decoy.Page = "nginx"
|
||||||
}
|
}
|
||||||
|
if c.Updates.Source == "" {
|
||||||
|
c.Updates.Source = "gitea"
|
||||||
|
}
|
||||||
if c.APITokens == nil {
|
if c.APITokens == nil {
|
||||||
c.APITokens = []APIToken{}
|
c.APITokens = []APIToken{}
|
||||||
}
|
}
|
||||||
@@ -382,6 +394,9 @@ func (c *Config) validate() error {
|
|||||||
if _, ok := decoyPages[c.Decoy.Page]; !ok {
|
if _, ok := decoyPages[c.Decoy.Page]; !ok {
|
||||||
return fmt.Errorf("unknown decoy page %q", c.Decoy.Page)
|
return fmt.Errorf("unknown decoy page %q", c.Decoy.Page)
|
||||||
}
|
}
|
||||||
|
if _, ok := updateSources[c.Updates.Source]; !ok {
|
||||||
|
return fmt.Errorf("update source must be gitea or github")
|
||||||
|
}
|
||||||
switch c.Web.TLS.Mode {
|
switch c.Web.TLS.Mode {
|
||||||
case "acme":
|
case "acme":
|
||||||
if c.Web.TLS.Domain == "" {
|
if c.Web.TLS.Domain == "" {
|
||||||
|
|||||||
@@ -222,15 +222,16 @@ func run(configPath string) error {
|
|||||||
auth := newAuth(store)
|
auth := newAuth(store)
|
||||||
app := &App{
|
app := &App{
|
||||||
store: store, kernel: kernel, recon: recon, stats: stats, auth: auth, tls: webTLS,
|
store: store, kernel: kernel, recon: recon, stats: stats, auth: auth, tls: webTLS,
|
||||||
logPath: logPath, logw: logw, geo: geo, started: time.Now(), shutdown: shutdown,
|
logPath: logPath, logw: logw, geo: geo, updates: newUpdater(cfg.Updates), started: time.Now(), shutdown: shutdown,
|
||||||
}
|
}
|
||||||
|
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
wg.Add(4)
|
wg.Add(5)
|
||||||
go func() { defer wg.Done(); recon.Run(stop) }()
|
go func() { defer wg.Done(); recon.Run(stop) }()
|
||||||
go func() { defer wg.Done(); stats.Run(stop) }()
|
go func() { defer wg.Done(); stats.Run(stop) }()
|
||||||
go func() { defer wg.Done(); stats.RunPings(stop) }()
|
go func() { defer wg.Done(); stats.RunPings(stop) }()
|
||||||
go func() { defer wg.Done(); geo.Run(stop) }()
|
go func() { defer wg.Done(); geo.Run(stop) }()
|
||||||
|
go func() { defer wg.Done(); app.updates.Run(stop) }()
|
||||||
go func() {
|
go func() {
|
||||||
t := time.NewTicker(10 * time.Minute)
|
t := time.NewTicker(10 * time.Minute)
|
||||||
defer t.Stop()
|
defer t.Stop()
|
||||||
|
|||||||
+76
-6
@@ -14,6 +14,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -77,6 +78,7 @@ func TestValidate(t *testing.T) {
|
|||||||
"bad dns": func(c *Config) { c.Peers[0].DNS = []string{"dns.example"} },
|
"bad dns": func(c *Config) { c.Peers[0].DNS = []string{"dns.example"} },
|
||||||
"bad port": func(c *Config) { c.Server.ListenPort = 70000 },
|
"bad port": func(c *Config) { c.Server.ListenPort = 70000 },
|
||||||
"unmasked net": func(c *Config) { c.Server.IPv4 = "10.84.12.5/24" },
|
"unmasked net": func(c *Config) { c.Server.IPv4 = "10.84.12.5/24" },
|
||||||
|
"update source": func(c *Config) { c.Updates.Source = "sourceforge" },
|
||||||
} {
|
} {
|
||||||
cc := c.clone()
|
cc := c.clone()
|
||||||
mutate(cc)
|
mutate(cc)
|
||||||
@@ -312,8 +314,13 @@ func TestAPI(t *testing.T) {
|
|||||||
secret := tok["token"].(string)
|
secret := tok["token"].(string)
|
||||||
|
|
||||||
// Read-only token: GET works, changes are refused, admin endpoints too.
|
// Read-only token: GET works, changes are refused, admin endpoints too.
|
||||||
bearer := func(method, path string, want int) {
|
bearer := func(method, path string, want int, body ...any) {
|
||||||
req, _ := http.NewRequest(method, srv.URL+"/api/v1"+path, nil)
|
var rd io.Reader
|
||||||
|
if len(body) > 0 {
|
||||||
|
b, _ := json.Marshal(body[0])
|
||||||
|
rd = bytes.NewReader(b)
|
||||||
|
}
|
||||||
|
req, _ := http.NewRequest(method, srv.URL+"/api/v1"+path, rd)
|
||||||
req.Header.Set("Authorization", "Bearer "+secret)
|
req.Header.Set("Authorization", "Bearer "+secret)
|
||||||
resp, err := http.DefaultClient.Do(req)
|
resp, err := http.DefaultClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -329,10 +336,20 @@ func TestAPI(t *testing.T) {
|
|||||||
bearer("GET", "/tokens", 403)
|
bearer("GET", "/tokens", 403)
|
||||||
bearer("GET", "/peers/"+id+"/setup", 403) // the link would set up a device
|
bearer("GET", "/peers/"+id+"/setup", 403) // the link would set up a device
|
||||||
|
|
||||||
// A full-access token manages users and tokens, but not backups.
|
// A full-access token changes settings, but users, passwords, tokens,
|
||||||
|
// the sign-in rules and backups need a signed-in user.
|
||||||
secret = call("POST", "/tokens", map[string]string{"name": "full", "scope": "rw"}, 201)["token"].(string)
|
secret = call("POST", "/tokens", map[string]string{"name": "full", "scope": "rw"}, 201)["token"].(string)
|
||||||
bearer("GET", "/users", 200)
|
uid := call("GET", "/auth/me", nil, 200)["id"].(string)
|
||||||
bearer("GET", "/tokens", 200)
|
bearer("PATCH", "/settings", 200, map[string]any{"log": store.Get().Log})
|
||||||
|
bearer("PATCH", "/settings", 403, map[string]any{"signin": map[string]bool{"requireMfa": false}})
|
||||||
|
bearer("GET", "/users", 403)
|
||||||
|
bearer("POST", "/users", 403, map[string]any{"username": "eve", "password": "correct horse battery"})
|
||||||
|
bearer("POST", "/users/"+uid+"/reset-password", 403, map[string]any{"password": "correct horse battery"})
|
||||||
|
bearer("POST", "/users/"+uid+"/reset-mfa", 403)
|
||||||
|
bearer("POST", "/auth/password", 403, map[string]string{"current": "x", "new": "y"})
|
||||||
|
bearer("GET", "/tokens", 403)
|
||||||
|
bearer("POST", "/tokens", 403, map[string]string{"name": "more", "scope": "rw"})
|
||||||
|
bearer("DELETE", "/tokens/"+tok["id"].(string), 403)
|
||||||
bearer("GET", "/backup", 403)
|
bearer("GET", "/backup", 403)
|
||||||
|
|
||||||
call("DELETE", "/peers/"+id, nil, 200)
|
call("DELETE", "/peers/"+id, nil, 200)
|
||||||
@@ -409,6 +426,60 @@ func TestSysctlConf(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLoginLockout(t *testing.T) {
|
||||||
|
store, err := openStore(filepath.Join(t.TempDir(), "config.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
hash, _ := hashPassword("a long test password")
|
||||||
|
_ = store.Update(func(c *Config) error { c.Users[0].PasswordHash = hash; return nil })
|
||||||
|
a := newAuth(store)
|
||||||
|
const right, wrong = "a long test password", "a wrong password"
|
||||||
|
|
||||||
|
// Ten wrong attempts at once from one /64: five are checked, the others
|
||||||
|
// are locked out before any password check.
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
var mu sync.Mutex
|
||||||
|
got := map[string]int{}
|
||||||
|
for i := range 10 {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
_, _, err := a.Login("admin", wrong, fmt.Sprintf("2001:db8::%x", i+1))
|
||||||
|
mu.Lock()
|
||||||
|
got[err.Error()]++
|
||||||
|
mu.Unlock()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
if got["wrong username or password"] != 5 || got[errLocked.Error()] != 5 {
|
||||||
|
t.Fatalf("parallel attempts: %v", got)
|
||||||
|
}
|
||||||
|
if _, _, err := a.Login("admin", right, "2001:db8::ffff"); !errors.Is(err, errLocked) {
|
||||||
|
t.Fatalf("same /64: %v, want locked", err)
|
||||||
|
}
|
||||||
|
if _, _, err := a.Login("admin", right, "2001:db8:0:1::1"); err != nil {
|
||||||
|
t.Fatalf("other /64: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A right password takes its own attempt back. With two-step sign-in
|
||||||
|
// the earlier failures stay, so wrong codes still lead to the lockout.
|
||||||
|
_ = store.Update(func(c *Config) error { c.Users[0].MFA = &UserMFA{TOTPSecret: newTOTPSecret()}; return nil })
|
||||||
|
ip := "192.0.2.7"
|
||||||
|
for range maxFailures - 1 {
|
||||||
|
_, _, _ = a.Login("admin", wrong, ip)
|
||||||
|
}
|
||||||
|
if _, tk, err := a.Login("admin", right, ip); err != nil || tk == "" {
|
||||||
|
t.Fatalf("5th attempt, right password: ticket %q, %v", tk, err)
|
||||||
|
}
|
||||||
|
if _, _, err := a.Login("admin", wrong, ip); err == nil || errors.Is(err, errLocked) {
|
||||||
|
t.Fatalf("6th attempt: %v, want wrong password", err)
|
||||||
|
}
|
||||||
|
if _, _, err := a.Login("admin", right, ip); !errors.Is(err, errLocked) {
|
||||||
|
t.Fatalf("7th attempt: %v, want locked", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestWriteIfChanged(t *testing.T) {
|
func TestWriteIfChanged(t *testing.T) {
|
||||||
p := filepath.Join(t.TempDir(), "x.conf")
|
p := filepath.Join(t.TempDir(), "x.conf")
|
||||||
if ch, err := writeIfChanged(p, "a\n", 0o644); !ch || err != nil {
|
if ch, err := writeIfChanged(p, "a\n", 0o644); !ch || err != nil {
|
||||||
@@ -914,7 +985,6 @@ func TestUsers(t *testing.T) {
|
|||||||
if n := len(admin("GET", "/users", nil, 200)["users"].([]any)); n != 2 {
|
if n := len(admin("GET", "/users", nil, 200)["users"].([]any)); n != 2 {
|
||||||
t.Fatalf("users: %d, want 2", n)
|
t.Fatalf("users: %d, want 2", n)
|
||||||
}
|
}
|
||||||
admin("PATCH", "/settings", map[string]any{"adminUsername": "x"}, 400)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestDecoy checks that the decoy hides the web interface but leaves the API
|
// TestDecoy checks that the decoy hides the web interface but leaves the API
|
||||||
|
|||||||
@@ -236,23 +236,34 @@ func newMFAState() mfaState {
|
|||||||
|
|
||||||
var errBadTicket = errors.New("the sign-in expired; enter your password again")
|
var errBadTicket = errors.New("the sign-in expired; enter your password again")
|
||||||
|
|
||||||
// failLocked counts a failed attempt from ip toward the lockout. a.mu must
|
// failLocked counts a failed attempt from ip toward the lockout and returns
|
||||||
// be held.
|
// a function that takes it back, for an attempt counted before it was
|
||||||
func (a *Auth) failLocked(ip string) {
|
// checked. a.mu must be held, also when calling undo.
|
||||||
f := a.fails[ip]
|
func (a *Auth) failLocked(ip string) (undo func()) {
|
||||||
|
key := lockKey(ip)
|
||||||
|
f := a.fails[key]
|
||||||
if f == nil {
|
if f == nil {
|
||||||
f = &failState{}
|
f = &failState{}
|
||||||
a.fails[ip] = f
|
a.fails[key] = f
|
||||||
}
|
}
|
||||||
f.count++
|
f.count++
|
||||||
if f.count >= maxFailures {
|
locked := f.count >= maxFailures
|
||||||
|
if locked {
|
||||||
f.count = 0
|
f.count = 0
|
||||||
f.until = time.Now().Add(lockoutTime)
|
f.until = time.Now().Add(lockoutTime)
|
||||||
}
|
}
|
||||||
|
return func() {
|
||||||
|
switch {
|
||||||
|
case locked:
|
||||||
|
f.count, f.until = maxFailures-1, time.Time{}
|
||||||
|
case f.count > 0:
|
||||||
|
f.count--
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Auth) lockedLocked(ip string) bool {
|
func (a *Auth) lockedLocked(ip string) bool {
|
||||||
f := a.fails[ip]
|
f := a.fails[lockKey(ip)]
|
||||||
return f != nil && time.Now().Before(f.until)
|
return f != nil && time.Now().Before(f.until)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -302,7 +313,7 @@ func (a *Auth) finishSignIn(u *User, ip string) string {
|
|||||||
cfg := a.store.Get()
|
cfg := a.store.Get()
|
||||||
a.mu.Lock()
|
a.mu.Lock()
|
||||||
defer a.mu.Unlock()
|
defer a.mu.Unlock()
|
||||||
delete(a.fails, ip)
|
delete(a.fails, lockKey(ip))
|
||||||
a.logins[u.ID] = tokenUse{At: time.Now(), IP: ip}
|
a.logins[u.ID] = tokenUse{At: time.Now(), IP: ip}
|
||||||
return a.newSessionLocked(cfg, u, sessionInfo{Started: time.Now(), IP: ip})
|
return a.newSessionLocked(cfg, u, sessionInfo{Started: time.Now(), IP: ip})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user