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>
This commit is contained in:
adamandClaude Opus 5.5 committed 2026-10-11 21:33:16 +01:00
1 parent 5b8f681e2a
commit 76aaaf3ef5
33 files changed
+2088 -47

No files matched your search

+15 -1
View File
@@ -17,7 +17,8 @@ All notable changes to Jarl are documented here. The format follows
- The systemd unit changed (it allows `AF_NETLINK`). The package replaces
it; an override that adds `AF_NETLINK` (`systemctl edit jarl`) does no
harm and can be removed.
- The database moves to schema 7 (disabled zones) as jarld starts.
- The database moves to schema 8 (disabled zones, client groups) as
jarld starts.
- If statistics showed a spike after views were created or changed, see
"Statistics" under Fixed: the bad minutes can be removed from
`/var/lib/jarl/stats.db` (minute rows have `res = 'm'`; the hours and
@@ -25,6 +26,16 @@ All notable changes to Jarl are documented here. The format follows
### Added
- Different blocking for different devices (docs/blocking.md), like
Pi-hole's groups, in one view and without loading any list twice.
Client groups put devices (addresses, networks or ACLs of them) at a
blocking step: `off` (only your rules), `normal` (the default), or steps
you add above it, each including the lists of the steps below. Lists
start at a step. Jarl puts a small "gate" policy zone in front of each
step's lists that lets the devices below that step through. Web UI: a
Devices panel on the Blocking page and a step on each list. CLI: `jarl
block step`, `jarl block group`, `jarl blocklist update --step`. API:
`/blocking/steps`, `/blocking/groups`. Database schema 8.
- View keys: every view gets a TSIG key, `jarl-view-NAME`, at the front of
its match-clients, so a query signed with it lands in that view from any
address. Jarl signs its delegation check's queries to this server's own
@@ -140,6 +151,9 @@ All notable changes to Jarl are documented here. The format follows
### Fixed
- Reverting a changeset added needless "restore" operations for
blocklists, rules and views that hadn't changed: copying the state
turned their empty view lists into missing ones.
- Under the shipped systemd unit, jarld couldn't list the host's
addresses (it needs a netlink socket, which `RestrictAddressFamilies`
didn't allow), so the name server checks didn't recognize this server's
+14 -4
View File
@@ -43,7 +43,7 @@ named answers "doesn't exist" for the domains on them (docs/blocking.md).
Hosts, plain domain and Adblock (||domain^) lists work. Your own block and
allow rules ('jarl block') take precedence over every list.`}
var views []string
var comment string
var comment, step string
list := &cobra.Command{
Use: "list",
@@ -59,7 +59,7 @@ allow rules ('jarl block') take precedence over every list.`}
return err
}
return output(lists, func(w io.Writer) {
fmt.Fprintln(w, "NAME\tSTATE\tDOMAINS\tVIEWS\tUPDATED\tMEMORY\tURL")
fmt.Fprintln(w, "NAME\tSTATE\tSTEP\tDOMAINS\tVIEWS\tUPDATED\tMEMORY\tURL")
for _, b := range lists {
domains, updated, mem := "-", "-", "-"
if s := b.Status; s != nil && s.UpdatedAt != nil {
@@ -68,7 +68,7 @@ allow rules ('jarl block') take precedence over every list.`}
if b.MemoryMB > 0 {
mem = fmt.Sprintf("~%d MB", b.MemoryMB)
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", b.Name, b.State, domains, viewsText(b.Views, b.AutoViews), updated, mem, b.URL)
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", b.Name, b.State, b.Step, domains, viewsText(b.Views, b.AutoViews), updated, mem, b.URL)
}
for _, b := range lists {
if b.Status != nil && b.Status.Error != "" {
@@ -95,6 +95,9 @@ allow rules ('jarl block') take precedence over every list.`}
}
body := map[string]any{"name": args[0], "url": args[1]}
viewsBody(body, views)
if step != "" {
body["step"] = step
}
if comment != "" {
body["comment"] = comment
}
@@ -111,6 +114,7 @@ allow rules ('jarl block') take precedence over every list.`}
}
add.Flags().StringArrayVar(&views, "view", nil, "a view it applies to (repeat for several; default: every view that offers recursion)")
add.Flags().StringVar(&comment, "comment", "", "comment")
add.Flags().StringVar(&step, "step", "", "the blocking step it starts at (default: normal; see 'jarl block step')")
var rename, newURL string
update := &cobra.Command{
@@ -136,6 +140,9 @@ allow rules ('jarl block') take precedence over every list.`}
if f.Changed("comment") {
body["comment"] = comment
}
if f.Changed("step") {
body["step"] = step
}
viewsBody(body, views)
if len(body) == 0 {
return fmt.Errorf("nothing to change (see --help)")
@@ -151,6 +158,7 @@ allow rules ('jarl block') take precedence over every list.`}
update.Flags().StringVar(&newURL, "url", "", "new URL")
update.Flags().StringArrayVar(&views, "view", nil, `a view it applies to (repeat for several; "auto": every view that offers recursion)`)
update.Flags().StringVar(&comment, "comment", "", "new comment")
update.Flags().StringVar(&step, "step", "", "the blocking step it starts at (normal, or a step above it)")
toggle := func(disable bool) *cobra.Command {
verb := map[bool]string{true: "disable", false: "enable"}[disable]
@@ -394,6 +402,8 @@ names below it (docs/blocking.md).`}
switch {
case m.Result == "none":
fmt.Fprintf(w, "%snot blocked\n", where)
case m.List != "" && m.Step != "":
fmt.Fprintf(w, "%sblocked for clients at step %s and above, by blocklist %s (entry %s); not for others\n", where, m.Step, m.List, m.Entry)
case m.List != "":
fmt.Fprintf(w, "%sblocked by blocklist %s (entry %s)\n", where, m.List, m.Entry)
default:
@@ -473,7 +483,7 @@ at once. "indefinitely" pauses until 'jarl block resume'.
},
}
cmd.AddCommand(addRule("block"), addRule("allow"), list, del, check, pause, resume)
cmd.AddCommand(addRule("block"), addRule("allow"), list, del, check, pause, resume, blockStepCmd(), blockGroupCmd())
return cmd
}
+271
View File
@@ -0,0 +1,271 @@
package main
import (
"fmt"
"io"
"net/url"
"strings"
"github.com/spf13/cobra"
"jarl/internal/api"
)
// Blocking steps and client groups (docs/blocking.md).
func blockStepCmd() *cobra.Command {
cmd := &cobra.Command{Use: "step", Aliases: []string{"steps"}, Short: "Blocking steps: which lists apply to which client groups",
Long: `Steps decide which blocklists a client group gets. Each step includes the
lists of the steps below it, plus its own:
off only your rules (built in)
normal your rules + the normal lists; clients not in a group (built in)
strict normal + the lists at step strict (a step you add)
...
Lists start at a step ('jarl blocklist update LIST --step strict'), client
groups are at one ('jarl block group add kids 10.10.0.50 --step strict').`}
list := &cobra.Command{
Use: "list",
Short: "List the steps, lowest first",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var steps []api.BlockStep
if err := c.Get(ctx(), "/blocking/steps", nil, &steps); err != nil {
return err
}
return output(steps, func(w io.Writer) {
fmt.Fprintln(w, "STEP\tLISTS\tGROUPS\tCOMMENT")
fmt.Fprintln(w, "off\t-\t(built in)\tonly your rules")
fmt.Fprintln(w, "normal\t-\t(built in)\tthe default")
for _, s := range steps {
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", s.Name, orDash(strings.Join(s.Lists, ", ")), orDash(strings.Join(s.Groups, ", ")), s.Comment)
}
})
},
}
var comment string
add := &cobra.Command{
Use: "add NAME",
Short: "Add a step above the existing ones",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
resp, err := c.Mutate(ctx(), "POST", "/blocking/steps", nil, map[string]any{"name": args[0], "comment": comment})
if err != nil {
return err
}
return report(resp)
},
}
add.Flags().StringVar(&comment, "comment", "", "comment")
rename := &cobra.Command{
Use: "rename STEP NEW-NAME",
Short: "Rename a step",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
resp, err := c.Mutate(ctx(), "PATCH", "/blocking/steps/"+url.PathEscape(args[0]), nil, map[string]any{"name": args[1]})
if err != nil {
return err
}
return report(resp)
},
}
order := &cobra.Command{
Use: "order STEP...",
Short: "Set the order of the steps above normal, lowest first",
Example: " jarl block step order strict very-strict",
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
resp, err := c.Mutate(ctx(), "POST", "/blocking/steps/order", nil, map[string]any{"steps": args})
if err != nil {
return err
}
return report(resp)
},
}
del := &cobra.Command{
Use: "delete STEP",
Short: "Delete a step (refused while lists or groups use it)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
resp, err := c.Mutate(ctx(), "DELETE", "/blocking/steps/"+url.PathEscape(args[0]), nil, nil)
if err != nil {
return err
}
return report(resp)
},
}
cmd.AddCommand(list, add, rename, order, del)
return cmd
}
func blockGroupCmd() *cobra.Command {
cmd := &cobra.Command{Use: "group", Aliases: []string{"groups"}, Short: "Client groups: blocking steps for some devices",
Long: `A client group puts devices at a blocking step: off (only your rules),
normal, or a step you added ('jarl block step'). Members are addresses,
prefixes, or ACLs made of them. Devices not in a group are at normal.
named must see the devices' own addresses (not a router forwarding for
them), and they need fixed addresses (DHCP reservations).`}
list := &cobra.Command{
Use: "list",
Short: "List client groups",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var groups []api.ClientGroup
if err := c.Get(ctx(), "/blocking/groups", nil, &groups); err != nil {
return err
}
return output(groups, func(w io.Writer) {
fmt.Fprintln(w, "GROUP\tSTEP\tMEMBERS\tVIEWS\tCOMMENT")
for _, g := range groups {
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", g.Name, g.Step, orDash(strings.Join(g.Members, ", ")), viewsText(g.Views, g.AutoViews), g.Comment)
}
})
},
}
var step, comment string
var views []string
add := &cobra.Command{
Use: "add NAME MEMBER...",
Short: "Add a client group",
Example: ` jarl block group add work 10.10.0.20 --step off
jarl block group add kids 10.10.0.50 10.10.0.51 2001:db8:1::/64 --step strict
jarl block group add guests guest-net --step normal # an ACL`,
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
body := map[string]any{"name": args[0], "members": args[1:], "comment": comment}
if step != "" {
body["step"] = step
}
viewsBody(body, views)
resp, err := c.Mutate(ctx(), "POST", "/blocking/groups", nil, body)
if err != nil {
return err
}
return report(resp)
},
}
add.Flags().StringVar(&step, "step", "", "the group's step: off, normal (default) or one you added")
add.Flags().StringArrayVar(&views, "view", nil, "a view it applies in (repeat for several; default: every view with blocking)")
add.Flags().StringVar(&comment, "comment", "", "comment")
var addM, removeM []string
var rename string
update := &cobra.Command{
Use: "update GROUP",
Short: "Change a group's members, step, name, views or comment",
Example: ` jarl block group update kids --add 10.10.0.52 --remove 10.10.0.50
jarl block group update kids --step very-strict`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
body := map[string]any{}
f := cmd.Flags()
if len(addM) > 0 || len(removeM) > 0 {
var g api.ClientGroup
var all []api.ClientGroup
if err := c.Get(ctx(), "/blocking/groups", nil, &all); err != nil {
return err
}
for _, x := range all {
if x.Name == strings.ToLower(args[0]) {
g = x
}
}
if g.ID == 0 {
return fmt.Errorf("client group %s not found", args[0])
}
drop := map[string]bool{}
for _, m := range removeM {
drop[m] = true
}
members := []string{}
for _, m := range g.Members {
if !drop[m] {
members = append(members, m)
}
}
body["members"] = append(members, addM...)
}
if f.Changed("step") {
body["step"] = step
}
if f.Changed("name") {
body["name"] = rename
}
if f.Changed("comment") {
body["comment"] = comment
}
viewsBody(body, views)
if len(body) == 0 {
return fmt.Errorf("nothing to change (see --help)")
}
resp, err := c.Mutate(ctx(), "PATCH", "/blocking/groups/"+url.PathEscape(args[0]), nil, body)
if err != nil {
return err
}
return report(resp)
},
}
update.Flags().StringArrayVar(&addM, "add", nil, "add a member (repeat for several)")
update.Flags().StringArrayVar(&removeM, "remove", nil, "remove a member (repeat for several)")
update.Flags().StringVar(&step, "step", "", "new step")
update.Flags().StringVar(&rename, "name", "", "new name")
update.Flags().StringArrayVar(&views, "view", nil, `a view it applies in (repeat; "auto": every view with blocking)`)
update.Flags().StringVar(&comment, "comment", "", "new comment")
del := &cobra.Command{
Use: "delete GROUP",
Short: "Delete a client group (its devices go back to normal)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
resp, err := c.Mutate(ctx(), "DELETE", "/blocking/groups/"+url.PathEscape(args[0]), nil, nil)
if err != nil {
return err
}
return report(resp)
},
}
cmd.AddCommand(list, add, update, del)
return cmd
}
func orDash(s string) string {
if s == "" {
return "-"
}
return s
}
+7 -2
View File
@@ -420,14 +420,19 @@ Needs `blocking:read` (`blocking:write` to change). See
| Method | Path | Body / query |
|--------|------|--------------|
| GET | `/blocking/lists` | each with `views` (where it applies), `auto_views` (not chosen: every view that offers recursion), `state` (ok, waiting, error, disabled, idle), `status` (`entries`, `records`, `skipped`, `checked_at`, `updated_at`, `error`) and `memory_mb` (named's memory for it, estimated) |
| POST | `/blocking/lists` | `{"name": "hagezi-multi", "url": "https://...", "views": ["internal"], "comment": ""}` (`views` empty or absent: automatic); jarld downloads it right after |
| POST | `/blocking/lists` | `{"name": "hagezi-multi", "url": "https://...", "views": ["internal"], "comment": "", "step": "normal"}` (`views` empty or absent: automatic; `step` the blocking step it starts at); jarld downloads it right after |
| GET / PATCH / DELETE | `/blocking/lists/{id or name}` | PATCH any of `name`, `url`, `views`, `disabled`, `comment` |
| POST | `/blocking/lists/{id or name}/refresh` | download now and wait; `?force=1` even when the server says nothing changed. Returns the list; a failed update is in `state` and `status.error` |
| GET | `/blocking/rules` | each with `views` and `auto_views` |
| POST | `/blocking/rules` | `{"name": "doubleclick.net", "action": "block", "exact": false, "views": [], "comment": ""}` (`action` block or allow; `exact`: only the name, not the names below it) |
| PATCH / DELETE | `/blocking/rules/{id}` | |
| DELETE | `/blocking/rules` | `?name=DOMAIN[&view=V][&missing_ok=1]`: the rules for a domain |
| GET | `/blocking/check` | `?name=DOMAIN`: per view with blocking, `{view, result: blocked\|allowed\|none, entry, rule_id, list}` (what applies when blocking isn't paused) |
| GET | `/blocking/check` | `?name=DOMAIN`: per view with blocking, `{view, result: blocked\|allowed\|none, entry, rule_id, list, step}` (what applies to normal devices when blocking isn't paused; `step` when only devices at that step or above are blocked) |
| GET / POST | `/blocking/steps` | the steps above normal, lowest first, with the `lists` and `groups` at each; POST `{"name": "strict", "comment": ""}` adds one on top |
| PATCH / DELETE | `/blocking/steps/{id or name}` | `{"name", "position", "comment"}`; delete is refused while lists or groups use it |
| POST | `/blocking/steps/order` | `{"steps": ["strict", "very-strict"]}`: every step above normal, lowest first |
| GET / POST | `/blocking/groups` | client groups; POST `{"name": "kids", "members": ["10.10.0.50", "tablets"], "step": "strict", "views": []}` |
| PATCH / DELETE | `/blocking/groups/{id or name}` | the same fields |
| GET | `/blocking/status` | `{paused_until, paused_indefinitely}` (null and false when not paused) |
| POST | `/blocking/pause` | `{"minutes": 15}` (1 to 1440): let every name through until then; jarld resumes by itself. `{"indefinitely": true}`: until resumed |
| POST | `/blocking/resume` | end a pause now |
+60
View File
@@ -147,6 +147,56 @@ A list's state is **ok** (in use), **waiting** (not downloaded yet),
**error** (the last update failed; the previous version stays in use),
**disabled**, or **idle** (it applies to no view, so it does nothing).
## Different blocking for different devices
Client groups give some devices less or more blocking than the rest, like
Pi-hole's groups: no blocking for a work laptop, an extra list for the
kids' tablets.
**Steps** decide which lists a device gets. Each step includes the lists
of the steps below it, plus its own:
| Step | Gets |
|------|------|
| `off` (built in) | only your rules |
| `normal` (built in) | your rules and the normal lists; every device not in a group |
| `strict` (one you add) | normal, plus the lists at step `strict` |
| `very-strict` (one you add) | strict, plus the lists at step `very-strict` |
Every list starts at a step (normal unless you choose one), and every
group is at a step. Your rules apply at every step, `off` included.
```sh
jarl block step add strict
jarl blocklist update social-media --step strict
jarl block group add work 10.10.0.20 --step off
jarl block group add kids 10.10.0.50 10.10.0.51 2001:db8:1::/64 --step strict
jarl block group list
```
In the web UI, the Blocking page's **Devices** panel holds the steps and
the groups, and each list has a step.
- **Members** are addresses, networks (`10.10.0.0/24`), or ACLs made only
of those (`key`, `localnets` and `!` can't be used: blocking matches
clients by address). A device is in one group: two groups with the same
address are refused, but a more specific address in another group wins
(`10.10.0.0/24` in one group, `10.10.0.20` in another).
- **Steps only add.** A group gets everything up to its step, so "kids get
list A, guests get list B, and neither gets the other's" can't be done
with steps. That needs a view per group, each with its own lists (and
their memory).
- Lists aren't copied per step: each is loaded once, and the steps cost a
small zone each. Lists at a step no group reaches block nothing (Jarl
warns, and shows them as "no effect").
- named must see the device's own address. When a router hands out itself
as DNS server and forwards to named, every query comes from the router,
and groups can't tell devices apart. Devices also need fixed addresses
(DHCP reservations); IPv6 privacy addresses change, so add a device's
network, or rely on its IPv4 address.
- `jarl block check NAME` (and the Blocking page) answers for normal
devices, and says when a list of a higher step blocks the name.
## Checking a name
`jarl block check NAME`, or the box at the top of the Blocking page, says
@@ -254,6 +304,16 @@ response-policy {
blocking in the same view: Jarl refuses the change and says so.
- Rewrites are logged to Jarl's response log when statistics read it, and
not at all otherwise.
- With client groups, a **gate** zone stands in front of the lists of each
step: `off.gate.jarl.invalid` before the normal lists,
`STEP.gate.jarl.invalid` before a step's. named checks the zones in
order and the first match decides; a gate matches the devices below its
step by address (`rpz-client-ip` triggers) with `rpz-passthru`, so they
never reach the lists behind it. A gate is never logged, and its file is
next to the rules zone (`/var/lib/bind/jarl/VIEW/strict.gate.jarl.invalid.rpz`).
For "everyone except these devices", a gate lists the address ranges
around them, at most a few hundred entries. named matches IPv4 clients as
IPv4-mapped IPv6 addresses, so the IPv6 part leaves `::ffff:0:0/96` out.
Lists and rules need the `blocking` permission ([permissions.md](permissions.md)).
+6
View File
@@ -476,6 +476,12 @@ jarl block delete DOMAIN [--view V] | --id ID
jarl block check DOMAIN # blocked? by which rule or list?
jarl block pause [DURATION] # let everything through for a while (default 10m, at most 24h)
jarl block pause indefinitely # ... until 'jarl block resume'
jarl block step list | add NAME | rename STEP NEW | order STEP... | delete STEP
jarl block group list
jarl block group add NAME MEMBER... [--step S] [--view V]... # addresses, networks or ACLs
jarl block group update GROUP [--add M] [--remove M] [--step S] [--name N]
jarl block group delete GROUP
jarl blocklist update LIST --step STEP # devices at that step or above get it
jarl block resume
```
+5
View File
@@ -115,6 +115,11 @@ From [blocking.md](blocking.md).
| `blocking_view_missing` | error | A list or rule names a view that doesn't exist |
| `blocking_no_view` | warning | A list or rule applies to no view: none offers recursion, and blocking only changes recursive answers |
| `blocking_no_recursion` | warning | A list or rule names a view that doesn't offer recursion, where it has no effect |
| `blocking_step_missing` | error | A list or client group is at a step that doesn't exist (a list can't be at `off`) |
| `client_group_member_invalid` | error | Client group members are addresses, networks, or ACLs made only of those |
| `client_group_overlap` | error | The same address or network is in two client groups of a view |
| `client_group_empty` | warning | A client group has no members |
| `blocklist_step_unused` | warning | A list is at a step that no client group reaches, so it blocks nothing |
## Security lints
+21
View File
@@ -506,6 +506,27 @@ type Blocklist struct {
// MemoryMB estimates what named needs for the list, in all its views.
MemoryMB int `json:"memory_mb,omitempty"`
Pending string `json:"pending,omitempty"`
// Step is the name of the step the list starts at ("normal" or one
// above it).
Step string `json:"step"`
}
// BlockStep is a blocking step above normal as returned by the API, with
// what uses it.
type BlockStep struct {
model.BlockStep
Lists []string `json:"lists"`
Groups []string `json:"groups"`
Pending string `json:"pending,omitempty"`
}
// ClientGroup is a client group as returned by the API.
type ClientGroup struct {
model.ClientGroup
Step string `json:"step"`
Views []string `json:"views"`
AutoViews bool `json:"auto_views,omitempty"`
Pending string `json:"pending,omitempty"`
}
// BlockRule is a block or allow rule as returned by the API.
+180
View File
@@ -0,0 +1,180 @@
package blocking
import (
"fmt"
"math"
"net/netip"
"sort"
"strings"
)
// Client steps (docs/blocking.md): named checks policy zones in order and
// the first match decides, so a "gate" zone that lets some clients through
// (rpz-passthru, matched on the client's address) keeps them from every
// zone after it. Jarl puts a gate in front of the lists of each step,
// letting through the clients whose step is below it.
// GateSuffix follows a gate's name: off.gate.jarl.invalid,
// strict.gate.jarl.invalid.
const GateSuffix = ".gate.jarl.invalid"
// v4Mapped holds the IPv4-mapped IPv6 addresses.
var v4Mapped = netip.MustParsePrefix("::ffff:0:0/96")
// GateZone returns the policy zone name of the gate in front of a step's
// lists ("off" for the gate in front of the normal lists).
func GateZone(step string) string { return step + GateSuffix }
// Assignment gives the addresses in Prefix a step rank (-1 off, 0 normal,
// 1, 2, ... the steps above it).
type Assignment struct {
Prefix netip.Prefix
Rank int
}
// PassBelow returns the fewest prefixes that cover exactly the addresses whose
// rank is below k. An address has the rank of the most specific
// assignment containing it, and rank 0 (normal) when none does. When two
// assignments have the same prefix, the higher rank counts (validation
// refuses that anyway).
func PassBelow(assigns []Assignment, k int) []netip.Prefix {
var out []netip.Prefix
for _, root := range []netip.Prefix{netip.MustParsePrefix("0.0.0.0/0"), netip.MustParsePrefix("::/0")} {
var mine []Assignment
if !root.Addr().Is4() {
// named matches IPv4 clients as IPv4-mapped IPv6 addresses:
// keep IPv6 prefixes clear of them, so only the IPv4 ones
// decide for IPv4 clients.
mine = append(mine, Assignment{v4Mapped, math.MaxInt})
}
rank := 0
for _, a := range assigns {
p := a.Prefix.Masked()
if p.Addr().Is4() != root.Addr().Is4() {
continue
}
if p.Bits() == 0 {
rank = max(rank, a.Rank)
continue
}
mine = append(mine, Assignment{p, a.Rank})
}
for _, p := range cover(root, rank, mine, k) {
if p.Bits() == 0 {
// A trigger needs at least one bit: use both halves.
lo, hi := halves(p)
out = append(out, lo, hi)
continue
}
out = append(out, p)
}
}
return out
}
// cover returns the prefixes inside p whose addresses have a rank below k.
// rank is p's own; inside are the assignments strictly inside p.
func cover(p netip.Prefix, rank int, inside []Assignment, k int) []netip.Prefix {
if len(inside) == 0 {
if rank < k {
return []netip.Prefix{p}
}
return nil
}
var out []netip.Prefix
lo, hi := halves(p)
full := 0
for _, h := range []netip.Prefix{lo, hi} {
var sub []Assignment
for _, a := range inside {
if a.Prefix.Bits() > h.Bits() && h.Contains(a.Prefix.Addr()) {
sub = append(sub, a)
}
}
c := cover(h, ownRank(inside, h, rank), sub, k)
if len(c) == 1 && c[0] == h {
full++
}
out = append(out, c...)
}
if full == 2 {
return []netip.Prefix{p}
}
return out
}
// ownRank is the rank of h: its own assignments' (the highest), or the
// inherited one without any.
func ownRank(inside []Assignment, h netip.Prefix, inherited int) int {
r, found := 0, false
for _, a := range inside {
if a.Prefix == h {
if !found || a.Rank > r {
r = a.Rank
}
found = true
}
}
if !found {
return inherited
}
return r
}
// halves splits a prefix into its two halves.
func halves(p netip.Prefix) (netip.Prefix, netip.Prefix) {
bits := p.Bits() + 1
lo := netip.PrefixFrom(p.Addr(), bits).Masked()
b := lo.Addr().AsSlice()
i := (bits - 1) / 8
b[i] |= 0x80 >> ((bits - 1) % 8)
a, _ := netip.AddrFromSlice(b)
return lo, netip.PrefixFrom(a, bits)
}
// ClientIPOwner returns the policy zone owner name that matches clients
// in p (an rpz-client-ip trigger): 32.37.0.10.10.rpz-client-ip for
// 10.10.0.37/32, 64.zz.3d7f.8013.2a02.rpz-client-ip for 2a02:8013:3d7f::/64.
func ClientIPOwner(p netip.Prefix) string {
p = p.Masked()
a := p.Addr()
var labels []string
if a.Is4() {
b := a.As4()
labels = []string{fmt.Sprint(b[3]), fmt.Sprint(b[2]), fmt.Sprint(b[1]), fmt.Sprint(b[0])}
} else {
// RFC 5952 text, with "zz" for the "::" and the words reversed.
s := a.String()
var words []string
if left, right, ok := strings.Cut(s, "::"); ok {
if left != "" {
words = append(words, strings.Split(left, ":")...)
}
words = append(words, "zz")
if right != "" {
words = append(words, strings.Split(right, ":")...)
}
} else {
words = strings.Split(s, ":")
}
for i := len(words) - 1; i >= 0; i-- {
labels = append(labels, words[i])
}
}
return fmt.Sprint(p.Bits()) + "." + strings.Join(labels, ".") + ".rpz-client-ip"
}
// GateZoneFile renders a gate: every client in prefixes passes.
func GateZoneFile(title string, prefixes []netip.Prefix) string {
owners := make([]string, 0, len(prefixes))
for _, p := range prefixes {
owners = append(owners, ClientIPOwner(p))
}
sort.Strings(owners)
var sb strings.Builder
sb.WriteString(header(title, 1))
for _, o := range owners {
sb.WriteString(o + "\tCNAME\trpz-passthru.\n")
}
return sb.String()
}
+96
View File
@@ -0,0 +1,96 @@
package blocking
import (
"net/netip"
"slices"
"strings"
"testing"
)
func pfx(s string) netip.Prefix { return netip.MustParsePrefix(s) }
// rankOf is the rank PassBelow's model gives an address.
func rankOf(assigns []Assignment, a netip.Addr) int {
best, bits := 0, -1
for _, x := range assigns {
if x.Prefix.Contains(a) && x.Prefix.Bits() >= bits {
if x.Prefix.Bits() > bits || x.Rank > best {
best = x.Rank
}
bits = x.Prefix.Bits()
}
}
return best
}
func covered(ps []netip.Prefix, a netip.Addr) bool {
for _, p := range ps {
if p.Contains(a) {
return true
}
}
return false
}
func TestPassBelow(t *testing.T) {
assigns := []Assignment{
{pfx("10.10.0.20/32"), -1}, // work laptop: off
{pfx("10.10.0.0/24"), 2}, // the kids' network: very strict
{pfx("10.10.0.50/32"), 1}, // except one tablet: strict
{pfx("2a02:db8::/64"), 1}, // an IPv6 network: strict
{pfx("2a02:db8::99/128"), 0}, // one device in it: normal
}
probe := []string{"10.10.0.20", "10.10.0.21", "10.10.0.50", "10.10.0.51", "10.10.1.1", "192.168.1.1", "0.0.0.1", "255.255.255.255",
"2a02:db8::1", "2a02:db8::99", "2a02:db8:0:1::1", "::1", "ffff::1"}
for k := 0; k <= 3; k++ {
gate := PassBelow(assigns, k)
for _, s := range probe {
a := netip.MustParseAddr(s)
if want := rankOf(assigns, a) < k; covered(gate, a) != want {
t.Errorf("k=%d %s: passes=%v, want %v (rank %d)", k, s, !want, want, rankOf(assigns, a))
}
}
for _, p := range gate {
if p.Bits() == 0 {
t.Errorf("k=%d: a /0 trigger", k)
}
// named matches IPv4 clients as ::ffff:a.b.c.d: no IPv6
// prefix may reach them.
if p.Addr().Is6() && p.Overlaps(v4Mapped) {
t.Errorf("k=%d: %s covers IPv4-mapped addresses", k, p)
}
}
}
// Off clients only, before the normal lists.
if got := PassBelow(assigns, 0); !slices.Equal(got, []netip.Prefix{pfx("10.10.0.20/32")}) {
t.Errorf("off gate = %v", got)
}
// Nobody above normal: everyone passes the gate of step 1: two IPv4
// halves, and IPv6 around the IPv4-mapped /96 (96 prefixes).
if got := PassBelow(nil, 1); len(got) != 2+96 {
t.Errorf("empty step-1 gate = %v", got)
}
// The complement of one IPv4 address is 32 prefixes (plus IPv6's 96).
if got := PassBelow([]Assignment{{pfx("10.10.0.50/32"), 1}}, 1); len(got) != 32+96 {
t.Errorf("complement of one address: %d prefixes", len(got))
}
}
func TestClientIPOwner(t *testing.T) {
for p, want := range map[string]string{
"10.10.0.37/32": "32.37.0.10.10.rpz-client-ip",
"10.10.0.0/24": "24.0.0.10.10.rpz-client-ip",
"2a02:db8::/64": "64.zz.db8.2a02.rpz-client-ip",
"2a02:db8::1/128": "128.1.zz.db8.2a02.rpz-client-ip",
"::/1": "1.zz.rpz-client-ip",
"2001:db8:1:2:3:4:5:6/128": "128.6.5.4.3.2.1.db8.2001.rpz-client-ip",
} {
if got := ClientIPOwner(pfx(p)); got != want {
t.Errorf("%s: %s, want %s", p, got, want)
}
}
z := GateZoneFile("t", []netip.Prefix{pfx("10.10.0.20/32")})
if !strings.Contains(z, "32.20.0.10.10.rpz-client-ip\tCNAME\trpz-passthru.\n") {
t.Errorf("gate zone:\n%s", z)
}
}
+3
View File
@@ -40,6 +40,9 @@ type Match struct {
Entry string `json:"entry,omitempty"` // the matching entry: the name, or *.parent
RuleID int64 `json:"rule_id,omitempty"`
List string `json:"list,omitempty"` // the blocklist, when a list matched
// Step is the list's step when it is above normal: only clients at
// that step or above get this answer (others: none).
Step string `json:"step,omitempty"`
}
// Actions of rules.
+10 -4
View File
@@ -335,7 +335,9 @@ func (l *limitedReader) Read(p []byte) (int, error) {
// CheckBlocking reports, for every view with blocking, whether name is
// blocked or allowed there, and by which rule or list. It looks names up
// the way named does: rules first, then the lists in order.
// the way named does: rules first, then the lists in order. The answer is
// for clients at the normal step; a list of a higher step that blocks the
// name says so (Match.Step), and clients at step off skip every list.
func (e *Engine) CheckBlocking(st *model.State, name string) ([]blocking.Match, error) {
n, err := blocking.NormalizeName(name)
if err != nil {
@@ -349,8 +351,8 @@ func (e *Engine) CheckBlocking(st *model.State, name string) ([]blocking.Match,
out = append(out, blocking.Match{View: z.View, Result: "none"})
cur = &out[len(out)-1]
}
if cur.Result != "none" {
continue // an earlier zone decided
if cur.Result != "none" || z.IsGate() {
continue // an earlier zone decided, or a gate (it never blocks)
}
if z.ListID == 0 {
owner, act, ok := blocking.MatchRules(blocking.RuleEntries(render.ViewRules(st, z.ViewID)), n)
@@ -372,7 +374,11 @@ func (e *Engine) CheckBlocking(st *model.State, name string) ([]blocking.Match,
feeds[z.File] = entry
}
if entry != "" {
cur.Result, cur.Entry, cur.List = "blocked", entry, st.Blocklists[z.ListID].Name
b := st.Blocklists[z.ListID]
cur.Result, cur.Entry, cur.List = "blocked", entry, b.Name
if b.StepID != model.StepNormal {
cur.Step = st.StepName(b.StepID)
}
}
}
return out, nil
+15
View File
@@ -117,6 +117,16 @@ func applyBlocklistPut(st *model.State, op model.Op, c *applyCtx) error {
if in.Disabled != nil {
b.Disabled = *in.Disabled
}
step, given, err := stepRef(st, in.Step, in.StepID)
if err != nil {
return err
}
if given {
if step == model.StepOff {
return errf(KindBadRequest, "a blocklist starts at normal or a step above it, not off")
}
b.StepID = step
}
if in.Comment != nil {
b.Comment = *in.Comment
}
@@ -226,6 +236,11 @@ func blockingUsers(st *model.State, viewID int64) []string {
out = append(out, ruleLabel(r))
}
}
for _, g := range st.SortedClientGroups() {
if slices.Contains(g.ViewIDs, viewID) {
out = append(out, "client group "+g.Name)
}
}
return out
}
+263
View File
@@ -0,0 +1,263 @@
package engine
import (
"encoding/json"
"net/netip"
"strings"
"jarl/internal/model"
)
// ---- blocking steps and client groups (docs/blocking.md)
// stepRef resolves a step given by name or ID. ok is false when neither
// is given.
func stepRef(st *model.State, name *string, id *int64) (int64, bool, error) {
switch {
case name != nil:
s, found := st.StepByName(*name)
if !found {
return 0, true, errf(KindBadRequest, "blocking step %q does not exist (steps: %s)", *name, stepNames(st))
}
return s, true, nil
case id != nil:
if _, found := st.StepRank(*id); !found {
return 0, true, errf(KindBadRequest, "blocking step %d does not exist", *id)
}
return *id, true, nil
}
return 0, false, nil
}
// stepNames lists the steps for messages: "off, normal, strict".
func stepNames(st *model.State) string {
names := []string{"off", "normal"}
for _, s := range st.OrderedBlockSteps() {
names = append(names, s.Name)
}
return strings.Join(names, ", ")
}
// NormalizeMember checks a client group member: an address, a prefix or
// an ACL name (an ACL's own elements are checked by validation). It
// returns the canonical form.
func NormalizeMember(st *model.State, m string) (string, error) {
m = strings.TrimSpace(m)
if a, err := netip.ParseAddr(m); err == nil {
if a.Zone() != "" {
return "", errf(KindBadRequest, "%q: an address with a zone can't be matched", m)
}
return a.Unmap().String(), nil
}
if p, err := netip.ParsePrefix(m); err == nil {
return p.Masked().String(), nil
}
if a := st.ACLByName(m); a != nil {
return a.Name, nil
}
return "", errf(KindBadRequest, "%q is not an address, a prefix (10.10.0.0/24) or an ACL", m)
}
func applyBlockStepPut(st *model.State, op model.Op, c *applyCtx) error {
if err := c.require(blockingAction()); err != nil {
return err
}
var in BlockStepInput
if err := decode(op.Data, &in); err != nil {
return err
}
var s *model.BlockStep
if op.Kind == OpBlockStepCreate {
if op.ID <= 0 || st.BlockSteps[op.ID] != nil {
return errf(KindConflict, "step ID %d is already in use", op.ID)
}
if in.Name == nil {
return errf(KindBadRequest, "a blocking step needs a name")
}
pos := 0
for _, o := range st.BlockSteps {
pos = max(pos, o.Position+1)
}
s = &model.BlockStep{ID: op.ID, Position: pos}
} else {
s = st.BlockSteps[op.ID]
if s == nil {
return errf(KindNotFound, "blocking step %d does not exist", op.ID)
}
if err := checkVersion(op, s.Version, "step "+s.Name); err != nil {
return err
}
}
if in.Name != nil {
name := strings.ToLower(strings.TrimSpace(*in.Name))
if !blocklistNameRe.MatchString(name) || name == "off" || name == "normal" {
return errf(KindBadRequest, "step name %q: use 1 to 40 lowercase letters, digits and hyphens, like strict (off and normal are built in)", *in.Name)
}
if id, found := st.StepByName(name); found && id != s.ID {
return errf(KindConflict, "step %s already exists", name)
}
s.Name = name
}
if in.Position != nil {
s.Position = *in.Position
}
if in.Comment != nil {
s.Comment = *in.Comment
}
s.Touch(c.user(), c.now)
st.BlockSteps[s.ID] = s
return nil
}
func applyBlockStepDelete(st *model.State, op model.Op, c *applyCtx) error {
if err := c.require(blockingAction()); err != nil {
return err
}
s := st.BlockSteps[op.ID]
if s == nil {
return errf(KindNotFound, "blocking step %d does not exist", op.ID)
}
if err := checkVersion(op, s.Version, "step "+s.Name); err != nil {
return err
}
if users := stepUsers(st, s.ID); len(users) > 0 {
return errf(KindConflict, "step %s is used by %s; move them to another step first", s.Name, strings.Join(users, ", "))
}
delete(st.BlockSteps, s.ID)
return nil
}
// stepUsers lists the blocklists and client groups at a step.
func stepUsers(st *model.State, id int64) []string {
var out []string
for _, b := range st.SortedBlocklists() {
if b.StepID == id {
out = append(out, "blocklist "+b.Name)
}
}
for _, g := range st.SortedClientGroups() {
if g.StepID == id {
out = append(out, "client group "+g.Name)
}
}
return out
}
func applyClientGroupPut(st *model.State, op model.Op, c *applyCtx) error {
if err := c.require(blockingAction()); err != nil {
return err
}
var in ClientGroupInput
if err := decode(op.Data, &in); err != nil {
return err
}
var g *model.ClientGroup
if op.Kind == OpClientGroupCreate {
if op.ID <= 0 || st.ClientGroups[op.ID] != nil {
return errf(KindConflict, "client group ID %d is already in use", op.ID)
}
if in.Name == nil {
return errf(KindBadRequest, "a client group needs a name")
}
g = &model.ClientGroup{ID: op.ID, Members: []string{}, StepID: model.StepNormal, ViewIDs: []int64{}}
} else {
g = st.ClientGroups[op.ID]
if g == nil {
return errf(KindNotFound, "client group %d does not exist", op.ID)
}
if err := checkVersion(op, g.Version, "client group "+g.Name); err != nil {
return err
}
}
if in.Name != nil {
name := strings.ToLower(strings.TrimSpace(*in.Name))
if !blocklistNameRe.MatchString(name) {
return errf(KindBadRequest, "client group name %q: use 1 to 40 lowercase letters, digits and hyphens, like kids", *in.Name)
}
if other := st.ClientGroupByName(name); other != nil && other.ID != g.ID {
return errf(KindConflict, "client group %s already exists", name)
}
g.Name = name
}
if in.Members != nil {
members := []string{}
seen := map[string]bool{}
for _, m := range *in.Members {
if strings.TrimSpace(m) == "" {
continue
}
n, err := NormalizeMember(st, m)
if err != nil {
return err
}
if !seen[n] {
seen[n] = true
members = append(members, n)
}
}
g.Members = members
}
step, given, err := stepRef(st, in.Step, in.StepID)
if err != nil {
return err
}
if given {
g.StepID = step
}
ids, err := viewIDs(st, in.Views, in.ViewIDs)
if err != nil {
return err
}
if ids != nil {
g.ViewIDs = ids
}
if in.Comment != nil {
g.Comment = *in.Comment
}
g.Touch(c.user(), c.now)
st.ClientGroups[g.ID] = g
return nil
}
func applyClientGroupDelete(st *model.State, op model.Op, c *applyCtx) error {
if err := c.require(blockingAction()); err != nil {
return err
}
g := st.ClientGroups[op.ID]
if g == nil {
return errf(KindNotFound, "client group %d does not exist", op.ID)
}
if err := checkVersion(op, g.Version, "client group "+g.Name); err != nil {
return err
}
delete(st.ClientGroups, g.ID)
return nil
}
// ---- expansion
func (x *expander) blockStepCreate(req Request) ([]model.Op, error) {
return x.simpleCreate(req, OpBlockStepCreate, func(data json.RawMessage) (string, error) {
var in BlockStepInput
if err := decode(data, &in); err != nil {
return "", err
}
if in.Name == nil {
return "", errf(KindBadRequest, "a blocking step needs a name")
}
return "add blocking step " + strings.ToLower(*in.Name), nil
})
}
func (x *expander) clientGroupCreate(req Request) ([]model.Op, error) {
return x.simpleCreate(req, OpClientGroupCreate, func(data json.RawMessage) (string, error) {
var in ClientGroupInput
if err := decode(data, &in); err != nil {
return "", err
}
if in.Name == nil {
return "", errf(KindBadRequest, "a client group needs a name")
}
return "add client group " + strings.ToLower(*in.Name), nil
})
}
+123
View File
@@ -0,0 +1,123 @@
package engine
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"jarl/internal/model"
)
func clientGroup(name, step string, members ...string) Request {
return NewRequest(OpClientGroupCreate, 0, ClientGroupInput{Name: &name, Step: &step, Members: &members})
}
func TestClientGroups(t *testing.T) {
env := newEnv(t)
setupZone(env)
_, srv := newListServer(t, "||ads.example^\n")
env.submit(env.admin,
NewRequest(OpBlockStepCreate, 0, BlockStepInput{Name: model.Ptr("strict")}),
NewRequest(OpACLCreate, 0, ACLInput{Name: model.Ptr("tablets"), Elements: &[]string{"10.0.0.50", "10.0.0.51"}}),
)
env.submit(env.admin,
blocklist("ads", srv.URL+"/a.txt"),
NewRequest(OpBlocklistCreate, 0, BlocklistInput{Name: model.Ptr("social"), URL: model.Ptr(srv.URL + "/s.txt"), Step: model.Ptr("strict")}),
clientGroup("work", "off", "10.0.0.20"),
clientGroup("kids", "strict", "tablets", "2001:db8:1::/64"),
)
conf, _ := os.ReadFile(filepath.Join(env.dir, "etc/named.conf"))
c := string(conf)
// Off clients pass before the normal lists, everyone below strict
// before the strict ones; gates aren't logged.
want := "response-policy {\n" +
"\t\tzone \"off.gate.jarl.invalid\" min-update-interval 0 log no;\n" +
"\t\tzone \"ads.blocklist.jarl.invalid\" min-update-interval 0 log no;\n" +
"\t\tzone \"strict.gate.jarl.invalid\" min-update-interval 0 log no;\n" +
"\t\tzone \"social.blocklist.jarl.invalid\" min-update-interval 0 log no;\n"
if !strings.Contains(c, want) {
t.Errorf("response-policy:\n%s", c)
}
gate := func(name string) string {
b, err := os.ReadFile(filepath.Join(env.dir, "zones/_default", name+".gate.jarl.invalid.rpz"))
if err != nil {
t.Fatal(err)
}
return string(b)
}
if g := gate("off"); !strings.Contains(g, "32.20.0.0.10.rpz-client-ip\tCNAME\trpz-passthru.\n") || strings.Count(g, "rpz-client-ip") != 1 {
t.Errorf("off gate:\n%s", g)
}
// Everyone but the kids' tablets (via the ACL) and network passes.
sg := gate("strict")
for _, notIn := range []string{"32.50.0.0.10.rpz-client-ip", "32.51.0.0.10.rpz-client-ip", "64.zz.1.db8.2001.rpz-client-ip"} {
if strings.Contains(sg, notIn) {
t.Errorf("strict gate lets %s through:\n%s", notIn, sg)
}
}
// 10.0.0.0/27 holds the work laptop, 10.0.0.52/30 the next addresses
// after the tablets, 128.0.0.0/1 the upper half of IPv4.
for _, in := range []string{"27.0.0.0.10.rpz-client-ip", "30.52.0.0.10.rpz-client-ip", "1.0.0.0.128.rpz-client-ip"} {
if !strings.Contains(sg, in) {
t.Errorf("strict gate lacks %s:\n%s", in, sg)
}
}
st := env.state()
strict, _ := st.StepByName("strict")
// A step that lists or groups use can't be deleted.
if e := env.submitErr(env.admin, NewRequest(OpBlockStepDelete, strict, nil)); e.Kind != KindConflict || !strings.Contains(e.Message, "blocklist social") {
t.Errorf("deleting a used step: %+v", e)
}
// One client, one group; ACLs must hold addresses only.
if e := env.submitErr(env.admin, clientGroup("dup", "normal", "10.0.0.20")); e.Kind != KindValidation || !strings.Contains(e.Message, "10.0.0.20/32 is in client groups") {
t.Errorf("an address in two groups: %+v", e)
}
env.submit(env.admin, NewRequest(OpACLCreate, 0, ACLInput{Name: model.Ptr("keyed"), Elements: &[]string{"localnets"}}))
if e := env.submitErr(env.admin, clientGroup("bad", "normal", "keyed")); e.Kind != KindValidation || !strings.Contains(e.Message, "isn't an address") {
t.Errorf("an ACL with a key: %+v", e)
}
for _, req := range []Request{clientGroup("x", "nope", "10.1.1.1"), clientGroup("y", "normal", "not-an-acl"), clientGroup("Bad Name", "normal")} {
if e := env.submitErr(env.admin, req); e.Kind != KindBadRequest {
t.Errorf("%s: %+v", req.Data, e)
}
}
// A more specific address in another group is fine: it wins.
env.submit(env.admin, clientGroup("guest", "normal", "2001:db8:1::9"))
if sg := gate("strict"); !strings.Contains(sg, "128.9.zz.1.db8.2001.rpz-client-ip") {
t.Errorf("the guest inside the kids' network passes the strict gate:\n%s", sg)
}
// The kids' group gone: the strict list blocks nobody (a warning).
kids := env.state().ClientGroupByName("kids")
res := env.submit(env.admin, NewRequest(OpClientGroupDelete, kids.ID, nil))
found := false
for _, p := range res.Problems {
found = found || p.Code == "blocklist_step_unused"
}
if !found {
t.Errorf("no warning for a step no group reaches: %+v", res.Problems)
}
// Reverting brings it back.
rev, err := env.e.Revert(context.Background(), res.ChangesetID, env.admin)
if err != nil {
t.Fatal(err)
}
if len(rev.Ops) != 1 {
t.Errorf("revert ops: %+v (only the deleted group should come back)", rev.Ops)
}
if _, err := env.e.Apply(context.Background(), rev.ID, env.admin, ApplyOptions{}); err != nil || env.state().ClientGroupByName("kids") == nil {
t.Fatalf("revert: %v", err)
}
// Renaming the ACL renames the member; deleting it is refused.
tablets := env.state().ACLByName("tablets")
env.submit(env.admin, NewRequest(OpACLUpdate, tablets.ID, ACLInput{Name: model.Ptr("pads")}))
if g := env.state().ClientGroupByName("kids"); g.Members[0] != "pads" {
t.Errorf("ACL rename: members %v", g.Members)
}
if e := env.submitErr(env.admin, NewRequest(OpACLDelete, tablets.ID, nil)); e.Kind != KindConflict || !strings.Contains(e.Message, "client group kids") {
t.Errorf("deleting an ACL a group uses: %+v", e)
}
}
+30 -1
View File
@@ -104,9 +104,19 @@ func DiffOps(from, to *model.State) []model.Op {
add(OpRecordUpdate, k, o.Version, in, fmt.Sprintf("restore %s %s %s", n.Name, n.Type, n.Data))
}
}
// Steps before the lists and groups that use them.
for _, k := range sortedKeys(to.BlockSteps) {
n := to.BlockSteps[k]
in := BlockStepInput{Name: &n.Name, Position: model.Ptr(n.Position), Comment: &n.Comment}
if o, ok := from.BlockSteps[k]; !ok {
add(OpBlockStepCreate, k, 0, in, "add blocking step "+n.Name)
} else if !sameObject(o, n) {
add(OpBlockStepUpdate, k, o.Version, in, "restore blocking step "+n.Name)
}
}
for _, k := range sortedKeys(to.Blocklists) {
n := to.Blocklists[k]
in := BlocklistInput{Name: &n.Name, URL: &n.URL, ViewIDs: model.Ptr(append([]int64{}, n.ViewIDs...)), Disabled: &n.Disabled, Comment: &n.Comment}
in := BlocklistInput{Name: &n.Name, URL: &n.URL, ViewIDs: model.Ptr(append([]int64{}, n.ViewIDs...)), Disabled: &n.Disabled, Comment: &n.Comment, StepID: model.Ptr(n.StepID)}
if o, ok := from.Blocklists[k]; !ok {
add(OpBlocklistCreate, k, 0, in, "add blocklist "+n.Name)
} else if !sameObject(o, n) {
@@ -122,7 +132,21 @@ func DiffOps(from, to *model.State) []model.Op {
add(OpBlockRuleUpdate, k, o.Version, in, "restore rule: "+ruleLabel(n))
}
}
for _, k := range sortedKeys(to.ClientGroups) {
n := to.ClientGroups[k]
in := ClientGroupInput{Name: &n.Name, Members: model.Ptr(append([]string{}, n.Members...)), StepID: model.Ptr(n.StepID), ViewIDs: model.Ptr(append([]int64{}, n.ViewIDs...)), Comment: &n.Comment}
if o, ok := from.ClientGroups[k]; !ok {
add(OpClientGroupCreate, k, 0, in, "add client group "+n.Name)
} else if !sameObject(o, n) {
add(OpClientGroupUpdate, k, o.Version, in, "restore client group "+n.Name)
}
}
// Deletions, most dependent objects first.
for _, k := range sortedKeys(from.ClientGroups) {
if _, ok := to.ClientGroups[k]; !ok {
add(OpClientGroupDelete, k, from.ClientGroups[k].Version, nil, "delete client group "+from.ClientGroups[k].Name)
}
}
for _, k := range sortedKeys(from.BlockRules) {
if _, ok := to.BlockRules[k]; !ok {
add(OpBlockRuleDelete, k, from.BlockRules[k].Version, nil, "delete rule: "+ruleLabel(from.BlockRules[k]))
@@ -133,6 +157,11 @@ func DiffOps(from, to *model.State) []model.Op {
add(OpBlocklistDelete, k, from.Blocklists[k].Version, nil, "delete blocklist "+from.Blocklists[k].Name)
}
}
for _, k := range sortedKeys(from.BlockSteps) {
if _, ok := to.BlockSteps[k]; !ok {
add(OpBlockStepDelete, k, from.BlockSteps[k].Version, nil, "delete blocking step "+from.BlockSteps[k].Name)
}
}
for _, k := range sortedKeys(from.Records) {
if _, ok := to.Records[k]; !ok {
o := from.Records[k]
+5 -3
View File
@@ -501,13 +501,15 @@ func (e *Engine) makePlan(ctx context.Context, base, work *model.State, user str
if len(work.Zones) != len(base.Zones) {
pl.configChanged = true
}
// Rules zones whose content changed. New ones come with a change to
// named.conf, and reconfig loads them.
// Rules zones and gates whose content changed. New ones come with a
// change to named.conf, and reconfig loads them.
for _, z := range pl.out.Policy {
if z.ListID != 0 || !changedPaths[z.File] {
continue
}
if slices.Contains(baseOut.Policy, z) {
if slices.ContainsFunc(baseOut.Policy, func(o render.PolicyZone) bool {
return o.Name == z.Name && o.ViewID == z.ViewID && o.File == z.File
}) {
pl.reloadPolicy = append(pl.reloadPolicy, z)
}
}
+24
View File
@@ -264,6 +264,30 @@ func (x *expander) expand(req Request) ([]model.Op, error) {
verb = "delete rule: "
}
return []model.Op{x.versioned(req, r.Version, verb+ruleLabel(r))}, nil
case OpBlockStepCreate:
return x.blockStepCreate(req)
case OpBlockStepUpdate, OpBlockStepDelete:
s := x.st.BlockSteps[req.ID]
if s == nil {
return nil, errf(KindNotFound, "blocking step %d does not exist", req.ID)
}
verb := "change blocking step "
if req.Op == OpBlockStepDelete {
verb = "delete blocking step "
}
return []model.Op{x.versioned(req, s.Version, verb+s.Name)}, nil
case OpClientGroupCreate:
return x.clientGroupCreate(req)
case OpClientGroupUpdate, OpClientGroupDelete:
g := x.st.ClientGroups[req.ID]
if g == nil {
return nil, errf(KindNotFound, "client group %d does not exist", req.ID)
}
verb := "change client group "
if req.Op == OpClientGroupDelete {
verb = "delete client group "
}
return []model.Op{x.versioned(req, g.Version, verb+g.Name)}, nil
}
return nil, errf(KindBadRequest, "unknown operation %q", req.Op)
}
+34 -1
View File
@@ -39,6 +39,13 @@ const (
OpBlockRuleDelete = "blockrule.delete"
OpBlockingPause = "blocking.pause"
OpBlockStepCreate = "blockstep.create"
OpBlockStepUpdate = "blockstep.update"
OpBlockStepDelete = "blockstep.delete"
OpClientGroupCreate = "clientgroup.create"
OpClientGroupUpdate = "clientgroup.update"
OpClientGroupDelete = "clientgroup.delete"
// Requests only: disabling and enabling records, by ID or by match
// (zone, name, type and optionally data), become record.update
// operations.
@@ -50,7 +57,8 @@ const (
var OpKinds = []string{OpZoneCreate, OpZoneUpdate, OpZoneDelete, OpRecordCreate, OpRecordUpdate, OpRecordDelete, OpRRSetReplace,
OpViewCreate, OpViewUpdate, OpViewDelete, OpViewReorder, OpACLCreate, OpACLUpdate, OpACLDelete,
OpTSIGCreate, OpTSIGUpdate, OpTSIGDelete, OpOptionCreate, OpOptionUpdate, OpOptionDelete, OpSettingsUpdate,
OpBlocklistCreate, OpBlocklistUpdate, OpBlocklistDelete, OpBlockRuleCreate, OpBlockRuleUpdate, OpBlockRuleDelete, OpBlockingPause}
OpBlocklistCreate, OpBlocklistUpdate, OpBlocklistDelete, OpBlockRuleCreate, OpBlockRuleUpdate, OpBlockRuleDelete, OpBlockingPause,
OpBlockStepCreate, OpBlockStepUpdate, OpBlockStepDelete, OpClientGroupCreate, OpClientGroupUpdate, OpClientGroupDelete}
// Request is an operation as submitted by a client. Requests are expanded
// into one or more stored operations (for example an A record with "ptr"
@@ -236,6 +244,31 @@ type BlocklistInput struct {
ViewIDs *[]int64 `json:"view_ids,omitempty"`
Disabled *bool `json:"disabled,omitempty"`
Comment *string `json:"comment,omitempty"`
// Step is the step the list starts at, by name ("normal" or a step
// above it) or ID (StepID; 0 is normal).
Step *string `json:"step,omitempty"`
StepID *int64 `json:"step_id,omitempty"`
}
// BlockStepInput creates or updates a blocking step above Normal.
// Position orders the steps (new steps go on top).
type BlockStepInput struct {
Name *string `json:"name,omitempty"`
Position *int `json:"position,omitempty"`
Comment *string `json:"comment,omitempty"`
}
// ClientGroupInput creates or updates a client group. Members are
// addresses, prefixes or ACL names; the step is given by name ("off",
// "normal" or a step) or ID; views as for BlocklistInput.
type ClientGroupInput struct {
Name *string `json:"name,omitempty"`
Members *[]string `json:"members,omitempty"`
Step *string `json:"step,omitempty"`
StepID *int64 `json:"step_id,omitempty"`
Views *[]string `json:"views,omitempty"`
ViewIDs *[]int64 `json:"view_ids,omitempty"`
Comment *string `json:"comment,omitempty"`
}
// BlockRuleInput creates or updates a block or allow rule. Views as for
+8
View File
@@ -106,6 +106,14 @@ func applyOp(st *model.State, op model.Op, c *applyCtx) error {
err = applyBlockRulePut(st, op, c)
case OpBlockRuleDelete:
err = applyBlockRuleDelete(st, op, c)
case OpBlockStepCreate, OpBlockStepUpdate:
err = applyBlockStepPut(st, op, c)
case OpBlockStepDelete:
err = applyBlockStepDelete(st, op, c)
case OpClientGroupCreate, OpClientGroupUpdate:
err = applyClientGroupPut(st, op, c)
case OpClientGroupDelete:
err = applyClientGroupDelete(st, op, c)
case OpBlockingPause:
err = applyBlockingPause(st, op, c)
default:
+11
View File
@@ -152,6 +152,11 @@ func FindRefs(st *model.State, kind refKind, name string) []string {
out = append(out, "view "+v.Name)
}
}
for _, g := range st.SortedClientGroups() {
if _, hit := rewriteElements(g.Members, kind, name, name); hit {
out = append(out, "client group "+g.Name)
}
}
var opts []string
for _, o := range st.Options {
stmt, err := namedconf.ParseOptionValue(o.Name, o.Value)
@@ -188,6 +193,12 @@ func renameRefs(st *model.State, kind refKind, old, new string, c *applyCtx) {
v.Touch(c.user(), c.now)
}
}
for _, g := range st.ClientGroups {
if l, hit := rewriteElements(g.Members, kind, old, new); hit {
g.Members = l
g.Touch(c.user(), c.now)
}
}
for _, o := range st.Options {
stmt, err := namedconf.ParseOptionValue(o.Name, o.Value)
if err != nil {
+143
View File
@@ -1,6 +1,8 @@
package model
import (
"fmt"
"net/netip"
"slices"
"sort"
"strings"
@@ -17,10 +19,126 @@ type Blocklist struct {
// 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"
@@ -134,3 +252,28 @@ func (s *State) BlockingViews(viewIDs []int64) []int64 {
}
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 acl := s.ACLByName(e); acl != nil && !seen[acl.Name] {
seen[acl.Name] = true
add(acl.Elements)
}
}
}
add(g.Members)
return out
}
+23 -6
View File
@@ -9,6 +9,7 @@ package model
import (
"encoding/json"
"slices"
"sort"
"strings"
"time"
@@ -249,6 +250,8 @@ type State struct {
// Blocking (docs/blocking.md).
Blocklists map[int64]*Blocklist
BlockRules map[int64]*BlockRule
BlockSteps map[int64]*BlockStep
ClientGroups map[int64]*ClientGroup
}
// NewState returns an empty state with default settings.
@@ -264,6 +267,8 @@ func NewState() *State {
Blocklists: map[int64]*Blocklist{},
BlockRules: map[int64]*BlockRule{},
BlockSteps: map[int64]*BlockStep{},
ClientGroups: map[int64]*ClientGroup{},
}
}
@@ -280,12 +285,14 @@ func (s *State) Clone() *State {
Blocklists: make(map[int64]*Blocklist, len(s.Blocklists)),
BlockRules: make(map[int64]*BlockRule, len(s.BlockRules)),
BlockSteps: make(map[int64]*BlockStep, len(s.BlockSteps)),
ClientGroups: make(map[int64]*ClientGroup, len(s.ClientGroups)),
}
c.Settings.Nameservers = append([]string(nil), s.Settings.Nameservers...)
c.Settings.Nameservers = slices.Clone(s.Settings.Nameservers)
for id, v := range s.Views {
n := *v
n.MatchClients = append([]string(nil), v.MatchClients...)
n.MatchDestinations = append([]string(nil), v.MatchDestinations...)
n.MatchClients = slices.Clone(v.MatchClients)
n.MatchDestinations = slices.Clone(v.MatchDestinations)
if v.MatchRecursiveOnly != nil {
b := *v.MatchRecursiveOnly
n.MatchRecursiveOnly = &b
@@ -294,7 +301,7 @@ func (s *State) Clone() *State {
}
for id, a := range s.ACLs {
n := *a
n.Elements = append([]string(nil), a.Elements...)
n.Elements = slices.Clone(a.Elements)
c.ACLs[id] = &n
}
for id, k := range s.Keys {
@@ -319,14 +326,24 @@ func (s *State) Clone() *State {
}
for id, b := range s.Blocklists {
n := *b
n.ViewIDs = append([]int64(nil), b.ViewIDs...)
n.ViewIDs = slices.Clone(b.ViewIDs)
c.Blocklists[id] = &n
}
for id, r := range s.BlockRules {
n := *r
n.ViewIDs = append([]int64(nil), r.ViewIDs...)
n.ViewIDs = slices.Clone(r.ViewIDs)
c.BlockRules[id] = &n
}
for id, st := range s.BlockSteps {
n := *st
c.BlockSteps[id] = &n
}
for id, g := range s.ClientGroups {
n := *g
n.Members = slices.Clone(g.Members)
n.ViewIDs = slices.Clone(g.ViewIDs)
c.ClientGroups[id] = &n
}
return c
}
+78 -6
View File
@@ -2,6 +2,7 @@ package render
import (
"fmt"
"net/netip"
"path/filepath"
"slices"
"strings"
@@ -21,9 +22,16 @@ type PolicyZone struct {
ViewID int64 // 0 without views
View string // "" without views
File string
ListID int64 // the blocklist; 0 for the rules zone
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") }
@@ -41,7 +49,9 @@ func (p Paths) rulesFile(view string) string {
// 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 the enabled lists by name.
// (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()
@@ -65,11 +75,27 @@ func PolicyZones(s *model.State, p Paths) []PolicyZone {
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 && slices.Contains(s.BlockingViews(b.ViewIDs), vid) {
zones = append(zones, PolicyZone{Name: blocking.ListZone(b.Name), ViewID: vid, View: view, File: p.BlocklistFile(b.ID), ListID: b.ID})
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 {
@@ -80,6 +106,39 @@ func PolicyZones(s *model.State, p Paths) []PolicyZone {
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
@@ -106,7 +165,9 @@ func writeResponsePolicy(w *confWriter, zones []PolicyZone, logRewrites bool) {
// 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"}
if !logRewrites {
// 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, " "))))
@@ -132,8 +193,11 @@ func writePolicyZones(w *confWriter, s *model.State, zones []PolicyZone, out *Ou
for _, z := range zones {
w.write("\n")
label := "blocking: your rules"
if z.ListID != 0 {
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",
@@ -143,6 +207,14 @@ func writePolicyZones(w *confWriter, s *model.State, zones []PolicyZone, out *Ou
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
+2 -2
View File
@@ -33,11 +33,11 @@ func viewNames(st *model.State, ids []int64) []string {
func presentBlocklist(st *model.State, b *model.Blocklist, status *model.BlocklistStatus) api.Blocklist {
views := st.BlockingViews(b.ViewIDs)
out := api.Blocklist{Blocklist: *b, Views: viewNames(st, views), AutoViews: len(b.ViewIDs) == 0 && len(st.Views) > 0, Status: status}
out := api.Blocklist{Blocklist: *b, Views: viewNames(st, views), AutoViews: len(b.ViewIDs) == 0 && len(st.Views) > 0, Status: status, Step: st.StepName(b.StepID)}
switch {
case b.Disabled:
out.State = "disabled"
case len(views) == 0:
case len(views) == 0 || !stepReached(st, b, views):
out.State = "idle"
case status != nil && status.Error != "":
out.State = "error"
+265
View File
@@ -0,0 +1,265 @@
package server
import (
"encoding/json"
"net/http"
"slices"
"strconv"
"jarl/internal/api"
"jarl/internal/auth"
"jarl/internal/engine"
"jarl/internal/model"
)
// Blocking steps and client groups (docs/blocking.md).
func presentBlockStep(st *model.State, s *model.BlockStep) api.BlockStep {
out := api.BlockStep{BlockStep: *s, Lists: []string{}, Groups: []string{}}
for _, b := range st.SortedBlocklists() {
if b.StepID == s.ID {
out.Lists = append(out.Lists, b.Name)
}
}
for _, g := range st.SortedClientGroups() {
if g.StepID == s.ID {
out.Groups = append(out.Groups, g.Name)
}
}
return out
}
func presentClientGroup(st *model.State, g *model.ClientGroup) api.ClientGroup {
return api.ClientGroup{ClientGroup: *g, Step: st.StepName(g.StepID), Views: viewNames(st, st.BlockingViews(g.ViewIDs)), AutoViews: len(g.ViewIDs) == 0 && len(st.Views) > 0}
}
func stepObject(st *model.State, id int64) any {
if s := st.BlockSteps[id]; s != nil {
return presentBlockStep(st, s)
}
return nil
}
func groupObject(st *model.State, id int64) any {
if g := st.ClientGroups[id]; g != nil {
return presentClientGroup(st, g)
}
return nil
}
func (s *Server) listBlockSteps(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := requireBlocking(p); err != nil {
return err
}
st, base, err := s.states(r)
if err != nil {
return err
}
out := []api.BlockStep{}
for _, x := range st.OrderedBlockSteps() {
o := presentBlockStep(st, x)
o.Pending = pending(base, base != nil && base.BlockSteps[x.ID] == nil, base != nil && base.BlockSteps[x.ID] != nil && base.BlockSteps[x.ID].Version != x.Version)
out = append(out, o)
}
if base != nil {
for _, x := range base.OrderedBlockSteps() {
if st.BlockSteps[x.ID] == nil {
o := presentBlockStep(base, x)
o.Pending = "delete"
out = append(out, o)
}
}
}
writeJSON(w, http.StatusOK, out)
return nil
}
// stepByPath finds a step above normal by ID or name.
func stepByPath(r *http.Request, st *model.State) (*model.BlockStep, error) {
ref := r.PathValue("id")
if id, err := strconv.ParseInt(ref, 10, 64); err == nil {
if x := st.BlockSteps[id]; x != nil {
return x, nil
}
} else if id, ok := st.StepByName(ref); ok && id > 0 {
return st.BlockSteps[id], nil
}
return nil, notFound("blocking step %s not found (off and normal are built in)", ref)
}
func (s *Server) createBlockStep(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
body, err := readBody(r)
if err != nil {
return err
}
return s.mutate(w, r, p, "", []engine.Request{request(engine.OpBlockStepCreate, 0, body)}, stepObject)
}
func (s *Server) updateBlockStep(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
st, err := s.state(r)
if err != nil {
return err
}
x, err := stepByPath(r, st)
if err != nil {
return err
}
body, err := readBody(r)
if err != nil {
return err
}
id := x.ID
return s.mutate(w, r, p, "", []engine.Request{request(engine.OpBlockStepUpdate, id, body)}, func(st *model.State, _ int64) any { return stepObject(st, id) })
}
func (s *Server) deleteBlockStep(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
st, err := s.state(r)
if err != nil {
return err
}
x, err := stepByPath(r, st)
if err != nil {
return err
}
return s.mutate(w, r, p, "", []engine.Request{request(engine.OpBlockStepDelete, x.ID, nil)}, nil)
}
// orderBlockSteps: POST /blocking/steps/order {"steps": ["strict",
// "very-strict"]} (names or IDs as strings) sets the order of the steps
// above normal, lowest first.
func (s *Server) orderBlockSteps(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
st, err := s.state(r)
if err != nil {
return err
}
var in struct {
Steps []string `json:"steps"`
}
if err := decodeJSON(r, &in); err != nil {
return err
}
if len(in.Steps) != len(st.BlockSteps) {
return badRequest("give every step above normal once (%d)", len(st.BlockSteps))
}
var reqs []engine.Request
seen := map[int64]bool{}
for i, ref := range in.Steps {
var x *model.BlockStep
if id, err := strconv.ParseInt(ref, 10, 64); err == nil {
x = st.BlockSteps[id]
} else if id, ok := st.StepByName(ref); ok && id > 0 {
x = st.BlockSteps[id]
}
if x == nil || seen[x.ID] {
return badRequest("step %q doesn't exist or is given twice", ref)
}
seen[x.ID] = true
if x.Position != i {
data, _ := json.Marshal(engine.BlockStepInput{Position: model.Ptr(i)})
reqs = append(reqs, request(engine.OpBlockStepUpdate, x.ID, data))
}
}
if len(reqs) == 0 {
writeJSON(w, http.StatusOK, map[string]string{"message": "the steps are already in that order"})
return nil
}
return s.mutate(w, r, p, "", reqs, nil)
}
func (s *Server) listClientGroups(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := requireBlocking(p); err != nil {
return err
}
st, base, err := s.states(r)
if err != nil {
return err
}
out := []api.ClientGroup{}
for _, g := range st.SortedClientGroups() {
o := presentClientGroup(st, g)
o.Pending = pending(base, base != nil && base.ClientGroups[g.ID] == nil, base != nil && base.ClientGroups[g.ID] != nil && base.ClientGroups[g.ID].Version != g.Version)
out = append(out, o)
}
if base != nil {
for _, g := range base.SortedClientGroups() {
if st.ClientGroups[g.ID] == nil {
o := presentClientGroup(base, g)
o.Pending = "delete"
out = append(out, o)
}
}
}
writeJSON(w, http.StatusOK, out)
return nil
}
func groupByPath(r *http.Request, st *model.State) (*model.ClientGroup, error) {
ref := r.PathValue("id")
if id, err := strconv.ParseInt(ref, 10, 64); err == nil {
if g := st.ClientGroups[id]; g != nil {
return g, nil
}
} else if g := st.ClientGroupByName(ref); g != nil {
return g, nil
}
return nil, notFound("client group %s not found", ref)
}
func (s *Server) createClientGroup(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
body, err := readBody(r)
if err != nil {
return err
}
return s.mutate(w, r, p, "", []engine.Request{request(engine.OpClientGroupCreate, 0, body)}, groupObject)
}
func (s *Server) updateClientGroup(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
st, err := s.state(r)
if err != nil {
return err
}
g, err := groupByPath(r, st)
if err != nil {
return err
}
body, err := readBody(r)
if err != nil {
return err
}
id := g.ID
return s.mutate(w, r, p, "", []engine.Request{request(engine.OpClientGroupUpdate, id, body)}, func(st *model.State, _ int64) any { return groupObject(st, id) })
}
func (s *Server) deleteClientGroup(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
st, err := s.state(r)
if err != nil {
return err
}
g, err := groupByPath(r, st)
if err != nil {
return err
}
return s.mutate(w, r, p, "", []engine.Request{request(engine.OpClientGroupDelete, g.ID, nil)}, nil)
}
// stepReached reports whether any client gets a list: always for a list at
// normal; for one above it, when a client group with members in one of its
// views is at its step or above.
func stepReached(st *model.State, b *model.Blocklist, views []int64) bool {
rank, _ := st.StepRank(b.StepID)
if rank <= 0 {
return true
}
for _, g := range st.ClientGroups {
r, ok := st.StepRank(g.StepID)
if !ok || r < rank || len(st.GroupPrefixes(g)) == 0 {
continue
}
for _, v := range st.BlockingViews(g.ViewIDs) {
if slices.Contains(views, v) {
return true
}
}
}
return false
}
+9
View File
@@ -69,6 +69,15 @@ func (s *Server) routes() {
m.HandleFunc("DELETE "+p+"/blocking/rules", s.authed(s.deleteBlockRulesByName))
m.HandleFunc("PATCH "+p+"/blocking/rules/{id}", s.authed(s.updateBlockRule))
m.HandleFunc("DELETE "+p+"/blocking/rules/{id}", s.authed(s.deleteBlockRule))
m.HandleFunc("GET "+p+"/blocking/steps", s.authed(s.listBlockSteps))
m.HandleFunc("POST "+p+"/blocking/steps", s.authed(s.createBlockStep))
m.HandleFunc("POST "+p+"/blocking/steps/order", s.authed(s.orderBlockSteps))
m.HandleFunc("PATCH "+p+"/blocking/steps/{id}", s.authed(s.updateBlockStep))
m.HandleFunc("DELETE "+p+"/blocking/steps/{id}", s.authed(s.deleteBlockStep))
m.HandleFunc("GET "+p+"/blocking/groups", s.authed(s.listClientGroups))
m.HandleFunc("POST "+p+"/blocking/groups", s.authed(s.createClientGroup))
m.HandleFunc("PATCH "+p+"/blocking/groups/{id}", s.authed(s.updateClientGroup))
m.HandleFunc("DELETE "+p+"/blocking/groups/{id}", s.authed(s.deleteClientGroup))
m.HandleFunc("GET "+p+"/blocking/check", s.authed(s.checkBlocking))
m.HandleFunc("GET "+p+"/blocking/status", s.authed(s.blockingStatus))
m.HandleFunc("POST "+p+"/blocking/pause", s.authed(s.pauseBlocking(false)))
+59 -7
View File
@@ -150,14 +150,14 @@ func loadState(ctx context.Context, e execer) (*model.State, error) {
return nil, err
}
rows, err = e.QueryContext(ctx, `SELECT id, name, url, view_ids, disabled, comment, version, created_at, updated_at, created_by, updated_by FROM blocklists`)
rows, err = e.QueryContext(ctx, `SELECT id, name, url, view_ids, disabled, step_id, comment, version, created_at, updated_at, created_by, updated_by FROM blocklists`)
if err != nil {
return nil, err
}
for rows.Next() {
b := &model.Blocklist{}
var views, ca, ua string
if err := rows.Scan(&b.ID, &b.Name, &b.URL, &views, &b.Disabled, &b.Comment, &b.Version, &ca, &ua, &b.CreatedBy, &b.UpdatedBy); err != nil {
if err := rows.Scan(&b.ID, &b.Name, &b.URL, &views, &b.Disabled, &b.StepID, &b.Comment, &b.Version, &ca, &ua, &b.CreatedBy, &b.UpdatedBy); err != nil {
rows.Close()
return nil, err
}
@@ -187,6 +187,44 @@ func loadState(ctx context.Context, e execer) (*model.State, error) {
if err := closeRows(rows); err != nil {
return nil, err
}
rows, err = e.QueryContext(ctx, `SELECT id, name, position, comment, version, created_at, updated_at, created_by, updated_by FROM block_steps`)
if err != nil {
return nil, err
}
for rows.Next() {
s := &model.BlockStep{}
var ca, ua string
if err := rows.Scan(&s.ID, &s.Name, &s.Position, &s.Comment, &s.Version, &ca, &ua, &s.CreatedBy, &s.UpdatedBy); err != nil {
rows.Close()
return nil, err
}
s.CreatedAt, s.UpdatedAt = parseTS(ca), parseTS(ua)
st.BlockSteps[s.ID] = s
}
if err := closeRows(rows); err != nil {
return nil, err
}
rows, err = e.QueryContext(ctx, `SELECT id, name, members, step_id, view_ids, comment, version, created_at, updated_at, created_by, updated_by FROM client_groups`)
if err != nil {
return nil, err
}
for rows.Next() {
g := &model.ClientGroup{}
var members, views, ca, ua string
if err := rows.Scan(&g.ID, &g.Name, &members, &g.StepID, &views, &g.Comment, &g.Version, &ca, &ua, &g.CreatedBy, &g.UpdatedBy); err != nil {
rows.Close()
return nil, err
}
json.Unmarshal([]byte(members), &g.Members)
json.Unmarshal([]byte(views), &g.ViewIDs)
g.CreatedAt, g.UpdatedAt = parseTS(ca), parseTS(ua)
st.ClientGroups[g.ID] = g
}
if err := closeRows(rows); err != nil {
return nil, err
}
return st, nil
}
@@ -204,7 +242,7 @@ func (t *Tx) SaveState(old, new *model.State) error {
// ReplaceState replaces the stored configuration with st (used by import
// and tests).
func (t *Tx) ReplaceState(st *model.State) error {
for _, tbl := range []string{"views", "acls", "tsig_keys", "zones", "records", "options", "blocklists", "block_rules"} {
for _, tbl := range []string{"views", "acls", "tsig_keys", "zones", "records", "options", "blocklists", "block_rules", "block_steps", "client_groups"} {
if _, err := t.tx.ExecContext(t.ctx, "DELETE FROM "+tbl); err != nil {
return err
}
@@ -272,17 +310,31 @@ func saveState(ctx context.Context, e execer, old, new *model.State) error {
return err
}
if err := diffMap(old.Blocklists, new.Blocklists, func(b *model.Blocklist) error {
_, err := e.ExecContext(ctx, `INSERT OR REPLACE INTO blocklists (id, name, url, view_ids, disabled, comment, version, created_at, updated_at, created_by, updated_by) VALUES (?,?,?,?,?,?,?,?,?,?,?)`,
b.ID, b.Name, b.URL, jsonText(nonNilIDs(b.ViewIDs)), b.Disabled, b.Comment, b.Version, ts(b.CreatedAt), ts(b.UpdatedAt), b.CreatedBy, b.UpdatedBy)
_, err := e.ExecContext(ctx, `INSERT OR REPLACE INTO blocklists (id, name, url, view_ids, disabled, step_id, comment, version, created_at, updated_at, created_by, updated_by) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`,
b.ID, b.Name, b.URL, jsonText(nonNilIDs(b.ViewIDs)), b.Disabled, b.StepID, b.Comment, b.Version, ts(b.CreatedAt), ts(b.UpdatedAt), b.CreatedBy, b.UpdatedBy)
return err
}, func(id int64) error { return del(ctx, e, "blocklists", id) }); err != nil {
return err
}
return diffMap(old.BlockRules, new.BlockRules, func(r *model.BlockRule) error {
if err := diffMap(old.BlockRules, new.BlockRules, func(r *model.BlockRule) error {
_, err := e.ExecContext(ctx, `INSERT OR REPLACE INTO block_rules (id, name, action, exact, view_ids, comment, version, created_at, updated_at, created_by, updated_by) VALUES (?,?,?,?,?,?,?,?,?,?,?)`,
r.ID, r.Name, r.Action, r.Exact, jsonText(nonNilIDs(r.ViewIDs)), r.Comment, r.Version, ts(r.CreatedAt), ts(r.UpdatedAt), r.CreatedBy, r.UpdatedBy)
return err
}, func(id int64) error { return del(ctx, e, "block_rules", id) })
}, func(id int64) error { return del(ctx, e, "block_rules", id) }); err != nil {
return err
}
if err := diffMap(old.BlockSteps, new.BlockSteps, func(s *model.BlockStep) error {
_, err := e.ExecContext(ctx, `INSERT OR REPLACE INTO block_steps (id, name, position, comment, version, created_at, updated_at, created_by, updated_by) VALUES (?,?,?,?,?,?,?,?,?)`,
s.ID, s.Name, s.Position, s.Comment, s.Version, ts(s.CreatedAt), ts(s.UpdatedAt), s.CreatedBy, s.UpdatedBy)
return err
}, func(id int64) error { return del(ctx, e, "block_steps", id) }); err != nil {
return err
}
return diffMap(old.ClientGroups, new.ClientGroups, func(g *model.ClientGroup) error {
_, err := e.ExecContext(ctx, `INSERT OR REPLACE INTO client_groups (id, name, members, step_id, view_ids, comment, version, created_at, updated_at, created_by, updated_by) VALUES (?,?,?,?,?,?,?,?,?,?,?)`,
g.ID, g.Name, jsonText(nonNil(g.Members)), g.StepID, jsonText(nonNilIDs(g.ViewIDs)), g.Comment, g.Version, ts(g.CreatedAt), ts(g.UpdatedAt), g.CreatedBy, g.UpdatedBy)
return err
}, func(id int64) error { return del(ctx, e, "client_groups", id) })
}
func nonNilIDs(ids []int64) []int64 {
+22
View File
@@ -304,6 +304,28 @@ CREATE TABLE blocklist_status (
// 7: disabled zones, kept but not loaded by named.
`
ALTER TABLE zones ADD COLUMN disabled INTEGER NOT NULL DEFAULT 0;
`,
// 8: blocking steps and client groups (docs/blocking.md).
`
ALTER TABLE blocklists ADD COLUMN step_id INTEGER NOT NULL DEFAULT 0;
CREATE TABLE block_steps (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
position INTEGER NOT NULL DEFAULT 0,
comment TEXT NOT NULL DEFAULT '',
version INTEGER NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
created_by TEXT NOT NULL DEFAULT '', updated_by TEXT NOT NULL DEFAULT ''
);
CREATE TABLE client_groups (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
members TEXT NOT NULL DEFAULT '[]',
step_id INTEGER NOT NULL DEFAULT 0,
view_ids TEXT NOT NULL DEFAULT '[]',
comment TEXT NOT NULL DEFAULT '',
version INTEGER NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
created_by TEXT NOT NULL DEFAULT '', updated_by TEXT NOT NULL DEFAULT ''
);
`,
}
+94
View File
@@ -1,9 +1,11 @@
package validate
import (
"net/netip"
"strings"
"jarl/internal/model"
"jarl/internal/namedconf"
)
// checkBlocking checks blocklists and block/allow rules (docs/blocking.md).
@@ -67,6 +69,7 @@ func (c *checker) checkBlocking() {
seen[key] = r
}
}
c.checkClientGroups(effective)
// Jarl writes the response-policy statement where blocking is used;
// named allows one per view (or for the server).
for vid := range used {
@@ -91,3 +94,94 @@ func (c *checker) blockingScope(viewID int64) string {
}
return ""
}
// checkClientGroups checks blocking steps and client groups: steps exist,
// members are addresses (directly or through ACLs), and no address is in
// two groups of the same view.
func (c *checker) checkClientGroups(effective func(objType string, id int64, label string, ids []int64) []int64) {
for _, b := range c.s.SortedBlocklists() {
if _, ok := c.s.StepRank(b.StepID); !ok || b.StepID == model.StepOff {
c.errf("blocking_step_missing", "blocklist", b.ID, "blocklist %s is at step %d, which doesn't exist", b.Name, b.StepID)
}
}
// The highest step any group reaches, per view: lists above it block
// nothing.
top := map[int64]int{}
seen := map[string]*model.ClientGroup{} // view|prefix -> first group
for _, g := range c.s.SortedClientGroups() {
label := "client group " + g.Name
rank, ok := c.s.StepRank(g.StepID)
if !ok {
c.errf("blocking_step_missing", "clientgroup", g.ID, "%s is at step %d, which doesn't exist", label, g.StepID)
}
if len(g.Members) == 0 {
c.warnf("client_group_empty", "clientgroup", g.ID, "%s has no members", label)
}
for _, m := range g.Members {
c.checkMember(g, m, map[string]bool{})
}
views := effective("clientgroup", g.ID, label, g.ViewIDs)
prefixes := c.s.GroupPrefixes(g)
for _, vid := range views {
if len(prefixes) > 0 {
top[vid] = max(top[vid], rank)
}
for _, p := range prefixes {
key := c.blockingScope(vid) + "|" + p.String()
if first := seen[key]; first != nil && first.ID != g.ID {
c.errf("client_group_overlap", "clientgroup", g.ID, "%s is in client groups %s and %s; a client can only be in one (a more specific prefix in another group is fine)", p, first.Name, g.Name)
continue
}
seen[key] = g
}
}
}
for _, b := range c.s.SortedBlocklists() {
rank, ok := c.s.StepRank(b.StepID)
if !ok || rank == 0 || b.Disabled {
continue
}
reached := false
for _, vid := range c.s.BlockingViews(b.ViewIDs) {
if top[vid] >= rank {
reached = true
}
}
if !reached {
c.warnf("blocklist_step_unused", "blocklist", b.ID, "blocklist %s blocks nothing: it is at step %s, and no client group is at that step or above", b.Name, c.s.StepName(b.StepID))
}
}
}
// checkMember checks one member of a client group: an address, a prefix,
// or an ACL whose elements are (recursively) addresses and prefixes.
func (c *checker) checkMember(g *model.ClientGroup, m string, seen map[string]bool) {
if _, err := netip.ParseAddr(m); err == nil {
return
}
if _, err := netip.ParsePrefix(m); err == nil {
return
}
acl := c.s.ACLByName(m)
if acl == nil {
c.errf("client_group_member_invalid", "clientgroup", g.ID, "client group %s: %q is not an address, a prefix or an ACL", g.Name, m)
return
}
if seen[acl.Name] {
return
}
seen[acl.Name] = true
for _, e := range acl.Elements {
el, err := namedconf.ParseAMElement(e)
if err != nil {
continue // reported for the ACL
}
if el.Negated || el.Kind == namedconf.AMKey || el.Kind == namedconf.AMNested || el.Kind == namedconf.AMName && el.IsBuiltin() {
c.errf("client_group_member_invalid", "clientgroup", g.ID, "client group %s uses ACL %s, whose element %q isn't an address or a prefix: blocking matches clients by address only", g.Name, acl.Name, e)
continue
}
if el.Kind == namedconf.AMName {
c.checkMember(g, el.Name, seen)
}
}
}
+149 -9
View File
@@ -24,8 +24,9 @@ export async function render(el, params, query) {
clearTimeout(refreshTimer);
// Queries blocked in the last day, per list and for the rules together
// (null without query statistics, or without permission to see them).
const [lists, rules, views, status, hitList] = await Promise.all([get("/blocking/lists", draftQuery()), get("/blocking/rules", draftQuery()), get("/views"), get("/blocking/status"),
can("stats") ? get("/stats/breakdown", { group: "blocklist", since: "24h" }).catch(() => null) : null]);
const [lists, rules, views, status, hitList, steps, groups] = await Promise.all([get("/blocking/lists", draftQuery()), get("/blocking/rules", draftQuery()), get("/views"), get("/blocking/status"),
can("stats") ? get("/stats/breakdown", { group: "blocklist", since: "24h" }).catch(() => null) : null,
get("/blocking/steps", draftQuery()), get("/blocking/groups", draftQuery())]);
const hits = hitList ? Object.fromEntries(hitList.map((e) => [e.key, e.count])) : null;
const writable = can("blocking", true);
const recursive = lists.concat(rules).find((x) => x.auto_views);
@@ -58,7 +59,8 @@ export async function render(el, params, query) {
h("label", { class: "muted" }, "Check a name"), checkName, h("button", { type: "submit" }, "Check")),
checkOut),
rulesPanel(rules, views, autoViews, writable, hits),
listsPanel(lists, views, autoViews, writable, hits));
listsPanel(lists, views, autoViews, writable, hits, steps),
devicesPanel(groups, steps, views, autoViews, writable));
if (checkName.value) runCheck();
// Lists are downloaded in the background, and a pause ends by itself:
@@ -114,7 +116,8 @@ function checkResult(name, res, rules, writable) {
m.view ? h("span", { class: "muted" }, "view " + m.view + ": ") : null,
m.result === "blocked" ? badge("blocked", "err") : m.result === "allowed" ? badge("allowed", "ok") : badge("not blocked", ""),
" ",
m.list ? ["by blocklist ", h("strong", null, m.list), " (entry ", h("code", null, m.entry), ")"]
m.list && m.step ? ["for devices at step ", h("strong", null, m.step), " and above, by blocklist ", h("strong", null, m.list), " (entry ", h("code", null, m.entry), "); other devices aren't blocked"]
: m.list ? ["by blocklist ", h("strong", null, m.list), " (entry ", h("code", null, m.entry), ")"]
: m.result !== "none" ? ["by your rule to ", rule ? rule.action : "", " ", h("code", null, rule ? rule.name : m.entry.replace(/^\*\./, "")), rule && !rule.exact ? " and below" : ""] : null);
})),
action);
@@ -176,7 +179,8 @@ async function editDialog(title, path, rows, data) {
], { sticky: true });
}
function editList(l, views, autoViews) {
function editList(l, views, autoViews, steps) {
const step = stepSelect(steps, l.step, false);
const name = input({ value: l.name, class: "mono" });
const url = input({ value: l.url, class: "mono", style: { width: "100%" } });
const comment = input({ value: l.comment || "" });
@@ -184,8 +188,9 @@ function editList(l, views, autoViews) {
editDialog("Edit blocklist " + l.name, "/blocking/lists/" + l.id, [
["Name", name], ["URL", url, "A new URL is downloaded right after saving."],
vp.picker ? ["Views", vp.picker, "None ticked: every view that offers recursion."] : null,
steps.length ? ["Step", step, "Devices at this step or above get the list. normal: every device except those at off."] : null,
["Comment", comment],
], () => ({ name: name.value.trim(), url: url.value.trim(), views: vp.value(), comment: comment.value }));
], () => ({ name: name.value.trim(), url: url.value.trim(), views: vp.value(), comment: comment.value, step: step.value }));
}
function editRule(r, views, autoViews) {
@@ -253,21 +258,33 @@ function rulesPanel(rules, views, autoViews, writable, hits) {
].filter(Boolean), rules, { empty: "No rules yet.", rowClass: (r) => r.pending ? "pending-" + r.pending : "" }));
}
function listsPanel(lists, views, autoViews, writable, hits) {
// stepSelect picks a step: off (only for groups), normal and the steps
// above it.
function stepSelect(steps, value, withOff) {
const opts = [];
if (withOff) opts.push(["off", "off (only your rules)"]);
opts.push(["normal", "normal" + (withOff ? " (like devices in no group)" : " (every device)")]);
steps.filter((s) => s.pending !== "delete").forEach((s) => opts.push([s.name, s.name]));
return select(opts, value || "normal");
}
function listsPanel(lists, views, autoViews, writable, hits, steps) {
const name = input({ placeholder: "hagezi-multi", class: "mono", style: { width: "11em" } });
const url = input({ placeholder: "https://…", class: "mono" });
const vp = viewPicker(views, autoViews);
const listStep = stepSelect(steps, "normal", false);
const err = h("div");
const form = h("form", { class: "inline-form", onsubmit: async (ev) => {
ev.preventDefault();
try {
const r = await change("POST", "/blocking/lists", { name: name.value.trim(), url: url.value.trim(), views: vp.value() });
const r = await change("POST", "/blocking/lists", { name: name.value.trim(), url: url.value.trim(), views: vp.value(), step: listStep.value });
toast(describeResult(r) + (r.applied ? " — jarld downloads it now" : ""));
rerender();
} catch (e) { mount(err, errorBox(e)); }
} },
h("div", { class: "f" }, h("label", null, "Name"), name),
h("div", { class: "f grow" }, h("label", null, "URL"), url),
steps.length ? h("div", { class: "f" }, h("label", null, "Step"), listStep) : null,
vp.el,
h("div", { class: "f" }, h("label", null, " "), h("button", { class: "primary", type: "submit" }, "Add list")));
const used = new Set(lists.map((l) => l.url));
@@ -284,12 +301,13 @@ function listsPanel(lists, views, autoViews, writable, hits) {
{ label: "State", render: (l) => { const [t, k] = stateBadge[l.state] || [l.state, ""]; return [badge(t, k), l.status && l.status.error ? h("div", { class: "small err-text" }, l.status.error) : null]; } },
{ label: "Domains", render: (l) => l.status && l.status.updated_at ? l.status.entries.toLocaleString() : "–", tdClass: "right" },
hits ? { label: "Blocked (24h)", render: (l) => hitsLink(hits[l.name]), class: "right", tdClass: "right" } : null,
steps.length ? { label: "Step", render: (l) => l.step === "normal" ? h("span", { class: "muted" }, "normal") : badge(l.step, "accent") } : null,
views.length ? { label: "Views", render: viewsCell } : null,
{ label: "Updated", render: (l) => l.status && l.status.updated_at ? h("span", { title: "checked " + time(l.status.checked_at) }, relTime(l.status.updated_at)) : "–", tdClass: "small" },
{ label: "Memory", render: (l) => l.memory_mb ? h("span", { title: "named's memory for this list, estimated (all its views)" }, "~" + l.memory_mb + " MB") : "–", tdClass: "small right" },
{ label: "URL", render: (l) => h("a", { href: l.url, target: "_blank", rel: "noopener", class: "small mono", title: l.url }, shortURL(l.url)) },
writable ? { label: "", render: (l) => l.pending === "delete" ? "" : h("span", null,
h("button", { class: "small", onclick: () => editList(l, views, autoViews) }, "Edit"), " ",
h("button", { class: "small", onclick: () => editList(l, views, autoViews, steps) }, "Edit"), " ",
h("button", { class: "small", disabled: l.state === "disabled" || !!l.pending, title: "Download it now (it is checked daily anyway)", onclick: () => refresh(l) }, "Refresh"), " ",
h("button", { class: "small", onclick: () => toggle(l) }, l.disabled ? "Enable" : "Disable"), " ",
h("button", { class: "small danger", onclick: () => del("/blocking/lists/" + l.id, "blocklist " + l.name) }, "Delete")), tdClass: "actions-cell" } : null,
@@ -329,3 +347,125 @@ async function del(path, what) {
} catch (e) { toast(e.message, "err", 8000); }
}
// ---- devices: blocking steps and client groups (docs/blocking.md)
// devicesPanel shows the steps (each includes the lists of the steps
// below it) and the client groups that put devices at a step.
function devicesPanel(groups, steps, views, autoViews, writable) {
const live = steps.filter((s) => s.pending !== "delete");
const name = input({ placeholder: "kids", class: "mono", style: { width: "9em" } });
const members = input({ placeholder: "10.10.0.50 10.10.0.51 or an ACL", class: "mono" });
const step = stepSelect(steps, live.length ? live[0].name : "off", true);
const vp = viewPicker(views, autoViews);
const err = h("div");
const form = h("form", { class: "inline-form", onsubmit: async (ev) => {
ev.preventDefault();
try {
const r = await change("POST", "/blocking/groups", { name: name.value.trim(), members: splitMembers(members.value), step: step.value, views: vp.value() });
toast(describeResult(r));
rerender();
} catch (e) { mount(err, errorBox(e)); }
} },
h("div", { class: "f" }, h("label", null, "Group"), name),
h("div", { class: "f grow" }, h("label", null, "Devices"), members),
h("div", { class: "f" }, h("label", null, "Step"), step),
vp.el,
h("div", { class: "f" }, h("label", null, " "), h("button", { class: "primary", type: "submit" }, "Add group")));
return h("div", { class: "panel" },
h("h2", null, "Devices"),
h("p", { class: "muted small" }, "Give some devices less or more blocking. Each step gets the lists of the steps below it plus its own; devices in no group are at normal. Your rules apply at every step. named must see the devices' own addresses (not a router's), so give them fixed ones."),
stepsLine(steps, writable),
writable ? [form, err] : null,
table([
{ label: "Group", render: (g) => [h("strong", { class: "mono" }, g.name), g.pending ? [" ", pendingBadge(g.pending)] : null] },
{ label: "Step", render: (g) => g.step === "off" ? badge("off", "") : g.step === "normal" ? h("span", { class: "muted" }, "normal") : badge(g.step, "accent") },
{ label: "Devices", render: (g) => g.members.length ? h("span", { class: "mono small" }, g.members.join(", ")) : h("span", { class: "muted" }, "none") },
views.length ? { label: "Views", render: viewsCell } : null,
{ label: "Comment", render: (g) => h("span", { class: "muted" }, g.comment || "") },
writable ? { label: "", render: (g) => g.pending === "delete" ? "" : h("span", null,
h("button", { class: "small", onclick: () => editGroup(g, steps, views, autoViews) }, "Edit"), " ",
h("button", { class: "small danger", onclick: () => del("/blocking/groups/" + g.id, "client group " + g.name) }, "Delete")), tdClass: "actions-cell" } : null,
].filter(Boolean), groups, { empty: "No groups: every device is at normal.", rowClass: (g) => g.pending ? "pending-" + g.pending : "" }));
}
// splitMembers turns "a, b c" into a list.
function splitMembers(s) {
return s.split(/[\s,;]+/).map((x) => x.trim()).filter(Boolean);
}
// stepsLine shows the steps in order, with controls for the ones above
// normal.
function stepsLine(steps, writable) {
const live = steps.filter((s) => s.pending !== "delete");
const order = async (ids) => {
try {
const r = await change("POST", "/blocking/steps/order", { steps: ids.map(String) });
toast(describeResult(r));
rerender();
} catch (e) { toast(e.message, "err", 8000); }
};
const move = (i, d) => {
const ids = live.map((s) => s.id);
[ids[i], ids[i + d]] = [ids[i + d], ids[i]];
order(ids);
};
const add = async () => {
const n = await promptName("Add a step", "Name of the new step (above the others), e.g. strict", "");
if (!n) return;
try {
const r = await change("POST", "/blocking/steps", { name: n });
toast(describeResult(r));
rerender();
} catch (e) { toast(e.message, "err", 8000); }
};
const rename = async (s) => {
const n = await promptName("Rename step " + s.name, "New name", s.name);
if (!n || n === s.name) return;
try {
const r = await change("PATCH", "/blocking/steps/" + s.id, { name: n });
toast(describeResult(r));
rerender();
} catch (e) { toast(e.message, "err", 8000); }
};
const chip = (label, title) => h("span", { class: "step-chip", title }, label);
const items = [chip("off", "only your rules"), chip("normal", "your rules and the normal lists; devices in no group")];
live.forEach((s, i) => {
const used = s.lists.length || s.groups.length;
items.push(h("span", { class: "step-chip accent", title: "lists: " + (s.lists.join(", ") || "none") + "; groups: " + (s.groups.join(", ") || "none") },
s.name, s.pending ? [" ", pendingBadge(s.pending)] : null,
writable ? h("span", { class: "step-tools" },
h("button", { class: "small", disabled: i === 0, title: "Move down: fewer lists", onclick: () => move(i, -1) }, "←"),
h("button", { class: "small", disabled: i === live.length - 1, title: "Move up: more lists", onclick: () => move(i, 1) }, "→"),
h("button", { class: "small", title: "Rename", onclick: () => rename(s) }, "✎"),
h("button", { class: "small danger", disabled: !!used, title: used ? "In use by lists or groups" : "Delete", onclick: () => del("/blocking/steps/" + s.id, "step " + s.name) }, "×")) : null));
});
return h("div", { class: "steps-line" }, h("span", { class: "muted small" }, "Steps: "),
items.map((x, i) => [i ? h("span", { class: "muted" }, " → ") : null, x]),
writable ? [" ", h("button", { class: "small", onclick: add }, "Add step")] : null);
}
// promptName asks for a short name in a dialog.
async function promptName(title, label, value) {
const i = input({ value, class: "mono" });
const v = await modal(title, h("div", { class: "form-grid" }, field(label, i)), [
{ label: "Cancel", value: null },
{ label: "OK", kind: "primary", value: true, onclick: () => !!i.value.trim() },
]);
return v === true ? i.value.trim() : null;
}
function editGroup(g, steps, views, autoViews) {
const name = input({ value: g.name, class: "mono" });
const members = input({ value: g.members.join(" "), class: "mono", style: { width: "100%" } });
const step = stepSelect(steps, g.step, true);
const comment = input({ value: g.comment || "" });
const vp = viewPicker(views, autoViews, ownViews(g));
editDialog("Edit client group " + g.name, "/blocking/groups/" + g.id, [
["Name", name],
["Devices", members, "Addresses, networks (10.10.0.0/24) or ACLs, separated by spaces. A device can be in one group; a more specific address in another group wins."],
["Step", step],
vp.picker ? ["Views", vp.picker, "None ticked: every view with blocking."] : null,
["Comment", comment],
], () => ({ name: name.value.trim(), members: splitMembers(members.value), step: step.value, views: vp.value(), comment: comment.value }));
}
+6
View File
@@ -326,6 +326,12 @@ td.break { word-break: break-all; }
tr .reveal { opacity: 0; transition: opacity 0.1s; }
tr:hover .reveal, tr:focus-within .reveal { opacity: 1; }
@media (hover: none) { tr .reveal { opacity: 1; } }
/* Blocking steps, in order, on the Blocking page. */
.steps-line { display: flex; flex-wrap: wrap; align-items: center; gap: 4px; margin: 4px 0 10px; }
.step-chip { display: inline-flex; align-items: center; gap: 4px; padding: 2px 8px; border: 1px solid var(--border); border-radius: 12px; font-family: var(--mono); font-size: 13px; background: var(--panel); }
.step-chip.accent { border-color: var(--accent); }
.step-tools { display: inline-flex; gap: 2px; margin-left: 2px; }
.step-tools button { padding: 0 5px; min-width: 0; }
h2.section { margin: 24px 0 10px; font-size: 18px; }
.filter-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 12px; }
.segmented { display: inline-flex; border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; background: var(--panel); }
+37 -1
View File
@@ -486,7 +486,7 @@ expect_rpz "an Adblock entry blocks the names below it" x.tracker.adbraun.test t
expect_rpz "a hosts entry blocks the name" ads.adbraun.test test.blocklist.jarl.invalid.
expect_rpz "but not the names below it" sub.ads.adbraun.test none
run "blocklist list" 0 $J blocklist list
contains "^test *ok *2 "
contains "^test *ok *normal *2 "
run "block allow" 0 $J block allow ok.tracker.adbraun.test
expect_rpz "an allow rule beats the list" deep.ok.tracker.adbraun.test none
expect_rpz "the rest of the list still blocks" x.tracker.adbraun.test test.blocklist.jarl.invalid.
@@ -516,6 +516,42 @@ printf '<html>gone</html>\n' > $E/lists/list.txt
run "a broken download fails (exit 2)" 2 $J blocklist refresh test --force
contains "no domains found"
expect_rpz "and the previous version stays" x.new.adbraun.test test.blocklist.jarl.invalid.
# Client groups and steps: per-device blocking in one view, by client
# address. 127.0.0.2 and .3 are in homelab too, so they get view internal.
rpz_from() { # SOURCE NAME
$DIG +tries=1 +time=3 -b "$1" -p $DNS @127.0.0.1 "$2" A | awk '$4 == "SOA" && $1 ~ /jarl\.invalid\.$/ {print $1; f = 1} END {if (!f) print "none"}'
}
expect_rpz_from() { # DESC SOURCE NAME WANT
local got
for i in $(seq 30); do got=$(rpz_from "$2" "$3"); [ "$got" = "$4" ] && break; sleep 0.2; done
if [ "$got" = "$4" ]; then ok "$1: $3 from $2 -> $4"; else bad "$1: $3 from $2 -> $got, want $4"; fi
}
printf '||social.adbraun.test^\n' > $E/lists/strict.txt
run "block step add" 0 $J block step add strict
run "blocklist add at a step" 0 $J blocklist add social http://127.0.0.1:$LISTS/strict.txt --step strict
contains "applied"
run "a step no group reaches: warned" 0 $J status
contains "blocks nothing"
run "block group add (off)" 0 $J block group add work 127.0.0.2 --step off
run "block group add (strict, with an IPv6 network)" 0 $J block group add kids 127.0.0.3 2001:db8:77::/64 --step strict
run "block group list" 0 $J block group list
contains "kids *strict *127.0.0.3, 2001:db8:77::/64"
expect_rpz_from "normal: the normal list" 127.0.0.1 x.new.adbraun.test test.blocklist.jarl.invalid.
expect_rpz_from "normal: not the strict list" 127.0.0.1 x.social.adbraun.test none
expect_rpz_from "off: no lists" 127.0.0.2 x.new.adbraun.test none
expect_rpz_from "off: but your rules" 127.0.0.2 blocked.adbraun.test rules.jarl.invalid.
expect_rpz_from "strict: the strict list" 127.0.0.3 x.social.adbraun.test social.blocklist.jarl.invalid.
expect_rpz_from "strict: and the normal list" 127.0.0.3 x.new.adbraun.test test.blocklist.jarl.invalid.
run "block check names the step" 0 $J block check x.social.adbraun.test
contains "for clients at step strict and above"
run "a step in use can't be deleted (exit 5)" 5 $J block step delete strict
run "move the work laptop to normal" 0 $J block group update work --step normal
expect_rpz_from "now normal" 127.0.0.2 x.new.adbraun.test test.blocklist.jarl.invalid.
run "block group delete" 0 $J block group delete work
run "block group delete kids" 0 $J block group delete kids
run "blocklist delete social" 0 $J blocklist delete social --yes
run "block step delete" 0 $J block step delete strict
run "no gates left" 1 grep -q 'gate.jarl.invalid' $E/etc-jarl/named.conf
run "blocklist disable" 0 $J blocklist disable test
expect_rpz "a disabled list doesn't block" x.new.adbraun.test none
run "blocklist delete" 0 $J blocklist delete test --yes