initial commit
This commit is contained in:
@@ -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.",
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,788 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/owen/vibedns/internal/app"
|
||||
"github.com/owen/vibedns/internal/database"
|
||||
"github.com/owen/vibedns/internal/models"
|
||||
"github.com/owen/vibedns/internal/validate"
|
||||
"github.com/owen/vibedns/internal/zonefile"
|
||||
)
|
||||
|
||||
// --- Zones --------------------------------------------------------------
|
||||
|
||||
func (s *Server) listZones(w http.ResponseWriter, r *http.Request) error {
|
||||
zones, err := s.app.Zones(r.Context(), database.ZoneFilter{
|
||||
Kind: query(r, "kind"),
|
||||
Search: query(r, "search"),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
writeJSON(w, http.StatusOK, listBody{Items: nonNil(zones), Total: len(zones)})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) getZone(w http.ResponseWriter, r *http.Request) error {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
zone, err := s.app.Zone(r.Context(), id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
writeJSON(w, http.StatusOK, zone)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) createZone(w http.ResponseWriter, r *http.Request) error {
|
||||
var in app.ZoneInput
|
||||
if err := decode(r, &in); err != nil {
|
||||
return err
|
||||
}
|
||||
zone, err := s.app.CreateZone(r.Context(), s.actor(r), in)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, zone)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) updateZone(w http.ResponseWriter, r *http.Request) error {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var in app.ZoneInput
|
||||
if err := decode(r, &in); err != nil {
|
||||
return err
|
||||
}
|
||||
zone, err := s.app.UpdateZone(r.Context(), s.actor(r), id, in)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
writeJSON(w, http.StatusOK, zone)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) deleteZone(w http.ResponseWriter, r *http.Request) error {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.app.DeleteZone(r.Context(), s.actor(r), id); err != nil {
|
||||
return err
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) cloneZone(w http.ResponseWriter, r *http.Request) error {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var in struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
if err := decode(r, &in); err != nil {
|
||||
return err
|
||||
}
|
||||
zone, err := s.app.CloneZone(r.Context(), s.actor(r), id, in.Name, in.Description)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, zone)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) exportZone(w http.ResponseWriter, r *http.Request) error {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
zone, body, err := s.app.ExportZoneFile(r.Context(), id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/dns; charset=utf-8")
|
||||
w.Header().Set("Content-Disposition", "attachment; filename=\""+zonefile.SuggestFilename(zone.Name)+"\"")
|
||||
_, _ = w.Write(body)
|
||||
return nil
|
||||
}
|
||||
|
||||
// importZone accepts a zone file as the raw request body, which is what
|
||||
// `curl --data-binary @example.zone` sends.
|
||||
func (s *Server) importZone(w http.ResponseWriter, r *http.Request) error {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mode := app.ImportMode(query(r, "mode"))
|
||||
if mode == "" {
|
||||
mode = app.ImportMerge
|
||||
}
|
||||
result, err := s.app.ImportZoneFile(r.Context(), s.actor(r), id, r.Body, mode)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
writeJSON(w, http.StatusOK, result)
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- Records ------------------------------------------------------------
|
||||
|
||||
func (s *Server) recordFilter(r *http.Request, zoneID int64) database.RecordFilter {
|
||||
limit, offset := limitOffset(r, 100)
|
||||
return database.RecordFilter{
|
||||
ZoneID: zoneID,
|
||||
Search: query(r, "search"),
|
||||
Type: query(r, "type"),
|
||||
Enabled: query(r, "status"),
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) listRecords(w http.ResponseWriter, r *http.Request) error {
|
||||
f := s.recordFilter(r, queryInt64(r, "zone_id"))
|
||||
records, total, err := s.app.Records(r.Context(), f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
writeJSON(w, http.StatusOK, listBody{
|
||||
Items: nonNil(records), Total: total, Limit: f.Limit, Offset: f.Offset,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) listZoneRecords(w http.ResponseWriter, r *http.Request) error {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := s.app.Zone(r.Context(), id); err != nil {
|
||||
return err
|
||||
}
|
||||
f := s.recordFilter(r, id)
|
||||
records, total, err := s.app.Records(r.Context(), f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
writeJSON(w, http.StatusOK, listBody{
|
||||
Items: nonNil(records), Total: total, Limit: f.Limit, Offset: f.Offset,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) getRecord(w http.ResponseWriter, r *http.Request) error {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rec, err := s.app.Record(r.Context(), id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
writeJSON(w, http.StatusOK, rec)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) createRecord(w http.ResponseWriter, r *http.Request) error {
|
||||
zoneID, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var in app.RecordInput
|
||||
if err := decode(r, &in); err != nil {
|
||||
return err
|
||||
}
|
||||
rec, err := s.app.CreateRecord(r.Context(), s.actor(r), zoneID, in)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, rec)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) updateRecord(w http.ResponseWriter, r *http.Request) error {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var in app.RecordInput
|
||||
if err := decode(r, &in); err != nil {
|
||||
return err
|
||||
}
|
||||
rec, err := s.app.UpdateRecord(r.Context(), s.actor(r), id, in)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
writeJSON(w, http.StatusOK, rec)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) deleteRecord(w http.ResponseWriter, r *http.Request) error {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.app.DeleteRecord(r.Context(), s.actor(r), id); err != nil {
|
||||
return err
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return nil
|
||||
}
|
||||
|
||||
// listRecordTypes publishes the record type catalogue, including the field
|
||||
// definitions the UI builds its editors from.
|
||||
func (s *Server) listRecordTypes(w http.ResponseWriter, r *http.Request) error {
|
||||
types := s.app.RecordTypes()
|
||||
writeJSON(w, http.StatusOK, listBody{Items: types, Total: len(types)})
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- Networks -----------------------------------------------------------
|
||||
|
||||
func (s *Server) listNetworks(w http.ResponseWriter, r *http.Request) error {
|
||||
nets, err := s.app.Networks(r.Context(), query(r, "search"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
writeJSON(w, http.StatusOK, listBody{Items: nonNil(nets), Total: len(nets)})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) getNetwork(w http.ResponseWriter, r *http.Request) error {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, err := s.app.Network(r.Context(), id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
writeJSON(w, http.StatusOK, n)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) createNetwork(w http.ResponseWriter, r *http.Request) error {
|
||||
var in app.NetworkInput
|
||||
if err := decode(r, &in); err != nil {
|
||||
return err
|
||||
}
|
||||
n, err := s.app.CreateNetwork(r.Context(), s.actor(r), in)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, n)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) updateNetwork(w http.ResponseWriter, r *http.Request) error {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var in app.NetworkInput
|
||||
if err := decode(r, &in); err != nil {
|
||||
return err
|
||||
}
|
||||
n, err := s.app.UpdateNetwork(r.Context(), s.actor(r), id, in)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
writeJSON(w, http.StatusOK, n)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) deleteNetwork(w http.ResponseWriter, r *http.Request) error {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.app.DeleteNetwork(r.Context(), s.actor(r), id); err != nil {
|
||||
return err
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- Policies -----------------------------------------------------------
|
||||
|
||||
func (s *Server) listPolicies(w http.ResponseWriter, r *http.Request) error {
|
||||
p, err := s.app.Policies(r.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
writeJSON(w, http.StatusOK, listBody{Items: nonNil(p), Total: len(p)})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) getPolicy(w http.ResponseWriter, r *http.Request) error {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p, err := s.app.Policy(r.Context(), id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
writeJSON(w, http.StatusOK, p)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) createPolicy(w http.ResponseWriter, r *http.Request) error {
|
||||
var in app.PolicyInput
|
||||
if err := decode(r, &in); err != nil {
|
||||
return err
|
||||
}
|
||||
p, err := s.app.CreatePolicy(r.Context(), s.actor(r), in)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, p)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) updatePolicy(w http.ResponseWriter, r *http.Request) error {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var in app.PolicyInput
|
||||
if err := decode(r, &in); err != nil {
|
||||
return err
|
||||
}
|
||||
p, err := s.app.UpdatePolicy(r.Context(), s.actor(r), id, in)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
writeJSON(w, http.StatusOK, p)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) deletePolicy(w http.ResponseWriter, r *http.Request) error {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.app.DeletePolicy(r.Context(), s.actor(r), id); err != nil {
|
||||
return err
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- Domain lists -------------------------------------------------------
|
||||
|
||||
// kindFor maps the URL segment to the stored list kind.
|
||||
func kindFor(segment string) string {
|
||||
if segment == "allowlists" {
|
||||
return models.KindAllowlist
|
||||
}
|
||||
return models.KindBlacklist
|
||||
}
|
||||
|
||||
func (s *Server) listListsFor(segment string) handler {
|
||||
kind := kindFor(segment)
|
||||
return func(w http.ResponseWriter, r *http.Request) error {
|
||||
lists, err := s.app.DomainLists(r.Context(), kind, query(r, "search"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
writeJSON(w, http.StatusOK, listBody{Items: nonNil(lists), Total: len(lists)})
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) createListFor(segment string) handler {
|
||||
kind := kindFor(segment)
|
||||
return func(w http.ResponseWriter, r *http.Request) error {
|
||||
var in app.ListInput
|
||||
if err := decode(r, &in); err != nil {
|
||||
return err
|
||||
}
|
||||
in.Kind = kind // the URL decides the kind, not the body
|
||||
l, err := s.app.CreateDomainList(r.Context(), s.actor(r), in)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, l)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) getList(w http.ResponseWriter, r *http.Request) error {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
l, err := s.app.DomainList(r.Context(), id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
writeJSON(w, http.StatusOK, l)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) updateList(w http.ResponseWriter, r *http.Request) error {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var in app.ListInput
|
||||
if err := decode(r, &in); err != nil {
|
||||
return err
|
||||
}
|
||||
l, err := s.app.UpdateDomainList(r.Context(), s.actor(r), id, in)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
writeJSON(w, http.StatusOK, l)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) deleteList(w http.ResponseWriter, r *http.Request) error {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.app.DeleteDomainList(r.Context(), s.actor(r), id); err != nil {
|
||||
return err
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) listDomains(w http.ResponseWriter, r *http.Request) error {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
limit, offset := limitOffset(r, 100)
|
||||
entries, total, err := s.app.DomainEntries(r.Context(), id, query(r, "search"), limit, offset)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
writeJSON(w, http.StatusOK, listBody{
|
||||
Items: nonNil(entries), Total: total, Limit: limit, Offset: offset,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) addDomain(w http.ResponseWriter, r *http.Request) error {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var in struct {
|
||||
Domain string `json:"domain"`
|
||||
MatchSubdomains *bool `json:"match_subdomains"`
|
||||
Comment string `json:"comment"`
|
||||
}
|
||||
if err := decode(r, &in); err != nil {
|
||||
return err
|
||||
}
|
||||
// Subdomain matching is the useful default: it is what makes a blocklist
|
||||
// of a few hundred thousand names cover the millions of hosts beneath them.
|
||||
match := true
|
||||
if in.MatchSubdomains != nil {
|
||||
match = *in.MatchSubdomains
|
||||
}
|
||||
entry, err := s.app.AddDomain(r.Context(), s.actor(r), id, in.Domain, match, in.Comment)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, entry)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) deleteDomain(w http.ResponseWriter, r *http.Request) error {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.app.DeleteDomain(r.Context(), s.actor(r), id); err != nil {
|
||||
return err
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) clearDomains(w http.ResponseWriter, r *http.Request) error {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, err := s.app.ClearDomains(r.Context(), s.actor(r), id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"removed": n})
|
||||
return nil
|
||||
}
|
||||
|
||||
// importDomains reads the list from the raw request body, so a caller can pipe
|
||||
// a multi-megabyte hosts file straight in with --data-binary.
|
||||
func (s *Server) importDomains(w http.ResponseWriter, r *http.Request) error {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
match := true
|
||||
if v := query(r, "match_subdomains"); v != "" {
|
||||
match = queryBool(r, "match_subdomains")
|
||||
}
|
||||
summary, err := s.app.ImportDomains(r.Context(), s.actor(r), id, r.Body, match)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
writeJSON(w, http.StatusOK, summary)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) exportDomains(w http.ResponseWriter, r *http.Request) error {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
if _, err := s.app.ExportDomains(r.Context(), id, w); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- Cache --------------------------------------------------------------
|
||||
|
||||
func (s *Server) getCache(w http.ResponseWriter, r *http.Request) error {
|
||||
writeJSON(w, http.StatusOK, s.app.CacheView())
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) flushCache(w http.ResponseWriter, r *http.Request) error {
|
||||
if name := query(r, "name"); name != "" {
|
||||
n, err := s.app.FlushCacheName(r.Context(), s.actor(r), name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"removed": n, "name": name})
|
||||
return nil
|
||||
}
|
||||
n, err := s.app.FlushCache(r.Context(), s.actor(r))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"removed": n})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) listCacheEntries(w http.ResponseWriter, r *http.Request) error {
|
||||
limit, offset := limitOffset(r, 100)
|
||||
entries, total := s.app.CacheEntries(query(r, "search"), limit, offset)
|
||||
writeJSON(w, http.StatusOK, listBody{
|
||||
Items: nonNil(entries), Total: total, Limit: limit, Offset: offset,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) deleteCacheEntry(w http.ResponseWriter, r *http.Request) error {
|
||||
name := query(r, "name")
|
||||
qtype := query(r, "type")
|
||||
if name == "" || qtype == "" {
|
||||
return app.Invalid("Both the name and type query parameters are required.")
|
||||
}
|
||||
if err := s.app.DeleteCacheEntry(r.Context(), s.actor(r), name, qtype, queryBool(r, "dnssec")); err != nil {
|
||||
return err
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- Settings -----------------------------------------------------------
|
||||
|
||||
func (s *Server) getSettings(w http.ResponseWriter, r *http.Request) error {
|
||||
writeJSON(w, http.StatusOK, s.app.Settings())
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateSettings merges the submitted fields over the current settings, so a
|
||||
// caller can change one value without restating the whole configuration.
|
||||
func (s *Server) updateSettings(w http.ResponseWriter, r *http.Request) error {
|
||||
next := s.app.Settings()
|
||||
if err := decode(r, &next); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.app.SaveSettings(r.Context(), s.actor(r), app.GroupDNS, next); err != nil {
|
||||
return err
|
||||
}
|
||||
writeJSON(w, http.StatusOK, s.app.Settings())
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- Statistics and logs ------------------------------------------------
|
||||
|
||||
func (s *Server) getStats(w http.ResponseWriter, r *http.Request) error {
|
||||
dash, err := s.app.Dashboard(r.Context(), queryInt(r, "top", 10))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
writeJSON(w, http.StatusOK, dash)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) listQueryLog(w http.ResponseWriter, r *http.Request) error {
|
||||
limit, offset := limitOffset(r, 100)
|
||||
f := database.QueryLogFilter{
|
||||
Domain: query(r, "domain"),
|
||||
ClientIP: query(r, "client"),
|
||||
QType: query(r, "type"),
|
||||
Rcode: query(r, "rcode"),
|
||||
Source: query(r, "source"),
|
||||
Blocked: query(r, "blocked"),
|
||||
NetworkID: queryInt64(r, "network_id"),
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
}
|
||||
entries, total, err := s.app.QueryLogs(r.Context(), f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
writeJSON(w, http.StatusOK, listBody{
|
||||
Items: nonNil(entries), Total: total, Limit: limit, Offset: offset,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) clearQueryLog(w http.ResponseWriter, r *http.Request) error {
|
||||
n, err := s.app.ClearQueryLog(r.Context(), s.actor(r))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"removed": n})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) listAuditLog(w http.ResponseWriter, r *http.Request) error {
|
||||
limit, offset := limitOffset(r, 100)
|
||||
f := database.AuditFilter{
|
||||
Search: query(r, "search"),
|
||||
ObjectType: query(r, "object_type"),
|
||||
Source: query(r, "source"),
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
}
|
||||
entries, total, err := s.app.AuditLogs(r.Context(), f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
writeJSON(w, http.StatusOK, listBody{
|
||||
Items: nonNil(entries), Total: total, Limit: limit, Offset: offset,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- Operations ---------------------------------------------------------
|
||||
|
||||
func (s *Server) listBackups(w http.ResponseWriter, r *http.Request) error {
|
||||
backups, err := s.app.BackupList()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"status": s.app.BackupStatus(),
|
||||
"backups": nonNil(backups),
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) createBackup(w http.ResponseWriter, r *http.Request) error {
|
||||
info, err := s.app.RunBackup(r.Context(), s.actor(r))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, info)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) exportConfig(w http.ResponseWriter, r *http.Request) error {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
return s.app.WriteConfigExport(r.Context(), w, queryBool(r, "include_domains"))
|
||||
}
|
||||
|
||||
func (s *Server) importConfig(w http.ResponseWriter, r *http.Request) error {
|
||||
report, err := s.app.ImportConfig(r.Context(), s.actor(r), r.Body, queryBool(r, "apply_settings"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
writeJSON(w, http.StatusOK, report)
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- Tools --------------------------------------------------------------
|
||||
|
||||
// reverseZone previews the zone apex a subnet maps to. The zone creation form
|
||||
// calls this as the operator types.
|
||||
func (s *Server) reverseZone(w http.ResponseWriter, r *http.Request) error {
|
||||
cidr := query(r, "cidr")
|
||||
if cidr == "" {
|
||||
return app.Invalid("The cidr query parameter is required.")
|
||||
}
|
||||
name, note, err := s.app.ReverseZoneName(cidr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
kind, _ := validate.ReverseZoneKindForCIDR(cidr)
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"cidr": cidr,
|
||||
"zone": strings.TrimSuffix(name, "."),
|
||||
"fqdn": name,
|
||||
"kind": kind,
|
||||
"note": note,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) lookup(w http.ResponseWriter, r *http.Request) error {
|
||||
name := query(r, "name")
|
||||
if name == "" {
|
||||
return app.Invalid("The name query parameter is required.")
|
||||
}
|
||||
result, err := s.app.Lookup(r.Context(), name, query(r, "type"), query(r, "client"), queryBool(r, "dnssec"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
writeJSON(w, http.StatusOK, result)
|
||||
return nil
|
||||
}
|
||||
|
||||
// domainCheck reports which policy lists cover a name.
|
||||
func (s *Server) domainCheck(w http.ResponseWriter, r *http.Request) error {
|
||||
name := query(r, "domain")
|
||||
if name == "" {
|
||||
return app.Invalid("The domain query parameter is required.")
|
||||
}
|
||||
hits, err := s.app.LookupDomain(r.Context(), name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"domain": name,
|
||||
"matches": nonNil(hits),
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// nonNil turns a nil slice into an empty one so JSON responses always carry
|
||||
// [] rather than null, which is what most clients expect from a collection.
|
||||
func nonNil[T any](v []T) []T {
|
||||
if v == nil {
|
||||
return []T{}
|
||||
}
|
||||
return v
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/owen/vibedns/internal/app"
|
||||
)
|
||||
|
||||
// routes registers every REST endpoint.
|
||||
//
|
||||
// Paths follow the usual collection/item shape, and PUT and PATCH are both
|
||||
// accepted for updates: the service layer carries forward any field a caller
|
||||
// omits, so a partial update behaves the way PATCH callers expect.
|
||||
func (s *Server) routes(mux *http.ServeMux) {
|
||||
const v1 = "/api/v1"
|
||||
|
||||
// Zones.
|
||||
mux.HandleFunc("GET "+v1+"/zones", s.h(s.listZones))
|
||||
mux.HandleFunc("POST "+v1+"/zones", s.h(s.createZone))
|
||||
mux.HandleFunc("GET "+v1+"/zones/{id}", s.h(s.getZone))
|
||||
mux.HandleFunc("PUT "+v1+"/zones/{id}", s.h(s.updateZone))
|
||||
mux.HandleFunc("PATCH "+v1+"/zones/{id}", s.h(s.updateZone))
|
||||
mux.HandleFunc("DELETE "+v1+"/zones/{id}", s.h(s.deleteZone))
|
||||
mux.HandleFunc("POST "+v1+"/zones/{id}/clone", s.h(s.cloneZone))
|
||||
mux.HandleFunc("GET "+v1+"/zones/{id}/export", s.h(s.exportZone))
|
||||
mux.HandleFunc("POST "+v1+"/zones/{id}/import", s.h(s.importZone))
|
||||
|
||||
// Records, both nested under a zone and flat.
|
||||
mux.HandleFunc("GET "+v1+"/zones/{id}/records", s.h(s.listZoneRecords))
|
||||
mux.HandleFunc("POST "+v1+"/zones/{id}/records", s.h(s.createRecord))
|
||||
mux.HandleFunc("GET "+v1+"/records", s.h(s.listRecords))
|
||||
mux.HandleFunc("GET "+v1+"/records/{id}", s.h(s.getRecord))
|
||||
mux.HandleFunc("PUT "+v1+"/records/{id}", s.h(s.updateRecord))
|
||||
mux.HandleFunc("PATCH "+v1+"/records/{id}", s.h(s.updateRecord))
|
||||
mux.HandleFunc("DELETE "+v1+"/records/{id}", s.h(s.deleteRecord))
|
||||
mux.HandleFunc("GET "+v1+"/record-types", s.h(s.listRecordTypes))
|
||||
|
||||
// Client networks.
|
||||
mux.HandleFunc("GET "+v1+"/networks", s.h(s.listNetworks))
|
||||
mux.HandleFunc("POST "+v1+"/networks", s.h(s.createNetwork))
|
||||
mux.HandleFunc("GET "+v1+"/networks/{id}", s.h(s.getNetwork))
|
||||
mux.HandleFunc("PUT "+v1+"/networks/{id}", s.h(s.updateNetwork))
|
||||
mux.HandleFunc("PATCH "+v1+"/networks/{id}", s.h(s.updateNetwork))
|
||||
mux.HandleFunc("DELETE "+v1+"/networks/{id}", s.h(s.deleteNetwork))
|
||||
|
||||
// Policies.
|
||||
mux.HandleFunc("GET "+v1+"/policies", s.h(s.listPolicies))
|
||||
mux.HandleFunc("POST "+v1+"/policies", s.h(s.createPolicy))
|
||||
mux.HandleFunc("GET "+v1+"/policies/{id}", s.h(s.getPolicy))
|
||||
mux.HandleFunc("PUT "+v1+"/policies/{id}", s.h(s.updatePolicy))
|
||||
mux.HandleFunc("PATCH "+v1+"/policies/{id}", s.h(s.updatePolicy))
|
||||
mux.HandleFunc("DELETE "+v1+"/policies/{id}", s.h(s.deletePolicy))
|
||||
|
||||
// Blacklists and allowlists share one implementation, differing only in
|
||||
// the kind they filter and create.
|
||||
for _, kind := range []string{"blacklists", "allowlists"} {
|
||||
k := kind
|
||||
mux.HandleFunc("GET "+v1+"/"+k, s.h(s.listListsFor(k)))
|
||||
mux.HandleFunc("POST "+v1+"/"+k, s.h(s.createListFor(k)))
|
||||
mux.HandleFunc("GET "+v1+"/"+k+"/{id}", s.h(s.getList))
|
||||
mux.HandleFunc("PUT "+v1+"/"+k+"/{id}", s.h(s.updateList))
|
||||
mux.HandleFunc("PATCH "+v1+"/"+k+"/{id}", s.h(s.updateList))
|
||||
mux.HandleFunc("DELETE "+v1+"/"+k+"/{id}", s.h(s.deleteList))
|
||||
mux.HandleFunc("GET "+v1+"/"+k+"/{id}/domains", s.h(s.listDomains))
|
||||
mux.HandleFunc("POST "+v1+"/"+k+"/{id}/domains", s.h(s.addDomain))
|
||||
mux.HandleFunc("DELETE "+v1+"/"+k+"/{id}/domains", s.h(s.clearDomains))
|
||||
mux.HandleFunc("POST "+v1+"/"+k+"/{id}/import", s.h(s.importDomains))
|
||||
mux.HandleFunc("GET "+v1+"/"+k+"/{id}/export", s.h(s.exportDomains))
|
||||
}
|
||||
mux.HandleFunc("DELETE "+v1+"/domains/{id}", s.h(s.deleteDomain))
|
||||
|
||||
// Cache.
|
||||
mux.HandleFunc("GET "+v1+"/cache", s.h(s.getCache))
|
||||
mux.HandleFunc("DELETE "+v1+"/cache", s.h(s.flushCache))
|
||||
mux.HandleFunc("GET "+v1+"/cache/entries", s.h(s.listCacheEntries))
|
||||
mux.HandleFunc("DELETE "+v1+"/cache/entries", s.h(s.deleteCacheEntry))
|
||||
|
||||
// Settings.
|
||||
mux.HandleFunc("GET "+v1+"/settings", s.h(s.getSettings))
|
||||
mux.HandleFunc("PUT "+v1+"/settings", s.h(s.updateSettings))
|
||||
mux.HandleFunc("PATCH "+v1+"/settings", s.h(s.updateSettings))
|
||||
|
||||
// Statistics and logs.
|
||||
mux.HandleFunc("GET "+v1+"/stats", s.h(s.getStats))
|
||||
mux.HandleFunc("GET "+v1+"/querylog", s.h(s.listQueryLog))
|
||||
mux.HandleFunc("DELETE "+v1+"/querylog", s.h(s.clearQueryLog))
|
||||
mux.HandleFunc("GET "+v1+"/auditlog", s.h(s.listAuditLog))
|
||||
|
||||
// Operations.
|
||||
mux.HandleFunc("GET "+v1+"/backups", s.h(s.listBackups))
|
||||
mux.HandleFunc("POST "+v1+"/backups", s.h(s.createBackup))
|
||||
mux.HandleFunc("GET "+v1+"/config/export", s.h(s.exportConfig))
|
||||
mux.HandleFunc("POST "+v1+"/config/import", s.h(s.importConfig))
|
||||
|
||||
// Tools.
|
||||
mux.HandleFunc("GET "+v1+"/tools/reverse-zone", s.h(s.reverseZone))
|
||||
mux.HandleFunc("GET "+v1+"/tools/lookup", s.h(s.lookup))
|
||||
mux.HandleFunc("GET "+v1+"/tools/domain-check", s.h(s.domainCheck))
|
||||
|
||||
// Service metadata. Both the bare path and the trailing-slash form serve
|
||||
// the index, because ServeMux redirects the former to the latter.
|
||||
mux.HandleFunc("GET "+v1, s.h(s.index))
|
||||
mux.HandleFunc("GET "+v1+"/{$}", s.h(s.index))
|
||||
mux.HandleFunc("GET "+v1+"/", s.h(s.notFound))
|
||||
}
|
||||
|
||||
// index describes the API surface, so a caller can discover it with one GET.
|
||||
func (s *Server) index(w http.ResponseWriter, r *http.Request) error {
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"version": "v1",
|
||||
"resources": []string{
|
||||
"/api/v1/zones", "/api/v1/records", "/api/v1/networks",
|
||||
"/api/v1/policies", "/api/v1/blacklists", "/api/v1/allowlists",
|
||||
"/api/v1/cache", "/api/v1/settings", "/api/v1/stats",
|
||||
"/api/v1/querylog", "/api/v1/auditlog", "/api/v1/backups",
|
||||
"/api/v1/config/export", "/api/v1/config/import",
|
||||
"/api/v1/tools/lookup", "/api/v1/tools/reverse-zone",
|
||||
},
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) notFound(w http.ResponseWriter, r *http.Request) error {
|
||||
return app.NotFound("No API endpoint matches %s %s.", r.Method, r.URL.Path)
|
||||
}
|
||||
Reference in New Issue
Block a user