Files
jarl/internal/server/h_clientgroups.go
adamandClaude Opus 5.5 76aaaf3ef5 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>
2026-10-11 21:33:16 +01:00

266 lines
7.4 KiB
Go

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
}