Added:
- Server statistics: named's memory and CPU (from /proc), cache size,
sockets and client counts against their limits (statistics channel,
rndc status), host memory and load, and free disk space. Memory and
CPU are kept over time as gauges (sum and sample count, averaged in
timelines). Statistics page Server section, jarl stats server,
GET /stats/server.
- jarl record add|set|delete|disable|enable --view a,b | all: the same
change in each view, applied as one changeset.
- Blocking can be paused until resumed (jarl block pause indefinitely,
"Until I resume", {"indefinitely": true}).
Changed:
- List options (allow-transfer, forwarders, ...) get their braces added
when given without them.
- The dashboard lists named-checkconf's warnings about the running
configuration; the CLI prints them after an apply.
- Stats page: tile and card rows fill their width, and the queries and
failed answers charts share margins so their time axes line up.
- Dashboard: options have their own tile.
- 24-hour times in the web UI.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
313 lines
8.4 KiB
Go
313 lines
8.4 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
|
|
}
|
|
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))
|
|
}
|
|
}
|
|
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{}
|
|
}
|