252 lines
8.1 KiB
Go
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 ""
|
|
}
|