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
|
||||
|
||||
- **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**
|
||||
(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
|
||||
@@ -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
|
||||
rule's hits. API: `summary`, `allowed`, `blocked`, `open`,
|
||||
`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):
|
||||
Housecarl's ruleset is loaded beside the firewall you have but blocks,
|
||||
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")),
|
||||
table([
|
||||
{ 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: "Hits", render: (r) => hits(cs, "r" + r.id) },
|
||||
{ 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("tbody", null, es.map((e) => h("tr", null,
|
||||
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),
|
||||
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)),
|
||||
|
||||
@@ -32,7 +32,7 @@ export async function render(el) {
|
||||
}
|
||||
|
||||
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 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];
|
||||
|
||||
@@ -24,7 +24,14 @@ const builtins = {
|
||||
};
|
||||
|
||||
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()]);
|
||||
// As nftables checks them: the rules that count traffic first.
|
||||
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" },
|
||||
builtinsRow(chain, settings, cs),
|
||||
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)]
|
||||
: [everyPacket(r) ? [badge("every packet", "accent"), " "] : null, describe(r), extras(r, qs[r.id])] },
|
||||
{ label: "Action", render: actionBadge },
|
||||
@@ -59,10 +67,15 @@ export async function render(el, params, query) {
|
||||
h("button", { class: "small reveal", onclick: () => del(r) }, "Delete"),
|
||||
];
|
||||
} } : 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,
|
||||
h("div", { class: "policy small" }, "Then the policy: ", badge(policy, policy === "drop" ? "err" : "ok"),
|
||||
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
|
||||
|
||||
@@ -180,7 +180,7 @@ function rangeRow(since) {
|
||||
// ruleHref: a counter's mark to its page ("r12" → the rules, "b-…" → the
|
||||
// built-ins under Settings).
|
||||
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("f")) return "#/nat";
|
||||
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("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", 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)),
|
||||
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; }
|
||||
td.matrix-partly { background: var(--warn-soft); color: var(--text); }
|
||||
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; }
|
||||
td.matrix-none { color: var(--muted); }
|
||||
tr.matrix-rest td { border-top: 2px solid var(--border); }
|
||||
|
||||
Reference in new issue
Block a user