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
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"…) :-(
Expand All @@ -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
Expand All @@ -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
Expand Down
32 changes: 32 additions & 0 deletions doc/ETCD-structure.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<domain>/-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 <zone> PRESIGNED 1` or by writing `<zone>/-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.
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/const.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ const (

const (
MetaMinimumSerial = "X-PE3-MINIMUM-SERIAL"
MetaFixedSerial = "X-PE3-FIXED-SERIAL"
)

type ipMetaT map[int]struct {
Expand Down
81 changes: 81 additions & 0 deletions src/dnssec_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
//go:build unit

/* Copyright 2016-2026 nix <https://keybase.io/nixn>

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)
}
}
20 changes: 18 additions & 2 deletions src/rr.go
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,22 @@
}
}

// 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)
Expand Down Expand Up @@ -303,8 +319,8 @@
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 {
Expand Down Expand Up @@ -335,7 +351,7 @@
params.SetContent(content, nil)
}

func parseOctets(value any, ipVer int, asPrefix bool) ([]byte, error) {

Check failure on line 354 in src/rr.go

View workflow job for this annotation

GitHub Actions / golangci-lint

cyclomatic complexity 60 of func `parseOctets` is high (> 30) (gocyclo)
//goland:noinspection GoPreferNilSlice
values := []any{}
sepFirst := false
Expand Down