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/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
|
||||||
|
@ -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
|
||||||
|
@ -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)
|
||||||
|
@ -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` | ❌ | ✅ | ✅ | ✅ | ❔ | ✅ | ❌ | ❔ | ❔ | ❌ | ❔ | ❌ | ❔ | ❌ | ❌ | ❌ | ✅ |
|
||||||
|
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?
|
"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
|
||||||
|
@ -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",
|
||||||
|
@ -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"
|
||||||
|
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