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>
328 lines
8.8 KiB
Go
328 lines
8.8 KiB
Go
package engine
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/url"
|
|
"regexp"
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
|
|
"jarl/internal/auth"
|
|
"jarl/internal/blocking"
|
|
"jarl/internal/model"
|
|
)
|
|
|
|
// ---- blocking: blocklists and rules (docs/blocking.md)
|
|
|
|
var blocklistNameRe = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,38}[a-z0-9])?$`)
|
|
|
|
func blockingAction() auth.Action { return auth.Action{Resource: auth.ResBlocking, Write: true} }
|
|
|
|
// viewIDs resolves the views of a blocklist or rule input: names or IDs,
|
|
// in view order, without duplicates. nil, nil means "not given".
|
|
func viewIDs(st *model.State, names *[]string, ids *[]int64) ([]int64, error) {
|
|
if names == nil && ids == nil {
|
|
return nil, nil
|
|
}
|
|
want := map[int64]bool{}
|
|
if names != nil {
|
|
for _, n := range *names {
|
|
v := st.ViewByName(strings.TrimSpace(n))
|
|
if v == nil {
|
|
return nil, errf(KindBadRequest, "view %q does not exist", n)
|
|
}
|
|
want[v.ID] = true
|
|
}
|
|
}
|
|
if ids != nil {
|
|
for _, id := range *ids {
|
|
if st.Views[id] == nil {
|
|
return nil, errf(KindBadRequest, "view %d does not exist", id)
|
|
}
|
|
want[id] = true
|
|
}
|
|
}
|
|
out := []int64{}
|
|
for _, v := range st.OrderedViews() {
|
|
if want[v.ID] {
|
|
out = append(out, v.ID)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// CheckBlocklistURL checks a blocklist URL: http or https, with a host.
|
|
func CheckBlocklistURL(raw string) (string, error) {
|
|
raw = strings.TrimSpace(raw)
|
|
u, err := url.Parse(raw)
|
|
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
|
|
return "", fmt.Errorf("%q is not an http or https URL", raw)
|
|
}
|
|
return raw, nil
|
|
}
|
|
|
|
func applyBlocklistPut(st *model.State, op model.Op, c *applyCtx) error {
|
|
if err := c.require(blockingAction()); err != nil {
|
|
return err
|
|
}
|
|
var in BlocklistInput
|
|
if err := decode(op.Data, &in); err != nil {
|
|
return err
|
|
}
|
|
var b *model.Blocklist
|
|
if op.Kind == OpBlocklistCreate {
|
|
if op.ID == 0 || st.Blocklists[op.ID] != nil {
|
|
return errf(KindConflict, "blocklist ID %d is already in use", op.ID)
|
|
}
|
|
if in.Name == nil || in.URL == nil {
|
|
return errf(KindBadRequest, "a blocklist needs a name and a URL")
|
|
}
|
|
b = &model.Blocklist{ID: op.ID, ViewIDs: []int64{}}
|
|
} else {
|
|
b = st.Blocklists[op.ID]
|
|
if b == nil {
|
|
return errf(KindNotFound, "blocklist %d does not exist", op.ID)
|
|
}
|
|
if err := checkVersion(op, b.Version, "blocklist "+b.Name); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if in.Name != nil {
|
|
name := strings.ToLower(strings.TrimSpace(*in.Name))
|
|
if !blocklistNameRe.MatchString(name) {
|
|
return errf(KindBadRequest, "blocklist name %q: use 1 to 40 lowercase letters, digits and hyphens, like hagezi-pro", *in.Name)
|
|
}
|
|
if other := st.BlocklistByName(name); other != nil && other.ID != b.ID {
|
|
return errf(KindConflict, "blocklist %s already exists", name)
|
|
}
|
|
b.Name = name
|
|
}
|
|
if in.URL != nil {
|
|
u, err := CheckBlocklistURL(*in.URL)
|
|
if err != nil {
|
|
return errf(KindBadRequest, "%v", err)
|
|
}
|
|
b.URL = u
|
|
}
|
|
ids, err := viewIDs(st, in.Views, in.ViewIDs)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if ids != nil {
|
|
b.ViewIDs = ids
|
|
}
|
|
if in.Disabled != nil {
|
|
b.Disabled = *in.Disabled
|
|
}
|
|
step, given, err := stepRef(st, in.Step, in.StepID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if given {
|
|
if step == model.StepOff {
|
|
return errf(KindBadRequest, "a blocklist starts at normal or a step above it, not off")
|
|
}
|
|
b.StepID = step
|
|
}
|
|
if in.Comment != nil {
|
|
b.Comment = *in.Comment
|
|
}
|
|
b.Touch(c.user(), c.now)
|
|
st.Blocklists[b.ID] = b
|
|
return nil
|
|
}
|
|
|
|
func applyBlocklistDelete(st *model.State, op model.Op, c *applyCtx) error {
|
|
if err := c.require(blockingAction()); err != nil {
|
|
return err
|
|
}
|
|
b := st.Blocklists[op.ID]
|
|
if b == nil {
|
|
return errf(KindNotFound, "blocklist %d does not exist", op.ID)
|
|
}
|
|
if err := checkVersion(op, b.Version, "blocklist "+b.Name); err != nil {
|
|
return err
|
|
}
|
|
delete(st.Blocklists, b.ID)
|
|
return nil
|
|
}
|
|
|
|
func applyBlockRulePut(st *model.State, op model.Op, c *applyCtx) error {
|
|
if err := c.require(blockingAction()); err != nil {
|
|
return err
|
|
}
|
|
var in BlockRuleInput
|
|
if err := decode(op.Data, &in); err != nil {
|
|
return err
|
|
}
|
|
var r *model.BlockRule
|
|
if op.Kind == OpBlockRuleCreate {
|
|
if op.ID == 0 || st.BlockRules[op.ID] != nil {
|
|
return errf(KindConflict, "rule ID %d is already in use", op.ID)
|
|
}
|
|
if in.Name == nil {
|
|
return errf(KindBadRequest, "a rule needs a domain name")
|
|
}
|
|
r = &model.BlockRule{ID: op.ID, Action: model.BlockActionBlock, ViewIDs: []int64{}}
|
|
} else {
|
|
r = st.BlockRules[op.ID]
|
|
if r == nil {
|
|
return errf(KindNotFound, "rule %d does not exist", op.ID)
|
|
}
|
|
if err := checkVersion(op, r.Version, "rule "+r.Name); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if in.Name != nil {
|
|
n, err := blocking.NormalizeName(*in.Name)
|
|
if err != nil {
|
|
return errf(KindBadRequest, "%v", err)
|
|
}
|
|
r.Name = n
|
|
}
|
|
if in.Action != nil {
|
|
a := strings.ToLower(strings.TrimSpace(*in.Action))
|
|
if a != model.BlockActionBlock && a != model.BlockActionAllow {
|
|
return errf(KindBadRequest, "a rule's action is block or allow, not %q", *in.Action)
|
|
}
|
|
r.Action = a
|
|
}
|
|
if in.Exact != nil {
|
|
r.Exact = *in.Exact
|
|
}
|
|
ids, err := viewIDs(st, in.Views, in.ViewIDs)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if ids != nil {
|
|
r.ViewIDs = ids
|
|
}
|
|
if in.Comment != nil {
|
|
r.Comment = *in.Comment
|
|
}
|
|
r.Touch(c.user(), c.now)
|
|
st.BlockRules[r.ID] = r
|
|
return nil
|
|
}
|
|
|
|
func applyBlockRuleDelete(st *model.State, op model.Op, c *applyCtx) error {
|
|
if err := c.require(blockingAction()); err != nil {
|
|
return err
|
|
}
|
|
r := st.BlockRules[op.ID]
|
|
if r == nil {
|
|
return errf(KindNotFound, "rule %d does not exist", op.ID)
|
|
}
|
|
if err := checkVersion(op, r.Version, "rule "+r.Name); err != nil {
|
|
return err
|
|
}
|
|
delete(st.BlockRules, r.ID)
|
|
return nil
|
|
}
|
|
|
|
// blockingUsers lists the blocklists and rules that name a view.
|
|
func blockingUsers(st *model.State, viewID int64) []string {
|
|
var out []string
|
|
for _, b := range st.SortedBlocklists() {
|
|
if slices.Contains(b.ViewIDs, viewID) {
|
|
out = append(out, "blocklist "+b.Name)
|
|
}
|
|
}
|
|
for _, r := range st.SortedBlockRules() {
|
|
if slices.Contains(r.ViewIDs, viewID) {
|
|
out = append(out, ruleLabel(r))
|
|
}
|
|
}
|
|
for _, g := range st.SortedClientGroups() {
|
|
if slices.Contains(g.ViewIDs, viewID) {
|
|
out = append(out, "client group "+g.Name)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// ruleLabel describes a rule: "block ads.example (and below)".
|
|
func ruleLabel(r *model.BlockRule) string {
|
|
s := r.Action + " " + r.Name
|
|
if !r.Exact {
|
|
s += " (and below)"
|
|
}
|
|
return s
|
|
}
|
|
|
|
// ---- expansion
|
|
|
|
func (x *expander) blocklistCreate(req Request) ([]model.Op, error) {
|
|
return x.simpleCreate(req, OpBlocklistCreate, func(data json.RawMessage) (string, error) {
|
|
var in BlocklistInput
|
|
if err := decode(data, &in); err != nil {
|
|
return "", err
|
|
}
|
|
if in.Name == nil || in.URL == nil {
|
|
return "", errf(KindBadRequest, "a blocklist needs a name and a URL")
|
|
}
|
|
return "add blocklist " + *in.Name + " (" + *in.URL + ")", nil
|
|
})
|
|
}
|
|
|
|
func (x *expander) blockRuleCreate(req Request) ([]model.Op, error) {
|
|
return x.simpleCreate(req, OpBlockRuleCreate, func(data json.RawMessage) (string, error) {
|
|
var in BlockRuleInput
|
|
if err := decode(data, &in); err != nil {
|
|
return "", err
|
|
}
|
|
if in.Name == nil {
|
|
return "", errf(KindBadRequest, "a rule needs a domain name")
|
|
}
|
|
r := &model.BlockRule{Name: strings.ToLower(strings.TrimSuffix(strings.TrimSpace(*in.Name), ".")), Action: model.BlockActionBlock}
|
|
if in.Action != nil {
|
|
r.Action = strings.ToLower(*in.Action)
|
|
}
|
|
if in.Exact != nil {
|
|
r.Exact = *in.Exact
|
|
}
|
|
return ruleLabel(r), nil
|
|
})
|
|
}
|
|
|
|
func applyBlockingPause(st *model.State, op model.Op, c *applyCtx) error {
|
|
if err := c.require(blockingAction()); err != nil {
|
|
return err
|
|
}
|
|
var in BlockingPauseInput
|
|
if err := decode(op.Data, &in); err != nil {
|
|
return err
|
|
}
|
|
if in.Until != nil && in.Until.Sub(c.now) > MaxBlockingPause+time.Minute {
|
|
return errf(KindBadRequest, "blocking can be paused for at most %s, or until it is resumed", MaxBlockingPause)
|
|
}
|
|
if in.Indefinitely && in.Until != nil {
|
|
return errf(KindBadRequest, "a pause is either until a time or until resumed, not both")
|
|
}
|
|
st.Settings.BlockingPausedUntil = in.Until
|
|
st.Settings.BlockingPausedIndefinitely = in.Indefinitely
|
|
return nil
|
|
}
|
|
|
|
// ResumeBlocking ends a pause whose time is up, and returns when the
|
|
// current pause ends (zero when blocking isn't paused).
|
|
func (e *Engine) ResumeBlocking(ctx context.Context) time.Time {
|
|
st, err := e.store.LoadState(ctx)
|
|
if err != nil || st.Settings.BlockingPausedUntil == nil {
|
|
return time.Time{}
|
|
}
|
|
until := *st.Settings.BlockingPausedUntil
|
|
if e.Now().Before(until) {
|
|
return until
|
|
}
|
|
if _, err := e.Submit(ctx, []Request{NewRequest(OpBlockingPause, 0, BlockingPauseInput{})}, auth.System, "system", "resume blocking after the pause", ApplyOptions{}); err != nil {
|
|
e.log.Warn("resuming blocking after the pause failed; it will be tried again", "err", err)
|
|
return e.Now().Add(time.Minute)
|
|
}
|
|
e.log.Info("blocking resumed after the pause")
|
|
return time.Time{}
|
|
}
|