diff --git a/.github/workflows/pr_integration_tests.yml b/.github/workflows/pr_integration_tests.yml index 89a7d2281e..ff951675a3 100644 --- a/.github/workflows/pr_integration_tests.yml +++ b/.github/workflows/pr_integration_tests.yml @@ -65,7 +65,7 @@ jobs: Write-Host "Integration test providers: $Providers" echo "integration_test_providers=$(ConvertTo-Json -InputObject $Providers -Compress)" >> $env:GITHUB_OUTPUT env: - PROVIDERS: "['ALIDNS', 'AXFRDDNS', 'AXFRDDNS_DNSSEC', 'AZURE_DNS','BIND','BUNNY_DNS','CLOUDFLAREAPI','CLOUDNS','CNR','DIGITALOCEAN','FORTIGATE','GANDI_V5','GCLOUD','GIDINET','HEDNS','HETZNER_V2','HUAWEICLOUD','INWX','JOKER','MIKROTIK','MYTHICBEASTS', 'NAMEDOTCOM','NS1','POWERDNS','ROUTE53','SAKURACLOUD','TRANSIP','UNIFI']" + PROVIDERS: "['ALIDNS', 'AXFRDDNS', 'AXFRDDNS_DNSSEC', 'AZURE_DNS','BIND','BUNNY_DNS','CLOUDFLAREAPI','CLOUDNS','CNR','DIGITALOCEAN','FORTIGATE','GANDI_V5','GCLOUD','GIDINET','HEDNS','HETZNER_V2','HUAWEICLOUD','INWX','JOKER','MIKROTIK','MYTHICBEASTS', 'NAMEDOTCOM','NETNOD','NS1','POWERDNS','ROUTE53','SAKURACLOUD','TRANSIP','UNIFI']" ENV_CONTEXT: ${{ toJson(env) }} VARS_CONTEXT: ${{ toJson(vars) }} SECRETS_CONTEXT: ${{ toJson(secrets) }} @@ -108,6 +108,7 @@ jobs: MIKROTIK_DOMAIN: ${{ vars.MIKROTIK_DOMAIN }} MYTHICBEASTS_DOMAIN: ${{ vars.MYTHICBEASTS_DOMAIN }} NAMEDOTCOM_DOMAIN: ${{ vars.NAMEDOTCOM_DOMAIN }} + NETNOD_DOMAIN: ${{ vars.NETNOD_DOMAIN }} NS1_DOMAIN: ${{ vars.NS1_DOMAIN }} POWERDNS_DOMAIN: ${{ vars.POWERDNS_DOMAIN }} ROUTE53_DOMAIN: ${{ vars.ROUTE53_DOMAIN }} @@ -197,6 +198,9 @@ jobs: NAMEDOTCOM_URL: ${{ secrets.NAMEDOTCOM_URL }} NAMEDOTCOM_USER: ${{ secrets.NAMEDOTCOM_USER }} # + NETNOD_APIKEY: ${{ secrets.NETNOD_APIKEY }} + NETNOD_APIURL: ${{ secrets.NETNOD_APIURL }} + # NS1_TOKEN: ${{ secrets.NS1_TOKEN }} # POWERDNS_APIKEY: ${{ secrets.POWERDNS_APIKEY }} diff --git a/.goreleaser.yml b/.goreleaser.yml index 96422d42eb..b75a384bfe 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -37,7 +37,7 @@ changelog: regexp: "(?i)^.*(major|new provider|feature)[(\\w)]*:+.*$" order: 1 - title: 'Provider-specific changes:' - regexp: "(?i)((adguardhome|akamaiedgedns|alidns|autodns|axfrddns|azure_dns|azure_private_dns|bind|bunny_dns|cloudflare|cloudflareapi|cloudns|cnr|cscglobal|desec|digitalocean|dnscale|dnsimple|dnsmadeeasy|dnsoverhttps|domainnameshop|dynadot|easyname|exoscale|fortigate|gandi_v5|gcloud|gcore|gidinet|hedns|hetzner|hetzner_v2|hostingde|huaweicloud|infomaniak|internetbs|inwx|joker|linode|loopia|luadns|mikrotik|mythicbeasts|namecheap|namedotcom|netcup|netlify|ns1|opensrs|oracle|ovh|packetframe|porkbun|powerdns|realtimeregister|route53|rwth|sakuracloud|softlayer|transip|unifi|vercel|vultr).*:)+.*" + regexp: "(?i)((adguardhome|akamaiedgedns|alidns|autodns|axfrddns|azure_dns|azure_private_dns|bind|bunny_dns|cloudflare|cloudflareapi|cloudns|cnr|cscglobal|desec|digitalocean|dnscale|dnsimple|dnsmadeeasy|dnsoverhttps|domainnameshop|dynadot|easyname|exoscale|fortigate|gandi_v5|gcloud|gcore|gidinet|hedns|hetzner|hetzner_v2|hostingde|huaweicloud|infomaniak|internetbs|inwx|joker|linode|loopia|luadns|mikrotik|mythicbeasts|namecheap|namedotcom|netcup|netlify|netnod|ns1|opensrs|oracle|ovh|packetframe|porkbun|powerdns|realtimeregister|route53|rwth|sakuracloud|softlayer|transip|unifi|vercel|vultr).*:)+.*" order: 2 - title: 'Documentation:' regexp: "(?i)^.*(docs)[(\\w)]*:+.*$" diff --git a/OWNERS b/OWNERS index 9abc9edc33..fdff8d1d27 100644 --- a/OWNERS +++ b/OWNERS @@ -44,6 +44,7 @@ providers/namecheap @willpower232 # providers/namedotcom NEEDS VOLUNTEER providers/netcup @kordianbruck providers/netlify @SphericalKat +providers/netnod @Netnod providers/ns1 @costasd # providers/opensrs NEEDS VOLUNTEER providers/oracle @kallsyms diff --git a/README.md b/README.md index 743f4e77a8..c001cc4e4a 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,7 @@ Currently supported DNS providers: - Namecheap - Netcup - Netlify +- Netnod - NS1 - Oracle Cloud - OVH diff --git a/documentation/SUMMARY.md b/documentation/SUMMARY.md index f0e79a6b4b..049498bcd6 100644 --- a/documentation/SUMMARY.md +++ b/documentation/SUMMARY.md @@ -168,6 +168,7 @@ * [Name.com](provider/namedotcom.md) * [Netcup](provider/netcup.md) * [Netlify](provider/netlify.md) +* [Netnod](provider/netnod.md) * [NS1](provider/ns1.md) * [OpenSRS](provider/opensrs.md) * [Oracle Cloud](provider/oracle.md) diff --git a/documentation/provider/index.md b/documentation/provider/index.md index b56d2c39cd..118fc63d2f 100644 --- a/documentation/provider/index.md +++ b/documentation/provider/index.md @@ -68,6 +68,7 @@ Jump to a table: | [`NAMEDOTCOM`](namedotcom.md) | ❌ | ✅ | ✅ | | [`NETCUP`](netcup.md) | ❌ | ✅ | ❌ | | [`NETLIFY`](netlify.md) | ❌ | ✅ | ❌ | +| [`NETNOD`](netnod.md) | ❌ | ✅ | ❌ | | [`NS1`](ns1.md) | ❌ | ✅ | ❌ | | [`OPENSRS`](opensrs.md) | ❌ | ❌ | ✅ | | [`ORACLE`](oracle.md) | ❌ | ✅ | ❌ | @@ -135,6 +136,7 @@ Jump to a table: | [`NAMEDOTCOM`](namedotcom.md) | ❔ | ✅ | ❌ | ✅ | | [`NETCUP`](netcup.md) | ❔ | ❌ | ❌ | ❌ | | [`NETLIFY`](netlify.md) | ✅ | ❌ | ❌ | ✅ | +| [`NETNOD`](netnod.md) | ❔ | ✅ | ✅ | ✅ | | [`NS1`](ns1.md) | ✅ | ✅ | ✅ | ✅ | | [`OPENSRS`](opensrs.md) | ❔ | ❔ | ❌ | ❔ | | [`ORACLE`](oracle.md) | ❔ | ✅ | ✅ | ✅ | @@ -198,6 +200,7 @@ Jump to a table: | [`NAMEDOTCOM`](namedotcom.md) | ✅ | ❔ | ❌ | ❌ | ❔ | | [`NETCUP`](netcup.md) | ❔ | ❔ | ❌ | ❌ | ❔ | | [`NETLIFY`](netlify.md) | ✅ | ❔ | ❌ | ❌ | ❔ | +| [`NETNOD`](netnod.md) | ✅ | ❌ | ❌ | ✅ | ❌ | | [`NS1`](ns1.md) | ✅ | ✅ | ❌ | ✅ | ❔ | | [`ORACLE`](oracle.md) | ✅ | ❔ | ❔ | ✅ | ❔ | | [`OVH`](ovh.md) | ❌ | ❔ | ❔ | ❌ | ❔ | @@ -259,6 +262,7 @@ Jump to a table: | [`NAMEDOTCOM`](namedotcom.md) | ❔ | ❔ | ✅ | ❔ | | [`NETCUP`](netcup.md) | ❔ | ❔ | ✅ | ❔ | | [`NETLIFY`](netlify.md) | ❔ | ❌ | ✅ | ❔ | +| [`NETNOD`](netnod.md) | ❌ | ❌ | ✅ | ❌ | | [`NS1`](ns1.md) | ✅ | ✅ | ✅ | ✅ | | [`ORACLE`](oracle.md) | ❔ | ✅ | ✅ | ❔ | | [`OVH`](ovh.md) | ❔ | ❔ | ✅ | ❔ | @@ -319,6 +323,7 @@ Jump to a table: | [`NAMECHEAP`](namecheap.md) | ✅ | ❔ | ❔ | ❔ | ❌ | | [`NETCUP`](netcup.md) | ✅ | ❔ | ❔ | ❔ | ✅ | | [`NETLIFY`](netlify.md) | ✅ | ❔ | ❔ | ❌ | ❌ | +| [`NETNOD`](netnod.md) | ✅ | ✅ | ❔ | ❌ | ✅ | | [`NS1`](ns1.md) | ✅ | ✅ | ❔ | ❔ | ✅ | | [`ORACLE`](oracle.md) | ✅ | ❔ | ❔ | ✅ | ✅ | | [`OVH`](ovh.md) | ✅ | ❔ | ❔ | ✅ | ✅ | @@ -366,6 +371,7 @@ Jump to a table: | [`LOOPIA`](loopia.md) | ❌ | ❌ | ❌ | | [`MIKROTIK`](mikrotik.md) | ❌ | ❔ | ❌ | | [`NETLIFY`](netlify.md) | ❌ | ❔ | ❌ | +| [`NETNOD`](netnod.md) | ❌ | ❌ | ❌ | | [`NS1`](ns1.md) | ✅ | ❔ | ✅ | | [`ORACLE`](oracle.md) | ❔ | ❔ | ❌ | | [`PORKBUN`](porkbun.md) | ❌ | ❔ | ❌ | @@ -452,6 +458,7 @@ Providers in this category and their maintainers are: |[`NAMECHEAP`](namecheap.md)|@willpower232| |[`NETCUP`](netcup.md)|@kordianbruck| |[`NETLIFY`](netlify.md)|@SphericalKat| +|[`NETNOD`](netnod.md)|@Netnod| |[`NS1`](ns1.md)|@costasd| |[`OPENSRS`](opensrs.md)|@philhug| |[`ORACLE`](oracle.md)|@kallsyms| diff --git a/documentation/provider/netnod.md b/documentation/provider/netnod.md new file mode 100644 index 0000000000..565dc3e263 --- /dev/null +++ b/documentation/provider/netnod.md @@ -0,0 +1,59 @@ +## Configuration + +To use this provider, add an entry to `creds.json` with `TYPE` set to `NETNOD` along with your API URL and API Key. The API URL can be omitted to use the default value `https://primarydnsapi.netnod.se`. + +Example: + +{% code title="creds.json" %} + +```json +{ + "netnod": { + "TYPE": "NETNOD", + "apiKey": "your-key", + "apiUrl": "https://primarydnsapi.netnod.se" + } +} +``` + +{% endcode %} + +## Metadata + +The following provider metadata is available: + +{% code title="dnsconfig.js" %} + +```javascript +var DSP_NETNOD = NewDnsProvider('netnod', { + default_ns: ['a.example.com.', 'b.example.com.'], + also_notify: ['192.36.148.17', '2001:7fe::53'], + allow_transfer_keys: ['netnod-key1.'], +}); +``` + +{% endcode %} + +- `default_ns` sets the nameservers used when creating zones. +- `also_notify` sets a list of IP addresses that will receive DNS NOTIFY messages when a zone is created. This is the provider-level default and applies to all zones unless overridden per zone (see below). +- `allow_transfer_keys` sets the TSIG key IDs permitted to perform zone transfers from the distribution servers when a zone is created. + This should include all keys used for DNS secondary replication, including those used by the Netnod secondary DNS service. This is the provider-level default and applies to all zones unless overridden per zone. + +## Usage + +An example configuration: + +{% code title="dnsconfig.js" %} + +```javascript +var REG_NONE = NewRegistrar('none'); +var DSP_NETNOD = NewDnsProvider('netnod'); + +D('example.com', REG_NONE, DnsProvider(DSP_NETNOD), A('test', '1.2.3.4')); +``` + +{% endcode %} + +## Activation + +See the [Netnod DNS](https://www.netnod.se/dns/dns-enterprise-services). diff --git a/go.mod b/go.mod index 780b1b50f0..0a7e378abf 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/DNSControl/dnscontrol/v4 -go 1.25.0 +go 1.26 retract v4.8.0 @@ -80,6 +80,7 @@ require ( github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.195 github.com/kylelemons/godebug v1.1.0 github.com/luadns/luadns-go v0.3.0 + github.com/netnod/netnod-primary-dns-client v1.0.0 github.com/mattn/go-isatty v0.0.22 github.com/nicholas-fedor/shoutrrr v0.15.0 github.com/nozzle/throttler v0.0.0-20180817012639-2ea982251481 diff --git a/go.sum b/go.sum index de376adfb6..8ac414b26a 100644 --- a/go.sum +++ b/go.sum @@ -344,6 +344,8 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/namedotcom/go v0.0.0-20180403034216-08470befbe04 h1:o6uBwrhM5C8Ll3MAAxrQxRHEu7FkapwTuI2WmL1rw4g= github.com/namedotcom/go v0.0.0-20180403034216-08470befbe04/go.mod h1:5sN+Lt1CaY4wsPvgQH/jsuJi4XO2ssZbdsIizr4CVC8= +github.com/netnod/netnod-primary-dns-client v1.0.0 h1:ISyr7eOsQtUAZ4c1ov0sFV22I4yB09Du1kf9zy/q85U= +github.com/netnod/netnod-primary-dns-client v1.0.0/go.mod h1:MLvJf4UhbAB8WQdypLqYv9rwtOGD+3cZlAVXMwgad3c= github.com/nicholas-fedor/shoutrrr v0.15.0 h1:4gKIev9ucsY50dy+GkkPQKyfIJdKOEwr0dnsVpGdDWk= github.com/nicholas-fedor/shoutrrr v0.15.0/go.mod h1:Z6b9KNn8q9nXl27/p39zo7iJHBmqkigzVxWnbfwcU8w= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= diff --git a/integrationTest/profiles.json b/integrationTest/profiles.json index b20a260d29..1e05a486b8 100644 --- a/integrationTest/profiles.json +++ b/integrationTest/profiles.json @@ -285,6 +285,12 @@ "slug": "$NETLIFY_ACCOUNT_SLUG", "token": "$NETLIFY_TOKEN" }, + "NETNOD": { + "TYPE": "NETNOD", + "apiKey": "$NETNOD_APIKEY", + "apiUrl": "$NETNOD_APIURL", + "domain": "$NETNOD_DOMAIN" + }, "NS1": { "TYPE": "NS1", "api_token": "$NS1_TOKEN", diff --git a/pkg/providers/_all/all.go b/pkg/providers/_all/all.go index b73eea8d49..5d0c676d99 100644 --- a/pkg/providers/_all/all.go +++ b/pkg/providers/_all/all.go @@ -49,6 +49,7 @@ import ( _ "github.com/DNSControl/dnscontrol/v4/providers/namedotcom" _ "github.com/DNSControl/dnscontrol/v4/providers/netcup" _ "github.com/DNSControl/dnscontrol/v4/providers/netlify" + _ "github.com/DNSControl/dnscontrol/v4/providers/netnod" _ "github.com/DNSControl/dnscontrol/v4/providers/ns1" _ "github.com/DNSControl/dnscontrol/v4/providers/opensrs" _ "github.com/DNSControl/dnscontrol/v4/providers/oracle" diff --git a/providers/netnod/auditrecords.go b/providers/netnod/auditrecords.go new file mode 100644 index 0000000000..3e51ce4d1a --- /dev/null +++ b/providers/netnod/auditrecords.go @@ -0,0 +1,18 @@ +package netnod + +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.TxtHasDoubleQuotes) // Last verified 2023-11-11 + a.Add("TXT", rejectif.TxtHasBackslash) // Last verified 2023-11-11 + + return a.Audit(records) +} diff --git a/providers/netnod/convert.go b/providers/netnod/convert.go new file mode 100644 index 0000000000..af661ca67b --- /dev/null +++ b/providers/netnod/convert.go @@ -0,0 +1,40 @@ +package netnod + +import ( + "strings" + + "github.com/StackExchange/dnscontrol/v4/models" + netnodPrimaryDNS "github.com/netnod/netnod-primary-dns-client" +) + +// toRecordConfig converts a Netnod DNS Record to a RecordConfig. #rtype_variations +func toRecordConfig(domain string, r netnodPrimaryDNS.Record, ttl int, name string, rtype string) (*models.RecordConfig, error) { + // trimming trailing dot and domain from name + name = strings.TrimSuffix(name, domain+".") + name = strings.TrimSuffix(name, ".") + + rc := &models.RecordConfig{ + TTL: uint32(ttl), + Original: r, + Type: rtype, + } + rc.SetLabel(name, domain) + + switch rtype { + case "TXT": + // API accepts long TXTs without requiring to split them. + // The API then returns them as they initially came in, e.g. "averylooooooo[...]oooooongstring" or "string" "string" + // So we need to strip away " and split into multiple string + // We can't use SetTargetRFC1035Quoted, it would split the long strings into multiple parts + return rc, rc.SetTargetTXTs(parseTxt(r.Content)) + default: + return rc, rc.PopulateFromString(rtype, r.Content, domain) + } +} + +func parseTxt(content string) (result []string) { + for r := range strings.SplitSeq(content, "\" ") { + result = append(result, strings.Trim(r, "\"")) + } + return +} diff --git a/providers/netnod/convert_test.go b/providers/netnod/convert_test.go new file mode 100644 index 0000000000..8e8cd67a35 --- /dev/null +++ b/providers/netnod/convert_test.go @@ -0,0 +1,67 @@ +package netnod + +import ( + "fmt" + "strings" + "testing" + + netnodPrimaryDNS "github.com/netnod/netnod-primary-dns-client" + "github.com/stretchr/testify/assert" +) + +func TestToRecordConfig(t *testing.T) { + record := netnodPrimaryDNS.Record{ + Content: "simple", + } + recordConfig, err := toRecordConfig("example.com", record, 120, "test", "TXT") + + assert.NoError(t, err) + assert.Equal(t, "test.example.com", recordConfig.NameFQDN) + assert.Equal(t, "\"simple\"", recordConfig.String()) + assert.Equal(t, uint32(120), recordConfig.TTL) + assert.Equal(t, "TXT", recordConfig.Type) + + largeContent := fmt.Sprintf("\"%s\" \"%s\"", strings.Repeat("A", 300), strings.Repeat("B", 300)) + largeRecord := netnodPrimaryDNS.Record{ + Content: largeContent, + } + recordConfig, err = toRecordConfig("example.com", largeRecord, 5, "large", "TXT") + + assert.NoError(t, err) + assert.Equal(t, "large.example.com", recordConfig.NameFQDN) + assert.Equal(t, `"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB" "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"`, + recordConfig.String()) + assert.Equal(t, uint32(5), recordConfig.TTL) + assert.Equal(t, "TXT", recordConfig.Type) + + luaRecord := netnodPrimaryDNS.Record{ + Content: "TXT \"return 'Hello, world!'\"", + } + recordConfig, err = toRecordConfig("example.com", luaRecord, 3600, "script", "LUA") + + assert.NoError(t, err) + assert.Equal(t, "script.example.com", recordConfig.NameFQDN) + assert.Equal(t, "LUA", recordConfig.Type) + assert.Equal(t, "TXT", recordConfig.LuaRType) + assert.Equal(t, "return 'Hello, world!'", recordConfig.GetTargetTXTJoined()) + assert.Equal(t, "TXT \"return 'Hello, world!'\"", recordConfig.GetTargetCombined()) + assert.Equal(t, uint32(3600), recordConfig.TTL) +} + +func TestParseText(t *testing.T) { + // short TXT record + short := parseTxt("\"simple\"") + assert.Equal(t, []string{"simple"}, short) + + // TXT record with multiple parts + multiple := parseTxt("\"simple\" \"simple2\"") + assert.Equal(t, []string{"simple", "simple2"}, multiple) + + // long TXT record + long := parseTxt(fmt.Sprintf("\"%s\"", strings.Repeat("A", 300))) + assert.Equal(t, []string{strings.Repeat("A", 300)}, long) + + // multiple long TXT record + multipleLong := parseTxt(fmt.Sprintf("\"%s\" \"%s\"", strings.Repeat("A", 300), strings.Repeat("B", 300))) + assert.Equal(t, []string{strings.Repeat("A", 300), strings.Repeat("B", 300)}, multipleLong) +} diff --git a/providers/netnod/diff.go b/providers/netnod/diff.go new file mode 100644 index 0000000000..73272279a4 --- /dev/null +++ b/providers/netnod/diff.go @@ -0,0 +1,108 @@ +package netnod + +import ( + "fmt" + "regexp" + "strings" + + "github.com/StackExchange/dnscontrol/v4/models" + "github.com/StackExchange/dnscontrol/v4/pkg/diff2" + "github.com/fatih/color" + netnodPrimaryDNS "github.com/netnod/netnod-primary-dns-client" +) + +func (dsp *netnodProvider) getDiff2DomainCorrections(dc *models.DomainConfig, existing models.Records) ([]*models.Correction, int, error) { + changes, actualChangeCount, err := diff2.ByRecordSet(existing, dc, nil) + if err != nil { + return nil, 0, err + } + + var corrections []*models.Correction + var changeMsgs []string + var rrChangeSets []netnodPrimaryDNS.RRset + var deleteMsgs []string + var rrDeleteSets []netnodPrimaryDNS.RRset + + // for pretty alignment, add an empty string + changeMsgs = append(changeMsgs, color.YellowString("± BATCHED CHANGE/CREATEs for %s", dc.Name)) + deleteMsgs = append(deleteMsgs, color.RedString("- BATCHED DELETEs for %s", dc.Name)) + + for _, change := range changes { + labelName := canonical(change.Key.NameFQDN) + labelType := change.Key.Type + + switch change.Type { + case diff2.REPORT: + corrections = append(corrections, &models.Correction{Msg: change.MsgsJoined}) + case diff2.CREATE, diff2.CHANGE: + labelTTL := int64(change.New[0].TTL) + records := buildRecordList(change) + + rrChangeSets = append(rrChangeSets, netnodPrimaryDNS.RRset{ + Name: labelName, + Type: labelType, + TTL: &labelTTL, + Records: records, + ChangeType: "REPLACE", + }) + changeMsgs = append(changeMsgs, change.MsgsJoined) + case diff2.DELETE: + rrDeleteSets = append(rrDeleteSets, netnodPrimaryDNS.RRset{ + Name: labelName, + Type: labelType, + ChangeType: "DELETE", + }) + deleteMsgs = append(deleteMsgs, change.MsgsJoined) + default: + panic(fmt.Sprintf("unhandled change.Type %s", change.Type)) + } + } + + domainVariant := dc.Name + "." + + // only append a Correction if there are any, otherwise causes an error when sending an empty rrset + if len(rrDeleteSets) > 0 { + corrections = append(corrections, &models.Correction{ + Msg: strings.Join(deleteMsgs, "\n"), + F: func() error { + return dsp.client.PatchZoneRRsets(domainVariant, rrDeleteSets) + }, + }) + } + if len(rrChangeSets) > 0 { + corrections = append(corrections, &models.Correction{ + Msg: strings.Join(changeMsgs, "\n"), + F: func() error { + return dsp.client.PatchZoneRRsets(domainVariant, rrChangeSets) + }, + }) + } + return corrections, actualChangeCount, nil +} + +// httpsParamQuoteRe matches HTTPS SVCB parameter values that are quoted but +// don't contain characters requiring quoting (+ or /). These are stripped of +// their quotes before sending to the API (e.g. alpn="h2,h3" => alpn=h2,h3). +// Values containing + or / (e.g. ECH base64 data) retain their quotes. +var httpsParamQuoteRe = regexp.MustCompile(`="([^"+/ ]*)"`) + +// buildRecordList returns a list of records for the resource record set from a change +func buildRecordList(change diff2.Change) (records []netnodPrimaryDNS.Record) { + for _, recordContent := range change.New { + record := netnodPrimaryDNS.Record{ + Content: recordContent.GetTargetCombined(), + } + if recordContent.Type == "HTTPS" { + // The API rejects double-quoted simple param values (e.g. alpn="h2,h3") + // but requires quotes around values containing + or / (e.g. ECH base64). + // Strip quotes only from values that don't contain those characters. + record.Content = httpsParamQuoteRe.ReplaceAllString(record.Content, `=$1`) + } + records = append(records, record) + } + return +} + +func canonical(fqdn string) string { + return fqdn + "." +} diff --git a/providers/netnod/dns.go b/providers/netnod/dns.go new file mode 100644 index 0000000000..e5053d159a --- /dev/null +++ b/providers/netnod/dns.go @@ -0,0 +1,94 @@ +package netnod + +import ( + "strings" + + "github.com/StackExchange/dnscontrol/v4/models" + + netnodPrimaryDNS "github.com/netnod/netnod-primary-dns-client" +) + +// GetNameservers returns the nameservers for a domain. +func (dsp *netnodProvider) GetNameservers(string) ([]*models.Nameserver, error) { + var r []string + for _, j := range dsp.nameservers { + r = append(r, j.Name) + } + return models.ToNameservers(r) +} + +// GetZoneRecords gets the records of a zone and returns them in RecordConfig format. +func (dsp *netnodProvider) GetZoneRecords(dc *models.DomainConfig) (models.Records, error) { + domain := dc.Name + curRecords := models.Records{} + domainVariant := domain + "." + zone, err := dsp.client.GetZone(domainVariant) + if err != nil { + return nil, err + } + if zone == nil { + return curRecords, nil + } + + // loop over grouped records by type, called RRSet + for _, rrset := range zone.RRsets { + // Skip SOA records - they are managed by the provider + if rrset.Type == "SOA" { + continue + } + ttl := 0 + if rrset.TTL != nil { + ttl = int(*rrset.TTL) + } + // loop over single records of this group and create records + for _, record := range rrset.Records { + r, err := toRecordConfig(domain, record, ttl, rrset.Name, rrset.Type) + if err != nil { + return nil, err + } + curRecords = append(curRecords, r) + } + } + + return curRecords, nil +} + +// GetZoneRecordsCorrections returns a list of corrections that will turn existing records into dc.Records. +func (dsp *netnodProvider) GetZoneRecordsCorrections(dc *models.DomainConfig, existing models.Records) ([]*models.Correction, int, error) { + corrections, actualChangeCount, err := dsp.getDiff2DomainCorrections(dc, existing) + if err != nil { + return nil, 0, err + } + + return corrections, actualChangeCount, nil +} + +// EnsureZoneExists creates a zone if it does not exist +func (dsp *netnodProvider) EnsureZoneExists(domain string, metadata map[string]string) error { + domainVariant := domain + "." + zone, err := dsp.client.GetZone(domainVariant) + if err != nil { + return err + } + if zone != nil { + return nil + } + + // Per-zone overrides take precedence over provider-level defaults. + alsoNotify := dsp.AlsoNotify + if v, ok := metadata["also_notify"]; ok { + alsoNotify = strings.Split(v, ",") + } + + allowTransferKeys := dsp.AllowTransferKeys + if v, ok := metadata["allow_transfer_keys"]; ok { + allowTransferKeys = strings.Split(v, ",") + } + + _, err = dsp.client.CreateZone(&netnodPrimaryDNS.Zone{ + Name: domainVariant, + AllowTransferKeys: allowTransferKeys, + AlsoNotify: alsoNotify, + }) + return err +} diff --git a/providers/netnod/listzones.go b/providers/netnod/listzones.go new file mode 100644 index 0000000000..ea514a48a3 --- /dev/null +++ b/providers/netnod/listzones.go @@ -0,0 +1,17 @@ +package netnod + +import "strings" + +// ListZones returns all the zones in an account +func (dsp *netnodProvider) ListZones() ([]string, error) { + zones, err := dsp.client.ListZones() + if err != nil { + return nil, err + } + + var result []string + for _, zone := range zones { + result = append(result, strings.TrimSuffix(zone.Name, ".")) + } + return result, nil +} diff --git a/providers/netnod/netnodProvider.go b/providers/netnod/netnodProvider.go new file mode 100644 index 0000000000..162a297aa5 --- /dev/null +++ b/providers/netnod/netnodProvider.go @@ -0,0 +1,92 @@ +package netnod + +import ( + "encoding/json" + "errors" + + "github.com/StackExchange/dnscontrol/v4/models" + "github.com/StackExchange/dnscontrol/v4/pkg/providers" + netnodPrimaryDNS "github.com/netnod/netnod-primary-dns-client" +) + +var features = providers.DocumentationNotes{ + // The default for unlisted capabilities is 'Cannot'. + // See providers/capabilities.go for the entire list of capabilities. + providers.CanAutoDNSSEC: providers.Cannot(), + providers.CanGetZones: providers.Can(), + providers.CanConcur: providers.Unimplemented(), + providers.CanUseAlias: providers.Can(), + providers.CanUseCAA: providers.Can(), + providers.CanUseDHCID: providers.Cannot(), + providers.CanUseDNAME: providers.Cannot(), + providers.CanUseDNSKEY: providers.Cannot(), + providers.CanUseDS: providers.Cannot(), + providers.CanUseHTTPS: providers.Can(), + providers.CanUseLOC: providers.Cannot(), + providers.CanUseNAPTR: providers.Cannot(), + providers.CanUseOPENPGPKEY: providers.Cannot(), + providers.CanUsePTR: providers.Can(), + providers.CanUseSOA: providers.Cannot(), + providers.CanUseSRV: providers.Can(), + providers.CanUseSSHFP: providers.Cannot(), + providers.CanUseSVCB: providers.Cannot(), + providers.CanUseTLSA: providers.Can(), + providers.DocCreateDomains: providers.Can(), + providers.DocDualHost: providers.Can(), + providers.DocOfficiallySupported: providers.Cannot(), +} + +func init() { + const providerName = "NETNOD" + const providerMaintainer = "@Netnod" + fns := providers.DspFuncs{ + Initializer: newDSP, + RecordAuditor: AuditRecords, + } + providers.RegisterDomainServiceProviderType(providerName, fns, features) + providers.RegisterMaintainer(providerName, providerMaintainer) +} + +// netnodProvider represents the netnodProvider DNSServiceProvider. +type netnodProvider struct { + client *netnodPrimaryDNS.Client + APIKey string + APIUrl string + DefaultNS []string `json:"default_ns"` + AlsoNotify []string `json:"also_notify"` + AllowTransferKeys []string `json:"allow_transfer_keys"` + + nameservers []*models.Nameserver +} + +// newDSP initializes a Netnod DNSServiceProvider. +func newDSP(m map[string]string, metadata json.RawMessage) (providers.DNSServiceProvider, error) { + dsp := &netnodProvider{} + + dsp.APIKey = m["apiKey"] + if dsp.APIKey == "" { + return nil, errors.New("Netnod API Key is required") + } + + dsp.APIUrl = m["apiUrl"] + + // load js config + if len(metadata) != 0 { + err := json.Unmarshal(metadata, dsp) + if err != nil { + return nil, err + } + } + var nss []string + for _, ns := range dsp.DefaultNS { + nss = append(nss, ns[0:len(ns)-1]) + } + var err error + dsp.nameservers, err = models.ToNameservers(nss) + if err != nil { + return dsp, err + } + + dsp.client = netnodPrimaryDNS.NewClient(dsp.APIUrl, dsp.APIKey) + return dsp, nil +}