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:
1 parent
5b8f681e2a
commit
76aaaf3ef5
33 files changed
+2096
-55
No files matched your search
@@ -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 }));
|
||||
}
|
||||
@@ -326,6 +326,12 @@ td.break { word-break: break-all; }
|
||||
tr .reveal { opacity: 0; transition: opacity 0.1s; }
|
||||
tr:hover .reveal, tr:focus-within .reveal { opacity: 1; }
|
||||
@media (hover: none) { tr .reveal { opacity: 1; } }
|
||||
/* Blocking steps, in order, on the Blocking page. */
|
||||
.steps-line { display: flex; flex-wrap: wrap; align-items: center; gap: 4px; margin: 4px 0 10px; }
|
||||
.step-chip { display: inline-flex; align-items: center; gap: 4px; padding: 2px 8px; border: 1px solid var(--border); border-radius: 12px; font-family: var(--mono); font-size: 13px; background: var(--panel); }
|
||||
.step-chip.accent { border-color: var(--accent); }
|
||||
.step-tools { display: inline-flex; gap: 2px; margin-left: 2px; }
|
||||
.step-tools button { padding: 0 5px; min-width: 0; }
|
||||
h2.section { margin: 24px 0 10px; font-size: 18px; }
|
||||
.filter-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 12px; }
|
||||
.segmented { display: inline-flex; border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; background: var(--panel); }
|
||||
|
||||
Reference in new issue
Block a user