Files
jarl/internal/web/static/assets/pages/exposure.js
T
adamandClaude Opus 5.5 c27a6c10bd Mail reverse DNS check, what Jarl adds to named.conf, lists one per line
jarld checks the reverse DNS of the public zones' MX and SPF addresses as the internet sees it, every 6 hours (Exposure page, jarl exposure mail, jarl_mail_reverse_dns_ok). The Server options page, jarl option generated and GET /options/generated list what Jarl writes into named.conf by itself; stats.rate_limit_log turns off the query-errors logging. Option lists of three or more elements are shown one per line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 21:02:11 +01:00

153 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Internet exposure (docs/exposure.md): which views are reachable from
// the internet, why, and what to harden, with fixes staged like any edit
// and findings that can be accepted on purpose.
import { h, mount, table, toast, badge, modal, input, field, errorBox, select, plural, relTime } from "../ui.js";
import { api, get, change, describeResult, can } from "../api.js";
import { rerender } from "../app.js";
const sevKind = { warning: "warn", error: "err", info: "" };
export async function render(el) {
const r = await get("/exposure");
const writable = can("options", true);
const parts = [h("div", { class: "page-head" },
h("div", { class: "grow" }, h("h1", null, "Internet exposure"),
h("div", { class: "muted" }, "Which views can be reached from the internet, and what to harden on them. A view is public when its match-clients let in anyone (they end in any), whatever kind of addresses your own networks use. Fixes are staged as pending changes for you to review.")))];
parts.push(h("div", { class: "panel" }, h("h2", null, "Views"), table([
{ label: "View", render: (s) => s.view ? h("a", { href: "#/views/" + s.view_id }, s.view) : h("span", { class: "muted" }, "the server (no views)") },
{ label: "Exposure", render: (s) => [badge(s.public ? "public" : "private", s.public ? "warn" : "ok"), s.override ? h("span", { class: "muted small" }, " set by hand") : null] },
{ label: "Why", render: (s) => h("span", { class: "small" }, s.reason) },
{ label: "Rate limiting", render: (s) => s.public ? badge(s.rate_limit, { off: "err", "log-only": "warn", enforcing: "ok" }[s.rate_limit]) : h("span", { class: "muted" }, "–") },
{ label: "", render: (s) => writable ? overrideSelect(s) : null, tdClass: "actions-cell" },
], r.scopes)));
const all = [];
for (const s of r.scopes) for (const f of s.findings) all.push({ ...f, scope: s.view || "server", viewID: s.view_id });
for (const f of r.server) all.push({ ...f, scope: "server", viewID: 0 });
parts.push(h("div", { class: "panel" }, h("h2", null, "Findings ", badge(plural(all.filter((f) => f.severity === "warning").length, "warning"), all.some((f) => f.severity === "warning") ? "warn" : "ok")),
all.length ? h("div", { class: "findings" }, all.map((f) => findingRow(f, writable))) : h("div", { class: "empty" }, r.scopes.some((s) => s.public) ? "Nothing to harden." : "No view is reachable from the internet, so there is nothing to check.")));
if (r.rate_limiting && r.rate_limit_log) parts.push(activity(r.rate_limiting, r.scopes));
else if (r.rate_limiting && r.scopes.some((s) => s.public && s.rate_limit !== "off")) {
parts.push(h("div", { class: "msg info" }, "What rate limiting limits isn't counted: stats.rate_limit_log is off in /etc/jarl/jarl.yaml, so its log lines stay in named's syslog only."));
}
if (r.scopes.some((s) => s.public)) parts.push(mailPanel(r.mail));
mount(el, parts);
}
function overrideSelect(s) {
const sel = select([["", "Work it out"], ["public", "Public"], ["private", "Private"]], s.override || "");
sel.title = "Override the classification when Jarl gets it wrong (e.g. a port forward, where named only sees private addresses)";
sel.addEventListener("change", async () => {
try {
const r = s.view_id
? await change("PATCH", "/views/" + s.view_id, { exposure: sel.value || "auto" })
: await change("PATCH", "/settings", { exposure: sel.value || "auto" });
toast(describeResult(r));
rerender();
} catch (e) { toast(e.message, "err", 8000); }
});
return sel;
}
function findingRow(f, writable) {
const acked = f.acknowledged;
const actions = writable ? h("div", { class: "actions" },
f.fix && !acked ? h("button", { class: "small primary", title: f.fix, onclick: () => fix(f) }, "Fix") : null,
acked
? h("button", { class: "small", onclick: () => unack(f) }, "Take back")
: h("button", { class: "small", onclick: () => ack(f) }, "Acknowledge")) : null;
return h("div", { class: "finding " + (acked ? "acked" : f.severity) },
h("div", { class: "grow" },
h("div", null, badge(acked ? "acknowledged" : f.severity, acked ? "" : sevKind[f.severity]), " ", h("strong", null, f.scope), " ", h("span", { class: "mono small muted" }, f.code)),
h("div", null, f.message),
f.fix && !acked ? h("div", { class: "small muted" }, "Fix: " + f.fix) : null),
actions);
}
async function fix(f) {
try {
const r = await change("POST", "/exposure/fix", { view_id: f.viewID, code: f.code });
toast(describeResult(r));
(r.warnings || []).forEach((w) => toast(w, "warn", 8000));
rerender();
} catch (e) { toast(e.message, "err", 10000); }
}
async function ack(f) {
const reason = input({ placeholder: "e.g. public resolver for the club's network, monitored", style: { width: "100%" } });
const err = h("div");
await modal("Acknowledge " + f.code, h("div", null,
h("p", null, f.message),
h("p", { class: "muted small" }, "The finding stays visible, as info, with your name, the date and your reason. Jarl warns again if it comes up somewhere else."),
field("Why is this accepted?", reason), err), [
{ label: "Cancel", value: null },
{ label: "Acknowledge", kind: "primary", onclick: async () => {
try {
const r = await change("POST", "/exposure/ack", { view_id: f.viewID, code: f.code, reason: reason.value.trim() });
toast(describeResult(r));
rerender();
return true;
} catch (e) { mount(err, errorBox(e)); return false; }
} },
]);
}
async function unack(f) {
try {
const r = await change("POST", "/exposure/unack", { view_id: f.viewID, code: f.code });
toast(describeResult(r));
rerender();
} catch (e) { toast(e.message, "err", 8000); }
}
// activity shows what rate limiting limited, or would have in log-only
// mode, so you can check nobody legitimate is hit before enforcing.
function activity(a, scopes) {
const counts = Object.fromEntries((a.counts || []).map((e) => [e.key, e.count]));
const would = (counts["would-drop"] || 0) + (counts["would-slip"] || 0);
const did = (counts["drop"] || 0) + (counts["slip"] || 0);
const logOnly = scopes.some((s) => s.public && s.rate_limit === "log-only");
const any = scopes.some((s) => s.public && s.rate_limit !== "off");
if (!any) return null;
return h("div", { class: "panel" }, h("h2", null, "Rate limiting, last " + a.since),
!would && !did ? h("div", { class: "empty" }, "Nothing was limited" + (logOnly ? " (or would have been)" : "") + ". If you just turned it on, give it a day.") : [
h("p", null,
did ? plural(did, "response") + " limited (" + (counts["drop"] || 0) + " dropped, " + (counts["slip"] || 0) + " slipped). " : "",
would ? plural(would, "response") + " would have been limited in log-only mode (" + (counts["would-drop"] || 0) + " dropped, " + (counts["would-slip"] || 0) + " slipped)." : ""),
h("p", { class: "muted small" }, "Dropped responses get no answer; slipped ones get a short reply asking the client to retry over TCP (or with a DNS cookie), which a real client does and a spoofed victim of an attack never sees."),
logOnly ? h("p", { class: "muted small" }, "Check the networks below: your secondaries, monitoring, or a big shared resolver shouldn't be among them. If they are, add them to exempt-clients; otherwise start enforcing.") : null,
h("div", { class: "chart-grid" },
table([{ label: "Client network (limited or would be)", render: (e) => h("span", { class: "mono" }, e.key) }, { label: "Responses", render: (e) => String(e.count) }], a.networks || []),
table([{ label: "Name and type", render: (e) => h("span", { class: "mono" }, e.key) }, { label: "Responses", render: (e) => String(e.count) }], a.names || []))]);
}
// mailPanel: the reverse DNS of the addresses that send and receive the
// public zones' mail, as receivers check it (docs/exposure.md#mail).
function mailPanel(m) {
const out = h("div");
const lv = { ok: "ok", info: "", warning: "warn" };
const draw = (x) => {
if (!x) return mount(out, h("div", { class: "muted" }, "Not checked yet: Jarl checks a minute after it starts, then every 6 hours."));
const rows = x.results.length ? table([
{ label: "Address", render: (r) => [h("span", { class: "mono" }, r.address), h("div", { class: "muted small" }, r.zone + ": " + r.roles.join(", "))] },
{ label: "PTR", render: (r) => r.ptr.length ? h("span", { class: "mono" }, r.ptr.join(", ")) : h("span", { class: "muted" }, "none") },
{ label: "Reverse zone run by", render: (r) => h("span", { class: "small" }, r.managed_by || "–") },
{ label: "", render: (r) => [badge(r.ok ? (r.level === "info" ? "works, note" : "works") : "problem", lv[r.level]),
r.level !== "ok" ? h("div", { class: "small" }, r.message) : null,
r.local_zone ? h("div", { class: "muted small" }, "Jarl's reverse zone " + r.local_zone + " only answers your own clients: receivers ask " + (r.managed_by || "the address's owner") + ".") : null] },
], x.results) : h("div", { class: "empty" }, "No public zone has an MX record or SPF addresses.");
mount(out, rows, (x.errors || []).map((e) => h("div", { class: "msg err small" }, e)),
h("div", { class: "muted small" }, "Checked ", relTime(x.checked), "."));
};
draw(m);
const btn = h("button", { class: "small", onclick: async () => {
btn.disabled = true;
try { draw(await api("POST", "/exposure/mail", { body: {} })); } catch (e) { toast(e.message, "err", 8000); } finally { btn.disabled = false; }
} }, "Check now");
return h("div", { class: "panel" }, h("div", { class: "page-head" }, h("h2", { class: "grow" }, "Mail reverse DNS"), h("div", { class: "actions" }, btn)),
h("p", { class: "muted small" }, "Receivers such as Gmail and Outlook reject or mark mail from an address without a PTR record whose name resolves back to it. These records belong to whoever owns the address (your hosting provider or ISP), so Jarl looks them up as the internet sees them: the addresses of your public zones' MX hosts and SPF records."),
out);
}