client.go raw
1 package internal
2
3 import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "errors"
8 "fmt"
9 "io"
10 "net/http"
11 "net/url"
12 "path"
13 "strconv"
14 "strings"
15 "time"
16
17 "github.com/go-acme/lego/v4/providers/dns/internal/errutils"
18 "github.com/miekg/dns"
19 )
20
21 // APIKeyHeader API key header.
22 const APIKeyHeader = "X-Api-Key"
23
24 // Client the PowerDNS API client.
25 type Client struct {
26 serverName string
27 apiKey string
28
29 apiVersion int
30
31 Host *url.URL
32 HTTPClient *http.Client
33 }
34
35 // NewClient creates a new Client.
36 func NewClient(host *url.URL, serverName string, apiVersion int, apiKey string) *Client {
37 return &Client{
38 serverName: serverName,
39 apiKey: apiKey,
40 apiVersion: apiVersion,
41 Host: host,
42 HTTPClient: &http.Client{Timeout: 5 * time.Second},
43 }
44 }
45
46 func (c *Client) APIVersion() int {
47 return c.apiVersion
48 }
49
50 func (c *Client) SetAPIVersion(ctx context.Context) error {
51 var err error
52
53 c.apiVersion, err = c.getAPIVersion(ctx)
54
55 return err
56 }
57
58 func (c *Client) getAPIVersion(ctx context.Context) (int, error) {
59 endpoint := c.joinPath("/", "api")
60
61 req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
62 if err != nil {
63 return 0, err
64 }
65
66 result, err := c.do(req)
67 if err != nil {
68 return 0, err
69 }
70
71 var versions []apiVersion
72
73 err = json.Unmarshal(result, &versions)
74 if err != nil {
75 return 0, err
76 }
77
78 latestVersion := 0
79 for _, v := range versions {
80 if v.Version > latestVersion {
81 latestVersion = v.Version
82 }
83 }
84
85 return latestVersion, err
86 }
87
88 func (c *Client) GetHostedZone(ctx context.Context, authZone string) (*HostedZone, error) {
89 endpoint := c.joinPath("/", "servers", c.serverName, "zones", dns.Fqdn(authZone))
90
91 req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
92 if err != nil {
93 return nil, err
94 }
95
96 result, err := c.do(req)
97 if err != nil {
98 return nil, err
99 }
100
101 var zone HostedZone
102
103 err = json.Unmarshal(result, &zone)
104 if err != nil {
105 return nil, err
106 }
107
108 // convert pre-v1 API result
109 if len(zone.Records) > 0 {
110 zone.RRSets = []RRSet{}
111 for _, record := range zone.Records {
112 set := RRSet{
113 Name: record.Name,
114 Type: record.Type,
115 Records: []Record{record},
116 }
117 zone.RRSets = append(zone.RRSets, set)
118 }
119 }
120
121 return &zone, nil
122 }
123
124 func (c *Client) UpdateRecords(ctx context.Context, zone *HostedZone, sets RRSets) error {
125 endpoint := c.joinPath("/", "servers", c.serverName, "zones", zone.ID)
126
127 req, err := newJSONRequest(ctx, http.MethodPatch, endpoint, sets)
128 if err != nil {
129 return err
130 }
131
132 _, err = c.do(req)
133 if err != nil {
134 return err
135 }
136
137 return nil
138 }
139
140 func (c *Client) Notify(ctx context.Context, zone *HostedZone) error {
141 if c.apiVersion < 1 || zone.Kind != "Master" && zone.Kind != "Slave" {
142 return nil
143 }
144
145 endpoint := c.joinPath("/", "servers", c.serverName, "zones", zone.ID, "notify")
146
147 req, err := newJSONRequest(ctx, http.MethodPut, endpoint, nil)
148 if err != nil {
149 return err
150 }
151
152 _, err = c.do(req)
153 if err != nil {
154 return err
155 }
156
157 return nil
158 }
159
160 func (c *Client) joinPath(elem ...string) *url.URL {
161 p := path.Join(elem...)
162
163 if p != "/api" && c.apiVersion > 0 && !strings.HasPrefix(p, "/api/v") {
164 p = path.Join("/api", "v"+strconv.Itoa(c.apiVersion), p)
165 }
166
167 return c.Host.JoinPath(p)
168 }
169
170 func (c *Client) do(req *http.Request) (json.RawMessage, error) {
171 req.Header.Set(APIKeyHeader, c.apiKey)
172
173 resp, err := c.HTTPClient.Do(req)
174 if err != nil {
175 return nil, errutils.NewHTTPDoError(req, err)
176 }
177
178 defer func() { _ = resp.Body.Close() }()
179
180 if resp.StatusCode != http.StatusUnprocessableEntity && (resp.StatusCode < 200 || resp.StatusCode >= 300) {
181 return nil, errutils.NewUnexpectedResponseStatusCodeError(req, resp)
182 }
183
184 var msg json.RawMessage
185
186 err = json.NewDecoder(resp.Body).Decode(&msg)
187 if err != nil {
188 if errors.Is(err, io.EOF) {
189 // empty body
190 return nil, nil
191 }
192 // other error
193 return nil, err
194 }
195
196 // check for PowerDNS error message
197 if len(msg) > 0 && msg[0] == '{' {
198 var errInfo apiError
199
200 err = json.Unmarshal(msg, &errInfo)
201 if err != nil {
202 return nil, errutils.NewUnmarshalError(req, resp.StatusCode, msg, err)
203 }
204
205 if errInfo.ShortMsg != "" {
206 return nil, fmt.Errorf("error talking to PDNS API: %w", errInfo)
207 }
208 }
209
210 return msg, nil
211 }
212
213 func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) {
214 buf := new(bytes.Buffer)
215
216 if payload != nil {
217 err := json.NewEncoder(buf).Encode(payload)
218 if err != nil {
219 return nil, fmt.Errorf("failed to create request JSON body: %w", err)
220 }
221 }
222
223 req, err := http.NewRequestWithContext(ctx, method, strings.TrimSuffix(endpoint.String(), "/"), buf)
224 if err != nil {
225 return nil, fmt.Errorf("unable to create request: %w", err)
226 }
227
228 req.Header.Set("Accept", "application/json")
229
230 // PowerDNS doesn't follow HTTP convention about the "Content-Type" header.
231 if method != http.MethodGet && method != http.MethodDelete {
232 req.Header.Set("Content-Type", "application/json")
233 }
234
235 return req, nil
236 }
237