// Package jarlsets fills the sets whose addresses come from Jarl. It reads // the A and AAAA records of the sets' names through Jarl's API, following // CNAMEs within the zones Jarl holds, every minute or so, and keeps what it // last read (in the database too) for when Jarl can't be reached. The // firewall gets, for each set, the addresses that lie within what the set // allows (zones' connected networks, or networks it names); the others are // left out and reported. When what the sets hold changes, the ruleset is // applied again (no history entry: the configuration didn't change). package jarlsets import ( "context" "encoding/json" "fmt" "log/slog" "net/netip" "sort" "strings" "sync" "time" "housecarl/internal/api" "housecarl/internal/jarl" "housecarl/internal/model" "housecarl/internal/validate" ) // MetaKey is the database meta key holding what was last read. const MetaKey = "jarl_sets" // maxChain is how many CNAMEs are followed for one name. const maxChain = 8 // Source is Jarl (internal/jarl.Client). type Source interface { AllZones(ctx context.Context) ([]jarl.ZoneInfo, error) Records(ctx context.Context, zone int64) ([]jarl.Record, error) } // Lookup is what Jarl gave for one name in one view. type Lookup struct { Name string `json:"name"` View string `json:"view,omitempty"` Found string `json:"found,omitempty"` Chain []string `json:"chain,omitempty"` Addresses []string `json:"addresses,omitempty"` // Problem is why Jarl's records give no addresses (Jarl answered). Problem string `json:"problem,omitempty"` // Checked is when Jarl last answered; Error why the last try failed. Checked time.Time `json:"checked"` Error string `json:"error,omitempty"` } // saved is what the database keeps. type saved struct { Lookups map[string]*Lookup `json:"lookups"` // Nets are the zones' connected networks last seen, for when a zone's // interfaces have no address (at boot, a link down). Nets map[string][]netip.Prefix `json:"nets,omitempty"` } // Resolver keeps the Jarl sets' addresses. type Resolver struct { // Source is Jarl (nil: not configured; the sets keep what was read). Source Source // Interval is how often Jarl is read (default a minute). Interval time.Duration // State is the configuration in force. State func() *model.State // Connected are the networks connected to the firewall, with their // zones. Connected func(st *model.State) map[netip.Prefix]string // Apply puts the sets' new addresses in force. Apply func(ctx context.Context) error // Audit records a change of a set's addresses. Audit func(ctx context.Context, set, detail string) // Load and Save read and write the database's copy. Load func(ctx context.Context) (string, error) Save func(ctx context.Context, value string) error Log *slog.Logger run sync.Mutex // one refresh at a time mu sync.Mutex data saved // applied is what the ruleset in force holds (known after an apply). applied map[string][]string known bool read time.Time err string kick chan struct{} now func() time.Time } func (r *Resolver) init() { if r.data.Lookups == nil { r.data.Lookups = map[string]*Lookup{} } if r.data.Nets == nil { r.data.Nets = map[string][]netip.Prefix{} } if r.kick == nil { r.kick = make(chan struct{}, 1) } if r.now == nil { r.now = time.Now } if r.Log == nil { r.Log = slog.Default() } } // Restore reads what was saved before housecarld (or the machine) // stopped. Call it before the first apply. func (r *Resolver) Restore(ctx context.Context) { r.mu.Lock() defer r.mu.Unlock() r.init() if r.Load == nil { return } v, err := r.Load(ctx) if err != nil || v == "" { return } var s saved if err := json.Unmarshal([]byte(v), &s); err != nil { r.Log.Warn("the saved Jarl set addresses can't be read; reading Jarl again", "err", err) return } if s.Lookups != nil { r.data.Lookups = s.Lookups } if s.Nets != nil { r.data.Nets = s.Nets } } // Kick has the next refresh happen now (after a change of the // configuration: a new name). func (r *Resolver) Kick() { r.mu.Lock() r.init() k := r.kick r.mu.Unlock() select { case k <- struct{}{}: default: } } // Run reads Jarl until ctx ends. func (r *Resolver) Run(ctx context.Context) { r.mu.Lock() r.init() k := r.kick every := r.Interval r.mu.Unlock() if every <= 0 { every = time.Minute } t := time.NewTicker(every) defer t.Stop() for { r.Refresh(ctx) select { case <-ctx.Done(): return case <-t.C: case <-k: } } } // Refresh reads Jarl now and puts what changed in force. func (r *Resolver) Refresh(ctx context.Context) { r.run.Lock() defer r.run.Unlock() st := r.State() r.read1(ctx, st) r.sync(ctx, st) } // needed are the names the sets of st look up, by key. func needed(st *model.State) map[string]model.JarlName { out := map[string]model.JarlName{} for _, s := range st.Sets { if s.Jarl == nil { continue } for _, n := range s.Jarl.Names { out[n.String()] = n } } return out } // read1 reads every name from Jarl once. func (r *Resolver) read1(ctx context.Context, st *model.State) { names := needed(st) r.mu.Lock() r.init() src := r.Source r.mu.Unlock() // The zones' networks, for when they can't be seen (Entries). r.rememberNets(st) if src == nil || len(names) == 0 { r.mu.Lock() changed := false for k := range r.data.Lookups { if _, ok := names[k]; !ok { delete(r.data.Lookups, k) changed = true } } r.mu.Unlock() if changed { r.save(ctx) } return } ctx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() zones, err := src.AllZones(ctx) now := r.now() results := map[string]*Lookup{} if err == nil { l := &lookup{ctx: ctx, src: src, zones: zones, records: map[int64][]jarl.Record{}, errs: map[int64]error{}} for k, n := range names { results[k] = l.resolve(n) } } r.mu.Lock() if err != nil { r.err = err.Error() } else { r.err, r.read = "", now } for k, n := range names { old := r.data.Lookups[k] res := results[k] switch { case res != nil && res.Error == "": res.Checked = now r.data.Lookups[k] = res case old != nil: // Keep what Jarl last said. old.Error = errText(err, res) default: r.data.Lookups[k] = &Lookup{Name: n.Name, View: n.View, Error: errText(err, res)} } if err != nil || res != nil && res.Error != "" { r.Log.Warn("reading a Jarl set's name failed; the set keeps the addresses it had", "name", n.String(), "err", r.data.Lookups[k].Error) } } for k := range r.data.Lookups { if _, ok := names[k]; !ok { delete(r.data.Lookups, k) } } r.mu.Unlock() r.save(ctx) } func errText(err error, res *Lookup) string { if err != nil { return err.Error() } return res.Error } func (r *Resolver) save(ctx context.Context) { if r.Save == nil { return } r.mu.Lock() b, err := json.Marshal(r.data) r.mu.Unlock() if err == nil { err = r.Save(ctx, string(b)) } if err != nil { r.Log.Warn("saving the Jarl set addresses failed", "err", err) } } // rememberNets keeps each zone's connected networks. func (r *Resolver) rememberNets(st *model.State) { if r.Connected == nil { return } now := zoneNets(r.Connected(st)) r.mu.Lock() defer r.mu.Unlock() for z, ps := range now { r.data.Nets[z] = ps } } func zoneNets(conn map[netip.Prefix]string) map[string][]netip.Prefix { out := map[string][]netip.Prefix{} for p, z := range conn { out[z] = append(out[z], p) } for _, ps := range out { sort.Slice(ps, func(i, j int) bool { return ps[i].String() < ps[j].String() }) } return out } // sync applies the ruleset again if the sets' addresses changed. func (r *Resolver) sync(ctx context.Context, st *model.State) { want := r.Entries(st) r.mu.Lock() known, applied := r.known, r.applied r.mu.Unlock() // No Jarl sets, nothing to do (and an empty configuration is never // applied: its policies would close the machine). if len(want) == 0 || st.Empty() || known && sameEntries(applied, want) || r.Apply == nil { return } if err := r.Apply(ctx); err != nil { r.Log.Error("putting the Jarl sets' new addresses in force failed", "err", err) return } for _, s := range st.Sets { if s.Jarl == nil { continue } add, del := diff(applied[s.Name], want[s.Name]) if len(add) == 0 && len(del) == 0 { continue } var parts []string if len(add) > 0 { parts = append(parts, "added "+strings.Join(add, ", ")) } if len(del) > 0 { parts = append(parts, "removed "+strings.Join(del, ", ")) } detail := strings.Join(parts, "; ") r.Log.Info("a Jarl set's addresses changed", "set", s.Name, "change", detail) if r.Audit != nil { r.Audit(ctx, s.Name, detail) } } } func sameEntries(a, b map[string][]string) bool { if len(a) != len(b) { return false } for k, v := range a { w, ok := b[k] if !ok || strings.Join(v, ",") != strings.Join(w, ",") { return false } } return true } func diff(old, now []string) (add, del []string) { was := map[string]bool{} for _, a := range old { was[a] = true } is := map[string]bool{} for _, a := range now { is[a] = true if !was[a] { add = append(add, a) } } for _, a := range old { if !is[a] { del = append(del, a) } } return add, del } // Applied implements nft.JarlSets. func (r *Resolver) Applied(entries map[string][]string) { c := map[string][]string{} for k, v := range entries { c[k] = append([]string(nil), v...) } r.mu.Lock() r.applied, r.known = c, true r.mu.Unlock() } // Entries implements nft.JarlSets: every Jarl set of st (empty or not), // with the addresses its names have that lie within what it allows. func (r *Resolver) Entries(st *model.State) map[string][]string { out := map[string][]string{} nets := r.nets(st) r.mu.Lock() defer r.mu.Unlock() r.init() for _, s := range st.Sets { if s.Jarl == nil { continue } seen := map[string]bool{} list := []string{} for _, n := range s.Jarl.Names { lu := r.data.Lookups[n.String()] if lu == nil { continue } for _, a := range lu.Addresses { if seen[a] || allowed(a, s.Jarl.Within, nets) != "" { continue } seen[a] = true list = append(list, a) } } sortAddrs(list) out[s.Name] = list } return out } // nets are the zones' connected networks: now, or as last seen. func (r *Resolver) nets(st *model.State) map[string][]netip.Prefix { var now map[string][]netip.Prefix if r.Connected != nil { now = zoneNets(r.Connected(st)) } r.mu.Lock() defer r.mu.Unlock() r.init() out := map[string][]netip.Prefix{} for _, z := range st.Zones { if ps := now[z.Name]; len(ps) > 0 { out[z.Name] = ps } else { out[z.Name] = r.data.Nets[z.Name] } } return out } func sortAddrs(list []string) { sort.Slice(list, func(i, j int) bool { a, _ := netip.ParseAddr(list[i]) b, _ := netip.ParseAddr(list[j]) return a.Less(b) }) } // allowed says why an address isn't let in ("" if it is). func allowed(addr string, within []string, nets map[string][]netip.Prefix) string { a, err := netip.ParseAddr(addr) if err != nil { return "not an address" } a = a.Unmap() switch { case a.IsUnspecified(), a.IsLoopback(), a.IsMulticast(): return "not a host's address (unspecified, loopback or multicast)" } for _, w := range within { if l, err := model.ParseAddress(w); err == nil { if l.Contains(a) { return "" } continue } for _, p := range nets[w] { if p.Contains(a) { return "" } } } return "not within " + strings.Join(within, ", ") } // ---- looking names up // lookup reads Jarl's records for one refresh. type lookup struct { ctx context.Context src Source zones []jarl.ZoneInfo records map[int64][]jarl.Record errs map[int64]error } func fqdn(n string) string { n = strings.ToLower(strings.TrimSpace(n)) if !strings.HasSuffix(n, ".") { n += "." } return n } func under(name, zone string) bool { return zone == "." || name == zone || strings.HasSuffix(name, "."+zone) } func short(n string) string { return strings.TrimSuffix(n, ".") } // zoneFor finds the zone of Jarl's that answers for name in view ("": // the only view whose zones hold it). It returns the zone, the view, or // why there is none. func (l *lookup) zoneFor(name, view string) (*jarl.ZoneInfo, string, string) { if view == "" { views := map[string]bool{} for _, z := range l.zones { if z.Type != "hint" && under(name, fqdn(z.Name)) { views[z.View] = true } } switch len(views) { case 0: return nil, "", fmt.Sprintf("no zone in Jarl holds %s (or the token can't see it)", short(name)) case 1: for v := range views { view = v } default: var vs []string for v := range views { vs = append(vs, v) } sort.Strings(vs) return nil, "", fmt.Sprintf("%s is in Jarl's views %s: name the view, such as %s@%s", short(name), strings.Join(vs, ", "), short(name), vs[0]) } } var best *jarl.ZoneInfo for i := range l.zones { z := &l.zones[i] zn := fqdn(z.Name) if z.Type == "hint" || !strings.EqualFold(z.View, view) || !under(name, zn) { continue } if best == nil || len(zn) > len(fqdn(best.Name)) { best = z } } where := "" if view != "" { where = " in view " + view } if best == nil { return nil, view, fmt.Sprintf("no zone%s holds %s in Jarl (or the token can't see it)", where, short(name)) } if best.Type == "in-view" { var served *jarl.ZoneInfo for i := range l.zones { z := &l.zones[i] if fqdn(z.Name) == fqdn(best.Name) && strings.EqualFold(z.View, best.InView) && z.Type != "in-view" { served = z } } if served == nil { return nil, view, fmt.Sprintf("zone %s%s serves view %s's zone, which the token can't see", short(best.Name), where, best.InView) } best = served } if best.Disabled { return nil, view, fmt.Sprintf("zone %s%s is disabled in Jarl", short(best.Name), where) } if best.Type != "primary" { return nil, view, fmt.Sprintf("zone %s%s is a %s zone: Jarl doesn't hold its records", short(best.Name), where, best.Type) } return best, view, "" } func (l *lookup) zoneRecords(id int64) ([]jarl.Record, error) { if err, ok := l.errs[id]; ok { return nil, err } if rs, ok := l.records[id]; ok { return rs, nil } rs, err := l.src.Records(l.ctx, id) if err != nil { l.errs[id] = err return nil, err } l.records[id] = rs return rs, nil } // resolve looks a name up as named would answer it in the view: its A // and AAAA records, or those of the names its CNAMEs lead to. func (l *lookup) resolve(n model.JarlName) *Lookup { out := &Lookup{Name: n.Name, View: n.View} name, view := fqdn(n.Name), n.View seen := map[string]bool{name: true} for hop := 0; ; hop++ { z, v, problem := l.zoneFor(name, view) if hop == 0 { out.Found = v } if problem != "" { if hop > 0 { problem = fmt.Sprintf("its CNAME leads to %s, which Housecarl can't follow: %s (only names Jarl holds are followed)", short(name), problem) } out.Problem = problem return out } view = v recs, err := l.zoneRecords(z.ID) if err != nil { out.Error = err.Error() return out } apex := fqdn(z.Name) var addrs []string cname := "" for _, rec := range recs { owner := fqdn(rec.Name) switch { case rec.Type == "NS" && owner != apex && under(name, owner): out.Problem = fmt.Sprintf("%s is below a delegation (NS records at %s): Jarl doesn't hold its records", short(name), short(owner)) return out case owner != name: case rec.Type == "A" || rec.Type == "AAAA": if a, err := netip.ParseAddr(strings.TrimSpace(rec.Data)); err == nil { addrs = append(addrs, a.Unmap().String()) } case rec.Type == "CNAME": cname = target(rec.Data, apex) } } if cname != "" { if seen[cname] || len(out.Chain) >= maxChain { out.Problem = fmt.Sprintf("its CNAMEs go round in a loop or are longer than %d (%s)", maxChain, strings.Join(append(out.Chain, short(cname)), " → ")) return out } seen[cname] = true out.Chain = append(out.Chain, short(cname)) name = cname continue } if len(addrs) == 0 { if hop > 0 { out.Problem = fmt.Sprintf("its CNAME leads to %s, which has no A or AAAA record in Jarl", short(name)) } else { out.Problem = fmt.Sprintf("%s has no A, AAAA or CNAME record in Jarl (wildcards aren't followed)", short(name)) } return out } sortAddrs(addrs) out.Addresses = dedup(addrs) return out } } // target is a CNAME's target as an FQDN (a relative one is under the // zone, as in a zone file). func target(data, apex string) string { t := strings.ToLower(strings.TrimSpace(data)) if strings.HasSuffix(t, ".") { return t } if t == "@" { return apex } if apex == "." { return t + "." } return t + "." + apex } func dedup(in []string) []string { var out []string for i, a := range in { if i == 0 || a != in[i-1] { out = append(out, a) } } return out } // ---- status // Status is what a Jarl set holds and why. func (r *Resolver) Status(st *model.State, set *model.AddressSet) api.JarlSet { out := api.JarlSet{Set: set.Name, Entries: []string{}, Names: []api.JarlName{}, Within: []string{}} if set.Jarl == nil { return out } out.Within = append(out.Within, set.Jarl.Within...) out.Entries = r.Entries(st)[set.Name] nets := r.nets(st) r.mu.Lock() defer r.mu.Unlock() r.init() out.Configured = r.Source != nil if !r.read.IsZero() { t := r.read out.Read = &t } out.Error = r.err for _, n := range set.Jarl.Names { jn := api.JarlName{Name: n.Name, View: n.View, Addresses: []api.JarlAddress{}} if lu := r.data.Lookups[n.String()]; lu != nil { jn.Found, jn.Chain, jn.Problem, jn.Error = lu.Found, lu.Chain, lu.Problem, lu.Error if !lu.Checked.IsZero() { t := lu.Checked jn.Checked = &t } for _, a := range lu.Addresses { why := allowed(a, set.Jarl.Within, nets) jn.Addresses = append(jn.Addresses, api.JarlAddress{Address: a, Used: why == "", Why: why}) } } out.Names = append(out.Names, jn) } return out } // Problems are the warnings about the Jarl sets for the status page. func (r *Resolver) Problems(st *model.State) []validate.Problem { var out []validate.Problem var sets []*model.AddressSet for _, s := range st.Sets { if s.Jarl != nil { sets = append(sets, s) } } if len(sets) == 0 { return nil } r.mu.Lock() configured, readErr := r.Source != nil, r.err r.mu.Unlock() if !configured { out = append(out, validate.Problem{Severity: validate.Warning, Code: "jarl_not_configured", Object: "jarl", Message: "sets take their addresses from Jarl, but housecarl.yaml names no Jarl server (jarl.server, docs/jarl.md): they keep the addresses they last had, none if Jarl was never read"}) } else if readErr != "" { out = append(out, validate.Problem{Severity: validate.Warning, Code: "jarl_unreachable", Object: "jarl", Message: "reading Jarl failed: " + readErr + "; the Jarl sets keep the addresses they had"}) } for _, s := range sets { obj := "set " + s.Name for _, n := range r.Status(st, s).Names { switch { case n.Problem != "": out = append(out, validate.Problem{Severity: validate.Warning, Code: "jarl_name", Object: obj, Message: n.Name + viewSuffix(n.View) + ": " + n.Problem + "; it adds no addresses"}) case n.Error != "" && readErr == "": out = append(out, validate.Problem{Severity: validate.Warning, Code: "jarl_unreachable", Object: obj, Message: n.Name + viewSuffix(n.View) + ": reading it from Jarl failed: " + n.Error + "; it keeps the addresses it had"}) } for _, a := range n.Addresses { if !a.Used { out = append(out, validate.Problem{Severity: validate.Warning, Code: "jarl_outside", Object: obj, Message: fmt.Sprintf("%s%s has address %s, which is left out: %s", n.Name, viewSuffix(n.View), a.Address, a.Why)}) } } } } return out } func viewSuffix(v string) string { if v == "" { return "" } return "@" + v }