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) } } // Each address gets its own views; a view no longer given loses it, and // records of the host names that aren't the setup's stay. func TestSetupAddressViews(t *testing.T) { e := newEnv(t) e.submit(engine.NewRequest(engine.OpZoneCreate, 0, engine.ZoneInput{Name: model.Ptr("example.com"), View: model.Ptr("external"), LinkWith: "internal"})) e.submit(engine.NewRequest(engine.OpRRSetReplace, 0, engine.RRSetInput{Zone: "example.com", View: "external", Name: "doh.example.com.", Type: "A", Data: []string{"198.51.100.9"}, Local: model.Ptr(true)})) hosts := func(st *model.State, view string) []string { z := st.FindZone("example.com.", st.ViewByName(view).ID) var out []string for _, r := range st.ZoneRecords(z.ID) { if strings.HasPrefix(r.Name, "dns.") || strings.HasPrefix(r.Name, "doh.") { out = append(out, strings.SplitN(r.Name, ".", 2)[0]+" "+r.Type+" "+r.Data) } } return out } setup := Setup{Hostnames: []string{"dns.example.com", "doh.example.com"}, Certificate: "dns", DoT: true, DoH: true, Addresses: []string{"10.0.10.5", "203.0.113.5", "10.0.10.6"}, AddressViews: map[string][]string{"10.0.10.5": {"internal"}, "203.0.113.5": {"external"}, "10.0.10.6": {"external"}}, PublicViews: map[string]bool{"external": true}} reqs, notes, err := Plan(e.state(), setup, e.certDir) if err != nil { t.Fatal(err) } if !slices.ContainsFunc(notes, func(n string) bool { return strings.Contains(n, "view external is reachable from the internet, but gets the private address 10.0.10.6") }) { t.Errorf("notes %q", notes) } e.submit(reqs...) st := e.state() if got, want := hosts(st, "internal"), []string{"dns A 10.0.10.5", "doh A 10.0.10.5"}; !slices.Equal(got, want) { t.Errorf("internal %q, want %q", got, want) } // The external doh record that isn't the setup's stays. if got, want := hosts(st, "external"), []string{"dns A 10.0.10.6", "dns A 203.0.113.5", "doh A 10.0.10.6", "doh A 198.51.100.9", "doh A 203.0.113.5"}; !slices.Equal(got, want) { t.Errorf("external %q, want %q", got, want) } if !slices.Equal(st.Settings.EncryptedDNSHosts, []string{"dns.example.com", "doh.example.com"}) { t.Errorf("hosts %q", st.Settings.EncryptedDNSHosts) } av := CurrentAddressViews(st, st.Settings.EncryptedDNSHosts) if !slices.Equal(av["10.0.10.5"], []string{"internal"}) || !slices.Equal(av["10.0.10.6"], []string{"external"}) { t.Errorf("address views %v", av) } uses := CertUses(st, filepath.Join(e.certDir, "dns")) if len(uses) != 2 || !strings.HasPrefix(uses[0], "DNS over TLS on 10.0.10.5, 203.0.113.5, 10.0.10.6 port 853") || !strings.HasPrefix(uses[1], "DNS over HTTPS on") { t.Errorf("cert uses %q", uses) } // 10.0.10.6 no longer in external, doh.example.com no longer a host name. setup = Setup{Hostname: "dns.example.com", Certificate: "dns", DoT: true, Addresses: []string{"10.0.10.5", "203.0.113.5", "10.0.10.6"}, AddressViews: map[string][]string{"10.0.10.5": {"internal"}, "203.0.113.5": {"external"}}} reqs, _, err = Plan(st, setup, e.certDir) if err != nil { t.Fatal(err) } e.submit(reqs...) st = e.state() if got, want := hosts(st, "external"), []string{"dns A 203.0.113.5", "doh A 198.51.100.9"}; !slices.Equal(got, want) { t.Errorf("external after %q, want %q", got, want) } if got, want := hosts(st, "internal"), []string{"dns A 10.0.10.5"}; !slices.Equal(got, want) { t.Errorf("internal after %q, want %q", got, want) } }