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
@@ -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)))
|
||||
|
||||
Reference in new issue
Block a user