package pdns import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/url" "strconv" "strings" "time" ) const soaEditAPIIncrease = "INCREASE" type HTTPClient interface { Do(*http.Request) (*http.Response, error) } type Client struct { baseURL string apiKey string serverID string httpClient HTTPClient } type Server struct { ID string `json:"id"` DaemonType string `json:"daemon_type"` Version string `json:"version"` } type Zone struct { ID string `json:"id,omitempty"` Name string `json:"name,omitempty"` Type string `json:"type,omitempty"` Kind string `json:"kind,omitempty"` Serial uint64 `json:"serial,omitempty"` SOAEditAPI string `json:"soa_edit_api,omitempty"` Nameservers []string `json:"nameservers,omitempty"` Masters []string `json:"masters,omitempty"` RRSets []RRSet `json:"rrsets,omitempty"` } type RRSet struct { Name string `json:"name"` Type string `json:"type"` TTL uint32 `json:"ttl"` Records []Record `json:"records"` } type Record struct { Content string `json:"content"` Disabled bool `json:"disabled"` } type changeRRSet struct { Name string `json:"name"` Type string `json:"type"` TTL uint32 `json:"ttl,omitempty"` ChangeType string `json:"changetype"` Records []Record `json:"records,omitempty"` } func (z Zone) DisplayKind() string { if z.Kind != "" { return z.Kind } return z.Type } func NewClient(baseURL, apiKey, serverID string, httpClient HTTPClient) *Client { if httpClient == nil { httpClient = &http.Client{Timeout: 15 * time.Second} } return &Client{ baseURL: strings.TrimRight(baseURL, "/"), apiKey: apiKey, serverID: strings.Trim(serverID, "/"), httpClient: httpClient, } } func (c *Client) GetServer(ctx context.Context) (Server, error) { var server Server err := c.do(ctx, http.MethodGet, c.path(""), nil, &server) return server, err } func (c *Client) ListZones(ctx context.Context) ([]Zone, error) { var zones []Zone err := c.do(ctx, http.MethodGet, c.path("/zones"), nil, &zones) return zones, err } func (c *Client) CreateZone(ctx context.Context, zone Zone) (Zone, error) { zone.SOAEditAPI = soaEditAPIIncrease var created Zone if err := c.do(ctx, http.MethodPost, c.path("/zones"), zone, &created); err != nil { return Zone{}, err } zoneID := created.ID if zoneID == "" { zoneID = created.Name } if zoneID == "" { zoneID = zone.Name } if err := c.SetZoneSOAEditAPI(ctx, zoneID); err != nil { return Zone{}, fmt.Errorf("set SOA-EDIT-API for new zone %s: %w", zoneID, err) } return created, nil } func (c *Client) EnsureAllZonesSOAEditAPI(ctx context.Context) error { zones, err := c.ListZones(ctx) if err != nil { return err } for _, zone := range zones { zoneID := zone.ID if zoneID == "" { zoneID = zone.Name } if zoneID == "" { return fmt.Errorf("zone without id or name cannot be updated") } if err := c.SetZoneSOAEditAPI(ctx, zoneID); err != nil { return fmt.Errorf("set SOA-EDIT-API for %s: %w", zoneID, err) } } return nil } func (c *Client) SetZoneSOAEditAPI(ctx context.Context, zoneID string) error { body := Zone{SOAEditAPI: soaEditAPIIncrease} return c.do(ctx, http.MethodPut, c.path("/zones/"+url.PathEscape(zoneID)), body, nil) } func (c *Client) DeleteZone(ctx context.Context, zoneID string) error { return c.do(ctx, http.MethodDelete, c.path("/zones/"+url.PathEscape(zoneID)), nil, nil) } func (c *Client) GetZone(ctx context.Context, zoneID string) (Zone, error) { var zone Zone err := c.do(ctx, http.MethodGet, c.path("/zones/"+url.PathEscape(zoneID)), nil, &zone) return zone, err } func (c *Client) CreateRRSet(ctx context.Context, zoneID string, rrset RRSet) error { if allowsMultipleRecords(rrset.Type) { zone, err := c.GetZone(ctx, zoneID) if err != nil { return fmt.Errorf("read zone before merging records: %w", err) } if existing, ok := findRRSet(zone, rrset.Name, rrset.Type); ok { rrset.Records = mergeRecords(existing.Records, rrset.Records) } } return c.patchZone(ctx, zoneID, []changeRRSet{{ Name: rrset.Name, Type: rrset.Type, TTL: rrset.TTL, ChangeType: "REPLACE", Records: rrset.Records, }}) } func allowsMultipleRecords(recordType string) bool { switch strings.ToUpper(strings.TrimSpace(recordType)) { case "A", "AAAA", "CAA", "MX", "NS", "SRV", "TXT": return true default: return false } } func findRRSet(zone Zone, name, recordType string) (RRSet, bool) { recordType = strings.ToUpper(strings.TrimSpace(recordType)) for _, rrset := range zone.RRSets { if strings.EqualFold(rrset.Name, name) && strings.EqualFold(rrset.Type, recordType) { return rrset, true } } return RRSet{}, false } func mergeRecords(existing, added []Record) []Record { merged := make([]Record, 0, len(existing)+len(added)) seen := make(map[string]struct{}, len(existing)+len(added)) for _, record := range append(append([]Record{}, existing...), added...) { key := recordKey(record) if _, ok := seen[key]; ok { continue } seen[key] = struct{}{} merged = append(merged, record) } return merged } func recordKey(record Record) string { return strings.TrimSpace(record.Content) + "\x00" + strconv.FormatBool(record.Disabled) } func (c *Client) DeleteRRSet(ctx context.Context, zoneID, name, recordType string) error { return c.patchZone(ctx, zoneID, []changeRRSet{{ Name: name, Type: recordType, ChangeType: "DELETE", }}) } func (c *Client) patchZone(ctx context.Context, zoneID string, rrsets []changeRRSet) error { body := struct { RRSets []changeRRSet `json:"rrsets"` }{RRSets: rrsets} return c.do(ctx, http.MethodPatch, c.path("/zones/"+url.PathEscape(zoneID)), body, nil) } func (c *Client) path(suffix string) string { return "/api/v1/servers/" + url.PathEscape(c.serverID) + suffix } func (c *Client) do(ctx context.Context, method, apiPath string, in any, out any) error { var body io.Reader if in != nil { payload, err := json.Marshal(in) if err != nil { return fmt.Errorf("encode request: %w", err) } body = bytes.NewReader(payload) } req, err := http.NewRequestWithContext(ctx, method, c.baseURL+apiPath, body) if err != nil { return fmt.Errorf("create request: %w", err) } req.Header.Set("X-API-Key", c.apiKey) req.Header.Set("Accept", "application/json") if in != nil { req.Header.Set("Content-Type", "application/json") } resp, err := c.httpClient.Do(req) if err != nil { return fmt.Errorf("powerdns request failed: %w", err) } defer resp.Body.Close() responseBody, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) if err != nil { return fmt.Errorf("read powerdns response: %w", err) } if resp.StatusCode < 200 || resp.StatusCode >= 300 { message := strings.TrimSpace(string(responseBody)) if message == "" { message = resp.Status } return fmt.Errorf("powerdns returned %s: %s", resp.Status, message) } if out == nil || len(responseBody) == 0 { return nil } if err := json.Unmarshal(responseBody, out); err != nil { return fmt.Errorf("decode powerdns response: %w", err) } return nil }