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:
1 parent
1afed741bf
commit
293e6ddd33
47 files changed
+3717
-95
No files matched your search
@@ -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);
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user