1package mythicbeasts 2 3import ( 4 "bytes" 5 "encoding/json" 6 "errors" 7 "fmt" 8 "io" 9 "net/http" 10 "path" 11 "strings" 12) 13 14const ( 15 apiBaseURL = "https://api.mythic-beasts.com/dns/v2" 16 authBaseURL = "https://auth.mythic-beasts.com/login" 17) 18 19type authResponse struct { 20 // The bearer token for use in API requests 21 Token string `json:"access_token"` 22 23 // The maximum lifetime of the token in seconds 24 Lifetime int `json:"expires_in"` 25 26 // The token type (must be 'bearer') 27 TokenType string `json:"token_type"` 28} 29 30type authResponseError struct { 31 ErrorMsg string `json:"error"` 32 ErrorDescription string `json:"error_description"` 33} 34 35func (a authResponseError) Error() string { 36 return fmt.Sprintf("%s: %s", a.ErrorMsg, a.ErrorDescription) 37} 38 39type createTXTRequest struct { 40 Records []createTXTRecord `json:"records"` 41} 42 43type createTXTRecord struct { 44 Host string `json:"host"` 45 TTL int `json:"ttl"` 46 Type string `json:"type"` 47 Data string `json:"data"` 48} 49 50type createTXTResponse struct { 51 Added int `json:"records_added"` 52 Removed int `json:"records_removed"` 53 Message string `json:"message"` 54} 55 56type deleteTXTResponse struct { 57 Removed int `json:"records_removed"` 58 Message string `json:"message"` 59} 60 61// Logs into mythic beasts and acquires a bearer token for use in future API calls. 62// https://www.mythic-beasts.com/support/api/auth#sec-obtaining-a-token 63func (d *DNSProvider) login() error { 64 if d.token != "" { 65 // Already authenticated, stop now 66 return nil 67 } 68 69 reqBody := strings.NewReader("grant_type=client_credentials") 70 71 req, err := http.NewRequest(http.MethodPost, d.config.AuthAPIEndpoint.String(), reqBody) 72 if err != nil { 73 return err 74 } 75 76 req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 77 req.SetBasicAuth(d.config.UserName, d.config.Password) 78 79 resp, err := d.config.HTTPClient.Do(req) 80 if err != nil { 81 return err 82 } 83 84 defer func() { _ = resp.Body.Close() }() 85 86 body, err := io.ReadAll(resp.Body) 87 if err != nil { 88 return fmt.Errorf("login: %w", err) 89 } 90 91 if resp.StatusCode != 200 { 92 if resp.StatusCode < 400 || resp.StatusCode > 499 { 93 return fmt.Errorf("login: unknown error in auth API: %d", resp.StatusCode) 94 } 95 96 // Returned body should be a JSON thing 97 errResp := &authResponseError{} 98 err = json.Unmarshal(body, errResp) 99 if err != nil { 100 return fmt.Errorf("login: error parsing error: %w", err) 101 } 102 103 return fmt.Errorf("login: %d: %w", resp.StatusCode, errResp) 104 } 105 106 authResp := authResponse{} 107 err = json.Unmarshal(body, &authResp) 108 if err != nil { 109 return fmt.Errorf("login: error parsing response: %w", err) 110 } 111 112 if authResp.TokenType != "bearer" { 113 return fmt.Errorf("login: received unexpected token type: %s", authResp.TokenType) 114 } 115 116 d.token = authResp.Token 117 118 // Success 119 return nil 120} 121 122// https://www.mythic-beasts.com/support/api/dnsv2#ep-get-zoneszonerecords 123func (d *DNSProvider) createTXTRecord(zone, leaf, value string) error { 124 if d.token == "" { 125 return fmt.Errorf("createTXTRecord: not logged in") 126 } 127 128 createReq := createTXTRequest{ 129 Records: []createTXTRecord{{ 130 Host: leaf, 131 TTL: d.config.TTL, 132 Type: "TXT", 133 Data: value, 134 }}, 135 } 136 137 reqBody, err := json.Marshal(createReq) 138 if err != nil { 139 return fmt.Errorf("createTXTRecord: marshaling request body failed: %w", err) 140 } 141 142 endpoint, err := d.config.APIEndpoint.Parse(path.Join(d.config.APIEndpoint.Path, "zones", zone, "records", leaf, "TXT")) 143 if err != nil { 144 return fmt.Errorf("createTXTRecord: failed to parse URL: %w", err) 145 } 146 147 req, err := http.NewRequest(http.MethodPost, endpoint.String(), bytes.NewReader(reqBody)) 148 if err != nil { 149 return fmt.Errorf("createTXTRecord: %w", err) 150 } 151 152 req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", d.token)) 153 req.Header.Set("Content-Type", "application/json") 154 155 resp, err := d.config.HTTPClient.Do(req) 156 if err != nil { 157 return fmt.Errorf("createTXTRecord: unable to perform HTTP request: %w", err) 158 } 159 160 defer func() { _ = resp.Body.Close() }() 161 162 body, err := io.ReadAll(resp.Body) 163 if err != nil { 164 return fmt.Errorf("createTXTRecord: %w", err) 165 } 166 167 if resp.StatusCode != 200 { 168 return fmt.Errorf("createTXTRecord: error in API: %d", resp.StatusCode) 169 } 170 171 createResp := createTXTResponse{} 172 err = json.Unmarshal(body, &createResp) 173 if err != nil { 174 return fmt.Errorf("createTXTRecord: error parsing response: %w", err) 175 } 176 177 if createResp.Added != 1 { 178 return errors.New("createTXTRecord: did not add TXT record for some reason") 179 } 180 181 // Success 182 return nil 183} 184 185// https://www.mythic-beasts.com/support/api/dnsv2#ep-delete-zoneszonerecords 186func (d *DNSProvider) removeTXTRecord(zone, leaf, value string) error { 187 if d.token == "" { 188 return fmt.Errorf("removeTXTRecord: not logged in") 189 } 190 191 endpoint, err := d.config.APIEndpoint.Parse(path.Join(d.config.APIEndpoint.Path, "zones", zone, "records", leaf, "TXT")) 192 if err != nil { 193 return fmt.Errorf("createTXTRecord: failed to parse URL: %w", err) 194 } 195 196 query := endpoint.Query() 197 query.Add("data", value) 198 endpoint.RawQuery = query.Encode() 199 200 req, err := http.NewRequest(http.MethodDelete, endpoint.String(), nil) 201 if err != nil { 202 return fmt.Errorf("removeTXTRecord: %w", err) 203 } 204 205 req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", d.token)) 206 207 resp, err := d.config.HTTPClient.Do(req) 208 if err != nil { 209 return fmt.Errorf("removeTXTRecord: unable to perform HTTP request: %w", err) 210 } 211 212 defer func() { _ = resp.Body.Close() }() 213 214 body, err := io.ReadAll(resp.Body) 215 if err != nil { 216 return fmt.Errorf("removeTXTRecord: %w", err) 217 } 218 219 if resp.StatusCode != 200 { 220 return fmt.Errorf("removeTXTRecord: error in API: %d", resp.StatusCode) 221 } 222 223 deleteResp := deleteTXTResponse{} 224 err = json.Unmarshal(body, &deleteResp) 225 if err != nil { 226 return fmt.Errorf("removeTXTRecord: error parsing response: %w", err) 227 } 228 229 if deleteResp.Removed != 1 { 230 return errors.New("deleteTXTRecord: did not add TXT record for some reason") 231 } 232 233 // Success 234 return nil 235} 236