Different blocking for different devices: steps and client groups

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>
This commit is contained in:
adamandClaude Opus 5.5 committed 2026-10-11 21:33:16 +01:00
1 parent 5b8f681e2a
commit 76aaaf3ef5
33 files changed
+2096 -55

No files matched your search

+144 -1
View File
@@ -1,6 +1,8 @@
package model
import (
"fmt"
"net/netip"
"slices"
"sort"
"strings"
@@ -17,10 +19,126 @@ type Blocklist struct {
// that offers recursion (see BlockingViews).
ViewIDs []int64 `json:"view_ids"`
Disabled bool `json:"disabled,omitempty"`
Comment string `json:"comment"`
// StepID is the blocking step the list starts at: StepNormal for
// every client, or a BlockStep for the clients of groups at that step
// or above.
StepID int64 `json:"step_id"`
Comment string `json:"comment"`
Meta
}
// Built-in blocking steps (BlockStep IDs are positive).
const (
StepOff int64 = -1 // no blocklists (rules still apply)
StepNormal int64 = 0 // the default: every client not in a group
)
// BlockStep is a blocking step above Normal. Steps are ordered: each
// includes the lists of the steps below it, plus its own (docs/blocking.md).
type BlockStep struct {
ID int64 `json:"id"`
Name string `json:"name"`
Position int `json:"position"`
Comment string `json:"comment"`
Meta
}
// ClientGroup gives the clients with these addresses a blocking step.
type ClientGroup struct {
ID int64 `json:"id"`
Name string `json:"name"`
// Members are addresses, prefixes, or names of ACLs made of them.
Members []string `json:"members"`
StepID int64 `json:"step_id"` // StepOff, StepNormal or a BlockStep
ViewIDs []int64 `json:"view_ids"` // as for Blocklist
Comment string `json:"comment"`
Meta
}
// OrderedBlockSteps returns the steps above Normal, lowest first.
func (s *State) OrderedBlockSteps() []*BlockStep {
out := make([]*BlockStep, 0, len(s.BlockSteps))
for _, st := range s.BlockSteps {
out = append(out, st)
}
sort.Slice(out, func(i, j int) bool {
if out[i].Position != out[j].Position {
return out[i].Position < out[j].Position
}
return out[i].ID < out[j].ID
})
return out
}
// StepRank orders steps: -1 for Off, 0 for Normal, 1, 2, ... for the
// steps above it. ok is false for a step that doesn't exist.
func (s *State) StepRank(id int64) (rank int, ok bool) {
switch id {
case StepOff:
return -1, true
case StepNormal:
return 0, true
}
for i, st := range s.OrderedBlockSteps() {
if st.ID == id {
return i + 1, true
}
}
return 0, false
}
// StepName returns a step's name ("off" and "normal" for the built-in
// ones).
func (s *State) StepName(id int64) string {
switch id {
case StepOff:
return "off"
case StepNormal:
return "normal"
}
if st := s.BlockSteps[id]; st != nil {
return st.Name
}
return fmt.Sprintf("step %d", id)
}
// StepByName finds a step by name, the built-in ones included.
func (s *State) StepByName(name string) (int64, bool) {
name = strings.ToLower(strings.TrimSpace(name))
switch name {
case "off":
return StepOff, true
case "normal":
return StepNormal, true
}
for _, st := range s.BlockSteps {
if strings.EqualFold(st.Name, name) {
return st.ID, true
}
}
return 0, false
}
// ClientGroupByName finds a client group by name.
func (s *State) ClientGroupByName(name string) *ClientGroup {
for _, g := range s.ClientGroups {
if strings.EqualFold(g.Name, name) {
return g
}
}
return nil
}
// SortedClientGroups returns the client groups ordered by name.
func (s *State) SortedClientGroups() []*ClientGroup {
out := make([]*ClientGroup, 0, len(s.ClientGroups))
for _, g := range s.ClientGroups {
out = append(out, g)
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
// Block rule actions.
const (
BlockActionBlock = "block"
@@ -134,3 +252,28 @@ func (s *State) BlockingViews(viewIDs []int64) []int64 {
}
return out
}
// GroupPrefixes returns the addresses of a client group as prefixes: its
// own, and those of the ACLs it names (and the ACLs they name). Elements
// that aren't addresses are left out (validation reports them).
func (s *State) GroupPrefixes(g *ClientGroup) []netip.Prefix {
var out []netip.Prefix
seen := map[string]bool{}
var add func(elems []string)
add = func(elems []string) {
for _, e := range elems {
e = strings.TrimSpace(e)
if a, err := netip.ParseAddr(e); err == nil {
a = a.Unmap()
out = append(out, netip.PrefixFrom(a, a.BitLen()))
} else if p, err := netip.ParsePrefix(e); err == nil {
out = append(out, p.Masked())
} else if acl := s.ACLByName(e); acl != nil && !seen[acl.Name] {
seen[acl.Name] = true
add(acl.Elements)
}
}
}
add(g.Members)
return out
}