package jarlsets import ( "context" "errors" "io" "log/slog" "net/netip" "reflect" "strings" "testing" "housecarl/internal/jarl" "housecarl/internal/model" ) // fakeJarl is a Jarl with a few zones. type fakeJarl struct { zones []jarl.ZoneInfo records map[int64][]jarl.Record down bool // AllZones fails broken map[int64]bool // Records fails calls int } func (f *fakeJarl) AllZones(ctx context.Context) ([]jarl.ZoneInfo, error) { if f.down { return nil, errors.New("can't reach Jarl") } return f.zones, nil } func (f *fakeJarl) Records(ctx context.Context, id int64) ([]jarl.Record, error) { f.calls++ if f.broken[id] { return nil, errors.New("Jarl answered 500") } return f.records[id], nil } func rec(name, typ, data string) jarl.Record { return jarl.Record{Name: name, Type: typ, Data: data} } // newFake: example.com in views internal (1) and external (2), linked // sub-zone wg.example.com in internal (3), other.org only in internal (4), // a secondary (5), a disabled zone (6), an in-view zone in vps (7). func newFake() *fakeJarl { return &fakeJarl{ zones: []jarl.ZoneInfo{ {ID: 1, Name: "example.com.", View: "internal", Type: "primary"}, {ID: 2, Name: "example.com.", View: "external", Type: "primary"}, {ID: 3, Name: "wg.example.com.", View: "internal", Type: "primary"}, {ID: 4, Name: "other.org.", View: "internal", Type: "primary"}, {ID: 5, Name: "sec.net.", View: "internal", Type: "secondary"}, {ID: 6, Name: "off.net.", View: "internal", Type: "primary", Disabled: true}, {ID: 7, Name: "other.org.", View: "vps", Type: "in-view", InView: "internal"}, {ID: 8, Name: ".", View: "internal", Type: "hint"}, }, records: map[int64][]jarl.Record{ 1: { rec("example.com.", "NS", "ns1.example.com."), rec("nas.example.com.", "A", "10.10.0.20"), rec("nas.example.com.", "A", "10.10.0.21"), rec("nas.example.com.", "AAAA", "2001:db8:10::20"), rec("files.example.com.", "CNAME", "nas.example.com."), rec("vpn.example.com.", "CNAME", "peer1.wg.example.com."), rec("ext.example.com.", "CNAME", "host.duckdns.org."), rec("loop1.example.com.", "CNAME", "loop2.example.com."), rec("loop2.example.com.", "CNAME", "loop1"), rec("rel.example.com.", "CNAME", "nas"), rec("mail.example.com.", "MX", "10 nas.example.com."), rec("lab.example.com.", "NS", "ns.lab.example.com."), rec("www.example.com.", "A", "10.10.0.30"), rec("bad.example.com.", "A", "127.0.0.1"), rec("far.example.com.", "A", "192.0.2.9"), rec("dead.example.com.", "CNAME", "nothing.example.com."), rec("toother.example.com.", "CNAME", "srv.other.org."), }, 2: { rec("www.example.com.", "A", "203.0.113.30"), }, 3: { rec("peer1.wg.example.com.", "A", "10.50.50.2"), }, 4: { rec("srv.other.org.", "A", "10.10.0.40"), }, }, broken: map[int64]bool{}, } } func resolveOne(t *testing.T, f *fakeJarl, s string) *Lookup { t.Helper() l := &lookup{ctx: context.Background(), src: f, zones: f.zones, records: map[int64][]jarl.Record{}, errs: map[int64]error{}} return l.resolve(model.ParseJarlName(s)) } func TestResolve(t *testing.T) { f := newFake() cases := []struct { name string addrs []string chain []string problem string // part of it found string }{ {name: "nas.example.com@internal", addrs: []string{"10.10.0.20", "10.10.0.21", "2001:db8:10::20"}, found: "internal"}, {name: "NAS.Example.com.@internal", addrs: []string{"10.10.0.20", "10.10.0.21", "2001:db8:10::20"}, found: "internal"}, {name: "files.example.com@internal", addrs: []string{"10.10.0.20", "10.10.0.21", "2001:db8:10::20"}, chain: []string{"nas.example.com"}}, {name: "rel.example.com@internal", addrs: []string{"10.10.0.20", "10.10.0.21", "2001:db8:10::20"}, chain: []string{"nas.example.com"}}, // Into the sub-zone, and into another zone of the same view. {name: "vpn.example.com@internal", addrs: []string{"10.50.50.2"}, chain: []string{"peer1.wg.example.com"}}, {name: "toother.example.com@internal", addrs: []string{"10.10.0.40"}, chain: []string{"srv.other.org"}}, // Only internal has wg.example.com, but external's example.com // answers for the name too (that it doesn't exist): the view must be // named. {name: "peer1.wg.example.com", problem: "views external, internal"}, {name: "srv.other.org", problem: "views internal, vps"}, // Views give different answers. {name: "www.example.com@internal", addrs: []string{"10.10.0.30"}}, {name: "www.example.com@external", addrs: []string{"203.0.113.30"}}, {name: "www.example.com", problem: "views external, internal: name the view"}, {name: "www.example.com@vps", problem: "no zone in view vps holds www.example.com"}, // An in-view zone serves the other view's records. {name: "srv.other.org@vps", addrs: []string{"10.10.0.40"}}, {name: "ext.example.com@internal", chain: []string{"host.duckdns.org"}, problem: "its CNAME leads to host.duckdns.org, which Housecarl can't follow"}, {name: "loop1.example.com@internal", problem: "loop"}, {name: "dead.example.com@internal", chain: []string{"nothing.example.com"}, problem: "nothing.example.com, which has no A or AAAA record"}, {name: "mail.example.com@internal", problem: "has no A, AAAA or CNAME record"}, {name: "missing.example.com@internal", problem: "has no A, AAAA or CNAME record"}, {name: "host.lab.example.com@internal", problem: "below a delegation (NS records at lab.example.com)"}, {name: "a.sec.net", problem: "is a secondary zone"}, {name: "a.off.net", problem: "is disabled"}, {name: "nowhere.test", problem: "no zone in Jarl holds nowhere.test"}, } for _, c := range cases { lu := resolveOne(t, f, c.name) if lu.Error != "" { t.Errorf("%s: error %s", c.name, lu.Error) } if !reflect.DeepEqual(lu.Addresses, c.addrs) { t.Errorf("%s: addresses %v, want %v (problem %q)", c.name, lu.Addresses, c.addrs, lu.Problem) } if c.chain != nil && !reflect.DeepEqual(lu.Chain, c.chain) { t.Errorf("%s: chain %v, want %v", c.name, lu.Chain, c.chain) } if c.problem == "" && lu.Problem != "" || !strings.Contains(lu.Problem, c.problem) { t.Errorf("%s: problem %q, want %q", c.name, lu.Problem, c.problem) } if c.found != "" && lu.Found != c.found { t.Errorf("%s: found in %q, want %q", c.name, lu.Found, c.found) } } } func TestResolveNoViews(t *testing.T) { f := &fakeJarl{zones: []jarl.ZoneInfo{{ID: 1, Name: "lan.", Type: "primary"}}, records: map[int64][]jarl.Record{1: {rec("nas.lan.", "A", "192.168.1.5")}}} if lu := resolveOne(t, f, "nas.lan"); !reflect.DeepEqual(lu.Addresses, []string{"192.168.1.5"}) || lu.Problem != "" { t.Errorf("%+v", lu) } } // harness is a resolver over the fake Jarl, with lan = 10.10.0.0/24 and // wg = 10.50.50.0/24 connected. type harness struct { r *Resolver f *fakeJarl st *model.State conn map[netip.Prefix]string applies int audits []string saved string } func newHarness(sets ...*model.AddressSet) *harness { h := &harness{f: newFake(), conn: map[netip.Prefix]string{ netip.MustParsePrefix("10.10.0.0/24"): "lan", netip.MustParsePrefix("10.50.50.0/24"): "wg", }} h.st = &model.State{Zones: []*model.Zone{{Name: "lan"}, {Name: "wg"}, {Name: "wan"}}, Sets: sets} h.r = &Resolver{Source: h.f, State: func() *model.State { return h.st }, Log: slog.New(slog.NewTextHandler(io.Discard, nil)), Connected: func(*model.State) map[netip.Prefix]string { return h.conn }, Apply: func(ctx context.Context) error { h.applies++ h.r.Applied(h.r.Entries(h.st)) return nil }, Audit: func(ctx context.Context, set, detail string) { h.audits = append(h.audits, set+": "+detail) }, Load: func(ctx context.Context) (string, error) { return h.saved, nil }, Save: func(ctx context.Context, v string) error { h.saved = v; return nil }} return h } func jset(name string, within []string, names ...string) *model.AddressSet { j := &model.JarlSource{Within: within} for _, n := range names { j.Names = append(j.Names, model.ParseJarlName(n)) } return &model.AddressSet{Name: name, Jarl: j} } func TestWithin(t *testing.T) { h := newHarness( jset("nas", []string{"lan"}, "nas.example.com@internal", "vpn.example.com@internal", "bad.example.com@internal", "far.example.com@internal"), jset("wide", []string{"lan", "wg", "192.0.2.0/24", "2001:db8:10::/64"}, "nas.example.com@internal", "vpn.example.com@internal", "far.example.com@internal"), jset("none", []string{"lan"}, "missing.example.com@internal"), ) h.r.Refresh(context.Background()) got := h.r.Entries(h.st) want := map[string][]string{ "nas": {"10.10.0.20", "10.10.0.21"}, "wide": {"10.10.0.20", "10.10.0.21", "10.50.50.2", "192.0.2.9", "2001:db8:10::20"}, "none": {}, } if !reflect.DeepEqual(got, want) { t.Fatalf("entries %v, want %v", got, want) } st := h.r.Status(h.st, h.st.Sets[0]) why := map[string]string{} for _, n := range st.Names { for _, a := range n.Addresses { why[a.Address] = a.Why } } if why["10.10.0.20"] != "" || !strings.Contains(why["10.50.50.2"], "not within lan") || !strings.Contains(why["127.0.0.1"], "loopback") || why["2001:db8:10::20"] == "" { t.Errorf("why: %v", why) } codes := map[string]int{} for _, p := range h.r.Problems(h.st) { codes[p.Code]++ } // nas: 3 left out (v6, wg, loopback, far = 4); wide: none; none: a name problem. if codes["jarl_outside"] != 4 || codes["jarl_name"] != 1 || codes["jarl_unreachable"] != 0 { t.Errorf("problems %v: %+v", codes, h.r.Problems(h.st)) } // The lan interface loses its address: its networks as last seen count. h.conn = map[netip.Prefix]string{} if got := h.r.Entries(h.st)["nas"]; !reflect.DeepEqual(got, []string{"10.10.0.20", "10.10.0.21"}) { t.Errorf("without lan's address: %v", got) } } func TestKeepsWhatItHad(t *testing.T) { h := newHarness(jset("nas", []string{"lan", "wg"}, "nas.example.com@internal", "peer1.wg.example.com@internal")) ctx := context.Background() h.r.Refresh(ctx) if h.applies != 1 || len(h.audits) != 1 || !strings.Contains(h.audits[0], "added 10.10.0.20, 10.10.0.21, 10.50.50.2") { t.Fatalf("first read: %d applies, audits %v", h.applies, h.audits) } // Nothing changed: no apply. h.r.Refresh(ctx) if h.applies != 1 { t.Errorf("unchanged: %d applies", h.applies) } // Jarl down: the addresses stay, a warning says so. h.f.down = true h.r.Refresh(ctx) if got := h.r.Entries(h.st)["nas"]; len(got) != 3 || h.applies != 1 { t.Errorf("Jarl down: %v, %d applies", got, h.applies) } if ps := h.r.Problems(h.st); len(ps) != 2 || ps[0].Code != "jarl_unreachable" || ps[1].Code != "jarl_outside" { t.Errorf("Jarl down: %+v", ps) } // One zone's records fail: only its name keeps the old addresses. h.f.down = false h.f.broken[3] = true h.f.records[1][1] = rec("nas.example.com.", "A", "10.10.0.22") h.r.Refresh(ctx) if got := h.r.Entries(h.st)["nas"]; !reflect.DeepEqual(got, []string{"10.10.0.21", "10.10.0.22", "10.50.50.2"}) { t.Errorf("one zone down: %v", got) } if h.applies != 2 || !strings.Contains(h.audits[1], "added 10.10.0.22; removed 10.10.0.20") { t.Errorf("audits %v", h.audits) } // After a restart, before Jarl is read: what was saved. r2 := &Resolver{Load: h.r.Load, State: h.r.State, Connected: h.r.Connected} r2.Restore(ctx) if got := r2.Entries(h.st)["nas"]; !reflect.DeepEqual(got, []string{"10.10.0.21", "10.10.0.22", "10.50.50.2"}) { t.Errorf("restored: %v", got) } // Jarl answers that the name is gone: its addresses go. h.f.broken[3] = false h.f.records[3] = nil h.r.Refresh(ctx) if got := h.r.Entries(h.st)["nas"]; !reflect.DeepEqual(got, []string{"10.10.0.21", "10.10.0.22"}) { t.Errorf("deleted record: %v", got) } // A name taken out of the set is forgotten. h.st.Sets[0].Jarl.Names = h.st.Sets[0].Jarl.Names[:1] h.r.Refresh(ctx) if len(h.r.data.Lookups) != 1 { t.Errorf("lookups %v", h.r.data.Lookups) } } func TestNotConfigured(t *testing.T) { h := newHarness(jset("nas", []string{"lan"}, "nas.example.com@internal")) h.r.Source = nil h.r.Refresh(context.Background()) if ps := h.r.Problems(h.st); len(ps) != 1 || ps[0].Code != "jarl_not_configured" { t.Errorf("%+v", ps) } if got := h.r.Entries(h.st); !reflect.DeepEqual(got, map[string][]string{"nas": {}}) { t.Errorf("%v", got) } } // TestNoSetsNoApply: without Jarl sets nothing is applied, not even at the // first read (an empty configuration must never be applied). func TestNoSetsNoApply(t *testing.T) { h := newHarness() h.r.Refresh(context.Background()) if h.applies != 0 { t.Errorf("%d applies", h.applies) } }