client.go raw

   1  package internal
   2  
   3  import (
   4  	"context"
   5  	"encoding/json"
   6  	"errors"
   7  	"fmt"
   8  	"io"
   9  	"net/http"
  10  	"net/url"
  11  	"strconv"
  12  	"time"
  13  
  14  	"github.com/go-acme/lego/v4/challenge/dns01"
  15  	"github.com/go-acme/lego/v4/providers/dns/internal/errutils"
  16  )
  17  
  18  const defaultBaseURL = "https://api.cloudns.net/dns/"
  19  
  20  // Client the ClouDNS client.
  21  type Client struct {
  22  	authID       string
  23  	subAuthID    string
  24  	authPassword string
  25  
  26  	BaseURL    *url.URL
  27  	HTTPClient *http.Client
  28  }
  29  
  30  // NewClient creates a ClouDNS client.
  31  func NewClient(authID, subAuthID, authPassword string) (*Client, error) {
  32  	if authID == "" && subAuthID == "" {
  33  		return nil, errors.New("credentials missing: authID or subAuthID")
  34  	}
  35  
  36  	if authPassword == "" {
  37  		return nil, errors.New("credentials missing: authPassword")
  38  	}
  39  
  40  	baseURL, err := url.Parse(defaultBaseURL)
  41  	if err != nil {
  42  		return nil, err
  43  	}
  44  
  45  	return &Client{
  46  		authID:       authID,
  47  		subAuthID:    subAuthID,
  48  		authPassword: authPassword,
  49  		BaseURL:      baseURL,
  50  		HTTPClient:   &http.Client{Timeout: 10 * time.Second},
  51  	}, nil
  52  }
  53  
  54  // GetZone Get domain name information for a FQDN.
  55  func (c *Client) GetZone(ctx context.Context, authFQDN string) (*Zone, error) {
  56  	authZone, err := dns01.FindZoneByFqdn(authFQDN)
  57  	if err != nil {
  58  		return nil, fmt.Errorf("could not find zone: %w", err)
  59  	}
  60  
  61  	authZoneName := dns01.UnFqdn(authZone)
  62  
  63  	endpoint := c.BaseURL.JoinPath("get-zone-info.json")
  64  
  65  	q := endpoint.Query()
  66  	q.Set("domain-name", authZoneName)
  67  	endpoint.RawQuery = q.Encode()
  68  
  69  	req, err := c.newRequest(ctx, http.MethodGet, endpoint)
  70  	if err != nil {
  71  		return nil, err
  72  	}
  73  
  74  	rawMessage, err := c.do(req)
  75  	if err != nil {
  76  		return nil, err
  77  	}
  78  
  79  	var zone Zone
  80  
  81  	if len(rawMessage) > 0 {
  82  		if err = json.Unmarshal(rawMessage, &zone); err != nil {
  83  			return nil, errutils.NewUnmarshalError(req, http.StatusOK, rawMessage, err)
  84  		}
  85  	}
  86  
  87  	if zone.Name == authZoneName {
  88  		return &zone, nil
  89  	}
  90  
  91  	return nil, fmt.Errorf("zone %s not found for authFQDN %s", authZoneName, authFQDN)
  92  }
  93  
  94  // FindTxtRecord returns the TXT record a zone ID and a FQDN.
  95  func (c *Client) FindTxtRecord(ctx context.Context, zoneName, fqdn string) (*TXTRecord, error) {
  96  	subDomain, err := dns01.ExtractSubDomain(fqdn, zoneName)
  97  	if err != nil {
  98  		return nil, err
  99  	}
 100  
 101  	endpoint := c.BaseURL.JoinPath("records.json")
 102  
 103  	q := endpoint.Query()
 104  	q.Set("domain-name", zoneName)
 105  	q.Set("host", subDomain)
 106  	q.Set("type", "TXT")
 107  	endpoint.RawQuery = q.Encode()
 108  
 109  	req, err := c.newRequest(ctx, http.MethodGet, endpoint)
 110  	if err != nil {
 111  		return nil, err
 112  	}
 113  
 114  	rawMessage, err := c.do(req)
 115  	if err != nil {
 116  		return nil, err
 117  	}
 118  
 119  	// the API returns [] when there is no records.
 120  	if string(rawMessage) == "[]" {
 121  		return nil, nil
 122  	}
 123  
 124  	var records map[string]TXTRecord
 125  	if err = json.Unmarshal(rawMessage, &records); err != nil {
 126  		return nil, errutils.NewUnmarshalError(req, http.StatusOK, rawMessage, err)
 127  	}
 128  
 129  	for _, record := range records {
 130  		if record.Host == subDomain && record.Type == "TXT" {
 131  			return &record, nil
 132  		}
 133  	}
 134  
 135  	return nil, nil
 136  }
 137  
 138  // ListTxtRecords returns the TXT records a zone ID and a FQDN.
 139  func (c *Client) ListTxtRecords(ctx context.Context, zoneName, fqdn string) ([]TXTRecord, error) {
 140  	subDomain, err := dns01.ExtractSubDomain(fqdn, zoneName)
 141  	if err != nil {
 142  		return nil, err
 143  	}
 144  
 145  	endpoint := c.BaseURL.JoinPath("records.json")
 146  
 147  	q := endpoint.Query()
 148  	q.Set("domain-name", zoneName)
 149  	q.Set("host", subDomain)
 150  	q.Set("type", "TXT")
 151  	endpoint.RawQuery = q.Encode()
 152  
 153  	req, err := c.newRequest(ctx, http.MethodGet, endpoint)
 154  	if err != nil {
 155  		return nil, err
 156  	}
 157  
 158  	rawMessage, err := c.do(req)
 159  	if err != nil {
 160  		return nil, err
 161  	}
 162  
 163  	// the API returns [] when there is no records.
 164  	if string(rawMessage) == "[]" {
 165  		return nil, nil
 166  	}
 167  
 168  	var raw map[string]TXTRecord
 169  	if err = json.Unmarshal(rawMessage, &raw); err != nil {
 170  		return nil, errutils.NewUnmarshalError(req, http.StatusOK, rawMessage, err)
 171  	}
 172  
 173  	var records []TXTRecord
 174  
 175  	for _, record := range raw {
 176  		if record.Host == subDomain && record.Type == "TXT" {
 177  			records = append(records, record)
 178  		}
 179  	}
 180  
 181  	return records, nil
 182  }
 183  
 184  // AddTxtRecord adds a TXT record.
 185  func (c *Client) AddTxtRecord(ctx context.Context, zoneName, fqdn, value string, ttl int) error {
 186  	subDomain, err := dns01.ExtractSubDomain(fqdn, zoneName)
 187  	if err != nil {
 188  		return err
 189  	}
 190  
 191  	endpoint := c.BaseURL.JoinPath("add-record.json")
 192  
 193  	q := endpoint.Query()
 194  	q.Set("domain-name", zoneName)
 195  	q.Set("host", subDomain)
 196  	q.Set("record", value)
 197  	q.Set("ttl", strconv.Itoa(ttlRounder(ttl)))
 198  	q.Set("record-type", "TXT")
 199  	endpoint.RawQuery = q.Encode()
 200  
 201  	req, err := c.newRequest(ctx, http.MethodPost, endpoint)
 202  	if err != nil {
 203  		return err
 204  	}
 205  
 206  	rawMessage, err := c.do(req)
 207  	if err != nil {
 208  		return err
 209  	}
 210  
 211  	resp := apiResponse{}
 212  	if err = json.Unmarshal(rawMessage, &resp); err != nil {
 213  		return errutils.NewUnmarshalError(req, http.StatusOK, rawMessage, err)
 214  	}
 215  
 216  	if resp.Status != "Success" {
 217  		return fmt.Errorf("failed to add TXT record: %s %s", resp.Status, resp.StatusDescription)
 218  	}
 219  
 220  	return nil
 221  }
 222  
 223  // RemoveTxtRecord removes a TXT record.
 224  func (c *Client) RemoveTxtRecord(ctx context.Context, recordID int, zoneName string) error {
 225  	endpoint := c.BaseURL.JoinPath("delete-record.json")
 226  
 227  	q := endpoint.Query()
 228  	q.Set("domain-name", zoneName)
 229  	q.Set("record-id", strconv.Itoa(recordID))
 230  	endpoint.RawQuery = q.Encode()
 231  
 232  	req, err := c.newRequest(ctx, http.MethodPost, endpoint)
 233  	if err != nil {
 234  		return err
 235  	}
 236  
 237  	rawMessage, err := c.do(req)
 238  	if err != nil {
 239  		return err
 240  	}
 241  
 242  	resp := apiResponse{}
 243  	if err = json.Unmarshal(rawMessage, &resp); err != nil {
 244  		return errutils.NewUnmarshalError(req, http.StatusOK, rawMessage, err)
 245  	}
 246  
 247  	if resp.Status != "Success" {
 248  		return fmt.Errorf("failed to remove TXT record: %s %s", resp.Status, resp.StatusDescription)
 249  	}
 250  
 251  	return nil
 252  }
 253  
 254  // GetUpdateStatus gets sync progress of all CloudDNS NS servers.
 255  func (c *Client) GetUpdateStatus(ctx context.Context, zoneName string) (*SyncProgress, error) {
 256  	endpoint := c.BaseURL.JoinPath("update-status.json")
 257  
 258  	q := endpoint.Query()
 259  	q.Set("domain-name", zoneName)
 260  	endpoint.RawQuery = q.Encode()
 261  
 262  	req, err := c.newRequest(ctx, http.MethodGet, endpoint)
 263  	if err != nil {
 264  		return nil, err
 265  	}
 266  
 267  	rawMessage, err := c.do(req)
 268  	if err != nil {
 269  		return nil, err
 270  	}
 271  
 272  	// the API returns [] when there is no records.
 273  	if string(rawMessage) == "[]" {
 274  		return nil, errors.New("no nameservers records returned")
 275  	}
 276  
 277  	var records []UpdateRecord
 278  	if err = json.Unmarshal(rawMessage, &records); err != nil {
 279  		return nil, errutils.NewUnmarshalError(req, http.StatusOK, rawMessage, err)
 280  	}
 281  
 282  	updatedCount := 0
 283  
 284  	for _, record := range records {
 285  		if record.Updated {
 286  			updatedCount++
 287  		}
 288  	}
 289  
 290  	return &SyncProgress{Complete: updatedCount == len(records), Updated: updatedCount, Total: len(records)}, nil
 291  }
 292  
 293  func (c *Client) newRequest(ctx context.Context, method string, endpoint *url.URL) (*http.Request, error) {
 294  	q := endpoint.Query()
 295  
 296  	if c.subAuthID != "" {
 297  		q.Set("sub-auth-id", c.subAuthID)
 298  	} else {
 299  		q.Set("auth-id", c.authID)
 300  	}
 301  
 302  	q.Set("auth-password", c.authPassword)
 303  
 304  	endpoint.RawQuery = q.Encode()
 305  
 306  	req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), nil)
 307  	if err != nil {
 308  		return nil, fmt.Errorf("unable to create request: %w", err)
 309  	}
 310  
 311  	return req, nil
 312  }
 313  
 314  func (c *Client) do(req *http.Request) (json.RawMessage, error) {
 315  	resp, err := c.HTTPClient.Do(req)
 316  	if err != nil {
 317  		return nil, errutils.NewHTTPDoError(req, err)
 318  	}
 319  
 320  	defer func() { _ = resp.Body.Close() }()
 321  
 322  	if resp.StatusCode != http.StatusOK {
 323  		return nil, errutils.NewUnexpectedResponseStatusCodeError(req, resp)
 324  	}
 325  
 326  	raw, err := io.ReadAll(resp.Body)
 327  	if err != nil {
 328  		return nil, errutils.NewReadResponseError(req, resp.StatusCode, err)
 329  	}
 330  
 331  	return raw, nil
 332  }
 333  
 334  // Rounds the given TTL in seconds to the next accepted value.
 335  // Accepted TTL values are:
 336  //   - 60 = 1 minute
 337  //   - 300 = 5 minutes
 338  //   - 900 = 15 minutes
 339  //   - 1800 = 30 minutes
 340  //   - 3600 = 1 hour
 341  //   - 21600 = 6 hours
 342  //   - 43200 = 12 hours
 343  //   - 86400 = 1 day
 344  //   - 172800 = 2 days
 345  //   - 259200 = 3 days
 346  //   - 604800 = 1 week
 347  //   - 1209600 = 2 weeks
 348  //   - 2592000 = 1 month
 349  //
 350  // See https://www.cloudns.net/wiki/article/58/ for details.
 351  func ttlRounder(ttl int) int {
 352  	for _, validTTL := range []int{60, 300, 900, 1800, 3600, 21600, 43200, 86400, 172800, 259200, 604800, 1209600} {
 353  		if ttl <= validTTL {
 354  			return validTTL
 355  		}
 356  	}
 357  
 358  	return 2592000
 359  }
 360