package importer import ( "context" "os" "path/filepath" "strings" "testing" "jarl/internal/model" "jarl/internal/validate" ) // copyTree copies testdata/ to a temp dir, replacing @DIR@. func copyTree(t *testing.T, name string) string { t.Helper() dst := t.TempDir() src := filepath.Join("testdata", name) entries, err := os.ReadDir(src) if err != nil { t.Fatal(err) } for _, e := range entries { b, err := os.ReadFile(filepath.Join(src, e.Name())) if err != nil { t.Fatal(err) } b = []byte(strings.ReplaceAll(string(b), "@DIR@", dst)) if err := os.WriteFile(filepath.Join(dst, e.Name()), b, 0o644); err != nil { t.Fatal(err) } } return dst } func seqAlloc() func(context.Context, int) ([]int64, error) { next := int64(0) return func(_ context.Context, n int) ([]int64, error) { out := make([]int64, n) for i := range out { next++ out[i] = next } return out, nil } } func TestImportDebian(t *testing.T) { dir := copyTree(t, "debian") st, rep, err := Import(context.Background(), filepath.Join(dir, "named.conf"), Options{Alloc: seqAlloc(), User: "import"}) if err != nil { t.Fatal(err) } // rndc.key stays an include; its secret is not imported. if len(rep.Kept) != 1 || !strings.HasSuffix(rep.Kept[0], "rndc.key") { t.Errorf("kept = %v", rep.Kept) } if st.KeyByName("rndc-key") != nil { t.Error("rndc key imported") } if k := st.KeyByName("xfer-key"); k == nil || k.Algorithm != "hmac-sha256" { t.Errorf("xfer-key = %+v", k) } if a := st.ACLByName("homelab"); a == nil || strings.Join(a.Elements, ",") != "192.168.1.0/24,10.10.0.0/16,localhost" { t.Errorf("acl = %+v", a) } if o := st.FindOption(model.ScopeOptions, 0, "allow-recursion"); o == nil || o.Value != "{ homelab; }" { t.Errorf("allow-recursion = %+v", o) } if st.FindOption(model.ScopeTopLevel, 0, "controls") == nil || st.FindOption(model.ScopeTopLevel, 0, "logging") == nil { t.Error("controls/logging not kept") } z := st.FindZone("adbraun.lol", 0) if z == nil || z.Type != model.ZonePrimary || z.SOA.Serial != 2024051201 || z.SOA.RName != "admin.adbraun.lol." || z.DefaultTTL != 3600 { t.Fatalf("zone = %+v", z) } if st.FindOption(model.ScopeZone, z.ID, "allow-transfer") == nil { t.Error("zone option lost") } recs := map[string]*model.Record{} for _, r := range st.ZoneRecords(z.ID) { recs[r.Name+" "+r.Type+" "+r.Data] = r } if r := recs["nas.adbraun.lol. A 192.168.1.20"]; r == nil || r.Comment != "synology" || r.TTL != nil { t.Errorf("nas = %+v", r) } if r := recs["www.adbraun.lol. A 192.168.1.11"]; r == nil || r.TTL == nil || *r.TTL != 300 { t.Errorf("www = %+v", r) } if recs["adbraun.lol. MX 10 mail.adbraun.lol."] == nil || recs["media.adbraun.lol. CNAME nas.adbraun.lol."] == nil { t.Errorf("records = %v", recs) } rz := st.FindZone("1.168.192.in-addr.arpa", 0) if rz == nil { t.Fatal("reverse zone missing") } if n := len(st.ZoneRecords(rz.ID)); n != 6 { // NS, 2 PTR, 3 generated PTR t.Errorf("reverse zone has %d records", n) } if fz := st.FindZone("consul", 0); fz == nil || fz.Type != model.ZoneForward { t.Error("forward zone missing") } // The imported configuration is valid (the open allow-query is fine // because allow-recursion is restricted). ps := validate.State(st, validate.Options{ExternalKeys: []string{"rndc-key"}}) if validate.HasErrors(ps) { t.Errorf("imported state has errors: %v", validate.Errors(ps)) } for _, p := range ps { if p.Code == "security_open_resolver" { t.Error("false open resolver warning") } } if rep.Counts["zones"] != 3 || len(rep.Zones) != 3 { t.Errorf("report = %+v", rep) } } func TestImportErrors(t *testing.T) { dir := t.TempDir() os.WriteFile(filepath.Join(dir, "named.conf"), []byte(`include "`+dir+`/missing.conf";`), 0o644) if _, _, err := Import(context.Background(), filepath.Join(dir, "named.conf"), Options{Alloc: seqAlloc()}); err == nil { t.Error("missing include accepted") } os.WriteFile(filepath.Join(dir, "named.conf"), []byte(`zone "x.test" { type primary; file "`+dir+`/x.db"; };`), 0o644) os.WriteFile(filepath.Join(dir, "x.db"), []byte("$TTL 300\n@ IN SOA ns1.x.test. h.x.test. 1 2 3 4 5\n@ IN NS ns1\nns1 IN A 192.0.2.1\nbad_name IN A 192.0.2.2\nother.test. IN A 192.0.2.3\n"), 0o644) st, rep, err := Import(context.Background(), filepath.Join(dir, "named.conf"), Options{Alloc: seqAlloc()}) if err != nil { t.Fatal(err) } if len(rep.Skipped) != 2 || len(st.Records) != 2 { t.Errorf("skipped = %v, records = %d", rep.Skipped, len(st.Records)) } } func TestParseTTL(t *testing.T) { cases := map[string]uint32{"3600": 3600, "1h": 3600, "1h30m": 5400, "1W": 604800, "2d3s": 172803} for in, want := range cases { if got, ok := ParseTTL(in); !ok || got != want { t.Errorf("ParseTTL(%q) = %d %v", in, got, ok) } } for _, bad := range []string{"", "h", "1x", "-5"} { if _, ok := ParseTTL(bad); ok { t.Errorf("ParseTTL(%q) accepted", bad) } } } func TestImportAllowUpdateWarning(t *testing.T) { dir := t.TempDir() os.WriteFile(filepath.Join(dir, "x.db"), []byte("$TTL 300\n@ IN SOA ns1.x.test. h.x.test. 1 2 3 4 5\n@ IN NS ns1\nns1 IN A 192.0.2.1\n"), 0o644) for conf, want := range map[string]bool{ `allow-update { none; };`: false, `allow-update { 127.0.0.1; };`: true, `update-policy local;`: true, } { os.WriteFile(filepath.Join(dir, "named.conf"), []byte(`zone "x.test" { type primary; file "`+dir+`/x.db"; `+conf+` };`), 0o644) _, rep, err := Import(context.Background(), filepath.Join(dir, "named.conf"), Options{Alloc: seqAlloc()}) if err != nil { t.Fatal(err) } if got := len(rep.Warnings) > 0; got != want { t.Errorf("%s: warnings = %v", conf, rep.Warnings) } } } func TestImportSignedZones(t *testing.T) { dir := t.TempDir() zone := "$TTL 300\n@ IN SOA ns1 h 1 2 3 4 5\n@ IN NS ns1\nns1 IN A 192.0.2.1\n" for _, n := range []string{"a", "b", "c"} { os.WriteFile(filepath.Join(dir, n+".db"), []byte(strings.ReplaceAll(zone, "ns1", "ns1."+n+".test.")), 0o644) } conf := `options { directory "` + dir + `"; dnssec-policy default; key-directory "/var/cache/bind/keys"; }; view "external" { zone "a.test" { type primary; file "a.db"; key-directory "/elsewhere"; }; zone "b.test" { type primary; file "b.db"; dnssec-policy none; }; }; view "internal" { dnssec-policy "none"; zone "c.test" { type primary; file "c.db"; }; };` os.WriteFile(filepath.Join(dir, "named.conf"), []byte(conf), 0o644) st, rep, err := Import(context.Background(), filepath.Join(dir, "named.conf"), Options{Alloc: seqAlloc()}) if err != nil { t.Fatal(err) } policy := map[string]string{} for _, z := range st.Zones { policy[z.Name] = z.DNSSECPolicy } if policy["a.test."] != "default" || policy["b.test."] != "" || policy["c.test."] != "" { t.Errorf("policies: %v", policy) } for _, o := range st.Options { if o.Name == "dnssec-policy" || o.Scope == model.ScopeZone && o.Name == "key-directory" { t.Errorf("option kept: %+v", o) } } if !strings.Contains(strings.Join(rep.Warnings, "\n"), "zone a.test is signed (dnssec-policy default). Before 'jarld adopt', copy its key files (Ka.test.+*)") { t.Errorf("warnings: %v", rep.Warnings) } if ps := validate.Errors(validate.State(st, validate.Options{})); len(ps) != 0 { t.Errorf("imported state has errors: %v", ps) } }