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>
266 lines
7.4 KiB
Go
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
|
|
}
|