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>
83 lines
4.2 KiB
Go
83 lines
4.2 KiB
Go
package server
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"jarl/internal/dnssec"
|
|
"jarl/internal/model"
|
|
"jarl/internal/nscheck"
|
|
)
|
|
|
|
func TestDNSSECEndpoints(t *testing.T) {
|
|
e := newAPIEnv(t)
|
|
// Nothing answers DNS here, so the parent can't be checked.
|
|
e.srv.nsChecker = &nscheck.Checker{Timeout: 200 * time.Millisecond, Resolver: "127.0.0.1:1"}
|
|
e.do("POST", "/zones", e.token, map[string]any{"name": "example.com", "records": []map[string]any{{"name": "ns1", "type": "A", "data": "192.0.2.1"}}}, 201)
|
|
e.do("GET", "/zones/example.com/dnssec?view=", e.token, nil, 200)
|
|
|
|
e.do("PATCH", "/zones/example.com", e.token, map[string]any{"dnssec_policy": "default"}, 200)
|
|
got := e.do("GET", "/zones/example.com/dnssec", e.token, nil, 200)
|
|
if got["policy"] != "default" || got["phase"].(map[string]any)["code"] != dnssec.PhaseStarting || !strings.HasSuffix(got["key_dir"].(string), "zones/dnssec-keys/_default") {
|
|
t.Errorf("status: %v", got)
|
|
}
|
|
// Switching off at once is refused; insecure is the way.
|
|
out := e.do("PATCH", "/zones/example.com", e.token, map[string]any{"dnssec_policy": "off"}, 422)
|
|
if !strings.Contains(out["error"].(map[string]any)["message"].(string), "switching DNSSEC off at once") {
|
|
t.Errorf("switching off: %v", out)
|
|
}
|
|
cd := e.do("POST", "/zones/example.com/dnssec/check-ds", e.token, nil, 200)
|
|
if cd["parent"].(map[string]any)["error"] == "" || len(cd["marked"].([]any)) != 0 {
|
|
t.Errorf("check-ds without a resolver: %v", cd)
|
|
}
|
|
// Wipe: refused while the parent can't be checked, unless forced.
|
|
out = e.do("POST", "/zones/example.com/dnssec/wipe", e.token, nil, 409)
|
|
if !strings.Contains(out["error"].(map[string]any)["message"].(string), "wipe with force") {
|
|
t.Errorf("wipe: %v", out)
|
|
}
|
|
e.do("POST", "/zones/example.com/dnssec/wipe", e.token, map[string]any{"force": true, "dry_run": true}, 200)
|
|
if e.do("GET", "/zones/example.com", e.token, nil, 200)["dnssec_policy"] != "default" {
|
|
t.Error("a dry run changed the zone")
|
|
}
|
|
e.do("POST", "/zones/example.com/dnssec/wipe", e.token, map[string]any{"force": true}, 200)
|
|
if p, ok := e.do("GET", "/zones/example.com", e.token, nil, 200)["dnssec_policy"]; ok && p != "" {
|
|
t.Errorf("after wipe: %v", p)
|
|
}
|
|
e.do("POST", "/zones/example.com/dnssec/wipe", e.token, map[string]any{"force": true}, 409) // nothing left
|
|
|
|
// Secondary zones aren't signed by Jarl.
|
|
e.do("POST", "/zones", e.token, map[string]any{"name": "copy.example", "type": "secondary", "options": []map[string]any{{"name": "primaries", "value": "{ 192.0.2.1; }"}}}, 201)
|
|
e.do("GET", "/zones/copy.example/dnssec", e.token, nil, 400)
|
|
e.do("PATCH", "/zones/copy.example", e.token, map[string]any{"dnssec_policy": "default"}, 422)
|
|
}
|
|
|
|
func TestDSChanges(t *testing.T) {
|
|
ds := dnssec.DS{KeyTag: 1, Algorithm: 13, DigestType: 2, Digest: "AA"}
|
|
key := func(goal, dsState string) *dnssec.Key {
|
|
d := ds
|
|
return &dnssec.Key{Tag: 1, Role: "CSK", DS: &d, Goal: goal, DSState: dsState}
|
|
}
|
|
with := &nscheck.ParentDSReport{Servers: []nscheck.ParentAnswer{{Status: "delegated", DS: []dnssec.DS{ds}}, {Status: "delegated", DS: []dnssec.DS{ds}}}, DS: []dnssec.DS{ds}, Consistent: true}
|
|
half := &nscheck.ParentDSReport{Servers: []nscheck.ParentAnswer{{Status: "delegated", DS: []dnssec.DS{ds}}, {Status: "delegated"}}, DS: []dnssec.DS{ds}}
|
|
without := &nscheck.ParentDSReport{Servers: []nscheck.ParentAnswer{{Status: "delegated"}}, Consistent: true}
|
|
signed := &model.Zone{DNSSECPolicy: "default"}
|
|
off := &model.Zone{DNSSECPolicy: "insecure"}
|
|
|
|
if p, w := dsChanges(signed, []*dnssec.Key{key("omnipresent", "rumoured")}, with); len(p) != 1 || len(w) != 0 {
|
|
t.Errorf("DS added: %v %v", p, w)
|
|
}
|
|
if p, _ := dsChanges(signed, []*dnssec.Key{key("omnipresent", "rumoured")}, half); len(p) != 0 {
|
|
t.Error("published while not every parent server has it")
|
|
}
|
|
if p, w := dsChanges(signed, []*dnssec.Key{key("omnipresent", "rumoured")}, without); len(p)+len(w) != 0 {
|
|
t.Error("a signed zone without a DS yet is just waiting")
|
|
}
|
|
if p, w := dsChanges(off, []*dnssec.Key{key("hidden", "unretentive")}, without); len(p) != 0 || len(w) != 1 {
|
|
t.Errorf("DS removed: %v %v", p, w)
|
|
}
|
|
if _, w := dsChanges(off, []*dnssec.Key{key("hidden", "unretentive")}, half); len(w) != 0 {
|
|
t.Error("withdrawn while a parent server still has it")
|
|
}
|
|
}
|