| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159 |
- 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.
- Token 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),
- }
- 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"
- }
|