Files
jarl/internal/web/static/assets/pages/dashboard.js
T
adamandClaude Opus 5.5 88f63ffb0a TLS certificates: Jarl obtains and renews them over ACME
Jarl is now an ACME client (lego's core, no DNS providers). It answers
DNS-01 challenges in a zone it serves, or through Porkbun's DNS API while
Porkbun serves the domain; "auto" decides from the domain's public name
servers at every renewal, and a CNAME on _acme-challenge is followed.
Certificates are used for jarld's HTTPS (served without a restart; each
port speaks HTTPS and plain HTTP, plain HTTP from loopback and trusted
proxies still served) and/or written for named's DNS over TLS/HTTPS, with
rndc reconfig on renewal. Renewal follows the CA's ARI window or two
thirds of the lifetime, with back-off. Certificates you renew yourself
can be used too. Certificates page and dashboard card, jarl cert,
/api/v1/certificates, Prometheus gauges, docs/certificates.md, and an
e2e section against Pebble.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 14:35:53 +01:00

137 lines
9.0 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.
import { h, mount, time, fmtTime, relTime, problemsList, badge, plural } from "../ui.js";
import { get, state } from "../api.js";
export async function render(el) {
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;
const n = s.named;
const cards = h("div", { class: "cards" },
card("named", n ? (n.running ? badge("running", "ok") : badge("unknown", "warn")) : badge(s.named_error ? "unreachable" : "not checked", s.named_error ? "err" : ""),
n ? [n.version, h("br"), "last configured " + (n.last_configured || "?")] : (s.named_error || "")),
card("Jarl", s.adopted ? badge("active", "ok") : badge("not adopted", "warn"),
s.adopted ? "named loads Jarl's configuration" : "named does not load Jarl's configuration yet — run 'jarld adopt' on the server"),
card("Zones", (s.counts || {}).zones ?? "–", plural((s.counts || {}).records || 0, "record") + " · " + plural((s.counts || {}).views || 0, "view")),
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,
st.certificates ? certCard(st.certificates) : null,
);
const last = s.last_applied;
const parts = [h("div", { class: "page-head" },
h("div", { class: "grow" }, h("h1", null, "Dashboard"), h("div", { class: "muted" }, "Signed in as " + state.me.user.username + " · " + (state.me.permissions || []).join(", "))),
h("div", { class: "actions" },
h("a", { class: "button primary", href: "#/zones?new=1" }, "New zone"),
h("a", { class: "button", href: "#/changes" }, "Pending changes" + (pending ? " (" + pending + " open)" : "")))),
cards];
if (s.drift && s.drift.length) {
parts.push(h("div", { class: "msg warn" },
h("strong", null, "Files changed outside Jarl: "),
s.drift.map((d) => d.path + " (" + d.reason + ")").join(", "),
h("div", { class: "small" }, "The next apply refuses to overwrite them unless forced. An administrator can rewrite them with 'jarld resync' (or System → Resync).")));
}
const b = st.backup;
if (b && b.stale) {
parts.push(h("div", { class: "msg warn" },
h("strong", null, "The newest backup is " + relTime(b.last_at).replace(" ago", "") + " old. "),
"Backups should run daily: check 'systemctl status jarl-backup.timer' and 'journalctl -u jarl-backup' on the server."));
}
if (b && b.last_error) {
parts.push(h("div", { class: "msg err" },
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 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, " ",
h("a", { href: "#/certificates" }, "Certificates")));
}
if (st.blocking_paused_until) {
parts.push(h("div", { class: "msg info" },
h("strong", null, "Blocking is paused until " + fmtTime(st.blocking_paused_until) + "."), " It resumes by itself. ", h("a", { href: "#/blocking" }, "Blocking")));
}
if (st.blocking_paused_indefinitely) {
parts.push(h("div", { class: "msg warn" },
h("strong", null, "Blocking is paused until someone resumes it."), " Every name is answered normally. ", h("a", { href: "#/blocking" }, "Blocking")));
}
for (const e of st.blocklist_errors || []) {
parts.push(h("div", { class: "msg warn" },
h("strong", null, "Blocklist " + e.name + " couldn't be updated: "), e.error + ". ",
e.updated_at ? ["named keeps using the version from ", time(e.updated_at), ". "] : "It isn't in use yet. ",
"jarld tries again within the hour. ", h("a", { href: "#/blocking" }, "Blocking")));
}
const tf = st.totp;
if (tf && tf.missing && tf.missing.length) {
parts.push(h("div", { class: "msg warn" },
h("strong", null, "Two-factor login is required (" + tf.policy + ") but not set up by: "), tf.missing.join(", "), ". ",
"They can only set it up when they next log in. ", h("a", { href: "#/users" }, "Users")));
}
if (tf && tf.shared_admin) {
parts.push(h("div", { class: "msg info" },
h("strong", null, "The first account, " + tf.shared_admin + ", is still active "), "next to personal administrator accounts. If it is shared, disable it under ",
h("a", { href: "#/users" }, "Users"), ": everyone then logs in as themselves, with their own two-factor login, and the audit log shows who did what. Root on the server can always get back in with 'jarld user passwd'."));
}
const exp = st.expiring_tokens || [];
if (exp.length) {
const now = new Date();
parts.push(h("div", { class: "msg warn" },
h("strong", null, exp.length === 1 ? "An API token expires soon: " : "API tokens expire soon: "),
exp.map((t, i) => [i ? ", " : "", h("a", { href: state.me.admin ? "#/users" : "#/account" }, t.username + "/" + t.name), " ",
new Date(t.expires_at) < now ? h("span", null, "expired ", time(t.expires_at)) : h("span", null, "expires ", time(t.expires_at))]),
h("div", { class: "small" }, "Rotate a token to give it a new lifetime and secret, keeping its permissions (API tokens → Rotate, or 'jarl token rotate'). Then update whatever uses it.")));
}
parts.push(h("div", { class: "panel" },
h("h2", null, "Last change"),
last ? h("div", null,
h("a", { href: "#/history/" + last.id }, "#" + last.id + " " + (last.description || "")), " ",
h("span", { class: "muted" }, "by " + last.username + " via " + last.via + ", "), time(last.applied_at))
: h("div", { class: "muted" }, "Nothing applied yet.")));
const problems = s.problems || [];
const real = problems.filter((p) => p.severity !== "info");
parts.push(h("div", { class: "panel" },
h("h2", null, "Configuration checks"),
real.length ? problemsList(real) : h("div", { class: "msg ok" }, "No problems found in the current configuration."),
problems.length > real.length ? h("details", null, h("summary", { class: "muted" }, plural(problems.length - real.length, "note")), problemsList(problems.filter((p) => p.severity === "info"))) : null));
if (s.paths) {
parts.push(h("div", { class: "panel" }, h("h2", null, "Files"), h("dl", { class: "kv" },
h("dt", null, "named's main config"), h("dd", { class: "mono" }, s.main_config || "–"),
h("dt", null, "Jarl's named.conf"), h("dd", { class: "mono" }, s.paths.config_dir + "/named.conf"),
h("dt", null, "Zone files"), h("dd", { class: "mono" }, s.paths.zone_dir),
h("dt", null, "BIND tools"), h("dd", null, s.bind_version || "–"))));
}
mount(el, parts);
}
function backupCard(b) {
let value;
if (b.last_error) value = badge("failed", "err");
else if (!b.last_at) value = badge("none yet", "");
else value = badge(relTime(b.last_at), b.stale ? "warn" : "ok");
const sub = b.last_at ? h("span", { class: "mono small", title: b.last_file }, b.last_file.split("/").pop()) : "jarl-backup.timer writes to " + b.dir;
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));
}
// certCard sums up the certificates Jarl manages.
function certCard(c) {
const bad = c.problems.length;
const value = bad ? badge(bad === 1 ? "1 needs attention" : bad + " need attention", c.problems.some((p) => p.state !== "expiring") ? "err" : "warn") : badge(c.total === 1 ? "1 current" : c.total + " current", "ok");
const sub = c.next ? ["next to expire: " + c.next + " ", time(c.next_after)] : "none obtained yet";
return h("a", { class: "card link", href: "#/certificates" }, h("div", { class: "label" }, "Certificates"), 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);
}