client.go raw
1 package internal
2
3 import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "fmt"
8 "io"
9 "net/http"
10 "net/url"
11 "time"
12
13 "github.com/go-acme/lego/v4/providers/dns/internal/errutils"
14 )
15
16 const apiBaseURL = "https://admin.vshosting.cloud/clouddns"
17
18 const authorizationHeader = "Authorization"
19
20 // Client handles all communication with CloudDNS API.
21 type Client struct {
22 clientID string
23 email string
24 password string
25 ttl int
26
27 apiBaseURL *url.URL
28
29 loginURL *url.URL
30
31 HTTPClient *http.Client
32 }
33
34 // NewClient returns a Client instance configured to handle CloudDNS API communication.
35 func NewClient(clientID, email, password string, ttl int) *Client {
36 baseURL, _ := url.Parse(apiBaseURL)
37 loginBaseURL, _ := url.Parse(loginURL)
38
39 return &Client{
40 clientID: clientID,
41 email: email,
42 password: password,
43 ttl: ttl,
44 apiBaseURL: baseURL,
45 loginURL: loginBaseURL,
46 HTTPClient: &http.Client{Timeout: 5 * time.Second},
47 }
48 }
49
50 // AddRecord is a high level method to add a new record into CloudDNS zone.
51 func (c *Client) AddRecord(ctx context.Context, zone, recordName, recordValue string) error {
52 domain, err := c.getDomain(ctx, zone)
53 if err != nil {
54 return err
55 }
56
57 record := Record{DomainID: domain.ID, Name: recordName, Value: recordValue, Type: "TXT"}
58
59 err = c.addTxtRecord(ctx, record)
60 if err != nil {
61 return err
62 }
63
64 return c.publishRecords(ctx, domain.ID)
65 }
66
67 // DeleteRecord is a high level method to remove a record from zone.
68 func (c *Client) DeleteRecord(ctx context.Context, zone, recordName string) error {
69 domain, err := c.getDomain(ctx, zone)
70 if err != nil {
71 return err
72 }
73
74 record, err := c.getRecord(ctx, domain.ID, recordName)
75 if err != nil {
76 return err
77 }
78
79 err = c.deleteRecord(ctx, record)
80 if err != nil {
81 return err
82 }
83
84 return c.publishRecords(ctx, domain.ID)
85 }
86
87 func (c *Client) addTxtRecord(ctx context.Context, record Record) error {
88 endpoint := c.apiBaseURL.JoinPath("record-txt")
89
90 req, err := newJSONRequest(ctx, http.MethodPost, endpoint, record)
91 if err != nil {
92 return err
93 }
94
95 return c.do(req, nil)
96 }
97
98 func (c *Client) deleteRecord(ctx context.Context, record Record) error {
99 endpoint := c.apiBaseURL.JoinPath("record", record.ID)
100
101 req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil)
102 if err != nil {
103 return err
104 }
105
106 return c.do(req, nil)
107 }
108
109 func (c *Client) getDomain(ctx context.Context, zone string) (Domain, error) {
110 searchQuery := SearchQuery{
111 Search: []Search{
112 {Name: "clientId", Operator: "eq", Value: c.clientID},
113 {Name: "domainName", Operator: "eq", Value: zone},
114 },
115 }
116
117 endpoint := c.apiBaseURL.JoinPath("domain", "search")
118
119 req, err := newJSONRequest(ctx, http.MethodPost, endpoint, searchQuery)
120 if err != nil {
121 return Domain{}, err
122 }
123
124 var result SearchResponse
125
126 err = c.do(req, &result)
127 if err != nil {
128 return Domain{}, err
129 }
130
131 if len(result.Items) == 0 {
132 return Domain{}, fmt.Errorf("domain not found: %s", zone)
133 }
134
135 return result.Items[0], nil
136 }
137
138 func (c *Client) getRecord(ctx context.Context, domainID, recordName string) (Record, error) {
139 endpoint := c.apiBaseURL.JoinPath("domain", domainID)
140
141 req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
142 if err != nil {
143 return Record{}, err
144 }
145
146 var result DomainInfo
147
148 err = c.do(req, &result)
149 if err != nil {
150 return Record{}, err
151 }
152
153 for _, record := range result.LastDomainRecordList {
154 if record.Name == recordName && record.Type == "TXT" {
155 return record, nil
156 }
157 }
158
159 return Record{}, fmt.Errorf("record not found: domainID %s, name %s", domainID, recordName)
160 }
161
162 func (c *Client) publishRecords(ctx context.Context, domainID string) error {
163 endpoint := c.apiBaseURL.JoinPath("domain", domainID, "publish")
164
165 payload := DomainInfo{SoaTTL: c.ttl}
166
167 req, err := newJSONRequest(ctx, http.MethodPut, endpoint, payload)
168 if err != nil {
169 return err
170 }
171
172 return c.do(req, nil)
173 }
174
175 func (c *Client) do(req *http.Request, result any) error {
176 at := getAccessToken(req.Context())
177 if at != "" {
178 req.Header.Set(authorizationHeader, "Bearer "+at)
179 }
180
181 resp, err := c.HTTPClient.Do(req)
182 if err != nil {
183 return errutils.NewHTTPDoError(req, err)
184 }
185
186 defer func() { _ = resp.Body.Close() }()
187
188 if resp.StatusCode/100 != 2 {
189 return parseError(req, resp)
190 }
191
192 if result == nil {
193 return nil
194 }
195
196 raw, err := io.ReadAll(resp.Body)
197 if err != nil {
198 return errutils.NewReadResponseError(req, resp.StatusCode, err)
199 }
200
201 err = json.Unmarshal(raw, result)
202 if err != nil {
203 return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
204 }
205
206 return nil
207 }
208
209 func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) {
210 buf := new(bytes.Buffer)
211
212 if payload != nil {
213 err := json.NewEncoder(buf).Encode(payload)
214 if err != nil {
215 return nil, fmt.Errorf("failed to create request JSON body: %w", err)
216 }
217 }
218
219 req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf)
220 if err != nil {
221 return nil, fmt.Errorf("unable to create request: %w", err)
222 }
223
224 req.Header.Set("Accept", "application/json")
225
226 if payload != nil {
227 req.Header.Set("Content-Type", "application/json")
228 }
229
230 return req, nil
231 }
232
233 func parseError(req *http.Request, resp *http.Response) error {
234 raw, _ := io.ReadAll(resp.Body)
235
236 var response APIError
237
238 err := json.Unmarshal(raw, &response)
239 if err != nil {
240 return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw)
241 }
242
243 return fmt.Errorf("[status code %d] %w", resp.StatusCode, response.Error)
244 }
245