Files
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

410 lines
12 KiB
Go

package server
import (
"context"
"net/http"
"slices"
"strconv"
"strings"
"time"
"jarl/internal/api"
"jarl/internal/auth"
"jarl/internal/blocking"
"jarl/internal/engine"
"jarl/internal/model"
)
// ---- blocking (docs/blocking.md)
// bytesPerRecord is what named 9.20 needs per policy zone record and view,
// measured with lists of a few hundred thousand entries.
const bytesPerRecord = 630
func viewNames(st *model.State, ids []int64) []string {
out := []string{}
for _, id := range ids {
if v := st.Views[id]; v != nil {
out = append(out, v.Name)
}
}
return out
}
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, Step: st.StepName(b.StepID)}
switch {
case b.Disabled:
out.State = "disabled"
case len(views) == 0 || !stepReached(st, b, views):
out.State = "idle"
case status != nil && status.Error != "":
out.State = "error"
case status == nil || status.UpdatedAt == nil:
out.State = "waiting"
default:
out.State = "ok"
}
if status != nil && !b.Disabled {
out.MemoryMB = int((int64(status.Records)*bytesPerRecord*int64(len(views)) + 1<<19) >> 20)
}
return out
}
func presentBlockRule(st *model.State, r *model.BlockRule) api.BlockRule {
return api.BlockRule{BlockRule: *r, Views: viewNames(st, st.BlockingViews(r.ViewIDs)), AutoViews: len(r.ViewIDs) == 0 && len(st.Views) > 0}
}
func requireBlocking(p *auth.Principal) error {
return require(p, auth.Action{Resource: auth.ResBlocking})
}
func (s *Server) listBlocklists(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
}
statuses, err := s.store.BlocklistStatuses(r.Context())
if err != nil {
return err
}
out := []api.Blocklist{}
for _, b := range st.SortedBlocklists() {
o := presentBlocklist(st, b, statuses[b.ID])
o.Pending = pending(base, base != nil && base.Blocklists[b.ID] == nil, base != nil && base.Blocklists[b.ID] != nil && base.Blocklists[b.ID].Version != b.Version)
out = append(out, o)
}
if base != nil {
for _, b := range base.SortedBlocklists() {
if st.Blocklists[b.ID] == nil {
o := presentBlocklist(base, b, statuses[b.ID])
o.Pending = "delete"
out = append(out, o)
}
}
}
writeJSON(w, http.StatusOK, out)
return nil
}
// pending returns the pending marker of an object listed with a changeset.
func pending(base *model.State, added, changed bool) string {
switch {
case base == nil:
return ""
case added:
return "add"
case changed:
return "change"
}
return ""
}
func (s *Server) blocklistByPath(r *http.Request, st *model.State) (*model.Blocklist, error) {
ref := r.PathValue("id")
var b *model.Blocklist
if id, err := strconv.ParseInt(ref, 10, 64); err == nil {
b = st.Blocklists[id]
} else {
b = st.BlocklistByName(ref)
}
if b == nil {
return nil, notFound("blocklist %s not found", ref)
}
return b, nil
}
func (s *Server) blocklistObject(ctx context.Context) func(*model.State, int64) any {
return func(st *model.State, id int64) any {
b := st.Blocklists[id]
if b == nil {
return nil
}
statuses, _ := s.store.BlocklistStatuses(ctx)
return presentBlocklist(st, b, statuses[id])
}
}
func (s *Server) getBlocklist(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := requireBlocking(p); err != nil {
return err
}
st, err := s.state(r)
if err != nil {
return err
}
b, err := s.blocklistByPath(r, st)
if err != nil {
return err
}
writeJSON(w, http.StatusOK, s.blocklistObject(r.Context())(st, b.ID))
return nil
}
func (s *Server) createBlocklist(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.OpBlocklistCreate, 0, body)}, s.blocklistObject(r.Context()))
}
func (s *Server) updateBlocklist(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
st, err := s.state(r)
if err != nil {
return err
}
b, err := s.blocklistByPath(r, st)
if err != nil {
return err
}
body, err := readBody(r)
if err != nil {
return err
}
id := b.ID
return s.mutate(w, r, p, "", []engine.Request{request(engine.OpBlocklistUpdate, id, body)}, func(st *model.State, _ int64) any { return s.blocklistObject(r.Context())(st, id) })
}
func (s *Server) deleteBlocklist(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
st, err := s.state(r)
if err != nil {
return err
}
b, err := s.blocklistByPath(r, st)
if err != nil {
return err
}
return s.mutate(w, r, p, "", []engine.Request{request(engine.OpBlocklistDelete, b.ID, nil)}, nil)
}
// refreshBlocklist: POST /blocking/lists/{id}/refresh[?force=1] downloads
// the list now and waits for the result.
func (s *Server) refreshBlocklist(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := require(p, auth.Action{Resource: auth.ResBlocking, Write: true}); err != nil {
return err
}
st, err := s.store.LoadState(r.Context())
if err != nil {
return err
}
b, err := s.blocklistByPath(r, st)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Minute)
defer cancel()
status, rerr := s.eng.RefreshBlocklist(ctx, b.ID, queryBool(r, "force"))
if status == nil {
return rerr
}
writeJSON(w, http.StatusOK, presentBlocklist(st, b, status))
return nil
}
func (s *Server) listBlockRules(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.BlockRule{}
for _, rule := range st.SortedBlockRules() {
o := presentBlockRule(st, rule)
o.Pending = pending(base, base != nil && base.BlockRules[rule.ID] == nil, base != nil && base.BlockRules[rule.ID] != nil && base.BlockRules[rule.ID].Version != rule.Version)
out = append(out, o)
}
if base != nil {
for _, rule := range base.SortedBlockRules() {
if st.BlockRules[rule.ID] == nil {
o := presentBlockRule(base, rule)
o.Pending = "delete"
out = append(out, o)
}
}
}
writeJSON(w, http.StatusOK, out)
return nil
}
func ruleObject(st *model.State, id int64) any {
if rule := st.BlockRules[id]; rule != nil {
return presentBlockRule(st, rule)
}
return nil
}
func (s *Server) ruleByPath(r *http.Request, st *model.State) (*model.BlockRule, error) {
id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
rule := st.BlockRules[id]
if rule == nil {
return nil, notFound("rule %s not found", r.PathValue("id"))
}
return rule, nil
}
func (s *Server) createBlockRule(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.OpBlockRuleCreate, 0, body)}, ruleObject)
}
func (s *Server) updateBlockRule(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
st, err := s.state(r)
if err != nil {
return err
}
rule, err := s.ruleByPath(r, st)
if err != nil {
return err
}
body, err := readBody(r)
if err != nil {
return err
}
id := rule.ID
return s.mutate(w, r, p, "", []engine.Request{request(engine.OpBlockRuleUpdate, id, body)}, func(st *model.State, _ int64) any { return ruleObject(st, id) })
}
func (s *Server) deleteBlockRule(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
st, err := s.state(r)
if err != nil {
return err
}
rule, err := s.ruleByPath(r, st)
if err != nil {
return err
}
return s.mutate(w, r, p, "", []engine.Request{request(engine.OpBlockRuleDelete, rule.ID, nil)}, nil)
}
// deleteBlockRulesByName: DELETE /blocking/rules?name=NAME[&view=VIEW]
// deletes the rules for a domain (in one view, or all).
func (s *Server) deleteBlockRulesByName(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
st, err := s.state(r)
if err != nil {
return err
}
q := r.URL.Query()
name := strings.TrimSuffix(strings.ToLower(strings.TrimSpace(q.Get("name"))), ".")
if name == "" {
return badRequest("give the domain of the rules to delete (name=)")
}
var viewID int64 = -1
if vn := q.Get("view"); vn != "" {
v := st.ViewByName(vn)
if v == nil {
return notFound("view %s not found", vn)
}
viewID = v.ID
}
var reqs []engine.Request
for _, rule := range st.SortedBlockRules() {
if rule.Name != name {
continue
}
if viewID >= 0 && !slices.Contains(st.BlockingViews(rule.ViewIDs), viewID) {
continue
}
reqs = append(reqs, request(engine.OpBlockRuleDelete, rule.ID, nil))
}
if len(reqs) == 0 {
if queryBool(r, "missing_ok") {
writeJSON(w, http.StatusOK, api.MutationResponse{})
return nil
}
return notFound("there is no rule for %s", name)
}
return s.mutate(w, r, p, "", reqs, nil)
}
// checkBlocking: GET /blocking/check?name=NAME says what blocking does
// with a name in each view.
func (s *Server) checkBlocking(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := requireBlocking(p); err != nil {
return err
}
st, err := s.store.LoadState(r.Context())
if err != nil {
return err
}
out, err := s.eng.CheckBlocking(st, r.URL.Query().Get("name"))
if err != nil {
return err
}
if out == nil {
out = []blocking.Match{}
}
writeJSON(w, http.StatusOK, out)
return nil
}
// blocklistErrors lists the enabled blocklists whose last update failed,
// for the dashboard.
func (s *Server) blocklistErrors(ctx context.Context) []api.BlocklistError {
st, err := s.store.LoadState(ctx)
if err != nil || len(st.Blocklists) == 0 {
return nil
}
statuses, err := s.store.BlocklistStatuses(ctx)
if err != nil {
return nil
}
var out []api.BlocklistError
for _, b := range st.SortedBlocklists() {
if s := statuses[b.ID]; s != nil && s.Error != "" && !b.Disabled {
out = append(out, api.BlocklistError{Name: b.Name, Error: s.Error, UpdatedAt: s.UpdatedAt})
}
}
return out
}
// blockingStatus: GET /blocking/status says whether blocking is paused.
func (s *Server) blockingStatus(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := requireBlocking(p); err != nil {
return err
}
st, err := s.store.LoadState(r.Context())
if err != nil {
return err
}
writeJSON(w, http.StatusOK, map[string]any{"paused_until": st.Settings.BlockingPausedUntil, "paused_indefinitely": st.Settings.BlockingPausedIndefinitely})
return nil
}
// pauseBlocking: POST /blocking/pause {"minutes": 10} lets every name
// through until then, {"indefinitely": true} until resumed; POST
// /blocking/resume ends it.
func (s *Server) pauseBlocking(resume bool) func(http.ResponseWriter, *http.Request, *auth.Principal) error {
return func(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
var in engine.BlockingPauseInput
if !resume {
var body struct {
Minutes int `json:"minutes"`
Indefinitely bool `json:"indefinitely"`
}
if err := decodeJSON(r, &body); err != nil {
return err
}
if body.Indefinitely {
in.Indefinitely = true
return s.mutate(w, r, p, "", []engine.Request{engine.NewRequest(engine.OpBlockingPause, 0, in)}, nil)
}
if body.Minutes < 1 || time.Duration(body.Minutes)*time.Minute > engine.MaxBlockingPause {
return badRequest("pause for 1 to %d minutes, or indefinitely", int(engine.MaxBlockingPause/time.Minute))
}
until := time.Now().Add(time.Duration(body.Minutes) * time.Minute).Truncate(time.Second)
in.Until = &until
}
return s.mutate(w, r, p, "", []engine.Request{engine.NewRequest(engine.OpBlockingPause, 0, in)}, nil)
}
}