Files
fancy-dns/internal/api/zones.go
T
matsandClaude Sonnet 5.5 467733a7f9 Remove the ConfigMap, RPZ and managed-directory editors the cluster objects replace
Zones, address redirects, inline rules, lists and groups are cluster objects now
(see the previous commits and deploy/MIGRATION.md), so the mechanisms that held
them are gone:

* internal/kubezones and the resolver's service-account access to a ConfigMap,
  the address-overrides read/write path and its poller, the zone-file poller and
  the Local zones editor with its /api/local-zones routes.
* localzone.Managed, the directory of console-written master files, and the
  blocklist.managed_zones key. The resolver now writes no zone data at all; its
  zone hooks only list. The shared half of that file (the record validator, the
  renderer and the round-trip check) stays as zonestate.go, because DNSZone
  rendering is built on it. Its tests are ported to RenderZone, which keeps the
  coverage of every editable record type, wildcards, relative owners, the
  SOA/NS rule and the delegation rule.
* Tests of the removed write paths: the contract test now builds its zone from a
  DNSZone, and a new test pins that every zone write route on the resolver is
  refused and changes nothing.

Standalone installs keep editing master files on disk and SIGHUP, and the
YAML-backed domain, client, upstream and settings editors, which have not moved.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 10:17:17 +02:00

251 lines
8.0 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 is the directory the API may rewrite, "" when the console
// has no managed zone directory configured (blocklist.managed_zones).
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 ""
}