client.go 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166
  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: the six-digit TOTP code from
  29. // the authenticator app, which is valid for roughly 30 seconds.
  30. Token string
  31. // Endpoint overrides DefaultEndpoint when set. Use the demo system to
  32. // exercise writes without touching a live zone.
  33. Endpoint string
  34. }
  35. // Client talks to the AutoDNS XML gateway.
  36. type Client struct {
  37. endpoint string
  38. auth Auth
  39. http *http.Client
  40. limiter *rate.Limiter
  41. }
  42. // Option customises a Client.
  43. type Option func(*Client)
  44. // WithEndpoint overrides the gateway URL. Use the demo system,
  45. // https://demo.autodns.com/gateway/, to exercise writes without touching a
  46. // live zone.
  47. func WithEndpoint(url string) Option {
  48. return func(c *Client) { c.endpoint = url }
  49. }
  50. // WithHTTPClient overrides the underlying HTTP client.
  51. func WithHTTPClient(h *http.Client) Option {
  52. return func(c *Client) { c.http = h }
  53. }
  54. // WithInterval overrides the floor between two requests.
  55. func WithInterval(d time.Duration) Option {
  56. return func(c *Client) { c.limiter = rate.NewLimiter(rate.Every(d), 1) }
  57. }
  58. // New returns a Client for the given credentials.
  59. func New(creds Credentials, opts ...Option) *Client {
  60. c := &Client{
  61. endpoint: DefaultEndpoint,
  62. auth: Auth{
  63. User: creds.User,
  64. Password: creds.Password,
  65. Context: creds.Context,
  66. Token: creds.Token,
  67. },
  68. http: &http.Client{Timeout: 30 * time.Second},
  69. limiter: rate.NewLimiter(rate.Every(minInterval), 1),
  70. }
  71. if creds.Endpoint != "" {
  72. c.endpoint = creds.Endpoint
  73. }
  74. for _, opt := range opts {
  75. opt(c)
  76. }
  77. return c
  78. }
  79. // do sends one request and returns the parsed response.
  80. //
  81. // It does not treat a failed task as an error: the gateway reports task
  82. // failures with HTTP 200. The caller inspects the returned result. Only
  83. // transport-level problems and non-2xx statuses (an nginx 404 for a wrong
  84. // path) come back as an error here.
  85. func (c *Client) do(ctx context.Context, req *Request) (*Response, error) {
  86. if err := c.limiter.Wait(ctx); err != nil {
  87. return nil, fmt.Errorf("rate limiter: %w", err)
  88. }
  89. // The gateway rejects a body that starts with a byte order mark, which
  90. // xml.Marshal can emit; the header is written by hand to be sure.
  91. body, err := xml.Marshal(req)
  92. if err != nil {
  93. return nil, fmt.Errorf("encode request: %w", err)
  94. }
  95. if len(body) >= 3 && body[0] == 0xEF && body[1] == 0xBB && body[2] == 0xBF {
  96. body = body[3:]
  97. }
  98. payload := append([]byte(xml.Header), body...)
  99. httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint, bytes.NewReader(payload))
  100. if err != nil {
  101. return nil, fmt.Errorf("build request: %w", err)
  102. }
  103. httpReq.Header.Set("Content-Type", "application/xml; charset=utf-8")
  104. httpReq.Header.Set("Accept", "application/xml")
  105. httpReq.Header.Set("User-Agent", "schlundtech-dns")
  106. resp, err := c.http.Do(httpReq)
  107. if err != nil {
  108. return nil, fmt.Errorf("contact gateway: %w", err)
  109. }
  110. defer resp.Body.Close()
  111. raw, err := io.ReadAll(io.LimitReader(resp.Body, maxBodyBytes))
  112. if err != nil {
  113. return nil, fmt.Errorf("read response: %w", err)
  114. }
  115. if resp.StatusCode < 200 || resp.StatusCode >= 300 {
  116. return nil, fmt.Errorf("gateway returned HTTP %d (this is a routing error, not a task error)", resp.StatusCode)
  117. }
  118. var parsed Response
  119. if err := xml.Unmarshal(raw, &parsed); err != nil {
  120. return nil, fmt.Errorf("parse response: %w", err)
  121. }
  122. if len(parsed.Result) == 0 {
  123. return nil, fmt.Errorf("gateway returned no result block (stid %s)", parsed.Stid)
  124. }
  125. return &parsed, nil
  126. }
  127. // run sends a single-task request and returns its result, converting a failed
  128. // task into an *APIError.
  129. func (c *Client) run(ctx context.Context, task Task) (*Response, Result, error) {
  130. req := &Request{Auth: c.auth, Language: "en", Task: []Task{task}}
  131. resp, err := c.do(ctx, req)
  132. if err != nil {
  133. return nil, Result{}, err
  134. }
  135. result := resp.Result[0]
  136. if !statusOK(result.Status) {
  137. return resp, result, fromResult(result, resp.Stid)
  138. }
  139. return resp, result, nil
  140. }
  141. // statusOK reports whether a task succeeded. Type is lower case in the
  142. // documented responses but is compared case-insensitively to be safe.
  143. func statusOK(s Status) bool {
  144. return s.Type == "success" || s.Type == "SUCCESS"
  145. }