Files
adamandClaude Opus 5.5 76aaaf3ef5 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>
2026-10-11 21:33:16 +01:00

240 lines
7.9 KiB
Go

package render
import (
"fmt"
"net/netip"
"path/filepath"
"slices"
"strings"
"jarl/internal/blocking"
"jarl/internal/model"
"jarl/internal/namedconf"
)
// KindPolicy is the kind of a rendered rules policy zone file.
const KindPolicy = "policy"
// PolicyZone is a response policy zone in named's configuration
// (docs/blocking.md).
type PolicyZone struct {
Name string // without the final dot
ViewID int64 // 0 without views
View string // "" without views
File string
ListID int64 // the blocklist; 0 for the rules zone and gates
// Gate, for a gate zone: the clients it lets through (past every zone
// after it), and the step whose lists it is in front of.
Gate []netip.Prefix
GateStep string
}
// IsGate reports whether z is a gate zone.
func (z PolicyZone) IsGate() bool { return z.GateStep != "" }
// BlocklistsDir holds the zone files of downloaded blocklists, one per
// list, shared by the views that use it.
func (p Paths) BlocklistsDir() string { return filepath.Join(p.CacheDir, "blocklists") }
// BlocklistFile returns the zone file of a blocklist. It is named after
// the list's ID, so renaming the list doesn't move it.
func (p Paths) BlocklistFile(id int64) string {
return filepath.Join(p.BlocklistsDir(), fmt.Sprintf("list-%d.rpz", id))
}
// rulesFile returns a view's rules zone file.
func (p Paths) rulesFile(view string) string {
return filepath.Join(p.ZoneDir, ViewDir(view), blocking.RulesZone+".rpz")
}
// PolicyZones returns the policy zones named loads, per view in view
// order, each view's in the order named checks them: the rules zone first
// (rules win over lists), then for each step (normal, then the steps above
// it) a gate letting through the clients below that step, when there are
// any, and the step's enabled lists by name.
func PolicyZones(s *model.State, p Paths) []PolicyZone {
var out []PolicyZone
rules, lists := s.SortedBlockRules(), s.SortedBlocklists()
scopes := []int64{0}
if len(s.Views) > 0 {
scopes = nil
for _, v := range s.OrderedViews() {
scopes = append(scopes, v.ID)
}
}
for _, vid := range scopes {
view := ""
if v := s.Views[vid]; v != nil {
view = v.Name
}
var zones []PolicyZone
hasRules := false
for _, r := range rules {
if slices.Contains(s.BlockingViews(r.ViewIDs), vid) {
hasRules = true
break
}
}
assigns := ClientAssignments(s, vid)
for k, step := range append([]int64{model.StepNormal}, stepIDs(s)...) {
var tier []PolicyZone
for _, b := range lists {
if !b.Disabled && b.StepID == step && slices.Contains(s.BlockingViews(b.ViewIDs), vid) {
tier = append(tier, PolicyZone{Name: blocking.ListZone(b.Name), ViewID: vid, View: view, File: p.BlocklistFile(b.ID), ListID: b.ID})
}
}
if len(tier) == 0 {
continue
}
// The clients below this step pass, and never see its lists.
if pass := blocking.PassBelow(assigns, k); len(pass) > 0 {
name := "off"
if k > 0 {
name = s.StepName(step)
}
zones = append(zones, PolicyZone{Name: blocking.GateZone(name), ViewID: vid, View: view, File: p.gateFile(view, name), Gate: pass, GateStep: s.StepName(step)})
}
zones = append(zones, tier...)
}
// While paused, every view with blocking gets a rules zone: it
// lets everything through.
if hasRules || s.Settings.BlockingPaused() && len(zones) > 0 {
out = append(out, PolicyZone{Name: blocking.RulesZone, ViewID: vid, View: view, File: p.rulesFile(view)})
}
out = append(out, zones...)
}
return out
}
// gateFile returns a view's gate zone file for a step.
func (p Paths) gateFile(view, name string) string {
return filepath.Join(p.ZoneDir, ViewDir(view), blocking.GateZone(name)+".rpz")
}
// stepIDs returns the IDs of the steps above normal, lowest first.
func stepIDs(s *model.State) []int64 {
var out []int64
for _, st := range s.OrderedBlockSteps() {
out = append(out, st.ID)
}
return out
}
// ClientAssignments returns the step ranks the client groups of a view
// give addresses (blocking.PassBelow).
func ClientAssignments(s *model.State, viewID int64) []blocking.Assignment {
var out []blocking.Assignment
for _, g := range s.SortedClientGroups() {
if !slices.Contains(s.BlockingViews(g.ViewIDs), viewID) {
continue
}
rank, ok := s.StepRank(g.StepID)
if !ok {
continue
}
for _, p := range s.GroupPrefixes(g) {
out = append(out, blocking.Assignment{Prefix: p, Rank: rank})
}
}
return out
}
// ViewRules returns the rules that apply in a view (0 without views).
func ViewRules(s *model.State, viewID int64) []blocking.Rule {
var out []blocking.Rule
for _, r := range s.SortedBlockRules() {
if slices.Contains(s.BlockingViews(r.ViewIDs), viewID) {
out = append(out, blocking.Rule{Name: r.Name, Action: r.Action, Exact: r.Exact})
}
}
return out
}
// writeResponsePolicy writes the response-policy statement for a view's
// policy zones (or the server's, without views). Rewrites are logged to
// Jarl's response log when statistics read it, and not at all otherwise
// (they would go to named's default log).
func writeResponsePolicy(w *confWriter, zones []PolicyZone, logRewrites bool) {
if len(zones) == 0 {
return
}
w.begin("blocking", 0, "blocking (rules and blocklists)")
w.write("\t// Blocking (docs/blocking.md): rules first, then blocklists.\n\tresponse-policy {\n")
for _, z := range zones {
// Apply a new version at once: Jarl reloads a list only when it
// changed (at most every blocking.refresh), so named's default
// delay for batching frequent updates only adds a wait.
opts := []string{"min-update-interval 0"}
// A gate lets most queries through: logging that would double
// the response log, and it blocks nothing.
if !logRewrites || z.IsGate() {
opts = append(opts, "log no")
}
w.write(fmt.Sprintf("\t\tzone %s%s;\n", namedconf.QuoteString(z.Name), prefixSpace(strings.Join(opts, " "))))
}
// Block without waiting for the real answer, and also for clients
// that ask for DNSSEC data.
w.write("\t} qname-wait-recurse no break-dnssec yes;\n")
w.end()
}
func prefixSpace(s string) string {
if s == "" {
return ""
}
return " " + s
}
// writePolicyZones writes the zone statements of policy zones and adds
// their files to the output: rules zone files are rendered, blocklist
// files only listed (jarld downloads them; see Output.Blocklists).
func writePolicyZones(w *confWriter, s *model.State, zones []PolicyZone, out *Output, indent int) {
tabs := strings.Repeat("\t", indent)
for _, z := range zones {
w.write("\n")
label := "blocking: your rules"
switch {
case z.ListID != 0:
label = "blocking: blocklist " + s.Blocklists[z.ListID].Name
case z.IsGate():
label = "blocking: clients below step " + z.GateStep
}
w.begin("blocking", z.ListID, label)
w.write(fmt.Sprintf("%szone %s {\n%s\ttype primary;\n%s\tfile %s;\n%s\tallow-query { none; };\n%s\tallow-transfer { none; };\n%s\tnotify no;\n%s};\n",
tabs, namedconf.QuoteString(z.Name), tabs, tabs, namedconf.QuoteString(z.File), tabs, tabs, tabs, tabs))
w.end()
if z.ListID != 0 {
out.Blocklists = appendUnique(out.Blocklists, z.File)
continue
}
if z.IsGate() {
title := "Clients that skip the lists from step " + z.GateStep + " on"
if z.View != "" {
title += " in view " + z.View
}
out.Files = append(out.Files, &File{Path: z.File, Content: []byte(blocking.GateZoneFile(title, z.Gate)), Mode: 0o640, Kind: KindPolicy})
continue
}
title := "Blocking rules"
if z.View != "" {
title += " for view " + z.View
}
content := blocking.RulesZoneFile(title, ViewRules(s, z.ViewID))
if s.Settings.BlockingPaused() {
content = blocking.PausedZoneFile(title)
}
out.Files = append(out.Files, &File{Path: z.File, Content: []byte(content), Mode: 0o640, Kind: KindPolicy})
}
}
// scopeZones returns the policy zones of one view (0 without views).
func scopeZones(all []PolicyZone, viewID int64) []PolicyZone {
var out []PolicyZone
for _, z := range all {
if z.ViewID == viewID {
out = append(out, z)
}
}
return out
}