// SPDX-License-Identifier: GPL-2.0-or-later // Copyright (C) 2026 Bryan Joshua Pedini package main import ( "encoding/json" "net/http" "sort" "strconv" "strings" "github.com/gorilla/mux" ) // pdnsZone is the zone-list entry shape. type pdnsZone struct { ID string `json:"id"` Name string `json:"name"` URL string `json:"url"` Kind string `json:"kind"` } // pdnsRecord is one record within an rrset. Field order matches PowerDNS. type pdnsRecord struct { Content string `json:"content"` Disabled bool `json:"disabled"` } // pdnsRRset is one rrset. Field order MUST be name, type, ttl, records — // acme.sh regex-scrapes the zone-detail JSON expecting "name" before // "records". type pdnsRRset struct { Name string `json:"name"` Type string `json:"type"` TTL int `json:"ttl"` Records []pdnsRecord `json:"records"` } // pdnsZoneDetail is the zone-detail response shape. type pdnsZoneDetail struct { ID string `json:"id"` Name string `json:"name"` Kind string `json:"kind"` URL string `json:"url"` RRsets []pdnsRRset `json:"rrsets"` } // checkServerID returns false and writes a 404 if server_id doesn't match // the configured PDNS_SERVER_ID. Cross-cutting per SEMANTICS §5. func (s *WebServer) checkServerID(w http.ResponseWriter, r *http.Request) bool { if mux.Vars(r)["server_id"] != s.Config.PDNSServerID { writeJSONError(w, http.StatusNotFound, "Not Found") return false } return true } // canonicalZone lowercases and ensures a trailing dot, per SEMANTICS §4. func canonicalZone(zoneID string) string { zone := strings.ToLower(zoneID) if !strings.HasSuffix(zone, ".") { zone += "." } return zone } // handleListZones implements GET /api/v1/servers/{server_id}/zones. func (s *WebServer) handleListZones(w http.ResponseWriter, r *http.Request) { if !s.checkServerID(w, r) { return } zones, err := s.Store.ListZones(r.Context()) if err != nil { s.Logger.Error("list zones failed", "error", err) writeJSONError(w, http.StatusInternalServerError, "internal error") return } serverID := mux.Vars(r)["server_id"] out := make([]pdnsZone, 0, len(zones)) for _, zone := range zones { out = append(out, pdnsZone{ ID: zone, Name: zone, URL: "/api/v1/servers/" + serverID + "/zones/" + zone, Kind: "Native", }) } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(out) } // soaContent is the type-specific content shape for SOA rows. type soaContent struct { TTL int `json:"ttl"` Mbox string `json:"mbox"` NS string `json:"ns"` Refresh int `json:"refresh"` Retry int `json:"retry"` Expire int `json:"expire"` } // rrsetContent renders the pdns rrset "content" string for one row per // SEMANTICS §4's mapping table. Unparsable/unknown content is emitted raw, // never dropped. func rrsetContent(recordType, content string) string { switch recordType { case "TXT": var c struct { Text string `json:"text"` } if err := json.Unmarshal([]byte(content), &c); err != nil { return content } return `"` + c.Text + `"` case "A", "AAAA": var c struct { IP string `json:"ip"` } if err := json.Unmarshal([]byte(content), &c); err != nil { return content } return c.IP case "CNAME", "NS": var c struct { Host string `json:"host"` } if err := json.Unmarshal([]byte(content), &c); err != nil { return content } return c.Host case "SOA": var c soaContent if err := json.Unmarshal([]byte(content), &c); err != nil { return content } ttl := c.TTL if ttl == 0 { ttl = 300 } return c.NS + " " + c.Mbox + " 0 " + strconv.Itoa(c.Refresh) + " " + strconv.Itoa(c.Retry) + " " + strconv.Itoa(c.Expire) + " " + strconv.Itoa(ttl) default: return content } } // rrsetKey groups rows by (name, record_type). type rrsetKey struct { name string recordType string } // handleZoneDetail implements GET /api/v1/servers/{server_id}/zones/{zone_id}. func (s *WebServer) handleZoneDetail(w http.ResponseWriter, r *http.Request) { if !s.checkServerID(w, r) { return } vars := mux.Vars(r) zone := canonicalZone(vars["zone_id"]) records, err := s.Store.GetZoneRecords(r.Context(), zone) if err != nil { s.Logger.Error("get zone records failed", "error", err) writeJSONError(w, http.StatusInternalServerError, "internal error") return } if len(records) == 0 { writeJSONError(w, http.StatusNotFound, "Not Found") return } groups := make(map[rrsetKey][]Record) var order []rrsetKey for _, rec := range records { key := rrsetKey{name: rec.Name, recordType: rec.RecordType} if _, ok := groups[key]; !ok { order = append(order, key) } groups[key] = append(groups[key], rec) } sort.Slice(order, func(i, j int) bool { if order[i].name != order[j].name { return order[i].name < order[j].name } return order[i].recordType < order[j].recordType }) rrsets := make([]pdnsRRset, 0, len(order)) for _, key := range order { rows := groups[key] fqdn := zone if key.name != "" { fqdn = key.name + "." + zone } ttl := 300 for _, rec := range rows { if rec.TTL.Valid { ttl = int(rec.TTL.Int64) break } } recs := make([]pdnsRecord, 0, len(rows)) for _, rec := range rows { recs = append(recs, pdnsRecord{ Content: rrsetContent(rec.RecordType, rec.Content), Disabled: false, }) } rrsets = append(rrsets, pdnsRRset{ Name: fqdn, Type: key.recordType, TTL: ttl, Records: recs, }) } serverID := vars["server_id"] detail := pdnsZoneDetail{ ID: zone, Name: zone, Kind: "Native", URL: "/api/v1/servers/" + serverID + "/zones/" + zone, RRsets: rrsets, } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(detail) } // patchRecord is one record entry within a PATCH rrset. type patchRecord struct { Content string `json:"content"` Disabled bool `json:"disabled"` } // patchRRset is one rrset entry within a PATCH request body. type patchRRset struct { Name string `json:"name"` Type string `json:"type"` TTL int `json:"ttl"` ChangeType string `json:"changetype"` Records []patchRecord `json:"records"` } // patchRequest is the PATCH zone body. type patchRequest struct { RRsets []patchRRset `json:"rrsets"` } // unq strips exactly one leading and one trailing double quote, if present. func unq(c string) string { if len(c) >= 1 && strings.HasPrefix(c, `"`) { c = c[1:] } if len(c) >= 1 && strings.HasSuffix(c, `"`) { c = c[:len(c)-1] } return c } // recordContent marshals the type-specific content JSON for storage, per // SEMANTICS §4. supportedTypes lists what this handler accepts. func recordContent(recordType, apiContent string) (string, bool) { switch recordType { case "TXT": b, err := json.Marshal(map[string]string{"text": unq(apiContent)}) if err != nil { return "", false } return string(b), true case "A", "AAAA": b, err := json.Marshal(map[string]string{"ip": apiContent}) if err != nil { return "", false } return string(b), true case "CNAME", "NS": b, err := json.Marshal(map[string]string{"host": apiContent}) if err != nil { return "", false } return string(b), true default: return "", false } } // handleZonePatch implements PATCH /api/v1/servers/{server_id}/zones/{zone_id}. func (s *WebServer) handleZonePatch(w http.ResponseWriter, r *http.Request) { if !s.checkServerID(w, r) { return } vars := mux.Vars(r) zone := canonicalZone(vars["zone_id"]) exists, err := s.Store.ZoneExists(r.Context(), zone) if err != nil { s.Logger.Error("zone exists check failed", "error", err) writeJSONError(w, http.StatusInternalServerError, "internal error") return } if !exists { writeJSONError(w, http.StatusNotFound, "Not Found") return } var body patchRequest if err := json.NewDecoder(r.Body).Decode(&body); err != nil { writeJSONError(w, http.StatusUnprocessableEntity, "malformed JSON body") return } changes := make([]RRsetChange, 0, len(body.RRsets)) for _, rrset := range body.RRsets { if rrset.ChangeType != "REPLACE" && rrset.ChangeType != "DELETE" { writeJSONError(w, http.StatusUnprocessableEntity, "unknown or missing changetype") return } if rrset.Name == "" || rrset.Type == "" { writeJSONError(w, http.StatusUnprocessableEntity, "missing name or type") return } name := strings.ToLower(rrset.Name) if !strings.HasSuffix(name, ".") { name += "." } if name != zone && !strings.HasSuffix(name, "."+zone) { writeJSONError(w, http.StatusUnprocessableEntity, "name is not part of zone") return } rel := strings.TrimSuffix(name, zone) rel = strings.TrimSuffix(rel, ".") change := RRsetChange{ Zone: zone, Name: rel, RecordType: rrset.Type, ChangeType: rrset.ChangeType, } if rrset.ChangeType == "REPLACE" { if len(rrset.Records) == 0 { writeJSONError(w, http.StatusUnprocessableEntity, "REPLACE requires at least one record") return } ttl := rrset.TTL if ttl <= 0 { ttl = s.Config.DefaultTTL } change.TTL = ttl contents := make([]string, 0, len(rrset.Records)) for _, rec := range rrset.Records { content, ok := recordContent(rrset.Type, rec.Content) if !ok { writeJSONError(w, http.StatusUnprocessableEntity, "unsupported record type") return } contents = append(contents, content) } change.Contents = contents } changes = append(changes, change) } if err := s.Store.ApplyRRsets(r.Context(), changes); err != nil { s.Logger.Error("apply rrsets failed", "error", err) writeJSONError(w, http.StatusInternalServerError, "internal error") return } w.WriteHeader(http.StatusNoContent) } // handleZoneNotify implements PUT /api/v1/servers/{server_id}/zones/{zone_id}/notify. func (s *WebServer) handleZoneNotify(w http.ResponseWriter, r *http.Request) { if !s.checkServerID(w, r) { return } vars := mux.Vars(r) zone := canonicalZone(vars["zone_id"]) exists, err := s.Store.ZoneExists(r.Context(), zone) if err != nil { s.Logger.Error("zone exists check failed", "error", err) writeJSONError(w, http.StatusInternalServerError, "internal error") return } if !exists { writeJSONError(w, http.StatusNotFound, "Not Found") return } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(map[string]string{"result": "Notification queued"}) }