// Package api implements the versioned REST interface under /api/v1. // // It is a thin JSON layer over the same service package the web UI uses, so // validation, auditing and cache invalidation behave identically whichever // interface a change arrives through. package api import ( "encoding/json" "errors" "fmt" "io" "log/slog" "net/http" "strconv" "strings" "github.com/owen/vibedns/internal/app" "github.com/owen/vibedns/internal/auditlog" "github.com/owen/vibedns/internal/auth" ) // Server serves the REST API. type Server struct { app *app.App log *slog.Logger } // New creates the API server. func New(a *app.App, log *slog.Logger) *Server { return &Server{app: a, log: log} } // Handler returns the API handler, already wrapped in authentication. func (s *Server) Handler() http.Handler { mux := http.NewServeMux() s.routes(mux) return s.authenticate(mux) } // --- response helpers --------------------------------------------------- // errorBody is the single error shape every failing endpoint returns. type errorBody struct { Error struct { Status int `json:"status"` Message string `json:"message"` Code string `json:"code,omitempty"` } `json:"error"` } // listBody wraps collections so pagination can be added without breaking // clients that already parse the response. type listBody struct { Items any `json:"items"` Total int `json:"total"` Limit int `json:"limit,omitempty"` Offset int `json:"offset,omitempty"` } func writeJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.Header().Set("Cache-Control", "no-store") w.WriteHeader(status) enc := json.NewEncoder(w) enc.SetIndent("", " ") if err := enc.Encode(v); err != nil { // The status line is already sent; nothing useful is left to do. return } } // writeError renders a service error as JSON, logging server-side faults. func (s *Server) writeError(w http.ResponseWriter, r *http.Request, err error) { status := app.StatusOf(err) if app.IsInternal(err) { s.log.Error("api request failed", "method", r.Method, "path", r.URL.Path, "error", err) } var body errorBody body.Error.Status = status body.Error.Message = app.MessageOf(err) body.Error.Code = codeFor(status) writeJSON(w, status, body) } func codeFor(status int) string { switch status { case http.StatusBadRequest: return "invalid_request" case http.StatusUnauthorized: return "unauthorized" case http.StatusForbidden: return "forbidden" case http.StatusNotFound: return "not_found" case http.StatusConflict: return "conflict" case http.StatusTooManyRequests: return "rate_limited" default: return "internal_error" } } // handler is an API handler that may return an error for central rendering. type handler func(http.ResponseWriter, *http.Request) error func (s *Server) h(fn handler) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if err := fn(w, r); err != nil { s.writeError(w, r, err) } } } // decode reads a JSON request body into v. // // Unknown fields are rejected so a typo in a field name fails loudly instead // of being silently ignored, which is the difference between a caller noticing // their script is wrong and quietly not applying a setting. func decode(r *http.Request, v any) error { if r.Body == nil { return app.Invalid("A JSON request body is required.") } dec := json.NewDecoder(io.LimitReader(r.Body, 32<<20)) dec.DisallowUnknownFields() if err := dec.Decode(v); err != nil { if errors.Is(err, io.EOF) { return app.Invalid("A JSON request body is required.") } return app.Invalid("The request body is not valid JSON: %v", err) } return nil } // pathID reads an {id} path parameter. func pathID(r *http.Request, name string) (int64, error) { raw := r.PathValue(name) id, err := strconv.ParseInt(raw, 10, 64) if err != nil || id <= 0 { return 0, app.Invalid("%q is not a valid identifier.", raw) } return id, nil } // query reads a trimmed query parameter. func query(r *http.Request, key string) string { return strings.TrimSpace(r.URL.Query().Get(key)) } // queryInt reads an integer query parameter. func queryInt(r *http.Request, key string, def int) int { raw := query(r, key) if raw == "" { return def } v, err := strconv.Atoi(raw) if err != nil { return def } return v } // queryInt64 reads an int64 query parameter. func queryInt64(r *http.Request, key string) int64 { v, err := strconv.ParseInt(query(r, key), 10, 64) if err != nil { return 0 } return v } // queryBool reads a boolean query parameter. func queryBool(r *http.Request, key string) bool { v, err := strconv.ParseBool(query(r, key)) return err == nil && v } // limitOffset reads pagination parameters with sane bounds. func limitOffset(r *http.Request, defLimit int) (int, int) { limit := queryInt(r, "limit", defLimit) switch { case limit < 1: limit = defLimit case limit > 1000: limit = 1000 } offset := queryInt(r, "offset", 0) if offset < 0 { offset = 0 } return limit, offset } // actor builds the audit actor for an API request. func (s *Server) actor(r *http.Request) auditlog.Actor { p, _ := auth.PrincipalFrom(r.Context()) source := auditlog.SourceAPI if p.Kind == auth.KindAdmin { // A browser session hitting the API is still the administrator, but it // arrived over the API surface. source = auditlog.SourceAPI } return auditlog.Actor{Name: p.Name, Source: source, ClientIP: p.ClientIP} } // authenticate enforces credentials on every API route. // // Both bearer tokens and the administrator's Basic credentials are accepted: // tokens for automation, Basic so that the same URLs work from a browser or // curl session without minting a token first. func (s *Server) authenticate(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { p, err := s.app.Auth.Authenticate(r, true) if err != nil { s.writeAuthError(w, r, err) return } ctx := auth.WithPrincipal(r.Context(), p) r = r.WithContext(ctx) if csrfRequired(r, p) { if err := s.app.Auth.CheckCSRF(r, p); err != nil { s.writeError(w, r, app.Forbidden("%s", err.Error())) return } } next.ServeHTTP(w, r) }) } // csrfRequired decides whether a request needs a CSRF token. // // CSRF protection exists because a browser replays HTTP Basic credentials on // cross-site requests. The narrow case where that is actually exploitable is a // request an attacker's page can cause the browser to send *without* a preflight // — that is, a form submission, which is limited to the three CORS-safelisted // content types. Anything else (a JSON body, or any custom header) forces a // preflight the attacker's origin cannot pass. // // So tokens, safe methods, and non-form requests are exempt; a Basic-auth // request carrying a form-shaped body is not. func csrfRequired(r *http.Request, p auth.Principal) bool { if p.Kind == auth.KindToken { return false // never sent automatically by a browser } switch r.Method { case http.MethodGet, http.MethodHead, http.MethodOptions: return false } if r.Header.Get(auth.CSRFHeaderName) != "" { return true // a token was offered, so verify it } ct := strings.ToLower(strings.TrimSpace(strings.SplitN(r.Header.Get("Content-Type"), ";", 2)[0])) switch ct { case "application/x-www-form-urlencoded", "multipart/form-data", "text/plain", "": // Forgeable by a cross-origin form: require the token. return true default: // Any other content type triggers a CORS preflight, which a // cross-origin attacker cannot satisfy. return false } } func (s *Server) writeAuthError(w http.ResponseWriter, r *http.Request, err error) { switch { case errors.Is(err, auth.ErrLockedOut): w.Header().Set("Retry-After", "300") s.writeError(w, r, &app.Error{ Status: http.StatusTooManyRequests, Message: "Too many failed authentication attempts from this address. Try again shortly.", }) case errors.Is(err, auth.ErrNoAdmin): s.writeError(w, r, &app.Error{ Status: http.StatusServiceUnavailable, Message: "No administrator account exists yet.", }) default: w.Header().Set("WWW-Authenticate", fmt.Sprintf("Bearer realm=%q, Basic realm=%q", auth.Realm, auth.Realm)) s.writeError(w, r, &app.Error{ Status: http.StatusUnauthorized, Message: "Authentication is required. Send an API token as a bearer token, or use the administrator's Basic credentials.", }) } }