Skip to content

feat: serve pre-signed DNSSEC zones (DNSKEY/RRSIG/NSEC* + PRESIGNED metadata)#4

Merged
nixn merged 2 commits into
nixn:masterfrom
jleal52:feat/dnssec-presigned
Jun 10, 2026
Merged

feat: serve pre-signed DNSSEC zones (DNSKEY/RRSIG/NSEC* + PRESIGNED metadata)#4
nixn merged 2 commits into
nixn:masterfrom
jleal52:feat/dnssec-presigned

Conversation

@jleal52

@jleal52 jleal52 commented May 4, 2026

Copy link
Copy Markdown
Contributor

Summary

Pre-signed DNSSEC support: any zone whose apex carries a DNSKEY record is served as PRESIGNED=1 with DNSKEY, RRSIG, NSEC, NSEC3, NSEC3PARAM, DS, CDS and CDNSKEY records passed back to PowerDNS verbatim. Detection is automatic (no config switch). Online signing is intentionally out of scope.

Motivation

Today, configuring remote-dnssec=yes against this backend produces a steady stream of unknown/unimplemented request: getDomainKeys errors and breaks ANY queries on apex names with an ALIAS (PowerDNS treats the empty-key answer as a sign the backend cannot serve the zone). The fix is small: implement the three DNSSEC-related backend methods PowerDNS probes (getDomainMetadata, getAllDomainMetadata, getDomainKeys) and accept the DNSSEC record types as opaque presentation-format strings that are stored once by an external signer and served unchanged.

This unlocks the pre-signed deployment model — sign offline (ldns-signzone, dnssec-signzone, OpenDNSSEC, ...), push the resulting records into ETCD with the same naming scheme, and let pdns-etcd3 serve them — without taking on the much larger surface of online signing (key storage, KASP, NSEC chain navigation, key rollover) or modifying any existing record path.

What changed

  • src/rr.goDNSKEY, RRSIG, NSEC, NSEC3, NSEC3PARAM, DS, CDS and CDNSKEY are added to both parses (regex that captures the entire content) and rrFuncs (a new passthrough(key) rrFunc that emits the value unchanged).
  • src/data.go — two helpers on *dataNode: hasDNSKEY() checks a single node, hasDNSKEYForZone(name) walks down from the data root with name normalisation (case-insensitive, trailing-dot tolerant) and returns whether the matching node has any DNSKEY at its apex.
  • src/pdns-etcd3.gohandleRequest now produces real responses for getalldomainmetadata (returns {"PRESIGNED":["1"]} when the zone is pre-signed, {} otherwise), getdomainmetadata (returns ["1"] only when the requested kind matches a present metadata key), and getdomainkeys (returns [], no longer an "unknown/unimplemented" error). The detection helper collectMetadata(zoneName) is the single source of truth.
  • src/dnssec_test.go — unit tests under the existing unit build tag covering the registration of the new types in both maps, presentation-format round-trip via parseContent for each type, the data-node helpers (with case-insensitive / trailing-dot variants), and the metadata producer (signed / unsigned / missing / empty-name).
  • README.md and doc/ETCD-structure.md — new "Pre-signed DNSSEC" subsections explaining the supported types, detection rule, a minimal example layout and the operational notes (re-sign cadence, atomic NSEC chain updates, serial bump). The Features list and the Planned section are adjusted accordingly.

Net diff: ~340 lines (~145 of which are tests and docs).

Out of scope (deliberate)

  • Online signing — keys are not stored in ETCD, no key management methods are implemented. Operators run their preferred external signer.
  • getBeforeAndAfterNamesAbsolute — denial-of-existence proofs for non-present names are not synthesised. NSEC3 with opt-out and pre-computed chain entries in ETCD covers the common case for static zones.
  • TSIGgetTSIGKey and friends remain unimplemented (orthogonal to pre-signing).

Tests

  • make unit-tests — green, including the four new test functions.
  • make integration-tests — passes for everything except TestParallelRequests, which is pre-existing flakiness on this branch's base (upstream/master also fails the same test ~2 of 3 runs in the same environment; the threshold dataRoot.readers.max < nCPU/2 is borderline on machines with many CPUs).
  • End-to-end smoke against a real ETCD 3.6.5 + the standalone HTTP listener: zone with two DNSKEYs + an RRSIG + an NSEC returns the records correctly via lookup, getalldomainmetadata returns {"PRESIGNED":["1"]} for the signed zone and {} for an unsigned sibling, getdomainmetadata PRESIGNED returns ["1"] for the signed zone, getdomainkeys returns [].

Backwards compatibility

  • No protocol changes. Existing zones without DNSKEY records continue to behave exactly as before (getalldomainmetadata returns {}, getdomainmetadata returns []).
  • No new configuration parameters. No changes to existing record types.
  • The previously-erroring getdomainkeys call now returns [] cleanly, removing the long-standing log noise even on deployments that do not enable DNSSEC.

@nixn

nixn commented May 4, 2026

Copy link
Copy Markdown
Owner

Thank you for the PR and the work you put into it.

I have to walk through your changes thoroughly and comment them. I screened your changes at a whole and see some points worth discussing. Please give me some time to do so.

I have already begun to implement DNSSEC live signing (currently not commit-ready, because it uses transactions and it is challenging to make it work). But I like the idea to first implement support for the pre-signed model, because it does not rely on transactions and apparently would make at least you happy :-) ; but I have to coordinate that work with the live-signing model (which is definitely on the planned features list).

Before the next "big change" (which will be some DNSSEC support) I would like to release the next development version (0.4.0), which has some important changes worth releasing. It should take place in a few days.

@jleal52

jleal52 commented May 4, 2026

Copy link
Copy Markdown
Contributor Author

Can confirm: solving my own problems does, in fact, make me happy 🙂 — happiness levels measurably rising on the operational side already.

No rush at all. Take whatever time you need; I'd rather have this land cleanly alongside the live-signing work than rushed. I'm happy to rebase on top of 0.4.0 once it's out and to revisit anything that conflicts with where you're heading on the transactional side.

@nixn

nixn commented May 6, 2026

Copy link
Copy Markdown
Owner

I have a question about signing, because I do not yet understand it yet in much detail and did not dive deep into documentation yet: When (pre-)signing the zone, is the serial part of the signature or not?

You have added a special handling or override for the SOA serial in the PR; is it because the serial is part of the signature and would invalidate the signature when changed automatically due to some unrelated or even effectless changes (e.g. change an ip-prefix from "1.2.3." to [1, 2, 3], leads to a serial change, but no data change)? Or can the serial change and the signature depends only on other data?

@jleal52

jleal52 commented May 7, 2026

Copy link
Copy Markdown
Contributor Author

Yes — the SOA serial is part of the signature, but only of the SOA RRSIG. DNSSEC signs each RRset independently, so RRSIG(A), RRSIG(MX), RRSIG(NS) etc. cover only their own RRset's RDATA and are unaffected by serial changes. Only RRSIG(SOA) breaks if the served serial differs from the one the signer saw — but that's enough for validating resolvers to reject the whole answer.

A correction on what you read: the PR does not actually add a serial override. The doc note in ETCD-structure.md ("override with an explicit serial in the SOA entry") is aspirational — soa() in src/rr.go still uses zoneRev() and the SOA plain-string regex hard-codes _ for the serial slot. My mistake; the doc is wrong.

To close the gap I'd add a real override: accept a numeric serial field in the SOA entry (JSON5/YAML, or a number instead of _ in plain-string); when set, soa() uses it verbatim, otherwise it falls back to zoneRev() as today. Happy to do that in this PR or as a follow-up — let me know which fits better with the live-signing work.

@nixn

nixn commented May 19, 2026

Copy link
Copy Markdown
Owner

Finally I have sorted and committed most of my local work, only the DNSSEC transactions stuff for live signing is still WIP. But this should not affect your PR, so you could now rebase it on the latest code. This current committed state should be the base for the release 0.4.0, your PR would be applied afterwards.

Some comments on your proposed changes (in no particular order):

  1. Metadata support is now implemented, so setting PRESIGNED=1 should be done the regular way instead of automatically, being dependent on DNSKEY. (hasDNSKEY would be not needed anymore). Also the NSEC params could now be stored as metadata, if needed.
  2. Live signing is a planned feature, no need to state that it is not supported (currently). If you wish, you can point to the planned feature section in README (it could be described there more in detail, like the split of presigned mode and live signing).
  3. There is no need to implement a "passthrough" mechanic. It is already given by the plain string records. If there is no regular expression for a specific RR type, there is also no parsing of it, so there is just no need to handle it, it is already passed as-is then (compare with TXT, it has object-support but no parsing support). If you like, you could add object-support to any subset of {DNSKEY, RRSIG, NSEC, NSEC3, NSEC3PARAM, DS, CDS, CDNSKEY}, if you do, you could also add parsing support (providing an appropriate regular expression), but this is also optional. You can also look in the tests for the "HINFO" or "TYPExxx" records, they are neither object-supported nor being parsed, which is the default for all RRs.
  4. For the serial field: Do we need an override possibility to make presigned mode work? If so, it could be done by setting the SOA record with the forced plain string (but this is not very convenient), it possibly could be done with an own metadata key, e.g. X-PE3-FIXED-SERIAL, which is applied to the SOA record when present. It could be changed within the same transaction as when some other RRs change (as you described in the operational notes). Even when not using DNSSEC, this metadata key could be useful for some people, who want to control the serial manually.
  5. Please check your documentation additions to not be the explanation of DNSSEC on itself, but only the usage of pdns-etcd3 for / in a DNSSEC scenario (it mostly is already that way, so don't take it too serious).

@jleal52 jleal52 force-pushed the feat/dnssec-presigned branch from 3f57448 to c9bd733 Compare May 19, 2026 10:22
@jleal52

jleal52 commented May 19, 2026

Copy link
Copy Markdown
Contributor Author

Rebased on latest master and reworked per feedback. Force-push incoming.

  • PRESIGNED=1 now rides the metadata mechanism on master — dropped my auto-detection (hasDNSKEY + custom getAllDomainMetadata/getDomainMetadata/getDomainKeys).
  • Dropped the passthrough rrFunc and dnssecPassthroughRE. The default plain-string path in processValuesEntry already serves DNSKEY/RRSIG/NSEC*/DS/CDS/CDNSKEY verbatim, just like HINFO/TYPExxx.
  • New X-PE3-FIXED-SERIAL metadata; soa() uses it (parsed as uint32) when set, falls back to zoneRev() otherwise.
  • Docs reframed around pdns-etcd3 usage only: a "Pre-signed DNSSEC" section with a setup checklist, a "Reserved X-PE3-* keys" subsection, and a SOA note pointing at the override. Planned item is now "Online DNSSEC signing".

Diff vs master: +57/-3 across 4 files plus `dnssec_test.go`. Unit tests, vet, and golangci-lint v2.9 clean (remaining lint warnings are all preexisting in master).

Comment thread doc/ETCD-structure.md Outdated
Comment thread doc/ETCD-structure.md Outdated
Comment thread doc/ETCD-structure.md Outdated
Comment thread src/dnssec_test.go Outdated
Comment thread src/rr.go Outdated
Comment thread README.md Outdated

@nixn nixn left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Please review my suggestions and comments.

nixn pushed a commit that referenced this pull request Jun 1, 2026
as described in PR #4 it is more flaky the more CPUs are available to it
@jleal52

jleal52 commented Jun 8, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review — all six points applied in 0ec6cb6:

  • Moved the serial helper out of the *rrParams receiver into a free soaSerial(*dataNode) function, since it's SOA-specific and shouldn't hang off the receiver shared by every RR type.
  • Dropped the redundant plain-string passthrough test — you're right, verbatim serving of alphanumeric-leading plain strings is already the default path and covered elsewhere.
  • Applied your four doc/README suggestions (X-PE3-MINIMUM-SERIAL note, the pre-signed intro, the NSEC-chain wording, and the trimmed Features bullet).

Pushed as a separate commit on top of the rebased branch so the delta from your review is easy to see.

jleal52 added 2 commits June 8, 2026 07:21
When set on a zone, this metadata key replaces the automatic
zoneRev-derived serial in the SOA record (parsed as uint32, per
RFC 1035). Invalid/missing values fall back to zoneRev() unchanged.

Motivation: pre-signed DNSSEC needs the served SOA serial to match
the one baked into RRSIG(SOA); otherwise validators reject the
answer. Also useful without DNSSEC for manual serial control.

Pre-signed zones otherwise need no new code: PRESIGNED=1 rides the
existing metadata mechanism, and DNSSEC record types (DNSKEY, RRSIG,
NSEC*, DS, CDS, CDNSKEY) fall through the default plain-string path
and are served verbatim.
- move soaSerial out of the *rrParams receiver into a free function
  (SOA-specific logic shouldn't hang off the receiver common to all RRs)
- drop the redundant plain-string passthrough test (verbatim serving of
  alphanumeric-leading plain strings is already covered by the default path)
- docs: tighten the X-PE3-MINIMUM-SERIAL note, the pre-signed intro and the
  NSEC-chain wording; trim the README Features bullet
@jleal52 jleal52 force-pushed the feat/dnssec-presigned branch from 0ec6cb6 to 26e1a68 Compare June 8, 2026 05:23
@nixn nixn merged commit 8bb4eff into nixn:master Jun 10, 2026
24 checks passed
@nixn

nixn commented Jun 10, 2026

Copy link
Copy Markdown
Owner

Thank you for your work.

There should be a test for DNSSEC in pre-signed mode, but (again) the main work was implementing the support, which is now merged. I have already put work into live signing (heads up: it seems to basically work) and also added testing for it, so I would write the test myself, because the base work for it is done already (unfortunately that was not straightforward); no need for you to waste time on that. I really admire your courage to use pdns-etcd3 in production, please feel free to report any further issue with that.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants