Repository navigation
Expand file tree
/
Copy pathclient.go
More file actions
289 lines (262 loc) · 9.37 KB
/
Copy pathclient.go
File metadata and controls
289 lines (262 loc) · 9.37 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
package mittwald
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"sync"
"time"
"github.com/mittwald/api-client-go/mittwaldv2"
generatedv2 "github.com/mittwald/api-client-go/mittwaldv2/generated/clients"
"github.com/mittwald/api-client-go/mittwaldv2/generated/clients/domainclientv2"
"github.com/mittwald/api-client-go/mittwaldv2/generated/clients/projectclientv2"
"github.com/mittwald/api-client-go/mittwaldv2/generated/schemas/dnsv2"
"golang.org/x/net/idna"
)
// requestTimeout bounds one attempt of a request; waiting between attempts is
// bounded by the caller's context only.
const requestTimeout = 60 * time.Second
// projectPageSize is the number of projects requested per page.
const projectPageSize = 100
// defaultAPIURL is the API's address.
const defaultAPIURL = "https://api.mittwald.de/v2"
func newClient(token, apiURL string) (generatedv2.Client, error) {
return mittwaldv2.New(context.Background(),
mittwaldv2.WithHTTPClient(newAPIRunner(&http.Client{Timeout: requestTimeout})), // must come first
mittwaldv2.WithAccessToken(token),
mittwaldv2.WithBaseURL(apiURL),
)
}
// listProjectZones returns every DNS zone of a project, named in ASCII. The
// API names zones in Unicode.
func listProjectZones(ctx context.Context, c generatedv2.Client, projectID string) ([]dnsv2.Zone, error) {
zones, resp, err := c.Domain().ListDNSZones(ctx, domainclientv2.ListDNSZonesRequest{ProjectID: projectID})
closeBody(resp)
if err != nil {
return nil, fmt.Errorf("listing DNS zones of project %s: %w", projectID, err)
}
for i := range *zones {
z := &(*zones)[i]
if z.Domain, err = idna.ToASCII(z.Domain); err != nil {
return nil, fmt.Errorf("zone %s: %w", z.Id, err)
}
}
return *zones, nil
}
// listDomainProjects returns the project ID of every domain in the token's
// domain list, by the domain's name in ASCII.
func listDomainProjects(ctx context.Context, c generatedv2.Client) (map[string]string, error) {
domains, resp, err := c.Domain().ListDomains(ctx, domainclientv2.ListDomainsRequest{})
closeBody(resp)
if err != nil {
return nil, fmt.Errorf("listing domains: %w", err)
}
projects := map[string]string{}
for _, d := range *domains {
name, err := idna.ToASCII(d.Domain)
if err != nil || d.ProjectId == "" {
continue
}
projects[name] = d.ProjectId
}
return projects, nil
}
// listProjectIDs returns the IDs of all projects the token can access.
func listProjectIDs(ctx context.Context, c generatedv2.Client) ([]string, error) {
var ids []string
limit := int64(projectPageSize)
for skip := int64(0); ; skip += limit {
page, resp, err := c.Project().ListProjects(ctx, projectclientv2.ListProjectsRequest{Limit: &limit, Skip: &skip})
closeBody(resp)
if err != nil {
return nil, fmt.Errorf("listing projects: %w", err)
}
for _, p := range *page {
ids = append(ids, p.Id)
}
if int64(len(*page)) < limit {
return ids, nil
}
}
}
// createZone creates the zone for label (relative to the root zone) and returns its ID.
func createZone(ctx context.Context, c generatedv2.Client, rootID, label string) (string, error) {
created, resp, err := c.Domain().CreateDNSZone(ctx, domainclientv2.CreateDNSZoneRequest{
Body: domainclientv2.CreateDNSZoneRequestBody{Name: label, ParentZoneId: rootID},
})
closeBody(resp)
if err != nil {
return "", fmt.Errorf("creating zone %q: %w", label, err)
}
return created.Id, nil
}
func deleteZone(ctx context.Context, c generatedv2.Client, id string) error {
resp, err := c.Domain().DeleteDNSZone(ctx, domainclientv2.DeleteDNSZoneRequest{DNSZoneID: id})
closeBody(resp)
if err != nil {
return fmt.Errorf("deleting zone %s: %w", id, err)
}
return nil
}
// setRecordSet replaces one record set of a zone.
func setRecordSet(ctx context.Context, c generatedv2.Client, zoneID string, set domainclientv2.UpdateRecordSetRequestPathRecordSet, body domainclientv2.UpdateRecordSetRequestBody) error {
resp, err := c.Domain().UpdateRecordSet(ctx, domainclientv2.UpdateRecordSetRequest{DNSZoneID: zoneID, RecordSet: set, Body: body})
closeBody(resp)
if err != nil {
return fmt.Errorf("setting %s records of zone %s: %w", set, zoneID, err)
}
return nil
}
// closeBody releases the connection of a response; the client leaves that to the caller.
func closeBody(resp *http.Response) {
if resp != nil && resp.Body != nil {
_, _ = io.Copy(io.Discard, resp.Body)
_ = resp.Body.Close()
}
}
// The API allows a user a number of requests per window and reports what is
// left on every successful response (X-Ratelimit-Remaining, and
// X-Ratelimit-Reset in seconds). libdns expects retries not to add more than a
// couple of seconds, so once nothing is left a request waits for the reset
// only if it comes within maxResetWait, and fails otherwise.
//
// A request is repeated up to maxRetries times with a short, growing pause
// after a 429, and, if it cannot create anything twice (not a POST), after a
// 500, 502, 503 or 504.
//
// The API is eventually consistent: a write answers with the ID of its event
// (ETag), and a request that sends it as If-Event-Reached waits until the
// event is processed, or answers 412 of type FailedPrecondition without doing
// anything. (Other 412s, such as a CNAME on a name with records, are final.)
// Every request after a write waits for it this way, so that a read sees it
// and a write to a zone just created finds the zone, and is repeated after a
// 412. The permissions of a zone just created can still lag behind, so a
// request that is not a POST is also repeated after a 403 or 404, as
// mittwald's own client does.
//
// All waiting ends with the request's context.
const (
firstRetryPause = 500 * time.Millisecond
maxRetries = 4
maxResetWait = 5 * time.Second
)
// errRateLimited is returned when the rate limit resets too late to wait for it.
var errRateLimited = errors.New("mittwald: API rate limit used up")
type apiRunner struct {
inner interface {
Do(*http.Request) (*http.Response, error)
}
now func() time.Time
mu sync.Mutex
blockedUntil time.Time // no request before this time; the limit is used up
lastEvent string // ETag of the last write
}
func newAPIRunner(inner *http.Client) *apiRunner {
return &apiRunner{inner: inner, now: time.Now}
}
func (r *apiRunner) Do(req *http.Request) (*http.Response, error) {
r.mu.Lock()
if r.lastEvent != "" {
req.Header.Set("If-Event-Reached", r.lastEvent)
}
wait := r.blockedUntil.Sub(r.now())
r.mu.Unlock()
pause := firstRetryPause
for attempt := 0; ; attempt++ {
if wait > maxResetWait {
return nil, fmt.Errorf("%w, it resets in %s", errRateLimited, wait.Round(time.Second))
}
if err := sleep(req.Context(), wait); err != nil {
return nil, err
}
resp, err := r.inner.Do(req)
if err != nil {
return resp, err
}
if !retryable(req, resp) || attempt == maxRetries {
r.observe(req, resp)
return resp, nil
}
closeBody(resp)
if req.Body != nil {
if req.GetBody == nil {
return nil, errors.New("cannot repeat the request: its body cannot be sent again")
}
if req.Body, err = req.GetBody(); err != nil {
return nil, err
}
}
wait, pause = pause, 2*pause
}
}
func retryable(req *http.Request, resp *http.Response) bool {
barrier := req.Header.Get("If-Event-Reached") != ""
switch resp.StatusCode {
case http.StatusTooManyRequests:
return true
case http.StatusPreconditionFailed:
return barrier && eventNotReached(resp)
case http.StatusForbidden, http.StatusNotFound:
return barrier && req.Method != http.MethodPost
case http.StatusInternalServerError, http.StatusBadGateway, http.StatusServiceUnavailable, http.StatusGatewayTimeout:
return req.Method != http.MethodPost
}
return false
}
// eventNotReached reports whether a 412 says that the event of
// If-Event-Reached is not processed yet. It reads the body and puts it back.
func eventNotReached(resp *http.Response) bool {
body, err := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
_ = resp.Body.Close()
resp.Body = io.NopCloser(bytes.NewReader(body))
var e struct {
Type string `json:"type"`
}
return err == nil && json.Unmarshal(body, &e) == nil && e.Type == "FailedPrecondition"
}
// observe remembers the event of a write, and when the limit resets once a
// response says nothing is left.
func (r *apiRunner) observe(req *http.Request, resp *http.Response) {
r.mu.Lock()
defer r.mu.Unlock()
if etag := resp.Header.Get("ETag"); etag != "" && req.Method != http.MethodGet && resp.StatusCode < http.StatusBadRequest {
r.lastEvent = etag
}
remaining, err1 := strconv.Atoi(resp.Header.Get("X-Ratelimit-Remaining"))
reset, err2 := strconv.Atoi(resp.Header.Get("X-Ratelimit-Reset"))
if err1 == nil && err2 == nil && remaining <= 0 && reset >= 0 {
r.blockedUntil = r.now().Add(time.Duration(reset) * time.Second)
}
}
// sleep waits for d or until ctx ends.
func sleep(ctx context.Context, d time.Duration) error {
if d <= 0 {
return ctx.Err()
}
t := time.NewTimer(d)
defer t.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-t.C:
return nil
}
}
// fqdnNoDot returns a name the way the zones are named here: lower case,
// ASCII (Punycode) and without the trailing dot.
func fqdnNoDot(name string) string {
n := strings.ToLower(strings.TrimSuffix(name, "."))
if a, err := idna.ToASCII(n); err == nil {
return a
}
return n
}
// normalZone returns a zone as fqdnNoDot does, with the trailing dot.
func normalZone(zone string) string {
return fqdnNoDot(zone) + "."
}