package api import ( "bytes" "context" "encoding/xml" "fmt" "io" "net/http" "time" "golang.org/x/time/rate" ) // DefaultEndpoint is Schlundtech's hostname for the AutoDNS XML gateway. It // resolves to the same host as gateway.autodns.com. const DefaultEndpoint = "https://gateway.schlundtech.de/" // maxBodyBytes caps how much of a response body is read, so a misbehaving // endpoint cannot exhaust memory. const maxBodyBytes = 8 << 20 // minInterval is the floor between two requests. The gateway allows three // requests per second and IP; 350ms keeps a sequential command comfortably // under that. const minInterval = 350 * time.Millisecond // Credentials authenticate against the gateway. type Credentials struct { User string Password string // Context is the project the records belong to. Schlundtech uses 10. Context string // Token is the optional second-factor token: the six-digit TOTP code from // the authenticator app, which is valid for roughly 30 seconds. Token string // Endpoint overrides DefaultEndpoint when set. Use the demo system to // exercise writes without touching a live zone. Endpoint string } // Client talks to the AutoDNS XML gateway. type Client struct { endpoint string auth Auth http *http.Client limiter *rate.Limiter } // Option customises a Client. type Option func(*Client) // WithEndpoint overrides the gateway URL. Use the demo system, // https://demo.autodns.com/gateway/, to exercise writes without touching a // live zone. func WithEndpoint(url string) Option { return func(c *Client) { c.endpoint = url } } // WithHTTPClient overrides the underlying HTTP client. func WithHTTPClient(h *http.Client) Option { return func(c *Client) { c.http = h } } // WithInterval overrides the floor between two requests. func WithInterval(d time.Duration) Option { return func(c *Client) { c.limiter = rate.NewLimiter(rate.Every(d), 1) } } // New returns a Client for the given credentials. func New(creds Credentials, opts ...Option) *Client { c := &Client{ endpoint: DefaultEndpoint, auth: Auth{ User: creds.User, Password: creds.Password, Context: creds.Context, Token: creds.Token, }, http: &http.Client{Timeout: 30 * time.Second}, limiter: rate.NewLimiter(rate.Every(minInterval), 1), } if creds.Endpoint != "" { c.endpoint = creds.Endpoint } for _, opt := range opts { opt(c) } return c } // do sends one request and returns the parsed response. // // It does not treat a failed task as an error: the gateway reports task // failures with HTTP 200. The caller inspects the returned result. Only // transport-level problems and non-2xx statuses (an nginx 404 for a wrong // path) come back as an error here. func (c *Client) do(ctx context.Context, req *Request) (*Response, error) { if err := c.limiter.Wait(ctx); err != nil { return nil, fmt.Errorf("rate limiter: %w", err) } // The gateway rejects a body that starts with a byte order mark, which // xml.Marshal can emit; the header is written by hand to be sure. body, err := xml.Marshal(req) if err != nil { return nil, fmt.Errorf("encode request: %w", err) } if len(body) >= 3 && body[0] == 0xEF && body[1] == 0xBB && body[2] == 0xBF { body = body[3:] } payload := append([]byte(xml.Header), body...) httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint, bytes.NewReader(payload)) if err != nil { return nil, fmt.Errorf("build request: %w", err) } httpReq.Header.Set("Content-Type", "application/xml; charset=utf-8") httpReq.Header.Set("Accept", "application/xml") httpReq.Header.Set("User-Agent", "schlundtech-dns") resp, err := c.http.Do(httpReq) if err != nil { return nil, fmt.Errorf("contact gateway: %w", err) } defer resp.Body.Close() raw, err := io.ReadAll(io.LimitReader(resp.Body, maxBodyBytes)) if err != nil { return nil, fmt.Errorf("read response: %w", err) } if resp.StatusCode < 200 || resp.StatusCode >= 300 { return nil, fmt.Errorf("gateway returned HTTP %d (this is a routing error, not a task error)", resp.StatusCode) } var parsed Response if err := xml.Unmarshal(raw, &parsed); err != nil { return nil, fmt.Errorf("parse response: %w", err) } if len(parsed.Result) == 0 { return nil, fmt.Errorf("gateway returned no result block (stid %s)", parsed.Stid) } return &parsed, nil } // run sends a single-task request and returns its result, converting a failed // task into an *APIError. func (c *Client) run(ctx context.Context, task Task) (*Response, Result, error) { req := &Request{Auth: c.auth, Language: "en", Task: []Task{task}} resp, err := c.do(ctx, req) if err != nil { return nil, Result{}, err } result := resp.Result[0] if !statusOK(result.Status) { return resp, result, fromResult(result, resp.Stid) } return resp, result, nil } // statusOK reports whether a task succeeded. Type is lower case in the // documented responses but is compared case-insensitively to be safe. func statusOK(s Status) bool { return s.Type == "success" || s.Type == "SUCCESS" }