package engine import ( "os" "path/filepath" "strings" "testing" "time" "jarl/internal/dnssec" "jarl/internal/model" "jarl/internal/render" "jarl/internal/store" ) // waitPhase polls the zone's DNSSEC status until ok accepts it. func (n *namedEnv) waitPhase(z *model.Zone, what string, ok func(*dnssec.Status) bool) *dnssec.Status { n.t.Helper() deadline := time.Now().Add(15 * time.Second) for { st := n.state() s := n.e.DNSSEC(n.ctx, st, st.Zones[z.ID]) if ok(s) { return s } if time.Now().After(deadline) { n.t.Fatalf("waiting for %s: %+v (phase %+v)", what, s, s.Phase) } time.Sleep(200 * time.Millisecond) } } func TestDNSSECWithRealNamed(t *testing.T) { n := newNamedEnv(t) z := n.state().ZonesNamed("example.test")[0] zoneFile := filepath.Join(n.dir, "zones/_default/example.test.zone") // Switching signing on for an existing zone: named makes a key and // signs the zone (inline signing, keeping Jarl's file as it is). n.submit(n.admin, NewRequest(OpZoneUpdate, z.ID, ZoneInput{DNSSECPolicy: model.Ptr("default")})) conf, _ := os.ReadFile(n.e.cfg.Paths.MainConfig()) if !strings.Contains(string(conf), `dnssec-policy "default";`) || !strings.Contains(string(conf), `key-directory "`+filepath.Join(n.dir, "zones/dnssec-keys/_default")+`";`) { t.Errorf("named.conf:\n%s", conf) } s := n.waitPhase(z, "signing", func(s *dnssec.Status) bool { return len(s.Keys) == 1 && s.Named != nil && s.Named.Secure }) k := s.Keys[0] if s.Phase.Code != dnssec.PhaseSigning || k.Role != "CSK" || k.Algorithm != 13 || k.DS == nil || s.Phase.DSReadyAt == nil { t.Errorf("status: %+v, key %+v", s.Phase, k) } if got := n.dig("example.test", "DNSKEY"); !strings.HasPrefix(got, "257 3 13 ") { t.Errorf("DNSKEY = %q", got) } // Changes still load (Jarl's serial is the unsigned zone's), and the // serial check sees the signed zone's serial. r := findRec(n.state(), "www.example.test.", "A") n.submit(n.admin, NewRequest(OpRecordUpdate, r.ID, RecordInput{Data: model.Ptr("192.0.2.12")})) if got := n.dig("www.example.test", "A"); got != "192.0.2.12" { t.Errorf("www = %q", got) } cur := n.state() served, ok, err := n.e.LoadedSerial(n.ctx, cur, cur.Zones[z.ID]) if !ok || err != nil || !serialGreater(served, cur.Zones[z.ID].SOA.Serial) { t.Errorf("served serial %d (%v %v), Jarl's %d", served, ok, err, cur.Zones[z.ID].SOA.Serial) } // Unsafe steps are refused. if e := n.submitErr(n.admin, NewRequest(OpZoneUpdate, z.ID, ZoneInput{DNSSECPolicy: model.Ptr("")})); !strings.Contains(e.Message, "switching DNSSEC off at once") { t.Errorf("switching off directly: %v", e) } if e := n.submitErr(n.admin, NewRequest(OpZoneDelete, z.ID, nil)); !strings.Contains(e.Message, "turn DNSSEC off before deleting") { t.Errorf("deleting: %v", e) } r = findRec(n.state(), "www.example.test.", "A") if e := n.submitErr(n.admin, NewRequest(OpRecordUpdate, r.ID, RecordInput{TTL: model.Some(uint32(172800))})); !strings.Contains(e.Message, "max-zone-ttl") { t.Errorf("long TTL: %v", e) } // A restore of an older database: Jarl's serial is now below named's, // which named refuses for a signed zone. Resync raises it. before := n.state() older := before.Clone() older.Zones[z.ID].SOA.Serial -= 5 findRec(older, "www.example.test.", "A").Data = "192.0.2.10" if err := n.st.InTx(n.ctx, func(tx *store.Tx) error { return tx.SaveState(before, older) }); err != nil { t.Fatal(err) } res, err := n.e.Resync(n.ctx, n.admin, ApplyOptions{Force: true}) if err != nil { t.Fatalf("resync: %v", err) } if len(res.Serials) != 1 || !serialGreater(res.Serials[0].New, before.Zones[z.ID].SOA.Serial) { t.Errorf("serials: %+v (named had %d)", res.Serials, before.Zones[z.ID].SOA.Serial) } if got := n.dig("www.example.test", "A"); got != "192.0.2.10" { t.Errorf("www after restore = %q", got) } if n.state().Zones[z.ID].SOA.Serial != res.Serials[0].New { t.Error("raised serial not saved") } // Turning off: insecure first. The DS was never at the parent, so // named removes the keys at once and finishing is allowed. n.submit(n.admin, NewRequest(OpZoneUpdate, z.ID, ZoneInput{DNSSECPolicy: model.Ptr("insecure")})) n.waitPhase(z, "removal", func(s *dnssec.Status) bool { return s.Phase.CanFinish }) n.submit(n.admin, NewRequest(OpZoneUpdate, z.ID, ZoneInput{DNSSECPolicy: model.Ptr("")})) if got := n.dig("example.test", "DNSKEY"); got != "" { t.Errorf("DNSKEY after switching off = %q", got) } s = n.waitPhase(z, "leftover keys", func(s *dnssec.Status) bool { return true }) if s.Phase.Code != dnssec.PhaseLeftover { t.Errorf("after switching off: %+v", s.Phase) } // Signing again makes a new key (the old one stays retired); a wipe // switches off at once and deletes keys and signed files. n.submit(n.admin, NewRequest(OpZoneUpdate, z.ID, ZoneInput{DNSSECPolicy: model.Ptr("default")})) s = n.waitPhase(z, "signing again", func(s *dnssec.Status) bool { return len(s.Keys) == 2 && s.Named != nil && s.Named.Secure }) if s.Phase.Code != dnssec.PhaseSigning || s.Keys[0].Tag == k.Tag && s.Keys[0].Goal == dnssec.Omnipresent { t.Errorf("signing again: %+v %+v", s.Phase, s.Keys[0]) } if _, err := os.Stat(zoneFile + ".signed"); err != nil { t.Errorf("signed copy: %v", err) } w, err := n.e.WipeDNSSEC(n.ctx, z.ID, n.admin, "test", ApplyOptions{}) if err != nil { t.Fatalf("wipe: %v", err) } if len(w.Removed) < 4 || w.Result == nil || !w.Result.Applied { t.Errorf("wipe: %+v", w) } if files, _ := dnssec.Files(render.KeyDir(n.state(), z, n.e.cfg.Paths), z.Name); len(files) != 0 { t.Errorf("keys left: %v", files) } if _, err := os.Stat(zoneFile + ".signed"); !os.IsNotExist(err) { t.Errorf("signed copy left: %v", err) } if got := n.dig("www.example.test", "A"); got != "192.0.2.10" { t.Errorf("www after wipe = %q", got) } if _, err := n.e.WipeDNSSEC(n.ctx, z.ID, n.admin, "test", ApplyOptions{}); err == nil { t.Error("a second wipe found something") } // Deleting the zone also removes named's journal. n.submit(n.admin, NewRequest(OpZoneDelete, z.ID, nil)) if left, _ := filepath.Glob(zoneFile + "*"); len(left) != 0 { t.Errorf("left after deleting the zone: %v", left) } }