// 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 } from "../ui.js"; import { 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) parts.push(activity(r.rate_limiting, r.scopes)); 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 || []))]); }