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>
280 lines
7.9 KiB
Go
280 lines
7.9 KiB
Go
package model
|
|
|
|
import (
|
|
"fmt"
|
|
"net/netip"
|
|
"slices"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Blocklist is a list of domains to block, downloaded from URL and kept up
|
|
// to date by jarld (docs/blocking.md).
|
|
type Blocklist struct {
|
|
ID int64 `json:"id"`
|
|
Name string `json:"name"` // a DNS label: it names the list's policy zone
|
|
URL string `json:"url"`
|
|
// ViewIDs are the views the list applies to; empty means every view
|
|
// that offers recursion (see BlockingViews).
|
|
ViewIDs []int64 `json:"view_ids"`
|
|
Disabled bool `json:"disabled,omitempty"`
|
|
// 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"
|
|
BlockActionAllow = "allow"
|
|
)
|
|
|
|
// BlockRule blocks or allows a domain, and unless Exact the names below
|
|
// it. Rules take precedence over blocklists.
|
|
type BlockRule struct {
|
|
ID int64 `json:"id"`
|
|
Name string `json:"name"` // lowercase, without the final dot
|
|
Action string `json:"action"` // block or allow
|
|
Exact bool `json:"exact,omitempty"`
|
|
ViewIDs []int64 `json:"view_ids"` // as for Blocklist
|
|
Comment string `json:"comment"`
|
|
Meta
|
|
}
|
|
|
|
// BlocklistStatus is what jarld knows about a list's downloads. It is not
|
|
// configuration: it is kept outside changesets.
|
|
type BlocklistStatus struct {
|
|
ID int64 `json:"id"`
|
|
URL string `json:"url"` // the URL last tried
|
|
ETag string `json:"-"`
|
|
LastModified string `json:"-"`
|
|
CheckedAt *time.Time `json:"checked_at,omitempty"` // last attempt
|
|
UpdatedAt *time.Time `json:"updated_at,omitempty"` // last time the content changed
|
|
Entries int `json:"entries"` // domains
|
|
Records int `json:"records"` // policy zone records
|
|
Skipped int `json:"skipped"` // lines that weren't usable
|
|
SHA256 string `json:"-"`
|
|
Error string `json:"error,omitempty"` // the last attempt failed
|
|
}
|
|
|
|
// BlocklistByName finds a blocklist by name.
|
|
func (s *State) BlocklistByName(name string) *Blocklist {
|
|
for _, b := range s.Blocklists {
|
|
if strings.EqualFold(b.Name, name) {
|
|
return b
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// SortedBlocklists returns the blocklists ordered by name.
|
|
func (s *State) SortedBlocklists() []*Blocklist {
|
|
out := make([]*Blocklist, 0, len(s.Blocklists))
|
|
for _, b := range s.Blocklists {
|
|
out = append(out, b)
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
|
return out
|
|
}
|
|
|
|
// SortedBlockRules returns the rules ordered by domain, parents first.
|
|
func (s *State) SortedBlockRules() []*BlockRule {
|
|
out := make([]*BlockRule, 0, len(s.BlockRules))
|
|
for _, r := range s.BlockRules {
|
|
out = append(out, r)
|
|
}
|
|
sort.Slice(out, func(i, j int) bool {
|
|
a, b := out[i], out[j]
|
|
if a.Name != b.Name {
|
|
return CanonicalLess(a.Name+".", b.Name+".")
|
|
}
|
|
if a.Exact != b.Exact {
|
|
return !a.Exact
|
|
}
|
|
return a.ID < b.ID
|
|
})
|
|
return out
|
|
}
|
|
|
|
// RecursionOn reports whether a view (0: the server without views) offers
|
|
// recursion: its recursion option, else the server's, else BIND's default
|
|
// (yes).
|
|
func (s *State) RecursionOn(viewID int64) bool {
|
|
o := s.FindOption(ScopeView, viewID, "recursion")
|
|
if viewID == 0 || o == nil {
|
|
if g := s.FindOption(ScopeOptions, 0, "recursion"); o == nil {
|
|
o = g
|
|
}
|
|
}
|
|
if o == nil {
|
|
return true
|
|
}
|
|
switch strings.ToLower(strings.Trim(strings.TrimSpace(o.Value), `";`)) {
|
|
case "no", "false", "0":
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// BlockingViews returns the views a blocklist or rule with viewIDs applies
|
|
// to, in view order: the given views, or when none are given every view
|
|
// that offers recursion (blocking only affects recursive queries). Without
|
|
// views it returns []int64{0}, the whole server, if it offers recursion.
|
|
func (s *State) BlockingViews(viewIDs []int64) []int64 {
|
|
views := s.OrderedViews()
|
|
if len(views) == 0 {
|
|
if !s.RecursionOn(0) {
|
|
return nil
|
|
}
|
|
return []int64{0}
|
|
}
|
|
var out []int64
|
|
for _, v := range views {
|
|
if len(viewIDs) == 0 && s.RecursionOn(v.ID) || slices.Contains(viewIDs, v.ID) {
|
|
out = append(out, v.ID)
|
|
}
|
|
}
|
|
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
|
|
}
|