Add one-time setup links as an alternative to the QR code

A config can now be handed over as a one-time link, valid for 1 h, 24 h or
7 days and protected by a PIN by default. Keys are made only when the link
is opened; the link works once and is revoked after 5 wrong PINs. Issuing a
new config offers the same choice, and the current config keeps working
until the link is used.

Remove the option to paste a client's public key, in the web UI, the API
and the iOS app.
This commit is contained in:
Daniel Redetzke
2026-10-03 23:10:28 +03:00
parent 55aaaa3a78
commit ef1988e4d0
17 changed files with 885 additions and 119 deletions
+11
View File
@@ -38,6 +38,10 @@ dependencies on the server: the binary installs, updates and removes itself.
- **Client private keys are never stored.** A config is shown once, as a
download or QR code. "Issue new config" makes new keys.
- **Setup links:** instead of showing the QR code, you can send the device's
owner a one-time link, valid for 1 hour, 24 hours or 7 days and protected by
a 4-digit PIN by default. The keys are made only when the link is opened.
The link works once, and 5 wrong PINs revoke it.
- **The service is not root.** It runs as user `ghostwire` with only
`CAP_NET_ADMIN` and `CAP_NET_BIND_SERVICE`, and can write only to
`/opt/ghostwire`.
@@ -164,11 +168,18 @@ GET /peers POST /peers (returns the config and QR once)
GET /peers/{id} PATCH /peers/{id} DELETE /peers/{id}
POST /peers/{id}/enable | /disable | /issue-config
GET /peers/{id}/stats?range=… GET /peers/{id}/sessions?limit=100
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
admin: GET|POST /tokens · DELETE /tokens/{id} · GET /backup · POST /restore
public: GET /setup/{token} · POST /setup/{token} {"pin"} (what a setup link opens)
```
`POST /peers` and `POST /peers/{id}/issue-config` take
`{"delivery": "link", "linkHours": 1|24|168, "linkPIN": true}` to answer with a
setup link (`setup.url`, `setup.pin`, `setup.qr`) instead of a config. With a
link, the peer's current keys keep working until the link is opened.
Traffic is reported from the peer's point of view: `down` is what the peer
downloaded, `up` is what it uploaded.
+89 -30
View File
@@ -12,8 +12,6 @@ import (
"slices"
"strings"
"time"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
)
// App wires the parts together and serves the HTTP API.
@@ -126,6 +124,13 @@ func (a *App) routes() http.Handler {
g("POST /api/v1/peers/{id}/issue-config", a.issueConfig)
g("GET /api/v1/peers/{id}/stats", a.peerStats)
g("GET /api/v1/peers/{id}/sessions", a.peerSessions)
g("GET /api/v1/peers/{id}/setup", a.getSetup)
g("DELETE /api/v1/peers/{id}/setup", a.revokeSetup)
// Setup links work without signing in: the token in the link is the
// credential.
mux.HandleFunc("GET /api/v1/setup/{token}", a.setupInfo)
mux.HandleFunc("POST /api/v1/setup/{token}", a.setupRedeem)
// Full-access tokens (the iOS app) may change app settings and read logs.
// Password, tokens and backups stay with the admin account.
@@ -143,6 +148,7 @@ func (a *App) routes() http.Handler {
mux.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "no such endpoint"})
})
mux.HandleFunc("GET /setup/{token}", setupPage)
mux.Handle("/", webHandler())
csrf := http.NewCrossOriginProtection()
@@ -505,6 +511,7 @@ type peerView struct {
EffKeepalive int `json:"effectiveKeepalive"`
Created time.Time `json:"created"`
ConfigIssued *time.Time `json:"configIssued"`
Setup *setupView `json:"setup"` // null = no pending setup link
Stats PeerSummary `json:"stats"`
}
@@ -513,7 +520,7 @@ func (a *App) peerView(c *Config, p *Peer) peerView {
ID: p.ID, Name: p.Name, Note: p.Note, Enabled: p.Enabled, PublicKey: p.PublicKey,
HasPSK: p.PresharedKey != "", IPv4: p.IPv4, DNS: p.DNS, AllowedIPs: p.AllowedIPs, Keepalive: p.Keepalive,
EffDNS: peerDNS(c, p), EffAllowed: peerAllowedIPs(c, p), EffKeepalive: peerKeepalive(c, p),
Created: p.Created, ConfigIssued: p.ConfigIssued, Stats: a.stats.Summary(p.ID),
Created: p.Created, ConfigIssued: p.ConfigIssued, Setup: viewSetup(p.Setup), Stats: a.stats.Summary(p.ID),
}
if c.Server.IPv6Enabled {
v.IPv6 = mapIPv6(netip.MustParsePrefix(c.Server.IPv6), netip.MustParseAddr(p.IPv4)).String()
@@ -545,21 +552,19 @@ func (a *App) getPeer(w http.ResponseWriter, r *http.Request) {
type issuedConfig struct {
Peer peerView `json:"peer"`
Config string `json:"config"`
QR string `json:"qr,omitempty"`
HasPrivKey bool `json:"includesPrivateKey"`
QR string `json:"qr"`
HasPrivKey bool `json:"includesPrivateKey"` // always true; kept for older clients
ApplyError string `json:"applyError"`
}
// newKeys returns a fresh key pair, or only the given public key when the
// client made its own keys.
func newKeys(clientPublic string) (priv, pub string, err error) {
if clientPublic != "" {
k, err := wgtypes.ParseKey(strings.TrimSpace(clientPublic))
if err != nil {
return "", "", badRequest("public key is not a valid WireGuard key")
}
return "", k.String(), nil
}
// linkCreated answers a create or issue request that asked for a setup link.
type linkCreated struct {
Peer peerView `json:"peer"`
Setup setupSecret `json:"setup"`
ApplyError string `json:"applyError"`
}
func newKeys() (priv, pub string, err error) {
k, err := newPrivateKey()
if err != nil {
return "", "", err
@@ -570,17 +575,24 @@ func newKeys(clientPublic string) (priv, pub string, err error) {
func (a *App) issue(id, priv string) (issuedConfig, error) {
cfg := a.store.Get()
_, p := cfg.peerByID(id)
out := issuedConfig{Peer: a.peerView(cfg, p), Config: clientConfig(cfg, p, priv), HasPrivKey: priv != ""}
if priv != "" {
out := issuedConfig{Peer: a.peerView(cfg, p), Config: clientConfig(cfg, p, priv), HasPrivKey: true}
qr, err := qrDataURL(out.Config)
if err != nil {
return out, err
}
out.QR = qr
}
return out, nil
}
func (a *App) linkCreated(r *http.Request, id string) (linkCreated, error) {
cfg := a.store.Get()
_, p := cfg.peerByID(id)
out := linkCreated{Peer: a.peerView(cfg, p)}
sec, err := secretFor(r, p.Setup)
out.Setup = sec
return out, err
}
func (a *App) createPeer(w http.ResponseWriter, r *http.Request) {
var in struct {
Name string `json:"name"`
@@ -589,23 +601,36 @@ func (a *App) createPeer(w http.ResponseWriter, r *http.Request) {
DNS []string `json:"dns"`
AllowedIPs []string `json:"allowedIPs"`
Keepalive *int `json:"keepalive"`
PublicKey string `json:"publicKey"`
PresharedKey *bool `json:"presharedKey"`
setupRequest
}
if err := readJSON(r, &in); err != nil {
writeErr(w, err)
return
}
in.Name = strings.TrimSpace(in.Name)
priv, pub, err := newKeys(in.PublicKey)
link, err := in.newLink()
if err != nil {
writeErr(w, err)
return
}
p := Peer{
ID: newID(), Name: in.Name, Note: strings.TrimSpace(in.Note), Enabled: true, PublicKey: pub,
ID: newID(), Name: in.Name, Note: strings.TrimSpace(in.Note), Enabled: true,
DNS: in.DNS, AllowedIPs: in.AllowedIPs, Keepalive: in.Keepalive, Created: time.Now().UTC(),
}
// With a link, the keys are made when the link is opened.
var priv string
if in.wantsLink() {
p.Setup = link
} else {
var pub string
if priv, pub, err = newKeys(); err != nil {
writeErr(w, err)
return
}
now := time.Now().UTC()
p.PublicKey, p.ConfigIssued = pub, &now
}
if in.PresharedKey == nil || *in.PresharedKey {
psk, err := newPresharedKey()
if err != nil {
@@ -614,8 +639,6 @@ func (a *App) createPeer(w http.ResponseWriter, r *http.Request) {
}
p.PresharedKey = psk.String()
}
now := time.Now().UTC()
p.ConfigIssued = &now
err = a.store.Update(func(c *Config) error {
if err := validatePeerName(p.Name); err != nil {
return &userError{err.Error()}
@@ -636,7 +659,16 @@ func (a *App) createPeer(w http.ResponseWriter, r *http.Request) {
writeErr(w, err)
return
}
a.audit(r, "peer created", "peer", p.Name, "ip", p.IPv4)
a.audit(r, "peer created", "peer", p.Name, "ip", p.IPv4, "delivery", map[bool]string{true: "link", false: "show"}[in.wantsLink()])
if in.wantsLink() {
out, err := a.linkCreated(r, p.ID)
if err != nil {
writeErr(w, err)
return
}
writeJSON(w, http.StatusCreated, out)
return
}
out, err := a.issue(p.ID, priv)
if err != nil {
writeErr(w, err)
@@ -751,19 +783,45 @@ func (a *App) deletePeer(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "applyError": a.apply()})
}
// issueConfig replaces the peer's keys. The old device stops working.
// issueConfig replaces the peer's keys. The old device stops working. With
// a setup link, the keys are replaced only when the link is opened.
func (a *App) issueConfig(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
var in struct {
PublicKey string `json:"publicKey"`
}
var in setupRequest
if r.ContentLength > 0 {
if err := readJSON(r, &in); err != nil {
writeErr(w, err)
return
}
}
priv, pub, err := newKeys(in.PublicKey)
link, err := in.newLink()
if err != nil {
writeErr(w, err)
return
}
if in.wantsLink() {
var name string
if err := a.store.Update(func(c *Config) error {
_, p := c.peerByID(id)
if p == nil {
return badRequest("no such peer")
}
p.Setup, name = link, p.Name
return nil
}); err != nil {
writeErr(w, err)
return
}
a.audit(r, "setup link created", "peer", name, "expires", link.Expires)
out, err := a.linkCreated(r, id)
if err != nil {
writeErr(w, err)
return
}
writeJSON(w, http.StatusOK, out)
return
}
priv, pub, err := newKeys()
if err != nil {
writeErr(w, err)
return
@@ -780,7 +838,8 @@ func (a *App) issueConfig(w http.ResponseWriter, r *http.Request) {
return badRequest("no such peer")
}
now := time.Now().UTC()
p.PublicKey, p.ConfigIssued, name = pub, &now, p.Name
// A config issued here replaces any pending link.
p.PublicKey, p.ConfigIssued, p.Setup, name = pub, &now, nil, p.Name
if p.PresharedKey != "" {
p.PresharedKey = psk.String()
}
+35
View File
@@ -100,6 +100,7 @@ h1 { margin: 0; font-size: 26px; font-weight: 600; letter-spacing: -0.01em; over
.dot.ok { background: var(--good); }
.dot.off { background: transparent; border: 1.5px solid #9a9b97; }
.dot.bad { background: var(--bad); }
.dot.warn { background: var(--up); }
.dot.big { width: 10px; height: 10px; }
.tag { display: inline-block; font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 999px; background: var(--warn-bg); color: var(--warn-ink); margin-left: 6px; vertical-align: 1px; }
.notice { display: flex; gap: 10px; align-items: flex-start; padding: 12px 14px; border-radius: 10px; background: var(--warn-bg); color: var(--warn-ink); font-size: 13px; }
@@ -200,6 +201,11 @@ dialog::backdrop { background: rgba(22, 23, 26, .55); }
.dlg .foot { display: flex; justify-content: flex-end; flex-wrap: wrap; gap: 8px; }
.qrrow { display: flex; flex-wrap: wrap; gap: 16px; align-items: center; }
.qrrow .col { display: flex; flex-direction: column; gap: 8px; }
.qr.small { width: 168px; height: 168px; }
.kv.grow { flex: 1 1 240px; margin: 0; }
.pinrow { display: flex; align-items: center; gap: 12px; }
.pinval { font-family: var(--mono); font-size: 22px; font-weight: 600; letter-spacing: 0.3em; }
.linkopts { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 12px 20px; align-items: end; padding: 4px 0 4px 40px; }
/* toast */
.toasts { position: fixed; right: 16px; bottom: 16px; display: flex; flex-direction: column; gap: 8px; z-index: 50; max-width: calc(100vw - 32px); }
@@ -222,4 +228,33 @@ dialog::backdrop { background: rgba(22, 23, 26, .55); }
.loginform .err-text:empty { display: none; }
.loginfoot { margin: 0; font-family: var(--mono); font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase; color: #8d8e93; }
.err-text { color: var(--bad-ink); font-size: 13px; margin: 0; }
/* setup link page (setup.html): same dark look as the login page */
.setuppage { min-height: 100vh; display: flex; justify-content: center; padding: 48px 16px; background: var(--ink); color: #f4f4f1; font-size: 15px; }
.setuppage .loading-page { color: #8d8e93; }
.setupbox { width: 100%; max-width: 400px; display: flex; flex-direction: column; gap: 24px; margin: auto 0; }
.setupbox h1 { font-size: 22px; }
.setupbox p { margin: 0; color: #c9c9c3; }
.setupbox a { color: #9cc3f5; }
.setupbox a:hover { color: #fff; }
.setupbox .center, .setupbox.center { text-align: center; display: flex; flex-direction: column; align-items: center; gap: 8px; }
.setupbox.center { gap: 20px; }
.setupbox .ghost { opacity: .45; }
.setupbox p.note { text-align: center; font-size: 12px; color: #8d8e93; }
.setupbox .brand { padding: 0; }
.setupbox .brand.stack { flex-direction: column; gap: 14px; }
.setupbox .brand .wm { font-size: 16px; letter-spacing: 0.12em; }
.setupbox .brand.stack .wm { font-size: 18px; letter-spacing: 0.18em; margin-right: -0.18em; }
.setupbox strong.mono { color: #fff; font-weight: 500; }
.loginform input.pin { min-height: 56px; font-family: var(--mono); font-size: 24px; letter-spacing: 0.5em; text-align: center; }
.setupbox .notice { background: #3a2a14; color: #f6d3a6; }
.steps { margin: 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 20px; }
.steps li { display: flex; gap: 14px; }
.steps li > div { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 10px; }
.steps .num { flex: none; width: 28px; height: 28px; border-radius: 50%; border: 1px solid #3a3b41; display: grid; place-items: center; font-family: var(--mono); font-size: 13px; }
.steps strong { font-weight: 500; }
.steps p { font-size: 13px; }
.steps .btn { min-height: 48px; width: 100%; }
.steps .btn:not(.primary) { background: #222328; border-color: #3a3b41; color: #fff; }
.steps .btn.primary { background: #f4f4f1; border-color: #f4f4f1; color: var(--ink); font-weight: 600; }
.steps .qr { width: 100%; height: auto; max-width: 280px; align-self: center; }
.loading-page { padding: 40px; color: var(--ink-3); }
+123 -40
View File
@@ -109,6 +109,10 @@
function peerState(p) {
if (!p.enabled) return { key: 'disabled', label: 'Disabled', dot: 'dot bad' };
if (!p.publicKey) {
if (p.setup && !p.setup.expired) return { key: 'setup', label: 'Waiting for setup', dot: 'dot warn' };
return { key: 'nokey', label: p.setup ? 'Setup link expired' : 'No config yet', dot: 'dot off' };
}
if (p.stats.online) return { key: 'online', label: 'Online · ' + ago(p.stats.lastHandshake), dot: 'dot ok' };
if (p.stats.lastHandshake) return { key: 'offline', label: 'Offline · ' + ago(p.stats.lastHandshake), dot: 'dot' };
return { key: 'never', label: 'Never connected', dot: 'dot off' };
@@ -232,6 +236,52 @@
d.addEventListener('close', () => { applied(res); if (onClose) onClose(); });
}
// linkDialog shows a setup link with its PIN, to send to the device's owner.
function linkDialog(name, setup, onClose) {
const share = navigator.share
? h('button', { type: 'button', class: 'btn', onClick: () => navigator.share({ title: 'VPN setup for ' + name, url: setup.url }).catch(() => {}) }, 'Share…')
: null;
const d = dialog((close) => h('div', { class: 'dlg' },
h('h2', null, 'Setup link for ' + name),
h('div', { class: 'notice' }, setup.pin
? 'Anyone with this link and the PIN can set up this peer once. Send the PIN separately, e.g. by phone or another messenger.'
: 'Anyone with this link can set up this peer once. Send it only to the device\'s owner.'),
h('div', { class: 'field' }, h('label', { htmlFor: 'sl' }, 'Link'),
h('div', { class: 'row' }, h('input', { id: 'sl', class: 'mono', value: setup.url, readOnly: true, onFocus: (e) => e.target.select() }),
h('button', { type: 'button', class: 'btn primary', onClick: () => copy(setup.url) }, 'Copy link'), share)),
h('div', { class: 'qrrow' },
h('img', { class: 'qr small', src: setup.qr, alt: 'QR code of the setup link for ' + name }),
h('dl', { class: 'kv grow' },
setup.pin ? [h('dt', null, 'PIN'), h('dd', { class: 'pinrow' }, h('span', { class: 'pinval' }, setup.pin), h('button', { type: 'button', class: 'btn small', onClick: () => copy(setup.pin) }, 'Copy'))] : null,
h('dt', null, 'Valid until'), h('dd', null, fmtStamp(setup.expires)),
h('dt', null, 'Uses'), h('dd', null, 'Once. Then the link stops working.'))),
h('p', { class: 'hint' }, 'The QR code holds only the link, not the config. Until the link is used, you can copy it again or revoke it on the peer\'s page.'),
h('div', { class: 'foot' }, h('button', { type: 'button', class: 'btn primary', onClick: close }, 'Done'))));
d.addEventListener('close', () => { if (onClose) onClose(); });
}
// handover is the "Show it here" / "Send a setup link" choice used when a
// config is created or issued again.
function handover({ showHint, linkHint, onChange }) {
let mode = 'show';
const hours = h('select', { id: 'lh' }, [[1, '1 hour'], [24, '24 hours'], [168, '7 days']].map(([v, t]) => h('option', { value: String(v), selected: v === 24 }, t)));
const pin = h('input', { type: 'checkbox', checked: true });
const more = h('div', { class: 'linkopts', hidden: true },
h('div', { class: 'field' }, h('label', { htmlFor: 'lh' }, 'Link valid for'), hours),
h('label', { class: 'check' }, pin, h('span', null, 'Require a PIN', h('br'), h('span', { class: 'hint' }, 'Send it by another channel than the link'))));
const opt = (v, title, hint) => h('label', { class: 'opt' },
h('input', { type: 'radio', name: 'handover', value: v, checked: v === mode, onChange: () => { mode = v; more.hidden = v !== 'link'; if (onChange) onChange(); } }),
h('span', null, h('strong', null, title), h('br'), h('span', { class: 'hint' }, hint)));
return {
el: h('fieldset', null, h('legend', { class: 'legend' }, 'Hand over the config'),
opt('show', 'Show it here', showHint),
opt('link', 'Send a setup link', linkHint),
more),
link: () => mode === 'link',
body: () => mode === 'link' ? { delivery: 'link', linkHours: Number(hours.value), linkPIN: pin.checked } : {},
};
}
// ---------- chart ----------
function niceTop(max) {
@@ -512,7 +562,7 @@
const rows = data.peers.filter((p) => {
const st = peerState(p).key;
const hit = !q || (p.name + ' ' + p.ipv4 + ' ' + p.note).toLowerCase().includes(q);
const keep = filter === 'all' || filter === st || (filter === 'offline' && (st === 'offline' || st === 'never'));
const keep = filter === 'all' || filter === st || (filter === 'offline' && ['offline', 'never', 'setup', 'nokey'].includes(st));
return hit && keep;
});
tbody.replaceChildren(...rows.map((p) => h('tr', null,
@@ -597,7 +647,6 @@
const name = h('input', { id: 'n', autocomplete: 'off', required: true });
const note = h('input', { id: 'no' });
const ip = h('input', { id: 'ip', class: 'mono', placeholder: 'Next free address' });
const pub = h('textarea', { id: 'pk', class: 'mono', rows: 2, placeholder: 'Base64 public key from the device', hidden: true, 'aria-label': 'Client public key' });
const psk = h('input', { type: 'checkbox', checked: true });
const preview = h('pre', { class: 'code' });
const ch = overrideChoices(srv, null);
@@ -605,19 +654,30 @@
const dns = choice({ id: 'dns', label: 'DNS', ...ch.dns, placeholder: '9.9.9.9, 149.112.112.112', onChange: update });
const allowed = choice({ id: 'ai', label: 'Route through the VPN (AllowedIPs)', ...ch.allowed, placeholder: '10.0.0.0/24, 192.168.1.0/24', hint: 'Used in the client config', onChange: update });
const ka = choice({ id: 'ka', label: 'Persistent keepalive', ...ch.ka, placeholder: 'Seconds', hint: 'Keeps the tunnel open behind NAT', onChange: update });
let keyMode = 'generate';
const ho = handover({
showHint: 'QR code and download right after you click Create. Best when the device is next to you.',
linkHint: 'A one-time link you send to the device\'s owner. Keys are made when the link is opened and never stored.',
onChange: update,
});
const qrBox = h('div', { class: 'ph' });
function drawPreview() {
const link = ho.link();
submit.textContent = link ? 'Create peer and link' : 'Create peer';
qrBox.replaceChildren(link ? 'Setup link' : 'QR code', h('br'), 'after creation');
aside.textContent = link
? 'With a setup link, keys are created when the recipient opens it. Until then the peer is inactive.'
: 'Keys are created when you click Create peer. Then the config can be downloaded or scanned once.';
let o;
try { o = overrides(dns, allowed, ka, srv); } catch { o = {}; }
const d = srv.clientDefaults;
const lines = ['[Interface]',
'PrivateKey = ' + (keyMode === 'generate' ? '‹generated on create›' : '‹stays on the device›'),
'PrivateKey = ' + (link ? '‹made when the link is opened›' : '‹generated on create›'),
'Address = ' + (ip.value || '‹next free›') + '/' + srv.ipv4.split('/')[1] + (srv.ipv6Enabled ? ',‹mapped IPv6›' : '')];
const dnsList = o.dns === undefined || o.dns === null ? d.dns : o.dns;
if (dnsList.length) lines.push('DNS = ' + dnsList.join(', '));
lines.push('', '[Peer]', 'PublicKey = ' + srv.publicKey);
if (psk.checked) lines.push('PresharedKey = ‹generated on create›');
if (psk.checked) lines.push('PresharedKey = ' + (link ? '‹made when the link is opened›' : '‹generated on create›'));
lines.push('Endpoint = ' + (srv.endpoint || '‹set the endpoint in Server›') + ':' + (srv.endpointPort || srv.listenPort));
lines.push('AllowedIPs = ' + ((o.allowedIPs == null ? d.allowedIPs : o.allowedIPs).join(', ')));
const k = o.keepalive == null ? d.keepalive : o.keepalive;
@@ -625,23 +685,21 @@
preview.textContent = lines.join('\n');
}
const keyOpt = (v, title, hint) => h('label', { class: 'opt' },
h('input', { type: 'radio', name: 'keys', value: v, checked: v === keyMode, onChange: () => { keyMode = v; pub.hidden = v !== 'paste'; drawPreview(); } }),
h('span', null, h('strong', null, title), h('br'), h('span', { class: 'hint' }, hint)));
const aside = h('p', { class: 'lead' });
const submit = h('button', { type: 'submit', class: 'btn primary' }, 'Create peer');
const form = h('form', { class: 'card grow', onSubmit: async (e) => {
e.preventDefault();
err.textContent = '';
let body;
try {
body = { name: name.value.trim(), note: note.value.trim(), ipv4: ip.value.trim(), presharedKey: psk.checked, ...overrides(dns, allowed, ka, srv) };
body = { name: name.value.trim(), note: note.value.trim(), ipv4: ip.value.trim(), presharedKey: psk.checked, ...overrides(dns, allowed, ka, srv), ...ho.body() };
} catch (x) { err.textContent = x.message; return; }
if (keyMode === 'paste') body.publicKey = pub.value.trim();
submit.disabled = true;
try {
const res = await api('POST', '/peers', body);
configDialog(res, () => { location.hash = '#/peers/' + res.peer.id; });
const done = () => { location.hash = '#/peers/' + res.peer.id; };
if (res.setup) linkDialog(res.peer.name, res.setup, done);
else configDialog(res, done);
} catch (x) {
err.textContent = x.message;
submit.disabled = false;
@@ -653,12 +711,8 @@
h('div', { class: 'field' }, h('label', { htmlFor: 'ip' }, 'IPv4 address'), ip, h('span', { class: 'hint' }, 'Leave empty for the next free address in ' + srv.ipv4)),
srv.ipv6Enabled ? h('div', { class: 'field' }, h('label', null, 'IPv6 address'), h('input', { class: 'mono', readOnly: true, value: 'Derived from the IPv4 address' })) : null),
h('div', { class: 'grid section' }, allowed.el, dns.el, ka.el),
h('fieldset', { class: 'section' },
h('legend', { class: 'legend' }, 'Keys'),
keyOpt('generate', 'Generate here', 'The private key appears once in the config and QR code. It isn\'t stored.'),
keyOpt('paste', 'Paste the client\'s public key', 'For clients that make their own keys'),
pub,
h('label', { class: 'check' }, psk, 'Add a preshared key')),
h('div', { class: 'section' }, h('label', { class: 'check' }, psk, 'Add a preshared key')),
h('div', { class: 'section' }, ho.el),
err,
h('div', { class: 'formfoot' }, h('a', { class: 'btn', href: '#/peers' }, 'Cancel'), submit));
for (const el of [ip, psk]) el.addEventListener('input', drawPreview);
@@ -667,13 +721,13 @@
fill(wrap,
h('a', { class: 'back', href: '#/peers' }, '← Peers'),
h('div', null, h('h1', null, 'Add peer'), h('p', { class: 'sub' }, 'Creates a key pair, 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('aside', { class: 'card aside', 'aria-labelledby': 'pv' },
h('h2', { id: 'pv' }, 'Client config preview'),
h('p', { class: 'lead' }, 'Keys are created when you click Create peer. Then the config can be downloaded or scanned once.'),
aside,
preview,
h('div', { class: 'qrrow section' }, h('div', { class: 'ph' }, 'QR code', h('br'), 'after creation')))));
h('div', { class: 'qrrow section' }, qrBox))));
name.focus();
}
@@ -714,25 +768,53 @@
location.hash = '#/peers';
} catch (e) { toast(e.message, true); }
};
const reissue = async (publicKey) => {
if (!publicKey && !await confirmDialog({ title: 'Issue a new config?', text: 'New keys are created. The device that uses the current config stops working until it gets the new one.', ok: 'Issue new config' })) return;
try {
const res = await api('POST', '/peers/' + id + '/issue-config', publicKey ? { publicKey } : undefined);
configDialog(res, render);
} catch (e) { toast(e.message, true); }
};
const pasteKey = () => {
const inp = h('textarea', { class: 'mono', rows: 2, 'aria-label': 'Public key' });
const e = h('p', { class: 'err-text' });
const reissue = () => {
const ho = handover({
showHint: 'New keys now; QR code and download on this screen',
linkHint: p.publicKey ? 'The current config keeps working until the link is opened' : 'A one-time link you send to the device\'s owner',
});
const e = h('p', { class: 'err-text', role: 'alert' });
dialog((close) => h('form', { class: 'dlg', onSubmit: async (ev) => {
ev.preventDefault();
e.textContent = '';
try {
const res = await api('POST', '/peers/' + id + '/issue-config', ho.body());
close();
await reissue(inp.value.trim());
if (res.setup) linkDialog(p.name, res.setup, render);
else configDialog(res, render);
} catch (x) { e.textContent = x.message; }
} },
h('h2', null, 'Use a key from the device'),
h('p', null, 'Paste the public key the device generated. The current config stops working.'),
inp, e,
h('div', { class: 'foot' }, h('button', { type: 'button', class: 'btn', onClick: close }, 'Cancel'), h('button', { type: 'submit', class: 'btn primary' }, 'Replace key'))));
h('h2', null, (p.publicKey ? 'Issue a new config for ' : 'Issue a config for ') + p.name + '?'),
p.publicKey ? h('p', null, 'New keys are created. The device that uses the current config stops working once it is replaced.') : null,
p.setup ? h('p', null, 'This replaces the current setup link.') : null,
ho.el, e,
h('div', { class: 'foot' }, h('button', { type: 'button', class: 'btn', onClick: close }, 'Cancel'), h('button', { type: 'submit', class: 'btn primary' }, 'Continue'))));
};
const showLink = async () => {
try { linkDialog(p.name, await api('GET', '/peers/' + id + '/setup')); } catch (e) { toast(e.message, true); }
};
const copyLink = async () => {
try { copy((await api('GET', '/peers/' + id + '/setup')).url); } catch (e) { toast(e.message, true); }
};
const revoke = async () => {
if (!await confirmDialog({ title: 'Revoke the setup link?', text: 'The link stops working immediately.', ok: 'Revoke link', danger: true })) return;
try { await api('DELETE', '/peers/' + id + '/setup'); toast('Setup link revoked'); render(); } catch (e) { toast(e.message, true); }
};
const setupCard = () => {
const su = p.setup;
if (!su) return null;
return h('section', { class: 'card', 'aria-labelledby': 'sl' },
h('div', { class: 'cardhead' }, h('h2', { id: 'sl' }, 'Setup link'), h('span', { class: 'hint' }, su.expired ? 'Expired' : 'Not opened yet')),
h('dl', { class: 'kv' },
h('dt', null, su.expired ? 'Expired' : 'Expires'), h('dd', null, fmtStamp(su.expires)),
h('dt', null, 'PIN'), h('dd', null, su.pinRequired ? 'Required · ' + su.pinFails + ' of 5 wrong tries' : 'Not required'),
p.publicKey ? [h('dt', null, 'Current config'), h('dd', null, 'Keeps working until the link is opened')] : null),
h('div', { class: 'actions section' }, su.expired
? [h('button', { type: 'button', class: 'btn primary', onClick: reissue }, 'New link…'),
h('button', { type: 'button', class: 'btn', onClick: revoke }, 'Remove')]
: [h('button', { type: 'button', class: 'btn primary', onClick: copyLink }, 'Copy link'),
h('button', { type: 'button', class: 'btn', onClick: showLink }, su.pinRequired ? 'Show link & PIN' : 'Show link'),
h('button', { type: 'button', class: 'btn danger', onClick: revoke }, 'Revoke')]));
};
// settings form
@@ -765,6 +847,8 @@
h('button', { type: 'button', class: 'btn', onClick: toggle }, p.enabled ? 'Disable' : 'Enable'),
h('button', { type: 'button', class: 'btn danger', onClick: del }, 'Delete'))),
setupCard(),
h('section', { class: 'card', 'aria-labelledby': 'traffic' },
h('div', { class: 'cardhead' }, h('div', null, h('h2', { id: 'traffic' }, 'Traffic'), totals), pills),
traffic),
@@ -777,16 +861,15 @@
h('dt', null, 'Endpoint'), h('dd', { class: 'mono' }, p.stats.endpoint || '–'),
h('dt', null, 'Location'), h('dd', null, fmtLocation(p.stats.location) || '–'),
h('dt', null, 'Latest handshake'), h('dd', null, ago(p.stats.lastHandshake)),
h('dt', null, 'Public key'), h('dd', { class: 'mono' }, p.publicKey),
h('dt', null, 'Public key'), h('dd', { class: 'mono' }, p.publicKey || '–'),
h('dt', null, 'Preshared key'), h('dd', null, p.hasPresharedKey ? 'Set' : 'None'),
h('dt', null, 'All-time traffic'), h('dd', null, 'Download ' + fmtBytes(p.stats.downTotal) + ' · Upload ' + fmtBytes(p.stats.upTotal)))),
h('section', { class: 'card' },
h('h2', null, 'Client configuration'),
h('p', { class: 'lead' }, 'This server doesn\'t keep the peer\'s private key. To set up a device again, issue a new config. The old one stops working.'),
h('div', { class: 'actions' },
h('button', { type: 'button', class: 'btn', onClick: () => reissue() }, 'Issue new config & QR'),
h('button', { type: 'button', class: 'btn', onClick: pasteKey }, 'Use a key from the device…')),
h('p', { class: 'hint', style: { margin: '12px 0 0' } }, p.configIssued ? 'Last issued ' + fmtDate(p.configIssued) + '.' : 'Created with a key from the device.'))),
h('button', { type: 'button', class: 'btn', onClick: reissue }, p.publicKey ? 'Issue new config…' : 'Issue config…')),
h('p', { class: 'hint', style: { margin: '12px 0 0' } }, p.configIssued ? 'Last issued ' + fmtDate(p.configIssued) + '.' : 'No config issued yet.'))),
h('section', { class: 'card flush', 'aria-labelledby': 'hist' },
h('div', { class: 'cardhead' }, h('h2', { id: 'hist' }, 'Connection history'),
+8 -1
View File
@@ -118,8 +118,15 @@ type Peer struct {
Keepalive *int `json:"keepalive,omitempty"` // nil = server default
Created time.Time `json:"created"`
ConfigIssued *time.Time `json:"configIssued,omitempty"`
// Setup is a pending one-time setup link. A peer created with a link has
// no public key until the link is opened.
Setup *SetupLink `json:"setup,omitempty"`
}
// hasKey reports whether the peer has a public key, i.e. it can be in the
// kernel. A peer waiting for its setup link has none yet.
func (p *Peer) hasKey() bool { return p.PublicKey != "" }
type LogConfig struct {
Level string `json:"level"` // debug | info | warn | error
MaxSizeMB int `json:"maxSizeMB"`
@@ -346,7 +353,7 @@ func (c *Config) validate() error {
return fmt.Errorf("address %s is used twice", ip)
}
ips[ip] = true
if keys[p.PublicKey] {
if p.hasKey() && keys[p.PublicKey] {
return fmt.Errorf("peer %q: public key is used by another peer", p.Name)
}
keys[p.PublicKey] = true
+9
View File
@@ -112,9 +112,18 @@ nonisolated struct Peer: Decodable, Identifiable, Hashable {
let effectiveKeepalive: Int
let created: Date
let configIssued: Date?
let setup: SetupInfo? // pending setup link, nil if none
let stats: PeerStats
}
/// A pending setup link as peer lists show it. The link itself is not in it.
nonisolated struct SetupInfo: Decodable, Hashable {
let expires: Date
let expired: Bool
let pinRequired: Bool
let pinFails: Int
}
nonisolated struct PeerList: Decodable {
let peers: [Peer]
let capacity: Int
+11 -19
View File
@@ -12,8 +12,6 @@ struct PeerDetailView: View {
@State private var issued: IssuedConfig?
@State private var confirmIssue = false
@State private var confirmDelete = false
@State private var askKey = false
@State private var deviceKey = ""
@State private var editing = false
@State private var sessions: [ConnSession] = []
@State private var allSessions = false
@@ -64,20 +62,10 @@ struct PeerDetailView: View {
Text("The device loses access immediately. Its traffic history is deleted too. This cannot be undone.")
}
.confirmationDialog("Issue a new config?", isPresented: $confirmIssue, titleVisibility: .visible) {
Button("Issue new config") { Task { await issue(publicKey: nil) } }
Button("Issue new config") { Task { await issue() } }
} message: {
Text("New keys are created. The device that uses the current config stops working until it gets the new one.")
}
.alert("Use a key from the device", isPresented: $askKey) {
TextField("Public key", text: $deviceKey)
.font(.mono(.footnote))
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
Button("Cancel", role: .cancel) {}
Button("Replace key") { Task { await issue(publicKey: deviceKey) } }
} message: {
Text("Paste the public key the device generated. The current config stops working.")
}
.sheet(item: $issued, onDismiss: { Task { await load() } }) { IssuedConfigView(issued: $0) }
.sheet(isPresented: $editing, onDismiss: { Task { await load() } }) {
if let p = peer, let s = server { PeerEditView(peer: p, server: s) }
@@ -120,7 +108,7 @@ struct PeerDetailView: View {
KV(key: "Endpoint", value: p.stats.endpoint.isEmpty ? "–" : p.stats.endpoint, mono: true)
KV(key: "Location", value: p.stats.location?.label.isEmpty == false ? p.stats.location!.label : "–")
KV(key: "Latest handshake", value: ago(p.stats.lastHandshake))
KV(key: "Public key", value: p.publicKey, mono: true)
KV(key: "Public key", value: p.publicKey.isEmpty ? "–" : p.publicKey, mono: true)
KV(key: "Preshared key", value: p.hasPresharedKey ? "Set" : "None")
KV(key: "All-time traffic", value: "Download \(fmtBytes(p.stats.downTotal)) · Upload \(fmtBytes(p.stats.upTotal))")
}
@@ -195,9 +183,13 @@ struct PeerDetailView: View {
.foregroundStyle(Color.gwText2)
Button { confirmIssue = true } label: { Label("Issue new config & QR", systemImage: "qrcode") }
.buttonStyle(PrimaryButtonStyle())
Button("Use a key from the device…") { deviceKey = ""; askKey = true }
.buttonStyle(SecondaryButtonStyle())
Text(p.configIssued.map { "Last issued \(fmtDate($0))." } ?? "Created with a key from the device.")
if let s = p.setup {
Text(s.expired ? "The setup link expired \(fmtDate(s.expires)). Manage setup links in the web interface."
: "A setup link is waiting to be opened (until \(fmtDate(s.expires))). Issuing a config here replaces it.")
.font(.footnote)
.foregroundStyle(Color.gwWarnInk)
}
Text(p.configIssued.map { "Last issued \(fmtDate($0))." } ?? "No config issued yet.")
.font(.caption)
.foregroundStyle(Color.gwText2)
}
@@ -259,10 +251,10 @@ struct PeerDetailView: View {
}
}
private func issue(publicKey: String?) async {
private func issue() async {
guard let api = session.api else { return }
do {
let body: [String: Any?]? = publicKey.map { ["publicKey": $0.trimmingCharacters(in: .whitespacesAndNewlines)] }
let body: [String: Any?]? = nil
issued = try await api.send("POST", "/peers/\(peerID)/issue-config", body)
} catch {
session.alert = session.message(for: error)
+1 -16
View File
@@ -109,8 +109,6 @@ struct AddPeerView: View {
@State private var note = ""
@State private var ipv4 = ""
@State private var overrides = PeerOverrides()
@State private var pasteKey = false
@State private var publicKey = ""
@State private var psk = true
@State private var error: String?
@State private var busy = false
@@ -164,23 +162,11 @@ struct AddPeerView: View {
OverrideSections(o: $overrides, server: server)
Section {
Picker("Keys", selection: $pasteKey) {
Text("Generate here").tag(false)
Text("Paste the client's public key").tag(true)
}
.pickerStyle(.inline)
.labelsHidden()
if pasteKey {
TextField("Public key", text: $publicKey)
.font(.mono(.footnote))
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
}
Toggle("Add a preshared key", isOn: $psk)
} header: {
Text("Keys")
} footer: {
Text(pasteKey ? "For clients that make their own keys." : "The private key appears once in the config and QR code. It isn't stored.")
Text("The private key appears once in the config and QR code. It isn't stored.")
}
if let error {
@@ -201,7 +187,6 @@ struct AddPeerView: View {
body["note"] = note.trimmingCharacters(in: .whitespaces)
body["ipv4"] = ipv4.trimmingCharacters(in: .whitespaces)
body["presharedKey"] = psk
if pasteKey { body["publicKey"] = publicKey.trimmingCharacters(in: .whitespacesAndNewlines) }
let r: IssuedConfig = try await api.send("POST", "/peers", body)
session.reportApply(r.applyError)
issued = r
+7 -3
View File
@@ -104,10 +104,11 @@ struct Notice: View {
}
enum PeerState {
case online(Date), offline(Date), never, disabled
case online(Date), offline(Date), never, disabled, waiting, noConfig
init(_ p: Peer) {
if !p.enabled { self = .disabled }
else if p.publicKey.isEmpty { self = p.setup.map { !$0.expired } == true ? .waiting : .noConfig }
else if let h = p.stats.lastHandshake { self = p.stats.online ? .online(h) : .offline(h) }
else { self = .never }
}
@@ -118,13 +119,15 @@ enum PeerState {
case .offline(let d): "Offline · " + ago(d)
case .never: "Never connected"
case .disabled: "Disabled"
case .waiting: "Waiting for setup"
case .noConfig: "No config yet"
}
}
var key: String {
switch self {
case .online: "online"
case .offline, .never: "offline"
case .offline, .never, .waiting, .noConfig: "offline"
case .disabled: "disabled"
}
}
@@ -136,7 +139,8 @@ struct StatusDot: View {
switch state {
case .online: Circle().fill(Color.gwGood).frame(width: 8, height: 8)
case .offline: Circle().fill(Color.gray).frame(width: 8, height: 8)
case .never: Circle().stroke(Color.gray, lineWidth: 1.5).frame(width: 8, height: 8)
case .never, .noConfig: Circle().stroke(Color.gray, lineWidth: 1.5).frame(width: 8, height: 8)
case .waiting: Circle().fill(Color.gwUp).frame(width: 8, height: 8)
case .disabled: Circle().fill(Color.gwBad).frame(width: 8, height: 8)
}
}
+1 -1
View File
@@ -126,7 +126,7 @@ func (k *linuxKernel) syncDevice(c *Config) error {
desired := map[wgtypes.Key]want{}
for i := range c.Peers {
p := &c.Peers[i]
if !p.Enabled {
if !p.Enabled || !p.hasKey() {
continue
}
pub, err := wgtypes.ParseKey(p.PublicKey)
+1 -1
View File
@@ -30,7 +30,7 @@ func (k *simKernel) Apply(c *Config) error {
defer k.mu.Unlock()
keep := map[string]bool{}
for i, p := range c.Peers {
if !p.Enabled {
if !p.Enabled || !p.hasKey() {
continue
}
keep[p.PublicKey] = true
+117
View File
@@ -314,6 +314,7 @@ func TestAPI(t *testing.T) {
bearer("GET", "/peers", 200)
bearer("DELETE", "/peers/"+id, 403)
bearer("GET", "/tokens", 403)
bearer("GET", "/peers/"+id+"/setup", 403) // the link would set up a device
call("DELETE", "/peers/"+id, nil, 200)
if len(store.Get().Peers) != 0 {
@@ -502,3 +503,119 @@ func TestGeoDatabase(t *testing.T) {
t.Logf("%s → %+v", ep, info)
}
}
// TestSetupLink creates a peer with a link, checks PIN handling and that the
// link works exactly once without storing the private key.
func TestSetupLink(t *testing.T) {
dir := t.TempDir()
store, err := openStore(filepath.Join(dir, "config.json"))
if err != nil {
t.Fatal(err)
}
hash, _ := hashPassword("a long test password")
_ = store.Update(func(c *Config) error { c.Admin.PasswordHash = hash; c.Server.Endpoint = "vpn.example.net"; return nil })
k := &fakeKernel{}
st, _ := openStats(filepath.Join(dir, "stats.json"), store, k)
app := &App{store: store, kernel: k, recon: newReconciler(k, store), stats: st, auth: newAuth(store),
tls: &webTLS{}, logPath: filepath.Join(dir, "log.jsonl"), started: time.Now(), shutdown: func() {}}
srv := httptest.NewServer(app.routes())
defer srv.Close()
jar, _ := cookiejar.New(nil)
admin := &http.Client{Jar: jar}
call := func(cl *http.Client, 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+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(admin, "POST", "/api/v1/auth/login", map[string]string{"username": "admin", "password": "a long test password"}, 200)
// Two peers waiting for setup have no key yet; that must not clash.
created := call(admin, "POST", "/api/v1/peers", map[string]any{"name": "phone-anna", "delivery": "link", "linkHours": 24}, 201)
call(admin, "POST", "/api/v1/peers", map[string]any{"name": "laptop-ben", "delivery": "link", "linkPIN": false}, 201)
call(admin, "POST", "/api/v1/peers", map[string]any{"name": "x", "delivery": "link", "linkHours": 5}, 400)
peer := created["peer"].(map[string]any)
id := peer["id"].(string)
if peer["publicKey"] != "" || created["config"] != nil {
t.Fatalf("link peer got keys or a config: %v", created)
}
setup := created["setup"].(map[string]any)
path, pin := setup["path"].(string), setup["pin"].(string)
if len(pin) != setupPINLen || !strings.HasPrefix(setup["url"].(string), srv.URL+"/setup/") || setup["qr"] == "" {
t.Fatalf("bad setup answer: %v", setup)
}
if s := call(admin, "GET", "/api/v1/peers/"+id+"/setup", nil, 200); s["pin"] != pin {
t.Fatal("admin cannot read the link again")
}
public := &http.Client{}
api := strings.Replace(path, "/setup/", "/api/v1/setup/", 1)
if info := call(public, "GET", api, nil, 200); info["name"] != "phone-anna" || info["pinRequired"] != true {
t.Fatalf("setup info: %v", info)
}
call(public, "GET", "/api/v1/setup/nonsense", nil, 404)
call(public, "GET", path, nil, 200) // the page itself
wrong := "0000"
if wrong == pin {
wrong = "1111"
}
if r := call(public, "POST", api, map[string]string{"pin": wrong}, 403); r["triesLeft"].(float64) != setupMaxFails-1 {
t.Fatalf("wrong PIN: %v", r)
}
got := call(public, "POST", api, map[string]string{"pin": pin}, 200)
conf := got["config"].(string)
if !strings.Contains(conf, "PrivateKey = ") || got["qr"] == "" {
t.Fatal("redeemed config lacks the private key or QR")
}
priv := strings.TrimSpace(strings.SplitN(strings.SplitN(conf, "PrivateKey = ", 2)[1], "\n", 2)[0])
raw, _ := json.Marshal(store.Get())
if bytes.Contains(raw, []byte(priv)) {
t.Fatal("client private key was stored")
}
call(public, "POST", api, map[string]string{"pin": pin}, 404) // works once
call(public, "GET", api, nil, 404)
p := call(admin, "GET", "/api/v1/peers/"+id, nil, 200)
if p["publicKey"] == "" || p["setup"] != nil || p["configIssued"] == nil {
t.Fatalf("peer not set up: %v", p)
}
// Re-issue by link: the old key stays until the link is used.
oldKey := p["publicKey"]
re := call(admin, "POST", "/api/v1/peers/"+id+"/issue-config", map[string]any{"delivery": "link"}, 200)
if re["peer"].(map[string]any)["publicKey"] != oldKey {
t.Fatal("issuing a link replaced the key early")
}
api = strings.Replace(re["setup"].(map[string]any)["path"].(string), "/setup/", "/api/v1/setup/", 1)
for i := 0; i < setupMaxFails; i++ {
want := 403
if i == setupMaxFails-1 {
want = 404 // revoked by the last wrong PIN
}
call(public, "POST", api, map[string]string{"pin": "x"}, want)
}
if p := call(admin, "GET", "/api/v1/peers/"+id, nil, 200); p["setup"] != nil || p["publicKey"] != oldKey {
t.Fatalf("link not revoked after wrong PINs: %v", p)
}
// Revoking by hand.
call(admin, "POST", "/api/v1/peers/"+id+"/issue-config", map[string]any{"delivery": "link"}, 200)
call(admin, "DELETE", "/api/v1/peers/"+id+"/setup", nil, 200)
call(admin, "GET", "/api/v1/peers/"+id+"/setup", nil, 404)
}
+18
View File
@@ -0,0 +1,18 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="referrer" content="no-referrer">
<meta name="robots" content="noindex">
<title>Set up your VPN · GHOSTWIRE</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<link rel="stylesheet" href="/app.css">
<script src="/setup.js" defer></script>
</head>
<body>
<div id="app" class="setuppage"><p class="loading-page">Loading…</p></div>
<noscript><p class="loading-page">This page needs JavaScript.</p></noscript>
</body>
</html>
+132
View File
@@ -0,0 +1,132 @@
'use strict';
// The page a setup link opens. It works without signing in: the token in
// the URL is the credential. The config exists only in this page's memory;
// leaving the page loses it, as the link works once.
(() => {
const app = document.getElementById('app');
const token = location.pathname.split('/').pop();
function h(tag, props, ...kids) {
const el = document.createElement(tag);
for (const [k, v] of Object.entries(props || {})) {
if (v == null || v === false) continue;
if (k === 'class') el.className = v;
else if (k.startsWith('on')) el.addEventListener(k.slice(2).toLowerCase(), v);
else if (['value', 'hidden', 'htmlFor', 'disabled', 'src', 'alt', 'href', 'type', 'id', 'autocomplete', 'inputMode', 'maxLength', 'required'].includes(k)) el[k] = v;
else el.setAttribute(k, v === true ? '' : v);
}
for (const c of kids.flat(Infinity)) if (c != null && c !== false) el.append(c instanceof Node ? c : String(c));
return el;
}
// Same drawing as favicon.svg.
function logo(size, plain) {
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);
s.innerHTML = (plain ? '' : '<rect width="64" height="64" rx="14" fill="#1b1b1d"/><rect x="0.5" y="0.5" width="63" height="63" rx="13.5" fill="none" stroke="#fff" stroke-opacity="0.2"/>') +
'<circle cx="32" cy="32" r="22" fill="none" stroke="#fff" stroke-width="3.5"/><g transform="translate(32 33) scale(0.66) translate(-32 -33)">' +
'<path d="M18 50V30a14 14 0 0 1 28 0v20l-4.7-4-4.6 4-4.7-4-4.7 4-4.6-4z" fill="#fff"/>' +
(plain ? '' : '<circle cx="27" cy="30" r="3.2" fill="#c8372d"/><circle cx="37" cy="30" r="3.2" fill="#c8372d"/>') + '</g>';
return s;
}
const fmtExpiry = (iso) => new Date(iso).toLocaleString(undefined, { weekday: 'short', day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' });
async function call(method, body) {
const opt = { method, headers: {} };
if (body) {
opt.headers['Content-Type'] = 'application/json';
opt.body = JSON.stringify(body);
}
const r = await fetch('/api/v1/setup/' + encodeURIComponent(token), opt);
let data = {};
try { data = await r.json(); } catch { /* empty */ }
return { status: r.status, data };
}
function show(...kids) { app.replaceChildren(h('div', { class: 'setupbox' }, kids)); }
function invalid() {
app.replaceChildren(h('div', { class: 'setupbox center' },
h('span', { class: 'ghost' }, logo(72, true)),
h('h1', null, 'This link isn\'t valid'),
h('p', null, 'It was already used, it expired, or it was revoked. Ask whoever sent it for a new one.'),
h('p', { class: 'loginfoot' }, 'GHOSTWIRE')));
}
function start(info) {
const err = h('p', { class: 'err-text', role: 'alert' });
const pin = info.pinRequired
? h('input', { id: 'pin', class: 'pin', inputMode: 'numeric', autocomplete: 'one-time-code', maxLength: 8, required: true })
: null;
const btn = h('button', { type: 'submit', class: 'btn primary' }, info.pinRequired ? 'Continue' : 'Get my VPN profile');
const form = h('form', { class: 'loginform', onSubmit: async (e) => {
e.preventDefault();
err.textContent = '';
btn.disabled = true;
try {
const { status, data } = await call('POST', { pin: pin ? pin.value.trim() : '' });
if (status === 200) return ready(data);
if (status === 404) return invalid();
err.textContent = data.error || 'Something went wrong. Try again.';
if (pin) pin.select();
} catch {
err.textContent = 'No connection to the server. Try again.';
}
btn.disabled = false;
} },
pin ? h('div', { class: 'field' }, h('label', { htmlFor: 'pin' }, 'PIN'), pin) : null,
err, btn);
app.replaceChildren(h('div', { class: 'setupbox' },
h('div', { class: 'brand stack' }, logo(56), h('div', { class: 'wm' }, 'GHOSTWIRE')),
h('div', { class: 'center' },
h('h1', null, 'Set up your VPN'),
h('p', null, 'This link adds the VPN profile ', h('strong', { class: 'mono' }, info.name), ' to your device.',
info.pinRequired ? ' Enter the PIN you were given.' : '')),
form,
h('p', { class: 'note' }, 'The link works once and expires ' + fmtExpiry(info.expires) + '.')));
(pin || btn).focus();
}
function ready(res) {
const file = res.name + '.conf';
const download = () => {
const url = URL.createObjectURL(new Blob([res.config], { type: 'application/octet-stream' }));
const a = h('a', { href: url, download: file });
document.body.append(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
};
const qr = h('img', { class: 'qr', src: res.qr, alt: 'QR code of the VPN profile ' + res.name, hidden: true });
const qrBtn = h('button', { type: 'button', class: 'btn', onClick: () => { qr.hidden = !qr.hidden; qrBtn.textContent = qr.hidden ? 'Show QR code' : 'Hide QR code'; } }, 'Show QR code');
const step = (n, title, ...body) => h('li', null, h('span', { class: 'num' }, String(n)), h('div', null, h('strong', null, title), body));
// Leaving the page loses the only copy of the private key.
window.addEventListener('beforeunload', (e) => e.preventDefault());
show(
h('div', { class: 'brand' }, logo(32), h('div', { class: 'wm' }, 'GHOSTWIRE')),
h('h1', null, 'Your VPN profile is ready'),
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' },
step(1, 'Install WireGuard',
h('p', null, h('a', { href: 'https://apps.apple.com/app/wireguard/id1441195209', rel: 'noopener' }, 'App Store'), ' · ',
h('a', { href: 'https://play.google.com/store/apps/details?id=com.wireguard.android', rel: 'noopener' }, 'Google Play'), ' · ',
h('a', { href: 'https://www.wireguard.com/install/', rel: 'noopener' }, 'Other systems'))),
step(2, 'Add the profile',
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”.')),
step(3, 'Setting up another device?',
qrBtn, qr,
h('p', null, 'Opened this on a computer? Scan the QR code with WireGuard on your phone.'))));
}
(async () => {
try {
const { status, data } = await call('GET');
if (status === 200) start(data);
else invalid();
} catch {
show(h('h1', null, 'No connection'), h('p', null, 'The server can\'t be reached. Reload the page to try again.'));
}
})();
})();
+299
View File
@@ -0,0 +1,299 @@
package main
import (
"crypto/rand"
"crypto/subtle"
"fmt"
"log/slog"
"math/big"
"net"
"net/http"
"slices"
"time"
)
// A setup link hands a client config to someone who is not next to the
// admin. The keys are made only when the link is opened, so the private key
// is never stored: the link works once, then it is deleted.
//
// The token and PIN are kept in config.json (0600) so the admin can copy the
// link again. Whoever can read that file already holds the server key.
type SetupLink struct {
Token string `json:"token"`
PIN string `json:"pin,omitempty"`
Created time.Time `json:"created"`
Expires time.Time `json:"expires"`
Fails int `json:"fails,omitempty"` // wrong PINs so far
}
const (
setupMaxFails = 5 // wrong PINs before the link is revoked
setupPINLen = 4
)
// setupHours are the lifetimes the UI offers.
var setupHours = []int{1, 24, 168}
func (s *SetupLink) expired(now time.Time) bool { return !now.Before(s.Expires) }
func randomPIN() string {
max := big.NewInt(1)
for range setupPINLen {
max.Mul(max, big.NewInt(10))
}
n, err := rand.Int(rand.Reader, max)
if err != nil {
panic(err)
}
return fmt.Sprintf("%0*d", setupPINLen, n)
}
// setupRequest is the part of a create or issue request that asks for a
// link instead of a config shown right away.
type setupRequest struct {
Delivery string `json:"delivery"` // "" or "show": config now; "link": setup link
LinkHours int `json:"linkHours"` // 1, 24 or 168; default 24
LinkPIN *bool `json:"linkPIN"` // default true
}
func (in setupRequest) wantsLink() bool { return in.Delivery == "link" }
func (in setupRequest) newLink() (*SetupLink, error) {
switch in.Delivery {
case "", "show", "link":
default:
return nil, badRequest("delivery must be show or link")
}
hours := in.LinkHours
if hours == 0 {
hours = 24
}
if !slices.Contains(setupHours, hours) {
return nil, badRequest("linkHours must be 1, 24 or 168")
}
now := time.Now().UTC()
l := &SetupLink{Token: randomString(24), Created: now, Expires: now.Add(time.Duration(hours) * time.Hour)}
if in.LinkPIN == nil || *in.LinkPIN {
l.PIN = randomPIN()
}
return l, nil
}
// setupView is what peer lists show about a pending link: no secrets, so
// read-only tokens may see it.
type setupView struct {
Created time.Time `json:"created"`
Expires time.Time `json:"expires"`
Expired bool `json:"expired"`
PINRequired bool `json:"pinRequired"`
PINFails int `json:"pinFails"`
}
func viewSetup(s *SetupLink) *setupView {
if s == nil {
return nil
}
return &setupView{Created: s.Created, Expires: s.Expires, Expired: s.expired(time.Now()), PINRequired: s.PIN != "", PINFails: s.Fails}
}
// setupSecret is the link itself, for the admin who sends it.
type setupSecret struct {
URL string `json:"url"`
Path string `json:"path"`
PIN string `json:"pin,omitempty"`
Expires time.Time `json:"expires"`
QR string `json:"qr"`
}
// setupBase is the scheme and host the admin reached this server with.
// Behind a local reverse proxy, X-Forwarded-Proto tells whether that was
// HTTPS.
func setupBase(r *http.Request) string {
scheme := "http"
if r.TLS != nil || (fromLoopback(r) && r.Header.Get("X-Forwarded-Proto") == "https") {
scheme = "https"
}
return scheme + "://" + r.Host
}
func fromLoopback(r *http.Request) bool {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
host = r.RemoteAddr
}
ip := net.ParseIP(host)
return ip != nil && ip.IsLoopback()
}
func secretFor(r *http.Request, s *SetupLink) (setupSecret, error) {
path := "/setup/" + s.Token
out := setupSecret{URL: setupBase(r) + path, Path: path, PIN: s.PIN, Expires: s.Expires}
qr, err := qrDataURL(out.URL)
if err != nil {
return out, err
}
out.QR = qr
return out, nil
}
// peerByToken finds the peer whose link matches token, in constant time per
// comparison.
func (c *Config) peerByToken(token string) *Peer {
if token == "" {
return nil
}
var found *Peer
for i := range c.Peers {
if s := c.Peers[i].Setup; s != nil && subtle.ConstantTimeCompare([]byte(s.Token), []byte(token)) == 1 {
found = &c.Peers[i]
}
}
return found
}
// --- admin endpoints ---
func (a *App) getSetup(w http.ResponseWriter, r *http.Request) {
// The link sets up a device, so read-only tokens must not see it.
if who(r).Scope == "ro" {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "this token is read-only"})
return
}
cfg := a.store.Get()
_, p := cfg.peerByID(r.PathValue("id"))
if p == nil || p.Setup == nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "this peer has no setup link"})
return
}
out, err := secretFor(r, p.Setup)
if err != nil {
writeErr(w, err)
return
}
writeJSON(w, http.StatusOK, out)
}
func (a *App) revokeSetup(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
var name string
if err := a.store.Update(func(c *Config) error {
_, p := c.peerByID(id)
if p == nil {
return badRequest("no such peer")
}
p.Setup, name = nil, p.Name
return nil
}); err != nil {
writeErr(w, err)
return
}
a.audit(r, "setup link revoked", "peer", name)
cfg := a.store.Get()
_, p := cfg.peerByID(id)
writeJSON(w, http.StatusOK, map[string]any{"peer": a.peerView(cfg, p)})
}
// --- public endpoints, reached with the link alone ---
// errSetupInvalid is the one answer for unknown, used, expired and revoked
// links, so a visitor cannot tell whether a link ever existed.
const errSetupInvalid = "this link isn't valid"
func setupInvalid(w http.ResponseWriter) {
writeJSON(w, http.StatusNotFound, map[string]string{"error": errSetupInvalid})
}
func (a *App) setupInfo(w http.ResponseWriter, r *http.Request) {
cfg := a.store.Get()
p := cfg.peerByToken(r.PathValue("token"))
if p == nil || p.Setup.expired(time.Now()) {
setupInvalid(w)
return
}
writeJSON(w, http.StatusOK, map[string]any{"name": p.Name, "pinRequired": p.Setup.PIN != "", "expires": p.Setup.Expires})
}
// setupRedeem makes the keys, stores the public key and returns the config.
// The link is deleted in the same update, so it cannot be used twice.
func (a *App) setupRedeem(w http.ResponseWriter, r *http.Request) {
var in struct {
PIN string `json:"pin"`
}
if err := readJSON(r, &in); err != nil {
writeErr(w, err)
return
}
k, err := newPrivateKey()
if err != nil {
writeErr(w, err)
return
}
psk, err := newPresharedKey()
if err != nil {
writeErr(w, err)
return
}
ip := remoteIP(r)
var (
id, name string
hadKey bool
invalid bool
wrongPIN bool
triesLeft int
revokedNow bool
)
err = a.store.Update(func(c *Config) error {
p := c.peerByToken(r.PathValue("token"))
if p == nil || p.Setup.expired(time.Now()) {
invalid = true
return nil
}
name = p.Name
if p.Setup.PIN != "" && subtle.ConstantTimeCompare([]byte(p.Setup.PIN), []byte(in.PIN)) != 1 {
wrongPIN = true
p.Setup.Fails++
triesLeft = setupMaxFails - p.Setup.Fails
if triesLeft <= 0 {
p.Setup, revokedNow = nil, true
}
return nil
}
now := time.Now().UTC()
id, hadKey = p.ID, p.hasKey()
p.PublicKey, p.ConfigIssued, p.Setup = k.PublicKey().String(), &now, nil
if p.PresharedKey != "" {
p.PresharedKey = psk.String()
}
return nil
})
switch {
case err != nil:
writeErr(w, err)
return
case invalid:
slog.Warn("setup link not valid", "remote", ip)
setupInvalid(w)
return
case revokedNow:
slog.Warn("setup link revoked after wrong PINs", "audit", true, "actor", "setup link", "peer", name, "remote", ip)
setupInvalid(w)
return
case wrongPIN:
slog.Warn("setup link: wrong PIN", "peer", name, "remote", ip)
writeJSON(w, http.StatusForbidden, map[string]any{"error": fmt.Sprintf("Wrong PIN. %d tries left.", triesLeft), "triesLeft": triesLeft})
return
}
if hadKey {
a.stats.Forget(id)
}
slog.Info("peer config issued", "audit", true, "actor", "setup link", "peer", name, "remote", ip)
out, err := a.issue(id, k.String())
if err != nil {
writeErr(w, err)
return
}
if e := a.apply(); e != "" {
slog.Error("apply after setup link failed", "err", e)
}
writeJSON(w, http.StatusOK, map[string]any{"name": out.Peer.Name, "config": out.Config, "qr": out.QR})
}
+2
View File
@@ -198,7 +198,9 @@ func (s *Stats) sample() {
idByKey := map[string]string{}
exists := map[string]bool{}
for _, p := range cfg.Peers {
if p.hasKey() {
idByKey[p.PublicKey] = p.ID
}
exists[p.ID] = true
}
now := time.Now()
+15 -2
View File
@@ -8,14 +8,14 @@ import (
// The web UI and its icons are built into the binary. The UI talks only to
// /api/v1, the same API the iOS app uses.
//
//go:embed index.html app.js app.css favicon.svg apple-touch-icon.png
//go:embed index.html setup.html app.js setup.js app.css favicon.svg apple-touch-icon.png
var webFiles embed.FS
func webHandler() http.Handler {
files := http.FileServerFS(webFiles)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/", "/app.js", "/app.css", "/favicon.svg", "/apple-touch-icon.png":
case "/", "/app.js", "/setup.js", "/app.css", "/favicon.svg", "/apple-touch-icon.png":
w.Header().Set("Cache-Control", "no-cache")
files.ServeHTTP(w, r)
case "/favicon.ico":
@@ -26,3 +26,16 @@ func webHandler() http.Handler {
}
})
}
// setupPage serves the page a setup link opens. The token stays in the URL;
// setup.js reads it from there and talks to /api/v1/setup.
func setupPage(w http.ResponseWriter, r *http.Request) {
b, err := webFiles.ReadFile("setup.html")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(b)
}