Names beside inside addresses

Reverse lookups (internal/names, POST /names) put a name beside each
inside address in connections, the log, quota usage, the dashboard's
quotas and the statistics' devices and blocked sources, in the web UI
and hc (--no-names to leave them out). Only inside addresses are looked
up: an inside zone's networks, and private ones outside the external
zones'. Names are cached (names.cache, 10m) and never stored;
names.resolver picks the DNS server.

Also: hc stats top devices listed each device twice; the dashboard's
quotas named the rule's ID twice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
abrown38andClaude Opus 5.5 committed 2026-10-21 10:58:35 +01:00
1 parent 3f24ae0676
commit 42e2013cb2
26 files changed
+536 -22

No files matched your search

@@ -3,7 +3,7 @@
import { h, mount, input, debounce, toast, confirmDialog, badge } from "../ui.js";
import { get, api, can } from "../api.js";
import { onLeave } from "../app.js";
import { fmtSeconds } from "../widgets.js";
import { fmtSeconds, lookupNames, named } from "../widgets.js";
export async function render(el, params, query) {
const search = input({ type: "search", placeholder: "address, :port, protocol, state…", value: query.get("q") || "" });
@@ -22,14 +22,15 @@ export async function render(el, params, query) {
};
const draw = async () => {
shown = await get("/connections", { q: search.value.trim(), limit: 500 });
const names = await lookupNames(shown.flatMap((c) => [c.source, c.destination, c.dnat_to]));
count.textContent = shown.length === 1 ? "1 connection" : shown.length + " connections";
mount(out, shown.length ? h("div", { class: "table-wrap" }, h("table", null,
h("thead", null, h("tr", null, ["Protocol", "From", "To", "NAT", "State", "Expires", ""].map((t) => h("th", null, t)))),
h("tbody", null, shown.map((c) => h("tr", null,
h("td", null, c.protocol),
h("td", { class: "mono" }, hostPort(c.source, c.source_port)),
h("td", { class: "mono" }, hostPort(c.destination, c.destination_port)),
h("td", { class: "mono small" }, c.dnat_to ? "to " + hostPort(c.dnat_to, c.dnat_port || c.destination_port) : "", c.snat_to ? (c.dnat_to ? ", " : "") + "as " + c.snat_to : ""),
h("td", { class: "mono" }, named(names, c.source, hostPort(c.source, c.source_port))),
h("td", { class: "mono" }, named(names, c.destination, hostPort(c.destination, c.destination_port))),
h("td", { class: "mono small" }, c.dnat_to ? ["to ", named(names, c.dnat_to, hostPort(c.dnat_to, c.dnat_port || c.destination_port))] : "", c.snat_to ? (c.dnat_to ? ", " : "") + "as " + c.snat_to : ""),
h("td", null, c.state || "", c.assured ? null : [" ", badge("one way", "")]),
h("td", { class: "small" }, fmtSeconds(c.timeout)),
h("td", { class: "actions-cell" }, write ? h("button", { class: "small reveal", onclick: () => close([c], "this connection") }, "Close") : null)))))) :
@@ -3,7 +3,7 @@
import { h, mount, table, badge, problemsList, time, plural } from "../ui.js";
import { get, can } from "../api.js";
import { onLeave } from "../app.js";
import { describe, hits, humanCount, fmtBytes, usageBar } from "../widgets.js";
import { describe, hits, humanCount, fmtBytes, usageBar, lookupNames, named } from "../widgets.js";
import { lineChart, chartCard, legend, timeTable, stepSeconds } from "../charts.js";
import { bytes } from "./stats.js";
@@ -55,7 +55,7 @@ export async function render(el) {
{ label: "Hits", render: (r) => hits(cs, "r" + r.id) },
{ label: "Comment", render: (r) => r.comment },
], busiest)) : null,
quotas.length ? quotaPanel(quotas) : null,
quotas.length ? quotaPanel(quotas, await lookupNames(quotas.map((q) => (q.addresses || [])[0]).filter(Boolean).map((a) => a.address))) : null,
log.length ? h("div", { class: "panel" },
h("div", { class: "panel-head" }, h("h2", null, "Latest logged packets"), h("a", { class: "button small", href: "#/log" }, "The log")),
table([
@@ -73,16 +73,16 @@ export async function render(el) {
// quotaPanel lists every rule's quota with what it used: the total, or
// for a per-address quota the busiest address (the rest are on the rule).
function quotaPanel(quotas) {
function quotaPanel(quotas, names) {
const perAddress = (q) => q.quota.per_source || q.quota.per_destination;
const used = (q) => perAddress(q) ? ((q.addresses || [])[0] || { used: 0 }).used : q.used;
return h("div", { class: "panel" },
h("div", { class: "panel-head" }, h("h2", null, "Quotas"), h("a", { class: "button small", href: "#/stats" }, "Statistics")),
table([
{ label: "Rule", render: (q) => h("a", { href: "#/rules?rule=" + q.rule_id }, "#" + q.rule_id + " ", q.rule) },
{ label: "Rule", render: (q) => h("a", { href: "#/rules?rule=" + q.rule_id }, q.rule) },
{ label: "Allowance", render: (q) => fmtBytes(q.quota.bytes) + (q.quota.per_source ? " each source" : q.quota.per_destination ? " each destination" : "") },
{ label: "Used", render: (q) => perAddress(q)
? [plural((q.addresses || []).length, "address", "addresses"), q.addresses && q.addresses.length ? ", busiest " + fmtBytes(used(q)) : ""]
? [plural((q.addresses || []).length, "address", "addresses"), q.addresses && q.addresses.length ? [", busiest ", named(names, q.addresses[0].address), " " + fmtBytes(used(q))] : ""]
: fmtBytes(q.used) },
{ label: "", render: (q) => usageBar(used(q), q.quota.bytes) },
{ label: "Next reset", render: (q) => q.next_reset ? time(q.next_reset) : h("span", { class: "muted" }, "by hand") },
+4 -1
View File
@@ -3,6 +3,7 @@
import { h, mount, input, debounce, badge } from "../ui.js";
import { get, state } from "../api.js";
import { onLeave } from "../app.js";
import { lookupNames, named } from "../widgets.js";
export async function render(el, params, query) {
const search = input({ type: "search", placeholder: "address, interface, rule…", value: query.get("q") || "" });
@@ -12,6 +13,7 @@ export async function render(el, params, query) {
const addr = (a, port) => port ? (a.includes(":") ? "[" + a + "]:" + port : a + ":" + port) : a;
const draw = async () => {
const es = await get("/log", { limit: 300, q: search.value.trim() });
const names = await lookupNames(es.flatMap((e) => [e.source, e.destination]));
mount(out, es.length ? h("div", { class: "table-wrap" }, h("table", { class: "activity" },
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,
@@ -19,7 +21,8 @@ export async function render(el, params, query) {
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)),
h("td", { class: "mono" }, named(names, e.source, addr(e.source, e.source_port))),
h("td", { class: "mono" }, named(names, e.destination, addr(e.destination, e.destination_port))),
h("td", null, e.protocol, e.icmp_type ? " " + e.icmp_type : "", e.tcp_flags ? h("span", { class: "muted small" }, " " + e.tcp_flags) : null)))))) :
h("div", { class: "empty" }, state.me.mode === "shadow"
? "Nothing logged yet. In shadow mode, what the policies would drop is logged; turn logging on for a rule with Edit → Log."
+3 -2
View File
@@ -2,7 +2,7 @@
import { h, mount, table, modal, errorBox, field, input, select, checkbox, confirmDialog, badge, textarea, toast } from "../ui.js";
import { get, api, change, can } from "../api.js";
import { rerender } from "../app.js";
import { names, matchEditor, describe, hits, counters, listEditor, rowClass, live, fmtBytes, quotas, usageBar } from "../widgets.js";
import { names, matchEditor, describe, hits, counters, listEditor, rowClass, live, fmtBytes, quotas, usageBar, lookupNames, named } from "../widgets.js";
const chains = [
["input", "Input", "Traffic to the firewall itself."],
@@ -179,10 +179,11 @@ function quotaLine(r, q) {
}
async function usageDialog(r, q) {
const names = await lookupNames(q.addresses.map((a) => a.address));
await modal("Quota usage of " + (r.comment || "rule #" + r.id), h("div", null,
h("p", { class: "muted" }, "Each " + (r.quota.per_source ? "source" : "destination") + " has " + fmtBytes(r.quota.bytes) + "."),
table([
{ label: "Address", render: (a) => h("span", { class: "mono" }, a.address) },
{ label: "Address", render: (a) => h("span", { class: "mono" }, named(names, a.address)) },
{ label: "Used", render: (a) => fmtBytes(a.used) },
{ label: "", render: (a) => usageBar(a.used, r.quota.bytes) },
], q.addresses)), [{ label: "Close", value: null }], { wide: true });
@@ -3,6 +3,7 @@
import { h, mount, fmtTime, errorBox } from "../ui.js";
import { get } from "../api.js";
import { lineChart, chartCard, legend, timeTable, heatmap, heatmapTable, barList, compact, stepSeconds, endLabelWidth } from "../charts.js";
import { withNames } from "../widgets.js";
const ranges = [["1h", "1 hour"], ["6h", "6 hours"], ["24h", "24 hours"], ["7d", "7 days"], ["30d", "30 days"], ["90d", "90 days"], ["365d", "a year"]];
const rangeSecs = { "1h": 3600, "6h": 6 * 3600, "24h": 86400, "7d": 7 * 86400, "30d": 30 * 86400, "90d": 90 * 86400, "365d": 365 * 86400 };
@@ -142,6 +143,7 @@ async function loadAll(since, heatSince) {
get("/stats/timeline", { since, keys: "g:conntrack,g:conntrack_max,g:mem,g:rss,g:load,g:cpu" }),
get("/stats/timeline", { since, keys: "quota:*" }),
]);
await withNames(summary.devices_down, summary.devices_up, summary.blocked_sources);
return { summary, zones, traffic, drops, heat, hits, gauges,
quotas: quotas.series.map((x) => ({ key: x.key.slice(6), count: x.values.reduce((a, v) => a + Math.max(v, 0), 0) })).filter((e) => e.count > 0).sort((a, b) => b.count - a.count) };
}
+1
View File
@@ -85,6 +85,7 @@ h1 { font-size: 20px; margin: 0 0 4px; }
h2 { font-size: 16px; margin: 18px 0 8px; }
h3 { font-size: 14px; margin: 14px 0 6px; }
.muted { color: var(--muted); }
.host-name { font-weight: 600; }
.small { font-size: 12px; }
.nowrap { white-space: nowrap; }
.warn-text { color: var(--warn); }
+22 -1
View File
@@ -1,7 +1,7 @@
// Widgets shared by several pages: list editors with suggestions, the
// match editor of rules and NAT, descriptions, hit counts, durations.
import { h, input, select, field, checkbox } from "./ui.js";
import { get } from "./api.js";
import { get, api } from "./api.js";
// ---- durations
@@ -163,6 +163,27 @@ export function usageBar(used, total) {
return h("div", { class: "usage" }, h("div", { class: "usage-bar" + (used >= total ? " full" : "") }, seg));
}
// lookupNames asks for inside addresses' names (docs/configuration.md):
// {address: name}, {} when names are off or can't be had.
export async function lookupNames(addrs) {
const list = [...new Set(addrs.filter(Boolean))];
if (!list.length) return {};
try { return (await api("POST", "/names", { body: { addresses: list } })).names || {}; } catch { return {}; }
}
// named shows an address (or text with it, such as address:port) after
// its name's first label; the whole name is in the tooltip.
export function named(names, addr, text = addr) {
const n = names[addr];
return n ? h("span", { title: n + " (" + addr + ")" }, h("span", { class: "host-name" }, n.split(".")[0]), " ", h("span", { class: "muted" }, text)) : text;
}
// withNames sets each top-list entry's name from its key (an address).
export async function withNames(...lists) {
const names = await lookupNames(lists.flat().map((e) => e.key));
for (const e of lists.flat()) if (names[e.key]) e.name = names[e.key].split(".")[0];
}
// quotas loads what the rules' quotas used, by rule ID ({} without
// log:read).
export async function quotas() {