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:
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
|
||||||
}
|
func newKeys() (priv, pub string, err error) {
|
||||||
return "", k.String(), nil
|
|
||||||
}
|
|
||||||
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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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); }
|
||||||
|
|||||||
@@ -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();
|
||||||
close();
|
e.textContent = '';
|
||||||
await reissue(inp.value.trim());
|
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('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'),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||||
@@ -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
@@ -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})
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
idByKey[p.PublicKey] = p.ID
|
if p.hasKey() {
|
||||||
|
idByKey[p.PublicKey] = p.ID
|
||||||
|
}
|
||||||
exists[p.ID] = true
|
exists[p.ID] = true
|
||||||
}
|
}
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user