Encrypted DNS health, comparing a zone's views, --local everywhere
jarld checks the DoT and DoH listeners every 10 minutes as a client would (page, dashboard, jarl encrypted-dns health, jarl_encrypted_dns_up). The Compare views tab and jarl zone diff show what differs between a zone's views, with Use this version. --local works on unlinked zones. A changed certificates.dir is followed by encrypted DNS's tls statements. Sharing an already shared version replaces other views' overrides. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
67ae0091b3
commit
13ffd8cad4
31 files changed
+1153
-18
No files matched your search
@@ -18,6 +18,9 @@ export async function render(el) {
|
||||
st.backup ? backupCard(st.backup) : null,
|
||||
exposure ? exposureCard(exposure) : null,
|
||||
st.certificates ? certCard(st.certificates) : null,
|
||||
st.encrypted_dns ? h("a", { class: "card link", href: "#/encrypted-dns" }, h("div", { class: "label" }, "Encrypted DNS"),
|
||||
h("div", { class: "value" }, st.encrypted_dns.ok ? badge("working", "ok") : badge("not working", "err")),
|
||||
h("div", { class: "sub" }, "checked ", time(st.encrypted_dns.checked))) : null,
|
||||
);
|
||||
const last = s.last_applied;
|
||||
const parts = [h("div", { class: "page-head" },
|
||||
@@ -43,6 +46,9 @@ export async function render(el) {
|
||||
h("strong", null, "The last backup failed "), time(b.last_error_at), ": " + b.last_error,
|
||||
h("div", { class: "small" }, "See 'journalctl -u jarl-backup' on the server.")));
|
||||
}
|
||||
for (const p of (st.encrypted_dns || {}).problems || []) {
|
||||
parts.push(h("div", { class: "msg err" }, h("strong", null, "Encrypted DNS: "), p, " ", h("a", { href: "#/encrypted-dns" }, "Encrypted DNS")));
|
||||
}
|
||||
for (const c of (st.certificates || {}).problems || []) {
|
||||
parts.push(h("div", { class: "msg " + (c.state === "expiring" ? "warn" : "err") },
|
||||
h("strong", null, "Certificate " + c.name + " " + (c.state === "expired" ? "has expired" : c.state === "expiring" ? "expires soon" : "can't be renewed") + ": "), c.message, " ",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// set up and what to give clients, and a setup that obtains the
|
||||
// certificate if needed, checks the ports and stages named's
|
||||
// configuration like any change.
|
||||
import { h, mount, field, input, badge, toast, errorBox, confirmDialog, copyBox, checkbox } from "../ui.js";
|
||||
import { h, mount, field, input, badge, toast, errorBox, confirmDialog, copyBox, checkbox, fmtTime, relTime } from "../ui.js";
|
||||
import { api, get, change, describeResult, state } from "../api.js";
|
||||
import { rerender } from "../app.js";
|
||||
|
||||
@@ -50,6 +50,7 @@ function statusPanel(s) {
|
||||
}
|
||||
return [
|
||||
h("div", { class: "panel" }, h("h2", null, "Set up ", badge("on", "ok")), h("dl", { class: "kv" }, rows)),
|
||||
healthPanel(s.health),
|
||||
h("div", { class: "panel" }, h("h2", null, "For your clients"),
|
||||
h("p", { class: "muted small" }, "Clients check the certificate against the host name, so it must resolve to this server for them (Jarl added it to the private views you chose). Open the ports in the server's firewall for your networks only."),
|
||||
clients),
|
||||
@@ -223,3 +224,28 @@ async function disable() {
|
||||
rerender();
|
||||
} catch (e) { toast(e.message, "err", 8000); }
|
||||
}
|
||||
|
||||
// healthPanel shows the last check of the listeners: Jarl connects to each
|
||||
// as a client would, checks the certificate and asks a question.
|
||||
function healthPanel(hl) {
|
||||
const kind = { dot: "DNS over TLS", doh: "DNS over HTTPS" };
|
||||
const out = h("div");
|
||||
const draw = (x) => {
|
||||
if (!x) return mount(out, h("div", { class: "muted" }, "Not checked yet: Jarl checks a minute after it starts, then every 10 minutes."));
|
||||
mount(out, h("ul", { class: "probes" }, x.probes.map((p) => h("li", null,
|
||||
badge(p.ok ? "working" : "not working", p.ok ? "ok" : "err"), " ",
|
||||
h("strong", null, kind[p.kind]), " ", h("span", { class: "mono" }, p.address + " port " + p.port),
|
||||
p.ok ? h("span", { class: "muted small" }, " answered " + p.rcode + " in " + p.ms + " ms" + (p.cert_not_after ? " · certificate until " + fmtTime(p.cert_not_after) : "")) : null,
|
||||
p.problem ? h("div", { class: "msg err small" }, p.problem) : null,
|
||||
p.warning ? h("div", { class: "msg warn small" }, p.warning) : null))),
|
||||
h("div", { class: "muted small" }, "Checked ", relTime(x.checked), "."));
|
||||
};
|
||||
draw(hl);
|
||||
const btn = h("button", { class: "small", onclick: async () => {
|
||||
btn.disabled = true;
|
||||
try { draw(await api("POST", "/encrypted-dns/health", { 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" }, "Health"), h("div", { class: "actions" }, btn)),
|
||||
h("p", { class: "muted small" }, "Jarl connects to each listener as a client would: the certificate must be for the host name, current, and the one Jarl last wrote; then it asks for the host name."),
|
||||
out);
|
||||
}
|
||||
@@ -21,6 +21,7 @@ export async function render(el, params, query) {
|
||||
zone.type === "primary" ? ["file", "Zone file"] : null,
|
||||
["primary", "secondary", "mirror"].includes(zone.type) ? ["servers", "Name servers"] : null,
|
||||
zone.type === "primary" ? ["dnssec", "DNSSEC"] : null,
|
||||
zone.other_views ? ["compare", "Compare views"] : null,
|
||||
zone.registrar && state.me && state.me.admin ? ["registrar", "Registrar"] : null,
|
||||
].filter(Boolean);
|
||||
const body = h("div");
|
||||
@@ -64,6 +65,7 @@ export async function render(el, params, query) {
|
||||
else if (tab === "servers") await serversTab(body, zone);
|
||||
else if (tab === "dnssec") await dnssecTab(body, zone);
|
||||
else if (tab === "registrar") await registrarTab(body, zone);
|
||||
else if (tab === "compare") await compareTab(body, zone);
|
||||
// A later click (another tab) has taken over the page.
|
||||
if (seq !== renderSeq || !holder.isConnected) return;
|
||||
holder.replaceWith(body);
|
||||
@@ -71,6 +73,40 @@ export async function render(el, params, query) {
|
||||
if (first) first.focus();
|
||||
}
|
||||
|
||||
// ---- comparing a zone across views
|
||||
|
||||
// compareTab shows the names and types that differ between the views that
|
||||
// have the zone, with a button to share one view's version (linked zones).
|
||||
async function compareTab(el, zone) {
|
||||
let all = false;
|
||||
const out = h("div");
|
||||
const draw = async () => {
|
||||
const r = await get("/zones/" + zone.id + "/compare", { all: all ? 1 : "" });
|
||||
const head = h("tr", null, h("th", null, "Name"), h("th", null, "Type"), r.views.map((v) => h("th", null, v)));
|
||||
const rows = r.rows.map((row) => h("tr", { class: row.same ? "muted" : "" },
|
||||
h("td", { class: "mono" }, row.name), h("td", null, h("strong", null, row.type)),
|
||||
row.views.map((vs) => h("td", { class: "compare-cell" },
|
||||
vs.records.length ? vs.records.map((d) => h("div", { class: "mono small" }, d)) : h("span", { class: "muted" }, "–"),
|
||||
vs.linked === "local" ? [badge("only " + vs.view, "accent"), " "] : null,
|
||||
r.linked && !row.same && vs.records.length ? h("button", { class: "small", title: "Make view " + vs.view + "'s version every view's: the others' records of this name and type are replaced",
|
||||
onclick: () => share(vs, row) }, "Use this version") : null))));
|
||||
mount(out,
|
||||
h("p", { class: "muted" }, r.same + " names and types are the same in " + r.views.join(", ") + (r.rows.length && !all ? "; these differ." : ".") + " ",
|
||||
r.linked ? "The zones are linked: shared records are kept in step, and a view's own records override them there." : "The zones aren't linked: a change in one doesn't reach the others. Link them (Link… above) to keep what is the same in step; linking changes no answer."),
|
||||
r.rows.length ? h("div", { class: "table-wrap" }, h("table", { class: "compare" }, h("thead", null, head), h("tbody", null, rows))) : h("div", { class: "empty" }, "Nothing differs."),
|
||||
h("label", { class: "check" }, h("input", { type: "checkbox", checked: all, onchange: (e) => { all = e.target.checked; draw(); } }), "Show what is the same too"));
|
||||
};
|
||||
const share = async (vs, row) => {
|
||||
if (!await confirmDialog("Use view " + vs.view + "'s " + row.name + " " + row.type, "Every view gets " + vs.records.join(", ") + " for " + row.name + " " + row.type + ", shared from now on; the other views' records of that name and type are replaced.", "Use this version")) return;
|
||||
try {
|
||||
toast(describeResult(await change("PATCH", "/records/" + vs.ids[0], { local: false })));
|
||||
await draw();
|
||||
} catch (e) { toast(e.message, "err", 8000); }
|
||||
};
|
||||
await draw();
|
||||
mount(el, out);
|
||||
}
|
||||
|
||||
// ---- linked zones (docs/views.md#linked-zones)
|
||||
|
||||
// linkButton links the zone with the same zone in other views, or takes
|
||||
|
||||
@@ -557,3 +557,13 @@ header.top .header-right { display: flex; align-items: center; gap: 12px; margin
|
||||
header.top form.search input { width: 160px; }
|
||||
header.top { gap: 10px; }
|
||||
}
|
||||
|
||||
/* Encrypted DNS: the health of each listener. */
|
||||
ul.probes { list-style: none; padding: 0; margin: 0 0 8px; }
|
||||
ul.probes > li { padding: 6px 0; border-bottom: 1px solid var(--border); }
|
||||
ul.probes > li .msg { margin: 6px 0 0; }
|
||||
|
||||
/* Comparing a zone across views. */
|
||||
table.compare td.compare-cell { vertical-align: top; }
|
||||
table.compare td.compare-cell button { margin-top: 4px; }
|
||||
table.compare tr.muted td { color: var(--muted); }
|
||||
Reference in new issue
Block a user