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