client.go raw
1 package internal
2
3 import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "errors"
8 "fmt"
9 "io"
10 "net/http"
11 "net/url"
12 "time"
13
14 "github.com/go-acme/lego/v4/providers/dns/internal/errutils"
15 )
16
17 const defaultBaseURL = "https://api.hyperone.com/v2"
18
19 const defaultLocationID = "pl-waw-1"
20
21 type signer interface {
22 GetJWT() (string, error)
23 }
24
25 // Client the HyperOne client.
26 type Client struct {
27 passport *Passport
28 signer signer
29
30 baseURL *url.URL
31 HTTPClient *http.Client
32 }
33
34 // NewClient Creates a new HyperOne client.
35 func NewClient(apiEndpoint, locationID string, passport *Passport) (*Client, error) {
36 if passport == nil {
37 return nil, errors.New("the passport is missing")
38 }
39
40 projectID, err := passport.ExtractProjectID()
41 if err != nil {
42 return nil, err
43 }
44
45 if apiEndpoint == "" {
46 apiEndpoint = defaultBaseURL
47 }
48
49 baseURL, err := url.Parse(apiEndpoint)
50 if err != nil {
51 return nil, err
52 }
53
54 tokenSigner := &TokenSigner{
55 PrivateKey: passport.PrivateKey,
56 KeyID: passport.CertificateID,
57 Audience: apiEndpoint,
58 Issuer: passport.Issuer,
59 Subject: passport.SubjectID,
60 }
61
62 if locationID == "" {
63 locationID = defaultLocationID
64 }
65
66 client := &Client{
67 HTTPClient: &http.Client{Timeout: 5 * time.Second},
68 baseURL: baseURL.JoinPath("dns", locationID, "project", projectID),
69 passport: passport,
70 signer: tokenSigner,
71 }
72
73 return client, nil
74 }
75
76 // FindRecordset looks for recordset with given recordType and name and returns it.
77 // In case if recordset is not found returns nil.
78 // https://api.hyperone.com/v2/docs#operation/dns_project_zone_recordset_list
79 func (c *Client) FindRecordset(ctx context.Context, zoneID, recordType, name string) (*Recordset, error) {
80 // https://api.hyperone.com/v2/dns/{locationId}/project/{projectId}/zone/{zoneId}/recordset
81 endpoint := c.baseURL.JoinPath("zone", zoneID, "recordset")
82
83 req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
84 if err != nil {
85 return nil, err
86 }
87
88 var recordSets []Recordset
89
90 err = c.do(req, &recordSets)
91 if err != nil {
92 return nil, fmt.Errorf("failed to get recordsets from server: %w", err)
93 }
94
95 for _, v := range recordSets {
96 if v.RecordType == recordType && v.Name == name {
97 return &v, nil
98 }
99 }
100
101 // when recordset is not present returns nil, but error is not thrown
102 return nil, nil
103 }
104
105 // CreateRecordset creates recordset and record with given value within one request.
106 // https://api.hyperone.com/v2/docs#operation/dns_project_zone_recordset_create
107 func (c *Client) CreateRecordset(ctx context.Context, zoneID, recordType, name, recordValue string, ttl int) (*Recordset, error) {
108 // https://api.hyperone.com/v2/dns/{locationId}/project/{projectId}/zone/{zoneId}/recordset
109 endpoint := c.baseURL.JoinPath("zone", zoneID, "recordset")
110
111 recordsetInput := Recordset{
112 RecordType: recordType,
113 Name: name,
114 TTL: ttl,
115 Record: &Record{Content: recordValue},
116 }
117
118 req, err := newJSONRequest(ctx, http.MethodPost, endpoint, recordsetInput)
119 if err != nil {
120 return nil, err
121 }
122
123 var recordsetResponse Recordset
124
125 err = c.do(req, &recordsetResponse)
126 if err != nil {
127 return nil, fmt.Errorf("failed to create recordset: %w", err)
128 }
129
130 return &recordsetResponse, nil
131 }
132
133 // DeleteRecordset deletes a recordset.
134 // https://api.hyperone.com/v2/docs#operation/dns_project_zone_recordset_delete
135 func (c *Client) DeleteRecordset(ctx context.Context, zoneID, recordsetID string) error {
136 // https://api.hyperone.com/v2/dns/{locationId}/project/{projectId}/zone/{zoneId}/recordset/{recordsetId}
137 endpoint := c.baseURL.JoinPath("zone", zoneID, "recordset", recordsetID)
138
139 req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil)
140 if err != nil {
141 return err
142 }
143
144 return c.do(req, nil)
145 }
146
147 // GetRecords gets all records within specified recordset.
148 // https://api.hyperone.com/v2/docs#operation/dns_project_zone_recordset_record_list
149 func (c *Client) GetRecords(ctx context.Context, zoneID, recordsetID string) ([]Record, error) {
150 // https://api.hyperone.com/v2/dns/{locationId}/project/{projectId}/zone/{zoneId}/recordset/{recordsetId}/record
151 endpoint := c.baseURL.JoinPath("zone", zoneID, "recordset", recordsetID, "record")
152
153 req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
154 if err != nil {
155 return nil, err
156 }
157
158 var records []Record
159
160 err = c.do(req, &records)
161 if err != nil {
162 return nil, fmt.Errorf("failed to get records from server: %w", err)
163 }
164
165 return records, err
166 }
167
168 // CreateRecord creates a record.
169 // https://api.hyperone.com/v2/docs#operation/dns_project_zone_recordset_record_create
170 func (c *Client) CreateRecord(ctx context.Context, zoneID, recordsetID, recordContent string) (*Record, error) {
171 // https://api.hyperone.com/v2/dns/{locationId}/project/{projectId}/zone/{zoneId}/recordset/{recordsetId}/record
172 endpoint := c.baseURL.JoinPath("zone", zoneID, "recordset", recordsetID, "record")
173
174 req, err := newJSONRequest(ctx, http.MethodPost, endpoint, Record{Content: recordContent})
175 if err != nil {
176 return nil, err
177 }
178
179 var recordResponse Record
180
181 err = c.do(req, &recordResponse)
182 if err != nil {
183 return nil, fmt.Errorf("failed to set record: %w", err)
184 }
185
186 return &recordResponse, nil
187 }
188
189 // DeleteRecord deletes a record.
190 // https://api.hyperone.com/v2/docs#operation/dns_project_zone_recordset_record_delete
191 func (c *Client) DeleteRecord(ctx context.Context, zoneID, recordsetID, recordID string) error {
192 // https://api.hyperone.com/v2/dns/{locationId}/project/{projectId}/zone/{zoneId}/recordset/{recordsetId}/record/{recordId}
193 endpoint := c.baseURL.JoinPath("zone", zoneID, "recordset", recordsetID, "record", recordID)
194
195 req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil)
196 if err != nil {
197 return err
198 }
199
200 return c.do(req, nil)
201 }
202
203 // FindZone looks for DNS Zone and returns nil if it does not exist.
204 func (c *Client) FindZone(ctx context.Context, name string) (*Zone, error) {
205 zones, err := c.GetZones(ctx)
206 if err != nil {
207 return nil, err
208 }
209
210 for _, zone := range zones {
211 if zone.DNSName == name {
212 return &zone, nil
213 }
214 }
215
216 return nil, fmt.Errorf("failed to find zone for %s", name)
217 }
218
219 // GetZones gets all user's zones.
220 // https://api.hyperone.com/v2/docs#operation/dns_project_zone_list
221 func (c *Client) GetZones(ctx context.Context) ([]Zone, error) {
222 // https://api.hyperone.com/v2/dns/{locationId}/project/{projectId}/zone
223 endpoint := c.baseURL.JoinPath("zone")
224
225 req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
226 if err != nil {
227 return nil, err
228 }
229
230 var zones []Zone
231
232 err = c.do(req, &zones)
233 if err != nil {
234 return nil, fmt.Errorf("failed to fetch available zones: %w", err)
235 }
236
237 return zones, nil
238 }
239
240 func (c *Client) do(req *http.Request, result any) error {
241 jwt, err := c.signer.GetJWT()
242 if err != nil {
243 return fmt.Errorf("failed to sign the request: %w", err)
244 }
245
246 req.Header.Set("Authorization", "Bearer "+jwt)
247
248 resp, err := c.HTTPClient.Do(req)
249 if err != nil {
250 return errutils.NewHTTPDoError(req, err)
251 }
252
253 defer func() { _ = resp.Body.Close() }()
254
255 if resp.StatusCode/100 != 2 {
256 return parseError(req, resp)
257 }
258
259 if result == nil {
260 return nil
261 }
262
263 raw, err := io.ReadAll(resp.Body)
264 if err != nil {
265 return errutils.NewReadResponseError(req, resp.StatusCode, err)
266 }
267
268 if err = json.Unmarshal(raw, result); err != nil {
269 return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
270 }
271
272 return nil
273 }
274
275 func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) {
276 buf := new(bytes.Buffer)
277
278 if payload != nil {
279 err := json.NewEncoder(buf).Encode(payload)
280 if err != nil {
281 return nil, fmt.Errorf("failed to create request JSON body: %w", err)
282 }
283 }
284
285 req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf)
286 if err != nil {
287 return nil, fmt.Errorf("unable to create request: %w", err)
288 }
289
290 req.Header.Set("Accept", "application/json")
291
292 if payload != nil {
293 req.Header.Set("Content-Type", "application/json")
294 }
295
296 return req, nil
297 }
298
299 func parseError(req *http.Request, resp *http.Response) error {
300 var msg string
301 if resp.StatusCode == http.StatusForbidden {
302 msg = "forbidden: check if service account you are trying to use has permissions required for managing DNS"
303 } else {
304 msg = "unknown error"
305 }
306
307 return fmt.Errorf("%s: %w", msg, errutils.NewUnexpectedResponseStatusCodeError(req, resp))
308 }
309