Skip to content

AXFRDDNS: convert to codeberg.org/miekg/dns (dnsv2) - #4658

Merged
TomOnTime merged 2 commits into
DNSControl:release_candidate_v5from
shuvamk:axfrddns-dnsv2
Aug 2, 2026
Merged

AXFRDDNS: convert to codeberg.org/miekg/dns (dnsv2)#4658
TomOnTime merged 2 commits into
DNSControl:release_candidate_v5from
shuvamk:axfrddns-dnsv2

Conversation

@shuvamk

@shuvamk shuvamk commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Fixes #4358. Based on release_candidate_v5, as you asked on the issue.

#4529 had already done the record-conversion side, so what was left was the network layer: dnsv1.Conn, dnsv1.Transfer, dnsv1.Client and the RFC 2136 message builders. providers/axfrddns no longer imports github.com/miekg/dns.

Three places where the dnsv2 API forced a behaviour change

1. Single-message AXFR would have taken 30 seconds. This is the one worth reading.

transferInAXFR only checks for the terminating SOA when options.TimersOnly is true, and it sets TimersOnly at the end of the first loop iteration (transfer.go:174-186). v1's inAxfr flips first before the isSOALast check (xfr.go:117-131), so v1 returns on the closing SOA even when the whole zone arrives in one message. Under dnsv2 the read loop just keeps going until the server closes or ReadTimeout (30 s) expires.

Measured against BIND 9.20 in a container, allow-transfer any:

8-record zone (1 message) 1208-record zone (3 messages)
release_candidate_v5 (dnsv1) 4.4 ms 17.1 ms
this branch, before the fix 30.0 s 18 ms
this branch as it stands 2.0–4.9 ms 14.8 ms

transfer-message-size defaults to 20480 bytes, so a typical AXFRDDNS zone fits in one message and this would have cost 30 s on every preview and every push — including the integration tests.

FetchZoneRecords now latches once the accumulated answer ends in a SOA and closes the connection. It drains the remaining envelopes rather than breaking, because TransferInWithConn hands back an unbuffered channel — with a bare break, a server that sends a spurious envelope after the closing SOA parks the library's reader goroutine forever on ch <- (16 of 20 transfers, measured). Pinned by TestFetchZoneRecordsReturnsOnTheClosingSOA.

The real fix is one line upstream in your forktransferInAXFR should make the terminating-SOA check on the first iteration too, the way v1's inAxfr does. Every other dnsv2 AXFR caller has this stall today. The provider-side latch stays correct either way, so this doesn't block on it, but you may prefer to fix it there and simplify here.

2. A connection dropped mid-AXFR used to be an error and silently became a success. dnsv1.Transfer reported a read error on the envelope channel; dnsv2 closes the channel silently. Left alone, a truncated transfer would have been accepted as a complete zone and every record that didn't arrive would have been diffed as a deletion. FetchZoneRecords now requires the closing SOA of RFC 5936 §2.2 before trusting the answer, which restores the v1 failure. The guard counts records rather than messages, since a server may answer the opening SOA in a message of its own (v1 special-cased exactly that at xfr.go:114-118) — TestFetchZoneRecordsDoesNotStopOnTheOpeningSOA pins that boundary.

3. Refused-transfer error text changed. Now refused to transfer the zone example.com: dns: bad rcode: NOTAUTH rather than ...: NOT AUTH (9). The old text came from matching msg.Error.Error() against the literal "dns: bad xfr rcode: 9", which dnsv2 no longer produces.

TSIG

There's no TsigSecret map in dnsv2 — a Client carries a TSIGSigner. So Key.secret now holds the decoded secret rather than its base64 form (readKey already validated the encoding, it just threw the result away), and Key.signer() returns dnsv2.HmacTSIG, or md5Provider for HMAC-MD5, which HmacTSIG doesn't implement. md5Provider was already carrying HMAC-MD5 for the same reason under v1 and is reshaped to the TSIGSigner interface.

Client.Exchange neither signs nor verifies, unlike v1's, so the update path calls dnsv2.TSIGSign before the exchange and dnsv2.TSIGVerify on the reply. As in v1 (client.go:267, which verifies only when the reply carries a TSIG), an unsigned reply is still not rejected while a wrongly-signed one is — both directions are pinned by TestBuildCorrection.

RFC 2136 builders

dnsv2 has no Msg.SetUpdate/Insert/Remove/RemoveName, so newUpdate, insert, remove and removeName reimplement them locally against RFC 2136 §2.3, §2.5.1, §2.5.3 and §2.5.4.

Verified by construction rather than by inspection: the same update built both ways and packed, for A, AAAA, CNAME, MX, NS, TXT, SRV, CAA, PTR, NAPTR and TLSA across all three operations, is byte-for-byte identical. A message holding all eleven is identical in length and decodes identically under the v1 parser, differing only in which of two equivalent name-compression offsets it picks.

Tests

providers/axfrddns/axfrddnsProvider_test.go is new — the provider's first tests. TSIG key parsing and algorithm mapping; AXFR against an in-process nameserver unauthenticated and under HMAC-SHA256/SHA512/MD5; rejection of a wrong TSIG secret, a truncated transfer and a reply signed with the wrong key; acceptance of an unsigned reply; the closing-SOA return; a zone whose opening SOA arrives in a message of its own; the reported text of a refused transfer; the RFC 2136 encoding of each update operation; message chunking; and a DDNS update pushed to a server that verifies the TSIG and replies signed.

Each behaviour above was checked by mutation — perturb the source, confirm the matching test goes red, restore, confirm green.

go test ./... 71 ok / 0 fail. go vet clean, staticcheck clean, golangci-lint clean apart from the pre-existing pkg/js/hash.go:21 finding that already carries a //lint:ignore. -race and -count=5 clean on the package. bin/generate-all.sh leaves the tree empty. gofmt -l clean on providers/axfrddns/. BIND integration test passes.

Two things you may want to know

  • A bug in your fork, not reachable from here. dnsv2.Msg.Pack() fails with dns packing: overflow RR header on a zero-rdata ANY RR whose owner name doesn't compress against the question name — e.g. zone example.com. with name x.net., or zone example.org. with name a.example.com.. removeName produces exactly that RR shape. It can't be hit from axfrddns because owner names always come from dc and are always in-zone, so I've left the code alone.
  • Sequencing with TEST: Add golden-file tests for provider record conversion #4653. That PR deliberately did not extract axfrddns's converter, because this branch rewrites the same function. Worth landing this one first.

go.mod and go.sum are untouched — both modules were already there.

Converts providers/axfrddns from github.com/miekg/dns to
codeberg.org/miekg/dns. providers/mythicbeasts still imports v1, as do
models/{makers,quotes,record,t_loc,t_svcb,target}.go,
pkg/dnsrr/{dnsrr,v1v2}.go and pkg/normalize/validate.go.

DNSControl#4529 had already converted the record-conversion side; what was left
was the network layer: dnsv1.Conn, dnsv1.Transfer, dnsv1.Client and the
RFC 2136 message builders.

The dnsv2 network API differs from v1 in three ways that shaped this
diff:

* Zone transfer. dnsv1.Transfer{Conn: ...} becomes
  Client.TransferInWithConn(ctx, msg, conn), so getAxfrConnection now
  returns the raw net.Conn it already dialled and the TLS/unix/tcp
  branches are unchanged. Envelope.RR became Envelope.Answer.

* TSIG. There is no TsigSecret map; a Client carries a TSIGSigner.
  Key.secret therefore holds the decoded secret rather than its base64
  form (readKey already validated the encoding, it just discarded the
  result), and Key.signer() returns dnsv2.HmacTSIG, or md5Provider for
  HMAC-MD5, which dnsv2.HmacTSIG does not implement. md5Provider was
  already carrying HMAC-MD5 for the same reason under v1 and is
  reshaped to the TSIGSigner interface (Sign/Verify/Key).
  Client.Exchange does not sign or verify, unlike v1's, so the update
  path calls dnsv2.TSIGSign before the exchange and dnsv2.TSIGVerify on
  the reply. As in v1 (client.go:267, which verifies only when the
  reply carries a TSIG), an unsigned reply is not rejected; both
  directions of that gate are pinned by TestBuildCorrection.

* RFC 2136 message builders. dnsv2 has no Msg.SetUpdate/Insert/Remove/
  RemoveName, so newUpdate, insert, remove and removeName reimplement
  them locally against RFC 2136 sections 2.3, 2.5.1, 2.5.3 and 2.5.4.
  Verified by building the same update both ways and packing both: for
  A, AAAA, CNAME, MX, NS, TXT, SRV, CAA, PTR, NAPTR and TLSA the
  v1-built and v2-built messages are identical byte for byte in all
  three operations, and a message holding all eleven is identical in
  length and decodes identically under the v1 parser, differing only in
  which of two equivalent name-compression offsets is used.

Three behaviour changes are deliberate:

* FetchZoneRecords closes the connection itself once the closing SOA
  has arrived. dnsv1's inAxfr leaves its first iteration before it
  looks for that SOA, so it returns as soon as the SOA arrives even
  when the whole zone fits in a single message. dnsv2's transferInAXFR
  only makes that check once options.TimersOnly is set, and it sets it
  at the end of the first iteration, so a single-message AXFR loops
  once more and blocks reading until the server closes the connection
  or Client.ReadTimeout (dnsTimeout, 30s) expires. Measured against
  BIND 9.20 serving example.com over TCP with allow-transfer any: 4 ms
  under v1, 30.0 s under a straight translation, both returning the
  same 8 records. BIND's transfer-message-size defaults to 20480
  bytes, so a typical AXFRDDNS zone arrives in one message and this
  would have cost 30 s on every preview and every push. Closing the
  connection on the SOA restores the v1 timing (4 ms, 8 records) and
  leaves multi-message transfers untouched: a 1204-record zone
  transfers in two messages in 17 ms either way. The remaining
  envelopes are drained so that the library's reader goroutine is never
  left blocked on the channel. Pinned by
  TestFetchZoneRecordsReturnsOnTheClosingSOA, whose nameserver answers
  in a single message and then holds the connection open.

* dnsv1.Transfer reported a read error on the envelope channel; dnsv2
  closes the channel silently, so a connection dropped mid-AXFR would
  be accepted as a complete zone and every missing record would be
  diffed as a deletion. FetchZoneRecords now requires the closing SOA
  of RFC 5936 section 2.2 before trusting the answer, which restores
  the v1 failure. The guard counts records rather than messages, since
  a server may answer the opening SOA in a message of its own;
  TestFetchZoneRecordsDoesNotStopOnTheOpeningSOA pins that boundary.

* A refused transfer is reported as
  "... refused to transfer the zone example.com: dns: bad rcode: NOTAUTH"
  rather than "...: NOT AUTH (9)". The old text came from matching
  msg.Error.Error() against the literal "dns: bad xfr rcode: 9", which
  dnsv2 no longer produces. Pinned by
  TestFetchZoneRecordsReportsRefusedTransfer.

Adds providers/axfrddns/axfrddnsProvider_test.go, the provider's first
tests: TSIG key parsing and algorithm mapping, AXFR against an
in-process nameserver unauthenticated and under HMAC-SHA256/SHA512/MD5
with the nameserver asserting that the request carried a TSIG,
rejection of a wrong TSIG secret, of a truncated transfer and of a
reply signed with the wrong key, acceptance of an unsigned reply, a
zone whose opening SOA arrives in a message of its own, return on the
closing SOA while the nameserver holds the connection open, the
reported text of a refused transfer, the RFC 2136 encoding of each
update operation, the splitting of an update that exceeds the 16 KiB
chunk size over several messages, and a DDNS update pushed to an
in-process nameserver that verifies the TSIG and replies signed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@TomOnTime

Copy link
Copy Markdown
Collaborator

Hey @hnrgrgr! Are you ok with these changes? They pass all tests.

shuvamk added a commit to shuvamk/dnscontrol that referenced this pull request Aug 2, 2026
…arness

Each file adapts one provider's record conversion functions to CheckToRC /
CheckToNative and names the recorded data after the provider and the function
under test. All 22 tests skip today ("<name> has no recorded data yet"), so
dropping a -record capture into providers/<x>/testdata/ and running
"go test ./providers/<x>/ -update" produces the goldens with no further code.

The adapters are per-provider because the signatures are: cloudflare's
nativeToRecord is a method; ns1's convert, route53's and azure's
nativeToRecords and gandiv5's nativeToRecords already return several records;
gcloud's nativeToRecord takes one rdata string out of a set, so the adapter
loops over set.Rrdatas the way getZoneSets does; digitalocean's and netlify's
toReq return no error; cnr's createRecordString is a method that also needs the
domain; vercel's toVercelCreateRequest needs the domain; luadns's
recordsToNative takes a slice but maps each record independently, so a
one-element slice exercises it faithfully.

Providers of the 21 that are not enrolled here, and why:

  BIND          ParseZoneContents takes a whole zone file, not a record
  MYTHICBEASTS  zoneFileToRecords takes an io.Reader
  AXFRDDNS      no separable converter (DNSControl#4658 rewrites it)
  POWERDNS      toRecordConfig needs the RRset's name/TTL/type, but
                RecordConfig.Original holds only zones.Record{Content,Disabled};
                buildRecordList is the only encoder and it takes a diff2.Change
  NETNOD        same on both counts: Original is
                netnodPrimaryDNS.Record{Content,Disabled}

CNR and TRANSIP get the toNative half only: CNR sets Original to
deleteRecordString(rc), a string rather than the map[string]string toRC reads,
and TRANSIP never sets Original at all, so -record cannot produce a .json for
either.

The set-level encoders are left out because CheckToNative converts one record at
a time: gandiv5's recordsToNative, azuredns's and azureprivatedns's
recordToNativeDiff2, gcloud's mkRRSs and ns1's buildRecord. Each merges a whole
recordset into one native, so calling it with a one-element slice would not
exercise what it is for.

testDomain is "example.com" in every file; it has to match the zone the data
was recorded against.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@TomOnTime

Copy link
Copy Markdown
Collaborator

All tests pass. I'm merging. Sorry for the fast pace but this PR is blocking other work.

@TomOnTime
TomOnTime merged commit 108cf31 into DNSControl:release_candidate_v5 Aug 2, 2026
7 checks passed
@shuvamk
shuvamk deleted the axfrddns-dnsv2 branch August 2, 2026 17:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Development

Successfully merging this pull request may close these issues.

2 participants