mirror of
https://github.com/StackExchange/dnscontrol.git
synced 2025-01-12 02:17:43 +08:00
8e643c2856
Co-authored-by: Tom Limoncelli <tlimoncelli@stackoverflow.com>
558 lines
14 KiB
Go
558 lines
14 KiB
Go
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
|
|
}
|
|
}
|