initial commit

This commit is contained in:
2026-08-16 21:18:45 -05:00
commit 1e05a01bcf
122 changed files with 29178 additions and 0 deletions
+289
View File
@@ -0,0 +1,289 @@
// 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.",
})
}
}