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 "" }