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

+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;
};