dcc3f91740
iOS app builds before Companion 2c9cc1c cannot decode /settings without it, and App Review still tests such builds. A test keeps it in place.
1206 lines
37 KiB
Go
1206 lines
37 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
|
||
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,
|
||
// Required by iOS app builds before 2c9cc1c, which App Review
|
||
// still tests.
|
||
"adminUsername": a.username(cfg, who(r).UserID),
|
||
})
|
||
}
|
||
|
||
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",
|
||
})
|
||
}
|