Internet exposure checks and response rate limiting
Jarl works out which views are reachable from the internet (match-clients
ending in a catch-all, not private-looking addresses: an internal view of a
global IPv6 prefix is still yours; a server without views: open allow-query
and a non-private listen address), with a per-view override (schema 9), and
checks public ones: rate limiting, open resolver, minimal-any, cache
snooping, open NOTIFY and transfers, version and host name, management
interfaces. Fixes are staged like any change, including rate limiting from
ISC's example in log-only mode with the secondaries exempt, then enforcing.
named logs what rate limiting limits (or would, in log-only mode, where its
counters don't move) as query-errors; Jarl routes those lines to its log
and counts them (rrl breakdown, ratelimited top lists, Prometheus
jarl_ratelimit_logged_total), shown on the new Exposure page.
A change that newly opens a public view up must be confirmed (accept_risk,
--accept-risk, a checkbox on the Changes page; 409 confirm_risk
otherwise). Findings can be acknowledged with a reason and stay visible as
info. GET /exposure, POST /exposure/fix|ack|unack, jarl exposure,
docs/exposure.md.
Also fixes query statistics skipping TSIG-signed queries ("IP#port/key
NAME" in named's log).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
cf550bd512
commit
bdd7acfd3b
50 files changed
+2019
-52
No files matched your search
@@ -18,6 +18,7 @@ import * as account from "./pages/account.js";
|
||||
import * as search from "./pages/search.js";
|
||||
import * as stats from "./pages/stats.js";
|
||||
import * as blocking from "./pages/blocking.js";
|
||||
import * as exposure from "./pages/exposure.js";
|
||||
|
||||
const routes = [
|
||||
{ pattern: /^$/, page: dashboard, nav: "dashboard" },
|
||||
@@ -37,6 +38,7 @@ const routes = [
|
||||
{ pattern: /^search$/, page: search, nav: "" },
|
||||
{ pattern: /^stats$/, page: stats, nav: "stats" },
|
||||
{ pattern: /^blocking$/, page: blocking, nav: "blocking" },
|
||||
{ pattern: /^exposure$/, page: exposure, nav: "exposure" },
|
||||
];
|
||||
|
||||
const navItems = [
|
||||
@@ -45,6 +47,7 @@ const navItems = [
|
||||
["zones", "#/zones", "Zones"],
|
||||
["blocking", "#/blocking", "Blocking"],
|
||||
["views", "#/views", "Views"],
|
||||
["exposure", "#/exposure", "Exposure"],
|
||||
["acls", "#/acls", "ACLs"],
|
||||
["keys", "#/keys", "TSIG keys"],
|
||||
["options", "#/options", "Server options"],
|
||||
|
||||
@@ -43,6 +43,10 @@ async function draftPanel(d) {
|
||||
let valid = false;
|
||||
const applyBtn = h("button", { class: "primary", disabled: true }, "Apply changes");
|
||||
const forceBox = h("label", { class: "check small hidden" }, h("input", { type: "checkbox" }), "Overwrite files changed outside Jarl");
|
||||
// Changes that open a public view up must be confirmed (docs/exposure.md).
|
||||
const riskBox = h("label", { class: "check risk-confirm hidden" }, h("input", { type: "checkbox" }), "");
|
||||
let risky = false;
|
||||
riskBox.querySelector("input").addEventListener("change", () => { applyBtn.disabled = !valid || (risky && !riskBox.querySelector("input").checked); });
|
||||
const ops = d.ops || [];
|
||||
const removeOp = async (seq) => {
|
||||
try {
|
||||
@@ -75,7 +79,10 @@ async function draftPanel(d) {
|
||||
try {
|
||||
const p = await get("/changesets/" + d.id + "/preview");
|
||||
valid = p.valid && !p.error;
|
||||
applyBtn.disabled = !valid;
|
||||
risky = (p.risks || []).length > 0;
|
||||
riskBox.classList.toggle("hidden", !risky);
|
||||
if (risky) riskBox.lastChild.textContent = " I understand: " + p.risks.map((x) => x.message).join("; ");
|
||||
applyBtn.disabled = !valid || (risky && !riskBox.querySelector("input").checked);
|
||||
const drift = (p.drift || []);
|
||||
forceBox.classList.toggle("hidden", drift.length === 0);
|
||||
mount(preview,
|
||||
@@ -97,7 +104,7 @@ async function draftPanel(d) {
|
||||
try {
|
||||
if (desc.value !== (d.description || "")) await api("PATCH", "/changesets/" + d.id, { body: { description: desc.value } });
|
||||
const force = forceBox.querySelector("input").checked;
|
||||
const r = await api("POST", "/changesets/" + d.id + "/apply", { body: { force } });
|
||||
const r = await api("POST", "/changesets/" + d.id + "/apply", { body: { force, accept_risk: riskBox.querySelector("input").checked } });
|
||||
const res = r.result || {};
|
||||
toast("Applied changeset #" + d.id + (res.reloaded && res.reloaded.length ? " — named reloaded" : ""));
|
||||
(r.warnings || []).forEach((w) => toast(w, "warn", 10000));
|
||||
@@ -119,6 +126,7 @@ async function draftPanel(d) {
|
||||
h("h2", null, "Changeset #" + d.id + " ", badge(plural(ops.length, "operation"), "warn")),
|
||||
desc,
|
||||
h("div", { style: { marginTop: "10px" } }, opsTable),
|
||||
riskBox,
|
||||
h("div", { class: "actions", style: { marginTop: "12px" } }, applyBtn, discard, forceBox),
|
||||
result),
|
||||
h("div", { class: "panel" }, h("h2", null, "Review"), preview));
|
||||
|
||||
@@ -2,7 +2,7 @@ import { h, mount, time, fmtTime, relTime, problemsList, badge, plural } from ".
|
||||
import { get, state } from "../api.js";
|
||||
|
||||
export async function render(el) {
|
||||
const [st, mine] = await Promise.all([get("/status"), get("/changesets", { status: "open", mine: 1, limit: 50 }).catch(() => [])]);
|
||||
const [st, mine, exposure] = await Promise.all([get("/status"), get("/changesets", { status: "open", mine: 1, limit: 50 }).catch(() => []), get("/exposure").catch(() => null)]);
|
||||
const s = st.engine || {};
|
||||
// The user's own changesets with something in them: what the Changes page shows.
|
||||
const pending = mine.filter((c) => (c.ops || []).length).length;
|
||||
@@ -16,6 +16,7 @@ export async function render(el) {
|
||||
card("Access", plural((s.counts || {}).acls || 0, "ACL"), plural((s.counts || {}).tsig_keys || 0, "TSIG key")),
|
||||
card("Options", (s.counts || {}).options ?? "–", "server, view, zone and top-level"),
|
||||
st.backup ? backupCard(st.backup) : null,
|
||||
exposure ? exposureCard(exposure) : null,
|
||||
);
|
||||
const last = s.last_applied;
|
||||
const parts = [h("div", { class: "page-head" },
|
||||
@@ -106,6 +107,16 @@ function backupCard(b) {
|
||||
return card("Backup", value, sub);
|
||||
}
|
||||
|
||||
// exposureCard sums up the internet exposure page.
|
||||
function exposureCard(r) {
|
||||
const pub = r.scopes.filter((s) => s.public);
|
||||
let warn = r.server.filter((f) => f.severity === "warning").length;
|
||||
for (const s of r.scopes) warn += s.findings.filter((f) => f.severity === "warning").length;
|
||||
const value = !pub.length ? badge("nothing public", "ok") : warn ? badge(plural(warn, "warning"), "warn") : badge("hardened", "ok");
|
||||
const sub = pub.length ? "public: " + pub.map((s) => s.view || "the server").join(", ") : "no view is reachable from the internet";
|
||||
return h("a", { class: "card link", href: "#/exposure" }, h("div", { class: "label" }, "Exposure"), h("div", { class: "value" }, value), h("div", { class: "sub" }, sub));
|
||||
}
|
||||
|
||||
function card(label, value, sub) {
|
||||
return h("div", { class: "card" }, h("div", { class: "label" }, label), h("div", { class: "value" }, value), sub ? h("div", { class: "sub" }, sub) : null);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
// 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 || []))]);
|
||||
}
|
||||
@@ -489,3 +489,15 @@ details.view-picker summary { cursor: pointer; padding: 5px 8px; border: 1px sol
|
||||
details.view-picker[open] .view-picker-list { position: absolute; z-index: 20; top: calc(100% + 4px); left: 0; min-width: 14em; max-height: 260px; overflow-y: auto; display: flex; flex-direction: column; gap: 4px; padding: 8px 10px; background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); }
|
||||
ul.plain li { padding: 3px 0; }
|
||||
.query-details { margin: 8px 0; }
|
||||
|
||||
/* Internet exposure findings */
|
||||
.findings { display: flex; flex-direction: column; gap: 8px; }
|
||||
.finding { display: flex; gap: 12px; align-items: flex-start; padding: 10px 12px; border: 1px solid var(--line); border-left-width: 3px; border-radius: 6px; }
|
||||
.finding.warning { border-left-color: var(--warn); }
|
||||
.finding.error { border-left-color: var(--err); }
|
||||
.finding.acked, .finding.info { border-left-color: var(--line); color: var(--muted); }
|
||||
.finding .actions { flex-shrink: 0; }
|
||||
.risk-confirm { margin-top: 8px; }
|
||||
|
||||
a.card.link { color: inherit; text-decoration: none; }
|
||||
a.card.link:hover { border-color: var(--accent, var(--link)); }
|
||||
Reference in new issue
Block a user