Client groups put devices (addresses, networks, or ACLs of them) at a blocking step: off (only your rules), normal (the default), or steps added above it, each including the lists of the steps below. Lists start at a step. In each view, a gate policy zone in front of a step's lists lets the devices below that step through (rpz-client-ip triggers with rpz-passthru), so every list is still loaded once. The IPv6 part of a gate leaves the IPv4-mapped range out: named matches IPv4 clients as ::ffff:a.b.c.d. Web UI: a Devices panel on the Blocking page and a step on each list. CLI: jarl block step, jarl block group, jarl blocklist --step. API: /blocking/steps, /blocking/groups. Database schema 8. Also: copying the state kept empty lists as empty (not nil), so reverting a changeset no longer adds restore operations for unchanged blocklists, rules and views. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
188 lines
6.7 KiB
Go
188 lines
6.7 KiB
Go
package validate
|
|
|
|
import (
|
|
"net/netip"
|
|
"strings"
|
|
|
|
"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
|
|
}
|
|
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)
|
|
}
|
|
}
|
|
}
|