package engine import ( "bytes" "context" "fmt" "io" "log/slog" "net" "os" "os/exec" "path/filepath" "strings" "testing" "time" "jarl/internal/auth" "jarl/internal/bind" "jarl/internal/model" "jarl/internal/render" "jarl/internal/store" "jarl/internal/testutil" ) func freePort(t *testing.T) int { l, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatal(err) } defer l.Close() return l.Addr().(*net.TCPAddr).Port } // namedEnv runs a real named on high ports, loading Jarl's configuration. type namedEnv struct { *testEnv bins *testutil.BindBinaries dnsPort int cmd *exec.Cmd log *bytes.Buffer } func newNamedEnv(t *testing.T) *namedEnv { bins := testutil.FindBind() if bins == nil || bins.Named == "" || bins.RNDC == "" || bins.Dig == "" { t.Skip("named, rndc and dig are needed (install bind9 or set JARL_BIND_ROOT)") } dir := t.TempDir() st, err := store.Open(filepath.Join(dir, "jarl.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { st.Close() }) keyFile := filepath.Join(dir, "rndc.key") out, err := runEnv(bins.Env, bins.TSIGKeygen, "-a", "hmac-sha256", "rndc-key") if err != nil { t.Fatal(err) } os.WriteFile(keyFile, []byte(out), 0o600) dnsPort, ctlPort := freePort(t), freePort(t) paths := render.Paths{ConfigDir: filepath.Join(dir, "etc"), ZoneDir: filepath.Join(dir, "zones"), CacheDir: filepath.Join(dir, "cache")} tools := &bind.Tools{NamedCheckConf: bins.CheckConf, NamedCheckZone: bins.CheckZone, RNDC: bins.RNDC, Env: bins.Env, RNDCArgs: []string{"-k", keyFile, "-s", "127.0.0.1", "-p", fmt.Sprint(ctlPort)}} cfg := Config{Paths: paths, StagingDir: filepath.Join(dir, "staging"), ReloadTimeout: 10 * time.Second, NoReload: true} quiet := slog.New(slog.NewTextHandler(io.Discard, nil)) e := New(st, tools, cfg, quiet) admin := &model.User{Username: "admin", Admin: true} st.CreateUser(context.Background(), admin) env := &namedEnv{testEnv: &testEnv{t: t, ctx: context.Background(), e: e, st: st, admin: &auth.Principal{User: admin, Method: "local"}, dir: dir}, bins: bins, dnsPort: dnsPort} run := filepath.Join(dir, "run") os.MkdirAll(run, 0o755) opt := func(scope, name, value string) Request { return NewRequest(OpOptionCreate, 0, OptionInput{Scope: &scope, Name: &name, Value: &value}) } // Bootstrap the configuration without named running. env.submit(env.admin, NewRequest(OpSettingsUpdate, 0, SettingsInput{Nameservers: &[]string{"ns1.example.test."}}), opt(model.ScopeTopLevel, "include", `"`+keyFile+`"`), opt(model.ScopeTopLevel, "controls", fmt.Sprintf("{ inet 127.0.0.1 port %d allow { 127.0.0.1; } keys { rndc-key; }; }", ctlPort)), opt(model.ScopeOptions, "directory", `"`+run+`"`), opt(model.ScopeOptions, "pid-file", `"`+filepath.Join(run, "named.pid")+`"`), opt(model.ScopeOptions, "session-keyfile", `"`+filepath.Join(run, "session.key")+`"`), opt(model.ScopeOptions, "listen-on", fmt.Sprintf("port %d { 127.0.0.1; }", dnsPort)), opt(model.ScopeOptions, "listen-on-v6", "{ none; }"), opt(model.ScopeOptions, "recursion", "no"), NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("example.test")}), rec("example.test", "ns1", "A", "127.0.0.1"), rec("example.test", "www", "A", "192.0.2.10"), ) env.log = &bytes.Buffer{} env.cmd = exec.Command(bins.Named, "-c", paths.MainConfig(), "-g", "-n", "1") env.cmd.Env = append(os.Environ(), bins.Env...) env.cmd.Stdout, env.cmd.Stderr = env.log, env.log if err := env.cmd.Start(); err != nil { t.Fatal(err) } t.Cleanup(func() { env.cmd.Process.Kill() env.cmd.Wait() if t.Failed() { t.Logf("named log:\n%s", env.log.String()) } }) deadline := time.Now().Add(10 * time.Second) for { if _, err := tools.Status(context.Background()); err == nil { break } if time.Now().After(deadline) { t.Fatalf("named did not start:\n%s", env.log.String()) } time.Sleep(100 * time.Millisecond) } // From now on, reload named on every change. e.cfg.NoReload = false return env } func runEnv(env []string, bin string, args ...string) (string, error) { cmd := exec.Command(bin, args...) cmd.Env = append(os.Environ(), env...) out, err := cmd.CombinedOutput() return string(out), err } func (n *namedEnv) dig(name, typ string) string { out, err := runEnv(n.bins.Env, n.bins.Dig, "+short", "+norec", "-p", fmt.Sprint(n.dnsPort), "@127.0.0.1", name, typ) if err != nil { n.t.Fatalf("dig %s %s: %v %s", name, typ, err, out) } return strings.TrimSpace(out) } func TestWithRealNamed(t *testing.T) { n := newNamedEnv(t) if got := n.dig("www.example.test", "A"); got != "192.0.2.10" { t.Fatalf("initial www = %q", got) } // Change a record: named serves it as soon as Apply returns. r := findRec(n.state(), "www.example.test.", "A") res := n.submit(n.admin, NewRequest(OpRecordUpdate, r.ID, RecordInput{Data: model.Ptr("192.0.2.11")})) if got := n.dig("www.example.test", "A"); got != "192.0.2.11" { t.Errorf("www after update = %q (reloaded %v)", got, res.Reloaded) } if len(res.Serials) != 1 || res.Serials[0].New != res.Serials[0].Old+1 { t.Errorf("serials = %+v", res.Serials) } // A new zone is loaded via reconfig. n.submit(n.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("second.test"), Nameservers: []string{"ns1.example.test."}}), rec("second.test", "@", "TXT", "hello from jarl")) if got := n.dig("second.test", "TXT"); got != `"hello from jarl"` { t.Errorf("second.test TXT = %q", got) } // ACME-style challenge add and cleanup. n.submit(n.admin, rec("example.test", "_acme-challenge", "TXT", "abc-123")) if got := n.dig("_acme-challenge.example.test", "TXT"); got != `"abc-123"` { t.Errorf("challenge = %q", got) } n.submit(n.admin, NewRequest(OpRecordDelete, 0, RecordInput{Zone: "example.test", Name: model.Ptr("_acme-challenge"), Type: model.Ptr("TXT")})) if got := n.dig("_acme-challenge.example.test", "TXT"); got != "" { t.Errorf("challenge after cleanup = %q", got) } // Deleting a zone removes it from named and its file from disk. z := n.state().ZonesNamed("second.test")[0] n.submit(n.admin, NewRequest(OpZoneDelete, z.ID, nil)) if got := n.dig("second.test", "TXT"); got != "" { t.Errorf("deleted zone still served: %q", got) } if _, err := os.Stat(filepath.Join(n.dir, "zones/_default/second.test.zone")); !os.IsNotExist(err) { t.Errorf("zone file of deleted zone still exists: %v", err) } st, err := n.e.Status(n.ctx) if err != nil || st.Named == nil || !st.Named.Running || len(st.Drift) != 0 { t.Errorf("status = %+v %v", st, err) } } func TestRollbackOnReloadFailure(t *testing.T) { n := newNamedEnv(t) // Make rndc fail for "reconfig" to simulate named rejecting the change. wrapper := filepath.Join(n.dir, "rndc-wrapper") os.WriteFile(wrapper, []byte(fmt.Sprintf("#!/bin/sh\nfor a in \"$@\"; do [ \"$a\" = reconfig ] && { echo 'rndc: reconfig failed: simulated' >&2; exit 1; }; done\nexec %s \"$@\"\n", n.bins.RNDC)), 0o755) n.e.tools.RNDC = wrapper confBefore, _ := os.ReadFile(n.e.cfg.Paths.MainConfig()) _, err := n.e.Submit(n.ctx, []Request{NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("broken.test"), Nameservers: []string{"ns1.example.test."}})}, n.admin, "test", "", ApplyOptions{}) ee, ok := AsError(err) if !ok || ee.Kind != KindBIND || !strings.Contains(ee.Message, "restored") { t.Fatalf("error = %v", err) } confAfter, _ := os.ReadFile(n.e.cfg.Paths.MainConfig()) if !bytes.Equal(confBefore, confAfter) { t.Error("named.conf was not restored") } if _, err := os.Stat(filepath.Join(n.dir, "zones/_default/broken.test.zone")); !os.IsNotExist(err) { t.Error("zone file of failed change left behind") } if len(n.state().ZonesNamed("broken.test")) != 0 { t.Error("failed change recorded in the database") } // named keeps serving the old data and Jarl keeps working. n.e.tools.RNDC = n.bins.RNDC if got := n.dig("www.example.test", "A"); got != "192.0.2.10" { t.Errorf("www = %q", got) } n.submit(n.admin, rec("example.test", "after", "A", "192.0.2.99")) if got := n.dig("after.example.test", "A"); got != "192.0.2.99" { t.Errorf("after = %q", got) } evs, _ := n.st.ListAudit(n.ctx, store.AuditFilter{Outcome: "failure"}) if len(evs) == 0 || !strings.Contains(evs[0].Message, "simulated") { t.Errorf("failure not audited: %+v", evs) } } func TestNamedCheckconfRejects(t *testing.T) { n := newNamedEnv(t) // An option value Jarl's quick checks accept but named rejects. name, value := "max-cache-size", "lots" scope := model.ScopeOptions _, err := n.e.Submit(n.ctx, []Request{NewRequest(OpOptionCreate, 0, OptionInput{Scope: &scope, Name: &name, Value: &value})}, n.admin, "test", "", ApplyOptions{}) ee, ok := AsError(err) if !ok || ee.Kind != KindValidation || !strings.Contains(ee.Message, "named-checkconf") { t.Fatalf("error = %v", err) } found := false for _, m := range ee.Check { if m.Error && strings.Contains(m.Object, "server option max-cache-size") { found = true } } if !found { t.Errorf("checkconf message not mapped to the option: %+v", ee.Check) } }