mirror of
https://github.com/StackExchange/dnscontrol.git
synced 2024-05-11 05:55:12 +00:00
New provider: Loopia DNS service provider (#2140)
Co-authored-by: Tom Limoncelli <tlimoncelli@stackoverflow.com>
This commit is contained in:
558
providers/loopia/client.go
Normal file
558
providers/loopia/client.go
Normal file
@ -0,0 +1,558 @@
|
||||
package loopia
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"golang.org/x/text/cases"
|
||||
"golang.org/x/text/language"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
/*
|
||||
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
|
||||
|
||||
*/
|
||||
|
||||
const (
|
||||
DefaultBaseNOURL = "https://api.loopia.no/RPCSERV"
|
||||
DefaultBaseRSURL = "https://api.loopia.rs/RPCSERV"
|
||||
DefaultBaseSEURL = "https://api.loopia.se/RPCSERV"
|
||||
defaultNS1 = "ns1.loopia.se."
|
||||
defaultNS2 = "ns2.loopia.se."
|
||||
)
|
||||
|
||||
// Section 2: Define the API client.
|
||||
|
||||
// LoopiaClient is the LoopiaClient handle used to store any client-related state.
|
||||
type LoopiaClient 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) *LoopiaClient {
|
||||
// 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 &LoopiaClient{
|
||||
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 *LoopiaClient) 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 *LoopiaClient) 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 *LoopiaClient) 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 *LoopiaClient) 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 *LoopiaClient) 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 *LoopiaClient) GetDomainNS(domain string) ([]string, error) {
|
||||
if c.ModifyNameServers {
|
||||
return []string{}, nil
|
||||
} else {
|
||||
if c.FetchNSEntries {
|
||||
return []string{defaultNS1, defaultNS2}, nil
|
||||
} else {
|
||||
//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
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
//CRUD: Create, Read, Update, Delete
|
||||
//Update
|
||||
|
||||
// UpdateRecordSimulate only prints info about a record update. Used for debugging.
|
||||
func (c *LoopiaClient) 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 *LoopiaClient) 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 *LoopiaClient) 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 *LoopiaClient) 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 *LoopiaClient) 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 *LoopiaClient) 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 *LoopiaClient) 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
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user