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