From 9aad2926fb48315c863115f5c36ad24a9a3097a4 Mon Sep 17 00:00:00 2001 From: Patrik Kernstock Date: Sat, 29 Nov 2025 18:17:13 +0100 Subject: [PATCH 1/3] INWX: Fix INWX provider after their unexpected data-type breaking-change (#3855) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #3854 Unfortunately I couldn't run the integrationTests properly as INWX doesn't seem to have properly updated their sandbox environment (it still presents `int` instead of `string` like production). Hence, the tests do fail. I don't want to run this against my own production account, to be frank. See: ```shell $ curl -X POST https://api.ote.domrobot.com/xmlrpc/ -H "Content-Type: application/xml" -d ' nameserver.info user [USER] lang en pass [PASS] domain [DOMAIN] ' | xmllint --format - | grep -iE "id|roId" -C3 % Total % Received % Xferd Average Speed Time Time Time Current Dload Upload Total Spent Left Speed 100 3968 0 2971 100 997 13375 4488 --:--:-- --:--:-- --:--:-- 17954 roId 9677 -- id 118057 -- id 118060 -- id 79610 -- id 77243 -- svTRID 20251127--ote ``` Hence, only done manualy tests via `dnscontrol push --domains `: (tested create, delete and modify) ```text CONCURRENTLY checking for 0 zone(s) SERIALLY checking for 1 zone(s) Serially checking for zone: "example.tld" CONCURRENTLY gathering records of 0 zone(s) SERIALLY gathering records of 1 zone(s) Serially Gathering: "example.tld" ******************** Domain: example.tld 3 corrections (PK-INWX) #1: - DELETE _test1.example.tld TXT "123" ttl=43200 SUCCESS! #2: ± MODIFY _test2.example.tld TXT ("1234" ttl=43200) -> ("12345" ttl=43200) SUCCESS! #3: + CREATE _test4.example.tld TXT "123" ttl=43200 SUCCESS! Done. 3 corrections. ``` --- go.mod | 2 +- go.sum | 4 ++-- pkg/zonerecs/zonerecords.go | 2 +- providers/inwx/auditrecords.go | 8 ++------ providers/inwx/dnssec.go | 11 ++--------- providers/inwx/inwxProvider.go | 35 +++++++++++++++++++--------------- 6 files changed, 28 insertions(+), 34 deletions(-) diff --git a/go.mod b/go.mod index 8c3fa5482..d8e7a6098 100644 --- a/go.mod +++ b/go.mod @@ -38,7 +38,7 @@ require ( github.com/miekg/dns v1.1.68 github.com/mittwald/go-powerdns v0.6.7 github.com/namedotcom/go v0.0.0-20180403034216-08470befbe04 - github.com/nrdcg/goinwx v0.11.0 + github.com/nrdcg/goinwx v0.12.0 github.com/ovh/go-ovh v1.9.0 github.com/philhug/opensrs-go v0.0.0-20171126225031-9dfa7433020d github.com/pkg/errors v0.9.1 diff --git a/go.sum b/go.sum index 4b5b7cf3d..ce2279391 100644 --- a/go.sum +++ b/go.sum @@ -307,8 +307,8 @@ github.com/nicholas-fedor/shoutrrr v0.12.0 h1:8mwJdfU+uBEybSymwQJMGl/grG7lvVUKbV github.com/nicholas-fedor/shoutrrr v0.12.0/go.mod h1:WYiRalR4C43Qmd2zhPWGIFIxu633NB1hDM6Ap/DQcsA= github.com/nozzle/throttler v0.0.0-20180817012639-2ea982251481 h1:Up6+btDp321ZG5/zdSLo48H9Iaq0UQGthrhWC6pCxzE= github.com/nozzle/throttler v0.0.0-20180817012639-2ea982251481/go.mod h1:yKZQO8QE2bHlgozqWDiRVqTFlLQSj30K/6SAK8EeYFw= -github.com/nrdcg/goinwx v0.11.0 h1:GER0SE3POub7rxARt3Y3jRy1OON1hwF1LRxHz5xsFBw= -github.com/nrdcg/goinwx v0.11.0/go.mod h1:0BXSC0FxVtU4aTjX0Zw3x0DK32tjugLzeNIAGtwXvPQ= +github.com/nrdcg/goinwx v0.12.0 h1:ujdUqDBnaRSFwzVnImvPHYw3w3m9XgmGImNUw1GyMb4= +github.com/nrdcg/goinwx v0.12.0/go.mod h1:IrVKd3ZDbFiMjdPgML4CSxZAY9wOoqLvH44zv3NodJ0= github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns= github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A= diff --git a/pkg/zonerecs/zonerecords.go b/pkg/zonerecs/zonerecords.go index 7904ee99c..84876bad0 100644 --- a/pkg/zonerecs/zonerecords.go +++ b/pkg/zonerecs/zonerecords.go @@ -19,7 +19,7 @@ func CorrectZoneRecords(driver models.DNSProvider, dc *models.DomainConfig) ([]* models.CanonicalizeTargets(existingRecords, dc.Name) models.CanonicalizeTargets(dc.Records, dc.Name) - // Copy dc so that any corrections code that wants to + // Copy dc so that any correction code that wants to // modify the records may. For example, if the provider only // supports certain TTL values, it will adjust the ones in // dc.Records. diff --git a/providers/inwx/auditrecords.go b/providers/inwx/auditrecords.go index 7a2853d19..95ca7b771 100644 --- a/providers/inwx/auditrecords.go +++ b/providers/inwx/auditrecords.go @@ -10,12 +10,8 @@ import ( // supported, an empty list is returned. func AuditRecords(records []*models.RecordConfig) []error { a := rejectif.Auditor{} - - a.Add("TXT", rejectif.TxtHasBackticks) // Last verified 2021-03-01 - + a.Add("TXT", rejectif.TxtHasBackticks) // Last verified 2021-03-01 a.Add("TXT", rejectif.TxtHasTrailingSpace) // Last verified 2021-03-01 - - a.Add("TXT", rejectif.TxtIsEmpty) // Last verified 2021-03-01 - + a.Add("TXT", rejectif.TxtIsEmpty) // Last verified 2021-03-01 return a.Audit(records) } diff --git a/providers/inwx/dnssec.go b/providers/inwx/dnssec.go index 7053644f0..c9ccfabda 100644 --- a/providers/inwx/dnssec.go +++ b/providers/inwx/dnssec.go @@ -10,25 +10,22 @@ const ( // testing shows 'AUTO' is what to expect if the domain has automatic // DNSSEC enabled. - // AutoDNSSEC is the status for DNSSEC enabled with automatic management + // AutoDNSSECStatus is the status for DNSSEC enabled with automatic management AutoDNSSECStatus = "AUTO" - // ManualDNSSEC is the status for DNSSEC enabled with manual management + // ManualDNSSECStatus is the status for DNSSEC enabled with manual management ManualDNSSECStatus = "MANUAL" ) // DNSSecStatus returns domain dnssec status func (api *inwxAPI) DNSSecStatus(domain string) (string, error) { - resp, err := api.client.Dnssec.Info([]string{domain}) if err != nil { return "", err } - // domain has no DNSSEC configuration if len(resp.Data) == 0 { return "", nil } - return resp.Data[0].DNSSecStatus, nil } @@ -40,16 +37,12 @@ func (api *inwxAPI) enableAutoDNSSEC(domain string) error { if err != nil { return err } - err = api.client.Dnssec.Enable(domain) - return err } // disableAutoDNSSEC disables automatic management of DNSSEC func (api *inwxAPI) disableAutoDNSSEC(domain string) error { - err := api.client.Dnssec.Disable(domain) - return err } diff --git a/providers/inwx/inwxProvider.go b/providers/inwx/inwxProvider.go index 3a4eb4097..ef5461eee 100644 --- a/providers/inwx/inwxProvider.go +++ b/providers/inwx/inwxProvider.go @@ -22,6 +22,9 @@ import ( /* INWX Registrar and DNS provider +Based on this great INWX API implementation: +https://github.com/nrdcg/goinwx + Info required in `creds.json`: - username - password @@ -34,7 +37,6 @@ Either of the following settings is required when two factor authentication is e Additional settings available in `creds.json`: - sandbox (set to 1 to use the sandbox API from INWX) - */ // InwxProductionDefaultNs contains the default INWX nameservers. @@ -182,10 +184,10 @@ func makeNameserverRecordRequest(domain string, rec *models.RecordConfig) *goinw switch rType := rec.Type; rType { /* - INWX is a little bit special for CNAME,NS,MX and SRV records: + INWX is a little bit special for CNAME, NS, MX and SRV records: The API will not accept any target with a final dot but will instead always add this final dot internally. - Records with empty targets (i.e. records with target ".") + Records with empty targets (i.e., records with target ".") are allowed. */ case "CNAME", "NS", "ALIAS": @@ -219,14 +221,14 @@ func (api *inwxAPI) createRecord(domain string, rec *models.RecordConfig) error } // updateRecord is used by GetDomainCorrections to update an existing record. -func (api *inwxAPI) updateRecord(RecordID int, rec *models.RecordConfig) error { +func (api *inwxAPI) updateRecord(RecordID string, rec *models.RecordConfig) error { req := makeNameserverRecordRequest("", rec) err := api.client.Nameservers.UpdateRecord(RecordID, req) return err } // deleteRecord is used by GetDomainCorrections to delete a record. -func (api *inwxAPI) deleteRecord(RecordID int) error { +func (api *inwxAPI) deleteRecord(RecordID string) error { return api.client.Nameservers.DeleteRecord(RecordID) } @@ -244,7 +246,8 @@ func (api *inwxAPI) AutoDnssecToggle(dc *models.DomainConfig, corrections []*mod } if dnssecStatus == ManualDNSSECStatus && dc.AutoDNSSEC != "" { - return corrections, fmt.Errorf("INWX: Domain %s has manual DNSSEC enabled. Disable it before using AUTODNSSEC_ON/AUTODNSSEC_OFF", dc.Name) + return corrections, fmt.Errorf("INWX: Domain %s has manual DNSSEC enabled. Disable it before using "+ + "AUTODNSSEC_ON/AUTODNSSEC_OFF", dc.Name) } if dnssecStatus != AutoDNSSECStatus && dc.AutoDNSSEC == "on" { @@ -289,23 +292,25 @@ func (api *inwxAPI) GetZoneRecordsCorrections(dc *models.DomainConfig, foundReco return nil, 0, err } for _, change := range changes { - changeMsgs := change.MsgsJoined + changeMessage := change.MsgsJoined switch change.Type { case diff2.REPORT: - corrections = append(corrections, &models.Correction{Msg: changeMsgs}) + corrections = append(corrections, &models.Correction{Msg: changeMessage}) case diff2.CHANGE: oldRec := change.Old[0] newRec := change.New[0] if isNullMX(newRec) || isNullMX(oldRec) { - // changing to or from a Null MX has to be delete then create + // changing to or from a Null MX has to be deleted then create deletes = append(deletes, &models.Correction{ - Msg: color.RedString("- DELETE %s %s %s ttl=%d", oldRec.GetLabelFQDN(), oldRec.Type, oldRec.ToComparableNoTTL(), oldRec.TTL), + Msg: color.RedString("- DELETE %s %s %s ttl=%d", oldRec.GetLabelFQDN(), oldRec.Type, + oldRec.ToComparableNoTTL(), oldRec.TTL), F: func() error { return api.deleteRecord(oldRec.Original.(goinwx.NameserverRecord).ID) }, }) deferred = append(deferred, &models.Correction{ - Msg: color.GreenString("+ CREATE %s %s %s ttl=%d", newRec.GetLabelFQDN(), newRec.Type, newRec.ToComparableNoTTL(), newRec.TTL), + Msg: color.GreenString("+ CREATE %s %s %s ttl=%d", newRec.GetLabelFQDN(), newRec.Type, + newRec.ToComparableNoTTL(), newRec.TTL), F: func() error { return api.createRecord(dc.Name, newRec) }, @@ -313,7 +318,7 @@ func (api *inwxAPI) GetZoneRecordsCorrections(dc *models.DomainConfig, foundReco } else { recID := oldRec.Original.(goinwx.NameserverRecord).ID corrections = append(corrections, &models.Correction{ - Msg: changeMsgs, + Msg: changeMessage, F: func() error { return api.updateRecord(recID, newRec) }, @@ -322,7 +327,7 @@ func (api *inwxAPI) GetZoneRecordsCorrections(dc *models.DomainConfig, foundReco } case diff2.CREATE: creates = append(creates, &models.Correction{ - Msg: changeMsgs, + Msg: changeMessage, F: func() error { return api.createRecord(dc.Name, change.New[0]) }, @@ -330,7 +335,7 @@ func (api *inwxAPI) GetZoneRecordsCorrections(dc *models.DomainConfig, foundReco case diff2.DELETE: recID := change.Old[0].Original.(goinwx.NameserverRecord).ID deletes = append(deletes, &models.Correction{ - Msg: changeMsgs, + Msg: changeMessage, F: func() error { return api.deleteRecord(recID) }, }) default: @@ -343,7 +348,7 @@ func (api *inwxAPI) GetZoneRecordsCorrections(dc *models.DomainConfig, foundReco return corrections, actualChangeCount, nil } -// getDefaultNameservers returns string map with default nameservers based on e.g. sandbox mode. +// getDefaultNameservers returns a string map with default nameservers based on e.g. sandbox mode. func (api *inwxAPI) getDefaultNameservers() []string { if api.sandbox { return InwxSandboxDefaultNs From 1b2f5d4d340c1774b111db71d81d2a7ff5271729 Mon Sep 17 00:00:00 2001 From: Tom Limoncelli <6293917+tlimoncelli@users.noreply.github.com> Date: Sat, 29 Nov 2025 12:17:44 -0500 Subject: [PATCH 2/3] BUGFIX: IDN support is broken for domain names (#3845) # Issue Fixes https://github.com/StackExchange/dnscontrol/issues/3842 CC @das7pad # Resolution Convert domain.Name to IDN earlier in the pipeline. Hack the --domains processing to convert everything to IDN. * Domain names are now stored 3 ways: The original input from dnsconfig.js, canonical IDN format (`xn--...`), and Unicode format. All are downcased. Providers that haven't been updated will receive the IDN format instead of the original input format. This might break some providers but only for users with unicode in their D("domain.tld"). PLEASE TEST YOUR PROVIDER. * BIND filename formatting options have been added to access the new formats. # Breaking changes * BIND zonefiles may change. The default used the name input in the D() statement. It now defaults to the IDN name + "!tag" if there is a tag. * Providers that are not IDN-aware may break (hopefully only if they weren't processing IDN already) --------- Co-authored-by: Jakob Ackermann --- commands/commands.go | 36 ----- commands/commands_test.go | 127 ----------------- commands/getZones.go | 5 +- commands/gz_test.go | 2 +- commands/ppreviewPush.go | 24 +--- commands/ppreviewPush_test.go | 2 +- documentation/provider/bind.md | 30 ++-- integrationTest/helpers_integration_test.go | 4 +- models/dns.go | 17 +-- models/domain.go | 85 +++++------ models/domain_test.go | 68 --------- models/t_caa.go | 2 +- pkg/domaintags/domaintags.go | 81 +++++++++++ pkg/domaintags/domaintags_test.go | 133 ++++++++++++++++++ pkg/domaintags/permitlist.go | 91 ++++++++++++ pkg/domaintags/permitlist_test.go | 110 +++++++++++++++ pkg/js/README-parse_tests.md | 2 + pkg/js/js.go | 6 + pkg/js/js_test.go | 9 +- pkg/js/parse_tests/001-basic.json | 1 - pkg/js/parse_tests/002-ttl.json | 1 - pkg/js/parse_tests/003-meta.json | 1 - pkg/js/parse_tests/004-ips.json | 1 - pkg/js/parse_tests/005-ignored-records.json | 2 - pkg/js/parse_tests/006-transforms.json | 1 - .../parse_tests/007-importTransformTTL.json | 3 - pkg/js/parse_tests/008-import.json | 1 - pkg/js/parse_tests/009-reverse.json | 1 - pkg/js/parse_tests/010-alias.json | 1 - pkg/js/parse_tests/011-cfRedirect.json | 1 - pkg/js/parse_tests/012-duration.json | 1 - pkg/js/parse_tests/013-mx.json | 1 - pkg/js/parse_tests/014-caa.json | 1 - pkg/js/parse_tests/015-tlsa.json | 1 - pkg/js/parse_tests/017-txt.json | 1 - pkg/js/parse_tests/018-dkim.json | 1 - pkg/js/parse_tests/019-r53-alias.json | 1 - pkg/js/parse_tests/020-complexRequire.json | 1 - pkg/js/parse_tests/021-srv.json | 1 - pkg/js/parse_tests/022-sshfp.json | 1 - .../parse_tests/023-ignored-glob-records.json | 1 - pkg/js/parse_tests/024-json-import.json | 1 - pkg/js/parse_tests/025-autodnssec.json | 3 - pkg/js/parse_tests/026-azure-alias.json | 1 - pkg/js/parse_tests/027-ds.json | 1 - pkg/js/parse_tests/028-dextend.json | 3 - pkg/js/parse_tests/029-dextendsub.json | 18 +-- ...zone => xn--dsseldorf-q9a.example.net.zone} | 0 ...ample.net.zone => xn--tda.example.net.zone} | 0 pkg/js/parse_tests/030-dextenddoc.json | 1 - pkg/js/parse_tests/031-dextendnames.json | 2 - pkg/js/parse_tests/032-reverseip.json | 1 - pkg/js/parse_tests/033-revextend.json | 2 - pkg/js/parse_tests/034-nameserver-ttl.json | 2 - pkg/js/parse_tests/035-naptr.json | 1 - pkg/js/parse_tests/036-dextendcf.json | 1 - pkg/js/parse_tests/037-splithor.json | 18 +-- pkg/js/parse_tests/038-soa.json | 1 - pkg/js/parse_tests/039-include.json | 6 +- pkg/js/parse_tests/040-cfWorkerRoute.json | 1 - pkg/js/parse_tests/040-r53-zone.json | 4 +- pkg/js/parse_tests/043-safety.json | 2 - pkg/js/parse_tests/044-ensureabsent.json | 1 - pkg/js/parse_tests/045-loc.json | 1 - pkg/js/parse_tests/046-DHCID.json | 1 - pkg/js/parse_tests/047-DNAME.json | 1 - pkg/js/parse_tests/047-SVCB.json | 1 - pkg/js/parse_tests/048-DNSKEY.json | 1 - pkg/js/parse_tests/049-json5-require.json | 1 - pkg/js/parse_tests/050-cfSingleRedirect.json | 1 - pkg/js/parse_tests/051-HASH.json | 1 - .../parse_tests/054-b3487_d_extend_rev.json | 1 - pkg/js/parse_tests/055-b3550-ipv6ptr.json | 2 - pkg/js/parse_tests/056-openpgpkey.json | 1 - pkg/js/parse_tests/057-smimea.json | 1 - pkg/normalize/importTransform_test.go | 4 + pkg/normalize/validate.go | 4 - pkg/printer/printer.go | 10 +- providers/bind/bindProvider.go | 42 ++++-- providers/bind/fnames.go | 23 ++- providers/bind/fnames_test.go | 64 ++++++--- providers/powerdns/diff.go | 2 +- providers/powerdns/dnssec.go | 2 +- 83 files changed, 623 insertions(+), 470 deletions(-) delete mode 100644 commands/commands_test.go delete mode 100644 models/domain_test.go create mode 100644 pkg/domaintags/domaintags.go create mode 100644 pkg/domaintags/domaintags_test.go create mode 100644 pkg/domaintags/permitlist.go create mode 100644 pkg/domaintags/permitlist_test.go rename pkg/js/parse_tests/029-dextendsub/{düsseldorf.example.net.zone => xn--dsseldorf-q9a.example.net.zone} (100%) rename pkg/js/parse_tests/029-dextendsub/{ü.example.net.zone => xn--tda.example.net.zone} (100%) diff --git a/commands/commands.go b/commands/commands.go index fbf1265ac..3ebb051f0 100644 --- a/commands/commands.go +++ b/commands/commands.go @@ -303,39 +303,3 @@ func (args *FilterArgs) flags() []cli.Flag { }, } } - -// domainInList takes a domain and a list of domains and returns true if the -// domain is in the list, accounting for wildcards and tags. -func domainInList(domain string, list []string) bool { - for _, item := range list { - if item == domain { - return true - } - if strings.HasPrefix(item, "*") && strings.HasSuffix(domain, item[1:]) { - return true - } - filterDom, filterTag, isFilterTagged := strings.Cut(item, "!") - splitDom, domainTag, isDomainTagged := strings.Cut(domain, "!") - if splitDom == filterDom { - if isDomainTagged { - if filterTag == "*" { - return true - } - if domainTag == "" && !isFilterTagged { - // domain example.com! == filter example.com - return true - } - if isFilterTagged && domainTag == filterTag { - return true - } - } - if isFilterTagged { - if filterTag == "" && !isDomainTagged { - // filter example.com! == domain example.com - return true - } - } - } - } - return false -} diff --git a/commands/commands_test.go b/commands/commands_test.go deleted file mode 100644 index 73f974bbd..000000000 --- a/commands/commands_test.go +++ /dev/null @@ -1,127 +0,0 @@ -package commands - -import "testing" - -func Test_domainInList(t *testing.T) { - type args struct { - domain string - list []string - } - tests := []struct { - name string - args args - want bool - }{ - { - name: "small", - args: args{ - domain: "foo.com", - list: []string{"foo.com"}, - }, - want: true, - }, - { - name: "big", - args: args{ - domain: "foo.com", - list: []string{"example.com", "foo.com", "baz.com"}, - }, - want: true, - }, - { - name: "missing", - args: args{ - domain: "foo.com", - list: []string{"bar.com"}, - }, - want: false, - }, - { - name: "wildcard", - args: args{ - domain: "*.10.in-addr.arpa", - list: []string{"bar.com", "10.in-addr.arpa", "example.com"}, - }, - want: false, - }, - { - name: "wildcardmissing", - args: args{ - domain: "*.10.in-addr.arpa", - list: []string{"bar.com", "6.in-addr.arpa", "example.com"}, - }, - want: false, - }, - { - name: "tagged", - args: args{ - domain: "foo.com!bar", - list: []string{"foo.com"}, - }, - want: false, - }, - { - name: "taggedWildcard", - args: args{ - domain: "foo.com!bar", - list: []string{"foo.com!*"}, - }, - want: true, - }, - { - name: "taggedWildcardMatchesEmpty", - args: args{ - domain: "foo.com!", - list: []string{"foo.com!*"}, - }, - want: true, - }, - { - name: "taggedWildcardNotMatchUntagged", - args: args{ - domain: "foo.com", - list: []string{"foo.com!*"}, - }, - want: false, - }, - { - name: "taggedEmtpy", - args: args{ - domain: "foo.com", - list: []string{"foo.com!"}, - }, - want: true, - }, - { - name: "domainTaggedEmtpy", - args: args{ - domain: "foo.com!", - list: []string{"foo.com"}, - }, - want: true, - }, - { - name: "filterTaggedNoMatch", - args: args{ - domain: "foo.com", - list: []string{"foo.com!foo"}, - }, - want: false, - }, - { - name: "domainTaggedNoMatch", - args: args{ - domain: "foo.com!foo", - list: []string{"foo.com"}, - }, - want: false, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := domainInList(tt.args.domain, tt.args.list); got != tt.want { - t.Errorf("domainInList() = %v, want %v", got, tt.want) - } - }) - } -} diff --git a/commands/getZones.go b/commands/getZones.go index 4839ba343..80c6327e7 100644 --- a/commands/getZones.go +++ b/commands/getZones.go @@ -200,7 +200,10 @@ func GetZone(args GetZoneArgs) error { // fetch all of the records zoneRecs := make([]models.Records, len(zones)) for i, zone := range zones { - recs, err := provider.GetZoneRecords(zone, nil) + recs, err := provider.GetZoneRecords(zone, + map[string]string{ + models.DomainUniqueName: zone, + }) if err != nil { return fmt.Errorf("failed GetZone gzr: %w", err) } diff --git a/commands/gz_test.go b/commands/gz_test.go index 9eb123de3..c53009a07 100644 --- a/commands/gz_test.go +++ b/commands/gz_test.go @@ -63,7 +63,7 @@ func testFormat(t *testing.T, domain, format string) { // Read the expected result want, err := os.ReadFile(expectedFilename) if err != nil { - log.Fatal(fmt.Errorf("can't read expected %q: %w", outfile.Name(), err)) + log.Fatal(fmt.Errorf("can't read expected %q: %w", expectedFilename, err)) } if w, g := string(want), string(got); w != g { diff --git a/commands/ppreviewPush.go b/commands/ppreviewPush.go index f666dbde3..4ee9fbacc 100644 --- a/commands/ppreviewPush.go +++ b/commands/ppreviewPush.go @@ -20,6 +20,7 @@ import ( "github.com/StackExchange/dnscontrol/v4/models" "github.com/StackExchange/dnscontrol/v4/pkg/bindserial" "github.com/StackExchange/dnscontrol/v4/pkg/credsfile" + "github.com/StackExchange/dnscontrol/v4/pkg/domaintags" "github.com/StackExchange/dnscontrol/v4/pkg/nameservers" "github.com/StackExchange/dnscontrol/v4/pkg/normalize" "github.com/StackExchange/dnscontrol/v4/pkg/notifications" @@ -288,7 +289,7 @@ func prun(args PPreviewArgs, push bool, interactive bool, out printer.CLI, repor continue // Do not emit noise when zone exists } if !started { - out.StartDomain(zone.GetUniqueName()) + out.StartDomain(zone) started = true } skip := skipProvider(provider.Name, providersToProcess) @@ -351,7 +352,7 @@ func prun(args PPreviewArgs, push bool, interactive bool, out printer.CLI, repor // Now we know what to do, print or do the tasks. out.PrintfIf(fullMode, "PHASE 3: CORRECTIONS\n") for _, zone := range zonesToProcess { - out.StartDomain(zone.GetUniqueName()) + out.StartDomain(zone) // Process DNS provider changes: providersToProcess := whichProvidersToProcess(zone.DNSProviderInstances, args.Providers) @@ -400,29 +401,16 @@ func prun(args PPreviewArgs, push bool, interactive bool, out printer.CLI, repor return nil } -// func countActions(corrections []*models.Correction) int { -// r := 0 -// for _, c := range corrections { -// if c.F != nil { -// r++ -// } -// } -// return r -//} - // whichZonesToProcess takes a list of DomainConfigs and a filter string and -// returns a list of DomainConfigs whose metadata[DomainUniqueName] matched the +// returns a list of DomainConfigs whose Domain.UniqueName matched the // filter. The filter string is a comma-separated list of domain names. If the // filter string is empty or "all", all domains are returned. func whichZonesToProcess(domains []*models.DomainConfig, filter string) []*models.DomainConfig { - if filter == "" || filter == "all" { - return domains - } + fh := domaintags.CompilePermitList(filter) - permitList := strings.Split(filter, ",") var picked []*models.DomainConfig for _, domain := range domains { - if domainInList(domain.GetUniqueName(), permitList) { + if fh.Permitted(domain.GetUniqueName()) { picked = append(picked, domain) } } diff --git a/commands/ppreviewPush_test.go b/commands/ppreviewPush_test.go index 735749f9b..83dd4c941 100644 --- a/commands/ppreviewPush_test.go +++ b/commands/ppreviewPush_test.go @@ -23,7 +23,7 @@ func Test_whichZonesToProcess(t *testing.T) { } for _, dc := range allDC { - dc.UpdateSplitHorizonNames() + dc.PostProcess() } type args struct { diff --git a/documentation/provider/bind.md b/documentation/provider/bind.md index 0a485320b..5136e0795 100644 --- a/documentation/provider/bind.md +++ b/documentation/provider/bind.md @@ -22,7 +22,8 @@ Example: { "bind": { "TYPE": "BIND", - "directory": "myzones" + "directory": "myzones", + "filenameformat": "%U.zone" } } ``` @@ -89,10 +90,13 @@ file name is the name as specified in the `D()` function plus ".zone". The filenameformat is a string with a few printf-like `%` verbs: - * `%U` the domain name as specified in `D()` - * `%D` the domain name without any split horizon tag (the "example.com" part of "example.com!tag") - * `%T` the split horizon tag, or "" (the "tag" part of "example.com!tag") - * `%?x` this returns `x` if the split horizon tag is non-null, otherwise nothing. `x` can be any printable. + * The domain name without tag (the `example.com` part of `example.com!tag`): + * `%D` as specified in `D()` (no IDN conversion, but downcased) + * `%I` converted to IDN/Punycode (`xn--...`) and downcased. + * `%N` converted to Unicode (downcased first) + * `%T` the split horizon tag, or "" (the `tag` part of `example.com!tag`) + * `%?x` this returns `x` if the split horizon tag is non-null, otherwise nothing. `x` can be any printable but is usually `!`. + * `%U` short for "%I%?!%T". This is the universal, canonical, name for the domain used for comparisons within DNSControl. This is best for filenames which is why it is used in the default. * `%%` `%` * ordinary characters (not `%`) are copied unchanged to the output stream * FYI: format strings must not end with an incomplete `%` or `%?` @@ -101,19 +105,17 @@ Typical values: * `%U.zone` (The default) * `example.com.zone` or `example.com!tag.zone` - * `%T%*U%D.zone` (optional tag and `_` + domain + `.zone`) + * `%T%?_%I.zone` (optional tag and `_` + domain + `.zone`) * `tag_example.com.zone` or `example.com.zone` * `db_%T%?_%D` * `db_inside_example.com` or `db_example.com` - * `db_%D` - * `db_example.com` -The last example will generate the same name for both -`D("example.com!inside")` and `D("example.com!outside")`. This -assumes two BIND providers are configured in `creds.json`, each with -a different `directory` setting. Otherwise `dnscontrol` will write -both domains to the same file, flapping between the two back and -forth. +{% hint style="warning" %} +**Warning** DNSControl will not warn you if two zones generate the same +filename. Instead, each will write to the same place. The content would end up +flapping back and forth between the two. The best way to prevent this is to +always include the tag (`%T`) or use `%U` which includes the tag. +{% endhint %} (new in v4.2.0) `dnscontrol push` will create subdirectories along the path to the filename. This includes both the portion of the path created by the diff --git a/integrationTest/helpers_integration_test.go b/integrationTest/helpers_integration_test.go index 2385b796c..a289513f2 100644 --- a/integrationTest/helpers_integration_test.go +++ b/integrationTest/helpers_integration_test.go @@ -40,7 +40,7 @@ func getDomainConfigWithNameservers(t *testing.T, prv providers.DNSServiceProvid dc := &models.DomainConfig{ Name: domainName, } - dc.UpdateSplitHorizonNames() + dc.PostProcess() // fix up nameservers ns, err := prv.GetNameservers(domainName) @@ -148,6 +148,8 @@ func makeChanges(t *testing.T, prv providers.DNSServiceProvider, dc *models.Doma return } + //fmt.Printf("DEBUG: Running test %q: Names %q %q %q\n", desc, dom.Name, dom.NameRaw, dom.NameUnicode) + // get and run corrections for first time _, corrections, actualChangeCount, err := zonerecs.CorrectZoneRecords(prv, dom) if err != nil { diff --git a/models/dns.go b/models/dns.go index 524a3e007..384aea4d7 100644 --- a/models/dns.go +++ b/models/dns.go @@ -105,17 +105,10 @@ type Correction struct { Msg string } -// DomainContainingFQDN finds the best domain from the dns config for the given record fqdn. -// It will chose the domain whose name is the longest suffix match for the fqdn. -func (config *DNSConfig) DomainContainingFQDN(fqdn string) *DomainConfig { - fqdn = strings.TrimSuffix(fqdn, ".") - longestLength := 0 - var d *DomainConfig - for _, dom := range config.Domains { - if (dom.Name == fqdn || strings.HasSuffix(fqdn, "."+dom.Name)) && len(dom.Name) > longestLength { - longestLength = len(dom.Name) - d = dom - } +// PostProcess performs and post-processing required after running dnsconfig.js and loading the result. +func (config *DNSConfig) PostProcess() error { + for _, domain := range config.Domains { + domain.PostProcess() } - return d + return nil } diff --git a/models/domain.go b/models/domain.go index f978b8868..845b1b460 100644 --- a/models/domain.go +++ b/models/domain.go @@ -2,28 +2,33 @@ package models import ( "fmt" - "strings" "sync" + "github.com/StackExchange/dnscontrol/v4/pkg/domaintags" "github.com/qdm12/reprint" "golang.org/x/net/idna" ) const ( - // DomainUniqueName is the full `example.com!tag` name` - DomainUniqueName = "dnscontrol_uniquename" - // DomainTag is the tag part of `example.com!tag` name - DomainTag = "dnscontrol_tag" + DomainTag = "dnscontrol_tag" // A copy of DomainConfig.Tag + DomainUniqueName = "dnscontrol_uniquename" // A copy of DomainConfig.UniqueName + DomainNameRaw = "dnscontrol_nameraw" // A copy of DomainConfig.NameRaw + DomainNameIDN = "dnscontrol_nameidn" // A copy of DomainConfig.NameIDN + DomainNameUnicode = "dnscontrol_nameunicode" // A copy of DomainConfig.NameUnicode ) // DomainConfig describes a DNS domain (technically a DNS zone). type DomainConfig struct { - Name string `json:"name"` // NO trailing "." + Name string `json:"name"` // NO trailing "." Converted to IDN (punycode) early in the pipeline. + NameRaw string `json:"-"` // name as entered by user in dnsconfig.js + NameUnicode string `json:"-"` // name in Unicode format + + Tag string `json:"tag,omitempty"` // Split horizon tag. + UniqueName string `json:"-"` // .Name + "!" + .Tag + RegistrarName string `json:"registrar"` DNSProviderNames map[string]int `json:"dnsProviders"` - // Metadata[DomainUniqueName] // .Name + "!" + .Tag - // Metadata[DomainTag] // split horizon tag Metadata map[string]string `json:"meta,omitempty"` Records Records `json:"records"` Nameservers []*Nameserver `json:"nameservers,omitempty"` @@ -56,43 +61,39 @@ type DomainConfig struct { pendingPopulateCorrections map[string][]*Correction // Corrections for zone creations at each provider } -// GetSplitHorizonNames returns the domain's name, uniquename, and tag. -func (dc *DomainConfig) GetSplitHorizonNames() (name, uniquename, tag string) { - return dc.Name, dc.Metadata[DomainUniqueName], dc.Metadata[DomainTag] -} - -// GetUniqueName returns the domain's uniquename. -func (dc *DomainConfig) GetUniqueName() (uniquename string) { - return dc.Metadata[DomainUniqueName] -} - -// UpdateSplitHorizonNames updates the split horizon fields -// (uniquename and tag) based on name. -func (dc *DomainConfig) UpdateSplitHorizonNames() { - name, unique, tag := dc.GetSplitHorizonNames() - - if unique == "" { - unique = name - } - - if tag == "" { - l := strings.SplitN(name, "!", 2) - if len(l) == 2 { - name = l[0] - tag = l[1] - } - if tag == "" { - // ensure empty tagged domain is treated as untagged - unique = name - } - } - - dc.Name = name +// PostProcess performs and post-processing required after running dnsconfig.js and loading the result. +// It is called by dns.go's PostProcess() function. +func (dc *DomainConfig) PostProcess() { + // Ensure the metadata map is initialized. if dc.Metadata == nil { dc.Metadata = map[string]string{} } - dc.Metadata[DomainUniqueName] = unique - dc.Metadata[DomainTag] = tag + + // Turn the user-supplied name into the fixed forms. + ff := domaintags.MakeDomainFixForms(dc.Name) + dc.Tag, dc.NameRaw, dc.Name, dc.NameUnicode, dc.UniqueName = ff.Tag, ff.NameRaw, ff.NameIDN, ff.NameUnicode, ff.UniqueName + + // Store the FixForms is Metadata so we don't have to change the signature of every function that might need them. + // This is a bit ugly but avoids a huge refactor. Please avoid using these to make the future refactor easier. + if dc.Tag != "" { + dc.Metadata[DomainTag] = dc.Tag + } + //dc.Metadata[DomainNameRaw] = dc.NameRaw + //dc.Metadata[DomainNameIDN] = dc.Name + //dc.Metadata[DomainNameUnicode] = dc.NameUnicode + dc.Metadata[DomainUniqueName] = dc.UniqueName +} + +// GetSplitHorizonNames returns the domain's name, uniquename, and tag. +// Deprecated: use .Name, .Uniquename, and .Tag directly instead. +func (dc *DomainConfig) GetSplitHorizonNames() (name, uniquename, tag string) { + return dc.Name, dc.UniqueName, dc.Tag +} + +// GetUniqueName returns the domain's uniquename. +// Deprecated: dc.UniqueName directly instead. +func (dc *DomainConfig) GetUniqueName() (uniquename string) { + return dc.UniqueName } // Copy returns a deep copy of the DomainConfig. diff --git a/models/domain_test.go b/models/domain_test.go deleted file mode 100644 index 140b8edff..000000000 --- a/models/domain_test.go +++ /dev/null @@ -1,68 +0,0 @@ -package models - -import ( - "testing" -) - -func Test_UpdateSplitHorizonNames(t *testing.T) { - tests := []struct { - name string - dc *DomainConfig - expected *DomainConfig - }{ - { - name: "testNoTag", - dc: &DomainConfig{ - Name: "example.com", - }, - expected: &DomainConfig{ - Name: "example.com", - Metadata: map[string]string{ - DomainUniqueName: "example.com", - DomainTag: "", - }, - }, - }, - { - name: "testEmptyTag", - dc: &DomainConfig{ - Name: "example.com!", - }, - expected: &DomainConfig{ - Name: "example.com", - Metadata: map[string]string{ - DomainUniqueName: "example.com", - DomainTag: "", - }, - }, - }, - { - name: "testWithTag", - dc: &DomainConfig{ - Name: "example.com!john", - }, - expected: &DomainConfig{ - Name: "example.com", - Metadata: map[string]string{ - DomainUniqueName: "example.com!john", - DomainTag: "john", - }, - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - tt.dc.UpdateSplitHorizonNames() - if tt.dc.Name != tt.expected.Name { - t.Errorf("expected name %s, got %s", tt.expected.Name, tt.dc.Name) - } - if tt.dc.Metadata[DomainUniqueName] != tt.expected.Metadata[DomainUniqueName] { - t.Errorf("expected unique name %s, got %s", tt.expected.Metadata[DomainUniqueName], tt.dc.Metadata[DomainUniqueName]) - } - if tt.dc.Metadata[DomainTag] != tt.expected.Metadata[DomainTag] { - t.Errorf("expected tag %s, got %s", tt.expected.Metadata[DomainTag], tt.dc.Metadata[DomainTag]) - } - }) - } -} diff --git a/models/t_caa.go b/models/t_caa.go index 73ced3a13..53e94a137 100644 --- a/models/t_caa.go +++ b/models/t_caa.go @@ -23,7 +23,7 @@ func (rc *RecordConfig) SetTargetCAA(flag uint8, tag string, target string) erro // Per: https://www.iana.org/assignments/pkix-parameters/pkix-parameters.xhtml#caa-properties excluding reserved tags allowedTags := []string{"issue", "issuewild", "iodef", "contactemail", "contactphone", "issuemail", "issuevmc"} if !slices.Contains(allowedTags, tag) { - return fmt.Errorf("CAA tag (%v) is not one of the valid types.", tag) + return fmt.Errorf("CAA tag (%v) is not one of the valid types", tag) } return nil diff --git a/pkg/domaintags/domaintags.go b/pkg/domaintags/domaintags.go new file mode 100644 index 000000000..adf07d810 --- /dev/null +++ b/pkg/domaintags/domaintags.go @@ -0,0 +1,81 @@ +package domaintags + +import ( + "strings" + + "golang.org/x/net/idna" +) + +// DomainFixedForms stores the various fixed forms of a domain name and tag. +type DomainFixedForms struct { + NameRaw string // "originalinput.com" (name as input by the user, lowercased (no tag)) + NameIDN string // "punycode.com" + NameUnicode string // "unicode.com" (converted to downcase BEFORE unicode conversion) + UniqueName string // "punycode.com!tag" + + Tag string // The tag portion of `example.com!tag` + HasBang bool // Was there a "!" in the input when creating this struct? +} + +// MakeDomainFixedForms turns the user-supplied name into the fixed forms. +// * .Tag: the domain tag (of "example.com!tag") +// * .NameRaw: lowercase version of how the user input the name in dnsconfig.js. +// * .Name: punycode version, downcased. +// * .NameUnicode: unicode version of the name, downcased. +// * .UniqueName: "example.com!tag" unique across the entire config. +func MakeDomainFixForms(n string) DomainFixedForms { + var err error + var tag, nameRaw, nameIDN, nameUnicode, uniqueName string + var hasBang bool + + // Split tag from name. + p := strings.SplitN(n, "!", 2) + if len(p) == 2 { + tag = p[1] + hasBang = true + } else { + tag = "" + hasBang = false + } + + nameRaw = strings.ToLower(p[0]) + if strings.HasPrefix(n, nameRaw) { + // Avoid pointless duplication. + nameRaw = n[0:len(nameRaw)] + } + + nameIDN, err = idna.ToASCII(nameRaw) + if err != nil { + nameIDN = nameRaw // Fallback to raw name on error. + } else { + // Avoid pointless duplication. + if nameIDN == nameRaw { + nameIDN = nameRaw + } + } + + nameUnicode, err = idna.ToUnicode(nameRaw) + if err != nil { + nameUnicode = nameRaw // Fallback to raw name on error. + } else { + // Avoid pointless duplication. + if nameUnicode == nameRaw { + nameUnicode = nameRaw + } + } + + if hasBang { + uniqueName = nameIDN + "!" + tag + } else { + uniqueName = nameIDN + } + + return DomainFixedForms{ + Tag: tag, + NameRaw: nameRaw, + NameIDN: nameIDN, + NameUnicode: nameUnicode, + UniqueName: uniqueName, + HasBang: hasBang, + } +} diff --git a/pkg/domaintags/domaintags_test.go b/pkg/domaintags/domaintags_test.go new file mode 100644 index 000000000..f6c010c33 --- /dev/null +++ b/pkg/domaintags/domaintags_test.go @@ -0,0 +1,133 @@ +package domaintags + +import ( + "testing" +) + +func Test_MakeDomainFixForms(t *testing.T) { + tests := []struct { + name string + input string + wantTag string + wantNameRaw string + wantNameIDN string + wantNameUnicode string + wantUniqueName string + wantHasBang bool + }{ + { + name: "simple domain", + input: "example.com", + wantTag: "", + wantNameRaw: "example.com", + wantNameIDN: "example.com", + wantNameUnicode: "example.com", + wantUniqueName: "example.com", + wantHasBang: false, + }, + { + name: "domain with tag", + input: "example.com!mytag", + wantTag: "mytag", + wantNameRaw: "example.com", + wantNameIDN: "example.com", + wantNameUnicode: "example.com", + wantUniqueName: "example.com!mytag", + wantHasBang: true, + }, + { + name: "domain with empty tag", + input: "example.com!", + wantTag: "", + wantNameRaw: "example.com", + wantNameIDN: "example.com", + wantNameUnicode: "example.com", + wantUniqueName: "example.com!", + wantHasBang: true, + }, + { + name: "unicode domain", + input: "उदाहरण.com", + wantTag: "", + wantNameRaw: "उदाहरण.com", + wantNameIDN: "xn--p1b6ci4b4b3a.com", + wantNameUnicode: "उदाहरण.com", + wantUniqueName: "xn--p1b6ci4b4b3a.com", + wantHasBang: false, + }, + { + name: "unicode domain with tag", + input: "उदाहरण.com!mytag", + wantTag: "mytag", + wantNameRaw: "उदाहरण.com", + wantNameIDN: "xn--p1b6ci4b4b3a.com", + wantNameUnicode: "उदाहरण.com", + wantUniqueName: "xn--p1b6ci4b4b3a.com!mytag", + wantHasBang: true, + }, + { + name: "punycode domain", + input: "xn--p1b6ci4b4b3a.com", + wantTag: "", + wantNameRaw: "xn--p1b6ci4b4b3a.com", + wantNameIDN: "xn--p1b6ci4b4b3a.com", + wantNameUnicode: "उदाहरण.com", + wantUniqueName: "xn--p1b6ci4b4b3a.com", + wantHasBang: false, + }, + { + name: "punycode domain with tag", + input: "xn--p1b6ci4b4b3a.com!mytag", + wantTag: "mytag", + wantNameRaw: "xn--p1b6ci4b4b3a.com", + wantNameIDN: "xn--p1b6ci4b4b3a.com", + wantNameUnicode: "उदाहरण.com", + wantUniqueName: "xn--p1b6ci4b4b3a.com!mytag", + wantHasBang: true, + }, + { + name: "mixed case domain", + input: "Example.COM", + wantTag: "", + wantNameRaw: "example.com", + wantNameIDN: "example.com", + wantNameUnicode: "example.com", + wantUniqueName: "example.com", + wantHasBang: false, + }, + { + name: "mixed case domain with tag", + input: "Example.COM!MyTag", + wantTag: "MyTag", + wantNameRaw: "example.com", + wantNameIDN: "example.com", + wantNameUnicode: "example.com", + wantUniqueName: "example.com!MyTag", + wantHasBang: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := MakeDomainFixForms(tt.input) + if got.Tag != tt.wantTag { + t.Errorf("MakeDomainFixForms() gotTag = %v, want %v", got.Tag, tt.wantTag) + } + if got.NameRaw != tt.wantNameRaw { + t.Errorf("MakeDomainFixForms() gotNameRaw = %v, want %v", got.NameRaw, tt.wantNameRaw) + } + if got.NameIDN != tt.wantNameIDN { + t.Errorf("MakeDomainFixForms() gotNameIDN = %v, want %v", got.NameIDN, tt.wantNameIDN) + } + if got.NameUnicode != tt.wantNameUnicode { + t.Errorf("MakeDomainFixForms() gotNameUnicode = %v, want %v", got.NameUnicode, tt.wantNameUnicode) + } + if got.UniqueName != tt.wantUniqueName { + t.Errorf("MakeDomainFixForms() gotUniqueName = %v, want %v", got.UniqueName, tt.wantUniqueName) + } + if got.HasBang != tt.wantHasBang { + t.Errorf("MakeDomainFixForms() gotHasTag = %v, want %v", got.HasBang, tt.wantHasBang) + } + }) + } +} diff --git a/pkg/domaintags/permitlist.go b/pkg/domaintags/permitlist.go new file mode 100644 index 000000000..b9e8e904b --- /dev/null +++ b/pkg/domaintags/permitlist.go @@ -0,0 +1,91 @@ +package domaintags + +import ( + "strings" +) + +type PermitList struct { + // If the permit list is "all" or "". + all bool + items []DomainFixedForms +} + +// CompilePermitList compiles a list of domain strings into a PermitList structure. The +func CompilePermitList(s string) PermitList { + s = strings.TrimSpace(s) + if s == "" || s == "*" || strings.ToLower(s) == "all" { + return PermitList{all: true} + } + + sl := PermitList{} + for _, l := range strings.Split(s, ",") { + l = strings.TrimSpace(l) + if l == "" { // Skip empty entries. They match nothing. + continue + } + ff := MakeDomainFixForms(l) + if ff.HasBang && ff.NameIDN == "" { // Treat empty name as wildcard. + ff.NameIDN = "*" + } + sl.items = append(sl.items, ff) + } + + return sl +} + +func (pl *PermitList) Permitted(domToCheck string) bool { + + // If the permit list is "all", everything is permitted. + if pl.all { + return true + } + + domToCheckFF := MakeDomainFixForms(domToCheck) + + for _, filterItem := range pl.items { + + // Special case: filter=example.com!* does not match example.com (no tag) + if filterItem.Tag == "*" && !domToCheckFF.HasBang { + continue + } + // Special case: filter=example.com!* does not match example.com! (empty tag) + if filterItem.Tag == "*" && domToCheckFF.HasBang && domToCheckFF.Tag == "" { + continue + } + // Special case: filter=example.com! does not match example.com!tag + if filterItem.HasBang && filterItem.Tag == "" && domToCheckFF.HasBang && domToCheckFF.Tag != "" { + continue + } + + // Skip if tags don't match + if (filterItem.Tag != "*") && (domToCheckFF.Tag != filterItem.Tag) { + continue + } + + // Now that we know the tag matches, we can focus on the name. + + // `*!tag` or `*` matches everything. + if filterItem.NameIDN == "*" { + return true + } + + // If the name starts with "*." then match the suffix. + if strings.HasPrefix(filterItem.NameIDN, "*.") { + // example.com matches *.example.com + if domToCheckFF.NameIDN == filterItem.NameIDN[2:] || domToCheckFF.NameUnicode == filterItem.NameUnicode[2:] { + return true + } + // foo.example.com matches *.example.com + if strings.HasSuffix(domToCheckFF.NameIDN, filterItem.NameIDN[1:]) || strings.HasSuffix(domToCheckFF.NameUnicode, filterItem.NameUnicode[1:]) { + return true + } + } + + // No wildcards? Exact match. + if filterItem.NameIDN == domToCheckFF.NameIDN || filterItem.NameUnicode == domToCheckFF.NameUnicode { + return true + } + } + + return false +} diff --git a/pkg/domaintags/permitlist_test.go b/pkg/domaintags/permitlist_test.go new file mode 100644 index 000000000..d6845a50b --- /dev/null +++ b/pkg/domaintags/permitlist_test.go @@ -0,0 +1,110 @@ +package domaintags + +import "testing" + +func TestPermitList_Permitted(t *testing.T) { + // MakeDomainFixForms is not exported, so we can't directly use it here + // to create complex test cases with IDNs easily without duplicating its logic. + // However, the existing tests cover a wide range of practical scenarios. + // For the purpose of this test, we'll assume MakeDomainFixForms works as expected + // and focus on the logic of the Permitted method itself. + + testCases := []struct { + name string + permitList string + domain string + expected bool + }{ + // "all" or empty permit list + {"all permits everything", "all", "example.com", true}, + {"all permits everything with tag", "all", "example.com!tag1", true}, + {"empty string permits everything", "", "example.com", true}, + {"whitespace string permits everything", " ", "example.com", true}, + + // Simple exact matches + {"exact match", "example.com", "example.com", true}, + {"exact match with tag", "example.com!tag1", "example.com!tag1", true}, + {"exact mismatch domain", "example.com", "google.com", false}, + {"exact mismatch tag", "example.com!tag1", "example.com!tag2", false}, + {"exact mismatch domain with tag", "example.com!tag1", "google.com!tag1", false}, + {"domain with tag not in list without tag", "example.com", "example.com!tag1", false}, + {"domain without tag not in list with tag", "example.com!tag1", "example.com", false}, + + // Wildcard domain name + {"wildcard domain matches", "*!tag1", "example.com!tag1", true}, + {"wildcard domain mismatch tag", "*!tag1", "example.com!tag2", false}, + {"wildcard domain no tag", "*!tag1", "example.com", false}, + {"wildcard domain and tag", "*", "example.com!tag1", true}, + {"wildcard domain and tag no tag", "*", "example.com", true}, + + // Wildcard tag + {"wildcard tag matches", "example.com!*", "example.com!tag1", true}, + {"wildcard tag matches no tag", "example.com!*", "example.com", false}, + {"wildcard tag mismatch domain", "example.com!*", "google.com!tag1", false}, + + // Suffix matching + {"suffix match base domain", "*.example.com", "example.com", true}, + {"suffix match subdomain", "*.example.com", "foo.example.com", true}, + {"suffix match another subdomain", "*.example.com", "foo.bar.example.com", true}, + {"suffix mismatch different domain", "*.example.com", "google.com", false}, + {"suffix mismatch partial", "*.example.com", "badexample.com", false}, + {"suffix match with tag", "*.example.com!tag1", "foo.example.com!tag1", true}, + {"suffix match base domain with tag", "*.example.com!tag1", "example.com!tag1", true}, + {"suffix mismatch tag", "*.example.com!tag1", "foo.example.com!tag2", false}, + {"suffix mismatch domain with tag", "*.example.com!tag1", "google.com!tag1", false}, + + // Multiple items in list + {"multiple items first match", "google.com,example.com", "google.com", true}, + {"multiple items second match", "google.com,example.com", "example.com", true}, + {"multiple items no match", "google.com,example.com", "other.com", false}, + {"multiple items with tags match", "google.com!tag1,example.com!tag2", "example.com!tag2", true}, + {"multiple items with tags mismatch", "google.com!tag1,example.com!tag2", "example.com!tag1", false}, + {"multiple complex items match", "a.com,*.b.com!tag1,c.com!*", "foo.b.com!tag1", true}, + {"multiple complex items match 2", "a.com,*.b.com!tag1,c.com!*", "c.com!anytag", true}, + {"multiple complex items no match", "a.com,*.b.com!tag1,c.com!*", "foo.b.com!tag2", false}, + + // IDN/Unicode cases (assuming MakeDomainFixForms works) + {"IDN exact match punycode", "xn--e1a4c.com", "xn--e1a4c.com", true}, // д.com + {"IDN exact match unicode", "д.com", "д.com", true}, + {"IDN mixed match", "xn--d1a.com", "д.com", true}, + {"IDN mixed match reversed", "д.com", "xn--d1a.com", true}, + {"IDN suffix match punycode", "*.xn--e1a4c.com", "sub.xn--e1a4c.com", true}, + {"IDN suffix match unicode", "*.д.com", "sub.д.com", true}, + {"IDN suffix match mixed", "*.xn--d1a.com", "sub.д.com", true}, + {"IDN suffix match mixed reversed", "*.д.com", "sub.xn--d1a.com", true}, + {"IDN suffix match base", "*.д.com", "д.com", true}, + + // Edge cases + {"empty list", " ", "example.com", true}, // TrimSpace makes it "", which is "all" + {"list with empty items", "one.com,,two.com", "one.com", true}, + {"list with empty items 2", "one.com,,two.com", "two.com", true}, + {"list with empty items no match", "one.com,,two.com", "three.com", false}, + {"no match on empty list", "nonexistent", "example.com", false}, + + // Weird backwards compatibility with no tag being different than empty tag + {"empty tag vs no tag mismatch", "example.com", "example.com!foo", false}, + + // testMultiFilterTaggedWildcard + {"testMultiFilterTaggedWildcard_0", "example.com!*", "example.com!", false}, + {"testMultiFilterTaggedWildcard_1", "example.com!*", "example.com", false}, + {"testMultiFilterTaggedWildcard_2", "example.com!*", "example.net", false}, + {"testMultiFilterTaggedWildcard_3", "example.com!*", "example.com!george", true}, + {"testMultiFilterTaggedWildcard_4", "example.com!*", "example.com!john", true}, + + // testFilterEmptyTagAndNoTag + {"testFilterEmptyTagAndNoTag_0", "example.com!,example.com", "example.com!", true}, + {"testFilterEmptyTagAndNoTag_1", "example.com!,example.com", "example.com", true}, + {"testFilterEmptyTagAndNoTag_2", "example.com!,example.com", "example.net", false}, + {"testFilterEmptyTagAndNoTag_3", "example.com!,example.com", "example.com!tag", false}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + pl := CompilePermitList(tc.permitList) + got := pl.Permitted(tc.domain) + if got != tc.expected { + t.Errorf("PermitList(%q).Permitted(%q) = %v; want %v", tc.permitList, tc.domain, got, tc.expected) + } + }) + } +} diff --git a/pkg/js/README-parse_tests.md b/pkg/js/README-parse_tests.md index 0db9c1b7a..3010dfe3d 100644 --- a/pkg/js/README-parse_tests.md +++ b/pkg/js/README-parse_tests.md @@ -36,6 +36,8 @@ Back-port the ACTUAL results to the expected results: (This is dangerous. You may be committing buggy results to the "expected" files. Carefully inspect the resulting PR.) ``` +find . -type f -name \*.ACTUAL -print -delete +go test -count=1 ./... cd parse_tests fmtjson *.json *.json.ACTUAL for i in *.ACTUAL ; do f=$(basename $i .ACTUAL) ; cp $i $f ; done diff --git a/pkg/js/js.go b/pkg/js/js.go index 6c614d4c5..52708b9cb 100644 --- a/pkg/js/js.go +++ b/pkg/js/js.go @@ -119,6 +119,12 @@ func ExecuteJavascriptString(script []byte, devMode bool, variables map[string]s if err = json.Unmarshal([]byte(str), conf); err != nil { return nil, err } + + err = conf.PostProcess() + if err != nil { + return nil, err + } + return conf, nil } diff --git a/pkg/js/js_test.go b/pkg/js/js_test.go index 8b16a128b..f42c02e4c 100644 --- a/pkg/js/js_test.go +++ b/pkg/js/js_test.go @@ -18,8 +18,7 @@ import ( ) const ( - testDir = "pkg/js/parse_tests" - errorDir = "pkg/js/error_tests" + testDir = "pkg/js/parse_tests" ) func init() { @@ -49,9 +48,6 @@ func TestParsedFiles(t *testing.T) { if err != nil { t.Fatal(err) } - for _, dc := range conf.Domains { - dc.UpdateSplitHorizonNames() - } errs := normalize.ValidateAndNormalizeConfig(conf) if len(errs) != 0 { @@ -115,8 +111,7 @@ func TestParsedFiles(t *testing.T) { var dCount int for _, dc := range conf.Domains { var zoneFile string - dc.UpdateSplitHorizonNames() - if dc.Metadata[models.DomainTag] != "" { + if dc.Tag != "" { zoneFile = filepath.Join(testDir, testName, dc.GetUniqueName()+".zone") } else { zoneFile = filepath.Join(testDir, testName, dc.Name+".zone") diff --git a/pkg/js/parse_tests/001-basic.json b/pkg/js/parse_tests/001-basic.json index 59609c518..82289bf2e 100644 --- a/pkg/js/parse_tests/001-basic.json +++ b/pkg/js/parse_tests/001-basic.json @@ -11,7 +11,6 @@ "Cloudflare": -1 }, "meta": { - "dnscontrol_tag": "", "dnscontrol_uniquename": "foo.com" }, "name": "foo.com", diff --git a/pkg/js/parse_tests/002-ttl.json b/pkg/js/parse_tests/002-ttl.json index 5a4147f0d..48bd3db4a 100644 --- a/pkg/js/parse_tests/002-ttl.json +++ b/pkg/js/parse_tests/002-ttl.json @@ -11,7 +11,6 @@ "Cloudflare": -1 }, "meta": { - "dnscontrol_tag": "", "dnscontrol_uniquename": "foo.com" }, "name": "foo.com", diff --git a/pkg/js/parse_tests/003-meta.json b/pkg/js/parse_tests/003-meta.json index a0b3f52d6..21f988ee3 100644 --- a/pkg/js/parse_tests/003-meta.json +++ b/pkg/js/parse_tests/003-meta.json @@ -4,7 +4,6 @@ { "dnsProviders": {}, "meta": { - "dnscontrol_tag": "", "dnscontrol_uniquename": "foo.com" }, "name": "foo.com", diff --git a/pkg/js/parse_tests/004-ips.json b/pkg/js/parse_tests/004-ips.json index 230c750cb..4147e135f 100644 --- a/pkg/js/parse_tests/004-ips.json +++ b/pkg/js/parse_tests/004-ips.json @@ -11,7 +11,6 @@ "Cloudflare": 0 }, "meta": { - "dnscontrol_tag": "", "dnscontrol_uniquename": "foo.com" }, "name": "foo.com", diff --git a/pkg/js/parse_tests/005-ignored-records.json b/pkg/js/parse_tests/005-ignored-records.json index 0bb768053..142896949 100644 --- a/pkg/js/parse_tests/005-ignored-records.json +++ b/pkg/js/parse_tests/005-ignored-records.json @@ -4,7 +4,6 @@ { "dnsProviders": {}, "meta": { - "dnscontrol_tag": "", "dnscontrol_uniquename": "foo.com" }, "name": "foo.com", @@ -56,7 +55,6 @@ { "dnsProviders": {}, "meta": { - "dnscontrol_tag": "", "dnscontrol_uniquename": "diff2.com" }, "name": "diff2.com", diff --git a/pkg/js/parse_tests/006-transforms.json b/pkg/js/parse_tests/006-transforms.json index f766a1520..2a54d02bf 100644 --- a/pkg/js/parse_tests/006-transforms.json +++ b/pkg/js/parse_tests/006-transforms.json @@ -11,7 +11,6 @@ "Cloudflare": -1 }, "meta": { - "dnscontrol_tag": "", "dnscontrol_uniquename": "foo.com" }, "name": "foo.com", diff --git a/pkg/js/parse_tests/007-importTransformTTL.json b/pkg/js/parse_tests/007-importTransformTTL.json index d86d8224c..39027b66e 100644 --- a/pkg/js/parse_tests/007-importTransformTTL.json +++ b/pkg/js/parse_tests/007-importTransformTTL.json @@ -4,7 +4,6 @@ { "dnsProviders": {}, "meta": { - "dnscontrol_tag": "", "dnscontrol_uniquename": "foo1.com" }, "name": "foo1.com", @@ -29,7 +28,6 @@ { "dnsProviders": {}, "meta": { - "dnscontrol_tag": "", "dnscontrol_uniquename": "inny" }, "name": "inny", @@ -54,7 +52,6 @@ { "dnsProviders": {}, "meta": { - "dnscontrol_tag": "", "dnscontrol_uniquename": "com.inny" }, "name": "com.inny", diff --git a/pkg/js/parse_tests/008-import.json b/pkg/js/parse_tests/008-import.json index 8049dfb63..ce6010f13 100644 --- a/pkg/js/parse_tests/008-import.json +++ b/pkg/js/parse_tests/008-import.json @@ -4,7 +4,6 @@ { "dnsProviders": {}, "meta": { - "dnscontrol_tag": "", "dnscontrol_uniquename": "foo.com" }, "name": "foo.com", diff --git a/pkg/js/parse_tests/009-reverse.json b/pkg/js/parse_tests/009-reverse.json index 96ec46cf5..911740edd 100644 --- a/pkg/js/parse_tests/009-reverse.json +++ b/pkg/js/parse_tests/009-reverse.json @@ -4,7 +4,6 @@ { "dnsProviders": {}, "meta": { - "dnscontrol_tag": "", "dnscontrol_uniquename": "2.1.in-addr.arpa" }, "name": "2.1.in-addr.arpa", diff --git a/pkg/js/parse_tests/010-alias.json b/pkg/js/parse_tests/010-alias.json index 73a364114..cde8d47e1 100644 --- a/pkg/js/parse_tests/010-alias.json +++ b/pkg/js/parse_tests/010-alias.json @@ -4,7 +4,6 @@ { "dnsProviders": {}, "meta": { - "dnscontrol_tag": "", "dnscontrol_uniquename": "foo.com" }, "name": "foo.com", diff --git a/pkg/js/parse_tests/011-cfRedirect.json b/pkg/js/parse_tests/011-cfRedirect.json index 5180a8aa5..be71ee946 100644 --- a/pkg/js/parse_tests/011-cfRedirect.json +++ b/pkg/js/parse_tests/011-cfRedirect.json @@ -4,7 +4,6 @@ { "dnsProviders": {}, "meta": { - "dnscontrol_tag": "", "dnscontrol_uniquename": "foo.com" }, "name": "foo.com", diff --git a/pkg/js/parse_tests/012-duration.json b/pkg/js/parse_tests/012-duration.json index a1d6f7cd4..6a1637d6b 100644 --- a/pkg/js/parse_tests/012-duration.json +++ b/pkg/js/parse_tests/012-duration.json @@ -4,7 +4,6 @@ { "dnsProviders": {}, "meta": { - "dnscontrol_tag": "", "dnscontrol_uniquename": "foo.com" }, "name": "foo.com", diff --git a/pkg/js/parse_tests/013-mx.json b/pkg/js/parse_tests/013-mx.json index 70cb7c8c0..15b25a8d5 100644 --- a/pkg/js/parse_tests/013-mx.json +++ b/pkg/js/parse_tests/013-mx.json @@ -4,7 +4,6 @@ { "dnsProviders": {}, "meta": { - "dnscontrol_tag": "", "dnscontrol_uniquename": "foo.com" }, "name": "foo.com", diff --git a/pkg/js/parse_tests/014-caa.json b/pkg/js/parse_tests/014-caa.json index 14669cee7..c6c6fe922 100644 --- a/pkg/js/parse_tests/014-caa.json +++ b/pkg/js/parse_tests/014-caa.json @@ -4,7 +4,6 @@ { "dnsProviders": {}, "meta": { - "dnscontrol_tag": "", "dnscontrol_uniquename": "foo.com" }, "name": "foo.com", diff --git a/pkg/js/parse_tests/015-tlsa.json b/pkg/js/parse_tests/015-tlsa.json index 14e4d71e4..7abbd1a40 100644 --- a/pkg/js/parse_tests/015-tlsa.json +++ b/pkg/js/parse_tests/015-tlsa.json @@ -4,7 +4,6 @@ { "dnsProviders": {}, "meta": { - "dnscontrol_tag": "", "dnscontrol_uniquename": "foo.com" }, "name": "foo.com", diff --git a/pkg/js/parse_tests/017-txt.json b/pkg/js/parse_tests/017-txt.json index 670b412c0..1a11556b2 100644 --- a/pkg/js/parse_tests/017-txt.json +++ b/pkg/js/parse_tests/017-txt.json @@ -4,7 +4,6 @@ { "dnsProviders": {}, "meta": { - "dnscontrol_tag": "", "dnscontrol_uniquename": "foo.com" }, "name": "foo.com", diff --git a/pkg/js/parse_tests/018-dkim.json b/pkg/js/parse_tests/018-dkim.json index 683ce7555..cfad47f93 100644 --- a/pkg/js/parse_tests/018-dkim.json +++ b/pkg/js/parse_tests/018-dkim.json @@ -4,7 +4,6 @@ { "dnsProviders": {}, "meta": { - "dnscontrol_tag": "", "dnscontrol_uniquename": "foo.com" }, "name": "foo.com", diff --git a/pkg/js/parse_tests/019-r53-alias.json b/pkg/js/parse_tests/019-r53-alias.json index 71dc75b8a..f500adc26 100644 --- a/pkg/js/parse_tests/019-r53-alias.json +++ b/pkg/js/parse_tests/019-r53-alias.json @@ -4,7 +4,6 @@ { "dnsProviders": {}, "meta": { - "dnscontrol_tag": "", "dnscontrol_uniquename": "foo.com" }, "name": "foo.com", diff --git a/pkg/js/parse_tests/020-complexRequire.json b/pkg/js/parse_tests/020-complexRequire.json index 42bdd3116..41d7226c3 100644 --- a/pkg/js/parse_tests/020-complexRequire.json +++ b/pkg/js/parse_tests/020-complexRequire.json @@ -4,7 +4,6 @@ { "dnsProviders": {}, "meta": { - "dnscontrol_tag": "", "dnscontrol_uniquename": "sortfoo.com" }, "name": "sortfoo.com", diff --git a/pkg/js/parse_tests/021-srv.json b/pkg/js/parse_tests/021-srv.json index a5ed0be7e..70940fe89 100644 --- a/pkg/js/parse_tests/021-srv.json +++ b/pkg/js/parse_tests/021-srv.json @@ -4,7 +4,6 @@ { "dnsProviders": {}, "meta": { - "dnscontrol_tag": "", "dnscontrol_uniquename": "foo.com" }, "name": "foo.com", diff --git a/pkg/js/parse_tests/022-sshfp.json b/pkg/js/parse_tests/022-sshfp.json index 682efcef5..a1b74c43c 100644 --- a/pkg/js/parse_tests/022-sshfp.json +++ b/pkg/js/parse_tests/022-sshfp.json @@ -4,7 +4,6 @@ { "dnsProviders": {}, "meta": { - "dnscontrol_tag": "", "dnscontrol_uniquename": "foo.com" }, "name": "foo.com", diff --git a/pkg/js/parse_tests/023-ignored-glob-records.json b/pkg/js/parse_tests/023-ignored-glob-records.json index fdacff727..b7774bb8b 100644 --- a/pkg/js/parse_tests/023-ignored-glob-records.json +++ b/pkg/js/parse_tests/023-ignored-glob-records.json @@ -4,7 +4,6 @@ { "dnsProviders": {}, "meta": { - "dnscontrol_tag": "", "dnscontrol_uniquename": "foo.com" }, "name": "foo.com", diff --git a/pkg/js/parse_tests/024-json-import.json b/pkg/js/parse_tests/024-json-import.json index 0cf167eb4..f08ec91e2 100644 --- a/pkg/js/parse_tests/024-json-import.json +++ b/pkg/js/parse_tests/024-json-import.json @@ -4,7 +4,6 @@ { "dnsProviders": {}, "meta": { - "dnscontrol_tag": "", "dnscontrol_uniquename": "foo.com" }, "name": "foo.com", diff --git a/pkg/js/parse_tests/025-autodnssec.json b/pkg/js/parse_tests/025-autodnssec.json index 587dfe1d7..84a07d4d0 100644 --- a/pkg/js/parse_tests/025-autodnssec.json +++ b/pkg/js/parse_tests/025-autodnssec.json @@ -4,7 +4,6 @@ { "dnsProviders": {}, "meta": { - "dnscontrol_tag": "", "dnscontrol_uniquename": "nothing.com" }, "name": "nothing.com", @@ -15,7 +14,6 @@ "auto_dnssec": "on", "dnsProviders": {}, "meta": { - "dnscontrol_tag": "", "dnscontrol_uniquename": "with.com" }, "name": "with.com", @@ -26,7 +24,6 @@ "auto_dnssec": "off", "dnsProviders": {}, "meta": { - "dnscontrol_tag": "", "dnscontrol_uniquename": "without.com" }, "name": "without.com", diff --git a/pkg/js/parse_tests/026-azure-alias.json b/pkg/js/parse_tests/026-azure-alias.json index ca6716335..1fbf6c8fe 100644 --- a/pkg/js/parse_tests/026-azure-alias.json +++ b/pkg/js/parse_tests/026-azure-alias.json @@ -4,7 +4,6 @@ { "dnsProviders": {}, "meta": { - "dnscontrol_tag": "", "dnscontrol_uniquename": "foo.com" }, "name": "foo.com", diff --git a/pkg/js/parse_tests/027-ds.json b/pkg/js/parse_tests/027-ds.json index bb13a682a..d2eb195ed 100644 --- a/pkg/js/parse_tests/027-ds.json +++ b/pkg/js/parse_tests/027-ds.json @@ -4,7 +4,6 @@ { "dnsProviders": {}, "meta": { - "dnscontrol_tag": "", "dnscontrol_uniquename": "foo.com" }, "name": "foo.com", diff --git a/pkg/js/parse_tests/028-dextend.json b/pkg/js/parse_tests/028-dextend.json index 2f92ae97c..7790dbb69 100644 --- a/pkg/js/parse_tests/028-dextend.json +++ b/pkg/js/parse_tests/028-dextend.json @@ -11,7 +11,6 @@ "Cloudflare": -1 }, "meta": { - "dnscontrol_tag": "", "dnscontrol_uniquename": "foo.com" }, "name": "foo.com", @@ -38,7 +37,6 @@ "Cloudflare": -1 }, "meta": { - "dnscontrol_tag": "", "dnscontrol_uniquename": "bar.foo.com" }, "name": "bar.foo.com", @@ -65,7 +63,6 @@ "Cloudflare": -1 }, "meta": { - "dnscontrol_tag": "", "dnscontrol_uniquename": "foo.edu" }, "name": "foo.edu", diff --git a/pkg/js/parse_tests/029-dextendsub.json b/pkg/js/parse_tests/029-dextendsub.json index c241fab13..8c12ca68f 100644 --- a/pkg/js/parse_tests/029-dextendsub.json +++ b/pkg/js/parse_tests/029-dextendsub.json @@ -11,7 +11,6 @@ "Cloudflare": -1 }, "meta": { - "dnscontrol_tag": "", "dnscontrol_uniquename": "foo.net" }, "name": "foo.net", @@ -70,7 +69,6 @@ "Cloudflare": -1 }, "meta": { - "dnscontrol_tag": "", "dnscontrol_uniquename": "foo.tld" }, "name": "foo.tld", @@ -104,7 +102,6 @@ "Cloudflare": -1 }, "meta": { - "dnscontrol_tag": "", "dnscontrol_uniquename": "bar.foo.tld" }, "name": "bar.foo.tld", @@ -138,7 +135,6 @@ "Cloudflare": -1 }, "meta": { - "dnscontrol_tag": "", "dnscontrol_uniquename": "foo.help" }, "name": "foo.help", @@ -181,7 +177,6 @@ "Cloudflare": -1 }, "meta": { - "dnscontrol_tag": "", "dnscontrol_uniquename": "bar.foo.help" }, "name": "bar.foo.help", @@ -224,7 +219,6 @@ "Cloudflare": -1 }, "meta": { - "dnscontrol_tag": "", "dnscontrol_uniquename": "foo.here" }, "name": "foo.here", @@ -283,7 +277,6 @@ "Cloudflare": -1 }, "meta": { - "dnscontrol_tag": "", "dnscontrol_uniquename": "example.com" }, "name": "example.com", @@ -342,10 +335,9 @@ "Cloudflare": -1 }, "meta": { - "dnscontrol_tag": "", - "dnscontrol_uniquename": "d\u00fcsseldorf.example.net" + "dnscontrol_uniquename": "xn--dsseldorf-q9a.example.net" }, - "name": "d\u00fcsseldorf.example.net", + "name": "xn--dsseldorf-q9a.example.net", "records": [ { "filepos": "[line:94:5]", @@ -417,10 +409,9 @@ "Cloudflare": -1 }, "meta": { - "dnscontrol_tag": "", - "dnscontrol_uniquename": "\u00fc.example.net" + "dnscontrol_uniquename": "xn--tda.example.net" }, - "name": "\u00fc.example.net", + "name": "xn--tda.example.net", "records": [ { "filepos": "[line:116:5]", @@ -492,7 +483,6 @@ "Cloudflare": -1 }, "meta": { - "dnscontrol_tag": "", "dnscontrol_uniquename": "example.tld" }, "name": "example.tld", diff --git a/pkg/js/parse_tests/029-dextendsub/düsseldorf.example.net.zone b/pkg/js/parse_tests/029-dextendsub/xn--dsseldorf-q9a.example.net.zone similarity index 100% rename from pkg/js/parse_tests/029-dextendsub/düsseldorf.example.net.zone rename to pkg/js/parse_tests/029-dextendsub/xn--dsseldorf-q9a.example.net.zone diff --git a/pkg/js/parse_tests/029-dextendsub/ü.example.net.zone b/pkg/js/parse_tests/029-dextendsub/xn--tda.example.net.zone similarity index 100% rename from pkg/js/parse_tests/029-dextendsub/ü.example.net.zone rename to pkg/js/parse_tests/029-dextendsub/xn--tda.example.net.zone diff --git a/pkg/js/parse_tests/030-dextenddoc.json b/pkg/js/parse_tests/030-dextenddoc.json index bc7dfa01d..a20ba9f35 100644 --- a/pkg/js/parse_tests/030-dextenddoc.json +++ b/pkg/js/parse_tests/030-dextenddoc.json @@ -11,7 +11,6 @@ "Cloudflare": -1 }, "meta": { - "dnscontrol_tag": "", "dnscontrol_uniquename": "domain.tld" }, "name": "domain.tld", diff --git a/pkg/js/parse_tests/031-dextendnames.json b/pkg/js/parse_tests/031-dextendnames.json index a5c74aa5c..8a276646e 100644 --- a/pkg/js/parse_tests/031-dextendnames.json +++ b/pkg/js/parse_tests/031-dextendnames.json @@ -11,7 +11,6 @@ "Cloudflare": -1 }, "meta": { - "dnscontrol_tag": "", "dnscontrol_uniquename": "domain.tld" }, "name": "domain.tld", @@ -114,7 +113,6 @@ "Cloudflare": -1 }, "meta": { - "dnscontrol_tag": "", "dnscontrol_uniquename": "sub.domain.tld" }, "name": "sub.domain.tld", diff --git a/pkg/js/parse_tests/032-reverseip.json b/pkg/js/parse_tests/032-reverseip.json index 01a12e395..1bf7c471c 100644 --- a/pkg/js/parse_tests/032-reverseip.json +++ b/pkg/js/parse_tests/032-reverseip.json @@ -11,7 +11,6 @@ "bind": -1 }, "meta": { - "dnscontrol_tag": "", "dnscontrol_uniquename": "3.2.1.in-addr.arpa" }, "name": "3.2.1.in-addr.arpa", diff --git a/pkg/js/parse_tests/033-revextend.json b/pkg/js/parse_tests/033-revextend.json index daf2e6154..39d68a929 100644 --- a/pkg/js/parse_tests/033-revextend.json +++ b/pkg/js/parse_tests/033-revextend.json @@ -11,7 +11,6 @@ "bind": -1 }, "meta": { - "dnscontrol_tag": "", "dnscontrol_uniquename": "8.9.in-addr.arpa" }, "name": "8.9.in-addr.arpa", @@ -39,7 +38,6 @@ "bind": -1 }, "meta": { - "dnscontrol_tag": "", "dnscontrol_uniquename": "example.com" }, "name": "example.com", diff --git a/pkg/js/parse_tests/034-nameserver-ttl.json b/pkg/js/parse_tests/034-nameserver-ttl.json index 52b412f1e..140d21f5c 100644 --- a/pkg/js/parse_tests/034-nameserver-ttl.json +++ b/pkg/js/parse_tests/034-nameserver-ttl.json @@ -4,7 +4,6 @@ { "dnsProviders": {}, "meta": { - "dnscontrol_tag": "", "dnscontrol_uniquename": "foo.com", "ns_ttl": "86400" }, @@ -15,7 +14,6 @@ { "dnsProviders": {}, "meta": { - "dnscontrol_tag": "", "dnscontrol_uniquename": "bar.com", "ns_ttl": "300" }, diff --git a/pkg/js/parse_tests/035-naptr.json b/pkg/js/parse_tests/035-naptr.json index 16b745555..58561e529 100644 --- a/pkg/js/parse_tests/035-naptr.json +++ b/pkg/js/parse_tests/035-naptr.json @@ -4,7 +4,6 @@ { "dnsProviders": {}, "meta": { - "dnscontrol_tag": "", "dnscontrol_uniquename": "foo.com" }, "name": "foo.com", diff --git a/pkg/js/parse_tests/036-dextendcf.json b/pkg/js/parse_tests/036-dextendcf.json index 7ca254a62..dbf452bdd 100644 --- a/pkg/js/parse_tests/036-dextendcf.json +++ b/pkg/js/parse_tests/036-dextendcf.json @@ -11,7 +11,6 @@ "Cloudflare": -1 }, "meta": { - "dnscontrol_tag": "", "dnscontrol_uniquename": "foo.com" }, "name": "foo.com", diff --git a/pkg/js/parse_tests/037-splithor.json b/pkg/js/parse_tests/037-splithor.json index bf2ac2a93..fabc65da5 100644 --- a/pkg/js/parse_tests/037-splithor.json +++ b/pkg/js/parse_tests/037-splithor.json @@ -19,7 +19,6 @@ "otherconfig": -1 }, "meta": { - "dnscontrol_tag": "", "dnscontrol_uniquename": "example.com" }, "name": "example.com", @@ -66,7 +65,8 @@ "type": "A" } ], - "registrar": "Third-Party" + "registrar": "Third-Party", + "tag": "inside" }, { "dnsProviders": { @@ -86,14 +86,14 @@ "type": "A" } ], - "registrar": "Third-Party" + "registrar": "Third-Party", + "tag": "outside" }, { "dnsProviders": { "bind": -1 }, "meta": { - "dnscontrol_tag": "", "dnscontrol_uniquename": "example.net" }, "name": "example.net", @@ -147,7 +147,8 @@ "type": "A" } ], - "registrar": "Third-Party" + "registrar": "Third-Party", + "tag": "inside" }, { "dnsProviders": { @@ -181,14 +182,14 @@ "type": "A" } ], - "registrar": "Third-Party" + "registrar": "Third-Party", + "tag": "outside" }, { "dnsProviders": { "bind": -1 }, "meta": { - "dnscontrol_tag": "", "dnscontrol_uniquename": "empty.example.net" }, "name": "empty.example.net", @@ -215,8 +216,7 @@ "bind": -1 }, "meta": { - "dnscontrol_tag": "", - "dnscontrol_uniquename": "example-b.net" + "dnscontrol_uniquename": "example-b.net!" }, "name": "example-b.net", "records": [ diff --git a/pkg/js/parse_tests/038-soa.json b/pkg/js/parse_tests/038-soa.json index 43aed9b2e..6c63bee83 100644 --- a/pkg/js/parse_tests/038-soa.json +++ b/pkg/js/parse_tests/038-soa.json @@ -4,7 +4,6 @@ { "dnsProviders": {}, "meta": { - "dnscontrol_tag": "", "dnscontrol_uniquename": "foo.com" }, "name": "foo.com", diff --git a/pkg/js/parse_tests/039-include.json b/pkg/js/parse_tests/039-include.json index 526cd1338..43e499d12 100644 --- a/pkg/js/parse_tests/039-include.json +++ b/pkg/js/parse_tests/039-include.json @@ -24,7 +24,8 @@ "type": "A" } ], - "registrar": "Third-Party" + "registrar": "Third-Party", + "tag": "external" }, { "dnsProviders": { @@ -51,7 +52,8 @@ "type": "A" } ], - "registrar": "Third-Party" + "registrar": "Third-Party", + "tag": "internal" } ], "registrars": [ diff --git a/pkg/js/parse_tests/040-cfWorkerRoute.json b/pkg/js/parse_tests/040-cfWorkerRoute.json index 479624ab7..9673c9bb7 100644 --- a/pkg/js/parse_tests/040-cfWorkerRoute.json +++ b/pkg/js/parse_tests/040-cfWorkerRoute.json @@ -4,7 +4,6 @@ { "dnsProviders": {}, "meta": { - "dnscontrol_tag": "", "dnscontrol_uniquename": "foo.com" }, "name": "foo.com", diff --git a/pkg/js/parse_tests/040-r53-zone.json b/pkg/js/parse_tests/040-r53-zone.json index 1e3d68b35..ee7dd322b 100644 --- a/pkg/js/parse_tests/040-r53-zone.json +++ b/pkg/js/parse_tests/040-r53-zone.json @@ -4,7 +4,6 @@ { "dnsProviders": {}, "meta": { - "dnscontrol_tag": "", "dnscontrol_uniquename": "foo.com", "zone_id": "Z2FTEDLFRTZ" }, @@ -37,7 +36,8 @@ "type": "R53_ALIAS" } ], - "registrar": "none" + "registrar": "none", + "tag": "internal" } ], "registrars": [] diff --git a/pkg/js/parse_tests/043-safety.json b/pkg/js/parse_tests/043-safety.json index a6ee5a4f5..82e9d313e 100644 --- a/pkg/js/parse_tests/043-safety.json +++ b/pkg/js/parse_tests/043-safety.json @@ -4,7 +4,6 @@ { "dnsProviders": {}, "meta": { - "dnscontrol_tag": "", "dnscontrol_uniquename": "unsafe.com" }, "name": "unsafe.com", @@ -15,7 +14,6 @@ { "dnsProviders": {}, "meta": { - "dnscontrol_tag": "", "dnscontrol_uniquename": "safe.com" }, "name": "safe.com", diff --git a/pkg/js/parse_tests/044-ensureabsent.json b/pkg/js/parse_tests/044-ensureabsent.json index f0d3745c4..106da8a17 100644 --- a/pkg/js/parse_tests/044-ensureabsent.json +++ b/pkg/js/parse_tests/044-ensureabsent.json @@ -4,7 +4,6 @@ { "dnsProviders": {}, "meta": { - "dnscontrol_tag": "", "dnscontrol_uniquename": "example.com" }, "name": "example.com", diff --git a/pkg/js/parse_tests/045-loc.json b/pkg/js/parse_tests/045-loc.json index 45be3fcb1..4830e3edb 100644 --- a/pkg/js/parse_tests/045-loc.json +++ b/pkg/js/parse_tests/045-loc.json @@ -4,7 +4,6 @@ { "dnsProviders": {}, "meta": { - "dnscontrol_tag": "", "dnscontrol_uniquename": "foo.com" }, "name": "foo.com", diff --git a/pkg/js/parse_tests/046-DHCID.json b/pkg/js/parse_tests/046-DHCID.json index 3c62541a1..919805504 100644 --- a/pkg/js/parse_tests/046-DHCID.json +++ b/pkg/js/parse_tests/046-DHCID.json @@ -4,7 +4,6 @@ { "dnsProviders": {}, "meta": { - "dnscontrol_tag": "", "dnscontrol_uniquename": "foo.com" }, "name": "foo.com", diff --git a/pkg/js/parse_tests/047-DNAME.json b/pkg/js/parse_tests/047-DNAME.json index d365ec88a..efb252d5f 100644 --- a/pkg/js/parse_tests/047-DNAME.json +++ b/pkg/js/parse_tests/047-DNAME.json @@ -4,7 +4,6 @@ { "dnsProviders": {}, "meta": { - "dnscontrol_tag": "", "dnscontrol_uniquename": "foo.com" }, "name": "foo.com", diff --git a/pkg/js/parse_tests/047-SVCB.json b/pkg/js/parse_tests/047-SVCB.json index 8bfe47a6e..92dd4a5d5 100644 --- a/pkg/js/parse_tests/047-SVCB.json +++ b/pkg/js/parse_tests/047-SVCB.json @@ -4,7 +4,6 @@ { "dnsProviders": {}, "meta": { - "dnscontrol_tag": "", "dnscontrol_uniquename": "foo.com" }, "name": "foo.com", diff --git a/pkg/js/parse_tests/048-DNSKEY.json b/pkg/js/parse_tests/048-DNSKEY.json index df7863e4f..a16d0518e 100644 --- a/pkg/js/parse_tests/048-DNSKEY.json +++ b/pkg/js/parse_tests/048-DNSKEY.json @@ -4,7 +4,6 @@ { "dnsProviders": {}, "meta": { - "dnscontrol_tag": "", "dnscontrol_uniquename": "foo.com" }, "name": "foo.com", diff --git a/pkg/js/parse_tests/049-json5-require.json b/pkg/js/parse_tests/049-json5-require.json index 0cf167eb4..f08ec91e2 100644 --- a/pkg/js/parse_tests/049-json5-require.json +++ b/pkg/js/parse_tests/049-json5-require.json @@ -4,7 +4,6 @@ { "dnsProviders": {}, "meta": { - "dnscontrol_tag": "", "dnscontrol_uniquename": "foo.com" }, "name": "foo.com", diff --git a/pkg/js/parse_tests/050-cfSingleRedirect.json b/pkg/js/parse_tests/050-cfSingleRedirect.json index 7f37f3257..380b1022e 100644 --- a/pkg/js/parse_tests/050-cfSingleRedirect.json +++ b/pkg/js/parse_tests/050-cfSingleRedirect.json @@ -4,7 +4,6 @@ { "dnsProviders": {}, "meta": { - "dnscontrol_tag": "", "dnscontrol_uniquename": "foo.com" }, "name": "foo.com", diff --git a/pkg/js/parse_tests/051-HASH.json b/pkg/js/parse_tests/051-HASH.json index b58f325ce..befbd9cb4 100644 --- a/pkg/js/parse_tests/051-HASH.json +++ b/pkg/js/parse_tests/051-HASH.json @@ -4,7 +4,6 @@ { "dnsProviders": {}, "meta": { - "dnscontrol_tag": "", "dnscontrol_uniquename": "a9993e364706816aba3e25717850c26c9cd0d89d" }, "name": "a9993e364706816aba3e25717850c26c9cd0d89d", diff --git a/pkg/js/parse_tests/054-b3487_d_extend_rev.json b/pkg/js/parse_tests/054-b3487_d_extend_rev.json index 33f0eb368..166102876 100644 --- a/pkg/js/parse_tests/054-b3487_d_extend_rev.json +++ b/pkg/js/parse_tests/054-b3487_d_extend_rev.json @@ -11,7 +11,6 @@ "bind": -1 }, "meta": { - "dnscontrol_tag": "", "dnscontrol_uniquename": "6.10.in-addr.arpa" }, "name": "6.10.in-addr.arpa", diff --git a/pkg/js/parse_tests/055-b3550-ipv6ptr.json b/pkg/js/parse_tests/055-b3550-ipv6ptr.json index 9c1fd4326..85cd501f5 100644 --- a/pkg/js/parse_tests/055-b3550-ipv6ptr.json +++ b/pkg/js/parse_tests/055-b3550-ipv6ptr.json @@ -11,7 +11,6 @@ "bind": -1 }, "meta": { - "dnscontrol_tag": "", "dnscontrol_uniquename": "d.c.b.a.1.1.0.2.ip6.arpa" }, "name": "d.c.b.a.1.1.0.2.ip6.arpa", @@ -38,7 +37,6 @@ "bind": -1 }, "meta": { - "dnscontrol_tag": "", "dnscontrol_uniquename": "8.b.d.0.1.0.0.2.ip6.arpa" }, "name": "8.b.d.0.1.0.0.2.ip6.arpa", diff --git a/pkg/js/parse_tests/056-openpgpkey.json b/pkg/js/parse_tests/056-openpgpkey.json index 7678ddb6c..80f302656 100644 --- a/pkg/js/parse_tests/056-openpgpkey.json +++ b/pkg/js/parse_tests/056-openpgpkey.json @@ -4,7 +4,6 @@ { "dnsProviders": {}, "meta": { - "dnscontrol_tag": "", "dnscontrol_uniquename": "foo.com" }, "name": "foo.com", diff --git a/pkg/js/parse_tests/057-smimea.json b/pkg/js/parse_tests/057-smimea.json index e57c5d345..bec0486da 100644 --- a/pkg/js/parse_tests/057-smimea.json +++ b/pkg/js/parse_tests/057-smimea.json @@ -4,7 +4,6 @@ { "dnsProviders": {}, "meta": { - "dnscontrol_tag": "", "dnscontrol_uniquename": "foo.com" }, "name": "foo.com", diff --git a/pkg/normalize/importTransform_test.go b/pkg/normalize/importTransform_test.go index 4e2c3bb8b..ffd41ee2a 100644 --- a/pkg/normalize/importTransform_test.go +++ b/pkg/normalize/importTransform_test.go @@ -32,6 +32,10 @@ func TestImportTransform(t *testing.T) { cfg := &models.DNSConfig{ Domains: []*models.DomainConfig{src, dst}, } + err := cfg.PostProcess() + if err != nil { + t.Fatal(err) + } if errs := ValidateAndNormalizeConfig(cfg); len(errs) != 0 { for _, err := range errs { t.Error(err) diff --git a/pkg/normalize/validate.go b/pkg/normalize/validate.go index a787f4dfa..5c19e00b8 100644 --- a/pkg/normalize/validate.go +++ b/pkg/normalize/validate.go @@ -584,10 +584,6 @@ func ValidateAndNormalizeConfig(config *models.DNSConfig) (errs []error) { // processSplitHorizonDomains finds "domain.tld!tag" domains and pre-processes them. func processSplitHorizonDomains(config *models.DNSConfig) error { - // Parse out names and tags. - for _, d := range config.Domains { - d.UpdateSplitHorizonNames() - } // Verify uniquenames are unique seen := map[string]bool{} diff --git a/pkg/printer/printer.go b/pkg/printer/printer.go index 9826b4eed..9f7063d96 100644 --- a/pkg/printer/printer.go +++ b/pkg/printer/printer.go @@ -13,7 +13,7 @@ import ( // CLI is an abstraction around the CLI. type CLI interface { Printer - StartDomain(domain string) + StartDomain(dc *models.DomainConfig) StartDNSProvider(name string, skip bool) EndProvider(name string, numCorrections int, err error) EndProvider2(name string, numCorrections int) @@ -89,8 +89,12 @@ type ConsolePrinter struct { } // StartDomain is called at the start of each domain. -func (c ConsolePrinter) StartDomain(domain string) { - fmt.Fprintf(c.Writer, "******************** Domain: %s\n", domain) +func (c ConsolePrinter) StartDomain(dc *models.DomainConfig) { + if dc.Name == dc.NameUnicode { + fmt.Fprintf(c.Writer, "******************** Domain: %s\n", dc.Name) + } else { + fmt.Fprintf(c.Writer, "******************** Domain: %s (%s)\n", dc.NameUnicode, dc.Name) + } } // PrintCorrection is called to print/format each correction. diff --git a/providers/bind/bindProvider.go b/providers/bind/bindProvider.go index 432f679ed..525e8c74f 100644 --- a/providers/bind/bindProvider.go +++ b/providers/bind/bindProvider.go @@ -24,6 +24,7 @@ import ( "github.com/StackExchange/dnscontrol/v4/models" "github.com/StackExchange/dnscontrol/v4/pkg/bindserial" "github.com/StackExchange/dnscontrol/v4/pkg/diff2" + "github.com/StackExchange/dnscontrol/v4/pkg/domaintags" "github.com/StackExchange/dnscontrol/v4/pkg/prettyzone" "github.com/StackExchange/dnscontrol/v4/pkg/printer" "github.com/StackExchange/dnscontrol/v4/providers" @@ -167,20 +168,23 @@ func (c *bindProvider) GetZoneRecords(domain string, meta map[string]string) (mo if _, err := os.Stat(c.directory); os.IsNotExist(err) { printer.Printf("\nWARNING: BIND directory %q does not exist! (will create)\n", c.directory) } - _, okTag := meta[models.DomainTag] - _, okUnique := meta[models.DomainUniqueName] - if !okTag && !okUnique { - // This layering violation is needed for tests only. - // Otherwise, this is set already. - // Note: In this situation there is no "uniquename" or "tag". - zonefile = filepath.Join(c.directory, - makeFileName(c.filenameformat, domain, domain, "")) - } else { - zonefile = filepath.Join(c.directory, - makeFileName(c.filenameformat, - meta[models.DomainUniqueName], domain, meta[models.DomainTag]), - ) + ff := domaintags.DomainFixedForms{ + Tag: meta[models.DomainTag], + NameRaw: meta[models.DomainNameRaw], + NameIDN: domain, + NameUnicode: meta[models.DomainNameUnicode], + UniqueName: meta[models.DomainUniqueName], } + zonefile = filepath.Join(c.directory, + makeFileName( + c.filenameformat, + ff, + ), + ) + //fmt.Printf("DEBUG: Reading zonefile %q\n", zonefile) + //fmt.Printf("DEBUG: Meta %+v\n", meta) + //fmt.Printf("DEBUG: Domain Names %+v\n", ff) + content, err := os.ReadFile(zonefile) if os.IsNotExist(err) { // If the file doesn't exist, that's not an error. Just informational. @@ -273,8 +277,16 @@ func (c *bindProvider) GetZoneRecordsCorrections(dc *models.DomainConfig, foundR } zonefile = filepath.Join(c.directory, - makeFileName(c.filenameformat, - dc.Metadata[models.DomainUniqueName], dc.Name, dc.Metadata[models.DomainTag]), + makeFileName( + c.filenameformat, + domaintags.DomainFixedForms{ + Tag: dc.Tag, + NameRaw: dc.NameRaw, + NameIDN: dc.Name, + NameUnicode: dc.NameUnicode, + UniqueName: dc.UniqueName, + }, + ), ) // We only change the serial number if there is a change. diff --git a/providers/bind/fnames.go b/providers/bind/fnames.go index 51ab58c89..db7b22514 100644 --- a/providers/bind/fnames.go +++ b/providers/bind/fnames.go @@ -3,18 +3,23 @@ package bind import ( "bytes" "fmt" - "os" "path/filepath" "regexp" "strings" + + "github.com/StackExchange/dnscontrol/v4/pkg/domaintags" ) // makeFileName uses format to generate a zone's filename. See the -func makeFileName(format, uniquename, domain, tag string) string { - // fmt.Printf("DEBUG: makeFileName(%q, %q, %q, %q)\n", format, uniquename, domain, tag) +func makeFileName(format string, ff domaintags.DomainFixedForms) string { + //fmt.Printf("DEBUG: makeFileName(%q, %+v)\n", format, ff) + nameRaw := ff.NameRaw + nameIDN := ff.NameIDN + nameUnicode := ff.NameUnicode + uniquename := ff.UniqueName + tag := ff.Tag if format == "" { - fmt.Fprintf(os.Stderr, "BUG: makeFileName called with null format\n") - return uniquename + panic("BUG: makeFileName called with null format") } var b bytes.Buffer @@ -36,11 +41,17 @@ func makeFileName(format, uniquename, domain, tag string) string { tok = tokens[pos] switch tok { case "D": - b.WriteString(domain) + b.WriteString(nameRaw) case "T": b.WriteString(tag) case "U": b.WriteString(uniquename) + case "I": + b.WriteString(nameIDN) + case "N": + b.WriteString(nameUnicode) + case "%": + b.WriteString("%") case "?": if pos == lastpos { b.WriteString("%(format may not end in %?)") diff --git a/providers/bind/fnames_test.go b/providers/bind/fnames_test.go index 34306cc28..66a0bcb8a 100644 --- a/providers/bind/fnames_test.go +++ b/providers/bind/fnames_test.go @@ -3,12 +3,25 @@ package bind import ( "reflect" "testing" + + "github.com/StackExchange/dnscontrol/v4/pkg/domaintags" ) func Test_makeFileName(t *testing.T) { - uu := "uni" - dd := "domy" - tt := "tagy" + ff := domaintags.DomainFixedForms{ + NameRaw: "raw", + NameIDN: "idn", + NameUnicode: "unicode", + UniqueName: "unique!taga", + Tag: "tagb", + } + tagless := domaintags.DomainFixedForms{ + NameRaw: "raw", + NameIDN: "idn", + NameUnicode: "unicode", + UniqueName: "unique", + Tag: "", + } fmtDefault := "%U.zone" fmtBasic := "%U - %T - %D" fmtBk1 := "db_%U" // Something I've seen in books on DNS @@ -19,35 +32,42 @@ func Test_makeFileName(t *testing.T) { fmtErrorUnk := "literal%o" // Unknown % verb type args struct { - format string - uniquename string - domain string - tag string + format string + ff domaintags.DomainFixedForms } tests := []struct { name string args args want string }{ - {"literal", args{"literal", uu, dd, tt}, "literal"}, - {"basic", args{fmtBasic, uu, dd, tt}, "uni - tagy - domy"}, - {"solo", args{"%D", uu, dd, tt}, "domy"}, - {"front", args{"%Daaa", uu, dd, tt}, "domyaaa"}, - {"tail", args{"bbb%D", uu, dd, tt}, "bbbdomy"}, - {"def", args{fmtDefault, uu, dd, tt}, "uni.zone"}, - {"bk1", args{fmtBk1, uu, dd, tt}, "db_uni"}, - {"bk2", args{fmtBk2, uu, dd, tt}, "db_tagy_domy"}, - {"fanWI", args{fmtFancy, uu, dd, tt}, "tagy_domy.zone"}, - {"fanWO", args{fmtFancy, uu, dd, ""}, "domy.zone"}, - {"errP", args{fmtErrorPct, uu, dd, tt}, "literal%(format may not end in %)"}, - {"errQ", args{fmtErrorOpt, uu, dd, tt}, "literal%(format may not end in %?)"}, - {"errU", args{fmtErrorUnk, uu, dd, tt}, "literal%(unknown %verb %o)"}, + {"literal", args{"literal", ff}, "literal"}, + {"middle", args{"mid%Dle", ff}, "midrawle"}, + {"D", args{"%D", ff}, "raw"}, + {"I", args{"%I", ff}, "idn"}, + {"N", args{"%N", ff}, "unicode"}, + {"T", args{"%T", ff}, "tagb"}, + {"x1", args{"XX%?xYY", ff}, "XXxYY"}, + {"x2", args{"AA%?xBB", tagless}, "AABB"}, + {"U", args{"%U", ff}, "unique!taga"}, + {"percent", args{"%%", ff}, "%"}, + // + {"default", args{fmtDefault, ff}, "unique!taga.zone"}, + {"basic", args{fmtBasic, ff}, "unique!taga - tagb - raw"}, + {"front", args{"%Daaa", ff}, "rawaaa"}, + {"tail", args{"bbb%D", ff}, "bbbraw"}, + {"bk1", args{fmtBk1, ff}, "db_unique!taga"}, + {"bk2", args{fmtBk2, ff}, "db_tagb_raw"}, + {"fanWI", args{fmtFancy, ff}, "tagb_raw.zone"}, + {"fanWO", args{fmtFancy, tagless}, "raw.zone"}, + {"errP", args{fmtErrorPct, ff}, "literal%(format may not end in %)"}, + {"errQ", args{fmtErrorOpt, ff}, "literal%(format may not end in %?)"}, + {"errU", args{fmtErrorUnk, ff}, "literal%(unknown %verb %o)"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if got := makeFileName(tt.args.format, tt.args.uniquename, tt.args.domain, tt.args.tag); got != tt.want { - t.Errorf("makeFileName() = %v, want %v", got, tt.want) + if got := makeFileName(tt.args.format, tt.args.ff); got != tt.want { + t.Errorf("makeFileName(%q) = %q, want %q", tt.args.format, got, tt.want) } }) } diff --git a/providers/powerdns/diff.go b/providers/powerdns/diff.go index 2208e6bd1..d8622b398 100644 --- a/providers/powerdns/diff.go +++ b/providers/powerdns/diff.go @@ -58,7 +58,7 @@ func (dsp *powerdnsProvider) getDiff2DomainCorrections(dc *models.DomainConfig, } } - domainVariant := dsp.zoneName(dc.Name, dc.Metadata[models.DomainTag]) + domainVariant := dsp.zoneName(dc.Name, dc.Tag) // only append a Correction if there are any, otherwise causes an error when sending an empty rrset if len(rrDeleteSets) > 0 { diff --git a/providers/powerdns/dnssec.go b/providers/powerdns/dnssec.go index efaa2ad52..1a4300546 100644 --- a/providers/powerdns/dnssec.go +++ b/providers/powerdns/dnssec.go @@ -10,7 +10,7 @@ import ( // getDNSSECCorrections returns corrections that update a domain's DNSSEC state. func (dsp *powerdnsProvider) getDNSSECCorrections(dc *models.DomainConfig) ([]*models.Correction, error) { - domainVariant := dsp.zoneName(dc.Name, dc.Metadata[models.DomainTag]) + domainVariant := dsp.zoneName(dc.Name, dc.Tag) zoneCryptokeys, getErr := dsp.client.Cryptokeys().ListCryptokeys(context.Background(), dsp.ServerName, domainVariant) if getErr != nil { if _, ok := getErr.(pdnshttp.ErrNotFound); ok { From 1e67585e8faf08dff063f27100ecacb75024c728 Mon Sep 17 00:00:00 2001 From: Jakob Ackermann Date: Sun, 30 Nov 2025 15:14:54 +0100 Subject: [PATCH 3/3] HETZNER_V2: Add provider for Hetzner DNS API (#3837) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes https://github.com/StackExchange/dnscontrol/issues/3787 This PR is adding a `HETZNER_V2` provider for the "new" Hetzner DNS API. Testing: - The integration tests are passing. - Manual testing: - `preview` (see diff for existing zone) - `preview --populate-on-preview` (see full diff for newly created zone) - `push` (see full diff; no diff after push) - `push` (see full diff; no diff after push to newly created zone -- i.e. single pass and done) ```js var REG_NONE = NewRegistrar('none') var DSP = NewDnsProvider('HETZNER_V2') D('testing-2025-11-14-7.dev', REG_NONE, DnsProvider(DSP), A('@', '127.0.0.1') ) ```
``` # push for newly created zone CONCURRENTLY checking for 1 zone(s) SERIALLY checking for 0 zone(s) Waiting for concurrent checking(s) to complete...DONE ******************** Domain: testing-2025-11-14-7.dev 1 correction (HETZNER_V2) #1: Ensuring zone "testing-2025-11-14-7.dev" exists in "HETZNER_V2" SUCCESS! CONCURRENTLY gathering records of 1 zone(s) SERIALLY gathering records of 0 zone(s) Waiting for concurrent gathering(s) to complete...DONE ******************** Domain: testing-2025-11-14-7.dev 4 corrections (HETZNER_V2) #1: ± MODIFY-TTL testing-2025-11-14-7.dev NS helium.ns.hetzner.de. ttl=(3600->300) ± MODIFY-TTL testing-2025-11-14-7.dev NS hydrogen.ns.hetzner.com. ttl=(3600->300) ± MODIFY-TTL testing-2025-11-14-7.dev NS oxygen.ns.hetzner.com. ttl=(3600->300) SUCCESS! #2: + CREATE testing-2025-11-14-7.dev A 127.0.0.1 ttl=300 SUCCESS! Done. 5 corrections. ```
Feedback for @jooola and @LKaemmerling: - The SDK was very useful in getting 80% there! Nice! :tada: - Footgun: - The `result` values are not "up-to-date" after waiting for an `Action`, e.g. `Zone.AuthoritativeNameservers.Assigned` is not set when `Client.Zone.Create()` returns and the following "wait" will not update it. - Taking a step back here: Waiting for an `Action` with a separate SDK call does not seem very natural to me. Does the SDK-user need to know that you are processing operations asynchronous? (Which seems like an implementation detail to me, something that the SDK could abstrct over.) Can `Client.Zone.Create()` return the final `Zone` instead of the intermediate result? - Features missing compared to the DNS Console, in priority order: - It is no longer possible to remove your provided name servers from the root/apex. Use-case: dual-home/multi-home zone with fewer than three servers from Hetzner. I'm operating one of these and cannot migrate over until this is fixed. - Performance regression due to lack of bulk create/modify. E.g. [one of the test suites](https://github.com/StackExchange/dnscontrol/blob/a71b89e5a2bf9a31872b6b2095c9d4eea641dad3/integrationTest/integration_test.go#L619) spends about 4.5 minutes on making creating 100 record-sets and then another 4 minutes for deleting them in sequence again. With your async API, these are `create 2*100 + delete 2*100 = 400` API calls. Previously, these were `create 1 + delete 100 = 101` API calls. Are you planning on adding batch processing again? - Usability nits - Compared to other record-set based APIs, upserts for record-sets are missing. This applies to records of a record-set and the ttl of the record-set (see separate SDK calls for the cases `diff2.CREATE` vs `diff2.CHANGE` and two calls in `diff2.CHANGE` for updating the TTL vs records). - Some SDK methods return an `Action` (e.g. `Zone.ChangeRRSetTTL()`), others wrap the `Action` in a struct (`Client.Zone.CreateRRSet()`) -- even when the struct has a single field (`ZoneRRSetDeleteResult`). --------- Co-authored-by: "Jonas L." Co-authored-by: "Lukas Kämmerling" Co-authored-by: Tom Limoncelli <6293917+tlimoncelli@users.noreply.github.com> --- .github/workflows/pr_integration_tests.yml | 5 +- .goreleaser.yml | 2 +- OWNERS | 1 + documentation/SUMMARY.md | 3 +- documentation/provider/hetzner_v2.md | 54 +++++ documentation/provider/index.md | 6 + go.mod | 9 + go.sum | 22 ++ integrationTest/profiles.json | 5 + providers/_all/all.go | 1 + providers/hetznerv2/auditrecords.go | 12 + providers/hetznerv2/hetznerv2Provider.go | 264 +++++++++++++++++++++ 12 files changed, 381 insertions(+), 3 deletions(-) create mode 100644 documentation/provider/hetzner_v2.md create mode 100644 providers/hetznerv2/auditrecords.go create mode 100644 providers/hetznerv2/hetznerv2Provider.go diff --git a/.github/workflows/pr_integration_tests.yml b/.github/workflows/pr_integration_tests.yml index b8b76bd40..e3f4dd6b1 100644 --- a/.github/workflows/pr_integration_tests.yml +++ b/.github/workflows/pr_integration_tests.yml @@ -52,7 +52,7 @@ jobs: Write-Host "Integration test providers: $Providers" echo "integration_test_providers=$(ConvertTo-Json -InputObject $Providers -Compress)" >> $env:GITHUB_OUTPUT env: - PROVIDERS: "['AXFRDDNS', 'AXFRDDNS_DNSSEC', 'AZURE_DNS','BIND','BUNNY_DNS','CLOUDFLAREAPI','CLOUDNS','CNR','DIGITALOCEAN','FORTIGATE','GANDI_V5','GCLOUD','HEDNS','HEXONET','HUAWEICLOUD','INWX','JOKER','MYTHICBEASTS', 'NAMEDOTCOM','NS1','POWERDNS','ROUTE53','SAKURACLOUD','TRANSIP']" + PROVIDERS: "['AXFRDDNS', 'AXFRDDNS_DNSSEC', 'AZURE_DNS','BIND','BUNNY_DNS','CLOUDFLAREAPI','CLOUDNS','CNR','DIGITALOCEAN','FORTIGATE','GANDI_V5','GCLOUD','HEDNS','HETZNER_V2','HEXONET','HUAWEICLOUD','INWX','JOKER','MYTHICBEASTS', 'NAMEDOTCOM','NS1','POWERDNS','ROUTE53','SAKURACLOUD','TRANSIP']" ENV_CONTEXT: ${{ toJson(env) }} VARS_CONTEXT: ${{ toJson(vars) }} SECRETS_CONTEXT: ${{ toJson(secrets) }} @@ -87,6 +87,7 @@ jobs: GANDI_V5_DOMAIN: ${{ vars.GANDI_V5_DOMAIN }} GCLOUD_DOMAIN: ${{ vars.GCLOUD_DOMAIN }} HEDNS_DOMAIN: ${{ vars.HEDNS_DOMAIN }} + HETZNER_V2_DOMAIN: ${{ vars.HETZNER_V2_DOMAIN }} HEXONET_DOMAIN: ${{ vars.HEXONET_DOMAIN }} HUAWEICLOUD_DOMAIN: ${{ vars.HUAWEICLOUD_DOMAIN }} JOKER_DOMAIN: ${{ vars.JOKER_DOMAIN }} @@ -154,6 +155,8 @@ jobs: HEDNS_TOTP_SECRET: ${{ secrets.HEDNS_TOTP_SECRET }} HEDNS_USERNAME: ${{ secrets.HEDNS_USERNAME }} # + HETZNER_V2_API_TOKEN: ${{ secrets.HETZNER_V2_API_TOKEN }} + # HEXONET_ENTITY: ${{ secrets.HEXONET_ENTITY }} HEXONET_PW: ${{ secrets.HEXONET_PW }} HEXONET_UID: ${{ secrets.HEXONET_UID }} diff --git a/.goreleaser.yml b/.goreleaser.yml index 68ec15037..c2938e051 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -39,7 +39,7 @@ changelog: regexp: "(?i)^.*(major|new provider|feature)[(\\w)]*:+.*$" order: 1 - title: 'Provider-specific changes:' - regexp: "(?i)((adguardhome|akamaiedge|autodns|axfrd|azure|azure_private_dns|bind|bunnydns|cloudflare|cloudflareapi_old|cloudns|cnr|cscglobal|desec|digitalocean|dnsimple|dnsmadeeasy|doh|domainnameshop|dynadot|easyname|exoscale|fortigate|gandi|gcloud|gcore|hedns|hetzner|hexonet|hostingde|huaweicloud|inwx|joker|linode|loopia|luadns|mythicbeasts|namecheap|namedotcom|netcup|netlify|ns1|opensrs|oracle|ovh|packetframe|porkbun|powerdns|realtimeregister|route53|rwth|sakuracloud|softlayer|transip|vultr).*:)+.*" + regexp: "(?i)((adguardhome|akamaiedge|autodns|axfrd|azure|azure_private_dns|bind|bunnydns|cloudflare|cloudflareapi_old|cloudns|cnr|cscglobal|desec|digitalocean|dnsimple|dnsmadeeasy|doh|domainnameshop|dynadot|easyname|exoscale|fortigate|gandi|gcloud|gcore|hedns|hetzner|hetznerv2|hexonet|hostingde|huaweicloud|inwx|joker|linode|loopia|luadns|mythicbeasts|namecheap|namedotcom|netcup|netlify|ns1|opensrs|oracle|ovh|packetframe|porkbun|powerdns|realtimeregister|route53|rwth|sakuracloud|softlayer|transip|vultr).*:)+.*" order: 2 - title: 'Documentation:' regexp: "(?i)^.*(docs)[(\\w)]*:+.*$" diff --git a/OWNERS b/OWNERS index ea320dec8..3cc1ee6fe 100644 --- a/OWNERS +++ b/OWNERS @@ -25,6 +25,7 @@ providers/gcloud @riyadhalnur providers/gcore @xddxdd providers/hedns @rblenkinsopp providers/hetzner @das7pad +providers/hetznerv2 @das7pad providers/hexonet @KaiSchwarz-cnic providers/hostingde @juliusrickert providers/huaweicloud @huihuimoe diff --git a/documentation/SUMMARY.md b/documentation/SUMMARY.md index 4fdc07d67..d7d5dca11 100644 --- a/documentation/SUMMARY.md +++ b/documentation/SUMMARY.md @@ -137,7 +137,8 @@ * [Gandi_v5](provider/gandi_v5.md) * [Gcore](provider/gcore.md) * [Google Cloud DNS](provider/gcloud.md) -* [Hetzner DNS Console](provider/hetzner.md) +* [Hetzner DNS API](provider/hetzner_v2.md) +* [Hetzner DNS Console (legacy)](provider/hetzner.md) * [HEXONET](provider/hexonet.md) * [hosting.de](provider/hostingde.md) * [Huawei Cloud DNS](provider/huaweicloud.md) diff --git a/documentation/provider/hetzner_v2.md b/documentation/provider/hetzner_v2.md new file mode 100644 index 000000000..d8788d886 --- /dev/null +++ b/documentation/provider/hetzner_v2.md @@ -0,0 +1,54 @@ +## Configuration + +To use this provider, add an entry to `creds.json` with `TYPE` set to `HETZNER_V2` +along with a [Hetzner API Token](https://docs.hetzner.cloud/reference/cloud#getting-started). + +Example: + +{% code title="creds.json" %} +```json +{ + "hetzner_v2": { + "TYPE": "HETZNER_V2", + "api_token": "your-api-token" + } +} +``` +{% endcode %} + +## Metadata + +This provider does not recognize any special metadata fields unique to Hetzner DNS API. + +## Usage + +An example configuration: + +{% code title="dnsconfig.js" %} +```javascript +var REG_NONE = NewRegistrar("none"); +var DSP_HETZNER = NewDnsProvider("hetzner_v2"); + +D("example.com", REG_NONE, DnsProvider(DSP_HETZNER), + A("test", "1.2.3.4"), +); +``` +{% endcode %} + +## Activation + +Create a new API Key in the +[Hetzner Console](https://docs.hetzner.cloud/reference/cloud#getting-started). + +## Caveats + +### NS + +Removing the Hetzner provided NS records at the root is not possible. + +### SOA + +Hetzner DNS API does not allow changing the SOA record via their API. +There is an alternative method using an import of a full BIND file, but this + approach does not play nice with incremental changes or ignored records. +At this time you cannot update SOA records via DNSControl. diff --git a/documentation/provider/index.md b/documentation/provider/index.md index 911144f12..1a56c28ad 100644 --- a/documentation/provider/index.md +++ b/documentation/provider/index.md @@ -52,6 +52,7 @@ Jump to a table: | [`GCORE`](gcore.md) | ❌ | ✅ | ❌ | | [`HEDNS`](hedns.md) | ❌ | ✅ | ❌ | | [`HETZNER`](hetzner.md) | ❌ | ✅ | ❌ | +| [`HETZNER_V2`](hetzner_v2.md) | ❌ | ✅ | ❌ | | [`HEXONET`](hexonet.md) | ❌ | ✅ | ✅ | | [`HOSTINGDE`](hostingde.md) | ❌ | ✅ | ✅ | | [`HUAWEICLOUD`](huaweicloud.md) | ❌ | ✅ | ❌ | @@ -112,6 +113,7 @@ Jump to a table: | [`GCORE`](gcore.md) | ✅ | ✅ | ✅ | ✅ | | [`HEDNS`](hedns.md) | ❔ | ✅ | ✅ | ✅ | | [`HETZNER`](hetzner.md) | ✅ | ✅ | ✅ | ✅ | +| [`HETZNER_V2`](hetzner_v2.md) | ✅ | ✅ | ✅ | ✅ | | [`HEXONET`](hexonet.md) | ❔ | ✅ | ✅ | ❔ | | [`HOSTINGDE`](hostingde.md) | ❔ | ✅ | ✅ | ✅ | | [`HUAWEICLOUD`](huaweicloud.md) | ❔ | ✅ | ✅ | ✅ | @@ -169,6 +171,7 @@ Jump to a table: | [`GCORE`](gcore.md) | ✅ | ❔ | ❌ | ✅ | ❔ | | [`HEDNS`](hedns.md) | ✅ | ❔ | ✅ | ✅ | ❌ | | [`HETZNER`](hetzner.md) | ❌ | ❔ | ❌ | ❌ | ❌ | +| [`HETZNER_V2`](hetzner_v2.md) | ❌ | ❔ | ❌ | ✅ | ❌ | | [`HEXONET`](hexonet.md) | ❌ | ❔ | ❔ | ✅ | ❔ | | [`HOSTINGDE`](hostingde.md) | ✅ | ❔ | ❌ | ✅ | ✅ | | [`HUAWEICLOUD`](huaweicloud.md) | ❌ | ❔ | ❌ | ❌ | ❌ | @@ -223,6 +226,7 @@ Jump to a table: | [`GCORE`](gcore.md) | ❔ | ❌ | ✅ | ✅ | | [`HEDNS`](hedns.md) | ❔ | ✅ | ✅ | ✅ | | [`HETZNER`](hetzner.md) | ❔ | ❌ | ✅ | ❔ | +| [`HETZNER_V2`](hetzner_v2.md) | ❔ | ❌ | ✅ | ✅ | | [`HEXONET`](hexonet.md) | ❔ | ❔ | ✅ | ❔ | | [`HOSTINGDE`](hostingde.md) | ❔ | ❌ | ✅ | ❔ | | [`HUAWEICLOUD`](huaweicloud.md) | ❔ | ❌ | ✅ | ❌ | @@ -276,6 +280,7 @@ Jump to a table: | [`GCORE`](gcore.md) | ✅ | ✅ | ❔ | ❌ | ❌ | | [`HEDNS`](hedns.md) | ✅ | ✅ | ❔ | ✅ | ❌ | | [`HETZNER`](hetzner.md) | ✅ | ❔ | ❔ | ❌ | ✅ | +| [`HETZNER_V2`](hetzner_v2.md) | ✅ | ✅ | ❔ | ❌ | ✅ | | [`HEXONET`](hexonet.md) | ✅ | ❔ | ❔ | ❔ | ✅ | | [`HOSTINGDE`](hostingde.md) | ✅ | ❔ | ❔ | ✅ | ✅ | | [`HUAWEICLOUD`](huaweicloud.md) | ✅ | ❌ | ❔ | ❌ | ❌ | @@ -320,6 +325,7 @@ Jump to a table: | [`GCORE`](gcore.md) | ✅ | ❔ | ❌ | | [`HEDNS`](hedns.md) | ❌ | ❔ | ❌ | | [`HETZNER`](hetzner.md) | ❌ | ❔ | ✅ | +| [`HETZNER_V2`](hetzner_v2.md) | ❌ | ❔ | ✅ | | [`HOSTINGDE`](hostingde.md) | ✅ | ❔ | ✅ | | [`HUAWEICLOUD`](huaweicloud.md) | ❔ | ❔ | ❌ | | [`INWX`](inwx.md) | ✅ | ❔ | ❔ | diff --git a/go.mod b/go.mod index d8e7a6098..6b2894dd6 100644 --- a/go.mod +++ b/go.mod @@ -66,6 +66,7 @@ require ( github.com/fbiville/markdown-table-formatter v0.3.0 github.com/google/go-cmp v0.7.0 github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 + github.com/hetznercloud/hcloud-go/v2 v2.30.0 github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.174 github.com/kylelemons/godebug v1.1.0 github.com/luadns/luadns-go v0.3.0 @@ -97,8 +98,10 @@ require ( github.com/aws/aws-sdk-go-v2/service/sso v1.30.1 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.5 // indirect github.com/aws/smithy-go v1.23.2 // indirect + github.com/beorn7/perks v1.0.1 // indirect github.com/bits-and-blooms/bitset v1.24.0 // indirect github.com/boombuler/barcode v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/deepmap/oapi-codegen v1.9.1 // indirect @@ -126,10 +129,15 @@ require ( github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/patrickmn/go-cache v2.1.0+incompatible // indirect github.com/peterhellberg/link v1.2.0 // indirect github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/prometheus/client_golang v1.23.2 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.66.1 // indirect + github.com/prometheus/procfs v0.16.1 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/sergi/go-diff v1.2.0 // indirect github.com/shopspring/decimal v1.4.0 // indirect @@ -147,6 +155,7 @@ require ( go.opentelemetry.io/otel v1.37.0 // indirect go.opentelemetry.io/otel/metric v1.37.0 // indirect go.opentelemetry.io/otel/trace v1.37.0 // indirect + go.yaml.in/yaml/v2 v2.4.2 // indirect golang.org/x/mod v0.29.0 // indirect golang.org/x/sync v0.18.0 // indirect golang.org/x/sys v0.38.0 // indirect diff --git a/go.sum b/go.sum index ce2279391..a508646c3 100644 --- a/go.sum +++ b/go.sum @@ -78,6 +78,8 @@ github.com/aws/smithy-go v1.23.2 h1:Crv0eatJUQhaManss33hS5r40CG3ZFH+21XSkqMrIUM= github.com/aws/smithy-go v1.23.2/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= github.com/babolivier/go-doh-client v0.0.0-20201028162107-a76cff4cb8b6 h1:4NNbNM2Iq/k57qEu7WfL67UrbPq1uFWxW4qODCohi+0= github.com/babolivier/go-doh-client v0.0.0-20201028162107-a76cff4cb8b6/go.mod h1:J29hk+f9lJrblVIfiJOtTFk+OblBawmib4uz/VdKzlg= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/billputer/go-namecheap v0.0.0-20210108011502-994a912fb7f9 h1:2vQTbEJvFsyd1VefzZ34GUkUD6TkJleYYJh9/25WBE4= github.com/billputer/go-namecheap v0.0.0-20210108011502-994a912fb7f9/go.mod h1:bqqNsI2akL+lLWyApkYY0cxquWPKwEBU0Wd3chi3TEg= github.com/bits-and-blooms/bitset v1.24.0 h1:H4x4TuulnokZKvHLfzVRTHJfFfnHEeSYJizujEZvmAM= @@ -90,6 +92,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/centralnicgroup-opensource/rtldev-middleware-go-sdk/v5 v5.0.18 h1:RvyTDU0VmnUBd3Qm2i6irEXtCR2KRIxnRlD8l+5z/DY= github.com/centralnicgroup-opensource/rtldev-middleware-go-sdk/v5 v5.0.18/go.mod h1:a6n4wXFHbMW0iJFxHIJR4PkgG5krP52nOVCBU0m+Obw= +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/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= @@ -229,6 +233,8 @@ github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB1 github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU= github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk= +github.com/hetznercloud/hcloud-go/v2 v2.30.0 h1:fgAUtCCw4PbJNSs9XPLHVu0//dTNMbPq8P/48ovmdG8= +github.com/hetznercloud/hcloud-go/v2 v2.30.0/go.mod h1:zv7x2kM7xyJ5mW/+y4HbfxQYhk8TE57ypTa1hofsYdw= github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.174 h1:FBlx7E5rl8doUTbizt+DXR0zU05Mu2oEYvc/2GMB7pc= github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.174/go.mod h1:M+yna96Fx9o5GbIUnF3OvVvQGjgfVSyeJbV9Yb1z/wI= github.com/influxdata/tdigest v0.0.1 h1:XpFptwYmnEKUqmkcDjrzffswZ3nvNeevbUSLPP/ZzIY= @@ -246,6 +252,8 @@ github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfV github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU= github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k= github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/kolo/xmlrpc v0.0.0-20220921171641-a4b6fa1dd06b h1:udzkj9S/zlT5X367kqJis0QP7YMxobob6zhzq6Yre00= github.com/kolo/xmlrpc v0.0.0-20220921171641-a4b6fa1dd06b/go.mod h1:pcaDhQK0/NJZEvtCO0qQPPropqV0sJOJ6YW7X+9kRwM= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= @@ -300,6 +308,8 @@ github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lN github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +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/nbio/st v0.0.0-20140626010706-e9e8d9816f32/go.mod h1:9wM+0iRr9ahx58uYLpLIr5fm8diHn0JbqRycJi6w0Ms= @@ -333,7 +343,15 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs= github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= +github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= +github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= +github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= github.com/qdm12/reprint v0.0.0-20200326205758-722754a53494 h1:wSmWgpuccqS2IOfmYrbRiUgv+g37W5suLLLxwwniTSc= github.com/qdm12/reprint v0.0.0-20200326205758-722754a53494/go.mod h1:yipyliwI08eQ6XwDm1fEwKPdF/xdbkiHtrU+1Hg+vc4= github.com/robertkrimen/otto v0.0.0-20200922221731-ef014fd054ac/go.mod h1:xvqspoSXJTIpemEonrMDFq6XzwHYYgToXWj5eRX1OtY= @@ -426,6 +444,10 @@ go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFh go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps= go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= +go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= diff --git a/integrationTest/profiles.json b/integrationTest/profiles.json index 8c85a2975..4d84672a8 100644 --- a/integrationTest/profiles.json +++ b/integrationTest/profiles.json @@ -176,6 +176,11 @@ "api_key": "$HETZNER_API_KEY", "domain": "$HETZNER_DOMAIN" }, + "HETZNER_V2": { + "TYPE": "HETZNER_V2", + "api_token": "$HETZNER_V2_API_TOKEN", + "domain": "$HETZNER_V2_DOMAIN" + }, "HEXONET": { "TYPE": "HEXONET", "apientity": "$HEXONET_ENTITY", diff --git a/providers/_all/all.go b/providers/_all/all.go index 92b403e0d..74355f5f8 100644 --- a/providers/_all/all.go +++ b/providers/_all/all.go @@ -30,6 +30,7 @@ import ( _ "github.com/StackExchange/dnscontrol/v4/providers/gcore" _ "github.com/StackExchange/dnscontrol/v4/providers/hedns" _ "github.com/StackExchange/dnscontrol/v4/providers/hetzner" + _ "github.com/StackExchange/dnscontrol/v4/providers/hetznerv2" _ "github.com/StackExchange/dnscontrol/v4/providers/hexonet" _ "github.com/StackExchange/dnscontrol/v4/providers/hostingde" _ "github.com/StackExchange/dnscontrol/v4/providers/huaweicloud" diff --git a/providers/hetznerv2/auditrecords.go b/providers/hetznerv2/auditrecords.go new file mode 100644 index 000000000..dae136cee --- /dev/null +++ b/providers/hetznerv2/auditrecords.go @@ -0,0 +1,12 @@ +package hetznerv2 + +import ( + "github.com/StackExchange/dnscontrol/v4/models" +) + +// 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(_ []*models.RecordConfig) []error { + return nil +} diff --git a/providers/hetznerv2/hetznerv2Provider.go b/providers/hetznerv2/hetznerv2Provider.go new file mode 100644 index 000000000..7c9535b75 --- /dev/null +++ b/providers/hetznerv2/hetznerv2Provider.go @@ -0,0 +1,264 @@ +package hetznerv2 + +import ( + "context" + "encoding/json" + "errors" + + "github.com/hetznercloud/hcloud-go/v2/hcloud" + "golang.org/x/net/idna" + + "github.com/StackExchange/dnscontrol/v4/models" + "github.com/StackExchange/dnscontrol/v4/pkg/diff2" + "github.com/StackExchange/dnscontrol/v4/pkg/txtutil" + "github.com/StackExchange/dnscontrol/v4/pkg/version" + "github.com/StackExchange/dnscontrol/v4/pkg/zonecache" + "github.com/StackExchange/dnscontrol/v4/providers" +) + +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.CanConcur: providers.Can(), + providers.CanGetZones: providers.Can(), + providers.CanOnlyDiff1Features: providers.Can(), + providers.CanUseAlias: providers.Cannot(), + providers.CanUseCAA: providers.Can(), + providers.CanUseDS: providers.Can(), + providers.CanUseDSForChildren: providers.Cannot(), + providers.CanUseLOC: providers.Cannot(), + providers.CanUseNAPTR: providers.Cannot(), + providers.CanUsePTR: providers.Can(), + providers.CanUseSOA: providers.Cannot(), + providers.CanUseSRV: providers.Can(), + providers.CanUseSVCB: providers.Can(), + providers.CanUseHTTPS: providers.Can(), + providers.CanUseSSHFP: providers.Cannot(), + providers.CanUseTLSA: providers.Can(), + providers.DocCreateDomains: providers.Can(), + providers.DocOfficiallySupported: providers.Cannot(), + providers.DocDualHost: providers.Can(), +} + +func init() { + const providerName = "HETZNER_V2" + const providerMaintainer = "@das7pad" + fns := providers.DspFuncs{ + Initializer: New, + RecordAuditor: AuditRecords, + } + providers.RegisterDomainServiceProviderType(providerName, fns, features) + providers.RegisterMaintainer(providerName, providerMaintainer) +} + +// New creates a new API handle. +func New(settings map[string]string, _ json.RawMessage) (providers.DNSServiceProvider, error) { + apiToken := settings["api_token"] + if apiToken == "" { + return nil, errors.New("missing HETZNER_V2 api_token") + } + + h := &hetznerv2Provider{ + client: hcloud.NewClient( + hcloud.WithToken(apiToken), + hcloud.WithApplication("dnscontrol", version.Version()), + ), + } + h.zoneCache = zonecache.New(h.fetchAllZones) + return h, nil +} + +type hetznerv2Provider struct { + zoneCache zonecache.ZoneCache[*hcloud.Zone] + client *hcloud.Client +} + +// fetchAllZones is used by the zonecache.ZoneCache. +func (h *hetznerv2Provider) fetchAllZones() (map[string]*hcloud.Zone, error) { + flat, err := h.client.Zone.All(context.Background()) + if err != nil { + return nil, err + } + zones := make(map[string]*hcloud.Zone, len(flat)) + for _, z := range flat { + zones[z.Name] = z + } + return zones, nil +} + +// EnsureZoneExists creates a zone if it does not exist +func (h *hetznerv2Provider) EnsureZoneExists(domain string, _ map[string]string) error { + encoded, err := idna.ToASCII(domain) + if err != nil { + return err + } + if ok, err2 := h.zoneCache.HasZone(encoded); err2 != nil || ok { + return err2 + } + result, _, err := h.client.Zone.Create(context.Background(), hcloud.ZoneCreateOpts{ + Name: encoded, + Mode: hcloud.ZoneModePrimary, + }) + if err != nil { + return err + } + err = h.client.Action.WaitFor(context.Background(), result.Action) + if err != nil { + return err + } + z, _, err := h.client.Zone.GetByID(context.Background(), result.Zone.ID) + if err != nil { + return err + } + h.zoneCache.SetZone(encoded, z) + return nil +} + +// GetZoneRecordsCorrections returns a list of corrections that will turn existing records into dc.Records. +func (h *hetznerv2Provider) GetZoneRecordsCorrections(dc *models.DomainConfig, existingRecords models.Records) ([]*models.Correction, int, error) { + encoded, err := idna.ToASCII(dc.Name) + if err != nil { + return nil, 0, err + } + + z, err := h.zoneCache.GetZone(encoded) + if err != nil { + return nil, 0, err + } + + // Hetzner Cloud has a "ByRecordSet" API for DNS. + // At each label:rtype pair, we either delete all records or UPSERT the desired records. + instructions, actualChangeCount, err := diff2.ByRecordSet(existingRecords, dc, nil) + if err != nil { + return nil, 0, err + } + + var reports []*models.Correction + for _, instruction := range instructions { + switch instruction.Type { + case diff2.REPORT: + reports = append(reports, &models.Correction{ + Msg: instruction.MsgsJoined, + }) + continue + case diff2.CREATE: + first := instruction.New[0] + ttl := int(first.TTL) + opts := hcloud.ZoneRRSetCreateOpts{ + Name: first.Name, + Type: hcloud.ZoneRRSetType(first.Type), + TTL: &ttl, + } + for _, r := range instruction.New { + opts.Records = append(opts.Records, hcloud.ZoneRRSetRecord{ + Value: r.GetTargetCombinedFunc(txtutil.EncodeQuoted), + }) + } + reports = append(reports, &models.Correction{ + F: func() error { + _, _, err2 := h.client.Zone.CreateRRSet(context.Background(), z, opts) + return err2 + }, + Msg: instruction.MsgsJoined, + }) + case diff2.CHANGE: + rrSet := instruction.Old[0].Original.(*hcloud.ZoneRRSet) + reports = append(reports, &models.Correction{ + F: func() error { + if instruction.New[0].TTL != instruction.Old[0].TTL { + ttl := int(instruction.New[0].TTL) + opts := hcloud.ZoneRRSetChangeTTLOpts{TTL: &ttl} + _, _, err2 := h.client.Zone.ChangeRRSetTTL(context.Background(), rrSet, opts) + if err2 != nil { + return err2 + } + } + + opts := hcloud.ZoneRRSetSetRecordsOpts{} + for _, r := range instruction.New { + opts.Records = append(opts.Records, hcloud.ZoneRRSetRecord{ + Value: r.GetTargetCombinedFunc(txtutil.EncodeQuoted), + }) + } + _, _, err2 := h.client.Zone.SetRRSetRecords(context.Background(), rrSet, opts) + return err2 + }, + Msg: instruction.MsgsJoined, + }) + case diff2.DELETE: + reports = append(reports, &models.Correction{ + F: func() error { + rc := instruction.Old[0].Original.(*hcloud.ZoneRRSet) + _, _, err2 := h.client.Zone.DeleteRRSet(context.Background(), rc) + return err2 + }, + Msg: instruction.MsgsJoined, + }) + } + } + + return reports, actualChangeCount, nil +} + +// GetNameservers returns the nameservers for a domain. +func (h *hetznerv2Provider) GetNameservers(domain string) ([]*models.Nameserver, error) { + encoded, err := idna.ToASCII(domain) + if err != nil { + return nil, err + } + z, err := h.zoneCache.GetZone(encoded) + if err != nil { + return nil, err + } + return models.ToNameserversStripTD(z.AuthoritativeNameservers.Assigned) +} + +// GetZoneRecords gets the records of a zone and returns them in RecordConfig format. +func (h *hetznerv2Provider) GetZoneRecords(domain string, _ map[string]string) (models.Records, error) { + encoded, err := idna.ToASCII(domain) + if err != nil { + return nil, err + } + z, err := h.zoneCache.GetZone(encoded) + if err != nil { + return nil, err + } + opts := hcloud.ZoneRRSetListOpts{} + opts.PerPage = 100 + records, err := h.client.Zone.AllRRSetsWithOpts(context.Background(), z, opts) + if err != nil { + return nil, err + } + existingRecords := make([]*models.RecordConfig, 0, len(records)) + for _, rrSet := range records { + if rrSet.Type == hcloud.ZoneRRSetTypeSOA { + // SOA records are not available for editing, hide them. + continue + } + base := models.RecordConfig{ + Type: string(rrSet.Type), + Original: rrSet, + } + base.SetLabel(rrSet.Name, z.Name) + if rrSet.TTL != nil { + base.TTL = uint32(*rrSet.TTL) + } else { + base.TTL = uint32(z.TTL) + } + + for _, r := range rrSet.Records { + rc := base + if err = rc.PopulateFromStringFunc(rc.Type, r.Value, z.Name, txtutil.ParseQuoted); err != nil { + return nil, err + } + existingRecords = append(existingRecords, &rc) + } + } + return existingRecords, nil +} + +// ListZones lists the zones on this account. +func (h *hetznerv2Provider) ListZones() ([]string, error) { + return h.zoneCache.GetZoneNames() +}