Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11ed877c3c | ||
|
|
76aaaf3ef5 | ||
|
|
5b8f681e2a | ||
|
|
8f0a0cc233 |
No files matched your search
+36
-1
@@ -6,8 +6,36 @@ All notable changes to Jarl are documented here. The format follows
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Upgrading from 0.3
|
||||||
|
|
||||||
|
- Back up first (`sudo jarld backup`).
|
||||||
|
- When jarld first starts, it rewrites named's configuration: options
|
||||||
|
move into their new fixed order, and every view gets its view key in
|
||||||
|
`match-clients` and `keys.conf`. It is checked with named-checkconf
|
||||||
|
before named reloads, like any apply. Files you changed by hand are left
|
||||||
|
alone; the log then says to run `jarl resync`.
|
||||||
|
- 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 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
|
||||||
|
days that include them are `'h'` and `'d'`).
|
||||||
|
|
||||||
### Added
|
### 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
|
- 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
|
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
|
address. Jarl signs its delegation check's queries to this server's own
|
||||||
@@ -75,7 +103,11 @@ All notable changes to Jarl are documented here. The format follows
|
|||||||
reorder them; view links everywhere open the view's page.
|
reorder them; view links everywhere open the view's page.
|
||||||
- Web UI: A and AAAA records have a **+ CNAME** button that fills in the
|
- Web UI: A and AAAA records have a **+ CNAME** button that fills in the
|
||||||
add form with a CNAME pointing to the record's name, leaving only the
|
add form with a CNAME pointing to the record's name, leaving only the
|
||||||
alias's name to type.
|
alias's name to type. It shows when the row is hovered or focused
|
||||||
|
(always on touch screens).
|
||||||
|
- Blocking page: how many queries each blocklist blocked in the last 24
|
||||||
|
hours, and your rules together, with statistics on (linking to the
|
||||||
|
Statistics page).
|
||||||
- Options are kept in one fixed order, in named.conf, the web UI and the
|
- Options are kept in one fixed order, in named.conf, the web UI and the
|
||||||
CLI: grouped by topic (files, listening, access control, recursion and
|
CLI: grouped by topic (files, listening, access control, recursion and
|
||||||
forwarding, DNSSEC, transfers and notify, then the rest), alphabetical
|
forwarding, DNSSEC, transfers and notify, then the rest), alphabetical
|
||||||
@@ -119,6 +151,9 @@ All notable changes to Jarl are documented here. The format follows
|
|||||||
|
|
||||||
### Fixed
|
### 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
|
- Under the shipped systemd unit, jarld couldn't list the host's
|
||||||
addresses (it needs a netlink socket, which `RestrictAddressFamilies`
|
addresses (it needs a netlink socket, which `RestrictAddressFamilies`
|
||||||
didn't allow), so the name server checks didn't recognize this server's
|
didn't allow), so the name server checks didn't recognize this server's
|
||||||
|
|||||||
@@ -172,8 +172,9 @@ review step.
|
|||||||
## Status
|
## Status
|
||||||
|
|
||||||
Version 0.3.0 is the current release, and 0.4.0 is in testing
|
Version 0.3.0 is the current release, and 0.4.0 is in testing
|
||||||
(0.4.0-rc.3): it adds blocking (blocklists and your own rules, like
|
(0.4.0-rc.5): it adds blocking (blocklists and your own rules, like
|
||||||
Pi-hole, done by named), disabling zones, server statistics and view keys. 0.3.0 added disabled records, script-friendly
|
Pi-hole, done by named, with different blocking for different devices),
|
||||||
|
disabling zones, server statistics and view keys. 0.3.0 added disabled records, script-friendly
|
||||||
CLI output and client names in the statistics, on top of 0.2.0's DNSSEC
|
CLI output and client names in the statistics, on top of 0.2.0's DNSSEC
|
||||||
signing, Porkbun support, two-factor login and view colours. Jarl is feature-complete for one server's configuration.
|
signing, Porkbun support, two-factor login and view colours. Jarl is feature-complete for one server's configuration.
|
||||||
Managing several servers from one jarld and dynamic updates are not
|
Managing several servers from one jarld and dynamic updates are not
|
||||||
|
|||||||
+14
-4
@@ -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
|
Hosts, plain domain and Adblock (||domain^) lists work. Your own block and
|
||||||
allow rules ('jarl block') take precedence over every list.`}
|
allow rules ('jarl block') take precedence over every list.`}
|
||||||
var views []string
|
var views []string
|
||||||
var comment string
|
var comment, step string
|
||||||
|
|
||||||
list := &cobra.Command{
|
list := &cobra.Command{
|
||||||
Use: "list",
|
Use: "list",
|
||||||
@@ -59,7 +59,7 @@ allow rules ('jarl block') take precedence over every list.`}
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return output(lists, func(w io.Writer) {
|
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 {
|
for _, b := range lists {
|
||||||
domains, updated, mem := "-", "-", "-"
|
domains, updated, mem := "-", "-", "-"
|
||||||
if s := b.Status; s != nil && s.UpdatedAt != nil {
|
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 {
|
if b.MemoryMB > 0 {
|
||||||
mem = fmt.Sprintf("~%d MB", b.MemoryMB)
|
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 {
|
for _, b := range lists {
|
||||||
if b.Status != nil && b.Status.Error != "" {
|
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]}
|
body := map[string]any{"name": args[0], "url": args[1]}
|
||||||
viewsBody(body, views)
|
viewsBody(body, views)
|
||||||
|
if step != "" {
|
||||||
|
body["step"] = step
|
||||||
|
}
|
||||||
if comment != "" {
|
if comment != "" {
|
||||||
body["comment"] = 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().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(&comment, "comment", "", "comment")
|
||||||
|
add.Flags().StringVar(&step, "step", "", "the blocking step it starts at (default: normal; see 'jarl block step')")
|
||||||
|
|
||||||
var rename, newURL string
|
var rename, newURL string
|
||||||
update := &cobra.Command{
|
update := &cobra.Command{
|
||||||
@@ -136,6 +140,9 @@ allow rules ('jarl block') take precedence over every list.`}
|
|||||||
if f.Changed("comment") {
|
if f.Changed("comment") {
|
||||||
body["comment"] = comment
|
body["comment"] = comment
|
||||||
}
|
}
|
||||||
|
if f.Changed("step") {
|
||||||
|
body["step"] = step
|
||||||
|
}
|
||||||
viewsBody(body, views)
|
viewsBody(body, views)
|
||||||
if len(body) == 0 {
|
if len(body) == 0 {
|
||||||
return fmt.Errorf("nothing to change (see --help)")
|
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().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().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(&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 {
|
toggle := func(disable bool) *cobra.Command {
|
||||||
verb := map[bool]string{true: "disable", false: "enable"}[disable]
|
verb := map[bool]string{true: "disable", false: "enable"}[disable]
|
||||||
@@ -394,6 +402,8 @@ names below it (docs/blocking.md).`}
|
|||||||
switch {
|
switch {
|
||||||
case m.Result == "none":
|
case m.Result == "none":
|
||||||
fmt.Fprintf(w, "%snot blocked\n", where)
|
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 != "":
|
case m.List != "":
|
||||||
fmt.Fprintf(w, "%sblocked by blocklist %s (entry %s)\n", where, m.List, m.Entry)
|
fmt.Fprintf(w, "%sblocked by blocklist %s (entry %s)\n", where, m.List, m.Entry)
|
||||||
default:
|
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
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,271 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"jarl/internal/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Blocking steps and client groups (docs/blocking.md).
|
||||||
|
|
||||||
|
func blockStepCmd() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{Use: "step", Aliases: []string{"steps"}, Short: "Blocking steps: which lists apply to which client groups",
|
||||||
|
Long: `Steps decide which blocklists a client group gets. Each step includes the
|
||||||
|
lists of the steps below it, plus its own:
|
||||||
|
|
||||||
|
off only your rules (built in)
|
||||||
|
normal your rules + the normal lists; clients not in a group (built in)
|
||||||
|
strict normal + the lists at step strict (a step you add)
|
||||||
|
...
|
||||||
|
|
||||||
|
Lists start at a step ('jarl blocklist update LIST --step strict'), client
|
||||||
|
groups are at one ('jarl block group add kids 10.10.0.50 --step strict').`}
|
||||||
|
list := &cobra.Command{
|
||||||
|
Use: "list",
|
||||||
|
Short: "List the steps, lowest first",
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newClient()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var steps []api.BlockStep
|
||||||
|
if err := c.Get(ctx(), "/blocking/steps", nil, &steps); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return output(steps, func(w io.Writer) {
|
||||||
|
fmt.Fprintln(w, "STEP\tLISTS\tGROUPS\tCOMMENT")
|
||||||
|
fmt.Fprintln(w, "off\t-\t(built in)\tonly your rules")
|
||||||
|
fmt.Fprintln(w, "normal\t-\t(built in)\tthe default")
|
||||||
|
for _, s := range steps {
|
||||||
|
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", s.Name, orDash(strings.Join(s.Lists, ", ")), orDash(strings.Join(s.Groups, ", ")), s.Comment)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
var comment string
|
||||||
|
add := &cobra.Command{
|
||||||
|
Use: "add NAME",
|
||||||
|
Short: "Add a step above the existing ones",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newClient()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
resp, err := c.Mutate(ctx(), "POST", "/blocking/steps", nil, map[string]any{"name": args[0], "comment": comment})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return report(resp)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
add.Flags().StringVar(&comment, "comment", "", "comment")
|
||||||
|
rename := &cobra.Command{
|
||||||
|
Use: "rename STEP NEW-NAME",
|
||||||
|
Short: "Rename a step",
|
||||||
|
Args: cobra.ExactArgs(2),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newClient()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
resp, err := c.Mutate(ctx(), "PATCH", "/blocking/steps/"+url.PathEscape(args[0]), nil, map[string]any{"name": args[1]})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return report(resp)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
order := &cobra.Command{
|
||||||
|
Use: "order STEP...",
|
||||||
|
Short: "Set the order of the steps above normal, lowest first",
|
||||||
|
Example: " jarl block step order strict very-strict",
|
||||||
|
Args: cobra.MinimumNArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newClient()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
resp, err := c.Mutate(ctx(), "POST", "/blocking/steps/order", nil, map[string]any{"steps": args})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return report(resp)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
del := &cobra.Command{
|
||||||
|
Use: "delete STEP",
|
||||||
|
Short: "Delete a step (refused while lists or groups use it)",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newClient()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
resp, err := c.Mutate(ctx(), "DELETE", "/blocking/steps/"+url.PathEscape(args[0]), nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return report(resp)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.AddCommand(list, add, rename, order, del)
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func blockGroupCmd() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{Use: "group", Aliases: []string{"groups"}, Short: "Client groups: blocking steps for some devices",
|
||||||
|
Long: `A client group puts devices at a blocking step: off (only your rules),
|
||||||
|
normal, or a step you added ('jarl block step'). Members are addresses,
|
||||||
|
prefixes, or ACLs made of them. Devices not in a group are at normal.
|
||||||
|
named must see the devices' own addresses (not a router forwarding for
|
||||||
|
them), and they need fixed addresses (DHCP reservations).`}
|
||||||
|
list := &cobra.Command{
|
||||||
|
Use: "list",
|
||||||
|
Short: "List client groups",
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newClient()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var groups []api.ClientGroup
|
||||||
|
if err := c.Get(ctx(), "/blocking/groups", nil, &groups); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return output(groups, func(w io.Writer) {
|
||||||
|
fmt.Fprintln(w, "GROUP\tSTEP\tMEMBERS\tVIEWS\tCOMMENT")
|
||||||
|
for _, g := range groups {
|
||||||
|
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", g.Name, g.Step, orDash(strings.Join(g.Members, ", ")), viewsText(g.Views, g.AutoViews), g.Comment)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
var step, comment string
|
||||||
|
var views []string
|
||||||
|
add := &cobra.Command{
|
||||||
|
Use: "add NAME MEMBER...",
|
||||||
|
Short: "Add a client group",
|
||||||
|
Example: ` jarl block group add work 10.10.0.20 --step off
|
||||||
|
jarl block group add kids 10.10.0.50 10.10.0.51 2001:db8:1::/64 --step strict
|
||||||
|
jarl block group add guests guest-net --step normal # an ACL`,
|
||||||
|
Args: cobra.MinimumNArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newClient()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
body := map[string]any{"name": args[0], "members": args[1:], "comment": comment}
|
||||||
|
if step != "" {
|
||||||
|
body["step"] = step
|
||||||
|
}
|
||||||
|
viewsBody(body, views)
|
||||||
|
resp, err := c.Mutate(ctx(), "POST", "/blocking/groups", nil, body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return report(resp)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
add.Flags().StringVar(&step, "step", "", "the group's step: off, normal (default) or one you added")
|
||||||
|
add.Flags().StringArrayVar(&views, "view", nil, "a view it applies in (repeat for several; default: every view with blocking)")
|
||||||
|
add.Flags().StringVar(&comment, "comment", "", "comment")
|
||||||
|
var addM, removeM []string
|
||||||
|
var rename string
|
||||||
|
update := &cobra.Command{
|
||||||
|
Use: "update GROUP",
|
||||||
|
Short: "Change a group's members, step, name, views or comment",
|
||||||
|
Example: ` jarl block group update kids --add 10.10.0.52 --remove 10.10.0.50
|
||||||
|
jarl block group update kids --step very-strict`,
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newClient()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
body := map[string]any{}
|
||||||
|
f := cmd.Flags()
|
||||||
|
if len(addM) > 0 || len(removeM) > 0 {
|
||||||
|
var g api.ClientGroup
|
||||||
|
var all []api.ClientGroup
|
||||||
|
if err := c.Get(ctx(), "/blocking/groups", nil, &all); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, x := range all {
|
||||||
|
if x.Name == strings.ToLower(args[0]) {
|
||||||
|
g = x
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if g.ID == 0 {
|
||||||
|
return fmt.Errorf("client group %s not found", args[0])
|
||||||
|
}
|
||||||
|
drop := map[string]bool{}
|
||||||
|
for _, m := range removeM {
|
||||||
|
drop[m] = true
|
||||||
|
}
|
||||||
|
members := []string{}
|
||||||
|
for _, m := range g.Members {
|
||||||
|
if !drop[m] {
|
||||||
|
members = append(members, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
body["members"] = append(members, addM...)
|
||||||
|
}
|
||||||
|
if f.Changed("step") {
|
||||||
|
body["step"] = step
|
||||||
|
}
|
||||||
|
if f.Changed("name") {
|
||||||
|
body["name"] = rename
|
||||||
|
}
|
||||||
|
if f.Changed("comment") {
|
||||||
|
body["comment"] = comment
|
||||||
|
}
|
||||||
|
viewsBody(body, views)
|
||||||
|
if len(body) == 0 {
|
||||||
|
return fmt.Errorf("nothing to change (see --help)")
|
||||||
|
}
|
||||||
|
resp, err := c.Mutate(ctx(), "PATCH", "/blocking/groups/"+url.PathEscape(args[0]), nil, body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return report(resp)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
update.Flags().StringArrayVar(&addM, "add", nil, "add a member (repeat for several)")
|
||||||
|
update.Flags().StringArrayVar(&removeM, "remove", nil, "remove a member (repeat for several)")
|
||||||
|
update.Flags().StringVar(&step, "step", "", "new step")
|
||||||
|
update.Flags().StringVar(&rename, "name", "", "new name")
|
||||||
|
update.Flags().StringArrayVar(&views, "view", nil, `a view it applies in (repeat; "auto": every view with blocking)`)
|
||||||
|
update.Flags().StringVar(&comment, "comment", "", "new comment")
|
||||||
|
del := &cobra.Command{
|
||||||
|
Use: "delete GROUP",
|
||||||
|
Short: "Delete a client group (its devices go back to normal)",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newClient()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
resp, err := c.Mutate(ctx(), "DELETE", "/blocking/groups/"+url.PathEscape(args[0]), nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return report(resp)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.AddCommand(list, add, update, del)
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func orDash(s string) string {
|
||||||
|
if s == "" {
|
||||||
|
return "-"
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
+7
-2
@@ -420,14 +420,19 @@ Needs `blocking:read` (`blocking:write` to change). See
|
|||||||
| Method | Path | Body / query |
|
| 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) |
|
| 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` |
|
| 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` |
|
| 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` |
|
| 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) |
|
| 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}` | |
|
| PATCH / DELETE | `/blocking/rules/{id}` | |
|
||||||
| DELETE | `/blocking/rules` | `?name=DOMAIN[&view=V][&missing_ok=1]`: the rules for a domain |
|
| 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) |
|
| 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/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 |
|
| POST | `/blocking/resume` | end a pause now |
|
||||||
|
|||||||
@@ -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),
|
**error** (the last update failed; the previous version stays in use),
|
||||||
**disabled**, or **idle** (it applies to no view, so it does nothing).
|
**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
|
## Checking a name
|
||||||
|
|
||||||
`jarl block check NAME`, or the box at the top of the Blocking page, says
|
`jarl block check NAME`, or the box at the top of the Blocking page, says
|
||||||
@@ -194,6 +244,10 @@ names, and the clients with the most blocked queries (`jarl stats summary`,
|
|||||||
breakdown blocklist`). Blocked answers are left out of the NXDOMAIN figures, which
|
breakdown blocklist`). Blocked answers are left out of the NXDOMAIN figures, which
|
||||||
are about names that really don't exist.
|
are about names that really don't exist.
|
||||||
|
|
||||||
|
The Blocking page shows the last 24 hours too: a **Blocked (24h)** column
|
||||||
|
for each list, and one count for your rules together (named logs which
|
||||||
|
policy zone matched, not which rule in it).
|
||||||
|
|
||||||
Without statistics named doesn't log what it blocks at all.
|
Without statistics named doesn't log what it blocks at all.
|
||||||
|
|
||||||
## Memory and performance
|
## Memory and performance
|
||||||
@@ -250,6 +304,16 @@ response-policy {
|
|||||||
blocking in the same view: Jarl refuses the change and says so.
|
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
|
- Rewrites are logged to Jarl's response log when statistics read it, and
|
||||||
not at all otherwise.
|
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)).
|
Lists and rules need the `blocking` permission ([permissions.md](permissions.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 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 [DURATION] # let everything through for a while (default 10m, at most 24h)
|
||||||
jarl block pause indefinitely # ... until 'jarl block resume'
|
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
|
jarl block resume
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -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_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_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_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
|
## Security lints
|
||||||
|
|
||||||
|
|||||||
@@ -506,6 +506,27 @@ type Blocklist struct {
|
|||||||
// MemoryMB estimates what named needs for the list, in all its views.
|
// MemoryMB estimates what named needs for the list, in all its views.
|
||||||
MemoryMB int `json:"memory_mb,omitempty"`
|
MemoryMB int `json:"memory_mb,omitempty"`
|
||||||
Pending string `json:"pending,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.
|
// BlockRule is a block or allow rule as returned by the API.
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,6 +40,9 @@ type Match struct {
|
|||||||
Entry string `json:"entry,omitempty"` // the matching entry: the name, or *.parent
|
Entry string `json:"entry,omitempty"` // the matching entry: the name, or *.parent
|
||||||
RuleID int64 `json:"rule_id,omitempty"`
|
RuleID int64 `json:"rule_id,omitempty"`
|
||||||
List string `json:"list,omitempty"` // the blocklist, when a list matched
|
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.
|
// Actions of rules.
|
||||||
|
|||||||
@@ -335,7 +335,9 @@ func (l *limitedReader) Read(p []byte) (int, error) {
|
|||||||
|
|
||||||
// CheckBlocking reports, for every view with blocking, whether name is
|
// CheckBlocking reports, for every view with blocking, whether name is
|
||||||
// blocked or allowed there, and by which rule or list. It looks names up
|
// 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) {
|
func (e *Engine) CheckBlocking(st *model.State, name string) ([]blocking.Match, error) {
|
||||||
n, err := blocking.NormalizeName(name)
|
n, err := blocking.NormalizeName(name)
|
||||||
if err != nil {
|
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"})
|
out = append(out, blocking.Match{View: z.View, Result: "none"})
|
||||||
cur = &out[len(out)-1]
|
cur = &out[len(out)-1]
|
||||||
}
|
}
|
||||||
if cur.Result != "none" {
|
if cur.Result != "none" || z.IsGate() {
|
||||||
continue // an earlier zone decided
|
continue // an earlier zone decided, or a gate (it never blocks)
|
||||||
}
|
}
|
||||||
if z.ListID == 0 {
|
if z.ListID == 0 {
|
||||||
owner, act, ok := blocking.MatchRules(blocking.RuleEntries(render.ViewRules(st, z.ViewID)), n)
|
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
|
feeds[z.File] = entry
|
||||||
}
|
}
|
||||||
if 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
|
return out, nil
|
||||||
|
|||||||
@@ -117,6 +117,16 @@ func applyBlocklistPut(st *model.State, op model.Op, c *applyCtx) error {
|
|||||||
if in.Disabled != nil {
|
if in.Disabled != nil {
|
||||||
b.Disabled = *in.Disabled
|
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 {
|
if in.Comment != nil {
|
||||||
b.Comment = *in.Comment
|
b.Comment = *in.Comment
|
||||||
}
|
}
|
||||||
@@ -226,6 +236,11 @@ func blockingUsers(st *model.State, viewID int64) []string {
|
|||||||
out = append(out, ruleLabel(r))
|
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
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
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) {
|
for _, k := range sortedKeys(to.Blocklists) {
|
||||||
n := to.Blocklists[k]
|
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 {
|
if o, ok := from.Blocklists[k]; !ok {
|
||||||
add(OpBlocklistCreate, k, 0, in, "add blocklist "+n.Name)
|
add(OpBlocklistCreate, k, 0, in, "add blocklist "+n.Name)
|
||||||
} else if !sameObject(o, n) {
|
} 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))
|
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.
|
// 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) {
|
for _, k := range sortedKeys(from.BlockRules) {
|
||||||
if _, ok := to.BlockRules[k]; !ok {
|
if _, ok := to.BlockRules[k]; !ok {
|
||||||
add(OpBlockRuleDelete, k, from.BlockRules[k].Version, nil, "delete rule: "+ruleLabel(from.BlockRules[k]))
|
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)
|
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) {
|
for _, k := range sortedKeys(from.Records) {
|
||||||
if _, ok := to.Records[k]; !ok {
|
if _, ok := to.Records[k]; !ok {
|
||||||
o := from.Records[k]
|
o := from.Records[k]
|
||||||
|
|||||||
@@ -501,13 +501,15 @@ func (e *Engine) makePlan(ctx context.Context, base, work *model.State, user str
|
|||||||
if len(work.Zones) != len(base.Zones) {
|
if len(work.Zones) != len(base.Zones) {
|
||||||
pl.configChanged = true
|
pl.configChanged = true
|
||||||
}
|
}
|
||||||
// Rules zones whose content changed. New ones come with a change to
|
// Rules zones and gates whose content changed. New ones come with a
|
||||||
// named.conf, and reconfig loads them.
|
// change to named.conf, and reconfig loads them.
|
||||||
for _, z := range pl.out.Policy {
|
for _, z := range pl.out.Policy {
|
||||||
if z.ListID != 0 || !changedPaths[z.File] {
|
if z.ListID != 0 || !changedPaths[z.File] {
|
||||||
continue
|
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)
|
pl.reloadPolicy = append(pl.reloadPolicy, z)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -264,6 +264,30 @@ func (x *expander) expand(req Request) ([]model.Op, error) {
|
|||||||
verb = "delete rule: "
|
verb = "delete rule: "
|
||||||
}
|
}
|
||||||
return []model.Op{x.versioned(req, r.Version, verb+ruleLabel(r))}, nil
|
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)
|
return nil, errf(KindBadRequest, "unknown operation %q", req.Op)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,13 @@ const (
|
|||||||
OpBlockRuleDelete = "blockrule.delete"
|
OpBlockRuleDelete = "blockrule.delete"
|
||||||
OpBlockingPause = "blocking.pause"
|
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
|
// Requests only: disabling and enabling records, by ID or by match
|
||||||
// (zone, name, type and optionally data), become record.update
|
// (zone, name, type and optionally data), become record.update
|
||||||
// operations.
|
// operations.
|
||||||
@@ -50,7 +57,8 @@ const (
|
|||||||
var OpKinds = []string{OpZoneCreate, OpZoneUpdate, OpZoneDelete, OpRecordCreate, OpRecordUpdate, OpRecordDelete, OpRRSetReplace,
|
var OpKinds = []string{OpZoneCreate, OpZoneUpdate, OpZoneDelete, OpRecordCreate, OpRecordUpdate, OpRecordDelete, OpRRSetReplace,
|
||||||
OpViewCreate, OpViewUpdate, OpViewDelete, OpViewReorder, OpACLCreate, OpACLUpdate, OpACLDelete,
|
OpViewCreate, OpViewUpdate, OpViewDelete, OpViewReorder, OpACLCreate, OpACLUpdate, OpACLDelete,
|
||||||
OpTSIGCreate, OpTSIGUpdate, OpTSIGDelete, OpOptionCreate, OpOptionUpdate, OpOptionDelete, OpSettingsUpdate,
|
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
|
// 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"
|
// 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"`
|
ViewIDs *[]int64 `json:"view_ids,omitempty"`
|
||||||
Disabled *bool `json:"disabled,omitempty"`
|
Disabled *bool `json:"disabled,omitempty"`
|
||||||
Comment *string `json:"comment,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
|
// BlockRuleInput creates or updates a block or allow rule. Views as for
|
||||||
|
|||||||
@@ -106,6 +106,14 @@ func applyOp(st *model.State, op model.Op, c *applyCtx) error {
|
|||||||
err = applyBlockRulePut(st, op, c)
|
err = applyBlockRulePut(st, op, c)
|
||||||
case OpBlockRuleDelete:
|
case OpBlockRuleDelete:
|
||||||
err = applyBlockRuleDelete(st, op, c)
|
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:
|
case OpBlockingPause:
|
||||||
err = applyBlockingPause(st, op, c)
|
err = applyBlockingPause(st, op, c)
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -152,6 +152,11 @@ func FindRefs(st *model.State, kind refKind, name string) []string {
|
|||||||
out = append(out, "view "+v.Name)
|
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
|
var opts []string
|
||||||
for _, o := range st.Options {
|
for _, o := range st.Options {
|
||||||
stmt, err := namedconf.ParseOptionValue(o.Name, o.Value)
|
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)
|
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 {
|
for _, o := range st.Options {
|
||||||
stmt, err := namedconf.ParseOptionValue(o.Name, o.Value)
|
stmt, err := namedconf.ParseOptionValue(o.Name, o.Value)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package model
|
package model
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/netip"
|
||||||
"slices"
|
"slices"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -17,10 +19,126 @@ type Blocklist struct {
|
|||||||
// that offers recursion (see BlockingViews).
|
// that offers recursion (see BlockingViews).
|
||||||
ViewIDs []int64 `json:"view_ids"`
|
ViewIDs []int64 `json:"view_ids"`
|
||||||
Disabled bool `json:"disabled,omitempty"`
|
Disabled bool `json:"disabled,omitempty"`
|
||||||
|
// StepID is the blocking step the list starts at: StepNormal for
|
||||||
|
// every client, or a BlockStep for the clients of groups at that step
|
||||||
|
// or above.
|
||||||
|
StepID int64 `json:"step_id"`
|
||||||
Comment string `json:"comment"`
|
Comment string `json:"comment"`
|
||||||
Meta
|
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.
|
// Block rule actions.
|
||||||
const (
|
const (
|
||||||
BlockActionBlock = "block"
|
BlockActionBlock = "block"
|
||||||
@@ -134,3 +252,28 @@ func (s *State) BlockingViews(viewIDs []int64) []int64 {
|
|||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GroupPrefixes returns the addresses of a client group as prefixes: its
|
||||||
|
// own, and those of the ACLs it names (and the ACLs they name). Elements
|
||||||
|
// that aren't addresses are left out (validation reports them).
|
||||||
|
func (s *State) GroupPrefixes(g *ClientGroup) []netip.Prefix {
|
||||||
|
var out []netip.Prefix
|
||||||
|
seen := map[string]bool{}
|
||||||
|
var add func(elems []string)
|
||||||
|
add = func(elems []string) {
|
||||||
|
for _, e := range elems {
|
||||||
|
e = strings.TrimSpace(e)
|
||||||
|
if a, err := netip.ParseAddr(e); err == nil {
|
||||||
|
a = a.Unmap()
|
||||||
|
out = append(out, netip.PrefixFrom(a, a.BitLen()))
|
||||||
|
} else if p, err := netip.ParsePrefix(e); err == nil {
|
||||||
|
out = append(out, p.Masked())
|
||||||
|
} else if acl := s.ACLByName(e); acl != nil && !seen[acl.Name] {
|
||||||
|
seen[acl.Name] = true
|
||||||
|
add(acl.Elements)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
add(g.Members)
|
||||||
|
return out
|
||||||
|
}
|
||||||
+23
-6
@@ -9,6 +9,7 @@ package model
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"slices"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -249,6 +250,8 @@ type State struct {
|
|||||||
// Blocking (docs/blocking.md).
|
// Blocking (docs/blocking.md).
|
||||||
Blocklists map[int64]*Blocklist
|
Blocklists map[int64]*Blocklist
|
||||||
BlockRules map[int64]*BlockRule
|
BlockRules map[int64]*BlockRule
|
||||||
|
BlockSteps map[int64]*BlockStep
|
||||||
|
ClientGroups map[int64]*ClientGroup
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewState returns an empty state with default settings.
|
// NewState returns an empty state with default settings.
|
||||||
@@ -264,6 +267,8 @@ func NewState() *State {
|
|||||||
|
|
||||||
Blocklists: map[int64]*Blocklist{},
|
Blocklists: map[int64]*Blocklist{},
|
||||||
BlockRules: map[int64]*BlockRule{},
|
BlockRules: map[int64]*BlockRule{},
|
||||||
|
BlockSteps: map[int64]*BlockStep{},
|
||||||
|
ClientGroups: map[int64]*ClientGroup{},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -280,12 +285,14 @@ func (s *State) Clone() *State {
|
|||||||
|
|
||||||
Blocklists: make(map[int64]*Blocklist, len(s.Blocklists)),
|
Blocklists: make(map[int64]*Blocklist, len(s.Blocklists)),
|
||||||
BlockRules: make(map[int64]*BlockRule, len(s.BlockRules)),
|
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 {
|
for id, v := range s.Views {
|
||||||
n := *v
|
n := *v
|
||||||
n.MatchClients = append([]string(nil), v.MatchClients...)
|
n.MatchClients = slices.Clone(v.MatchClients)
|
||||||
n.MatchDestinations = append([]string(nil), v.MatchDestinations...)
|
n.MatchDestinations = slices.Clone(v.MatchDestinations)
|
||||||
if v.MatchRecursiveOnly != nil {
|
if v.MatchRecursiveOnly != nil {
|
||||||
b := *v.MatchRecursiveOnly
|
b := *v.MatchRecursiveOnly
|
||||||
n.MatchRecursiveOnly = &b
|
n.MatchRecursiveOnly = &b
|
||||||
@@ -294,7 +301,7 @@ func (s *State) Clone() *State {
|
|||||||
}
|
}
|
||||||
for id, a := range s.ACLs {
|
for id, a := range s.ACLs {
|
||||||
n := *a
|
n := *a
|
||||||
n.Elements = append([]string(nil), a.Elements...)
|
n.Elements = slices.Clone(a.Elements)
|
||||||
c.ACLs[id] = &n
|
c.ACLs[id] = &n
|
||||||
}
|
}
|
||||||
for id, k := range s.Keys {
|
for id, k := range s.Keys {
|
||||||
@@ -319,14 +326,24 @@ func (s *State) Clone() *State {
|
|||||||
}
|
}
|
||||||
for id, b := range s.Blocklists {
|
for id, b := range s.Blocklists {
|
||||||
n := *b
|
n := *b
|
||||||
n.ViewIDs = append([]int64(nil), b.ViewIDs...)
|
n.ViewIDs = slices.Clone(b.ViewIDs)
|
||||||
c.Blocklists[id] = &n
|
c.Blocklists[id] = &n
|
||||||
}
|
}
|
||||||
for id, r := range s.BlockRules {
|
for id, r := range s.BlockRules {
|
||||||
n := *r
|
n := *r
|
||||||
n.ViewIDs = append([]int64(nil), r.ViewIDs...)
|
n.ViewIDs = slices.Clone(r.ViewIDs)
|
||||||
c.BlockRules[id] = &n
|
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
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package render
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/netip"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -21,9 +22,16 @@ type PolicyZone struct {
|
|||||||
ViewID int64 // 0 without views
|
ViewID int64 // 0 without views
|
||||||
View string // "" without views
|
View string // "" without views
|
||||||
File string
|
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
|
// BlocklistsDir holds the zone files of downloaded blocklists, one per
|
||||||
// list, shared by the views that use it.
|
// list, shared by the views that use it.
|
||||||
func (p Paths) BlocklistsDir() string { return filepath.Join(p.CacheDir, "blocklists") }
|
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
|
// 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
|
// 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 {
|
func PolicyZones(s *model.State, p Paths) []PolicyZone {
|
||||||
var out []PolicyZone
|
var out []PolicyZone
|
||||||
rules, lists := s.SortedBlockRules(), s.SortedBlocklists()
|
rules, lists := s.SortedBlockRules(), s.SortedBlocklists()
|
||||||
@@ -65,11 +75,27 @@ func PolicyZones(s *model.State, p Paths) []PolicyZone {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
assigns := ClientAssignments(s, vid)
|
||||||
|
for k, step := range append([]int64{model.StepNormal}, stepIDs(s)...) {
|
||||||
|
var tier []PolicyZone
|
||||||
for _, b := range lists {
|
for _, b := range lists {
|
||||||
if !b.Disabled && slices.Contains(s.BlockingViews(b.ViewIDs), vid) {
|
if !b.Disabled && b.StepID == step && 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})
|
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
|
// While paused, every view with blocking gets a rules zone: it
|
||||||
// lets everything through.
|
// lets everything through.
|
||||||
if hasRules || s.Settings.BlockingPaused() && len(zones) > 0 {
|
if hasRules || s.Settings.BlockingPaused() && len(zones) > 0 {
|
||||||
@@ -80,6 +106,39 @@ func PolicyZones(s *model.State, p Paths) []PolicyZone {
|
|||||||
return out
|
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).
|
// ViewRules returns the rules that apply in a view (0 without views).
|
||||||
func ViewRules(s *model.State, viewID int64) []blocking.Rule {
|
func ViewRules(s *model.State, viewID int64) []blocking.Rule {
|
||||||
var out []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
|
// changed (at most every blocking.refresh), so named's default
|
||||||
// delay for batching frequent updates only adds a wait.
|
// delay for batching frequent updates only adds a wait.
|
||||||
opts := []string{"min-update-interval 0"}
|
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")
|
opts = append(opts, "log no")
|
||||||
}
|
}
|
||||||
w.write(fmt.Sprintf("\t\tzone %s%s;\n", namedconf.QuoteString(z.Name), prefixSpace(strings.Join(opts, " "))))
|
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 {
|
for _, z := range zones {
|
||||||
w.write("\n")
|
w.write("\n")
|
||||||
label := "blocking: your rules"
|
label := "blocking: your rules"
|
||||||
if z.ListID != 0 {
|
switch {
|
||||||
|
case z.ListID != 0:
|
||||||
label = "blocking: blocklist " + s.Blocklists[z.ListID].Name
|
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.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",
|
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)
|
out.Blocklists = appendUnique(out.Blocklists, z.File)
|
||||||
continue
|
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"
|
title := "Blocking rules"
|
||||||
if z.View != "" {
|
if z.View != "" {
|
||||||
title += " for view " + z.View
|
title += " for view " + z.View
|
||||||
|
|||||||
@@ -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 {
|
func presentBlocklist(st *model.State, b *model.Blocklist, status *model.BlocklistStatus) api.Blocklist {
|
||||||
views := st.BlockingViews(b.ViewIDs)
|
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 {
|
switch {
|
||||||
case b.Disabled:
|
case b.Disabled:
|
||||||
out.State = "disabled"
|
out.State = "disabled"
|
||||||
case len(views) == 0:
|
case len(views) == 0 || !stepReached(st, b, views):
|
||||||
out.State = "idle"
|
out.State = "idle"
|
||||||
case status != nil && status.Error != "":
|
case status != nil && status.Error != "":
|
||||||
out.State = "error"
|
out.State = "error"
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -69,6 +69,15 @@ func (s *Server) routes() {
|
|||||||
m.HandleFunc("DELETE "+p+"/blocking/rules", s.authed(s.deleteBlockRulesByName))
|
m.HandleFunc("DELETE "+p+"/blocking/rules", s.authed(s.deleteBlockRulesByName))
|
||||||
m.HandleFunc("PATCH "+p+"/blocking/rules/{id}", s.authed(s.updateBlockRule))
|
m.HandleFunc("PATCH "+p+"/blocking/rules/{id}", s.authed(s.updateBlockRule))
|
||||||
m.HandleFunc("DELETE "+p+"/blocking/rules/{id}", s.authed(s.deleteBlockRule))
|
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/check", s.authed(s.checkBlocking))
|
||||||
m.HandleFunc("GET "+p+"/blocking/status", s.authed(s.blockingStatus))
|
m.HandleFunc("GET "+p+"/blocking/status", s.authed(s.blockingStatus))
|
||||||
m.HandleFunc("POST "+p+"/blocking/pause", s.authed(s.pauseBlocking(false)))
|
m.HandleFunc("POST "+p+"/blocking/pause", s.authed(s.pauseBlocking(false)))
|
||||||
|
|||||||
+59
-7
@@ -150,14 +150,14 @@ func loadState(ctx context.Context, e execer) (*model.State, error) {
|
|||||||
return nil, err
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
b := &model.Blocklist{}
|
b := &model.Blocklist{}
|
||||||
var views, ca, ua string
|
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()
|
rows.Close()
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -187,6 +187,44 @@ func loadState(ctx context.Context, e execer) (*model.State, error) {
|
|||||||
if err := closeRows(rows); err != nil {
|
if err := closeRows(rows); err != nil {
|
||||||
return nil, err
|
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
|
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
|
// ReplaceState replaces the stored configuration with st (used by import
|
||||||
// and tests).
|
// and tests).
|
||||||
func (t *Tx) ReplaceState(st *model.State) error {
|
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 {
|
if _, err := t.tx.ExecContext(t.ctx, "DELETE FROM "+tbl); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -272,17 +310,31 @@ func saveState(ctx context.Context, e execer, old, new *model.State) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := diffMap(old.Blocklists, new.Blocklists, func(b *model.Blocklist) error {
|
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 (?,?,?,?,?,?,?,?,?,?,?)`,
|
_, 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.Comment, b.Version, ts(b.CreatedAt), ts(b.UpdatedAt), b.CreatedBy, b.UpdatedBy)
|
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
|
return err
|
||||||
}, func(id int64) error { return del(ctx, e, "blocklists", id) }); err != nil {
|
}, func(id int64) error { return del(ctx, e, "blocklists", id) }); err != nil {
|
||||||
return err
|
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 (?,?,?,?,?,?,?,?,?,?,?)`,
|
_, 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)
|
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
|
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 {
|
func nonNilIDs(ids []int64) []int64 {
|
||||||
|
|||||||
@@ -304,6 +304,28 @@ CREATE TABLE blocklist_status (
|
|||||||
// 7: disabled zones, kept but not loaded by named.
|
// 7: disabled zones, kept but not loaded by named.
|
||||||
`
|
`
|
||||||
ALTER TABLE zones ADD COLUMN disabled INTEGER NOT NULL DEFAULT 0;
|
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 ''
|
||||||
|
);
|
||||||
`,
|
`,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
package validate
|
package validate
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"net/netip"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"jarl/internal/model"
|
"jarl/internal/model"
|
||||||
|
"jarl/internal/namedconf"
|
||||||
)
|
)
|
||||||
|
|
||||||
// checkBlocking checks blocklists and block/allow rules (docs/blocking.md).
|
// checkBlocking checks blocklists and block/allow rules (docs/blocking.md).
|
||||||
@@ -67,6 +69,7 @@ func (c *checker) checkBlocking() {
|
|||||||
seen[key] = r
|
seen[key] = r
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
c.checkClientGroups(effective)
|
||||||
// Jarl writes the response-policy statement where blocking is used;
|
// Jarl writes the response-policy statement where blocking is used;
|
||||||
// named allows one per view (or for the server).
|
// named allows one per view (or for the server).
|
||||||
for vid := range used {
|
for vid := range used {
|
||||||
@@ -91,3 +94,94 @@ func (c *checker) blockingScope(viewID int64) string {
|
|||||||
}
|
}
|
||||||
return ""
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,7 +22,12 @@ let refreshTimer = null;
|
|||||||
|
|
||||||
export async function render(el, params, query) {
|
export async function render(el, params, query) {
|
||||||
clearTimeout(refreshTimer);
|
clearTimeout(refreshTimer);
|
||||||
const [lists, rules, views, status] = await Promise.all([get("/blocking/lists", draftQuery()), get("/blocking/rules", draftQuery()), get("/views"), get("/blocking/status")]);
|
// 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, 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 writable = can("blocking", true);
|
||||||
const recursive = lists.concat(rules).find((x) => x.auto_views);
|
const recursive = lists.concat(rules).find((x) => x.auto_views);
|
||||||
const autoViews = recursive ? recursive.views : null;
|
const autoViews = recursive ? recursive.views : null;
|
||||||
@@ -53,8 +58,9 @@ export async function render(el, params, query) {
|
|||||||
h("form", { class: "actions", onsubmit: runCheck },
|
h("form", { class: "actions", onsubmit: runCheck },
|
||||||
h("label", { class: "muted" }, "Check a name"), checkName, h("button", { type: "submit" }, "Check")),
|
h("label", { class: "muted" }, "Check a name"), checkName, h("button", { type: "submit" }, "Check")),
|
||||||
checkOut),
|
checkOut),
|
||||||
rulesPanel(rules, views, autoViews, writable),
|
rulesPanel(rules, views, autoViews, writable, hits),
|
||||||
listsPanel(lists, views, autoViews, writable));
|
listsPanel(lists, views, autoViews, writable, hits, steps),
|
||||||
|
devicesPanel(groups, steps, views, autoViews, writable));
|
||||||
if (checkName.value) runCheck();
|
if (checkName.value) runCheck();
|
||||||
|
|
||||||
// Lists are downloaded in the background, and a pause ends by itself:
|
// Lists are downloaded in the background, and a pause ends by itself:
|
||||||
@@ -110,7 +116,8 @@ function checkResult(name, res, rules, writable) {
|
|||||||
m.view ? h("span", { class: "muted" }, "view " + m.view + ": ") : null,
|
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.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);
|
: 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);
|
action);
|
||||||
@@ -172,7 +179,8 @@ async function editDialog(title, path, rows, data) {
|
|||||||
], { sticky: true });
|
], { 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 name = input({ value: l.name, class: "mono" });
|
||||||
const url = input({ value: l.url, class: "mono", style: { width: "100%" } });
|
const url = input({ value: l.url, class: "mono", style: { width: "100%" } });
|
||||||
const comment = input({ value: l.comment || "" });
|
const comment = input({ value: l.comment || "" });
|
||||||
@@ -180,8 +188,9 @@ function editList(l, views, autoViews) {
|
|||||||
editDialog("Edit blocklist " + l.name, "/blocking/lists/" + l.id, [
|
editDialog("Edit blocklist " + l.name, "/blocking/lists/" + l.id, [
|
||||||
["Name", name], ["URL", url, "A new URL is downloaded right after saving."],
|
["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,
|
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],
|
["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) {
|
function editRule(r, views, autoViews) {
|
||||||
@@ -207,7 +216,12 @@ function viewsCell(x) {
|
|||||||
return x.auto_views ? [x.views.join(", "), h("span", { class: "muted small" }, " (auto)")] : x.views.join(", ");
|
return x.auto_views ? [x.views.join(", "), h("span", { class: "muted small" }, " (auto)")] : x.views.join(", ");
|
||||||
}
|
}
|
||||||
|
|
||||||
function rulesPanel(rules, views, autoViews, writable) {
|
// hitsLink shows a count of blocked queries, linking to the statistics.
|
||||||
|
function hitsLink(n) {
|
||||||
|
return h("a", { href: "#/stats?since=24h", title: "queries blocked in the last 24 hours; more on the Statistics page" }, (n || 0).toLocaleString());
|
||||||
|
}
|
||||||
|
|
||||||
|
function rulesPanel(rules, views, autoViews, writable, hits) {
|
||||||
const name = input({ placeholder: "ads.example.com", class: "mono" });
|
const name = input({ placeholder: "ads.example.com", class: "mono" });
|
||||||
const action = select([["block", "Block"], ["allow", "Allow"]], "block");
|
const action = select([["block", "Block"], ["allow", "Allow"]], "block");
|
||||||
const exact = checkbox("only this exact name", false);
|
const exact = checkbox("only this exact name", false);
|
||||||
@@ -230,6 +244,7 @@ function rulesPanel(rules, views, autoViews, writable) {
|
|||||||
h("div", { class: "f" }, h("label", null, " "), h("button", { class: "primary", type: "submit" }, "Add rule")));
|
h("div", { class: "f" }, h("label", null, " "), h("button", { class: "primary", type: "submit" }, "Add rule")));
|
||||||
return h("div", { class: "panel" },
|
return h("div", { class: "panel" },
|
||||||
h("h2", null, "Your rules"),
|
h("h2", null, "Your rules"),
|
||||||
|
hits && rules.some((r) => r.action === "block") ? h("p", { class: "small" }, "Blocked ", hitsLink(hits.rules), " queries in the last 24 hours (all rules together: named doesn't say which rule matched).") : null,
|
||||||
h("p", { class: "muted small" }, "A rule covers the domain and every name below it, unless it's for the exact name only. Rules beat every blocklist, and the most specific rule wins: allowing cdn.example.com overrides blocking example.com for cdn.example.com and the names below it."),
|
h("p", { class: "muted small" }, "A rule covers the domain and every name below it, unless it's for the exact name only. Rules beat every blocklist, and the most specific rule wins: allowing cdn.example.com overrides blocking example.com for cdn.example.com and the names below it."),
|
||||||
writable ? [form, err] : null,
|
writable ? [form, err] : null,
|
||||||
table([
|
table([
|
||||||
@@ -243,21 +258,33 @@ function rulesPanel(rules, views, autoViews, writable) {
|
|||||||
].filter(Boolean), rules, { empty: "No rules yet.", rowClass: (r) => r.pending ? "pending-" + r.pending : "" }));
|
].filter(Boolean), rules, { empty: "No rules yet.", rowClass: (r) => r.pending ? "pending-" + r.pending : "" }));
|
||||||
}
|
}
|
||||||
|
|
||||||
function listsPanel(lists, views, autoViews, writable) {
|
// 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 name = input({ placeholder: "hagezi-multi", class: "mono", style: { width: "11em" } });
|
||||||
const url = input({ placeholder: "https://…", class: "mono" });
|
const url = input({ placeholder: "https://…", class: "mono" });
|
||||||
const vp = viewPicker(views, autoViews);
|
const vp = viewPicker(views, autoViews);
|
||||||
|
const listStep = stepSelect(steps, "normal", false);
|
||||||
const err = h("div");
|
const err = h("div");
|
||||||
const form = h("form", { class: "inline-form", onsubmit: async (ev) => {
|
const form = h("form", { class: "inline-form", onsubmit: async (ev) => {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
try {
|
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" : ""));
|
toast(describeResult(r) + (r.applied ? " — jarld downloads it now" : ""));
|
||||||
rerender();
|
rerender();
|
||||||
} catch (e) { mount(err, errorBox(e)); }
|
} catch (e) { mount(err, errorBox(e)); }
|
||||||
} },
|
} },
|
||||||
h("div", { class: "f" }, h("label", null, "Name"), name),
|
h("div", { class: "f" }, h("label", null, "Name"), name),
|
||||||
h("div", { class: "f grow" }, h("label", null, "URL"), url),
|
h("div", { class: "f grow" }, h("label", null, "URL"), url),
|
||||||
|
steps.length ? h("div", { class: "f" }, h("label", null, "Step"), listStep) : null,
|
||||||
vp.el,
|
vp.el,
|
||||||
h("div", { class: "f" }, h("label", null, " "), h("button", { class: "primary", type: "submit" }, "Add list")));
|
h("div", { class: "f" }, h("label", null, " "), h("button", { class: "primary", type: "submit" }, "Add list")));
|
||||||
const used = new Set(lists.map((l) => l.url));
|
const used = new Set(lists.map((l) => l.url));
|
||||||
@@ -273,12 +300,14 @@ function listsPanel(lists, views, autoViews, writable) {
|
|||||||
{ label: "Name", render: (l) => [h("strong", { class: "mono" }, l.name), l.pending ? [" ", pendingBadge(l.pending)] : null] },
|
{ label: "Name", render: (l) => [h("strong", { class: "mono" }, l.name), l.pending ? [" ", pendingBadge(l.pending)] : null] },
|
||||||
{ 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: "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" },
|
{ 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,
|
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: "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: "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)) },
|
{ 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,
|
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", 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", 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,
|
h("button", { class: "small danger", onclick: () => del("/blocking/lists/" + l.id, "blocklist " + l.name) }, "Delete")), tdClass: "actions-cell" } : null,
|
||||||
@@ -318,3 +347,125 @@ async function del(path, what) {
|
|||||||
} catch (e) { toast(e.message, "err", 8000); }
|
} 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 }));
|
||||||
|
}
|
||||||
@@ -482,7 +482,7 @@ async function recordsTab(el, zone, query) {
|
|||||||
{ label: "Comment", render: (r) => [r.pending ? [pendingBadge(r.pending), " "] : null, h("span", { class: "muted" }, r.comment || "")] },
|
{ label: "Comment", render: (r) => [r.pending ? [pendingBadge(r.pending), " "] : null, h("span", { class: "muted" }, r.comment || "")] },
|
||||||
{ label: "", render: (r) => r.pending === "delete" ? "" : h("span", null,
|
{ label: "", render: (r) => r.pending === "delete" ? "" : h("span", null,
|
||||||
!reverse && (r.type === "A" || r.type === "AAAA") && !r.disabled
|
!reverse && (r.type === "A" || r.type === "AAAA") && !r.disabled
|
||||||
? [h("button", { class: "small", title: "Add a CNAME record that points to " + r.name.replace(/\.$/, ""), onclick: () => aliasTo(r) }, "+ CNAME"), " "] : null,
|
? [h("button", { class: "small reveal", title: "Add a CNAME record that points to " + r.name.replace(/\.$/, ""), onclick: () => aliasTo(r) }, "+ CNAME"), " "] : null,
|
||||||
h("button", { class: "small", title: "Edit", onclick: () => edit(r) }, "Edit"), " ",
|
h("button", { class: "small", title: "Edit", onclick: () => edit(r) }, "Edit"), " ",
|
||||||
r.disabled
|
r.disabled
|
||||||
? h("button", { class: "small toggle", title: "Put the record back in the zone", onclick: () => toggle(r) }, "Enable")
|
? h("button", { class: "small toggle", title: "Put the record back in the zone", onclick: () => toggle(r) }, "Enable")
|
||||||
|
|||||||
@@ -320,6 +320,18 @@ td.break { word-break: break-all; }
|
|||||||
}
|
}
|
||||||
|
|
||||||
.refreshing { opacity: 0.6; transition: opacity 0.2s; }
|
.refreshing { opacity: 0.6; transition: opacity 0.2s; }
|
||||||
|
/* Secondary row actions show when the row is hovered or focused; touch
|
||||||
|
screens, which can't hover, always show them. Opacity keeps them in the
|
||||||
|
layout (no shifting) and reachable with Tab. */
|
||||||
|
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; }
|
h2.section { margin: 24px 0 10px; font-size: 18px; }
|
||||||
.filter-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 12px; }
|
.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); }
|
.segmented { display: inline-flex; border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; background: var(--panel); }
|
||||||
|
|||||||
+37
-1
@@ -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 "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
|
expect_rpz "but not the names below it" sub.ads.adbraun.test none
|
||||||
run "blocklist list" 0 $J blocklist list
|
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
|
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 "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.
|
expect_rpz "the rest of the list still blocks" x.tracker.adbraun.test test.blocklist.jarl.invalid.
|
||||||
@@ -516,6 +516,42 @@ printf '<html>gone</html>\n' > $E/lists/list.txt
|
|||||||
run "a broken download fails (exit 2)" 2 $J blocklist refresh test --force
|
run "a broken download fails (exit 2)" 2 $J blocklist refresh test --force
|
||||||
contains "no domains found"
|
contains "no domains found"
|
||||||
expect_rpz "and the previous version stays" x.new.adbraun.test test.blocklist.jarl.invalid.
|
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
|
run "blocklist disable" 0 $J blocklist disable test
|
||||||
expect_rpz "a disabled list doesn't block" x.new.adbraun.test none
|
expect_rpz "a disabled list doesn't block" x.new.adbraun.test none
|
||||||
run "blocklist delete" 0 $J blocklist delete test --yes
|
run "blocklist delete" 0 $J blocklist delete test --yes
|
||||||
|
|||||||
Reference in new issue
Block a user