4 Commits

Author SHA1 Message Date
Daniel Redetzke 3e8dba6072 Fixes from the audit: input checks, apply order, sign-in limits
- The server endpoint must be a plain host name or IP address. It is
  written into client configs as is, so a newline could add lines such
  as PreUp, which wg-quick runs as root on the client.
- Listen addresses and the session length (1–720 hours) are checked.
  Before web settings or a restore are saved, the server tries the new
  listen addresses and certificate files, so a value it cannot start
  with is refused instead of stopping the service at the next restart.
- Kernel applies run one at a time and read the config once it is
  their turn, so an older config can no longer be applied last.
- Pending passkey sign-ins are capped: 10 per address, 1000 in total.
- Behind a local proxy, the last X-Forwarded-For entry is the client;
  earlier ones come from the client and are ignored.
- With LAN access off, peers are also kept from the IPv6 networks on
  the uplink, not only from its private IPv4 networks.
- A change that leaves no user with a password is refused, and so is a
  backup without one or from a newer version.
2026-10-05 23:09:03 +03:00
Daniel Redetzke aa4ca20296 API: the "Last apply" check is now "Kernel in sync"
GET /api/v1/status names the check Kernel in sync, the same as the
health row in the web interface, which now reads the new name directly.
The detail is unchanged: "applied <time>" or the kernel's error.
2026-10-05 22:29:47 +03:00
Daniel Redetzke 1bfede250c Health: "Last apply" reads "Kernel in sync"
The health row that shows when the config was last written to the
kernel is now called Kernel in sync, with the time since then, or Out
of sync and the kernel's error when applying failed. The API keeps the
check's name, so clients are unaffected.
2026-10-05 22:28:16 +03:00
Daniel Redetzke 8edde9f5e7 Links: arrows for moving around, ↗ for outside pages
Links to another page (All peers, Log, Add the first one) are ink with
an arrow that nudges on hover, the back link gets ←, and links that
open an outside page get ↗ and "opens in a new tab" for screen
readers, on the setup page too. Peer names look the same everywhere,
and links in text get a pale underline. Buttons and the sidebar stay
as they are.
2026-10-05 21:28:26 +03:00
11 changed files with 532 additions and 51 deletions
+75 -5
View File
@@ -3,15 +3,18 @@ package main
import ( import (
"cmp" "cmp"
"context" "context"
"crypto/tls"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"io" "io"
"log/slog" "log/slog"
"net"
"net/http" "net/http"
"net/netip" "net/netip"
"slices" "slices"
"strings" "strings"
"syscall"
"time" "time"
) )
@@ -29,7 +32,8 @@ type App struct {
geo *Geo // nil in tests geo *Geo // nil in tests
updates *Updater // nil in tests updates *Updater // nil in tests
started time.Time started time.Time
shutdown func() // graceful stop; systemd restarts the service shutdown func() // graceful stop; systemd restarts the service
webAddrs []string // the addresses the web server listens on now
} }
// --- helpers --- // --- helpers ---
@@ -353,7 +357,7 @@ func (a *App) status(w http.ResponseWriter, r *http.Request) {
d30, u30 := sumPoints(a.stats.series(nil, "30d")) d30, u30 := sumPoints(a.stats.series(nil, "30d"))
checks := a.kernel.Checks(cfg) checks := a.kernel.Checks(cfg)
last, applyErr := a.recon.Status() last, applyErr := a.recon.Status()
ac := Check{Name: "Last apply", OK: applyErr == nil, Detail: "applied " + last.Format(time.RFC3339)} ac := Check{Name: "Kernel in sync", OK: applyErr == nil, Detail: "applied " + last.Format(time.RFC3339)}
if applyErr != nil { if applyErr != nil {
ac.Detail = applyErr.Error() ac.Detail = applyErr.Error()
} }
@@ -1090,11 +1094,15 @@ func (a *App) patchSettings(w http.ResponseWriter, r *http.Request) {
b, _ := json.Marshal(w) b, _ := json.Marshal(w)
return string(b) return string(b)
} }
before := listen() before, oldWeb := listen(), c.Web
if err := field(m, "web", &c.Web); err != nil { if err := field(m, "web", &c.Web); err != nil {
return err return err
} }
restart = listen() != before if restart = listen() != before; restart {
if err := a.checkWebStart(oldWeb, c.Web); err != nil {
return err
}
}
if err := field(m, "stats", &c.Stats); err != nil { if err := field(m, "stats", &c.Stats); err != nil {
return err return err
} }
@@ -1236,7 +1244,20 @@ func (a *App) restore(w http.ResponseWriter, r *http.Request) {
writeErr(w, badRequest("this file has no server key; is it a backup of this app?")) writeErr(w, badRequest("this file has no server key; is it a backup of this app?"))
return return
} }
if err := a.store.Update(func(c *Config) error { *c = in; return nil }); err != nil { if in.Version > configVersion {
writeErr(w, badRequest("this backup is from a newer version of %s; update this server first", appName))
return
}
in.applyDefaults()
if !in.passwordSet() {
writeErr(w, badRequest("this backup has no user with a password; restoring it would lock everyone out"))
return
}
if err := a.store.Update(func(c *Config) error {
old := c.Web
*c = in
return a.checkWebStart(old, c.Web)
}); err != nil {
writeErr(w, err) writeErr(w, err)
return return
} }
@@ -1244,6 +1265,55 @@ func (a *App) restore(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "applyError": a.apply(), "restartRequired": true}) writeJSON(w, http.StatusOK, map[string]any{"ok": true, "applyError": a.apply(), "restartRequired": true})
} }
// checkWebStart refuses web settings the service could not start with: an
// address it cannot listen on, or certificate files it cannot read. The
// service would stop at the next restart, and the web interface and the API
// with it.
func (a *App) checkWebStart(old, next WebConfig) error {
if err := validateListen(next.Listen, "listen address", false); err != nil {
return &userError{err.Error()}
}
if err := validateListen(next.HTTPListen, "HTTP listen address", true); err != nil {
return &userError{err.Error()}
}
if next.TLS.Mode == "files" && next.TLS != old.TLS {
if _, err := tls.LoadX509KeyPair(next.TLS.CertFile, next.TLS.KeyFile); err != nil {
return badRequest("the certificate files cannot be used: %v", err)
}
}
addrs := []string{next.Listen}
if next.HTTPListen != "" && next.TLS.Mode != "off" {
addrs = append(addrs, next.HTTPListen)
}
for _, addr := range addrs {
if err := a.canListen(addr); err != nil {
return badRequest("cannot listen on %s: %v", addr, err)
}
}
return nil
}
// canListen tries to listen on addr. An address the service listens on now,
// or one whose port it holds, is fine: it is free again after the restart.
func (a *App) canListen(addr string) error {
if slices.Contains(a.webAddrs, addr) {
return nil
}
ln, err := net.Listen("tcp", addr)
if err == nil {
return ln.Close()
}
if errors.Is(err, syscall.EADDRINUSE) {
_, port, _ := net.SplitHostPort(addr)
for _, own := range a.webAddrs {
if _, p, _ := net.SplitHostPort(own); p == port {
return nil
}
}
}
return err
}
// applyRuntime applies the settings that take effect without a restart: log // applyRuntime applies the settings that take effect without a restart: log
// level and log rotation. Traffic retention is read by the stats sampler. // level and log rotation. Traffic retention is read by the stats sampler.
func (a *App) applyRuntime(c *Config) { func (a *App) applyRuntime(c *Config) {
+18 -6
View File
@@ -33,8 +33,20 @@
* { box-sizing: border-box; } * { box-sizing: border-box; }
html, body { margin: 0; } html, body { margin: 0; }
body { background: var(--ground); color: var(--ink); font-family: var(--sans); font-size: 14px; line-height: 1.45; } body { background: var(--ground); color: var(--ink); font-family: var(--sans); font-size: 14px; line-height: 1.45; }
a { color: var(--link); } a { color: var(--link); text-decoration-color: rgba(28, 92, 171, .35); text-underline-offset: 3px; }
a:hover { color: var(--link-hover); } a:hover { color: var(--link-hover); text-decoration-color: currentColor; }
/* Links to another page (go, back) are ink with an arrow that nudges on
hover; outside links (ext) keep the link colour and get ↗. */
a.go, a.back { color: var(--ink); font-size: 13px; font-weight: 500; text-decoration: none; white-space: nowrap; }
a.go:hover, a.back:hover { color: var(--link); }
a.go .ar { margin-left: 4px; }
a.back .ar { margin-right: 4px; }
a.ext .ar { margin-left: 2px; font-size: .8em; }
.ar { display: inline-block; transition: transform .15s; }
a.go:hover .ar { transform: translateX(3px); }
a.back:hover .ar { transform: translateX(-3px); }
a.ext:hover .ar { transform: translate(2px, -2px); }
@media (prefers-reduced-motion: reduce) { .ar { transition: none; } }
:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; } :focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; }
[hidden] { display: none !important; } [hidden] { display: none !important; }
.mono { font-family: var(--mono); font-size: 13px; } .mono { font-family: var(--mono); font-size: 13px; }
@@ -86,7 +98,7 @@ a.brand, a.brand:hover { color: #fff; text-decoration: none; }
.titleline { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; } .titleline { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; }
h1 { margin: 0; font-size: 26px; font-weight: 600; letter-spacing: -0.01em; overflow-wrap: anywhere; } h1 { margin: 0; font-size: 26px; font-weight: 600; letter-spacing: -0.01em; overflow-wrap: anywhere; }
.sub { margin: 4px 0 0; color: var(--ink-2); } .sub { margin: 4px 0 0; color: var(--ink-2); }
.back { font-size: 13px; margin-bottom: -8px; } .back { margin-bottom: -8px; align-self: flex-start; }
/* cards */ /* cards */
.card { background: var(--surface); border: 1px solid var(--line); border-radius: 12px; padding: 20px; min-width: 0; } .card { background: var(--surface); border: 1px solid var(--line); border-radius: 12px; padding: 20px; min-width: 0; }
@@ -151,7 +163,7 @@ tr:last-child td { border-bottom: 0; }
.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; } .num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
td .note { font-size: 12px; color: var(--ink-3); } td .note { font-size: 12px; color: var(--ink-3); }
a.pname { color: var(--ink); font-weight: 600; text-decoration: none; } a.pname { 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; } a.pname:hover { color: var(--link); }
.empty { padding: 24px 12px; margin: 0; text-align: center; color: var(--ink-3); } .empty { padding: 24px 12px; margin: 0; text-align: center; color: var(--ink-3); }
/* forms */ /* forms */
@@ -334,8 +346,8 @@ dialog::backdrop { background: rgba(22, 23, 26, .55); }
.setupbox { width: 100%; max-width: 400px; display: flex; flex-direction: column; gap: 24px; margin: auto 0; } .setupbox { width: 100%; max-width: 400px; display: flex; flex-direction: column; gap: 24px; margin: auto 0; }
.setupbox h1 { font-size: 22px; } .setupbox h1 { font-size: 22px; }
.setupbox p { margin: 0; color: #c9c9c3; } .setupbox p { margin: 0; color: #c9c9c3; }
.setupbox a { color: #9cc3f5; } .setupbox a { color: #9cc3f5; text-decoration-color: rgba(156, 195, 245, .4); }
.setupbox a:hover { color: #fff; } .setupbox a:hover { color: #fff; text-decoration-color: currentColor; }
.setupbox .center, .setupbox.center { text-align: center; display: flex; flex-direction: column; align-items: center; gap: 8px; } .setupbox .center, .setupbox.center { text-align: center; display: flex; flex-direction: column; align-items: center; gap: 8px; }
.setupbox.center { gap: 20px; } .setupbox.center { gap: 20px; }
.setupbox .ghost { opacity: .45; } .setupbox .ghost { opacity: .45; }
+22 -14
View File
@@ -169,6 +169,14 @@
const badge = (st) => h('span', { class: 'badge' }, h('span', { class: st.dot }), st.label); const badge = (st) => h('span', { class: 'badge' }, h('span', { class: st.dot }), st.label);
// go links to another page of the app; back returns to one. Their arrows
// nudge on hover. ext opens an outside page in a new tab, marked with ↗.
const arrow = (c) => h('span', { class: 'ar', 'aria-hidden': 'true' }, c);
const go = (href, text) => h('a', { class: 'go', href }, text, arrow('→'));
const back = (href, text) => h('a', { class: 'back', href }, arrow('←'), text);
const ext = (href, text) => h('a', { class: 'ext', href, target: '_blank', rel: 'noopener' }, text, arrow('↗'), h('span', { class: 'sr' }, ' (opens in a new tab)'));
const peerLink = (p) => h('a', { class: 'pname', href: '#/peers/' + p.id }, p.name);
// svg builds an SVG element; attrs are set as attributes. // svg builds an SVG element; attrs are set as attributes.
function svg(tag, attrs, ...kids) { function svg(tag, attrs, ...kids) {
const el = document.createElementNS('http://www.w3.org/2000/svg', tag); const el = document.createElementNS('http://www.w3.org/2000/svg', tag);
@@ -1086,17 +1094,17 @@
h('div', { class: 'cols' }, h('div', { class: 'cols' },
h('section', { class: 'card flush' }, h('section', { class: 'card flush' },
h('div', { class: 'cardhead' }, h('h2', null, 'Peers'), h('a', { href: '#/peers' }, 'All peers')), h('div', { class: 'cardhead' }, h('h2', null, 'Peers'), go('#/peers', 'All peers')),
top.length ? h('div', { class: 'tbl' }, h('table', { class: 'narrow' }, top.length ? h('div', { class: 'tbl' }, h('table', { class: 'narrow' },
h('thead', null, h('tr', null, h('th', null, 'Name'), h('th', null, 'Status'), h('th', { class: 'num' }, 'Download, 24 h'), h('th', { class: 'num' }, 'Upload, 24 h'))), h('thead', null, h('tr', null, h('th', null, 'Name'), h('th', null, 'Status'), h('th', { class: 'num' }, 'Download, 24 h'), h('th', { class: 'num' }, 'Upload, 24 h'))),
h('tbody', null, top.map((p) => h('tr', null, h('tbody', null, top.map((p) => h('tr', null,
h('td', null, h('a', { href: '#/peers/' + p.id }, p.name)), h('td', null, peerLink(p)),
h('td', null, badge(peerState(p))), h('td', null, badge(peerState(p))),
h('td', { class: 'num' }, fmtBytes(p.stats.down24h)), h('td', { class: 'num' }, fmtBytes(p.stats.down24h)),
h('td', { class: 'num' }, fmtBytes(p.stats.up24h))))))) h('td', { class: 'num' }, fmtBytes(p.stats.up24h)))))))
: h('p', { class: 'empty' }, 'No peers yet. ', h('a', { href: '#/peers/new' }, 'Add the first one'))), : h('p', { class: 'empty' }, 'No peers yet. ', go('#/peers/new', 'Add the first one'))),
logs ? h('section', { class: 'card' }, logs ? h('section', { class: 'card' },
h('div', { class: 'cardhead' }, h('h2', null, 'Recent activity'), h('a', { href: '#/log' }, 'Log')), h('div', { class: 'cardhead' }, h('h2', null, 'Recent activity'), go('#/log', 'Log')),
logs.lines.length logs.lines.length
? h('div', null, logs.lines.map((l) => h('div', { class: 'ev' }, h('time', { datetime: l.time }, fmtWhen(l.time)), h('span', null, describeAudit(l))))) ? h('div', null, logs.lines.map((l) => h('div', { class: 'ev' }, h('time', { datetime: l.time }, fmtWhen(l.time)), h('span', null, describeAudit(l)))))
: h('p', { class: 'empty' }, 'No changes yet.')) : null)); : h('p', { class: 'empty' }, 'No changes yet.')) : null));
@@ -1294,7 +1302,7 @@
h('tbody', null, online.map(({ p, r, hist }) => { h('tbody', null, online.map(({ p, r, hist }) => {
const idle = r[0] + r[1] < IDLE_BPS; const idle = r[0] + r[1] < IDLE_BPS;
return h('tr', { class: idle ? 'idle' : null }, return h('tr', { class: idle ? 'idle' : null },
h('td', null, h('a', { href: '#/peers/' + p.id }, p.name), idle ? h('span', { class: 'tag plain' }, 'idle') : null), h('td', null, peerLink(p), idle ? h('span', { class: 'tag plain' }, 'idle') : null),
h('td', null, rateSpark(hist)), h('td', null, rateSpark(hist)),
h('td', { class: 'num' }, rate(r[0], idle)), h('td', { class: 'num' }, rate(r[0], idle)),
h('td', { class: 'num' }, rate(r[1], idle)), h('td', { class: 'num' }, rate(r[1], idle)),
@@ -1424,7 +1432,7 @@
return hit && keep; return hit && keep;
}); });
tbody.replaceChildren(...sorted(rows).map((p) => h('tr', null, tbody.replaceChildren(...sorted(rows).map((p) => h('tr', null,
h('td', null, h('a', { class: 'pname', href: '#/peers/' + p.id }, p.name), p.note ? h('div', { class: 'note' }, p.note) : null), h('td', null, peerLink(p), p.note ? h('div', { class: 'note' }, p.note) : null),
h('td', { class: 'mono' }, p.ipv4), h('td', { class: 'mono' }, p.ipv4),
h('td', null, badge(peerState(p))), h('td', null, badge(peerState(p))),
h('td', { class: 'mono muted' }, p.stats.endpoint || '–', h('td', { class: 'mono muted' }, p.stats.endpoint || '–',
@@ -1579,7 +1587,7 @@
drawPreview(); drawPreview();
fill(wrap, fill(wrap,
h('a', { class: 'back', href: '#/peers' }, '← Peers'), back('#/peers', 'Peers'),
h('div', null, h('h1', null, 'Add peer'), h('p', { class: 'sub' }, 'Assigns the next free address and adds the peer to ' + srv.interface + ' without a restart.')), h('div', null, h('h1', null, 'Add peer'), h('p', { class: 'sub' }, 'Assigns the next free address and adds the peer to ' + srv.interface + ' without a restart.')),
h('div', { class: 'split' }, form, h('div', { class: 'split' }, form,
h('aside', { class: 'card aside', 'aria-labelledby': 'pv' }, h('aside', { class: 'card aside', 'aria-labelledby': 'pv' },
@@ -1736,7 +1744,7 @@
}; };
fill(wrap, fill(wrap,
h('a', { class: 'back', href: '#/peers' }, '← Peers'), back('#/peers', 'Peers'),
h('div', { class: 'head' }, h('div', { class: 'head' },
h('div', null, h('div', null,
h('div', { class: 'titleline' }, h('h1', null, p.name), badge(st.key === 'online' ? { ...st, label: 'Online · handshake ' + ago(p.stats.lastHandshake) } : st)), h('div', { class: 'titleline' }, h('h1', null, p.name), badge(st.key === 'online' ? { ...st, label: 'Online · handshake ' + ago(p.stats.lastHandshake) } : st)),
@@ -1787,7 +1795,7 @@
: h('p', { class: 'empty' }, 'No connections recorded yet.'), : h('p', { class: 'empty' }, 'No connections recorded yet.'),
sessions.length > SHORT ? h('div', { style: { margin: '8px 12px 0' } }, sessMore) : null, sessions.length > SHORT ? h('div', { style: { margin: '8px 12px 0' } }, sessMore) : null,
h('p', { class: 'hint', style: { margin: '4px 12px 12px' } }, 'Country and network: ', h('p', { class: 'hint', style: { margin: '4px 12px 12px' } }, 'Country and network: ',
h('a', { href: 'https://db-ip.com', target: '_blank', rel: 'noopener' }, 'IP Geolocation by DB-IP'), ext('https://db-ip.com', 'IP Geolocation by DB-IP'),
'. Kept as long as the daily traffic history.')), '. Kept as long as the daily traffic history.')),
h('form', { class: 'card', onSubmit: save }, h('form', { class: 'card', onSubmit: save },
@@ -1847,8 +1855,8 @@
t.status = c.ok ? 'Present' : 'Missing'; t.status = c.ok ? 'Present' : 'Missing';
if (/^table /.test(c.detail)) { t.raw = c.detail.replace(/ (present|missing)$/, ''); t.problem = null; } if (/^table /.test(c.detail)) { t.raw = c.detail.replace(/ (present|missing)$/, ''); t.problem = null; }
break; break;
case 'Last apply': case 'Kernel in sync':
if (c.ok) { const iso = c.detail.replace(/^applied /, ''); t.status = ago(iso); t.title = fmtStamp(iso); } else t.status = 'Failed'; if (c.ok) { const iso = c.detail.replace(/^applied /, ''); t.status = ago(iso); t.title = fmtStamp(iso); } else t.status = 'Out of sync';
break; break;
case 'Latency check': t.status = c.ok ? 'Tunnel ping works' : 'Failing'; break; case 'Latency check': t.status = c.ok ? 'Tunnel ping works' : 'Failing'; break;
} }
@@ -1980,7 +1988,7 @@
fieldEl('up6', 'IPv6 uplink interface', h('input', { id: 'up6', class: 'mono', value: draft.uplinkV6, placeholder: 'auto: ' + (srv.detectedUplinkV6 || 'none found'), onInput: str('uplinkV6') })), fieldEl('up6', 'IPv6 uplink interface', h('input', { id: 'up6', class: 'mono', value: draft.uplinkV6, placeholder: 'auto: ' + (srv.detectedUplinkV6 || 'none found'), onInput: str('uplinkV6') })),
cb('nat', 'Masquerade (NAT) peer traffic to the internet'), cb('nat', 'Masquerade (NAT) peer traffic to the internet'),
cb('peerToPeer', 'Allow peers to reach each other'), cb('peerToPeer', 'Allow peers to reach each other'),
cb('lanAccess', 'Allow peers to reach the server\'s LAN', 'Private networks on the uplink interface'), cb('lanAccess', 'Allow peers to reach the server\'s LAN', 'Private IPv4 and the IPv6 networks on the uplink interface'),
cb('openPort', 'Accept UDP ' + draft.listenPort + ' in the input chain'))), cb('openPort', 'Accept UDP ' + draft.listenPort + ' in the input chain'))),
h('section', { class: 'card', 'aria-labelledby': 'ky' }, h('section', { class: 'card', 'aria-labelledby': 'ky' },
@@ -2117,7 +2125,7 @@
notes ? h('div', { class: 'upnotes' }, notes ? h('div', { class: 'upnotes' },
h('div', { class: 'hd' }, h('strong', null, 'What\'s new in ' + rel.version), h('div', { class: 'hd' }, h('strong', null, 'What\'s new in ' + rel.version),
h('span', { class: 'muted' }, 'Released ' + fmtDate(rel.published) + ' · from ' + srcName()), h('span', { class: 'muted' }, 'Released ' + fmtDate(rel.published) + ' · from ' + srcName()),
h('a', { href: rel.url, target: '_blank', rel: 'noopener' }, 'Full notes on ' + srcName())), ext(rel.url, 'Full notes on ' + srcName())),
/security/i.test(notes.summary) ? h('p', { class: 'notice' }, 'Includes security fixes.') : null, /security/i.test(notes.summary) ? h('p', { class: 'notice' }, 'Includes security fixes.') : null,
notes.summary ? h('p', null, mdInline(notes.summary)) : null, notes.summary ? h('p', null, mdInline(notes.summary)) : null,
notes.items.length ? h('ul', null, notes.items.map((t) => h('li', null, mdInline(t)))) : null) : null, notes.items.length ? h('ul', null, notes.items.map((t) => h('li', null, mdInline(t)))) : null) : null,
@@ -2125,7 +2133,7 @@
h('div', { class: 'hd' }, h('strong', null, 'Update this server'), h('span', { class: 'muted' }, 'Run on the server. VPN connections stay up.')), h('div', { class: 'hd' }, h('strong', null, 'Update this server'), h('span', { class: 'muted' }, 'Run on the server. VPN connections stay up.')),
h('pre', { class: 'code' }, cmds), h('pre', { class: 'code' }, cmds),
h('div', null, h('button', { type: 'button', class: 'btn small', onClick: () => copy(cmds) }, 'Copy commands'))) : null, h('div', null, h('button', { type: 'button', class: 'btn small', onClick: () => copy(cmds) }, 'Copy commands'))) : null,
st.available && !st.file ? h('p', null, 'No release file is built for this platform. ', h('a', { href: rel.url, target: '_blank', rel: 'noopener' }, 'See the release')) : null, st.available && !st.file ? h('p', null, 'No release file is built for this platform. ', ext(rel.url, 'See the release')) : null,
h('fieldset', { class: 'section' }, h('legend', { class: 'legend' }, 'Release source'), h('fieldset', { class: 'section' }, h('legend', { class: 'legend' }, 'Release source'),
h('div', { class: 'grid' }, SOURCES.map(([k, name, where]) => h('label', { class: 'opt' }, h('div', { class: 'grid' }, SOURCES.map(([k, name, where]) => h('label', { class: 'opt' },
h('input', { type: 'radio', name: 'upsrc', value: k, checked: st.source === k, onChange: () => save({ source: k }) }), h('input', { type: 'radio', name: 'upsrc', value: k, checked: st.source === k, onChange: () => save({ source: k }) }),
+7 -2
View File
@@ -287,9 +287,14 @@ func remoteIP(r *http.Request) string {
host = r.RemoteAddr host = r.RemoteAddr
} }
// Behind a local reverse proxy the real client is in X-Forwarded-For. // Behind a local reverse proxy the real client is in X-Forwarded-For.
// The proxy appends the address it saw, so only the last entry counts:
// earlier ones come from the client and can be anything.
if ip := net.ParseIP(host); ip != nil && ip.IsLoopback() { if ip := net.ParseIP(host); ip != nil && ip.IsLoopback() {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" { if xff := r.Header.Values("X-Forwarded-For"); len(xff) > 0 {
return strings.TrimSpace(strings.Split(xff, ",")[0]) list := strings.Split(xff[len(xff)-1], ",")
if last := strings.TrimSpace(list[len(list)-1]); net.ParseIP(last) != nil {
return last
}
} }
} }
return host return host
+40 -1
View File
@@ -11,6 +11,7 @@ import (
"path/filepath" "path/filepath"
"regexp" "regexp"
"slices" "slices"
"strconv"
"strings" "strings"
"sync" "sync"
"syscall" "syscall"
@@ -76,8 +77,29 @@ const (
minLogFiles, maxLogFiles = 1, 100 minLogFiles, maxLogFiles = 1, 100
minHourlyHours, maxHourlyHrs = 24, 24 * 31 minHourlyHours, maxHourlyHrs = 24, 24 * 31
minDailyDays, maxDailyDays = 7, 3660 minDailyDays, maxDailyDays = 7, 3660
minSessionHours = 1
maxSessionHours = 30 * 24
) )
// validateListen checks a listen address like ":443" or "192.0.2.1:443".
// Empty is allowed when optional (the HTTP listener is then off).
func validateListen(addr, field string, optional bool) error {
if addr == "" && optional {
return nil
}
host, port, err := net.SplitHostPort(addr)
if err != nil {
return fmt.Errorf("%s %q must look like :443 or 192.0.2.1:443", field, addr)
}
if n, err := strconv.Atoi(port); err != nil || n < 1 || n > 65535 {
return fmt.Errorf("%s %q: the port must be 1–65535", field, addr)
}
if host != "" && host != "localhost" && checkEndpoint(host) != nil {
return fmt.Errorf("%s %q: %q is not an IP address or host name", field, addr, host)
}
return nil
}
type WebConfig struct { type WebConfig struct {
Listen string `json:"listen"` // HTTPS (or HTTP when tls.mode is "off") listen address Listen string `json:"listen"` // HTTPS (or HTTP when tls.mode is "off") listen address
HTTPListen string `json:"httpListen"` // plain HTTP for ACME http-01 and redirects; "" disables HTTPListen string `json:"httpListen"` // plain HTTP for ACME http-01 and redirects; "" disables
@@ -364,7 +386,9 @@ func (c *Config) validate() error {
if v6.Masked() != v6 { if v6.Masked() != v6 {
return fmt.Errorf("IPv6 network must be the network address, e.g. %s", v6.Masked()) return fmt.Errorf("IPv6 network must be the network address, e.g. %s", v6.Masked())
} }
if s.Endpoint != "" && strings.ContainsAny(s.Endpoint, " /:") && net.ParseIP(s.Endpoint) == nil { // The endpoint is written into client configs as is, so it must be a
// plain host name or IP: anything else could add lines to them.
if s.Endpoint != "" && checkEndpoint(s.Endpoint) != nil {
return errors.New("endpoint must be a host name or IP address without port") return errors.New("endpoint must be a host name or IP address without port")
} }
if err := validateHostList(s.ClientDefaults.DNS, "DNS", false); err != nil { if err := validateHostList(s.ClientDefaults.DNS, "DNS", false); err != nil {
@@ -397,6 +421,15 @@ func (c *Config) validate() error {
if _, ok := updateSources[c.Updates.Source]; !ok { if _, ok := updateSources[c.Updates.Source]; !ok {
return fmt.Errorf("update source must be gitea or github") return fmt.Errorf("update source must be gitea or github")
} }
if err := validateListen(c.Web.Listen, "listen address", false); err != nil {
return err
}
if err := validateListen(c.Web.HTTPListen, "HTTP listen address", true); err != nil {
return err
}
if h := c.Web.SessionHours; h < minSessionHours || h > maxSessionHours {
return fmt.Errorf("session length must be %d–%d hours", minSessionHours, maxSessionHours)
}
switch c.Web.TLS.Mode { switch c.Web.TLS.Mode {
case "acme": case "acme":
if c.Web.TLS.Domain == "" { if c.Web.TLS.Domain == "" {
@@ -586,6 +619,12 @@ func (s *Store) Update(fn func(c *Config) error) error {
s.mu.Unlock() s.mu.Unlock()
return &userError{err.Error()} return &userError{err.Error()}
} }
// With no user left (applyDefaults then adds an "admin" without a
// password), nobody could sign in until someone ran "passwd" on the server.
if old.passwordSet() && !next.passwordSet() {
s.mu.Unlock()
return &userError{"this would leave no user with a password, and nobody could sign in"}
}
if err := writeFileAtomic(s.path, next, 0o600); err != nil { if err := writeFileAtomic(s.path, next, 0o600); err != nil {
s.mu.Unlock() s.mu.Unlock()
return err return err
+29
View File
@@ -4,6 +4,7 @@ import (
"log/slog" "log/slog"
"net/netip" "net/netip"
"os" "os"
"slices"
"strings" "strings"
"sync" "sync"
"time" "time"
@@ -40,6 +41,28 @@ type Kernel interface {
Close() error Close() error
} }
// lanBlock picks, from the networks on the uplinks, the ones peers must not
// reach while LAN access is off: private IPv4 networks, and IPv6 networks
// except link-local, since a home LAN uses global IPv6 addresses. IPv6
// prefixes shorter than /48 are left out: they are no LAN.
func lanBlock(nets []netip.Prefix) []netip.Prefix {
var out []netip.Prefix
for _, p := range nets {
a := p.Addr().Unmap()
p = netip.PrefixFrom(a, min(p.Bits(), a.BitLen())).Masked()
switch {
case a.Is4() && !a.IsPrivate():
continue
case a.Is6() && (a.IsLinkLocalUnicast() || a.IsLoopback() || p.Bits() < 48):
continue
}
if !slices.Contains(out, p) {
out = append(out, p)
}
}
return out
}
// readSysctl returns the trimmed content of a /proc/sys file, or "". // readSysctl returns the trimmed content of a /proc/sys file, or "".
func readSysctl(path string) string { func readSysctl(path string) string {
b, err := os.ReadFile(path) b, err := os.ReadFile(path)
@@ -56,6 +79,10 @@ type Reconciler struct {
store *Store store *Store
trigger chan struct{} trigger chan struct{}
// applyMu runs one apply at a time. Each reads the config once it holds
// the lock, so the last apply always uses the newest config.
applyMu sync.Mutex
mu sync.Mutex mu sync.Mutex
lastErr error lastErr error
lastApply time.Time lastApply time.Time
@@ -76,6 +103,8 @@ func (r *Reconciler) Kick() {
// ApplyNow applies synchronously and returns the result, so an API call can // ApplyNow applies synchronously and returns the result, so an API call can
// report kernel errors to the user. // report kernel errors to the user.
func (r *Reconciler) ApplyNow() error { func (r *Reconciler) ApplyNow() error {
r.applyMu.Lock()
defer r.applyMu.Unlock()
err := r.kernel.Apply(r.store.Get()) err := r.kernel.Apply(r.store.Get())
r.mu.Lock() r.mu.Lock()
r.lastErr, r.lastApply = err, time.Now() r.lastErr, r.lastApply = err, time.Now()
+20 -19
View File
@@ -217,7 +217,8 @@ func (k *linuxKernel) Apply(c *Config) error {
if c.Server.IPv6Enabled { if c.Server.IPv6Enabled {
_ = os.WriteFile("/proc/sys/net/ipv6/conf/all/forwarding", []byte("1"), 0o644) _ = os.WriteFile("/proc/sys/net/ipv6/conf/all/forwarding", []byte("1"), 0o644)
} }
return applyFirewall(c, k.Uplink(c, false), k.Uplink(c, true), lanNetworks(k.Uplink(c, false))) up4, up6 := k.Uplink(c, false), k.Uplink(c, true)
return applyFirewall(c, up4, up6, lanNetworks(up4, up6))
} }
func (k *linuxKernel) Sample(iface string) ([]PeerSample, error) { func (k *linuxKernel) Sample(iface string) ([]PeerSample, error) {
@@ -264,27 +265,27 @@ func (k *linuxKernel) Uplink(c *Config, v6 bool) string {
return l.Attrs().Name return l.Attrs().Name
} }
// lanNetworks returns the private IPv4 networks on the uplink, used to block // lanNetworks returns the LAN networks on the IPv4 and IPv6 uplinks (see
// peers from the server's LAN when LAN access is off. // lanBlock), used to block peers from the server's LAN when LAN access is off.
func lanNetworks(uplink string) []netip.Prefix { func lanNetworks(uplinks ...string) []netip.Prefix {
if uplink == "" { var nets []netip.Prefix
return nil for i, uplink := range uplinks {
} if uplink == "" || slices.Contains(uplinks[:i], uplink) {
l, err := netlink.LinkByName(uplink)
if err != nil {
return nil
}
addrs, _ := netlink.AddrList(l, netlink.FAMILY_V4)
var out []netip.Prefix
for _, a := range addrs {
if !a.IP.IsPrivate() {
continue continue
} }
ones, _ := a.Mask.Size() l, err := netlink.LinkByName(uplink)
ip, _ := netip.AddrFromSlice(a.IP.To4()) if err != nil {
out = append(out, netip.PrefixFrom(ip, ones).Masked()) continue
}
addrs, _ := netlink.AddrList(l, netlink.FAMILY_ALL)
for _, a := range addrs {
ones, _ := a.Mask.Size()
if ip, ok := netip.AddrFromSlice(a.IP); ok {
nets = append(nets, netip.PrefixFrom(ip.Unmap(), ones))
}
}
} }
return out return lanBlock(nets)
} }
// publicAddr reports the uplink's address for the health check: the first // publicAddr reports the uplink's address for the health check: the first
+4
View File
@@ -224,6 +224,10 @@ func run(configPath string) error {
app := &App{ app := &App{
store: store, kernel: kernel, recon: recon, stats: stats, speeds: speeds, auth: auth, tls: webTLS, store: store, kernel: kernel, recon: recon, stats: stats, speeds: speeds, auth: auth, tls: webTLS,
logPath: logPath, logw: logw, geo: geo, updates: newUpdater(cfg.Updates), started: time.Now(), shutdown: shutdown, logPath: logPath, logw: logw, geo: geo, updates: newUpdater(cfg.Updates), started: time.Now(), shutdown: shutdown,
webAddrs: []string{cfg.Web.Listen},
}
if cfg.Web.HTTPListen != "" && cfg.Web.TLS.Mode != "off" {
app.webAddrs = append(app.webAddrs, cfg.Web.HTTPListen)
} }
var wg sync.WaitGroup var wg sync.WaitGroup
+268
View File
@@ -6,6 +6,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"net"
"net/http" "net/http"
"net/http/cookiejar" "net/http/cookiejar"
"net/http/httptest" "net/http/httptest"
@@ -79,6 +80,15 @@ func TestValidate(t *testing.T) {
"bad port": func(c *Config) { c.Server.ListenPort = 70000 }, "bad port": func(c *Config) { c.Server.ListenPort = 70000 },
"unmasked net": func(c *Config) { c.Server.IPv4 = "10.84.12.5/24" }, "unmasked net": func(c *Config) { c.Server.IPv4 = "10.84.12.5/24" },
"update source": func(c *Config) { c.Updates.Source = "sourceforge" }, "update source": func(c *Config) { c.Updates.Source = "sourceforge" },
// The endpoint goes into client configs: no extra lines.
"endpoint newline": func(c *Config) { c.Server.Endpoint = "vpn.example.net\n[Interface]\nPreUp=id;#" },
"endpoint tab": func(c *Config) { c.Server.Endpoint = "vpn.example.net\tx" },
"endpoint port": func(c *Config) { c.Server.Endpoint = "vpn.example.net:51820" },
"listen": func(c *Config) { c.Web.Listen = "not-an-address" },
"listen port": func(c *Config) { c.Web.Listen = ":70000" },
"http listen": func(c *Config) { c.Web.HTTPListen = "80" },
"session hours": func(c *Config) { c.Web.SessionHours = -1 },
"session too long": func(c *Config) { c.Web.SessionHours = 100000 },
} { } {
cc := c.clone() cc := c.clone()
mutate(cc) mutate(cc)
@@ -86,6 +96,20 @@ func TestValidate(t *testing.T) {
t.Errorf("%s: expected an error", name) t.Errorf("%s: expected an error", name)
} }
} }
for _, ep := range []string{"vpn.example.net", "203.0.113.7", "2001:db8::1"} {
cc := c.clone()
cc.Server.Endpoint = ep
if err := cc.validate(); err != nil {
t.Errorf("endpoint %q rejected: %v", ep, err)
}
}
for _, l := range []string{":443", "0.0.0.0:8443", "[::]:443", "localhost:8080"} {
cc := c.clone()
cc.Web.Listen = l
if err := cc.validate(); err != nil {
t.Errorf("listen %q rejected: %v", l, err)
}
}
} }
func TestClientConfig(t *testing.T) { func TestClientConfig(t *testing.T) {
@@ -1339,3 +1363,247 @@ func TestSpeeds(t *testing.T) {
t.Fatalf("kept %d points, want %d", n, speedPoints) t.Fatalf("kept %d points, want %d", n, speedPoints)
} }
} }
// signedInApp starts the API with a signed-in admin and returns the app
// and a call function.
func signedInApp(t *testing.T) (*App, func(method, path string, body any, want int) map[string]any) {
t.Helper()
dir := t.TempDir()
store, err := openStore(filepath.Join(dir, "config.json"))
if err != nil {
t.Fatal(err)
}
hash, _ := hashPassword("a long test password")
_ = store.Update(func(c *Config) error { c.Users[0].PasswordHash = hash; return nil })
k := &fakeKernel{}
st, _ := openStats(filepath.Join(dir, "stats.json"), store, k)
app := &App{store: store, kernel: k, recon: newReconciler(k, store), stats: st, auth: newAuth(store),
tls: &webTLS{}, logPath: filepath.Join(dir, "log.jsonl"), started: time.Now(), shutdown: func() {}}
srv := httptest.NewServer(app.routes())
t.Cleanup(srv.Close)
jar, _ := cookiejar.New(nil)
cl := &http.Client{Jar: jar}
call := func(method, path string, body any, want int) map[string]any {
t.Helper()
var rd io.Reader
if body != nil {
b, _ := json.Marshal(body)
rd = bytes.NewReader(b)
}
req, _ := http.NewRequest(method, srv.URL+"/api/v1"+path, rd)
req.Header.Set("Content-Type", "application/json")
resp, err := cl.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var out map[string]any
_ = json.NewDecoder(resp.Body).Decode(&out)
if resp.StatusCode != want {
t.Fatalf("%s %s: status %d, want %d: %v", method, path, resp.StatusCode, want, out)
}
return out
}
call("POST", "/auth/login", map[string]string{"username": "admin", "password": "a long test password"}, 200)
return app, call
}
// Web settings the service could not start with are refused before they
// are saved: a restart would otherwise take the web interface and the API
// down for good.
func TestWebSettingsCheck(t *testing.T) {
app, call := signedInApp(t)
web := func(change func(w *WebConfig)) map[string]any {
w := app.store.Get().Web
w.HTTPListen = ""
change(&w)
return map[string]any{"web": w}
}
call("PATCH", "/settings", web(func(w *WebConfig) { w.Listen = "not-an-address" }), 400)
call("PATCH", "/settings", web(func(w *WebConfig) { w.SessionHours = -1 }), 400)
call("PATCH", "/settings", web(func(w *WebConfig) {
w.TLS = TLSConfig{Mode: "files", CertFile: "/nonexistent/cert.pem", KeyFile: "/nonexistent/key.pem"}
}), 400)
// A port another program holds is refused; a free one is saved.
busy, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer busy.Close()
call("PATCH", "/settings", web(func(w *WebConfig) { w.Listen = busy.Addr().String() }), 400)
free, _ := net.Listen("tcp", "127.0.0.1:0")
addr := free.Addr().String()
free.Close()
call("PATCH", "/settings", web(func(w *WebConfig) { w.Listen = addr; w.TLS = TLSConfig{Mode: "off"} }), 200)
if app.store.Get().Web.Listen != addr {
t.Fatal("valid listen address not saved")
}
// The address the service listens on now is in use by itself: fine.
app.webAddrs = []string{busy.Addr().String()}
call("PATCH", "/settings", web(func(w *WebConfig) { w.Listen = busy.Addr().String() }), 200)
// Restore runs the same check.
backup := app.store.Get()
backup.Web.Listen = "not-an-address"
call("POST", "/restore", backup, 400)
}
func TestRemoteIP(t *testing.T) {
for _, c := range []struct {
remote string
xff []string
want string
}{
{"203.0.113.5:1234", nil, "203.0.113.5"},
{"203.0.113.5:1234", []string{"198.51.100.1"}, "203.0.113.5"}, // not from a local proxy
{"127.0.0.1:1234", []string{"198.51.100.1"}, "198.51.100.1"},
// The client sent its own header; the proxy appended the real address.
{"127.0.0.1:1234", []string{"1.2.3.4, 198.51.100.1"}, "198.51.100.1"},
{"127.0.0.1:1234", []string{"1.2.3.4", "198.51.100.1"}, "198.51.100.1"},
{"127.0.0.1:1234", []string{"garbage"}, "127.0.0.1"},
} {
r := httptest.NewRequest("GET", "/", nil)
r.RemoteAddr = c.remote
for _, v := range c.xff {
r.Header.Add("X-Forwarded-For", v)
}
if got := remoteIP(r); got != c.want {
t.Errorf("%s %v: got %s, want %s", c.remote, c.xff, got, c.want)
}
}
}
// Anyone can start a passkey sign-in, so pending ones are capped per
// address and in total.
func TestPasskeyLoginCap(t *testing.T) {
a := newAuth(nil)
start := func(id, ip string, expires time.Time) bool {
a.mu.Lock()
defer a.mu.Unlock()
return a.addPasskeyLoginLocked(id, &ceremony{ip: lockKey(ip), expires: expires})
}
later := time.Now().Add(ticketTTL)
for i := range maxPasskeyLoginsPerIP {
if !start(fmt.Sprint("a", i), "198.51.100.1", later) {
t.Fatalf("sign-in %d refused", i)
}
}
if start("a-more", "198.51.100.1", later) {
t.Fatal("too many sign-ins from one address accepted")
}
if !start("b0", "198.51.100.2", later) {
t.Fatal("another address refused")
}
// Expired ones make room again.
a.mfa.logins = map[string]*ceremony{}
start("old", "198.51.100.3", time.Now().Add(-time.Second))
if !start("new", "198.51.100.3", later) || len(a.mfa.logins) != 1 {
t.Fatalf("expired sign-in not dropped: %d pending", len(a.mfa.logins))
}
// In total, the oldest makes room.
a.mfa.logins = map[string]*ceremony{}
for i := range maxPasskeyLogins {
start(fmt.Sprint("c", i), fmt.Sprintf("10.0.%d.%d", i/250, i%250), later.Add(time.Duration(i)*time.Millisecond))
}
start("last", "192.0.2.1", later.Add(time.Hour))
if _, ok := a.mfa.logins["c0"]; ok || len(a.mfa.logins) != maxPasskeyLogins {
t.Fatalf("cap not kept: %d pending, oldest kept %v", len(a.mfa.logins), ok)
}
}
func TestLanBlock(t *testing.T) {
got := lanBlock([]netip.Prefix{
netip.MustParsePrefix("192.168.1.20/24"),
netip.MustParsePrefix("203.0.113.9/24"), // public IPv4: not a LAN
netip.MustParsePrefix("2001:db8:1:2::20/64"),
netip.MustParsePrefix("fd00:1:2:3::20/64"),
netip.MustParsePrefix("fe80::1/64"),
netip.MustParsePrefix("2001:db8::1/32"), // no LAN
netip.MustParsePrefix("192.168.1.30/24"), // same network twice
})
want := []netip.Prefix{
netip.MustParsePrefix("192.168.1.0/24"),
netip.MustParsePrefix("2001:db8:1:2::/64"),
netip.MustParsePrefix("fd00:1:2:3::/64"),
}
if !slices.Equal(got, want) {
t.Fatalf("got %v, want %v", got, want)
}
}
// A change that would leave no user with a password is refused: restoring
// a backup without users, or the last users deleting each other.
func TestNoUserLeftWithPassword(t *testing.T) {
app, call := signedInApp(t)
if err := app.store.Update(func(c *Config) error { c.Users = nil; return nil }); err == nil {
t.Fatal("removing every user was accepted")
}
if !app.store.Get().passwordSet() {
t.Fatal("password lost")
}
backup := app.store.Get()
backup.Users, backup.APITokens = nil, nil
call("POST", "/restore", backup, 400)
backup = app.store.Get()
backup.Version = configVersion + 1
call("POST", "/restore", backup, 400)
call("POST", "/restore", app.store.Get(), 200)
if !app.store.Get().passwordSet() {
t.Fatal("password lost")
}
}
// slowKernel records the configs it applied; the first apply takes a while.
type slowKernel struct {
fakeKernel
mu sync.Mutex
calls int
applied []string // peer names, per apply
}
func (k *slowKernel) Apply(c *Config) error {
k.mu.Lock()
k.calls++
first := k.calls == 1
k.mu.Unlock()
if first {
time.Sleep(200 * time.Millisecond)
}
var names []string
for _, p := range c.Peers {
names = append(names, p.Name)
}
k.mu.Lock()
k.applied = append(k.applied, strings.Join(names, ","))
k.mu.Unlock()
return nil
}
// Applies run one at a time, so a slow apply of an older config cannot
// finish after the newest one and undo it in the kernel.
func TestApplyOrder(t *testing.T) {
store, err := openStore(filepath.Join(t.TempDir(), "config.json"))
if err != nil {
t.Fatal(err)
}
k := &slowKernel{}
r := newReconciler(k, store)
var wg sync.WaitGroup
wg.Add(1)
go func() { defer wg.Done(); _ = r.ApplyNow() }() // the old config, slowly
time.Sleep(50 * time.Millisecond)
if err := store.Update(func(c *Config) error {
c.Peers = append(c.Peers, Peer{ID: newID(), Name: "phone", IPv4: serverIPv4(netip.MustParsePrefix(c.Server.IPv4)).Next().String()})
return nil
}); err != nil {
t.Fatal(err)
}
_ = r.ApplyNow()
wg.Wait()
if last := k.applied[len(k.applied)-1]; last != "phone" {
t.Fatalf("the kernel ended with %q, not the newest config; applies: %q", last, k.applied)
}
}
+42 -1
View File
@@ -217,6 +217,7 @@ type ticket struct {
type ceremony struct { type ceremony struct {
userID string // "" for a passkey sign-in userID string // "" for a passkey sign-in
ip string // lockKey of who started a passkey sign-in
data *webauthn.SessionData data *webauthn.SessionData
expires time.Time expires time.Time
} }
@@ -551,12 +552,52 @@ func (a *App) loginPasskeyBegin(w http.ResponseWriter, r *http.Request) {
return return
} }
id := randomString(24) id := randomString(24)
ip := remoteIP(r)
a.auth.mu.Lock() a.auth.mu.Lock()
a.auth.mfa.logins[id] = &ceremony{data: data, expires: time.Now().Add(ticketTTL)} ok := a.auth.addPasskeyLoginLocked(id, &ceremony{data: data, ip: lockKey(ip), expires: time.Now().Add(ticketTTL)})
a.auth.mu.Unlock() a.auth.mu.Unlock()
if !ok {
writeJSON(w, http.StatusTooManyRequests, map[string]string{"error": errBusy.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{"id": id, "options": opts}) writeJSON(w, http.StatusOK, map[string]any{"id": id, "options": opts})
} }
// Anyone can start a passkey sign-in, so the pending ones are capped: per
// address, and in total, where the oldest makes room.
const (
maxPasskeyLogins = 1000
maxPasskeyLoginsPerIP = 10
)
// addPasskeyLoginLocked stores a started passkey sign-in, or reports false
// when its address has too many pending. a.mu must be held.
func (a *Auth) addPasskeyLoginLocked(id string, c *ceremony) bool {
now := time.Now()
var fromIP int
var oldestID string
for k, x := range a.mfa.logins {
if now.After(x.expires) {
delete(a.mfa.logins, k)
continue
}
if x.ip == c.ip {
fromIP++
}
if oldestID == "" || x.expires.Before(a.mfa.logins[oldestID].expires) {
oldestID = k
}
}
if fromIP >= maxPasskeyLoginsPerIP {
return false
}
if len(a.mfa.logins) >= maxPasskeyLogins {
delete(a.mfa.logins, oldestID)
}
a.mfa.logins[id] = c
return true
}
func (a *App) loginPasskeyFinish(w http.ResponseWriter, r *http.Request) { func (a *App) loginPasskeyFinish(w http.ResponseWriter, r *http.Request) {
id := r.URL.Query().Get("id") id := r.URL.Query().Get("id")
ip := remoteIP(r) ip := remoteIP(r)
+7 -3
View File
@@ -20,6 +20,10 @@
} }
// Same drawing as favicon.svg. // Same drawing as favicon.svg.
// ext opens an outside page in a new tab, marked with ↗ as in the app.
const ext = (href, text) => h('a', { class: 'ext', href, target: '_blank', rel: 'noopener' }, text,
h('span', { class: 'ar', 'aria-hidden': 'true' }, '↗'), h('span', { class: 'sr' }, ' (opens in a new tab)'));
function logo(size, plain) { function logo(size, plain) {
const s = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); const s = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
for (const [k, v] of Object.entries({ width: size, height: size, viewBox: '0 0 64 64', 'aria-hidden': 'true' })) s.setAttribute(k, v); for (const [k, v] of Object.entries({ width: size, height: size, viewBox: '0 0 64 64', 'aria-hidden': 'true' })) s.setAttribute(k, v);
@@ -108,9 +112,9 @@
h('div', { class: 'notice' }, 'Save it now. This page can\'t be opened again: the private key exists only here and isn\'t stored anywhere.'), h('div', { class: 'notice' }, 'Save it now. This page can\'t be opened again: the private key exists only here and isn\'t stored anywhere.'),
h('ol', { class: 'steps' }, h('ol', { class: 'steps' },
step(1, 'Install WireGuard', step(1, 'Install WireGuard',
h('p', null, h('a', { href: 'https://apps.apple.com/app/wireguard/id1441195209', rel: 'noopener' }, 'App Store'), ' · ', h('p', null, ext('https://apps.apple.com/app/wireguard/id1441195209', 'App Store'), ' · ',
h('a', { href: 'https://play.google.com/store/apps/details?id=com.wireguard.android', rel: 'noopener' }, 'Google Play'), ' · ', ext('https://play.google.com/store/apps/details?id=com.wireguard.android', 'Google Play'), ' · ',
h('a', { href: 'https://www.wireguard.com/install/', rel: 'noopener' }, 'Other systems'))), ext('https://www.wireguard.com/install/', 'Other systems'))),
step(2, 'Add the profile', step(2, 'Add the profile',
h('button', { type: 'button', class: 'btn primary', onClick: download }, 'Download ' + file), h('button', { type: 'button', class: 'btn primary', onClick: download }, 'Download ' + file),
h('p', null, 'Open the downloaded file with WireGuard, or in WireGuard tap + and choose “Create from file”.')), h('p', null, 'Open the downloaded file with WireGuard, or in WireGuard tap + and choose “Create from file”.')),