You've already forked coredns-powerdns-api-wrapper
first commit
This commit is contained in:
411
pdns.go
Normal file
411
pdns.go
Normal file
@@ -0,0 +1,411 @@
|
||||
// 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"})
|
||||
}
|
||||
Reference in New Issue
Block a user