Add DNSSEC signing, key backups and Porkbun support

Zones get a DNSSEC setting: named signs them with BIND's default (or a
custom) dnssec-policy and inline signing, with keys in a per-view key
directory. Jarl shows the keys, the DS record and when it is safe to
publish it, checks the parent's DS records (check-ds, which also tells
named), and refuses steps that would break a signed domain: switching
straight to unsigned, finishing a turn-off too early, deleting or moving
a signed zone, TTLs above max-zone-ttl. Turning off goes through the
insecure policy; wipe starts over once the parent has no DS.

Backups store the DNSSEC keys and restore puts them back for named; the
backup unit gets CAP_DAC_READ_SEARCH to read them. Jarl raises a zone's
serial above named's whenever it writes an older one (after a restore).
The serials check compares the signed serial.

With a Porkbun API key in jarl.yaml, the registrar's name servers, glue
records and DS records can be shown and changed, each change checked
first (dry run, force to override).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
abrown38andClaude Opus 5.5 committed 2026-10-08 21:19:04 +00:00
1 parent 293e6ddd33
commit 5630e7a848
72 files changed
+5760 -153

No files matched your search

+46 -10
View File
@@ -223,6 +223,18 @@ func (t *Tools) ReloadZone(ctx context.Context, zone, view string) error {
return err
}
// CheckDS tells named that the parent zone has published (or withdrawn)
// the DS record for key tag of a zone it signs (rndc dnssec -checkds).
func (t *Tools) CheckDS(ctx context.Context, zone, view string, tag uint16, alg uint8, published bool) (string, error) {
which := "withdrawn"
if published {
which = "published"
}
z := zoneArgs("", zone, view)[1:]
args := append([]string{"dnssec", "-checkds", "-key", strconv.Itoa(int(tag)), "-alg", strconv.Itoa(int(alg)), which}, z...)
return t.RNDCRun(ctx, args...)
}
func zoneArgs(cmd, zone, view string) []string {
z := strings.TrimSuffix(zone, ".")
if z == "" {
@@ -236,16 +248,32 @@ func zoneArgs(cmd, zone, view string) []string {
// ZoneStatus is the result of rndc zonestatus.
type ZoneStatus struct {
Name string `json:"name"`
Type string `json:"type"`
Files string `json:"files"`
Serial uint32 `json:"serial"`
HasSerial bool `json:"-"`
Nodes int `json:"nodes"`
LastLoaded string `json:"last_loaded"`
Secure bool `json:"secure"`
Dynamic bool `json:"dynamic"`
Raw string `json:"raw"`
Name string `json:"name"`
Type string `json:"type"`
Files string `json:"files"`
// Serial is the serial of the zone file named loaded. For a zone
// named signs (inline signing) that is the unsigned zone Jarl wrote;
// the signed zone that named serves has its own serial, SignedSerial.
Serial uint32 `json:"serial"`
HasSerial bool `json:"-"`
SignedSerial uint32 `json:"signed_serial,omitempty"`
HasSignedSerial bool `json:"-"`
Nodes int `json:"nodes"`
LastLoaded string `json:"last_loaded"`
Secure bool `json:"secure"`
InlineSigning bool `json:"inline_signing"`
NextKeyEvent string `json:"next_key_event,omitempty"`
Dynamic bool `json:"dynamic"`
Raw string `json:"raw"`
}
// ServedSerial is the serial named answers queries with: the signed
// zone's for a zone it signs, otherwise the loaded one.
func (zs *ZoneStatus) ServedSerial() (uint32, bool) {
if zs.HasSignedSerial {
return zs.SignedSerial, true
}
return zs.Serial, zs.HasSerial
}
// ZoneStatus returns named's view of a zone.
@@ -277,6 +305,14 @@ func ParseZoneStatus(out string) *ZoneStatus {
if n, err := strconv.ParseUint(v, 10, 32); err == nil {
zs.Serial, zs.HasSerial = uint32(n), true
}
case "signed serial":
if n, err := strconv.ParseUint(v, 10, 32); err == nil {
zs.SignedSerial, zs.HasSignedSerial = uint32(n), true
}
case "inline signing":
zs.InlineSigning = v == "yes"
case "next key event":
zs.NextKeyEvent = v
case "nodes":
zs.Nodes, _ = strconv.Atoi(v)
case "last loaded":
+30
View File
@@ -17,3 +17,33 @@ zone example.com/IN/internal: loaded serial 1
t.Errorf("exit 1: %+v", bad)
}
}
func TestParseZoneStatusSigned(t *testing.T) {
// rndc zonestatus of a zone named signs with dnssec-policy (BIND 9.20).
zs := ParseZoneStatus(`name: example.test
type: primary
files: /var/lib/bind/jarl/_default/example.test.zone
serial: 2026100802
signed serial: 2026100804
nodes: 4
last loaded: Thu, 08 Oct 2026 19:03:31 GMT
secure: yes
inline signing: yes
key maintenance: automatic
next key event: Thu, 08 Oct 2026 21:08:31 GMT
next resign node: new.example.test/A
next resign time: Sat, 17 Oct 2026 07:32:59 GMT
dynamic: no
reconfigurable via modzone: no
`)
if zs.Serial != 2026100802 || zs.SignedSerial != 2026100804 || !zs.Secure || !zs.InlineSigning || zs.NextKeyEvent != "Thu, 08 Oct 2026 21:08:31 GMT" {
t.Errorf("%+v", zs)
}
if s, ok := zs.ServedSerial(); !ok || s != 2026100804 {
t.Errorf("served serial %d %v", s, ok)
}
plain := ParseZoneStatus("name: example.com\nserial: 7\nsecure: no\n")
if s, ok := plain.ServedSerial(); !ok || s != 7 || plain.HasSignedSerial {
t.Errorf("unsigned: %+v", plain)
}
}