7 Commits

Author SHA1 Message Date
Daniel Redetzke 74cbcfe15f Health card: public addresses up top, checks as tiles
The public IPv4 and IPv6 addresses, with their uplink, lead the card.
Every other check is a tile with a plain-word status, the raw setting
and, when it fails, what is wrong. The header counts passing or failing
checks.
2026-10-05 09:09:44 +03:00
Daniel Redetzke 38a3804b8a Revert "Send adminUsername in /settings again"
This reverts commit c846345a1c.
2026-10-05 08:54:04 +03:00
Daniel Redetzke c846345a1c Send adminUsername in /settings again
iOS app builds before Companion 2c9cc1c cannot decode /settings without
it, and App Review still tests such builds. A test keeps it in place.
2026-10-05 08:51:19 +03:00
Daniel Redetzke da627b2bc7 API tokens no longer manage users, passwords or tokens
A full-access token could create a user or reset a password, sign in as
that user and so reach backups and two-step sign-in settings. Users,
passwords, API tokens and the sign-in rules in PATCH /settings now need
a signed-in user again. /auth/me no longer returns tokenId, and
/settings no longer returns adminUsername.
2026-10-05 08:41:18 +03:00
Daniel Redetzke 511c6026ac Show peer names in plain ink instead of underlined links 2026-10-05 01:57:00 +03:00
Daniel Redetzke 85b401d05e Show dialogs again when an extension moves them
Bitwarden moves elements around in <body>. A moved dialog stayed open but
fell out of the top layer to the bottom of the page, so the Decoy
confirmation seemed to vanish and the checkbox stayed ticked unsaved.
2026-10-05 01:45:32 +03:00
Daniel Redetzke b54ff1b002 Cap concurrent password checks and count attempts before checking
Every argon2 run takes 64 MiB and nothing limited how many ran at once,
so parallel sign-in attempts could run the server out of memory (8 at
once used about 600 MB). At most two now run at once; at most 16
sign-ins wait for one, more get HTTP 429. 30 parallel sign-ins peaked
at 275 MB.

A sign-in attempt now counts toward the lockout before its password is
checked, so parallel attempts cannot get past it; a right password
takes its own attempt back. IPv6 addresses are locked out by /64.
2026-10-05 00:23:05 +03:00
7 changed files with 295 additions and 103 deletions
+14 -14
View File
@@ -227,27 +227,28 @@ After editing `config.json` by hand, run `sudo systemctl reload ghostwire`.
Base path `/api/v1`. The web interface signs in with a session cookie; every
user is an admin. Apps and scripts use `Authorization: Bearer <token>`; create
the token under Settings → Pair iOS app, or in the iOS app under Settings →
Access → API tokens. 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
GET. Full-access tokens can do everything the web interface does except backup
and restore. Users, passwords and API tokens need a full-access token even for
reading.
GET. Full-access tokens can do everything the web interface does except the
endpoints marked "signed in": users, passwords, API tokens, the sign-in rules,
backup and restore.
For a user with two-step sign-in, `POST /auth/login` answers
`{"mfa": true, "ticket": "…", "methods": ["key", "totp", "recovery"]}`
instead of starting a session; the ticket is good for 5 minutes, and one of
the `/auth/login/…` steps turns it into the session. `PATCH /settings`
`{"signin": {"requireMfa": true}}` requires two-step sign-in for every user.
`{"signin": {"requireMfa": true}}` requires two-step sign-in for every user;
only a signed-in user can change it.
`POST /users` and `POST /users/{id}/reset-password` take
`{"password": "…", "mustChangePassword": true}`; with `true` (the default) the
user can do nothing but choose a new password at the next sign-in.
```
POST /auth/login · /auth/logout GET /auth/me POST /auth/password (own password)
GET /users POST /users PATCH /users/{id} DELETE /users/{id}
POST /users/{id}/reset-password POST /users/{id}/reset-mfa
POST /auth/login · /auth/logout GET /auth/me
signed in: POST /auth/password (own password)
signed in: GET|POST /users · PATCH|DELETE /users/{id}
signed in: POST /users/{id}/reset-password · /users/{id}/reset-mfa
GET /auth/options (public: is passkey sign-in offered here)
POST /auth/login/totp · /auth/login/recovery {"ticket", "code"}
POST /auth/login/key/begin {"ticket"} · /auth/login/key/finish?ticket= (body: the WebAuthn credential)
@@ -265,8 +266,7 @@ GET /peers/{id}/latency (24 h, one point per 5 minutes)
GET /peers/{id}/setup (not read-only) DELETE /peers/{id}/setup
GET /settings PATCH /settings POST /restart
GET /logs?level=&limit=&audit=1 GET /logs/download
GET /tokens POST /tokens DELETE /tokens/{id}
signed in: GET /backup · POST /restore
signed in: GET|POST /tokens · DELETE /tokens/{id} · GET /backup · POST /restore
public: GET /setup/{token} · POST /setup/{token} {"pin"} (what a setup link opens)
```
@@ -296,9 +296,9 @@ override a drop in another table, so if ufw or firewalld is active, allow UDP
## iOS app
The native iPhone app (SwiftUI, iOS 17+) lives in its own project,
GHOSTWIRE-Companion. It does everything the web interface does except
backup and restore, and adding an authenticator app or passkeys for two-step
sign-in. Pair it in the web interface under
GHOSTWIRE-Companion. It manages peers, the server and the app settings and
shows stats and logs. Users, passwords, API tokens, two-step sign-in, backup
and restore stay in the web interface. Pair it in the web interface under
Settings → Pair iOS app: scan the QR code, or tap "Copy pairing code" and paste
it into the app's "Enter manually". Self-signed certificates are pinned during
pairing.
+15 -31
View File
@@ -97,17 +97,6 @@ func (a *App) guard(adminOnly bool, h http.HandlerFunc) http.HandlerFunc {
}
}
// fullAccess refuses read-only tokens, also for GET.
func fullAccess(h http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if who(r).Scope == "ro" {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "this token is read-only"})
return
}
h(w, r)
}
}
// applyResult saves-then-applies: the config is already stored, so a kernel
// error is reported but does not undo the change.
func (a *App) apply() string {
@@ -121,8 +110,6 @@ func (a *App) routes() http.Handler {
mux := http.NewServeMux()
g := func(pattern string, h http.HandlerFunc) { mux.HandleFunc(pattern, a.guard(false, h)) }
adm := func(pattern string, h http.HandlerFunc) { mux.HandleFunc(pattern, a.guard(true, h)) }
// full is for signed-in users and full-access tokens, even for reading.
full := func(pattern string, h http.HandlerFunc) { mux.HandleFunc(pattern, a.guard(false, fullAccess(h))) }
mux.HandleFunc("POST /api/v1/auth/login", a.login)
mux.HandleFunc("POST /api/v1/auth/logout", a.logout)
@@ -146,13 +133,13 @@ func (a *App) routes() http.Handler {
adm("DELETE /api/v1/auth/mfa/keys/{id}", a.keyRemove)
adm("POST /api/v1/auth/mfa/recovery-codes", a.newRecoveryCodesHandler)
g("GET /api/v1/auth/me", a.me)
full("POST /api/v1/auth/password", a.changePassword)
full("GET /api/v1/users", a.listUsers)
full("POST /api/v1/users", a.createUser)
full("PATCH /api/v1/users/{id}", a.patchUser)
full("POST /api/v1/users/{id}/reset-password", a.resetPassword)
full("DELETE /api/v1/users/{id}", a.deleteUser)
full("POST /api/v1/users/{id}/reset-mfa", a.resetMFA)
adm("POST /api/v1/auth/password", a.changePassword)
adm("GET /api/v1/users", a.listUsers)
adm("POST /api/v1/users", a.createUser)
adm("PATCH /api/v1/users/{id}", a.patchUser)
adm("POST /api/v1/users/{id}/reset-password", a.resetPassword)
adm("DELETE /api/v1/users/{id}", a.deleteUser)
adm("POST /api/v1/users/{id}/reset-mfa", a.resetMFA)
g("GET /api/v1/status", a.status)
g("GET /api/v1/stats", a.allStats)
@@ -182,13 +169,14 @@ func (a *App) routes() http.Handler {
mux.HandleFunc("POST /api/v1/setup/{token}", a.setupRedeem)
// Full-access tokens (the iOS app) may change app settings, read logs and
// manage users and tokens. Backups need a signed-in user.
// restart. Users, passwords, API tokens, the sign-in rules and backups
// need a signed-in user.
g("GET /api/v1/settings", a.getSettings)
g("PATCH /api/v1/settings", a.patchSettings)
g("POST /api/v1/restart", a.restart)
full("GET /api/v1/tokens", a.listTokens)
full("POST /api/v1/tokens", a.createToken)
full("DELETE /api/v1/tokens/{id}", a.deleteToken)
adm("GET /api/v1/tokens", a.listTokens)
adm("POST /api/v1/tokens", a.createToken)
adm("DELETE /api/v1/tokens/{id}", a.deleteToken)
g("GET /api/v1/logs", a.logs)
g("GET /api/v1/logs/download", a.downloadLog)
adm("GET /api/v1/backup", a.backup)
@@ -232,7 +220,7 @@ func (a *App) login(w http.ResponseWriter, r *http.Request) {
if err != nil {
slog.Warn("login failed", "user", in.Username, "remote", ip, "reason", err.Error())
code := http.StatusUnauthorized
if errors.Is(err, errLocked) {
if errors.Is(err, errLocked) || errors.Is(err, errBusy) {
code = http.StatusTooManyRequests
}
writeJSON(w, code, map[string]string{"error": err.Error()})
@@ -270,9 +258,6 @@ func (a *App) me(w http.ResponseWriter, r *http.Request) {
"id": p.UserID, "name": p.Name, "isAdmin": p.IsAdmin, "scope": p.Scope,
"mustChangePassword": p.MustChangePassword, "mfaSetupRequired": p.MFASetupRequired, "version": version, "session": p.Session,
}
if p.TokenID != "" {
out["tokenId"] = p.TokenID // lets an app find its own token in /tokens
}
if _, u := a.store.Get().userByID(p.UserID); u != nil {
out["username"], out["note"], out["created"] = u.Username, u.Note, u.Created
}
@@ -1014,7 +999,6 @@ func (a *App) getSettings(w http.ResponseWriter, r *http.Request) {
"decoy": cfg.Decoy,
"signin": cfg.SignIn,
"geo": a.geoStatus(),
"adminUsername": a.username(cfg, who(r).UserID), // kept for older iOS app versions
"fingerprint": a.tls.Fingerprint(),
"logPath": a.logPath,
})
@@ -1026,8 +1010,8 @@ func (a *App) patchSettings(w http.ResponseWriter, r *http.Request) {
writeErr(w, err)
return
}
if _, ok := m["adminUsername"]; ok {
writeErr(w, badRequest("usernames are changed under /users"))
if _, ok := m["signin"]; ok && !who(r).IsAdmin {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "API tokens cannot change the sign-in rules; sign in to the web interface"})
return
}
var restart bool
+24 -4
View File
@@ -144,6 +144,8 @@ td { padding: 12px; border-bottom: 1px solid var(--line-2); vertical-align: midd
tr:last-child td { border-bottom: 0; }
.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
td .note { font-size: 12px; color: var(--ink-3); }
a.pname { color: var(--ink); font-weight: 600; text-decoration: none; }
a.pname:hover, a.pname:focus-visible { color: var(--ink); text-decoration: underline; text-underline-offset: 3px; }
.empty { padding: 24px 12px; margin: 0; text-align: center; color: var(--ink-3); }
/* forms */
@@ -171,10 +173,28 @@ fieldset { border: 0; margin: 0; padding: 0; min-width: 0; display: flex; flex-d
.kv dt { color: var(--ink-2); }
.kv dd { margin: 0; min-width: 0; overflow-wrap: anywhere; }
/* checks */
.chk { display: flex; gap: 10px; align-items: center; padding: 10px 0; border-bottom: 1px solid var(--line-2); font-size: 13px; }
.chk:last-child { border-bottom: 0; }
.chk b { font-weight: 500; min-width: 160px; }
/* health: public addresses, then one tile per check */
.hchead { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; }
.hchead > span { font-size: 13px; color: var(--ink-2); }
.hchead > span.bad { color: var(--bad-ink); font-weight: 500; }
.hcaddrs { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 12px; margin-top: 16px; }
.hcaddr { background: var(--ground); border-radius: 10px; padding: 14px 16px; min-width: 0; }
.hcaddr .l { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--ink-2); }
.hcaddr .v { margin-top: 4px; font-size: 15px; font-weight: 500; overflow-wrap: anywhere; }
.hcaddr .v.mono { font-size: 20px; }
.hcaddr .n { font-family: var(--sans); font-size: 12px; font-weight: 400; color: var(--ink-2); }
.hcaddr.bad { background: #fdf6f5; box-shadow: inset 0 0 0 1px #e6b3b0; }
.hcaddr.bad .v { color: var(--bad-ink); }
.hctiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 12px; margin-top: 12px; }
.hctile { border: 1px solid var(--line); border-radius: 10px; padding: 12px 14px; display: flex; flex-direction: column; gap: 4px; min-width: 0; }
.hctile .l { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--ink-2); }
.hctile .l > span:first-child { flex: 1; min-width: 0; }
.hctile .s { font-size: 15px; font-weight: 500; }
.hctile .r { font-size: 11.5px; color: var(--ink-3); overflow-wrap: anywhere; }
.hctile .p { font-size: 12.5px; color: var(--bad-ink); overflow-wrap: anywhere; }
.hctile.bad { border-color: #e6b3b0; background: #fdf6f5; }
.hctile.bad .s { color: var(--bad-ink); }
@media (max-width: 640px) { .hctiles { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
/* activity */
.ev { display: flex; gap: 12px; padding: 10px 0; border-bottom: 1px solid var(--line-2); font-size: 13px; }
+79 -7
View File
@@ -257,13 +257,26 @@
else if (okMsg) toast(okMsg);
}
// dialog shows a modal dialog. Extensions such as Bitwarden move elements
// around in <body>; a moved dialog stays open but drops out of the top
// layer to the bottom of the page, so it is shown as a modal again. That
// goes through close(), whose close event arrives after the dialog is open
// again and is kept from the listeners added by callers.
function dialog(build) {
const d = h('dialog');
const close = () => d.close();
d.addEventListener('close', () => d.remove());
const moved = new MutationObserver(() => {
if (d.open && d.isConnected && !d.matches(':modal')) { d.close(); d.showModal(); }
});
d.addEventListener('close', (e) => {
if (d.open) { e.stopImmediatePropagation(); return; }
moved.disconnect();
d.remove();
});
d.append(build(close));
document.body.append(d);
d.showModal();
moved.observe(document.body, { childList: true, subtree: true });
return d;
}
@@ -1111,7 +1124,7 @@
return hit && keep;
});
tbody.replaceChildren(...sorted(rows).map((p) => h('tr', null,
h('td', null, h('a', { href: '#/peers/' + p.id }, h('strong', null, p.name)), p.note ? h('div', { class: 'note' }, p.note) : null),
h('td', null, h('a', { class: 'pname', href: '#/peers/' + p.id }, p.name), p.note ? h('div', { class: 'note' }, p.note) : null),
h('td', { class: 'mono' }, p.ipv4),
h('td', null, badge(peerState(p))),
h('td', { class: 'mono muted' }, p.stats.endpoint || '–',
@@ -1497,6 +1510,69 @@
const DNS_PRESETS = [['Quad9', '9.9.9.9, 149.112.112.112']];
// healthParts turns the server's checks into the Health card: the public
// address per IP family (from its uplink and public address checks), then
// one tile per other check with a plain-word status, the raw setting and,
// when it fails, what is wrong.
function healthParts(checks) {
const by = Object.fromEntries(checks.map((c) => [c.name, c]));
const addrs = [];
for (const fam of ['IPv4', 'IPv6']) {
const up = by[fam + ' uplink'], pub = by['Public ' + fam];
if (!up) continue;
const m = pub && pub.ok ? /^(\S+) \((.+)\)$/.exec(pub.detail) : null;
addrs.push({
label: 'Public ' + fam + (up.ok ? ' · ' + up.detail : ''),
ok: up.ok && (!pub || pub.ok),
value: m ? m[1] : (pub ? pub.detail : up.detail),
note: m ? m[2] : null,
mono: !!(pub && pub.ok),
});
}
const sysctl = (d) => d.replace(/^net\.ipv[46]\.(conf\.)?/, '');
const tiles = checks.filter((c) => !/^(IPv[46] uplink|Public IPv[46])$/.test(c.name)).map((c) => {
const t = { label: c.name, ok: c.ok, status: c.ok ? 'OK' : 'Problem', raw: null, problem: c.ok ? null : c.detail };
switch (c.name) {
case 'WireGuard interface': t.status = c.detail; t.problem = null; break;
case 'IPv4 forwarding': case 'IPv6 forwarding': t.status = c.ok ? 'On' : 'Off'; t.raw = sysctl(c.detail); t.problem = null; break;
case 'IPv6 router announcements': {
const [setting, ...why] = c.detail.split(': ');
t.label = 'Router announcements';
t.status = !c.ok ? 'Ignored' : setting.endsWith('=0') ? 'Not used' : 'Accepted';
t.raw = sysctl(setting);
t.problem = c.ok || !why.length ? null : why.join(': ');
break;
}
case 'nftables rules':
t.status = c.ok ? 'Present' : 'Missing';
if (/^table /.test(c.detail)) { t.raw = c.detail.replace(/ (present|missing)$/, ''); t.problem = null; }
break;
case 'Last apply':
if (c.ok) { const iso = c.detail.replace(/^applied /, ''); t.status = ago(iso); t.title = fmtStamp(iso); } else t.status = 'Failed';
break;
case 'Latency check': t.status = c.ok ? 'Tunnel ping works' : 'Failing'; break;
}
return t;
});
return { addrs, tiles, failing: checks.filter((c) => !c.ok).length, total: checks.length };
}
function healthCard(checks) {
const hp = healthParts(checks);
const dot = (ok) => [h('span', { class: ok ? 'dot ok' : 'dot bad' }), h('span', { class: 'sr' }, ok ? 'OK: ' : 'Problem: ')];
return h('section', { class: 'card', 'aria-labelledby': 'hc' },
h('div', { class: 'hchead' }, h('h2', { id: 'hc' }, 'Health'),
h('span', { class: hp.failing ? 'bad' : null }, hp.failing ? hp.failing + ' of ' + hp.total + ' checks failing' : 'All ' + hp.total + ' checks pass')),
hp.addrs.length ? h('div', { class: 'hcaddrs' }, hp.addrs.map((a) => h('div', { class: a.ok ? 'hcaddr' : 'hcaddr bad' },
h('div', { class: 'l' }, dot(a.ok), a.label),
h('div', { class: a.mono ? 'v mono' : 'v' }, a.value, a.note ? h('span', { class: 'n' }, ' ' + a.note) : null)))) : null,
h('div', { class: 'hctiles' }, hp.tiles.map((t) => h('div', { class: t.ok ? 'hctile' : 'hctile bad', title: t.title || null },
h('div', { class: 'l' }, h('span', null, t.label), dot(t.ok)),
h('div', { class: 's' }, t.status),
t.raw ? h('div', { class: 'r mono' }, t.raw) : null,
t.problem ? h('div', { class: 'p' }, t.problem) : null))));
}
async function viewServer(wrap) {
const [srv, st] = await Promise.all([api('GET', '/server'), api('GET', '/status')]);
const orig = JSON.parse(JSON.stringify(srv));
@@ -1567,11 +1643,7 @@
fill(wrap,
h('div', null, h('h1', null, 'Server'), h('p', { class: 'sub' }, 'WireGuard interface, address plan, client defaults and firewall')),
result,
h('section', { class: 'card', 'aria-labelledby': 'hc' },
h('h2', { id: 'hc' }, 'Health'),
h('div', { style: { marginTop: '8px' } }, st.checks.map((c) => h('div', { class: 'chk' },
h('span', { class: c.ok ? 'dot ok' : 'dot bad' }), h('span', { class: 'sr' }, c.ok ? 'OK: ' : 'Problem: '),
h('b', null, c.name), h('span', { class: c.ok ? 'muted' : null }, c.detail))))),
healthCard(st.checks),
h('section', { class: 'card', 'aria-labelledby': 'if' },
h('h2', { id: 'if' }, 'Interface'),
+60 -24
View File
@@ -10,6 +10,7 @@ import (
"fmt"
"net"
"net/http"
"net/netip"
"strings"
"sync"
"time"
@@ -26,12 +27,23 @@ const (
argonKeyLen = 32
)
// Every argon2 run takes argonMemory (64 MiB). argonSlots caps how many run
// at once, so a burst of sign-ins cannot run the server out of memory: two
// slots are 128 MiB at most.
var argonSlots = make(chan struct{}, 2)
func argonKey(pw, salt []byte, t, m uint32, p uint8, n uint32) []byte {
argonSlots <- struct{}{}
defer func() { <-argonSlots }()
return argon2.IDKey(pw, salt, t, m, p, n)
}
func hashPassword(pw string) (string, error) {
salt := make([]byte, 16)
if _, err := rand.Read(salt); err != nil {
return "", err
}
key := argon2.IDKey([]byte(pw), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
key := argonKey([]byte(pw), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
b64 := base64.RawStdEncoding
return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
argon2.Version, argonMemory, argonTime, argonThreads, b64.EncodeToString(salt), b64.EncodeToString(key)), nil
@@ -54,7 +66,7 @@ func verifyPassword(encoded, pw string) bool {
if err1 != nil || err2 != nil {
return false
}
got := argon2.IDKey([]byte(pw), salt, t, m, p, uint32(len(want)))
got := argonKey([]byte(pw), salt, t, m, p, uint32(len(want)))
return subtle.ConstantTimeCompare(got, want) == 1
}
@@ -140,13 +152,17 @@ type Auth struct {
sessions map[string]*session
used map[string]tokenUse
logins map[string]tokenUse // last sign-in per user ID
fails map[string]*failState
fails map[string]*failState // by lockKey
waiting int // sign-ins waiting for or running a password check
mfa mfaState
}
const (
maxFailures = 5
lockoutTime = 15 * time.Minute
// maxWaiting sign-ins may wait for a password check; more are turned
// away until the queue is shorter.
maxWaiting = 16
)
func newAuth(s *Store) *Auth {
@@ -155,23 +171,51 @@ func newAuth(s *Store) *Auth {
func cookieName() string { return appName + "_session" }
var errLocked = errors.New("too many failed attempts, try again later")
var (
errLocked = errors.New("too many failed attempts, try again later")
errBusy = errors.New("too many sign-ins at once, try again in a moment")
)
// lockKey is what failed sign-ins are counted by: the IPv4 address, or the
// /64 network of an IPv6 address, since one device can pick any address in
// its /64.
func lockKey(ip string) string {
a, err := netip.ParseAddr(ip)
if err != nil || a.Unmap().Is4() {
return ip
}
p, _ := a.Prefix(64)
return p.String()
}
// Login checks the credentials and returns a new session id, or, for a user
// with two-step sign-in, a ticket for the second step.
func (a *Auth) Login(user, pw, ip string) (sessionID, ticket string, err error) {
a.mu.Lock()
f := a.fails[ip]
if f != nil && time.Now().Before(f.until) {
a.mu.Unlock()
return "", "", errLocked
}
a.mu.Unlock()
cfg := a.store.Get()
if !cfg.passwordSet() {
return "", "", errors.New("no password is set; run: " + appName + " passwd")
}
// The attempt counts as failed before the password is checked, so
// parallel attempts cannot get past the lockout; a right password takes
// it back.
a.mu.Lock()
if a.lockedLocked(ip) {
a.mu.Unlock()
return "", "", errLocked
}
if a.waiting >= maxWaiting {
a.mu.Unlock()
return "", "", errBusy
}
a.waiting++
undo := a.failLocked(ip)
a.mu.Unlock()
defer func() {
a.mu.Lock()
a.waiting--
a.mu.Unlock()
}()
// An unknown username costs as much time as a wrong password, so the
// answer time does not tell which usernames exist.
u := cfg.userByName(strings.TrimSpace(user))
@@ -185,21 +229,13 @@ func (a *Auth) Login(user, pw, ip string) (sessionID, ticket string, err error)
a.mu.Lock()
defer a.mu.Unlock()
if !okUser || !okPw {
if f == nil {
f = &failState{}
a.fails[ip] = f
}
f.count++
if f.count >= maxFailures {
f.count = 0
f.until = time.Now().Add(lockoutTime)
}
return "", "", errors.New("wrong username or password")
}
undo()
if u.hasMFA() {
return "", a.newTicketLocked(u, ip), nil
}
delete(a.fails, ip)
delete(a.fails, lockKey(ip))
a.logins[u.ID] = tokenUse{At: time.Now(), IP: ip}
return a.newSessionLocked(cfg, u, sessionInfo{Started: time.Now(), IP: ip}), "", nil
}
@@ -321,9 +357,9 @@ func (a *Auth) sweep() {
delete(a.sessions, id)
}
}
for ip, f := range a.fails {
for key, f := range a.fails {
if now.After(f.until) && f.count == 0 {
delete(a.fails, ip)
delete(a.fails, key)
}
}
for id, t := range a.mfa.tickets {
+75 -6
View File
@@ -14,6 +14,7 @@ import (
"path/filepath"
"slices"
"strings"
"sync"
"testing"
"time"
)
@@ -312,8 +313,13 @@ func TestAPI(t *testing.T) {
secret := tok["token"].(string)
// Read-only token: GET works, changes are refused, admin endpoints too.
bearer := func(method, path string, want int) {
req, _ := http.NewRequest(method, srv.URL+"/api/v1"+path, nil)
bearer := func(method, path string, want int, body ...any) {
var rd io.Reader
if len(body) > 0 {
b, _ := json.Marshal(body[0])
rd = bytes.NewReader(b)
}
req, _ := http.NewRequest(method, srv.URL+"/api/v1"+path, rd)
req.Header.Set("Authorization", "Bearer "+secret)
resp, err := http.DefaultClient.Do(req)
if err != nil {
@@ -329,10 +335,20 @@ func TestAPI(t *testing.T) {
bearer("GET", "/tokens", 403)
bearer("GET", "/peers/"+id+"/setup", 403) // the link would set up a device
// A full-access token manages users and tokens, but not backups.
// A full-access token changes settings, but users, passwords, tokens,
// the sign-in rules and backups need a signed-in user.
secret = call("POST", "/tokens", map[string]string{"name": "full", "scope": "rw"}, 201)["token"].(string)
bearer("GET", "/users", 200)
bearer("GET", "/tokens", 200)
uid := call("GET", "/auth/me", nil, 200)["id"].(string)
bearer("PATCH", "/settings", 200, map[string]any{"log": store.Get().Log})
bearer("PATCH", "/settings", 403, map[string]any{"signin": map[string]bool{"requireMfa": false}})
bearer("GET", "/users", 403)
bearer("POST", "/users", 403, map[string]any{"username": "eve", "password": "correct horse battery"})
bearer("POST", "/users/"+uid+"/reset-password", 403, map[string]any{"password": "correct horse battery"})
bearer("POST", "/users/"+uid+"/reset-mfa", 403)
bearer("POST", "/auth/password", 403, map[string]string{"current": "x", "new": "y"})
bearer("GET", "/tokens", 403)
bearer("POST", "/tokens", 403, map[string]string{"name": "more", "scope": "rw"})
bearer("DELETE", "/tokens/"+tok["id"].(string), 403)
bearer("GET", "/backup", 403)
call("DELETE", "/peers/"+id, nil, 200)
@@ -409,6 +425,60 @@ func TestSysctlConf(t *testing.T) {
}
}
func TestLoginLockout(t *testing.T) {
store, err := openStore(filepath.Join(t.TempDir(), "config.json"))
if err != nil {
t.Fatal(err)
}
hash, _ := hashPassword("a long test password")
_ = store.Update(func(c *Config) error { c.Users[0].PasswordHash = hash; return nil })
a := newAuth(store)
const right, wrong = "a long test password", "a wrong password"
// Ten wrong attempts at once from one /64: five are checked, the others
// are locked out before any password check.
var wg sync.WaitGroup
var mu sync.Mutex
got := map[string]int{}
for i := range 10 {
wg.Add(1)
go func() {
defer wg.Done()
_, _, err := a.Login("admin", wrong, fmt.Sprintf("2001:db8::%x", i+1))
mu.Lock()
got[err.Error()]++
mu.Unlock()
}()
}
wg.Wait()
if got["wrong username or password"] != 5 || got[errLocked.Error()] != 5 {
t.Fatalf("parallel attempts: %v", got)
}
if _, _, err := a.Login("admin", right, "2001:db8::ffff"); !errors.Is(err, errLocked) {
t.Fatalf("same /64: %v, want locked", err)
}
if _, _, err := a.Login("admin", right, "2001:db8:0:1::1"); err != nil {
t.Fatalf("other /64: %v", err)
}
// A right password takes its own attempt back. With two-step sign-in
// the earlier failures stay, so wrong codes still lead to the lockout.
_ = store.Update(func(c *Config) error { c.Users[0].MFA = &UserMFA{TOTPSecret: newTOTPSecret()}; return nil })
ip := "192.0.2.7"
for range maxFailures - 1 {
_, _, _ = a.Login("admin", wrong, ip)
}
if _, tk, err := a.Login("admin", right, ip); err != nil || tk == "" {
t.Fatalf("5th attempt, right password: ticket %q, %v", tk, err)
}
if _, _, err := a.Login("admin", wrong, ip); err == nil || errors.Is(err, errLocked) {
t.Fatalf("6th attempt: %v, want wrong password", err)
}
if _, _, err := a.Login("admin", right, ip); !errors.Is(err, errLocked) {
t.Fatalf("7th attempt: %v, want locked", err)
}
}
func TestWriteIfChanged(t *testing.T) {
p := filepath.Join(t.TempDir(), "x.conf")
if ch, err := writeIfChanged(p, "a\n", 0o644); !ch || err != nil {
@@ -914,7 +984,6 @@ func TestUsers(t *testing.T) {
if n := len(admin("GET", "/users", nil, 200)["users"].([]any)); n != 2 {
t.Fatalf("users: %d, want 2", n)
}
admin("PATCH", "/settings", map[string]any{"adminUsername": "x"}, 400)
}
// TestDecoy checks that the decoy hides the web interface but leaves the API
+19 -8
View File
@@ -236,23 +236,34 @@ func newMFAState() mfaState {
var errBadTicket = errors.New("the sign-in expired; enter your password again")
// failLocked counts a failed attempt from ip toward the lockout. a.mu must
// be held.
func (a *Auth) failLocked(ip string) {
f := a.fails[ip]
// failLocked counts a failed attempt from ip toward the lockout and returns
// a function that takes it back, for an attempt counted before it was
// checked. a.mu must be held, also when calling undo.
func (a *Auth) failLocked(ip string) (undo func()) {
key := lockKey(ip)
f := a.fails[key]
if f == nil {
f = &failState{}
a.fails[ip] = f
a.fails[key] = f
}
f.count++
if f.count >= maxFailures {
locked := f.count >= maxFailures
if locked {
f.count = 0
f.until = time.Now().Add(lockoutTime)
}
return func() {
switch {
case locked:
f.count, f.until = maxFailures-1, time.Time{}
case f.count > 0:
f.count--
}
}
}
func (a *Auth) lockedLocked(ip string) bool {
f := a.fails[ip]
f := a.fails[lockKey(ip)]
return f != nil && time.Now().Before(f.until)
}
@@ -302,7 +313,7 @@ func (a *Auth) finishSignIn(u *User, ip string) string {
cfg := a.store.Get()
a.mu.Lock()
defer a.mu.Unlock()
delete(a.fails, ip)
delete(a.fails, lockKey(ip))
a.logins[u.ID] = tokenUse{At: time.Now(), IP: ip}
return a.newSessionLocked(cfg, u, sessionInfo{Started: time.Now(), IP: ip})
}