Files
jarl/internal/model/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

284 lines
8.0 KiB
Go

package model
import (
"fmt"
"net/netip"
"slices"
"sort"
"strings"
"time"
"jarl/internal/iprange"
)
// 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 r, ok, err := iprange.Parse(e); ok && err == nil {
out = append(out, r.Prefixes()...)
} else if acl := s.ACLByName(e); acl != nil && !seen[acl.Name] {
seen[acl.Name] = true
add(acl.Elements)
}
}
}
add(g.Members)
return out
}