Files
adamandClaude Opus 5.5 9db76a113c Certificates for other hosts: jarl cert get
jarld obtains and renews a certificate for another host; the host asks
for it with one command, waits, downloads it when it is newer than its
copy and runs a hook with the paths in JARL_CERT_* variables.

- Certificates have an owner user; another user can't take the name.
  An existing one is obtained again only when a name is added or
  removed. A new certificates grant (names=) says which names a user
  may have; it is never part of "*". Users may own
  certificates.max_per_user certificates (10) or their own cert_limit.
- POST /certificates/{name}/request; owners may list, show, export and
  delete theirs; keys only over HTTPS, the socket or loopback; every
  download is recorded and shown as "Pulled by".
- CLI: jarl cert get (saved settings, all saved when no name), cert
  saved, cert forget, cert schedule (daily systemd timer, Linux, root).
- Migration 14. Tests: server, auth, and e2e against Pebble.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 16:58:40 +01:00

239 lines
14 KiB
JavaScript
Raw Permalink 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, table, field, input, select, textarea, toast, errorBox, confirmDialog, modal, lines, checkbox, time, fmtTime, copyBox, badge } from "../ui.js";
import { api, get, state } from "../api.js";
import { rerender } from "../app.js";
export async function render(el) {
if (!state.me.admin) {
mount(el, h("h1", null, "Users"), h("div", { class: "msg warn" }, "Only administrators can manage users."));
return;
}
const [users, grants] = await Promise.all([get("/users"), get("/catalog/grants")]);
mount(el,
h("div", { class: "page-head" },
h("div", { class: "grow" }, h("h1", null, "Users"),
h("div", { class: "muted" }, "People log in to the web UI with a password; scripts and the CLI use API tokens. Each token belongs to a user and can be restricted to a subset of that user's permissions.")),
h("div", { class: "actions" }, h("button", { class: "primary", onclick: () => userDialog(null, grants) }, "New user"))),
h("div", { class: "panel" }, table([
{ label: "User", render: (u) => h("strong", null, u.username) },
{ label: "Role", render: (u) => [u.admin ? badge("admin", "accent") : null, u.disabled ? badge("disabled", "err") : null, !u.has_password ? badge("tokens only") : null] },
{ label: "2FA", render: (u) => u.totp_enabled_at ? badge("on", "ok") : u.totp_required ? badge("not set up", "warn") : !u.has_password ? h("span", { class: "muted" }, "–") : h("span", { class: "muted" }, "off") },
{ label: "Permissions", render: (u) => u.admin ? h("span", { class: "muted" }, "everything") : h("div", { class: "grants" }, (u.grant_strings || []).map((g) => h("div", null, g))), tdClass: "small" },
{ label: "Last login", render: (u) => u.last_login_at ? time(u.last_login_at) : h("span", { class: "muted" }, "never") },
{ label: "", render: (u) => h("span", null,
h("button", { class: "small", onclick: () => tokensDialog(u, grants) }, "Tokens"), " ",
h("button", { class: "small", onclick: () => userDialog(u, grants) }, "Edit"), " ",
u.totp_enabled_at ? [h("button", { class: "small", title: "Turn off their two-factor login (a lost phone without recovery codes)", onclick: () => resetTOTP(u) }, "Reset 2FA"), " "] : null,
h("button", { class: "small danger", disabled: u.id === state.me.user.id, onclick: () => del(u) }, "Delete")), tdClass: "actions-cell" },
], users)),
grantHelp(grants));
}
export function grantHelp(grants) {
return h("div", { class: "panel" },
h("h2", null, "Permissions"),
h("p", { class: "small" }, "A grant is ", h("code", null, grants.syntax), ". Write includes read. Filters are patterns where * matches anything, e.g. ", h("code", null, "names=_acme-challenge.*"), ". Zone names without the trailing dot; zones outside views are in view _default."),
h("p", { class: "small" }, "Resources: ", grants.resources.join(", "), ", or * for all. Users management is for administrators only."),
h("h3", null, "Templates"),
h("div", { class: "grants" }, Object.entries(grants.roles).map(([name, gs]) => h("div", null, h("strong", null, name + ": "), gs.join("; ")))),
h("h3", null, "Examples"),
h("pre", null, "records:write zones=adbraun.lol names=_acme-challenge.* types=TXT # ACME DNS-01 only\nzones:write zones=*.lab.adbraun.lol # lab subzones\nrecords:write views=internal # internal view only\n*:read # read-only"));
}
async function userDialog(u, grants) {
const err = h("div");
const name = input({ value: u ? u.username : "", autocomplete: "off" });
const pw = input({ type: "password", autocomplete: "new-password", placeholder: u ? "leave empty to keep" : "at least 8 characters" });
const admin = checkbox("Administrator (can do everything, including managing users)", u ? u.admin : false);
const disabled = checkbox("Disabled (can't log in, tokens stop working)", u ? u.disabled : false);
const service = checkbox("Service account: no password, API tokens only", u ? !u.has_password : false);
const gr = textarea(u ? (u.grant_strings || []).join("\n") : "", { class: "mono", rows: 5, placeholder: "records:write zones=example.com\n*:read" });
const tmpl = select([["", "Insert a template…"], ...Object.keys(grants.roles).map((r) => [r, r])], "");
tmpl.addEventListener("change", () => {
if (!tmpl.value) return;
gr.value = (gr.value.trim() ? gr.value.trim() + "\n" : "") + grants.roles[tmpl.value].join("\n");
tmpl.value = "";
});
const comment = input({ value: u ? u.comment : "" });
const certLimit = input({ type: "number", min: "0", value: u && u.cert_limit != null ? String(u.cert_limit) : "", placeholder: "certificates.max_per_user" });
await modal(u ? "Edit " + u.username : "New user", h("div", null, h("div", { class: "form-grid" },
field("Username", name),
field("Password", pw),
h("span"), service,
h("span"), admin,
u ? h("span") : null, u ? disabled : null,
field("Permissions", gr, "One grant per line (ignored for administrators)."),
h("span"), tmpl,
field("Certificates", certLimit, "How many certificates this user may own through jarl cert get (needs a certificates grant). Empty: the default from jarl.yaml."),
field("Comment", comment)), err), [
{ label: "Cancel", value: null },
{ label: u ? "Save" : "Create user", kind: "primary", onclick: async () => {
const data = { username: name.value.trim(), admin: admin.input.checked, grant_strings: lines(gr.value), comment: comment.value };
if (service.input.checked) data.password = "";
else if (pw.value) data.password = pw.value;
if (u) data.disabled = disabled.input.checked;
if (certLimit.value.trim() !== "") data.cert_limit = Number(certLimit.value);
else if (u && u.cert_limit != null) data.cert_limit = -1;
try {
if (u) await api("PATCH", "/users/" + u.id, { body: data });
else await api("POST", "/users", { body: data });
toast(u ? "User updated" : "User created");
rerender();
return true;
} catch (e) { mount(err, errorBox(e)); return false; }
} },
], { wide: true, sticky: true });
}
async function resetTOTP(u) {
if (!(await confirmDialog("Reset two-factor login", "Turn off two-factor login for " + u.username + "? Their recovery codes stop working and they are logged out. They log in with the password next time" + (u.totp_required ? " and must set it up again." : ", and can set it up again under Account."), "Reset", "danger"))) return;
try {
await api("DELETE", "/users/" + u.id + "/totp");
toast("Two-factor login of " + u.username + " is off");
rerender();
} catch (e) { toast(e.message, "err", 8000); }
}
async function del(u) {
if (!(await confirmDialog("Delete user", "Delete " + u.username + " and all their API tokens?", "Delete", "danger"))) return;
try {
await api("DELETE", "/users/" + u.id);
toast("User deleted");
rerender();
} catch (e) { toast(e.message, "err", 8000); }
}
// tokensDialog manages a user's tokens (also used by the account page).
export async function tokensDialog(u, grants, own) {
const box = h("div");
const base = own ? "/me/tokens" : "/users/" + u.id + "/tokens";
const draw = async () => {
const tokens = await get(base);
mount(box, tokenTable(tokens, draw), newTokenForm(base, grants, draw, u));
};
await draw();
await modal("API tokens of " + u.username, box, [{ label: "Close", value: null }], { wide: true });
}
export function tokenTable(tokens, redraw) {
return table([
{ 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), 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"), " ",
h("button", { class: "small danger", onclick: async () => {
if (!(await confirmDialog("Revoke token", "Revoke " + t.name + "? Anything using it stops working immediately.", "Revoke", "danger"))) return;
await api("DELETE", "/tokens/" + t.id);
toast("Token revoked");
redraw();
} }, "Revoke")], tdClass: "actions-cell nowrap" },
], 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) {
const parts = [];
if (t.previous_secret_valid_until && new Date(t.previous_secret_valid_until) > new Date()) {
parts.push(h("div", { class: "small warn-text" }, "old secret stops ", time(t.previous_secret_valid_until)));
} else if (t.rotated_at) {
parts.push(h("div", { class: "muted small" }, "rotated ", time(t.rotated_at)));
}
return parts;
}
// rotateDialog gives a token a new secret. The token keeps its name,
// grants and ID; only the programs using it need the new secret.
async function rotateDialog(t, redraw) {
const grace = select([["", "stops working now"], ["10m", "keeps working for 10 minutes"], ["1h", "keeps working for 1 hour"], ["1d", "keeps working for 1 day"], ["7d", "keeps working for 7 days"]], "");
const expOpts = [["", t.expires_at ? "the same lifetime again, from now" : "never expires (as now)"], ["30d", "30 days from now"], ["90d", "90 days from now"], ["365d", "1 year from now"]];
if (t.expires_at) expOpts.push(["never", "never expires"]);
const exp = select(expOpts, "");
const err = h("div");
const box = h("div");
const form = h("div", null,
h("p", null, "Gives ", h("strong", null, t.name), " a new secret. It keeps its name, ID and permissions, so only the programs that use it need updating."),
h("div", { class: "form-grid" },
field("Old secret", grace, "A grace period lets you update clients without an outage. If the token may have leaked, let it stop now."),
field("Expires", exp)),
err,
h("div", { class: "actions", style: { marginTop: "8px" } }, h("button", { class: "primary", onclick: async (e) => {
mount(err);
const body = {};
if (grace.value) body.grace = grace.value;
if (exp.value === "never") body.no_expiry = true;
else if (exp.value) body.expires_in = exp.value;
e.target.disabled = true;
try {
const r = await api("POST", "/tokens/" + t.id + "/rotate", { body });
mount(box, h("div", { class: "msg ok" },
h("p", null, "New secret for " + t.name + ". Copy it now: it is not shown again."),
copyBox(r.token),
h("p", { class: "small" }, r.previous_secret_valid_until ? ["The old secret keeps working until ", fmtTime(r.previous_secret_valid_until), " (", time(r.previous_secret_valid_until), ")."] : "The old secret no longer works.")));
if (redraw) redraw();
} catch (ex) { e.target.disabled = false; mount(err, errorBox(ex)); }
} }, "Rotate")));
mount(box, form);
await modal("Rotate token " + t.name, box, [{ label: "Close", value: null }], { sticky: true });
}
// coveredBy reports whether a user (admin, or grants) may do what a grant
// string ("records:write names=..." ) allows, by resource and access. A
// filter on either side only narrows the token further, so it is ignored.
function coveredBy(user, grant) {
if (!user || user.admin) return true;
const [res, acc] = grant.split(/\s+/)[0].split(":");
return (user.grants || []).some((g) => (g.resource === "*" || g.resource === res) && (g.access === "write" || acc === "read"));
}
// newTokenForm makes a token for owner (the user it belongs to). Its
// templates are only the roles within what owner may do: a token never
// does more than its user, so offering more would only mislead.
export function newTokenForm(base, grants, redraw, owner) {
const err = h("div");
const out = h("div");
const name = input({ placeholder: "e.g. certbot on web01" });
const exp = select([["", "never expires"], ["30d", "30 days"], ["90d", "90 days"], ["365d", "1 year"]], "90d");
const gr = textarea("", { class: "mono", rows: 3, placeholder: "optional restriction, e.g.\nrecords:write zones=example.com names=_acme-challenge.* types=TXT" });
const roles = Object.keys(grants.roles).filter((r) => grants.roles[r].every((g) => coveredBy(owner, g)));
const tmpl = select([["", "Insert a template…"], ...roles.map((r) => [r, r])], "");
tmpl.addEventListener("change", () => {
if (!tmpl.value) return;
gr.value = (gr.value.trim() ? gr.value.trim() + "\n" : "") + grants.roles[tmpl.value].join("\n");
tmpl.value = "";
});
return h("div", { class: "panel", style: { marginTop: "12px" } },
h("h3", null, "New token"),
h("div", { class: "form-grid" },
field("Name", name),
field("Expires", exp),
field("Restrict to", gr, "Leave empty for all of the user's permissions. A token can never do more than its user."),
roles.length ? [h("span"), tmpl] : null),
err,
h("div", { class: "actions", style: { marginTop: "8px" } }, h("button", { class: "primary", onclick: async () => {
mount(err); mount(out);
const body = { name: name.value.trim(), expires_in: exp.value || undefined };
const g = lines(gr.value);
if (g.length) body.grant_strings = g;
try {
const t = await api("POST", base, { body });
mount(out, h("div", { class: "msg ok" }, h("p", null, "Token created. Copy it now: it is not shown again."), copyBox(t.token)));
name.value = ""; gr.value = "";
if (redraw) setTimeout(redraw, 15000);
} catch (e) { mount(err, errorBox(e)); }
} }, "Create token")),
out);
}