You've already forked coredns-powerdns-api-wrapper
412 lines
10 KiB
Go
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"})
|
|
}
|