package encdns import ( "context" "io" "log/slog" "net/netip" "os" "path/filepath" "slices" "strings" "testing" "jarl/internal/auth" "jarl/internal/engine" "jarl/internal/model" "jarl/internal/render" "jarl/internal/store" ) type env struct { t *testing.T eng *engine.Engine st *store.Store certDir string } func newEnv(t *testing.T) *env { dir := t.TempDir() st, err := store.Open(filepath.Join(dir, "jarl.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { st.Close() }) eng := engine.New(st, nil, engine.Config{ Paths: render.Paths{ConfigDir: filepath.Join(dir, "etc"), ZoneDir: filepath.Join(dir, "zones"), CacheDir: filepath.Join(dir, "cache")}, StagingDir: filepath.Join(dir, "staging"), NoReload: true, SkipCheck: true, }, slog.New(slog.NewTextHandler(io.Discard, nil))) e := &env{t: t, eng: eng, st: st, certDir: filepath.Join(dir, "tls")} os.MkdirAll(filepath.Join(e.certDir, "dns"), 0o755) os.WriteFile(filepath.Join(e.certDir, "dns", "fullchain.pem"), []byte("x"), 0o644) os.WriteFile(filepath.Join(e.certDir, "dns", "privkey.pem"), []byte("x"), 0o600) e.submit(engine.NewRequest(engine.OpSettingsUpdate, 0, engine.SettingsInput{Nameservers: &[]string{"ns1.example.net."}}), engine.NewRequest(engine.OpViewCreate, 0, engine.ViewInput{Name: model.Ptr("internal"), MatchClients: &[]string{"10.0.0.0/8"}}), engine.NewRequest(engine.OpViewCreate, 0, engine.ViewInput{Name: model.Ptr("external"), MatchClients: &[]string{"any"}})) e.submit(engine.NewRequest(engine.OpZoneCreate, 0, engine.ZoneInput{Name: model.Ptr("example.com"), View: model.Ptr("internal")})) return e } func (e *env) submit(reqs ...engine.Request) *engine.Result { e.t.Helper() res, err := e.eng.Submit(context.Background(), reqs, auth.System, "test", "", engine.ApplyOptions{}) if err != nil { e.t.Fatalf("%v (%+v)", err, res) } return res } func (e *env) state() *model.State { st, err := e.st.LoadState(context.Background()) if err != nil { e.t.Fatal(err) } return st } func values(st *model.State, scope, name string) []string { var out []string for _, o := range st.ScopedOptions(scope, 0) { if o.Name == name { out = append(out, o.Value) } } return out } func TestSetup(t *testing.T) { e := newEnv(t) setup := Setup{Hostname: "dns.example.com", Certificate: "dns", Addresses: []string{"10.0.10.5", "2001:db8::5"}, DoT: true, DoH: true, Views: []string{"internal", "external"}} reqs, notes, err := Plan(e.state(), setup, e.certDir) if err != nil { t.Fatal(err) } e.submit(reqs...) st := e.state() tls := values(st, model.ScopeTopLevel, "tls") if len(tls) != 1 || !strings.HasPrefix(tls[0], "jarl-dns {") || !strings.Contains(tls[0], filepath.Join(e.certDir, "dns", "fullchain.pem")) { t.Errorf("tls = %q", tls) } // The plain listeners named had by default are kept explicitly. want4 := []string{"{ any; }", "port 853 tls jarl-dns { 10.0.10.5; }", "port 443 tls jarl-dns http default { 10.0.10.5; }"} if got := values(st, model.ScopeOptions, "listen-on"); !slices.Equal(got, want4) { t.Errorf("listen-on = %q, want %q", got, want4) } if got := values(st, model.ScopeOptions, "listen-on-v6"); len(got) != 3 || got[0] != "{ any; }" { t.Errorf("listen-on-v6 = %q", got) } if len(notes) != 3 || !strings.Contains(strings.Join(notes, " "), "external has no zone") { t.Errorf("notes = %q", notes) } // The host name's addresses in the internal view. z := st.FindZone("example.com.", st.ViewByName("internal").ID) var addrs []string for _, r := range st.ZoneRecords(z.ID) { if r.Name == "dns.example.com." { addrs = append(addrs, r.Type+" "+r.Data) } } if !slices.Equal(addrs, []string{"A 10.0.10.5", "AAAA 2001:db8::5"}) { t.Errorf("records = %q", addrs) } s := GetStatus(st, e.certDir) if !s.Enabled || s.Certificate != "dns" || len(s.DoT) != 2 || len(s.DoH) != 2 || s.DoH[0].Port != 443 || s.Other { t.Errorf("status = %+v", s) } // Setting up again replaces the setup: DoT only, one address, and the // plain listeners aren't doubled. reqs, _, err = Plan(st, Setup{Hostname: "dns.example.com", Certificate: "dns", Addresses: []string{"10.0.10.5"}, DoT: true}, e.certDir) if err != nil { t.Fatal(err) } e.submit(reqs...) st = e.state() if got := values(st, model.ScopeOptions, "listen-on"); !slices.Equal(got, []string{"{ any; }", "port 853 tls jarl-dns { 10.0.10.5; }"}) { t.Errorf("after the second setup: %q", got) } if got := values(st, model.ScopeOptions, "listen-on-v6"); !slices.Equal(got, []string{"{ any; }"}) { t.Errorf("listen-on-v6 after the second setup: %q", got) } if got := values(st, model.ScopeTopLevel, "tls"); len(got) != 1 { t.Errorf("tls statements: %q", got) } // Off: the setup's listeners and tls statement go, the rest stays. e.submit(Disable(st)...) st = e.state() if got := values(st, model.ScopeOptions, "listen-on"); !slices.Equal(got, []string{"{ any; }"}) || len(values(st, model.ScopeTopLevel, "tls")) != 0 || GetStatus(st, e.certDir).Enabled { t.Errorf("after disabling: listen-on %q tls %q", got, values(st, model.ScopeTopLevel, "tls")) } } func TestSetupRefuses(t *testing.T) { e := newEnv(t) st := e.state() for _, s := range []Setup{ {Hostname: "dns.example.com", Certificate: "dns", Addresses: []string{"10.0.10.5"}}, // neither DoT nor DoH {Hostname: "dns.example.com", Certificate: "dns", Addresses: []string{"0.0.0.0"}, DoT: true}, // every address {Hostname: "dns.example.com", Certificate: "dns", DoT: true}, // no address {Hostname: "dns.example.com", Certificate: "dns", Addresses: []string{"10.0.10.5"}, DoH: true, DoHPort: 53}, // port 53 {Hostname: "dns", Certificate: "dns", Addresses: []string{"10.0.10.5"}, DoT: true}, // not a host name } { if _, _, err := Plan(st, s, e.certDir); err == nil { t.Errorf("accepted %+v", s) } } // A tls statement whose files are missing is refused by validation. _, err := e.eng.Submit(context.Background(), []engine.Request{engine.NewRequest(engine.OpOptionCreate, 0, engine.OptionInput{ Scope: model.Ptr(model.ScopeTopLevel), Name: model.Ptr("tls"), Value: model.Ptr(`x { cert-file "/nonexistent/c.pem"; key-file "/nonexistent/k.pem"; }`)})}, auth.System, "test", "", engine.ApplyOptions{}) if err == nil || !strings.Contains(err.Error(), "doesn't exist") { t.Errorf("missing files: %v", err) } } func TestPorts(t *testing.T) { s, err := parseProcAddr("0100007F:0035") if err != nil || s.Addr != netip.MustParseAddr("127.0.0.1") || s.Port != 53 { t.Errorf("v4: %+v %v", s, err) } s, err = parseProcAddr("00000000000000000000000001000000:01BB") if err != nil || s.Addr != netip.MustParseAddr("::1") || s.Port != 443 { t.Errorf("v6: %+v %v", s, err) } e := newEnv(t) old := ListeningSockets t.Cleanup(func() { ListeningSockets = old }) ListeningSockets = func() ([]Socket, error) { return []Socket{{netip.MustParseAddr("0.0.0.0"), 443}, {netip.MustParseAddr("10.0.10.5"), 8443}, {netip.MustParseAddr("::"), 853}}, nil } c, err := PortCheck(e.state(), []string{"10.0.10.5"}, []int{853, 443, 8443}, []string{"0.0.0.0:443"}) if err != nil || len(c) != 3 || c[1].What != "jarld (Jarl's web UI and API)" || c[2].What != "another program" { t.Errorf("conflicts = %+v %v", c, err) } if p := FreeDoHPort(e.state(), []string{"10.0.10.5"}, nil); p != 4443 { t.Errorf("free DoH port = %d, want 4443", p) } }