package render import ( "context" "os" "path/filepath" "strings" "testing" "jarl/internal/dnsname" "jarl/internal/model" "jarl/internal/rr" "jarl/internal/testutil" "jarl/internal/validate" ) // sampleState builds a split-horizon configuration exercising most // features. func sampleState(t *testing.T) *model.State { t.Helper() s := model.NewState() id := int64(0) next := func() int64 { id++; return id } s.ACLs[next()] = &model.ACL{ID: id, Name: "lan", Elements: []string{"192.168.1.0/24", "10/8"}, Comment: "home networks"} s.Keys[next()] = &model.TSIGKey{ID: id, Name: "xfer-key", Algorithm: "hmac-sha256", Secret: "s0cvDUATzPDPntCVDfO3S9JjT8+51cRZSuWTNM9Eosc="} internal := &model.View{ID: next(), Name: "internal", Position: 0, MatchClients: []string{"lan", "localhost"}} external := &model.View{ID: next(), Name: "external", Position: 1, MatchClients: []string{"any"}} s.Views[internal.ID], s.Views[external.ID] = internal, external opt := func(scope string, scopeID int64, name, value string) { o := &model.Option{ID: next(), Scope: scope, ScopeID: scopeID, Name: name, Value: value, Position: int(id)} s.Options[o.ID] = o } opt(model.ScopeOptions, 0, "directory", `"/var/cache/bind"`) opt(model.ScopeOptions, 0, "dnssec-validation", "auto") opt(model.ScopeOptions, 0, "listen-on-v6", "{ any; }") opt(model.ScopeView, internal.ID, "recursion", "yes") opt(model.ScopeView, internal.ID, "allow-recursion", "{ lan; localhost; }") opt(model.ScopeView, external.ID, "recursion", "no") opt(model.ScopeTopLevel, 0, "logging", `{ channel default_log { stderr; severity info; }; category default { default_log; }; }`) zone := func(name string, v *model.View, typ string) *model.Zone { z := &model.Zone{ID: next(), Name: dnsname.Fqdn(name), ViewID: v.ID, Type: typ, DefaultTTL: 3600, SOA: model.SOA{MName: "ns1.example.com.", RName: "hostmaster.example.com.", Serial: 2026100700, Refresh: 3600, Retry: 900, Expire: 1209600, Minimum: 300}} s.Zones[z.ID] = z return z } rec := func(z *model.Zone, name, typ, data string) *model.Record { owner, _ := dnsname.Owner(name, z.Name) p, err := rr.Parse(z.Name, owner, typ, data) if err != nil { t.Fatalf("%s %s %s: %v", name, typ, data, err) } r := &model.Record{ID: next(), ZoneID: z.ID, Name: owner, Type: p.Type, Data: p.Data} s.Records[r.ID] = r return r } in := zone("example.com", internal, model.ZonePrimary) in.Comment = "internal version" rec(in, "@", "NS", "ns1") rec(in, "ns1", "A", "192.168.1.2") r := rec(in, "nas", "A", "192.168.1.20") r.Comment = "storage box" r.TTL = model.Ptr(uint32(300)) rec(in, "www", "CNAME", "nas") rec(in, "@", "TXT", "v=spf1 -all") rec(in, "@", "CAA", `0 issue "letsencrypt.org"`) rec(in, "_acme-challenge", "TXT", "abc123") ext := zone("example.com", external, model.ZonePrimary) rec(ext, "@", "NS", "ns1") rec(ext, "ns1", "A", "203.0.113.2") rec(ext, "www", "A", "203.0.113.10") opt(model.ScopeZone, ext.ID, "allow-transfer", "{ key xfer-key; }") rev := zone("1.168.192.in-addr.arpa", internal, model.ZonePrimary) rec(rev, "@", "NS", "ns1.example.com.") rec(rev, "20", "PTR", "nas.example.com.") fwd := zone("consul", internal, model.ZoneForward) opt(model.ScopeZone, fwd.ID, "forwarders", "{ 127.0.0.1 port 8600; }") opt(model.ScopeZone, fwd.ID, "forward", "only") sec := zone("partner.example", external, model.ZoneSecondary) opt(model.ScopeZone, sec.ID, "primaries", "{ 198.51.100.1 key xfer-key; }") hint := zone(".", internal, model.ZoneHint) hint.File = "/usr/share/dns/root.hints" return s } func TestRenderDeterministicAndValid(t *testing.T) { s := sampleState(t) if ps := validate.Errors(validate.State(s, validate.Options{})); len(ps) > 0 { t.Fatalf("sample state has errors: %v", ps) } dir := t.TempDir() p := Paths{ConfigDir: filepath.Join(dir, "etc"), ZoneDir: filepath.Join(dir, "zones"), CacheDir: filepath.Join(dir, "cache")} out1 := Render(s, p) out2 := Render(s.Clone(), p) if len(out1.Files) != len(out2.Files) { t.Fatal("file count differs") } for i := range out1.Files { if string(out1.Files[i].Content) != string(out2.Files[i].Content) { t.Errorf("rendering of %s is not deterministic", out1.Files[i].Path) } } conf := string(out1.File(p.MainConfig()).Content) for _, want := range []string{ `include "` + p.KeysConfig() + `";`, "acl \"lan\" { 192.168.1.0/24; 10.0.0.0/8; };", "view \"internal\" {\n\tmatch-clients { lan; localhost; };", "\tzone \"example.com\" {\n\t\ttype primary;", "zone \"consul\" {\n\t\ttype forward;", "\t\tforwarders { 127.0.0.1 port 8600; };", `file "/usr/share/dns/root.hints";`, } { if !strings.Contains(conf, want) { t.Errorf("named.conf missing %q:\n%s", want, conf) } } if strings.Contains(conf, "s0cvDUAT") { t.Error("secret leaked into named.conf") } keys := out1.File(p.KeysConfig()) if !keys.Secret || !strings.Contains(string(keys.Content), `secret "s0cvDUAT`) { t.Error("keys.conf wrong") } if len(out1.Dirs) != 1 || !strings.HasSuffix(out1.Dirs[0], "cache/external") { t.Errorf("dirs = %v", out1.Dirs) } // Source map: the line with the internal zone maps to that zone. lines := strings.Split(conf, "\n") for i, l := range lines { if strings.Contains(l, "forwarders { 127.0.0.1 port 8600; }") { span, ok := out1.Lookup(p.MainConfig(), i+1) if !ok || span.ObjectType != "option" || !strings.Contains(span.Label, "consul") { t.Errorf("span for line %d = %+v %v", i+1, span, ok) } } } zf := ZoneFile(s, s.FindZone("example.com", s.ViewByName("internal").ID)) for _, want := range []string{"$ORIGIN example.com.", "$TTL 3600", "nas\t300\tIN\tA\t192.168.1.20\t; storage box", "www\t\tIN\tCNAME\tnas.example.com.", "@\t\tIN\tNS\tns1.example.com.", "2026100700\t; serial"} { if !strings.Contains(zf, want) { t.Errorf("zone file missing %q:\n%s", want, zf) } } } func TestRenderWithNamedCheckconf(t *testing.T) { tools := testutil.BindTools(t) s := sampleState(t) dir := t.TempDir() p := Paths{ConfigDir: filepath.Join(dir, "etc"), ZoneDir: filepath.Join(dir, "zones"), CacheDir: filepath.Join(dir, "cache")} // named-checkconf changes into the directory option, so it must exist. s.FindOption(model.ScopeOptions, 0, "directory").Value = `"` + dir + `"` // The hint zone's file must exist too. hints := filepath.Join(dir, "root.hints") if err := os.WriteFile(hints, []byte(".\t3600000\tNS\tA.ROOT-SERVERS.NET.\nA.ROOT-SERVERS.NET.\t3600000\tA\t198.41.0.4\n"), 0o644); err != nil { t.Fatal(err) } for _, z := range s.Zones { if z.Type == model.ZoneHint { z.File = hints } } out := Render(s, p) for _, f := range out.Files { if err := os.MkdirAll(filepath.Dir(f.Path), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(f.Path, f.Content, 0o644); err != nil { t.Fatal(err) } } res, err := tools.CheckConfig(context.Background(), p.MainConfig(), true) if err != nil { t.Fatal(err) } if !res.OK { t.Fatalf("named-checkconf failed:\n%s\n--- named.conf:\n%s", res.Output, out.File(p.MainConfig()).Content) } for _, f := range out.Files { if f.Kind != KindZone { continue } z := s.Zones[f.ZoneID] r, err := tools.CheckZone(context.Background(), z.Name, f.Path) if err != nil || !r.OK { t.Errorf("named-checkzone %s: %v\n%s", z.Name, err, r.Output) } } } func TestZoneContentHash(t *testing.T) { s := sampleState(t) z := s.FindZone("example.com", s.ViewByName("internal").ID) h1 := ZoneContentHash(s, z) z.SOA.Serial++ for _, r := range s.ZoneRecords(z.ID) { r.Comment = "changed comment" } if ZoneContentHash(s, z) != h1 { t.Error("serial or comments changed the content hash") } z.DefaultTTL = 7200 if ZoneContentHash(s, z) == h1 { t.Error("default TTL change not reflected") } }