From 76aaaf3ef554e7e264a0411205795d7f91abda43 Mon Sep 17 00:00:00 2001 From: adam Date: Sun, 11 Oct 2026 21:33:16 +0100 Subject: [PATCH] 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 --- CHANGELOG.md | 16 +- cmd/jarl/blocking.go | 18 +- cmd/jarl/clientgroups.go | 271 +++++++++++++++++++ docs/api.md | 9 +- docs/blocking.md | 60 ++++ docs/cli.md | 6 + docs/validation.md | 5 + internal/api/types.go | 21 ++ internal/blocking/clients.go | 180 ++++++++++++ internal/blocking/clients_test.go | 96 +++++++ internal/blocking/zone.go | 3 + internal/engine/blocklists.go | 14 +- internal/engine/blockops.go | 15 + internal/engine/clientgroups.go | 263 ++++++++++++++++++ internal/engine/clientgroups_test.go | 123 +++++++++ internal/engine/diffops.go | 31 ++- internal/engine/engine.go | 8 +- internal/engine/expand.go | 24 ++ internal/engine/inputs.go | 35 ++- internal/engine/ops.go | 8 + internal/engine/refs.go | 11 + internal/model/blocking.go | 145 +++++++++- internal/model/model.go | 41 ++- internal/render/blocking.go | 86 +++++- internal/server/h_blocking.go | 4 +- internal/server/h_clientgroups.go | 265 ++++++++++++++++++ internal/server/routes.go | 9 + internal/store/state.go | 66 ++++- internal/store/store.go | 22 ++ internal/validate/blocking.go | 94 +++++++ internal/web/static/assets/pages/blocking.js | 158 ++++++++++- internal/web/static/assets/style.css | 6 + scripts/e2e.sh | 38 ++- 33 files changed, 2096 insertions(+), 55 deletions(-) create mode 100644 cmd/jarl/clientgroups.go create mode 100644 internal/blocking/clients.go create mode 100644 internal/blocking/clients_test.go create mode 100644 internal/engine/clientgroups.go create mode 100644 internal/engine/clientgroups_test.go create mode 100644 internal/server/h_clientgroups.go diff --git a/CHANGELOG.md b/CHANGELOG.md index a118d4d..eb85ac4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/cmd/jarl/blocking.go b/cmd/jarl/blocking.go index 0aa8e23..bece893 100644 --- a/cmd/jarl/blocking.go +++ b/cmd/jarl/blocking.go @@ -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 } diff --git a/cmd/jarl/clientgroups.go b/cmd/jarl/clientgroups.go new file mode 100644 index 0000000..8b1b022 --- /dev/null +++ b/cmd/jarl/clientgroups.go @@ -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 +} diff --git a/docs/api.md b/docs/api.md index 18ef2b6..c0bb6fb 100644 --- a/docs/api.md +++ b/docs/api.md @@ -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 | diff --git a/docs/blocking.md b/docs/blocking.md index 73ccd95..3a2560e 100644 --- a/docs/blocking.md +++ b/docs/blocking.md @@ -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)). diff --git a/docs/cli.md b/docs/cli.md index 9ed9896..a053489 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -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 ``` diff --git a/docs/validation.md b/docs/validation.md index 30e676a..5c64e76 100644 --- a/docs/validation.md +++ b/docs/validation.md @@ -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 diff --git a/internal/api/types.go b/internal/api/types.go index e408327..da245d0 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -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. diff --git a/internal/blocking/clients.go b/internal/blocking/clients.go new file mode 100644 index 0000000..236a143 --- /dev/null +++ b/internal/blocking/clients.go @@ -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() +} diff --git a/internal/blocking/clients_test.go b/internal/blocking/clients_test.go new file mode 100644 index 0000000..b390411 --- /dev/null +++ b/internal/blocking/clients_test.go @@ -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) + } +} diff --git a/internal/blocking/zone.go b/internal/blocking/zone.go index 5123d88..c5fc24b 100644 --- a/internal/blocking/zone.go +++ b/internal/blocking/zone.go @@ -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. diff --git a/internal/engine/blocklists.go b/internal/engine/blocklists.go index 8bc7193..93d9767 100644 --- a/internal/engine/blocklists.go +++ b/internal/engine/blocklists.go @@ -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 diff --git a/internal/engine/blockops.go b/internal/engine/blockops.go index 7035e35..993b91a 100644 --- a/internal/engine/blockops.go +++ b/internal/engine/blockops.go @@ -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 } diff --git a/internal/engine/clientgroups.go b/internal/engine/clientgroups.go new file mode 100644 index 0000000..b8668a4 --- /dev/null +++ b/internal/engine/clientgroups.go @@ -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 + }) +} diff --git a/internal/engine/clientgroups_test.go b/internal/engine/clientgroups_test.go new file mode 100644 index 0000000..a788b27 --- /dev/null +++ b/internal/engine/clientgroups_test.go @@ -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) + } +} diff --git a/internal/engine/diffops.go b/internal/engine/diffops.go index 534625c..1366e36 100644 --- a/internal/engine/diffops.go +++ b/internal/engine/diffops.go @@ -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] diff --git a/internal/engine/engine.go b/internal/engine/engine.go index 93dbf37..1a20767 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -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) } } diff --git a/internal/engine/expand.go b/internal/engine/expand.go index 973773c..8ca239c 100644 --- a/internal/engine/expand.go +++ b/internal/engine/expand.go @@ -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) } diff --git a/internal/engine/inputs.go b/internal/engine/inputs.go index a25426e..1aac1d6 100644 --- a/internal/engine/inputs.go +++ b/internal/engine/inputs.go @@ -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 diff --git a/internal/engine/ops.go b/internal/engine/ops.go index f22be04..f4fe72c 100644 --- a/internal/engine/ops.go +++ b/internal/engine/ops.go @@ -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: diff --git a/internal/engine/refs.go b/internal/engine/refs.go index a610a62..d164dff 100644 --- a/internal/engine/refs.go +++ b/internal/engine/refs.go @@ -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 { diff --git a/internal/model/blocking.go b/internal/model/blocking.go index d16a15c..f9e865d 100644 --- a/internal/model/blocking.go +++ b/internal/model/blocking.go @@ -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"` - Comment string `json:"comment"` + // 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 +} diff --git a/internal/model/model.go b/internal/model/model.go index 9cba0af..0a247da 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -9,6 +9,7 @@ package model import ( "encoding/json" + "slices" "sort" "strings" "time" @@ -247,8 +248,10 @@ type State struct { Records map[int64]*Record Options map[int64]*Option // Blocking (docs/blocking.md). - Blocklists map[int64]*Blocklist - BlockRules map[int64]*BlockRule + Blocklists map[int64]*Blocklist + BlockRules map[int64]*BlockRule + BlockSteps map[int64]*BlockStep + ClientGroups map[int64]*ClientGroup } // NewState returns an empty state with default settings. @@ -262,8 +265,10 @@ func NewState() *State { Records: map[int64]*Record{}, Options: map[int64]*Option{}, - Blocklists: map[int64]*Blocklist{}, - BlockRules: map[int64]*BlockRule{}, + Blocklists: map[int64]*Blocklist{}, + BlockRules: map[int64]*BlockRule{}, + BlockSteps: map[int64]*BlockStep{}, + ClientGroups: map[int64]*ClientGroup{}, } } @@ -278,14 +283,16 @@ func (s *State) Clone() *State { Records: make(map[int64]*Record, len(s.Records)), Options: make(map[int64]*Option, len(s.Options)), - Blocklists: make(map[int64]*Blocklist, len(s.Blocklists)), - BlockRules: make(map[int64]*BlockRule, len(s.BlockRules)), + 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 } diff --git a/internal/render/blocking.go b/internal/render/blocking.go index bf44eaa..c48f753 100644 --- a/internal/render/blocking.go +++ b/internal/render/blocking.go @@ -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,10 +75,26 @@ func PolicyZones(s *model.State, p Paths) []PolicyZone { break } } - 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}) + assigns := ClientAssignments(s, vid) + for k, step := range append([]int64{model.StepNormal}, stepIDs(s)...) { + var tier []PolicyZone + for _, b := range lists { + if !b.Disabled && b.StepID == step && slices.Contains(s.BlockingViews(b.ViewIDs), vid) { + tier = append(tier, PolicyZone{Name: blocking.ListZone(b.Name), ViewID: vid, View: view, File: p.BlocklistFile(b.ID), ListID: b.ID}) + } } + if len(tier) == 0 { + continue + } + // The clients below this step pass, and never see its lists. + if pass := blocking.PassBelow(assigns, k); len(pass) > 0 { + name := "off" + if k > 0 { + name = s.StepName(step) + } + zones = append(zones, PolicyZone{Name: blocking.GateZone(name), ViewID: vid, View: view, File: p.gateFile(view, name), Gate: pass, GateStep: s.StepName(step)}) + } + zones = append(zones, tier...) } // While paused, every view with blocking gets a rules zone: it // lets everything through. @@ -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 diff --git a/internal/server/h_blocking.go b/internal/server/h_blocking.go index b208f95..d500656 100644 --- a/internal/server/h_blocking.go +++ b/internal/server/h_blocking.go @@ -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" diff --git a/internal/server/h_clientgroups.go b/internal/server/h_clientgroups.go new file mode 100644 index 0000000..42875a1 --- /dev/null +++ b/internal/server/h_clientgroups.go @@ -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 +} diff --git a/internal/server/routes.go b/internal/server/routes.go index b1c7e49..c2b5c76 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -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))) diff --git a/internal/store/state.go b/internal/store/state.go index 7649b3e..70b4938 100644 --- a/internal/store/state.go +++ b/internal/store/state.go @@ -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 { diff --git a/internal/store/store.go b/internal/store/store.go index f4d4dee..e617a07 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -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 '' +); `, } diff --git a/internal/validate/blocking.go b/internal/validate/blocking.go index ff950d1..f285926 100644 --- a/internal/validate/blocking.go +++ b/internal/validate/blocking.go @@ -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) + } + } +} diff --git a/internal/web/static/assets/pages/blocking.js b/internal/web/static/assets/pages/blocking.js index 05f8255..4aa141c 100644 --- a/internal/web/static/assets/pages/blocking.js +++ b/internal/web/static/assets/pages/blocking.js @@ -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 })); +} diff --git a/internal/web/static/assets/style.css b/internal/web/static/assets/style.css index 9647856..1fbe1d2 100644 --- a/internal/web/static/assets/style.css +++ b/internal/web/static/assets/style.css @@ -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); } diff --git a/scripts/e2e.sh b/scripts/e2e.sh index 47806fc..7642bfb 100755 --- a/scripts/e2e.sh +++ b/scripts/e2e.sh @@ -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 'gone\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