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:
adamandClaude Opus 5.5 committed 2026-10-11 21:33:16 +01:00
1 parent 5b8f681e2a
commit 76aaaf3ef5
33 files changed
+2096 -55

No files matched your search

+59 -7
View File
@@ -150,14 +150,14 @@ func loadState(ctx context.Context, e execer) (*model.State, error) {
return nil, err
}
rows, err = e.QueryContext(ctx, `SELECT id, name, url, view_ids, disabled, comment, version, created_at, updated_at, created_by, updated_by FROM blocklists`)
rows, err = e.QueryContext(ctx, `SELECT id, name, url, view_ids, disabled, step_id, comment, version, created_at, updated_at, created_by, updated_by FROM blocklists`)
if err != nil {
return nil, err
}
for rows.Next() {
b := &model.Blocklist{}
var views, ca, ua string
if err := rows.Scan(&b.ID, &b.Name, &b.URL, &views, &b.Disabled, &b.Comment, &b.Version, &ca, &ua, &b.CreatedBy, &b.UpdatedBy); err != nil {
if err := rows.Scan(&b.ID, &b.Name, &b.URL, &views, &b.Disabled, &b.StepID, &b.Comment, &b.Version, &ca, &ua, &b.CreatedBy, &b.UpdatedBy); err != nil {
rows.Close()
return nil, err
}
@@ -187,6 +187,44 @@ func loadState(ctx context.Context, e execer) (*model.State, error) {
if err := closeRows(rows); err != nil {
return nil, err
}
rows, err = e.QueryContext(ctx, `SELECT id, name, position, comment, version, created_at, updated_at, created_by, updated_by FROM block_steps`)
if err != nil {
return nil, err
}
for rows.Next() {
s := &model.BlockStep{}
var ca, ua string
if err := rows.Scan(&s.ID, &s.Name, &s.Position, &s.Comment, &s.Version, &ca, &ua, &s.CreatedBy, &s.UpdatedBy); err != nil {
rows.Close()
return nil, err
}
s.CreatedAt, s.UpdatedAt = parseTS(ca), parseTS(ua)
st.BlockSteps[s.ID] = s
}
if err := closeRows(rows); err != nil {
return nil, err
}
rows, err = e.QueryContext(ctx, `SELECT id, name, members, step_id, view_ids, comment, version, created_at, updated_at, created_by, updated_by FROM client_groups`)
if err != nil {
return nil, err
}
for rows.Next() {
g := &model.ClientGroup{}
var members, views, ca, ua string
if err := rows.Scan(&g.ID, &g.Name, &members, &g.StepID, &views, &g.Comment, &g.Version, &ca, &ua, &g.CreatedBy, &g.UpdatedBy); err != nil {
rows.Close()
return nil, err
}
json.Unmarshal([]byte(members), &g.Members)
json.Unmarshal([]byte(views), &g.ViewIDs)
g.CreatedAt, g.UpdatedAt = parseTS(ca), parseTS(ua)
st.ClientGroups[g.ID] = g
}
if err := closeRows(rows); err != nil {
return nil, err
}
return st, nil
}
@@ -204,7 +242,7 @@ func (t *Tx) SaveState(old, new *model.State) error {
// ReplaceState replaces the stored configuration with st (used by import
// and tests).
func (t *Tx) ReplaceState(st *model.State) error {
for _, tbl := range []string{"views", "acls", "tsig_keys", "zones", "records", "options", "blocklists", "block_rules"} {
for _, tbl := range []string{"views", "acls", "tsig_keys", "zones", "records", "options", "blocklists", "block_rules", "block_steps", "client_groups"} {
if _, err := t.tx.ExecContext(t.ctx, "DELETE FROM "+tbl); err != nil {
return err
}
@@ -272,17 +310,31 @@ func saveState(ctx context.Context, e execer, old, new *model.State) error {
return err
}
if err := diffMap(old.Blocklists, new.Blocklists, func(b *model.Blocklist) error {
_, err := e.ExecContext(ctx, `INSERT OR REPLACE INTO blocklists (id, name, url, view_ids, disabled, comment, version, created_at, updated_at, created_by, updated_by) VALUES (?,?,?,?,?,?,?,?,?,?,?)`,
b.ID, b.Name, b.URL, jsonText(nonNilIDs(b.ViewIDs)), b.Disabled, b.Comment, b.Version, ts(b.CreatedAt), ts(b.UpdatedAt), b.CreatedBy, b.UpdatedBy)
_, err := e.ExecContext(ctx, `INSERT OR REPLACE INTO blocklists (id, name, url, view_ids, disabled, step_id, comment, version, created_at, updated_at, created_by, updated_by) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`,
b.ID, b.Name, b.URL, jsonText(nonNilIDs(b.ViewIDs)), b.Disabled, b.StepID, b.Comment, b.Version, ts(b.CreatedAt), ts(b.UpdatedAt), b.CreatedBy, b.UpdatedBy)
return err
}, func(id int64) error { return del(ctx, e, "blocklists", id) }); err != nil {
return err
}
return diffMap(old.BlockRules, new.BlockRules, func(r *model.BlockRule) error {
if err := diffMap(old.BlockRules, new.BlockRules, func(r *model.BlockRule) error {
_, err := e.ExecContext(ctx, `INSERT OR REPLACE INTO block_rules (id, name, action, exact, view_ids, comment, version, created_at, updated_at, created_by, updated_by) VALUES (?,?,?,?,?,?,?,?,?,?,?)`,
r.ID, r.Name, r.Action, r.Exact, jsonText(nonNilIDs(r.ViewIDs)), r.Comment, r.Version, ts(r.CreatedAt), ts(r.UpdatedAt), r.CreatedBy, r.UpdatedBy)
return err
}, func(id int64) error { return del(ctx, e, "block_rules", id) })
}, func(id int64) error { return del(ctx, e, "block_rules", id) }); err != nil {
return err
}
if err := diffMap(old.BlockSteps, new.BlockSteps, func(s *model.BlockStep) error {
_, err := e.ExecContext(ctx, `INSERT OR REPLACE INTO block_steps (id, name, position, comment, version, created_at, updated_at, created_by, updated_by) VALUES (?,?,?,?,?,?,?,?,?)`,
s.ID, s.Name, s.Position, s.Comment, s.Version, ts(s.CreatedAt), ts(s.UpdatedAt), s.CreatedBy, s.UpdatedBy)
return err
}, func(id int64) error { return del(ctx, e, "block_steps", id) }); err != nil {
return err
}
return diffMap(old.ClientGroups, new.ClientGroups, func(g *model.ClientGroup) error {
_, err := e.ExecContext(ctx, `INSERT OR REPLACE INTO client_groups (id, name, members, step_id, view_ids, comment, version, created_at, updated_at, created_by, updated_by) VALUES (?,?,?,?,?,?,?,?,?,?,?)`,
g.ID, g.Name, jsonText(nonNil(g.Members)), g.StepID, jsonText(nonNilIDs(g.ViewIDs)), g.Comment, g.Version, ts(g.CreatedAt), ts(g.UpdatedAt), g.CreatedBy, g.UpdatedBy)
return err
}, func(id int64) error { return del(ctx, e, "client_groups", id) })
}
func nonNilIDs(ids []int64) []int64 {
+22
View File
@@ -304,6 +304,28 @@ CREATE TABLE blocklist_status (
// 7: disabled zones, kept but not loaded by named.
`
ALTER TABLE zones ADD COLUMN disabled INTEGER NOT NULL DEFAULT 0;
`,
// 8: blocking steps and client groups (docs/blocking.md).
`
ALTER TABLE blocklists ADD COLUMN step_id INTEGER NOT NULL DEFAULT 0;
CREATE TABLE block_steps (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
position INTEGER NOT NULL DEFAULT 0,
comment TEXT NOT NULL DEFAULT '',
version INTEGER NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
created_by TEXT NOT NULL DEFAULT '', updated_by TEXT NOT NULL DEFAULT ''
);
CREATE TABLE client_groups (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
members TEXT NOT NULL DEFAULT '[]',
step_id INTEGER NOT NULL DEFAULT 0,
view_ids TEXT NOT NULL DEFAULT '[]',
comment TEXT NOT NULL DEFAULT '',
version INTEGER NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
created_by TEXT NOT NULL DEFAULT '', updated_by TEXT NOT NULL DEFAULT ''
);
`,
}