diff --git a/README.md b/README.md index c25d9e7..8ceb25b 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,11 @@ the fourth development release, considered alpha quality. Any testing is appreci * [Multi-level defaults and options](doc/ETCD-structure.md#defaults-and-options), overridable * [Domain metadata](https://doc.powerdns.com/authoritative/domainmetadata.html) * can also be read and modified with the command line tool `pdnsutil` (`pdnssec` in v3.4) +* [Pre-signed DNSSEC zones](doc/ETCD-structure.md#pre-signed-dnssec) + * an external signer (e.g. `ldns-signzone`, `dnssec-signzone`, OpenDNSSEC) produces the signed records (`RRSIG`, `NSEC`/`NSEC3`, `DNSKEY`, …) and stores them in ETCD just like any other record + * `PRESIGNED=1` is set as [metadata](doc/ETCD-structure.md#metadata) on the zone + * the served SOA serial can be pinned with the [`X-PE3-FIXED-SERIAL`](doc/ETCD-structure.md#metadata) metadata to match the value baked into `RRSIG(SOA)` by the signer + * online signing is still [planned](#planned) * [Upgrade data structure](doc/ETCD-structure.md#upgrading) (if needed for new program version) without interrupting service * Run [standalone](#standalone-modes) for usage as a [Unix or HTTP connector][pdns-remote-usage] * This could be needed for big data sets, because the initialization from PowerDNS is done lazily (at least as of v4) on first request (which possibly could time out on "big data"…) :-( @@ -59,7 +64,8 @@ the fourth development release, considered alpha quality. Any testing is appreci * "Labels" for selectively applying defaults and/or options to record entries * sth. like `com/example/-options-ptr` → `{"auto-ptr": true}` and `com/example/www/-options-collect` → `{"collect": …}` for `com/example/www-1/A+ptr+collect` without global options * precedence betweeen QTYPE and id (id > label > QTYPE) -* DNSSEC support ([PowerDNS DNSSEC-specific calls][pdns-dnssec]) +* Online DNSSEC signing ([PowerDNS DNSSEC-specific calls][pdns-dnssec]) + * pre-signed zones are already supported, see [Features](#features) and [ETCD structure](doc/ETCD-structure.md#pre-signed-dnssec) * [Search][pdns-search] support [pdns-dnssec]: https://doc.powerdns.com/authoritative/appendices/backend-writers-guide.html#dnssec-support @@ -83,7 +89,7 @@ the fourth development release, considered alpha quality. Any testing is appreci ### Overview over the support of optional [PDNS features in a remote backend][pdns-remote]: * Primary and (Auto)Secondary: no * AXFR support: not yet -* DNSSEC (live-signing): not yet (planned feature) +* DNSSEC: pre-signed yes, live-signing not yet (planned feature) * Metadata: yes * Search (web API): not yet (planned feature) * API lookup (for web): not yet diff --git a/doc/ETCD-structure.md b/doc/ETCD-structure.md index fcccaaa..1baaed4 100644 --- a/doc/ETCD-structure.md +++ b/doc/ETCD-structure.md @@ -297,11 +297,41 @@ this is especially important when using user-defined keys, beginning with `X-`. The values of metadata entries are always read as strings and not modified in any way, just passed as-is to PDNS. +### Reserved `X-PE3-*` keys + +Metadata keys starting with `X-PE3-` are reserved for use by this backend. They are not forwarded to PowerDNS as ordinary metadata (PDNS already ignores unknown `X-` keys), but they alter how pdns-etcd3 serves the zone: + +* `X-PE3-FIXED-SERIAL` — when set on a zone, its first value (parsed as an unsigned 32-bit integer per [RFC 1035][rfc1035]) replaces the automatic [zone-revision serial](#soa) in the served `SOA` record. Anything that does not parse (out of range, unparseable, empty) is logged and the automatic serial is used instead. + * The main use case is [pre-signed DNSSEC](#pre-signed-dnssec): the served `SOA` serial must match the value the external signer baked into `RRSIG(SOA)`, otherwise validating resolvers reject the answer. + * It is also useful without DNSSEC, for operators who want to control the serial manually (e.g. to mirror an existing zone byte-for-byte). +* `X-PE3-MINIMUM-SERIAL` — zone serial handling. It is added/removed automatically by transactions to ensure the zone revision (and thus the serial) never moves backwards across a key-delete. Operators should write this key by hand, when they are only deleting keys manually. + +[rfc1035]: https://datatracker.ietf.org/doc/html/rfc1035#section-3.3.13 + ## Locks Every zone can be locked for transactions. These entries have a prefix of `/-lock-` and are handled automatically, there is no need to create or delete them manually. They are not part of the automatic SOA serial determination. +## Pre-signed DNSSEC + +pdns-etcd3 supports DNSSEC currently only in the [pre-signed (front-signing)][rfc4035-presigned] model: an external signer (`ldns-signzone`, `dnssec-signzone`, OpenDNSSEC, …) produces the signed records and pushes them into ETCD under the same key layout as ordinary records. The backend itself does not sign anything yet; [online signing](https://doc.powerdns.com/authoritative/dnssec/modes-of-operation.html) is on the [Planned](../README.md#planned) list. + +Setup checklist: + +1. Store each signed record as a plain presentation-format string under its name and qtype. The relevant qtypes (`DNSKEY`, `RRSIG`, `NSEC`, `NSEC3`, `NSEC3PARAM`, `DS`, `CDS`, `CDNSKEY`) are not object-supported and have no plain-string parser — they are passed through to PowerDNS unchanged, which is exactly what pre-signed zones need. + * As always, [start such values with a backtick](#resource-record-values) if the first character is non-alphanumeric — for example `RRSIG` entries that begin with whitespace, parentheses or a leading sign character. +2. Set `PRESIGNED=1` as ordinary metadata on the zone, e.g. via `pdnsutil set-meta PRESIGNED 1` or by writing `/-metadata-/PRESIGNED` directly. PowerDNS will then serve `RRSIG`/`NSEC`/`DNSKEY` records from the backend instead of trying to sign on the fly. +3. (Recommended) Set [`X-PE3-FIXED-SERIAL`](#reserved-x-pe3--keys) on the zone to the serial value the signer wrote into `SOA` / `RRSIG(SOA)`. Update it in the same transaction as the new signed RRsets so the served serial stays consistent with the signatures. + +Operational notes: + +* Re-signing happens outside the backend. When a re-sign batch lands in ETCD, the zone is reloaded as usual. +* `NSEC`/`NSEC3` chains must remain consistent at all times. Push chain updates atomically (e.g. using a single ETCD transaction or [transaction locks](#locks)) so resolvers never observe a broken denial-of-existence chain. +* `getDomainKeys` is not implemented — pdns-etcd3 does not store DNSSEC private keys. This is fine for `PRESIGNED=1` zones; it is the missing piece for online signing. + +[rfc4035-presigned]: https://datatracker.ietf.org/doc/html/rfc4035 + ## Supported records For each of the supported record types the entry values may be objects. @@ -459,6 +489,8 @@ as described in syntax for "domain name"! It also can be only the local part (wi There is no serial field, because the program takes the latest modification revision of the zone as serial. This way the operator does not have to increase it manually each time he/she changes DNS data. +If the automatic serial does not fit the use case (e.g. [pre-signed DNSSEC zones](#pre-signed-dnssec) where the serial must match the value the signer put into `RRSIG(SOA)`, or operators wanting full manual control), set the [`X-PE3-FIXED-SERIAL`](#reserved-x-pe3--keys) metadata on the zone. Its value (parsed as an unsigned 32-bit integer per RFC 1035) is then served verbatim in the `SOA` record. With no such metadata, the automatic serial is used. + Options: * `no-aa` or `not-authoritative`: boolean * don't set the AA-bit for this zone, when set to true diff --git a/src/const.go b/src/const.go index 2a41c48..b387d24 100644 --- a/src/const.go +++ b/src/const.go @@ -63,6 +63,7 @@ const ( const ( MetaMinimumSerial = "X-PE3-MINIMUM-SERIAL" + MetaFixedSerial = "X-PE3-FIXED-SERIAL" ) type ipMetaT map[int]struct { diff --git a/src/dnssec_test.go b/src/dnssec_test.go new file mode 100644 index 0000000..848301b --- /dev/null +++ b/src/dnssec_test.go @@ -0,0 +1,81 @@ +//go:build unit + +/* Copyright 2016-2026 nix + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. */ + +package src + +import ( + "fmt" + "testing" +) + +// TestFixedSerial: MetaFixedSerial metadata overrides zoneRev() in the SOA serial. +func TestFixedSerial(t *testing.T) { + type meta = []string + for i, spec := range []test[meta, int64]{ + // no metadata at all → zoneRev fallback (maxRev=42 set below) + {nil, ve[int64]{v: 42}}, + // empty list → zoneRev fallback + {meta{}, ve[int64]{v: 42}}, + // valid override + {meta{"123456"}, ve[int64]{v: 123456}}, + // whitespace is trimmed + {meta{" 2026010101 "}, ve[int64]{v: 2026010101}}, + // uint32 boundary still accepted + {meta{"4294967295"}, ve[int64]{v: 4294967295}}, + // out of uint32 range → zoneRev fallback + {meta{"4294967296"}, ve[int64]{v: 42}}, + // negative / unparseable → zoneRev fallback + {meta{"-1"}, ve[int64]{v: 42}}, + {meta{"abc"}, ve[int64]{v: 42}}, + // extra entries are ignored — first one wins + {meta{"7", "8", "9"}, ve[int64]{v: 7}}, + } { + tf := func(_ *testing.T, in meta) (int64, error) { + dn := newDataNode(nil, "", "TEST/", false) + dn.maxRev = 42 + if in != nil { + dn.metadata[MetaFixedSerial] = in + } + return soaSerial(dn), nil + } + checkRun(t, fmt.Sprintf("(%d)%v", i+1, spec.input), tf, spec.input, spec.expected, false) + } +} + +// TestSOAFixedSerialThroughProcessValues: the override actually lands in the served SOA content. +func TestSOAFixedSerialThroughProcessValues(t *testing.T) { + RootLog.ChildLog("data").SetLevel(10) + root := newDataNode(nil, "", "TEST/", false) + zone := root.getChildCreate([]namePart{{"tld", ""}}) + zone.metadata[MetaFixedSerial] = []string{"2026051901"} + soaValues := objectValueType{ + "primary": "ns", + "mail": "horst.master", + "refresh": "1h", + "retry": "30m", + "expire": 604800, + "neg-ttl": "10m", + } + zone.processValuesEntry("SOA", "", &valueType{key: "SOA", content: soaValues}) + got, ok := zone.records["SOA"][""] + if !ok { + t.Fatalf("expected an SOA record, got none") + } + want := `ns.tld. horst\.master.tld. 2026051901 3600 1800 604800 600` + if got.content != want { + t.Errorf("SOA content mismatch:\n got: %q\nwant: %q", got.content, want) + } +} diff --git a/src/rr.go b/src/rr.go index 25c3c66..739b5dd 100644 --- a/src/rr.go +++ b/src/rr.go @@ -267,6 +267,22 @@ func domainName(key string) rrFunc { } } +// soaSerial returns MetaFixedSerial (if set and a valid uint32) or data.zoneRev() otherwise. +func soaSerial(data *dataNode) int64 { + values, ok := data.metadata[MetaFixedSerial] + if !ok || len(values) == 0 { + return data.zoneRev() + } + raw := strings.TrimSpace(values[0]) + v, err := strconv.ParseUint(raw, 10, 32) + if err != nil { + data.Logf(WarningLevel)("invalid %s metadata, falling back to zoneRev", MetaFixedSerial)("value", raw, "err", err) + return data.zoneRev() + } + data.Logf(3)("using %s metadata as SOA serial", MetaFixedSerial)("serial", v) + return int64(v) +} + func soa(params *rrParams) { // primary primary, vPath, err := getValue[string]("primary", params) @@ -303,8 +319,8 @@ func soa(params *rrParams) { params.Logf(ErrorLevel)("failed to append zone domain to 'mail': %v", err)("vp", Supplier1(ptr2strS, vPath)) return } - // serial - serial := params.data.zoneRev() // no need for findZone(), because SOA defines the zone + // serial: MetaFixedSerial overrides zoneRev (e.g. to match RRSIG(SOA) in pre-signed mode). + serial := soaSerial(params.data) // refresh refresh, vPath, err := getDuration("refresh", params) if vPath == nil || err != nil {