1
0
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:
Paul Dee
2023-03-15 14:54:07 +01:00
committed by GitHub
parent 9beb00f6b1
commit 8e643c2856
16 changed files with 2585 additions and 1 deletions

3
OWNERS
View File

@ -23,9 +23,10 @@ providers/hexonet @KaiSchwarz-cnic
providers/hostingde @juliusrickert providers/hostingde @juliusrickert
providers/internetbs @pragmaton providers/internetbs @pragmaton
providers/inwx @patschi providers/inwx @patschi
providers/msdns @tlimoncelli
providers/linode @koesie10 providers/linode @koesie10
providers/loopia @systemcrash
providers/luadns @riku22 providers/luadns @riku22
providers/msdns @tlimoncelli
providers/namecheap @willpower232 providers/namecheap @willpower232
# providers/namedotcom NEEDS VOLUNTEER # providers/namedotcom NEEDS VOLUNTEER
providers/netcup @kordianbruck providers/netcup @kordianbruck

View File

@ -42,6 +42,7 @@ Currently supported DNS providers:
- Hurricane Electric DNS - Hurricane Electric DNS
- INWX - INWX
- Linode - Linode
- Loopia
- LuaDNS - LuaDNS
- Microsoft Windows Server DNS Server - Microsoft Windows Server DNS Server
- NS1 - NS1

View File

@ -112,6 +112,7 @@
* [Internet.bs](providers/internetbs.md) * [Internet.bs](providers/internetbs.md)
* [INWX](providers/inwx.md) * [INWX](providers/inwx.md)
* [Linode](providers/linode.md) * [Linode](providers/linode.md)
* [Loopia](providers/loopia.md)
* [LuaDNS](providers/luadns.md) * [LuaDNS](providers/luadns.md)
* [Microsoft DNS Server on Microsoft Windows Server](providers/msdns.md) * [Microsoft DNS Server on Microsoft Windows Server](providers/msdns.md)
* [Namecheap](providers/namecheap.md) * [Namecheap](providers/namecheap.md)

View File

@ -40,6 +40,7 @@ If a feature is definitively not supported for whatever reason, we would also li
| `INTERNETBS` | ❌ | ❌ | ✅ | ❔ | ❔ | ❔ | ❔ | ❔ | ❔ | ❔ | ❔ | ❔ | ❔ | ❔ | ❌ | ✅ | ❔ | | `INTERNETBS` | ❌ | ❌ | ✅ | ❔ | ❔ | ❔ | ❔ | ❔ | ❔ | ❔ | ❔ | ❔ | ❔ | ❔ | ❌ | ✅ | ❔ |
| `INWX` | ❌ | ✅ | ✅ | ❌ | ❔ | ✅ | ✅ | ✅ | ❔ | ✅ | ✅ | ✅ | ❔ | ✅ | ✅ | ✅ | ✅ | | `INWX` | ❌ | ✅ | ✅ | ❌ | ❔ | ✅ | ✅ | ✅ | ❔ | ✅ | ✅ | ✅ | ❔ | ✅ | ✅ | ✅ | ✅ |
| `LINODE` | ❌ | ✅ | ❌ | ❔ | ❔ | ✅ | ❔ | ❔ | ❔ | ❔ | ❔ | ❔ | ❔ | ❌ | ❌ | ✅ | ✅ | | `LINODE` | ❌ | ✅ | ❌ | ❔ | ❔ | ✅ | ❔ | ❔ | ❔ | ❔ | ❔ | ❔ | ❔ | ❌ | ❌ | ✅ | ✅ |
| `LOOPIA` | ❌ | ✅ | ✅ | ❌ | ❌ | ✅ | ❌ | ✅ | ❌ | ✅ | ✅ | ✅ | ❌ | ✅ | ❌ | ✅ | ✅ |
| `LUADNS` | ✅ | ✅ | ❌ | ✅ | ❔ | ✅ | ✅ | ❔ | ❔ | ✅ | ✅ | ✅ | ❔ | ✅ | ✅ | ✅ | ✅ | | `LUADNS` | ✅ | ✅ | ❌ | ✅ | ❔ | ✅ | ✅ | ❔ | ❔ | ✅ | ✅ | ✅ | ❔ | ✅ | ✅ | ✅ | ✅ |
| `MSDNS` | ✅ | ✅ | ❌ | ❌ | ❔ | ❌ | ✅ | ✅ | ❔ | ✅ | ❔ | ❔ | ❔ | ❌ | ❌ | ✅ | ✅ | | `MSDNS` | ✅ | ✅ | ❌ | ❌ | ❔ | ❌ | ✅ | ✅ | ❔ | ✅ | ❔ | ❔ | ❔ | ❌ | ❌ | ✅ | ✅ |
| `NAMECHEAP` | ❌ | ✅ | ✅ | ✅ | ❔ | ✅ | ❌ | ❔ | ❔ | ❌ | ❔ | ❌ | ❔ | ❌ | ❌ | ❌ | ✅ | | `NAMECHEAP` | ❌ | ✅ | ✅ | ✅ | ❔ | ✅ | ❌ | ❔ | ❔ | ❌ | ❔ | ❌ | ❔ | ❌ | ❌ | ❌ | ✅ |

View File

@ -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 requests 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 requests 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.

View File

@ -915,6 +915,7 @@ func makeTests(t *testing.T) []*TestGroup {
"DIGITALOCEAN", // No paging. Why bother? "DIGITALOCEAN", // No paging. Why bother?
"CSCGLOBAL", // Doesn't page. Works fine. Due to the slow API we skip. "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. "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. "MSDNS", // No paging done. No need to test.
"NAMEDOTCOM", // Their API is so damn slow. We'll add it back as needed. "NAMEDOTCOM", // Their API is so damn slow. We'll add it back as needed.
"NS1", // Free acct only allows 50 records, therefore we skip "NS1", // Free acct only allows 50 records, therefore we skip

View File

@ -134,6 +134,11 @@
"domain": "$LINODE_DOMAIN", "domain": "$LINODE_DOMAIN",
"token": "$LINODE_TOKEN" "token": "$LINODE_TOKEN"
}, },
"LOOPIA": {
"username": "$LOOPIA_USERNAME",
"password": "$LOOPIA_PASSWORD",
"domain": "$LOOPIA_DOMAIN"
},
"LUADNS": { "LUADNS": {
"domain": "$LUADNS_DOMAIN", "domain": "$LUADNS_DOMAIN",
"email": "$LUADNS_EMAIL", "email": "$LUADNS_EMAIL",

View File

@ -29,6 +29,7 @@ import (
_ "github.com/StackExchange/dnscontrol/v3/providers/internetbs" _ "github.com/StackExchange/dnscontrol/v3/providers/internetbs"
_ "github.com/StackExchange/dnscontrol/v3/providers/inwx" _ "github.com/StackExchange/dnscontrol/v3/providers/inwx"
_ "github.com/StackExchange/dnscontrol/v3/providers/linode" _ "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/luadns"
_ "github.com/StackExchange/dnscontrol/v3/providers/msdns" _ "github.com/StackExchange/dnscontrol/v3/providers/msdns"
_ "github.com/StackExchange/dnscontrol/v3/providers/namecheap" _ "github.com/StackExchange/dnscontrol/v3/providers/namecheap"

View File

@ -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
}

558
providers/loopia/client.go Normal file
View 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
}
}

View File

@ -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, "<?xml version='1.0' encoding='UTF-8'?>")
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>name<name>",
},
{
desc: "faulty string",
xml: "<value><string>foo<string></value>",
},
{
desc: "faulty int",
xml: "<value><int>1<int></value>",
},
}
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
}

View File

@ -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()
}

View File

@ -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")
}
}

View File

@ -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
}

View File

@ -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 = `<?xml version="1.0" encoding="UTF-8"?>
<methodResponse>
<params>
<param>
<value>
<string>
OK
</string>
</value>
</param>
</params>
</methodResponse>`
const responseAuthError = `<?xml version="1.0" encoding="UTF-8"?>
<methodResponse>
<params>
<param>
<value>
<string>
AUTH_ERROR
</string>
</value>
</param>
</params>
</methodResponse>`
const responseUnknownError = `<?xml version="1.0" encoding="UTF-8"?>
<methodResponse>
<params>
<param>
<value>
<string>
UNKNOWN_ERROR
</string>
</value>
</param>
</params>
</methodResponse>`
const responseRPCError = `<?xml version="1.0" encoding="UTF-8"?>
<methodResponse>
<fault>
<value>
<struct>
<member>
<name>
faultCode
</name>
<value>
<int>
201
</int>
</value>
</member>
<member>
<name>
faultString
</name>
<value>
<string>
Method signature error: 42
</string>
</value>
</member>
</struct>
</value>
</fault>
</methodResponse>`
const addZoneRecordGoodAuth = `<?xml version="1.0"?>
<methodCall>
<methodName>addZoneRecord</methodName>
<params>
<param>
<value>
<string>apiuser</string>
</value>
</param>
<param>
<value>
<string>goodpassword</string>
</value>
</param>
<param>
<value>
<string>example.com</string>
</value>
</param>
<param>
<value>
<string>_acme-challenge</string>
</value>
</param>
<param>
<value>
<struct>
<member>
<name>type</name>
<value>
<string>TXT</string>
</value>
</member>
<member>
<name>ttl</name>
<value>
<int>123</int>
</value>
</member>
<member>
<name>priority</name>
<value>
<int>0</int>
</value>
</member>
<member>
<name>rdata</name>
<value>
<string>TXTrecord</string>
</value>
</member>
<member>
<name>record_id</name>
<value>
<int>0</int>
</value>
</member>
</struct>
</value>
</param>
</params>
</methodCall>`
const addZoneRecordBadAuth = `<?xml version="1.0"?>
<methodCall>
<methodName>addZoneRecord</methodName>
<params>
<param>
<value>
<string>apiuser</string>
</value>
</param>
<param>
<value>
<string>badpassword</string>
</value>
</param>
<param>
<value>
<string>example.com</string>
</value>
</param>
<param>
<value>
<string>_acme-challenge</string>
</value>
</param>
<param>
<value>
<struct>
<member>
<name>type</name>
<value>
<string>TXT</string>
</value>
</member>
<member>
<name>ttl</name>
<value>
<int>123</int>
</value>
</member>
<member>
<name>priority</name>
<value>
<int>0</int>
</value>
</member>
<member>
<name>rdata</name>
<value>
<string>TXTrecord</string>
</value>
</member>
<member>
<name>record_id</name>
<value>
<int>0</int>
</value>
</member>
</struct>
</value>
</param>
</params>
</methodCall>`
const addZoneRecordNonValidDomain = `<?xml version="1.0"?>
<methodCall>
<methodName>addZoneRecord</methodName>
<params>
<param>
<value>
<string>apiuser</string>
</value>
</param>
<param>
<value>
<string>goodpassword</string>
</value>
</param>
<param>
<value>
<string>badexample.com</string>
</value>
</param>
<param>
<value>
<string>_acme-challenge</string>
</value>
</param>
<param>
<value>
<struct>
<member>
<name>type</name>
<value>
<string>TXT</string>
</value>
</member>
<member>
<name>ttl</name>
<value>
<int>123</int>
</value>
</member>
<member>
<name>priority</name>
<value>
<int>0</int>
</value>
</member>
<member>
<name>rdata</name>
<value>
<string>TXTrecord</string>
</value>
</member>
<member>
<name>record_id</name>
<value>
<int>0</int>
</value>
</member>
</struct>
</value>
</param>
</params>
</methodCall>`
const addZoneRecordEmptyResponse = `<?xml version="1.0"?>
<methodCall>
<methodName>addZoneRecord</methodName>
<params>
<param>
<value>
<string>apiuser</string>
</value>
</param>
<param>
<value>
<string>goodpassword</string>
</value>
</param>
<param>
<value>
<string>empty.com</string>
</value>
</param>
<param>
<value>
<string>_acme-challenge</string>
</value>
</param>
<param>
<value>
<struct>
<member>
<name>type</name>
<value>
<string>TXT</string>
</value>
</member>
<member>
<name>ttl</name>
<value>
<int>123</int>
</value>
</member>
<member>
<name>priority</name>
<value>
<int>0</int>
</value>
</member>
<member>
<name>rdata</name>
<value>
<string>TXTrecord</string>
</value>
</member>
<member>
<name>record_id</name>
<value>
<int>0</int>
</value>
</member>
</struct>
</value>
</param>
</params>
</methodCall>`
const getZoneRecords = `<?xml version="1.0"?>
<methodCall>
<methodName>getZoneRecords</methodName>
<params>
<param>
<value>
<string>apiuser</string>
</value>
</param>
<param>
<value>
<string>goodpassword</string>
</value>
</param>
<param>
<value>
<string>example.com</string>
</value>
</param>
<param>
<value>
<string>_acme-challenge</string>
</value>
</param>
</params>
</methodCall>`
const getZoneRecordsResponse = `<?xml version="1.0" encoding="UTF-8"?>
<methodResponse>
<params>
<param>
<value>
<array>
<data>
<value>
<struct>
<member>
<name>type</name>
<value>
<string>TXT</string>
</value>
</member>
<member>
<name>ttl</name>
<value>
<int>300</int>
</value>
</member>
<member>
<name>priority</name>
<value>
<int>0</int>
</value>
</member>
<member>
<name>rdata</name>
<value>
<string>LHDhK3oGRvkiefQnx7OOczTY5Tic_xZ6HcMOc_gmtoM</string>
</value>
</member>
<member>
<name>record_id</name>
<value>
<int>12345678</int>
</value>
</member>
</struct>
</value>
</data>
</array>
</value>
</param>
</params>
</methodResponse>`
const removeRecordGoodAuth = `<?xml version="1.0"?>
<methodCall>
<methodName>removeZoneRecord</methodName>
<params>
<param>
<value>
<string>apiuser</string>
</value>
</param>
<param>
<value>
<string>goodpassword</string>
</value>
</param>
<param>
<value>
<string>example.com</string>
</value>
</param>
<param>
<value>
<string>_acme-challenge</string>
</value>
</param>
<param>
<value>
<int>12345678</int>
</value>
</param>
</params>
</methodCall>`
const removeRecordBadAuth = `<?xml version="1.0"?>
<methodCall>
<methodName>removeZoneRecord</methodName>
<params>
<param>
<value>
<string>apiuser</string>
</value>
</param>
<param>
<value>
<string>badpassword</string>
</value>
</param>
<param>
<value>
<string>example.com</string>
</value>
</param>
<param>
<value>
<string>_acme-challenge</string>
</value>
</param>
<param>
<value>
<int>12345678</int>
</value>
</param>
</params>
</methodCall>`
const removeRecordNonValidDomain = `<?xml version="1.0"?>
<methodCall>
<methodName>removeZoneRecord</methodName>
<params>
<param>
<value>
<string>apiuser</string>
</value>
</param>
<param>
<value>
<string>goodpassword</string>
</value>
</param>
<param>
<value>
<string>badexample.com</string>
</value>
</param>
<param>
<value>
<string>_acme-challenge</string>
</value>
</param>
<param>
<value>
<int>12345678</int>
</value>
</param>
</params>
</methodCall>`
const removeRecordEmptyResponse = `<?xml version="1.0"?>
<methodCall>
<methodName>removeZoneRecord</methodName>
<params>
<param>
<value>
<string>apiuser</string>
</value>
</param>
<param>
<value>
<string>goodpassword</string>
</value>
</param>
<param>
<value>
<string>empty.com</string>
</value>
</param>
<param>
<value>
<string>_acme-challenge</string>
</value>
</param>
<param>
<value>
<int>12345678</int>
</value>
</param>
</params>
</methodCall>`
const removeSubdomainGoodAuth = `<?xml version="1.0"?>
<methodCall>
<methodName>removeSubdomain</methodName>
<params>
<param>
<value>
<string>apiuser</string>
</value>
</param>
<param>
<value>
<string>goodpassword</string>
</value>
</param>
<param>
<value>
<string>example.com</string>
</value>
</param>
<param>
<value>
<string>_acme-challenge</string>
</value>
</param>
</params>
</methodCall>`
const removeSubdomainBadAuth = `<?xml version="1.0"?>
<methodCall>
<methodName>removeSubdomain</methodName>
<params>
<param>
<value>
<string>apiuser</string>
</value>
</param>
<param>
<value>
<string>badpassword</string>
</value>
</param>
<param>
<value>
<string>example.com</string>
</value>
</param>
<param>
<value>
<string>_acme-challenge</string>
</value>
</param>
</params>
</methodCall>`
const removeSubdomainNonValidDomain = `<?xml version="1.0"?>
<methodCall>
<methodName>removeSubdomain</methodName>
<params>
<param>
<value>
<string>apiuser</string>
</value>
</param>
<param>
<value>
<string>goodpassword</string>
</value>
</param>
<param>
<value>
<string>badexample.com</string>
</value>
</param>
<param>
<value>
<string>_acme-challenge</string>
</value>
</param>
</params>
</methodCall>`
const removeSubdomainEmptyResponse = `<?xml version="1.0"?>
<methodCall>
<methodName>removeSubdomain</methodName>
<params>
<param>
<value>
<string>apiuser</string>
</value>
</param>
<param>
<value>
<string>goodpassword</string>
</value>
</param>
<param>
<value>
<string>empty.com</string>
</value>
</param>
<param>
<value>
<string>_acme-challenge</string>
</value>
</param>
</params>
</methodCall>`

208
providers/loopia/types.go Normal file
View File

@ -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"`
}