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>
192 lines
6.8 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|