From b2ee265578dad749297d960839c5861f10cfda01 Mon Sep 17 00:00:00 2001 From: Asif Nawaz <107853964+AsifNawaz-cnic@users.noreply.github.com> Date: Fri, 6 Dec 2024 22:01:42 +0000 Subject: [PATCH] NEW PROVIDER: CentralNic Reseller (CNR) - formerly RRPProxy (#3203) --- .github/workflows/pr_test.yml | 7 +- OWNERS | 1 + README.md | 2 + documentation/provider/cnr.md | 112 ++++++++++ documentation/providers.md | 1 + go.mod | 1 + go.sum | 2 + integrationTest/providers.json | 8 + providers/_all/all.go | 1 + providers/cnr/auditrecords.go | 21 ++ providers/cnr/cnrProvider.go | 83 ++++++++ providers/cnr/domains.go | 55 +++++ providers/cnr/error.go | 12 ++ providers/cnr/nameservers.go | 106 ++++++++++ providers/cnr/records.go | 367 +++++++++++++++++++++++++++++++++ 15 files changed, 778 insertions(+), 1 deletion(-) create mode 100644 documentation/provider/cnr.md create mode 100644 providers/cnr/auditrecords.go create mode 100644 providers/cnr/cnrProvider.go create mode 100644 providers/cnr/domains.go create mode 100644 providers/cnr/error.go create mode 100644 providers/cnr/nameservers.go create mode 100644 providers/cnr/records.go diff --git a/.github/workflows/pr_test.yml b/.github/workflows/pr_test.yml index dc4a075bc4..9ed01a17ae 100644 --- a/.github/workflows/pr_test.yml +++ b/.github/workflows/pr_test.yml @@ -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 }} diff --git a/OWNERS b/OWNERS index 214b2a48f9..ff50183b7b 100644 --- a/OWNERS +++ b/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 diff --git a/README.md b/README.md index b1e962ac2b..93427631ac 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/documentation/provider/cnr.md b/documentation/provider/cnr.md new file mode 100644 index 0000000000..b7583726dc --- /dev/null +++ b/documentation/provider/cnr.md @@ -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. diff --git a/documentation/providers.md b/documentation/providers.md index f1e7e9b005..111d55f0c7 100644 --- a/documentation/providers.md +++ b/documentation/providers.md @@ -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) | ❌ | ✅ | ❌ | ✅ | ❔ | ✅ | ❔ | ❔ | ❌ | ❔ | ❔ | ❔ | ✅ | ❔ | ❔ | ❔ | ❔ | ❔ | ❔ | ❔ | ❔ | ✅ | ✅ | diff --git a/go.mod b/go.mod index a9f00ede3d..cd4b510727 100644 --- a/go.mod +++ b/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 diff --git a/go.sum b/go.sum index ba492636bc..294d6da62b 100644 --- a/go.sum +++ b/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= diff --git a/integrationTest/providers.json b/integrationTest/providers.json index f4e50ce549..5b1f538706 100644 --- a/integrationTest/providers.json +++ b/integrationTest/providers.json @@ -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", diff --git a/providers/_all/all.go b/providers/_all/all.go index e8a8775b0c..617697fa18 100644 --- a/providers/_all/all.go +++ b/providers/_all/all.go @@ -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" diff --git a/providers/cnr/auditrecords.go b/providers/cnr/auditrecords.go new file mode 100644 index 0000000000..2712559a61 --- /dev/null +++ b/providers/cnr/auditrecords.go @@ -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) +} diff --git a/providers/cnr/cnrProvider.go b/providers/cnr/cnrProvider.go new file mode 100644 index 0000000000..3ff2166c85 --- /dev/null +++ b/providers/cnr/cnrProvider.go @@ -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) +} diff --git a/providers/cnr/domains.go b/providers/cnr/domains.go new file mode 100644 index 0000000000..fed530407b --- /dev/null +++ b/providers/cnr/domains.go @@ -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 +} diff --git a/providers/cnr/error.go b/providers/cnr/error.go new file mode 100644 index 0000000000..a51c6b0045 --- /dev/null +++ b/providers/cnr/error.go @@ -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()) +} diff --git a/providers/cnr/nameservers.go b/providers/cnr/nameservers.go new file mode 100644 index 0000000000..d2f7e000ba --- /dev/null +++ b/providers/cnr/nameservers.go @@ -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 + } +} diff --git a/providers/cnr/records.go b/providers/cnr/records.go new file mode 100644 index 0000000000..bfe5d8fcd4 --- /dev/null +++ b/providers/cnr/records.go @@ -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" +}