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