Subnets served per interface, pools, reservations, options at three levels, leases in SQLite saved before the ACK, ping and decline conflict checks, validated changes with history and undo, users and tokens, an audit log, a Kea importer, a sandboxed systemd unit and Debian packages. Tested with real udhcpc and dhcpcd clients in Docker (scripts/e2e.sh). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
193 lines
5.0 KiB
Go
193 lines
5.0 KiB
Go
package server
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"steward/internal/api"
|
|
"steward/internal/auth"
|
|
"steward/internal/engine"
|
|
"steward/internal/store"
|
|
)
|
|
|
|
// httpError is an error with an HTTP status.
|
|
type httpError struct {
|
|
status int
|
|
code string
|
|
msg string
|
|
}
|
|
|
|
func (e *httpError) Error() string { return e.msg }
|
|
|
|
func badRequest(format string, args ...any) error {
|
|
return &httpError{http.StatusBadRequest, "bad_request", fmt.Sprintf(format, args...)}
|
|
}
|
|
|
|
func notFound(format string, args ...any) error {
|
|
return &httpError{http.StatusNotFound, "not_found", fmt.Sprintf(format, args...)}
|
|
}
|
|
|
|
func forbidden(format string, args ...any) error {
|
|
return &httpError{http.StatusForbidden, "forbidden", fmt.Sprintf(format, args...)}
|
|
}
|
|
|
|
func conflict(format string, args ...any) error {
|
|
return &httpError{http.StatusConflict, "conflict", fmt.Sprintf(format, args...)}
|
|
}
|
|
|
|
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.SetEscapeHTML(false)
|
|
enc.Encode(v)
|
|
}
|
|
|
|
func writeError(w http.ResponseWriter, status int, code, msg string) {
|
|
writeJSON(w, status, api.ErrorBody{Error: api.ErrorInfo{Code: code, Message: msg}})
|
|
}
|
|
|
|
func engineStatus(kind string) int {
|
|
switch kind {
|
|
case engine.KindBadRequest:
|
|
return http.StatusBadRequest
|
|
case engine.KindNotFound:
|
|
return http.StatusNotFound
|
|
case engine.KindConflict:
|
|
return http.StatusConflict
|
|
case engine.KindValidation:
|
|
return http.StatusUnprocessableEntity
|
|
}
|
|
return http.StatusInternalServerError
|
|
}
|
|
|
|
// writeErr writes an error response. result, if given, is the preview of
|
|
// a refused change.
|
|
func (s *Server) writeErr(w http.ResponseWriter, r *http.Request, err error, result any) {
|
|
var he *httpError
|
|
if errors.As(err, &he) {
|
|
writeError(w, he.status, he.code, he.msg)
|
|
return
|
|
}
|
|
if ee, ok := engine.AsError(err); ok {
|
|
body := api.ErrorBody{Error: api.ErrorInfo{Code: ee.Kind, Message: ee.Message, Problems: ee.Problems}}
|
|
if result != nil {
|
|
body.Error.Result, _ = json.Marshal(result)
|
|
}
|
|
writeJSON(w, engineStatus(ee.Kind), body)
|
|
return
|
|
}
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
writeError(w, http.StatusNotFound, "not_found", "not found")
|
|
return
|
|
}
|
|
if errors.Is(err, store.ErrConflict) {
|
|
writeError(w, http.StatusConflict, "conflict", "already exists")
|
|
return
|
|
}
|
|
s.log.Error("request failed", "method", r.Method, "path", r.URL.Path, "err", err)
|
|
writeError(w, http.StatusInternalServerError, "internal", "internal server error: "+err.Error())
|
|
}
|
|
|
|
// handler is an authenticated API handler.
|
|
type handler func(w http.ResponseWriter, r *http.Request, p *auth.Principal) error
|
|
|
|
// decodeJSON reads a JSON body into v, refusing unknown fields.
|
|
func decodeJSON(r *http.Request, v any) error {
|
|
ct := r.Header.Get("Content-Type")
|
|
if ct != "" && !strings.HasPrefix(ct, "application/json") {
|
|
return badRequest("expected a JSON body (Content-Type: application/json)")
|
|
}
|
|
dec := json.NewDecoder(r.Body)
|
|
dec.DisallowUnknownFields()
|
|
if err := dec.Decode(v); err != nil {
|
|
if errors.Is(err, io.EOF) {
|
|
return badRequest("the request body is empty")
|
|
}
|
|
var mbe *http.MaxBytesError
|
|
if errors.As(err, &mbe) {
|
|
return &httpError{http.StatusRequestEntityTooLarge, "too_large", "the request body is too large"}
|
|
}
|
|
return badRequest("invalid JSON: %v", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// decodeOptional is decodeJSON, but an empty body leaves v alone.
|
|
func decodeOptional(r *http.Request, v any) error {
|
|
b, err := io.ReadAll(r.Body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if strings.TrimSpace(string(b)) == "" {
|
|
return nil
|
|
}
|
|
dec := json.NewDecoder(strings.NewReader(string(b)))
|
|
dec.DisallowUnknownFields()
|
|
if err := dec.Decode(v); err != nil {
|
|
return badRequest("invalid JSON: %v", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func pathID(r *http.Request, name string) (int64, error) {
|
|
v := r.PathValue(name)
|
|
id, err := strconv.ParseInt(v, 10, 64)
|
|
if err != nil || id <= 0 {
|
|
return 0, badRequest("invalid %s %q", name, v)
|
|
}
|
|
return id, nil
|
|
}
|
|
|
|
func queryBool(r *http.Request, name string) bool {
|
|
switch strings.ToLower(r.URL.Query().Get(name)) {
|
|
case "1", "true", "yes", "on":
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func queryInt(r *http.Request, name string, def int) int {
|
|
if v := r.URL.Query().Get(name); v != "" {
|
|
if n, err := strconv.Atoi(v); err == nil {
|
|
return n
|
|
}
|
|
}
|
|
return def
|
|
}
|
|
|
|
func require(p *auth.Principal, resource string, write bool) error {
|
|
if p.Can(resource, write) {
|
|
return nil
|
|
}
|
|
access := "read"
|
|
if write {
|
|
access = "write"
|
|
}
|
|
return forbidden("permission denied: this needs %s:%s", resource, access)
|
|
}
|
|
|
|
func requireAdmin(p *auth.Principal) error {
|
|
if !p.IsAdmin() {
|
|
return forbidden("this needs an administrator (and not a restricted token)")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// via names where a request came from, for the history.
|
|
func via(r *http.Request, p *auth.Principal) string {
|
|
switch {
|
|
case strings.Contains(r.UserAgent(), "steward-cli"):
|
|
return "cli"
|
|
case p.Method == "session":
|
|
return "web"
|
|
}
|
|
return "api"
|
|
}
|