Sets from Jarl: sets holding the addresses of names in Jarl
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>
This commit is contained in:
1 parent
50dce02746
commit
68ba2d06c5
37 files changed
+2315
-59
No files matched your search
@@ -0,0 +1,767 @@
|
||||
// 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
|
||||
}
|
||||
Reference in new issue
Block a user