package acl import ( "fmt" "net/netip" "strings" "git.k8s-git-k6n.net/mats/fancy-dns/internal/config" ) // Finding is one observation about a group, tied to the field it concerns so // an editor can show it next to the input. type Finding struct { Field string `json:"field"` Message string `json:"message"` } // Analysis is what Analyze says about a group that is structurally valid. // // It exists for people who do not know what the settings do. The engine's // behaviour is not always what a setting's name suggests (an allow list is an // allowlist-only mode, a rate limit of 0 exempts the group from the default), // so the analysis states the effective behaviour in plain words and flags the // settings that are legal but almost certainly not what was meant. type Analysis struct { // Summary is the group in plain English, one sentence per line. Summary []string `json:"summary"` // Warnings are legal settings that probably do not do what was intended. Warnings []Finding `json:"warnings"` // Notes are facts worth knowing that are not mistakes. Notes []Finding `json:"notes"` // Unreachable is set when no client can ever land in the group, because // the groups above it claim everything it selects. Unreachable bool `json:"unreachable"` } // Context is the group under review and everything around it. type Context struct { // Groups is the full evaluation order, with the group under review in it. Groups []config.Group // Index is the position of the group under review within Groups. Index int // DefaultRateLimit is acl.default_rate_limit (queries/second/client). DefaultRateLimit int // DefaultPolicy is what a client that matches no group gets. DefaultPolicy string // Upstreams are the configured upstream addresses. Nil means unknown, and // the upstream check is skipped. Upstreams []string } // Analyze explains Groups[Index]. It does not validate: a group that fails // validation is reported by the caller's own checks, and Analyze copes with // whatever it is handed. func Analyze(c Context) Analysis { a := Analysis{Summary: []string{}, Warnings: []Finding{}, Notes: []Finding{}} if c.Index < 0 || c.Index >= len(c.Groups) { return a } g := c.Groups[c.Index] warn := func(field, format string, args ...any) { a.Warnings = append(a.Warnings, Finding{field, fmt.Sprintf(format, args...)}) } note := func(field, format string, args ...any) { a.Notes = append(a.Notes, Finding{field, fmt.Sprintf(format, args...)}) } a.Summary = append(a.Summary, whoSentence(g), c.orderSentence()) // What the group does. switch g.Policy { case "deny": a.Summary = append(a.Summary, "Every query from these clients is refused (REFUSED). Nothing below applies to them.") for _, ignored := range []struct { field string set bool }{ {"allow_domains", len(g.AllowDomains) > 0}, {"deny_domains", len(g.DenyDomains) > 0}, {"skip_blocklist", g.SkipBlocklist}, {"skip_cache", g.SkipCache}, {"rate_limit", g.RateLimit > 0}, {"max_concurrent", g.MaxConcurrent > 0}, {"upstreams", len(g.Upstreams) > 0}, } { if ignored.set { note(ignored.field, "Ignored: this group's policy is deny, which refuses the query before anything else is looked at.") } } case "block_only": a.Summary = append(a.Summary, "Queries are resolved normally, and names on a blocklist are blocked.") warn("policy", "block_only currently behaves exactly like allow: both resolve everything and both apply the blocklists. Only deny changes what the group can do. Pick allow unless you are keeping block_only for a reason.") default: a.Summary = append(a.Summary, "Queries are resolved normally, and names on a blocklist are blocked.") } if g.Policy != "deny" { a.analyzeBehaviour(c, g, warn, note) } a.analyzeMatch(g, warn, note) a.analyzeDomains(g, warn, note) a.analyzeOrder(c, warn) return a } func (a *Analysis) analyzeBehaviour(c Context, g config.Group, warn, note func(string, string, ...any)) { if len(g.AllowDomains) > 0 { a.Summary = append(a.Summary, fmt.Sprintf("Allowlist mode: ONLY %s can be looked up. Every other name is answered as blocked.", joinShort(g.AllowDomains))) note("allow_domains", "A non-empty allow list turns the group into allowlist-only mode. It does not exempt names from the blocklists; use \"Skip blocklists\" for that.") } if len(g.DenyDomains) > 0 { a.Summary = append(a.Summary, fmt.Sprintf("%s is always blocked for these clients, whatever the blocklists say.", joinShort(g.DenyDomains))) } if g.SkipBlocklist { a.Summary = append(a.Summary, "Blocklists are ignored for these clients: ads and trackers resolve normally.") } if g.SkipCache { a.Summary = append(a.Summary, "Nothing is served from or stored in the cache for these clients; every query goes upstream.") note("skip_cache", "Every query from this group costs an upstream round trip, so it is slower and adds load. Use it for debugging, not as a default.") } switch { case g.RateLimit > 0: a.Summary = append(a.Summary, fmt.Sprintf("Each client address may send %d queries per second; above that, queries are refused.", g.RateLimit)) case c.DefaultRateLimit > 0: a.Summary = append(a.Summary, "There is no rate limit for these clients.") warn("rate_limit", "0 means unlimited here, not \"use the default\": this group is exempt from the default limit of %d queries per second per client.", c.DefaultRateLimit) } if g.MaxConcurrent > 0 { a.Summary = append(a.Summary, fmt.Sprintf("A client may have at most %d queries in flight; the excess get SERVFAIL.", g.MaxConcurrent)) } if g.LogQueries { a.Summary = append(a.Summary, "Every query from these clients is written to the access log, even when logging is otherwise sampled or limited to blocked queries.") } if len(g.Upstreams) > 0 { a.Summary = append(a.Summary, fmt.Sprintf("Queries go to %s instead of the default upstreams.", joinShort(g.Upstreams))) note("upstreams", "Conditional-forwarding zones (upstream.forward_zones) still take precedence over a group's upstreams.") if c.Upstreams != nil { known := map[string]bool{} for _, u := range c.Upstreams { known[strings.ToLower(u)] = true } var unknown []string for _, u := range g.Upstreams { if !known[strings.ToLower(u)] { unknown = append(unknown, u) } } switch { case len(unknown) == len(g.Upstreams): warn("upstreams", "None of these addresses is a configured upstream, so the setting is ignored and this group uses ALL upstreams. Use an address exactly as it appears on the Upstreams page.") case len(unknown) > 0: warn("upstreams", "%s is not a configured upstream and is ignored.", joinShort(unknown)) } } } } func (a *Analysis) analyzeMatch(g config.Group, warn, note func(string, string, ...any)) { if g.Match.Any { warn("match.any", "This group matches every client, so any addresses, suffixes or tags below it are ignored, and so are all groups after it.") } for _, s := range g.Match.CIDRs { p, err := netip.ParsePrefix(strings.TrimSpace(s)) if err != nil { continue } if p != p.Masked() { warn("match.cidrs", "%s has host bits set; it is treated as %s.", s, p.Masked()) } if (p.Addr().Is4() && p.Bits() < 8) || (!p.Addr().Is4() && p.Bits() < 16) { warn("match.cidrs", "%s is a very wide range: it covers a large share of the address space, probably more than you meant.", s) } } if len(g.Match.PTRSuffixes) > 0 || len(g.Match.Tags) > 0 { note("match", "Reverse-DNS suffixes and tags depend on the client's PTR record. The first queries from a client whose name is not yet cached are matched on address alone, so use a CIDR when the match must be reliable.") } for _, t := range g.Match.Tags { if strings.Contains(t, ".") { warn("match.tags", "Tag %q contains a dot. A tag is a single label of the client's reverse name (\"laptop\" for laptop.lan.example.net), so this never matches. Use a reverse-DNS suffix for a domain.", t) } } for _, s := range g.Match.PTRSuffixes { if strings.ContainsAny(s, "*/ ") { warn("match.ptr_suffixes", "%q is not a plain domain suffix; wildcards and paths are not understood here, so this never matches.", s) } } } func (a *Analysis) analyzeDomains(g config.Group, warn, note func(string, string, ...any)) { lint := func(field string, patterns []string) { for _, raw := range patterns { p := strings.ToLower(strings.TrimSpace(raw)) switch { case p == "": case strings.Contains(p, "://") || strings.Contains(p, "/"): warn(field, "%q looks like a URL. Use only the host name, e.g. example.com.", raw) case strings.ContainsAny(p, " \t"): warn(field, "%q contains a space, so it never matches a name.", raw) case strings.HasPrefix(p, "."): warn(field, "%q starts with a dot, which is matched literally and never matches. Write *.%s to cover the name and everything under it.", raw, strings.TrimLeft(p, ".")) case p == "*" || p == "**" || p == "*.*": if field == "deny_domains" { warn(field, "%q blocks every name for this group.", raw) } else { warn(field, "%q allows every name, so the allow list restricts nothing.", raw) } case strings.Contains(strings.TrimPrefix(strings.TrimPrefix(p, "**."), "*."), "*"): warn(field, "%q uses a wildcard somewhere other than the start, which is not supported and never matches. Only a leading *. or **. works.", raw) } } } lint("allow_domains", g.AllowDomains) lint("deny_domains", g.DenyDomains) if len(g.AllowDomains) > 0 || len(g.DenyDomains) > 0 { note("deny_domains", "*.example.com and **.example.com mean the same thing in these lists: the name itself and everything under it. Write example.com for the exact name only.") } if len(g.AllowDomains) > 0 && len(g.DenyDomains) > 0 { allow, deny := NewDomainSet(g.AllowDomains), NewDomainSet(g.DenyDomains) for _, p := range g.AllowDomains { name := strings.TrimPrefix(strings.TrimPrefix(strings.ToLower(strings.TrimSpace(p)), "**."), "*.") if name != "" && deny.Match(name) && allow.Match(name) { warn("allow_domains", "%q is also covered by the deny list, and deny wins, so it stays blocked.", p) } } } } // analyzeOrder reports groups that shadow this one, and groups this one would // shadow. Evaluation is first match wins, so a group whose clients are all // claimed higher up is dead weight. func (a *Analysis) analyzeOrder(c Context, warn func(string, string, ...any)) { g := c.Groups[c.Index] if g.Match.Any { // The group is the catch-all: the warning on match.any covers it, and // everything after it is reported below. } else if covered, by, all := coverage(g, c.Groups[:c.Index]); all { a.Unreachable = true warn("match", "No client can ever land in this group: everything it selects is already claimed by %q above it, which wins.", by) } else { for _, item := range covered { warn("match", "%s is already claimed by %q above this group, so those clients use that group's settings instead.", item.item, item.by) } } for j := c.Index + 1; j < len(c.Groups); j++ { later := c.Groups[j] with := coverageAll(later, c.Groups[:j]) without := coverageAll(later, append(append([]config.Group(nil), c.Groups[:c.Index]...), c.Groups[c.Index+1:j]...)) if with && !without { warn("match", "Saving this group makes %q unreachable: it now claims everything that group selects.", later.Name) } } } type claimed struct{ item, by string } // coverage reports which of g's selectors a group in prior already claims, // and whether that is all of them (so g can never match anyone). func coverage(g config.Group, prior []config.Group) (items []claimed, first string, all bool) { total := len(g.Match.CIDRs) + len(g.Match.PTRSuffixes) + len(g.Match.Tags) if total == 0 { return nil, "", false } for _, p := range prior { if p.Match.Any { return nil, p.Name, true } } for _, s := range g.Match.CIDRs { c, err := netip.ParsePrefix(strings.TrimSpace(s)) if err != nil { continue } for _, p := range prior { if by := coveringCIDR(c, p); by { items = append(items, claimed{s, p.Name}) break } } } for _, s := range g.Match.PTRSuffixes { sfx := normSuffix(s) for _, p := range prior { if coveringSuffix(sfx, p) { items = append(items, claimed{s, p.Name}) break } } } for _, t := range g.Match.Tags { t = strings.ToLower(strings.TrimSpace(t)) for _, p := range prior { if containsFold(p.Match.Tags, t) { items = append(items, claimed{"tag " + t, p.Name}) break } } } if len(items) == total { first = items[0].by return items, first, true } return items, "", false } func coverageAll(g config.Group, prior []config.Group) bool { if g.Match.Any { return false } _, _, all := coverage(g, prior) return all } func coveringCIDR(c netip.Prefix, p config.Group) bool { for _, s := range p.Match.CIDRs { q, err := netip.ParsePrefix(strings.TrimSpace(s)) if err != nil { continue } if q.Bits() <= c.Bits() && q.Contains(c.Masked().Addr()) { return true } } return false } func coveringSuffix(sfx string, p config.Group) bool { for _, s := range p.Match.PTRSuffixes { earlier := normSuffix(s) // Anything under sfx is under earlier when sfx ends with earlier at a // label boundary; the same test the matcher itself uses. if matchPTR(sfx, earlier) { return true } } return false } func normSuffix(s string) string { s = strings.ToLower(strings.TrimSpace(s)) if s != "" && !strings.HasSuffix(s, ".") { s += "." } return s } func containsFold(list []string, want string) bool { for _, s := range list { if strings.EqualFold(strings.TrimSpace(s), want) { return true } } return false } func whoSentence(g config.Group) string { if g.Match.Any { return "Applies to every client." } var parts []string if len(g.Match.CIDRs) > 0 { parts = append(parts, "whose address is in "+joinShort(g.Match.CIDRs)) } if len(g.Match.PTRSuffixes) > 0 { parts = append(parts, "whose reverse-DNS name ends in "+joinShort(g.Match.PTRSuffixes)) } if len(g.Match.Tags) > 0 { parts = append(parts, "tagged "+joinShort(g.Match.Tags)) } if len(parts) == 0 { return "Selects no clients yet." } return "Applies to clients " + strings.Join(parts, ", or ") + "." } func (c Context) orderSentence() string { pos, n := c.Index+1, len(c.Groups) switch { case pos == 1 && n == 1: return "It is the only group; clients that match nothing get the default policy (" + c.defaultPolicy() + ")." case pos == 1: return fmt.Sprintf("It is checked first of %d groups; a client matching it never reaches the others.", n) default: return fmt.Sprintf("It is checked %s of %d groups, after the %d above it; clients matching none get the default policy (%s).", ordinal(pos), n, pos-1, c.defaultPolicy()) } } func (c Context) defaultPolicy() string { if c.DefaultPolicy == "" { return "allow" } return c.DefaultPolicy } func ordinal(n int) string { switch n { case 2: return "second" case 3: return "third" } return fmt.Sprintf("number %d", n) } // joinShort lists up to three items and counts the rest. func joinShort(items []string) string { clean := make([]string, 0, len(items)) for _, s := range items { if s = strings.TrimSpace(s); s != "" { clean = append(clean, s) } } if len(clean) > 3 { return strings.Join(clean[:3], ", ") + fmt.Sprintf(" and %d more", len(clean)-3) } return strings.Join(clean, ", ") }