package main import ( "cmp" "context" "encoding/json" "errors" "fmt" "io" "log/slog" "net/http" "net/netip" "slices" "strings" "time" ) // App wires the parts together and serves the HTTP API. type App struct { store *Store kernel Kernel recon *Reconciler stats *Stats auth *Auth tls *webTLS logPath string logw *rotatingWriter // nil in tests geo *Geo // nil in tests started time.Time shutdown func() // graceful stop; systemd restarts the service } // --- helpers --- func writeJSON(w http.ResponseWriter, code int, v any) { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.Header().Set("Cache-Control", "no-store") w.WriteHeader(code) _ = json.NewEncoder(w).Encode(v) } func writeErr(w http.ResponseWriter, err error) { var ue *userError switch { case errors.As(err, &ue): writeJSON(w, http.StatusBadRequest, map[string]string{"error": ue.msg}) default: slog.Error("request failed", "err", err) writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) } } func readJSON(r *http.Request, v any) error { dec := json.NewDecoder(io.LimitReader(r.Body, 1<<20)) if err := dec.Decode(v); err != nil { return badRequest("invalid JSON: %v", err) } return nil } type ctxKey struct{} func who(r *http.Request) *principal { return r.Context().Value(ctxKey{}).(*principal) } func (a *App) audit(r *http.Request, msg string, args ...any) { p := who(r) slog.Info(msg, append([]any{"audit", true, "actor", p.Name, "remote", p.RemoteIP}, args...)...) } // guard requires authentication. adminOnly endpoints refuse API tokens; // read-only tokens may only use GET. func (a *App) guard(adminOnly bool, h http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { p, ok := a.auth.Authenticate(r) if !ok { writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "not signed in"}) return } if adminOnly && !p.IsAdmin { writeJSON(w, http.StatusForbidden, map[string]string{"error": "API tokens cannot do this; sign in to the web interface"}) return } if p.MustChangePassword && r.URL.Path != "/api/v1/auth/me" && r.URL.Path != "/api/v1/auth/password" { writeJSON(w, http.StatusForbidden, map[string]string{"error": "choose a new password first", "code": "password_change_required"}) return } if p.MFASetupRequired && r.URL.Path != "/api/v1/auth/me" && r.URL.Path != "/api/v1/auth/password" && !strings.HasPrefix(r.URL.Path, "/api/v1/auth/mfa") { writeJSON(w, http.StatusForbidden, map[string]string{"error": "set up two-step sign-in first", "code": "mfa_setup_required"}) return } if p.Scope == "ro" && r.Method != http.MethodGet { writeJSON(w, http.StatusForbidden, map[string]string{"error": "this token is read-only"}) return } h(w, r.WithContext(context.WithValue(r.Context(), ctxKey{}, p))) } } // applyResult saves-then-applies: the config is already stored, so a kernel // error is reported but does not undo the change. func (a *App) apply() string { if err := a.recon.ApplyNow(); err != nil { return err.Error() } return "" } func (a *App) routes() http.Handler { mux := http.NewServeMux() g := func(pattern string, h http.HandlerFunc) { mux.HandleFunc(pattern, a.guard(false, h)) } adm := func(pattern string, h http.HandlerFunc) { mux.HandleFunc(pattern, a.guard(true, h)) } mux.HandleFunc("POST /api/v1/auth/login", a.login) mux.HandleFunc("POST /api/v1/auth/logout", a.logout) // The second step of signing in, and signing in with a passkey alone. mux.HandleFunc("GET /api/v1/auth/options", a.signInOptions) mux.HandleFunc("POST /api/v1/auth/login/totp", a.loginTOTP) mux.HandleFunc("POST /api/v1/auth/login/recovery", a.loginRecovery) mux.HandleFunc("POST /api/v1/auth/login/key/begin", a.loginKeyBegin) mux.HandleFunc("POST /api/v1/auth/login/key/finish", a.loginKeyFinish) mux.HandleFunc("POST /api/v1/auth/login/passkey/begin", a.loginPasskeyBegin) mux.HandleFunc("POST /api/v1/auth/login/passkey/finish", a.loginPasskeyFinish) // Your own two-step sign-in. Keys and passkeys need a browser, so these // are for signed-in users only. adm("GET /api/v1/auth/mfa", a.mfaStatus) adm("POST /api/v1/auth/mfa/totp/setup", a.totpSetup) adm("POST /api/v1/auth/mfa/totp/confirm", a.totpConfirm) adm("DELETE /api/v1/auth/mfa/totp", a.totpRemove) adm("POST /api/v1/auth/mfa/keys/begin", a.keyBegin) adm("POST /api/v1/auth/mfa/keys/finish", a.keyFinish) adm("PATCH /api/v1/auth/mfa/keys/{id}", a.keyRename) adm("DELETE /api/v1/auth/mfa/keys/{id}", a.keyRemove) adm("POST /api/v1/auth/mfa/recovery-codes", a.newRecoveryCodesHandler) g("GET /api/v1/auth/me", a.me) adm("POST /api/v1/auth/password", a.changePassword) adm("GET /api/v1/users", a.listUsers) adm("POST /api/v1/users", a.createUser) adm("PATCH /api/v1/users/{id}", a.patchUser) adm("POST /api/v1/users/{id}/reset-password", a.resetPassword) adm("DELETE /api/v1/users/{id}", a.deleteUser) adm("POST /api/v1/users/{id}/reset-mfa", a.resetMFA) g("GET /api/v1/status", a.status) g("GET /api/v1/stats", a.allStats) g("GET /api/v1/server", a.getServer) g("PATCH /api/v1/server", a.patchServer) g("POST /api/v1/server/rotate-key", a.rotateServerKey) g("GET /api/v1/server/detect-ip", a.detectIP) g("GET /api/v1/peers", a.listPeers) g("POST /api/v1/peers", a.createPeer) g("GET /api/v1/peers/{id}", a.getPeer) g("PATCH /api/v1/peers/{id}", a.patchPeer) g("DELETE /api/v1/peers/{id}", a.deletePeer) g("POST /api/v1/peers/{id}/enable", a.setEnabled(true)) g("POST /api/v1/peers/{id}/disable", a.setEnabled(false)) 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}/latency", a.peerLatency) 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, read logs and // restart. Users, passwords, API tokens, the sign-in rules and backups // need a signed-in user. g("GET /api/v1/settings", a.getSettings) g("PATCH /api/v1/settings", a.patchSettings) g("POST /api/v1/restart", a.restart) adm("GET /api/v1/tokens", a.listTokens) adm("POST /api/v1/tokens", a.createToken) adm("DELETE /api/v1/tokens/{id}", a.deleteToken) g("GET /api/v1/logs", a.logs) g("GET /api/v1/logs/download", a.downloadLog) adm("GET /api/v1/backup", a.backup) adm("POST /api/v1/restore", a.restore) 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}", a.setupPage) mux.HandleFunc("GET /setup/{token}/{file}", a.setupAsset) mux.Handle("/", a.webHandler()) csrf := http.NewCrossOriginProtection() return securityHeaders(csrf.Handler(mux)) } func securityHeaders(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { h := w.Header() h.Set("Content-Security-Policy", "default-src 'self'; img-src 'self' data:; style-src 'self'; script-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'") h.Set("X-Content-Type-Options", "nosniff") h.Set("Referrer-Policy", "no-referrer") h.Set("X-Frame-Options", "DENY") if r.TLS != nil { h.Set("Strict-Transport-Security", "max-age=31536000") } next.ServeHTTP(w, r) }) } // --- auth --- func (a *App) login(w http.ResponseWriter, r *http.Request) { var in struct{ Username, Password string } if err := readJSON(r, &in); err != nil { writeErr(w, err) return } ip := remoteIP(r) id, ticket, err := a.auth.Login(in.Username, in.Password, ip) if err != nil { slog.Warn("login failed", "user", in.Username, "remote", ip, "reason", err.Error()) code := http.StatusUnauthorized if errors.Is(err, errLocked) || errors.Is(err, errBusy) { code = http.StatusTooManyRequests } writeJSON(w, code, map[string]string{"error": err.Error()}) return } if ticket != "" { // The password was right; the second step makes the session. _, u := a.auth.ticketUserID(ticket) writeJSON(w, http.StatusOK, map[string]any{"mfa": true, "ticket": ticket, "methods": mfaMethods(u)}) return } a.setSessionCookie(w, r, id) slog.Info("login", "audit", true, "actor", in.Username, "remote", ip) writeJSON(w, http.StatusOK, map[string]any{"ok": true}) } func (a *App) setSessionCookie(w http.ResponseWriter, r *http.Request, id string) { http.SetCookie(w, &http.Cookie{ Name: cookieName(), Value: id, Path: "/", HttpOnly: true, Secure: r.TLS != nil, SameSite: http.SameSiteStrictMode, MaxAge: a.store.Get().Web.SessionHours * 3600, }) } func (a *App) logout(w http.ResponseWriter, r *http.Request) { if c, err := r.Cookie(cookieName()); err == nil { a.auth.Logout(c.Value) } http.SetCookie(w, &http.Cookie{Name: cookieName(), Value: "", Path: "/", MaxAge: -1, HttpOnly: true, Secure: r.TLS != nil}) writeJSON(w, http.StatusOK, map[string]any{"ok": true}) } func (a *App) me(w http.ResponseWriter, r *http.Request) { p := who(r) out := map[string]any{ "id": p.UserID, "name": p.Name, "isAdmin": p.IsAdmin, "scope": p.Scope, "mustChangePassword": p.MustChangePassword, "mfaSetupRequired": p.MFASetupRequired, "version": version, "session": p.Session, } if _, u := a.store.Get().userByID(p.UserID); u != nil { out["username"], out["note"], out["created"] = u.Username, u.Note, u.Created } writeJSON(w, http.StatusOK, out) } // changePassword changes the signed-in user's own password. Their other // sessions end; this one continues with a new session id. func (a *App) changePassword(w http.ResponseWriter, r *http.Request) { var in struct{ Current, New string } if err := readJSON(r, &in); err != nil { writeErr(w, err) return } id := who(r).UserID _, u := a.store.Get().userByID(id) if u == nil || !verifyPassword(u.PasswordHash, in.Current) { writeErr(w, badRequest("current password is wrong")) return } if in.New == in.Current { writeErr(w, badRequest("choose a password different from the current one")) return } if err := validatePassword(in.New); err != nil { writeErr(w, err) return } hash, err := hashPassword(in.New) if err != nil { writeErr(w, err) return } var updated User if err := a.store.Update(func(c *Config) error { _, u := c.userByID(id) if u == nil { return badRequest("no such user") } u.PasswordHash, u.MustChangePassword = hash, false updated = *u return nil }); err != nil { writeErr(w, err) return } if c, err := r.Cookie(cookieName()); err == nil { a.auth.Logout(c.Value) } info := sessionInfo{Started: time.Now(), IP: remoteIP(r)} if s := who(r).Session; s != nil { info = *s } a.setSessionCookie(w, r, a.auth.NewSession(&updated, info)) a.audit(r, "password changed") writeJSON(w, http.StatusOK, map[string]any{"ok": true}) } // --- status & stats --- func (a *App) status(w http.ResponseWriter, r *http.Request) { cfg := a.store.Get() var online, enabled, never int var top string var topBytes int64 for _, p := range cfg.Peers { s := a.stats.Summary(p.ID) if p.Enabled { enabled++ if s.Online { online++ } } if s.LastHandshake == nil { never++ } if t := s.Down30d + s.Up30d; t > topBytes { topBytes, top = t, p.Name } } d24, u24 := sumPoints(a.stats.series(nil, "24h")) d30, u30 := sumPoints(a.stats.series(nil, "30d")) checks := a.kernel.Checks(cfg) last, applyErr := a.recon.Status() ac := Check{Name: "Last apply", OK: applyErr == nil, Detail: "applied " + last.Format(time.RFC3339)} if applyErr != nil { ac.Detail = applyErr.Error() } checks = append(checks, ac) if pc, ok := a.stats.PingCheck(cfg); ok { checks = append(checks, pc) } healthy := true for _, c := range checks { healthy = healthy && c.OK } v4 := netip.MustParsePrefix(cfg.Server.IPv4) writeJSON(w, http.StatusOK, map[string]any{ "version": version, "interface": cfg.Server.Interface, "listenPort": cfg.Server.ListenPort, "endpoint": endpointString(cfg), "ipv4": cfg.Server.IPv4, "ipv6": cfg.Server.IPv6, "ipv6Enabled": cfg.Server.IPv6Enabled, "capacity": capacity(v4), "started": a.started, "healthy": healthy, "checks": checks, "peers": map[string]int{ "total": len(cfg.Peers), "enabled": enabled, "online": online, "disabled": len(cfg.Peers) - enabled, "never": never, }, "traffic24h": map[string]int64{"down": d24, "up": u24}, "traffic30d": map[string]int64{"down": d30, "up": u30}, "topPeer30d": top, }) } func validRange(r *http.Request) string { rng := r.URL.Query().Get("range") if !slices.Contains([]string{"24h", "7d", "30d", "90d"}, rng) { rng = "24h" } return rng } func (a *App) allStats(w http.ResponseWriter, r *http.Request) { rng := validRange(r) writeJSON(w, http.StatusOK, map[string]any{"range": rng, "points": a.stats.series(nil, rng)}) } func (a *App) peerStats(w http.ResponseWriter, r *http.Request) { cfg := a.store.Get() if _, p := cfg.peerByID(r.PathValue("id")); p == nil { writeJSON(w, http.StatusNotFound, map[string]string{"error": "no such peer"}) return } rng := validRange(r) writeJSON(w, http.StatusOK, map[string]any{"range": rng, "points": a.stats.series([]string{r.PathValue("id")}, rng)}) } func (a *App) peerLatency(w http.ResponseWriter, r *http.Request) { if _, p := a.store.Get().peerByID(r.PathValue("id")); p == nil { writeJSON(w, http.StatusNotFound, map[string]string{"error": "no such peer"}) return } writeJSON(w, http.StatusOK, map[string]any{"stepSeconds": int(latencyStep.Seconds()), "points": a.stats.LatencyHistory(r.PathValue("id"))}) } // latencyMode turns the API value into the stored one: "off" (or nothing) // is stored empty. func latencyMode(v string) (string, error) { if v == "off" { return latencyOff, nil } if !validLatencyCheck(v) { return "", badRequest("latencyCheck must be off, active or always") } return v, nil } // --- server --- type serverView struct { Interface string `json:"interface"` PublicKey string `json:"publicKey"` KeyCreated time.Time `json:"keyCreated"` ListenPort int `json:"listenPort"` MTU int `json:"mtu"` IPv4 string `json:"ipv4"` IPv6 string `json:"ipv6"` IPv6Enabled bool `json:"ipv6Enabled"` Endpoint string `json:"endpoint"` EndpointPort int `json:"endpointPort"` UplinkV4 string `json:"uplinkV4"` UplinkV6 string `json:"uplinkV6"` DetectedV4 string `json:"detectedUplinkV4"` DetectedV6 string `json:"detectedUplinkV6"` NAT bool `json:"nat"` PeerToPeer bool `json:"peerToPeer"` LANAccess bool `json:"lanAccess"` OpenPort bool `json:"openPort"` ClientDefaults ClientDefaults `json:"clientDefaults"` } func (a *App) serverView(cfg *Config) serverView { s := cfg.Server return serverView{ Interface: s.Interface, PublicKey: serverPublicKey(cfg), KeyCreated: s.KeyCreated, ListenPort: s.ListenPort, MTU: s.MTU, IPv4: s.IPv4, IPv6: s.IPv6, IPv6Enabled: s.IPv6Enabled, Endpoint: s.Endpoint, EndpointPort: s.EndpointPort, UplinkV4: s.UplinkV4, UplinkV6: s.UplinkV6, DetectedV4: a.kernel.Uplink(&Config{}, false), DetectedV6: a.kernel.Uplink(&Config{}, true), NAT: s.NAT, PeerToPeer: s.PeerToPeer, LANAccess: s.LANAccess, OpenPort: s.OpenPort, ClientDefaults: s.ClientDefaults, } } func (a *App) getServer(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, a.serverView(a.store.Get())) } // decodeFields reads a PATCH body as raw fields so absent and null differ. func decodeFields(r *http.Request) (map[string]json.RawMessage, error) { var m map[string]json.RawMessage if err := readJSON(r, &m); err != nil { return nil, err } return m, nil } func field[T any](m map[string]json.RawMessage, key string, dst *T) error { raw, ok := m[key] if !ok { return nil } if err := json.Unmarshal(raw, dst); err != nil { return badRequest("%s: %v", key, err) } return nil } func (a *App) patchServer(w http.ResponseWriter, r *http.Request) { m, err := decodeFields(r) if err != nil { writeErr(w, err) return } var changed []string var reissue bool err = a.store.Update(func(c *Config) error { s := &c.Server before := s.clientFacing() oldV4 := s.IPv4 for _, f := range []struct { key string dst any }{ {"listenPort", &s.ListenPort}, {"mtu", &s.MTU}, {"ipv4", &s.IPv4}, {"ipv6", &s.IPv6}, {"ipv6Enabled", &s.IPv6Enabled}, {"endpoint", &s.Endpoint}, {"endpointPort", &s.EndpointPort}, {"uplinkV4", &s.UplinkV4}, {"uplinkV6", &s.UplinkV6}, {"nat", &s.NAT}, {"peerToPeer", &s.PeerToPeer}, {"lanAccess", &s.LANAccess}, {"openPort", &s.OpenPort}, {"clientDefaults", &s.ClientDefaults}, } { if _, ok := m[f.key]; ok { if err := json.Unmarshal(m[f.key], f.dst); err != nil { return badRequest("%s: %v", f.key, err) } changed = append(changed, f.key) } } s.Endpoint = strings.TrimSpace(s.Endpoint) if s.IPv4 != oldV4 { if err := renumberPeers(c, oldV4); err != nil { return err } } reissue = before != s.clientFacing() return nil }) if err != nil { writeErr(w, err) return } a.audit(r, "server settings changed", "fields", changed) writeJSON(w, http.StatusOK, map[string]any{ "server": a.serverView(a.store.Get()), "applyError": a.apply(), "reissueNeeded": reissue, }) } // clientFacing captures the settings baked into issued client configs; // changing any of them means existing devices need a new config. func (s *Server) clientFacing() string { return fmt.Sprint(s.ListenPort, s.EndpointPort, s.Endpoint, s.IPv4, s.IPv6, s.IPv6Enabled) } // renumberPeers moves peers into a new IPv4 network, keeping each host part. func renumberPeers(c *Config, oldNet string) error { oldP, err := netip.ParsePrefix(oldNet) if err != nil { return err } newP, err := netip.ParsePrefix(c.Server.IPv4) if err != nil || !newP.Addr().Is4() { return badRequest("IPv4 network must be an IPv4 CIDR") } newP = newP.Masked() c.Server.IPv4 = newP.String() for i := range c.Peers { old := netip.MustParseAddr(c.Peers[i].IPv4) host := addrToU32(old) - addrToU32(oldP.Addr()) if host >= 1<<(32-newP.Bits())-1 { return badRequest("%s is too small for the existing peers", newP) } c.Peers[i].IPv4 = u32ToAddr(addrToU32(newP.Addr()) + host).String() } return nil } func (a *App) rotateServerKey(w http.ResponseWriter, r *http.Request) { key, err := newPrivateKey() if err != nil { writeErr(w, err) return } if err := a.store.Update(func(c *Config) error { c.Server.PrivateKey = key.String() c.Server.KeyCreated = time.Now().UTC() return nil }); err != nil { writeErr(w, err) return } a.audit(r, "server key rotated") writeJSON(w, http.StatusOK, map[string]any{"server": a.serverView(a.store.Get()), "applyError": a.apply()}) } func (a *App) detectIP(w http.ResponseWriter, r *http.Request) { ip, err := detectPublicIP(r.Context()) if err != nil { writeErr(w, badRequest("%v", err)) return } writeJSON(w, http.StatusOK, map[string]string{"ip": ip.String()}) } // detectPublicIP asks an outside service which address this server has. func detectPublicIP(ctx context.Context) (netip.Addr, error) { ctx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() req, _ := http.NewRequestWithContext(ctx, http.MethodGet, "https://checkip.amazonaws.com", nil) resp, err := http.DefaultClient.Do(req) if err != nil { return netip.Addr{}, fmt.Errorf("could not detect the public IP: %v", err) } defer resp.Body.Close() b, _ := io.ReadAll(io.LimitReader(resp.Body, 100)) ip, err := netip.ParseAddr(strings.TrimSpace(string(b))) if err != nil { return netip.Addr{}, errors.New("unexpected answer from the IP service") } return ip, nil } // --- peers --- type peerView struct { ID string `json:"id"` Name string `json:"name"` Note string `json:"note"` Enabled bool `json:"enabled"` PublicKey string `json:"publicKey"` HasPSK bool `json:"hasPresharedKey"` IPv4 string `json:"ipv4"` IPv6 string `json:"ipv6,omitempty"` DNS []string `json:"dns"` // null = server default AllowedIPs []string `json:"allowedIPs"` // null = server default Keepalive *int `json:"keepalive"` // null = server default EffDNS []string `json:"effectiveDNS"` EffAllowed []string `json:"effectiveAllowedIPs"` EffKeepalive int `json:"effectiveKeepalive"` LatencyCheck string `json:"latencyCheck"` // off | active | always Created time.Time `json:"created"` ConfigIssued *time.Time `json:"configIssued"` Setup *setupView `json:"setup"` // null = no pending setup link Stats PeerSummary `json:"stats"` } func (a *App) peerView(c *Config, p *Peer) peerView { v := 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), LatencyCheck: cmp.Or(p.LatencyCheck, "off"), 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() } return v } func (a *App) listPeers(w http.ResponseWriter, r *http.Request) { cfg := a.store.Get() out := make([]peerView, 0, len(cfg.Peers)) for i := range cfg.Peers { out = append(out, a.peerView(cfg, &cfg.Peers[i])) } v4 := netip.MustParsePrefix(cfg.Server.IPv4) writeJSON(w, http.StatusOK, map[string]any{"peers": out, "capacity": capacity(v4), "network": cfg.Server.IPv4}) } func (a *App) getPeer(w http.ResponseWriter, r *http.Request) { cfg := a.store.Get() _, p := cfg.peerByID(r.PathValue("id")) if p == nil { writeJSON(w, http.StatusNotFound, map[string]string{"error": "no such peer"}) return } writeJSON(w, http.StatusOK, a.peerView(cfg, p)) } // issuedConfig is returned exactly once; the private key is not stored. type issuedConfig struct { Peer peerView `json:"peer"` Config string `json:"config"` QR string `json:"qr"` HasPrivKey bool `json:"includesPrivateKey"` // always true; kept for older clients ApplyError string `json:"applyError"` } // 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 } return k.String(), k.PublicKey().String(), nil } 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: 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"` Note string `json:"note"` IPv4 string `json:"ipv4"` DNS []string `json:"dns"` AllowedIPs []string `json:"allowedIPs"` Keepalive *int `json:"keepalive"` PresharedKey *bool `json:"presharedKey"` LatencyCheck string `json:"latencyCheck"` setupRequest } if err := readJSON(r, &in); err != nil { writeErr(w, err) return } in.Name = strings.TrimSpace(in.Name) lc, err := latencyMode(in.LatencyCheck) if err != nil { writeErr(w, err) return } 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, DNS: in.DNS, AllowedIPs: in.AllowedIPs, Keepalive: in.Keepalive, LatencyCheck: lc, 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 { writeErr(w, err) return } p.PresharedKey = psk.String() } err = a.store.Update(func(c *Config) error { if err := validatePeerName(p.Name); err != nil { return &userError{err.Error()} } if in.IPv4 == "" || in.IPv4 == "auto" { ip, err := nextFreeIPv4(c) if err != nil { return err } p.IPv4 = ip.String() } else { p.IPv4 = strings.TrimSpace(in.IPv4) } c.Peers = append(c.Peers, p) return nil }) if err != nil { writeErr(w, err) return } 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) return } out.ApplyError = a.apply() writeJSON(w, http.StatusCreated, out) } func (a *App) patchPeer(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") m, err := decodeFields(r) if err != nil { writeErr(w, err) return } var name string var changed []string err = a.store.Update(func(c *Config) error { _, p := c.peerByID(id) if p == nil { return badRequest("no such peer") } for _, f := range []struct { key string dst any }{ {"name", &p.Name}, {"note", &p.Note}, {"ipv4", &p.IPv4}, {"enabled", &p.Enabled}, } { if raw, ok := m[f.key]; ok { if err := json.Unmarshal(raw, f.dst); err != nil { return badRequest("%s: %v", f.key, err) } changed = append(changed, f.key) } } // For the overrides, null means "use the server default". if raw, ok := m["dns"]; ok { p.DNS = nil if err := json.Unmarshal(raw, &p.DNS); err != nil { return badRequest("dns: %v", err) } changed = append(changed, "dns") } if raw, ok := m["allowedIPs"]; ok { p.AllowedIPs = nil if err := json.Unmarshal(raw, &p.AllowedIPs); err != nil { return badRequest("allowedIPs: %v", err) } changed = append(changed, "allowedIPs") } if raw, ok := m["keepalive"]; ok { p.Keepalive = nil if err := json.Unmarshal(raw, &p.Keepalive); err != nil { return badRequest("keepalive: %v", err) } changed = append(changed, "keepalive") } if raw, ok := m["latencyCheck"]; ok { var v string if err := json.Unmarshal(raw, &v); err != nil { return badRequest("latencyCheck: %v", err) } lc, err := latencyMode(v) if err != nil { return err } p.LatencyCheck = lc changed = append(changed, "latencyCheck") } p.Name = strings.TrimSpace(p.Name) p.Note = strings.TrimSpace(p.Note) name = p.Name return nil }) if err != nil { writeErr(w, err) return } a.audit(r, "peer updated", "peer", name, "fields", changed) cfg := a.store.Get() _, p := cfg.peerByID(id) writeJSON(w, http.StatusOK, map[string]any{"peer": a.peerView(cfg, p), "applyError": a.apply()}) } func (a *App) setEnabled(on bool) http.HandlerFunc { return func(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.Enabled, name = on, p.Name return nil }); err != nil { writeErr(w, err) return } a.audit(r, map[bool]string{true: "peer enabled", false: "peer disabled"}[on], "peer", name) cfg := a.store.Get() _, p := cfg.peerByID(id) writeJSON(w, http.StatusOK, map[string]any{"peer": a.peerView(cfg, p), "applyError": a.apply()}) } } func (a *App) deletePeer(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") var name string if err := a.store.Update(func(c *Config) error { i, p := c.peerByID(id) if p == nil { return badRequest("no such peer") } name = p.Name c.Peers = slices.Delete(c.Peers, i, i+1) return nil }); err != nil { writeErr(w, err) return } a.audit(r, "peer deleted", "peer", name) writeJSON(w, http.StatusOK, map[string]any{"ok": true, "applyError": a.apply()}) } // 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 setupRequest if r.ContentLength > 0 { if err := readJSON(r, &in); err != nil { writeErr(w, err) return } } 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 } psk, err := newPresharedKey() if err != nil { writeErr(w, err) return } var name string if err := a.store.Update(func(c *Config) error { _, p := c.peerByID(id) if p == nil { return badRequest("no such peer") } now := time.Now().UTC() // 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() } return nil }); err != nil { writeErr(w, err) return } a.stats.Forget(id) a.audit(r, "peer config issued", "peer", name) out, err := a.issue(id, priv) if err != nil { writeErr(w, err) return } out.ApplyError = a.apply() writeJSON(w, http.StatusOK, out) } // --- settings, tokens, logs, backup --- func (a *App) getSettings(w http.ResponseWriter, r *http.Request) { cfg := a.store.Get() writeJSON(w, http.StatusOK, map[string]any{ "web": cfg.Web, "log": cfg.Log, "stats": cfg.Stats, "decoy": cfg.Decoy, "signin": cfg.SignIn, "geo": a.geoStatus(), "fingerprint": a.tls.Fingerprint(), "logPath": a.logPath, }) } func (a *App) patchSettings(w http.ResponseWriter, r *http.Request) { m, err := decodeFields(r) if err != nil { writeErr(w, err) return } if _, ok := m["signin"]; ok && !who(r).IsAdmin { writeJSON(w, http.StatusForbidden, map[string]string{"error": "API tokens cannot change the sign-in rules; sign in to the web interface"}) return } var restart bool err = a.store.Update(func(c *Config) error { before, _ := json.Marshal(c.Web) if err := field(m, "web", &c.Web); err != nil { return err } after, _ := json.Marshal(c.Web) restart = string(before) != string(after) if err := field(m, "stats", &c.Stats); err != nil { return err } if err := field(m, "decoy", &c.Decoy); err != nil { return err } if err := field(m, "signin", &c.SignIn); err != nil { return err } return field(m, "log", &c.Log) }) if err != nil { writeErr(w, err) return } a.applyRuntime(a.store.Get()) a.audit(r, "app settings changed", "restartRequired", restart) writeJSON(w, http.StatusOK, map[string]any{"ok": true, "restartRequired": restart}) } func (a *App) restart(w http.ResponseWriter, r *http.Request) { a.audit(r, "service restart requested") writeJSON(w, http.StatusOK, map[string]any{"ok": true}) go func() { time.Sleep(500 * time.Millisecond) a.shutdown() }() } type tokenView struct { ID string `json:"id"` Name string `json:"name"` Scope string `json:"scope"` Owner string `json:"owner"` // username OwnerID string `json:"ownerId"` Created time.Time `json:"created"` LastUsed *tokenUse `json:"lastUsed"` } func (a *App) listTokens(w http.ResponseWriter, r *http.Request) { cfg := a.store.Get() out := []tokenView{} for _, t := range cfg.APITokens { out = append(out, tokenView{t.ID, t.Name, t.Scope, a.username(cfg, t.UserID), t.UserID, t.Created, a.auth.TokenUse(t.ID)}) } writeJSON(w, http.StatusOK, map[string]any{"tokens": out}) } func (a *App) createToken(w http.ResponseWriter, r *http.Request) { var in struct{ Name, Scope string } if err := readJSON(r, &in); err != nil { writeErr(w, err) return } in.Name = strings.TrimSpace(in.Name) if in.Name == "" || len(in.Name) > 64 { writeErr(w, badRequest("token name must be 1–64 characters")) return } if in.Scope != "ro" { in.Scope = "rw" } secret := tokenPrefix + randomString(32) t := APIToken{ID: newID(), Name: in.Name, Hash: hashToken(secret), Scope: in.Scope, UserID: who(r).UserID, Created: time.Now().UTC()} if err := a.store.Update(func(c *Config) error { c.APITokens = append(c.APITokens, t); return nil }); err != nil { writeErr(w, err) return } a.audit(r, "api token created", "token", t.Name, "scope", t.Scope) // The pairing payload lets the iOS app connect by scanning one QR code. pairing, _ := json.Marshal(map[string]string{ "url": "https://" + r.Host, "token": secret, "fingerprint": a.tls.Fingerprint(), }) qr, _ := qrDataURL(string(pairing)) writeJSON(w, http.StatusCreated, map[string]any{ "token": secret, "id": t.ID, "name": t.Name, "scope": t.Scope, "pairing": string(pairing), "qr": qr, }) } func (a *App) deleteToken(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") var name string if err := a.store.Update(func(c *Config) error { i := slices.IndexFunc(c.APITokens, func(t APIToken) bool { return t.ID == id }) if i < 0 { return badRequest("no such token") } name = c.APITokens[i].Name c.APITokens = slices.Delete(c.APITokens, i, i+1) return nil }); err != nil { writeErr(w, err) return } a.audit(r, "api token revoked", "token", name) writeJSON(w, http.StatusOK, map[string]any{"ok": true}) } func (a *App) logs(w http.ResponseWriter, r *http.Request) { q := r.URL.Query() limit := 200 if _, err := fmt.Sscan(q.Get("limit"), &limit); err != nil || limit < 1 || limit > 2000 { limit = 200 } lines, err := readLogTail(a.logPath, limit, q.Get("level"), q.Get("audit") == "1") if err != nil { writeErr(w, err) return } writeJSON(w, http.StatusOK, map[string]any{"lines": lines}) } func (a *App) downloadLog(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/x-ndjson") w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", appName+".jsonl")) http.ServeFile(w, r, a.logPath) } func (a *App) backup(w http.ResponseWriter, r *http.Request) { a.audit(r, "backup downloaded") w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", appName+"-backup-"+time.Now().Format("2006-01-02")+".json")) w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") enc := json.NewEncoder(w) enc.SetIndent("", " ") _ = enc.Encode(a.store.Get()) } func (a *App) restore(w http.ResponseWriter, r *http.Request) { var in Config if err := readJSON(r, &in); err != nil { writeErr(w, err) return } if in.Server.PrivateKey == "" { writeErr(w, badRequest("this file has no server key; is it a backup of this app?")) return } if err := a.store.Update(func(c *Config) error { *c = in; return nil }); err != nil { writeErr(w, err) return } a.audit(r, "backup restored", "peers", len(in.Peers)) writeJSON(w, http.StatusOK, map[string]any{"ok": true, "applyError": a.apply(), "restartRequired": true}) } // applyRuntime applies the settings that take effect without a restart: log // level and log rotation. Traffic retention is read by the stats sampler. func (a *App) applyRuntime(c *Config) { logLevel.Set(parseLevel(c.Log.Level)) if a.logw != nil { a.logw.SetLimits(c.Log.MaxSizeMB, c.Log.MaxFiles) } a.geo.SetEnabled(c.Stats.geoEnabled()) } func (a *App) geoStatus() GeoStatus { if a.geo == nil { return GeoStatus{} } return a.geo.Status() } // peerSessions returns the connection history, newest first. func (a *App) peerSessions(w http.ResponseWriter, r *http.Request) { if _, p := a.store.Get().peerByID(r.PathValue("id")); p == nil { writeJSON(w, http.StatusNotFound, map[string]string{"error": "no such peer"}) return } limit := 100 if _, err := fmt.Sscan(r.URL.Query().Get("limit"), &limit); err != nil || limit < 1 || limit > 1000 { limit = 100 } writeJSON(w, http.StatusOK, map[string]any{ "sessions": a.stats.Sessions(r.PathValue("id"), limit), "attribution": "IP geolocation by DB-IP (https://db-ip.com), CC BY 4.0", }) }