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 }