NEW PROVIDER: CentralNic Reseller (CNR) - formerly RRPProxy (#3203)

This commit is contained in:
Asif Nawaz 2024-12-06 22:01:42 +00:00 committed by GitHub
parent be48b6e72f
commit b2ee265578
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 778 additions and 1 deletions

View file

@ -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
View file

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

View file

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

View 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.

View file

@ -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
View file

@ -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
View file

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

View file

@ -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",

View file

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

View 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)
}

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

View 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
View 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"
}