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 - **Client private keys are never stored.** A config is shown once, as a
download or QR code. "Issue new config" makes new keys. 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 - **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 `CAP_NET_ADMIN` and `CAP_NET_BIND_SERVICE`, and can write only to
`/opt/ghostwire`. `/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} GET /peers/{id} PATCH /peers/{id} DELETE /peers/{id}
POST /peers/{id}/enable | /disable | /issue-config POST /peers/{id}/enable | /disable | /issue-config
GET /peers/{id}/stats?range=… GET /peers/{id}/sessions?limit=100 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 /settings PATCH /settings POST /restart
GET /logs?level=&limit=&audit=1 GET /logs/download GET /logs?level=&limit=&audit=1 GET /logs/download
admin: GET|POST /tokens · DELETE /tokens/{id} · GET /backup · POST /restore 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 Traffic is reported from the peer's point of view: `down` is what the peer
downloaded, `up` is what it uploaded. downloaded, `up` is what it uploaded.
+88 -29
View File
@@ -12,8 +12,6 @@ import (
"slices" "slices"
"strings" "strings"
"time" "time"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
) )
// App wires the parts together and serves the HTTP API. // 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("POST /api/v1/peers/{id}/issue-config", a.issueConfig)
g("GET /api/v1/peers/{id}/stats", a.peerStats) g("GET /api/v1/peers/{id}/stats", a.peerStats)
g("GET /api/v1/peers/{id}/sessions", a.peerSessions) 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. // Full-access tokens (the iOS app) may change app settings and read logs.
// Password, tokens and backups stay with the admin account. // 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) { mux.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "no such endpoint"}) writeJSON(w, http.StatusNotFound, map[string]string{"error": "no such endpoint"})
}) })
mux.HandleFunc("GET /setup/{token}", setupPage)
mux.Handle("/", webHandler()) mux.Handle("/", webHandler())
csrf := http.NewCrossOriginProtection() csrf := http.NewCrossOriginProtection()
@@ -505,6 +511,7 @@ type peerView struct {
EffKeepalive int `json:"effectiveKeepalive"` EffKeepalive int `json:"effectiveKeepalive"`
Created time.Time `json:"created"` Created time.Time `json:"created"`
ConfigIssued *time.Time `json:"configIssued"` ConfigIssued *time.Time `json:"configIssued"`
Setup *setupView `json:"setup"` // null = no pending setup link
Stats PeerSummary `json:"stats"` 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, 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, 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), 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 { if c.Server.IPv6Enabled {
v.IPv6 = mapIPv6(netip.MustParsePrefix(c.Server.IPv6), netip.MustParseAddr(p.IPv4)).String() 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 { type issuedConfig struct {
Peer peerView `json:"peer"` Peer peerView `json:"peer"`
Config string `json:"config"` Config string `json:"config"`
QR string `json:"qr,omitempty"` QR string `json:"qr"`
HasPrivKey bool `json:"includesPrivateKey"` HasPrivKey bool `json:"includesPrivateKey"` // always true; kept for older clients
ApplyError string `json:"applyError"` ApplyError string `json:"applyError"`
} }
// newKeys returns a fresh key pair, or only the given public key when the // linkCreated answers a create or issue request that asked for a setup link.
// client made its own keys. type linkCreated struct {
func newKeys(clientPublic string) (priv, pub string, err error) { Peer peerView `json:"peer"`
if clientPublic != "" { Setup setupSecret `json:"setup"`
k, err := wgtypes.ParseKey(strings.TrimSpace(clientPublic)) ApplyError string `json:"applyError"`
if err != nil {
return "", "", badRequest("public key is not a valid WireGuard key")
}
return "", k.String(), nil
} }
func newKeys() (priv, pub string, err error) {
k, err := newPrivateKey() k, err := newPrivateKey()
if err != nil { if err != nil {
return "", "", err return "", "", err
@@ -570,17 +575,24 @@ func newKeys(clientPublic string) (priv, pub string, err error) {
func (a *App) issue(id, priv string) (issuedConfig, error) { func (a *App) issue(id, priv string) (issuedConfig, error) {
cfg := a.store.Get() cfg := a.store.Get()
_, p := cfg.peerByID(id) _, p := cfg.peerByID(id)
out := issuedConfig{Peer: a.peerView(cfg, p), Config: clientConfig(cfg, p, priv), HasPrivKey: priv != ""} out := issuedConfig{Peer: a.peerView(cfg, p), Config: clientConfig(cfg, p, priv), HasPrivKey: true}
if priv != "" {
qr, err := qrDataURL(out.Config) qr, err := qrDataURL(out.Config)
if err != nil { if err != nil {
return out, err return out, err
} }
out.QR = qr out.QR = qr
}
return out, nil 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) { func (a *App) createPeer(w http.ResponseWriter, r *http.Request) {
var in struct { var in struct {
Name string `json:"name"` Name string `json:"name"`
@@ -589,23 +601,36 @@ func (a *App) createPeer(w http.ResponseWriter, r *http.Request) {
DNS []string `json:"dns"` DNS []string `json:"dns"`
AllowedIPs []string `json:"allowedIPs"` AllowedIPs []string `json:"allowedIPs"`
Keepalive *int `json:"keepalive"` Keepalive *int `json:"keepalive"`
PublicKey string `json:"publicKey"`
PresharedKey *bool `json:"presharedKey"` PresharedKey *bool `json:"presharedKey"`
setupRequest
} }
if err := readJSON(r, &in); err != nil { if err := readJSON(r, &in); err != nil {
writeErr(w, err) writeErr(w, err)
return return
} }
in.Name = strings.TrimSpace(in.Name) in.Name = strings.TrimSpace(in.Name)
priv, pub, err := newKeys(in.PublicKey) link, err := in.newLink()
if err != nil { if err != nil {
writeErr(w, err) writeErr(w, err)
return return
} }
p := Peer{ 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(), 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 { if in.PresharedKey == nil || *in.PresharedKey {
psk, err := newPresharedKey() psk, err := newPresharedKey()
if err != nil { if err != nil {
@@ -614,8 +639,6 @@ func (a *App) createPeer(w http.ResponseWriter, r *http.Request) {
} }
p.PresharedKey = psk.String() p.PresharedKey = psk.String()
} }
now := time.Now().UTC()
p.ConfigIssued = &now
err = a.store.Update(func(c *Config) error { err = a.store.Update(func(c *Config) error {
if err := validatePeerName(p.Name); err != nil { if err := validatePeerName(p.Name); err != nil {
return &userError{err.Error()} return &userError{err.Error()}
@@ -636,7 +659,16 @@ func (a *App) createPeer(w http.ResponseWriter, r *http.Request) {
writeErr(w, err) writeErr(w, err)
return 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) out, err := a.issue(p.ID, priv)
if err != nil { if err != nil {
writeErr(w, err) 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()}) 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) { func (a *App) issueConfig(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id") id := r.PathValue("id")
var in struct { var in setupRequest
PublicKey string `json:"publicKey"`
}
if r.ContentLength > 0 { if r.ContentLength > 0 {
if err := readJSON(r, &in); err != nil { if err := readJSON(r, &in); err != nil {
writeErr(w, err) writeErr(w, err)
return 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 { if err != nil {
writeErr(w, err) writeErr(w, err)
return return
@@ -780,7 +838,8 @@ func (a *App) issueConfig(w http.ResponseWriter, r *http.Request) {
return badRequest("no such peer") return badRequest("no such peer")
} }
now := time.Now().UTC() 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 != "" { if p.PresharedKey != "" {
p.PresharedKey = psk.String() 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.ok { background: var(--good); }
.dot.off { background: transparent; border: 1.5px solid #9a9b97; } .dot.off { background: transparent; border: 1.5px solid #9a9b97; }
.dot.bad { background: var(--bad); } .dot.bad { background: var(--bad); }
.dot.warn { background: var(--up); }
.dot.big { width: 10px; height: 10px; } .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; } .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; } .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; } .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 { display: flex; flex-wrap: wrap; gap: 16px; align-items: center; }
.qrrow .col { display: flex; flex-direction: column; gap: 8px; } .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 */ /* toast */
.toasts { position: fixed; right: 16px; bottom: 16px; display: flex; flex-direction: column; gap: 8px; z-index: 50; max-width: calc(100vw - 32px); } .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; } .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; } .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; } .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); } .loading-page { padding: 40px; color: var(--ink-3); }
+123 -40
View File
@@ -109,6 +109,10 @@
function peerState(p) { function peerState(p) {
if (!p.enabled) return { key: 'disabled', label: 'Disabled', dot: 'dot bad' }; 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.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' }; if (p.stats.lastHandshake) return { key: 'offline', label: 'Offline · ' + ago(p.stats.lastHandshake), dot: 'dot' };
return { key: 'never', label: 'Never connected', dot: 'dot off' }; return { key: 'never', label: 'Never connected', dot: 'dot off' };
@@ -232,6 +236,52 @@
d.addEventListener('close', () => { applied(res); if (onClose) onClose(); }); 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 ---------- // ---------- chart ----------
function niceTop(max) { function niceTop(max) {
@@ -512,7 +562,7 @@
const rows = data.peers.filter((p) => { const rows = data.peers.filter((p) => {
const st = peerState(p).key; const st = peerState(p).key;
const hit = !q || (p.name + ' ' + p.ipv4 + ' ' + p.note).toLowerCase().includes(q); 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; return hit && keep;
}); });
tbody.replaceChildren(...rows.map((p) => h('tr', null, tbody.replaceChildren(...rows.map((p) => h('tr', null,
@@ -597,7 +647,6 @@
const name = h('input', { id: 'n', autocomplete: 'off', required: true }); const name = h('input', { id: 'n', autocomplete: 'off', required: true });
const note = h('input', { id: 'no' }); const note = h('input', { id: 'no' });
const ip = h('input', { id: 'ip', class: 'mono', placeholder: 'Next free address' }); 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 psk = h('input', { type: 'checkbox', checked: true });
const preview = h('pre', { class: 'code' }); const preview = h('pre', { class: 'code' });
const ch = overrideChoices(srv, null); 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 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 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 }); 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() { 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; let o;
try { o = overrides(dns, allowed, ka, srv); } catch { o = {}; } try { o = overrides(dns, allowed, ka, srv); } catch { o = {}; }
const d = srv.clientDefaults; const d = srv.clientDefaults;
const lines = ['[Interface]', 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›' : '')]; '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; const dnsList = o.dns === undefined || o.dns === null ? d.dns : o.dns;
if (dnsList.length) lines.push('DNS = ' + dnsList.join(', ')); if (dnsList.length) lines.push('DNS = ' + dnsList.join(', '));
lines.push('', '[Peer]', 'PublicKey = ' + srv.publicKey); 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('Endpoint = ' + (srv.endpoint || '‹set the endpoint in Server›') + ':' + (srv.endpointPort || srv.listenPort));
lines.push('AllowedIPs = ' + ((o.allowedIPs == null ? d.allowedIPs : o.allowedIPs).join(', '))); lines.push('AllowedIPs = ' + ((o.allowedIPs == null ? d.allowedIPs : o.allowedIPs).join(', ')));
const k = o.keepalive == null ? d.keepalive : o.keepalive; const k = o.keepalive == null ? d.keepalive : o.keepalive;
@@ -625,23 +685,21 @@
preview.textContent = lines.join('\n'); preview.textContent = lines.join('\n');
} }
const keyOpt = (v, title, hint) => h('label', { class: 'opt' }, const aside = h('p', { class: 'lead' });
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 submit = h('button', { type: 'submit', class: 'btn primary' }, 'Create peer'); const submit = h('button', { type: 'submit', class: 'btn primary' }, 'Create peer');
const form = h('form', { class: 'card grow', onSubmit: async (e) => { const form = h('form', { class: 'card grow', onSubmit: async (e) => {
e.preventDefault(); e.preventDefault();
err.textContent = ''; err.textContent = '';
let body; let body;
try { 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; } } catch (x) { err.textContent = x.message; return; }
if (keyMode === 'paste') body.publicKey = pub.value.trim();
submit.disabled = true; submit.disabled = true;
try { try {
const res = await api('POST', '/peers', body); 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) { } catch (x) {
err.textContent = x.message; err.textContent = x.message;
submit.disabled = false; 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)), 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), 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('div', { class: 'grid section' }, allowed.el, dns.el, ka.el),
h('fieldset', { class: 'section' }, h('div', { class: 'section' }, h('label', { class: 'check' }, psk, 'Add a preshared key')),
h('legend', { class: 'legend' }, 'Keys'), h('div', { class: 'section' }, ho.el),
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')),
err, err,
h('div', { class: 'formfoot' }, h('a', { class: 'btn', href: '#/peers' }, 'Cancel'), submit)); h('div', { class: 'formfoot' }, h('a', { class: 'btn', href: '#/peers' }, 'Cancel'), submit));
for (const el of [ip, psk]) el.addEventListener('input', drawPreview); for (const el of [ip, psk]) el.addEventListener('input', drawPreview);
@@ -667,13 +721,13 @@
fill(wrap, fill(wrap,
h('a', { class: 'back', href: '#/peers' }, '← Peers'), 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('div', { class: 'split' }, form,
h('aside', { class: 'card aside', 'aria-labelledby': 'pv' }, h('aside', { class: 'card aside', 'aria-labelledby': 'pv' },
h('h2', { id: 'pv' }, 'Client config preview'), 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, preview,
h('div', { class: 'qrrow section' }, h('div', { class: 'ph' }, 'QR code', h('br'), 'after creation'))))); h('div', { class: 'qrrow section' }, qrBox))));
name.focus(); name.focus();
} }
@@ -714,25 +768,53 @@
location.hash = '#/peers'; location.hash = '#/peers';
} catch (e) { toast(e.message, true); } } catch (e) { toast(e.message, true); }
}; };
const reissue = async (publicKey) => { const reissue = () => {
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; const ho = handover({
try { showHint: 'New keys now; QR code and download on this screen',
const res = await api('POST', '/peers/' + id + '/issue-config', publicKey ? { publicKey } : undefined); linkHint: p.publicKey ? 'The current config keeps working until the link is opened' : 'A one-time link you send to the device\'s owner',
configDialog(res, render); });
} catch (e) { toast(e.message, true); } const e = h('p', { class: 'err-text', role: 'alert' });
};
const pasteKey = () => {
const inp = h('textarea', { class: 'mono', rows: 2, 'aria-label': 'Public key' });
const e = h('p', { class: 'err-text' });
dialog((close) => h('form', { class: 'dlg', onSubmit: async (ev) => { dialog((close) => h('form', { class: 'dlg', onSubmit: async (ev) => {
ev.preventDefault(); ev.preventDefault();
e.textContent = '';
try {
const res = await api('POST', '/peers/' + id + '/issue-config', ho.body());
close(); 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('h2', null, (p.publicKey ? 'Issue a new config for ' : 'Issue a config for ') + p.name + '?'),
h('p', null, 'Paste the public key the device generated. The current config stops working.'), p.publicKey ? h('p', null, 'New keys are created. The device that uses the current config stops working once it is replaced.') : null,
inp, e, p.setup ? h('p', null, 'This replaces the current setup link.') : null,
h('div', { class: 'foot' }, h('button', { type: 'button', class: 'btn', onClick: close }, 'Cancel'), h('button', { type: 'submit', class: 'btn primary' }, 'Replace key')))); 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 // settings form
@@ -765,6 +847,8 @@
h('button', { type: 'button', class: 'btn', onClick: toggle }, p.enabled ? 'Disable' : 'Enable'), h('button', { type: 'button', class: 'btn', onClick: toggle }, p.enabled ? 'Disable' : 'Enable'),
h('button', { type: 'button', class: 'btn danger', onClick: del }, 'Delete'))), h('button', { type: 'button', class: 'btn danger', onClick: del }, 'Delete'))),
setupCard(),
h('section', { class: 'card', 'aria-labelledby': 'traffic' }, h('section', { class: 'card', 'aria-labelledby': 'traffic' },
h('div', { class: 'cardhead' }, h('div', null, h('h2', { id: 'traffic' }, 'Traffic'), totals), pills), h('div', { class: 'cardhead' }, h('div', null, h('h2', { id: 'traffic' }, 'Traffic'), totals), pills),
traffic), traffic),
@@ -777,16 +861,15 @@
h('dt', null, 'Endpoint'), h('dd', { class: 'mono' }, p.stats.endpoint || '–'), 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, 'Location'), h('dd', null, fmtLocation(p.stats.location) || '–'),
h('dt', null, 'Latest handshake'), h('dd', null, ago(p.stats.lastHandshake)), 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, '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('dt', null, 'All-time traffic'), h('dd', null, 'Download ' + fmtBytes(p.stats.downTotal) + ' · Upload ' + fmtBytes(p.stats.upTotal)))),
h('section', { class: 'card' }, h('section', { class: 'card' },
h('h2', null, 'Client configuration'), 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('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('div', { class: 'actions' },
h('button', { type: 'button', class: 'btn', onClick: () => reissue() }, 'Issue new config & QR'), h('button', { type: 'button', class: 'btn', onClick: reissue }, p.publicKey ? 'Issue new config…' : 'Issue config…')),
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) + '.' : 'No config issued yet.'))),
h('p', { class: 'hint', style: { margin: '12px 0 0' } }, p.configIssued ? 'Last issued ' + fmtDate(p.configIssued) + '.' : 'Created with a key from the device.'))),
h('section', { class: 'card flush', 'aria-labelledby': 'hist' }, h('section', { class: 'card flush', 'aria-labelledby': 'hist' },
h('div', { class: 'cardhead' }, h('h2', { id: 'hist' }, 'Connection history'), 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 Keepalive *int `json:"keepalive,omitempty"` // nil = server default
Created time.Time `json:"created"` Created time.Time `json:"created"`
ConfigIssued *time.Time `json:"configIssued,omitempty"` 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 { type LogConfig struct {
Level string `json:"level"` // debug | info | warn | error Level string `json:"level"` // debug | info | warn | error
MaxSizeMB int `json:"maxSizeMB"` MaxSizeMB int `json:"maxSizeMB"`
@@ -346,7 +353,7 @@ func (c *Config) validate() error {
return fmt.Errorf("address %s is used twice", ip) return fmt.Errorf("address %s is used twice", ip)
} }
ips[ip] = true 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) return fmt.Errorf("peer %q: public key is used by another peer", p.Name)
} }
keys[p.PublicKey] = true keys[p.PublicKey] = true
+9
View File
@@ -112,9 +112,18 @@ nonisolated struct Peer: Decodable, Identifiable, Hashable {
let effectiveKeepalive: Int let effectiveKeepalive: Int
let created: Date let created: Date
let configIssued: Date? let configIssued: Date?
let setup: SetupInfo? // pending setup link, nil if none
let stats: PeerStats 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 { nonisolated struct PeerList: Decodable {
let peers: [Peer] let peers: [Peer]
let capacity: Int let capacity: Int
+11 -19
View File
@@ -12,8 +12,6 @@ struct PeerDetailView: View {
@State private var issued: IssuedConfig? @State private var issued: IssuedConfig?
@State private var confirmIssue = false @State private var confirmIssue = false
@State private var confirmDelete = false @State private var confirmDelete = false
@State private var askKey = false
@State private var deviceKey = ""
@State private var editing = false @State private var editing = false
@State private var sessions: [ConnSession] = [] @State private var sessions: [ConnSession] = []
@State private var allSessions = false @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.") 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) { .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: { } message: {
Text("New keys are created. The device that uses the current config stops working until it gets the new one.") 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(item: $issued, onDismiss: { Task { await load() } }) { IssuedConfigView(issued: $0) }
.sheet(isPresented: $editing, onDismiss: { Task { await load() } }) { .sheet(isPresented: $editing, onDismiss: { Task { await load() } }) {
if let p = peer, let s = server { PeerEditView(peer: p, server: s) } 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: "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: "Location", value: p.stats.location?.label.isEmpty == false ? p.stats.location!.label : "–")
KV(key: "Latest handshake", value: ago(p.stats.lastHandshake)) 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: "Preshared key", value: p.hasPresharedKey ? "Set" : "None")
KV(key: "All-time traffic", value: "Download \(fmtBytes(p.stats.downTotal)) · Upload \(fmtBytes(p.stats.upTotal))") 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) .foregroundStyle(Color.gwText2)
Button { confirmIssue = true } label: { Label("Issue new config & QR", systemImage: "qrcode") } Button { confirmIssue = true } label: { Label("Issue new config & QR", systemImage: "qrcode") }
.buttonStyle(PrimaryButtonStyle()) .buttonStyle(PrimaryButtonStyle())
Button("Use a key from the device…") { deviceKey = ""; askKey = true } if let s = p.setup {
.buttonStyle(SecondaryButtonStyle()) Text(s.expired ? "The setup link expired \(fmtDate(s.expires)). Manage setup links in the web interface."
Text(p.configIssued.map { "Last issued \(fmtDate($0))." } ?? "Created with a key from the device.") : "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) .font(.caption)
.foregroundStyle(Color.gwText2) .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 } guard let api = session.api else { return }
do { 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) issued = try await api.send("POST", "/peers/\(peerID)/issue-config", body)
} catch { } catch {
session.alert = session.message(for: error) session.alert = session.message(for: error)
+1 -16
View File
@@ -109,8 +109,6 @@ struct AddPeerView: View {
@State private var note = "" @State private var note = ""
@State private var ipv4 = "" @State private var ipv4 = ""
@State private var overrides = PeerOverrides() @State private var overrides = PeerOverrides()
@State private var pasteKey = false
@State private var publicKey = ""
@State private var psk = true @State private var psk = true
@State private var error: String? @State private var error: String?
@State private var busy = false @State private var busy = false
@@ -164,23 +162,11 @@ struct AddPeerView: View {
OverrideSections(o: $overrides, server: server) OverrideSections(o: $overrides, server: server)
Section { 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) Toggle("Add a preshared key", isOn: $psk)
} header: { } header: {
Text("Keys") Text("Keys")
} footer: { } 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 { if let error {
@@ -201,7 +187,6 @@ struct AddPeerView: View {
body["note"] = note.trimmingCharacters(in: .whitespaces) body["note"] = note.trimmingCharacters(in: .whitespaces)
body["ipv4"] = ipv4.trimmingCharacters(in: .whitespaces) body["ipv4"] = ipv4.trimmingCharacters(in: .whitespaces)
body["presharedKey"] = psk body["presharedKey"] = psk
if pasteKey { body["publicKey"] = publicKey.trimmingCharacters(in: .whitespacesAndNewlines) }
let r: IssuedConfig = try await api.send("POST", "/peers", body) let r: IssuedConfig = try await api.send("POST", "/peers", body)
session.reportApply(r.applyError) session.reportApply(r.applyError)
issued = r issued = r
+7 -3
View File
@@ -104,10 +104,11 @@ struct Notice: View {
} }
enum PeerState { enum PeerState {
case online(Date), offline(Date), never, disabled case online(Date), offline(Date), never, disabled, waiting, noConfig
init(_ p: Peer) { init(_ p: Peer) {
if !p.enabled { self = .disabled } 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 if let h = p.stats.lastHandshake { self = p.stats.online ? .online(h) : .offline(h) }
else { self = .never } else { self = .never }
} }
@@ -118,13 +119,15 @@ enum PeerState {
case .offline(let d): "Offline · " + ago(d) case .offline(let d): "Offline · " + ago(d)
case .never: "Never connected" case .never: "Never connected"
case .disabled: "Disabled" case .disabled: "Disabled"
case .waiting: "Waiting for setup"
case .noConfig: "No config yet"
} }
} }
var key: String { var key: String {
switch self { switch self {
case .online: "online" case .online: "online"
case .offline, .never: "offline" case .offline, .never, .waiting, .noConfig: "offline"
case .disabled: "disabled" case .disabled: "disabled"
} }
} }
@@ -136,7 +139,8 @@ struct StatusDot: View {
switch state { switch state {
case .online: Circle().fill(Color.gwGood).frame(width: 8, height: 8) case .online: Circle().fill(Color.gwGood).frame(width: 8, height: 8)
case .offline: Circle().fill(Color.gray).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) 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{} desired := map[wgtypes.Key]want{}
for i := range c.Peers { for i := range c.Peers {
p := &c.Peers[i] p := &c.Peers[i]
if !p.Enabled { if !p.Enabled || !p.hasKey() {
continue continue
} }
pub, err := wgtypes.ParseKey(p.PublicKey) 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() defer k.mu.Unlock()
keep := map[string]bool{} keep := map[string]bool{}
for i, p := range c.Peers { for i, p := range c.Peers {
if !p.Enabled { if !p.Enabled || !p.hasKey() {
continue continue
} }
keep[p.PublicKey] = true keep[p.PublicKey] = true
+117
View File
@@ -314,6 +314,7 @@ func TestAPI(t *testing.T) {
bearer("GET", "/peers", 200) bearer("GET", "/peers", 200)
bearer("DELETE", "/peers/"+id, 403) bearer("DELETE", "/peers/"+id, 403)
bearer("GET", "/tokens", 403) bearer("GET", "/tokens", 403)
bearer("GET", "/peers/"+id+"/setup", 403) // the link would set up a device
call("DELETE", "/peers/"+id, nil, 200) call("DELETE", "/peers/"+id, nil, 200)
if len(store.Get().Peers) != 0 { if len(store.Get().Peers) != 0 {
@@ -502,3 +503,119 @@ func TestGeoDatabase(t *testing.T) {
t.Logf("%s → %+v", ep, info) 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{} idByKey := map[string]string{}
exists := map[string]bool{} exists := map[string]bool{}
for _, p := range cfg.Peers { for _, p := range cfg.Peers {
if p.hasKey() {
idByKey[p.PublicKey] = p.ID idByKey[p.PublicKey] = p.ID
}
exists[p.ID] = true exists[p.ID] = true
} }
now := time.Now() 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 // 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. // /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 var webFiles embed.FS
func webHandler() http.Handler { func webHandler() http.Handler {
files := http.FileServerFS(webFiles) files := http.FileServerFS(webFiles)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path { 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") w.Header().Set("Cache-Control", "no-cache")
files.ServeHTTP(w, r) files.ServeHTTP(w, r)
case "/favicon.ico": 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)
}