dnscontrol/providers/cloudns/api.go
Jakob Ackermann d1765b6f58
CLOUDNS: populate zone cache when creating zone (#3331)
Hi @pragmaton!
While reviewing all the `ZoneCreator` implementations, I noticed that
the CLOUDNS provider has an incomplete caching implementation for zones.
The provider is populating the cache once on first access. Any zones
that are created will not be readable in the same life-cycle of
dnscontrol. This PR is populating the zone cache after creating a zone.
Would you mind giving this a try and let me know how it goes? Thanks!

Part of https://github.com/StackExchange/dnscontrol/issues/3007
2025-12-03 08:36:00 -05:00

336 lines
9.4 KiB
Go

package cloudns
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"sync"
"golang.org/x/time/rate"
)
// Api layer for ClouDNS
type cloudnsProvider struct {
creds struct {
id string
password string
subid string
}
requestLimit *rate.Limiter
sync.Mutex // Protects all access to the following fields:
domainIndex map[string]string
nameserversNames []string
}
type requestParams map[string]string
type errorResponse struct {
Status string `json:"status"`
Description string `json:"statusDescription"`
}
type nameserverRecord struct {
Type string `json:"type"`
Name string `json:"name"`
}
type nameserverResponse []nameserverRecord
type zoneRecord struct {
Name string `json:"name"`
Type string `json:"type"`
Status string `json:"status"`
Zone string `json:"zone"`
}
type zoneResponse []zoneRecord
type domainRecord struct {
ID string `json:"id"`
Type string `json:"type"`
Host string `json:"host"`
Target string `json:"record"`
Priority string `json:"priority"`
Weight string `json:"weight"`
Port string `json:"port"`
Service string `json:"service"`
Protocol string `json:"protocol"`
TTL string `json:"ttl"`
Status int8 `json:"status"`
CaaFlag string `json:"caa_flag,omitempty"`
CaaTag string `json:"caa_type,omitempty"`
CaaValue string `json:"caa_value,omitempty"`
TlsaUsage string `json:"tlsa_usage,omitempty"`
TlsaSelector string `json:"tlsa_selector,omitempty"`
TlsaMatchingType string `json:"tlsa_matching_type,omitempty"`
SshfpAlgorithm string `json:"algorithm,omitempty"`
SshfpFingerprint string `json:"fp_type,omitempty"`
DsKeyTag string `json:"key_tag,omitempty"`
DsAlgorithm string `json:"dsalgorithm,omitempty"`
DsDigestType string `json:"digest_type,omitempty"`
DsDigest string `json:"dsdigest,omitempty"`
LocLatDeg string `json:"lat_deg,omitempty"`
LocLatMin string `json:"lat_min,omitempty"`
LocLatSec string `json:"lat_sec,omitempty"`
LocLatDir string `json:"lat_dir,omitempty"`
LocLongDeg string `json:"long_deg,omitempty"`
LocLongMin string `json:"long_min,omitempty"`
LocLongSec string `json:"long_sec,omitempty"`
LocLongDir string `json:"long_dir,omitempty"`
LocAltitude string `json:"altitude,omitempty"`
LocSize string `json:"size,omitempty"`
LocHPrecision string `json:"h_precision,omitempty"`
LocVPrecision string `json:"v_precision,omitempty"`
NaptrOrder string `json:"order,omitempty"`
NaptrPreference string `json:"pref,omitempty"`
NaptrFlags string `json:"flag,omitempty"`
NaptrService string `json:"params,omitempty"`
NaptrRegexp string `json:"regexp,omitempty"`
NaptrReplacement string `json:"replace,omitempty"`
}
type recordResponse map[string]domainRecord
func (c *cloudnsProvider) fetchAvailableNameservers() ([]string, error) {
c.Lock()
defer c.Unlock()
if c.nameserversNames == nil {
bodyString, err := c.get("/dns/available-name-servers.json", requestParams{})
if err != nil {
return nil, fmt.Errorf("failed fetching available nameservers list from ClouDNS: %w", err)
}
var nr nameserverResponse
if err := json.Unmarshal(bodyString, &nr); err != nil {
return nil, fmt.Errorf("failed to unmarshal available nameservers list from ClouDNS: %w", err)
}
for _, nameserver := range nr {
if nameserver.Type == "premium" {
c.nameserversNames = append(c.nameserversNames, nameserver.Name)
}
}
}
return c.nameserversNames, nil
}
func (c *cloudnsProvider) fetchAvailableTTLValues(domain string) ([]uint32, error) {
allowedTTLValues := make([]uint32, 0)
params := requestParams{
"domain-name": domain,
}
bodyString, err := c.get("/dns/get-available-ttl.json", params)
if err != nil {
return nil, fmt.Errorf("failed fetching available TTL values list from ClouDNS: %w", err)
}
if err := json.Unmarshal(bodyString, &allowedTTLValues); err != nil {
return nil, fmt.Errorf("failed to unmarshal available TTL values list from ClouDNS: %w", err)
}
return allowedTTLValues, nil
}
func (c *cloudnsProvider) fetchDomainIndex(name string) (string, bool, error) {
c.Lock()
defer c.Unlock()
if c.domainIndex == nil {
rowsPerPage := 100
page := 1
for {
var dr zoneResponse
params := requestParams{
"page": strconv.Itoa(page),
"rows-per-page": strconv.Itoa(rowsPerPage),
}
endpoint := "/dns/list-zones.json"
bodyString, err := c.get(endpoint, params)
if err != nil {
return "", false, fmt.Errorf("failed fetching domain list from ClouDNS: %w", err)
}
if err := json.Unmarshal(bodyString, &dr); err != nil {
return "", false, fmt.Errorf("failed to unmarshal domain list from ClouDNS: %w", err)
}
if c.domainIndex == nil {
c.domainIndex = map[string]string{}
}
for _, domain := range dr {
c.domainIndex[domain.Name] = domain.Name
}
if len(dr) < rowsPerPage {
break
}
page++
}
}
index, ok := c.domainIndex[name]
return index, ok, nil
}
func (c *cloudnsProvider) createDomain(domain string) error {
params := requestParams{
"domain-name": domain,
"zone-type": "master",
}
if _, err := c.get("/dns/register.json", params); err != nil {
return fmt.Errorf("failed create domain (ClouDNS): %w", err)
}
c.Lock()
defer c.Unlock()
if c.domainIndex != nil {
c.domainIndex[domain] = domain
}
return nil
}
func (c *cloudnsProvider) createRecord(domainID string, rec requestParams) error {
rec["domain-name"] = domainID
if _, err := c.get("/dns/add-record.json", rec); err != nil { // here we add record
return fmt.Errorf("failed create record (ClouDNS): %w", err)
}
return nil
}
func (c *cloudnsProvider) deleteRecord(domainID string, recordID string) error {
params := requestParams{
"domain-name": domainID,
"record-id": recordID,
}
if _, err := c.get("/dns/delete-record.json", params); err != nil {
return fmt.Errorf("failed delete record (ClouDNS): %w", err)
}
return nil
}
func (c *cloudnsProvider) modifyRecord(domainID string, recordID string, rec requestParams) error {
rec["domain-name"] = domainID
rec["record-id"] = recordID
if _, err := c.get("/dns/mod-record.json", rec); err != nil {
return fmt.Errorf("failed update (ClouDNS): %w", err)
}
return nil
}
func (c *cloudnsProvider) getRecords(id string) ([]domainRecord, error) {
params := requestParams{"domain-name": id}
bodyString, err := c.get("/dns/records.json", params)
if err != nil {
return nil, fmt.Errorf("failed fetching record list from ClouDNS: %w", err)
}
var dr recordResponse
if err := json.Unmarshal(bodyString, &dr); err != nil {
return nil, fmt.Errorf("failed to unmarshal record list from ClouDNS: %w", err)
}
var records []domainRecord
for _, rec := range dr {
records = append(records, rec)
}
return records, nil
}
func (c *cloudnsProvider) isDnssecEnabled(id string) (bool, error) {
params := requestParams{"domain-name": id}
bodyString, err := c.get("/dns/get-dnssec-ds-records.json", params)
if err != nil {
// DNSSEC disabled is indicated by an error fetching the DS records.
var errResp errorResponse
err = json.Unmarshal(bodyString, &errResp)
if err == nil {
if errResp.Description == "The DNSSEC is not active." {
return false, nil
}
return false, fmt.Errorf("failed fetching DS records from ClouDNS: %w", err)
}
}
return true, nil
}
func (c *cloudnsProvider) setDnssec(id string, enabled bool) error {
params := requestParams{"domain-name": id}
var endpoint string
if enabled {
endpoint = "/dns/activate-dnssec.json"
} else {
endpoint = "/dns/deactivate-dnssec.json"
}
_, err := c.get(endpoint, params)
if err != nil {
return fmt.Errorf("failed setting DNSSEC at ClouDNS: %w", err)
}
return nil
}
func (c *cloudnsProvider) get(endpoint string, params requestParams) ([]byte, error) {
client := &http.Client{}
req, _ := http.NewRequest(http.MethodGet, "https://api.cloudns.net"+endpoint, nil)
q := req.URL.Query()
// TODO: Support sub-auth-user https://asia.cloudns.net/wiki/article/42/
// Add auth params
q.Add("auth-id", c.creds.id)
q.Add("auth-password", c.creds.password)
q.Add("sub-auth-id", c.creds.subid)
for pName, pValue := range params {
q.Add(pName, pValue)
}
req.URL.RawQuery = q.Encode()
// ClouDNS has a rate limit (not documented) of 10 request/second
if err := c.requestLimit.Wait(context.Background()); err != nil {
return nil, err
}
resp, err := client.Do(req)
if err != nil {
return []byte{}, err
}
bodyString, _ := io.ReadAll(resp.Body)
// Got error from API ?
var errResp errorResponse
err = json.Unmarshal(bodyString, &errResp)
if err == nil {
if errResp.Status == "Failed" {
// For debug only - req.URL.RequestURI() contains the authentication params:
// return bodyString, fmt.Errorf("ClouDNS API error: %s URL:%s%s ", errResp.Description, req.Host, req.URL.RequestURI())
return bodyString, fmt.Errorf("ClouDNS API error: %s", errResp.Description)
}
}
return bodyString, nil
}
func fixTTL(allowedTTLValues []uint32, ttl uint32) uint32 {
// if the TTL is larger than the largest allowed value, return the largest allowed value
if ttl > allowedTTLValues[len(allowedTTLValues)-1] {
return allowedTTLValues[len(allowedTTLValues)-1]
}
for _, v := range allowedTTLValues {
if v >= ttl {
return v
}
}
return allowedTTLValues[0]
}