Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 74cbcfe15f | |||
| 38a3804b8a | |||
| c846345a1c | |||
| da627b2bc7 | |||
| 511c6026ac | |||
| 85b401d05e | |||
| b54ff1b002 | |||
| a59a095691 | |||
| d32e851b74 | |||
| e3e6d04955 | |||
| 2c1b4a399a | |||
| 0a8dc8f7af | |||
| 6570611ed8 | |||
| ea13593925 |
@@ -40,19 +40,6 @@ dependencies on the server: the binary installs, updates and removes itself.
|
||||
- **HTTPS built in:** Let's Encrypt, a self-signed certificate, your own
|
||||
certificate files, or plain HTTP behind a reverse proxy.
|
||||
|
||||
## Screenshots
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
|  |  |
|
||||
| **Peers:** status, endpoint, latency and traffic at a glance | **Peer:** traffic, latency, connection history and settings |
|
||||
|  |  |
|
||||
| **Server:** health, address plan, client defaults and firewall | **Settings:** users, web interface and API tokens |
|
||||
|  |  |
|
||||
| **My account:** profile, password and your app tokens | **Sign-in** |
|
||||
|
||||
The screenshots show sample data from the built-in simulator.
|
||||
|
||||
## Security
|
||||
|
||||
- **Client private keys are never stored.** A config is shown once, as a
|
||||
@@ -65,16 +52,19 @@ The screenshots show sample data from the built-in simulator.
|
||||
`CAP_NET_ADMIN` and `CAP_NET_BIND_SERVICE`, and can write only to
|
||||
`/opt/ghostwire`.
|
||||
- **Sign-in:** one or more users, all admins. Passwords are stored as argon2id hashes.
|
||||
After 5 failed attempts, sign-in is locked for 15 minutes. Sessions use an
|
||||
After 5 failed attempts from one IP address, sign-in from it is locked for 15
|
||||
minutes; wrong two-step codes count too. Sessions use an
|
||||
HttpOnly, SameSite=Strict cookie and last 12 hours by default.
|
||||
- **Two-step sign-in:** each user can add an authenticator app (TOTP), 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.
|
||||
- **Two-step sign-in:** each user can add an authenticator app (TOTP) and
|
||||
passkeys under My account. A passkey signs in on its own, without username
|
||||
and password, and also works as the second step after a password. It can live
|
||||
on the device (Touch ID, Face ID, Windows Hello), in a password manager, or on
|
||||
a YubiKey with a PIN set. Turning it on gives 10 one-time recovery codes. An
|
||||
admin can require it for everyone (Settings → Sign-in) and reset it for a user
|
||||
who lost their phone or key. Passkeys use WebAuthn and need the server's
|
||||
domain name with a trusted certificate (Let's Encrypt, certificate files, or a
|
||||
reverse proxy); on a self-signed certificate or an IP address, only the
|
||||
authenticator app is offered. API tokens never need a second step.
|
||||
- **API tokens** are stored only as hashes and can be read-only or full access.
|
||||
- `config.json` holds the server private key and is readable only by the
|
||||
service (0600).
|
||||
@@ -227,7 +217,7 @@ After editing `config.json` by hand, run `sudo systemctl reload ghostwire`.
|
||||
| File | Content |
|
||||
|---|---|
|
||||
| `GHOSTWIRE` | the program |
|
||||
| `config.json` | all settings, server key, peers, token hashes (0600) |
|
||||
| `config.json` | all settings, server key, peers, pending setup links with their PINs, user password hashes, authenticator app secrets, passkeys, recovery code and token hashes (0600) |
|
||||
| `stats.json` | traffic and connection history per peer |
|
||||
| `geo-country.mmdb`, `geo-asn.mmdb` | DB-IP Lite databases for country and network lookups |
|
||||
| `GHOSTWIRE.jsonl` | log, one JSON object per line. Changes carry `"audit":true` |
|
||||
@@ -239,30 +229,32 @@ Base path `/api/v1`. The web interface signs in with a session cookie; every
|
||||
user is an admin. Apps and scripts use `Authorization: Bearer <token>`; create
|
||||
the token under Settings → Pair iOS app. A token belongs to the user who made
|
||||
it and is revoked when that user is deleted. A read-only token may only use
|
||||
GET. Full-access tokens can do everything the web interface does except backup
|
||||
and restore. Users, passwords and API tokens need a full-access token even for
|
||||
reading.
|
||||
GET. Full-access tokens can do everything the web interface does except the
|
||||
endpoints marked "signed in": users, passwords, API tokens, the sign-in rules,
|
||||
backup and restore.
|
||||
|
||||
For a user with two-step sign-in, `POST /auth/login` answers
|
||||
`{"mfa": true, "ticket": "…", "methods": ["key", "totp", "recovery"]}`
|
||||
instead of starting a session; the ticket is good for 5 minutes, and one of
|
||||
the `/auth/login/…` steps turns it into the session. `PATCH /settings`
|
||||
`{"signin": {"requireMfa": true}}` requires two-step sign-in for every user.
|
||||
`{"signin": {"requireMfa": true}}` requires two-step sign-in for every user;
|
||||
only a signed-in user can change it.
|
||||
|
||||
`POST /users` and `POST /users/{id}/reset-password` take
|
||||
`{"password": "…", "mustChangePassword": true}`; with `true` (the default) the
|
||||
user can do nothing but choose a new password at the next sign-in.
|
||||
|
||||
```
|
||||
POST /auth/login · /auth/logout GET /auth/me POST /auth/password (own password)
|
||||
GET /users POST /users PATCH /users/{id} DELETE /users/{id}
|
||||
POST /users/{id}/reset-password POST /users/{id}/reset-mfa
|
||||
POST /auth/login · /auth/logout GET /auth/me
|
||||
signed in: POST /auth/password (own password)
|
||||
signed in: GET|POST /users · PATCH|DELETE /users/{id}
|
||||
signed in: POST /users/{id}/reset-password · /users/{id}/reset-mfa
|
||||
GET /auth/options (public: is passkey sign-in offered here)
|
||||
POST /auth/login/totp · /auth/login/recovery {"ticket", "code"}
|
||||
POST /auth/login/key/begin {"ticket"} · /auth/login/key/finish?ticket= (body: the WebAuthn credential)
|
||||
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/keys/begin · /auth/mfa/keys/finish?name= · PATCH|DELETE /auth/mfa/keys/{id}
|
||||
signed in: POST /auth/mfa/recovery-codes
|
||||
GET /status GET /stats?range=24h|7d|30d|90d
|
||||
GET /server PATCH /server POST /server/rotate-key GET /server/detect-ip
|
||||
@@ -274,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)
|
||||
```
|
||||
|
||||
@@ -305,8 +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
|
||||
password, API tokens and backups. Pair it in the web interface under
|
||||
GHOSTWIRE-Companion. It manages peers, the server and the app settings and
|
||||
shows stats and logs. Users, passwords, API tokens, two-step sign-in, backup
|
||||
and restore stay in the web interface. Pair it in the web interface under
|
||||
Settings → Pair iOS app: scan the QR code, or tap "Copy pairing code" and paste
|
||||
it into the app's "Enter manually". Self-signed certificates are pinned during
|
||||
pairing.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -1008,15 +993,14 @@ func (a *App) issueConfig(w http.ResponseWriter, r *http.Request) {
|
||||
func (a *App) getSettings(w http.ResponseWriter, r *http.Request) {
|
||||
cfg := a.store.Get()
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"web": cfg.Web,
|
||||
"log": cfg.Log,
|
||||
"stats": cfg.Stats,
|
||||
"decoy": cfg.Decoy,
|
||||
"signin": cfg.SignIn,
|
||||
"geo": a.geoStatus(),
|
||||
"adminUsername": a.username(cfg, who(r).UserID), // kept for older iOS app versions
|
||||
"fingerprint": a.tls.Fingerprint(),
|
||||
"logPath": a.logPath,
|
||||
"web": cfg.Web,
|
||||
"log": cfg.Log,
|
||||
"stats": cfg.Stats,
|
||||
"decoy": cfg.Decoy,
|
||||
"signin": cfg.SignIn,
|
||||
"geo": a.geoStatus(),
|
||||
"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
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -144,7 +144,6 @@
|
||||
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(', ');
|
||||
}
|
||||
@@ -258,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;
|
||||
}
|
||||
|
||||
@@ -636,7 +648,7 @@
|
||||
// allows it.
|
||||
const passkeyRow = h('div', { class: 'loginalt', hidden: true },
|
||||
h('div', { class: 'or' }, 'or'),
|
||||
h('button', { type: 'button', class: 'btn altbtn', onClick: async () => {
|
||||
h('button', { type: 'button', class: 'btn altbtn signin', onClick: async () => {
|
||||
err.textContent = '';
|
||||
try {
|
||||
const b = await api('POST', '/auth/login/passkey/begin');
|
||||
@@ -644,7 +656,7 @@
|
||||
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'));
|
||||
} }, icon('key', 18), h('span', { class: 'en' }, 'Sign in with a passkey'), h('span', { class: 'ja', lang: 'ja', 'aria-hidden': 'true' }, 'パスキーでサインイン')));
|
||||
if (window.PublicKeyCredential) {
|
||||
api('GET', '/auth/options').then((o) => { passkeyRow.hidden = !o.passkeys; }).catch(() => {});
|
||||
}
|
||||
@@ -706,7 +718,7 @@
|
||||
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.';
|
||||
if (x && x.name === 'SecurityError') return 'Passkeys need this site on its domain name with a trusted certificate.';
|
||||
return x.message;
|
||||
}
|
||||
|
||||
@@ -720,11 +732,11 @@
|
||||
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.'],
|
||||
key: ['Use your passkey', 'Confirm with Touch ID, Face ID, Windows Hello or your password manager, or insert your YubiKey and touch it.'],
|
||||
totp: ['Enter the code', 'The 6-digit code from your authenticator app.'],
|
||||
recovery: ['Use a recovery code', 'One of the codes you saved when you set up two-step sign-in. Each works once.'],
|
||||
};
|
||||
const LINKS = { key: 'Use a security key instead', totp: 'Use an authenticator code instead', recovery: 'Use a recovery code' };
|
||||
const LINKS = { key: 'Use a passkey instead', totp: 'Use an authenticator code instead', recovery: 'Use a recovery code' };
|
||||
const head = h('div', { class: 'logintext' });
|
||||
const draw = () => {
|
||||
const err = h('p', { class: 'err-text', role: 'alert' });
|
||||
@@ -733,7 +745,7 @@
|
||||
.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 btn = h('button', { type: 'button', class: 'btn primary' }, 'Use passkey');
|
||||
const go = async () => {
|
||||
err.textContent = '';
|
||||
btn.disabled = true;
|
||||
@@ -788,8 +800,7 @@
|
||||
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,
|
||||
keys ? h('button', { type: 'button', class: 'btn altbtn', onClick: () => addPasskey(done) }, 'Use a passkey') : null,
|
||||
h('div', { class: 'loginlinks' }, h('button', { type: 'button', class: 'linkbtn', onClick: logout }, 'Sign out'))))));
|
||||
}
|
||||
|
||||
@@ -841,30 +852,28 @@
|
||||
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 });
|
||||
// addPasskey adds a passkey. It signs in on its own, and also serves as
|
||||
// the second step after a password.
|
||||
function addPasskey(onDone) {
|
||||
const nm = h('input', { id: 'kn', value: 'Passkey', autocomplete: 'off', maxLength: 64 });
|
||||
const e = h('p', { class: 'err-text', role: 'alert' });
|
||||
const btn = h('button', { type: 'submit', class: 'btn primary' }, passkey ? 'Add passkey' : 'Add security key');
|
||||
const btn = h('button', { type: 'submit', class: 'btn primary' }, 'Add passkey');
|
||||
dialog((close) => h('form', { class: 'dlg', onSubmit: async (ev) => {
|
||||
ev.preventDefault();
|
||||
e.textContent = '';
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const opts = await api('POST', '/auth/mfa/keys/begin', { passkey });
|
||||
const opts = await api('POST', '/auth/mfa/keys/begin');
|
||||
const cred = await webauthnCreate(opts);
|
||||
const res = await api('POST', '/auth/mfa/keys/finish?name=' + encodeURIComponent(nm.value.trim()), cred);
|
||||
close();
|
||||
toast((passkey ? 'Passkey' : 'Security key') + ' added');
|
||||
toast('Passkey 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')),
|
||||
h('h2', null, 'Add a passkey'),
|
||||
h('p', null, 'A passkey signs you in on its own, without username and password, and also works as the second step after your password. It can live on this device (Touch ID, Face ID, Windows Hello), in your password manager, or on a YubiKey with a PIN set.'),
|
||||
h('div', { class: 'field' }, h('label', { htmlFor: 'kn' }, 'Name'), nm, h('span', { class: 'hint' }, 'So you can tell your passkeys apart, for example "MacBook" or "YubiKey"')),
|
||||
e,
|
||||
h('div', { class: 'foot' }, h('button', { type: 'button', class: 'btn', onClick: close }, 'Cancel'), btn)));
|
||||
nm.select();
|
||||
@@ -910,7 +919,7 @@
|
||||
}
|
||||
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('div', { class: 'hint' }, 'Added ' + fmtDate(k.created) + ' · ' + (k.lastUsed ? 'last used ' + ago(k.lastUsed) : 'not used yet'))),
|
||||
h('button', { type: 'button', class: 'btn small', onClick: () => renameKey(k) }, 'Rename'),
|
||||
h('button', { type: 'button', class: 'btn danger small', onClick: () => removeKey(k) }, 'Remove')));
|
||||
}
|
||||
@@ -922,9 +931,8 @@
|
||||
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).'),
|
||||
keys ? h('button', { type: 'button', class: 'btn', onClick: () => addPasskey(draw) }, 'Add passkey') : null),
|
||||
keys ? null : h('p', { class: 'hint section' }, 'Passkeys need this site on its domain name with a trusted certificate (Let\'s Encrypt or certificate files).'),
|
||||
].filter(Boolean));
|
||||
};
|
||||
draw();
|
||||
@@ -1116,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 || '–',
|
||||
@@ -1502,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));
|
||||
@@ -1572,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'),
|
||||
@@ -1839,7 +1906,7 @@
|
||||
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;
|
||||
if (!await confirmDialog({ title: 'Reset two-step sign-in for ' + u.username + '?', text: 'Their authenticator app, passkeys and recovery codes are removed. They sign in with their password and can set it up again.', ok: 'Reset', danger: true })) return;
|
||||
try { await api('POST', '/users/' + u.id + '/reset-mfa'); toast('Two-step sign-in reset for ' + u.username); reloadUsers(); } catch (x) { toast(x.message, true); }
|
||||
};
|
||||
const deleteUser = async (u) => {
|
||||
@@ -2017,7 +2084,7 @@
|
||||
|
||||
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('p', { class: 'lead' }, 'Everyone sets up two-step sign-in under My account: an authenticator app or passkeys, including on a YubiKey. Changes apply immediately.'),
|
||||
h('label', { class: 'check' }, requireBox, h('span', null, 'Require two-step sign-in for everyone', h('br'),
|
||||
h('span', { class: 'hint' }, 'Users without it are asked to set it up right after their password. To help someone who lost their phone or key, use Edit → Reset two-step sign-in.')))),
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -139,14 +151,18 @@ type Auth struct {
|
||||
mu sync.Mutex
|
||||
sessions map[string]*session
|
||||
used map[string]tokenUse
|
||||
logins map[string]tokenUse // last sign-in per user ID
|
||||
fails map[string]*failState
|
||||
logins map[string]tokenUse // last sign-in per user ID
|
||||
fails map[string]*failState // by lockKey
|
||||
waiting int // sign-ins waiting for or running a password check
|
||||
mfa mfaState
|
||||
}
|
||||
|
||||
const (
|
||||
maxFailures = 5
|
||||
lockoutTime = 15 * time.Minute
|
||||
// maxWaiting sign-ins may wait for a password check; more are turned
|
||||
// away until the queue is shorter.
|
||||
maxWaiting = 16
|
||||
)
|
||||
|
||||
func newAuth(s *Store) *Auth {
|
||||
@@ -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 {
|
||||
|
||||
@@ -204,6 +204,9 @@ func (c *Config) applyDefaults() {
|
||||
c.Users = []User{u}
|
||||
}
|
||||
c.Admin = nil
|
||||
for i := range c.Users {
|
||||
dropSecurityKeys(&c.Users[i])
|
||||
}
|
||||
for i := range c.APITokens {
|
||||
if c.APITokens[i].UserID == "" {
|
||||
c.APITokens[i].UserID = c.Users[0].ID // tokens from before users existed
|
||||
|
||||
@@ -3,6 +3,8 @@ package main
|
||||
import (
|
||||
"log/slog"
|
||||
"net/netip"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
@@ -38,6 +40,15 @@ type Kernel interface {
|
||||
Close() error
|
||||
}
|
||||
|
||||
// readSysctl returns the trimmed content of a /proc/sys file, or "".
|
||||
func readSysctl(path string) string {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(b))
|
||||
}
|
||||
|
||||
// Reconciler applies the config to the kernel whenever it is triggered and
|
||||
// remembers the outcome for the health report.
|
||||
type Reconciler struct {
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/vishvananda/netlink"
|
||||
"golang.zx2c4.com/wireguard/wgctrl"
|
||||
@@ -318,14 +318,6 @@ func publicAddr(uplink string, v6 bool) (bool, string) {
|
||||
return false, "no address on " + uplink
|
||||
}
|
||||
|
||||
func readSysctl(path string) string {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(b))
|
||||
}
|
||||
|
||||
func (k *linuxKernel) Checks(c *Config) []Check {
|
||||
var out []Check
|
||||
link, err := netlink.LinkByName(c.Server.Interface)
|
||||
@@ -341,6 +333,19 @@ func (k *linuxKernel) Checks(c *Config) []Check {
|
||||
v := readSysctl("/proc/sys/net/ipv6/conf/all/forwarding")
|
||||
out = append(out, Check{"IPv6 forwarding", v == "1", "net.ipv6.conf.all.forwarding=" + v})
|
||||
}
|
||||
// With IPv6 forwarding on, accept_ra 1 means router announcements are
|
||||
// ignored: an IPv6 route learned from them expires (see sysctlConf).
|
||||
if readSysctl("/proc/sys/net/ipv6/conf/all/forwarding") == "1" {
|
||||
up := cmp.Or(k.Uplink(c, true), k.Uplink(c, false))
|
||||
if ra := readSysctl("/proc/sys/net/ipv6/conf/" + up + "/accept_ra"); up != "" && ra != "" {
|
||||
ok := ra != "1"
|
||||
detail := "net.ipv6.conf." + up + ".accept_ra=" + ra
|
||||
if !ok {
|
||||
detail += ": IPv6 from router announcements stops working; run " + appName + " update"
|
||||
}
|
||||
out = append(out, Check{"IPv6 router announcements", ok, detail})
|
||||
}
|
||||
}
|
||||
ok, detail := firewallPresent()
|
||||
out = append(out, Check{"nftables rules", ok, detail})
|
||||
up4 := k.Uplink(c, false)
|
||||
|
||||
@@ -12,7 +12,9 @@ import (
|
||||
"net/netip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -311,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 {
|
||||
@@ -328,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)
|
||||
@@ -376,6 +393,92 @@ func TestUnitFile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSysctlConf(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
conf, sys := filepath.Join(dir, "conf"), filepath.Join(dir, "net")
|
||||
for name, ra := range map[string]string{"eth0": "1", "wlan0": "2", "eth1": "0", "br0": "1", "veth1": "1", "lo": "1"} {
|
||||
_ = os.MkdirAll(filepath.Join(conf, name), 0o755)
|
||||
_ = os.WriteFile(filepath.Join(conf, name, "accept_ra"), []byte(ra+"\n"), 0o644)
|
||||
}
|
||||
for _, name := range []string{"eth0", "wlan0", "eth1"} { // network cards
|
||||
_ = os.MkdirAll(filepath.Join(sys, name, "device"), 0o755)
|
||||
}
|
||||
_ = os.MkdirAll(filepath.Join(sys, "veth1"), 0o755)
|
||||
// br0 carries the default route; the lo line is the kernel's unreachable route.
|
||||
routes := filepath.Join(dir, "ipv6_route")
|
||||
_ = os.WriteFile(routes, []byte(
|
||||
"00000000000000000000000000000000 00 00000000000000000000000000000000 00 fe800000000000000000000000000001 00000400 00000001 00000000 00000003 br0\n"+
|
||||
"00000000000000000000000000000000 00 00000000000000000000000000000000 00 00000000000000000000000000000000 ffffffff 00000001 00000000 00200200 lo\n"), 0o644)
|
||||
|
||||
got := raInterfaces(conf, sys, routes)
|
||||
if want := []string{"br0", "eth0", "wlan0"}; !slices.Equal(got, want) {
|
||||
t.Fatalf("raInterfaces = %v, want %v", got, want)
|
||||
}
|
||||
c := sysctlConf(got)
|
||||
for _, want := range []string{"net.ipv6.conf.all.forwarding=1\n", "net.ipv6.conf.default.accept_ra=2\n", "net.ipv6.conf.eth0.accept_ra=2\n", "net.ipv6.conf.br0.accept_ra=2\n"} {
|
||||
if !strings.Contains(c, want) {
|
||||
t.Errorf("sysctl conf lacks %q:\n%s", want, c)
|
||||
}
|
||||
}
|
||||
if strings.Contains(c, "eth1") || strings.Contains(c, "veth1") {
|
||||
t.Errorf("sysctl conf names eth1 (accept_ra 0) or veth1 (virtual):\n%s", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginLockout(t *testing.T) {
|
||||
store, err := openStore(filepath.Join(t.TempDir(), "config.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hash, _ := hashPassword("a long test password")
|
||||
_ = store.Update(func(c *Config) error { c.Users[0].PasswordHash = hash; return nil })
|
||||
a := newAuth(store)
|
||||
const right, wrong = "a long test password", "a wrong password"
|
||||
|
||||
// Ten wrong attempts at once from one /64: five are checked, the others
|
||||
// are locked out before any password check.
|
||||
var wg sync.WaitGroup
|
||||
var mu sync.Mutex
|
||||
got := map[string]int{}
|
||||
for i := range 10 {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_, _, err := a.Login("admin", wrong, fmt.Sprintf("2001:db8::%x", i+1))
|
||||
mu.Lock()
|
||||
got[err.Error()]++
|
||||
mu.Unlock()
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
if got["wrong username or password"] != 5 || got[errLocked.Error()] != 5 {
|
||||
t.Fatalf("parallel attempts: %v", got)
|
||||
}
|
||||
if _, _, err := a.Login("admin", right, "2001:db8::ffff"); !errors.Is(err, errLocked) {
|
||||
t.Fatalf("same /64: %v, want locked", err)
|
||||
}
|
||||
if _, _, err := a.Login("admin", right, "2001:db8:0:1::1"); err != nil {
|
||||
t.Fatalf("other /64: %v", err)
|
||||
}
|
||||
|
||||
// A right password takes its own attempt back. With two-step sign-in
|
||||
// the earlier failures stay, so wrong codes still lead to the lockout.
|
||||
_ = store.Update(func(c *Config) error { c.Users[0].MFA = &UserMFA{TOTPSecret: newTOTPSecret()}; return nil })
|
||||
ip := "192.0.2.7"
|
||||
for range maxFailures - 1 {
|
||||
_, _, _ = a.Login("admin", wrong, ip)
|
||||
}
|
||||
if _, tk, err := a.Login("admin", right, ip); err != nil || tk == "" {
|
||||
t.Fatalf("5th attempt, right password: ticket %q, %v", tk, err)
|
||||
}
|
||||
if _, _, err := a.Login("admin", wrong, ip); err == nil || errors.Is(err, errLocked) {
|
||||
t.Fatalf("6th attempt: %v, want wrong password", err)
|
||||
}
|
||||
if _, _, err := a.Login("admin", right, ip); !errors.Is(err, errLocked) {
|
||||
t.Fatalf("7th attempt: %v, want locked", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteIfChanged(t *testing.T) {
|
||||
p := filepath.Join(t.TempDir(), "x.conf")
|
||||
if ch, err := writeIfChanged(p, "a\n", 0o644); !ch || err != nil {
|
||||
@@ -881,7 +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
|
||||
@@ -1050,7 +1152,7 @@ func TestMFA(t *testing.T) {
|
||||
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)
|
||||
adm("POST", "/auth/mfa/keys/begin", nil, 400)
|
||||
|
||||
// Turn on the authenticator app; the first method brings recovery codes.
|
||||
setup := adm("POST", "/auth/mfa/totp/setup", nil, 200)
|
||||
@@ -1121,3 +1223,30 @@ func TestMFA(t *testing.T) {
|
||||
t.Fatal("reset left methods behind")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDropSecurityKeys checks that security keys from v0.3.0 are deleted on
|
||||
// load, and recovery codes with them when nothing else is left.
|
||||
func TestDropSecurityKeys(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.json")
|
||||
cfg := `{"users": [
|
||||
{"id": "a", "username": "a", "passwordHash": "x", "mfa": {"keys": [{"id": "k", "name": "YubiKey", "passkey": false}], "recoveryCodes": ["h"]}},
|
||||
{"id": "b", "username": "b", "passwordHash": "x", "mfa": {"keys": [{"id": "k1", "name": "YubiKey", "passkey": false}, {"id": "k2", "name": "Mac", "passkey": true}], "recoveryCodes": ["h"]}}
|
||||
]}`
|
||||
if err := os.WriteFile(path, []byte(cfg), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store, err := openStore(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c := store.Get()
|
||||
if a := c.Users[0].MFA; len(a.Keys) != 0 || len(a.RecoveryCodes) != 0 {
|
||||
t.Fatalf("user a kept %v", a)
|
||||
}
|
||||
if b := c.Users[1].MFA; len(b.Keys) != 1 || b.Keys[0].Name != "Mac" || len(b.RecoveryCodes) != 1 {
|
||||
t.Fatalf("user b: %v", b)
|
||||
}
|
||||
if b, _ := os.ReadFile(path); strings.Contains(string(b), "YubiKey") {
|
||||
t.Fatal("security key still in config.json")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,9 +24,10 @@ import (
|
||||
"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.
|
||||
// Two-step sign-in for the web interface: an authenticator app (TOTP) and
|
||||
// passkeys (WebAuthn, also on a YubiKey), plus one-time recovery codes. A
|
||||
// passkey signs in on its own and also serves as the second step after a
|
||||
// password. API tokens never need a second step.
|
||||
//
|
||||
// After a correct password, a user with two-step sign-in gets a short-lived
|
||||
// ticket instead of a session; the ticket and a code or key turn into the
|
||||
@@ -41,16 +42,29 @@ type UserMFA struct {
|
||||
Handle []byte `json:"handle,omitempty"` // WebAuthn user handle
|
||||
}
|
||||
|
||||
// MFAKey is a security key or passkey.
|
||||
// MFAKey is a passkey.
|
||||
type MFAKey struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Passkey bool `json:"passkey"` // discoverable: signs in without a password
|
||||
Passkey bool `json:"passkey"` // false only for security keys added by v0.3.0, which are deleted
|
||||
Created time.Time `json:"created"`
|
||||
LastUsed *time.Time `json:"lastUsed,omitempty"`
|
||||
Credential webauthn.Credential `json:"credential"`
|
||||
}
|
||||
|
||||
// dropSecurityKeys deletes the security keys v0.3.0 could add; only
|
||||
// passkeys are supported. A user left without a method loses their
|
||||
// recovery codes too.
|
||||
func dropSecurityKeys(u *User) {
|
||||
if u.MFA == nil {
|
||||
return
|
||||
}
|
||||
u.MFA.Keys = slices.DeleteFunc(u.MFA.Keys, func(k MFAKey) bool { return !k.Passkey })
|
||||
if !u.hasMFA() {
|
||||
u.MFA.RecoveryCodes = nil
|
||||
}
|
||||
}
|
||||
|
||||
func (u *User) hasMFA() bool {
|
||||
return u.MFA != nil && (u.MFA.TOTPSecret != "" || len(u.MFA.Keys) > 0)
|
||||
}
|
||||
@@ -166,7 +180,7 @@ func (w waUser) WebAuthnCredentials() []webauthn.Credential {
|
||||
return out
|
||||
}
|
||||
|
||||
// keysAvailable reports whether security keys and passkeys can work on this
|
||||
// keysAvailable reports whether passkeys can work on this
|
||||
// address: WebAuthn needs a domain name (not an IP address) and a
|
||||
// certificate the browser trusts, or localhost.
|
||||
func (a *App) keysAvailable(r *http.Request) bool {
|
||||
@@ -179,7 +193,7 @@ func (a *App) keysAvailable(r *http.Request) bool {
|
||||
|
||||
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")
|
||||
return nil, badRequest("passkeys need a domain name with a trusted certificate")
|
||||
}
|
||||
scheme := "https"
|
||||
if r.TLS == nil && hostOnly(r.Host) == "localhost" {
|
||||
@@ -198,12 +212,11 @@ type ticket struct {
|
||||
ip string
|
||||
expires time.Time
|
||||
fails int
|
||||
key *webauthn.SessionData // a security key challenge, once asked for
|
||||
key *webauthn.SessionData // a passkey challenge, once asked for
|
||||
}
|
||||
|
||||
type ceremony struct {
|
||||
userID string // "" for a passkey sign-in
|
||||
passkey bool
|
||||
data *webauthn.SessionData
|
||||
expires time.Time
|
||||
}
|
||||
@@ -223,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)
|
||||
}
|
||||
|
||||
@@ -289,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})
|
||||
}
|
||||
@@ -430,7 +454,7 @@ func (a *App) loginRecovery(w http.ResponseWriter, r *http.Request) {
|
||||
a.signedIn(w, r, u, "recovery code")
|
||||
}
|
||||
|
||||
// loginKeyBegin asks for one of the user's security keys or passkeys.
|
||||
// loginKeyBegin asks for one of the user's passkeys, as the second step.
|
||||
func (a *App) loginKeyBegin(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct{ Ticket string }
|
||||
if err := readJSON(r, &in); err != nil {
|
||||
@@ -448,7 +472,7 @@ func (a *App) loginKeyBegin(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if u.MFA == nil || len(u.MFA.Keys) == 0 {
|
||||
writeErr(w, badRequest("no security key is set up"))
|
||||
writeErr(w, badRequest("no passkey is set up"))
|
||||
return
|
||||
}
|
||||
opts, data, err := wa.BeginLogin(waUser{u}, webauthn.WithUserVerification(protocol.VerificationDiscouraged))
|
||||
@@ -488,13 +512,13 @@ func (a *App) loginKeyFinish(w http.ResponseWriter, r *http.Request) {
|
||||
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"))
|
||||
slog.Warn("login failed", "user", u.Username, "remote", ip, "reason", "passkey: "+err.Error())
|
||||
a.signInFailed(w, errors.New("the passkey was not accepted"))
|
||||
return
|
||||
}
|
||||
a.keyUsed(u.ID, cred)
|
||||
a.auth.dropTicket(id)
|
||||
a.signedIn(w, r, u, "security key")
|
||||
a.signedIn(w, r, u, "passkey")
|
||||
}
|
||||
|
||||
// keyUsed stores the key's new signature counter and when it was used.
|
||||
@@ -561,7 +585,7 @@ func (a *App) loginPasskeyFinish(w http.ResponseWriter, r *http.Request) {
|
||||
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) {
|
||||
if bytes.Equal(k.Credential.ID, rawID) {
|
||||
found = u
|
||||
return waUser{u}, nil
|
||||
}
|
||||
@@ -587,7 +611,6 @@ func (a *App) loginPasskeyFinish(w http.ResponseWriter, r *http.Request) {
|
||||
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"`
|
||||
}
|
||||
@@ -604,7 +627,7 @@ func (a *App) mfaStatus(w http.ResponseWriter, r *http.Request) {
|
||||
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})
|
||||
keys = append(keys, keyView{k.ID, k.Name, k.Created, k.LastUsed})
|
||||
}
|
||||
out["totp"], out["totpAdded"], out["keys"], out["recoveryLeft"] = m.TOTPSecret != "", m.TOTPAdded, keys, len(m.RecoveryCodes)
|
||||
}
|
||||
@@ -710,13 +733,8 @@ func (a *App) totpRemove(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// keyBegin starts adding a security key ({"passkey": false}) or a passkey.
|
||||
// keyBegin starts adding 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)
|
||||
@@ -748,17 +766,14 @@ func (a *App) keyBegin(w http.ResponseWriter, r *http.Request) {
|
||||
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}
|
||||
}
|
||||
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.mfa.enrolls[p.UserID] = &ceremony{userID: p.UserID, data: data, expires: time.Now().Add(ticketTTL)}
|
||||
a.auth.mu.Unlock()
|
||||
writeJSON(w, http.StatusOK, opts)
|
||||
}
|
||||
@@ -792,13 +807,13 @@ func (a *App) keyFinish(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if name == "" {
|
||||
name = map[bool]string{false: "Security key", true: "Passkey"}[cer.passkey]
|
||||
name = "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}
|
||||
key := MFAKey{ID: newID(), Name: name, Passkey: true, Created: time.Now().UTC(), Credential: *cred}
|
||||
if err := a.store.Update(func(c *Config) error {
|
||||
_, u := c.userByID(p.UserID)
|
||||
if u == nil || u.MFA == nil {
|
||||
@@ -811,7 +826,7 @@ func (a *App) keyFinish(w http.ResponseWriter, r *http.Request) {
|
||||
writeErr(w, err)
|
||||
return
|
||||
}
|
||||
a.audit(r, map[bool]string{false: "security key added", true: "passkey added"}[cer.passkey], "key", name)
|
||||
a.audit(r, "passkey added", "key", name)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "recoveryCodes": codes})
|
||||
}
|
||||
|
||||
@@ -865,7 +880,7 @@ func (a *App) keyRemove(w http.ResponseWriter, r *http.Request) {
|
||||
writeErr(w, err)
|
||||
return
|
||||
}
|
||||
a.audit(r, "security key removed", "key", name)
|
||||
a.audit(r, "passkey removed", "key", name)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
@@ -917,17 +932,9 @@ func (a *App) resetMFA(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// mfaSummary is what user lists show.
|
||||
func mfaSummary(u *User) map[string]any {
|
||||
out := map[string]any{"totp": false, "keys": 0, "passkeys": 0}
|
||||
out := map[string]any{"totp": false, "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
|
||||
out["totp"], out["passkeys"] = m.TOTPSecret != "", len(m.Keys)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 296 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 195 KiB |
|
Before Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 147 KiB |
|
Before Width: | Height: | Size: 81 KiB |
@@ -12,6 +12,7 @@ import (
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -273,7 +274,51 @@ WantedBy=multi-user.target
|
||||
// rewrite the unit for every release.
|
||||
const unitVersion = "unit-1"
|
||||
|
||||
const sysctlConf = "net.ipv4.ip_forward=1\nnet.ipv6.conf.all.forwarding=1\n"
|
||||
// sysctlConf turns on forwarding. With IPv6 forwarding on, Linux ignores
|
||||
// router announcements unless accept_ra is 2, and a server that gets its
|
||||
// IPv6 route from them (SLAAC, e.g. a Raspberry Pi at home) loses IPv6 when
|
||||
// the route expires. So every interface in ras keeps accepting them, as
|
||||
// pivpn does for its uplink.
|
||||
func sysctlConf(ras []string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("net.ipv4.ip_forward=1\nnet.ipv6.conf.all.forwarding=1\nnet.ipv6.conf.default.accept_ra=2\n")
|
||||
for _, name := range ras {
|
||||
fmt.Fprintf(&b, "net.ipv6.conf.%s.accept_ra=2\n", name)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// raInterfaces returns the network cards and the interface of the IPv6
|
||||
// default route, except those where router announcements are switched off
|
||||
// (accept_ra 0). The directories are /proc/sys/net/ipv6/conf and
|
||||
// /sys/class/net, routes is /proc/net/ipv6_route.
|
||||
func raInterfaces(confDir, netDir, routes string) []string {
|
||||
want := map[string]bool{}
|
||||
if b, err := os.ReadFile(routes); err == nil {
|
||||
for _, line := range strings.Split(string(b), "\n") {
|
||||
f := strings.Fields(line)
|
||||
if len(f) == 10 && f[0] == strings.Repeat("0", 32) && f[1] == "00" && f[9] != "lo" {
|
||||
want[f[9]] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
entries, _ := os.ReadDir(netDir)
|
||||
for _, e := range entries {
|
||||
// Only real devices: bridges, veth and tunnels come and go.
|
||||
if _, err := os.Stat(filepath.Join(netDir, e.Name(), "device")); err == nil {
|
||||
want[e.Name()] = true
|
||||
}
|
||||
}
|
||||
var out []string
|
||||
for name := range want {
|
||||
v := readSysctl(filepath.Join(confDir, name, "accept_ra"))
|
||||
if v == "1" || v == "2" {
|
||||
out = append(out, name)
|
||||
}
|
||||
}
|
||||
slices.Sort(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// writeSystemFiles writes the unit, sysctl and module files. It reports
|
||||
// whether the unit changed (systemd must then reload).
|
||||
@@ -281,7 +326,8 @@ func writeSystemFiles() (unitChanged bool, err error) {
|
||||
if unitChanged, err = writeIfChanged(unitPath, unitFile(), 0o644); err != nil {
|
||||
return false, err
|
||||
}
|
||||
sysChanged, err := writeIfChanged(sysctlPath, sysctlConf, 0o644)
|
||||
ras := raInterfaces("/proc/sys/net/ipv6/conf", "/sys/class/net", "/proc/net/ipv6_route")
|
||||
sysChanged, err := writeIfChanged(sysctlPath, sysctlConf(ras), 0o644)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ type userView struct {
|
||||
LastLogin *tokenUse `json:"lastLogin"` // since the service started
|
||||
Tokens int `json:"tokens"`
|
||||
You bool `json:"you"`
|
||||
MFA map[string]any `json:"mfa"` // {"totp": bool, "keys": n, "passkeys": n}
|
||||
MFA map[string]any `json:"mfa"` // {"totp": bool, "passkeys": n}
|
||||
}
|
||||
|
||||
func (a *App) userView(c *Config, u *User, me string) userView {
|
||||
|
||||