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

+149 -9
View File
@@ -24,8 +24,9 @@ export async function render(el, params, query) {
clearTimeout(refreshTimer);
// Queries blocked in the last day, per list and for the rules together
// (null without query statistics, or without permission to see them).
const [lists, rules, views, status, hitList] = await Promise.all([get("/blocking/lists", draftQuery()), get("/blocking/rules", draftQuery()), get("/views"), get("/blocking/status"),
can("stats") ? get("/stats/breakdown", { group: "blocklist", since: "24h" }).catch(() => null) : null]);
const [lists, rules, views, status, hitList, steps, groups] = await Promise.all([get("/blocking/lists", draftQuery()), get("/blocking/rules", draftQuery()), get("/views"), get("/blocking/status"),
can("stats") ? get("/stats/breakdown", { group: "blocklist", since: "24h" }).catch(() => null) : null,
get("/blocking/steps", draftQuery()), get("/blocking/groups", draftQuery())]);
const hits = hitList ? Object.fromEntries(hitList.map((e) => [e.key, e.count])) : null;
const writable = can("blocking", true);
const recursive = lists.concat(rules).find((x) => x.auto_views);
@@ -58,7 +59,8 @@ export async function render(el, params, query) {
h("label", { class: "muted" }, "Check a name"), checkName, h("button", { type: "submit" }, "Check")),
checkOut),
rulesPanel(rules, views, autoViews, writable, hits),
listsPanel(lists, views, autoViews, writable, hits));
listsPanel(lists, views, autoViews, writable, hits, steps),
devicesPanel(groups, steps, views, autoViews, writable));
if (checkName.value) runCheck();
// Lists are downloaded in the background, and a pause ends by itself:
@@ -114,7 +116,8 @@ function checkResult(name, res, rules, writable) {
m.view ? h("span", { class: "muted" }, "view " + m.view + ": ") : null,
m.result === "blocked" ? badge("blocked", "err") : m.result === "allowed" ? badge("allowed", "ok") : badge("not blocked", ""),
" ",
m.list ? ["by blocklist ", h("strong", null, m.list), " (entry ", h("code", null, m.entry), ")"]
m.list && m.step ? ["for devices at step ", h("strong", null, m.step), " and above, by blocklist ", h("strong", null, m.list), " (entry ", h("code", null, m.entry), "); other devices aren't blocked"]
: m.list ? ["by blocklist ", h("strong", null, m.list), " (entry ", h("code", null, m.entry), ")"]
: m.result !== "none" ? ["by your rule to ", rule ? rule.action : "", " ", h("code", null, rule ? rule.name : m.entry.replace(/^\*\./, "")), rule && !rule.exact ? " and below" : ""] : null);
})),
action);
@@ -176,7 +179,8 @@ async function editDialog(title, path, rows, data) {
], { sticky: true });
}
function editList(l, views, autoViews) {
function editList(l, views, autoViews, steps) {
const step = stepSelect(steps, l.step, false);
const name = input({ value: l.name, class: "mono" });
const url = input({ value: l.url, class: "mono", style: { width: "100%" } });
const comment = input({ value: l.comment || "" });
@@ -184,8 +188,9 @@ function editList(l, views, autoViews) {
editDialog("Edit blocklist " + l.name, "/blocking/lists/" + l.id, [
["Name", name], ["URL", url, "A new URL is downloaded right after saving."],
vp.picker ? ["Views", vp.picker, "None ticked: every view that offers recursion."] : null,
steps.length ? ["Step", step, "Devices at this step or above get the list. normal: every device except those at off."] : null,
["Comment", comment],
], () => ({ name: name.value.trim(), url: url.value.trim(), views: vp.value(), comment: comment.value }));
], () => ({ name: name.value.trim(), url: url.value.trim(), views: vp.value(), comment: comment.value, step: step.value }));
}
function editRule(r, views, autoViews) {
@@ -253,21 +258,33 @@ function rulesPanel(rules, views, autoViews, writable, hits) {
].filter(Boolean), rules, { empty: "No rules yet.", rowClass: (r) => r.pending ? "pending-" + r.pending : "" }));
}
function listsPanel(lists, views, autoViews, writable, hits) {
// stepSelect picks a step: off (only for groups), normal and the steps
// above it.
function stepSelect(steps, value, withOff) {
const opts = [];
if (withOff) opts.push(["off", "off (only your rules)"]);
opts.push(["normal", "normal" + (withOff ? " (like devices in no group)" : " (every device)")]);
steps.filter((s) => s.pending !== "delete").forEach((s) => opts.push([s.name, s.name]));
return select(opts, value || "normal");
}
function listsPanel(lists, views, autoViews, writable, hits, steps) {
const name = input({ placeholder: "hagezi-multi", class: "mono", style: { width: "11em" } });
const url = input({ placeholder: "https://…", class: "mono" });
const vp = viewPicker(views, autoViews);
const listStep = stepSelect(steps, "normal", false);
const err = h("div");
const form = h("form", { class: "inline-form", onsubmit: async (ev) => {
ev.preventDefault();
try {
const r = await change("POST", "/blocking/lists", { name: name.value.trim(), url: url.value.trim(), views: vp.value() });
const r = await change("POST", "/blocking/lists", { name: name.value.trim(), url: url.value.trim(), views: vp.value(), step: listStep.value });
toast(describeResult(r) + (r.applied ? " — jarld downloads it now" : ""));
rerender();
} catch (e) { mount(err, errorBox(e)); }
} },
h("div", { class: "f" }, h("label", null, "Name"), name),
h("div", { class: "f grow" }, h("label", null, "URL"), url),
steps.length ? h("div", { class: "f" }, h("label", null, "Step"), listStep) : null,
vp.el,
h("div", { class: "f" }, h("label", null, " "), h("button", { class: "primary", type: "submit" }, "Add list")));
const used = new Set(lists.map((l) => l.url));
@@ -284,12 +301,13 @@ function listsPanel(lists, views, autoViews, writable, hits) {
{ label: "State", render: (l) => { const [t, k] = stateBadge[l.state] || [l.state, ""]; return [badge(t, k), l.status && l.status.error ? h("div", { class: "small err-text" }, l.status.error) : null]; } },
{ label: "Domains", render: (l) => l.status && l.status.updated_at ? l.status.entries.toLocaleString() : "–", tdClass: "right" },
hits ? { label: "Blocked (24h)", render: (l) => hitsLink(hits[l.name]), class: "right", tdClass: "right" } : null,
steps.length ? { label: "Step", render: (l) => l.step === "normal" ? h("span", { class: "muted" }, "normal") : badge(l.step, "accent") } : null,
views.length ? { label: "Views", render: viewsCell } : null,
{ label: "Updated", render: (l) => l.status && l.status.updated_at ? h("span", { title: "checked " + time(l.status.checked_at) }, relTime(l.status.updated_at)) : "–", tdClass: "small" },
{ label: "Memory", render: (l) => l.memory_mb ? h("span", { title: "named's memory for this list, estimated (all its views)" }, "~" + l.memory_mb + " MB") : "–", tdClass: "small right" },
{ label: "URL", render: (l) => h("a", { href: l.url, target: "_blank", rel: "noopener", class: "small mono", title: l.url }, shortURL(l.url)) },
writable ? { label: "", render: (l) => l.pending === "delete" ? "" : h("span", null,
h("button", { class: "small", onclick: () => editList(l, views, autoViews) }, "Edit"), " ",
h("button", { class: "small", onclick: () => editList(l, views, autoViews, steps) }, "Edit"), " ",
h("button", { class: "small", disabled: l.state === "disabled" || !!l.pending, title: "Download it now (it is checked daily anyway)", onclick: () => refresh(l) }, "Refresh"), " ",
h("button", { class: "small", onclick: () => toggle(l) }, l.disabled ? "Enable" : "Disable"), " ",
h("button", { class: "small danger", onclick: () => del("/blocking/lists/" + l.id, "blocklist " + l.name) }, "Delete")), tdClass: "actions-cell" } : null,
@@ -329,3 +347,125 @@ async function del(path, what) {
} catch (e) { toast(e.message, "err", 8000); }
}
// ---- devices: blocking steps and client groups (docs/blocking.md)
// devicesPanel shows the steps (each includes the lists of the steps
// below it) and the client groups that put devices at a step.
function devicesPanel(groups, steps, views, autoViews, writable) {
const live = steps.filter((s) => s.pending !== "delete");
const name = input({ placeholder: "kids", class: "mono", style: { width: "9em" } });
const members = input({ placeholder: "10.10.0.50 10.10.0.51 or an ACL", class: "mono" });
const step = stepSelect(steps, live.length ? live[0].name : "off", true);
const vp = viewPicker(views, autoViews);
const err = h("div");
const form = h("form", { class: "inline-form", onsubmit: async (ev) => {
ev.preventDefault();
try {
const r = await change("POST", "/blocking/groups", { name: name.value.trim(), members: splitMembers(members.value), step: step.value, views: vp.value() });
toast(describeResult(r));
rerender();
} catch (e) { mount(err, errorBox(e)); }
} },
h("div", { class: "f" }, h("label", null, "Group"), name),
h("div", { class: "f grow" }, h("label", null, "Devices"), members),
h("div", { class: "f" }, h("label", null, "Step"), step),
vp.el,
h("div", { class: "f" }, h("label", null, " "), h("button", { class: "primary", type: "submit" }, "Add group")));
return h("div", { class: "panel" },
h("h2", null, "Devices"),
h("p", { class: "muted small" }, "Give some devices less or more blocking. Each step gets the lists of the steps below it plus its own; devices in no group are at normal. Your rules apply at every step. named must see the devices' own addresses (not a router's), so give them fixed ones."),
stepsLine(steps, writable),
writable ? [form, err] : null,
table([
{ label: "Group", render: (g) => [h("strong", { class: "mono" }, g.name), g.pending ? [" ", pendingBadge(g.pending)] : null] },
{ label: "Step", render: (g) => g.step === "off" ? badge("off", "") : g.step === "normal" ? h("span", { class: "muted" }, "normal") : badge(g.step, "accent") },
{ label: "Devices", render: (g) => g.members.length ? h("span", { class: "mono small" }, g.members.join(", ")) : h("span", { class: "muted" }, "none") },
views.length ? { label: "Views", render: viewsCell } : null,
{ label: "Comment", render: (g) => h("span", { class: "muted" }, g.comment || "") },
writable ? { label: "", render: (g) => g.pending === "delete" ? "" : h("span", null,
h("button", { class: "small", onclick: () => editGroup(g, steps, views, autoViews) }, "Edit"), " ",
h("button", { class: "small danger", onclick: () => del("/blocking/groups/" + g.id, "client group " + g.name) }, "Delete")), tdClass: "actions-cell" } : null,
].filter(Boolean), groups, { empty: "No groups: every device is at normal.", rowClass: (g) => g.pending ? "pending-" + g.pending : "" }));
}
// splitMembers turns "a, b c" into a list.
function splitMembers(s) {
return s.split(/[\s,;]+/).map((x) => x.trim()).filter(Boolean);
}
// stepsLine shows the steps in order, with controls for the ones above
// normal.
function stepsLine(steps, writable) {
const live = steps.filter((s) => s.pending !== "delete");
const order = async (ids) => {
try {
const r = await change("POST", "/blocking/steps/order", { steps: ids.map(String) });
toast(describeResult(r));
rerender();
} catch (e) { toast(e.message, "err", 8000); }
};
const move = (i, d) => {
const ids = live.map((s) => s.id);
[ids[i], ids[i + d]] = [ids[i + d], ids[i]];
order(ids);
};
const add = async () => {
const n = await promptName("Add a step", "Name of the new step (above the others), e.g. strict", "");
if (!n) return;
try {
const r = await change("POST", "/blocking/steps", { name: n });
toast(describeResult(r));
rerender();
} catch (e) { toast(e.message, "err", 8000); }
};
const rename = async (s) => {
const n = await promptName("Rename step " + s.name, "New name", s.name);
if (!n || n === s.name) return;
try {
const r = await change("PATCH", "/blocking/steps/" + s.id, { name: n });
toast(describeResult(r));
rerender();
} catch (e) { toast(e.message, "err", 8000); }
};
const chip = (label, title) => h("span", { class: "step-chip", title }, label);
const items = [chip("off", "only your rules"), chip("normal", "your rules and the normal lists; devices in no group")];
live.forEach((s, i) => {
const used = s.lists.length || s.groups.length;
items.push(h("span", { class: "step-chip accent", title: "lists: " + (s.lists.join(", ") || "none") + "; groups: " + (s.groups.join(", ") || "none") },
s.name, s.pending ? [" ", pendingBadge(s.pending)] : null,
writable ? h("span", { class: "step-tools" },
h("button", { class: "small", disabled: i === 0, title: "Move down: fewer lists", onclick: () => move(i, -1) }, "←"),
h("button", { class: "small", disabled: i === live.length - 1, title: "Move up: more lists", onclick: () => move(i, 1) }, "→"),
h("button", { class: "small", title: "Rename", onclick: () => rename(s) }, "✎"),
h("button", { class: "small danger", disabled: !!used, title: used ? "In use by lists or groups" : "Delete", onclick: () => del("/blocking/steps/" + s.id, "step " + s.name) }, "×")) : null));
});
return h("div", { class: "steps-line" }, h("span", { class: "muted small" }, "Steps: "),
items.map((x, i) => [i ? h("span", { class: "muted" }, " → ") : null, x]),
writable ? [" ", h("button", { class: "small", onclick: add }, "Add step")] : null);
}
// promptName asks for a short name in a dialog.
async function promptName(title, label, value) {
const i = input({ value, class: "mono" });
const v = await modal(title, h("div", { class: "form-grid" }, field(label, i)), [
{ label: "Cancel", value: null },
{ label: "OK", kind: "primary", value: true, onclick: () => !!i.value.trim() },
]);
return v === true ? i.value.trim() : null;
}
function editGroup(g, steps, views, autoViews) {
const name = input({ value: g.name, class: "mono" });
const members = input({ value: g.members.join(" "), class: "mono", style: { width: "100%" } });
const step = stepSelect(steps, g.step, true);
const comment = input({ value: g.comment || "" });
const vp = viewPicker(views, autoViews, ownViews(g));
editDialog("Edit client group " + g.name, "/blocking/groups/" + g.id, [
["Name", name],
["Devices", members, "Addresses, networks (10.10.0.0/24) or ACLs, separated by spaces. A device can be in one group; a more specific address in another group wins."],
["Step", step],
vp.picker ? ["Views", vp.picker, "None ticked: every view with blocking."] : null,
["Comment", comment],
], () => ({ name: name.value.trim(), members: splitMembers(members.value), step: step.value, views: vp.value(), comment: comment.value }));
}