diff --git a/OWNERS b/OWNERS index 1082e6c74..be30574de 100644 --- a/OWNERS +++ b/OWNERS @@ -23,9 +23,10 @@ providers/hexonet @KaiSchwarz-cnic providers/hostingde @juliusrickert providers/internetbs @pragmaton providers/inwx @patschi -providers/msdns @tlimoncelli providers/linode @koesie10 +providers/loopia @systemcrash providers/luadns @riku22 +providers/msdns @tlimoncelli providers/namecheap @willpower232 # providers/namedotcom NEEDS VOLUNTEER providers/netcup @kordianbruck diff --git a/README.md b/README.md index 39b6e2aa5..3daa197ef 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,7 @@ Currently supported DNS providers: - Hurricane Electric DNS - INWX - Linode +- Loopia - LuaDNS - Microsoft Windows Server DNS Server - NS1 diff --git a/documentation/SUMMARY.md b/documentation/SUMMARY.md index 7e09c5565..ec125207e 100644 --- a/documentation/SUMMARY.md +++ b/documentation/SUMMARY.md @@ -112,6 +112,7 @@ * [Internet.bs](providers/internetbs.md) * [INWX](providers/inwx.md) * [Linode](providers/linode.md) + * [Loopia](providers/loopia.md) * [LuaDNS](providers/luadns.md) * [Microsoft DNS Server on Microsoft Windows Server](providers/msdns.md) * [Namecheap](providers/namecheap.md) diff --git a/documentation/providers.md b/documentation/providers.md index 6bcd68176..b960b243c 100644 --- a/documentation/providers.md +++ b/documentation/providers.md @@ -40,6 +40,7 @@ If a feature is definitively not supported for whatever reason, we would also li | `INTERNETBS` | ❌ | ❌ | ✅ | ❔ | ❔ | ❔ | ❔ | ❔ | ❔ | ❔ | ❔ | ❔ | ❔ | ❔ | ❌ | ✅ | ❔ | | `INWX` | ❌ | ✅ | ✅ | ❌ | ❔ | ✅ | ✅ | ✅ | ❔ | ✅ | ✅ | ✅ | ❔ | ✅ | ✅ | ✅ | ✅ | | `LINODE` | ❌ | ✅ | ❌ | ❔ | ❔ | ✅ | ❔ | ❔ | ❔ | ❔ | ❔ | ❔ | ❔ | ❌ | ❌ | ✅ | ✅ | +| `LOOPIA` | ❌ | ✅ | ✅ | ❌ | ❌ | ✅ | ❌ | ✅ | ❌ | ✅ | ✅ | ✅ | ❌ | ✅ | ❌ | ✅ | ✅ | | `LUADNS` | ✅ | ✅ | ❌ | ✅ | ❔ | ✅ | ✅ | ❔ | ❔ | ✅ | ✅ | ✅ | ❔ | ✅ | ✅ | ✅ | ✅ | | `MSDNS` | ✅ | ✅ | ❌ | ❌ | ❔ | ❌ | ✅ | ✅ | ❔ | ✅ | ❔ | ❔ | ❔ | ❌ | ❌ | ✅ | ✅ | | `NAMECHEAP` | ❌ | ✅ | ✅ | ✅ | ❔ | ✅ | ❌ | ❔ | ❔ | ❌ | ❔ | ❌ | ❔ | ❌ | ❌ | ❌ | ✅ | diff --git a/documentation/providers/loopia.md b/documentation/providers/loopia.md new file mode 100644 index 000000000..a3ed6c425 --- /dev/null +++ b/documentation/providers/loopia.md @@ -0,0 +1,224 @@ +Loopia is a 💩 provider of DNS. Using DNSControl hides some of the 💩. +If you are stuck with Loopia, hopefully this will reduce the pain. + +They provide DNS services, both as a registrar, and a provider. +They provide support in English and other regional variants (Norwegian, Serbian, Swedish). + +This plugin is based on API documents found at +[https://www.loopia.com/api/](https://www.loopia.com/api/) +and by observing API responses. Hat tip to Github @hazzeh whose code for the +LEGO Loopia implementation was helpful. + +Sadly the Loopia API has some problems: +* API calls are limited to 60 calls per minute. If you go above this, + you will have to wait before you can make changes. +* When rate-limited, you will not receive a single HTTP + error: The errors propagate from the back-end, with no headers, or + Retry-After or anything useful. +* There are no guarantees of idempotency from their API. + +## Unimplemented API methods + + * `removeDomain` is not implemented for safety reasons. Should you wish to remove +a domain, do so from the Loopia control panel. + * `addDomain` + * `transferDomain` (to Loopia) + +This effectively means that this plugin does not access registrar functions. + +## Errors + +You may occasionally see this error +```text +HTTP Post Error: Post "https://api.loopia.se/RPCSERV": context deadline exceeded (Client.Timeout exceeded while awaiting headers) +``` + +The API endpoint didn't answer. Try again. 🤷 + + +## Configuration + +To use this provider, add an entry to `creds.json` with `TYPE` set to `LOOPIA` +along with your Loopia API login credentials. + +Example: + +{% code title="creds.json" %} +```json +{ + "loopia": { + "TYPE": "LOOPIA", + "username": "your-loopia-api-account-id@loopiaapi", + "password": "your-loopia-api-account-password", + "debug": "true" // Set to true for extra debug output. Remove or set to false to prevent extra debug output. + } +} +``` +{% endcode %} + +### Variables + +* `username` - string - your @loopiaapi created username +* `password` - string - your loopia API password +* `debug` - string - Set to true for extra debug output. Remove or set to false to prevent extra debug output. +* `rate_limit_per` - string - See [Rate Limiting](#rate-limiting) below. +* `region` - string - See [Regions](#regions) below. +* `modify_name_servers` - string - See [Modify Name Servers](#modify-name-servers) below. +* `fetch_apex_ns_entries` - string - See [Fetch NS Entries](#fetch-apex-ns-entries) below. + +There is no test endpoint. Fly free, grasshopper. + +Turning on debug will show the XML requests and responses, and include the +username and password from your `creds.json` file. If you want to share these, +like for a Github issue, be sure to redact those from the XML. + +### Fetch Apex NS Entries + +`creds.json` setting: `fetch_apex_ns_entries` + +... or use locally hard-coded variables: + +```go + defaultNS1 = "ns1.loopia.se." + defaultNS2 = "ns2.loopia.se." +``` + +API calls to loopia can be expensive time-wise. Set this to "false" (off) to +skip the API call to fetch the apex (`@`) entries, and use Loopia's default NS +servers. + +This setting defaults to "true" (on). + +### Modify Name Servers + +`creds.json` setting: `modify_name_servers` + +Setting this to "true" (on) allows you to modify NS entries. + +Loopia is weird. NS entries are inaccessible in the control panel. But you can see them. +Perhaps dnscontrol added an NS that you cannot delete now? Toggle this setting to +"true" in order to treat all NS entries as any other - making them accessible +to modification. Beware the consequences of changing from default NS entries. Likely +nothing will happen since the glue records provided won't match those in the domain, +and you will need to manually inform Loopia of this so they can update the glue records. + +In short: enable this setting to be able to delete NS entries. No `NS()` in your +`dnsconfig.js`? Existing ones will be deleted. Have some `NS()` or `NAMESERVER()` +entries? They'll be added. + +This setting defaults to "false" (off). + + +### Regions + +`creds.json` setting: `region` + + +Loopia operate in a few regions. Norway (`no`), Serbia (`rs`), Sweden (`se`). + +For the parameter `region`, specify one of `no`, `rs`, `se`, or omit, or leave empty for the default `se` Sweden. + +As of writing, `no` was broken 💩 and produced: + +```text +HTTP Post Error: Post "https://api.loopia.no/RPCSERV": x509: “*.loopia.rs” certificate name does not match input +``` + +### Rate Limiting + +`creds.json` setting: `rate_limit_per` + + +Loopia rate limits requests to 60 per minute. + +From their [web-site](https://www.loopia.com/api/rate_limiting/): +```text +You can make up to 60 calls per minute to LoopiaAPI. Of those, a maximum of 15 can be domain searches. +``` + +Depending on how many requests you make, you may encounter a limit. Modification +of each DNS record requires at least one API call. 🤦 + +Example: If the rate is 60/min and you make two requests every second, the 31st +request will be rejected. You will then have to wait for 29 seconds, until the +first request’s age reaches one minute. At that time, it will be dropped from +the calculation, and you can make another request. One second later, and +generally every time an old request’s age falls out of the sliding window +counting interval, you can make another request. + +Your per minute quota is 60 requests and in your settings you + specified `Minute`. DNSControl will perform at most one request per second. + DNSControl will emit a warning in case it breaches the quota. + +The setting `rate_limit_per` controls this behavior and accepts + a case-insensitive value of +- `Hour` +- `Minute` +- `Second` + +The default for `rate_limit_per` is `Second`. + +In your `creds.json` for all `LOOPIA` provider entries: + +{% code title="creds.json" %} +```json +{ + "loopia": { + "TYPE": "LOOPIA", + "username": "your-loopia-api-account-id@loopiaapi", + "password": "your-loopia-api-account-password", + "debug": "true", // Set to true for extra debug output. Remove or set to false to prevent extra debug output. + "rate_limit_per": "Minute" + } +} +``` +{% endcode %} + +## Usage + +Here's an example DNS Configuration `dnsconfig.js` using the provider module. +Even though it shows how you use Loopia as Domain Registrar AND DNS Provider, +you're not forced to do that (thank god). + + +{% code title="dnsconfig.js" %} +```javascript +// Providers: +var REG_LOOPIA = NewRegistrar("loopia"); +var DSP_LOOPIA = NewDnsProvider("loopia"); + +// Set Default TTL for all RR to reflect our Backend API Default +// If you use additional DNS Providers, configure a default TTL +// per domain using the domain modifier DefaultTTL instead. +DEFAULTS( + NAMESERVER_TTL(3600), + DefaultTTL(3600) +); + +// Domains: +D("example.com", REG_LOOPIA, DnsProvider(DSP_LOOPIA), + //NAMESERVER("ns1.loopia.se."), //default + //NAMESERVER("ns2.loopia.se."), //default + A("elk1", "192.0.2.1"), + A("test", "192.0.2.2") +); +``` +{% endcode %} + +## Metadata + +This provider does not recognize any special metadata fields unique to LOOPIA. + +## get-zones + +`dnscontrol get-zones` is implemented for this provider. + + +## New domains + +If a dnszone does not exist in your LOOPIA account, DNSControl will *not* automatically add it with the `dnscontrol push` or `dnscontrol preview` command. You'll need to do that via the control panel manually or using the command `dnscontrol create-domains`. +This is because it could lead to unwanted costs on customer-side that you may want to avoid. + +## Debug Mode + +As shown in the configuration examples above, this can be activated on demand and it can be used to check the API commands sent to Loopia. diff --git a/integrationTest/integration_test.go b/integrationTest/integration_test.go index b0e22bfbd..a201fa226 100644 --- a/integrationTest/integration_test.go +++ b/integrationTest/integration_test.go @@ -915,6 +915,7 @@ func makeTests(t *testing.T) []*TestGroup { "DIGITALOCEAN", // No paging. Why bother? "CSCGLOBAL", // Doesn't page. Works fine. Due to the slow API we skip. "GANDI_V5", // Their API is so damn slow. We'll add it back as needed. + "LOOPIA", // Their API is so damn slow. Plus, no paging. "MSDNS", // No paging done. No need to test. "NAMEDOTCOM", // Their API is so damn slow. We'll add it back as needed. "NS1", // Free acct only allows 50 records, therefore we skip diff --git a/integrationTest/providers.json b/integrationTest/providers.json index 0d9496bf4..08117ce36 100644 --- a/integrationTest/providers.json +++ b/integrationTest/providers.json @@ -134,6 +134,11 @@ "domain": "$LINODE_DOMAIN", "token": "$LINODE_TOKEN" }, + "LOOPIA": { + "username": "$LOOPIA_USERNAME", + "password": "$LOOPIA_PASSWORD", + "domain": "$LOOPIA_DOMAIN" + }, "LUADNS": { "domain": "$LUADNS_DOMAIN", "email": "$LUADNS_EMAIL", diff --git a/providers/_all/all.go b/providers/_all/all.go index fab691db5..d32117708 100644 --- a/providers/_all/all.go +++ b/providers/_all/all.go @@ -29,6 +29,7 @@ import ( _ "github.com/StackExchange/dnscontrol/v3/providers/internetbs" _ "github.com/StackExchange/dnscontrol/v3/providers/inwx" _ "github.com/StackExchange/dnscontrol/v3/providers/linode" + _ "github.com/StackExchange/dnscontrol/v3/providers/loopia" _ "github.com/StackExchange/dnscontrol/v3/providers/luadns" _ "github.com/StackExchange/dnscontrol/v3/providers/msdns" _ "github.com/StackExchange/dnscontrol/v3/providers/namecheap" diff --git a/providers/loopia/auditrecords.go b/providers/loopia/auditrecords.go new file mode 100644 index 000000000..6849229bb --- /dev/null +++ b/providers/loopia/auditrecords.go @@ -0,0 +1,31 @@ +package loopia + +import ( + "fmt" + "github.com/StackExchange/dnscontrol/v3/models" + "github.com/StackExchange/dnscontrol/v3/pkg/rejectif" +) + +// AuditRecords returns a list of errors corresponding to the records +// that aren't supported by this provider. If all records are +// supported, an empty list is returned. +func AuditRecords(records []*models.RecordConfig) []error { + a := rejectif.Auditor{} + + a.Add("TXT", rejectif.TxtIsEmpty) // Last verified 2023-03-10: Loopia returns 404 + + //Loopias TXT length limit appears to be 450 octets + a.Add("TXT", TxtHasSegmentLen450orLonger) + + return a.Audit(records) +} + +// TxtHasSegmentLen450orLonger audits TXT records for strings that are >450 octets. +func TxtHasSegmentLen450orLonger(rc *models.RecordConfig) error { + for _, txt := range rc.TxtStrings { + if len(txt) > 450 { + return fmt.Errorf("%q txtstring length > 450", rc.GetLabel()) + } + } + return nil +} diff --git a/providers/loopia/client.go b/providers/loopia/client.go new file mode 100644 index 000000000..43164db41 --- /dev/null +++ b/providers/loopia/client.go @@ -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(``+"\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 + } +} diff --git a/providers/loopia/client_test.go b/providers/loopia/client_test.go new file mode 100644 index 000000000..be609b67e --- /dev/null +++ b/providers/loopia/client_test.go @@ -0,0 +1,346 @@ +package loopia + +import ( + "encoding/xml" + "fmt" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestClient_CreateRecord(t *testing.T) { + serverResponses := map[string]string{ + addZoneRecordGoodAuth: responseOk, + addZoneRecordBadAuth: responseAuthError, + addZoneRecordNonValidDomain: responseUnknownError, + addZoneRecordEmptyResponse: "", + } + + serverURL := createFakeServer(t, serverResponses) + + testCases := []struct { + desc string + password string + domain string + err string + }{ + { + desc: "auth ok", + password: "goodpassword", + domain: exampleDomain, + }, + { + desc: "auth error", + password: "badpassword", + domain: exampleDomain, + err: "authentication error", + }, + { + desc: "unknown error", + password: "goodpassword", + domain: "badexample.com", + err: `unknown error: "UNKNOWN_ERROR"`, + }, + { + desc: "empty response", + password: "goodpassword", + domain: "empty.com", + err: "error unmarshalling the API response XML body: EOF", + }, + } + + zr := zRec{ + Type: "TXT", + TTL: 123, + Rdata: "TXTrecord", + RecordID: 0, + Priority: 0, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + client := NewClient("apiuser", test.password, "", false, true, false) + client.BaseURL = serverURL + "/" + + err := client.CreateRecord(test.domain, exampleSubDomain, zr.SetPS()) + if test.err == "" { + require.NoError(t, err) + } else { + require.Error(t, err) + assert.EqualError(t, err, test.err) + } + }) + } +} + +func TestClient_DeleteSubdomain(t *testing.T) { + serverResponses := map[string]string{ + removeSubdomainGoodAuth: responseOk, + removeSubdomainBadAuth: responseAuthError, + removeSubdomainNonValidDomain: responseUnknownError, + removeSubdomainEmptyResponse: "", + } + + serverURL := createFakeServer(t, serverResponses) + + testCases := []struct { + desc string + password string + domain string + err string + }{ + { + desc: "auth ok", + password: "goodpassword", + domain: exampleDomain, + }, + { + desc: "auth error", + password: "badpassword", + domain: exampleDomain, + err: "authentication error", + }, + { + desc: "unknown error", + password: "goodpassword", + domain: "badexample.com", + err: `unknown error: "UNKNOWN_ERROR"`, + }, + { + desc: "empty response", + password: "goodpassword", + domain: "empty.com", + err: "error unmarshalling the API response XML body: EOF", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + client := NewClient("apiuser", test.password, "", false, true, false) + client.BaseURL = serverURL + "/" + + err := client.DeleteSubdomain(test.domain, exampleSubDomain) + if test.err == "" { + require.NoError(t, err) + } else { + require.Error(t, err) + assert.EqualError(t, err, test.err) + } + }) + } +} + +func TestClient_DeleteRecord(t *testing.T) { + serverResponses := map[string]string{ + removeRecordGoodAuth: responseOk, + removeRecordBadAuth: responseAuthError, + removeRecordNonValidDomain: responseUnknownError, + removeRecordEmptyResponse: "", + } + + serverURL := createFakeServer(t, serverResponses) + + testCases := []struct { + desc string + password string + domain string + err string + }{ + { + desc: "auth ok", + password: "goodpassword", + domain: exampleDomain, + }, + { + desc: "auth error", + password: "badpassword", + domain: exampleDomain, + err: "authentication error", + }, + { + desc: "uknown error", + password: "goodpassword", + domain: "badexample.com", + err: `unknown error: "UNKNOWN_ERROR"`, + }, + { + desc: "empty response", + password: "goodpassword", + domain: "empty.com", + err: "error unmarshalling the API response XML body: EOF", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + client := NewClient("apiuser", test.password, "", false, true, false) + client.BaseURL = serverURL + "/" + + err := client.DeleteRecord(test.domain, exampleSubDomain, 12345678) + if test.err == "" { + require.NoError(t, err) + } else { + require.Error(t, err) + assert.EqualError(t, err, test.err) + } + }) + } +} + +func TestClient_GetDomainRecords(t *testing.T) { + serverResponses := map[string]string{ + getZoneRecords: getZoneRecordsResponse, + } + + serverURL := createFakeServer(t, serverResponses) + + client := NewClient("apiuser", "goodpassword", "", false, true, false) + client.BaseURL = serverURL + "/" + + recordObjs, err := client.GetDomainRecords(exampleDomain, exampleSubDomain) + require.NoError(t, err) + + zr := zRec{ + Type: "TXT", + TTL: 300, + Priority: 0, + Rdata: exampleRdata, + RecordID: 12345678, + } + expected := zr.SetZR() + var zrs []zoneRecord + zrs = append(zrs, expected) + + assert.EqualValues(t, zrs, recordObjs) +} + +func TestClient_rpcCall_404(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + w.WriteHeader(http.StatusNotFound) + + _, err = fmt.Fprint(w, "") + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + })) + + t.Cleanup(server.Close) + + call := &methodCall{ + MethodName: "dummyMethod", + Params: []param{ + paramString{Value: "test1"}, + }, + } + + client := NewClient("apiuser", "apipassword", "", false, true, false) + client.BaseURL = server.URL + "/" + + err := client.rpcCall(call, &responseString{}) + assert.EqualError(t, err, "HTTP Post Error: 404") +} + +func TestClient_rpcCall_RPCError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + _, err = fmt.Fprint(w, responseRPCError) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + })) + + t.Cleanup(server.Close) + + call := &methodCall{ + MethodName: "getDomains", + Params: []param{ + paramString{Value: "test1"}, + }, + } + + client := NewClient("apiuser", "apipassword", "", false, true, false) + client.BaseURL = server.URL + "/" + + err := client.rpcCall(call, &responseString{}) + assert.EqualError(t, err, "RPC Error: (201) Method signature error: 42") +} + +func TestUnmarshallFaultyRecordObject(t *testing.T) { + testCases := []struct { + desc string + xml string + }{ + { + desc: "faulty name", + xml: "name", + }, + { + desc: "faulty string", + xml: "foo", + }, + { + desc: "faulty int", + xml: "1", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + resp := &zoneRecord{} + + err := xml.Unmarshal([]byte(test.xml), resp) + require.Error(t, err) + }) + } +} + +func createFakeServer(t *testing.T, serverResponses map[string]string) string { + t.Helper() + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Content-Type") != "text/xml" { + http.Error(w, fmt.Sprintf("invalid content type: %s", r.Header.Get("Content-Type")), http.StatusBadRequest) + return + } + + req, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + resp, ok := serverResponses[string(req)] + if !ok { + http.Error(w, "no response for request", http.StatusBadRequest) + return + } + + _, err = fmt.Fprint(w, resp) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + }) + + server := httptest.NewServer(handler) + t.Cleanup(server.Close) + + return server.URL +} diff --git a/providers/loopia/convert.go b/providers/loopia/convert.go new file mode 100644 index 000000000..159a4742c --- /dev/null +++ b/providers/loopia/convert.go @@ -0,0 +1,66 @@ +package loopia + +// Convert the provider's native record description to models.RecordConfig. + +import ( + "fmt" + + "github.com/StackExchange/dnscontrol/v3/models" +) + +// nativeToRecord takes a DNS record from Loopia and returns a native RecordConfig struct. +func nativeToRecord(zr zoneRecord, origin string, subdomain string) (rc *models.RecordConfig, err error) { + record := zr.GetZR() + + rc = &models.RecordConfig{ + TTL: record.TTL, + Original: record, + Type: record.Type, + } + rc.SetLabel(subdomain, origin) + rc.SetTarget(record.Rdata) + + switch rtype := record.Type; rtype { + case "CAA": + err = rc.SetTargetCAAString(record.Rdata) + case "MX": + err = rc.SetTargetMX(record.Priority, record.Rdata) + case "NAPTR": + err = rc.SetTargetNAPTRString(record.Rdata) + case "TXT": + err = rc.SetTargetTXT(record.Rdata) + default: + err = rc.PopulateFromString(rtype, record.Rdata, origin) + } + if err != nil { + return nil, fmt.Errorf("unparsable record received from loopia: %w", err) + } + + return rc, nil +} + +func recordToNative(rc *models.RecordConfig, id ...uint32) paramStruct { + //rc is the record from dnscontrol to loopia + zrec := zRec{} + zrec.Type = rc.Type + zrec.TTL = rc.TTL + zrec.Rdata = rc.GetTargetCombined() + + if rc.Original != nil { + zrec.RecordID = rc.Original.(*zRec).RecordID + } else if len(id) > 0 { + zrec.RecordID = id[0] + } + switch zrec.Type { + case "TXT": + zrec.Rdata = rc.GetTargetTXTJoined() + case "MX": + zrec.Priority = rc.MxPreference + zrec.Rdata = rc.GetTargetField() + case "SRV": + zrec.Priority = rc.SrvPriority + } + // fmt.Printf("r2n:zr %+v\n", zrec) + + return zrec.SetPS() +} diff --git a/providers/loopia/convert_test.go b/providers/loopia/convert_test.go new file mode 100644 index 000000000..bc2d635a0 --- /dev/null +++ b/providers/loopia/convert_test.go @@ -0,0 +1,51 @@ +package loopia + +import ( + "reflect" + "testing" + + "github.com/StackExchange/dnscontrol/v3/models" +) + +func TestRecordToNative_1(t *testing.T) { + + rc := &models.RecordConfig{ + TTL: 3600, + } + rc.SetLabel("foo", "example.com") + rc.SetTarget("1.2.3.4") + rc.Type = "A" + + ns := recordToNative(rc, 0) + + nst := reflect.TypeOf(ns).Kind() + if nst != reflect.TypeOf(paramStruct{}).Kind() { + t.Errorf("recordToNative produced unexpected type") + } +} + +func TestNativeToRecord_1(t *testing.T) { + + zrec := zRec{} + zrec.Type = "A" + zrec.TTL = 300 + zrec.Rdata = "1.2.3.4" + zrec.Priority = 0 + zrec.RecordID = 0 + + rc, err := nativeToRecord(zrec.SetZR(), "example.com", "www") + + if rc.Type != "A" { + t.Errorf("nativeToRecord produced unexpected type") + } else if rc.TTL != 300 { + t.Errorf("nativeToRecord produced unexpected TTL") + } else if rc.GetTargetCombined() != "1.2.3.4" { + t.Errorf("nativeToRecord produced unexpected Rdata") + } else if rc.SrvPriority != 0 { + t.Errorf("nativeToRecord produced unexpected Priority") + } + + if err != nil { + t.Errorf("nativeToRecord error") + } +} diff --git a/providers/loopia/loopiaProvider.go b/providers/loopia/loopiaProvider.go new file mode 100644 index 000000000..d1e414aa0 --- /dev/null +++ b/providers/loopia/loopiaProvider.go @@ -0,0 +1,449 @@ +package loopia + +/* + +Loopia XML_RPC API V1 DNS provider: + +Documentation: https://www.loopia.com/api/ +Endpoint: https://api.loopia.se/RPCSERV + +Settings from `creds.json`: + - username + - password + - debug + - rate_limit_per + +*/ + +import ( + "encoding/json" + "fmt" + "sort" + "strconv" + "strings" + + "github.com/StackExchange/dnscontrol/v3/models" + "github.com/StackExchange/dnscontrol/v3/pkg/diff" + "github.com/StackExchange/dnscontrol/v3/pkg/diff2" + "github.com/StackExchange/dnscontrol/v3/pkg/printer" + "github.com/StackExchange/dnscontrol/v3/pkg/txtutil" + "github.com/StackExchange/dnscontrol/v3/providers" + "github.com/miekg/dns/dnsutil" +) + +// Section 1: Register this provider in the system. + +// init registers the provider to dnscontrol. +func init() { + fns := providers.DspFuncs{ + Initializer: newDsp, + RecordAuditor: AuditRecords, + } + providers.RegisterDomainServiceProviderType("LOOPIA", fns, features) + providers.RegisterRegistrarType("LOOPIA", newReg) +} + +// features declares which features and options are available. +var features = providers.DocumentationNotes{ + providers.CanAutoDNSSEC: providers.Cannot(), + providers.CanGetZones: providers.Can(), + providers.CanUseAKAMAICDN: providers.Cannot(), + providers.CanUseAzureAlias: providers.Cannot(), + providers.CanUseAlias: providers.Cannot(), + providers.CanUseCAA: providers.Can(), + providers.CanUseDS: providers.Cannot("Only supports DS records at the apex, only for .se and .nu domains; done automatically at back-end."), + providers.CanUseDSForChildren: providers.Cannot(), + providers.CanUseNAPTR: providers.Can(), + providers.CanUsePTR: providers.Cannot(), + providers.CanUseSOA: providers.Cannot("💩"), + providers.CanUseSRV: providers.Can(), + providers.CanUseSSHFP: providers.Can(), + providers.CanUseTLSA: providers.Can(), + providers.DocCreateDomains: providers.Cannot("Can only manage domains registered through their service"), + providers.DocDualHost: providers.Can(), + providers.DocOfficiallySupported: providers.Cannot(), +} + +// Section 2: Define the API client. + +// See client.go + +// newDsp generates a DNS Service Provider client handle. +func newDsp(conf map[string]string, metadata json.RawMessage) (providers.DNSServiceProvider, error) { + return newHelper(conf, metadata) +} + +// newReg generates a Registrar Provider client handle. +func newReg(conf map[string]string) (providers.Registrar, error) { + return newHelper(conf, nil) +} + +// newHelper generates a handle. +func newHelper(m map[string]string, metadata json.RawMessage) (*LoopiaClient, error) { + if m["username"] == "" { + return nil, fmt.Errorf("missing Loopia API username") + } + if m["password"] == "" { + return nil, fmt.Errorf("missing Loopia API password") + } + + const boolean_string_warn = " setting as a 'string': 't', 'true', 'True' etc" + var err error + + modify_name_servers := false + if m["modify_name_servers"] != "" { // optional + modify_name_servers, err = strconv.ParseBool(m["modify_name_servers"]) + if err != nil { + return nil, fmt.Errorf("creds.json requires the modify_name_servers" + boolean_string_warn) + } + } + + fetch_apex_ns_entries := false + if m["fetch_apex_ns_entries"] != "" { // optional + fetch_apex_ns_entries, err = strconv.ParseBool(m["fetch_apex_ns_entries"]) + if err != nil { + return nil, fmt.Errorf("creds.json requires the fetch_apex_ns_entries" + boolean_string_warn) + } + } + + dbg := false + if m["debug"] != "" { //debug is optional + dbg, err = strconv.ParseBool(m["debug"]) + if err != nil { + return nil, fmt.Errorf("creds.json requires the debug" + boolean_string_warn) + } + } + + api := NewClient(m["username"], m["password"], strings.ToLower(m["region"]), modify_name_servers, fetch_apex_ns_entries, dbg) + + quota := m["rate_limit_per"] + err = api.requestRateLimiter.setRateLimitPer(quota) + if err != nil { + return nil, fmt.Errorf("unexpected value for rate_limit_per: %w", err) + } + return api, nil +} + +// Section 3: Domain Service Provider (DSP) related functions + +// ListZones lists the zones on this account. +func (c *LoopiaClient) ListZones() ([]string, error) { + + listResp, err := c.GetDomains() + if err != nil { + return nil, err + } + + zones := make([]string, len(listResp)) + if c.Debug { + fmt.Printf("DEBUG: DOMAIN LIST START\n") + } + for i, zone := range listResp { + for _, prop := range zone.Properties { + if prop.Name() == "domain" { // the zones name is stored in property 'domain' + if c.Debug { + fmt.Printf("DEBUG: DOMAIN LIST %d: %v\n", i, prop.String()) + } + // zone := zone + zones[i] = prop.String() + } + } + } + if c.Debug { + fmt.Printf("DEBUG: DOMAIN LIST END\n") + } + return zones, nil +} + +// NB(tal): To future-proof your code, all new providers should +// implement GetDomainCorrections exactly as you see here +// (byte-for-byte the same). In 3.0 +// we plan on using just the individual calls to GetZoneRecords, +// PostProcessRecords, and so on. +// +// Currently every provider does things differently, which prevents +// us from doing things like using GetZoneRecords() of a provider +// to make convertzone work with all providers. + +// GetDomainCorrections get the current and existing records, +// post-process them, and generate corrections. +func (c *LoopiaClient) GetDomainCorrections(dc *models.DomainConfig) ([]*models.Correction, error) { + existing, err := c.GetZoneRecords(dc.Name) + if err != nil { + return nil, err + } + models.PostProcessRecords(existing) + clean := PrepFoundRecords(existing) + PrepDesiredRecords(dc) + return c.GenerateZoneRecordsCorrections(dc, clean) +} + +// GetZoneRecords gathers the DNS records and converts them to +// dnscontrol's format. +func (c *LoopiaClient) GetZoneRecords(domain string) (models.Records, error) { + + // Two approaches. One: get all SubDomains, and get their respective records + // simultaneously, or first get subdomains then fill each subdomain with its + // respective records on a subsequent pass. + + //step 1: subdomains + // Get existing subdomains for a domain: + subdomains, err := c.GetSubDomains(domain) + if err != nil { + return nil, err + } + + if c.Debug { + fmt.Printf("Amount of subdomains: %d\n", len(subdomains)) + } + + // Convert them to DNScontrol's native format: + existingRecords := []*models.RecordConfig{} + for _, subdomain := range subdomains { + //here seems like a good place to get the records for a subdomain. + //fukn ballz tho: each subdomain requires one API call. 💩 + if c.Debug { + fmt.Printf("%s\n", subdomain) + } + //step 2: records for subdomains + // Get subdomain records: + subdomainrecords, err := c.GetDomainRecords(domain, subdomain) + if err != nil { + return nil, err + } + + for _, subdRr := range subdomainrecords { + + //Note: subdomain cannot be any of [.-_ ] + record, err := nativeToRecord(subdRr, domain, subdomain) + if err != nil { + return nil, err + } + existingRecords = append(existingRecords, record) + + } + + } + + if c.Debug { + fmt.Printf("length of existingRecords: %d\n", len(existingRecords)) + } + + return existingRecords, nil +} + +// PrepFoundRecords munges any records to make them compatible with +// this provider. Usually this is a no-op. +func PrepFoundRecords(recs models.Records) models.Records { + // If there are records that need to be modified, removed, etc. we + // do it here. Usually this is a no-op. + return recs +} + +// PrepDesiredRecords munges any records to best suit this provider. +func PrepDesiredRecords(dc *models.DomainConfig) { + // Sort through the dc.Records, eliminate any that can't be + // supported; modify any that need adjustments to work with the + // provider. We try to do minimal changes otherwise it gets + // confusing. + + dc.Punycode() + + recordsToKeep := make([]*models.RecordConfig, 0, len(dc.Records)) + for _, rec := range dc.Records { + if rec.Type == "ALIAS" { + // Loopia does not support ALIAS. + // Therefore, we change this to a CNAME. + rec.Type = "CNAME" + } + if rec.TTL < 300 { + /* you can submit TTL lower than 300 but the dig results are normalized to 300 */ + printer.Warnf("Loopia does not support TTL < 300. Setting %s from %d to 300\n", rec.GetLabelFQDN(), rec.TTL) + rec.TTL = 300 + } else if rec.TTL > 2147483647 { + /* you can submit a TTL higher than 4294967296 but Loopia shortens it to 2147483647. 68 year timeout tho. */ + printer.Warnf("Loopia does not support TTL > 68 years. Setting %s from %d to 2147483647\n", rec.GetLabelFQDN(), rec.TTL) + rec.TTL = 2147483647 + } + // if rec.Type == "TXT" { + // rec.SetTarget("\"" + rec.GetTargetField() + "\"") // FIXME(systemcrash): Should do proper quoting. + // } + // if rec.Type == "NS" && rec.GetLabel() == "@" { + // if !strings.HasSuffix(rec.GetTargetField(), ".loopia.se.") { + // printer.Warnf("Loopia does not support changing apex NS records. Ignoring %s\n", rec.GetTargetField()) + // } + // continue + // } + recordsToKeep = append(recordsToKeep, rec) + } + dc.Records = recordsToKeep +} + +// gatherAffectedLabels takes the output of diff.ChangedGroups and +// regroups it by FQDN of the label, not by Key. It also returns +// a list of all the FQDNs. +func gatherAffectedLabels(groups map[models.RecordKey][]string) (labels map[string]bool, msgs map[string][]string) { + labels = map[string]bool{} + msgs = map[string][]string{} + for k, v := range groups { + labels[k.NameFQDN] = true + msgs[k.NameFQDN] = append(msgs[k.NameFQDN], v...) + } + return labels, msgs +} + +// GenerateZoneRecordsCorrections takes the desired and existing records +// and produces a Correction list. The correction list is simply +// a list of functions to call to actually make the desired +// correction, and a message to output to the user when the change is +// made. +func (c *LoopiaClient) GenerateZoneRecordsCorrections(dc *models.DomainConfig, existingRecords models.Records) ([]*models.Correction, error) { + if c.Debug { + debugRecords("GenerateZoneRecordsCorrections input:\n", existingRecords) + } + + // Normalize + txtutil.SplitSingleLongTxt(dc.Records) // Autosplit long TXT records + + var corrections []*models.Correction + var keysToUpdate map[models.RecordKey][]string + var differ diff.Differ + if !diff2.EnableDiff2 { + differ = diff.New(dc) + } else { + differ = diff.NewCompat(dc) + } + _, create, del, modify, err := differ.IncrementalDiff(existingRecords) + if err != nil { + return nil, err + } + keysToUpdate, err = differ.ChangedGroups(existingRecords) + if err != nil { + return nil, err + } + + for _, d := range create { + // fmt.Printf("a creation: subdomain: %+v, existingfqdn: %+v \n", d.Desired.Name, d.Desired.NameFQDN) + des := d.Desired + zrec := recordToNative(des) + corrections = append(corrections, &models.Correction{ + Msg: d.String(), + F: func() error { + // return c.CreateRecordSimulate(dc.Name, des.Name, zrec) + return c.CreateRecord(dc.Name, des.Name, zrec) + }, + }) + } + + // Determine which subdomains become extinct. Delete them. + affectedLabels, msgsForLabel := gatherAffectedLabels(keysToUpdate) + _, desiredRecords := dc.Records.GroupedByFQDN() + + for fqdn := range affectedLabels { + if len(desiredRecords[fqdn]) == 0 { + msgs := strings.Join(msgsForLabel[fqdn], "\n") + msgs = "records affected by deletion of subdomain " + fqdn + "\n" + msgs + subdomain := dnsutil.TrimDomainName(fqdn, dc.Name) + corrections = append(corrections, &models.Correction{ + Msg: msgs, + F: func() error { + return c.DeleteSubdomain(dc.Name, subdomain) + }, + }) + } + } + + for _, d := range del { + skip := false + for fqdn := range affectedLabels { + if len(desiredRecords[fqdn]) == 0 { + subdomain := dnsutil.TrimDomainName(fqdn, dc.Name) + if d.Existing.NameFQDN == fqdn && d.Existing.Name == subdomain { + // fmt.Printf("fqdn extinct wtf: %s\n", fqdn) + //deletion is a member of fqdn. skip its deletion (otherwise extra API call and its error) + skip = true + } + } + } + if !skip { + // fmt.Printf("a deletion: subdomain: %+v, existingfqdn: %+v \n", d.Existing.Name, d.Existing.NameFQDN) + existingRecord := d.Existing.Original.(zRec) + corrections = append(corrections, &models.Correction{ + Msg: d.String(), + F: func() error { + // return c.DeleteRecordSimulate(dc.Name, d.Existing.Name, existingRecord.RecordID) + return c.DeleteRecord(dc.Name, d.Existing.Name, existingRecord.RecordID) + }, + }) + } + } + + for _, d := range modify { + subdomain := d.Existing.Name + // fmt.Printf("a modification: subdomain: %+v, existingfqdn: %+v \n", d.Existing.Name, d.Existing.NameFQDN) + rec := d.Desired + existingID := d.Existing.Original.(zRec).RecordID + zrec := recordToNative(rec, existingID) + corrections = append(corrections, &models.Correction{ + Msg: d.String(), + F: func() error { + //weird BUG: if we provide d.Desired.Name, instead of 'subdomain', + //all change records get assigned a single subdomain, common across all change records. + // return c.UpdateRecordSimulate(dc.Name, subdomain, zrec) + return c.UpdateRecord(dc.Name, subdomain, zrec) + }, + }) + } + + return corrections, nil +} + +// debugRecords prints a list of RecordConfig. +func debugRecords(note string, recs []*models.RecordConfig) { + printer.Debugf(note) + for k, v := range recs { + printer.Printf(" %v: %v %v %v %v\n", k, v.GetLabel(), v.Type, v.TTL, v.GetTargetCombined()) + } +} + +// Section 3: Registrar-related functions + +// GetNameservers returns a list of nameservers for domain. +func (c *LoopiaClient) GetNameservers(domain string) ([]*models.Nameserver, error) { + if c.ModifyNameServers { + return nil, nil + } else { + nameservers, err := c.GetDomainNS(domain) + if err != nil { + return nil, err + } + return models.ToNameserversStripTD(nameservers) + } +} + +// GetRegistrarCorrections returns a list of corrections for this registrar. +func (c *LoopiaClient) GetRegistrarCorrections(dc *models.DomainConfig) ([]*models.Correction, error) { + + existingNs, err := c.GetDomainNS(dc.Name) + if err != nil { + return nil, err + } + sort.Strings(existingNs) + existing := strings.Join(existingNs, ",") + + desiredNs := models.NameserversToStrings(dc.Nameservers) + sort.Strings(desiredNs) + desired := strings.Join(desiredNs, ",") + + if existing != desired { + return []*models.Correction{ + { + Msg: fmt.Sprintf("Change Nameservers from '%s' to '%s'", existing, desired), + F: func() (err error) { + // err = c.UpdateNameServers(dc.Name, desiredNs) + return + }}, + }, nil + } + return nil, nil +} diff --git a/providers/loopia/mock_test.go b/providers/loopia/mock_test.go new file mode 100644 index 000000000..223539071 --- /dev/null +++ b/providers/loopia/mock_test.go @@ -0,0 +1,640 @@ +package loopia + +const ( + exampleDomain = "example.com" + exampleSubDomain = "_acme-challenge" + exampleRdata = "LHDhK3oGRvkiefQnx7OOczTY5Tic_xZ6HcMOc_gmtoM" +) + +// Testdata based on real traffic between an xml-rpc client and the api. +const responseOk = ` + + + + + + OK + + + + + ` + +const responseAuthError = ` + + + + + + AUTH_ERROR + + + + + ` + +const responseUnknownError = ` + + + + + + UNKNOWN_ERROR + + + + + ` + +const responseRPCError = ` + + + + + + + faultCode + + + + 201 + + + + + + faultString + + + + Method signature error: 42 + + + + + + +` + +const addZoneRecordGoodAuth = ` + + addZoneRecord + + + + apiuser + + + + + goodpassword + + + + + example.com + + + + + _acme-challenge + + + + + + + type + + TXT + + + + ttl + + 123 + + + + priority + + 0 + + + + rdata + + TXTrecord + + + + record_id + + 0 + + + + + + +` + +const addZoneRecordBadAuth = ` + + addZoneRecord + + + + apiuser + + + + + badpassword + + + + + example.com + + + + + _acme-challenge + + + + + + + type + + TXT + + + + ttl + + 123 + + + + priority + + 0 + + + + rdata + + TXTrecord + + + + record_id + + 0 + + + + + + +` + +const addZoneRecordNonValidDomain = ` + + addZoneRecord + + + + apiuser + + + + + goodpassword + + + + + badexample.com + + + + + _acme-challenge + + + + + + + type + + TXT + + + + ttl + + 123 + + + + priority + + 0 + + + + rdata + + TXTrecord + + + + record_id + + 0 + + + + + + +` + +const addZoneRecordEmptyResponse = ` + + addZoneRecord + + + + apiuser + + + + + goodpassword + + + + + empty.com + + + + + _acme-challenge + + + + + + + type + + TXT + + + + ttl + + 123 + + + + priority + + 0 + + + + rdata + + TXTrecord + + + + record_id + + 0 + + + + + + +` + +const getZoneRecords = ` + + getZoneRecords + + + + apiuser + + + + + goodpassword + + + + + example.com + + + + + _acme-challenge + + + +` + +const getZoneRecordsResponse = ` + + + + + + + + + + type + + TXT + + + + ttl + + 300 + + + + priority + + 0 + + + + rdata + + LHDhK3oGRvkiefQnx7OOczTY5Tic_xZ6HcMOc_gmtoM + + + + record_id + + 12345678 + + + + + + + + + +` + +const removeRecordGoodAuth = ` + + removeZoneRecord + + + + apiuser + + + + + goodpassword + + + + + example.com + + + + + _acme-challenge + + + + + 12345678 + + + +` + +const removeRecordBadAuth = ` + + removeZoneRecord + + + + apiuser + + + + + badpassword + + + + + example.com + + + + + _acme-challenge + + + + + 12345678 + + + +` + +const removeRecordNonValidDomain = ` + + removeZoneRecord + + + + apiuser + + + + + goodpassword + + + + + badexample.com + + + + + _acme-challenge + + + + + 12345678 + + + +` + +const removeRecordEmptyResponse = ` + + removeZoneRecord + + + + apiuser + + + + + goodpassword + + + + + empty.com + + + + + _acme-challenge + + + + + 12345678 + + + +` + +const removeSubdomainGoodAuth = ` + + removeSubdomain + + + + apiuser + + + + + goodpassword + + + + + example.com + + + + + _acme-challenge + + + +` + +const removeSubdomainBadAuth = ` + + removeSubdomain + + + + apiuser + + + + + badpassword + + + + + example.com + + + + + _acme-challenge + + + +` + +const removeSubdomainNonValidDomain = ` + + removeSubdomain + + + + apiuser + + + + + goodpassword + + + + + badexample.com + + + + + _acme-challenge + + + +` + +const removeSubdomainEmptyResponse = ` + + removeSubdomain + + + + apiuser + + + + + goodpassword + + + + + empty.com + + + + + _acme-challenge + + + +` diff --git a/providers/loopia/types.go b/providers/loopia/types.go new file mode 100644 index 000000000..66776c5e6 --- /dev/null +++ b/providers/loopia/types.go @@ -0,0 +1,208 @@ +package loopia + +import ( + "encoding/xml" + "fmt" +) + +// types for XML-RPC method calls and parameters + +type param interface { + param() +} + +type paramString struct { + XMLName xml.Name `xml:"param"` + Value string `xml:"value>string"` +} + +func (p paramString) param() {} + +type paramInt struct { + XMLName xml.Name `xml:"param"` + Value uint32 `xml:"value>int"` +} + +func (p paramInt) param() {} + +// payload of values for a subdomain record +type paramStruct struct { + XMLName xml.Name `xml:"param"` + StructMembers []structMember `xml:"value>struct>member"` +} + +func (p paramStruct) param() {} + +type structMember interface { + structMember() +} + +type structMemberString struct { + Name string `xml:"name"` + Value string `xml:"value>string"` +} + +func (m structMemberString) structMember() {} + +type structMemberInt struct { + Name string `xml:"name"` + Value uint32 `xml:"value>int"` +} + +func (m structMemberInt) structMember() {} + +type structMemberBool struct { + Name string `xml:"name"` + Value bool `xml:"value>boolean"` +} + +func (m structMemberBool) structMember() {} + +type methodCall struct { + XMLName xml.Name `xml:"methodCall"` + MethodName string `xml:"methodName"` + Params []param `xml:"params>param"` +} + +// types for XML-RPC responses + +type response interface { + faultCode() uint32 + faultString() string +} + +type responseString struct { + responseFault + Value string `xml:"params>param>value>string"` +} + +type responseFault struct { + FaultCode uint32 `xml:"fault>value>struct>member>value>int"` + FaultString string `xml:"fault>value>struct>member>value>string"` +} + +func (r responseFault) faultCode() uint32 { return r.FaultCode } +func (r responseFault) faultString() string { return r.FaultString } + +type rpcError struct { + faultCode uint32 + faultString string +} + +func (e rpcError) Error() string { + return fmt.Sprintf("RPC Error: (%d) %s", e.faultCode, e.faultString) +} + +type zRec struct { + // "name" "value" + Type string + Rdata string + Priority uint16 // the next 4 ints are 112 bits + TTL uint32 + RecordID uint32 +} + +// zoneRecord and domainObject are synonymous (but belong to different parts of +// XML structure in req/resp). Can we unify them? +type zoneRecord struct { + XMLName xml.Name `xml:"struct"` + Properties []Property `xml:"member"` + // Properties map[string]interface{} +} + +type zoneRecordsResponse struct { + responseFault + XMLName xml.Name `xml:"methodResponse"` + ZoneRecords []zoneRecord `xml:"params>param>value>array>data>value>struct"` +} + +type Property struct { + Key string `xml:"name"` + Value Value `xml:"value"` +} + +type Value struct { + // String string `xml:",any"` + String string `xml:"string"` + Int int `xml:"int"` + Bool bool `xml:"bool"` +} + +func (p Property) Name() string { return p.Key } +func (p Property) String() string { return p.Value.String } +func (p Property) Int() int { return p.Value.Int } +func (p Property) Bool() bool { return p.Value.Bool } + +func (zr *zoneRecord) GetZR() zRec { + record := zRec{} + for _, prop := range zr.Properties { + switch prop.Key { + case "type": + record.Type = prop.String() + case "ttl": + record.TTL = uint32(prop.Int()) + case "priority": + record.Priority = uint16(prop.Int()) + case "rdata": + record.Rdata = prop.String() + case "record_id": + record.RecordID = uint32(prop.Int()) + } + } + return record +} + +func (zrec *zRec) SetZR() zoneRecord { + //This method creates a zoneRecord to receive from responses. + return zoneRecord{ + XMLName: xml.Name{Local: "struct"}, + Properties: []Property{ + Property{Key: "type", Value: Value{String: zrec.Type}}, + Property{Key: "ttl", Value: Value{Int: int(zrec.TTL)}}, + Property{Key: "priority", Value: Value{Int: int(zrec.Priority)}}, + Property{Key: "rdata", Value: Value{String: zrec.Rdata}}, + Property{Key: "record_id", Value: Value{Int: int(zrec.RecordID)}}, + }, + } +} + +func (zrec *zRec) SetPS() paramStruct { + //This method creates a paramStruct for sending in requests. + return paramStruct{ + XMLName: xml.Name{Local: "struct"}, + StructMembers: []structMember{ + structMemberString{Name: "type", Value: zrec.Type}, + structMemberInt{Name: "ttl", Value: uint32(zrec.TTL)}, + structMemberInt{Name: "priority", Value: uint32(zrec.Priority)}, + structMemberString{Name: "rdata", Value: zrec.Rdata}, + structMemberInt{Name: "record_id", Value: uint32(zrec.RecordID)}, + }, + } +} + +type domainObject struct { + XMLName xml.Name `xml:"struct"` + Properties []Property `xml:"member"` +} + +// type LoopiaDomainObject struct { +// "name" "value" +// ReferenceNo int32 +// Domain string +// RenewalStatus string +// ExpirationDate string +// Paid bool +// Registered bool +// } + +type domainObjectsResponse struct { + responseFault + XMLName xml.Name `xml:"methodResponse"` + Domains []domainObject `xml:"params>param>value>array>data>value>struct"` +} + +type subDomainsResponse struct { + responseFault + XMLName xml.Name `xml:"methodResponse"` + Params []string `xml:"params>param>value>array>data>value>string"` +}