dnscontrol/providers/loopia/client.go
Tom Limoncelli 31765b0bef
LOOPIA: Linting corrections (#2175)
Co-authored-by: Tom Limoncelli <tlimoncelli@stackoverflow.com>
2023-03-16 13:57:18 -04:00

564 lines
14 KiB
Go

package loopia
import (
"bytes"
"encoding/xml"
"errors"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
"golang.org/x/text/cases"
"golang.org/x/text/language"
)
/*
Loopia domain structure for a domain called "apex":
-apex
+@SOA inaccessible via API
+@zoneRecord * ... <-- use getZoneRecords(... domain: "apex", subdomain: "*")
+@zoneRecord [NS1,NS2,TXT,TXT,A,AAAA,MX,NAPTR,etc] ... <-- use getZoneRecords(... domain: "apex", subdomain: "@")
+subdomain1 <-- use getSubdomains(... domain: "apex")
+zoneRecord ... <-- use getZoneRecords(... domain: "apex", subdomain: "subdomain1")
+subdomain2
+zoneRecord ... <-- use getZoneRecords(... domain: "apex", subdomain: "subdomain2")
+subsubdomain1.subdomain3
+zoneRecord ... <-- use getZoneRecords(... domain: "apex", subdomain: "subsubdomain1.subdomain3")
Note: wildcard '*' means "everything else not already defined"
getZoneRecords(... domain: "apex", subdomain: "@") returns only all @zoneRecords at the domain: "apex" level
getSubdomains(... domain: "apex") returns only all (sub)subdomains at the apex level
To build a complete local "existing/desired" zone of domain: "apex" requires at a minimum,
calls to getSubdomains, and getZoneRecords per subdomain.
*/
/*
Loopia available API functions (not necessarily implemented here):
addDomain
addSubdomain
addZoneRecord
getDomain
getDomains
getSubdomains
getZoneRecords
removeDomain
removeSubdomain
removeZoneRecord
updateDNSServers
updateZoneRecord
domainIsFree
getCreditsAmount
getInvoice
getUnpaidInvoices
orderDomain
payInvoiceUsingCredits
transferDomain
Loopia available API return (object) types:
account_type
contact
create_account_status_obj
customer_obj
domain_configuration
domain_obj
order_status
order_status_obj
invoice_obj
invoice_item_obj
record_obj
status
*/
// DefaultBaseNOURL and others are RPC end-points.
const (
DefaultBaseNOURL = "https://api.loopia.no/RPCSERV"
DefaultBaseRSURL = "https://api.loopia.rs/RPCSERV"
DefaultBaseSEURL = "https://api.loopia.se/RPCSERV"
)
// defaultNS1 and defaultNS2 are default NS records.
const (
defaultNS1 = "ns1.loopia.se."
defaultNS2 = "ns2.loopia.se."
)
// Section 2: Define the API client.
// APIClient is the APIClient handle used to store any client-related state.
type APIClient struct {
APIUser string
APIPassword string
BaseURL string
HTTPClient *http.Client
ModifyNameServers bool
FetchNSEntries bool
Debug bool
requestRateLimiter requestRateLimiter
}
// NewClient creates a new LoopiaClient.
func NewClient(apiUser, apiPassword string, region string, modifyns bool, fetchns bool, debug bool) *APIClient {
// DefaultBaseURL is url to the XML-RPC api.
var DefaultBaseURL string
switch region {
case "no":
DefaultBaseURL = DefaultBaseNOURL
case "rs":
DefaultBaseURL = DefaultBaseRSURL
case "se":
DefaultBaseURL = DefaultBaseSEURL
default:
DefaultBaseURL = DefaultBaseSEURL
}
return &APIClient{
APIUser: apiUser,
APIPassword: apiPassword,
BaseURL: DefaultBaseURL,
HTTPClient: &http.Client{Timeout: 10 * time.Second},
ModifyNameServers: modifyns,
FetchNSEntries: fetchns,
Debug: debug,
}
}
//CRUD: Create, Read, Update, Delete
//Create
// CreateRecordSimulate only prints info about a record addition. Used for debugging.
func (c *APIClient) CreateRecordSimulate(domain string, subdomain string, record paramStruct) error {
if c.Debug {
fmt.Printf("create: domain: %s; subdomain: %s; record: %+v\n", domain, subdomain, record)
}
return nil
}
// CreateRecord adds a record.
func (c *APIClient) CreateRecord(domain string, subdomain string, record paramStruct) error {
call := &methodCall{
MethodName: "addZoneRecord",
Params: []param{
paramString{Value: c.APIUser},
paramString{Value: c.APIPassword},
paramString{Value: domain},
paramString{Value: subdomain},
record,
},
}
resp := &responseString{}
err := c.rpcCall(call, resp)
if err != nil {
return err
}
return checkResponse(resp.Value)
}
//CRUD: Create, Read, Update, Delete
//Read
// getDomains lists all domains.
func (c *APIClient) getDomains() ([]domainObject, error) {
call := &methodCall{
MethodName: "getDomains",
Params: []param{
paramString{Value: c.APIUser},
paramString{Value: c.APIPassword},
},
}
//domainObjectsResponse is basically a zoneRecordsResponse
resp := &domainObjectsResponse{}
err := c.rpcCall(call, resp)
return resp.Domains, err
}
// getDomainRecords gets all records for a subdomain
func (c *APIClient) getDomainRecords(domain string, subdomain string) ([]zoneRecord, error) {
call := &methodCall{
MethodName: "getZoneRecords",
Params: []param{
paramString{Value: c.APIUser},
paramString{Value: c.APIPassword},
paramString{Value: domain},
paramString{Value: subdomain},
},
}
resp := &zoneRecordsResponse{}
err := c.rpcCall(call, resp)
return resp.ZoneRecords, err
}
// GetSubDomains gets all the subdomains within a domain, no records
func (c *APIClient) GetSubDomains(domain string) ([]string, error) {
call := &methodCall{
MethodName: "getSubdomains",
Params: []param{
paramString{Value: c.APIUser},
paramString{Value: c.APIPassword},
paramString{Value: domain},
},
}
resp := &subDomainsResponse{}
err := c.rpcCall(call, resp)
return resp.Params, err
}
// GetDomainNS gets all NS records for a subdomain, in this case, the apex "@"
func (c *APIClient) GetDomainNS(domain string) ([]string, error) {
if c.ModifyNameServers {
return nil, nil
}
if c.FetchNSEntries {
return []string{defaultNS1, defaultNS2}, nil
}
//fetch from the domain - an extra API call.
call := &methodCall{
MethodName: "getZoneRecords",
Params: []param{
paramString{Value: c.APIUser},
paramString{Value: c.APIPassword},
paramString{Value: domain},
paramString{Value: "@"},
},
}
resp := &zoneRecordsResponse{}
apexNSRecords := []string{}
err := c.rpcCall(call, resp)
if err != nil {
return nil, err
}
if c.Debug {
fmt.Printf("DEBUG: getZoneRecords(@) START\n")
}
for i, rec := range resp.ZoneRecords {
ns := rec.GetZR()
if ns.Type == "NS" {
apexNSRecords = append(apexNSRecords, ns.Rdata)
if c.Debug {
fmt.Printf("DEBUG: HERE %d: %v\n", i, ns)
}
}
}
return apexNSRecords, err
}
//CRUD: Create, Read, Update, Delete
//Update
// UpdateRecordSimulate only prints info about a record update. Used for debugging.
func (c *APIClient) UpdateRecordSimulate(domain string, subdomain string, rec paramStruct) error {
fmt.Printf("got update: domain: %s; subdomain: %s; record: %v\n", domain, subdomain, rec)
return nil
}
// UpdateRecord updates a record.
func (c *APIClient) UpdateRecord(domain string, subdomain string, rec paramStruct) error {
call := &methodCall{
MethodName: "updateZoneRecord",
Params: []param{
paramString{Value: c.APIUser},
paramString{Value: c.APIPassword},
paramString{Value: domain},
paramString{Value: subdomain},
rec,
// // alternatively:
// paramStruct{
// StructMembers: []structMember{
// structMemberString{Name: "type", Value: rtype},
// structMemberInt{Name: "ttl", Value: ttl},
// structMemberInt{Name: "priority", Value: prio},
// structMemberString{Name: "rdata", Value: value},
// structMemberInt{Name: "record_id", Value: id},
// },
// },
},
}
resp := &responseString{}
err := c.rpcCall(call, resp)
if err != nil {
return err
}
return checkResponse(resp.Value)
}
//CRUD: Create, Read, Update, Delete
//Delete
// DeleteRecordSimulate only prints info about a record deletion. Used for debugging.
func (c *APIClient) DeleteRecordSimulate(domain string, subdomain string, recordID uint32) error {
fmt.Printf("delete: domain: %s; subdomain: %s; recordID: %d\n", domain, subdomain, recordID)
return nil
}
// DeleteRecord deletes a record.
func (c *APIClient) DeleteRecord(domain string, subdomain string, recordID uint32) error {
call := &methodCall{
MethodName: "removeZoneRecord",
Params: []param{
paramString{Value: c.APIUser},
paramString{Value: c.APIPassword},
paramString{Value: domain},
paramString{Value: subdomain},
paramInt{Value: recordID},
},
}
resp := &responseString{}
err := c.rpcCall(call, resp)
if err != nil {
return err
}
return checkResponse(resp.Value)
}
// DeleteSubdomain deletes a sub-domain and its child records.
func (c *APIClient) DeleteSubdomain(domain, subdomain string) error {
call := &methodCall{
MethodName: "removeSubdomain",
Params: []param{
paramString{Value: c.APIUser},
paramString{Value: c.APIPassword},
paramString{Value: domain},
paramString{Value: subdomain},
},
}
resp := &responseString{}
err := c.rpcCall(call, resp)
if err != nil {
return err
}
return checkResponse(resp.Value)
}
// rpcCall makes an XML-RPC call to Loopia's RPC endpoint
// by marshaling the data given in the call argument to XML and sending that via HTTP Post to Loopia.
// The response is then unmarshalled into the resp argument.
func (c *APIClient) rpcCall(call *methodCall, resp response) error {
callBody, err := xml.MarshalIndent(call, "", " ")
if err != nil {
return fmt.Errorf("error marshalling the API request XML callBody: %w", err)
}
callBody = append([]byte(`<?xml version="1.0"?>`+"\n"), callBody...)
if c.Debug {
fmt.Print(string(callBody))
fmt.Printf("\n")
}
respBody, err := c.httpPost(c.BaseURL, "text/xml", bytes.NewReader(callBody))
if err != nil {
return err
}
if c.Debug {
fmt.Print(string(respBody))
fmt.Printf("\n")
}
err = xml.Unmarshal(respBody, resp)
if err != nil {
return fmt.Errorf("error unmarshalling the API response XML body: %w", err)
}
//yes - loopia are stoopid - the 429 error code comes from the DB behind the http proxy
c.requestRateLimiter.handleXMLResponse(resp)
if resp.faultCode() == 429 {
fmt.Printf("XMLresp: %+v\n", resp)
c.requestRateLimiter.handleRateLimitedRequest()
} else if resp.faultCode() != 0 {
return rpcError{
faultCode: resp.faultCode(),
faultString: strings.TrimSpace(resp.faultString()),
}
}
return nil
}
func (c *APIClient) httpPost(url string, bodyType string, body io.Reader) ([]byte, error) {
c.requestRateLimiter.beforeRequest()
resp, err := c.HTTPClient.Post(url, bodyType, body)
c.requestRateLimiter.afterRequest()
if err != nil {
return nil, fmt.Errorf("HTTP Post Error: %w", err)
}
cleanupResponseBody := func() {
err := resp.Body.Close()
if err != nil {
fmt.Printf("failed closing response body: %q\n", err)
}
}
c.requestRateLimiter.handleResponse(*resp)
// retry the request when rate-limited
if resp.StatusCode == 429 {
c.requestRateLimiter.handleRateLimitedRequest()
cleanupResponseBody()
} else if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("HTTP Post Error: %d", resp.StatusCode)
}
defer cleanupResponseBody()
b, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("HTTP Post Error: %w", err)
}
return b, nil
}
func checkResponse(value string) error {
switch v := strings.TrimSpace(value); v {
case "OK":
return nil
case "AUTH_ERROR":
return errors.New("authentication error")
default:
return fmt.Errorf("unknown error: %q", v)
}
}
// Rate limiting taken from Hetzner implementation. v nice.
func getHomogenousDelay(headers http.Header, quotaName string) (time.Duration, error) {
//Loopia, to my knowledge, are useless, and do not include such headers.
//In the event that they one day do, use this.
quota, err := parseHeaderAsInt(headers, "X-Ratelimit-Limit-"+cases.Title(language.Und, cases.NoLower).String((quotaName)))
if err != nil {
return 0, err
}
var unit time.Duration
switch quotaName {
case "hour":
unit = time.Hour
case "minute":
unit = time.Minute
case "second":
unit = time.Second
}
delay := time.Duration(int64(unit) / quota)
return delay, nil
}
func getRetryAfterDelay(header http.Header) (time.Duration, error) {
retryAfter, err := parseHeaderAsInt(header, "Retry-After")
if err != nil {
return 0, err
}
delay := time.Duration(retryAfter * int64(time.Second))
return delay, nil
}
func parseHeaderAsInt(headers http.Header, headerName string) (int64, error) {
value, ok := headers[headerName]
if !ok {
return 0, fmt.Errorf("header %q is missing", headerName)
}
return strconv.ParseInt(value[0], 10, 0)
}
type requestRateLimiter struct {
delay time.Duration
lastRequest time.Time
rateLimitPer string
}
func (requestRateLimiter *requestRateLimiter) afterRequest() {
requestRateLimiter.lastRequest = time.Now()
}
func (requestRateLimiter *requestRateLimiter) beforeRequest() {
if requestRateLimiter.delay == 0 {
return
}
time.Sleep(time.Until(requestRateLimiter.lastRequest.Add(requestRateLimiter.delay)))
}
func (requestRateLimiter *requestRateLimiter) setDefaultDelay() {
// default to a rate-limit of 1 req/s -- subsequent responses should update it.
requestRateLimiter.delay = time.Second
}
func (requestRateLimiter *requestRateLimiter) setRateLimitPer(quota string) error {
quotaNormalized := strings.ToLower(quota)
switch quotaNormalized {
case "hour", "minute", "second":
requestRateLimiter.rateLimitPer = quotaNormalized
case "":
requestRateLimiter.rateLimitPer = "second"
default:
return fmt.Errorf("%q is not a valid quota, expected 'Hour', 'Minute', 'Second' or unset", quota)
}
return nil
}
func (requestRateLimiter *requestRateLimiter) handleRateLimitedRequest() {
message := "Rate-Limited, consider bumping the setting 'rate_limit_per': %q -> %q"
switch requestRateLimiter.rateLimitPer {
case "hour":
message = "Rate-Limited, you are already using the slowest request rate. Consider contacting Loopia Support to change this."
case "minute":
message = fmt.Sprintf(message, "Minute", "Hour")
case "second":
message = fmt.Sprintf(message, "Second", "Minute")
}
fmt.Print(message)
}
func (requestRateLimiter *requestRateLimiter) handleResponse(resp http.Response) {
homogenousDelay, err := getHomogenousDelay(resp.Header, requestRateLimiter.rateLimitPer)
if err != nil {
requestRateLimiter.setDefaultDelay()
return
}
delay := homogenousDelay
if resp.StatusCode == 429 {
retryAfterDelay, err := getRetryAfterDelay(resp.Header)
if err == nil {
delay = retryAfterDelay
}
}
requestRateLimiter.delay = delay
}
func (requestRateLimiter *requestRateLimiter) handleXMLResponse(resp response) {
requestRateLimiter.setDefaultDelay()
if resp.faultCode() == 429 {
requestRateLimiter.delay = 60
}
}