Address order: sequential, reverse or random, per subnet and pool

- Which never-used pool address a new client gets: sequential (the
  lowest, as before), reverse or random, in the settings, a subnet or
  a single pool (FIRST-LAST:ORDER). When no never-used address is left,
  the one free the longest, whatever the order; returning clients keep
  theirs.
- A subnet's pools are used in the order listed, or one at random
  weighted by its never-used addresses. Pools now keep the order they
  are given in (no more sorting by address).
- CLI --address-order and --pool-order; --add-pool of an existing range
  changes its order in place. Web UI: per-pool order and a move-up
  button, pool and address order on the subnet and settings pages.
- Kea's allocator: random is imported. Schema migration 5.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
abrown38andClaude Opus 5.5 committed 2026-10-18 18:13:25 +01:00
1 parent 29fc991ce1
commit 8ee430276c
29 files changed
+653 -79

No files matched your search

+5 -3
View File
@@ -1,8 +1,8 @@
// Settings: the default lease time, conflict checks, authority, and the
// options every subnet gets.
import { h, mount, input, field, checkbox, errorBox, readOnly } from "../ui.js";
import { h, mount, input, field, checkbox, errorBox, readOnly, select } from "../ui.js";
import { get, change, can } from "../api.js";
import { optionsEditor, fmtSeconds, parseSeconds } from "../widgets.js";
import { optionsEditor, fmtSeconds, parseSeconds, ADDRESS_ORDERS } from "../widgets.js";
import { rerender } from "../app.js";
export async function render(el) {
@@ -14,6 +14,7 @@ export async function render(el) {
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 order = select(ADDRESS_ORDERS, s.address_order || "sequential");
const opts = await optionsEditor(s.options);
const err = h("div");
const save = async (dry) => {
@@ -22,7 +23,7 @@ export async function render(el) {
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 });
decline_hold: dh, not_authoritative: !auth.input.checked, address_order: order.value, options: opts.value() }, { query: dry ? { dry_run: 1 } : undefined });
if (!dry && r.applied) rerender();
} catch (e) { mount(err, errorBox(e)); }
};
@@ -35,6 +36,7 @@ export async function render(el) {
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("Address order", order, "Which address of a pool a new client gets: sequential (the lowest), reverse (the highest) or random. Only addresses never handed out count; when there are none left, the one free the longest. Subnets and pools can set their own."),
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,
+3 -2
View File
@@ -1,7 +1,7 @@
// 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 { get, change, can } from "../api.js";
import { usageBar, subnetForm, fmtSeconds, subnetLabel } from "../widgets.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";
@@ -19,7 +19,8 @@ export async function render(el, params) {
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",
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")),
h("div", { class: "subnet-usage" }, usageBar(s.usage)),
can("config", true) ? h("div", { class: "actions" }, h("button", {
+2 -2
View File
@@ -1,12 +1,12 @@
// 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";
import { usageBar, subnetForm, fmtSeconds, subnetLabel, poolText } from "../widgets.js";
import { toggleSubnet } from "./subnet.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(", ") : "–";
const poolsText = (s) => s.pools.length ? s.pools.map(poolText).join(", ") : "–";
mount(el,
h("div", { class: "page-head" },
h("div", { class: "grow" }, h("h1", null, "Subnets"),
+1
View File
@@ -342,6 +342,7 @@ button.chip:hover { background: var(--accent-soft); border-color: var(--accent);
.pool-rows { display: flex; flex-direction: column; gap: 6px; }
.pool-row { display: flex; gap: 6px; align-items: center; }
.pool-row input { font-family: var(--mono); width: 18em; }
.pool-row select { width: auto; }
/* Activity: one line per message. */
table.activity td { white-space: nowrap; }
table.activity td.detail { white-space: normal; color: var(--muted); }
+42 -12
View File
@@ -114,25 +114,51 @@ const examples = {
// ---- pools
// poolsEditor edits a subnet's pools; value() is ["first-last", ...].
// Each pool has its own address order, or the subnet's; ↑ moves a pool up
// (with pools in the order listed, the first is used first).
export function poolsEditor(pools, placeholder) {
const rows = h("div", { class: "pool-rows" });
const box = h("div", null, rows);
const list = [];
const addRow = (v) => {
const inp = input({ value: v || "", placeholder: placeholder || "10.0.10.100-10.0.10.199" });
const row = h("div", { class: "pool-row" }, inp,
h("button", { type: "button", class: "small", title: "Remove this pool", onclick: () => { row.remove(); list.splice(list.indexOf(inp), 1); } }, "✕"));
list.push(inp);
rows.append(row);
return inp;
let list = [];
const addRow = (v, order) => {
const it = { inp: input({ value: v || "", placeholder: placeholder || "10.0.10.100-10.0.10.199" }) };
it.sel = select([["", "subnet's order"], ...ADDRESS_ORDERS.map((o) => [o, o])], order || "", { title: "The order addresses of this pool are handed out in" });
it.row = h("div", { class: "pool-row" }, it.inp, it.sel,
h("button", { type: "button", class: "small", title: "Move this pool up: it is used before the ones below", onclick: () => {
const i = list.indexOf(it);
if (i > 0) { list.splice(i, 1); list.splice(i - 1, 0, it); rows.insertBefore(it.row, list[i].row); }
} }, "↑"),
h("button", { type: "button", class: "small", title: "Remove this pool", onclick: () => { it.row.remove(); list = list.filter((x) => x !== it); } }, "✕"));
list.push(it);
rows.append(it.row);
return it.inp;
};
for (const p of pools || []) addRow(p.start + "-" + p.end);
for (const p of pools || []) addRow(p.start + "-" + p.end, p.order);
box.append(h("button", { type: "button", class: "small", onclick: () => addRow("").focus() }, "+ Add pool"));
box.value = () => list.map((i) => i.value.trim()).filter((s) => s !== "");
box.setFirst = (v) => { if (!list.length) addRow(v); else if (!list[0].value) list[0].value = v; };
box.value = () => list.map((it) => {
const v = it.inp.value.trim();
return v && it.sel.value && !v.includes(":") ? v + ":" + it.sel.value : v;
}).filter((s) => s !== "");
box.setFirst = (v) => { if (!list.length) addRow(v); else if (!list[0].inp.value) list[0].inp.value = v; };
return box;
}
export const ADDRESS_ORDERS = ["sequential", "reverse", "random"];
// poolText shows a pool, with its own address order.
export function poolText(p) {
return p.start + "-" + p.end + (p.order ? " (" + p.order + ")" : "");
}
// orderText says how a subnet hands out new addresses, or "" for the
// defaults.
export function orderText(s) {
const parts = [];
if (s.pool_order === "random") parts.push("a pool at random");
if (s.address_order) parts.push(s.address_order + " addresses");
return parts.join(", ");
}
// ---- the subnet form
// v4 helpers for suggestions.
@@ -163,6 +189,8 @@ export async function subnetForm(sn, settings) {
const ifaceOther = input({ value: sn && !ifaceNames.includes(sn.interface) ? sn.interface : "", placeholder: "eth0.30 (one that comes up later)" });
const prefix = input({ value: sn ? sn.prefix : "", placeholder: "10.0.10.0/24", class: "mono" });
const pools = poolsEditor(sn ? sn.pools : []);
const poolOrder = select([["", "in the order listed"], ["random", "at random (weighted by free addresses)"]], sn ? sn.pool_order || "" : "");
const addrOrder = select([["", "the settings' (" + (settings.address_order || "sequential") + ")"], ...ADDRESS_ORDERS.map((o) => [o, o])], sn ? sn.address_order || "" : "");
const lease = input({ value: sn ? fmtSeconds(sn.lease_time) : "", placeholder: "the default: " + fmtSeconds(settings.lease_time || 43200) });
const opts = await optionsEditor(sn ? sn.options : [], settings.options);
const comment = input({ value: sn ? sn.comment : "" });
@@ -192,6 +220,8 @@ export async function subnetForm(sn, settings) {
field("Interface", h("div", null, ifaceSel, " ", ifaceOther)), ifaceHint,
field("Network", prefix, "The subnet's prefix; the mask clients get comes from it."),
field("Pools", pools, "Ranges handed out to any client: FIRST-LAST, or a prefix. Reserved addresses inside a pool are never handed out to others."),
field("Pool order", poolOrder, "Which pool a new client's address comes from."),
field("Address order", addrOrder, "Which address of a pool: sequential (the lowest), reverse (the highest) or random. A pool can set its own. New clients get an address never handed out before; when there are none, the one free the longest. Returning clients keep theirs."),
field("Lease time", lease, "12h, 1d, 30m; empty for the default."),
field("Options", opts),
field("Comment", comment),
@@ -199,7 +229,7 @@ export async function subnetForm(sn, settings) {
grid.values = () => {
const lt = parseSeconds(lease.value);
if (lt === null) throw new Error("Lease time: write it as 12h, 1d or 30m");
const v = { name: name.value.trim(), interface: ifaceValue(), prefix: prefix.value.trim(), pools: pools.value(), lease_time: lt, options: opts.value(), comment: comment.value };
const v = { name: name.value.trim(), interface: ifaceValue(), prefix: prefix.value.trim(), pools: pools.value(), pool_order: poolOrder.value || "listed", address_order: addrOrder.value || "default", lease_time: lt, options: opts.value(), comment: comment.value };
if (sn) v.disabled = disabled.input.checked;
return v;
};