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) } }