diff --git a/README.md b/README.md index 0ce453d..ea6f30c 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/api.go b/api.go index e5a3fad..eac19b4 100644 --- a/api.go +++ b/api.go @@ -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 != "" { - qr, err := qrDataURL(out.Config) - if err != nil { - return out, err - } - out.QR = qr + 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() } diff --git a/app.css b/app.css index 3df3a2b..83ae921 100644 --- a/app.css +++ b/app.css @@ -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); } diff --git a/app.js b/app.js index 045f078..8bd8df0 100644 --- a/app.js +++ b/app.js @@ -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(); - close(); - await reissue(inp.value.trim()); + e.textContent = ''; + try { + const res = await api('POST', '/peers/' + id + '/issue-config', ho.body()); + close(); + 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'), diff --git a/config.go b/config.go index 1d8a44a..99ef24e 100644 --- a/config.go +++ b/config.go @@ -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 diff --git a/ios/GHOSTWIRE/Models.swift b/ios/GHOSTWIRE/Models.swift index ea00692..272cd8b 100644 --- a/ios/GHOSTWIRE/Models.swift +++ b/ios/GHOSTWIRE/Models.swift @@ -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 diff --git a/ios/GHOSTWIRE/PeerDetailView.swift b/ios/GHOSTWIRE/PeerDetailView.swift index 0f527f2..01bc1b5 100644 --- a/ios/GHOSTWIRE/PeerDetailView.swift +++ b/ios/GHOSTWIRE/PeerDetailView.swift @@ -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) diff --git a/ios/GHOSTWIRE/PeerForms.swift b/ios/GHOSTWIRE/PeerForms.swift index 1e88bf2..0f88860 100644 --- a/ios/GHOSTWIRE/PeerForms.swift +++ b/ios/GHOSTWIRE/PeerForms.swift @@ -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 diff --git a/ios/GHOSTWIRE/Theme.swift b/ios/GHOSTWIRE/Theme.swift index d82f88c..18b03f7 100644 --- a/ios/GHOSTWIRE/Theme.swift +++ b/ios/GHOSTWIRE/Theme.swift @@ -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) } } diff --git a/kernel_linux.go b/kernel_linux.go index c3f3acf..dfc669b 100644 --- a/kernel_linux.go +++ b/kernel_linux.go @@ -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) diff --git a/kernel_other.go b/kernel_other.go index d57c86f..76b4c55 100644 --- a/kernel_other.go +++ b/kernel_other.go @@ -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 diff --git a/main_test.go b/main_test.go index 3f45d7d..b8cb9c2 100644 --- a/main_test.go +++ b/main_test.go @@ -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) +} diff --git a/setup.html b/setup.html new file mode 100644 index 0000000..b0abb29 --- /dev/null +++ b/setup.html @@ -0,0 +1,18 @@ + + +
+ + + + +Loading…