Files
coredns-powerdns-api-wrapper/pdns.go
2026-07-10 23:53:43 +02:00

412 lines
10 KiB
Go

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