Files
abrown38andClaude Opus 5.5 5630e7a848 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>
2026-10-08 21:19:04 +00:00

71 lines
2.4 KiB
Go

package validate
import (
"testing"
"jarl/internal/model"
)
func TestDNSSEC(t *testing.T) {
b := newBuilder()
ok := b.primary("signed.example", nil)
ok.DNSSECPolicy = model.DNSSECDefault
ps := State(b.s, Options{})
if len(codes(ps, Error)) != 0 {
t.Fatalf("a signed zone: %v", ps)
}
sec := b.zone("copy.example", nil, model.ZoneSecondary)
b.opt(model.ScopeZone, sec.ID, "primaries", "{ 192.0.2.1; }")
sec.DNSSECPolicy = model.DNSSECDefault
unknown := b.primary("custom.example", nil)
unknown.DNSSECPolicy = "fast"
long := b.primary("long.example", nil)
long.DNSSECPolicy = model.DNSSECInsecure
r := b.rec(long, "www", "A", "192.0.2.80")
r.TTL = model.Ptr(uint32(172800))
b.opt(model.ScopeZone, ok.ID, "inline-signing", "no")
ps = State(b.s, Options{})
for _, c := range []string{"dnssec_zone_type", "dnssec_policy_unknown", "dnssec_ttl_too_long", "dnssec_inline_signing"} {
if !hasCode(ps, c) {
t.Errorf("missing %s in %v", c, ps)
}
}
// A custom policy: its max-zone-ttl applies; an empty keys list would
// crash named.
b2 := newBuilder()
z := b2.primary("custom.example", nil)
z.DNSSECPolicy = "fast"
z.DefaultTTL = 600
b2.opt(model.ScopeTopLevel, 0, "dnssec-policy", `fast { keys { csk lifetime unlimited algorithm 13; }; max-zone-ttl PT5M; }`)
b2.opt(model.ScopeTopLevel, 0, "dnssec-policy", `"off" { keys { }; }`)
ps = State(b2.s, Options{})
if !hasCode(ps, "dnssec_ttl_too_long") || !hasCode(ps, "dnssec_policy_no_keys") || hasCode(ps, "dnssec_policy_unknown") {
t.Errorf("custom policy: %v", ps)
}
// dnssec-policy is set per zone in Jarl, not as an option.
b3 := newBuilder()
z = b3.primary("example.com", nil)
b3.opt(model.ScopeZone, z.ID, "dnssec-policy", "default")
b3.opt(model.ScopeOptions, 0, "dnssec-policy", "default")
b3.opt(model.ScopeZone, z.ID, "key-directory", `"/tmp"`)
if n := len(codes(State(b3.s, Options{}), Error)); n != 3 {
t.Errorf("managed options: %v", State(b3.s, Options{}))
}
}
func TestParseDuration(t *testing.T) {
for in, want := range map[string]uint32{"86400": 86400, "P1D": 86400, "PT5M": 300, "P1W": 604800, "1d": 86400, "12h30m": 45000, "pt1h": 3600} {
if got, ok := parseDuration(in); !ok || got != want {
t.Errorf("parseDuration(%q) = %d %v, want %d", in, got, ok, want)
}
}
for _, bad := range []string{"", "P", "unlimited", "1x"} {
if _, ok := parseDuration(bad); ok {
t.Errorf("parseDuration(%q) accepted", bad)
}
}
}