Files
jarl/internal/validate/blocking.go
T
adamandClaude Opus 5.5 e2e5007f94 Address ranges in address match lists (IPv4 and IPv6)
ACLs, views' match lists, options with address match lists and blocking
client groups take ranges such as 10.0.10.101-10.0.10.199 or
2001:db8::100-2001:db8::1ff (negated, or with spaces around the dash, too).
Jarl stores the range as written and writes the fewest prefixes that cover
exactly that range to named.conf, with the range as a comment. New package
internal/iprange; namedconf gets an AMRange element (a range used to parse
as an ACL name), ExpandRanges and statement comments. Mistakes are refused
when entered, validation sees what ranges cover, and IPv6 ranges in the
IPv4-mapped space get a warning. The web UI shows what a range becomes
while typing (GET /catalog/ranges).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 23:17:14 +01:00

192 lines
6.8 KiB
Go

package validate
import (
"net/netip"
"strings"
"jarl/internal/iprange"
"jarl/internal/model"
"jarl/internal/namedconf"
)
// checkBlocking checks blocklists and block/allow rules (docs/blocking.md).
func (c *checker) checkBlocking() {
viewNames := func(ids []int64) string {
var names []string
for _, id := range ids {
if v := c.s.Views[id]; v != nil {
names = append(names, v.Name)
}
}
return strings.Join(names, ", ")
}
used := map[int64]bool{} // views (0: the server) with blocking
// what describes where something applies, and warns when it can't
// have any effect.
effective := func(objType string, id int64, label string, ids []int64) []int64 {
for _, vid := range ids {
if c.s.Views[vid] == nil {
c.errf("blocking_view_missing", objType, id, "%s names view %d, which doesn't exist", label, vid)
}
}
views := c.s.BlockingViews(ids)
if len(views) == 0 {
c.warnf("blocking_no_view", objType, id, "%s has no effect: no view offers recursion, and blocking only changes answers to recursive queries", label)
}
var idle []int64
for _, vid := range views {
if !c.s.RecursionOn(vid) {
idle = append(idle, vid)
}
}
if len(idle) > 0 {
c.warnf("blocking_no_recursion", objType, id, "%s has no effect in view %s: it doesn't offer recursion, and blocking only changes answers to recursive queries", label, viewNames(idle))
}
for _, vid := range views {
used[vid] = true
}
return views
}
for _, b := range c.s.SortedBlocklists() {
if !b.Disabled {
effective("blocklist", b.ID, "blocklist "+b.Name, b.ViewIDs)
}
}
// One rule per name and view: two would compete for the same entries
// of the policy zone.
seen := map[string]*model.BlockRule{} // view|name -> first rule
for _, r := range c.s.SortedBlockRules() {
label := "the rule for " + r.Name
for _, vid := range effective("blockrule", r.ID, label, r.ViewIDs) {
key := c.blockingScope(vid) + "|" + r.Name
if first := seen[key]; first != nil && first.ID != r.ID {
where := ""
if v := c.s.Views[vid]; v != nil {
where = " in view " + v.Name
}
c.errf("blocking_rule_duplicate", "blockrule", r.ID, "there are two rules for %s%s (%s and %s); keep one", r.Name, where, first.Action, r.Action)
continue
}
seen[key] = r
}
}
c.checkClientGroups(effective)
// Jarl writes the response-policy statement where blocking is used;
// named allows one per view (or for the server).
for vid := range used {
if o := c.s.FindOption(model.ScopeView, vid, "response-policy"); vid != 0 && o != nil {
p := c.errf("blocking_response_policy", "option", o.ID, "view %s has a response-policy option, but Jarl's blocking writes that statement there; remove the option (or the blocking that applies to this view)", c.s.Views[vid].Name)
p.View = c.s.Views[vid].Name
}
}
if o := c.s.FindOption(model.ScopeOptions, 0, "response-policy"); o != nil && len(used) > 0 {
c.errf("blocking_response_policy", "option", o.ID, "the server has a response-policy option, which Jarl's blocking replaces wherever it is used; remove the option, or set up your policy zones with Jarl's blocking instead")
}
for _, z := range c.s.Zones {
if n := strings.TrimSuffix(z.Name, "."); n == "jarl.invalid" || strings.HasSuffix(n, ".jarl.invalid") {
c.zoneProblem(z, Error, "zone_name_reserved", "zone names under jarl.invalid are reserved for Jarl's blocking")
}
}
}
func (c *checker) blockingScope(viewID int64) string {
if v := c.s.Views[viewID]; v != nil {
return v.Name
}
return ""
}
// checkClientGroups checks blocking steps and client groups: steps exist,
// members are addresses (directly or through ACLs), and no address is in
// two groups of the same view.
func (c *checker) checkClientGroups(effective func(objType string, id int64, label string, ids []int64) []int64) {
for _, b := range c.s.SortedBlocklists() {
if _, ok := c.s.StepRank(b.StepID); !ok || b.StepID == model.StepOff {
c.errf("blocking_step_missing", "blocklist", b.ID, "blocklist %s is at step %d, which doesn't exist", b.Name, b.StepID)
}
}
// The highest step any group reaches, per view: lists above it block
// nothing.
top := map[int64]int{}
seen := map[string]*model.ClientGroup{} // view|prefix -> first group
for _, g := range c.s.SortedClientGroups() {
label := "client group " + g.Name
rank, ok := c.s.StepRank(g.StepID)
if !ok {
c.errf("blocking_step_missing", "clientgroup", g.ID, "%s is at step %d, which doesn't exist", label, g.StepID)
}
if len(g.Members) == 0 {
c.warnf("client_group_empty", "clientgroup", g.ID, "%s has no members", label)
}
for _, m := range g.Members {
c.checkMember(g, m, map[string]bool{})
}
views := effective("clientgroup", g.ID, label, g.ViewIDs)
prefixes := c.s.GroupPrefixes(g)
for _, vid := range views {
if len(prefixes) > 0 {
top[vid] = max(top[vid], rank)
}
for _, p := range prefixes {
key := c.blockingScope(vid) + "|" + p.String()
if first := seen[key]; first != nil && first.ID != g.ID {
c.errf("client_group_overlap", "clientgroup", g.ID, "%s is in client groups %s and %s; a client can only be in one (a more specific prefix in another group is fine)", p, first.Name, g.Name)
continue
}
seen[key] = g
}
}
}
for _, b := range c.s.SortedBlocklists() {
rank, ok := c.s.StepRank(b.StepID)
if !ok || rank == 0 || b.Disabled {
continue
}
reached := false
for _, vid := range c.s.BlockingViews(b.ViewIDs) {
if top[vid] >= rank {
reached = true
}
}
if !reached {
c.warnf("blocklist_step_unused", "blocklist", b.ID, "blocklist %s blocks nothing: it is at step %s, and no client group is at that step or above", b.Name, c.s.StepName(b.StepID))
}
}
}
// checkMember checks one member of a client group: an address, a prefix,
// or an ACL whose elements are (recursively) addresses and prefixes.
func (c *checker) checkMember(g *model.ClientGroup, m string, seen map[string]bool) {
if _, err := netip.ParseAddr(m); err == nil {
return
}
if _, err := netip.ParsePrefix(m); err == nil {
return
}
if _, ok, err := iprange.Parse(m); ok && err == nil {
return
}
acl := c.s.ACLByName(m)
if acl == nil {
c.errf("client_group_member_invalid", "clientgroup", g.ID, "client group %s: %q is not an address, a prefix or an ACL", g.Name, m)
return
}
if seen[acl.Name] {
return
}
seen[acl.Name] = true
for _, e := range acl.Elements {
el, err := namedconf.ParseAMElement(e)
if err != nil {
continue // reported for the ACL
}
if el.Negated || el.Kind == namedconf.AMKey || el.Kind == namedconf.AMNested || el.Kind == namedconf.AMName && el.IsBuiltin() {
c.errf("client_group_member_invalid", "clientgroup", g.ID, "client group %s uses ACL %s, whose element %q isn't an address or a prefix: blocking matches clients by address only", g.Name, acl.Name, e)
continue
}
if el.Kind == namedconf.AMName {
c.checkMember(g, el.Name, seen)
}
}
}