Add backups, token expiry warnings and name server checks

- Daily database backups: jarl-backup.timer runs 'jarld backup', which
  writes jarl-<UTC time>.db into backup.dir, checks it before naming it,
  and prunes by the time in the name while always keeping the newest
  keep_at_least. New backup: section in jarl.yaml; the dashboard and
  'jarl status' show the newest backup and warn when it is stale or
  failed. The Debian package enables the timer.
- 'jarld restore [FILE | latest]': checks the backup, shows it next to
  the current database, keeps the current one aside and records the
  restore. 'jarld serve' holds a database lock, so a restore can't run
  under a live jarld and two jarlds can't share a database.
- Token expiry warnings from 14 days ahead: dashboard, token lists, a
  daily log line, and a CLI warning via the Jarl-Token-Expires header;
  'jarl token list --expiring'.
- 'jarl zone serials' and the zone page's Name servers tab: compare the
  serial on a primary zone's secondaries (NS records, also-notify) or a
  secondary zone's primaries with this server's.
- 'jarl zone delegation': for zones on the internet, check the parent's
  delegation and glue against the zone; an undelegated zone gets a note.
- Server: Jarl/<version> header; CLI exit code 2 for failed checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
abrown38andClaude Opus 5.5 committed 2026-10-08 19:02:34 +00:00
1 parent 1afed741bf
commit 293e6ddd33
47 files changed
+3717 -95

No files matched your search

+31 -1
View File
@@ -1,4 +1,4 @@
import { h, mount, time, problemsList, badge, plural } from "../ui.js";
import { h, mount, time, relTime, problemsList, badge, plural } from "../ui.js";
import { get, state } from "../api.js";
export async function render(el) {
@@ -14,6 +14,7 @@ export async function render(el) {
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") + " · " + plural((s.counts || {}).options || 0, "option")),
st.backup ? backupCard(st.backup) : null,
);
const last = s.last_applied;
const parts = [h("div", { class: "page-head" },
@@ -28,6 +29,26 @@ export async function render(el) {
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.")));
}
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,
@@ -50,6 +71,15 @@ export async function render(el) {
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);
}
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);
}
+11 -1
View File
@@ -104,7 +104,7 @@ export function tokenTable(tokens, redraw) {
{ label: "Name", render: (t) => [h("strong", null, t.name), rotationNote(t)] },
{ label: "ID", render: (t) => "jarl_" + t.public_id + "_…", tdClass: "mono small" },
{ label: "Restricted to", render: (t) => t.grant_strings && t.grant_strings.length ? h("div", { class: "grants" }, t.grant_strings.map((g) => h("div", null, g))) : h("span", { class: "muted" }, "all of the user's permissions"), tdClass: "small" },
{ label: "Expires", render: (t) => t.expires_at ? time(t.expires_at) : h("span", { class: "muted" }, "never") },
{ label: "Expires", render: (t) => t.expires_at ? [time(t.expires_at), expiryBadge(t)] : h("span", { class: "muted" }, "never") },
{ label: "Last used", render: (t) => t.last_used_at ? [time(t.last_used_at), " ", h("span", { class: "muted small" }, t.last_used_ip || "")] : h("span", { class: "muted" }, "never") },
{ label: "", render: (t) => [
h("button", { class: "small", onclick: () => rotateDialog(t, redraw) }, "Rotate"), " ",
@@ -117,6 +117,16 @@ export function tokenTable(tokens, redraw) {
], tokens, { empty: "No tokens." });
}
// expiryBadge flags a token that expires within 14 days (the server's
// TokenExpiryWarning) or has expired.
function expiryBadge(t) {
if (t.revoked_at) return null;
const left = new Date(t.expires_at) - Date.now();
if (left < 0) return [" ", badge("expired", "err")];
if (left < 14 * 86400 * 1000) return [" ", badge("soon", "warn")];
return null;
}
// rotationNote says when a token last got a new secret, and whether its
// old secret still works.
function rotationNote(t) {
+101
View File
@@ -14,6 +14,7 @@ export async function render(el, params, query) {
["settings", "Settings"],
["options", "Options"],
zone.type === "primary" ? ["file", "Zone file"] : null,
["primary", "secondary", "mirror"].includes(zone.type) ? ["servers", "Name servers"] : null,
].filter(Boolean);
const body = h("div");
mount(el,
@@ -34,6 +35,106 @@ export async function render(el, params, query) {
else if (tab === "settings") await settingsTab(body, zone);
else if (tab === "options") await optionsEditor(body, { scope: "zone", scope_id: zone.id, catalogScope: "zone:" + zone.type, title: "Zone options" });
else if (tab === "file") await fileTab(body, zone);
else if (tab === "servers") await serversTab(body, zone);
}
// ---- name servers: serials on the other servers, and the delegation
const nsStatus = {
self: ["this server", ""], in_sync: ["in sync", "ok"], answered: ["answers", "ok"],
behind: [null, "warn"], ahead: [null, "warn"], not_authoritative: ["doesn't serve the zone", "err"],
refused: ["refused", "err"], unreachable: ["no answer", "err"], no_route: ["no route from here", ""],
no_address: ["no address", "err"], error: ["error", "err"],
};
function nsBadge(r) {
let [text, kind] = nsStatus[r.status] || [r.status, "err"];
if (r.status === "behind") text = "behind by " + -r.diff;
if (r.status === "ahead") text = "ahead by " + r.diff;
return [badge(text, kind), r.error && r.status !== "unreachable" ? h("div", { class: "muted small" }, r.error) : null];
}
function nsTable(servers, withSource) {
return table([
{ label: "Server", render: (r) => r.name ? display(r.name) : h("span", { class: "muted" }, "–"), tdClass: "mono" },
{ label: "Address", render: (r) => (r.address || "–") + (r.port ? " port " + r.port : ""), tdClass: "mono small" },
withSource ? { label: "From", render: (r) => r.source, tdClass: "small" } : null,
{ label: "Status", render: nsBadge },
{ label: "Serial", render: (r) => r.serial ?? "–", tdClass: "mono" },
{ label: "Time", render: (r) => r.rtt_ms ? r.rtt_ms + " ms" : "", tdClass: "small muted" },
].filter(Boolean), servers, { empty: "No other name servers to ask." });
}
async function serversTab(el, zone) {
const serials = h("div");
const loadSerials = async () => {
mount(serials, h("div", { class: "muted" }, "Asking the name servers…"));
try {
const r = await get("/zones/" + zone.id + "/serials");
const src = { named: "served by named here", jarl: "Jarl's; named wasn't asked" }[r.serial_source];
mount(serials,
h("p", null, "This server: serial ", h("strong", { class: "mono" }, r.serial ?? "unknown"), src ? h("span", { class: "muted" }, " (" + src + ")") : null),
nsTable(r.servers, true),
r.notes.map((n) => h("p", { class: "muted small" }, n)),
h("p", { class: "muted small" }, "Checked ", time(r.checked_at), "."));
} catch (err) {
mount(serials, errorBox(err));
}
};
const parts = [h("div", { class: "panel" },
h("div", { class: "page-head" },
h("h2", { class: "grow" }, zone.type === "primary" ? "Serials on the secondaries" : "Serials on the primaries"),
h("button", { class: "small", onclick: loadSerials }, "Check again")),
h("p", { class: "muted small" }, zone.type === "primary"
? "Asks the servers in the zone's NS records and its also-notify targets which serial they serve. A secondary that is behind hasn't picked up the latest change yet."
: "Asks the zone's primaries which serial they serve, to see whether this server's copy is up to date."),
serials)];
if (zone.type === "primary") {
const out = h("div");
const resolver = input({ placeholder: "the server's own" });
const run = async (e) => {
e.preventDefault();
mount(out, h("div", { class: "muted" }, "Asking the parent zone's servers…"));
try {
const q = resolver.value.trim() ? { resolver: resolver.value.trim() } : {};
mount(out, delegationReport(await get("/zones/" + zone.id + "/delegation", q)));
} catch (err) {
mount(out, errorBox(err));
}
};
parts.push(h("div", { class: "panel" },
h("h2", null, "Delegation from the parent zone"),
h("p", { class: "muted small" }, "For zones on the internet: checks that the parent zone (for adbraun.lol, \"lol\") points to this zone's name servers with the right glue addresses, and that each of them serves the zone. A zone used only on your own network isn't delegated, and doesn't need this."),
h("form", { class: "inline-form", onsubmit: run },
h("div", { class: "f" }, h("label", null, "Resolver (optional)"), resolver),
h("div", { class: "f" }, h("label", null, " "), h("button", { type: "submit" }, "Check delegation"))),
out));
}
mount(el, parts);
await loadSerials();
}
function delegationReport(r) {
const lvl = { ok: "ok", info: "", warning: "warn", error: "err" };
const parents = {};
for (const p of r.parent_servers) {
const say = p.status === "delegated" ? "delegate to " + p.ns.map(display).join(", ") : p.status === "not_delegated" ? "no delegation" : p.status + (p.error ? ": " + p.error : "");
(parents[say] = parents[say] || []).push(display(p.name));
}
return [
r.parent ? h("p", null, "Parent zone ", h("strong", { class: "mono" }, display(r.parent)), h("span", { class: "muted" }, " (found through " + r.resolver + ")")) : null,
Object.keys(parents).length ? h("ul", { class: "small" }, Object.entries(parents).map(([say, who]) =>
h("li", null, who.length > 1 ? who[0] + " and " + (who.length - 1) + " more: " : who[0] + ": ", say))) : null,
h("div", null, r.findings.map((f) => h("div", { class: "msg " + lvl[f.level] }, f.message))),
r.glue.length ? table([
{ label: "Glue for", render: (g) => display(g.name), tdClass: "mono" },
{ label: "Parent", render: (g) => g.parent.join(", ") || "none", tdClass: "mono small" },
{ label: "Zone", render: (g) => g.jarl.join(", ") || "none", tdClass: "mono small" },
{ label: "", render: (g) => g.match ? badge("matches", "ok") : badge("differs", "warn") },
], r.glue) : null,
r.servers.length ? [h("h3", null, "Delegated name servers"), nsTable(r.servers, false)] : null,
r.notes.map((n) => h("p", { class: "muted small" }, n)),
];
}
async function deleteZone(zone) {