lego/providers/dns/clouddns/internal/client.go
2023-05-05 09:49:38 +02:00

241 lines
5.4 KiB
Go

package internal
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"time"
"github.com/go-acme/lego/v4/providers/dns/internal/errutils"
)
const apiBaseURL = "https://admin.vshosting.cloud/clouddns"
const authorizationHeader = "Authorization"
// Client handles all communication with CloudDNS API.
type Client struct {
clientID string
email string
password string
ttl int
apiBaseURL *url.URL
loginURL *url.URL
HTTPClient *http.Client
}
// NewClient returns a Client instance configured to handle CloudDNS API communication.
func NewClient(clientID, email, password string, ttl int) *Client {
baseURL, _ := url.Parse(apiBaseURL)
loginBaseURL, _ := url.Parse(loginURL)
return &Client{
clientID: clientID,
email: email,
password: password,
ttl: ttl,
apiBaseURL: baseURL,
loginURL: loginBaseURL,
HTTPClient: &http.Client{Timeout: 5 * time.Second},
}
}
// AddRecord is a high level method to add a new record into CloudDNS zone.
func (c *Client) AddRecord(ctx context.Context, zone, recordName, recordValue string) error {
domain, err := c.getDomain(ctx, zone)
if err != nil {
return err
}
record := Record{DomainID: domain.ID, Name: recordName, Value: recordValue, Type: "TXT"}
err = c.addTxtRecord(ctx, record)
if err != nil {
return err
}
return c.publishRecords(ctx, domain.ID)
}
// DeleteRecord is a high level method to remove a record from zone.
func (c *Client) DeleteRecord(ctx context.Context, zone, recordName string) error {
domain, err := c.getDomain(ctx, zone)
if err != nil {
return err
}
record, err := c.getRecord(ctx, domain.ID, recordName)
if err != nil {
return err
}
err = c.deleteRecord(ctx, record)
if err != nil {
return err
}
return c.publishRecords(ctx, domain.ID)
}
func (c *Client) addTxtRecord(ctx context.Context, record Record) error {
endpoint := c.apiBaseURL.JoinPath("record-txt")
req, err := newJSONRequest(ctx, http.MethodPost, endpoint, record)
if err != nil {
return err
}
return c.do(req, nil)
}
func (c *Client) deleteRecord(ctx context.Context, record Record) error {
endpoint := c.apiBaseURL.JoinPath("record", record.ID)
req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil)
if err != nil {
return err
}
return c.do(req, nil)
}
func (c *Client) getDomain(ctx context.Context, zone string) (Domain, error) {
searchQuery := SearchQuery{
Search: []Search{
{Name: "clientId", Operator: "eq", Value: c.clientID},
{Name: "domainName", Operator: "eq", Value: zone},
},
}
endpoint := c.apiBaseURL.JoinPath("domain", "search")
req, err := newJSONRequest(ctx, http.MethodPost, endpoint, searchQuery)
if err != nil {
return Domain{}, err
}
var result SearchResponse
err = c.do(req, &result)
if err != nil {
return Domain{}, err
}
if len(result.Items) == 0 {
return Domain{}, fmt.Errorf("domain not found: %s", zone)
}
return result.Items[0], nil
}
func (c *Client) getRecord(ctx context.Context, domainID, recordName string) (Record, error) {
endpoint := c.apiBaseURL.JoinPath("domain", domainID)
req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return Record{}, err
}
var result DomainInfo
err = c.do(req, &result)
if err != nil {
return Record{}, err
}
for _, record := range result.LastDomainRecordList {
if record.Name == recordName && record.Type == "TXT" {
return record, nil
}
}
return Record{}, fmt.Errorf("record not found: domainID %s, name %s", domainID, recordName)
}
func (c *Client) publishRecords(ctx context.Context, domainID string) error {
endpoint := c.apiBaseURL.JoinPath("domain", domainID, "publish")
payload := DomainInfo{SoaTTL: c.ttl}
req, err := newJSONRequest(ctx, http.MethodPut, endpoint, payload)
if err != nil {
return err
}
return c.do(req, nil)
}
func (c *Client) do(req *http.Request, result any) error {
at := getAccessToken(req.Context())
if at != "" {
req.Header.Set(authorizationHeader, "Bearer "+at)
}
resp, err := c.HTTPClient.Do(req)
if err != nil {
return errutils.NewHTTPDoError(req, err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode/100 != 2 {
return parseError(req, resp)
}
if result == nil {
return nil
}
raw, err := io.ReadAll(resp.Body)
if err != nil {
return errutils.NewReadResponseError(req, resp.StatusCode, err)
}
err = json.Unmarshal(raw, result)
if err != nil {
return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
}
return nil
}
func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) {
buf := new(bytes.Buffer)
if payload != nil {
err := json.NewEncoder(buf).Encode(payload)
if err != nil {
return nil, fmt.Errorf("failed to create request JSON body: %w", err)
}
}
req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf)
if err != nil {
return nil, fmt.Errorf("unable to create request: %w", err)
}
req.Header.Set("Accept", "application/json")
if payload != nil {
req.Header.Set("Content-Type", "application/json")
}
return req, nil
}
func parseError(req *http.Request, resp *http.Response) error {
raw, _ := io.ReadAll(resp.Body)
var response APIError
err := json.Unmarshal(raw, &response)
if err != nil {
return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw)
}
return fmt.Errorf("[status code %d] %w", resp.StatusCode, response.Error)
}