7ad72472ea
Settings -> Upkeep -> Backup & restore lists the config.json.bak-* files that update leaves behind and removes one or all of them; they hold the same secrets as a backup. Removing needs a signed-in user and is logged. After a successful update only the newest 3 copies are kept, and a copy that would overwrite an older one (version unknown, or the same version twice) gets the time appended. The backup card now also names preshared keys and authenticator app secrets. The update notice uses the existing compareVersions instead of its own.
1233 lines
38 KiB
Go
1233 lines
38 KiB
Go
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
|
||
updates *Updater // 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/updates/check", a.checkUpdates)
|
||
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)
|
||
adm("GET /api/v1/update-backups", a.listUpdateBackups)
|
||
adm("DELETE /api/v1/update-backups", a.removeUpdateBackups)
|
||
adm("DELETE /api/v1/update-backups/{name}", a.removeUpdateBackup)
|
||
|
||
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 v := a.updates.Available(); v != "" {
|
||
out["updateAvailable"] = v
|
||
}
|
||
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(),
|
||
"updates": a.updates.Status(),
|
||
"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 {
|
||
// Session length applies to the next sign-in; everything else in
|
||
// web needs a restart.
|
||
listen := func() string {
|
||
w := c.Web
|
||
w.SessionHours = 0
|
||
b, _ := json.Marshal(w)
|
||
return string(b)
|
||
}
|
||
before := listen()
|
||
if err := field(m, "web", &c.Web); err != nil {
|
||
return err
|
||
}
|
||
restart = listen() != before
|
||
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
|
||
}
|
||
if err := field(m, "updates", &c.Updates); 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())
|
||
a.updates.Set(c.Updates)
|
||
}
|
||
|
||
// checkUpdates asks the release source now and returns what it found.
|
||
func (a *App) checkUpdates(w http.ResponseWriter, r *http.Request) {
|
||
if a.updates == nil || !a.updates.Status().Enabled {
|
||
writeErr(w, badRequest("the update check is switched off"))
|
||
return
|
||
}
|
||
a.updates.Check(r.Context())
|
||
writeJSON(w, http.StatusOK, a.updates.Status())
|
||
}
|
||
|
||
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",
|
||
})
|
||
}
|