Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1fe9b4e619 | |||
| ebbc282073 | |||
| 8f13a37d92 | |||
| 990aa55a3d | |||
| 60426577a5 | |||
| 04a1d1ab85 | |||
| ac2ce23613 | |||
| fe71b0b6c2 | |||
| 606b3fe89f | |||
| c7340d011a | |||
| 9220ff54aa |
@@ -1,3 +1,7 @@
|
|||||||
|
<p align="center">
|
||||||
|
<img src="favicon.svg" width="120" height="120" alt="GHOSTWIRE logo: the Hannya mask">
|
||||||
|
</p>
|
||||||
|
|
||||||
# GHOSTWIRE
|
# GHOSTWIRE
|
||||||
|
|
||||||
**ゴーストワイヤー** · A self-hosted WireGuard server manager in a single Go
|
**ゴーストワイヤー** · A self-hosted WireGuard server manager in a single Go
|
||||||
@@ -8,6 +12,8 @@ remove), hands out client configs as a download or QR code, and records traffic
|
|||||||
and connection history per peer. There are no install scripts and no
|
and connection history per peer. There are no install scripts and no
|
||||||
dependencies on the server: the binary installs, updates and removes itself.
|
dependencies on the server: the binary installs, updates and removes itself.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **One file of state:** everything lives in `config.json`. The kernel is
|
- **One file of state:** everything lives in `config.json`. The kernel is
|
||||||
@@ -34,6 +40,19 @@ dependencies on the server: the binary installs, updates and removes itself.
|
|||||||
- **HTTPS built in:** Let's Encrypt, a self-signed certificate, your own
|
- **HTTPS built in:** Let's Encrypt, a self-signed certificate, your own
|
||||||
certificate files, or plain HTTP behind a reverse proxy.
|
certificate files, or plain HTTP behind a reverse proxy.
|
||||||
|
|
||||||
|
## 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
|
## Security
|
||||||
|
|
||||||
- **Client private keys are never stored.** A config is shown once, as a
|
- **Client private keys are never stored.** A config is shown once, as a
|
||||||
@@ -48,6 +67,14 @@ dependencies on the server: the binary installs, updates and removes itself.
|
|||||||
- **Sign-in:** one or more users, all admins. Passwords are stored as argon2id hashes.
|
- **Sign-in:** one or more users, all admins. Passwords are stored as argon2id hashes.
|
||||||
After 5 failed attempts, sign-in is locked for 15 minutes. Sessions use an
|
After 5 failed attempts, sign-in is locked for 15 minutes. Sessions use an
|
||||||
HttpOnly, SameSite=Strict cookie and last 12 hours by default.
|
HttpOnly, SameSite=Strict cookie and last 12 hours by default.
|
||||||
|
- **Two-step sign-in:** each user can add an authenticator app (TOTP), security
|
||||||
|
keys such as a YubiKey, and passkeys that sign in without a password, under
|
||||||
|
My account. Turning it on gives 10 one-time recovery codes. An admin can
|
||||||
|
require it for everyone (Settings → Sign-in) and reset it for a user who lost
|
||||||
|
their phone or key. Security keys and passkeys use WebAuthn and need the
|
||||||
|
server's domain name with a trusted certificate (Let's Encrypt, certificate
|
||||||
|
files, or a reverse proxy); on a self-signed certificate or an IP address,
|
||||||
|
only the authenticator app is offered. API tokens never need a second step.
|
||||||
- **API tokens** are stored only as hashes and can be read-only or full access.
|
- **API tokens** are stored only as hashes and can be read-only or full access.
|
||||||
- `config.json` holds the server private key and is readable only by the
|
- `config.json` holds the server private key and is readable only by the
|
||||||
service (0600).
|
service (0600).
|
||||||
@@ -212,8 +239,15 @@ Base path `/api/v1`. The web interface signs in with a session cookie; every
|
|||||||
user is an admin. Apps and scripts use `Authorization: Bearer <token>`; create
|
user is an admin. Apps and scripts use `Authorization: Bearer <token>`; create
|
||||||
the token under Settings → Pair iOS app. A token belongs to the user who made
|
the token under Settings → Pair iOS app. A token belongs to the user who made
|
||||||
it and is revoked when that user is deleted. A read-only token may only use
|
it and is revoked when that user is deleted. A read-only token may only use
|
||||||
GET. Full-access tokens can do everything the web interface does except the
|
GET. Full-access tokens can do everything the web interface does except backup
|
||||||
endpoints marked "signed in": users, passwords, API tokens, backup and restore.
|
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
|
`POST /users` and `POST /users/{id}/reset-password` take
|
||||||
`{"password": "…", "mustChangePassword": true}`; with `true` (the default) the
|
`{"password": "…", "mustChangePassword": true}`; with `true` (the default) the
|
||||||
@@ -222,7 +256,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)
|
POST /auth/login · /auth/logout GET /auth/me POST /auth/password (own password)
|
||||||
GET /users POST /users PATCH /users/{id} DELETE /users/{id}
|
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 {"passkey"} · /auth/mfa/keys/finish?name= · PATCH|DELETE /auth/mfa/keys/{id}
|
||||||
|
signed in: POST /auth/mfa/recovery-codes
|
||||||
GET /status GET /stats?range=24h|7d|30d|90d
|
GET /status GET /stats?range=24h|7d|30d|90d
|
||||||
GET /server PATCH /server POST /server/rotate-key GET /server/detect-ip
|
GET /server PATCH /server POST /server/rotate-key GET /server/detect-ip
|
||||||
GET /peers POST /peers (returns the config and QR once)
|
GET /peers POST /peers (returns the config and QR once)
|
||||||
@@ -233,7 +274,8 @@ GET /peers/{id}/latency (24 h, one point per 5 minutes)
|
|||||||
GET /peers/{id}/setup (not read-only) DELETE /peers/{id}/setup
|
GET /peers/{id}/setup (not read-only) DELETE /peers/{id}/setup
|
||||||
GET /settings PATCH /settings POST /restart
|
GET /settings PATCH /settings POST /restart
|
||||||
GET /logs?level=&limit=&audit=1 GET /logs/download
|
GET /logs?level=&limit=&audit=1 GET /logs/download
|
||||||
signed in: GET|POST /tokens · DELETE /tokens/{id} · GET /backup · POST /restore
|
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)
|
public: GET /setup/{token} · POST /setup/{token} {"pin"} (what a setup link opens)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -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"})
|
writeJSON(w, http.StatusForbidden, map[string]string{"error": "choose a new password first", "code": "password_change_required"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if p.MFASetupRequired && r.URL.Path != "/api/v1/auth/me" && r.URL.Path != "/api/v1/auth/password" &&
|
||||||
|
!strings.HasPrefix(r.URL.Path, "/api/v1/auth/mfa") {
|
||||||
|
writeJSON(w, http.StatusForbidden, map[string]string{"error": "set up two-step sign-in first", "code": "mfa_setup_required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
if p.Scope == "ro" && r.Method != http.MethodGet {
|
if p.Scope == "ro" && r.Method != http.MethodGet {
|
||||||
writeJSON(w, http.StatusForbidden, map[string]string{"error": "this token is read-only"})
|
writeJSON(w, http.StatusForbidden, map[string]string{"error": "this token is read-only"})
|
||||||
return
|
return
|
||||||
@@ -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
|
// applyResult saves-then-applies: the config is already stored, so a kernel
|
||||||
// error is reported but does not undo the change.
|
// error is reported but does not undo the change.
|
||||||
func (a *App) apply() string {
|
func (a *App) apply() string {
|
||||||
@@ -105,16 +121,38 @@ func (a *App) routes() http.Handler {
|
|||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
g := func(pattern string, h http.HandlerFunc) { mux.HandleFunc(pattern, a.guard(false, h)) }
|
g := func(pattern string, h http.HandlerFunc) { mux.HandleFunc(pattern, a.guard(false, h)) }
|
||||||
adm := func(pattern string, h http.HandlerFunc) { mux.HandleFunc(pattern, a.guard(true, h)) }
|
adm := func(pattern string, h http.HandlerFunc) { mux.HandleFunc(pattern, a.guard(true, h)) }
|
||||||
|
// full is for signed-in users and full-access tokens, even for reading.
|
||||||
|
full := func(pattern string, h http.HandlerFunc) { mux.HandleFunc(pattern, a.guard(false, fullAccess(h))) }
|
||||||
|
|
||||||
mux.HandleFunc("POST /api/v1/auth/login", a.login)
|
mux.HandleFunc("POST /api/v1/auth/login", a.login)
|
||||||
mux.HandleFunc("POST /api/v1/auth/logout", a.logout)
|
mux.HandleFunc("POST /api/v1/auth/logout", a.logout)
|
||||||
|
// The second step of signing in, and signing in with a passkey alone.
|
||||||
|
mux.HandleFunc("GET /api/v1/auth/options", a.signInOptions)
|
||||||
|
mux.HandleFunc("POST /api/v1/auth/login/totp", a.loginTOTP)
|
||||||
|
mux.HandleFunc("POST /api/v1/auth/login/recovery", a.loginRecovery)
|
||||||
|
mux.HandleFunc("POST /api/v1/auth/login/key/begin", a.loginKeyBegin)
|
||||||
|
mux.HandleFunc("POST /api/v1/auth/login/key/finish", a.loginKeyFinish)
|
||||||
|
mux.HandleFunc("POST /api/v1/auth/login/passkey/begin", a.loginPasskeyBegin)
|
||||||
|
mux.HandleFunc("POST /api/v1/auth/login/passkey/finish", a.loginPasskeyFinish)
|
||||||
|
// Your own two-step sign-in. Keys and passkeys need a browser, so these
|
||||||
|
// are for signed-in users only.
|
||||||
|
adm("GET /api/v1/auth/mfa", a.mfaStatus)
|
||||||
|
adm("POST /api/v1/auth/mfa/totp/setup", a.totpSetup)
|
||||||
|
adm("POST /api/v1/auth/mfa/totp/confirm", a.totpConfirm)
|
||||||
|
adm("DELETE /api/v1/auth/mfa/totp", a.totpRemove)
|
||||||
|
adm("POST /api/v1/auth/mfa/keys/begin", a.keyBegin)
|
||||||
|
adm("POST /api/v1/auth/mfa/keys/finish", a.keyFinish)
|
||||||
|
adm("PATCH /api/v1/auth/mfa/keys/{id}", a.keyRename)
|
||||||
|
adm("DELETE /api/v1/auth/mfa/keys/{id}", a.keyRemove)
|
||||||
|
adm("POST /api/v1/auth/mfa/recovery-codes", a.newRecoveryCodesHandler)
|
||||||
g("GET /api/v1/auth/me", a.me)
|
g("GET /api/v1/auth/me", a.me)
|
||||||
adm("POST /api/v1/auth/password", a.changePassword)
|
full("POST /api/v1/auth/password", a.changePassword)
|
||||||
adm("GET /api/v1/users", a.listUsers)
|
full("GET /api/v1/users", a.listUsers)
|
||||||
adm("POST /api/v1/users", a.createUser)
|
full("POST /api/v1/users", a.createUser)
|
||||||
adm("PATCH /api/v1/users/{id}", a.patchUser)
|
full("PATCH /api/v1/users/{id}", a.patchUser)
|
||||||
adm("POST /api/v1/users/{id}/reset-password", a.resetPassword)
|
full("POST /api/v1/users/{id}/reset-password", a.resetPassword)
|
||||||
adm("DELETE /api/v1/users/{id}", a.deleteUser)
|
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/status", a.status)
|
||||||
g("GET /api/v1/stats", a.allStats)
|
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("GET /api/v1/setup/{token}", a.setupInfo)
|
||||||
mux.HandleFunc("POST /api/v1/setup/{token}", a.setupRedeem)
|
mux.HandleFunc("POST /api/v1/setup/{token}", a.setupRedeem)
|
||||||
|
|
||||||
// Full-access tokens (the iOS app) may change app settings and read logs.
|
// Full-access tokens (the iOS app) may change app settings, read logs and
|
||||||
// Users, passwords, tokens and backups need a signed-in user.
|
// manage users and tokens. Backups need a signed-in user.
|
||||||
g("GET /api/v1/settings", a.getSettings)
|
g("GET /api/v1/settings", a.getSettings)
|
||||||
g("PATCH /api/v1/settings", a.patchSettings)
|
g("PATCH /api/v1/settings", a.patchSettings)
|
||||||
g("POST /api/v1/restart", a.restart)
|
g("POST /api/v1/restart", a.restart)
|
||||||
adm("GET /api/v1/tokens", a.listTokens)
|
full("GET /api/v1/tokens", a.listTokens)
|
||||||
adm("POST /api/v1/tokens", a.createToken)
|
full("POST /api/v1/tokens", a.createToken)
|
||||||
adm("DELETE /api/v1/tokens/{id}", a.deleteToken)
|
full("DELETE /api/v1/tokens/{id}", a.deleteToken)
|
||||||
g("GET /api/v1/logs", a.logs)
|
g("GET /api/v1/logs", a.logs)
|
||||||
g("GET /api/v1/logs/download", a.downloadLog)
|
g("GET /api/v1/logs/download", a.downloadLog)
|
||||||
adm("GET /api/v1/backup", a.backup)
|
adm("GET /api/v1/backup", a.backup)
|
||||||
@@ -159,8 +197,9 @@ func (a *App) routes() http.Handler {
|
|||||||
mux.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "no such endpoint"})
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": "no such endpoint"})
|
||||||
})
|
})
|
||||||
mux.HandleFunc("GET /setup/{token}", setupPage)
|
mux.HandleFunc("GET /setup/{token}", a.setupPage)
|
||||||
mux.Handle("/", webHandler())
|
mux.HandleFunc("GET /setup/{token}/{file}", a.setupAsset)
|
||||||
|
mux.Handle("/", a.webHandler())
|
||||||
|
|
||||||
csrf := http.NewCrossOriginProtection()
|
csrf := http.NewCrossOriginProtection()
|
||||||
return securityHeaders(csrf.Handler(mux))
|
return securityHeaders(csrf.Handler(mux))
|
||||||
@@ -189,7 +228,7 @@ func (a *App) login(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
ip := remoteIP(r)
|
ip := remoteIP(r)
|
||||||
id, err := a.auth.Login(in.Username, in.Password, ip)
|
id, ticket, err := a.auth.Login(in.Username, in.Password, ip)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Warn("login failed", "user", in.Username, "remote", ip, "reason", err.Error())
|
slog.Warn("login failed", "user", in.Username, "remote", ip, "reason", err.Error())
|
||||||
code := http.StatusUnauthorized
|
code := http.StatusUnauthorized
|
||||||
@@ -199,6 +238,12 @@ func (a *App) login(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, code, map[string]string{"error": err.Error()})
|
writeJSON(w, code, map[string]string{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if ticket != "" {
|
||||||
|
// The password was right; the second step makes the session.
|
||||||
|
_, u := a.auth.ticketUserID(ticket)
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"mfa": true, "ticket": ticket, "methods": mfaMethods(u)})
|
||||||
|
return
|
||||||
|
}
|
||||||
a.setSessionCookie(w, r, id)
|
a.setSessionCookie(w, r, id)
|
||||||
slog.Info("login", "audit", true, "actor", in.Username, "remote", ip)
|
slog.Info("login", "audit", true, "actor", in.Username, "remote", ip)
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||||
@@ -223,7 +268,10 @@ func (a *App) me(w http.ResponseWriter, r *http.Request) {
|
|||||||
p := who(r)
|
p := who(r)
|
||||||
out := map[string]any{
|
out := map[string]any{
|
||||||
"id": p.UserID, "name": p.Name, "isAdmin": p.IsAdmin, "scope": p.Scope,
|
"id": p.UserID, "name": p.Name, "isAdmin": p.IsAdmin, "scope": p.Scope,
|
||||||
"mustChangePassword": p.MustChangePassword, "version": version, "session": p.Session,
|
"mustChangePassword": p.MustChangePassword, "mfaSetupRequired": p.MFASetupRequired, "version": version, "session": p.Session,
|
||||||
|
}
|
||||||
|
if 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 {
|
if _, u := a.store.Get().userByID(p.UserID); u != nil {
|
||||||
out["username"], out["note"], out["created"] = u.Username, u.Note, u.Created
|
out["username"], out["note"], out["created"] = u.Username, u.Note, u.Created
|
||||||
@@ -963,6 +1011,8 @@ func (a *App) getSettings(w http.ResponseWriter, r *http.Request) {
|
|||||||
"web": cfg.Web,
|
"web": cfg.Web,
|
||||||
"log": cfg.Log,
|
"log": cfg.Log,
|
||||||
"stats": cfg.Stats,
|
"stats": cfg.Stats,
|
||||||
|
"decoy": cfg.Decoy,
|
||||||
|
"signin": cfg.SignIn,
|
||||||
"geo": a.geoStatus(),
|
"geo": a.geoStatus(),
|
||||||
"adminUsername": a.username(cfg, who(r).UserID), // kept for older iOS app versions
|
"adminUsername": a.username(cfg, who(r).UserID), // kept for older iOS app versions
|
||||||
"fingerprint": a.tls.Fingerprint(),
|
"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 {
|
if err := field(m, "stats", &c.Stats); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := field(m, "decoy", &c.Decoy); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := field(m, "signin", &c.SignIn); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
return field(m, "log", &c.Log)
|
return field(m, "log", &c.Log)
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
--brand: "Shippori Mincho B1", "Hiragino Mincho ProN", "Yu Mincho", serif;
|
--brand: "Shippori Mincho B1", "Hiragino Mincho ProN", "Yu Mincho", serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
@font-face { font-family: "Shippori Mincho B1"; font-weight: 800; font-display: swap; src: url("/ShipporiMinchoB1-ExtraBold.woff2") format("woff2"); }
|
@font-face { font-family: "Shippori Mincho B1"; font-weight: 800; font-display: swap; src: url("ShipporiMinchoB1-ExtraBold.woff2") format("woff2"); }
|
||||||
|
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
html, body { margin: 0; }
|
html, body { margin: 0; }
|
||||||
@@ -56,7 +56,12 @@ a.brand, a.brand:hover { color: #fff; text-decoration: none; }
|
|||||||
.side a.nav.on { background: #2a2b31; color: #fff; }
|
.side a.nav.on { background: #2a2b31; color: #fff; }
|
||||||
.side .count { margin-left: auto; font-size: 12px; color: #8d8e93; }
|
.side .count { margin-left: auto; font-size: 12px; color: #8d8e93; }
|
||||||
.side .foot { margin-top: auto; padding-top: 16px; border-top: 1px solid #2c2d32; display: flex; flex-direction: column; gap: 2px; font-size: 12px; color: #8d8e93; }
|
.side .foot { margin-top: auto; padding-top: 16px; border-top: 1px solid #2c2d32; display: flex; flex-direction: column; gap: 2px; font-size: 12px; color: #8d8e93; }
|
||||||
.side .foot button { background: none; border: 0; padding: 0; font: inherit; color: #c9c9c3; text-decoration: underline; cursor: pointer; }
|
.side .acctrow { display: flex; align-items: center; gap: 4px; }
|
||||||
|
.side .acctrow .acct { flex: 1; min-width: 0; }
|
||||||
|
.side .signout { position: relative; flex: none; width: 40px; height: 40px; display: grid; place-items: center; border: 0; border-radius: 8px; background: none; color: #8d8e93; cursor: pointer; }
|
||||||
|
.side .signout:hover { background: #222328; color: #fff; }
|
||||||
|
.side .signout .tip { position: absolute; bottom: calc(100% + 6px); right: 0; padding: 3px 8px; border-radius: 5px; background: #000; color: #fff; font-size: 12px; white-space: nowrap; opacity: 0; pointer-events: none; transition: opacity 0.12s; }
|
||||||
|
.side .signout:hover .tip, .side .signout:focus-visible .tip { opacity: 1; }
|
||||||
.side .acct { display: flex; align-items: center; gap: 12px; min-height: 52px; padding: 0 12px; border-radius: 8px; color: #c9c9c3; text-decoration: none; }
|
.side .acct { display: flex; align-items: center; gap: 12px; min-height: 52px; padding: 0 12px; border-radius: 8px; color: #c9c9c3; text-decoration: none; }
|
||||||
.side .acct:hover { background: #222328; color: #fff; }
|
.side .acct:hover { background: #222328; color: #fff; }
|
||||||
.side .acct.on { background: #2a2b31; color: #fff; }
|
.side .acct.on { background: #2a2b31; color: #fff; }
|
||||||
@@ -66,6 +71,9 @@ a.brand, a.brand:hover { color: #fff; text-decoration: none; }
|
|||||||
.side .avatar { width: 28px; height: 28px; border-radius: 50%; background: #3a3b41; display: grid; place-items: center; flex: none; font-size: 13px; font-weight: 600; color: #fff; }
|
.side .avatar { width: 28px; height: 28px; border-radius: 50%; background: #3a3b41; display: grid; place-items: center; flex: none; font-size: 13px; font-weight: 600; color: #fff; }
|
||||||
.side .footrow { display: flex; justify-content: space-between; padding: 10px 12px 0; }
|
.side .footrow { display: flex; justify-content: space-between; padding: 10px 12px 0; }
|
||||||
.main { flex: 999 1 560px; min-width: 0; padding: 32px 32px 56px; }
|
.main { flex: 999 1 560px; min-width: 0; padding: 32px 32px 56px; }
|
||||||
|
/* Beside the page (not stacked above it on a phone), the sidebar stays in
|
||||||
|
place while the page scrolls, so the account link is always visible. */
|
||||||
|
@media (min-width: 800px) { .side { position: sticky; top: 0; height: 100vh; height: 100dvh; overflow-y: auto; } }
|
||||||
.wrap { max-width: 1120px; margin: 0 auto; display: flex; flex-direction: column; gap: 20px; }
|
.wrap { max-width: 1120px; margin: 0 auto; display: flex; flex-direction: column; gap: 20px; }
|
||||||
@media (max-width: 640px) { .main { padding: 20px 16px 40px; } }
|
@media (max-width: 640px) { .main { padding: 20px 16px 40px; } }
|
||||||
|
|
||||||
@@ -128,6 +136,10 @@ h1 { margin: 0; font-size: 26px; font-weight: 600; letter-spacing: -0.01em; over
|
|||||||
table { width: 100%; border-collapse: collapse; min-width: 720px; }
|
table { width: 100%; border-collapse: collapse; min-width: 720px; }
|
||||||
table.narrow { min-width: 520px; }
|
table.narrow { min-width: 520px; }
|
||||||
th { text-align: left; font-size: 12px; font-weight: 600; color: var(--ink-2); padding: 10px 12px; border-bottom: 1px solid var(--line); white-space: nowrap; }
|
th { text-align: left; font-size: 12px; font-weight: 600; color: var(--ink-2); padding: 10px 12px; border-bottom: 1px solid var(--line); white-space: nowrap; }
|
||||||
|
th .sort { display: inline-flex; align-items: center; gap: 4px; background: none; border: 0; padding: 0; font: inherit; color: inherit; cursor: pointer; }
|
||||||
|
th .sort:hover, th .sort.on { color: var(--ink); }
|
||||||
|
th .sort .arrow { font-size: 11px; opacity: 0.35; }
|
||||||
|
th .sort:hover .arrow, th .sort.on .arrow { opacity: 1; }
|
||||||
td { padding: 12px; border-bottom: 1px solid var(--line-2); vertical-align: middle; }
|
td { padding: 12px; border-bottom: 1px solid var(--line-2); vertical-align: middle; }
|
||||||
tr:last-child td { border-bottom: 0; }
|
tr:last-child td { border-bottom: 0; }
|
||||||
.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||||
@@ -264,7 +276,6 @@ dialog::backdrop { background: rgba(22, 23, 26, .55); }
|
|||||||
.linkbtn { background: none; border: 0; padding: 4px; font: inherit; font-size: 13px; color: #9cc3f5; text-decoration: underline; cursor: pointer; align-self: center; }
|
.linkbtn { background: none; border: 0; padding: 4px; font: inherit; font-size: 13px; color: #9cc3f5; text-decoration: underline; cursor: pointer; align-self: center; }
|
||||||
.linkbtn:hover { color: #fff; }
|
.linkbtn:hover { color: #fff; }
|
||||||
.loginform .err-text:empty { display: none; }
|
.loginform .err-text:empty { display: none; }
|
||||||
.loginfoot { margin: 0; font-family: var(--mono); font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase; color: #8d8e93; }
|
|
||||||
.err-text { color: var(--bad-ink); font-size: 13px; margin: 0; }
|
.err-text { color: var(--bad-ink); font-size: 13px; margin: 0; }
|
||||||
/* setup link page (setup.html): same dark look as the login page */
|
/* setup link page (setup.html): same dark look as the login page */
|
||||||
.setuppage { min-height: 100vh; display: flex; justify-content: center; padding: 48px 16px; background: var(--ink); color: #f4f4f1; font-size: 15px; }
|
.setuppage { min-height: 100vh; display: flex; justify-content: center; padding: 48px 16px; background: var(--ink); color: #f4f4f1; font-size: 15px; }
|
||||||
@@ -296,3 +307,18 @@ dialog::backdrop { background: rgba(22, 23, 26, .55); }
|
|||||||
.steps .btn.primary { background: #f4f4f1; border-color: #f4f4f1; color: var(--ink); font-weight: 600; }
|
.steps .btn.primary { background: #f4f4f1; border-color: #f4f4f1; color: var(--ink); font-weight: 600; }
|
||||||
.steps .qr { width: 100%; height: auto; max-width: 280px; align-self: center; }
|
.steps .qr { width: 100%; height: auto; max-width: 280px; align-self: center; }
|
||||||
.loading-page { padding: 40px; color: var(--ink-3); }
|
.loading-page { padding: 40px; color: var(--ink-3); }
|
||||||
|
|
||||||
|
/* two-step sign-in */
|
||||||
|
.loginalt { width: 100%; display: flex; flex-direction: column; gap: 14px; margin-top: -24px; }
|
||||||
|
.loginalt .or, .loginform .or { display: flex; align-items: center; gap: 10px; color: #8d8e93; font-size: 12px; }
|
||||||
|
.loginalt .or::before, .loginalt .or::after { content: ""; flex: 1; height: 1px; background: #2c2d32; }
|
||||||
|
.loginpage .btn.altbtn { min-height: 44px; width: 100%; font-size: 15px; font-weight: 500; background: none; border-color: #3a3b41; color: #f4f4f1; margin-top: 0; }
|
||||||
|
.loginpage .btn.altbtn:hover { background: #222328; border-color: #55565c; color: #fff; }
|
||||||
|
.loginlinks { display: flex; flex-direction: column; align-items: center; gap: 2px; margin-top: 6px; }
|
||||||
|
.loginform .codeinput { text-align: center; font-size: 20px; letter-spacing: 0.2em; }
|
||||||
|
.mfalist { display: flex; flex-direction: column; }
|
||||||
|
.mfarow { display: flex; align-items: center; gap: 8px; padding: 12px 0; border-top: 1px solid var(--line-2); }
|
||||||
|
.mfarow:first-child { border-top: 0; padding-top: 0; }
|
||||||
|
.mfarow .grow { flex: 1; min-width: 0; }
|
||||||
|
.dlg .secret { font-size: 15px; letter-spacing: 0.04em; overflow-wrap: anywhere; }
|
||||||
|
.dlg .codes { columns: 2; font-size: 15px; line-height: 1.8; }
|
||||||
|
|||||||
@@ -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"/>',
|
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"/>',
|
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"/>',
|
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.
|
// The Hannya mark: the horned demon mask of Noh. Same drawing as favicon.svg.
|
||||||
@@ -137,6 +139,16 @@
|
|||||||
return ts + ' ' + String(l.level).padEnd(5) + ' ' + l.msg + (rest ? ' ' + rest : '');
|
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.keys) parts.push(m.keys === 1 ? '1 key' : m.keys + ' keys');
|
||||||
|
if (m.passkeys) parts.push(m.passkeys === 1 ? '1 passkey' : m.passkeys + ' passkeys');
|
||||||
|
return parts.join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
// "Germany · Deutsche Telekom AG", "Local network" or "".
|
// "Germany · Deutsche Telekom AG", "Local network" or "".
|
||||||
function fmtLocation(g) {
|
function fmtLocation(g) {
|
||||||
if (!g) return '';
|
if (!g) return '';
|
||||||
@@ -215,7 +227,7 @@
|
|||||||
const r = await fetch('/api/v1' + path, opt);
|
const r = await fetch('/api/v1' + path, opt);
|
||||||
let data = {};
|
let data = {};
|
||||||
try { data = await r.json(); } catch { /* empty body */ }
|
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;
|
me = null;
|
||||||
showLogin();
|
showLogin();
|
||||||
throw new Error('Signed out');
|
throw new Error('Signed out');
|
||||||
@@ -224,6 +236,10 @@
|
|||||||
showNewPassword();
|
showNewPassword();
|
||||||
throw new Error('Signed out');
|
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);
|
if (!r.ok) throw new Error(data.error || r.statusText);
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
@@ -513,12 +529,13 @@
|
|||||||
srvBox,
|
srvBox,
|
||||||
NAV.map(([href, ic, label]) => (navLinks[href] = h('a', { class: 'nav', href }, icon(ic), label, ic === 'peers' ? peerCount : null))),
|
NAV.map(([href, ic, label]) => (navLinks[href] = h('a', { class: 'nav', href }, icon(ic), label, ic === 'peers' ? peerCount : null))),
|
||||||
h('div', { class: 'foot' },
|
h('div', { class: 'foot' },
|
||||||
(navLinks['#/account'] = h('a', { class: 'acct', href: '#/account' },
|
h('div', { class: 'acctrow' },
|
||||||
h('span', { class: 'avatar', 'aria-hidden': 'true' }, me.name.slice(0, 1).toUpperCase()),
|
(navLinks['#/account'] = h('a', { class: 'acct', href: '#/account' },
|
||||||
h('span', null, h('strong', null, me.name), h('span', null, 'My 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('div', { class: 'footrow' },
|
||||||
h('button', { type: 'button', onClick: logout }, 'Sign out'),
|
h('span', null, 'v' + me.version.replace(/^v/, '')))));
|
||||||
h('span', null, 'v' + me.version))));
|
|
||||||
main = h('main', { class: 'main', id: 'main' });
|
main = h('main', { class: 'main', id: 'main' });
|
||||||
app.replaceChildren(h('div', { class: 'shell' }, nav, main));
|
app.replaceChildren(h('div', { class: 'shell' }, nav, main));
|
||||||
refreshSide();
|
refreshSide();
|
||||||
@@ -564,6 +581,7 @@
|
|||||||
try { me = await api('GET', '/auth/me'); } catch { showLogin(); return; }
|
try { me = await api('GET', '/auth/me'); } catch { showLogin(); return; }
|
||||||
}
|
}
|
||||||
if (me.mustChangePassword) { showNewPassword(); return; }
|
if (me.mustChangePassword) { showNewPassword(); return; }
|
||||||
|
if (me.mfaSetupRequired) { showMFASetup(); return; }
|
||||||
if (!main || !main.isConnected) buildShell();
|
if (!main || !main.isConnected) buildShell();
|
||||||
every(30000, refreshSide);
|
every(30000, refreshSide);
|
||||||
const hash = location.hash || '#/';
|
const hash = location.hash || '#/';
|
||||||
@@ -602,9 +620,9 @@
|
|||||||
err.textContent = '';
|
err.textContent = '';
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
try {
|
try {
|
||||||
await api('POST', '/auth/login', { username: user.value, password: pw.value });
|
const res = await api('POST', '/auth/login', { username: user.value, password: pw.value });
|
||||||
me = await api('GET', '/auth/me');
|
if (res.mfa) { showSecondStep(res.ticket, res.methods, pw.value); return; }
|
||||||
if (me.mustChangePassword) showNewPassword(pw.value); else render();
|
await signedIn(pw.value);
|
||||||
} catch (x) {
|
} catch (x) {
|
||||||
err.textContent = x.message;
|
err.textContent = x.message;
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
@@ -614,13 +632,305 @@
|
|||||||
h('div', { class: 'field' }, h('label', { htmlFor: 'u' }, 'Username'), user),
|
h('div', { class: 'field' }, h('label', { htmlFor: 'u' }, 'Username'), user),
|
||||||
h('div', { class: 'field' }, h('label', { htmlFor: 'p' }, 'Password'), pw),
|
h('div', { class: 'field' }, h('label', { htmlFor: 'p' }, 'Password'), pw),
|
||||||
err, btn);
|
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', 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), 'Sign in with a passkey'));
|
||||||
|
if (window.PublicKeyCredential) {
|
||||||
|
api('GET', '/auth/options').then((o) => { passkeyRow.hidden = !o.passkeys; }).catch(() => {});
|
||||||
|
}
|
||||||
app.replaceChildren(h('div', { class: 'loginpage' }, h('div', { class: 'loginbox' },
|
app.replaceChildren(h('div', { class: 'loginpage' }, h('div', { class: 'loginbox' },
|
||||||
brand(72),
|
brand(72),
|
||||||
form),
|
form, passkeyRow)));
|
||||||
h('p', { class: 'loginfoot' }, 'WireGuard server manager')));
|
|
||||||
user.focus();
|
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 'Security keys 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 security key', 'Insert your key and touch it, or use the passkey on this device.'],
|
||||||
|
totp: ['Enter the code', 'The 6-digit code from your authenticator app.'],
|
||||||
|
recovery: ['Use a recovery code', 'One of the codes you saved when you set up two-step sign-in. Each works once.'],
|
||||||
|
};
|
||||||
|
const LINKS = { key: 'Use a security key instead', totp: 'Use an authenticator code instead', recovery: 'Use a recovery code' };
|
||||||
|
const 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 security key');
|
||||||
|
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: () => addKey(false, done) }, 'Use a security key') : null,
|
||||||
|
keys ? h('button', { type: 'button', class: 'btn altbtn', onClick: () => addKey(true, 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
// addKey adds a security key, or with passkey a passkey that also signs
|
||||||
|
// in without a password.
|
||||||
|
function addKey(passkey, onDone) {
|
||||||
|
const nm = h('input', { id: 'kn', value: passkey ? 'Passkey' : 'YubiKey', autocomplete: 'off', maxLength: 64 });
|
||||||
|
const e = h('p', { class: 'err-text', role: 'alert' });
|
||||||
|
const btn = h('button', { type: 'submit', class: 'btn primary' }, passkey ? 'Add passkey' : 'Add security key');
|
||||||
|
dialog((close) => h('form', { class: 'dlg', onSubmit: async (ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
e.textContent = '';
|
||||||
|
btn.disabled = true;
|
||||||
|
try {
|
||||||
|
const opts = await api('POST', '/auth/mfa/keys/begin', { passkey });
|
||||||
|
const cred = await webauthnCreate(opts);
|
||||||
|
const res = await api('POST', '/auth/mfa/keys/finish?name=' + encodeURIComponent(nm.value.trim()), cred);
|
||||||
|
close();
|
||||||
|
toast((passkey ? 'Passkey' : 'Security key') + ' added');
|
||||||
|
afterAdd(res, onDone);
|
||||||
|
} catch (x) { e.textContent = keyError(x); btn.disabled = false; }
|
||||||
|
} },
|
||||||
|
h('h2', null, passkey ? 'Add a passkey' : 'Add a security key'),
|
||||||
|
h('p', null, passkey
|
||||||
|
? 'A passkey signs you in on its own, without username and password. It can live in your password manager, on this device (Touch ID, Face ID, Windows Hello) or on a YubiKey.'
|
||||||
|
: 'A YubiKey or other FIDO2 key, asked for after your password. Have it ready: your browser asks you to insert and touch it.'),
|
||||||
|
h('div', { class: 'field' }, h('label', { htmlFor: 'kn' }, 'Name'), nm, h('span', { class: 'hint' }, 'So you can tell your keys apart')),
|
||||||
|
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' }, (k.passkey ? 'Passkey' : 'Security key') + ' · 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: () => addKey(false, draw) }, 'Add security key') : null,
|
||||||
|
keys ? h('button', { type: 'button', class: 'btn', onClick: () => addKey(true, draw) }, 'Add passkey') : null),
|
||||||
|
keys ? null : h('p', { class: 'hint section' }, 'Security keys and passkeys need this site on its domain name with a trusted certificate (Let\'s Encrypt or certificate files).'),
|
||||||
|
].filter(Boolean));
|
||||||
|
};
|
||||||
|
draw();
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
// showNewPassword is the screen after signing in with a temporary password
|
// showNewPassword is the screen after signing in with a temporary password
|
||||||
// an admin chose. current is that password when the user just typed it.
|
// an admin chose. current is that password when the user just typed it.
|
||||||
function showNewPassword(current) {
|
function showNewPassword(current) {
|
||||||
@@ -656,8 +966,7 @@
|
|||||||
h('div', { class: 'logintext' },
|
h('div', { class: 'logintext' },
|
||||||
h('h1', null, me ? 'Welcome, ' + me.name : 'Choose a new password'),
|
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.')),
|
h('p', null, 'An admin gave you a temporary password. Choose your own to continue.')),
|
||||||
form),
|
form)));
|
||||||
h('p', { class: 'loginfoot' }, 'WireGuard server manager')));
|
|
||||||
(cur || p1).focus();
|
(cur || p1).focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -738,9 +1047,50 @@
|
|||||||
|
|
||||||
// ---------- peers ----------
|
// ---------- 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) {
|
async function viewPeers(wrap) {
|
||||||
let q = '', filter = 'all', data = await api('GET', '/peers');
|
let q = '', filter = 'all', data = await api('GET', '/peers');
|
||||||
const tbody = h('tbody');
|
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 empty = h('p', { class: 'empty', hidden: true }, 'No peers match this filter.');
|
||||||
const sub = h('p', { class: 'sub' });
|
const sub = h('p', { class: 'sub' });
|
||||||
const pills = h('div', { class: 'pills', role: 'group', 'aria-label': 'Status filter' });
|
const pills = h('div', { class: 'pills', role: 'group', 'aria-label': 'Status filter' });
|
||||||
@@ -765,7 +1115,7 @@
|
|||||||
const keep = filter === 'all' || filter === st || (filter === 'offline' && ['offline', 'never', 'setup', 'nokey'].includes(st));
|
const keep = filter === 'all' || filter === st || (filter === 'offline' && ['offline', 'never', 'setup', 'nokey'].includes(st));
|
||||||
return hit && keep;
|
return hit && keep;
|
||||||
});
|
});
|
||||||
tbody.replaceChildren(...rows.map((p) => h('tr', null,
|
tbody.replaceChildren(...sorted(rows).map((p) => h('tr', null,
|
||||||
h('td', null, h('a', { href: '#/peers/' + p.id }, h('strong', null, p.name)), p.note ? h('div', { class: 'note' }, p.note) : null),
|
h('td', null, h('a', { href: '#/peers/' + p.id }, h('strong', null, p.name)), p.note ? h('div', { class: 'note' }, p.note) : null),
|
||||||
h('td', { class: 'mono' }, p.ipv4),
|
h('td', { class: 'mono' }, p.ipv4),
|
||||||
h('td', null, badge(peerState(p))),
|
h('td', null, badge(peerState(p))),
|
||||||
@@ -780,6 +1130,7 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
drawPills();
|
drawPills();
|
||||||
|
drawHead();
|
||||||
drawRows();
|
drawRows();
|
||||||
fill(wrap,
|
fill(wrap,
|
||||||
h('div', { class: 'head' },
|
h('div', { class: 'head' },
|
||||||
@@ -790,8 +1141,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(); } }),
|
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),
|
pills),
|
||||||
h('section', { class: 'card flush' }, h('div', { class: 'tbl' }, h('table', null,
|
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('thead', null, headRow),
|
||||||
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')))),
|
|
||||||
tbody), empty)),
|
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.'));
|
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 */ } });
|
every(15000, async () => { try { data = await api('GET', '/peers'); drawRows(); } catch { /* keep last */ } });
|
||||||
@@ -935,8 +1285,24 @@
|
|||||||
// ---------- peer detail ----------
|
// ---------- peer detail ----------
|
||||||
|
|
||||||
async function viewPeer(wrap, id) {
|
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;
|
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';
|
let range = '7d';
|
||||||
const st = peerState(p);
|
const st = peerState(p);
|
||||||
const traffic = h('div');
|
const traffic = h('div');
|
||||||
@@ -1109,14 +1475,9 @@
|
|||||||
sessions.length ? h('div', { class: 'tbl' }, h('table', null,
|
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('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('th', { class: 'num' }, 'Download'), h('th', { class: 'num' }, 'Upload'))),
|
||||||
h('tbody', null, sessions.map((se) => h('tr', null,
|
sessBody))
|
||||||
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)))))))
|
|
||||||
: h('p', { class: 'empty' }, 'No connections recorded yet.'),
|
: 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('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'),
|
h('a', { href: 'https://db-ip.com', target: '_blank', rel: 'noopener' }, 'IP Geolocation by DB-IP'),
|
||||||
'. Kept as long as the daily traffic history.')),
|
'. Kept as long as the daily traffic history.')),
|
||||||
@@ -1355,6 +1716,8 @@
|
|||||||
pwErr,
|
pwErr,
|
||||||
h('div', { class: 'formfoot' }, h('button', { type: 'submit', class: 'btn primary' }, 'Change password'))),
|
h('div', { class: 'formfoot' }, h('button', { type: 'submit', class: 'btn primary' }, 'Change password'))),
|
||||||
|
|
||||||
|
mfaCard(),
|
||||||
|
|
||||||
h('section', { class: 'card flush', 'aria-labelledby': 'mytk' },
|
h('section', { class: 'card flush', 'aria-labelledby': 'mytk' },
|
||||||
h('div', { class: 'cardhead' },
|
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.')),
|
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 +1740,7 @@
|
|||||||
const drawUsers = (users) => userBody.replaceChildren(...users.map((u) => h('tr', null,
|
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, 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, 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.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, u.tokens ? String(u.tokens) : h('span', { class: 'muted' }, 'None')),
|
||||||
h('td', null, fmtDate(u.created)),
|
h('td', null, fmtDate(u.created)),
|
||||||
@@ -1450,6 +1814,7 @@
|
|||||||
must.el,
|
must.el,
|
||||||
h('div', { class: 'actions' },
|
h('div', { class: 'actions' },
|
||||||
h('button', { type: 'button', class: 'btn', onClick: () => { close(); resetUser(u); } }, 'Reset password…'),
|
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…')),
|
h('button', { type: 'button', class: 'btn danger', onClick: () => { close(); deleteUser(u); } }, 'Delete user…')),
|
||||||
e,
|
e,
|
||||||
h('div', { class: 'foot' }, h('button', { type: 'button', class: 'btn', onClick: close }, 'Cancel'), h('button', { type: 'submit', class: 'btn primary' }, 'Save'))));
|
h('div', { class: 'foot' }, h('button', { type: 'button', class: 'btn', onClick: close }, 'Cancel'), h('button', { type: 'submit', class: 'btn primary' }, 'Save'))));
|
||||||
@@ -1473,6 +1838,10 @@
|
|||||||
pw.el, must.el, e,
|
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'))));
|
h('div', { class: 'foot' }, h('button', { type: 'button', class: 'btn', onClick: close }, 'Cancel'), h('button', { type: 'submit', class: 'btn primary' }, 'Reset password'))));
|
||||||
};
|
};
|
||||||
|
const resetMFA = async (u) => {
|
||||||
|
if (!await confirmDialog({ title: 'Reset two-step sign-in for ' + u.username + '?', text: 'Their authenticator app, security keys, passkeys and recovery codes are removed. They sign in with their password and can set it up again.', ok: 'Reset', danger: true })) return;
|
||||||
|
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 deleteUser = async (u) => {
|
||||||
const tokens = u.tokens ? ' Their ' + (u.tokens === 1 ? 'app token is' : u.tokens + ' app tokens are') + ' revoked too.' : '';
|
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;
|
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 +1913,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); }
|
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)));
|
} }, ['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
|
// data retention
|
||||||
const presetSelect = (id, value, presets, unit) => {
|
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]);
|
const opts = presets.some(([v]) => v === value) ? presets : [...presets, [value, value + ' ' + unit]].sort((a, b) => a[0] - b[0]);
|
||||||
@@ -1604,9 +2012,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('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('button', { type: 'button', class: 'btn primary', onClick: addUser }, 'Add user')),
|
||||||
h('div', { class: 'tbl' }, h('table', null,
|
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))),
|
userBody))),
|
||||||
|
|
||||||
|
h('section', { class: 'card', 'aria-labelledby': 'sgn' },
|
||||||
|
h('h2', { id: 'sgn' }, 'Sign-in'),
|
||||||
|
h('p', { class: 'lead' }, 'Everyone sets up two-step sign-in under My account: an authenticator app, security keys such as a YubiKey, or passkeys. Changes apply immediately.'),
|
||||||
|
h('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('form', { class: 'card', onSubmit: saveWeb, 'aria-labelledby': 'web' },
|
||||||
h('h2', { id: 'web' }, 'Web interface'),
|
h('h2', { id: 'web' }, 'Web interface'),
|
||||||
h('p', { class: 'lead' }, 'Changes take effect after the service restarts.'),
|
h('p', { class: 'lead' }, 'Changes take effect after the service restarts.'),
|
||||||
@@ -1621,6 +2035,14 @@
|
|||||||
webErr,
|
webErr,
|
||||||
h('div', { class: 'formfoot' }, h('button', { type: 'submit', class: 'btn primary' }, 'Save'))),
|
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('section', { class: 'card', 'aria-labelledby': 'api' },
|
||||||
h('div', { class: 'cardhead' },
|
h('div', { class: 'cardhead' },
|
||||||
h('div', null, h('h2', { id: 'api' }, 'API tokens'), h('p', { class: 'lead', style: { marginBottom: '0' } }, 'For the iOS app and scripts. A token appears once when you create it, and only a hash is stored.')),
|
h('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.')),
|
||||||
|
|||||||
@@ -102,7 +102,10 @@ type principal struct {
|
|||||||
RemoteIP string
|
RemoteIP string
|
||||||
// MustChangePassword blocks everything but changing the password.
|
// MustChangePassword blocks everything but changing the password.
|
||||||
MustChangePassword bool
|
MustChangePassword bool
|
||||||
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.
|
// sessionInfo is when and from where a browser session started.
|
||||||
@@ -138,6 +141,7 @@ type Auth struct {
|
|||||||
used map[string]tokenUse
|
used map[string]tokenUse
|
||||||
logins map[string]tokenUse // last sign-in per user ID
|
logins map[string]tokenUse // last sign-in per user ID
|
||||||
fails map[string]*failState
|
fails map[string]*failState
|
||||||
|
mfa mfaState
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -146,26 +150,27 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func newAuth(s *Store) *Auth {
|
func newAuth(s *Store) *Auth {
|
||||||
return &Auth{store: s, sessions: map[string]*session{}, used: map[string]tokenUse{}, logins: map[string]tokenUse{}, fails: map[string]*failState{}}
|
return &Auth{store: s, sessions: map[string]*session{}, used: map[string]tokenUse{}, logins: map[string]tokenUse{}, fails: map[string]*failState{}, mfa: newMFAState()}
|
||||||
}
|
}
|
||||||
|
|
||||||
func cookieName() string { return appName + "_session" }
|
func cookieName() string { return appName + "_session" }
|
||||||
|
|
||||||
var errLocked = errors.New("too many failed attempts, try again later")
|
var errLocked = errors.New("too many failed attempts, try again later")
|
||||||
|
|
||||||
// Login checks the credentials and returns a new session id.
|
// Login checks the credentials and returns a new session id, or, for a user
|
||||||
func (a *Auth) Login(user, pw, ip string) (string, error) {
|
// with two-step sign-in, a ticket for the second step.
|
||||||
|
func (a *Auth) Login(user, pw, ip string) (sessionID, ticket string, err error) {
|
||||||
a.mu.Lock()
|
a.mu.Lock()
|
||||||
f := a.fails[ip]
|
f := a.fails[ip]
|
||||||
if f != nil && time.Now().Before(f.until) {
|
if f != nil && time.Now().Before(f.until) {
|
||||||
a.mu.Unlock()
|
a.mu.Unlock()
|
||||||
return "", errLocked
|
return "", "", errLocked
|
||||||
}
|
}
|
||||||
a.mu.Unlock()
|
a.mu.Unlock()
|
||||||
|
|
||||||
cfg := a.store.Get()
|
cfg := a.store.Get()
|
||||||
if !cfg.passwordSet() {
|
if !cfg.passwordSet() {
|
||||||
return "", errors.New("no password is set; run: " + appName + " passwd")
|
return "", "", errors.New("no password is set; run: " + appName + " passwd")
|
||||||
}
|
}
|
||||||
// An unknown username costs as much time as a wrong password, so the
|
// An unknown username costs as much time as a wrong password, so the
|
||||||
// answer time does not tell which usernames exist.
|
// answer time does not tell which usernames exist.
|
||||||
@@ -189,11 +194,14 @@ func (a *Auth) Login(user, pw, ip string) (string, error) {
|
|||||||
f.count = 0
|
f.count = 0
|
||||||
f.until = time.Now().Add(lockoutTime)
|
f.until = time.Now().Add(lockoutTime)
|
||||||
}
|
}
|
||||||
return "", errors.New("wrong username or password")
|
return "", "", errors.New("wrong username or password")
|
||||||
|
}
|
||||||
|
if u.hasMFA() {
|
||||||
|
return "", a.newTicketLocked(u, ip), nil
|
||||||
}
|
}
|
||||||
delete(a.fails, ip)
|
delete(a.fails, ip)
|
||||||
a.logins[u.ID] = tokenUse{At: time.Now(), IP: ip}
|
a.logins[u.ID] = tokenUse{At: time.Now(), IP: ip}
|
||||||
return a.newSessionLocked(cfg, u, sessionInfo{Started: time.Now(), IP: ip}), nil
|
return a.newSessionLocked(cfg, u, sessionInfo{Started: time.Now(), IP: ip}), "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewSession replaces a session after the user changed their password; it
|
// NewSession replaces a session after the user changed their password; it
|
||||||
@@ -290,7 +298,8 @@ func (a *Auth) Authenticate(r *http.Request) (*principal, bool) {
|
|||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
info := s.info
|
info := s.info
|
||||||
return &principal{Name: u.Username, UserID: u.ID, Scope: "rw", IsAdmin: true, RemoteIP: ip, MustChangePassword: u.MustChangePassword, Session: &info}, true
|
return &principal{Name: u.Username, UserID: u.ID, Scope: "rw", IsAdmin: true, RemoteIP: ip, MustChangePassword: u.MustChangePassword,
|
||||||
|
MFASetupRequired: cfg.SignIn.RequireMFA && !u.hasMFA(), Session: &info}, true
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Auth) TokenUse(id string) *tokenUse {
|
func (a *Auth) TokenUse(id string) *tokenUse {
|
||||||
@@ -317,4 +326,16 @@ func (a *Auth) sweep() {
|
|||||||
delete(a.fails, ip)
|
delete(a.fails, ip)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
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"`
|
APITokens []APIToken `json:"apiTokens"`
|
||||||
// Admin is the single account of config version 1; applyDefaults moves
|
// Admin is the single account of config version 1; applyDefaults moves
|
||||||
// it into Users.
|
// it into Users.
|
||||||
Admin *Admin `json:"admin,omitempty"`
|
Admin *Admin `json:"admin,omitempty"`
|
||||||
Server Server `json:"server"`
|
Server Server `json:"server"`
|
||||||
Peers []Peer `json:"peers"`
|
Peers []Peer `json:"peers"`
|
||||||
Log LogConfig `json:"log"`
|
Log LogConfig `json:"log"`
|
||||||
Stats StatsConfig `json:"stats"`
|
Stats StatsConfig `json:"stats"`
|
||||||
|
Decoy DecoyConfig `json:"decoy"`
|
||||||
|
SignIn SignInConfig `json:"signin"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SignInConfig holds the rules for signing in to the web interface.
|
||||||
|
type SignInConfig struct {
|
||||||
|
// RequireMFA sends users without two-step sign-in to set it up before
|
||||||
|
// they can do anything else. API tokens are not affected.
|
||||||
|
RequireMFA bool `json:"requireMfa"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecoyConfig replaces the web interface with a stock web server page.
|
||||||
|
// The API keeps working, so the iOS app can turn it off again.
|
||||||
|
type DecoyConfig struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Page string `json:"page"` // nginx | apache | soon
|
||||||
}
|
}
|
||||||
|
|
||||||
// StatsConfig sets how long traffic history is kept in stats.json.
|
// StatsConfig sets how long traffic history is kept in stats.json.
|
||||||
@@ -84,6 +100,7 @@ type User struct {
|
|||||||
// the user can do nothing else until they pick their own.
|
// the user can do nothing else until they pick their own.
|
||||||
MustChangePassword bool `json:"mustChangePassword,omitempty"`
|
MustChangePassword bool `json:"mustChangePassword,omitempty"`
|
||||||
Created time.Time `json:"created"`
|
Created time.Time `json:"created"`
|
||||||
|
MFA *UserMFA `json:"mfa,omitempty"` // two-step sign-in, nil when never set up
|
||||||
}
|
}
|
||||||
|
|
||||||
type APIToken struct {
|
type APIToken struct {
|
||||||
@@ -223,6 +240,9 @@ func (c *Config) applyDefaults() {
|
|||||||
if c.Stats.DailyDays == 0 {
|
if c.Stats.DailyDays == 0 {
|
||||||
c.Stats.DailyDays = 400
|
c.Stats.DailyDays = 400
|
||||||
}
|
}
|
||||||
|
if c.Decoy.Page == "" {
|
||||||
|
c.Decoy.Page = "nginx"
|
||||||
|
}
|
||||||
if c.APITokens == nil {
|
if c.APITokens == nil {
|
||||||
c.APITokens = []APIToken{}
|
c.APITokens = []APIToken{}
|
||||||
}
|
}
|
||||||
@@ -356,6 +376,9 @@ func (c *Config) validate() error {
|
|||||||
} else if st.DailyDays < minDailyDays || st.DailyDays > maxDailyDays {
|
} else if st.DailyDays < minDailyDays || st.DailyDays > maxDailyDays {
|
||||||
return fmt.Errorf("daily traffic history must be %d–%d days", minDailyDays, maxDailyDays)
|
return fmt.Errorf("daily traffic history must be %d–%d days", minDailyDays, maxDailyDays)
|
||||||
}
|
}
|
||||||
|
if _, ok := decoyPages[c.Decoy.Page]; !ok {
|
||||||
|
return fmt.Errorf("unknown decoy page %q", c.Decoy.Page)
|
||||||
|
}
|
||||||
switch c.Web.TLS.Mode {
|
switch c.Web.TLS.Mode {
|
||||||
case "acme":
|
case "acme":
|
||||||
if c.Web.TLS.Domain == "" {
|
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
|
go 1.27.1
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/go-webauthn/webauthn v0.18.2
|
||||||
github.com/google/nftables v0.3.0
|
github.com/google/nftables v0.3.0
|
||||||
github.com/oschwald/maxminddb-golang v1.13.1
|
github.com/oschwald/maxminddb-golang v1.13.1
|
||||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
||||||
@@ -15,11 +16,20 @@ require (
|
|||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/fxamacker/cbor/v2 v2.9.4 // indirect
|
||||||
|
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||||
|
github.com/go-webauthn/x v0.3.1 // indirect
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||||
github.com/google/go-cmp v0.6.0 // indirect
|
github.com/google/go-cmp v0.6.0 // indirect
|
||||||
|
github.com/google/go-tpm v0.9.8 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/mdlayher/genetlink v1.3.2 // indirect
|
github.com/mdlayher/genetlink v1.3.2 // indirect
|
||||||
github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 // indirect
|
github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 // indirect
|
||||||
github.com/mdlayher/socket v0.5.1 // indirect
|
github.com/mdlayher/socket v0.5.1 // indirect
|
||||||
|
github.com/philhofer/fwd v1.2.0 // indirect
|
||||||
|
github.com/tinylib/msgp v1.6.4 // indirect
|
||||||
github.com/vishvananda/netns v0.0.5 // indirect
|
github.com/vishvananda/netns v0.0.5 // indirect
|
||||||
|
github.com/x448/float16 v0.8.4 // indirect
|
||||||
golang.org/x/sync v0.23.0 // indirect
|
golang.org/x/sync v0.23.0 // indirect
|
||||||
golang.org/x/text v0.42.0 // indirect
|
golang.org/x/text v0.42.0 // indirect
|
||||||
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173 // indirect
|
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173 // indirect
|
||||||
|
|||||||
@@ -1,9 +1,23 @@
|
|||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/fxamacker/cbor/v2 v2.9.4 h1:xwjVlxEMR3S605oUlgBjKLTTeGFciYPGYCtF/35LKGo=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/fxamacker/cbor/v2 v2.9.4/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||||
|
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
|
||||||
|
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||||
|
github.com/go-webauthn/webauthn v0.18.2 h1:0BeftmEHU7i3Dv0VFwBtidy/ba37Vcdjvqst9EYu8Sk=
|
||||||
|
github.com/go-webauthn/webauthn v0.18.2/go.mod h1:hEXaOuLxvZ3zG9miZe3ehlyeVso9AtklXG+kTn36k+A=
|
||||||
|
github.com/go-webauthn/x v0.3.1 h1:1ff37z3XfmTTomkhlURgGizLIDyOvPgTt2t9nlzKLRo=
|
||||||
|
github.com/go-webauthn/x v0.3.1/go.mod h1:ZInxAynYXfBPvvm5gzKZ7geBlL23K71xASMgohHl/Rg=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
|
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
|
||||||
|
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
|
||||||
|
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLzH5KDR0gKc0zcktin6KSAwL7+jWKBYceddTc=
|
||||||
|
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc=
|
||||||
github.com/google/nftables v0.3.0 h1:bkyZ0cbpVeMHXOrtlFc8ISmfVqq5gPJukoYieyVmITg=
|
github.com/google/nftables v0.3.0 h1:bkyZ0cbpVeMHXOrtlFc8ISmfVqq5gPJukoYieyVmITg=
|
||||||
github.com/google/nftables v0.3.0/go.mod h1:BCp9FsrbF1Fn/Yu6CLUc9GGZFw/+hsxfluNXXmxBfRM=
|
github.com/google/nftables v0.3.0/go.mod h1:BCp9FsrbF1Fn/Yu6CLUc9GGZFw/+hsxfluNXXmxBfRM=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/mdlayher/genetlink v1.3.2 h1:KdrNKe+CTu+IbZnm/GVUMXSqBBLqcGpRDa0xkQy56gw=
|
github.com/mdlayher/genetlink v1.3.2 h1:KdrNKe+CTu+IbZnm/GVUMXSqBBLqcGpRDa0xkQy56gw=
|
||||||
github.com/mdlayher/genetlink v1.3.2/go.mod h1:tcC3pkCrPUGIKKsCsp0B3AdaaKuHtaxoJRz3cc+528o=
|
github.com/mdlayher/genetlink v1.3.2/go.mod h1:tcC3pkCrPUGIKKsCsp0B3AdaaKuHtaxoJRz3cc+528o=
|
||||||
github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 h1:A1Cq6Ysb0GM0tpKMbdCXCIfBclan4oHk1Jb+Hrejirg=
|
github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 h1:A1Cq6Ysb0GM0tpKMbdCXCIfBclan4oHk1Jb+Hrejirg=
|
||||||
@@ -14,16 +28,24 @@ github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721 h1:RlZweED6sbSArvlE9
|
|||||||
github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721/go.mod h1:Ickgr2WtCLZ2MDGd4Gr0geeCH5HybhRJbonOgQpvSxc=
|
github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721/go.mod h1:Ickgr2WtCLZ2MDGd4Gr0geeCH5HybhRJbonOgQpvSxc=
|
||||||
github.com/oschwald/maxminddb-golang v1.13.1 h1:G3wwjdN9JmIK2o/ermkHM+98oX5fS+k5MbwsmL4MRQE=
|
github.com/oschwald/maxminddb-golang v1.13.1 h1:G3wwjdN9JmIK2o/ermkHM+98oX5fS+k5MbwsmL4MRQE=
|
||||||
github.com/oschwald/maxminddb-golang v1.13.1/go.mod h1:K4pgV9N/GcK694KSTmVSDTODk4IsCNThNdTmnaBZ/F8=
|
github.com/oschwald/maxminddb-golang v1.13.1/go.mod h1:K4pgV9N/GcK694KSTmVSDTODk4IsCNThNdTmnaBZ/F8=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
|
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
|
||||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
|
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
|
||||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
|
||||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
|
||||||
|
github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
|
||||||
|
github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
|
||||||
github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0=
|
github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0=
|
||||||
github.com/vishvananda/netlink v1.3.1/go.mod h1:ARtKouGSTGchR8aMwmkzC0qiNPrrWO5JS/XMVl45+b4=
|
github.com/vishvananda/netlink v1.3.1/go.mod h1:ARtKouGSTGchR8aMwmkzC0qiNPrrWO5JS/XMVl45+b4=
|
||||||
github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY=
|
github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY=
|
||||||
github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
|
github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
|
||||||
|
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||||
|
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||||
|
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||||
|
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
||||||
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
|
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
|
||||||
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
|
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
|
||||||
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
|
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
|
||||||
@@ -42,5 +64,3 @@ golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173 h1:/jFs0duh4rdb8uI
|
|||||||
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173/go.mod h1:tkCQ4FQXmpAgYVh++1cq16/dH4QJtmvpRv19DWGAHSA=
|
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173/go.mod h1:tkCQ4FQXmpAgYVh++1cq16/dH4QJtmvpRv19DWGAHSA=
|
||||||
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 h1:3GDAcqdIg1ozBNLgPy4SLT84nfcBjr6rhGtXYtrkWLU=
|
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 h1:3GDAcqdIg1ozBNLgPy4SLT84nfcBjr6rhGtXYtrkWLU=
|
||||||
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10/go.mod h1:T97yPqesLiNrOYxkwmhMI0ZIlJDm+p0PMR8eRVeR5tQ=
|
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10/go.mod h1:T97yPqesLiNrOYxkwmhMI0ZIlJDm+p0PMR8eRVeR5tQ=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
|
||||||
|
|||||||
@@ -328,6 +328,12 @@ func TestAPI(t *testing.T) {
|
|||||||
bearer("GET", "/tokens", 403)
|
bearer("GET", "/tokens", 403)
|
||||||
bearer("GET", "/peers/"+id+"/setup", 403) // the link would set up a device
|
bearer("GET", "/peers/"+id+"/setup", 403) // the link would set up a device
|
||||||
|
|
||||||
|
// A full-access token manages users and tokens, but not backups.
|
||||||
|
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)
|
call("DELETE", "/peers/"+id, nil, 200)
|
||||||
if len(store.Get().Peers) != 0 {
|
if len(store.Get().Peers) != 0 {
|
||||||
t.Fatal("peer not deleted")
|
t.Fatal("peer not deleted")
|
||||||
@@ -877,3 +883,241 @@ func TestUsers(t *testing.T) {
|
|||||||
}
|
}
|
||||||
admin("PATCH", "/settings", map[string]any{"adminUsername": "x"}, 400)
|
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", map[string]bool{"passkey": true}, 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,933 @@
|
|||||||
|
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),
|
||||||
|
// security keys such as a YubiKey and passkeys (both WebAuthn), plus
|
||||||
|
// one-time recovery codes. 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 security key or passkey.
|
||||||
|
type MFAKey struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Passkey bool `json:"passkey"` // discoverable: signs in without a password
|
||||||
|
Created time.Time `json:"created"`
|
||||||
|
LastUsed *time.Time `json:"lastUsed,omitempty"`
|
||||||
|
Credential webauthn.Credential `json:"credential"`
|
||||||
|
}
|
||||||
|
|
||||||
|
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 security keys and 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("security keys and 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 security key challenge, once asked for
|
||||||
|
}
|
||||||
|
|
||||||
|
type ceremony struct {
|
||||||
|
userID string // "" for a passkey sign-in
|
||||||
|
passkey bool
|
||||||
|
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. a.mu must
|
||||||
|
// be held.
|
||||||
|
func (a *Auth) failLocked(ip string) {
|
||||||
|
f := a.fails[ip]
|
||||||
|
if f == nil {
|
||||||
|
f = &failState{}
|
||||||
|
a.fails[ip] = f
|
||||||
|
}
|
||||||
|
f.count++
|
||||||
|
if f.count >= maxFailures {
|
||||||
|
f.count = 0
|
||||||
|
f.until = time.Now().Add(lockoutTime)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Auth) lockedLocked(ip string) bool {
|
||||||
|
f := a.fails[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, 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 security keys or passkeys.
|
||||||
|
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 security key 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", "security key: "+err.Error())
|
||||||
|
a.signInFailed(w, errors.New("the security key was not accepted"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.keyUsed(u.ID, cred)
|
||||||
|
a.auth.dropTicket(id)
|
||||||
|
a.signedIn(w, r, u, "security key")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 k.Passkey && 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"`
|
||||||
|
Passkey bool `json:"passkey"`
|
||||||
|
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.Passkey, 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 security key ({"passkey": false}) or a passkey.
|
||||||
|
func (a *App) keyBegin(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var in struct{ Passkey bool }
|
||||||
|
if err := readJSON(r, &in); err != nil {
|
||||||
|
writeErr(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
wa, err := a.webAuthn(r)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, err)
|
||||||
|
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.ResidentKeyRequirementDiscouraged, UserVerification: protocol.VerificationDiscouraged}
|
||||||
|
if in.Passkey {
|
||||||
|
sel = protocol.AuthenticatorSelection{ResidentKey: protocol.ResidentKeyRequirementRequired, UserVerification: protocol.VerificationRequired}
|
||||||
|
}
|
||||||
|
opts, data, err := wa.BeginRegistration(waUser{u}, webauthn.WithAuthenticatorSelection(sel), webauthn.WithExclusions(exclude))
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.auth.mu.Lock()
|
||||||
|
a.auth.mfa.enrolls[p.UserID] = &ceremony{userID: p.UserID, passkey: in.Passkey, data: data, expires: time.Now().Add(ticketTTL)}
|
||||||
|
a.auth.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 = map[bool]string{false: "Security key", true: "Passkey"}[cer.passkey]
|
||||||
|
}
|
||||||
|
if len(name) > maxKeyName {
|
||||||
|
name = name[:maxKeyName]
|
||||||
|
}
|
||||||
|
var codes []string
|
||||||
|
key := MFAKey{ID: newID(), Name: name, Passkey: cer.passkey, Created: time.Now().UTC(), Credential: *cred}
|
||||||
|
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, map[bool]string{false: "security key added", true: "passkey added"}[cer.passkey], "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, "security key 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, "keys": 0, "passkeys": 0}
|
||||||
|
if m := u.MFA; m != nil {
|
||||||
|
keys, passkeys := 0, 0
|
||||||
|
for _, k := range m.Keys {
|
||||||
|
if k.Passkey {
|
||||||
|
passkeys++
|
||||||
|
} else {
|
||||||
|
keys++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out["totp"], out["keys"], out["passkeys"] = m.TOTPSecret != "", keys, passkeys
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 195 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 147 KiB |
|
After Width: | Height: | Size: 81 KiB |
@@ -13,14 +13,15 @@ import (
|
|||||||
// everyone changes their own password with the current one.
|
// everyone changes their own password with the current one.
|
||||||
|
|
||||||
type userView struct {
|
type userView struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Note string `json:"note"`
|
Note string `json:"note"`
|
||||||
MustChangePassword bool `json:"mustChangePassword"`
|
MustChangePassword bool `json:"mustChangePassword"`
|
||||||
Created time.Time `json:"created"`
|
Created time.Time `json:"created"`
|
||||||
LastLogin *tokenUse `json:"lastLogin"` // since the service started
|
LastLogin *tokenUse `json:"lastLogin"` // since the service started
|
||||||
Tokens int `json:"tokens"`
|
Tokens int `json:"tokens"`
|
||||||
You bool `json:"you"`
|
You bool `json:"you"`
|
||||||
|
MFA map[string]any `json:"mfa"` // {"totp": bool, "keys": n, "passkeys": n}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) userView(c *Config, u *User, me string) userView {
|
func (a *App) userView(c *Config, u *User, me string) userView {
|
||||||
@@ -30,7 +31,7 @@ func (a *App) userView(c *Config, u *User, me string) userView {
|
|||||||
n++
|
n++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return userView{u.ID, u.Username, u.Note, u.MustChangePassword, u.Created, a.auth.LastLogin(u.ID), n, u.ID == me}
|
return userView{u.ID, u.Username, u.Note, u.MustChangePassword, u.Created, a.auth.LastLogin(u.ID), n, u.ID == me, mfaSummary(u)}
|
||||||
}
|
}
|
||||||
|
|
||||||
// username names a user for lists, or "" if the ID is unknown.
|
// username names a user for lists, or "" if the ID is unknown.
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
"embed"
|
"embed"
|
||||||
|
"encoding/hex"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The web UI and its icons are built into the binary. The UI talks only to
|
// The web UI and its icons are built into the binary. The UI talks only to
|
||||||
@@ -11,11 +16,59 @@ import (
|
|||||||
//go:embed index.html setup.html app.js setup.js app.css favicon.svg apple-touch-icon.png ShipporiMinchoB1-ExtraBold.woff2
|
//go:embed index.html setup.html app.js setup.js app.css favicon.svg apple-touch-icon.png ShipporiMinchoB1-ExtraBold.woff2
|
||||||
var webFiles embed.FS
|
var webFiles embed.FS
|
||||||
|
|
||||||
func webHandler() http.Handler {
|
// The pages load app.js, setup.js and app.css with ?v=<hash of the file>, so
|
||||||
|
// a new binary makes browsers fetch the new files, and a fingerprinted file
|
||||||
|
// can be cached for good.
|
||||||
|
var (
|
||||||
|
assetHash = map[string]string{}
|
||||||
|
indexPage []byte
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
for _, name := range []string{"app.js", "setup.js", "app.css"} {
|
||||||
|
b, err := webFiles.ReadFile(name)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
sum := sha256.Sum256(b)
|
||||||
|
assetHash[name] = hex.EncodeToString(sum[:5])
|
||||||
|
}
|
||||||
|
b, err := webFiles.ReadFile("index.html")
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
indexPage = fingerprint(b, "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
// fingerprint adds ?v=<hash> to the page's references to base + file.
|
||||||
|
func fingerprint(page []byte, base string) []byte {
|
||||||
|
s := string(page)
|
||||||
|
for name, h := range assetHash {
|
||||||
|
s = strings.ReplaceAll(s, `"`+base+name+`"`, `"`+base+name+"?v="+h+`"`)
|
||||||
|
}
|
||||||
|
return []byte(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) webHandler() http.Handler {
|
||||||
files := http.FileServerFS(webFiles)
|
files := http.FileServerFS(webFiles)
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if d := a.store.Get().Decoy; d.Enabled {
|
||||||
|
serveDecoy(w, r, d.Page)
|
||||||
|
return
|
||||||
|
}
|
||||||
switch r.URL.Path {
|
switch r.URL.Path {
|
||||||
case "/", "/app.js", "/setup.js", "/app.css", "/favicon.svg", "/apple-touch-icon.png":
|
case "/":
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
|
_, _ = w.Write(indexPage)
|
||||||
|
case "/app.js", "/setup.js", "/app.css":
|
||||||
|
if v := r.URL.Query().Get("v"); v != "" && v == assetHash[r.URL.Path[1:]] {
|
||||||
|
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||||
|
} else {
|
||||||
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
|
}
|
||||||
|
files.ServeHTTP(w, r)
|
||||||
|
case "/favicon.svg", "/apple-touch-icon.png":
|
||||||
w.Header().Set("Cache-Control", "no-cache")
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
files.ServeHTTP(w, r)
|
files.ServeHTTP(w, r)
|
||||||
case "/ShipporiMinchoB1-ExtraBold.woff2":
|
case "/ShipporiMinchoB1-ExtraBold.woff2":
|
||||||
@@ -30,15 +83,50 @@ func webHandler() http.Handler {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// setupAllowed reports whether the setup page and its files may be served for
|
||||||
|
// this token. With the decoy on, only a live setup link gets past the decoy.
|
||||||
|
func (a *App) setupAllowed(token string) bool {
|
||||||
|
cfg := a.store.Get()
|
||||||
|
if !cfg.Decoy.Enabled {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
p := cfg.peerByToken(token)
|
||||||
|
return p != nil && !p.Setup.expired(time.Now())
|
||||||
|
}
|
||||||
|
|
||||||
// setupPage serves the page a setup link opens. The token stays in the URL;
|
// setupPage serves the page a setup link opens. The token stays in the URL;
|
||||||
// setup.js reads it from there and talks to /api/v1/setup.
|
// setup.js reads it from there and talks to /api/v1/setup. The page loads its
|
||||||
func setupPage(w http.ResponseWriter, r *http.Request) {
|
// files from under the link, so they work while the decoy hides the root.
|
||||||
|
func (a *App) setupPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
token := r.PathValue("token")
|
||||||
|
if !a.setupAllowed(token) {
|
||||||
|
serveDecoy(w, r, a.store.Get().Decoy.Page)
|
||||||
|
return
|
||||||
|
}
|
||||||
b, err := webFiles.ReadFile("setup.html")
|
b, err := webFiles.ReadFile("setup.html")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
base := "/setup/" + url.PathEscape(token) + "/"
|
||||||
|
page := strings.NewReplacer(`href="/`, `href="`+base, `src="/`, `src="`+base).Replace(string(b))
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
w.Header().Set("Cache-Control", "no-store")
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
_, _ = w.Write(b)
|
_, _ = w.Write(fingerprint([]byte(page), base))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) setupAsset(w http.ResponseWriter, r *http.Request) {
|
||||||
|
file := r.PathValue("file")
|
||||||
|
switch file {
|
||||||
|
case "setup.js", "app.css", "favicon.svg", "apple-touch-icon.png", "ShipporiMinchoB1-ExtraBold.woff2":
|
||||||
|
default:
|
||||||
|
a.webHandler().ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !a.setupAllowed(r.PathValue("token")) {
|
||||||
|
serveDecoy(w, r, a.store.Get().Decoy.Page)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
http.ServeFileFS(w, r, webFiles, file)
|
||||||
}
|
}
|
||||||
|
|||||||