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>
124 lines
5.3 KiB
Go
124 lines
5.3 KiB
Go
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)
|
|
}
|
|
}
|