A set can take its addresses from Jarl: every A and AAAA record of its
names (NAME@VIEW), CNAMEs followed within Jarl's zones in the same view.
housecarld reads them every jarl.refresh and after each change, keeps
only the addresses within the zones and networks the set allows, and
keeps what it last read (saved in the database and the boot ruleset)
while Jarl can't be reached. A change of the addresses applies the
ruleset again without a history entry and is audited (set.jarl).
hc set add --jarl --within, hc set jarl; the kind "from Jarl" and
Addresses… on the Sets page; GET /sets/{name}/jarl and /jarl/refresh;
validation codes set_jarl_*, status warnings jarl_*; docs/jarl.md.
e2e phase with scripts/fakejarl.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
318 lines
12 KiB
Go
318 lines
12 KiB
Go
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)
|
|
}
|
|
}
|