internal/stats samples every minute what the kernel already counts: the
interfaces' counters (per zone too), Housecarl's table (hits per rule,
built-in and policy; drops by chain; quotas; per-device sets), the
connection tracking table, and housecarld's and the host's memory, CPU
and load. Logged drops feed top lists of sources and what they tried.
Aggregates go to a database of their own (stats.db; per minute 7 days,
per hour 90 days, per day 2 years), with the store and queries from
Jarl's.
Traffic per inside device is a setting (stats_per_device): counting sets
stats_up/down_v4/v6 and rules at the top of the forward chain, IPv6 per
/64, carried across reloads. Set names starting with meter_, conn_,
quota_ or stats_ are reserved (set_name_reserved).
GET /stats/{status,summary,timeline,top,hits,heatmap}, hc stats, a
Statistics page with Jarl's charts, two charts on the dashboard, and a
conntrack_full warning. e2e: 188 checks.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
78 lines
4.9 KiB
JavaScript
78 lines
4.9 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,
|
|
h("table", { class: "trace-steps" }, h("tbody", null, p.steps.map((s) => h("tr", { class: /^(drop|reject)/.test(s.verdict) ? "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)
|
|
: 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));
|
|
}
|