Skip to content

NEW PROVIDER: NexDNS - #4685

Open
morozov-alexey wants to merge 3 commits into
DNSControl:mainfrom
morozov-alexey:nexdns
Open

NEW PROVIDER: NexDNS#4685
morozov-alexey wants to merge 3 commits into
DNSControl:mainfrom
morozov-alexey:nexdns

Conversation

@morozov-alexey

@morozov-alexey morozov-alexey commented Aug 2, 2026

Copy link
Copy Markdown
Collaborator
  • Invite accepted
  • Email to Faisal received
  • Remaining PR notes resolved

Adds a DNS provider for NexDNS. I run the service and will maintain the provider.

The API addresses one record value at a time, so the provider uses diff2.ByRecord(). A record's id is derived from its name, type and content, so an id stops resolving as soon as the record it names changes; each correction therefore uses only the id it read from the zone it is about to modify.

Supported: A, AAAA, ALIAS, CAA, CNAME, DNAME, DS, MX, NS, PTR, SRV, TLSA, TXT. The SOA record and the apex NS records are maintained by the platform and rejected by the API, so both are left out of the zone contents and the desired state, and DocDualHost is Cannot.

The integration suite passes: 249 cases, 0 failures. It needed two changes in integrationTest/integration_test.go, and I would rather flag them than have you find them.

NEXDNS is added to the not(...) list of the apex-NS group: the apex NS records follow the zone's nameserver group and the API rejects an edit, which is what NAMEDOTCOM and NETCUP are excluded for.

In "IGNORE with modify", one address changes from 10.10.10.10 to 9.9.9.9. That value is only the "before" of the change the following cases make (10 -> 11 -> 12 -> 13), so the range looks incidental to what the test checks, while an RFC 1918 address is refused outright by any provider serving public zones only; 9.9.9.9 is already used elsewhere in the same file. Happy to exclude NEXDNS from the group instead if you prefer, but that would drop the whole of the IGNORE semantics from this provider's coverage over one value the test does not depend on.

Please create the GitHub label 'provider-NEXDNS'.

@TomOnTime TomOnTime left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi there!

Thank you for submitting this new provider! We love to get new submissions!

I'd really like to add NexDNS to the automated testing we do on GHA. Is there a free tier or some way I can get an API key to do the testing? It doesn't have to be a real domain (sandbox or unregistered zone is fine). Since this is an all-volunteer project, we look for any free way to do this testing.

Thanks!
Tom

@morozov-alexey

Copy link
Copy Markdown
Collaborator Author

Hi Tom, happy to. There is now a free account for the project, with no expiry and nothing to pay.

  • Test zone: dnscontrol-test.dev, unregistered as you suggested, so nothing it does can affect real DNS.
  • Token scopes: zones.read, records.read, records.write. No zone creation, so the suite cannot spend the account's one-domain allowance.
  • 300 requests/minute, which the suite stays well under.

The pr_integration_tests.yml entries (NEXDNS_DOMAIN and NEXDNS_API_TOKEN) are already in this PR, so only the secret values are missing on your side.

Where would you like the token sent? Happy to use whatever you normally do for this.

@TomOnTime

Copy link
Copy Markdown
Collaborator

Fantastic!

I normally use SendSafely.com. Please send to my email address (tal at whatexit dot org). I'm also open to any other mechanism (1password, etc). SendSafely has a free tier.

Tom

@morozov-alexey

Copy link
Copy Markdown
Collaborator Author

Sent via SendSafely to your address.

@TomOnTime

Copy link
Copy Markdown
Collaborator

I received the creds. Thanks! I'll try to get to this in the next few days.

@TomOnTime

TomOnTime commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator

Code looks good. I'll merge this and then move it to the v5 branch.

There's a lot of unit tests for things that don't normally need testing. I suspect an LLM was used? I don't mind, that's allowed. However the code does need to be cleaned up a bit.

@TomOnTime

Copy link
Copy Markdown
Collaborator

Thank you for contributing this new provider, @morozov-alexey !

A few action items before we can merge this PR:

  1. By now you should have received a Github invite to have the "triage" role for this repo. Please accept the invite so we can assign bugs to you.
  2. @fm: Faisal Misle is our “liaison to maintainers”. Please send him an email from your preferred address to dnscontrol so we can stay in touch. Your email address will not be shared, only used for DNSControl communication. Please email f at faisal dot fm and CC tal at what exit dot org

Thanks again!
Tom

"customer-number": "$NETCUP_CUSTOMER_NUMBER",
"domain": "$NETCUP_DOMAIN"
},
"NEXDNS": {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please put this in alphabetical order. If you run bin/generate-all.sh it will do that for you.

// rejectif has a helper for the trailing case but not for this one, because the
// integration suite leaves leading whitespace untested - it is nonetheless
// trimmed the same way here.
func rejectTxtLeadingSpace(rc *models.RecordConfig) error {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems useful to others. Pleave move it to pkg/rejectif/txt.go

Comment on lines +104 to +110
// rejectCaaTag rejects CAA records whose property tag the API does not accept.
func rejectCaaTag(rc *models.RecordConfig) error {
if !slices.Contains(caaTags, rc.CaaTag) {
return fmt.Errorf("caa tag %q is not supported", rc.CaaTag)
}
return nil
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems useful to others. Please move it to pkg/rejectif/caa.go

"github.com/DNSControl/dnscontrol/v4/models"
)

func TestAuditRecords(t *testing.T) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Move these to pkg/rejectif/*_test.go as appropriate.


switch rc.Type {
case "MX":
req.Content = rc.GetTargetField()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please move "Content" to the end of each case. I know this sounds weird, but i try to keep these in the same order as they appear in the struct defined in https://codeberg.org/miekg/dns/src/branch/main/rdata/rdata.go

tc("Create some records",
a("foo", "1.1.1.1"),
a("foo", "10.10.10.10"),
a("foo", "9.9.9.9"),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
a("foo", "9.9.9.9"),
a("foo", "9.9.9.9"), // Use a non-RFC1918 address since some DSP reject internal IPs

Comment thread providers/nexdns/nexdns_test.go Outdated
Comment on lines +15 to +35
func TestNewNexdns(t *testing.T) {
if _, err := newNexdns(map[string]string{}, json.RawMessage{}); err == nil {
t.Error("expected an error when api_token is missing")
}

p, err := newNexdns(map[string]string{"api_token": "nxd_notarealtoken"}, json.RawMessage{})
if err != nil {
t.Fatalf("newNexdns() error = %v", err)
}
if got := p.(*nexdnsProvider).client.baseURL; got != defaultAPIURL {
t.Errorf("baseURL = %q, want %q", got, defaultAPIURL)
}

p, err = newNexdns(map[string]string{"api_token": "nxd_notarealtoken", "api_url": "https://api.example.com/v1"}, json.RawMessage{})
if err != nil {
t.Fatalf("newNexdns() error = %v", err)
}
if got := p.(*nexdnsProvider).client.baseURL; got != "https://api.example.com/v1" {
t.Errorf("baseURL = %q, want the configured one", got)
}
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Delete. Seems superfluous

Suggested change
func TestNewNexdns(t *testing.T) {
if _, err := newNexdns(map[string]string{}, json.RawMessage{}); err == nil {
t.Error("expected an error when api_token is missing")
}
p, err := newNexdns(map[string]string{"api_token": "nxd_notarealtoken"}, json.RawMessage{})
if err != nil {
t.Fatalf("newNexdns() error = %v", err)
}
if got := p.(*nexdnsProvider).client.baseURL; got != defaultAPIURL {
t.Errorf("baseURL = %q, want %q", got, defaultAPIURL)
}
p, err = newNexdns(map[string]string{"api_token": "nxd_notarealtoken", "api_url": "https://api.example.com/v1"}, json.RawMessage{})
if err != nil {
t.Fatalf("newNexdns() error = %v", err)
}
if got := p.(*nexdnsProvider).client.baseURL; got != "https://api.example.com/v1" {
t.Errorf("baseURL = %q, want the configured one", got)
}
}

@morozov-alexey

Copy link
Copy Markdown
Collaborator Author

Trimmed the tests down to the round trip, the apex NS filter and the audit rules. Left the retry in api.go since a push is one request per record, but happy to drop that too.

Invite accepted, and emailed Faisal.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants