Different blocking for different devices: steps and client groups
Client groups put devices (addresses, networks, or ACLs of them) at a blocking step: off (only your rules), normal (the default), or steps added above it, each including the lists of the steps below. Lists start at a step. In each view, a gate policy zone in front of a step's lists lets the devices below that step through (rpz-client-ip triggers with rpz-passthru), so every list is still loaded once. The IPv6 part of a gate leaves the IPv4-mapped range out: named matches IPv4 clients as ::ffff:a.b.c.d. Web UI: a Devices panel on the Blocking page and a step on each list. CLI: jarl block step, jarl block group, jarl blocklist --step. API: /blocking/steps, /blocking/groups. Database schema 8. Also: copying the state kept empty lists as empty (not nil), so reverting a changeset no longer adds restore operations for unchanged blocklists, rules and views. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
5b8f681e2a
commit
76aaaf3ef5
33 files changed
+2096
-55
No files matched your search
@@ -506,6 +506,27 @@ type Blocklist struct {
|
||||
// MemoryMB estimates what named needs for the list, in all its views.
|
||||
MemoryMB int `json:"memory_mb,omitempty"`
|
||||
Pending string `json:"pending,omitempty"`
|
||||
// Step is the name of the step the list starts at ("normal" or one
|
||||
// above it).
|
||||
Step string `json:"step"`
|
||||
}
|
||||
|
||||
// BlockStep is a blocking step above normal as returned by the API, with
|
||||
// what uses it.
|
||||
type BlockStep struct {
|
||||
model.BlockStep
|
||||
Lists []string `json:"lists"`
|
||||
Groups []string `json:"groups"`
|
||||
Pending string `json:"pending,omitempty"`
|
||||
}
|
||||
|
||||
// ClientGroup is a client group as returned by the API.
|
||||
type ClientGroup struct {
|
||||
model.ClientGroup
|
||||
Step string `json:"step"`
|
||||
Views []string `json:"views"`
|
||||
AutoViews bool `json:"auto_views,omitempty"`
|
||||
Pending string `json:"pending,omitempty"`
|
||||
}
|
||||
|
||||
// BlockRule is a block or allow rule as returned by the API.
|
||||
|
||||
@@ -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
|
||||
RuleID int64 `json:"rule_id,omitempty"`
|
||||
List string `json:"list,omitempty"` // the blocklist, when a list matched
|
||||
// Step is the list's step when it is above normal: only clients at
|
||||
// that step or above get this answer (others: none).
|
||||
Step string `json:"step,omitempty"`
|
||||
}
|
||||
|
||||
// Actions of rules.
|
||||
|
||||
@@ -335,7 +335,9 @@ func (l *limitedReader) Read(p []byte) (int, error) {
|
||||
|
||||
// CheckBlocking reports, for every view with blocking, whether name is
|
||||
// blocked or allowed there, and by which rule or list. It looks names up
|
||||
// the way named does: rules first, then the lists in order.
|
||||
// the way named does: rules first, then the lists in order. The answer is
|
||||
// for clients at the normal step; a list of a higher step that blocks the
|
||||
// name says so (Match.Step), and clients at step off skip every list.
|
||||
func (e *Engine) CheckBlocking(st *model.State, name string) ([]blocking.Match, error) {
|
||||
n, err := blocking.NormalizeName(name)
|
||||
if err != nil {
|
||||
@@ -349,8 +351,8 @@ func (e *Engine) CheckBlocking(st *model.State, name string) ([]blocking.Match,
|
||||
out = append(out, blocking.Match{View: z.View, Result: "none"})
|
||||
cur = &out[len(out)-1]
|
||||
}
|
||||
if cur.Result != "none" {
|
||||
continue // an earlier zone decided
|
||||
if cur.Result != "none" || z.IsGate() {
|
||||
continue // an earlier zone decided, or a gate (it never blocks)
|
||||
}
|
||||
if z.ListID == 0 {
|
||||
owner, act, ok := blocking.MatchRules(blocking.RuleEntries(render.ViewRules(st, z.ViewID)), n)
|
||||
@@ -372,7 +374,11 @@ func (e *Engine) CheckBlocking(st *model.State, name string) ([]blocking.Match,
|
||||
feeds[z.File] = entry
|
||||
}
|
||||
if entry != "" {
|
||||
cur.Result, cur.Entry, cur.List = "blocked", entry, st.Blocklists[z.ListID].Name
|
||||
b := st.Blocklists[z.ListID]
|
||||
cur.Result, cur.Entry, cur.List = "blocked", entry, b.Name
|
||||
if b.StepID != model.StepNormal {
|
||||
cur.Step = st.StepName(b.StepID)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
|
||||
@@ -117,6 +117,16 @@ func applyBlocklistPut(st *model.State, op model.Op, c *applyCtx) error {
|
||||
if in.Disabled != nil {
|
||||
b.Disabled = *in.Disabled
|
||||
}
|
||||
step, given, err := stepRef(st, in.Step, in.StepID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if given {
|
||||
if step == model.StepOff {
|
||||
return errf(KindBadRequest, "a blocklist starts at normal or a step above it, not off")
|
||||
}
|
||||
b.StepID = step
|
||||
}
|
||||
if in.Comment != nil {
|
||||
b.Comment = *in.Comment
|
||||
}
|
||||
@@ -226,6 +236,11 @@ func blockingUsers(st *model.State, viewID int64) []string {
|
||||
out = append(out, ruleLabel(r))
|
||||
}
|
||||
}
|
||||
for _, g := range st.SortedClientGroups() {
|
||||
if slices.Contains(g.ViewIDs, viewID) {
|
||||
out = append(out, "client group "+g.Name)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
// Steps before the lists and groups that use them.
|
||||
for _, k := range sortedKeys(to.BlockSteps) {
|
||||
n := to.BlockSteps[k]
|
||||
in := BlockStepInput{Name: &n.Name, Position: model.Ptr(n.Position), Comment: &n.Comment}
|
||||
if o, ok := from.BlockSteps[k]; !ok {
|
||||
add(OpBlockStepCreate, k, 0, in, "add blocking step "+n.Name)
|
||||
} else if !sameObject(o, n) {
|
||||
add(OpBlockStepUpdate, k, o.Version, in, "restore blocking step "+n.Name)
|
||||
}
|
||||
}
|
||||
for _, k := range sortedKeys(to.Blocklists) {
|
||||
n := to.Blocklists[k]
|
||||
in := BlocklistInput{Name: &n.Name, URL: &n.URL, ViewIDs: model.Ptr(append([]int64{}, n.ViewIDs...)), Disabled: &n.Disabled, Comment: &n.Comment}
|
||||
in := BlocklistInput{Name: &n.Name, URL: &n.URL, ViewIDs: model.Ptr(append([]int64{}, n.ViewIDs...)), Disabled: &n.Disabled, Comment: &n.Comment, StepID: model.Ptr(n.StepID)}
|
||||
if o, ok := from.Blocklists[k]; !ok {
|
||||
add(OpBlocklistCreate, k, 0, in, "add blocklist "+n.Name)
|
||||
} else if !sameObject(o, n) {
|
||||
@@ -122,7 +132,21 @@ func DiffOps(from, to *model.State) []model.Op {
|
||||
add(OpBlockRuleUpdate, k, o.Version, in, "restore rule: "+ruleLabel(n))
|
||||
}
|
||||
}
|
||||
for _, k := range sortedKeys(to.ClientGroups) {
|
||||
n := to.ClientGroups[k]
|
||||
in := ClientGroupInput{Name: &n.Name, Members: model.Ptr(append([]string{}, n.Members...)), StepID: model.Ptr(n.StepID), ViewIDs: model.Ptr(append([]int64{}, n.ViewIDs...)), Comment: &n.Comment}
|
||||
if o, ok := from.ClientGroups[k]; !ok {
|
||||
add(OpClientGroupCreate, k, 0, in, "add client group "+n.Name)
|
||||
} else if !sameObject(o, n) {
|
||||
add(OpClientGroupUpdate, k, o.Version, in, "restore client group "+n.Name)
|
||||
}
|
||||
}
|
||||
// Deletions, most dependent objects first.
|
||||
for _, k := range sortedKeys(from.ClientGroups) {
|
||||
if _, ok := to.ClientGroups[k]; !ok {
|
||||
add(OpClientGroupDelete, k, from.ClientGroups[k].Version, nil, "delete client group "+from.ClientGroups[k].Name)
|
||||
}
|
||||
}
|
||||
for _, k := range sortedKeys(from.BlockRules) {
|
||||
if _, ok := to.BlockRules[k]; !ok {
|
||||
add(OpBlockRuleDelete, k, from.BlockRules[k].Version, nil, "delete rule: "+ruleLabel(from.BlockRules[k]))
|
||||
@@ -133,6 +157,11 @@ func DiffOps(from, to *model.State) []model.Op {
|
||||
add(OpBlocklistDelete, k, from.Blocklists[k].Version, nil, "delete blocklist "+from.Blocklists[k].Name)
|
||||
}
|
||||
}
|
||||
for _, k := range sortedKeys(from.BlockSteps) {
|
||||
if _, ok := to.BlockSteps[k]; !ok {
|
||||
add(OpBlockStepDelete, k, from.BlockSteps[k].Version, nil, "delete blocking step "+from.BlockSteps[k].Name)
|
||||
}
|
||||
}
|
||||
for _, k := range sortedKeys(from.Records) {
|
||||
if _, ok := to.Records[k]; !ok {
|
||||
o := from.Records[k]
|
||||
|
||||
@@ -501,13 +501,15 @@ func (e *Engine) makePlan(ctx context.Context, base, work *model.State, user str
|
||||
if len(work.Zones) != len(base.Zones) {
|
||||
pl.configChanged = true
|
||||
}
|
||||
// Rules zones whose content changed. New ones come with a change to
|
||||
// named.conf, and reconfig loads them.
|
||||
// Rules zones and gates whose content changed. New ones come with a
|
||||
// change to named.conf, and reconfig loads them.
|
||||
for _, z := range pl.out.Policy {
|
||||
if z.ListID != 0 || !changedPaths[z.File] {
|
||||
continue
|
||||
}
|
||||
if slices.Contains(baseOut.Policy, z) {
|
||||
if slices.ContainsFunc(baseOut.Policy, func(o render.PolicyZone) bool {
|
||||
return o.Name == z.Name && o.ViewID == z.ViewID && o.File == z.File
|
||||
}) {
|
||||
pl.reloadPolicy = append(pl.reloadPolicy, z)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -264,6 +264,30 @@ func (x *expander) expand(req Request) ([]model.Op, error) {
|
||||
verb = "delete rule: "
|
||||
}
|
||||
return []model.Op{x.versioned(req, r.Version, verb+ruleLabel(r))}, nil
|
||||
case OpBlockStepCreate:
|
||||
return x.blockStepCreate(req)
|
||||
case OpBlockStepUpdate, OpBlockStepDelete:
|
||||
s := x.st.BlockSteps[req.ID]
|
||||
if s == nil {
|
||||
return nil, errf(KindNotFound, "blocking step %d does not exist", req.ID)
|
||||
}
|
||||
verb := "change blocking step "
|
||||
if req.Op == OpBlockStepDelete {
|
||||
verb = "delete blocking step "
|
||||
}
|
||||
return []model.Op{x.versioned(req, s.Version, verb+s.Name)}, nil
|
||||
case OpClientGroupCreate:
|
||||
return x.clientGroupCreate(req)
|
||||
case OpClientGroupUpdate, OpClientGroupDelete:
|
||||
g := x.st.ClientGroups[req.ID]
|
||||
if g == nil {
|
||||
return nil, errf(KindNotFound, "client group %d does not exist", req.ID)
|
||||
}
|
||||
verb := "change client group "
|
||||
if req.Op == OpClientGroupDelete {
|
||||
verb = "delete client group "
|
||||
}
|
||||
return []model.Op{x.versioned(req, g.Version, verb+g.Name)}, nil
|
||||
}
|
||||
return nil, errf(KindBadRequest, "unknown operation %q", req.Op)
|
||||
}
|
||||
|
||||
@@ -39,6 +39,13 @@ const (
|
||||
OpBlockRuleDelete = "blockrule.delete"
|
||||
OpBlockingPause = "blocking.pause"
|
||||
|
||||
OpBlockStepCreate = "blockstep.create"
|
||||
OpBlockStepUpdate = "blockstep.update"
|
||||
OpBlockStepDelete = "blockstep.delete"
|
||||
OpClientGroupCreate = "clientgroup.create"
|
||||
OpClientGroupUpdate = "clientgroup.update"
|
||||
OpClientGroupDelete = "clientgroup.delete"
|
||||
|
||||
// Requests only: disabling and enabling records, by ID or by match
|
||||
// (zone, name, type and optionally data), become record.update
|
||||
// operations.
|
||||
@@ -50,7 +57,8 @@ const (
|
||||
var OpKinds = []string{OpZoneCreate, OpZoneUpdate, OpZoneDelete, OpRecordCreate, OpRecordUpdate, OpRecordDelete, OpRRSetReplace,
|
||||
OpViewCreate, OpViewUpdate, OpViewDelete, OpViewReorder, OpACLCreate, OpACLUpdate, OpACLDelete,
|
||||
OpTSIGCreate, OpTSIGUpdate, OpTSIGDelete, OpOptionCreate, OpOptionUpdate, OpOptionDelete, OpSettingsUpdate,
|
||||
OpBlocklistCreate, OpBlocklistUpdate, OpBlocklistDelete, OpBlockRuleCreate, OpBlockRuleUpdate, OpBlockRuleDelete, OpBlockingPause}
|
||||
OpBlocklistCreate, OpBlocklistUpdate, OpBlocklistDelete, OpBlockRuleCreate, OpBlockRuleUpdate, OpBlockRuleDelete, OpBlockingPause,
|
||||
OpBlockStepCreate, OpBlockStepUpdate, OpBlockStepDelete, OpClientGroupCreate, OpClientGroupUpdate, OpClientGroupDelete}
|
||||
|
||||
// Request is an operation as submitted by a client. Requests are expanded
|
||||
// into one or more stored operations (for example an A record with "ptr"
|
||||
@@ -236,6 +244,31 @@ type BlocklistInput struct {
|
||||
ViewIDs *[]int64 `json:"view_ids,omitempty"`
|
||||
Disabled *bool `json:"disabled,omitempty"`
|
||||
Comment *string `json:"comment,omitempty"`
|
||||
// Step is the step the list starts at, by name ("normal" or a step
|
||||
// above it) or ID (StepID; 0 is normal).
|
||||
Step *string `json:"step,omitempty"`
|
||||
StepID *int64 `json:"step_id,omitempty"`
|
||||
}
|
||||
|
||||
// BlockStepInput creates or updates a blocking step above Normal.
|
||||
// Position orders the steps (new steps go on top).
|
||||
type BlockStepInput struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Position *int `json:"position,omitempty"`
|
||||
Comment *string `json:"comment,omitempty"`
|
||||
}
|
||||
|
||||
// ClientGroupInput creates or updates a client group. Members are
|
||||
// addresses, prefixes or ACL names; the step is given by name ("off",
|
||||
// "normal" or a step) or ID; views as for BlocklistInput.
|
||||
type ClientGroupInput struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Members *[]string `json:"members,omitempty"`
|
||||
Step *string `json:"step,omitempty"`
|
||||
StepID *int64 `json:"step_id,omitempty"`
|
||||
Views *[]string `json:"views,omitempty"`
|
||||
ViewIDs *[]int64 `json:"view_ids,omitempty"`
|
||||
Comment *string `json:"comment,omitempty"`
|
||||
}
|
||||
|
||||
// BlockRuleInput creates or updates a block or allow rule. Views as for
|
||||
|
||||
@@ -106,6 +106,14 @@ func applyOp(st *model.State, op model.Op, c *applyCtx) error {
|
||||
err = applyBlockRulePut(st, op, c)
|
||||
case OpBlockRuleDelete:
|
||||
err = applyBlockRuleDelete(st, op, c)
|
||||
case OpBlockStepCreate, OpBlockStepUpdate:
|
||||
err = applyBlockStepPut(st, op, c)
|
||||
case OpBlockStepDelete:
|
||||
err = applyBlockStepDelete(st, op, c)
|
||||
case OpClientGroupCreate, OpClientGroupUpdate:
|
||||
err = applyClientGroupPut(st, op, c)
|
||||
case OpClientGroupDelete:
|
||||
err = applyClientGroupDelete(st, op, c)
|
||||
case OpBlockingPause:
|
||||
err = applyBlockingPause(st, op, c)
|
||||
default:
|
||||
|
||||
@@ -152,6 +152,11 @@ func FindRefs(st *model.State, kind refKind, name string) []string {
|
||||
out = append(out, "view "+v.Name)
|
||||
}
|
||||
}
|
||||
for _, g := range st.SortedClientGroups() {
|
||||
if _, hit := rewriteElements(g.Members, kind, name, name); hit {
|
||||
out = append(out, "client group "+g.Name)
|
||||
}
|
||||
}
|
||||
var opts []string
|
||||
for _, o := range st.Options {
|
||||
stmt, err := namedconf.ParseOptionValue(o.Name, o.Value)
|
||||
@@ -188,6 +193,12 @@ func renameRefs(st *model.State, kind refKind, old, new string, c *applyCtx) {
|
||||
v.Touch(c.user(), c.now)
|
||||
}
|
||||
}
|
||||
for _, g := range st.ClientGroups {
|
||||
if l, hit := rewriteElements(g.Members, kind, old, new); hit {
|
||||
g.Members = l
|
||||
g.Touch(c.user(), c.now)
|
||||
}
|
||||
}
|
||||
for _, o := range st.Options {
|
||||
stmt, err := namedconf.ParseOptionValue(o.Name, o.Value)
|
||||
if err != nil {
|
||||
|
||||
+144
-1
@@ -1,6 +1,8 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -17,10 +19,126 @@ type Blocklist struct {
|
||||
// that offers recursion (see BlockingViews).
|
||||
ViewIDs []int64 `json:"view_ids"`
|
||||
Disabled bool `json:"disabled,omitempty"`
|
||||
Comment string `json:"comment"`
|
||||
// StepID is the blocking step the list starts at: StepNormal for
|
||||
// every client, or a BlockStep for the clients of groups at that step
|
||||
// or above.
|
||||
StepID int64 `json:"step_id"`
|
||||
Comment string `json:"comment"`
|
||||
Meta
|
||||
}
|
||||
|
||||
// Built-in blocking steps (BlockStep IDs are positive).
|
||||
const (
|
||||
StepOff int64 = -1 // no blocklists (rules still apply)
|
||||
StepNormal int64 = 0 // the default: every client not in a group
|
||||
)
|
||||
|
||||
// BlockStep is a blocking step above Normal. Steps are ordered: each
|
||||
// includes the lists of the steps below it, plus its own (docs/blocking.md).
|
||||
type BlockStep struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Position int `json:"position"`
|
||||
Comment string `json:"comment"`
|
||||
Meta
|
||||
}
|
||||
|
||||
// ClientGroup gives the clients with these addresses a blocking step.
|
||||
type ClientGroup struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
// Members are addresses, prefixes, or names of ACLs made of them.
|
||||
Members []string `json:"members"`
|
||||
StepID int64 `json:"step_id"` // StepOff, StepNormal or a BlockStep
|
||||
ViewIDs []int64 `json:"view_ids"` // as for Blocklist
|
||||
Comment string `json:"comment"`
|
||||
Meta
|
||||
}
|
||||
|
||||
// OrderedBlockSteps returns the steps above Normal, lowest first.
|
||||
func (s *State) OrderedBlockSteps() []*BlockStep {
|
||||
out := make([]*BlockStep, 0, len(s.BlockSteps))
|
||||
for _, st := range s.BlockSteps {
|
||||
out = append(out, st)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].Position != out[j].Position {
|
||||
return out[i].Position < out[j].Position
|
||||
}
|
||||
return out[i].ID < out[j].ID
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// StepRank orders steps: -1 for Off, 0 for Normal, 1, 2, ... for the
|
||||
// steps above it. ok is false for a step that doesn't exist.
|
||||
func (s *State) StepRank(id int64) (rank int, ok bool) {
|
||||
switch id {
|
||||
case StepOff:
|
||||
return -1, true
|
||||
case StepNormal:
|
||||
return 0, true
|
||||
}
|
||||
for i, st := range s.OrderedBlockSteps() {
|
||||
if st.ID == id {
|
||||
return i + 1, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// StepName returns a step's name ("off" and "normal" for the built-in
|
||||
// ones).
|
||||
func (s *State) StepName(id int64) string {
|
||||
switch id {
|
||||
case StepOff:
|
||||
return "off"
|
||||
case StepNormal:
|
||||
return "normal"
|
||||
}
|
||||
if st := s.BlockSteps[id]; st != nil {
|
||||
return st.Name
|
||||
}
|
||||
return fmt.Sprintf("step %d", id)
|
||||
}
|
||||
|
||||
// StepByName finds a step by name, the built-in ones included.
|
||||
func (s *State) StepByName(name string) (int64, bool) {
|
||||
name = strings.ToLower(strings.TrimSpace(name))
|
||||
switch name {
|
||||
case "off":
|
||||
return StepOff, true
|
||||
case "normal":
|
||||
return StepNormal, true
|
||||
}
|
||||
for _, st := range s.BlockSteps {
|
||||
if strings.EqualFold(st.Name, name) {
|
||||
return st.ID, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// ClientGroupByName finds a client group by name.
|
||||
func (s *State) ClientGroupByName(name string) *ClientGroup {
|
||||
for _, g := range s.ClientGroups {
|
||||
if strings.EqualFold(g.Name, name) {
|
||||
return g
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SortedClientGroups returns the client groups ordered by name.
|
||||
func (s *State) SortedClientGroups() []*ClientGroup {
|
||||
out := make([]*ClientGroup, 0, len(s.ClientGroups))
|
||||
for _, g := range s.ClientGroups {
|
||||
out = append(out, g)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||
return out
|
||||
}
|
||||
|
||||
// Block rule actions.
|
||||
const (
|
||||
BlockActionBlock = "block"
|
||||
@@ -134,3 +252,28 @@ func (s *State) BlockingViews(viewIDs []int64) []int64 {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// GroupPrefixes returns the addresses of a client group as prefixes: its
|
||||
// own, and those of the ACLs it names (and the ACLs they name). Elements
|
||||
// that aren't addresses are left out (validation reports them).
|
||||
func (s *State) GroupPrefixes(g *ClientGroup) []netip.Prefix {
|
||||
var out []netip.Prefix
|
||||
seen := map[string]bool{}
|
||||
var add func(elems []string)
|
||||
add = func(elems []string) {
|
||||
for _, e := range elems {
|
||||
e = strings.TrimSpace(e)
|
||||
if a, err := netip.ParseAddr(e); err == nil {
|
||||
a = a.Unmap()
|
||||
out = append(out, netip.PrefixFrom(a, a.BitLen()))
|
||||
} else if p, err := netip.ParsePrefix(e); err == nil {
|
||||
out = append(out, p.Masked())
|
||||
} else if acl := s.ACLByName(e); acl != nil && !seen[acl.Name] {
|
||||
seen[acl.Name] = true
|
||||
add(acl.Elements)
|
||||
}
|
||||
}
|
||||
}
|
||||
add(g.Members)
|
||||
return out
|
||||
}
|
||||
+29
-12
@@ -9,6 +9,7 @@ package model
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -247,8 +248,10 @@ type State struct {
|
||||
Records map[int64]*Record
|
||||
Options map[int64]*Option
|
||||
// Blocking (docs/blocking.md).
|
||||
Blocklists map[int64]*Blocklist
|
||||
BlockRules map[int64]*BlockRule
|
||||
Blocklists map[int64]*Blocklist
|
||||
BlockRules map[int64]*BlockRule
|
||||
BlockSteps map[int64]*BlockStep
|
||||
ClientGroups map[int64]*ClientGroup
|
||||
}
|
||||
|
||||
// NewState returns an empty state with default settings.
|
||||
@@ -262,8 +265,10 @@ func NewState() *State {
|
||||
Records: map[int64]*Record{},
|
||||
Options: map[int64]*Option{},
|
||||
|
||||
Blocklists: map[int64]*Blocklist{},
|
||||
BlockRules: map[int64]*BlockRule{},
|
||||
Blocklists: map[int64]*Blocklist{},
|
||||
BlockRules: map[int64]*BlockRule{},
|
||||
BlockSteps: map[int64]*BlockStep{},
|
||||
ClientGroups: map[int64]*ClientGroup{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -278,14 +283,16 @@ func (s *State) Clone() *State {
|
||||
Records: make(map[int64]*Record, len(s.Records)),
|
||||
Options: make(map[int64]*Option, len(s.Options)),
|
||||
|
||||
Blocklists: make(map[int64]*Blocklist, len(s.Blocklists)),
|
||||
BlockRules: make(map[int64]*BlockRule, len(s.BlockRules)),
|
||||
Blocklists: make(map[int64]*Blocklist, len(s.Blocklists)),
|
||||
BlockRules: make(map[int64]*BlockRule, len(s.BlockRules)),
|
||||
BlockSteps: make(map[int64]*BlockStep, len(s.BlockSteps)),
|
||||
ClientGroups: make(map[int64]*ClientGroup, len(s.ClientGroups)),
|
||||
}
|
||||
c.Settings.Nameservers = append([]string(nil), s.Settings.Nameservers...)
|
||||
c.Settings.Nameservers = slices.Clone(s.Settings.Nameservers)
|
||||
for id, v := range s.Views {
|
||||
n := *v
|
||||
n.MatchClients = append([]string(nil), v.MatchClients...)
|
||||
n.MatchDestinations = append([]string(nil), v.MatchDestinations...)
|
||||
n.MatchClients = slices.Clone(v.MatchClients)
|
||||
n.MatchDestinations = slices.Clone(v.MatchDestinations)
|
||||
if v.MatchRecursiveOnly != nil {
|
||||
b := *v.MatchRecursiveOnly
|
||||
n.MatchRecursiveOnly = &b
|
||||
@@ -294,7 +301,7 @@ func (s *State) Clone() *State {
|
||||
}
|
||||
for id, a := range s.ACLs {
|
||||
n := *a
|
||||
n.Elements = append([]string(nil), a.Elements...)
|
||||
n.Elements = slices.Clone(a.Elements)
|
||||
c.ACLs[id] = &n
|
||||
}
|
||||
for id, k := range s.Keys {
|
||||
@@ -319,14 +326,24 @@ func (s *State) Clone() *State {
|
||||
}
|
||||
for id, b := range s.Blocklists {
|
||||
n := *b
|
||||
n.ViewIDs = append([]int64(nil), b.ViewIDs...)
|
||||
n.ViewIDs = slices.Clone(b.ViewIDs)
|
||||
c.Blocklists[id] = &n
|
||||
}
|
||||
for id, r := range s.BlockRules {
|
||||
n := *r
|
||||
n.ViewIDs = append([]int64(nil), r.ViewIDs...)
|
||||
n.ViewIDs = slices.Clone(r.ViewIDs)
|
||||
c.BlockRules[id] = &n
|
||||
}
|
||||
for id, st := range s.BlockSteps {
|
||||
n := *st
|
||||
c.BlockSteps[id] = &n
|
||||
}
|
||||
for id, g := range s.ClientGroups {
|
||||
n := *g
|
||||
n.Members = slices.Clone(g.Members)
|
||||
n.ViewIDs = slices.Clone(g.ViewIDs)
|
||||
c.ClientGroups[id] = &n
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package render
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
@@ -21,9 +22,16 @@ type PolicyZone struct {
|
||||
ViewID int64 // 0 without views
|
||||
View string // "" without views
|
||||
File string
|
||||
ListID int64 // the blocklist; 0 for the rules zone
|
||||
ListID int64 // the blocklist; 0 for the rules zone and gates
|
||||
// Gate, for a gate zone: the clients it lets through (past every zone
|
||||
// after it), and the step whose lists it is in front of.
|
||||
Gate []netip.Prefix
|
||||
GateStep string
|
||||
}
|
||||
|
||||
// IsGate reports whether z is a gate zone.
|
||||
func (z PolicyZone) IsGate() bool { return z.GateStep != "" }
|
||||
|
||||
// BlocklistsDir holds the zone files of downloaded blocklists, one per
|
||||
// list, shared by the views that use it.
|
||||
func (p Paths) BlocklistsDir() string { return filepath.Join(p.CacheDir, "blocklists") }
|
||||
@@ -41,7 +49,9 @@ func (p Paths) rulesFile(view string) string {
|
||||
|
||||
// PolicyZones returns the policy zones named loads, per view in view
|
||||
// order, each view's in the order named checks them: the rules zone first
|
||||
// (rules win over lists), then the enabled lists by name.
|
||||
// (rules win over lists), then for each step (normal, then the steps above
|
||||
// it) a gate letting through the clients below that step, when there are
|
||||
// any, and the step's enabled lists by name.
|
||||
func PolicyZones(s *model.State, p Paths) []PolicyZone {
|
||||
var out []PolicyZone
|
||||
rules, lists := s.SortedBlockRules(), s.SortedBlocklists()
|
||||
@@ -65,10 +75,26 @@ func PolicyZones(s *model.State, p Paths) []PolicyZone {
|
||||
break
|
||||
}
|
||||
}
|
||||
for _, b := range lists {
|
||||
if !b.Disabled && slices.Contains(s.BlockingViews(b.ViewIDs), vid) {
|
||||
zones = append(zones, PolicyZone{Name: blocking.ListZone(b.Name), ViewID: vid, View: view, File: p.BlocklistFile(b.ID), ListID: b.ID})
|
||||
assigns := ClientAssignments(s, vid)
|
||||
for k, step := range append([]int64{model.StepNormal}, stepIDs(s)...) {
|
||||
var tier []PolicyZone
|
||||
for _, b := range lists {
|
||||
if !b.Disabled && b.StepID == step && slices.Contains(s.BlockingViews(b.ViewIDs), vid) {
|
||||
tier = append(tier, PolicyZone{Name: blocking.ListZone(b.Name), ViewID: vid, View: view, File: p.BlocklistFile(b.ID), ListID: b.ID})
|
||||
}
|
||||
}
|
||||
if len(tier) == 0 {
|
||||
continue
|
||||
}
|
||||
// The clients below this step pass, and never see its lists.
|
||||
if pass := blocking.PassBelow(assigns, k); len(pass) > 0 {
|
||||
name := "off"
|
||||
if k > 0 {
|
||||
name = s.StepName(step)
|
||||
}
|
||||
zones = append(zones, PolicyZone{Name: blocking.GateZone(name), ViewID: vid, View: view, File: p.gateFile(view, name), Gate: pass, GateStep: s.StepName(step)})
|
||||
}
|
||||
zones = append(zones, tier...)
|
||||
}
|
||||
// While paused, every view with blocking gets a rules zone: it
|
||||
// lets everything through.
|
||||
@@ -80,6 +106,39 @@ func PolicyZones(s *model.State, p Paths) []PolicyZone {
|
||||
return out
|
||||
}
|
||||
|
||||
// gateFile returns a view's gate zone file for a step.
|
||||
func (p Paths) gateFile(view, name string) string {
|
||||
return filepath.Join(p.ZoneDir, ViewDir(view), blocking.GateZone(name)+".rpz")
|
||||
}
|
||||
|
||||
// stepIDs returns the IDs of the steps above normal, lowest first.
|
||||
func stepIDs(s *model.State) []int64 {
|
||||
var out []int64
|
||||
for _, st := range s.OrderedBlockSteps() {
|
||||
out = append(out, st.ID)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ClientAssignments returns the step ranks the client groups of a view
|
||||
// give addresses (blocking.PassBelow).
|
||||
func ClientAssignments(s *model.State, viewID int64) []blocking.Assignment {
|
||||
var out []blocking.Assignment
|
||||
for _, g := range s.SortedClientGroups() {
|
||||
if !slices.Contains(s.BlockingViews(g.ViewIDs), viewID) {
|
||||
continue
|
||||
}
|
||||
rank, ok := s.StepRank(g.StepID)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, p := range s.GroupPrefixes(g) {
|
||||
out = append(out, blocking.Assignment{Prefix: p, Rank: rank})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ViewRules returns the rules that apply in a view (0 without views).
|
||||
func ViewRules(s *model.State, viewID int64) []blocking.Rule {
|
||||
var out []blocking.Rule
|
||||
@@ -106,7 +165,9 @@ func writeResponsePolicy(w *confWriter, zones []PolicyZone, logRewrites bool) {
|
||||
// changed (at most every blocking.refresh), so named's default
|
||||
// delay for batching frequent updates only adds a wait.
|
||||
opts := []string{"min-update-interval 0"}
|
||||
if !logRewrites {
|
||||
// A gate lets most queries through: logging that would double
|
||||
// the response log, and it blocks nothing.
|
||||
if !logRewrites || z.IsGate() {
|
||||
opts = append(opts, "log no")
|
||||
}
|
||||
w.write(fmt.Sprintf("\t\tzone %s%s;\n", namedconf.QuoteString(z.Name), prefixSpace(strings.Join(opts, " "))))
|
||||
@@ -132,8 +193,11 @@ func writePolicyZones(w *confWriter, s *model.State, zones []PolicyZone, out *Ou
|
||||
for _, z := range zones {
|
||||
w.write("\n")
|
||||
label := "blocking: your rules"
|
||||
if z.ListID != 0 {
|
||||
switch {
|
||||
case z.ListID != 0:
|
||||
label = "blocking: blocklist " + s.Blocklists[z.ListID].Name
|
||||
case z.IsGate():
|
||||
label = "blocking: clients below step " + z.GateStep
|
||||
}
|
||||
w.begin("blocking", z.ListID, label)
|
||||
w.write(fmt.Sprintf("%szone %s {\n%s\ttype primary;\n%s\tfile %s;\n%s\tallow-query { none; };\n%s\tallow-transfer { none; };\n%s\tnotify no;\n%s};\n",
|
||||
@@ -143,6 +207,14 @@ func writePolicyZones(w *confWriter, s *model.State, zones []PolicyZone, out *Ou
|
||||
out.Blocklists = appendUnique(out.Blocklists, z.File)
|
||||
continue
|
||||
}
|
||||
if z.IsGate() {
|
||||
title := "Clients that skip the lists from step " + z.GateStep + " on"
|
||||
if z.View != "" {
|
||||
title += " in view " + z.View
|
||||
}
|
||||
out.Files = append(out.Files, &File{Path: z.File, Content: []byte(blocking.GateZoneFile(title, z.Gate)), Mode: 0o640, Kind: KindPolicy})
|
||||
continue
|
||||
}
|
||||
title := "Blocking rules"
|
||||
if z.View != "" {
|
||||
title += " for view " + z.View
|
||||
|
||||
@@ -33,11 +33,11 @@ func viewNames(st *model.State, ids []int64) []string {
|
||||
|
||||
func presentBlocklist(st *model.State, b *model.Blocklist, status *model.BlocklistStatus) api.Blocklist {
|
||||
views := st.BlockingViews(b.ViewIDs)
|
||||
out := api.Blocklist{Blocklist: *b, Views: viewNames(st, views), AutoViews: len(b.ViewIDs) == 0 && len(st.Views) > 0, Status: status}
|
||||
out := api.Blocklist{Blocklist: *b, Views: viewNames(st, views), AutoViews: len(b.ViewIDs) == 0 && len(st.Views) > 0, Status: status, Step: st.StepName(b.StepID)}
|
||||
switch {
|
||||
case b.Disabled:
|
||||
out.State = "disabled"
|
||||
case len(views) == 0:
|
||||
case len(views) == 0 || !stepReached(st, b, views):
|
||||
out.State = "idle"
|
||||
case status != nil && status.Error != "":
|
||||
out.State = "error"
|
||||
|
||||
@@ -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("PATCH "+p+"/blocking/rules/{id}", s.authed(s.updateBlockRule))
|
||||
m.HandleFunc("DELETE "+p+"/blocking/rules/{id}", s.authed(s.deleteBlockRule))
|
||||
m.HandleFunc("GET "+p+"/blocking/steps", s.authed(s.listBlockSteps))
|
||||
m.HandleFunc("POST "+p+"/blocking/steps", s.authed(s.createBlockStep))
|
||||
m.HandleFunc("POST "+p+"/blocking/steps/order", s.authed(s.orderBlockSteps))
|
||||
m.HandleFunc("PATCH "+p+"/blocking/steps/{id}", s.authed(s.updateBlockStep))
|
||||
m.HandleFunc("DELETE "+p+"/blocking/steps/{id}", s.authed(s.deleteBlockStep))
|
||||
m.HandleFunc("GET "+p+"/blocking/groups", s.authed(s.listClientGroups))
|
||||
m.HandleFunc("POST "+p+"/blocking/groups", s.authed(s.createClientGroup))
|
||||
m.HandleFunc("PATCH "+p+"/blocking/groups/{id}", s.authed(s.updateClientGroup))
|
||||
m.HandleFunc("DELETE "+p+"/blocking/groups/{id}", s.authed(s.deleteClientGroup))
|
||||
m.HandleFunc("GET "+p+"/blocking/check", s.authed(s.checkBlocking))
|
||||
m.HandleFunc("GET "+p+"/blocking/status", s.authed(s.blockingStatus))
|
||||
m.HandleFunc("POST "+p+"/blocking/pause", s.authed(s.pauseBlocking(false)))
|
||||
|
||||
+59
-7
@@ -150,14 +150,14 @@ func loadState(ctx context.Context, e execer) (*model.State, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rows, err = e.QueryContext(ctx, `SELECT id, name, url, view_ids, disabled, comment, version, created_at, updated_at, created_by, updated_by FROM blocklists`)
|
||||
rows, err = e.QueryContext(ctx, `SELECT id, name, url, view_ids, disabled, step_id, comment, version, created_at, updated_at, created_by, updated_by FROM blocklists`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for rows.Next() {
|
||||
b := &model.Blocklist{}
|
||||
var views, ca, ua string
|
||||
if err := rows.Scan(&b.ID, &b.Name, &b.URL, &views, &b.Disabled, &b.Comment, &b.Version, &ca, &ua, &b.CreatedBy, &b.UpdatedBy); err != nil {
|
||||
if err := rows.Scan(&b.ID, &b.Name, &b.URL, &views, &b.Disabled, &b.StepID, &b.Comment, &b.Version, &ca, &ua, &b.CreatedBy, &b.UpdatedBy); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
@@ -187,6 +187,44 @@ func loadState(ctx context.Context, e execer) (*model.State, error) {
|
||||
if err := closeRows(rows); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rows, err = e.QueryContext(ctx, `SELECT id, name, position, comment, version, created_at, updated_at, created_by, updated_by FROM block_steps`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for rows.Next() {
|
||||
s := &model.BlockStep{}
|
||||
var ca, ua string
|
||||
if err := rows.Scan(&s.ID, &s.Name, &s.Position, &s.Comment, &s.Version, &ca, &ua, &s.CreatedBy, &s.UpdatedBy); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
s.CreatedAt, s.UpdatedAt = parseTS(ca), parseTS(ua)
|
||||
st.BlockSteps[s.ID] = s
|
||||
}
|
||||
if err := closeRows(rows); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rows, err = e.QueryContext(ctx, `SELECT id, name, members, step_id, view_ids, comment, version, created_at, updated_at, created_by, updated_by FROM client_groups`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for rows.Next() {
|
||||
g := &model.ClientGroup{}
|
||||
var members, views, ca, ua string
|
||||
if err := rows.Scan(&g.ID, &g.Name, &members, &g.StepID, &views, &g.Comment, &g.Version, &ca, &ua, &g.CreatedBy, &g.UpdatedBy); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
json.Unmarshal([]byte(members), &g.Members)
|
||||
json.Unmarshal([]byte(views), &g.ViewIDs)
|
||||
g.CreatedAt, g.UpdatedAt = parseTS(ca), parseTS(ua)
|
||||
st.ClientGroups[g.ID] = g
|
||||
}
|
||||
if err := closeRows(rows); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return st, nil
|
||||
}
|
||||
|
||||
@@ -204,7 +242,7 @@ func (t *Tx) SaveState(old, new *model.State) error {
|
||||
// ReplaceState replaces the stored configuration with st (used by import
|
||||
// and tests).
|
||||
func (t *Tx) ReplaceState(st *model.State) error {
|
||||
for _, tbl := range []string{"views", "acls", "tsig_keys", "zones", "records", "options", "blocklists", "block_rules"} {
|
||||
for _, tbl := range []string{"views", "acls", "tsig_keys", "zones", "records", "options", "blocklists", "block_rules", "block_steps", "client_groups"} {
|
||||
if _, err := t.tx.ExecContext(t.ctx, "DELETE FROM "+tbl); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -272,17 +310,31 @@ func saveState(ctx context.Context, e execer, old, new *model.State) error {
|
||||
return err
|
||||
}
|
||||
if err := diffMap(old.Blocklists, new.Blocklists, func(b *model.Blocklist) error {
|
||||
_, err := e.ExecContext(ctx, `INSERT OR REPLACE INTO blocklists (id, name, url, view_ids, disabled, comment, version, created_at, updated_at, created_by, updated_by) VALUES (?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
b.ID, b.Name, b.URL, jsonText(nonNilIDs(b.ViewIDs)), b.Disabled, b.Comment, b.Version, ts(b.CreatedAt), ts(b.UpdatedAt), b.CreatedBy, b.UpdatedBy)
|
||||
_, err := e.ExecContext(ctx, `INSERT OR REPLACE INTO blocklists (id, name, url, view_ids, disabled, step_id, comment, version, created_at, updated_at, created_by, updated_by) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
b.ID, b.Name, b.URL, jsonText(nonNilIDs(b.ViewIDs)), b.Disabled, b.StepID, b.Comment, b.Version, ts(b.CreatedAt), ts(b.UpdatedAt), b.CreatedBy, b.UpdatedBy)
|
||||
return err
|
||||
}, func(id int64) error { return del(ctx, e, "blocklists", id) }); err != nil {
|
||||
return err
|
||||
}
|
||||
return diffMap(old.BlockRules, new.BlockRules, func(r *model.BlockRule) error {
|
||||
if err := diffMap(old.BlockRules, new.BlockRules, func(r *model.BlockRule) error {
|
||||
_, err := e.ExecContext(ctx, `INSERT OR REPLACE INTO block_rules (id, name, action, exact, view_ids, comment, version, created_at, updated_at, created_by, updated_by) VALUES (?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
r.ID, r.Name, r.Action, r.Exact, jsonText(nonNilIDs(r.ViewIDs)), r.Comment, r.Version, ts(r.CreatedAt), ts(r.UpdatedAt), r.CreatedBy, r.UpdatedBy)
|
||||
return err
|
||||
}, func(id int64) error { return del(ctx, e, "block_rules", id) })
|
||||
}, func(id int64) error { return del(ctx, e, "block_rules", id) }); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := diffMap(old.BlockSteps, new.BlockSteps, func(s *model.BlockStep) error {
|
||||
_, err := e.ExecContext(ctx, `INSERT OR REPLACE INTO block_steps (id, name, position, comment, version, created_at, updated_at, created_by, updated_by) VALUES (?,?,?,?,?,?,?,?,?)`,
|
||||
s.ID, s.Name, s.Position, s.Comment, s.Version, ts(s.CreatedAt), ts(s.UpdatedAt), s.CreatedBy, s.UpdatedBy)
|
||||
return err
|
||||
}, func(id int64) error { return del(ctx, e, "block_steps", id) }); err != nil {
|
||||
return err
|
||||
}
|
||||
return diffMap(old.ClientGroups, new.ClientGroups, func(g *model.ClientGroup) error {
|
||||
_, err := e.ExecContext(ctx, `INSERT OR REPLACE INTO client_groups (id, name, members, step_id, view_ids, comment, version, created_at, updated_at, created_by, updated_by) VALUES (?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
g.ID, g.Name, jsonText(nonNil(g.Members)), g.StepID, jsonText(nonNilIDs(g.ViewIDs)), g.Comment, g.Version, ts(g.CreatedAt), ts(g.UpdatedAt), g.CreatedBy, g.UpdatedBy)
|
||||
return err
|
||||
}, func(id int64) error { return del(ctx, e, "client_groups", id) })
|
||||
}
|
||||
|
||||
func nonNilIDs(ids []int64) []int64 {
|
||||
|
||||
@@ -304,6 +304,28 @@ CREATE TABLE blocklist_status (
|
||||
// 7: disabled zones, kept but not loaded by named.
|
||||
`
|
||||
ALTER TABLE zones ADD COLUMN disabled INTEGER NOT NULL DEFAULT 0;
|
||||
`,
|
||||
// 8: blocking steps and client groups (docs/blocking.md).
|
||||
`
|
||||
ALTER TABLE blocklists ADD COLUMN step_id INTEGER NOT NULL DEFAULT 0;
|
||||
CREATE TABLE block_steps (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
comment TEXT NOT NULL DEFAULT '',
|
||||
version INTEGER NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
|
||||
created_by TEXT NOT NULL DEFAULT '', updated_by TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
CREATE TABLE client_groups (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
members TEXT NOT NULL DEFAULT '[]',
|
||||
step_id INTEGER NOT NULL DEFAULT 0,
|
||||
view_ids TEXT NOT NULL DEFAULT '[]',
|
||||
comment TEXT NOT NULL DEFAULT '',
|
||||
version INTEGER NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
|
||||
created_by TEXT NOT NULL DEFAULT '', updated_by TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
`,
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package validate
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"strings"
|
||||
|
||||
"jarl/internal/model"
|
||||
"jarl/internal/namedconf"
|
||||
)
|
||||
|
||||
// checkBlocking checks blocklists and block/allow rules (docs/blocking.md).
|
||||
@@ -67,6 +69,7 @@ func (c *checker) checkBlocking() {
|
||||
seen[key] = r
|
||||
}
|
||||
}
|
||||
c.checkClientGroups(effective)
|
||||
// Jarl writes the response-policy statement where blocking is used;
|
||||
// named allows one per view (or for the server).
|
||||
for vid := range used {
|
||||
@@ -91,3 +94,94 @@ func (c *checker) blockingScope(viewID int64) string {
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// checkClientGroups checks blocking steps and client groups: steps exist,
|
||||
// members are addresses (directly or through ACLs), and no address is in
|
||||
// two groups of the same view.
|
||||
func (c *checker) checkClientGroups(effective func(objType string, id int64, label string, ids []int64) []int64) {
|
||||
for _, b := range c.s.SortedBlocklists() {
|
||||
if _, ok := c.s.StepRank(b.StepID); !ok || b.StepID == model.StepOff {
|
||||
c.errf("blocking_step_missing", "blocklist", b.ID, "blocklist %s is at step %d, which doesn't exist", b.Name, b.StepID)
|
||||
}
|
||||
}
|
||||
// The highest step any group reaches, per view: lists above it block
|
||||
// nothing.
|
||||
top := map[int64]int{}
|
||||
seen := map[string]*model.ClientGroup{} // view|prefix -> first group
|
||||
for _, g := range c.s.SortedClientGroups() {
|
||||
label := "client group " + g.Name
|
||||
rank, ok := c.s.StepRank(g.StepID)
|
||||
if !ok {
|
||||
c.errf("blocking_step_missing", "clientgroup", g.ID, "%s is at step %d, which doesn't exist", label, g.StepID)
|
||||
}
|
||||
if len(g.Members) == 0 {
|
||||
c.warnf("client_group_empty", "clientgroup", g.ID, "%s has no members", label)
|
||||
}
|
||||
for _, m := range g.Members {
|
||||
c.checkMember(g, m, map[string]bool{})
|
||||
}
|
||||
views := effective("clientgroup", g.ID, label, g.ViewIDs)
|
||||
prefixes := c.s.GroupPrefixes(g)
|
||||
for _, vid := range views {
|
||||
if len(prefixes) > 0 {
|
||||
top[vid] = max(top[vid], rank)
|
||||
}
|
||||
for _, p := range prefixes {
|
||||
key := c.blockingScope(vid) + "|" + p.String()
|
||||
if first := seen[key]; first != nil && first.ID != g.ID {
|
||||
c.errf("client_group_overlap", "clientgroup", g.ID, "%s is in client groups %s and %s; a client can only be in one (a more specific prefix in another group is fine)", p, first.Name, g.Name)
|
||||
continue
|
||||
}
|
||||
seen[key] = g
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, b := range c.s.SortedBlocklists() {
|
||||
rank, ok := c.s.StepRank(b.StepID)
|
||||
if !ok || rank == 0 || b.Disabled {
|
||||
continue
|
||||
}
|
||||
reached := false
|
||||
for _, vid := range c.s.BlockingViews(b.ViewIDs) {
|
||||
if top[vid] >= rank {
|
||||
reached = true
|
||||
}
|
||||
}
|
||||
if !reached {
|
||||
c.warnf("blocklist_step_unused", "blocklist", b.ID, "blocklist %s blocks nothing: it is at step %s, and no client group is at that step or above", b.Name, c.s.StepName(b.StepID))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// checkMember checks one member of a client group: an address, a prefix,
|
||||
// or an ACL whose elements are (recursively) addresses and prefixes.
|
||||
func (c *checker) checkMember(g *model.ClientGroup, m string, seen map[string]bool) {
|
||||
if _, err := netip.ParseAddr(m); err == nil {
|
||||
return
|
||||
}
|
||||
if _, err := netip.ParsePrefix(m); err == nil {
|
||||
return
|
||||
}
|
||||
acl := c.s.ACLByName(m)
|
||||
if acl == nil {
|
||||
c.errf("client_group_member_invalid", "clientgroup", g.ID, "client group %s: %q is not an address, a prefix or an ACL", g.Name, m)
|
||||
return
|
||||
}
|
||||
if seen[acl.Name] {
|
||||
return
|
||||
}
|
||||
seen[acl.Name] = true
|
||||
for _, e := range acl.Elements {
|
||||
el, err := namedconf.ParseAMElement(e)
|
||||
if err != nil {
|
||||
continue // reported for the ACL
|
||||
}
|
||||
if el.Negated || el.Kind == namedconf.AMKey || el.Kind == namedconf.AMNested || el.Kind == namedconf.AMName && el.IsBuiltin() {
|
||||
c.errf("client_group_member_invalid", "clientgroup", g.ID, "client group %s uses ACL %s, whose element %q isn't an address or a prefix: blocking matches clients by address only", g.Name, acl.Name, e)
|
||||
continue
|
||||
}
|
||||
if el.Kind == namedconf.AMName {
|
||||
c.checkMember(g, el.Name, seen)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,8 +24,9 @@ export async function render(el, params, query) {
|
||||
clearTimeout(refreshTimer);
|
||||
// Queries blocked in the last day, per list and for the rules together
|
||||
// (null without query statistics, or without permission to see them).
|
||||
const [lists, rules, views, status, hitList] = await Promise.all([get("/blocking/lists", draftQuery()), get("/blocking/rules", draftQuery()), get("/views"), get("/blocking/status"),
|
||||
can("stats") ? get("/stats/breakdown", { group: "blocklist", since: "24h" }).catch(() => null) : null]);
|
||||
const [lists, rules, views, status, hitList, steps, groups] = await Promise.all([get("/blocking/lists", draftQuery()), get("/blocking/rules", draftQuery()), get("/views"), get("/blocking/status"),
|
||||
can("stats") ? get("/stats/breakdown", { group: "blocklist", since: "24h" }).catch(() => null) : null,
|
||||
get("/blocking/steps", draftQuery()), get("/blocking/groups", draftQuery())]);
|
||||
const hits = hitList ? Object.fromEntries(hitList.map((e) => [e.key, e.count])) : null;
|
||||
const writable = can("blocking", true);
|
||||
const recursive = lists.concat(rules).find((x) => x.auto_views);
|
||||
@@ -58,7 +59,8 @@ export async function render(el, params, query) {
|
||||
h("label", { class: "muted" }, "Check a name"), checkName, h("button", { type: "submit" }, "Check")),
|
||||
checkOut),
|
||||
rulesPanel(rules, views, autoViews, writable, hits),
|
||||
listsPanel(lists, views, autoViews, writable, hits));
|
||||
listsPanel(lists, views, autoViews, writable, hits, steps),
|
||||
devicesPanel(groups, steps, views, autoViews, writable));
|
||||
if (checkName.value) runCheck();
|
||||
|
||||
// Lists are downloaded in the background, and a pause ends by itself:
|
||||
@@ -114,7 +116,8 @@ function checkResult(name, res, rules, writable) {
|
||||
m.view ? h("span", { class: "muted" }, "view " + m.view + ": ") : null,
|
||||
m.result === "blocked" ? badge("blocked", "err") : m.result === "allowed" ? badge("allowed", "ok") : badge("not blocked", ""),
|
||||
" ",
|
||||
m.list ? ["by blocklist ", h("strong", null, m.list), " (entry ", h("code", null, m.entry), ")"]
|
||||
m.list && m.step ? ["for devices at step ", h("strong", null, m.step), " and above, by blocklist ", h("strong", null, m.list), " (entry ", h("code", null, m.entry), "); other devices aren't blocked"]
|
||||
: m.list ? ["by blocklist ", h("strong", null, m.list), " (entry ", h("code", null, m.entry), ")"]
|
||||
: m.result !== "none" ? ["by your rule to ", rule ? rule.action : "", " ", h("code", null, rule ? rule.name : m.entry.replace(/^\*\./, "")), rule && !rule.exact ? " and below" : ""] : null);
|
||||
})),
|
||||
action);
|
||||
@@ -176,7 +179,8 @@ async function editDialog(title, path, rows, data) {
|
||||
], { sticky: true });
|
||||
}
|
||||
|
||||
function editList(l, views, autoViews) {
|
||||
function editList(l, views, autoViews, steps) {
|
||||
const step = stepSelect(steps, l.step, false);
|
||||
const name = input({ value: l.name, class: "mono" });
|
||||
const url = input({ value: l.url, class: "mono", style: { width: "100%" } });
|
||||
const comment = input({ value: l.comment || "" });
|
||||
@@ -184,8 +188,9 @@ function editList(l, views, autoViews) {
|
||||
editDialog("Edit blocklist " + l.name, "/blocking/lists/" + l.id, [
|
||||
["Name", name], ["URL", url, "A new URL is downloaded right after saving."],
|
||||
vp.picker ? ["Views", vp.picker, "None ticked: every view that offers recursion."] : null,
|
||||
steps.length ? ["Step", step, "Devices at this step or above get the list. normal: every device except those at off."] : null,
|
||||
["Comment", comment],
|
||||
], () => ({ name: name.value.trim(), url: url.value.trim(), views: vp.value(), comment: comment.value }));
|
||||
], () => ({ name: name.value.trim(), url: url.value.trim(), views: vp.value(), comment: comment.value, step: step.value }));
|
||||
}
|
||||
|
||||
function editRule(r, views, autoViews) {
|
||||
@@ -253,21 +258,33 @@ function rulesPanel(rules, views, autoViews, writable, hits) {
|
||||
].filter(Boolean), rules, { empty: "No rules yet.", rowClass: (r) => r.pending ? "pending-" + r.pending : "" }));
|
||||
}
|
||||
|
||||
function listsPanel(lists, views, autoViews, writable, hits) {
|
||||
// stepSelect picks a step: off (only for groups), normal and the steps
|
||||
// above it.
|
||||
function stepSelect(steps, value, withOff) {
|
||||
const opts = [];
|
||||
if (withOff) opts.push(["off", "off (only your rules)"]);
|
||||
opts.push(["normal", "normal" + (withOff ? " (like devices in no group)" : " (every device)")]);
|
||||
steps.filter((s) => s.pending !== "delete").forEach((s) => opts.push([s.name, s.name]));
|
||||
return select(opts, value || "normal");
|
||||
}
|
||||
|
||||
function listsPanel(lists, views, autoViews, writable, hits, steps) {
|
||||
const name = input({ placeholder: "hagezi-multi", class: "mono", style: { width: "11em" } });
|
||||
const url = input({ placeholder: "https://…", class: "mono" });
|
||||
const vp = viewPicker(views, autoViews);
|
||||
const listStep = stepSelect(steps, "normal", false);
|
||||
const err = h("div");
|
||||
const form = h("form", { class: "inline-form", onsubmit: async (ev) => {
|
||||
ev.preventDefault();
|
||||
try {
|
||||
const r = await change("POST", "/blocking/lists", { name: name.value.trim(), url: url.value.trim(), views: vp.value() });
|
||||
const r = await change("POST", "/blocking/lists", { name: name.value.trim(), url: url.value.trim(), views: vp.value(), step: listStep.value });
|
||||
toast(describeResult(r) + (r.applied ? " — jarld downloads it now" : ""));
|
||||
rerender();
|
||||
} catch (e) { mount(err, errorBox(e)); }
|
||||
} },
|
||||
h("div", { class: "f" }, h("label", null, "Name"), name),
|
||||
h("div", { class: "f grow" }, h("label", null, "URL"), url),
|
||||
steps.length ? h("div", { class: "f" }, h("label", null, "Step"), listStep) : null,
|
||||
vp.el,
|
||||
h("div", { class: "f" }, h("label", null, " "), h("button", { class: "primary", type: "submit" }, "Add list")));
|
||||
const used = new Set(lists.map((l) => l.url));
|
||||
@@ -284,12 +301,13 @@ function listsPanel(lists, views, autoViews, writable, hits) {
|
||||
{ label: "State", render: (l) => { const [t, k] = stateBadge[l.state] || [l.state, ""]; return [badge(t, k), l.status && l.status.error ? h("div", { class: "small err-text" }, l.status.error) : null]; } },
|
||||
{ label: "Domains", render: (l) => l.status && l.status.updated_at ? l.status.entries.toLocaleString() : "–", tdClass: "right" },
|
||||
hits ? { label: "Blocked (24h)", render: (l) => hitsLink(hits[l.name]), class: "right", tdClass: "right" } : null,
|
||||
steps.length ? { label: "Step", render: (l) => l.step === "normal" ? h("span", { class: "muted" }, "normal") : badge(l.step, "accent") } : null,
|
||||
views.length ? { label: "Views", render: viewsCell } : null,
|
||||
{ label: "Updated", render: (l) => l.status && l.status.updated_at ? h("span", { title: "checked " + time(l.status.checked_at) }, relTime(l.status.updated_at)) : "–", tdClass: "small" },
|
||||
{ label: "Memory", render: (l) => l.memory_mb ? h("span", { title: "named's memory for this list, estimated (all its views)" }, "~" + l.memory_mb + " MB") : "–", tdClass: "small right" },
|
||||
{ label: "URL", render: (l) => h("a", { href: l.url, target: "_blank", rel: "noopener", class: "small mono", title: l.url }, shortURL(l.url)) },
|
||||
writable ? { label: "", render: (l) => l.pending === "delete" ? "" : h("span", null,
|
||||
h("button", { class: "small", onclick: () => editList(l, views, autoViews) }, "Edit"), " ",
|
||||
h("button", { class: "small", onclick: () => editList(l, views, autoViews, steps) }, "Edit"), " ",
|
||||
h("button", { class: "small", disabled: l.state === "disabled" || !!l.pending, title: "Download it now (it is checked daily anyway)", onclick: () => refresh(l) }, "Refresh"), " ",
|
||||
h("button", { class: "small", onclick: () => toggle(l) }, l.disabled ? "Enable" : "Disable"), " ",
|
||||
h("button", { class: "small danger", onclick: () => del("/blocking/lists/" + l.id, "blocklist " + l.name) }, "Delete")), tdClass: "actions-cell" } : null,
|
||||
@@ -329,3 +347,125 @@ async function del(path, what) {
|
||||
} catch (e) { toast(e.message, "err", 8000); }
|
||||
}
|
||||
|
||||
|
||||
// ---- devices: blocking steps and client groups (docs/blocking.md)
|
||||
|
||||
// devicesPanel shows the steps (each includes the lists of the steps
|
||||
// below it) and the client groups that put devices at a step.
|
||||
function devicesPanel(groups, steps, views, autoViews, writable) {
|
||||
const live = steps.filter((s) => s.pending !== "delete");
|
||||
const name = input({ placeholder: "kids", class: "mono", style: { width: "9em" } });
|
||||
const members = input({ placeholder: "10.10.0.50 10.10.0.51 or an ACL", class: "mono" });
|
||||
const step = stepSelect(steps, live.length ? live[0].name : "off", true);
|
||||
const vp = viewPicker(views, autoViews);
|
||||
const err = h("div");
|
||||
const form = h("form", { class: "inline-form", onsubmit: async (ev) => {
|
||||
ev.preventDefault();
|
||||
try {
|
||||
const r = await change("POST", "/blocking/groups", { name: name.value.trim(), members: splitMembers(members.value), step: step.value, views: vp.value() });
|
||||
toast(describeResult(r));
|
||||
rerender();
|
||||
} catch (e) { mount(err, errorBox(e)); }
|
||||
} },
|
||||
h("div", { class: "f" }, h("label", null, "Group"), name),
|
||||
h("div", { class: "f grow" }, h("label", null, "Devices"), members),
|
||||
h("div", { class: "f" }, h("label", null, "Step"), step),
|
||||
vp.el,
|
||||
h("div", { class: "f" }, h("label", null, " "), h("button", { class: "primary", type: "submit" }, "Add group")));
|
||||
return h("div", { class: "panel" },
|
||||
h("h2", null, "Devices"),
|
||||
h("p", { class: "muted small" }, "Give some devices less or more blocking. Each step gets the lists of the steps below it plus its own; devices in no group are at normal. Your rules apply at every step. named must see the devices' own addresses (not a router's), so give them fixed ones."),
|
||||
stepsLine(steps, writable),
|
||||
writable ? [form, err] : null,
|
||||
table([
|
||||
{ label: "Group", render: (g) => [h("strong", { class: "mono" }, g.name), g.pending ? [" ", pendingBadge(g.pending)] : null] },
|
||||
{ label: "Step", render: (g) => g.step === "off" ? badge("off", "") : g.step === "normal" ? h("span", { class: "muted" }, "normal") : badge(g.step, "accent") },
|
||||
{ label: "Devices", render: (g) => g.members.length ? h("span", { class: "mono small" }, g.members.join(", ")) : h("span", { class: "muted" }, "none") },
|
||||
views.length ? { label: "Views", render: viewsCell } : null,
|
||||
{ label: "Comment", render: (g) => h("span", { class: "muted" }, g.comment || "") },
|
||||
writable ? { label: "", render: (g) => g.pending === "delete" ? "" : h("span", null,
|
||||
h("button", { class: "small", onclick: () => editGroup(g, steps, views, autoViews) }, "Edit"), " ",
|
||||
h("button", { class: "small danger", onclick: () => del("/blocking/groups/" + g.id, "client group " + g.name) }, "Delete")), tdClass: "actions-cell" } : null,
|
||||
].filter(Boolean), groups, { empty: "No groups: every device is at normal.", rowClass: (g) => g.pending ? "pending-" + g.pending : "" }));
|
||||
}
|
||||
|
||||
// splitMembers turns "a, b c" into a list.
|
||||
function splitMembers(s) {
|
||||
return s.split(/[\s,;]+/).map((x) => x.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
// stepsLine shows the steps in order, with controls for the ones above
|
||||
// normal.
|
||||
function stepsLine(steps, writable) {
|
||||
const live = steps.filter((s) => s.pending !== "delete");
|
||||
const order = async (ids) => {
|
||||
try {
|
||||
const r = await change("POST", "/blocking/steps/order", { steps: ids.map(String) });
|
||||
toast(describeResult(r));
|
||||
rerender();
|
||||
} catch (e) { toast(e.message, "err", 8000); }
|
||||
};
|
||||
const move = (i, d) => {
|
||||
const ids = live.map((s) => s.id);
|
||||
[ids[i], ids[i + d]] = [ids[i + d], ids[i]];
|
||||
order(ids);
|
||||
};
|
||||
const add = async () => {
|
||||
const n = await promptName("Add a step", "Name of the new step (above the others), e.g. strict", "");
|
||||
if (!n) return;
|
||||
try {
|
||||
const r = await change("POST", "/blocking/steps", { name: n });
|
||||
toast(describeResult(r));
|
||||
rerender();
|
||||
} catch (e) { toast(e.message, "err", 8000); }
|
||||
};
|
||||
const rename = async (s) => {
|
||||
const n = await promptName("Rename step " + s.name, "New name", s.name);
|
||||
if (!n || n === s.name) return;
|
||||
try {
|
||||
const r = await change("PATCH", "/blocking/steps/" + s.id, { name: n });
|
||||
toast(describeResult(r));
|
||||
rerender();
|
||||
} catch (e) { toast(e.message, "err", 8000); }
|
||||
};
|
||||
const chip = (label, title) => h("span", { class: "step-chip", title }, label);
|
||||
const items = [chip("off", "only your rules"), chip("normal", "your rules and the normal lists; devices in no group")];
|
||||
live.forEach((s, i) => {
|
||||
const used = s.lists.length || s.groups.length;
|
||||
items.push(h("span", { class: "step-chip accent", title: "lists: " + (s.lists.join(", ") || "none") + "; groups: " + (s.groups.join(", ") || "none") },
|
||||
s.name, s.pending ? [" ", pendingBadge(s.pending)] : null,
|
||||
writable ? h("span", { class: "step-tools" },
|
||||
h("button", { class: "small", disabled: i === 0, title: "Move down: fewer lists", onclick: () => move(i, -1) }, "←"),
|
||||
h("button", { class: "small", disabled: i === live.length - 1, title: "Move up: more lists", onclick: () => move(i, 1) }, "→"),
|
||||
h("button", { class: "small", title: "Rename", onclick: () => rename(s) }, "✎"),
|
||||
h("button", { class: "small danger", disabled: !!used, title: used ? "In use by lists or groups" : "Delete", onclick: () => del("/blocking/steps/" + s.id, "step " + s.name) }, "×")) : null));
|
||||
});
|
||||
return h("div", { class: "steps-line" }, h("span", { class: "muted small" }, "Steps: "),
|
||||
items.map((x, i) => [i ? h("span", { class: "muted" }, " → ") : null, x]),
|
||||
writable ? [" ", h("button", { class: "small", onclick: add }, "Add step")] : null);
|
||||
}
|
||||
|
||||
// promptName asks for a short name in a dialog.
|
||||
async function promptName(title, label, value) {
|
||||
const i = input({ value, class: "mono" });
|
||||
const v = await modal(title, h("div", { class: "form-grid" }, field(label, i)), [
|
||||
{ label: "Cancel", value: null },
|
||||
{ label: "OK", kind: "primary", value: true, onclick: () => !!i.value.trim() },
|
||||
]);
|
||||
return v === true ? i.value.trim() : null;
|
||||
}
|
||||
|
||||
function editGroup(g, steps, views, autoViews) {
|
||||
const name = input({ value: g.name, class: "mono" });
|
||||
const members = input({ value: g.members.join(" "), class: "mono", style: { width: "100%" } });
|
||||
const step = stepSelect(steps, g.step, true);
|
||||
const comment = input({ value: g.comment || "" });
|
||||
const vp = viewPicker(views, autoViews, ownViews(g));
|
||||
editDialog("Edit client group " + g.name, "/blocking/groups/" + g.id, [
|
||||
["Name", name],
|
||||
["Devices", members, "Addresses, networks (10.10.0.0/24) or ACLs, separated by spaces. A device can be in one group; a more specific address in another group wins."],
|
||||
["Step", step],
|
||||
vp.picker ? ["Views", vp.picker, "None ticked: every view with blocking."] : null,
|
||||
["Comment", comment],
|
||||
], () => ({ name: name.value.trim(), members: splitMembers(members.value), step: step.value, views: vp.value(), comment: comment.value }));
|
||||
}
|
||||
@@ -326,6 +326,12 @@ td.break { word-break: break-all; }
|
||||
tr .reveal { opacity: 0; transition: opacity 0.1s; }
|
||||
tr:hover .reveal, tr:focus-within .reveal { opacity: 1; }
|
||||
@media (hover: none) { tr .reveal { opacity: 1; } }
|
||||
/* Blocking steps, in order, on the Blocking page. */
|
||||
.steps-line { display: flex; flex-wrap: wrap; align-items: center; gap: 4px; margin: 4px 0 10px; }
|
||||
.step-chip { display: inline-flex; align-items: center; gap: 4px; padding: 2px 8px; border: 1px solid var(--border); border-radius: 12px; font-family: var(--mono); font-size: 13px; background: var(--panel); }
|
||||
.step-chip.accent { border-color: var(--accent); }
|
||||
.step-tools { display: inline-flex; gap: 2px; margin-left: 2px; }
|
||||
.step-tools button { padding: 0 5px; min-width: 0; }
|
||||
h2.section { margin: 24px 0 10px; font-size: 18px; }
|
||||
.filter-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 12px; }
|
||||
.segmented { display: inline-flex; border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; background: var(--panel); }
|
||||
|
||||
Reference in new issue
Block a user