client.go 4.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159
  1. package api
  2. import (
  3. "bytes"
  4. "context"
  5. "encoding/xml"
  6. "fmt"
  7. "io"
  8. "net/http"
  9. "time"
  10. "golang.org/x/time/rate"
  11. )
  12. // DefaultEndpoint is Schlundtech's hostname for the AutoDNS XML gateway. It
  13. // resolves to the same host as gateway.autodns.com.
  14. const DefaultEndpoint = "https://gateway.schlundtech.de/"
  15. // maxBodyBytes caps how much of a response body is read, so a misbehaving
  16. // endpoint cannot exhaust memory.
  17. const maxBodyBytes = 8 << 20
  18. // minInterval is the floor between two requests. The gateway allows three
  19. // requests per second and IP; 350ms keeps a sequential command comfortably
  20. // under that.
  21. const minInterval = 350 * time.Millisecond
  22. // Credentials authenticate against the gateway.
  23. type Credentials struct {
  24. User string
  25. Password string
  26. // Context is the project the records belong to. Schlundtech uses 10.
  27. Context string
  28. // Token is the optional second-factor token.
  29. Token string
  30. }
  31. // Client talks to the AutoDNS XML gateway.
  32. type Client struct {
  33. endpoint string
  34. auth Auth
  35. http *http.Client
  36. limiter *rate.Limiter
  37. }
  38. // Option customises a Client.
  39. type Option func(*Client)
  40. // WithEndpoint overrides the gateway URL. Use the demo system,
  41. // https://demo.autodns.com/gateway/, to exercise writes without touching a
  42. // live zone.
  43. func WithEndpoint(url string) Option {
  44. return func(c *Client) { c.endpoint = url }
  45. }
  46. // WithHTTPClient overrides the underlying HTTP client.
  47. func WithHTTPClient(h *http.Client) Option {
  48. return func(c *Client) { c.http = h }
  49. }
  50. // WithInterval overrides the floor between two requests.
  51. func WithInterval(d time.Duration) Option {
  52. return func(c *Client) { c.limiter = rate.NewLimiter(rate.Every(d), 1) }
  53. }
  54. // New returns a Client for the given credentials.
  55. func New(creds Credentials, opts ...Option) *Client {
  56. c := &Client{
  57. endpoint: DefaultEndpoint,
  58. auth: Auth{
  59. User: creds.User,
  60. Password: creds.Password,
  61. Context: creds.Context,
  62. Token: creds.Token,
  63. },
  64. http: &http.Client{Timeout: 30 * time.Second},
  65. limiter: rate.NewLimiter(rate.Every(minInterval), 1),
  66. }
  67. for _, opt := range opts {
  68. opt(c)
  69. }
  70. return c
  71. }
  72. // do sends one request and returns the parsed response.
  73. //
  74. // It does not treat a failed task as an error: the gateway reports task
  75. // failures with HTTP 200. The caller inspects the returned result. Only
  76. // transport-level problems and non-2xx statuses (an nginx 404 for a wrong
  77. // path) come back as an error here.
  78. func (c *Client) do(ctx context.Context, req *Request) (*Response, error) {
  79. if err := c.limiter.Wait(ctx); err != nil {
  80. return nil, fmt.Errorf("rate limiter: %w", err)
  81. }
  82. // The gateway rejects a body that starts with a byte order mark, which
  83. // xml.Marshal can emit; the header is written by hand to be sure.
  84. body, err := xml.Marshal(req)
  85. if err != nil {
  86. return nil, fmt.Errorf("encode request: %w", err)
  87. }
  88. if len(body) >= 3 && body[0] == 0xEF && body[1] == 0xBB && body[2] == 0xBF {
  89. body = body[3:]
  90. }
  91. payload := append([]byte(xml.Header), body...)
  92. httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint, bytes.NewReader(payload))
  93. if err != nil {
  94. return nil, fmt.Errorf("build request: %w", err)
  95. }
  96. httpReq.Header.Set("Content-Type", "application/xml; charset=utf-8")
  97. httpReq.Header.Set("Accept", "application/xml")
  98. httpReq.Header.Set("User-Agent", "schlundtech-dns")
  99. resp, err := c.http.Do(httpReq)
  100. if err != nil {
  101. return nil, fmt.Errorf("contact gateway: %w", err)
  102. }
  103. defer resp.Body.Close()
  104. raw, err := io.ReadAll(io.LimitReader(resp.Body, maxBodyBytes))
  105. if err != nil {
  106. return nil, fmt.Errorf("read response: %w", err)
  107. }
  108. if resp.StatusCode < 200 || resp.StatusCode >= 300 {
  109. return nil, fmt.Errorf("gateway returned HTTP %d (this is a routing error, not a task error)", resp.StatusCode)
  110. }
  111. var parsed Response
  112. if err := xml.Unmarshal(raw, &parsed); err != nil {
  113. return nil, fmt.Errorf("parse response: %w", err)
  114. }
  115. if len(parsed.Result) == 0 {
  116. return nil, fmt.Errorf("gateway returned no result block (stid %s)", parsed.Stid)
  117. }
  118. return &parsed, nil
  119. }
  120. // run sends a single-task request and returns its result, converting a failed
  121. // task into an *APIError.
  122. func (c *Client) run(ctx context.Context, task Task) (*Response, Result, error) {
  123. req := &Request{Auth: c.auth, Language: "en", Task: []Task{task}}
  124. resp, err := c.do(ctx, req)
  125. if err != nil {
  126. return nil, Result{}, err
  127. }
  128. result := resp.Result[0]
  129. if !statusOK(result.Status) {
  130. return resp, result, fromResult(result, resp.Stid)
  131. }
  132. return resp, result, nil
  133. }
  134. // statusOK reports whether a task succeeded. Type is lower case in the
  135. // documented responses but is compared case-insensitively to be safe.
  136. func statusOK(s Status) bool {
  137. return s.Type == "success" || s.Type == "SUCCESS"
  138. }