mirror of
https://github.com/StackExchange/dnscontrol.git
synced 2025-10-06 03:46:34 +08:00
NEW PROVIDER: CentralNic Reseller (CNR) - formerly RRPProxy (#3203)
This commit is contained in:
parent
be48b6e72f
commit
b2ee265578
15 changed files with 778 additions and 1 deletions
7
.github/workflows/pr_test.yml
vendored
7
.github/workflows/pr_test.yml
vendored
|
@ -88,7 +88,7 @@ jobs:
|
|||
Write-Host "Integration test providers: $Providers"
|
||||
echo "integration_test_providers=$(ConvertTo-Json -InputObject $Providers -Compress)" >> $env:GITHUB_OUTPUT
|
||||
env:
|
||||
PROVIDERS: "['AZURE_DNS','BIND','BUNNY_DNS','CLOUDFLAREAPI','CLOUDNS','DIGITALOCEAN','GANDI_V5','GCLOUD','HEDNS','HEXONET','HUAWEICLOUD','INWX','NAMEDOTCOM','NS1','POWERDNS','ROUTE53','SAKURACLOUD','TRANSIP']"
|
||||
PROVIDERS: "['AZURE_DNS','BIND','BUNNY_DNS','CLOUDFLAREAPI','CLOUDNS','CNR','DIGITALOCEAN','GANDI_V5','GCLOUD','HEDNS','HEXONET','HUAWEICLOUD','INWX','NAMEDOTCOM','NS1','POWERDNS','ROUTE53','SAKURACLOUD','TRANSIP']"
|
||||
ENV_CONTEXT: ${{ toJson(env) }}
|
||||
VARS_CONTEXT: ${{ toJson(vars) }}
|
||||
SECRETS_CONTEXT: ${{ toJson(secrets) }}
|
||||
|
@ -111,6 +111,7 @@ jobs:
|
|||
BUNNY_DNS_DOMAIN: ${{ vars.BUNNY_DNS_DOMAIN }}
|
||||
CLOUDFLAREAPI_DOMAIN: ${{ vars.CLOUDFLAREAPI_DOMAIN }}
|
||||
CLOUDNS_DOMAIN: ${{ vars.CLOUDNS_DOMAIN }}
|
||||
CNR_DOMAIN: ${{ vars.CNR_DOMAIN }}
|
||||
CSCGLOBAL_DOMAIN: ${{ vars.CSCGLOBAL_DOMAIN }}
|
||||
DIGITALOCEAN_DOMAIN: ${{ vars.DIGITALOCEAN_DOMAIN }}
|
||||
GANDI_V5_DOMAIN: ${{ vars.GANDI_V5_DOMAIN }}
|
||||
|
@ -146,6 +147,10 @@ jobs:
|
|||
CSCGLOBAL_APIKEY: ${{ secrets.CSCGLOBAL_APIKEY }}
|
||||
CSCGLOBAL_USERTOKEN: ${{ secrets.CSCGLOBAL_USERTOKEN }}
|
||||
#
|
||||
CNR_UID: ${{ secrets.CNR_UID }}
|
||||
CNR_PW: ${{ secrets.CNR_PW }}
|
||||
CNR_ENTITY: ${{ secrets.CNR_ENTITY }}
|
||||
#
|
||||
DIGITALOCEAN_TOKEN: ${{ secrets.DIGITALOCEAN_TOKEN }}
|
||||
#
|
||||
GANDI_V5_APIKEY: ${{ secrets.GANDI_V5_APIKEY }}
|
||||
|
|
1
OWNERS
1
OWNERS
|
@ -7,6 +7,7 @@ providers/bind @tlimoncelli
|
|||
providers/bunnydns @ppmathis
|
||||
providers/cloudflare @tresni
|
||||
providers/cloudns @pragmaton
|
||||
providers/cnr @KaiSchwarz-cnic
|
||||
providers/cscglobal @mikenz
|
||||
providers/desec @D3luxee
|
||||
providers/digitalocean @Deraen
|
||||
|
|
|
@ -25,6 +25,7 @@ Currently supported DNS providers:
|
|||
- Bunny DNS
|
||||
- Cloudflare
|
||||
- ClouDNS
|
||||
- CentralNic Reseller (CNR) - formerly RRPProxy
|
||||
- deSEC
|
||||
- DigitalOcean
|
||||
- DNS Made Easy
|
||||
|
@ -66,6 +67,7 @@ Currently supported Domain Registrars:
|
|||
|
||||
- AWS Route 53
|
||||
- CSC Global
|
||||
- CentralNic Reseller (formerly RRPProxy)
|
||||
- DNSOVERHTTPS
|
||||
- Dynadot
|
||||
- easyname
|
||||
|
|
112
documentation/provider/cnr.md
Normal file
112
documentation/provider/cnr.md
Normal file
|
@ -0,0 +1,112 @@
|
|||
CentralNic Reseller (CNR), formerly known as RRPProxy, is a prominent provider of domain registration and DNS solutions. Trusted by individuals, service providers, and registrars around the world, CNR is recognized for its cutting-edge technology, exceptional performance, and reliable uptime.
|
||||
|
||||
Our advanced DNS expertise is integral to our offering. With CentralNic Reseller, you benefit from a leading DNS platform that features robust DNS automation, DNSSEC for enhanced security, and PremiumDNS via our Anycast Network. Additionally, our platform supports a comprehensive set of features, as detailed by DNSControl.
|
||||
|
||||
This is based on API documents found at [https://kb.centralnicreseller.com/api/api-commands/api-command-reference#cat-dynamicdns](https://kb.centralnicreseller.com/api/api-commands/api-command-reference#cat-dynamicdns)
|
||||
|
||||
## Configuration
|
||||
|
||||
To use this provider, add an entry to `creds.json` with `TYPE` set to `CNR`
|
||||
along with your CentralNic Reseller login data.
|
||||
|
||||
Example:
|
||||
|
||||
{% code title="creds.json" %}
|
||||
```json
|
||||
{
|
||||
"CNR": {
|
||||
"TYPE": "CNR",
|
||||
"apilogin": "your-cnr-account-id",
|
||||
"apipassword": "your-cnr-account-password",
|
||||
"apientity": "LIVE", // for the LIVE system; use "OTE" for the OT&E system
|
||||
"debugmode": "0", // set it to "1" to get debug output of the communication with our Backend System API
|
||||
}
|
||||
}
|
||||
```
|
||||
{% endcode %}
|
||||
|
||||
Here a working example for our OT&E System:
|
||||
|
||||
{% code title="creds.json" %}
|
||||
```json
|
||||
{
|
||||
"CNR": {
|
||||
"TYPE": "CNR",
|
||||
"apilogin": "YourUserName",
|
||||
"apipassword": "YourPassword",
|
||||
"apientity": "OTE",
|
||||
"debugmode": "0"
|
||||
}
|
||||
}
|
||||
```
|
||||
{% endcode %}
|
||||
|
||||
{% hint style="info" %}
|
||||
**NOTE**: The above credentials are known to the public.
|
||||
{% endhint %}
|
||||
|
||||
With the above CentralNic Reseller entry in `creds.json`, you can run the
|
||||
integration tests as follows:
|
||||
|
||||
```shell
|
||||
dnscontrol get-zones --format=nameonly cnr CNR all
|
||||
```
|
||||
```shell
|
||||
# Review the output. Pick one domain and set CNR_DOMAIN.
|
||||
export CNR_DOMAIN=yodream.com # Pick a domain name.
|
||||
export CNR_ENTITY=OTE
|
||||
export CNR_UID=test.user
|
||||
export CNR_PW=test.passw0rd
|
||||
cd integrationTest # NOTE: Not needed if already in that subdirectory
|
||||
go test -v -verbose -provider CNR
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
Here's an example DNS Configuration `dnsconfig.js` using our provider module.
|
||||
Even though it shows how you use us as Domain Registrar AND DNS Provider, we don't force you to do that.
|
||||
You are free to decide if you want to use both of our provider technology or just one of them.
|
||||
|
||||
{% code title="dnsconfig.js" %}
|
||||
```javascript
|
||||
var REG_CNR = NewRegistrar("CNR");
|
||||
var DSP_CNR = NewDnsProvider("CNR");
|
||||
|
||||
// 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.
|
||||
// also check this issue for [NAMESERVER TTL](https://github.com/StackExchange/dnscontrol/issues/176).
|
||||
DEFAULTS(
|
||||
{"ns_ttl":"3600"},
|
||||
DefaultTTL(3600)
|
||||
);
|
||||
|
||||
D("example.com", REG_CNR, DnsProvider(DSP_CNR),
|
||||
NAMESERVER("ns1.rrpproxy.net"),
|
||||
NAMESERVER("ns2.rrpproxy.net"),
|
||||
NAMESERVER("ns3.rrpproxy.net"),
|
||||
NAMESERVER("ns4.rrpproxy.net"),
|
||||
A("elk1", "10.190.234.178"),
|
||||
A("test", "56.123.54.12"),
|
||||
END);
|
||||
```
|
||||
{% endcode %}
|
||||
|
||||
## Metadata
|
||||
|
||||
This provider does not recognize any special metadata fields unique to CentralNic Reseller (CNR).
|
||||
|
||||
## get-zones
|
||||
|
||||
`dnscontrol get-zones` is implemented for this provider. The list
|
||||
includes both basic and premier zones.
|
||||
|
||||
## New domains
|
||||
|
||||
If a dnszone does not exist in your CNR 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 we 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 send to our system.
|
||||
In general this is thought for our purpose to have an easy way to dive into issues. But if you're interested what's going on, feel free to activate it.
|
|
@ -23,6 +23,7 @@ If a feature is definitively not supported for whatever reason, we would also li
|
|||
| [`BUNNY_DNS`](provider/bunny_dns.md) | ❌ | ✅ | ❌ | ❌ | ✅ | ✅ | ❌ | ❔ | ❌ | ❌ | ✅ | ❌ | ✅ | ❌ | ❔ | ❌ | ❌ | ❌ | ❔ | ❔ | ❌ | ✅ | ✅ |
|
||||
| [`CLOUDFLAREAPI`](provider/cloudflareapi.md) | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ | ❔ | ✅ | ❌ | ✅ | ✅ | ❔ | ✅ | ✅ | ✅ | ✅ | ❔ | ❔ | ❔ | ❌ | ❌ | ✅ | ✅ |
|
||||
| [`CLOUDNS`](provider/cloudns.md) | ❌ | ✅ | ❌ | ❌ | ✅ | ✅ | ✅ | ❔ | ✅ | ❔ | ✅ | ❔ | ✅ | ✅ | ❔ | ✅ | ❔ | ❔ | ✅ | ❔ | ❔ | ✅ | ✅ |
|
||||
| [`CNR`](provider/cnr.md) | ❌ | ✅ | ✅ | ✅ | ❌ | ✅ | ❔ | ❔ | ❔ | ❔ | ✅ | ❔ | ✅ | ❔ | ❔ | ✅ | ❔ | ❔ | ❔ | ❔ | ✅ | ✅ | ✅ |
|
||||
| [`CSCGLOBAL`](provider/cscglobal.md) | ✅ | ✅ | ✅ | ✅ | ❔ | ✅ | ❔ | ❔ | ❔ | ❔ | ❔ | ❔ | ✅ | ❔ | ❔ | ❔ | ❔ | ❔ | ❔ | ❔ | ❔ | ❌ | ✅ |
|
||||
| [`DESEC`](provider/desec.md) | ❌ | ✅ | ❌ | ✅ | ❔ | ✅ | ✅ | ✅ | ❔ | ✅ | ✅ | ❔ | ✅ | ✅ | ✅ | ✅ | ✅ | ❔ | ❔ | ✅ | ❔ | ✅ | ✅ |
|
||||
| [`DIGITALOCEAN`](provider/digitalocean.md) | ❌ | ✅ | ❌ | ✅ | ❔ | ✅ | ❔ | ❔ | ❌ | ❔ | ❔ | ❔ | ✅ | ❔ | ❔ | ❔ | ❔ | ❔ | ❔ | ❔ | ❔ | ✅ | ✅ |
|
||||
|
|
1
go.mod
1
go.mod
|
@ -59,6 +59,7 @@ require (
|
|||
require (
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.16.0
|
||||
github.com/G-Core/gcore-dns-sdk-go v0.2.9
|
||||
github.com/centralnicgroup-opensource/rtldev-middleware-go-sdk/v5 v5.0.0
|
||||
github.com/fatih/color v1.18.0
|
||||
github.com/fbiville/markdown-table-formatter v0.3.0
|
||||
github.com/go-acme/lego/v4 v4.20.2
|
||||
|
|
2
go.sum
2
go.sum
|
@ -91,6 +91,8 @@ github.com/centralnicgroup-opensource/rtldev-middleware-go-sdk/v4 v4.0.7 h1:Jk7u
|
|||
github.com/centralnicgroup-opensource/rtldev-middleware-go-sdk/v4 v4.0.7/go.mod h1:FnQtD0+Q/1NZxi0eEWN+3ZRyMsE9vzSB3YjyunkbKD0=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/centralnicgroup-opensource/rtldev-middleware-go-sdk/v5 v5.0.0 h1:45FDlPw2mCKrP3C3i0mACQpnG14k3z6ZhDX853idMHw=
|
||||
github.com/centralnicgroup-opensource/rtldev-middleware-go-sdk/v5 v5.0.0/go.mod h1:gDHPM5Nia+C/Q4Uw5rn9i+OIP3S06WUe7RdCpNP2C+E=
|
||||
github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ=
|
||||
github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8=
|
||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||
|
|
|
@ -72,6 +72,14 @@
|
|||
"notification_emails": "$CSCGLOBAL_NOTIFICATION",
|
||||
"user-token": "$CSCGLOBAL_USERTOKEN"
|
||||
},
|
||||
"CNR": {
|
||||
"TYPE": "CNR",
|
||||
"apientity": "$CNR_ENTITY",
|
||||
"apilogin": "$CNR_UID",
|
||||
"apipassword": "$CNR_PW",
|
||||
"debugmode": "$CNR_DEBUGMODE",
|
||||
"domain": "$CNR_DOMAIN"
|
||||
},
|
||||
"DESEC": {
|
||||
"TYPE": "DESEC",
|
||||
"auth-token": "$DESEC_TOKEN",
|
||||
|
|
|
@ -10,6 +10,7 @@ import (
|
|||
_ "github.com/StackExchange/dnscontrol/v4/providers/azureprivatedns"
|
||||
_ "github.com/StackExchange/dnscontrol/v4/providers/bind"
|
||||
_ "github.com/StackExchange/dnscontrol/v4/providers/bunnydns"
|
||||
_ "github.com/StackExchange/dnscontrol/v4/providers/cnr"
|
||||
_ "github.com/StackExchange/dnscontrol/v4/providers/cloudflare"
|
||||
_ "github.com/StackExchange/dnscontrol/v4/providers/cloudns"
|
||||
_ "github.com/StackExchange/dnscontrol/v4/providers/cscglobal"
|
||||
|
|
21
providers/cnr/auditrecords.go
Normal file
21
providers/cnr/auditrecords.go
Normal file
|
@ -0,0 +1,21 @@
|
|||
package cnr
|
||||
|
||||
import (
|
||||
"github.com/StackExchange/dnscontrol/v4/models"
|
||||
"github.com/StackExchange/dnscontrol/v4/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 2021-10-01
|
||||
|
||||
a.Add("TXT", rejectif.TxtHasDoubleQuotes) // Last verified 2023-11-30
|
||||
|
||||
a.Add("SRV", rejectif.SrvHasNullTarget) // Last verified 2020-12-28
|
||||
|
||||
return a.Audit(records)
|
||||
}
|
83
providers/cnr/cnrProvider.go
Normal file
83
providers/cnr/cnrProvider.go
Normal file
|
@ -0,0 +1,83 @@
|
|||
// Package CNR implements a registrar that uses the CNR api to set name servers. It will self register it's providers when imported.
|
||||
package cnr
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/StackExchange/dnscontrol/v4/providers"
|
||||
cnrcl "github.com/centralnicgroup-opensource/rtldev-middleware-go-sdk/v5/apiclient"
|
||||
)
|
||||
|
||||
// GoReleaser: version
|
||||
var (
|
||||
version = "dev"
|
||||
)
|
||||
|
||||
// CNRClient describes a connection to the CNR API.
|
||||
type CNRClient struct {
|
||||
conf map[string]string
|
||||
APILogin string
|
||||
APIPassword string
|
||||
APIEntity string
|
||||
client *cnrcl.APIClient
|
||||
}
|
||||
|
||||
var features = providers.DocumentationNotes{
|
||||
// The default for unlisted capabilities is 'Cannot'.
|
||||
// See providers/capabilities.go for the entire list of capabilities.
|
||||
providers.CanGetZones: providers.Can(),
|
||||
providers.CanConcur: providers.Can(),
|
||||
providers.CanUseAlias: providers.Cannot("Not supported. You may use CNAME records instead. An Alternative solution is planned."),
|
||||
providers.CanUseCAA: providers.Can(),
|
||||
providers.CanUseLOC: providers.Unimplemented(),
|
||||
providers.CanUsePTR: providers.Can(),
|
||||
providers.CanUseSRV: providers.Can("SRV records with empty targets are not supported"),
|
||||
providers.CanUseTLSA: providers.Can(),
|
||||
providers.DocCreateDomains: providers.Can(),
|
||||
providers.DocDualHost: providers.Can(),
|
||||
providers.DocOfficiallySupported: providers.Cannot("Actively maintained provider module."),
|
||||
}
|
||||
|
||||
func newProvider(conf map[string]string) (*CNRClient, error) {
|
||||
api := &CNRClient{
|
||||
conf: conf,
|
||||
client: cnrcl.NewAPIClient(),
|
||||
}
|
||||
api.client.SetUserAgent("DNSControl", version)
|
||||
api.APILogin, api.APIPassword, api.APIEntity = conf["apilogin"], conf["apipassword"], conf["apientity"]
|
||||
if conf["debugmode"] == "1" {
|
||||
api.client.EnableDebugMode()
|
||||
}
|
||||
if api.APIEntity != "OTE" && api.APIEntity != "LIVE" {
|
||||
return nil, fmt.Errorf("wrong api system entity used. use \"OTE\" for OT&E system or \"LIVE\" for Live system")
|
||||
}
|
||||
if api.APIEntity == "OTE" {
|
||||
api.client.UseOTESystem()
|
||||
}
|
||||
if api.APILogin == "" || api.APIPassword == "" {
|
||||
return nil, fmt.Errorf("missing login credentials apilogin or apipassword")
|
||||
}
|
||||
api.client.SetCredentials(api.APILogin, api.APIPassword)
|
||||
return api, nil
|
||||
}
|
||||
|
||||
func newReg(conf map[string]string) (providers.Registrar, error) {
|
||||
return newProvider(conf)
|
||||
}
|
||||
|
||||
func newDsp(conf map[string]string, meta json.RawMessage) (providers.DNSServiceProvider, error) {
|
||||
return newProvider(conf)
|
||||
}
|
||||
|
||||
func init() {
|
||||
const providerName = "CNR"
|
||||
const providerMaintainer = "@KaiSchwarz-cnic"
|
||||
fns := providers.DspFuncs{
|
||||
Initializer: newDsp,
|
||||
RecordAuditor: AuditRecords,
|
||||
}
|
||||
providers.RegisterRegistrarType(providerName, newReg)
|
||||
providers.RegisterDomainServiceProviderType(providerName, fns, features)
|
||||
providers.RegisterMaintainer(providerName, providerMaintainer)
|
||||
}
|
55
providers/cnr/domains.go
Normal file
55
providers/cnr/domains.go
Normal file
|
@ -0,0 +1,55 @@
|
|||
package cnr
|
||||
|
||||
// EnsureZoneExists returns an error
|
||||
// * if access to dnszone is not allowed (not authorized) or
|
||||
// * if it doesn't exist and creating it fails
|
||||
func (n *CNRClient) EnsureZoneExists(domain string) error {
|
||||
r := n.client.Request(map[string]interface{}{
|
||||
"COMMAND": "StatusDNSZone",
|
||||
"DNSZONE": domain,
|
||||
})
|
||||
code := r.GetCode()
|
||||
if code == 545 {
|
||||
command := map[string]interface{}{
|
||||
"COMMAND": "AddDNSZone",
|
||||
"DNSZONE": domain,
|
||||
}
|
||||
if n.APIEntity == "OTE" {
|
||||
command["SOATTL"] = "33200"
|
||||
command["SOASERIAL"] = "0000000000"
|
||||
}
|
||||
// Create the zone
|
||||
r = n.client.Request(command)
|
||||
if !r.IsSuccess() {
|
||||
return n.GetCNRApiError("Failed to create not existing zone ", domain, r)
|
||||
}
|
||||
} else if code == 531 {
|
||||
return n.GetCNRApiError("Not authorized to manage dnszone", domain, r)
|
||||
} else if r.IsError() || r.IsTmpError() {
|
||||
return n.GetCNRApiError("Error while checking status of dnszone", domain, r)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListZones lists all the
|
||||
func (n *CNRClient) ListZones() ([]string, error) {
|
||||
var zones []string
|
||||
|
||||
// Basic
|
||||
|
||||
rs := n.client.RequestAllResponsePages(map[string]string{
|
||||
"COMMAND": "QueryDNSZoneList",
|
||||
})
|
||||
for _, r := range rs {
|
||||
if r.IsError() {
|
||||
return nil, n.GetCNRApiError("Error while QueryDNSZoneList", "Basic", &r)
|
||||
}
|
||||
zoneColumn := r.GetColumn("DNSZONE")
|
||||
if zoneColumn != nil {
|
||||
//return nil, fmt.Errorf("failed getting DNSZONE BASIC column")
|
||||
zones = append(zones, zoneColumn.GetData()...)
|
||||
}
|
||||
}
|
||||
|
||||
return zones, nil
|
||||
}
|
12
providers/cnr/error.go
Normal file
12
providers/cnr/error.go
Normal file
|
@ -0,0 +1,12 @@
|
|||
package cnr
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/centralnicgroup-opensource/rtldev-middleware-go-sdk/v5/response"
|
||||
)
|
||||
|
||||
// GetCNRApiError returns an error including API error code and error description.
|
||||
func (n *CNRClient) GetCNRApiError(format string, objectid string, r *response.Response) error {
|
||||
return fmt.Errorf(format+" %q. [%v %s]", objectid, r.GetCode(), r.GetDescription())
|
||||
}
|
106
providers/cnr/nameservers.go
Normal file
106
providers/cnr/nameservers.go
Normal file
|
@ -0,0 +1,106 @@
|
|||
package cnr
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/StackExchange/dnscontrol/v4/models"
|
||||
)
|
||||
|
||||
var defaultNameservers = []*models.Nameserver{
|
||||
{Name: "ns1.rrpproxy.net"},
|
||||
{Name: "ns2.rrpproxy.net"},
|
||||
{Name: "ns3.rrpproxy.net"},
|
||||
}
|
||||
|
||||
var nsRegex = regexp.MustCompile(`ns([1-3]{1})[0-9]+\.rrpproxy\.net`)
|
||||
|
||||
// GetNameservers gets the nameservers set on a domain.
|
||||
func (n *CNRClient) GetNameservers(domain string) ([]*models.Nameserver, error) {
|
||||
// NOTE: This information is taken over from HX and adapted to CNR... might be wrong...
|
||||
// This is an interesting edge case. CNR expects you to SET the nameservers to ns[1-3].rrpproxy.net,
|
||||
// but it will internally set it to (ns1xyz|ns2uvw|ns3asd).rrpproxy.net, where xyz/uvw/asd is a uniqueish number.
|
||||
// In order to avoid endless loops, we will use the unique nameservers if present, or else the generic ones if not.
|
||||
nss, err := n.getNameserversRaw(domain)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
toUse := []string{
|
||||
defaultNameservers[0].Name,
|
||||
defaultNameservers[1].Name,
|
||||
defaultNameservers[2].Name,
|
||||
}
|
||||
for _, ns := range nss {
|
||||
if matches := nsRegex.FindStringSubmatch(ns); len(matches) == 2 && len(matches[1]) == 1 {
|
||||
idx := matches[1][0] - '1' // regex ensures proper range
|
||||
toUse[idx] = matches[0]
|
||||
}
|
||||
}
|
||||
return models.ToNameservers(toUse)
|
||||
}
|
||||
|
||||
func (n *CNRClient) getNameserversRaw(domain string) ([]string, error) {
|
||||
r := n.client.Request(map[string]interface{}{
|
||||
"COMMAND": "StatusDomain",
|
||||
"DOMAIN": domain,
|
||||
})
|
||||
code := r.GetCode()
|
||||
if code != 200 {
|
||||
return nil, n.GetCNRApiError("Could not get status for domain", domain, r)
|
||||
}
|
||||
nsColumn := r.GetColumn("NAMESERVER")
|
||||
if nsColumn == nil {
|
||||
fmt.Println("No nameservers found")
|
||||
return []string{}, nil // No nameserver assigned
|
||||
}
|
||||
ns := nsColumn.GetData()
|
||||
sort.Strings(ns)
|
||||
return ns, nil
|
||||
}
|
||||
|
||||
// GetRegistrarCorrections gathers corrections that would being n to match dc.
|
||||
func (n *CNRClient) GetRegistrarCorrections(dc *models.DomainConfig) ([]*models.Correction, error) {
|
||||
nss, err := n.getNameserversRaw(dc.Name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
foundNameservers := strings.Join(nss, ",")
|
||||
|
||||
expected := []string{}
|
||||
for _, ns := range dc.Nameservers {
|
||||
name := strings.TrimRight(ns.Name, ".")
|
||||
expected = append(expected, name)
|
||||
}
|
||||
sort.Strings(expected)
|
||||
expectedNameservers := strings.Join(expected, ",")
|
||||
|
||||
if foundNameservers != expectedNameservers {
|
||||
return []*models.Correction{
|
||||
{
|
||||
Msg: fmt.Sprintf("Update nameservers %s -> %s", foundNameservers, expectedNameservers),
|
||||
F: n.updateNameservers(expected, dc.Name),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (n *CNRClient) updateNameservers(ns []string, domain string) func() error {
|
||||
return func() error {
|
||||
cmd := map[string]interface{}{
|
||||
"COMMAND": "ModifyDomain",
|
||||
"DOMAIN": domain,
|
||||
}
|
||||
for idx, ns := range ns {
|
||||
cmd[fmt.Sprintf("NAMESERVER%d", idx)] = ns
|
||||
}
|
||||
response := n.client.Request(cmd)
|
||||
code := response.GetCode()
|
||||
if code != 200 {
|
||||
return fmt.Errorf("%d %s", code, response.GetDescription())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
367
providers/cnr/records.go
Normal file
367
providers/cnr/records.go
Normal file
|
@ -0,0 +1,367 @@
|
|||
package cnr
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/StackExchange/dnscontrol/v4/models"
|
||||
"github.com/StackExchange/dnscontrol/v4/pkg/diff"
|
||||
"github.com/StackExchange/dnscontrol/v4/pkg/txtutil"
|
||||
)
|
||||
|
||||
// CNRRecord covers an individual DNS resource record.
|
||||
type CNRRecord struct {
|
||||
// DomainName is the zone that the record belongs to.
|
||||
DomainName string
|
||||
// Host is the hostname relative to the zone: e.g. for a record for blog.example.org, domain would be "example.org" and host would be "blog".
|
||||
// An apex record would be specified by either an empty host "" or "@".
|
||||
// A SRV record would be specified by "_{service}._{protocol}.{host}": e.g. "_sip._tcp.phone" for _sip._tcp.phone.example.org.
|
||||
Host string
|
||||
// FQDN is the Fully Qualified Domain Name. It is the combination of the host and the domain name. It always ends in a ".". FQDN is ignored in CreateRecord, specify via the Host field instead.
|
||||
Fqdn string
|
||||
// Type is one of the following: A, AAAA, ANAME, CNAME, MX, NS, SRV, or TXT.
|
||||
Type string
|
||||
// Answer is either the IP address for A or AAAA records; the target for ANAME, CNAME, MX, or NS records; the text for TXT records.
|
||||
// For SRV records, answer has the following format: "{weight} {port} {target}" e.g. "1 5061 sip.example.org".
|
||||
Answer string
|
||||
// TTL is the time this record can be cached for in seconds.
|
||||
TTL uint32
|
||||
// Priority is only required for MX and SRV records, it is ignored for all others.
|
||||
Priority uint32
|
||||
}
|
||||
|
||||
// GetZoneRecords gets the records of a zone and returns them in RecordConfig format.
|
||||
func (n *CNRClient) GetZoneRecords(domain string, meta map[string]string) (models.Records, error) {
|
||||
records, err := n.getRecords(domain)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
actual := make([]*models.RecordConfig, len(records))
|
||||
for i, r := range records {
|
||||
actual[i] = toRecord(r, domain)
|
||||
}
|
||||
|
||||
return actual, nil
|
||||
|
||||
}
|
||||
|
||||
// GetZoneRecordsCorrections returns a list of corrections that will turn existing records into dc.Records.
|
||||
func (n *CNRClient) GetZoneRecordsCorrections(dc *models.DomainConfig, actual models.Records) ([]*models.Correction, int, error) {
|
||||
toReport, create, del, mod, actualChangeCount, err := diff.NewCompat(dc).IncrementalDiff(actual)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
// Start corrections with the reports
|
||||
corrections := diff.GenerateMessageCorrections(toReport)
|
||||
|
||||
buf := &bytes.Buffer{}
|
||||
// Print a list of changes. Generate an actual change that is the zone
|
||||
changes := false
|
||||
var builder strings.Builder
|
||||
params := map[string]interface{}{}
|
||||
delrridx := 0
|
||||
addrridx := 0
|
||||
|
||||
for _, cre := range create {
|
||||
changes = true
|
||||
fmt.Fprintln(buf, cre)
|
||||
newRecordString, err := n.createRecordString(cre.Desired, dc.Name)
|
||||
if err != nil {
|
||||
return corrections, 0, err
|
||||
}
|
||||
key := fmt.Sprintf("ADDRR%d", addrridx)
|
||||
params[key] = newRecordString
|
||||
fmt.Fprintf(&builder, "\033[32m+ %s = %s\033[0m\n", key, newRecordString)
|
||||
addrridx++
|
||||
}
|
||||
for _, d := range del {
|
||||
changes = true
|
||||
fmt.Fprintln(buf, d)
|
||||
key := fmt.Sprintf("DELRR%d", delrridx)
|
||||
oldRecordString := n.deleteRecordString(d.Existing.Original.(*CNRRecord))
|
||||
params[key] = oldRecordString
|
||||
fmt.Fprintf(&builder, "\033[31m- %s = %s\033[0m\n", key, oldRecordString)
|
||||
delrridx++
|
||||
}
|
||||
for _, chng := range mod {
|
||||
changes = true
|
||||
fmt.Fprintln(buf, chng)
|
||||
// old record deletion
|
||||
key := fmt.Sprintf("DELRR%d", delrridx)
|
||||
oldRecordString := n.deleteRecordString(chng.Existing.Original.(*CNRRecord))
|
||||
params[key] = oldRecordString
|
||||
fmt.Fprintf(&builder, "\033[31m- %s = %s\033[0m\n", key, oldRecordString)
|
||||
delrridx++
|
||||
// new record creation
|
||||
newRecordString, err := n.createRecordString(chng.Desired, dc.Name)
|
||||
if err != nil {
|
||||
return corrections, 0, err
|
||||
}
|
||||
key = fmt.Sprintf("ADDRR%d", addrridx)
|
||||
params[key] = newRecordString
|
||||
fmt.Fprintf(&builder, "\033[32m+ %s = %s\033[0m\n", key, newRecordString)
|
||||
addrridx++
|
||||
}
|
||||
|
||||
if changes {
|
||||
msg := fmt.Sprintf("GENERATE_ZONE: %s\n%s", dc.Name, buf.String())
|
||||
if n.isDebugOn() {
|
||||
msg = fmt.Sprintf("GENERATE_ZONE: %s\n%sPROVIDER CNR, API COMMAND PARAMETERS:\n%s", dc.Name, buf.String(), builder.String())
|
||||
}
|
||||
corrections = append(corrections, &models.Correction{
|
||||
Msg: msg,
|
||||
F: func() error {
|
||||
return n.updateZoneBy(params, dc.Name)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return corrections, actualChangeCount, nil
|
||||
}
|
||||
|
||||
func toRecord(r *CNRRecord, origin string) *models.RecordConfig {
|
||||
rc := &models.RecordConfig{
|
||||
Type: r.Type,
|
||||
TTL: r.TTL,
|
||||
Original: r,
|
||||
}
|
||||
fqdn := r.Fqdn[:len(r.Fqdn)-1]
|
||||
rc.SetLabelFromFQDN(fqdn, origin)
|
||||
|
||||
switch r.Type {
|
||||
case "MX", "SRV":
|
||||
if r.Priority > 65535 {
|
||||
panic(fmt.Errorf("priority value out of range for %s record: %d", r.Type, r.Priority))
|
||||
}
|
||||
if r.Type == "MX" {
|
||||
if err := rc.SetTargetMX(uint16(r.Priority), r.Answer); err != nil {
|
||||
panic(fmt.Errorf("unparsable MX record received from centralnic reseller API: %w", err))
|
||||
}
|
||||
} else {
|
||||
// _service._proto.name. TTL Type Priority Weight Port Target.
|
||||
// e.g. _sip._tcp.phone.example.org. 86400 IN SRV 5 6 7 sip.example.org.
|
||||
// r.Anser covers the format "Priority Weight Port Target" and we've to remove the priority from the string
|
||||
r.Answer = strings.TrimPrefix(r.Answer, fmt.Sprintf("%d ", r.Priority))
|
||||
if err := rc.SetTargetSRVPriorityString(uint16(r.Priority), r.Answer); err != nil {
|
||||
panic(fmt.Errorf("unparsable SRV record received from centralnic reseller API: %w", err))
|
||||
}
|
||||
}
|
||||
default: // "A", "AAAA", "ANAME", "CNAME", "NS", "TXT", "CAA", "TLSA", "PTR"
|
||||
if err := rc.PopulateFromStringFunc(r.Type, r.Answer, r.Fqdn, txtutil.ParseQuoted); err != nil {
|
||||
panic(fmt.Errorf("unparsable record received from centralnic reseller API: %w", err))
|
||||
}
|
||||
}
|
||||
return rc
|
||||
}
|
||||
|
||||
// updateZoneBy updates the zone with the provided changes.
|
||||
func (n *CNRClient) updateZoneBy(params map[string]interface{}, domain string) error {
|
||||
zone := domain
|
||||
cmd := map[string]interface{}{
|
||||
"COMMAND": "ModifyDNSZone",
|
||||
"DNSZONE": zone,
|
||||
}
|
||||
for key, val := range params {
|
||||
cmd[key] = val
|
||||
}
|
||||
r := n.client.Request(cmd)
|
||||
if !r.IsSuccess() {
|
||||
return n.GetCNRApiError("Error while updating zone", zone, r)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// deleteRecordString constructs the record string based on the provided CNRRecord.
|
||||
func (n *CNRClient) getRecords(domain string) ([]*CNRRecord, error) {
|
||||
var records []*CNRRecord
|
||||
|
||||
// Command to find out the total numbers of resource records for the zone
|
||||
// so that the follow-up query can be done with the correct limit
|
||||
cmd := map[string]interface{}{
|
||||
"COMMAND": "QueryDNSZoneRRList",
|
||||
"DNSZONE": domain,
|
||||
"ORDERBY": "type",
|
||||
"FIRST": "0",
|
||||
"LIMIT": "1",
|
||||
}
|
||||
r := n.client.Request(cmd)
|
||||
|
||||
// Check if the request was successful
|
||||
if !r.IsSuccess() {
|
||||
if r.GetCode() == 545 {
|
||||
// If dns zone does not exist create a new one automatically
|
||||
if !isNoPopulate() {
|
||||
n.EnsureZoneExists(domain)
|
||||
} else {
|
||||
// Return specific error if the zone does not exist
|
||||
return nil, n.GetCNRApiError("Use `dnscontrol create-domains` to create not-existing zone", domain, r)
|
||||
}
|
||||
}
|
||||
// Return general error for any other issues
|
||||
return nil, n.GetCNRApiError("Failed loading resource records for zone", domain, r)
|
||||
}
|
||||
totalRecords := r.GetRecordsTotalCount()
|
||||
if totalRecords <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
limitation := 100
|
||||
totalRecords += limitation
|
||||
|
||||
// finally request all resource records available for the zone
|
||||
cmd["LIMIT"] = fmt.Sprintf("%d", totalRecords)
|
||||
cmd["WIDE"] = "1"
|
||||
r = n.client.Request(cmd)
|
||||
|
||||
// Check if the request was successful
|
||||
if !r.IsSuccess() {
|
||||
// Return general error for any other issues
|
||||
return nil, n.GetCNRApiError("Failed loading resource records for zone", domain, r)
|
||||
}
|
||||
|
||||
// loop over the records array
|
||||
rrs := r.GetRecords()
|
||||
for i := 0; i < len(rrs); i++ {
|
||||
data := rrs[i].GetData()
|
||||
// fmt.Printf("Data: %+v\n", data)
|
||||
if _, exists := data["NAME"]; !exists {
|
||||
continue
|
||||
}
|
||||
|
||||
if data["TYPE"] == "MX" {
|
||||
tmp := strings.Split(data["CONTENT"], " ")
|
||||
data["PRIO"] = tmp[0]
|
||||
data["CONTENT"] = tmp[1]
|
||||
}
|
||||
|
||||
// Parse the TTL string to an unsigned integer
|
||||
ttl, err := strconv.ParseUint(data["TTL"], 10, 32)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid TTL value for domain %s: %s", domain, data["TTL"])
|
||||
}
|
||||
|
||||
// Parse the TTL string to an unsigned integer
|
||||
priority, _ := strconv.ParseUint(data["PRIO"], 10, 32)
|
||||
|
||||
// Add dot to Answer if supported by the record type
|
||||
pattern := `^CNAME|MX|NS|SRV|PTR$`
|
||||
re, err := regexp.Compile(pattern)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error compiling regex in getRecords: %s", err)
|
||||
}
|
||||
if re.MatchString(data["TYPE"]) && !strings.HasSuffix(data["CONTENT"], ".") {
|
||||
data["CONTENT"] = fmt.Sprintf("%s.", data["CONTENT"])
|
||||
}
|
||||
|
||||
// Only append domain if it's not already a fully qualified domain name
|
||||
fqdn := fmt.Sprintf("%s.", domain)
|
||||
if data["NAME"] != "@" && !strings.HasSuffix(data["NAME"], domain+".") {
|
||||
fqdn = fmt.Sprintf("%s.%s.", data["NAME"], domain)
|
||||
}
|
||||
|
||||
// Initialize a new CNRRecord
|
||||
record := &CNRRecord{
|
||||
DomainName: domain,
|
||||
Host: data["NAME"],
|
||||
Fqdn: fqdn,
|
||||
Type: data["TYPE"],
|
||||
Answer: data["CONTENT"],
|
||||
TTL: uint32(ttl),
|
||||
Priority: uint32(priority),
|
||||
}
|
||||
// fmt.Printf("Record: %+v\n", record)
|
||||
|
||||
// Append the record to the records slice
|
||||
records = append(records, record)
|
||||
}
|
||||
|
||||
// Return the slice of records
|
||||
return records, nil
|
||||
}
|
||||
|
||||
// Function to create record string from given RecordConfig for the ADDRR# API parameter
|
||||
func (n *CNRClient) createRecordString(rc *models.RecordConfig, domain string) (string, error) {
|
||||
host := rc.GetLabel()
|
||||
answer := ""
|
||||
|
||||
switch rc.Type { // #rtype_variations
|
||||
case "A", "AAAA", "ANAME", "CNAME", "MX", "NS", "PTR":
|
||||
answer = rc.GetTargetField()
|
||||
if domain == host {
|
||||
host = fmt.Sprintf(`%s.`, host)
|
||||
}
|
||||
case "TLSA":
|
||||
answer = fmt.Sprintf(`%v %v %v %s`, rc.TlsaUsage, rc.TlsaSelector, rc.TlsaMatchingType, rc.GetTargetField())
|
||||
case "CAA":
|
||||
answer = fmt.Sprintf(`%v %s "%s"`, rc.CaaFlag, rc.CaaTag, rc.GetTargetField())
|
||||
case "TXT":
|
||||
answer = txtutil.EncodeQuoted(rc.GetTargetTXTJoined())
|
||||
case "SRV":
|
||||
if rc.GetTargetField() == "." {
|
||||
return "", fmt.Errorf("SRV records with empty targets are not supported")
|
||||
}
|
||||
// _service._proto.name. TTL Type Priority Weight Port Target.
|
||||
// e.g. _sip._tcp.phone.example.org. 86400 IN SRV 5 6 7 sip.example.org.
|
||||
answer = fmt.Sprintf("%d %d %d %v", uint32(rc.SrvPriority), rc.SrvWeight, rc.SrvPort, rc.GetTargetField())
|
||||
default:
|
||||
panic(fmt.Sprintf("createRecordString rtype %v unimplemented", rc.Type))
|
||||
// We panic so that we quickly find any switch statements
|
||||
// that have not been updated for a new RR type.
|
||||
}
|
||||
|
||||
str := host + " " + fmt.Sprint(rc.TTL) + " "
|
||||
|
||||
if rc.Type != "NS" { // TODO
|
||||
str += "IN "
|
||||
}
|
||||
str += rc.Type + " "
|
||||
// Handle MX records which have priority
|
||||
if rc.Type == "MX" {
|
||||
str += fmt.Sprint(uint32(rc.MxPreference)) + " "
|
||||
}
|
||||
str += answer
|
||||
return str, nil
|
||||
}
|
||||
|
||||
// deleteRecordString constructs the record string based on the provided CNRRecord.
|
||||
func (n *CNRClient) deleteRecordString(record *CNRRecord) string {
|
||||
// Initialize values slice
|
||||
values := []string{
|
||||
record.Host,
|
||||
fmt.Sprintf("%v", record.TTL),
|
||||
"IN",
|
||||
record.Type,
|
||||
}
|
||||
if record.Type == "SRV" {
|
||||
values = append(values, fmt.Sprintf("%d", record.Priority))
|
||||
}
|
||||
values = append(values, record.Answer)
|
||||
|
||||
// fmt.Printf("Values: %+v\n", values)
|
||||
|
||||
// Remove IN if the record type is "NS" TODO
|
||||
if record.Type == "NS" {
|
||||
values = append(values[:2], values[3:]...) // Skip over the "IN"
|
||||
}
|
||||
|
||||
// Return the final string by joining the elements with spaces
|
||||
return strings.Join(values, " ")
|
||||
}
|
||||
|
||||
// Function to check the no-populate argument
|
||||
func isNoPopulate() bool {
|
||||
for _, arg := range os.Args {
|
||||
if arg == "--no-populate" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Function to check if debug mode is enabled
|
||||
func (n *CNRClient) isDebugOn() bool {
|
||||
return n.conf["debugmode"] == "1"
|
||||
}
|
Loading…
Add table
Reference in a new issue