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