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
+93 -34
View File
@@ -12,8 +12,6 @@ import (
"slices"
"strings"
"time"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
)
// App wires the parts together and serves the HTTP API.
@@ -126,6 +124,13 @@ func (a *App) routes() http.Handler {
g("POST /api/v1/peers/{id}/issue-config", a.issueConfig)
g("GET /api/v1/peers/{id}/stats", a.peerStats)
g("GET /api/v1/peers/{id}/sessions", a.peerSessions)
g("GET /api/v1/peers/{id}/setup", a.getSetup)
g("DELETE /api/v1/peers/{id}/setup", a.revokeSetup)
// Setup links work without signing in: the token in the link is the
// credential.
mux.HandleFunc("GET /api/v1/setup/{token}", a.setupInfo)
mux.HandleFunc("POST /api/v1/setup/{token}", a.setupRedeem)
// Full-access tokens (the iOS app) may change app settings and read logs.
// Password, tokens and backups stay with the admin account.
@@ -143,6 +148,7 @@ func (a *App) routes() http.Handler {
mux.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "no such endpoint"})
})
mux.HandleFunc("GET /setup/{token}", setupPage)
mux.Handle("/", webHandler())
csrf := http.NewCrossOriginProtection()
@@ -505,6 +511,7 @@ type peerView struct {
EffKeepalive int `json:"effectiveKeepalive"`
Created time.Time `json:"created"`
ConfigIssued *time.Time `json:"configIssued"`
Setup *setupView `json:"setup"` // null = no pending setup link
Stats PeerSummary `json:"stats"`
}
@@ -513,7 +520,7 @@ func (a *App) peerView(c *Config, p *Peer) peerView {
ID: p.ID, Name: p.Name, Note: p.Note, Enabled: p.Enabled, PublicKey: p.PublicKey,
HasPSK: p.PresharedKey != "", IPv4: p.IPv4, DNS: p.DNS, AllowedIPs: p.AllowedIPs, Keepalive: p.Keepalive,
EffDNS: peerDNS(c, p), EffAllowed: peerAllowedIPs(c, p), EffKeepalive: peerKeepalive(c, p),
Created: p.Created, ConfigIssued: p.ConfigIssued, Stats: a.stats.Summary(p.ID),
Created: p.Created, ConfigIssued: p.ConfigIssued, Setup: viewSetup(p.Setup), Stats: a.stats.Summary(p.ID),
}
if c.Server.IPv6Enabled {
v.IPv6 = mapIPv6(netip.MustParsePrefix(c.Server.IPv6), netip.MustParseAddr(p.IPv4)).String()
@@ -545,21 +552,19 @@ func (a *App) getPeer(w http.ResponseWriter, r *http.Request) {
type issuedConfig struct {
Peer peerView `json:"peer"`
Config string `json:"config"`
QR string `json:"qr,omitempty"`
HasPrivKey bool `json:"includesPrivateKey"`
QR string `json:"qr"`
HasPrivKey bool `json:"includesPrivateKey"` // always true; kept for older clients
ApplyError string `json:"applyError"`
}
// newKeys returns a fresh key pair, or only the given public key when the
// client made its own keys.
func newKeys(clientPublic string) (priv, pub string, err error) {
if clientPublic != "" {
k, err := wgtypes.ParseKey(strings.TrimSpace(clientPublic))
if err != nil {
return "", "", badRequest("public key is not a valid WireGuard key")
}
return "", k.String(), nil
}
// linkCreated answers a create or issue request that asked for a setup link.
type linkCreated struct {
Peer peerView `json:"peer"`
Setup setupSecret `json:"setup"`
ApplyError string `json:"applyError"`
}
func newKeys() (priv, pub string, err error) {
k, err := newPrivateKey()
if err != nil {
return "", "", err
@@ -570,17 +575,24 @@ func newKeys(clientPublic string) (priv, pub string, err error) {
func (a *App) issue(id, priv string) (issuedConfig, error) {
cfg := a.store.Get()
_, p := cfg.peerByID(id)
out := issuedConfig{Peer: a.peerView(cfg, p), Config: clientConfig(cfg, p, priv), HasPrivKey: priv != ""}
if priv != "" {
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()
}