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>
240 lines
7.9 KiB
Go
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
|
|
}
|