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