Web UI: links to a rule open it, highlighted; rule IDs shown
A rule named in the matrix, the log, the tracer, statistics or the dashboard opens the Rules page on its chain, scrolled to it and highlighted (#/rules?rule=ID; the chain is looked up when the link has none). The Rules page shows each rule's ID beside its order. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
566c07cbc7
commit
21ddced1ca
8 files changed
+31
-8
No files matched your search
@@ -8,6 +8,11 @@ All notable changes to Housecarl are documented here. The format follows
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
- **Links to a rule open it.** A rule named in the matrix, the log, the
|
||||||
|
tracer, statistics or the dashboard's busiest rules opens the Rules
|
||||||
|
page on its chain, scrolled to it and highlighted (`#/rules?rule=ID`).
|
||||||
|
The Rules page shows each rule's ID (`#410`, as `hc` and labels name
|
||||||
|
it) beside its order.
|
||||||
- **A clearer zone matrix.** A cell reads allow, block, or **partly**
|
- **A clearer zone matrix.** A cell reads allow, block, or **partly**
|
||||||
(some new connections let through, the rest not), with the exceptions
|
(some new connections let through, the rest not), with the exceptions
|
||||||
counted by what they do (`+3 -1` instead of `+4`), and what they let
|
counted by what they do (`+3 -1` instead of `+4`), and what they let
|
||||||
@@ -17,6 +22,8 @@ All notable changes to Housecarl are documented here. The format follows
|
|||||||
between its interfaces passes the firewall). The details list each
|
between its interfaces passes the firewall). The details list each
|
||||||
rule's hits. API: `summary`, `allowed`, `blocked`, `open`,
|
rule's hits. API: `summary`, `allowed`, `blocked`, `open`,
|
||||||
`same_zone`, `note`, `hits`, and `short` and `hits` per exception.
|
`same_zone`, `note`, `hits`, and `short` and `hits` per exception.
|
||||||
|
Raw rules that are a match and then accept, drop or reject (without
|
||||||
|
interfaces) are read instead of shown as unknown.
|
||||||
- **Shadow mode** (`shadow.enabled` in housecarl.yaml; docs/shadow.md):
|
- **Shadow mode** (`shadow.enabled` in housecarl.yaml; docs/shadow.md):
|
||||||
Housecarl's ruleset is loaded beside the firewall you have but blocks,
|
Housecarl's ruleset is loaded beside the firewall you have but blocks,
|
||||||
rejects and translates nothing. Drops and rejects are counted and accept
|
rejects and translates nothing. Drops and rejects are counted and accept
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export async function render(el) {
|
|||||||
h("div", { class: "panel-head" }, h("h2", null, "Busiest rules"), h("a", { class: "button small", href: "#/rules" }, "All rules")),
|
h("div", { class: "panel-head" }, h("h2", null, "Busiest rules"), h("a", { class: "button small", href: "#/rules" }, "All rules")),
|
||||||
table([
|
table([
|
||||||
{ label: "Chain", render: (r) => r.chain },
|
{ label: "Chain", render: (r) => r.chain },
|
||||||
{ label: "Rule", render: (r) => r.raw || describe(r) },
|
{ label: "Rule", render: (r) => h("a", { href: "#/rules?chain=" + r.chain + "&rule=" + r.id }, "#" + r.id + " ", r.raw || describe(r)) },
|
||||||
{ label: "Action", render: (r) => badge(r.action, { accept: "ok", drop: "err", reject: "warn" }[r.action]) },
|
{ label: "Action", render: (r) => badge(r.action, { accept: "ok", drop: "err", reject: "warn" }[r.action]) },
|
||||||
{ label: "Hits", render: (r) => hits(cs, "r" + r.id) },
|
{ label: "Hits", render: (r) => hits(cs, "r" + r.id) },
|
||||||
{ label: "Comment", render: (r) => r.comment },
|
{ label: "Comment", render: (r) => r.comment },
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export async function render(el, params, query) {
|
|||||||
h("thead", null, h("tr", null, ["Time", "Why", "In", "Out", "From", "To", "Protocol"].map((t) => h("th", null, t)))),
|
h("thead", null, h("tr", null, ["Time", "Why", "In", "Out", "From", "To", "Protocol"].map((t) => h("th", null, t)))),
|
||||||
h("tbody", null, es.map((e) => h("tr", null,
|
h("tbody", null, es.map((e) => h("tr", null,
|
||||||
h("td", { class: "mono small" }, new Date(e.time).toLocaleTimeString(undefined, { hourCycle: "h23" })),
|
h("td", { class: "mono small" }, new Date(e.time).toLocaleTimeString(undefined, { hourCycle: "h23" })),
|
||||||
h("td", null, e.mark && e.mark.startsWith("r") ? h("a", { href: "#/rules", title: "rule " + e.mark.slice(1) }, e.prefix) : e.prefix,
|
h("td", null, e.mark && e.mark.startsWith("r") ? h("a", { href: "#/rules?rule=" + e.mark.slice(1), title: "rule #" + e.mark.slice(1) }, e.prefix) : e.prefix,
|
||||||
e.shadow && /^(drop|reject)$/.test(e.verdict) ? [" ", badge("would " + e.verdict, "warn")] : null),
|
e.shadow && /^(drop|reject)$/.test(e.verdict) ? [" ", badge("would " + e.verdict, "warn")] : null),
|
||||||
h("td", { class: "mono" }, e.in || ""), h("td", { class: "mono" }, e.out || ""),
|
h("td", { class: "mono" }, e.in || ""), h("td", { class: "mono" }, e.out || ""),
|
||||||
h("td", { class: "mono" }, addr(e.source, e.source_port)), h("td", { class: "mono" }, addr(e.destination, e.destination_port)),
|
h("td", { class: "mono" }, addr(e.source, e.source_port)), h("td", { class: "mono" }, addr(e.destination, e.destination_port)),
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export async function render(el) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function detail(c) {
|
function detail(c) {
|
||||||
const ruleLink = (id, chain, label) => id ? h("a", { href: "#/rules?chain=" + chain, onclick: () => document.querySelector(".modal-back") && document.querySelector(".modal-back").remove() }, label) : label;
|
const ruleLink = (id, chain, label) => id ? h("a", { href: "#/rules?chain=" + chain + "&rule=" + id, onclick: () => document.querySelector(".modal-back") && document.querySelector(".modal-back").remove() }, label) : label;
|
||||||
const v = (verdict) => badge(verdict, { allowed: "ok", blocked: "err", logged: "", unknown: "warn" }[verdict]);
|
const v = (verdict) => badge(verdict, { allowed: "ok", blocked: "err", logged: "", unknown: "warn" }[verdict]);
|
||||||
const hits = (n) => h("td", { class: "small num" }, n === undefined || n === null ? "–" : n.toLocaleString());
|
const hits = (n) => h("td", { class: "small num" }, n === undefined || n === null ? "–" : n.toLocaleString());
|
||||||
const summary = { allowed: "all allowed", blocked: "all blocked", partly: "partly allowed" }[c.summary];
|
const summary = { allowed: "all allowed", blocked: "all blocked", partly: "partly allowed" }[c.summary];
|
||||||
|
|||||||
@@ -24,7 +24,14 @@ const builtins = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export async function render(el, params, query) {
|
export async function render(el, params, query) {
|
||||||
const chain = query.get("chain") || "input";
|
// ?rule=ID (from the matrix, the log, the tracer, statistics): show
|
||||||
|
// that rule, in its chain, highlighted.
|
||||||
|
const target = Number(query.get("rule")) || 0;
|
||||||
|
let chain = query.get("chain");
|
||||||
|
if (!chain && target) {
|
||||||
|
try { chain = (await get("/rules/" + target)).chain; } catch { /* gone: the default chain */ }
|
||||||
|
}
|
||||||
|
chain = chain || "input";
|
||||||
const [all, settings, cs, qs] = await Promise.all([get("/rules", { chain }), get("/settings"), counters(), quotas()]);
|
const [all, settings, cs, qs] = await Promise.all([get("/rules", { chain }), get("/settings"), counters(), quotas()]);
|
||||||
// As nftables checks them: the rules that count traffic first.
|
// As nftables checks them: the rules that count traffic first.
|
||||||
const rules = [...all.filter(everyPacket), ...all.filter((r) => !everyPacket(r))];
|
const rules = [...all.filter(everyPacket), ...all.filter((r) => !everyPacket(r))];
|
||||||
@@ -40,7 +47,8 @@ export async function render(el, params, query) {
|
|||||||
h("div", { class: "panel" },
|
h("div", { class: "panel" },
|
||||||
builtinsRow(chain, settings, cs),
|
builtinsRow(chain, settings, cs),
|
||||||
table([
|
table([
|
||||||
{ label: "#", render: (r) => r.pending === "deleted" ? "" : String(live(rules).indexOf(r) + 1) },
|
{ label: "Order", render: (r) => r.pending === "deleted" ? "" : String(live(rules).indexOf(r) + 1) },
|
||||||
|
{ label: "ID", render: (r) => r.id ? h("span", { class: "muted mono small" }, "#" + r.id) : "" },
|
||||||
{ label: "Match", render: (r) => r.raw ? [badge("raw", ""), " ", h("span", { class: "mono small" }, r.raw)]
|
{ label: "Match", render: (r) => r.raw ? [badge("raw", ""), " ", h("span", { class: "mono small" }, r.raw)]
|
||||||
: [everyPacket(r) ? [badge("every packet", "accent"), " "] : null, describe(r), extras(r, qs[r.id])] },
|
: [everyPacket(r) ? [badge("every packet", "accent"), " "] : null, describe(r), extras(r, qs[r.id])] },
|
||||||
{ label: "Action", render: actionBadge },
|
{ label: "Action", render: actionBadge },
|
||||||
@@ -59,10 +67,15 @@ export async function render(el, params, query) {
|
|||||||
h("button", { class: "small reveal", onclick: () => del(r) }, "Delete"),
|
h("button", { class: "small reveal", onclick: () => del(r) }, "Delete"),
|
||||||
];
|
];
|
||||||
} } : null,
|
} } : null,
|
||||||
].filter(Boolean), rules, { empty: "No rules in this chain yet.", rowClass }),
|
].filter(Boolean), rules, { empty: "No rules in this chain yet.", rowClass: (r) => rowClass(r) + (target && r.id === target ? " row-target" : "") }),
|
||||||
rules.some(everyPacket) ? h("div", { class: "muted small" }, "Rules marked “every packet” are checked before established connections are accepted: they see all of a connection's traffic, not only its first packet, so a quota counts all of it and a block stops connections already open. They can drop, reject or log, not accept.") : null,
|
rules.some(everyPacket) ? h("div", { class: "muted small" }, "Rules marked “every packet” are checked before established connections are accepted: they see all of a connection's traffic, not only its first packet, so a quota counts all of it and a block stops connections already open. They can drop, reject or log, not accept.") : null,
|
||||||
h("div", { class: "policy small" }, "Then the policy: ", badge(policy, policy === "drop" ? "err" : "ok"),
|
h("div", { class: "policy small" }, "Then the policy: ", badge(policy, policy === "drop" ? "err" : "ok"),
|
||||||
policy === "drop" ? [" — ", hits(cs, "p-" + chain), " packets reached it"] : null)));
|
policy === "drop" ? [" — ", hits(cs, "p-" + chain), " packets reached it"] : null)));
|
||||||
|
if (target) {
|
||||||
|
const row = el.querySelector("tr.row-target");
|
||||||
|
if (row) requestAnimationFrame(() => row.scrollIntoView({ block: "center" }));
|
||||||
|
else el.querySelector(".tabs").after(h("div", { class: "msg warn" }, "There is no rule #" + target + " (any more)."));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// everyPacket: checked before established connections are accepted (as
|
// everyPacket: checked before established connections are accepted (as
|
||||||
|
|||||||
@@ -180,7 +180,7 @@ function rangeRow(since) {
|
|||||||
// ruleHref: a counter's mark to its page ("r12" → the rules, "b-…" → the
|
// ruleHref: a counter's mark to its page ("r12" → the rules, "b-…" → the
|
||||||
// built-ins under Settings).
|
// built-ins under Settings).
|
||||||
function ruleHref(mark) {
|
function ruleHref(mark) {
|
||||||
if (/^r\d/.test(mark)) return "#/rules";
|
if (/^r\d+$/.test(mark)) return "#/rules?rule=" + mark.slice(1);
|
||||||
if (mark.startsWith("b-") || mark.startsWith("p-")) return "#/settings";
|
if (mark.startsWith("b-") || mark.startsWith("p-")) return "#/settings";
|
||||||
if (mark.startsWith("f")) return "#/nat";
|
if (mark.startsWith("f")) return "#/nat";
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export async function render(el, params, query) {
|
|||||||
h("strong", null, p.would_drop.label || [p.would_drop.chain, p.would_drop.mark].filter(Boolean).join(" "))) : null,
|
h("strong", null, p.would_drop.label || [p.would_drop.chain, p.would_drop.mark].filter(Boolean).join(" "))) : null,
|
||||||
h("table", { class: "trace-steps" }, h("tbody", null, p.steps.map((s) => h("tr", { class: /^(drop|reject)/.test(s.verdict) || /\[would (drop|reject)\]/.test(s.rule || "") ? "step-drop" : s.verdict === "accept" ? "step-accept" : "" },
|
h("table", { class: "trace-steps" }, h("tbody", null, p.steps.map((s) => h("tr", { class: /^(drop|reject)/.test(s.verdict) || /\[would (drop|reject)\]/.test(s.rule || "") ? "step-drop" : s.verdict === "accept" ? "step-accept" : "" },
|
||||||
h("td", { class: "muted small" }, s.table + " " + s.chain),
|
h("td", { class: "muted small" }, s.table + " " + s.chain),
|
||||||
h("td", null, s.label ? (s.mark && s.mark.startsWith("r") ? h("a", { href: "#/rules?chain=" + s.chain }, s.label) : s.label)
|
h("td", null, s.label ? (s.mark && s.mark.startsWith("r") ? h("a", { href: "#/rules?chain=" + s.chain + "&rule=" + s.mark.slice(1).split("-")[0] }, s.label) : s.label)
|
||||||
: s.kind === "policy" ? s.chain + " policy" : h("span", { class: "mono small" }, s.rule)),
|
: s.kind === "policy" ? s.chain + " policy" : h("span", { class: "mono small" }, s.rule)),
|
||||||
h("td", { class: "small" }, s.verdict))))));
|
h("td", { class: "small" }, s.verdict))))));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -433,6 +433,9 @@ td.matrix-blocked { background: var(--del); color: var(--del-text); }
|
|||||||
.matrix-n { margin-left: 6px; font-weight: 400; font-size: 12px; }
|
.matrix-n { margin-left: 6px; font-weight: 400; font-size: 12px; }
|
||||||
td.matrix-partly { background: var(--warn-soft); color: var(--text); }
|
td.matrix-partly { background: var(--warn-soft); color: var(--text); }
|
||||||
td.matrix-same { opacity: 0.55; }
|
td.matrix-same { opacity: 0.55; }
|
||||||
|
/* The rule a link pointed at (?rule=ID). */
|
||||||
|
tr.row-target td { background: var(--accent-soft); font-weight: 600; }
|
||||||
|
tr.row-target td:first-child { box-shadow: inset 3px 0 0 var(--accent); }
|
||||||
.matrix-open { display: block; font-weight: 400; font-size: 12px; opacity: 0.85; margin-top: 2px; }
|
.matrix-open { display: block; font-weight: 400; font-size: 12px; opacity: 0.85; margin-top: 2px; }
|
||||||
td.matrix-none { color: var(--muted); }
|
td.matrix-none { color: var(--muted); }
|
||||||
tr.matrix-rest td { border-top: 2px solid var(--border); }
|
tr.matrix-rest td { border-top: 2px solid var(--border); }
|
||||||
|
|||||||
Reference in new issue
Block a user