Steward 0.1.0 (in development): a DHCPv4 server with a web UI, API and CLI
Subnets served per interface, pools, reservations, options at three levels, leases in SQLite saved before the ACK, ping and decline conflict checks, validated changes with history and undo, users and tokens, an audit log, a Kea importer, a sandboxed systemd unit and Debian packages. Tested with real udhcpc and dhcpcd clients in Docker (scripts/e2e.sh). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
3c0808591f
111 files changed
+17851
No files matched your search
@@ -0,0 +1,63 @@
|
||||
// Your account: password and API tokens.
|
||||
import { h, mount, table, time, modal, input, field, select, errorBox, toast, copyBox } from "../ui.js";
|
||||
import { get, api, state, catalog } from "../api.js";
|
||||
import { rerender } from "../app.js";
|
||||
import { revoke } from "./users.js";
|
||||
|
||||
export async function render(el) {
|
||||
const me = state.me;
|
||||
const [tokens, cat] = await Promise.all([get("/me/tokens"), catalog()]);
|
||||
const cur = input({ type: "password", autocomplete: "current-password" });
|
||||
const pw = input({ type: "password", autocomplete: "new-password" });
|
||||
const pw2 = input({ type: "password", autocomplete: "new-password" });
|
||||
const err = h("div");
|
||||
const change = async () => {
|
||||
mount(err);
|
||||
if (pw.value !== pw2.value) { mount(err, h("div", { class: "msg err" }, "The new passwords don't match.")); return; }
|
||||
try {
|
||||
await api("POST", "/me/password", { body: { current: cur.value, password: pw.value } });
|
||||
toast("Password changed; your other sessions are logged out");
|
||||
cur.value = pw.value = pw2.value = "";
|
||||
} catch (e) { mount(err, errorBox(e)); }
|
||||
};
|
||||
mount(el,
|
||||
h("div", { class: "page-head" }, h("div", { class: "grow" }, h("h1", null, me.user.username),
|
||||
h("div", { class: "muted" }, me.admin ? "Administrator" : "May: " + (me.permissions.join(", ") || "nothing")))),
|
||||
me.method === "session" ? h("div", { class: "panel" },
|
||||
h("h2", null, "Password"),
|
||||
h("div", { class: "form-grid" }, field("Current password", cur), field("New password", pw), field("Repeat", pw2)), err,
|
||||
h("div", { class: "actions", style: { marginTop: "12px" } }, h("button", { class: "primary", onclick: change }, "Change password"))) : null,
|
||||
h("div", { class: "panel" },
|
||||
h("div", { class: "page-head" }, h("div", { class: "grow" }, h("h2", null, "API tokens"),
|
||||
h("div", { class: "muted" }, "For the steward CLI and scripts: 'steward config set token'.")),
|
||||
h("button", { onclick: () => newToken(cat) }, "New token")),
|
||||
table([
|
||||
{ label: "Name", render: (t) => t.name },
|
||||
{ label: "May", render: (t) => h("span", { class: "mono small" }, t.grant_strings.join(" ")) },
|
||||
{ label: "Expires", render: (t) => t.expires_at ? time(t.expires_at) : "never" },
|
||||
{ label: "Last used", render: (t) => t.last_used_at ? time(t.last_used_at) : "never" },
|
||||
{ label: "", tdClass: "actions-cell", render: (t) => h("button", { class: "small reveal", onclick: () => revoke(t) }, "Revoke") },
|
||||
], tokens, { empty: "No tokens." })));
|
||||
}
|
||||
|
||||
async function newToken(cat) {
|
||||
const name = input({ placeholder: "laptop, backup script…" });
|
||||
const role = select([["", "everything you may do"], ...Object.keys(cat.roles).map((r) => [r, r + ": " + cat.roles[r].join(" ")])], "");
|
||||
const expires = select([["", "never"], ["30d", "30 days"], ["90d", "90 days"], ["365d", "a year"]], "90d");
|
||||
const err = h("div");
|
||||
await modal("New API token", h("div", null, h("div", { class: "form-grid" }, field("Name", name), field("May do", role), field("Expires", expires)), err), [
|
||||
{ label: "Cancel", value: null },
|
||||
{ label: "Create", kind: "primary", onclick: async () => {
|
||||
mount(err);
|
||||
try {
|
||||
const t = await api("POST", "/me/tokens", { body: { name: name.value.trim(), role: role.value || undefined, expires_in: expires.value || undefined } });
|
||||
await modal("Token created", h("div", null, h("p", null, "Copy it now: it is not shown again."), copyBox(t.secret)));
|
||||
rerender();
|
||||
return true;
|
||||
} catch (e) {
|
||||
mount(err, errorBox(e));
|
||||
return false;
|
||||
}
|
||||
} },
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// Activity: the latest DHCP messages, refreshed every few seconds.
|
||||
import { h, mount, input, select, debounce } from "../ui.js";
|
||||
import { get } from "../api.js";
|
||||
import { onLeave } from "../app.js";
|
||||
|
||||
export async function render(el, params, query) {
|
||||
const ifaces = await get("/interfaces");
|
||||
const mac = input({ placeholder: "MAC address", value: query.get("mac") || "", class: "mono" });
|
||||
const iface = select([["", "all interfaces"], ...ifaces.filter((i) => i.subnets.length).map((i) => [i.name, i.name])], query.get("interface") || "");
|
||||
const out = h("div");
|
||||
let live = true;
|
||||
const pause = h("button", { onclick: () => { live = !live; pause.textContent = live ? "Pause" : "Resume"; } }, "Pause");
|
||||
const draw = async () => {
|
||||
const evs = await get("/events", { mac: mac.value.trim(), interface: iface.value, limit: 300 });
|
||||
mount(out, evs.length ? h("div", { class: "table-wrap" }, h("table", { class: "activity" },
|
||||
h("thead", null, h("tr", null, ["Time", "Interface", "Message", "Client", "Address", "Host name", ""].map((t) => h("th", null, t)))),
|
||||
h("tbody", null, evs.map((e) => {
|
||||
const t = e.type.replace(/^DHCP/, "");
|
||||
return h("tr", null,
|
||||
h("td", { class: "mono small" }, new Date(e.time).toLocaleTimeString(undefined, { hourCycle: "h23" })),
|
||||
h("td", { class: "mono" }, e.interface),
|
||||
h("td", null, h("strong", { class: "type-" + t.split(" ")[0] }, t)),
|
||||
h("td", { class: "mono" }, e.mac ? h("a", { href: "#", onclick: (ev) => { ev.preventDefault(); mac.value = e.mac; draw(); } }, e.mac) : ""),
|
||||
h("td", { class: "mono" }, e.ip || ""),
|
||||
h("td", null, e.hostname || ""),
|
||||
h("td", { class: "detail" }, e.detail || ""));
|
||||
})))) : h("div", { class: "empty" }, "No DHCP messages yet" + (mac.value || iface.value ? " that match." : ".")));
|
||||
};
|
||||
mac.addEventListener("input", debounce(draw, 300));
|
||||
iface.addEventListener("change", draw);
|
||||
mount(el,
|
||||
h("div", { class: "page-head" }, h("div", { class: "grow" }, h("h1", null, "Activity"),
|
||||
h("div", { class: "muted" }, "The latest DHCP messages Steward received and sent, newest first. Click a MAC address to follow one client."))),
|
||||
h("div", { class: "filter-row" }, mac, iface, pause),
|
||||
h("div", { class: "panel" }, out));
|
||||
await draw();
|
||||
const t = setInterval(() => { if (live) draw().catch(() => {}); }, 3000);
|
||||
onLeave(() => clearInterval(t));
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// The audit log: logins, users, tokens, changes, lease deletions.
|
||||
import { h, mount, table, time, input, debounce, badge } from "../ui.js";
|
||||
import { get } from "../api.js";
|
||||
|
||||
export async function render(el) {
|
||||
const search = input({ type: "search", placeholder: "user, action, object or detail" });
|
||||
const out = h("div");
|
||||
const draw = async () => {
|
||||
const q = search.value.trim();
|
||||
const evs = await get("/audit", { q, limit: 300 });
|
||||
mount(out, table([
|
||||
{ label: "When", render: (e) => time(e.time) },
|
||||
{ label: "User", render: (e) => [e.username || "–", e.auth ? h("span", { class: "muted small" }, " " + e.auth) : null] },
|
||||
{ label: "From", render: (e) => h("span", { class: "mono small" }, e.ip || "") },
|
||||
{ label: "Action", render: (e) => h("span", { class: "mono" }, e.action) },
|
||||
{ label: "Object", render: (e) => e.object },
|
||||
{ label: "Result", render: (e) => badge(e.result, e.result === "success" ? "ok" : "err") },
|
||||
{ label: "Detail", render: (e) => h("span", { class: "small" }, e.detail) },
|
||||
], evs, { empty: "Nothing logged" + (q ? " that matches." : " yet.") }));
|
||||
};
|
||||
search.addEventListener("input", debounce(draw, 300));
|
||||
mount(el, h("div", { class: "page-head" }, h("div", { class: "grow" }, h("h1", null, "Audit log"))),
|
||||
h("div", { class: "filter-row" }, search), h("div", { class: "panel" }, out));
|
||||
await draw();
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// The dashboard: interfaces, subnets and how full they are, problems,
|
||||
// recent activity.
|
||||
import { h, mount, problemsList, table, time, plural } from "../ui.js";
|
||||
import { get, can } from "../api.js";
|
||||
import { usageBar, subnetLabel } from "../widgets.js";
|
||||
import { onLeave } from "../app.js";
|
||||
|
||||
export async function render(el) {
|
||||
const draw = async () => {
|
||||
const st = await get("/status");
|
||||
const events = can("leases") ? await get("/events", { limit: 8 }) : [];
|
||||
const leased = st.leases.active || 0;
|
||||
const reservations = st.subnets.reduce((n, s) => n + s.usage.reservations, 0);
|
||||
const poolSize = st.subnets.reduce((n, s) => n + (s.disabled ? 0 : s.usage.pool_size), 0);
|
||||
const notServing = st.interfaces.filter((i) => !i.listening);
|
||||
const errors = st.problems.filter((p) => p.severity === "error");
|
||||
mount(el,
|
||||
h("div", { class: "page-head" }, h("div", { class: "grow" }, h("h1", null, "Dashboard"))),
|
||||
h("div", { class: "cards" },
|
||||
card("Subnets", st.subnets.length, plural(st.interfaces.length, "interface"), "#/subnets"),
|
||||
card("Active leases", leased, poolSize ? Math.round(leased * 100 / poolSize) + "% of " + poolSize + " pool addresses" : "no pools yet", "#/leases"),
|
||||
card("Reservations", reservations, "fixed addresses", "#/reservations"),
|
||||
card("Problems", st.problems.length, errors.length ? plural(errors.length, "error") : notServing.length ? plural(notServing.length, "interface") + " not serving" : "none", null)),
|
||||
st.subnets.length === 0 ? h("div", { class: "panel" },
|
||||
h("h2", null, "Getting started"),
|
||||
h("p", null, "Steward serves no subnets yet. Add one for each network, on the interface its clients are on: ", h("a", { href: "#/subnets?new=1" }, "New subnet"), "."),
|
||||
h("p", null, "Moving from Kea? On the server, ", h("code", null, "sudo steward import kea /etc/kea/kea-dhcp4.conf --leases /var/lib/kea/kea-leases4.csv"),
|
||||
" brings over the subnets, reservations, options and current leases.")) : null,
|
||||
h("div", { class: "panel" },
|
||||
h("h2", null, "Interfaces"),
|
||||
st.interfaces.length ? table([
|
||||
{ label: "Interface", tdClass: "nowrap", render: (i) => [h("span", { class: "dot " + (i.listening ? "ok" : "err") }), h("span", { class: "mono" }, h("strong", null, i.name))] },
|
||||
{ label: "Addresses", render: (i) => h("span", { class: "mono small" }, i.addresses.join(", ") || "none") },
|
||||
{ label: "DHCP", render: (i) => i.listening ? h("span", { class: "muted" }, "serving") : h("span", { class: "err-text small" }, "not serving: " + i.error) },
|
||||
{ label: "Subnets", render: (i) => i.subnets.map((id, n) => {
|
||||
const s = st.subnets.find((x) => x.id === id);
|
||||
return [n ? ", " : "", s ? h("a", { href: "#/subnets/" + id }, subnetLabel(s)) : "#" + id];
|
||||
}) },
|
||||
], st.interfaces) : h("div", { class: "muted" }, "DHCP is served on the interfaces that have subnets.")),
|
||||
st.subnets.length ? h("div", { class: "panel" },
|
||||
h("h2", null, "Subnets"),
|
||||
table([
|
||||
{ label: "Subnet", render: (s) => h("a", { href: "#/subnets/" + s.id }, subnetLabel(s)) },
|
||||
{ label: "Interface", render: (s) => h("span", { class: "mono" }, s.interface) },
|
||||
{ label: "Pools in use", render: (s) => s.disabled ? h("span", { class: "badge off" }, "disabled") : usageBar(s.usage) },
|
||||
{ label: "Reservations", render: (s) => s.usage.reservations ? s.usage.reservations_leased + " of " + s.usage.reservations + " online" : "–" },
|
||||
], st.subnets)) : null,
|
||||
st.problems.length || st.ping_problem ? h("div", { class: "panel" },
|
||||
h("h2", null, "Problems"),
|
||||
problemsList(st.problems),
|
||||
st.ping_problem ? h("div", { class: "msg warn" }, st.ping_problem) : null) : null,
|
||||
events.length ? h("div", { class: "panel" },
|
||||
h("h2", null, "Latest DHCP messages ", h("a", { href: "#/activity", class: "small" }, "all")),
|
||||
table([
|
||||
{ label: "When", render: (e) => time(e.time) },
|
||||
{ label: "Interface", render: (e) => e.interface },
|
||||
{ label: "Message", render: (e) => h("span", { class: "type-" + e.type.replace(/^DHCP/, "").split(" ")[0] }, e.type.replace(/^DHCP/, "")) },
|
||||
{ label: "Client", render: (e) => h("span", { class: "mono" }, e.mac || "") },
|
||||
{ label: "Address", render: (e) => h("span", { class: "mono" }, e.ip || "") },
|
||||
{ label: "Host name", render: (e) => e.hostname || "" },
|
||||
], events, { })) : null);
|
||||
};
|
||||
await draw();
|
||||
const t = setInterval(() => { draw().catch(() => {}); }, 15000);
|
||||
onLeave(() => clearInterval(t));
|
||||
}
|
||||
|
||||
function card(label, value, sub, href) {
|
||||
const body = [h("div", { class: "label" }, label), h("div", { class: "value" }, String(value)), h("div", { class: "sub" }, sub)];
|
||||
return href ? h("a", { class: "card link", href }, body) : h("div", { class: "card" }, body);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// History: every configuration change, with undo.
|
||||
import { h, mount, table, time, confirmDialog, modal, errorBox, badge } from "../ui.js";
|
||||
import { get, change, can } from "../api.js";
|
||||
import { rerender } from "../app.js";
|
||||
|
||||
export async function render(el, params) {
|
||||
if (params.id) return entry(el, params.id);
|
||||
const hs = await get("/history", { limit: 200 });
|
||||
mount(el,
|
||||
h("div", { class: "page-head" }, h("div", { class: "grow" }, h("h1", null, "History"),
|
||||
h("div", { class: "muted" }, "Every configuration change, newest first. Undo applies a change backwards, if nothing it touched has changed since."))),
|
||||
h("div", { class: "panel" }, table([
|
||||
{ label: "#", render: (e) => h("a", { href: "#/history/" + e.id }, String(e.id)) },
|
||||
{ label: "When", render: (e) => time(e.time) },
|
||||
{ label: "By", render: (e) => [e.username, h("span", { class: "muted small" }, " via " + e.via)] },
|
||||
{ label: "Change", render: (e) => [h("a", { href: "#/history/" + e.id }, e.description),
|
||||
e.reverted_by ? [" ", badge("undone by #" + e.reverted_by, "")] : null, e.revert_of ? [" ", badge("undoes #" + e.revert_of, "accent")] : null] },
|
||||
can("config", true) ? { label: "", tdClass: "actions-cell", render: (e) => e.reverted_by ? null : h("button", { class: "small reveal", onclick: () => undo(e) }, "Undo") } : null,
|
||||
].filter(Boolean), hs, { empty: "No changes yet." })));
|
||||
}
|
||||
|
||||
async function undo(e) {
|
||||
if (!await confirmDialog("Undo change #" + e.id, "Undo “" + e.description + "”? This is recorded as a new change, which can be undone too.", "Undo")) return;
|
||||
try {
|
||||
await change("POST", "/history/" + e.id + "/revert");
|
||||
rerender();
|
||||
} catch (err) { modal("Can't undo", errorBox(err)); }
|
||||
}
|
||||
|
||||
async function entry(el, id) {
|
||||
const e = await get("/history/" + id);
|
||||
mount(el,
|
||||
h("nav", { class: "back-link" }, h("a", { href: "#/history" }, "← History")),
|
||||
h("div", { class: "page-head" },
|
||||
h("div", { class: "grow" }, h("h1", null, "#" + e.id + ": " + e.description),
|
||||
h("div", { class: "muted" }, new Date(e.time).toLocaleString(), " by ", e.username, " via ", e.via,
|
||||
e.reverted_by ? [" · undone by ", h("a", { href: "#/history/" + e.reverted_by }, "#" + e.reverted_by)] : null,
|
||||
e.revert_of ? [" · undoes ", h("a", { href: "#/history/" + e.revert_of }, "#" + e.revert_of)] : null)),
|
||||
can("config", true) && !e.reverted_by ? h("div", { class: "actions" }, h("button", { onclick: () => undo(e) }, "Undo")) : null),
|
||||
e.changes.map((c) => h("div", { class: "panel" },
|
||||
h("h3", null, action(c) + " " + c.kind + " ", h("span", { class: "mono" }, c.label)),
|
||||
fieldsTable(c))));
|
||||
}
|
||||
|
||||
function action(c) {
|
||||
if (!c.before) return "Created";
|
||||
if (!c.after) return "Deleted";
|
||||
return "Changed";
|
||||
}
|
||||
|
||||
// fieldsTable lists the fields that differ (all of them for a create or
|
||||
// delete).
|
||||
function fieldsTable(c) {
|
||||
const a = c.before || {}, b = c.after || {};
|
||||
const keys = [...new Set([...Object.keys(a), ...Object.keys(b)])].sort();
|
||||
const show = (v, k) => {
|
||||
if (v === undefined || v === "") return "–";
|
||||
if (k === "options") return v.length ? v.map((o) => o.name + "=" + o.value).join("; ") : "none";
|
||||
if (k === "pools") return v.length ? v.map((p) => p.start + "-" + p.end).join(", ") : "none";
|
||||
if (k === "lease_time" || k === "decline_hold") return v ? v + "s" : "default";
|
||||
return typeof v === "string" ? v : JSON.stringify(v);
|
||||
};
|
||||
const rows = keys.filter((k) => k !== "id").filter((k) => JSON.stringify(a[k]) !== JSON.stringify(b[k]) || !c.before || !c.after);
|
||||
return table([
|
||||
{ label: "Field", render: (k) => h("strong", null, k) },
|
||||
c.before ? { label: "Before", render: (k) => h("span", { class: "mono small" }, show(a[k], k)) } : null,
|
||||
c.after ? { label: c.before ? "After" : "Value", render: (k) => h("span", { class: "mono small" }, show(b[k], k)) } : null,
|
||||
].filter(Boolean), rows);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
// Leases: every address handed out, with filters; reserve or delete one.
|
||||
import { h, mount, table, time, input, select, confirmDialog, toast, modal, errorBox, debounce, badge } from "../ui.js";
|
||||
import { get, api, change, can } from "../api.js";
|
||||
import { stateText, subnetLabel } from "../widgets.js";
|
||||
|
||||
// leaseHost is the host name to show: the reservation's, or what the
|
||||
// client said.
|
||||
function leaseHost(l) {
|
||||
if (l.reserved_hostname && l.hostname && l.reserved_hostname !== l.hostname) return [l.reserved_hostname, h("span", { class: "muted small" }, " (says " + l.hostname + ")")];
|
||||
return l.reserved_hostname || l.hostname || h("span", { class: "muted" }, "–");
|
||||
}
|
||||
|
||||
// leasesTable shows leases with their actions; after a change, done() is
|
||||
// called.
|
||||
export function leasesTable(leases, done, opts = {}) {
|
||||
const write = can("leases", true), config = can("config", true);
|
||||
return table([
|
||||
{ label: "Address", render: (l) => h("span", { class: "mono" }, l.ip) },
|
||||
{ label: "MAC address", render: (l) => h("span", { class: "mono" }, l.mac || "–") },
|
||||
{ label: "Host name", render: leaseHost },
|
||||
{ label: "State", render: (l) => [stateBadge(l.state), l.reservation ? [" ", badge("reserved", "accent")] : null] },
|
||||
{ label: "Ends", render: (l) => time(l.end) },
|
||||
opts.subnet ? null : { label: "Subnet", render: (l) => l.subnet || "–" },
|
||||
{ label: "Interface", render: (l) => h("span", { class: "mono" }, l.interface) },
|
||||
{ label: "", tdClass: "actions-cell", render: (l) => [
|
||||
config && !l.reservation && l.mac && l.state !== "declined" ? h("button", { class: "small reveal", title: "Give this client this address from now on", onclick: () => reserve(l, done) }, "Reserve") : null, " ",
|
||||
write ? h("button", { class: "small reveal", title: "Forget this lease: the address is free at once", onclick: () => del(l, done) }, "Delete") : null,
|
||||
] },
|
||||
].filter(Boolean), leases, { empty: opts.empty || "No leases." });
|
||||
}
|
||||
|
||||
function stateBadge(st) {
|
||||
return badge(stateText(st), { active: "ok", declined: "warn" }[st] || "");
|
||||
}
|
||||
|
||||
async function reserve(l, done) {
|
||||
const host = input({ value: l.hostname || "", placeholder: "host name (optional)" });
|
||||
const err = h("div");
|
||||
await modal("Reserve " + l.ip, h("div", null,
|
||||
h("p", null, l.mac + " keeps " + l.ip + " from now on."),
|
||||
h("div", { class: "form-grid" }, h("label", null, "Host name"), host), err), [
|
||||
{ label: "Cancel", value: null },
|
||||
{ label: "Reserve", kind: "primary", onclick: async () => {
|
||||
try {
|
||||
await change("POST", "/leases/" + l.ip + "/reserve", { hostname: host.value.trim() });
|
||||
done();
|
||||
return true;
|
||||
} catch (e) {
|
||||
mount(err, errorBox(e));
|
||||
return false;
|
||||
}
|
||||
} },
|
||||
]);
|
||||
}
|
||||
|
||||
async function del(l, done) {
|
||||
if (!await confirmDialog("Delete lease", "Forget the lease of " + l.ip + (l.mac ? " (" + l.mac + ")" : "") + "? The address is free at once; if the client still uses it, it gets a NAK when it next renews and asks for a new address.", "Delete", "danger")) return;
|
||||
try {
|
||||
await api("DELETE", "/leases/" + l.ip);
|
||||
toast("Deleted the lease of " + l.ip);
|
||||
done();
|
||||
} catch (e) { toast(e.message, "err", 8000); }
|
||||
}
|
||||
|
||||
export async function render(el, params, query) {
|
||||
const subnets = await get("/subnets");
|
||||
const subnetSel = select([["", "all subnets"], ...subnets.map((s) => [String(s.id), subnetLabel(s)])], query.get("subnet") || "");
|
||||
const stateSel = select([["current", "active and held back"], ["active", "active"], ["expired", "expired"], ["released", "released"], ["declined", "held back"], ["", "all"]], query.get("state") || "current");
|
||||
const search = input({ type: "search", placeholder: "address, MAC or host name", value: query.get("q") || "" });
|
||||
const out = h("div");
|
||||
const draw = async () => {
|
||||
const st = stateSel.value === "current" ? "" : stateSel.value;
|
||||
let leases = await get("/leases", { subnet: subnetSel.value, state: st, q: search.value.trim() });
|
||||
if (stateSel.value === "current") leases = leases.filter((l) => l.state === "active" || l.state === "declined");
|
||||
const q = new URLSearchParams();
|
||||
if (subnetSel.value) q.set("subnet", subnetSel.value);
|
||||
if (stateSel.value !== "current") q.set("state", stateSel.value);
|
||||
if (search.value.trim()) q.set("q", search.value.trim());
|
||||
history.replaceState(null, "", "#/leases" + (q.toString() ? "?" + q : ""));
|
||||
mount(out, h("div", { class: "muted small" }, leases.length + (leases.length === 1 ? " lease" : " leases")), leasesTable(leases, draw));
|
||||
};
|
||||
subnetSel.addEventListener("change", draw);
|
||||
stateSel.addEventListener("change", draw);
|
||||
search.addEventListener("input", debounce(draw, 300));
|
||||
mount(el,
|
||||
h("div", { class: "page-head" }, h("div", { class: "grow" }, h("h1", null, "Leases"),
|
||||
h("div", { class: "muted" }, "Addresses handed out. Expired and released leases are kept a while, so a client that comes back gets its old address."))),
|
||||
h("div", { class: "filter-row" }, subnetSel, stateSel, search, h("button", { onclick: draw }, "Refresh")),
|
||||
h("div", { class: "panel" }, out));
|
||||
await draw();
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
// Reservations: fixed addresses for clients.
|
||||
import { h, mount, table, input, modal, errorBox, field, confirmDialog, select, debounce } from "../ui.js";
|
||||
import { get, change, can } from "../api.js";
|
||||
import { optionsEditor, subnetLabel } from "../widgets.js";
|
||||
|
||||
// reservationsTable lists reservations with Edit and Delete.
|
||||
export function reservationsTable(rs, done, opts = {}) {
|
||||
const write = can("config", true);
|
||||
return table([
|
||||
{ label: "Address", render: (r) => h("span", { class: "mono" }, r.ip) },
|
||||
{ label: "Client", render: (r) => h("span", { class: "mono" }, r.client_id ? "id " + r.client_id : r.mac) },
|
||||
{ label: "Host name", render: (r) => r.hostname || h("span", { class: "muted" }, "–") },
|
||||
opts.subnet ? null : { label: "Subnet", render: (r) => r.subnet_id ? h("a", { href: "#/subnets/" + r.subnet_id }, r.subnet) : "–" },
|
||||
{ label: "Online", render: (r) => r.lease ? h("span", { title: "lease until " + new Date(r.lease.end).toLocaleString() }, "yes, on " + r.lease.interface) : h("span", { class: "muted" }, "no") },
|
||||
{ label: "Options", render: (r) => r.options.length ? h("span", { class: "mono small" }, r.options.map((o) => o.name + "=" + o.value).join("; ")) : "" },
|
||||
{ label: "Comment", render: (r) => r.comment },
|
||||
write ? { label: "", tdClass: "actions-cell", render: (r) => [
|
||||
h("button", { class: "small reveal", onclick: () => reservationDialog(r, done) }, "Edit"), " ",
|
||||
h("button", { class: "small reveal", onclick: () => del(r, done) }, "Delete"),
|
||||
] } : null,
|
||||
].filter(Boolean), rs, { empty: opts.empty || "No reservations." });
|
||||
}
|
||||
|
||||
async function del(r, done) {
|
||||
if (!await confirmDialog("Delete reservation", "Delete the reservation of " + r.ip + " for " + (r.hostname || r.mac || r.client_id) + "? The client keeps its address until its lease runs out.", "Delete", "danger")) return;
|
||||
try {
|
||||
await change("DELETE", "/reservations/" + r.id);
|
||||
done();
|
||||
} catch (e) { modal("Not deleted", errorBox(e)); }
|
||||
}
|
||||
|
||||
// reservationDialog adds (r null) or edits a reservation. preset may give
|
||||
// {ip, mac, hostname} for a new one.
|
||||
export async function reservationDialog(r, done, preset = {}) {
|
||||
const [settings, subnets] = await Promise.all([get("/settings"), get("/subnets")]);
|
||||
// What the client gets from above: the settings', then its subnet's.
|
||||
const sub = subnets.find((x) => x.id === (r && r.subnet_id));
|
||||
const inherited = [...settings.options.filter((o) => !(sub && sub.options.some((x) => x.code === o.code))), ...(sub ? sub.options : [])];
|
||||
const ip = input({ value: r ? r.ip : preset.ip || "", placeholder: "10.0.10.20", class: "mono" });
|
||||
const mac = input({ value: r ? r.mac || "" : preset.mac || "", placeholder: "aa:bb:cc:dd:ee:ff", class: "mono" });
|
||||
const cid = input({ value: r ? r.client_id || "" : "", placeholder: "only for clients known by a client identifier", class: "mono" });
|
||||
const host = input({ value: r ? r.hostname : preset.hostname || "", placeholder: "nas" });
|
||||
const opts = await optionsEditor(r ? r.options : [], inherited);
|
||||
const comment = input({ value: r ? r.comment : "" });
|
||||
const err = h("div");
|
||||
const body = h("div", null, h("div", { class: "form-grid" },
|
||||
field("Address", ip, "Its subnet is the one whose network holds it. Inside a pool is fine: nobody else gets it."),
|
||||
field("MAC address", mac),
|
||||
field("Client identifier", cid, "Optional: match the client's identifier (option 61, hex) instead of its MAC address."),
|
||||
field("Host name", host, "Sent to the client."),
|
||||
field("Options", opts, "Only for this client; they win over the subnet's."),
|
||||
field("Comment", comment)), err);
|
||||
await modal(r ? "Edit reservation " + r.ip : "New reservation", body, [
|
||||
{ label: "Cancel", value: null },
|
||||
{ label: r ? "Save" : "Add reservation", kind: "primary", onclick: async () => {
|
||||
mount(err);
|
||||
const v = { ip: ip.value.trim(), mac: mac.value.trim(), client_id: cid.value.trim(), hostname: host.value.trim(), options: opts.value(), comment: comment.value };
|
||||
try {
|
||||
await change(r ? "PATCH" : "POST", r ? "/reservations/" + r.id : "/reservations", v);
|
||||
done();
|
||||
return true;
|
||||
} catch (e) {
|
||||
mount(err, errorBox(e));
|
||||
return false;
|
||||
}
|
||||
} },
|
||||
], { wide: true, sticky: true });
|
||||
}
|
||||
|
||||
export async function render(el, params, query) {
|
||||
const subnets = await get("/subnets");
|
||||
const subnetSel = select([["", "all subnets"], ...subnets.map((s) => [String(s.id), subnetLabel(s)])], query.get("subnet") || "");
|
||||
const search = input({ type: "search", placeholder: "address, MAC or host name" });
|
||||
const out = h("div");
|
||||
const draw = async () => {
|
||||
const q = search.value.trim().toLowerCase();
|
||||
const rs = (await get("/reservations", { subnet: subnetSel.value })).filter((r) =>
|
||||
!q || r.ip.includes(q) || (r.mac || "").includes(q) || (r.hostname || "").toLowerCase().includes(q) || (r.client_id || "").includes(q));
|
||||
mount(out, reservationsTable(rs, draw, { empty: q || subnetSel.value ? "None match." : "No reservations yet." }));
|
||||
};
|
||||
subnetSel.addEventListener("change", draw);
|
||||
search.addEventListener("input", debounce(draw, 200));
|
||||
mount(el,
|
||||
h("div", { class: "page-head" },
|
||||
h("div", { class: "grow" }, h("h1", null, "Reservations"), h("div", { class: "muted" }, "Fixed addresses for clients, by MAC address or client identifier.")),
|
||||
can("config", true) ? h("div", { class: "actions" }, h("button", { class: "primary", onclick: () => reservationDialog(null, draw) }, "New reservation")) : null),
|
||||
h("div", { class: "filter-row" }, subnetSel, search),
|
||||
h("div", { class: "panel" }, out));
|
||||
await draw();
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// Settings: the default lease time, conflict checks, authority, and the
|
||||
// options every subnet gets.
|
||||
import { h, mount, input, field, checkbox, errorBox } from "../ui.js";
|
||||
import { get, change, can } from "../api.js";
|
||||
import { optionsEditor, fmtSeconds, parseSeconds } from "../widgets.js";
|
||||
import { rerender } from "../app.js";
|
||||
|
||||
export async function render(el) {
|
||||
const s = await get("/settings");
|
||||
const write = can("config", true);
|
||||
const lease = input({ value: fmtSeconds(s.lease_time || 43200) });
|
||||
const ping = checkbox("Ping an address before offering it, and skip it if something answers", s.ping_check);
|
||||
const timeout = input({ type: "number", min: 50, max: 5000, value: s.ping_timeout_ms || 500 });
|
||||
timeout.style.width = "7em";
|
||||
const hold = input({ value: fmtSeconds(s.decline_hold || 86400) });
|
||||
const auth = checkbox("Authoritative: refuse (NAK) requests for addresses that don't belong on the interface", !s.not_authoritative);
|
||||
const opts = await optionsEditor(s.options);
|
||||
const err = h("div");
|
||||
const save = async (dry) => {
|
||||
mount(err);
|
||||
const lt = parseSeconds(lease.value), dh = parseSeconds(hold.value);
|
||||
if (lt === null || dh === null) { mount(err, h("div", { class: "msg err" }, "Write times as 12h, 1d or 30m.")); return; }
|
||||
try {
|
||||
const r = await change("PATCH", "/settings", { lease_time: lt, ping_check: ping.input.checked, ping_timeout_ms: parseInt(timeout.value, 10) || 0,
|
||||
decline_hold: dh, not_authoritative: !auth.input.checked, options: opts.value() }, { query: dry ? { dry_run: 1 } : undefined });
|
||||
if (!dry && r.applied) rerender();
|
||||
} catch (e) { mount(err, errorBox(e)); }
|
||||
};
|
||||
mount(el,
|
||||
h("div", { class: "page-head" }, h("div", { class: "grow" }, h("h1", null, "Settings and options"),
|
||||
h("div", { class: "muted" }, "Server-wide settings, and the options every subnet sends. A subnet's or a reservation's own option of the same name wins."))),
|
||||
h("div", { class: "panel" },
|
||||
h("div", { class: "form-grid" },
|
||||
field("Default lease time", lease, "How long clients keep an address unless a subnet says otherwise (12h, 1d). They renew halfway."),
|
||||
field("Options for every subnet", opts, "Usually domain-name-servers and domain-name; routers belong to each subnet."),
|
||||
field("Conflict check", h("div", null, ping, h("div", { class: "small" }, "wait ", timeout, " ms for an answer"))),
|
||||
field("Hold back for", hold, "How long an address a client declined (or one that answered a ping) isn't handed out."),
|
||||
field("Authority", auth, "Turn this off only if another DHCP server serves the same network.")),
|
||||
err,
|
||||
write ? h("div", { class: "actions", style: { marginTop: "14px" } },
|
||||
h("button", { class: "primary", onclick: () => save(false) }, "Save"),
|
||||
h("button", { onclick: () => save(true), title: "Check without saving" }, "Check")) : null));
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// A subnet's page: its leases, its reservations and its settings.
|
||||
import { h, mount, badge, errorBox, confirmDialog, modal, input, debounce } from "../ui.js";
|
||||
import { get, change, can } from "../api.js";
|
||||
import { usageBar, subnetForm, fmtSeconds, subnetLabel } from "../widgets.js";
|
||||
import { leasesTable } from "./leases.js";
|
||||
import { reservationsTable, reservationDialog } from "./reservations.js";
|
||||
import { rerender } from "../app.js";
|
||||
|
||||
const tabs = [["leases", "Leases"], ["reservations", "Reservations"], ["settings", "Settings"]];
|
||||
|
||||
export async function render(el, params) {
|
||||
const id = params.id;
|
||||
const tab = params.tab || "leases";
|
||||
const s = await get("/subnets/" + id);
|
||||
const body = h("div");
|
||||
mount(el,
|
||||
h("nav", { class: "back-link" }, h("a", { href: "#/subnets" }, "← Subnets")),
|
||||
h("div", { class: "page-head" },
|
||||
h("div", { class: "grow" },
|
||||
h("h1", null, subnetLabel(s), " ", s.disabled ? badge("disabled", "off") : null),
|
||||
h("div", { class: "muted" }, "On ", h("span", { class: "mono" }, s.interface), " · pools ",
|
||||
s.pools.length ? h("span", { class: "mono" }, s.pools.map((p) => p.start + "-" + p.end).join(", ")) : "none",
|
||||
" · lease time ", s.lease_time ? fmtSeconds(s.lease_time) : "the default")),
|
||||
h("div", { class: "subnet-usage" }, usageBar(s.usage))),
|
||||
h("div", { class: "tabs" }, tabs.map(([k, label]) => h("a", { href: "#/subnets/" + id + "/" + k, class: tab === k ? "active" : "" }, label))),
|
||||
body);
|
||||
if (tab === "leases") await leasesTab(body, s);
|
||||
else if (tab === "reservations") await reservationsTab(body, s);
|
||||
else await settingsTab(body, s);
|
||||
}
|
||||
|
||||
async function leasesTab(el, s) {
|
||||
const search = input({ type: "search", placeholder: "address, MAC or host name" });
|
||||
const out = h("div");
|
||||
let all = false;
|
||||
const toggle = h("a", { href: "#", class: "small", onclick: (e) => { e.preventDefault(); all = !all; toggle.textContent = all ? "Only current leases" : "Also expired and released"; draw(); } }, "Also expired and released");
|
||||
const draw = async () => {
|
||||
let ls = await get("/leases", { subnet: s.id, q: search.value.trim() });
|
||||
if (!all) ls = ls.filter((l) => l.state === "active" || l.state === "declined");
|
||||
mount(out, leasesTable(ls, rerender, { subnet: true, empty: all ? "No leases." : "No current leases." }));
|
||||
};
|
||||
search.addEventListener("input", debounce(draw, 300));
|
||||
mount(el, h("div", { class: "filter-row" }, search, toggle), h("div", { class: "panel" }, out));
|
||||
await draw();
|
||||
}
|
||||
|
||||
async function reservationsTab(el, s) {
|
||||
const rs = await get("/reservations", { subnet: s.id });
|
||||
mount(el,
|
||||
can("config", true) ? h("div", { class: "filter-row" }, h("button", { class: "primary", onclick: () => reservationDialog(null, rerender) }, "New reservation")) : null,
|
||||
h("div", { class: "panel" }, reservationsTable(rs, rerender, { subnet: true, empty: "No reservations in this subnet." })));
|
||||
}
|
||||
|
||||
async function settingsTab(el, s) {
|
||||
const settings = await get("/settings");
|
||||
if (!can("config", true)) {
|
||||
mount(el, h("div", { class: "panel" }, h("pre", null, JSON.stringify(s, null, 2))));
|
||||
return;
|
||||
}
|
||||
const form = await subnetForm(s, settings);
|
||||
const err = h("div");
|
||||
const save = async (dry) => {
|
||||
mount(err);
|
||||
try {
|
||||
const r = await change("PATCH", "/subnets/" + s.id, form.values(), { query: dry ? { dry_run: 1 } : undefined });
|
||||
if (!dry && r.applied) rerender();
|
||||
} catch (e) { mount(err, errorBox(e)); }
|
||||
};
|
||||
const del = async () => {
|
||||
const n = s.usage.reservations;
|
||||
if (!await confirmDialog("Delete subnet", "Delete " + subnetLabel(s) + (n ? " and its " + n + (n === 1 ? " reservation" : " reservations") : "") + "? Its clients get no answers from then on; their leases are kept until they run out. History can undo it.", "Delete", "danger")) return;
|
||||
try {
|
||||
await change("DELETE", "/subnets/" + s.id);
|
||||
location.hash = "#/subnets";
|
||||
} catch (e) { modal("Not deleted", errorBox(e)); }
|
||||
};
|
||||
mount(el, h("div", { class: "panel" }, form, err,
|
||||
h("div", { class: "actions", style: { marginTop: "14px" } },
|
||||
h("button", { class: "primary", onclick: () => save(false) }, "Save"),
|
||||
h("button", { onclick: () => save(true), title: "Check the change without saving it" }, "Check"),
|
||||
h("span", { class: "grow" }),
|
||||
h("button", { class: "danger", onclick: del }, "Delete subnet"))));
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
// The subnets, and the New subnet dialog.
|
||||
import { h, mount, table, modal, errorBox, badge } from "../ui.js";
|
||||
import { get, change, can } from "../api.js";
|
||||
import { usageBar, subnetForm, fmtSeconds, subnetLabel } from "../widgets.js";
|
||||
|
||||
export async function render(el, params, query) {
|
||||
const subnets = await get("/subnets");
|
||||
const poolsText = (s) => s.pools.length ? s.pools.map((p) => p.start + "-" + p.end).join(", ") : "–";
|
||||
mount(el,
|
||||
h("div", { class: "page-head" },
|
||||
h("div", { class: "grow" }, h("h1", null, "Subnets"),
|
||||
h("div", { class: "muted" }, "Each subnet is served on one interface: only clients on that interface get its addresses.")),
|
||||
can("config", true) ? h("div", { class: "actions" }, h("button", { class: "primary", onclick: () => newSubnet() }, "New subnet")) : null),
|
||||
h("div", { class: "panel" }, table([
|
||||
{ label: "Subnet", render: (s) => h("a", { href: "#/subnets/" + s.id }, subnetLabel(s)) },
|
||||
{ label: "Interface", render: (s) => h("span", { class: "mono" }, s.interface) },
|
||||
{ label: "Pools", render: (s) => h("span", { class: "mono small" }, poolsText(s)) },
|
||||
{ label: "In use", render: (s) => usageBar(s.usage) },
|
||||
{ label: "Reserved", class: "num", tdClass: "num", render: (s) => String(s.usage.reservations) },
|
||||
{ label: "Lease time", render: (s) => s.lease_time ? fmtSeconds(s.lease_time) : h("span", { class: "muted" }, "default") },
|
||||
{ label: "", render: (s) => s.disabled ? badge("disabled", "off") : null },
|
||||
], subnets, { empty: "No subnets yet. Add one for each network Steward should serve." })));
|
||||
if (query.get("new") === "1" && can("config", true)) newSubnet();
|
||||
}
|
||||
|
||||
export async function newSubnet() {
|
||||
const settings = await get("/settings");
|
||||
const form = await subnetForm(null, settings);
|
||||
const err = h("div");
|
||||
const res = await modal("New subnet", h("div", null, form, err), [
|
||||
{ label: "Cancel", value: null },
|
||||
{ label: "Add subnet", kind: "primary", onclick: async () => {
|
||||
mount(err);
|
||||
try {
|
||||
const r = await change("POST", "/subnets", form.values());
|
||||
location.hash = "#/subnets/" + (r.object ? r.object.id : "");
|
||||
return true;
|
||||
} catch (e) {
|
||||
mount(err, errorBox(e));
|
||||
return false;
|
||||
}
|
||||
} },
|
||||
], { wide: true, sticky: true });
|
||||
return res;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// Users and everyone's tokens (administrators).
|
||||
import { h, mount, table, time, modal, input, field, checkbox, select, errorBox, confirmDialog, toast, badge } from "../ui.js";
|
||||
import { get, api, catalog } from "../api.js";
|
||||
import { rerender } from "../app.js";
|
||||
|
||||
export async function render(el) {
|
||||
const [users, tokens, cat] = await Promise.all([get("/users"), get("/tokens"), catalog()]);
|
||||
mount(el,
|
||||
h("div", { class: "page-head" },
|
||||
h("div", { class: "grow" }, h("h1", null, "Users"), h("div", { class: "muted" }, "Administrators may do everything. Others get a role or grants: config, leases and audit, each read or write.")),
|
||||
h("div", { class: "actions" }, h("button", { class: "primary", onclick: () => userDialog(null, cat) }, "New user"))),
|
||||
h("div", { class: "panel" }, table([
|
||||
{ label: "User", render: (u) => [h("strong", null, u.username), u.disabled ? [" ", badge("disabled", "off")] : null] },
|
||||
{ label: "May", render: (u) => u.admin ? badge("administrator", "accent") : h("span", { class: "mono small" }, u.grant_strings.join(" ") || "nothing") },
|
||||
{ label: "Password", render: (u) => u.has_password ? "yes" : h("span", { class: "muted" }, "tokens only") },
|
||||
{ label: "Last login", render: (u) => u.last_login_at ? time(u.last_login_at) : "never" },
|
||||
{ label: "Comment", render: (u) => u.comment },
|
||||
{ label: "", tdClass: "actions-cell", render: (u) => [
|
||||
h("button", { class: "small reveal", onclick: () => userDialog(u, cat) }, "Edit"), " ",
|
||||
h("button", { class: "small reveal", onclick: () => del(u) }, "Delete")] },
|
||||
], users)),
|
||||
h("h2", null, "API tokens"),
|
||||
h("div", { class: "panel" }, table([
|
||||
{ label: "User", render: (t) => t.username },
|
||||
{ label: "Name", render: (t) => t.name },
|
||||
{ label: "May", render: (t) => h("span", { class: "mono small" }, t.grant_strings.join(" ")) },
|
||||
{ label: "Expires", render: (t) => t.expires_at ? time(t.expires_at) : "never" },
|
||||
{ label: "Last used", render: (t) => t.last_used_at ? [time(t.last_used_at), h("span", { class: "muted small" }, " from " + t.last_used_ip)] : "never" },
|
||||
{ label: "", tdClass: "actions-cell", render: (t) => h("button", { class: "small reveal", onclick: () => revoke(t) }, "Revoke") },
|
||||
], tokens, { empty: "No tokens." })));
|
||||
}
|
||||
|
||||
export async function revoke(t) {
|
||||
if (!await confirmDialog("Revoke token", "Revoke " + t.username + "'s token “" + t.name + "”? Anything using it is refused from now on.", "Revoke", "danger")) return;
|
||||
try {
|
||||
await api("DELETE", "/tokens/" + t.id);
|
||||
toast("Revoked");
|
||||
rerender();
|
||||
} catch (e) { toast(e.message, "err", 8000); }
|
||||
}
|
||||
|
||||
async function del(u) {
|
||||
if (!await confirmDialog("Delete user", "Delete " + u.username + " and their tokens?", "Delete", "danger")) return;
|
||||
try {
|
||||
await api("DELETE", "/users/" + u.id);
|
||||
rerender();
|
||||
} catch (e) { toast(e.message, "err", 8000); }
|
||||
}
|
||||
|
||||
async function userDialog(u, cat) {
|
||||
const name = input({ value: u ? u.username : "", autocomplete: "off" });
|
||||
const pw = input({ type: "password", autocomplete: "new-password", placeholder: u ? "unchanged" : "at least 8 characters" });
|
||||
const admin = checkbox("Administrator", u ? u.admin : false);
|
||||
const role = select([["", "custom grants"], ...Object.keys(cat.roles).map((r) => [r, r + ": " + cat.roles[r].join(" ")])], "");
|
||||
const grants = input({ value: u ? u.grant_strings.join(" ") : "", placeholder: "config:read leases:write", class: "mono" });
|
||||
const disabled = checkbox("Disabled (logs them out)", u ? u.disabled : false);
|
||||
const comment = input({ value: u ? u.comment : "" });
|
||||
const err = h("div");
|
||||
await modal(u ? "Edit " + u.username : "New user", h("div", null, h("div", { class: "form-grid" },
|
||||
field("Username", name),
|
||||
field("Password", pw, u ? "Leave empty to keep it." : "Leave empty for a service account that only uses API tokens."),
|
||||
h("label"), admin,
|
||||
field("Role", role),
|
||||
field("Grants", grants, "resource:access, separated by spaces; resources config, leases, audit; access read or write."),
|
||||
u ? [h("label"), disabled] : null,
|
||||
field("Comment", comment)), err), [
|
||||
{ label: "Cancel", value: null },
|
||||
{ label: u ? "Save" : "Add user", kind: "primary", onclick: async () => {
|
||||
mount(err);
|
||||
const body = { username: name.value.trim(), admin: admin.input.checked, comment: comment.value };
|
||||
if (role.value) body.role = role.value;
|
||||
else body.grants = grants.value.split(/[\s,]+/).filter(Boolean);
|
||||
if (pw.value || !u) body.password = pw.value;
|
||||
if (u) body.disabled = disabled.input.checked;
|
||||
try {
|
||||
await api(u ? "PATCH" : "POST", u ? "/users/" + u.id : "/users", { body });
|
||||
rerender();
|
||||
return true;
|
||||
} catch (e) {
|
||||
mount(err, errorBox(e));
|
||||
return false;
|
||||
}
|
||||
} },
|
||||
], { sticky: true });
|
||||
}
|
||||
Reference in new issue
Block a user