Files
adamandClaude Opus 5.5 d18fe18ccd Sort numeric labels by value
Records, zones and findings now order all-digit labels numerically, so
reverse zones list 2 before 101. model.CanonicalLess is replaced by
model.NameLess; other labels keep byte order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 08:49:58 +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 NameLess(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
}