Files
fancy-dns/internal/api/zones.go
T
mats 66f36cfac6
build / build-push (push) Successful in 1m11s
more stuff
2026-10-16 14:49:50 +02:00

252 lines
8.1 KiB
Go

package api
import (
"net/http"
"strings"
"time"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/localzone"
)
// The local-zone payloads are aliases rather than copies: the record, zone
// and delegation shapes are defined once in internal/localzone, where the
// writer produces them and the store's own snapshot test pins their fields.
// A copy here would be a second set of json tags to keep in step with the
// console, which is exactly the drift the contract tests in internal/app
// exist to catch.
type (
// ZoneRecord is one authoritative record.
ZoneRecord = localzone.Record
// Zone is one compiled local zone.
Zone = localzone.Zone
// ZoneDelegation is a cut below an apex.
ZoneDelegation = localzone.Delegation
)
// ZonesResponse is GET /api/zones.
//
// Every key is always present, and Types is the set of record types the write
// endpoints accept, so the console builds its type picker from the server
// instead of keeping its own list.
type ZonesResponse struct {
// Zones are the compiled local zones, sorted by apex.
Zones []localzone.Zone `json:"zones"`
// Files are every master file the zones were compiled from.
Files []string `json:"files"`
// ManagedDir says where a zone edit is stored, for the console to show:
// the console fills it with the Kubernetes namespace its DNSZone objects
// live in. The resolver itself leaves it empty, because it cannot write.
ManagedDir string `json:"managed_dir"`
// Writable is whether a write to ManagedDir would succeed.
Writable bool `json:"writable"`
// Types are the record types the write endpoints accept.
Types []string `json:"types"`
// Records is the total number of records across every zone.
Records int `json:"records"`
// BuiltAt is when the zone store was compiled.
BuiltAt time.Time `json:"built_at"`
// Error is the reason the store is stale, "" when it is current. It is
// here so a broken master file is visible in the console rather than
// only in the log.
Error string `json:"error"`
}
// ZoneRecordInput adds or replaces one record. A TTL of 0 means the zone's
// default, which is what a record written without a TTL column inherits.
type ZoneRecordInput struct {
Zone string `json:"zone"`
Name string `json:"name"`
Type string `json:"type"`
TTL uint32 `json:"ttl"`
Value string `json:"value"`
}
// ZoneDelegationInput hands a name below an apex, and everything under it, to
// other servers.
type ZoneDelegationInput struct {
Zone string `json:"zone"`
Name string `json:"name"`
Targets []string `json:"targets"`
TTL uint32 `json:"ttl"`
}
// ZoneWriteResponse is what a successful record or delegation change answers.
type ZoneWriteResponse struct {
OK bool `json:"ok"`
// Zone is the canonical apex the change landed in.
Zone string `json:"zone"`
// Serial is the zone's SOA serial after the write. It moves on every
// change, so it is the cheapest way for the console to tell that what it
// is looking at has been rewritten.
Serial uint32 `json:"serial"`
// Removed is set on a delete that matched something.
Removed bool `json:"removed"`
}
// handleZones returns the compiled local zones.
func (s *Server) handleZones(w http.ResponseWriter, _ *http.Request) {
if s.hooks.ListZones == nil {
writeError(w, http.StatusNotImplemented, "local zones not available")
return
}
v, err := s.hooks.ListZones()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, v)
}
// handleZoneDelete removes a zone the console owns.
func (s *Server) handleZoneDelete(w http.ResponseWriter, r *http.Request) {
if s.hooks.DeleteZone == nil {
writeError(w, http.StatusNotImplemented, "local zones not available")
return
}
zone := r.URL.Query().Get("zone")
if zone == "" {
writeError(w, http.StatusBadRequest, "zone is required")
return
}
if err := s.hooks.DeleteZone(r.Context(), zone); err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "zone": zone})
}
// handleZoneRecordAdd adds one record to a zone.
func (s *Server) handleZoneRecordAdd(w http.ResponseWriter, r *http.Request) {
if s.hooks.AddZoneRecord == nil {
writeError(w, http.StatusNotImplemented, "local zones not available")
return
}
var in ZoneRecordInput
if err := decodeJSON(r, &in); err != nil {
writeError(w, http.StatusBadRequest, "malformed request body")
return
}
if msg := validateZoneRecord(in); msg != "" {
writeError(w, http.StatusBadRequest, msg)
return
}
v, err := s.hooks.AddZoneRecord(r.Context(), in)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, v)
}
// handleZoneRecordPut replaces the RRset of one name and type.
func (s *Server) handleZoneRecordPut(w http.ResponseWriter, r *http.Request) {
if s.hooks.PutZoneRecord == nil {
writeError(w, http.StatusNotImplemented, "local zones not available")
return
}
var in ZoneRecordInput
if err := decodeJSON(r, &in); err != nil {
writeError(w, http.StatusBadRequest, "malformed request body")
return
}
if msg := validateZoneRecord(in); msg != "" {
writeError(w, http.StatusBadRequest, msg)
return
}
v, err := s.hooks.PutZoneRecord(r.Context(), in)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, v)
}
// handleZoneRecordDelete removes a record, or its whole RRset when no value is
// named.
func (s *Server) handleZoneRecordDelete(w http.ResponseWriter, r *http.Request) {
if s.hooks.DeleteZoneRecord == nil {
writeError(w, http.StatusNotImplemented, "local zones not available")
return
}
zone := r.URL.Query().Get("zone")
name := r.URL.Query().Get("name")
rrtype := r.URL.Query().Get("type")
if zone == "" || name == "" || rrtype == "" {
writeError(w, http.StatusBadRequest, "zone, name and type are required")
return
}
if err := s.hooks.DeleteZoneRecord(r.Context(), zone, name, rrtype, r.URL.Query().Get("value")); err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "zone": zone, "name": name, "type": rrtype})
}
// handleZoneDelegationAdd delegates a name to other servers.
func (s *Server) handleZoneDelegationAdd(w http.ResponseWriter, r *http.Request) {
if s.hooks.AddZoneDelegation == nil {
writeError(w, http.StatusNotImplemented, "local zones not available")
return
}
var in ZoneDelegationInput
if err := decodeJSON(r, &in); err != nil {
writeError(w, http.StatusBadRequest, "malformed request body")
return
}
if strings.TrimSpace(in.Zone) == "" {
writeError(w, http.StatusBadRequest, "zone is required")
return
}
if strings.TrimSpace(in.Name) == "" {
writeError(w, http.StatusBadRequest, "name is required")
return
}
if len(in.Targets) == 0 {
writeError(w, http.StatusBadRequest, "a delegation needs at least one NS target")
return
}
v, err := s.hooks.AddZoneDelegation(r.Context(), in)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, v)
}
// handleZoneDelegationDelete removes a cut.
func (s *Server) handleZoneDelegationDelete(w http.ResponseWriter, r *http.Request) {
if s.hooks.DeleteZoneDelegation == nil {
writeError(w, http.StatusNotImplemented, "local zones not available")
return
}
zone := r.URL.Query().Get("zone")
name := r.URL.Query().Get("name")
if zone == "" || name == "" {
writeError(w, http.StatusBadRequest, "zone and name are required")
return
}
if err := s.hooks.DeleteZoneDelegation(r.Context(), zone, name); err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "zone": zone, "name": name})
}
// validateZoneRecord rejects an obviously incomplete record here so the
// console gets a message about the field rather than one about the zone file.
func validateZoneRecord(in ZoneRecordInput) string {
if strings.TrimSpace(in.Zone) == "" {
return "zone is required"
}
if strings.TrimSpace(in.Name) == "" {
return "name is required"
}
if strings.TrimSpace(in.Type) == "" {
return "type is required"
}
if strings.TrimSpace(in.Value) == "" {
return "value is required"
}
return ""
}