Files
abrown38andClaude Opus 5.5 21ddced1ca 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>
2026-10-20 19:18:33 +01:00

80 lines
5.2 KiB
JavaScript

// Trace: follow packets through every table's rules, and see what
// decided their fate.
import { h, mount, input, select, field, errorBox, badge } from "../ui.js";
import { get, api, can } from "../api.js";
import { onLeave } from "../app.js";
export async function render(el, params, query) {
const address = input({ placeholder: "192.168.1.50, or 2001:db8:1::/64", class: "mono", value: (query && query.get && query.get("address")) || "" });
const port = input({ placeholder: "443", class: "short", type: "number", min: 1, max: 65535 });
const protocol = select([["", "any"], "tcp", "udp", "sctp", "icmp", "icmpv6"], "", { class: "inline" });
const seconds = input({ value: "60", class: "short", type: "number", min: 5, max: 600 });
const err = h("div");
const status = h("div");
const packets = h("div");
const write = can("config", true);
const startBtn = h("button", { class: "primary", onclick: start }, "Start tracing");
const stopBtn = h("button", { class: "hidden", onclick: stop }, "Stop");
let timer = null;
async function start() {
mount(err);
try {
await api("POST", "/trace", { body: { address: address.value.trim(), port: parseInt(port.value || "0", 10), protocol: protocol.value, seconds: parseInt(seconds.value || "60", 10) } });
poll();
} catch (e) { mount(err, errorBox(e)); }
}
async function stop() {
try { show(await api("DELETE", "/trace")); } catch (e) { mount(err, errorBox(e)); }
}
async function poll() {
clearTimeout(timer);
let st;
try { st = await get("/trace"); } catch (e) { mount(err, errorBox(e)); return; }
show(st);
if (st.running) timer = setTimeout(poll, 1000);
}
function show(st) {
startBtn.classList.toggle("hidden", st.running || !write);
stopBtn.classList.toggle("hidden", !st.running || !write);
const what = [st.input.address, st.input.protocol, st.input.port ? "port " + st.input.port : ""].filter(Boolean).join(" ");
mount(status, st.started && st.started !== "0001-01-01T00:00:00Z" ? h("div", { class: "msg " + (st.error ? "err" : st.running ? "info" : "ok") },
st.running ? ["Tracing ", h("strong", null, what), " until " + new Date(st.ends).toLocaleTimeString() + ". Make the traffic happen now."]
: ["Traced ", h("strong", null, what), ": " + (st.packets.length === 1 ? "1 packet" : st.packets.length + " packets") + "."],
st.error ? h("div", null, st.error) : null) : null);
mount(packets, st.packets.length ? st.packets.slice().reverse().map(packet)
: st.running ? h("div", { class: "empty" }, "No packets yet.") : null);
}
const hostPort = (a, p) => !a ? "" : !p ? a : a.includes(":") ? "[" + a + "]:" + p : a + ":" + p;
function packet(p) {
const head = [p.protocol, p.tcp_flags, p.icmp_type].filter(Boolean).join(" ");
const decided = p.decided_by.label || [p.decided_by.table, p.decided_by.chain, p.decided_by.kind].filter(Boolean).join(" ");
return h("div", { class: "panel trace-packet" },
h("div", { class: "trace-head" },
badge(p.verdict, p.verdict === "dropped" ? "err" : "ok"), " ",
h("span", { class: "mono" }, hostPort(p.source, p.source_port), " → ", hostPort(p.destination, p.destination_port)), " ",
h("span", { class: "muted" }, head, p.in ? " · in " + p.in : "", p.out ? " · out " + p.out : ""),
h("span", { class: "muted small trace-time" }, new Date(p.time).toLocaleTimeString())),
decided ? h("div", { class: "small" }, p.verdict === "dropped" ? "Dropped by " : "Accepted by ", h("strong", null, decided)) : null,
p.would_drop ? h("div", { class: "small" }, badge("shadow mode", "warn"), " Housecarl would have dropped it: ",
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 + "&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))))));
}
mount(el,
h("div", { class: "page-head" }, h("div", { class: "grow" }, h("h1", null, "Trace"),
h("div", { class: "muted" }, "Follows packets through the whole ruleset, Housecarl's rules and every other table (Docker, fail2ban…): each rule and policy they meet, and what accepted or dropped them. For “why doesn't this get through?”"))),
write ? h("div", { class: "panel" }, h("div", { class: "form-grid" },
field("Address", address, "Packets from or to it (either way). An address or a prefix."),
field("Port", h("div", null, port, " ", protocol), "Packets from or to it. Empty: any."),
field("For", h("div", null, seconds, " seconds"), "At most 600; it stops by itself after 200 packets.")),
h("div", { class: "actions form-actions" }, startBtn, stopBtn), err) : null,
status, packets);
await poll();
onLeave(() => clearTimeout(timer));
}