Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 33 additions & 2 deletions documentation/advanced-features/writing-providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -315,8 +315,39 @@ Capabilities are processed early by DNSControl. For example if a provider doesn

Enable optional capabilities in the `nameProvider.go` file and run the integration tests to see what works and what doesn't. Fix any bugs and repeat, repeat, repeat until you have all the capabilities you want to implement.

Declare record-type capabilities in `providers.Definition.Features` using
`providers.DocumentationNotes`. Named operational fields such as `CanConcur`
Declare supported record types in `providers.Definition.SupportedTypes`, for
example `[]string{"Default", "PTR"}`. This list is exhaustive:
`Default` contains `A`, `AAAA`, `CAA`, `CNAME`, `MX`, `NS`, `SRV`, and `TXT`.
It is a fixed baseline for typical authoritative DNS providers; verify each
provider's implementation and declare exceptions such as `NS:Cannot` explicitly.
Specialized providers can supply their own complete list. `RFC` includes all ordinary
types in DNSControl's record catalog; `*` also includes pseudo-types. Patterns
such as `BUNNY_*` match whole type names, with `*` matching zero or more characters.
Unknown concrete type names are errors.

An entry without a suffix means supported. Use `:Can`, `:Cannot`, or
`:Unimplemented` on concrete names or patterns, for example
`[]string{"RFC", "CAA:Cannot"}`. Both negative statuses reject records;
`Unimplemented` retains a distinct documentation status. `Default` and `RFC`
do not take suffixes. Type names are case-insensitive; status suffixes use the
spellings shown here.

Precedence is: exact entries, patterns with status suffixes, legacy `Features`,
then unsuffixed patterns/categories. Conflicting statuses at the winning
priority are errors, regardless of order. An exact entry can resolve conflicting
patterns. Nil `SupportedTypes` means `Default`; a non-nil empty slice declares
no support. During migration, legacy `Features` can still supply individual
type statuses, and a non-nil `Features` with nil `SupportedTypes` retains legacy
validation. General `DS` support includes child DS records; `CanUseDSForChildren`
can independently allow child DS records even with `DS:Cannot`.

Registration retains selectors until the complete catalog is available.
Importing `pkg/providers/_all` finalizes the registry; definition accessors also
ensure finalization for programs importing individual providers. Register all
providers and record types before concurrent reads. Tests registering additional
types or providers can call `providers.Finalize()` again to rebuild derived data.

Named operational fields such as `CanConcur`
can be set directly on the definition. Some fields are derived automatically for
you, such as `CanGetZones` and `DocCreateDomains`
(set based on the existance or absense of `providers.ZoneLister` and `providers.ZoneCreator`, respectively.)
Expand Down
7 changes: 6 additions & 1 deletion documentation/provider/bind.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
This provider maintains a directory with a collection of .zone files as appropriate for ISC BIND, and other systems that use the RFC 1035 zone-file format.

DNSControl's BIND provider can write every record type in its catalog, including
provider-specific pseudo-types such as `ALIAS` and `R53_ALIAS`. It writes these
types verbatim; it does not translate them into ordinary DNS records or
implement the corresponding vendor features.

This provider does not generate or update the named.conf file, nor does it deploy the .zone files to the BIND master. Both of those tasks are different at each site, so they are best done by a locally-written script.

## Configuration
Expand Down Expand Up @@ -142,7 +147,7 @@ If `filenameformat` is defined, `dnscontrol` makes a guess at which filenames ar
- create-domains: ✅
- [get-zones](../commands/get-zones.md): ✅
- DNS extensions
- [`ALIAS`](../language-reference/domain-modifiers/ALIAS.md): ❔
- [`ALIAS`](../language-reference/domain-modifiers/ALIAS.md): ✅
- [`DNAME`](../language-reference/domain-modifiers/DNAME.md): ✅
- [`LOC`](../language-reference/domain-modifiers/LOC.md): ✅
- [`PTR`](../language-reference/domain-modifiers/PTR.md): ✅
Expand Down
2 changes: 1 addition & 1 deletion documentation/provider/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,7 @@ Jump to a table:
| [`AXFRDDNS`](axfrddns.md) | ❌ | ✅ | ✅ | ✅ | ❌ |
| [`AZURE_DNS`](azuredns.md) | ❌ | ❔ | ❌ | ✅ | ❔ |
| [`AZURE_PRIVATE_DNS`](azureprivatedns.md) | ❌ | ❌ | ❌ | ✅ | ❔ |
| [`BIND`](bind.md) | ❔ | ✅ | ✅ | ✅ | ✅ |
| [`BIND`](bind.md) | ✅ | ✅ | ✅ | ✅ | ✅ |
| [`BUNNY_DNS`](bunnydns.md) | ✅ | ❔ | ❌ | ✅ | ❌ |
| [`CLOUDFLAREAPI`](cloudflareapi.md) | ✅ | ❔ | ✅ | ✅ | ❔ |
| [`CLOUDNS`](cloudns.md) | ✅ | ✅ | ✅ | ✅ | ❔ |
Expand Down
5 changes: 3 additions & 2 deletions pkg/normalize/provider_alias_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
func init() {
providers.Register[*validationProvider]("TEST_CUSTOM_PROVIDER", providers.Definition{
FriendlyName: "Custom type test", Aliases: []string{"TEST_CUSTOM_ALIAS"},
Features: providers.DocumentationNotes{}, // Exercise legacy ownership checks.
})
providers.RegisterCustomRecordType("TEST_CUSTOM_TYPE", "TEST_CUSTOM_PROVIDER", "")
providers.RegisterCustomRecordType("TEST_CUSTOM_TYPE_ALIAS_OWNER", "TEST_CUSTOM_ALIAS", "")
Expand All @@ -19,13 +20,13 @@ func TestCustomRecordProviderAliases(t *testing.T) {
for _, rtype := range []string{"TEST_CUSTOM_TYPE", "TEST_CUSTOM_TYPE_ALIAS_OWNER"} {
for _, providerType := range []string{"TEST_CUSTOM_PROVIDER", "TEST_CUSTOM_ALIAS"} {
r := &models.RecordConfig{Type: rtype, Metadata: map[string]string{}}
if err := validateRecordTypes(r, "example.com", []string{providerType}); err != nil {
if err := validateLegacyRecordTypes(r, "example.com", []string{providerType}); err != nil {
t.Fatal(err)
}
if r.Metadata["orig_custom_type"] != rtype {
t.Fatal("custom-type validation marker was lost")
}
if err := validateRecordTypes(r, "example.com", []string{providerType, plainProviderType}); err == nil {
if err := validateLegacyRecordTypes(r, "example.com", []string{providerType, plainProviderType}); err == nil {
t.Fatal("custom type accepted by an unrelated provider")
}
}
Expand Down
156 changes: 156 additions & 0 deletions pkg/normalize/supported_types_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
package normalize

import (
"errors"
"fmt"
"strings"
"testing"

"github.com/DNSControl/dnscontrol/v5/models"
"github.com/DNSControl/dnscontrol/v5/pkg/privatetypes"
"github.com/DNSControl/dnscontrol/v5/pkg/providers"
)

type supportedTypesAuditor struct{ validationProvider }

func (*supportedTypesAuditor) AuditRecords(models.Records) []error {
return []error{errors.New("stage 5 audit ran")}
}

func init() {
for name, def := range map[string]providers.Definition{
"S5_DEFAULT": {},
"S5_DEFAULT_EXCEPT": {SupportedTypes: []string{"Default", "NS:Cannot", "CAA:Cannot"}},
"S5_EMPTY": {SupportedTypes: []string{}},
"S5_RFC": {SupportedTypes: []string{"RFC"}},
"S5_ALL": {SupportedTypes: []string{"*"}},
"S5_CHILD": {SupportedTypes: []string{"DS:Cannot"}, CanUseDSForChildren: providers.Can()},
"S5_FULL_DS": {SupportedTypes: []string{"DS"}, CanUseDSForChildren: providers.Cannot()},
"S5_NO_DS": {SupportedTypes: []string{"DS:Cannot"}, CanUseDSForChildren: providers.Cannot()},
"S5_IMPORT_ONLY": {SupportedTypes: []string{"IMPORT_TRANSFORM"}},
} {
def.FriendlyName = name
providers.Register[*validationProvider](name, def)
}
providers.Register[*supportedTypesAuditor]("S5_AUDIT", providers.Definition{FriendlyName: "Auditor", SupportedTypes: []string{"*"}})
}

func TestExhaustiveRecordValidation(t *testing.T) {
for _, tc := range []struct {
provider, label, rtype, data string
allowed bool
}{
{"S5_DEFAULT", "@", "A", "192.0.2.1", true},
{"S5_EMPTY", "@", "A", "192.0.2.1", false},
{"S5_DEFAULT", "@", "TXT", `"text"`, true},
{"S5_DEFAULT", "child", "NS", "ns.example.net.", true},
{"S5_DEFAULT", "@", "NS", "ns.example.net.", false},
{"S5_DEFAULT", "@", "CAA", `0 issue "ca.example.net"`, true},
{"S5_DEFAULT", "_sip._tcp", "SRV", "0 5 5060 sip.example.net.", true},
{"S5_DEFAULT_EXCEPT", "child", "NS", "ns.example.net.", false},
{"S5_DEFAULT_EXCEPT", "@", "CAA", `0 issue "ca.example.net"`, false},
{"S5_DEFAULT_EXCEPT", "@", "TXT", `"text"`, true},
{ProviderNoDS, "@", "TXT", `"text"`, true},
{ProviderNoDS, "child", "NS", "ns.example.net.", true},
{"S5_RFC", "@", "TXT", `"text"`, true},
{"S5_RFC", "@", "HINFO", `"CPU" "OS"`, true},
{"S5_RFC", "@", "ALIAS", "target.example.net.", false},
{"S5_ALL", "@", "ALIAS", "target.example.net.", true},
{"S5_ALL", "edge", "AKAMAITLC", "A target.example.net.", true},
{"S5_ALL", "edge", "AKAMAITLC", "AAAA target.example.net.", true},
{"S5_ALL", "edge", "AKAMAITLC", "DUAL target.example.net.", true},
{"S5_DEFAULT", "edge", "AKAMAITLC", "A target.example.net.", false},
{"S5_CHILD", "child", "DS", "12345 8 2 ABCD", true},
{"S5_CHILD", "@", "DS", "12345 8 2 ABCD", false},
{"S5_FULL_DS", "@", "DS", "12345 8 2 ABCD", true},
{"S5_FULL_DS", "child", "DS", "12345 8 2 ABCD", true},
{"S5_NO_DS", "child", "DS", "12345 8 2 ABCD", false},
} {
t.Run(tc.provider+"/"+tc.rtype+"/"+tc.label+"/"+tc.data, func(t *testing.T) {
dc := lineDomain(tc.provider)
r := dc.MustNewRecordConfigParse(tc.label, 300, tc.rtype, tc.data)
dc.AddRecordConfig(r)
errs := validateDomain(t, dc)
if (len(errs) == 0) != tc.allowed {
t.Fatalf("allowed=%v, errors=%v", tc.allowed, errs)
}
if tc.allowed && r.Metadata["orig_custom_type"] != "" {
t.Fatal("exhaustive validation added a legacy custom-type marker")
}
})
}
}

func TestSupportedTypesRetainsOtherValidation(t *testing.T) {
t.Run("auditor", func(t *testing.T) {
dc := lineDomain("S5_AUDIT")
dc.AddRecordConfig(dc.MustNewRecordConfig("www", 300, "A", "192.0.2.1"))
if errs := validateDomain(t, dc); !strings.Contains(fmt.Sprint(errs), "stage 5 audit ran") {
t.Fatalf("errors = %v", errs)
}
})
t.Run("placement", func(t *testing.T) {
dc := lineDomain("S5_ALL")
dc.AddRecordConfig(dc.MustNewRecordConfig("@", 300, "CNAME", "target.example.net."))
if errs := validateDomain(t, dc); !strings.Contains(fmt.Sprint(errs), "cannot create CNAME record for bare domain") {
t.Fatalf("errors = %v", errs)
}
})
t.Run("unknown", func(t *testing.T) {
dc := lineDomain("S5_ALL")
r := dc.MustNewRecordConfig("www", 300, "A", "192.0.2.1")
r.Type = "UNRECOGNIZED"
dc.AddRecordConfig(r)
if errs := validateDomain(t, dc); !strings.Contains(fmt.Sprint(errs), "unknown record type UNRECOGNIZED") {
t.Fatalf("errors = %v", errs)
}
})
t.Run("every provider", func(t *testing.T) {
dc := lineDomain("S5_ALL")
dc.DNSProviderInstances = append(dc.DNSProviderInstances, &models.DNSProviderInstance{Name: "second", ProviderType: "S5_DEFAULT"})
dc.AddRecordConfig(dc.MustNewRecordConfigParse("@", 300, "HINFO", `"CPU" "OS"`))
if errs := validateDomain(t, dc); !strings.Contains(fmt.Sprint(errs), "S5_DEFAULT does not support") {
t.Fatalf("errors = %v", errs)
}
})
t.Run("operational", func(t *testing.T) {
dc := lineDomain("S5_ALL")
dc.AutoDNSSEC = "on"
if err := checkProviderCapabilities(dc); err == nil || !strings.Contains(err.Error(), "AUTODNSSEC") {
t.Fatalf("error = %v", err)
}
})
}

func TestSupportedTypesBeforeAndAfterTransforms(t *testing.T) {
for _, provider := range []string{"S5_DEFAULT", "S5_IMPORT_ONLY", "S5_ALL"} {
t.Run(provider, func(t *testing.T) {
source := models.MustNewDomainConfig("source.example")
source.AddRecordConfig(source.MustNewRecordConfig("www", 300, "A", "192.0.2.1"))
dest := lineDomain(provider)
transform := "0.0.0.0~255.255.255.255~~0.0.0.0"
r := dest.MustNewRecordConfig("@", 300, privatetypes.TypeIMPORTTRANSFORM, transform, 300, "", source.Name)
r.Metadata["transform_table"] = transform
dest.AddRecordConfig(r)
config := &models.DNSConfig{Domains: []*models.DomainConfig{source, dest}}
if err := config.PostProcess(); err != nil {
t.Fatal(err)
}
errs := ValidateAndNormalizeConfig(config)
switch provider {
case "S5_DEFAULT":
if !strings.Contains(fmt.Sprint(errs), "uses IMPORT_TRANSFORM records") {
t.Fatalf("original pseudo-type not checked: %v", errs)
}
case "S5_IMPORT_ONLY":
if !strings.Contains(fmt.Sprint(errs), "uses A records") {
t.Fatalf("transformed records not checked: %v", errs)
}
case "S5_ALL":
if len(errs) != 0 || len(dest.Records) != 1 || dest.Records[0].Type != "A" {
t.Fatalf("transform failed: records=%v, errors=%v", dest.Records, errs)
}
}
})
}
}
Loading
Loading