Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 511c6026ac | |||
| 85b401d05e | |||
| b54ff1b002 | |||
| a59a095691 | |||
| d32e851b74 | |||
| e3e6d04955 | |||
| 2c1b4a399a | |||
| 0a8dc8f7af | |||
| 6570611ed8 | |||
| ea13593925 | |||
| 1fe9b4e619 | |||
| ebbc282073 | |||
| 8f13a37d92 | |||
| 990aa55a3d | |||
| 60426577a5 | |||
| 04a1d1ab85 | |||
| ac2ce23613 | |||
| fe71b0b6c2 |
@@ -40,19 +40,6 @@ dependencies on the server: the binary installs, updates and removes itself.
|
||||
- **HTTPS built in:** Let's Encrypt, a self-signed certificate, your own
|
||||
certificate files, or plain HTTP behind a reverse proxy.
|
||||
|
||||
## Screenshots
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
|  |  |
|
||||
| **Peers:** status, endpoint, latency and traffic at a glance | **Peer:** traffic, latency, connection history and settings |
|
||||
|  |  |
|
||||
| **Server:** health, address plan, client defaults and firewall | **Settings:** users, web interface and API tokens |
|
||||
|  |  |
|
||||
| **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,8 +52,19 @@ The screenshots show sample data from the built-in simulator.
|
||||
`CAP_NET_ADMIN` and `CAP_NET_BIND_SERVICE`, and can write only to
|
||||
`/opt/ghostwire`.
|
||||
- **Sign-in:** one or more users, all admins. Passwords are stored as argon2id hashes.
|
||||
After 5 failed attempts, sign-in is locked for 15 minutes. Sessions use an
|
||||
After 5 failed attempts from one IP address, sign-in from it is locked for 15
|
||||
minutes; wrong two-step codes count too. Sessions use an
|
||||
HttpOnly, SameSite=Strict cookie and last 12 hours by default.
|
||||
- **Two-step sign-in:** each user can add an authenticator app (TOTP) and
|
||||
passkeys under My account. A passkey signs in on its own, without username
|
||||
and password, and also works as the second step after a password. It can live
|
||||
on the device (Touch ID, Face ID, Windows Hello), in a password manager, or on
|
||||
a YubiKey with a PIN set. Turning it on gives 10 one-time recovery codes. An
|
||||
admin can require it for everyone (Settings → Sign-in) and reset it for a user
|
||||
who lost their phone or key. Passkeys use WebAuthn and need the server's
|
||||
domain name with a trusted certificate (Let's Encrypt, certificate files, or a
|
||||
reverse proxy); on a self-signed certificate or an IP address, only the
|
||||
authenticator app is offered. API tokens never need a second step.
|
||||
- **API tokens** are stored only as hashes and can be read-only or full access.
|
||||
- `config.json` holds the server private key and is readable only by the
|
||||
service (0600).
|
||||
@@ -219,7 +217,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` |
|
||||
@@ -229,10 +227,18 @@ 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
|
||||
user is an admin. Apps and scripts use `Authorization: Bearer <token>`; create
|
||||
the token under Settings → Pair iOS app. A token belongs to the user who made
|
||||
the token under Settings → Pair iOS app, or in the iOS app under Settings →
|
||||
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
|
||||
GET. Full-access tokens can do everything the web interface does except the
|
||||
endpoints marked "signed in": users, passwords, API tokens, backup and restore.
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
`POST /users` and `POST /users/{id}/reset-password` take
|
||||
`{"password": "…", "mustChangePassword": true}`; with `true` (the default) the
|
||||
@@ -241,7 +247,14 @@ 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-password POST /users/{id}/reset-mfa
|
||||
GET /auth/options (public: is passkey sign-in offered here)
|
||||
POST /auth/login/totp · /auth/login/recovery {"ticket", "code"}
|
||||
POST /auth/login/key/begin {"ticket"} · /auth/login/key/finish?ticket= (body: the WebAuthn credential)
|
||||
POST /auth/login/passkey/begin · /auth/login/passkey/finish?id=
|
||||
signed in: GET /auth/mfa · POST /auth/mfa/totp/setup · /auth/mfa/totp/confirm · DELETE /auth/mfa/totp
|
||||
signed in: POST /auth/mfa/keys/begin · /auth/mfa/keys/finish?name= · PATCH|DELETE /auth/mfa/keys/{id}
|
||||
signed in: POST /auth/mfa/recovery-codes
|
||||
GET /status GET /stats?range=24h|7d|30d|90d
|
||||
GET /server PATCH /server POST /server/rotate-key GET /server/detect-ip
|
||||
GET /peers POST /peers (returns the config and QR once)
|
||||
@@ -252,7 +265,8 @@ 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 /logs?level=&limit=&audit=1 GET /logs/download
|
||||
signed in: GET|POST /tokens · DELETE /tokens/{id} · GET /backup · POST /restore
|
||||
GET /tokens POST /tokens DELETE /tokens/{id}
|
||||
signed in: GET /backup · POST /restore
|
||||
public: GET /setup/{token} · POST /setup/{token} {"pin"} (what a setup link opens)
|
||||
```
|
||||
|
||||
@@ -283,7 +297,8 @@ override a drop in another table, so if ufw or firewalld is active, allow UDP
|
||||
|
||||
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
|
||||
backup and restore, and adding an authenticator app or passkeys for two-step
|
||||
sign-in. 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.
|
||||
|
||||
@@ -84,6 +84,11 @@ func (a *App) guard(adminOnly bool, h http.HandlerFunc) http.HandlerFunc {
|
||||
writeJSON(w, http.StatusForbidden, map[string]string{"error": "choose a new password first", "code": "password_change_required"})
|
||||
return
|
||||
}
|
||||
if p.MFASetupRequired && r.URL.Path != "/api/v1/auth/me" && r.URL.Path != "/api/v1/auth/password" &&
|
||||
!strings.HasPrefix(r.URL.Path, "/api/v1/auth/mfa") {
|
||||
writeJSON(w, http.StatusForbidden, map[string]string{"error": "set up two-step sign-in first", "code": "mfa_setup_required"})
|
||||
return
|
||||
}
|
||||
if p.Scope == "ro" && r.Method != http.MethodGet {
|
||||
writeJSON(w, http.StatusForbidden, map[string]string{"error": "this token is read-only"})
|
||||
return
|
||||
@@ -92,6 +97,17 @@ 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 {
|
||||
@@ -105,16 +121,38 @@ 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)
|
||||
// The second step of signing in, and signing in with a passkey alone.
|
||||
mux.HandleFunc("GET /api/v1/auth/options", a.signInOptions)
|
||||
mux.HandleFunc("POST /api/v1/auth/login/totp", a.loginTOTP)
|
||||
mux.HandleFunc("POST /api/v1/auth/login/recovery", a.loginRecovery)
|
||||
mux.HandleFunc("POST /api/v1/auth/login/key/begin", a.loginKeyBegin)
|
||||
mux.HandleFunc("POST /api/v1/auth/login/key/finish", a.loginKeyFinish)
|
||||
mux.HandleFunc("POST /api/v1/auth/login/passkey/begin", a.loginPasskeyBegin)
|
||||
mux.HandleFunc("POST /api/v1/auth/login/passkey/finish", a.loginPasskeyFinish)
|
||||
// Your own two-step sign-in. Keys and passkeys need a browser, so these
|
||||
// are for signed-in users only.
|
||||
adm("GET /api/v1/auth/mfa", a.mfaStatus)
|
||||
adm("POST /api/v1/auth/mfa/totp/setup", a.totpSetup)
|
||||
adm("POST /api/v1/auth/mfa/totp/confirm", a.totpConfirm)
|
||||
adm("DELETE /api/v1/auth/mfa/totp", a.totpRemove)
|
||||
adm("POST /api/v1/auth/mfa/keys/begin", a.keyBegin)
|
||||
adm("POST /api/v1/auth/mfa/keys/finish", a.keyFinish)
|
||||
adm("PATCH /api/v1/auth/mfa/keys/{id}", a.keyRename)
|
||||
adm("DELETE /api/v1/auth/mfa/keys/{id}", a.keyRemove)
|
||||
adm("POST /api/v1/auth/mfa/recovery-codes", a.newRecoveryCodesHandler)
|
||||
g("GET /api/v1/auth/me", a.me)
|
||||
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)
|
||||
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)
|
||||
|
||||
g("GET /api/v1/status", a.status)
|
||||
g("GET /api/v1/stats", a.allStats)
|
||||
@@ -143,14 +181,14 @@ func (a *App) routes() http.Handler {
|
||||
mux.HandleFunc("GET /api/v1/setup/{token}", a.setupInfo)
|
||||
mux.HandleFunc("POST /api/v1/setup/{token}", a.setupRedeem)
|
||||
|
||||
// Full-access tokens (the iOS app) may change app settings and read logs.
|
||||
// Users, passwords, tokens and backups need a signed-in user.
|
||||
// Full-access tokens (the iOS app) may change app settings, read logs and
|
||||
// manage users and tokens. Backups need a signed-in user.
|
||||
g("GET /api/v1/settings", a.getSettings)
|
||||
g("PATCH /api/v1/settings", a.patchSettings)
|
||||
g("POST /api/v1/restart", a.restart)
|
||||
adm("GET /api/v1/tokens", a.listTokens)
|
||||
adm("POST /api/v1/tokens", a.createToken)
|
||||
adm("DELETE /api/v1/tokens/{id}", a.deleteToken)
|
||||
full("GET /api/v1/tokens", a.listTokens)
|
||||
full("POST /api/v1/tokens", a.createToken)
|
||||
full("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)
|
||||
@@ -159,8 +197,9 @@ func (a *App) routes() http.Handler {
|
||||
mux.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "no such endpoint"})
|
||||
})
|
||||
mux.HandleFunc("GET /setup/{token}", setupPage)
|
||||
mux.Handle("/", webHandler())
|
||||
mux.HandleFunc("GET /setup/{token}", a.setupPage)
|
||||
mux.HandleFunc("GET /setup/{token}/{file}", a.setupAsset)
|
||||
mux.Handle("/", a.webHandler())
|
||||
|
||||
csrf := http.NewCrossOriginProtection()
|
||||
return securityHeaders(csrf.Handler(mux))
|
||||
@@ -189,16 +228,22 @@ func (a *App) login(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
ip := remoteIP(r)
|
||||
id, err := a.auth.Login(in.Username, in.Password, ip)
|
||||
id, ticket, err := a.auth.Login(in.Username, in.Password, ip)
|
||||
if err != nil {
|
||||
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()})
|
||||
return
|
||||
}
|
||||
if ticket != "" {
|
||||
// The password was right; the second step makes the session.
|
||||
_, u := a.auth.ticketUserID(ticket)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"mfa": true, "ticket": ticket, "methods": mfaMethods(u)})
|
||||
return
|
||||
}
|
||||
a.setSessionCookie(w, r, id)
|
||||
slog.Info("login", "audit", true, "actor", in.Username, "remote", ip)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
@@ -223,7 +268,10 @@ func (a *App) me(w http.ResponseWriter, r *http.Request) {
|
||||
p := who(r)
|
||||
out := map[string]any{
|
||||
"id": p.UserID, "name": p.Name, "isAdmin": p.IsAdmin, "scope": p.Scope,
|
||||
"mustChangePassword": p.MustChangePassword, "version": version, "session": p.Session,
|
||||
"mustChangePassword": p.MustChangePassword, "mfaSetupRequired": p.MFASetupRequired, "version": version, "session": p.Session,
|
||||
}
|
||||
if p.TokenID != "" {
|
||||
out["tokenId"] = p.TokenID // lets an app find its own token in /tokens
|
||||
}
|
||||
if _, u := a.store.Get().userByID(p.UserID); u != nil {
|
||||
out["username"], out["note"], out["created"] = u.Username, u.Note, u.Created
|
||||
@@ -963,6 +1011,8 @@ func (a *App) getSettings(w http.ResponseWriter, r *http.Request) {
|
||||
"web": cfg.Web,
|
||||
"log": cfg.Log,
|
||||
"stats": cfg.Stats,
|
||||
"decoy": cfg.Decoy,
|
||||
"signin": cfg.SignIn,
|
||||
"geo": a.geoStatus(),
|
||||
"adminUsername": a.username(cfg, who(r).UserID), // kept for older iOS app versions
|
||||
"fingerprint": a.tls.Fingerprint(),
|
||||
@@ -991,6 +1041,12 @@ func (a *App) patchSettings(w http.ResponseWriter, r *http.Request) {
|
||||
if err := field(m, "stats", &c.Stats); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := field(m, "decoy", &c.Decoy); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := field(m, "signin", &c.SignIn); err != nil {
|
||||
return err
|
||||
}
|
||||
return field(m, "log", &c.Log)
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
--brand: "Shippori Mincho B1", "Hiragino Mincho ProN", "Yu Mincho", serif;
|
||||
}
|
||||
|
||||
@font-face { font-family: "Shippori Mincho B1"; font-weight: 800; font-display: swap; src: url("/ShipporiMinchoB1-ExtraBold.woff2") format("woff2"); }
|
||||
@font-face { font-family: "Shippori Mincho B1"; font-weight: 800; font-display: swap; src: url("ShipporiMinchoB1-ExtraBold.woff2") format("woff2"); }
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; }
|
||||
@@ -56,7 +56,12 @@ a.brand, a.brand:hover { color: #fff; text-decoration: none; }
|
||||
.side a.nav.on { background: #2a2b31; color: #fff; }
|
||||
.side .count { margin-left: auto; font-size: 12px; color: #8d8e93; }
|
||||
.side .foot { margin-top: auto; padding-top: 16px; border-top: 1px solid #2c2d32; display: flex; flex-direction: column; gap: 2px; font-size: 12px; color: #8d8e93; }
|
||||
.side .foot button { background: none; border: 0; padding: 0; font: inherit; color: #c9c9c3; text-decoration: underline; cursor: pointer; }
|
||||
.side .acctrow { display: flex; align-items: center; gap: 4px; }
|
||||
.side .acctrow .acct { flex: 1; min-width: 0; }
|
||||
.side .signout { position: relative; flex: none; width: 40px; height: 40px; display: grid; place-items: center; border: 0; border-radius: 8px; background: none; color: #8d8e93; cursor: pointer; }
|
||||
.side .signout:hover { background: #222328; color: #fff; }
|
||||
.side .signout .tip { position: absolute; bottom: calc(100% + 6px); right: 0; padding: 3px 8px; border-radius: 5px; background: #000; color: #fff; font-size: 12px; white-space: nowrap; opacity: 0; pointer-events: none; transition: opacity 0.12s; }
|
||||
.side .signout:hover .tip, .side .signout:focus-visible .tip { opacity: 1; }
|
||||
.side .acct { display: flex; align-items: center; gap: 12px; min-height: 52px; padding: 0 12px; border-radius: 8px; color: #c9c9c3; text-decoration: none; }
|
||||
.side .acct:hover { background: #222328; color: #fff; }
|
||||
.side .acct.on { background: #2a2b31; color: #fff; }
|
||||
@@ -131,10 +136,16 @@ h1 { margin: 0; font-size: 26px; font-weight: 600; letter-spacing: -0.01em; over
|
||||
table { width: 100%; border-collapse: collapse; min-width: 720px; }
|
||||
table.narrow { min-width: 520px; }
|
||||
th { text-align: left; font-size: 12px; font-weight: 600; color: var(--ink-2); padding: 10px 12px; border-bottom: 1px solid var(--line); white-space: nowrap; }
|
||||
th .sort { display: inline-flex; align-items: center; gap: 4px; background: none; border: 0; padding: 0; font: inherit; color: inherit; cursor: pointer; }
|
||||
th .sort:hover, th .sort.on { color: var(--ink); }
|
||||
th .sort .arrow { font-size: 11px; opacity: 0.35; }
|
||||
th .sort:hover .arrow, th .sort.on .arrow { opacity: 1; }
|
||||
td { padding: 12px; border-bottom: 1px solid var(--line-2); vertical-align: middle; }
|
||||
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 */
|
||||
@@ -267,7 +278,6 @@ dialog::backdrop { background: rgba(22, 23, 26, .55); }
|
||||
.linkbtn { background: none; border: 0; padding: 4px; font: inherit; font-size: 13px; color: #9cc3f5; text-decoration: underline; cursor: pointer; align-self: center; }
|
||||
.linkbtn:hover { color: #fff; }
|
||||
.loginform .err-text:empty { display: none; }
|
||||
.loginfoot { margin: 0; font-family: var(--mono); font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase; color: #8d8e93; }
|
||||
.err-text { color: var(--bad-ink); font-size: 13px; margin: 0; }
|
||||
/* setup link page (setup.html): same dark look as the login page */
|
||||
.setuppage { min-height: 100vh; display: flex; justify-content: center; padding: 48px 16px; background: var(--ink); color: #f4f4f1; font-size: 15px; }
|
||||
@@ -299,3 +309,18 @@ dialog::backdrop { background: rgba(22, 23, 26, .55); }
|
||||
.steps .btn.primary { background: #f4f4f1; border-color: #f4f4f1; color: var(--ink); font-weight: 600; }
|
||||
.steps .qr { width: 100%; height: auto; max-width: 280px; align-self: center; }
|
||||
.loading-page { padding: 40px; color: var(--ink-3); }
|
||||
|
||||
/* two-step sign-in */
|
||||
.loginalt { width: 100%; display: flex; flex-direction: column; gap: 14px; margin-top: -24px; }
|
||||
.loginalt .or, .loginform .or { display: flex; align-items: center; gap: 10px; color: #8d8e93; font-size: 12px; }
|
||||
.loginalt .or::before, .loginalt .or::after { content: ""; flex: 1; height: 1px; background: #2c2d32; }
|
||||
.loginpage .btn.altbtn { min-height: 44px; width: 100%; font-size: 15px; font-weight: 500; background: none; border-color: #3a3b41; color: #f4f4f1; margin-top: 0; }
|
||||
.loginpage .btn.altbtn:hover { background: #222328; border-color: #55565c; color: #fff; }
|
||||
.loginlinks { display: flex; flex-direction: column; align-items: center; gap: 2px; margin-top: 6px; }
|
||||
.loginform .codeinput { text-align: center; font-size: 20px; letter-spacing: 0.2em; }
|
||||
.mfalist { display: flex; flex-direction: column; }
|
||||
.mfarow { display: flex; align-items: center; gap: 8px; padding: 12px 0; border-top: 1px solid var(--line-2); }
|
||||
.mfarow:first-child { border-top: 0; padding-top: 0; }
|
||||
.mfarow .grow { flex: 1; min-width: 0; }
|
||||
.dlg .secret { font-size: 15px; letter-spacing: 0.04em; overflow-wrap: anywhere; }
|
||||
.dlg .codes { columns: 2; font-size: 15px; line-height: 1.8; }
|
||||
|
||||
@@ -48,6 +48,8 @@
|
||||
server: '<rect x="3" y="4" width="18" height="7" rx="1.5"/><rect x="3" y="13" width="18" height="7" rx="1.5"/><path d="M7 7.5h.01M7 16.5h.01"/>',
|
||||
settings: '<path d="M4 6h10M18 6h2M4 12h4M12 12h8M4 18h12"/><circle cx="16" cy="6" r="2"/><circle cx="10" cy="12" r="2"/><circle cx="18" cy="18" r="2"/>',
|
||||
plus: '<path d="M12 5v14M5 12h14"/>',
|
||||
key: '<circle cx="8" cy="15" r="4"/><path d="M11 12l9-9M17 6l3 3M14 9l2 2"/>',
|
||||
logout: '<path d="M14 4h4a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-4"/><path d="M10 16l-4-4 4-4M6 12h10"/>',
|
||||
};
|
||||
|
||||
// The Hannya mark: the horned demon mask of Noh. Same drawing as favicon.svg.
|
||||
@@ -137,6 +139,15 @@
|
||||
return ts + ' ' + String(l.level).padEnd(5) + ' ' + l.msg + (rest ? ' ' + rest : '');
|
||||
}
|
||||
|
||||
// mfaText summarizes a user's two-step sign-in: "App, 2 keys" or "".
|
||||
function mfaText(m) {
|
||||
if (!m) return '';
|
||||
const parts = [];
|
||||
if (m.totp) parts.push('App');
|
||||
if (m.passkeys) parts.push(m.passkeys === 1 ? '1 passkey' : m.passkeys + ' passkeys');
|
||||
return parts.join(', ');
|
||||
}
|
||||
|
||||
// "Germany · Deutsche Telekom AG", "Local network" or "".
|
||||
function fmtLocation(g) {
|
||||
if (!g) return '';
|
||||
@@ -215,7 +226,7 @@
|
||||
const r = await fetch('/api/v1' + path, opt);
|
||||
let data = {};
|
||||
try { data = await r.json(); } catch { /* empty body */ }
|
||||
if (r.status === 401 && path !== '/auth/login' && path !== '/auth/me') {
|
||||
if (r.status === 401 && !path.startsWith('/auth/login') && path !== '/auth/me') {
|
||||
me = null;
|
||||
showLogin();
|
||||
throw new Error('Signed out');
|
||||
@@ -224,6 +235,10 @@
|
||||
showNewPassword();
|
||||
throw new Error('Signed out');
|
||||
}
|
||||
if (r.status === 403 && data.code === 'mfa_setup_required') {
|
||||
showMFASetup();
|
||||
throw new Error('Signed out');
|
||||
}
|
||||
if (!r.ok) throw new Error(data.error || r.statusText);
|
||||
return data;
|
||||
}
|
||||
@@ -242,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;
|
||||
}
|
||||
|
||||
@@ -513,11 +541,12 @@
|
||||
srvBox,
|
||||
NAV.map(([href, ic, label]) => (navLinks[href] = h('a', { class: 'nav', href }, icon(ic), label, ic === 'peers' ? peerCount : null))),
|
||||
h('div', { class: 'foot' },
|
||||
(navLinks['#/account'] = h('a', { class: 'acct', href: '#/account' },
|
||||
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('div', { class: 'acctrow' },
|
||||
(navLinks['#/account'] = h('a', { class: 'acct', href: '#/account' },
|
||||
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('button', { type: 'button', onClick: logout }, 'Sign out'),
|
||||
h('span', null, 'v' + me.version.replace(/^v/, '')))));
|
||||
main = h('main', { class: 'main', id: 'main' });
|
||||
app.replaceChildren(h('div', { class: 'shell' }, nav, main));
|
||||
@@ -564,6 +593,7 @@
|
||||
try { me = await api('GET', '/auth/me'); } catch { showLogin(); return; }
|
||||
}
|
||||
if (me.mustChangePassword) { showNewPassword(); return; }
|
||||
if (me.mfaSetupRequired) { showMFASetup(); return; }
|
||||
if (!main || !main.isConnected) buildShell();
|
||||
every(30000, refreshSide);
|
||||
const hash = location.hash || '#/';
|
||||
@@ -602,9 +632,9 @@
|
||||
err.textContent = '';
|
||||
btn.disabled = true;
|
||||
try {
|
||||
await api('POST', '/auth/login', { username: user.value, password: pw.value });
|
||||
me = await api('GET', '/auth/me');
|
||||
if (me.mustChangePassword) showNewPassword(pw.value); else render();
|
||||
const res = await api('POST', '/auth/login', { username: user.value, password: pw.value });
|
||||
if (res.mfa) { showSecondStep(res.ticket, res.methods, pw.value); return; }
|
||||
await signedIn(pw.value);
|
||||
} catch (x) {
|
||||
err.textContent = x.message;
|
||||
btn.disabled = false;
|
||||
@@ -614,13 +644,301 @@
|
||||
h('div', { class: 'field' }, h('label', { htmlFor: 'u' }, 'Username'), user),
|
||||
h('div', { class: 'field' }, h('label', { htmlFor: 'p' }, 'Password'), pw),
|
||||
err, btn);
|
||||
// A passkey signs in without username and password, where the address
|
||||
// allows it.
|
||||
const passkeyRow = h('div', { class: 'loginalt', hidden: true },
|
||||
h('div', { class: 'or' }, 'or'),
|
||||
h('button', { type: 'button', class: 'btn altbtn signin', onClick: async () => {
|
||||
err.textContent = '';
|
||||
try {
|
||||
const b = await api('POST', '/auth/login/passkey/begin');
|
||||
const cred = await webauthnGet(b.options);
|
||||
await api('POST', '/auth/login/passkey/finish?id=' + encodeURIComponent(b.id), cred);
|
||||
await signedIn();
|
||||
} catch (x) { err.textContent = keyError(x); }
|
||||
} }, icon('key', 18), h('span', { class: 'en' }, 'Sign in with a passkey'), h('span', { class: 'ja', lang: 'ja', 'aria-hidden': 'true' }, 'パスキーでサインイン')));
|
||||
if (window.PublicKeyCredential) {
|
||||
api('GET', '/auth/options').then((o) => { passkeyRow.hidden = !o.passkeys; }).catch(() => {});
|
||||
}
|
||||
app.replaceChildren(h('div', { class: 'loginpage' }, h('div', { class: 'loginbox' },
|
||||
brand(72),
|
||||
form),
|
||||
h('p', { class: 'loginfoot' }, 'WireGuard server manager')));
|
||||
form, passkeyRow)));
|
||||
user.focus();
|
||||
}
|
||||
|
||||
// signedIn continues after a successful sign-in. password is the one just
|
||||
// typed, if any, so a temporary password need not be typed again.
|
||||
async function signedIn(password) {
|
||||
me = await api('GET', '/auth/me');
|
||||
if (me.mustChangePassword) showNewPassword(password); else render();
|
||||
}
|
||||
|
||||
// ---------- two-step sign-in ----------
|
||||
|
||||
const b64dec = (s) => {
|
||||
const b = atob(s.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - s.length % 4) % 4));
|
||||
return Uint8Array.from(b, (c) => c.charCodeAt(0)).buffer;
|
||||
};
|
||||
const b64enc = (buf) => btoa(String.fromCharCode(...new Uint8Array(buf))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
|
||||
// webauthnCreate and webauthnGet turn the server's options into the
|
||||
// browser call and the browser's answer back into JSON.
|
||||
async function webauthnCreate(opts) {
|
||||
const pk = opts.publicKey;
|
||||
pk.challenge = b64dec(pk.challenge);
|
||||
pk.user.id = b64dec(pk.user.id);
|
||||
(pk.excludeCredentials || []).forEach((c) => { c.id = b64dec(c.id); });
|
||||
const c = await navigator.credentials.create({ publicKey: pk });
|
||||
return {
|
||||
id: c.id, rawId: b64enc(c.rawId), type: c.type, authenticatorAttachment: c.authenticatorAttachment,
|
||||
response: {
|
||||
clientDataJSON: b64enc(c.response.clientDataJSON), attestationObject: b64enc(c.response.attestationObject),
|
||||
transports: c.response.getTransports ? c.response.getTransports() : [],
|
||||
},
|
||||
clientExtensionResults: c.getClientExtensionResults(),
|
||||
};
|
||||
}
|
||||
|
||||
async function webauthnGet(opts) {
|
||||
const pk = opts.publicKey;
|
||||
pk.challenge = b64dec(pk.challenge);
|
||||
(pk.allowCredentials || []).forEach((c) => { c.id = b64dec(c.id); });
|
||||
const c = await navigator.credentials.get({ publicKey: pk });
|
||||
return {
|
||||
id: c.id, rawId: b64enc(c.rawId), type: c.type, authenticatorAttachment: c.authenticatorAttachment,
|
||||
response: {
|
||||
clientDataJSON: b64enc(c.response.clientDataJSON), authenticatorData: b64enc(c.response.authenticatorData),
|
||||
signature: b64enc(c.response.signature), userHandle: c.response.userHandle ? b64enc(c.response.userHandle) : null,
|
||||
},
|
||||
clientExtensionResults: c.getClientExtensionResults(),
|
||||
};
|
||||
}
|
||||
|
||||
// keyError explains a failed key or passkey prompt.
|
||||
function keyError(x) {
|
||||
if (x && x.name === 'NotAllowedError') return 'Cancelled or timed out. Try again.';
|
||||
if (x && x.name === 'InvalidStateError') return 'This key is already set up for your account.';
|
||||
if (x && x.name === 'SecurityError') return 'Passkeys need this site on its domain name with a trusted certificate.';
|
||||
return x.message;
|
||||
}
|
||||
|
||||
// showSecondStep asks for a key, an authenticator code or a recovery code
|
||||
// after a correct password.
|
||||
function showSecondStep(ticket, methods, password) {
|
||||
cleanups.forEach((f) => f());
|
||||
cleanups = [];
|
||||
main = null;
|
||||
const canKey = methods.includes('key') && !!window.PublicKeyCredential;
|
||||
let mode = canKey ? 'key' : methods.includes('totp') ? 'totp' : 'recovery';
|
||||
const box = h('div', { class: 'loginform' });
|
||||
const TITLES = {
|
||||
key: ['Use your passkey', 'Confirm with Touch ID, Face ID, Windows Hello or your password manager, or insert your YubiKey and touch it.'],
|
||||
totp: ['Enter the code', 'The 6-digit code from your authenticator app.'],
|
||||
recovery: ['Use a recovery code', 'One of the codes you saved when you set up two-step sign-in. Each works once.'],
|
||||
};
|
||||
const LINKS = { key: 'Use a passkey instead', totp: 'Use an authenticator code instead', recovery: 'Use a recovery code' };
|
||||
const head = h('div', { class: 'logintext' });
|
||||
const draw = () => {
|
||||
const err = h('p', { class: 'err-text', role: 'alert' });
|
||||
head.replaceChildren(h('h1', null, TITLES[mode][0]), h('p', null, TITLES[mode][1]));
|
||||
const others = ['key', 'totp', 'recovery'].filter((m) => m !== mode && methods.includes(m) && (m !== 'key' || canKey))
|
||||
.map((m) => h('button', { type: 'button', class: 'linkbtn', onClick: () => { mode = m; draw(); } }, LINKS[m]));
|
||||
const foot = h('div', { class: 'loginlinks' }, others, h('button', { type: 'button', class: 'linkbtn', onClick: showLogin }, 'Start over'));
|
||||
if (mode === 'key') {
|
||||
const btn = h('button', { type: 'button', class: 'btn primary' }, 'Use passkey');
|
||||
const go = async () => {
|
||||
err.textContent = '';
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const opts = await api('POST', '/auth/login/key/begin', { ticket });
|
||||
const cred = await webauthnGet(opts);
|
||||
await api('POST', '/auth/login/key/finish?ticket=' + encodeURIComponent(ticket), cred);
|
||||
await signedIn(password);
|
||||
} catch (x) { err.textContent = keyError(x); btn.disabled = false; }
|
||||
};
|
||||
btn.addEventListener('click', go);
|
||||
box.replaceChildren(err, btn, foot);
|
||||
btn.focus();
|
||||
return;
|
||||
}
|
||||
const code = h('input', { id: 'mc', autocomplete: 'one-time-code', autocapitalize: 'none', required: true,
|
||||
inputMode: mode === 'totp' ? 'numeric' : 'text', class: 'mono codeinput', placeholder: mode === 'totp' ? '123 456' : 'XXXX-XXXX' });
|
||||
const btn = h('button', { type: 'submit', class: 'btn primary' }, 'Verify');
|
||||
box.replaceChildren(h('form', { class: 'loginform', onSubmit: async (e) => {
|
||||
e.preventDefault();
|
||||
err.textContent = '';
|
||||
btn.disabled = true;
|
||||
try {
|
||||
await api('POST', '/auth/login/' + mode, { ticket, code: code.value });
|
||||
await signedIn(password);
|
||||
} catch (x) {
|
||||
err.textContent = x.message;
|
||||
btn.disabled = false;
|
||||
code.select();
|
||||
}
|
||||
} }, h('div', { class: 'field' }, h('label', { htmlFor: 'mc', class: 'sr' }, TITLES[mode][0]), code), err, btn), foot);
|
||||
code.focus();
|
||||
};
|
||||
app.replaceChildren(h('div', { class: 'loginpage' }, h('div', { class: 'loginbox' }, brand(72), head, box)));
|
||||
draw();
|
||||
}
|
||||
|
||||
// showMFASetup is the screen for a user who must set up two-step sign-in
|
||||
// before doing anything else.
|
||||
async function showMFASetup() {
|
||||
cleanups.forEach((f) => f());
|
||||
cleanups = [];
|
||||
main = null;
|
||||
let st = { keysAvailable: false };
|
||||
try { st = await api('GET', '/auth/mfa'); } catch { /* offer the app only */ }
|
||||
const done = async () => { me = await api('GET', '/auth/me'); render(); };
|
||||
const keys = st.keysAvailable && window.PublicKeyCredential;
|
||||
app.replaceChildren(h('div', { class: 'loginpage' }, h('div', { class: 'loginbox' },
|
||||
brand(72),
|
||||
h('div', { class: 'logintext' },
|
||||
h('h1', null, 'Set up two-step sign-in'),
|
||||
h('p', null, 'This server asks for a second step after the password. Add one to continue.')),
|
||||
h('div', { class: 'loginform' },
|
||||
h('button', { type: 'button', class: 'btn primary', onClick: () => addTOTP(done) }, 'Use an authenticator app'),
|
||||
keys ? h('button', { type: 'button', class: 'btn altbtn', onClick: () => addPasskey(done) }, 'Use a passkey') : null,
|
||||
h('div', { class: 'loginlinks' }, h('button', { type: 'button', class: 'linkbtn', onClick: logout }, 'Sign out'))))));
|
||||
}
|
||||
|
||||
// recoveryDialog shows new recovery codes once.
|
||||
function recoveryDialog(codes, onClose) {
|
||||
const text = codes.join('\n');
|
||||
const d = dialog((close) => h('div', { class: 'dlg' },
|
||||
h('h2', null, 'Your recovery codes'),
|
||||
h('p', null, 'If you lose your phone or key, each of these signs you in once. Store them somewhere safe, such as your password manager. They are not shown again.'),
|
||||
h('pre', { class: 'code codes' }, text),
|
||||
h('div', { class: 'actions' },
|
||||
h('button', { type: 'button', class: 'btn', onClick: () => copy(text) }, 'Copy'),
|
||||
h('button', { type: 'button', class: 'btn', onClick: () => download(APP.toLowerCase() + '-recovery-codes.txt', text + '\n') }, 'Download')),
|
||||
h('div', { class: 'foot' }, h('button', { type: 'button', class: 'btn primary', onClick: close }, 'Done'))));
|
||||
if (onClose) d.addEventListener('close', onClose);
|
||||
}
|
||||
|
||||
// afterAdd shows recovery codes when the method was the first one.
|
||||
const afterAdd = (res, onDone) => {
|
||||
if (res.recoveryCodes && res.recoveryCodes.length) recoveryDialog(res.recoveryCodes, onDone);
|
||||
else if (onDone) onDone();
|
||||
};
|
||||
|
||||
async function addTOTP(onDone) {
|
||||
let s;
|
||||
try { s = await api('POST', '/auth/mfa/totp/setup'); } catch (x) { toast(x.message, true); return; }
|
||||
const code = h('input', { id: 'tc', class: 'mono', autocomplete: 'one-time-code', inputMode: 'numeric', placeholder: '123 456', required: true });
|
||||
const e = h('p', { class: 'err-text', role: 'alert' });
|
||||
dialog((close) => h('form', { class: 'dlg', onSubmit: async (ev) => {
|
||||
ev.preventDefault();
|
||||
e.textContent = '';
|
||||
try {
|
||||
const res = await api('POST', '/auth/mfa/totp/confirm', { code: code.value });
|
||||
close();
|
||||
toast('Authenticator app turned on');
|
||||
afterAdd(res, onDone);
|
||||
} catch (x) { e.textContent = x.message; code.select(); }
|
||||
} },
|
||||
h('h2', null, 'Add an authenticator app'),
|
||||
h('div', { class: 'qrrow' },
|
||||
h('img', { class: 'qr', src: s.qr, alt: 'QR code for the authenticator app' }),
|
||||
h('div', { class: 'col' },
|
||||
h('p', null, 'Scan the code with your authenticator app, for example 1Password, Google Authenticator or Authy. Or enter this key by hand:'),
|
||||
h('code', { class: 'mono secret' }, s.secret.match(/.{1,4}/g).join(' ')),
|
||||
h('div', null, h('button', { type: 'button', class: 'btn small', onClick: () => copy(s.secret) }, 'Copy key')))),
|
||||
h('div', { class: 'field' }, h('label', { htmlFor: 'tc' }, 'Code from the app'), code),
|
||||
e,
|
||||
h('div', { class: 'foot' }, h('button', { type: 'button', class: 'btn', onClick: close }, 'Cancel'), h('button', { type: 'submit', class: 'btn primary' }, 'Turn on'))));
|
||||
code.focus();
|
||||
}
|
||||
|
||||
// addPasskey adds a passkey. It signs in on its own, and also serves as
|
||||
// the second step after a password.
|
||||
function addPasskey(onDone) {
|
||||
const nm = h('input', { id: 'kn', value: 'Passkey', autocomplete: 'off', maxLength: 64 });
|
||||
const e = h('p', { class: 'err-text', role: 'alert' });
|
||||
const btn = h('button', { type: 'submit', class: 'btn primary' }, 'Add passkey');
|
||||
dialog((close) => h('form', { class: 'dlg', onSubmit: async (ev) => {
|
||||
ev.preventDefault();
|
||||
e.textContent = '';
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const opts = await api('POST', '/auth/mfa/keys/begin');
|
||||
const cred = await webauthnCreate(opts);
|
||||
const res = await api('POST', '/auth/mfa/keys/finish?name=' + encodeURIComponent(nm.value.trim()), cred);
|
||||
close();
|
||||
toast('Passkey added');
|
||||
afterAdd(res, onDone);
|
||||
} catch (x) { e.textContent = keyError(x); btn.disabled = false; }
|
||||
} },
|
||||
h('h2', null, 'Add a passkey'),
|
||||
h('p', null, 'A passkey signs you in on its own, without username and password, and also works as the second step after your password. It can live on this device (Touch ID, Face ID, Windows Hello), in your password manager, or on a YubiKey with a PIN set.'),
|
||||
h('div', { class: 'field' }, h('label', { htmlFor: 'kn' }, 'Name'), nm, h('span', { class: 'hint' }, 'So you can tell your passkeys apart, for example "MacBook" or "YubiKey"')),
|
||||
e,
|
||||
h('div', { class: 'foot' }, h('button', { type: 'button', class: 'btn', onClick: close }, 'Cancel'), btn)));
|
||||
nm.select();
|
||||
}
|
||||
|
||||
// mfaCard is the "Two-step sign-in" section of My account.
|
||||
function mfaCard() {
|
||||
const body = h('div', null, h('p', { class: 'muted' }, 'Loading…'));
|
||||
const card = h('section', { class: 'card', 'aria-labelledby': 'mfa' },
|
||||
h('h2', { id: 'mfa' }, 'Two-step sign-in'),
|
||||
h('p', { class: 'lead' }, 'Asks for a second proof after your password. App tokens, like the iOS app\'s, are not affected.'),
|
||||
body);
|
||||
const draw = async () => {
|
||||
let s;
|
||||
try { s = await api('GET', '/auth/mfa'); } catch (x) { body.replaceChildren(h('p', { class: 'err-text' }, x.message)); return; }
|
||||
const keys = s.keysAvailable && window.PublicKeyCredential;
|
||||
const removeKey = async (k) => {
|
||||
if (!await confirmDialog({ title: 'Remove ' + k.name + '?', text: 'It can no longer be used to sign in.', ok: 'Remove', danger: true })) return;
|
||||
try { await api('DELETE', '/auth/mfa/keys/' + k.id); toast('Removed ' + k.name); draw(); } catch (x) { toast(x.message, true); }
|
||||
};
|
||||
const renameKey = (k) => {
|
||||
const nm = h('input', { id: 'rk', value: k.name, maxLength: 64, required: true });
|
||||
const e = h('p', { class: 'err-text', role: 'alert' });
|
||||
dialog((close) => h('form', { class: 'dlg', onSubmit: async (ev) => {
|
||||
ev.preventDefault();
|
||||
try { await api('PATCH', '/auth/mfa/keys/' + k.id, { name: nm.value.trim() }); close(); draw(); } catch (x) { e.textContent = x.message; }
|
||||
} }, h('h2', null, 'Rename key'), h('div', { class: 'field' }, h('label', { htmlFor: 'rk' }, 'Name'), nm), e,
|
||||
h('div', { class: 'foot' }, h('button', { type: 'button', class: 'btn', onClick: close }, 'Cancel'), h('button', { type: 'submit', class: 'btn primary' }, 'Save'))));
|
||||
nm.select();
|
||||
};
|
||||
const removeTOTP = async () => {
|
||||
if (!await confirmDialog({ title: 'Remove the authenticator app?', text: 'Its codes stop working for this account.', ok: 'Remove', danger: true })) return;
|
||||
try { await api('DELETE', '/auth/mfa/totp'); toast('Authenticator app removed'); draw(); } catch (x) { toast(x.message, true); }
|
||||
};
|
||||
const newCodes = async () => {
|
||||
if (!await confirmDialog({ title: 'Make new recovery codes?', text: 'Your old codes stop working.', ok: 'Make new codes' })) return;
|
||||
try { recoveryDialog((await api('POST', '/auth/mfa/recovery-codes')).recoveryCodes, draw); } catch (x) { toast(x.message, true); }
|
||||
};
|
||||
const rows = [];
|
||||
if (s.totp) {
|
||||
rows.push(h('div', { class: 'mfarow' }, h('div', { class: 'grow' }, h('strong', null, 'Authenticator app'), h('div', { class: 'hint' }, 'Added ' + fmtDate(s.totpAdded))),
|
||||
h('button', { type: 'button', class: 'btn danger small', onClick: removeTOTP }, 'Remove')));
|
||||
}
|
||||
for (const k of s.keys) {
|
||||
rows.push(h('div', { class: 'mfarow' }, h('div', { class: 'grow' }, h('strong', null, k.name),
|
||||
h('div', { class: 'hint' }, 'Added ' + fmtDate(k.created) + ' · ' + (k.lastUsed ? 'last used ' + ago(k.lastUsed) : 'not used yet'))),
|
||||
h('button', { type: 'button', class: 'btn small', onClick: () => renameKey(k) }, 'Rename'),
|
||||
h('button', { type: 'button', class: 'btn danger small', onClick: () => removeKey(k) }, 'Remove')));
|
||||
}
|
||||
if (rows.length) {
|
||||
rows.push(h('div', { class: 'mfarow' }, h('div', { class: 'grow' }, h('strong', null, 'Recovery codes'), h('div', { class: 'hint' }, s.recoveryLeft + ' of 10 left')),
|
||||
h('button', { type: 'button', class: 'btn small', onClick: newCodes }, 'New codes')));
|
||||
}
|
||||
body.replaceChildren(...[
|
||||
rows.length ? h('div', { class: 'mfalist' }, rows) : h('div', { class: 'notice' }, s.required ? 'Two-step sign-in is required on this server.' : 'Two-step sign-in is off for your account.'),
|
||||
h('div', { class: 'actions section' },
|
||||
s.totp ? null : h('button', { type: 'button', class: 'btn primary', onClick: () => addTOTP(draw) }, 'Add authenticator app'),
|
||||
keys ? h('button', { type: 'button', class: 'btn', onClick: () => addPasskey(draw) }, 'Add passkey') : null),
|
||||
keys ? null : h('p', { class: 'hint section' }, 'Passkeys need this site on its domain name with a trusted certificate (Let\'s Encrypt or certificate files).'),
|
||||
].filter(Boolean));
|
||||
};
|
||||
draw();
|
||||
return card;
|
||||
}
|
||||
|
||||
// showNewPassword is the screen after signing in with a temporary password
|
||||
// an admin chose. current is that password when the user just typed it.
|
||||
function showNewPassword(current) {
|
||||
@@ -656,8 +974,7 @@
|
||||
h('div', { class: 'logintext' },
|
||||
h('h1', null, me ? 'Welcome, ' + me.name : 'Choose a new password'),
|
||||
h('p', null, 'An admin gave you a temporary password. Choose your own to continue.')),
|
||||
form),
|
||||
h('p', { class: 'loginfoot' }, 'WireGuard server manager')));
|
||||
form)));
|
||||
(cur || p1).focus();
|
||||
}
|
||||
|
||||
@@ -738,9 +1055,50 @@
|
||||
|
||||
// ---------- peers ----------
|
||||
|
||||
// PEER_SORT holds the sort keys of the peers table. Each returns a value
|
||||
// where smaller sorts first; null always sorts last. Numbers start
|
||||
// descending, text ascending.
|
||||
const STATE_ORDER = ['online', 'offline', 'never', 'setup', 'nokey', 'disabled'];
|
||||
const ipNum = (ip) => ip.split('.').reduce((n, o) => n * 256 + Number(o), 0);
|
||||
const PEER_SORT = {
|
||||
name: { label: 'Name', key: (p) => p.name.toLowerCase() },
|
||||
address: { label: 'Address', key: (p) => ipNum(p.ipv4) },
|
||||
status: { label: 'Status', key: (p) => STATE_ORDER.indexOf(peerState(p).key) * 1e13 - (p.stats.lastHandshake ? Date.parse(p.stats.lastHandshake) : 0) },
|
||||
endpoint: { label: 'Endpoint', key: (p) => p.stats.endpoint ? ((p.stats.location && p.stats.location.country) || '~') + ' ' + p.stats.endpoint : null },
|
||||
latency: { label: 'Latency', num: true, asc: true, key: (p) => { const st = latState(p); return st && st.ms != null ? st.ms : null; } },
|
||||
down: { label: 'Download, 30 d', num: true, key: (p) => p.stats.down30d },
|
||||
up: { label: 'Upload, 30 d', num: true, key: (p) => p.stats.up30d },
|
||||
enabled: { label: 'Enabled', key: (p) => (p.enabled ? 0 : 1) },
|
||||
};
|
||||
let peerSort = { by: null, desc: false }; // kept while the app is open
|
||||
|
||||
async function viewPeers(wrap) {
|
||||
let q = '', filter = 'all', data = await api('GET', '/peers');
|
||||
const tbody = h('tbody');
|
||||
const headRow = h('tr');
|
||||
const sortBy = (k) => {
|
||||
const c = PEER_SORT[k];
|
||||
peerSort = peerSort.by === k ? { by: k, desc: !peerSort.desc } : { by: k, desc: c.num && !c.asc };
|
||||
drawHead();
|
||||
drawRows();
|
||||
};
|
||||
const drawHead = () => headRow.replaceChildren(
|
||||
...Object.entries(PEER_SORT).map(([k, c]) => {
|
||||
const on = peerSort.by === k;
|
||||
return h('th', { class: c.num ? 'num' : null, 'aria-sort': on ? (peerSort.desc ? 'descending' : 'ascending') : 'none' },
|
||||
h('button', { type: 'button', class: on ? 'sort on' : 'sort', onClick: () => sortBy(k) }, c.label,
|
||||
h('span', { class: 'arrow', 'aria-hidden': 'true' }, on ? (peerSort.desc ? '↓' : '↑') : '↕')));
|
||||
}),
|
||||
h('th', null, h('span', { class: 'sr' }, 'Actions')));
|
||||
const sorted = (rows) => {
|
||||
if (!peerSort.by) return rows;
|
||||
const key = PEER_SORT[peerSort.by].key, dir = peerSort.desc ? -1 : 1;
|
||||
return rows.map((p) => [p, key(p)]).sort(([a, ka], [b, kb]) => {
|
||||
if (ka == null || kb == null) return ka == null && kb == null ? 0 : ka == null ? 1 : -1;
|
||||
const c = typeof ka === 'string' ? ka.localeCompare(kb) : ka - kb;
|
||||
return c * dir || a.name.localeCompare(b.name);
|
||||
}).map(([p]) => p);
|
||||
};
|
||||
const empty = h('p', { class: 'empty', hidden: true }, 'No peers match this filter.');
|
||||
const sub = h('p', { class: 'sub' });
|
||||
const pills = h('div', { class: 'pills', role: 'group', 'aria-label': 'Status filter' });
|
||||
@@ -765,8 +1123,8 @@
|
||||
const keep = filter === 'all' || filter === st || (filter === 'offline' && ['offline', 'never', 'setup', 'nokey'].includes(st));
|
||||
return hit && keep;
|
||||
});
|
||||
tbody.replaceChildren(...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),
|
||||
tbody.replaceChildren(...sorted(rows).map((p) => h('tr', 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 || '–',
|
||||
@@ -780,6 +1138,7 @@
|
||||
};
|
||||
|
||||
drawPills();
|
||||
drawHead();
|
||||
drawRows();
|
||||
fill(wrap,
|
||||
h('div', { class: 'head' },
|
||||
@@ -790,8 +1149,7 @@
|
||||
h('input', { id: 'q', type: 'search', placeholder: 'Search name, address or note', style: { flex: '1 1 260px', maxWidth: '360px' }, onInput: (e) => { q = e.target.value.toLowerCase(); drawRows(); } }),
|
||||
pills),
|
||||
h('section', { class: 'card flush' }, h('div', { class: 'tbl' }, h('table', null,
|
||||
h('thead', null, h('tr', null, ['Name', 'Address', 'Status', 'Endpoint'].map((t) => h('th', null, t)),
|
||||
h('th', { class: 'num' }, 'Latency'), h('th', { class: 'num' }, 'Download, 30 d'), h('th', { class: 'num' }, 'Upload, 30 d'), h('th', null, 'Enabled'), h('th', null, h('span', { class: 'sr' }, 'Actions')))),
|
||||
h('thead', null, headRow),
|
||||
tbody), empty)),
|
||||
h('p', { class: 'muted', style: { margin: '0', fontSize: '13px' } }, 'Online means a handshake in the last 3 minutes. Latency is the round trip from the server through the tunnel to the device and back, median of the last 5 minutes; turn it on in a peer\'s settings. Download and Upload are measured from the peer\'s side. Changes apply live without disconnecting other peers.'));
|
||||
every(15000, async () => { try { data = await api('GET', '/peers'); drawRows(); } catch { /* keep last */ } });
|
||||
@@ -935,8 +1293,24 @@
|
||||
// ---------- peer detail ----------
|
||||
|
||||
async function viewPeer(wrap, id) {
|
||||
const [p, srv, sess] = await Promise.all([api('GET', '/peers/' + id), api('GET', '/server'), api('GET', '/peers/' + id + '/sessions?limit=50')]);
|
||||
const [p, srv, sess] = await Promise.all([api('GET', '/peers/' + id), api('GET', '/server'), api('GET', '/peers/' + id + '/sessions?limit=100')]);
|
||||
const sessions = sess.sessions;
|
||||
// The history shows the newest rows; the rest open on request.
|
||||
const SHORT = 8;
|
||||
let allSessions = false;
|
||||
const sessBody = h('tbody');
|
||||
const sessMore = h('button', { type: 'button', class: 'btn small', onClick: () => { allSessions = !allSessions; drawSessions(); } });
|
||||
const drawSessions = () => {
|
||||
sessBody.replaceChildren(...(allSessions ? sessions : sessions.slice(0, SHORT)).map((se) => h('tr', null,
|
||||
h('td', null, fmtStamp(se.start)),
|
||||
h('td', null, se.open ? [h('span', { class: 'badge' }, h('span', { class: 'dot ok' }), 'Online now'), ' ', fmtDuration(se.seconds)] : fmtDuration(se.seconds)),
|
||||
h('td', null, fmtLocation(se.geo) || h('span', { class: 'muted' }, 'Unknown')),
|
||||
h('td', { class: 'mono muted' }, se.ip),
|
||||
h('td', { class: 'num' }, fmtBytes(se.down)),
|
||||
h('td', { class: 'num' }, fmtBytes(se.up)))));
|
||||
sessMore.textContent = allSessions ? 'Show fewer' : 'Show all ' + sessions.length;
|
||||
};
|
||||
drawSessions();
|
||||
let range = '7d';
|
||||
const st = peerState(p);
|
||||
const traffic = h('div');
|
||||
@@ -1109,14 +1483,9 @@
|
||||
sessions.length ? h('div', { class: 'tbl' }, h('table', null,
|
||||
h('thead', null, h('tr', null, h('th', null, 'Started'), h('th', null, 'Duration'), h('th', null, 'From'), h('th', null, 'Address'),
|
||||
h('th', { class: 'num' }, 'Download'), h('th', { class: 'num' }, 'Upload'))),
|
||||
h('tbody', null, sessions.map((se) => h('tr', null,
|
||||
h('td', null, fmtStamp(se.start)),
|
||||
h('td', null, se.open ? [h('span', { class: 'badge' }, h('span', { class: 'dot ok' }), 'Online now'), ' ', fmtDuration(se.seconds)] : fmtDuration(se.seconds)),
|
||||
h('td', null, fmtLocation(se.geo) || h('span', { class: 'muted' }, 'Unknown')),
|
||||
h('td', { class: 'mono muted' }, se.ip),
|
||||
h('td', { class: 'num' }, fmtBytes(se.down)),
|
||||
h('td', { class: 'num' }, fmtBytes(se.up)))))))
|
||||
sessBody))
|
||||
: h('p', { class: 'empty' }, 'No connections recorded yet.'),
|
||||
sessions.length > SHORT ? h('div', { style: { margin: '8px 12px 0' } }, sessMore) : null,
|
||||
h('p', { class: 'hint', style: { margin: '4px 12px 12px' } }, 'Country and network: ',
|
||||
h('a', { href: 'https://db-ip.com', target: '_blank', rel: 'noopener' }, 'IP Geolocation by DB-IP'),
|
||||
'. Kept as long as the daily traffic history.')),
|
||||
@@ -1355,6 +1724,8 @@
|
||||
pwErr,
|
||||
h('div', { class: 'formfoot' }, h('button', { type: 'submit', class: 'btn primary' }, 'Change password'))),
|
||||
|
||||
mfaCard(),
|
||||
|
||||
h('section', { class: 'card flush', 'aria-labelledby': 'mytk' },
|
||||
h('div', { class: 'cardhead' },
|
||||
h('div', null, h('h2', { id: 'mytk' }, 'My app tokens'), h('p', { class: 'lead', style: { marginBottom: '0' } }, 'Tokens you created for the iOS app and scripts. All tokens are listed under Settings → API tokens.')),
|
||||
@@ -1377,6 +1748,7 @@
|
||||
const drawUsers = (users) => userBody.replaceChildren(...users.map((u) => h('tr', null,
|
||||
h('td', null, h('strong', null, u.username), u.you ? h('span', { class: 'tag plain' }, 'You') : null, u.note ? h('div', { class: 'note' }, u.note) : null),
|
||||
h('td', null, u.mustChangePassword ? h('span', { class: 'badge warn' }, 'Must choose a password') : h('span', { class: 'muted' }, 'Active')),
|
||||
h('td', null, mfaText(u.mfa) ? h('span', { class: 'badge' }, mfaText(u.mfa)) : h('span', { class: s.signin.requireMfa ? 'badge warn' : 'muted' }, 'Off')),
|
||||
h('td', null, u.lastLogin ? ago(u.lastLogin.at) + ' · ' + u.lastLogin.ip : h('span', { class: 'muted' }, 'Not since restart')),
|
||||
h('td', null, u.tokens ? String(u.tokens) : h('span', { class: 'muted' }, 'None')),
|
||||
h('td', null, fmtDate(u.created)),
|
||||
@@ -1450,6 +1822,7 @@
|
||||
must.el,
|
||||
h('div', { class: 'actions' },
|
||||
h('button', { type: 'button', class: 'btn', onClick: () => { close(); resetUser(u); } }, 'Reset password…'),
|
||||
mfaText(u.mfa) ? h('button', { type: 'button', class: 'btn', onClick: () => { close(); resetMFA(u); } }, 'Reset two-step sign-in…') : null,
|
||||
h('button', { type: 'button', class: 'btn danger', onClick: () => { close(); deleteUser(u); } }, 'Delete user…')),
|
||||
e,
|
||||
h('div', { class: 'foot' }, h('button', { type: 'button', class: 'btn', onClick: close }, 'Cancel'), h('button', { type: 'submit', class: 'btn primary' }, 'Save'))));
|
||||
@@ -1473,6 +1846,10 @@
|
||||
pw.el, must.el, e,
|
||||
h('div', { class: 'foot' }, h('button', { type: 'button', class: 'btn', onClick: close }, 'Cancel'), h('button', { type: 'submit', class: 'btn primary' }, 'Reset password'))));
|
||||
};
|
||||
const resetMFA = async (u) => {
|
||||
if (!await confirmDialog({ title: 'Reset two-step sign-in for ' + u.username + '?', text: 'Their authenticator app, passkeys and recovery codes are removed. They sign in with their password and can set it up again.', ok: 'Reset', danger: true })) return;
|
||||
try { await api('POST', '/users/' + u.id + '/reset-mfa'); toast('Two-step sign-in reset for ' + u.username); reloadUsers(); } catch (x) { toast(x.message, true); }
|
||||
};
|
||||
const deleteUser = async (u) => {
|
||||
const tokens = u.tokens ? ' Their ' + (u.tokens === 1 ? 'app token is' : u.tokens + ' app tokens are') + ' revoked too.' : '';
|
||||
if (!await confirmDialog({ title: 'Delete ' + u.username + '?', text: u.username + ' is signed out and can no longer sign in.' + tokens, ok: 'Delete user', danger: true })) return;
|
||||
@@ -1544,6 +1921,45 @@
|
||||
try { await api('PATCH', '/settings', { log: { ...s.log, level: e.target.value } }); s.log.level = e.target.value; toast('Log level: ' + e.target.value); } catch (x) { toast(x.message, true); }
|
||||
} }, ['debug', 'info', 'warn', 'error'].map((l) => h('option', { value: l, selected: s.log.level === l }, l)));
|
||||
|
||||
// sign-in rules
|
||||
const requireBox = h('input', { type: 'checkbox', id: 'rq', checked: s.signin.requireMfa, onChange: async (e) => {
|
||||
const on = e.target.checked;
|
||||
if (on) {
|
||||
const mine = us.users.find((u) => u.you);
|
||||
const without = us.users.filter((u) => !mfaText(u.mfa)).map((u) => u.username);
|
||||
const text = 'Users without two-step sign-in must set it up right after their next sign-in, before they can do anything else. API tokens are not affected.' +
|
||||
(without.length ? ' Not set up yet: ' + without.join(', ') + '.' : '') +
|
||||
(mine && !mfaText(mine.mfa) ? ' That includes you: you are asked to set it up now.' : '');
|
||||
if (!await confirmDialog({ title: 'Require two-step sign-in?', text, ok: 'Require it' })) { e.target.checked = false; return; }
|
||||
}
|
||||
try {
|
||||
await api('PATCH', '/settings', { signin: { ...s.signin, requireMfa: on } });
|
||||
s.signin.requireMfa = on;
|
||||
toast(on ? 'Two-step sign-in required' : 'Two-step sign-in optional');
|
||||
me = await api('GET', '/auth/me');
|
||||
if (me.mfaSetupRequired) showMFASetup(); else reloadUsers();
|
||||
} catch (x) { e.target.checked = !on; toast(x.message, true); }
|
||||
} });
|
||||
|
||||
// decoy
|
||||
const decoyPages = [['nginx', 'nginx welcome page'], ['apache', 'Apache "It works!" page'], ['soon', '"Coming soon" page'], ['blank', 'Blank page'], ['forbidden', '"Forbidden" page'], ['private', '"Private server" page']];
|
||||
const decoyBox = h('input', { type: 'checkbox', id: 'dc', checked: s.decoy.enabled, onChange: async (e) => {
|
||||
const on = e.target.checked;
|
||||
if (on) {
|
||||
const hasApp = (tk.tokens || []).some((t) => t.scope === 'rw');
|
||||
if (!await confirmDialog({ title: 'Turn on Decoy?', ok: 'Turn on', danger: true,
|
||||
text: 'The web interface disappears right away and the server shows the decoy page instead. Only the iOS app can turn Decoy off again.' +
|
||||
(hasApp ? '' : ' No iOS app with full access is paired yet, so you could not get the web interface back.') })) {
|
||||
e.target.checked = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
try { await api('PATCH', '/settings', { decoy: { ...s.decoy, enabled: on } }); s.decoy.enabled = on; toast(on ? 'Decoy on. This tab keeps working until you close or reload it' : 'Decoy off'); } catch (x) { e.target.checked = !on; toast(x.message, true); }
|
||||
} });
|
||||
const decoySel = h('select', { id: 'dp', onChange: async (e) => {
|
||||
try { await api('PATCH', '/settings', { decoy: { ...s.decoy, page: e.target.value } }); s.decoy.page = e.target.value; toast('Decoy page saved'); } catch (x) { e.target.value = s.decoy.page; toast(x.message, true); }
|
||||
} }, decoyPages.map(([v, t]) => h('option', { value: v, selected: s.decoy.page === v }, t)));
|
||||
|
||||
// data retention
|
||||
const presetSelect = (id, value, presets, unit) => {
|
||||
const opts = presets.some(([v]) => v === value) ? presets : [...presets, [value, value + ' ' + unit]].sort((a, b) => a[0] - b[0]);
|
||||
@@ -1604,9 +2020,15 @@
|
||||
h('div', null, h('h2', { id: 'usr' }, 'Users'), h('p', { class: 'lead', style: { marginBottom: '0' } }, 'Everyone here is an admin. You cannot delete yourself, so one user always remains.')),
|
||||
h('button', { type: 'button', class: 'btn primary', onClick: addUser }, 'Add user')),
|
||||
h('div', { class: 'tbl' }, h('table', null,
|
||||
h('thead', null, h('tr', null, h('th', null, 'User'), h('th', null, 'Status'), h('th', null, 'Last sign-in'), h('th', null, 'App tokens'), h('th', null, 'Created'), h('th', null, h('span', { class: 'sr' }, 'Actions')))),
|
||||
h('thead', null, h('tr', null, h('th', null, 'User'), h('th', null, 'Status'), h('th', null, 'Two-step'), h('th', null, 'Last sign-in'), h('th', null, 'App tokens'), h('th', null, 'Created'), h('th', null, h('span', { class: 'sr' }, 'Actions')))),
|
||||
userBody))),
|
||||
|
||||
h('section', { class: 'card', 'aria-labelledby': 'sgn' },
|
||||
h('h2', { id: 'sgn' }, 'Sign-in'),
|
||||
h('p', { class: 'lead' }, 'Everyone sets up two-step sign-in under My account: an authenticator app or passkeys, including on a YubiKey. Changes apply immediately.'),
|
||||
h('label', { class: 'check' }, requireBox, h('span', null, 'Require two-step sign-in for everyone', h('br'),
|
||||
h('span', { class: 'hint' }, 'Users without it are asked to set it up right after their password. To help someone who lost their phone or key, use Edit → Reset two-step sign-in.')))),
|
||||
|
||||
h('form', { class: 'card', onSubmit: saveWeb, 'aria-labelledby': 'web' },
|
||||
h('h2', { id: 'web' }, 'Web interface'),
|
||||
h('p', { class: 'lead' }, 'Changes take effect after the service restarts.'),
|
||||
@@ -1621,6 +2043,14 @@
|
||||
webErr,
|
||||
h('div', { class: 'formfoot' }, h('button', { type: 'submit', class: 'btn primary' }, 'Save'))),
|
||||
|
||||
h('section', { class: 'card', 'aria-labelledby': 'dcy' },
|
||||
h('h2', { id: 'dcy' }, 'Decoy'),
|
||||
h('p', { class: 'lead' }, 'Shows an ordinary web server page instead of this interface. The iOS app and setup links keep working. Changes apply immediately.'),
|
||||
h('label', { class: 'check' }, decoyBox, h('span', null, 'Decoy', h('br'),
|
||||
h('span', { class: 'hint' }, 'Hides the web interface. Turn it off again in the iOS app.'))),
|
||||
h('div', { class: 'grid section' },
|
||||
h('div', { class: 'field' }, h('label', { htmlFor: 'dp' }, 'Decoy page'), decoySel))),
|
||||
|
||||
h('section', { class: 'card', 'aria-labelledby': 'api' },
|
||||
h('div', { class: 'cardhead' },
|
||||
h('div', null, h('h2', { id: 'api' }, 'API tokens'), h('p', { class: 'lead', style: { marginBottom: '0' } }, 'For the iOS app and scripts. A token appears once when you create it, and only a hash is stored.')),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -102,7 +114,10 @@ type principal struct {
|
||||
RemoteIP string
|
||||
// MustChangePassword blocks everything but changing the password.
|
||||
MustChangePassword bool
|
||||
Session *sessionInfo // nil for API tokens
|
||||
// MFASetupRequired blocks everything but setting up two-step sign-in,
|
||||
// when it is required and the user has none.
|
||||
MFASetupRequired bool
|
||||
Session *sessionInfo // nil for API tokens
|
||||
}
|
||||
|
||||
// sessionInfo is when and from where a browser session started.
|
||||
@@ -136,37 +151,71 @@ type Auth struct {
|
||||
mu sync.Mutex
|
||||
sessions map[string]*session
|
||||
used map[string]tokenUse
|
||||
logins map[string]tokenUse // last sign-in per user ID
|
||||
fails map[string]*failState
|
||||
logins map[string]tokenUse // last sign-in per user ID
|
||||
fails map[string]*failState // by lockKey
|
||||
waiting int // sign-ins waiting for or running a password check
|
||||
mfa mfaState
|
||||
}
|
||||
|
||||
const (
|
||||
maxFailures = 5
|
||||
lockoutTime = 15 * time.Minute
|
||||
// maxWaiting sign-ins may wait for a password check; more are turned
|
||||
// away until the queue is shorter.
|
||||
maxWaiting = 16
|
||||
)
|
||||
|
||||
func newAuth(s *Store) *Auth {
|
||||
return &Auth{store: s, sessions: map[string]*session{}, used: map[string]tokenUse{}, logins: map[string]tokenUse{}, fails: map[string]*failState{}}
|
||||
return &Auth{store: s, sessions: map[string]*session{}, used: map[string]tokenUse{}, logins: map[string]tokenUse{}, fails: map[string]*failState{}, mfa: newMFAState()}
|
||||
}
|
||||
|
||||
func cookieName() string { return appName + "_session" }
|
||||
|
||||
var errLocked = errors.New("too many failed attempts, try again later")
|
||||
var (
|
||||
errLocked = errors.New("too many failed attempts, try again later")
|
||||
errBusy = errors.New("too many sign-ins at once, try again in a moment")
|
||||
)
|
||||
|
||||
// Login checks the credentials and returns a new session id.
|
||||
func (a *Auth) Login(user, pw, ip string) (string, error) {
|
||||
a.mu.Lock()
|
||||
f := a.fails[ip]
|
||||
if f != nil && time.Now().Before(f.until) {
|
||||
a.mu.Unlock()
|
||||
return "", errLocked
|
||||
// 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
|
||||
}
|
||||
a.mu.Unlock()
|
||||
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) {
|
||||
cfg := a.store.Get()
|
||||
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
|
||||
// answer time does not tell which usernames exist.
|
||||
u := cfg.userByName(strings.TrimSpace(user))
|
||||
@@ -180,20 +229,15 @@ func (a *Auth) Login(user, pw, ip string) (string, 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")
|
||||
return "", "", errors.New("wrong username or password")
|
||||
}
|
||||
delete(a.fails, ip)
|
||||
undo()
|
||||
if u.hasMFA() {
|
||||
return "", a.newTicketLocked(u, ip), nil
|
||||
}
|
||||
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
|
||||
return a.newSessionLocked(cfg, u, sessionInfo{Started: time.Now(), IP: ip}), "", nil
|
||||
}
|
||||
|
||||
// NewSession replaces a session after the user changed their password; it
|
||||
@@ -290,7 +334,8 @@ func (a *Auth) Authenticate(r *http.Request) (*principal, bool) {
|
||||
return nil, false
|
||||
}
|
||||
info := s.info
|
||||
return &principal{Name: u.Username, UserID: u.ID, Scope: "rw", IsAdmin: true, RemoteIP: ip, MustChangePassword: u.MustChangePassword, Session: &info}, true
|
||||
return &principal{Name: u.Username, UserID: u.ID, Scope: "rw", IsAdmin: true, RemoteIP: ip, MustChangePassword: u.MustChangePassword,
|
||||
MFASetupRequired: cfg.SignIn.RequireMFA && !u.hasMFA(), Session: &info}, true
|
||||
}
|
||||
|
||||
func (a *Auth) TokenUse(id string) *tokenUse {
|
||||
@@ -312,9 +357,21 @@ 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 {
|
||||
if now.After(t.expires) {
|
||||
delete(a.mfa.tickets, id)
|
||||
}
|
||||
}
|
||||
for _, m := range []map[string]*ceremony{a.mfa.logins, a.mfa.enrolls} {
|
||||
for id, c := range m {
|
||||
if now.After(c.expires) {
|
||||
delete(m, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,11 +27,27 @@ type Config struct {
|
||||
APITokens []APIToken `json:"apiTokens"`
|
||||
// Admin is the single account of config version 1; applyDefaults moves
|
||||
// it into Users.
|
||||
Admin *Admin `json:"admin,omitempty"`
|
||||
Server Server `json:"server"`
|
||||
Peers []Peer `json:"peers"`
|
||||
Log LogConfig `json:"log"`
|
||||
Stats StatsConfig `json:"stats"`
|
||||
Admin *Admin `json:"admin,omitempty"`
|
||||
Server Server `json:"server"`
|
||||
Peers []Peer `json:"peers"`
|
||||
Log LogConfig `json:"log"`
|
||||
Stats StatsConfig `json:"stats"`
|
||||
Decoy DecoyConfig `json:"decoy"`
|
||||
SignIn SignInConfig `json:"signin"`
|
||||
}
|
||||
|
||||
// SignInConfig holds the rules for signing in to the web interface.
|
||||
type SignInConfig struct {
|
||||
// RequireMFA sends users without two-step sign-in to set it up before
|
||||
// they can do anything else. API tokens are not affected.
|
||||
RequireMFA bool `json:"requireMfa"`
|
||||
}
|
||||
|
||||
// DecoyConfig replaces the web interface with a stock web server page.
|
||||
// The API keeps working, so the iOS app can turn it off again.
|
||||
type DecoyConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Page string `json:"page"` // nginx | apache | soon
|
||||
}
|
||||
|
||||
// StatsConfig sets how long traffic history is kept in stats.json.
|
||||
@@ -84,6 +100,7 @@ type User struct {
|
||||
// the user can do nothing else until they pick their own.
|
||||
MustChangePassword bool `json:"mustChangePassword,omitempty"`
|
||||
Created time.Time `json:"created"`
|
||||
MFA *UserMFA `json:"mfa,omitempty"` // two-step sign-in, nil when never set up
|
||||
}
|
||||
|
||||
type APIToken struct {
|
||||
@@ -187,6 +204,9 @@ func (c *Config) applyDefaults() {
|
||||
c.Users = []User{u}
|
||||
}
|
||||
c.Admin = nil
|
||||
for i := range c.Users {
|
||||
dropSecurityKeys(&c.Users[i])
|
||||
}
|
||||
for i := range c.APITokens {
|
||||
if c.APITokens[i].UserID == "" {
|
||||
c.APITokens[i].UserID = c.Users[0].ID // tokens from before users existed
|
||||
@@ -223,6 +243,9 @@ func (c *Config) applyDefaults() {
|
||||
if c.Stats.DailyDays == 0 {
|
||||
c.Stats.DailyDays = 400
|
||||
}
|
||||
if c.Decoy.Page == "" {
|
||||
c.Decoy.Page = "nginx"
|
||||
}
|
||||
if c.APITokens == nil {
|
||||
c.APITokens = []APIToken{}
|
||||
}
|
||||
@@ -356,6 +379,9 @@ func (c *Config) validate() error {
|
||||
} else if st.DailyDays < minDailyDays || st.DailyDays > maxDailyDays {
|
||||
return fmt.Errorf("daily traffic history must be %d–%d days", minDailyDays, maxDailyDays)
|
||||
}
|
||||
if _, ok := decoyPages[c.Decoy.Page]; !ok {
|
||||
return fmt.Errorf("unknown decoy page %q", c.Decoy.Page)
|
||||
}
|
||||
switch c.Web.TLS.Mode {
|
||||
case "acme":
|
||||
if c.Web.TLS.Domain == "" {
|
||||
|
||||
@@ -0,0 +1,580 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"html"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// A decoy answers every web path like a freshly installed web server: the
|
||||
// front page is its stock welcome page and everything else is its stock
|
||||
// error page. Only /api/v1 and live setup links get past it.
|
||||
type decoyPage struct {
|
||||
server string // Server header, "" for none
|
||||
index func(host string) string // the front page
|
||||
indexCode int // status of the front page, 0 for 200
|
||||
error func(code int, r *http.Request) string // body for 404 and 405
|
||||
}
|
||||
|
||||
var decoyPages = map[string]decoyPage{
|
||||
"nginx": {server: nginxServer, index: func(string) string { return nginxIndex }, error: nginxError},
|
||||
"apache": {server: apacheServer, index: func(string) string { return apacheIndex }, error: apacheError},
|
||||
"soon": {index: soonIndex, error: soonError},
|
||||
// Generic pages that name no server software.
|
||||
"blank": {index: func(string) string { return "" }, error: func(int, *http.Request) string { return "" }},
|
||||
"forbidden": {index: func(string) string { return forbiddenIndex }, indexCode: http.StatusForbidden, error: soonError},
|
||||
"private": {index: func(string) string { return privateIndex }, error: soonError},
|
||||
}
|
||||
|
||||
// serveDecoy writes the decoy's answer for r. It drops the headers the web
|
||||
// interface adds, since a stock server sends none of them.
|
||||
func serveDecoy(w http.ResponseWriter, r *http.Request, name string) {
|
||||
d, ok := decoyPages[name]
|
||||
if !ok {
|
||||
d = decoyPages["nginx"]
|
||||
}
|
||||
h := w.Header()
|
||||
for _, k := range []string{"Content-Security-Policy", "X-Content-Type-Options", "Referrer-Policy", "X-Frame-Options", "Strict-Transport-Security", "Cache-Control"} {
|
||||
h.Del(k)
|
||||
}
|
||||
if d.server != "" {
|
||||
h.Set("Server", d.server)
|
||||
}
|
||||
h.Set("Content-Type", "text/html")
|
||||
code, body := http.StatusOK, ""
|
||||
switch {
|
||||
case r.Method != http.MethodGet && r.Method != http.MethodHead:
|
||||
code, body = http.StatusMethodNotAllowed, d.error(http.StatusMethodNotAllowed, r)
|
||||
case r.URL.Path == "/" || r.URL.Path == "/index.html":
|
||||
body = d.index(hostOnly(r.Host))
|
||||
if d.indexCode != 0 {
|
||||
code = d.indexCode
|
||||
}
|
||||
default:
|
||||
code, body = http.StatusNotFound, d.error(http.StatusNotFound, r)
|
||||
}
|
||||
w.WriteHeader(code)
|
||||
if r.Method != http.MethodHead {
|
||||
_, _ = w.Write([]byte(body))
|
||||
}
|
||||
}
|
||||
|
||||
func hostOnly(hostport string) string {
|
||||
if h, _, err := net.SplitHostPort(hostport); err == nil {
|
||||
return h
|
||||
}
|
||||
return hostport
|
||||
}
|
||||
|
||||
func hostPort(r *http.Request) string {
|
||||
if _, p, err := net.SplitHostPort(r.Host); err == nil {
|
||||
return p
|
||||
}
|
||||
if r.TLS != nil {
|
||||
return "443"
|
||||
}
|
||||
return "80"
|
||||
}
|
||||
|
||||
const nginxServer = "nginx/1.24.0 (Ubuntu)"
|
||||
|
||||
const nginxIndex = `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Welcome to nginx!</title>
|
||||
<style>
|
||||
html { color-scheme: light dark; }
|
||||
body { width: 35em; margin: 0 auto;
|
||||
font-family: Tahoma, Verdana, Arial, sans-serif; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Welcome to nginx!</h1>
|
||||
<p>If you see this page, the nginx web server is successfully installed and
|
||||
working. Further configuration is required.</p>
|
||||
|
||||
<p>For online documentation and support please refer to
|
||||
<a href="http://nginx.org/">nginx.org</a>.<br/>
|
||||
Commercial support is available at
|
||||
<a href="http://nginx.com/">nginx.com</a>.</p>
|
||||
|
||||
<p><em>Thank you for using nginx.</em></p>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
|
||||
func nginxError(code int, _ *http.Request) string {
|
||||
status := statusLine(code)
|
||||
return "<html>\r\n<head><title>" + status + "</title></head>\r\n<body>\r\n<center><h1>" + status +
|
||||
"</h1></center>\r\n<hr><center>" + nginxServer + "</center>\r\n</body>\r\n</html>\r\n"
|
||||
}
|
||||
|
||||
const apacheServer = "Apache/2.4.58 (Ubuntu)"
|
||||
|
||||
func apacheError(code int, r *http.Request) string {
|
||||
msg := "<p>The requested URL was not found on this server.</p>"
|
||||
if code == http.StatusMethodNotAllowed {
|
||||
msg = "<p>The requested method " + html.EscapeString(r.Method) + " is not allowed for this URL.</p>"
|
||||
}
|
||||
return "<!DOCTYPE HTML PUBLIC \"-//IETF//DTD HTML 2.0//EN\">\n<html><head>\n<title>" + statusLine(code) +
|
||||
"</title>\n</head><body>\n<h1>" + http.StatusText(code) + "</h1>\n" + msg + "\n<hr>\n<address>" + apacheServer +
|
||||
" Server at " + html.EscapeString(hostOnly(r.Host)) + " Port " + hostPort(r) + "</address>\n</body></html>\n"
|
||||
}
|
||||
|
||||
func soonIndex(host string) string {
|
||||
return strings.ReplaceAll(soonTemplate, "{{host}}", html.EscapeString(host))
|
||||
}
|
||||
|
||||
func soonError(code int, _ *http.Request) string {
|
||||
status := statusLine(code)
|
||||
return "<!DOCTYPE html>\n<html>\n<head><title>" + status + "</title></head>\n<body>\n<h1>" + status + "</h1>\n</body>\n</html>\n"
|
||||
}
|
||||
|
||||
func statusLine(code int) string {
|
||||
if code == http.StatusMethodNotAllowed {
|
||||
return "405 Not Allowed" // nginx's wording, also fine for the others
|
||||
}
|
||||
return "404 Not Found"
|
||||
}
|
||||
|
||||
const soonTemplate = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Coming soon</title>
|
||||
<style>
|
||||
html, body { height: 100%; margin: 0; }
|
||||
body { display: flex; align-items: center; justify-content: center; background: #f7f7f7; color: #444;
|
||||
font-family: Helvetica, Arial, sans-serif; text-align: center; }
|
||||
h1 { font-size: 28px; font-weight: 600; color: #222; margin: 0 0 10px; }
|
||||
p { margin: 0 0 6px; }
|
||||
.host { font-size: 13px; color: #888; margin-top: 18px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Coming soon</h1>
|
||||
<p>This site is under construction.</p>
|
||||
<p class="host">{{host}}</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
|
||||
const apacheIndex = `<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||
<html xmlns="http://www.w3.org/1999/xhtml">
|
||||
<head>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||
<title>Apache2 Ubuntu Default Page: It works</title>
|
||||
<style type="text/css" media="screen">
|
||||
* {
|
||||
margin: 0px 0px 0px 0px;
|
||||
padding: 0px 0px 0px 0px;
|
||||
}
|
||||
|
||||
body, html {
|
||||
padding: 3px 3px 3px 3px;
|
||||
|
||||
background-color: #D8DBE2;
|
||||
|
||||
font-family: Ubuntu, Verdana, sans-serif;
|
||||
font-size: 11pt;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
div.main_page {
|
||||
position: relative;
|
||||
display: table;
|
||||
|
||||
width: 800px;
|
||||
|
||||
margin-bottom: 3px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
padding: 0px 0px 0px 0px;
|
||||
|
||||
border-width: 2px;
|
||||
border-color: #212738;
|
||||
border-style: solid;
|
||||
|
||||
background-color: #FFFFFF;
|
||||
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
div.page_header {
|
||||
height: 180px;
|
||||
width: 100%;
|
||||
|
||||
background-color: #F5F6F7;
|
||||
}
|
||||
|
||||
div.page_header span {
|
||||
margin: 15px 0px 0px 50px;
|
||||
|
||||
font-size: 180%;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
div.page_header img {
|
||||
margin: 3px 0px 0px 40px;
|
||||
|
||||
border: 0px 0px 0px;
|
||||
}
|
||||
|
||||
div.banner {
|
||||
padding: 9px 6px 9px 6px;
|
||||
background-color: #E9510E;
|
||||
color: #FFFFFF;
|
||||
font-weight: bold;
|
||||
font-size: 112%;
|
||||
text-align: center;
|
||||
position: absolute;
|
||||
left: 40%;
|
||||
bottom: 30px;
|
||||
width: 20%;
|
||||
}
|
||||
|
||||
div.table_of_contents {
|
||||
clear: left;
|
||||
|
||||
min-width: 200px;
|
||||
|
||||
margin: 3px 3px 3px 3px;
|
||||
|
||||
background-color: #FFFFFF;
|
||||
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
div.table_of_contents_item {
|
||||
clear: left;
|
||||
|
||||
width: 100%;
|
||||
|
||||
margin: 4px 0px 0px 0px;
|
||||
|
||||
background-color: #FFFFFF;
|
||||
|
||||
color: #000000;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
div.table_of_contents_item a {
|
||||
margin: 6px 0px 0px 6px;
|
||||
}
|
||||
|
||||
div.content_section {
|
||||
margin: 3px 3px 3px 3px;
|
||||
|
||||
background-color: #FFFFFF;
|
||||
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
div.content_section_text {
|
||||
padding: 4px 8px 4px 8px;
|
||||
|
||||
color: #000000;
|
||||
font-size: 100%;
|
||||
}
|
||||
|
||||
div.content_section_text pre {
|
||||
margin: 8px 0px 8px 0px;
|
||||
padding: 8px 8px 8px 8px;
|
||||
|
||||
border-width: 1px;
|
||||
border-style: dotted;
|
||||
border-color: #000000;
|
||||
|
||||
background-color: #F5F6F7;
|
||||
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
div.content_section_text p {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
div.content_section_text ul, div.content_section_text li {
|
||||
padding: 4px 8px 4px 16px;
|
||||
}
|
||||
|
||||
div.section_header {
|
||||
padding: 3px 6px 3px 6px;
|
||||
|
||||
background-color: #8E9CB2;
|
||||
|
||||
color: #FFFFFF;
|
||||
font-weight: bold;
|
||||
font-size: 112%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
div.section_header_red {
|
||||
background-color: #CD214F;
|
||||
}
|
||||
|
||||
div.section_header_grey {
|
||||
background-color: #9F9386;
|
||||
}
|
||||
|
||||
.floating_element {
|
||||
position: relative;
|
||||
float: left;
|
||||
}
|
||||
|
||||
div.table_of_contents_item a,
|
||||
div.content_section_text a {
|
||||
text-decoration: none;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
div.table_of_contents_item a:link,
|
||||
div.table_of_contents_item a:visited,
|
||||
div.table_of_contents_item a:active {
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
div.table_of_contents_item a:hover {
|
||||
background-color: #000000;
|
||||
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
div.content_section_text a:link,
|
||||
div.content_section_text a:visited,
|
||||
div.content_section_text a:active {
|
||||
background-color: #DCDFE6;
|
||||
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
div.content_section_text a:hover {
|
||||
background-color: #000000;
|
||||
|
||||
color: #DCDFE6;
|
||||
}
|
||||
|
||||
div.validator {
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="main_page">
|
||||
<div class="page_header floating_element">
|
||||
<span class="floating_element">
|
||||
Apache2 Default Page
|
||||
</span>
|
||||
</div>
|
||||
<!-- <div class="table_of_contents floating_element">
|
||||
<div class="section_header section_header_grey">
|
||||
TABLE OF CONTENTS
|
||||
</div>
|
||||
<div class="table_of_contents_item floating_element">
|
||||
<a href="#about">About</a>
|
||||
</div>
|
||||
<div class="table_of_contents_item floating_element">
|
||||
<a href="#changes">Changes</a>
|
||||
</div>
|
||||
<div class="table_of_contents_item floating_element">
|
||||
<a href="#scope">Scope</a>
|
||||
</div>
|
||||
<div class="table_of_contents_item floating_element">
|
||||
<a href="#files">Config files</a>
|
||||
</div>
|
||||
</div>
|
||||
-->
|
||||
<div class="content_section floating_element">
|
||||
|
||||
|
||||
<div class="section_header section_header_red">
|
||||
<div id="about"></div>
|
||||
It works!
|
||||
</div>
|
||||
<div class="content_section_text">
|
||||
<p>
|
||||
This is the default welcome page used to test the correct
|
||||
operation of the Apache2 server after installation on Ubuntu systems.
|
||||
It is based on the equivalent page on Debian, from which the Ubuntu Apache
|
||||
packaging is derived.
|
||||
If you can read this page, it means that the Apache HTTP server installed at
|
||||
this site is working properly. You should <b>replace this file</b> (located at
|
||||
<tt>/var/www/html/index.html</tt>) before continuing to operate your HTTP server.
|
||||
</p>
|
||||
|
||||
|
||||
<p>
|
||||
If you are a normal user of this web site and don't know what this page is
|
||||
about, this probably means that the site is currently unavailable due to
|
||||
maintenance.
|
||||
If the problem persists, please contact the site's administrator.
|
||||
</p>
|
||||
|
||||
</div>
|
||||
<div class="section_header">
|
||||
<div id="changes"></div>
|
||||
Configuration Overview
|
||||
</div>
|
||||
<div class="content_section_text">
|
||||
<p>
|
||||
Ubuntu's Apache2 default configuration is different from the
|
||||
upstream default configuration, and split into several files optimized for
|
||||
interaction with Ubuntu tools. The configuration system is
|
||||
<b>fully documented in
|
||||
/usr/share/doc/apache2/README.Debian.gz</b>. Refer to this for the full
|
||||
documentation. Documentation for the web server itself can be
|
||||
found by accessing the <a href="/manual">manual</a> if the <tt>apache2-doc</tt>
|
||||
package was installed on this server.
|
||||
</p>
|
||||
<p>
|
||||
The configuration layout for an Apache2 web server installation on Ubuntu systems is as follows:
|
||||
</p>
|
||||
<pre>
|
||||
/etc/apache2/
|
||||
|-- apache2.conf
|
||||
| ` + "`" + `-- ports.conf
|
||||
|-- mods-enabled
|
||||
| |-- *.load
|
||||
| ` + "`" + `-- *.conf
|
||||
|-- conf-enabled
|
||||
| ` + "`" + `-- *.conf
|
||||
|-- sites-enabled
|
||||
| ` + "`" + `-- *.conf
|
||||
</pre>
|
||||
<ul>
|
||||
<li>
|
||||
<tt>apache2.conf</tt> is the main configuration
|
||||
file. It puts the pieces together by including all remaining configuration
|
||||
files when starting up the web server.
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<tt>ports.conf</tt> is always included from the
|
||||
main configuration file. It is used to determine the listening ports for
|
||||
incoming connections, and this file can be customized anytime.
|
||||
</li>
|
||||
|
||||
<li>
|
||||
Configuration files in the <tt>mods-enabled/</tt>,
|
||||
<tt>conf-enabled/</tt> and <tt>sites-enabled/</tt> directories contain
|
||||
particular configuration snippets which manage modules, global configuration
|
||||
fragments, or virtual host configurations, respectively.
|
||||
</li>
|
||||
|
||||
<li>
|
||||
They are activated by symlinking available
|
||||
configuration files from their respective
|
||||
*-available/ counterparts. These should be managed
|
||||
by using our helpers
|
||||
<tt>
|
||||
a2enmod,
|
||||
a2dismod,
|
||||
</tt>
|
||||
<tt>
|
||||
a2ensite,
|
||||
a2dissite,
|
||||
</tt>
|
||||
and
|
||||
<tt>
|
||||
a2enconf,
|
||||
a2disconf
|
||||
</tt>. See their respective man pages for detailed information.
|
||||
</li>
|
||||
|
||||
<li>
|
||||
The binary is called apache2 and is managed using systemd, so to
|
||||
start/stop the service use <tt>systemctl start apache2</tt> and
|
||||
<tt>systemctl stop apache2</tt>, and use <tt>systemctl status apache2</tt>
|
||||
and <tt>journalctl -u apache2</tt> to check status. <tt>system</tt>
|
||||
and <tt>apache2ctl</tt> can also be used for service management if
|
||||
desired.
|
||||
<b>Calling <tt>/usr/bin/apache2</tt> directly will not work</b> with the
|
||||
default configuration.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="section_header">
|
||||
<div id="docroot"></div>
|
||||
Document Roots
|
||||
</div>
|
||||
|
||||
<div class="content_section_text">
|
||||
<p>
|
||||
By default, Ubuntu does not allow access through the web browser to
|
||||
<em>any</em> file outside of those located in <tt>/var/www</tt>,
|
||||
<a href="http://httpd.apache.org/docs/2.4/mod/mod_userdir.html" rel="nofollow">public_html</a>
|
||||
directories (when enabled) and <tt>/usr/share</tt> (for web
|
||||
applications). If your site is using a web document root
|
||||
located elsewhere (such as in <tt>/srv</tt>) you may need to whitelist your
|
||||
document root directory in <tt>/etc/apache2/apache2.conf</tt>.
|
||||
</p>
|
||||
<p>
|
||||
The default Ubuntu document root is <tt>/var/www/html</tt>. You
|
||||
can make your own virtual hosts under /var/www.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="section_header">
|
||||
<div id="bugs"></div>
|
||||
Reporting Problems
|
||||
</div>
|
||||
<div class="content_section_text">
|
||||
<p>
|
||||
Please use the <tt>ubuntu-bug</tt> tool to report bugs in the
|
||||
Apache2 package with Ubuntu. However, check <a
|
||||
href="https://bugs.launchpad.net/ubuntu/+source/apache2"
|
||||
rel="nofollow">existing bug reports</a> before reporting a new bug.
|
||||
</p>
|
||||
<p>
|
||||
Please report bugs specific to modules (such as PHP and others)
|
||||
to their respective packages, not to the web server itself.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div class="validator">
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
|
||||
const forbiddenIndex = `<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>403 Forbidden</title></head>
|
||||
<body>
|
||||
<h1>Forbidden</h1>
|
||||
<p>You don't have permission to access this resource.</p>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
|
||||
const privateIndex = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Private</title>
|
||||
<style>
|
||||
html, body { height: 100%; margin: 0; }
|
||||
body { display: flex; align-items: center; justify-content: center; background: #111; color: #999;
|
||||
font-family: Georgia, serif; text-align: center; }
|
||||
h1 { font-size: 28px; font-weight: normal; letter-spacing: 0.04em; color: #fff; margin: 0 0 10px; }
|
||||
p { margin: 0; font-size: 15px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Private server</h1>
|
||||
<p>Nothing to see here.</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
@@ -3,6 +3,7 @@ module ghostwire
|
||||
go 1.27.1
|
||||
|
||||
require (
|
||||
github.com/go-webauthn/webauthn v0.18.2
|
||||
github.com/google/nftables v0.3.0
|
||||
github.com/oschwald/maxminddb-golang v1.13.1
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
||||
@@ -15,11 +16,20 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/fxamacker/cbor/v2 v2.9.4 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||
github.com/go-webauthn/x v0.3.1 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||
github.com/google/go-cmp v0.6.0 // indirect
|
||||
github.com/google/go-tpm v0.9.8 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/mdlayher/genetlink v1.3.2 // indirect
|
||||
github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 // indirect
|
||||
github.com/mdlayher/socket v0.5.1 // indirect
|
||||
github.com/philhofer/fwd v1.2.0 // indirect
|
||||
github.com/tinylib/msgp v1.6.4 // indirect
|
||||
github.com/vishvananda/netns v0.0.5 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
golang.org/x/sync v0.23.0 // indirect
|
||||
golang.org/x/text v0.42.0 // indirect
|
||||
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173 // indirect
|
||||
|
||||
@@ -1,9 +1,23 @@
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/fxamacker/cbor/v2 v2.9.4 h1:xwjVlxEMR3S605oUlgBjKLTTeGFciYPGYCtF/35LKGo=
|
||||
github.com/fxamacker/cbor/v2 v2.9.4/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/go-webauthn/webauthn v0.18.2 h1:0BeftmEHU7i3Dv0VFwBtidy/ba37Vcdjvqst9EYu8Sk=
|
||||
github.com/go-webauthn/webauthn v0.18.2/go.mod h1:hEXaOuLxvZ3zG9miZe3ehlyeVso9AtklXG+kTn36k+A=
|
||||
github.com/go-webauthn/x v0.3.1 h1:1ff37z3XfmTTomkhlURgGizLIDyOvPgTt2t9nlzKLRo=
|
||||
github.com/go-webauthn/x v0.3.1/go.mod h1:ZInxAynYXfBPvvm5gzKZ7geBlL23K71xASMgohHl/Rg=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
|
||||
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
|
||||
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLzH5KDR0gKc0zcktin6KSAwL7+jWKBYceddTc=
|
||||
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc=
|
||||
github.com/google/nftables v0.3.0 h1:bkyZ0cbpVeMHXOrtlFc8ISmfVqq5gPJukoYieyVmITg=
|
||||
github.com/google/nftables v0.3.0/go.mod h1:BCp9FsrbF1Fn/Yu6CLUc9GGZFw/+hsxfluNXXmxBfRM=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/mdlayher/genetlink v1.3.2 h1:KdrNKe+CTu+IbZnm/GVUMXSqBBLqcGpRDa0xkQy56gw=
|
||||
github.com/mdlayher/genetlink v1.3.2/go.mod h1:tcC3pkCrPUGIKKsCsp0B3AdaaKuHtaxoJRz3cc+528o=
|
||||
github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 h1:A1Cq6Ysb0GM0tpKMbdCXCIfBclan4oHk1Jb+Hrejirg=
|
||||
@@ -14,16 +28,24 @@ github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721 h1:RlZweED6sbSArvlE9
|
||||
github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721/go.mod h1:Ickgr2WtCLZ2MDGd4Gr0geeCH5HybhRJbonOgQpvSxc=
|
||||
github.com/oschwald/maxminddb-golang v1.13.1 h1:G3wwjdN9JmIK2o/ermkHM+98oX5fS+k5MbwsmL4MRQE=
|
||||
github.com/oschwald/maxminddb-golang v1.13.1/go.mod h1:K4pgV9N/GcK694KSTmVSDTODk4IsCNThNdTmnaBZ/F8=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
||||
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
|
||||
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
|
||||
github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
|
||||
github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
|
||||
github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0=
|
||||
github.com/vishvananda/netlink v1.3.1/go.mod h1:ARtKouGSTGchR8aMwmkzC0qiNPrrWO5JS/XMVl45+b4=
|
||||
github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY=
|
||||
github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
||||
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
||||
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
|
||||
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
|
||||
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
|
||||
@@ -42,5 +64,3 @@ golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173 h1:/jFs0duh4rdb8uI
|
||||
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173/go.mod h1:tkCQ4FQXmpAgYVh++1cq16/dH4QJtmvpRv19DWGAHSA=
|
||||
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 h1:3GDAcqdIg1ozBNLgPy4SLT84nfcBjr6rhGtXYtrkWLU=
|
||||
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10/go.mod h1:T97yPqesLiNrOYxkwmhMI0ZIlJDm+p0PMR8eRVeR5tQ=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -12,7 +12,9 @@ import (
|
||||
"net/netip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -328,6 +330,12 @@ 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.
|
||||
secret = call("POST", "/tokens", map[string]string{"name": "full", "scope": "rw"}, 201)["token"].(string)
|
||||
bearer("GET", "/users", 200)
|
||||
bearer("GET", "/tokens", 200)
|
||||
bearer("GET", "/backup", 403)
|
||||
|
||||
call("DELETE", "/peers/"+id, nil, 200)
|
||||
if len(store.Get().Peers) != 0 {
|
||||
t.Fatal("peer not deleted")
|
||||
@@ -370,6 +378,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 {
|
||||
@@ -877,3 +971,268 @@ func TestUsers(t *testing.T) {
|
||||
}
|
||||
admin("PATCH", "/settings", map[string]any{"adminUsername": "x"}, 400)
|
||||
}
|
||||
|
||||
// TestDecoy checks that the decoy hides the web interface but leaves the API
|
||||
// and live setup links alone.
|
||||
func TestDecoy(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
store, err := openStore(filepath.Join(dir, "config.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if store.Get().Decoy.Page != "nginx" {
|
||||
t.Fatalf("default decoy page %q", store.Get().Decoy.Page)
|
||||
}
|
||||
k := &fakeKernel{}
|
||||
st, _ := openStats(filepath.Join(dir, "stats.json"), store, k)
|
||||
app := &App{store: store, kernel: k, recon: newReconciler(k, store), stats: st, auth: newAuth(store),
|
||||
tls: &webTLS{}, logPath: filepath.Join(dir, "log.jsonl"), started: time.Now(), shutdown: func() {}}
|
||||
srv := httptest.NewServer(app.routes())
|
||||
defer srv.Close()
|
||||
|
||||
get := func(path string, want int) (string, http.Header) {
|
||||
t.Helper()
|
||||
resp, err := http.Get(srv.URL + path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != want {
|
||||
t.Fatalf("GET %s: status %d, want %d", path, resp.StatusCode, want)
|
||||
}
|
||||
return string(b), resp.Header
|
||||
}
|
||||
set := func(fn func(c *Config)) {
|
||||
if err := store.Update(func(c *Config) error { fn(c); return nil }); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
b, _ := get("/", 200)
|
||||
if !strings.Contains(b, `"/app.js?v=`+assetHash["app.js"]+`"`) || !strings.Contains(b, `"/app.css?v=`+assetHash["app.css"]+`"`) {
|
||||
t.Fatalf("web interface not served with fingerprinted files: %q", b)
|
||||
}
|
||||
if _, h := get("/app.js?v="+assetHash["app.js"], 200); !strings.Contains(h.Get("Cache-Control"), "immutable") {
|
||||
t.Fatalf("fingerprinted app.js: %v", h)
|
||||
}
|
||||
if _, h := get("/app.js?v=old", 200); h.Get("Cache-Control") != "no-cache" {
|
||||
t.Fatalf("stale app.js cached: %v", h)
|
||||
}
|
||||
set(func(c *Config) {
|
||||
v4 := netip.MustParsePrefix(c.Server.IPv4)
|
||||
c.Peers = append(c.Peers, Peer{ID: "p1", Name: "phone", IPv4: v4.Addr().Next().Next().Next().String(), Setup: &SetupLink{Token: "live-token", Expires: time.Now().Add(time.Hour)}})
|
||||
c.Decoy.Enabled = true
|
||||
})
|
||||
|
||||
b, h := get("/", 200)
|
||||
if !strings.Contains(b, "Welcome to nginx!") || h.Get("Server") != nginxServer || h.Get("Content-Security-Policy") != "" {
|
||||
t.Fatalf("nginx decoy: %q %v", b, h)
|
||||
}
|
||||
for _, p := range []string{"/app.js", "/app.css", "/favicon.svg", "/ShipporiMinchoB1-ExtraBold.woff2", "/setup/wrong", "/setup/wrong/app.css", "/setup/live-token/app.js"} {
|
||||
if b, _ := get(p, 404); strings.Contains(b, "GHOSTWIRE") || !strings.Contains(b, "404 Not Found") {
|
||||
t.Fatalf("%s leaks: %q", p, b)
|
||||
}
|
||||
}
|
||||
if b, _ := get("/setup/live-token", 200); !strings.Contains(b, `src="/setup/live-token/setup.js?v=`+assetHash["setup.js"]+`"`) {
|
||||
t.Fatalf("setup page files not under the link: %q", b)
|
||||
}
|
||||
get("/setup/live-token/app.css", 200)
|
||||
get("/api/v1/setup/live-token", 200)
|
||||
get("/api/v1/status", 401)
|
||||
|
||||
set(func(c *Config) { c.Decoy.Page = "apache" })
|
||||
if b, _ := get("/nope", 404); !strings.Contains(b, "Apache/2.4.58 (Ubuntu) Server at 127.0.0.1 Port") {
|
||||
t.Fatalf("apache 404: %q", b)
|
||||
}
|
||||
set(func(c *Config) { c.Decoy.Page = "soon" })
|
||||
if b, h := get("/", 200); !strings.Contains(b, "<p class=\"host\">127.0.0.1</p>") || h.Get("Server") != "" {
|
||||
t.Fatalf("soon decoy: %q", b)
|
||||
}
|
||||
set(func(c *Config) { c.Decoy.Page = "blank" })
|
||||
if b, _ := get("/", 200); b != "" {
|
||||
t.Fatalf("blank decoy: %q", b)
|
||||
}
|
||||
if b, _ := get("/app.js", 404); b != "" {
|
||||
t.Fatalf("blank 404: %q", b)
|
||||
}
|
||||
set(func(c *Config) { c.Decoy.Page = "forbidden" })
|
||||
if b, _ := get("/", 403); !strings.Contains(b, "Forbidden") {
|
||||
t.Fatalf("forbidden decoy: %q", b)
|
||||
}
|
||||
set(func(c *Config) { c.Decoy.Page = "private" })
|
||||
if b, _ := get("/", 200); !strings.Contains(b, "Private server") {
|
||||
t.Fatalf("private decoy: %q", b)
|
||||
}
|
||||
if err := store.Update(func(c *Config) error { c.Decoy.Page = "iis"; return nil }); err == nil {
|
||||
t.Fatal("unknown decoy page accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTOTPCode(t *testing.T) {
|
||||
// RFC 6238, appendix B (SHA-1), cut to 6 digits.
|
||||
key := []byte("12345678901234567890")
|
||||
for _, c := range []struct {
|
||||
unix int64
|
||||
want string
|
||||
}{{59, "287082"}, {1111111109, "081804"}, {1234567890, "005924"}, {2000000000, "279037"}} {
|
||||
if got := totpCode(key, uint64(c.unix/30)); got != c.want {
|
||||
t.Errorf("time %d: %s, want %s", c.unix, got, c.want)
|
||||
}
|
||||
}
|
||||
secret := b32.EncodeToString(key)
|
||||
now := time.Unix(1111111109, 0)
|
||||
if _, ok := totpMatch(secret, "081 804", now); !ok {
|
||||
t.Error("code with a space refused")
|
||||
}
|
||||
if _, ok := totpMatch(secret, "081804", now.Add(90*time.Second)); ok {
|
||||
t.Error("code three steps late accepted")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMFA signs in with an authenticator code and a recovery code, and
|
||||
// checks the "require" switch and the admin reset.
|
||||
func TestMFA(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
store, err := openStore(filepath.Join(dir, "config.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hash, _ := hashPassword("a long test password")
|
||||
_ = store.Update(func(c *Config) error { c.Users[0].PasswordHash = hash; return nil })
|
||||
k := &fakeKernel{}
|
||||
st, _ := openStats(filepath.Join(dir, "stats.json"), store, k)
|
||||
app := &App{store: store, kernel: k, recon: newReconciler(k, store), stats: st, auth: newAuth(store),
|
||||
tls: &webTLS{}, logPath: filepath.Join(dir, "log.jsonl"), started: time.Now(), shutdown: func() {}}
|
||||
srv := httptest.NewServer(app.routes())
|
||||
defer srv.Close()
|
||||
|
||||
client := func() func(method, path string, body any, want int) map[string]any {
|
||||
jar, _ := cookiejar.New(nil)
|
||||
cl := &http.Client{Jar: jar}
|
||||
return func(method, path string, body any, want int) map[string]any {
|
||||
t.Helper()
|
||||
var rd io.Reader
|
||||
if body != nil {
|
||||
b, _ := json.Marshal(body)
|
||||
rd = bytes.NewReader(b)
|
||||
}
|
||||
req, _ := http.NewRequest(method, srv.URL+"/api/v1"+path, rd)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := cl.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var out map[string]any
|
||||
_ = json.NewDecoder(resp.Body).Decode(&out)
|
||||
if resp.StatusCode != want {
|
||||
t.Fatalf("%s %s: status %d, want %d: %v", method, path, resp.StatusCode, want, out)
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
login := map[string]string{"username": "admin", "password": "a long test password"}
|
||||
adm := client()
|
||||
adm("POST", "/auth/login", login, 200)
|
||||
if o := adm("GET", "/auth/options", nil, 200); o["passkeys"] != false {
|
||||
t.Fatalf("passkeys offered on an IP address: %v", o)
|
||||
}
|
||||
adm("POST", "/auth/mfa/keys/begin", nil, 400)
|
||||
|
||||
// Turn on the authenticator app; the first method brings recovery codes.
|
||||
setup := adm("POST", "/auth/mfa/totp/setup", nil, 200)
|
||||
secret := setup["secret"].(string)
|
||||
if !strings.HasPrefix(setup["uri"].(string), "otpauth://totp/") || setup["qr"] == "" {
|
||||
t.Fatalf("setup: %v", setup)
|
||||
}
|
||||
adm("POST", "/auth/mfa/totp/confirm", map[string]string{"code": "000000"}, 400)
|
||||
key, _ := b32.DecodeString(secret)
|
||||
code := func(offset int) string { return totpCode(key, uint64(time.Now().Unix()/30)+uint64(offset)) }
|
||||
conf := adm("POST", "/auth/mfa/totp/confirm", map[string]string{"code": code(0)}, 200)
|
||||
codes := conf["recoveryCodes"].([]any)
|
||||
if len(codes) != recoveryCount {
|
||||
t.Fatalf("recovery codes: %v", conf)
|
||||
}
|
||||
if s := adm("GET", "/auth/mfa", nil, 200); s["totp"] != true || s["recoveryLeft"] != float64(recoveryCount) {
|
||||
t.Fatalf("status: %v", s)
|
||||
}
|
||||
|
||||
// A password alone now gives a ticket, not a session.
|
||||
c := client()
|
||||
r := c("POST", "/auth/login", login, 200)
|
||||
ticket, _ := r["ticket"].(string)
|
||||
if r["mfa"] != true || ticket == "" {
|
||||
t.Fatalf("login without second step: %v", r)
|
||||
}
|
||||
c("GET", "/peers", nil, 401)
|
||||
c("POST", "/auth/login/totp", map[string]string{"ticket": ticket, "code": "123456"}, 401)
|
||||
c("POST", "/auth/login/totp", map[string]string{"ticket": ticket, "code": code(0)}, 401) // used during setup
|
||||
c("POST", "/auth/login/totp", map[string]string{"ticket": ticket, "code": code(1)}, 200)
|
||||
c("GET", "/peers", nil, 200)
|
||||
|
||||
// A recovery code works once.
|
||||
c2 := client()
|
||||
ticket = c2("POST", "/auth/login", login, 200)["ticket"].(string)
|
||||
c2("POST", "/auth/login/recovery", map[string]string{"ticket": ticket, "code": strings.ToLower(codes[0].(string))}, 200)
|
||||
c3 := client()
|
||||
ticket = c3("POST", "/auth/login", login, 200)["ticket"].(string)
|
||||
c3("POST", "/auth/login/recovery", map[string]string{"ticket": ticket, "code": codes[0].(string)}, 401)
|
||||
c3("POST", "/auth/login/recovery", map[string]string{"ticket": ticket, "code": codes[1].(string)}, 200)
|
||||
|
||||
// Required for everyone: a user without it can only set it up.
|
||||
adm("PATCH", "/settings", map[string]any{"signin": map[string]bool{"requireMfa": true}}, 200)
|
||||
u := adm("POST", "/users", map[string]any{"username": "eve", "password": "eve's password 1", "mustChangePassword": false}, 201)["user"].(map[string]any)
|
||||
e := client()
|
||||
e("POST", "/auth/login", map[string]string{"username": "eve", "password": "eve's password 1"}, 200)
|
||||
if me := e("GET", "/auth/me", nil, 200); me["mfaSetupRequired"] != true {
|
||||
t.Fatalf("me: %v", me)
|
||||
}
|
||||
e("GET", "/peers", nil, 403)
|
||||
e("GET", "/auth/mfa", nil, 200)
|
||||
// The last method cannot be removed while it is required.
|
||||
adm("DELETE", "/auth/mfa/totp", nil, 400)
|
||||
|
||||
// An admin resets another user's two-step sign-in, not their own.
|
||||
_ = store.Update(func(c *Config) error {
|
||||
_, eu := c.userByID(u["id"].(string))
|
||||
eu.MFA = &UserMFA{TOTPSecret: newTOTPSecret(), RecoveryCodes: []string{"x"}}
|
||||
return nil
|
||||
})
|
||||
if l := adm("GET", "/users", nil, 200)["users"].([]any); l[1].(map[string]any)["mfa"].(map[string]any)["totp"] != true {
|
||||
t.Fatalf("users list: %v", l)
|
||||
}
|
||||
me := adm("GET", "/auth/me", nil, 200)
|
||||
adm("POST", "/users/"+me["id"].(string)+"/reset-mfa", nil, 400)
|
||||
adm("POST", "/users/"+u["id"].(string)+"/reset-mfa", nil, 200)
|
||||
if _, eu := store.Get().userByID(u["id"].(string)); eu.hasMFA() || len(eu.MFA.RecoveryCodes) != 0 {
|
||||
t.Fatal("reset left methods behind")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDropSecurityKeys checks that security keys from v0.3.0 are deleted on
|
||||
// load, and recovery codes with them when nothing else is left.
|
||||
func TestDropSecurityKeys(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.json")
|
||||
cfg := `{"users": [
|
||||
{"id": "a", "username": "a", "passwordHash": "x", "mfa": {"keys": [{"id": "k", "name": "YubiKey", "passkey": false}], "recoveryCodes": ["h"]}},
|
||||
{"id": "b", "username": "b", "passwordHash": "x", "mfa": {"keys": [{"id": "k1", "name": "YubiKey", "passkey": false}, {"id": "k2", "name": "Mac", "passkey": true}], "recoveryCodes": ["h"]}}
|
||||
]}`
|
||||
if err := os.WriteFile(path, []byte(cfg), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store, err := openStore(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c := store.Get()
|
||||
if a := c.Users[0].MFA; len(a.Keys) != 0 || len(a.RecoveryCodes) != 0 {
|
||||
t.Fatalf("user a kept %v", a)
|
||||
}
|
||||
if b := c.Users[1].MFA; len(b.Keys) != 1 || b.Keys[0].Name != "Mac" || len(b.RecoveryCodes) != 1 {
|
||||
t.Fatalf("user b: %v", b)
|
||||
}
|
||||
if b, _ := os.ReadFile(path); strings.Contains(string(b), "YubiKey") {
|
||||
t.Fatal("security key still in config.json")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,940 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha1"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/base32"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-webauthn/webauthn/protocol"
|
||||
"github.com/go-webauthn/webauthn/webauthn"
|
||||
)
|
||||
|
||||
// Two-step sign-in for the web interface: an authenticator app (TOTP) and
|
||||
// passkeys (WebAuthn, also on a YubiKey), plus one-time recovery codes. A
|
||||
// passkey signs in on its own and also serves as the second step after a
|
||||
// password. API tokens never need a second step.
|
||||
//
|
||||
// After a correct password, a user with two-step sign-in gets a short-lived
|
||||
// ticket instead of a session; the ticket and a code or key turn into the
|
||||
// session. A passkey signs in on its own, without username and password.
|
||||
|
||||
// UserMFA is a user's two-step sign-in setup, stored in config.json.
|
||||
type UserMFA struct {
|
||||
TOTPSecret string `json:"totpSecret,omitempty"` // base32
|
||||
TOTPAdded *time.Time `json:"totpAdded,omitempty"`
|
||||
Keys []MFAKey `json:"keys,omitempty"`
|
||||
RecoveryCodes []string `json:"recoveryCodes,omitempty"` // SHA-256 of the unused codes
|
||||
Handle []byte `json:"handle,omitempty"` // WebAuthn user handle
|
||||
}
|
||||
|
||||
// MFAKey is a passkey.
|
||||
type MFAKey struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Passkey bool `json:"passkey"` // false only for security keys added by v0.3.0, which are deleted
|
||||
Created time.Time `json:"created"`
|
||||
LastUsed *time.Time `json:"lastUsed,omitempty"`
|
||||
Credential webauthn.Credential `json:"credential"`
|
||||
}
|
||||
|
||||
// dropSecurityKeys deletes the security keys v0.3.0 could add; only
|
||||
// passkeys are supported. A user left without a method loses their
|
||||
// recovery codes too.
|
||||
func dropSecurityKeys(u *User) {
|
||||
if u.MFA == nil {
|
||||
return
|
||||
}
|
||||
u.MFA.Keys = slices.DeleteFunc(u.MFA.Keys, func(k MFAKey) bool { return !k.Passkey })
|
||||
if !u.hasMFA() {
|
||||
u.MFA.RecoveryCodes = nil
|
||||
}
|
||||
}
|
||||
|
||||
func (u *User) hasMFA() bool {
|
||||
return u.MFA != nil && (u.MFA.TOTPSecret != "" || len(u.MFA.Keys) > 0)
|
||||
}
|
||||
|
||||
const (
|
||||
ticketTTL = 5 * time.Minute
|
||||
recoveryCount = 10
|
||||
totpPeriod = 30
|
||||
totpDigits = 6
|
||||
maxKeyName = 64
|
||||
)
|
||||
|
||||
// --- TOTP (RFC 6238, SHA-1, 6 digits, 30 s) ---
|
||||
|
||||
var b32 = base32.StdEncoding.WithPadding(base32.NoPadding)
|
||||
|
||||
func newTOTPSecret() string {
|
||||
b := make([]byte, 20)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return b32.EncodeToString(b)
|
||||
}
|
||||
|
||||
func totpCode(key []byte, counter uint64) string {
|
||||
var msg [8]byte
|
||||
binary.BigEndian.PutUint64(msg[:], counter)
|
||||
m := hmac.New(sha1.New, key)
|
||||
m.Write(msg[:])
|
||||
sum := m.Sum(nil)
|
||||
off := sum[len(sum)-1] & 0x0f
|
||||
v := binary.BigEndian.Uint32(sum[off:off+4]) & 0x7fffffff
|
||||
return fmt.Sprintf("%0*d", totpDigits, v%1_000_000)
|
||||
}
|
||||
|
||||
// totpMatch returns the time step the code belongs to, allowing one step of
|
||||
// clock drift either way.
|
||||
func totpMatch(secret, code string, now time.Time) (uint64, bool) {
|
||||
key, err := b32.DecodeString(strings.ToUpper(secret))
|
||||
code = strings.Map(func(r rune) rune {
|
||||
if r >= '0' && r <= '9' {
|
||||
return r
|
||||
}
|
||||
return -1
|
||||
}, code)
|
||||
if err != nil || len(code) != totpDigits {
|
||||
return 0, false
|
||||
}
|
||||
step := uint64(now.Unix() / totpPeriod)
|
||||
for _, c := range []uint64{step, step - 1, step + 1} {
|
||||
if subtle.ConstantTimeCompare([]byte(totpCode(key, c)), []byte(code)) == 1 {
|
||||
return c, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func totpURI(secret, username string) string {
|
||||
label := url.PathEscape(appName + ":" + username)
|
||||
return "otpauth://totp/" + label + "?secret=" + secret + "&issuer=" + url.QueryEscape(appName) + "&algorithm=SHA1&digits=6&period=30"
|
||||
}
|
||||
|
||||
// --- recovery codes ---
|
||||
|
||||
const recoveryAlphabet = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"
|
||||
|
||||
// newRecoveryCodes returns codes to show once and their hashes to store.
|
||||
func newRecoveryCodes() (codes, hashes []string) {
|
||||
for range recoveryCount {
|
||||
b := make([]byte, 8)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
var s strings.Builder
|
||||
for i, x := range b {
|
||||
if i == 4 {
|
||||
s.WriteByte('-')
|
||||
}
|
||||
s.WriteByte(recoveryAlphabet[int(x)%len(recoveryAlphabet)])
|
||||
}
|
||||
codes = append(codes, s.String())
|
||||
hashes = append(hashes, hashRecovery(s.String()))
|
||||
}
|
||||
return codes, hashes
|
||||
}
|
||||
|
||||
func hashRecovery(code string) string {
|
||||
norm := strings.Map(func(r rune) rune {
|
||||
if r == '-' || r == ' ' {
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, strings.ToUpper(code))
|
||||
sum := sha256.Sum256([]byte(norm))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// --- WebAuthn ---
|
||||
|
||||
// waUser adapts a User to the webauthn library.
|
||||
type waUser struct{ u *User }
|
||||
|
||||
func (w waUser) WebAuthnID() []byte { return w.u.MFA.Handle }
|
||||
func (w waUser) WebAuthnName() string { return w.u.Username }
|
||||
func (w waUser) WebAuthnDisplayName() string { return w.u.Username }
|
||||
func (w waUser) WebAuthnCredentials() []webauthn.Credential {
|
||||
var out []webauthn.Credential
|
||||
if w.u.MFA != nil {
|
||||
for _, k := range w.u.MFA.Keys {
|
||||
out = append(out, k.Credential)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// keysAvailable reports whether passkeys can work on this
|
||||
// address: WebAuthn needs a domain name (not an IP address) and a
|
||||
// certificate the browser trusts, or localhost.
|
||||
func (a *App) keysAvailable(r *http.Request) bool {
|
||||
host := hostOnly(r.Host)
|
||||
if host == "localhost" {
|
||||
return true
|
||||
}
|
||||
return host != "" && net.ParseIP(host) == nil && a.store.Get().Web.TLS.Mode != "selfsigned"
|
||||
}
|
||||
|
||||
func (a *App) webAuthn(r *http.Request) (*webauthn.WebAuthn, error) {
|
||||
if !a.keysAvailable(r) {
|
||||
return nil, badRequest("passkeys need a domain name with a trusted certificate")
|
||||
}
|
||||
scheme := "https"
|
||||
if r.TLS == nil && hostOnly(r.Host) == "localhost" {
|
||||
scheme = "http"
|
||||
}
|
||||
return webauthn.New(&webauthn.Config{
|
||||
RPID: hostOnly(r.Host), RPDisplayName: appName, RPOrigins: []string{scheme + "://" + r.Host},
|
||||
})
|
||||
}
|
||||
|
||||
// --- pending ceremonies, kept in memory ---
|
||||
|
||||
// ticket is a sign-in waiting for its second step.
|
||||
type ticket struct {
|
||||
userID string
|
||||
ip string
|
||||
expires time.Time
|
||||
fails int
|
||||
key *webauthn.SessionData // a passkey challenge, once asked for
|
||||
}
|
||||
|
||||
type ceremony struct {
|
||||
userID string // "" for a passkey sign-in
|
||||
data *webauthn.SessionData
|
||||
expires time.Time
|
||||
}
|
||||
|
||||
type mfaState struct {
|
||||
tickets map[string]*ticket
|
||||
logins map[string]*ceremony // passkey sign-ins by id
|
||||
enrolls map[string]*ceremony // key registrations by user ID
|
||||
totpSetup map[string]string // TOTP secrets waiting for their first code, by user ID
|
||||
totpLast map[string]uint64 // last time step used per user, so a code works once
|
||||
}
|
||||
|
||||
func newMFAState() mfaState {
|
||||
return mfaState{tickets: map[string]*ticket{}, logins: map[string]*ceremony{}, enrolls: map[string]*ceremony{},
|
||||
totpSetup: map[string]string{}, totpLast: map[string]uint64{}}
|
||||
}
|
||||
|
||||
var errBadTicket = errors.New("the sign-in expired; enter your password again")
|
||||
|
||||
// failLocked counts a failed attempt from ip toward the lockout and returns
|
||||
// a function that takes it back, for an attempt counted before it was
|
||||
// checked. a.mu must be held, also when calling undo.
|
||||
func (a *Auth) failLocked(ip string) (undo func()) {
|
||||
key := lockKey(ip)
|
||||
f := a.fails[key]
|
||||
if f == nil {
|
||||
f = &failState{}
|
||||
a.fails[key] = f
|
||||
}
|
||||
f.count++
|
||||
locked := f.count >= maxFailures
|
||||
if locked {
|
||||
f.count = 0
|
||||
f.until = time.Now().Add(lockoutTime)
|
||||
}
|
||||
return func() {
|
||||
switch {
|
||||
case locked:
|
||||
f.count, f.until = maxFailures-1, time.Time{}
|
||||
case f.count > 0:
|
||||
f.count--
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Auth) lockedLocked(ip string) bool {
|
||||
f := a.fails[lockKey(ip)]
|
||||
return f != nil && time.Now().Before(f.until)
|
||||
}
|
||||
|
||||
// newTicket starts the second step for a user whose password was right.
|
||||
// a.mu must be held.
|
||||
func (a *Auth) newTicketLocked(u *User, ip string) string {
|
||||
id := randomString(32)
|
||||
a.mfa.tickets[id] = &ticket{userID: u.ID, ip: ip, expires: time.Now().Add(ticketTTL)}
|
||||
return id
|
||||
}
|
||||
|
||||
// ticketUser returns the live ticket and its user.
|
||||
func (a *Auth) ticketUser(id, ip string) (*ticket, *User, error) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if a.lockedLocked(ip) {
|
||||
return nil, nil, errLocked
|
||||
}
|
||||
t := a.mfa.tickets[id]
|
||||
if t == nil || time.Now().After(t.expires) {
|
||||
delete(a.mfa.tickets, id)
|
||||
return nil, nil, errBadTicket
|
||||
}
|
||||
_, u := a.store.Get().userByID(t.userID)
|
||||
if u == nil {
|
||||
delete(a.mfa.tickets, id)
|
||||
return nil, nil, errBadTicket
|
||||
}
|
||||
return t, u, nil
|
||||
}
|
||||
|
||||
// ticketFailed counts a wrong code; five end the ticket.
|
||||
func (a *Auth) ticketFailed(id, ip string) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
a.failLocked(ip)
|
||||
if t := a.mfa.tickets[id]; t != nil {
|
||||
t.fails++
|
||||
if t.fails >= maxFailures {
|
||||
delete(a.mfa.tickets, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// finishSignIn turns a passed second step into a session.
|
||||
func (a *Auth) finishSignIn(u *User, ip string) string {
|
||||
cfg := a.store.Get()
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
delete(a.fails, lockKey(ip))
|
||||
a.logins[u.ID] = tokenUse{At: time.Now(), IP: ip}
|
||||
return a.newSessionLocked(cfg, u, sessionInfo{Started: time.Now(), IP: ip})
|
||||
}
|
||||
|
||||
// --- sign-in endpoints (public) ---
|
||||
|
||||
func (a *App) signedIn(w http.ResponseWriter, r *http.Request, u *User, how string) {
|
||||
ip := remoteIP(r)
|
||||
a.setSessionCookie(w, r, a.auth.finishSignIn(u, ip))
|
||||
slog.Info("login", "audit", true, "actor", u.Username, "remote", ip, "method", how)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (a *App) signInFailed(w http.ResponseWriter, err error) {
|
||||
code := http.StatusUnauthorized
|
||||
if errors.Is(err, errLocked) {
|
||||
code = http.StatusTooManyRequests
|
||||
}
|
||||
writeJSON(w, code, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
// signInOptions tells the sign-in page whether to offer a passkey.
|
||||
func (a *App) signInOptions(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"passkeys": a.keysAvailable(r)})
|
||||
}
|
||||
|
||||
func (a *App) loginTOTP(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct{ Ticket, Code string }
|
||||
if err := readJSON(r, &in); err != nil {
|
||||
writeErr(w, err)
|
||||
return
|
||||
}
|
||||
ip := remoteIP(r)
|
||||
_, u, err := a.auth.ticketUser(in.Ticket, ip)
|
||||
if err != nil {
|
||||
a.signInFailed(w, err)
|
||||
return
|
||||
}
|
||||
if u.MFA == nil || u.MFA.TOTPSecret == "" || !a.auth.useTOTP(u.ID, u.MFA.TOTPSecret, in.Code) {
|
||||
a.auth.ticketFailed(in.Ticket, ip)
|
||||
slog.Warn("login failed", "user", u.Username, "remote", ip, "reason", "wrong authenticator code")
|
||||
a.signInFailed(w, errors.New("wrong code"))
|
||||
return
|
||||
}
|
||||
a.auth.dropTicket(in.Ticket)
|
||||
a.signedIn(w, r, u, "totp")
|
||||
}
|
||||
|
||||
// useTOTP checks a code and makes sure it is not used twice.
|
||||
func (a *Auth) useTOTP(userID, secret, code string) bool {
|
||||
step, ok := totpMatch(secret, code, time.Now())
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if last, seen := a.mfa.totpLast[userID]; seen && step <= last {
|
||||
return false
|
||||
}
|
||||
a.mfa.totpLast[userID] = step
|
||||
return true
|
||||
}
|
||||
|
||||
// ticketUserID returns the ticket's user without checking the lockout.
|
||||
func (a *Auth) ticketUserID(id string) (string, *User) {
|
||||
a.mu.Lock()
|
||||
t := a.mfa.tickets[id]
|
||||
a.mu.Unlock()
|
||||
if t == nil {
|
||||
return "", nil
|
||||
}
|
||||
_, u := a.store.Get().userByID(t.userID)
|
||||
return t.userID, u
|
||||
}
|
||||
|
||||
// mfaMethods lists what the second step can use: "key", "totp", "recovery".
|
||||
func mfaMethods(u *User) []string {
|
||||
out := []string{}
|
||||
if u == nil || u.MFA == nil {
|
||||
return out
|
||||
}
|
||||
if len(u.MFA.Keys) > 0 {
|
||||
out = append(out, "key")
|
||||
}
|
||||
if u.MFA.TOTPSecret != "" {
|
||||
out = append(out, "totp")
|
||||
}
|
||||
if len(u.MFA.RecoveryCodes) > 0 {
|
||||
out = append(out, "recovery")
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (a *Auth) dropTicket(id string) {
|
||||
a.mu.Lock()
|
||||
delete(a.mfa.tickets, id)
|
||||
a.mu.Unlock()
|
||||
}
|
||||
|
||||
func (a *App) loginRecovery(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct{ Ticket, Code string }
|
||||
if err := readJSON(r, &in); err != nil {
|
||||
writeErr(w, err)
|
||||
return
|
||||
}
|
||||
ip := remoteIP(r)
|
||||
_, u, err := a.auth.ticketUser(in.Ticket, ip)
|
||||
if err != nil {
|
||||
a.signInFailed(w, err)
|
||||
return
|
||||
}
|
||||
h := hashRecovery(in.Code)
|
||||
var left int
|
||||
used := false
|
||||
_ = a.store.Update(func(c *Config) error {
|
||||
_, cu := c.userByID(u.ID)
|
||||
if cu == nil || cu.MFA == nil {
|
||||
return nil
|
||||
}
|
||||
for i, x := range cu.MFA.RecoveryCodes {
|
||||
if subtle.ConstantTimeCompare([]byte(x), []byte(h)) == 1 {
|
||||
cu.MFA.RecoveryCodes = slices.Delete(cu.MFA.RecoveryCodes, i, i+1)
|
||||
used = true
|
||||
break
|
||||
}
|
||||
}
|
||||
left = len(cu.MFA.RecoveryCodes)
|
||||
return nil
|
||||
})
|
||||
if !used {
|
||||
a.auth.ticketFailed(in.Ticket, ip)
|
||||
slog.Warn("login failed", "user", u.Username, "remote", ip, "reason", "wrong recovery code")
|
||||
a.signInFailed(w, errors.New("wrong or used recovery code"))
|
||||
return
|
||||
}
|
||||
a.auth.dropTicket(in.Ticket)
|
||||
slog.Info("recovery code used", "audit", true, "actor", u.Username, "remote", ip, "left", left)
|
||||
a.signedIn(w, r, u, "recovery code")
|
||||
}
|
||||
|
||||
// loginKeyBegin asks for one of the user's passkeys, as the second step.
|
||||
func (a *App) loginKeyBegin(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct{ Ticket string }
|
||||
if err := readJSON(r, &in); err != nil {
|
||||
writeErr(w, err)
|
||||
return
|
||||
}
|
||||
t, u, err := a.auth.ticketUser(in.Ticket, remoteIP(r))
|
||||
if err != nil {
|
||||
a.signInFailed(w, err)
|
||||
return
|
||||
}
|
||||
wa, err := a.webAuthn(r)
|
||||
if err != nil {
|
||||
writeErr(w, err)
|
||||
return
|
||||
}
|
||||
if u.MFA == nil || len(u.MFA.Keys) == 0 {
|
||||
writeErr(w, badRequest("no passkey is set up"))
|
||||
return
|
||||
}
|
||||
opts, data, err := wa.BeginLogin(waUser{u}, webauthn.WithUserVerification(protocol.VerificationDiscouraged))
|
||||
if err != nil {
|
||||
writeErr(w, err)
|
||||
return
|
||||
}
|
||||
a.auth.mu.Lock()
|
||||
t.key = data
|
||||
a.auth.mu.Unlock()
|
||||
writeJSON(w, http.StatusOK, opts)
|
||||
}
|
||||
|
||||
// loginKeyFinish checks the key's answer. The ticket is in the query, the
|
||||
// body is the browser's credential.
|
||||
func (a *App) loginKeyFinish(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.URL.Query().Get("ticket")
|
||||
ip := remoteIP(r)
|
||||
t, u, err := a.auth.ticketUser(id, ip)
|
||||
if err != nil {
|
||||
a.signInFailed(w, err)
|
||||
return
|
||||
}
|
||||
wa, err := a.webAuthn(r)
|
||||
if err != nil {
|
||||
writeErr(w, err)
|
||||
return
|
||||
}
|
||||
a.auth.mu.Lock()
|
||||
data := t.key
|
||||
t.key = nil
|
||||
a.auth.mu.Unlock()
|
||||
if data == nil {
|
||||
writeErr(w, badRequest("ask for the key first"))
|
||||
return
|
||||
}
|
||||
cred, err := wa.FinishLogin(waUser{u}, *data, r)
|
||||
if err != nil {
|
||||
a.auth.ticketFailed(id, ip)
|
||||
slog.Warn("login failed", "user", u.Username, "remote", ip, "reason", "passkey: "+err.Error())
|
||||
a.signInFailed(w, errors.New("the passkey was not accepted"))
|
||||
return
|
||||
}
|
||||
a.keyUsed(u.ID, cred)
|
||||
a.auth.dropTicket(id)
|
||||
a.signedIn(w, r, u, "passkey")
|
||||
}
|
||||
|
||||
// keyUsed stores the key's new signature counter and when it was used.
|
||||
func (a *App) keyUsed(userID string, cred *webauthn.Credential) {
|
||||
now := time.Now().UTC()
|
||||
_ = a.store.Update(func(c *Config) error {
|
||||
if _, u := c.userByID(userID); u != nil && u.MFA != nil {
|
||||
for i := range u.MFA.Keys {
|
||||
if k := &u.MFA.Keys[i]; bytes.Equal(k.Credential.ID, cred.ID) {
|
||||
k.Credential.Authenticator = cred.Authenticator
|
||||
k.Credential.Flags = cred.Flags
|
||||
k.LastUsed = &now
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// loginPasskeyBegin starts a sign-in with a passkey alone.
|
||||
func (a *App) loginPasskeyBegin(w http.ResponseWriter, r *http.Request) {
|
||||
wa, err := a.webAuthn(r)
|
||||
if err != nil {
|
||||
writeErr(w, err)
|
||||
return
|
||||
}
|
||||
opts, data, err := wa.BeginDiscoverableLogin(webauthn.WithUserVerification(protocol.VerificationRequired))
|
||||
if err != nil {
|
||||
writeErr(w, err)
|
||||
return
|
||||
}
|
||||
id := randomString(24)
|
||||
a.auth.mu.Lock()
|
||||
a.auth.mfa.logins[id] = &ceremony{data: data, expires: time.Now().Add(ticketTTL)}
|
||||
a.auth.mu.Unlock()
|
||||
writeJSON(w, http.StatusOK, map[string]any{"id": id, "options": opts})
|
||||
}
|
||||
|
||||
func (a *App) loginPasskeyFinish(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.URL.Query().Get("id")
|
||||
ip := remoteIP(r)
|
||||
a.auth.mu.Lock()
|
||||
cer := a.auth.mfa.logins[id]
|
||||
delete(a.auth.mfa.logins, id)
|
||||
locked := a.auth.lockedLocked(ip)
|
||||
a.auth.mu.Unlock()
|
||||
if locked {
|
||||
a.signInFailed(w, errLocked)
|
||||
return
|
||||
}
|
||||
if cer == nil || time.Now().After(cer.expires) {
|
||||
a.signInFailed(w, errors.New("the sign-in expired; try again"))
|
||||
return
|
||||
}
|
||||
wa, err := a.webAuthn(r)
|
||||
if err != nil {
|
||||
writeErr(w, err)
|
||||
return
|
||||
}
|
||||
cfg := a.store.Get()
|
||||
var found *User
|
||||
cred, err := wa.FinishDiscoverableLogin(func(rawID, handle []byte) (webauthn.User, error) {
|
||||
for i := range cfg.Users {
|
||||
u := &cfg.Users[i]
|
||||
if u.MFA != nil && len(u.MFA.Handle) > 0 && bytes.Equal(u.MFA.Handle, handle) {
|
||||
for _, k := range u.MFA.Keys {
|
||||
if bytes.Equal(k.Credential.ID, rawID) {
|
||||
found = u
|
||||
return waUser{u}, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil, errors.New("unknown passkey")
|
||||
}, *cer.data, r)
|
||||
if err != nil || found == nil {
|
||||
a.auth.mu.Lock()
|
||||
a.auth.failLocked(ip)
|
||||
a.auth.mu.Unlock()
|
||||
slog.Warn("login failed", "remote", ip, "reason", "passkey not accepted")
|
||||
a.signInFailed(w, errors.New("this passkey is not known here"))
|
||||
return
|
||||
}
|
||||
a.keyUsed(found.ID, cred)
|
||||
a.signedIn(w, r, found, "passkey")
|
||||
}
|
||||
|
||||
// --- managing your own two-step sign-in (signed-in users) ---
|
||||
|
||||
type keyView struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Created time.Time `json:"created"`
|
||||
LastUsed *time.Time `json:"lastUsed"`
|
||||
}
|
||||
|
||||
func (a *App) mfaStatus(w http.ResponseWriter, r *http.Request) {
|
||||
cfg := a.store.Get()
|
||||
_, u := cfg.userByID(who(r).UserID)
|
||||
if u == nil {
|
||||
writeErr(w, badRequest("no such user"))
|
||||
return
|
||||
}
|
||||
out := map[string]any{"totp": false, "totpAdded": nil, "keys": []keyView{}, "recoveryLeft": 0,
|
||||
"keysAvailable": a.keysAvailable(r), "required": cfg.SignIn.RequireMFA}
|
||||
if m := u.MFA; m != nil {
|
||||
keys := []keyView{}
|
||||
for _, k := range m.Keys {
|
||||
keys = append(keys, keyView{k.ID, k.Name, k.Created, k.LastUsed})
|
||||
}
|
||||
out["totp"], out["totpAdded"], out["keys"], out["recoveryLeft"] = m.TOTPSecret != "", m.TOTPAdded, keys, len(m.RecoveryCodes)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// addFirstCodes gives a user recovery codes with their first method. It
|
||||
// returns the codes to show, or nil when the user already has codes. It runs
|
||||
// inside a store update.
|
||||
func addFirstCodes(u *User) []string {
|
||||
if len(u.MFA.RecoveryCodes) > 0 {
|
||||
return nil
|
||||
}
|
||||
codes, hashes := newRecoveryCodes()
|
||||
u.MFA.RecoveryCodes = hashes
|
||||
return codes
|
||||
}
|
||||
|
||||
func (a *App) totpSetup(w http.ResponseWriter, r *http.Request) {
|
||||
p := who(r)
|
||||
secret := newTOTPSecret()
|
||||
a.auth.mu.Lock()
|
||||
a.auth.mfa.totpSetup[p.UserID] = secret
|
||||
a.auth.mu.Unlock()
|
||||
_, u := a.store.Get().userByID(p.UserID)
|
||||
if u == nil {
|
||||
writeErr(w, badRequest("no such user"))
|
||||
return
|
||||
}
|
||||
uri := totpURI(secret, u.Username)
|
||||
qr, _ := qrDataURL(uri)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"secret": secret, "uri": uri, "qr": qr})
|
||||
}
|
||||
|
||||
func (a *App) totpConfirm(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct{ Code string }
|
||||
if err := readJSON(r, &in); err != nil {
|
||||
writeErr(w, err)
|
||||
return
|
||||
}
|
||||
p := who(r)
|
||||
a.auth.mu.Lock()
|
||||
secret := a.auth.mfa.totpSetup[p.UserID]
|
||||
a.auth.mu.Unlock()
|
||||
if secret == "" {
|
||||
writeErr(w, badRequest("start the setup again"))
|
||||
return
|
||||
}
|
||||
if !a.auth.useTOTP(p.UserID, secret, in.Code) {
|
||||
writeErr(w, badRequest("wrong code; check the time on your phone and try the next one"))
|
||||
return
|
||||
}
|
||||
var codes []string
|
||||
now := time.Now().UTC()
|
||||
if err := a.store.Update(func(c *Config) error {
|
||||
_, u := c.userByID(p.UserID)
|
||||
if u == nil {
|
||||
return badRequest("no such user")
|
||||
}
|
||||
if u.MFA == nil {
|
||||
u.MFA = &UserMFA{}
|
||||
}
|
||||
u.MFA.TOTPSecret, u.MFA.TOTPAdded = secret, &now
|
||||
codes = addFirstCodes(u)
|
||||
return nil
|
||||
}); err != nil {
|
||||
writeErr(w, err)
|
||||
return
|
||||
}
|
||||
a.auth.mu.Lock()
|
||||
delete(a.auth.mfa.totpSetup, p.UserID)
|
||||
a.auth.mu.Unlock()
|
||||
a.audit(r, "authenticator app added")
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "recoveryCodes": codes})
|
||||
}
|
||||
|
||||
// lastMethodCheck refuses to remove the last method while two-step sign-in
|
||||
// is required.
|
||||
func lastMethodCheck(c *Config, u *User) error {
|
||||
if c.SignIn.RequireMFA && !u.hasMFA() {
|
||||
return badRequest("two-step sign-in is required here; add another method first")
|
||||
}
|
||||
if !u.hasMFA() && u.MFA != nil {
|
||||
u.MFA.RecoveryCodes = nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) totpRemove(w http.ResponseWriter, r *http.Request) {
|
||||
p := who(r)
|
||||
if err := a.store.Update(func(c *Config) error {
|
||||
_, u := c.userByID(p.UserID)
|
||||
if u == nil || u.MFA == nil || u.MFA.TOTPSecret == "" {
|
||||
return badRequest("no authenticator app is set up")
|
||||
}
|
||||
u.MFA.TOTPSecret, u.MFA.TOTPAdded = "", nil
|
||||
return lastMethodCheck(c, u)
|
||||
}); err != nil {
|
||||
writeErr(w, err)
|
||||
return
|
||||
}
|
||||
a.audit(r, "authenticator app removed")
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// keyBegin starts adding a passkey.
|
||||
func (a *App) keyBegin(w http.ResponseWriter, r *http.Request) {
|
||||
wa, err := a.webAuthn(r)
|
||||
if err != nil {
|
||||
writeErr(w, err)
|
||||
return
|
||||
}
|
||||
p := who(r)
|
||||
// The user handle is made once and never changes.
|
||||
if err := a.store.Update(func(c *Config) error {
|
||||
_, u := c.userByID(p.UserID)
|
||||
if u == nil {
|
||||
return badRequest("no such user")
|
||||
}
|
||||
if u.MFA == nil {
|
||||
u.MFA = &UserMFA{}
|
||||
}
|
||||
if len(u.MFA.Handle) == 0 {
|
||||
u.MFA.Handle = make([]byte, 32)
|
||||
if _, err := rand.Read(u.MFA.Handle); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
writeErr(w, err)
|
||||
return
|
||||
}
|
||||
_, u := a.store.Get().userByID(p.UserID)
|
||||
var exclude []protocol.CredentialDescriptor
|
||||
for _, k := range u.MFA.Keys {
|
||||
exclude = append(exclude, k.Credential.Descriptor())
|
||||
}
|
||||
sel := protocol.AuthenticatorSelection{ResidentKey: protocol.ResidentKeyRequirementRequired, UserVerification: protocol.VerificationRequired}
|
||||
opts, data, err := wa.BeginRegistration(waUser{u}, webauthn.WithAuthenticatorSelection(sel), webauthn.WithExclusions(exclude))
|
||||
if err != nil {
|
||||
writeErr(w, err)
|
||||
return
|
||||
}
|
||||
a.auth.mu.Lock()
|
||||
a.auth.mfa.enrolls[p.UserID] = &ceremony{userID: p.UserID, data: data, expires: time.Now().Add(ticketTTL)}
|
||||
a.auth.mu.Unlock()
|
||||
writeJSON(w, http.StatusOK, opts)
|
||||
}
|
||||
|
||||
// keyFinish stores the new key. The name is in the query, the body is the
|
||||
// browser's credential.
|
||||
func (a *App) keyFinish(w http.ResponseWriter, r *http.Request) {
|
||||
p := who(r)
|
||||
name := strings.TrimSpace(r.URL.Query().Get("name"))
|
||||
a.auth.mu.Lock()
|
||||
cer := a.auth.mfa.enrolls[p.UserID]
|
||||
delete(a.auth.mfa.enrolls, p.UserID)
|
||||
a.auth.mu.Unlock()
|
||||
if cer == nil || time.Now().After(cer.expires) {
|
||||
writeErr(w, badRequest("adding the key took too long; try again"))
|
||||
return
|
||||
}
|
||||
wa, err := a.webAuthn(r)
|
||||
if err != nil {
|
||||
writeErr(w, err)
|
||||
return
|
||||
}
|
||||
_, u := a.store.Get().userByID(p.UserID)
|
||||
if u == nil {
|
||||
writeErr(w, badRequest("no such user"))
|
||||
return
|
||||
}
|
||||
cred, err := wa.FinishRegistration(waUser{u}, *cer.data, r)
|
||||
if err != nil {
|
||||
writeErr(w, badRequest("the key was not accepted: %v", err))
|
||||
return
|
||||
}
|
||||
if name == "" {
|
||||
name = "Passkey"
|
||||
}
|
||||
if len(name) > maxKeyName {
|
||||
name = name[:maxKeyName]
|
||||
}
|
||||
var codes []string
|
||||
key := MFAKey{ID: newID(), Name: name, Passkey: true, Created: time.Now().UTC(), Credential: *cred}
|
||||
if err := a.store.Update(func(c *Config) error {
|
||||
_, u := c.userByID(p.UserID)
|
||||
if u == nil || u.MFA == nil {
|
||||
return badRequest("no such user")
|
||||
}
|
||||
u.MFA.Keys = append(u.MFA.Keys, key)
|
||||
codes = addFirstCodes(u)
|
||||
return nil
|
||||
}); err != nil {
|
||||
writeErr(w, err)
|
||||
return
|
||||
}
|
||||
a.audit(r, "passkey added", "key", name)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "recoveryCodes": codes})
|
||||
}
|
||||
|
||||
func (a *App) keyRename(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct{ Name string }
|
||||
if err := readJSON(r, &in); err != nil {
|
||||
writeErr(w, err)
|
||||
return
|
||||
}
|
||||
in.Name = strings.TrimSpace(in.Name)
|
||||
if in.Name == "" || len(in.Name) > maxKeyName {
|
||||
writeErr(w, badRequest("name must be 1–%d characters", maxKeyName))
|
||||
return
|
||||
}
|
||||
id := r.PathValue("id")
|
||||
if err := a.store.Update(func(c *Config) error {
|
||||
_, u := c.userByID(who(r).UserID)
|
||||
if u == nil || u.MFA == nil {
|
||||
return badRequest("no such key")
|
||||
}
|
||||
for i := range u.MFA.Keys {
|
||||
if u.MFA.Keys[i].ID == id {
|
||||
u.MFA.Keys[i].Name = in.Name
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return badRequest("no such key")
|
||||
}); err != nil {
|
||||
writeErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (a *App) keyRemove(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
var name string
|
||||
if err := a.store.Update(func(c *Config) error {
|
||||
_, u := c.userByID(who(r).UserID)
|
||||
if u == nil || u.MFA == nil {
|
||||
return badRequest("no such key")
|
||||
}
|
||||
i := slices.IndexFunc(u.MFA.Keys, func(k MFAKey) bool { return k.ID == id })
|
||||
if i < 0 {
|
||||
return badRequest("no such key")
|
||||
}
|
||||
name = u.MFA.Keys[i].Name
|
||||
u.MFA.Keys = slices.Delete(u.MFA.Keys, i, i+1)
|
||||
return lastMethodCheck(c, u)
|
||||
}); err != nil {
|
||||
writeErr(w, err)
|
||||
return
|
||||
}
|
||||
a.audit(r, "passkey removed", "key", name)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (a *App) newRecoveryCodesHandler(w http.ResponseWriter, r *http.Request) {
|
||||
var codes []string
|
||||
if err := a.store.Update(func(c *Config) error {
|
||||
_, u := c.userByID(who(r).UserID)
|
||||
if u == nil || !u.hasMFA() {
|
||||
return badRequest("turn on two-step sign-in first")
|
||||
}
|
||||
var hashes []string
|
||||
codes, hashes = newRecoveryCodes()
|
||||
u.MFA.RecoveryCodes = hashes
|
||||
return nil
|
||||
}); err != nil {
|
||||
writeErr(w, err)
|
||||
return
|
||||
}
|
||||
a.audit(r, "recovery codes replaced")
|
||||
writeJSON(w, http.StatusOK, map[string]any{"recoveryCodes": codes})
|
||||
}
|
||||
|
||||
// resetMFA removes another user's two-step sign-in, for a lost phone or key.
|
||||
// Their user handle stays, so passkeys they still hold are just unknown.
|
||||
func (a *App) resetMFA(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if id == who(r).UserID {
|
||||
writeErr(w, badRequest("manage your own two-step sign-in under My account"))
|
||||
return
|
||||
}
|
||||
var name string
|
||||
if err := a.store.Update(func(c *Config) error {
|
||||
_, u := c.userByID(id)
|
||||
if u == nil {
|
||||
return badRequest("no such user")
|
||||
}
|
||||
name = u.Username
|
||||
if u.MFA != nil {
|
||||
u.MFA = &UserMFA{Handle: u.MFA.Handle}
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
writeErr(w, err)
|
||||
return
|
||||
}
|
||||
a.audit(r, "two-step sign-in reset", "user", name)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// mfaSummary is what user lists show.
|
||||
func mfaSummary(u *User) map[string]any {
|
||||
out := map[string]any{"totp": false, "passkeys": 0}
|
||||
if m := u.MFA; m != nil {
|
||||
out["totp"], out["passkeys"] = m.TOTPSecret != "", len(m.Keys)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
Before Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 296 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 195 KiB |
|
Before Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 147 KiB |
|
Before Width: | Height: | Size: 81 KiB |
@@ -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
|
||||
}
|
||||
|
||||
@@ -13,14 +13,15 @@ import (
|
||||
// everyone changes their own password with the current one.
|
||||
|
||||
type userView struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Note string `json:"note"`
|
||||
MustChangePassword bool `json:"mustChangePassword"`
|
||||
Created time.Time `json:"created"`
|
||||
LastLogin *tokenUse `json:"lastLogin"` // since the service started
|
||||
Tokens int `json:"tokens"`
|
||||
You bool `json:"you"`
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Note string `json:"note"`
|
||||
MustChangePassword bool `json:"mustChangePassword"`
|
||||
Created time.Time `json:"created"`
|
||||
LastLogin *tokenUse `json:"lastLogin"` // since the service started
|
||||
Tokens int `json:"tokens"`
|
||||
You bool `json:"you"`
|
||||
MFA map[string]any `json:"mfa"` // {"totp": bool, "passkeys": n}
|
||||
}
|
||||
|
||||
func (a *App) userView(c *Config, u *User, me string) userView {
|
||||
@@ -30,7 +31,7 @@ func (a *App) userView(c *Config, u *User, me string) userView {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return userView{u.ID, u.Username, u.Note, u.MustChangePassword, u.Created, a.auth.LastLogin(u.ID), n, u.ID == me}
|
||||
return userView{u.ID, u.Username, u.Note, u.MustChangePassword, u.Created, a.auth.LastLogin(u.ID), n, u.ID == me, mfaSummary(u)}
|
||||
}
|
||||
|
||||
// username names a user for lists, or "" if the ID is unknown.
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"embed"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The web UI and its icons are built into the binary. The UI talks only to
|
||||
@@ -11,11 +16,59 @@ import (
|
||||
//go:embed index.html setup.html app.js setup.js app.css favicon.svg apple-touch-icon.png ShipporiMinchoB1-ExtraBold.woff2
|
||||
var webFiles embed.FS
|
||||
|
||||
func webHandler() http.Handler {
|
||||
// The pages load app.js, setup.js and app.css with ?v=<hash of the file>, so
|
||||
// a new binary makes browsers fetch the new files, and a fingerprinted file
|
||||
// can be cached for good.
|
||||
var (
|
||||
assetHash = map[string]string{}
|
||||
indexPage []byte
|
||||
)
|
||||
|
||||
func init() {
|
||||
for _, name := range []string{"app.js", "setup.js", "app.css"} {
|
||||
b, err := webFiles.ReadFile(name)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
sum := sha256.Sum256(b)
|
||||
assetHash[name] = hex.EncodeToString(sum[:5])
|
||||
}
|
||||
b, err := webFiles.ReadFile("index.html")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
indexPage = fingerprint(b, "/")
|
||||
}
|
||||
|
||||
// fingerprint adds ?v=<hash> to the page's references to base + file.
|
||||
func fingerprint(page []byte, base string) []byte {
|
||||
s := string(page)
|
||||
for name, h := range assetHash {
|
||||
s = strings.ReplaceAll(s, `"`+base+name+`"`, `"`+base+name+"?v="+h+`"`)
|
||||
}
|
||||
return []byte(s)
|
||||
}
|
||||
|
||||
func (a *App) webHandler() http.Handler {
|
||||
files := http.FileServerFS(webFiles)
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if d := a.store.Get().Decoy; d.Enabled {
|
||||
serveDecoy(w, r, d.Page)
|
||||
return
|
||||
}
|
||||
switch r.URL.Path {
|
||||
case "/", "/app.js", "/setup.js", "/app.css", "/favicon.svg", "/apple-touch-icon.png":
|
||||
case "/":
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
_, _ = w.Write(indexPage)
|
||||
case "/app.js", "/setup.js", "/app.css":
|
||||
if v := r.URL.Query().Get("v"); v != "" && v == assetHash[r.URL.Path[1:]] {
|
||||
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||
} else {
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
}
|
||||
files.ServeHTTP(w, r)
|
||||
case "/favicon.svg", "/apple-touch-icon.png":
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
files.ServeHTTP(w, r)
|
||||
case "/ShipporiMinchoB1-ExtraBold.woff2":
|
||||
@@ -30,15 +83,50 @@ func webHandler() http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
// setupAllowed reports whether the setup page and its files may be served for
|
||||
// this token. With the decoy on, only a live setup link gets past the decoy.
|
||||
func (a *App) setupAllowed(token string) bool {
|
||||
cfg := a.store.Get()
|
||||
if !cfg.Decoy.Enabled {
|
||||
return true
|
||||
}
|
||||
p := cfg.peerByToken(token)
|
||||
return p != nil && !p.Setup.expired(time.Now())
|
||||
}
|
||||
|
||||
// setupPage serves the page a setup link opens. The token stays in the URL;
|
||||
// setup.js reads it from there and talks to /api/v1/setup.
|
||||
func setupPage(w http.ResponseWriter, r *http.Request) {
|
||||
// setup.js reads it from there and talks to /api/v1/setup. The page loads its
|
||||
// files from under the link, so they work while the decoy hides the root.
|
||||
func (a *App) setupPage(w http.ResponseWriter, r *http.Request) {
|
||||
token := r.PathValue("token")
|
||||
if !a.setupAllowed(token) {
|
||||
serveDecoy(w, r, a.store.Get().Decoy.Page)
|
||||
return
|
||||
}
|
||||
b, err := webFiles.ReadFile("setup.html")
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
base := "/setup/" + url.PathEscape(token) + "/"
|
||||
page := strings.NewReplacer(`href="/`, `href="`+base, `src="/`, `src="`+base).Replace(string(b))
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_, _ = w.Write(b)
|
||||
_, _ = w.Write(fingerprint([]byte(page), base))
|
||||
}
|
||||
|
||||
func (a *App) setupAsset(w http.ResponseWriter, r *http.Request) {
|
||||
file := r.PathValue("file")
|
||||
switch file {
|
||||
case "setup.js", "app.css", "favicon.svg", "apple-touch-icon.png", "ShipporiMinchoB1-ExtraBold.woff2":
|
||||
default:
|
||||
a.webHandler().ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
if !a.setupAllowed(r.PathValue("token")) {
|
||||
serveDecoy(w, r, a.store.Get().Decoy.Page)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
http.ServeFileFS(w, r, webFiles, file)
|
||||
}
|
||||
|
||||