lego/providers/dns/mythicbeasts/client.go
2021-12-03 16:19:00 +01:00

240 lines
6 KiB
Go

package mythicbeasts
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"path"
"strings"
"time"
)
const (
apiBaseURL = "https://api.mythic-beasts.com/dns/v2"
authBaseURL = "https://auth.mythic-beasts.com/login"
)
type authResponse struct {
// The bearer token for use in API requests
Token string `json:"access_token"`
// The maximum lifetime of the token in seconds
Lifetime int `json:"expires_in"`
// The token type (must be 'bearer')
TokenType string `json:"token_type"`
Deadline time.Time `json:"-"`
}
type authResponseError struct {
ErrorMsg string `json:"error"`
ErrorDescription string `json:"error_description"`
}
func (a authResponseError) Error() string {
return fmt.Sprintf("%s: %s", a.ErrorMsg, a.ErrorDescription)
}
type createTXTRequest struct {
Records []createTXTRecord `json:"records"`
}
type createTXTRecord struct {
Host string `json:"host"`
TTL int `json:"ttl"`
Type string `json:"type"`
Data string `json:"data"`
}
type createTXTResponse struct {
Added int `json:"records_added"`
Removed int `json:"records_removed"`
Message string `json:"message"`
}
type deleteTXTResponse struct {
Removed int `json:"records_removed"`
Message string `json:"message"`
}
// Logs into mythic beasts and acquires a bearer token for use in future API calls.
// https://www.mythic-beasts.com/support/api/auth#sec-obtaining-a-token
func (d *DNSProvider) login() error {
d.muToken.Lock()
defer d.muToken.Unlock()
if d.token != nil && time.Now().Before(d.token.Deadline) {
// Already authenticated, stop now
return nil
}
req, err := http.NewRequest(http.MethodPost, d.config.AuthAPIEndpoint.String(), strings.NewReader("grant_type=client_credentials"))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.SetBasicAuth(d.config.UserName, d.config.Password)
resp, err := d.config.HTTPClient.Do(req)
if err != nil {
return err
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("login: %w", err)
}
if resp.StatusCode != http.StatusOK {
if resp.StatusCode < 400 || resp.StatusCode > 499 {
return fmt.Errorf("login: unknown error in auth API: %d", resp.StatusCode)
}
// Returned body should be a JSON thing
errResp := &authResponseError{}
err = json.Unmarshal(body, errResp)
if err != nil {
return fmt.Errorf("login: error parsing error: %w", err)
}
return fmt.Errorf("login: %d: %w", resp.StatusCode, errResp)
}
authResp := authResponse{}
err = json.Unmarshal(body, &authResp)
if err != nil {
return fmt.Errorf("login: error parsing response: %w", err)
}
if authResp.TokenType != "bearer" {
return fmt.Errorf("login: received unexpected token type: %s", authResp.TokenType)
}
authResp.Deadline = time.Now().Add(time.Duration(authResp.Lifetime) * time.Second)
d.token = &authResp
// Success
return nil
}
// https://www.mythic-beasts.com/support/api/dnsv2#ep-get-zoneszonerecords
func (d *DNSProvider) createTXTRecord(zone, leaf, value string) error {
if d.token == nil {
return fmt.Errorf("createTXTRecord: not logged in")
}
createReq := createTXTRequest{
Records: []createTXTRecord{{
Host: leaf,
TTL: d.config.TTL,
Type: "TXT",
Data: value,
}},
}
reqBody, err := json.Marshal(createReq)
if err != nil {
return fmt.Errorf("createTXTRecord: marshaling request body failed: %w", err)
}
endpoint, err := d.config.APIEndpoint.Parse(path.Join(d.config.APIEndpoint.Path, "zones", zone, "records", leaf, "TXT"))
if err != nil {
return fmt.Errorf("createTXTRecord: failed to parse URL: %w", err)
}
req, err := http.NewRequest(http.MethodPost, endpoint.String(), bytes.NewReader(reqBody))
if err != nil {
return fmt.Errorf("createTXTRecord: %w", err)
}
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", d.token.Token))
req.Header.Set("Content-Type", "application/json")
resp, err := d.config.HTTPClient.Do(req)
if err != nil {
return fmt.Errorf("createTXTRecord: unable to perform HTTP request: %w", err)
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("createTXTRecord: %w", err)
}
if resp.StatusCode != 200 {
return fmt.Errorf("createTXTRecord: error in API: %d", resp.StatusCode)
}
createResp := createTXTResponse{}
err = json.Unmarshal(body, &createResp)
if err != nil {
return fmt.Errorf("createTXTRecord: error parsing response: %w", err)
}
if createResp.Added != 1 {
return errors.New("createTXTRecord: did not add TXT record for some reason")
}
// Success
return nil
}
// https://www.mythic-beasts.com/support/api/dnsv2#ep-delete-zoneszonerecords
func (d *DNSProvider) removeTXTRecord(zone, leaf, value string) error {
if d.token == nil {
return fmt.Errorf("removeTXTRecord: not logged in")
}
endpoint, err := d.config.APIEndpoint.Parse(path.Join(d.config.APIEndpoint.Path, "zones", zone, "records", leaf, "TXT"))
if err != nil {
return fmt.Errorf("removeTXTRecord: failed to parse URL: %w", err)
}
query := endpoint.Query()
query.Add("data", value)
endpoint.RawQuery = query.Encode()
req, err := http.NewRequest(http.MethodDelete, endpoint.String(), nil)
if err != nil {
return fmt.Errorf("removeTXTRecord: %w", err)
}
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", d.token.Token))
resp, err := d.config.HTTPClient.Do(req)
if err != nil {
return fmt.Errorf("removeTXTRecord: unable to perform HTTP request: %w", err)
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("removeTXTRecord: %w", err)
}
if resp.StatusCode != 200 {
return fmt.Errorf("removeTXTRecord: error in API: %d", resp.StatusCode)
}
deleteResp := deleteTXTResponse{}
err = json.Unmarshal(body, &deleteResp)
if err != nil {
return fmt.Errorf("removeTXTRecord: error parsing response: %w", err)
}
if deleteResp.Removed != 1 {
return errors.New("removeTXTRecord: did not add TXT record for some reason")
}
// Success
return nil
}