Allow and deny lists per subnet; every option of a DHCP message in words
- A subnet is a deny-list subnet (the default: everyone not denied) or
an allow-list subnet (only clients allowed or with a reservation);
the deny list counts in both. Entries are MAC addresses, prefixes
(aa:bb:cc:*) or client IDs. A refused client gets no answer (DENIED
in the activity log, with an Allow button) or a NAK on renewal.
steward access, the subnet's Access tab. Schema migration 7.
- The activity log keeps each message's header fields and options,
decoded into words (dhcp4.Describe): the parameter request list by
name, client IDs, lease times. ▸ on an Activity row, steward
activity show N / --details, GET /events/{id}.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b3c8329ae8
commit
3ad2f981d4
30 files changed
+1092
-37
No files matched your search
@@ -1,24 +1,52 @@
|
||||
// Activity: the latest DHCP messages, refreshed every few seconds.
|
||||
import { h, mount, input, select, debounce } from "../ui.js";
|
||||
import { get } from "../api.js";
|
||||
import { h, mount, input, select, debounce, toast, modal, errorBox } from "../ui.js";
|
||||
import { get, change, can } from "../api.js";
|
||||
import { onLeave } from "../app.js";
|
||||
import { subnetLabel } from "../widgets.js";
|
||||
|
||||
// allowClient puts a refused client on its subnet's allow list.
|
||||
async function allowClient(e) {
|
||||
try {
|
||||
const s = await get("/subnets/" + e.subnet_id);
|
||||
if ((s.allow || []).some((m) => m.match === e.mac)) { toast(e.mac + " is already allowed on " + subnetLabel(s)); return; }
|
||||
const allow = [...(s.allow || []), { match: e.mac, comment: "" }];
|
||||
await change("PATCH", "/subnets/" + e.subnet_id, { allow });
|
||||
toast(e.mac + " is allowed on " + subnetLabel(s) + "; it gets an address when it next asks");
|
||||
} catch (err) { modal("Not allowed", errorBox(err)); }
|
||||
}
|
||||
|
||||
// details shows a message's header fields and options in words.
|
||||
function details(e) {
|
||||
if (!e) return h("div", { class: "muted small" }, "Loading…");
|
||||
if (e.error) return h("div", { class: "msg err" }, e.error);
|
||||
const who = e.direction === "in" ? "Received from the client" : "Sent by Steward";
|
||||
return h("div", { class: "event-detail" },
|
||||
h("div", { class: "muted small" }, who),
|
||||
h("table", { class: "kv" }, h("tbody", null,
|
||||
e.fields.map((f) => h("tr", null, h("th", null, f.name), h("td", { class: "mono" }, f.value))),
|
||||
(e.options || []).map((o) => h("tr", null, h("th", null, o.name, h("span", { class: "muted small" }, " (" + o.code + ")")),
|
||||
h("td", { class: "mono" }, o.value))))));
|
||||
}
|
||||
|
||||
export async function render(el, params, query) {
|
||||
const [ifaces, subnets] = await Promise.all([get("/interfaces"), get("/subnets")]);
|
||||
const subnetSel = select([["", "all subnets"], ...subnets.map((s) => [String(s.id), subnetLabel(s)])], query.get("subnet") || "");
|
||||
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");
|
||||
// Messages opened to show their options, kept across refreshes.
|
||||
const open = new Map(); // id -> the event with its details, or null while loading
|
||||
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(), subnet: subnetSel.value, 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("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,
|
||||
const row = h("tr", null,
|
||||
h("td", null, e.direction ? h("button", { class: "small", title: open.has(e.id) ? "Hide the options" : "Show every option of this message",
|
||||
onclick: () => toggle(e.id) }, open.has(e.id) ? "▾" : "▸") : ""),
|
||||
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)),
|
||||
@@ -27,9 +55,19 @@ export async function render(el, params, query) {
|
||||
mac.value.trim() ? null : h("button", { class: "small reveal", title: "Show only this client's messages", onclick: () => { mac.value = e.mac; draw(); } }, "Only this")] : ""),
|
||||
h("td", { class: "mono" }, e.ip || ""),
|
||||
h("td", null, e.hostname || ""),
|
||||
h("td", { class: "detail" }, e.detail || ""));
|
||||
h("td", { class: "detail" }, e.detail || "",
|
||||
e.type === "DENIED" && e.subnet_id && e.mac && (e.detail || "").includes("allow list") && can("config", true)
|
||||
? [" ", h("button", { class: "small", title: "Put this client on the subnet's allow list", onclick: () => allowClient(e) }, "Allow")] : null));
|
||||
return open.has(e.id) ? [row, h("tr", { class: "event-details" }, h("td"), h("td", { colspan: 7 }, details(open.get(e.id))))] : row;
|
||||
})))) : h("div", { class: "empty" }, "No DHCP messages yet" + (mac.value || subnetSel.value || iface.value ? " that match." : ".")));
|
||||
};
|
||||
const toggle = async (id) => {
|
||||
if (open.has(id)) { open.delete(id); draw(); return; }
|
||||
open.set(id, null);
|
||||
draw();
|
||||
try { open.set(id, await get("/events/" + id)); } catch (e) { open.set(id, { error: e.message }); }
|
||||
draw();
|
||||
};
|
||||
mac.addEventListener("input", debounce(draw, 300));
|
||||
subnetSel.addEventListener("change", draw);
|
||||
iface.addEventListener("change", draw);
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
// A subnet's page: its leases, its reservations and its settings.
|
||||
import { h, mount, badge, errorBox, confirmDialog, modal, input, debounce, readOnly, checkbox } from "../ui.js";
|
||||
import { h, mount, badge, errorBox, confirmDialog, modal, input, debounce, readOnly, checkbox, select, field } from "../ui.js";
|
||||
import { get, change, can } from "../api.js";
|
||||
import { usageBar, subnetForm, fmtSeconds, subnetLabel, poolText, orderText } 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"]];
|
||||
const tabs = [["leases", "Leases"], ["reservations", "Reservations"], ["access", "Access"], ["settings", "Settings"]];
|
||||
|
||||
export async function render(el, params) {
|
||||
const id = params.id;
|
||||
@@ -21,7 +21,8 @@ export async function render(el, params) {
|
||||
h("div", { class: "muted" }, "On ", h("span", { class: "mono" }, s.interface), " · pools ",
|
||||
s.pools.length ? h("span", { class: "mono" }, s.pools.map(poolText).join(", ")) : "none",
|
||||
orderText(s) ? " · " + orderText(s) : "",
|
||||
" · lease time ", s.lease_time ? fmtSeconds(s.lease_time) : "the default")),
|
||||
" · lease time ", s.lease_time ? fmtSeconds(s.lease_time) : "the default",
|
||||
s.access === "allow-list" ? [" · ", badge("allow-list", "accent")] : "")),
|
||||
h("div", { class: "subnet-usage" }, usageBar(s.usage)),
|
||||
can("config", true) ? h("div", { class: "actions" }, h("button", {
|
||||
title: s.disabled ? "Answer this subnet's clients again" : "Stop answering this subnet's clients; its configuration and leases stay",
|
||||
@@ -31,9 +32,59 @@ export async function render(el, params) {
|
||||
body);
|
||||
if (tab === "leases") await leasesTab(body, s);
|
||||
else if (tab === "reservations") await reservationsTab(body, s);
|
||||
else if (tab === "access") accessTab(body, s);
|
||||
else await settingsTab(body, s);
|
||||
}
|
||||
|
||||
// matchList edits an allow or deny list: entries with a comment each.
|
||||
function matchList(entries, placeholder) {
|
||||
const rows = h("div", { class: "pool-rows" });
|
||||
let list = [];
|
||||
const addRow = (m) => {
|
||||
const it = { match: input({ value: m ? m.match : "", placeholder, class: "mono" }), comment: input({ value: m ? m.comment || "" : "", placeholder: "what it is (optional)" }) };
|
||||
it.row = h("div", { class: "pool-row" }, it.match, it.comment,
|
||||
h("button", { type: "button", class: "small", title: "Remove", onclick: () => { it.row.remove(); list = list.filter((x) => x !== it); } }, "✕"));
|
||||
list.push(it);
|
||||
rows.append(it.row);
|
||||
return it.match;
|
||||
};
|
||||
for (const m of entries || []) addRow(m);
|
||||
const box = h("div", null, rows, h("button", { type: "button", class: "small", onclick: () => addRow(null).focus() }, "+ Add"));
|
||||
box.value = () => list.map((it) => ({ match: it.match.value.trim(), comment: it.comment.value.trim() })).filter((m) => m.match);
|
||||
return box;
|
||||
}
|
||||
|
||||
// accessTab: who the subnet answers.
|
||||
function accessTab(el, s) {
|
||||
const mode = select([["deny-list", "Deny-list: every client except those denied"], ["allow-list", "Allow-list: only clients allowed or with a reservation"]], s.access || "deny-list");
|
||||
const ph = "aa:bb:cc:dd:ee:ff, aa:bb:cc:* or id 01aabb…";
|
||||
const allow = matchList(s.allow, ph), deny = matchList(s.deny, ph);
|
||||
// The allow list only counts in an allow-list subnet.
|
||||
const sync = () => allow.classList.toggle("dimmed", mode.value !== "allow-list");
|
||||
mode.addEventListener("change", sync);
|
||||
const err = h("div");
|
||||
const save = async (dry) => {
|
||||
mount(err);
|
||||
try {
|
||||
const r = await change("PATCH", "/subnets/" + s.id, { access: mode.value, allow: allow.value(), deny: deny.value() }, { query: dry ? { dry_run: 1 } : undefined });
|
||||
if (!dry && r.applied) rerender();
|
||||
} catch (e) { mount(err, errorBox(e)); }
|
||||
};
|
||||
const form = h("div", { class: "form-grid" },
|
||||
field("Who gets an address", mode),
|
||||
field("Allow list", allow, "Used by an allow-list subnet. Clients with a reservation in the subnet are allowed too."),
|
||||
field("Deny list", deny, "Never answered, in either mode, even with a reservation."));
|
||||
const write = can("config", true);
|
||||
mount(el, h("div", { class: "panel" },
|
||||
h("p", { class: "muted" }, "Entries are a MAC address, a prefix for one maker's devices (aa:bb:cc:*) or a client identifier (id 01aabb…). A refused client gets no answer, and shows as DENIED in Activity; one with a lease gets a NAK when it next renews. Phones use a private MAC address per network unless told not to."),
|
||||
form, err,
|
||||
write ? 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")) : null));
|
||||
if (!write) readOnly(el.querySelector(".panel"));
|
||||
sync();
|
||||
}
|
||||
|
||||
export async function toggleSubnet(s) {
|
||||
if (!s.disabled && !await confirmDialog("Disable subnet", "Stop answering the clients of " + subnetLabel(s) + "? They keep their addresses until their leases run out, then have none. The configuration and leases stay; Enable brings it back.", "Disable", "danger")) return;
|
||||
try {
|
||||
|
||||
@@ -346,6 +346,11 @@ button.chip:hover { background: var(--accent-soft); border-color: var(--accent);
|
||||
/* Activity: one line per message. */
|
||||
table.activity td { white-space: nowrap; }
|
||||
table.activity td.detail { white-space: normal; color: var(--muted); }
|
||||
table.activity tr.event-details > td { white-space: normal; background: var(--bg); }
|
||||
.event-detail table.kv { border-collapse: collapse; margin: 4px 0; }
|
||||
.event-detail table.kv th { text-align: left; font-weight: normal; color: var(--muted); padding: 2px 16px 2px 0; vertical-align: top; white-space: nowrap;
|
||||
text-transform: none; letter-spacing: normal; font-size: inherit; background: none; border: 0; }
|
||||
.event-detail table.kv td { padding: 2px 0; white-space: normal; word-break: break-word; background: none; border: 0; }
|
||||
.type-OFFER, .type-ACK { color: var(--ok); }
|
||||
.type-NAK, .type-DECLINE, .type-CONFLICT, .type-NO { color: var(--err); }
|
||||
.type-RELEASE { color: var(--muted); }
|
||||
@@ -355,3 +360,5 @@ input:disabled, select:disabled, textarea:disabled { background: var(--info-soft
|
||||
.panel-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 8px; }
|
||||
.panel-head h2 { margin: 0; }
|
||||
pre.wrap { white-space: pre-wrap; word-break: break-all; font-size: 12px; }
|
||||
|
||||
.dimmed { opacity: 0.55; }
|
||||
Reference in new issue
Block a user