v0.7.0: Secure Boot chain, boot rules + decision webhook, tokenized answer files

Three features, all zero-toggle and principle-clean (single static musl
binary, container-first, no test certs, no client trust-store changes).

Secure Boot via signed shim+GRUB (automatic):
- The v0.6.1 escalation ladder gains a third rung: Firmware -> Builtin
  -> Shim. Secure-Boot firmware downloads our unsigned iPXE but refuses
  to execute it — indistinguishable from a failed chainload — so after
  two unconfirmed attempts the MAC is offered Fedora's Microsoft-signed
  shimx64.efi, which loads the signed GRUB, which fetches a
  server-rendered grub.cfg. Fully signed chain, SB stays on.
- scripts/fetch-shim.sh pulls shim-x64/grub2-efi-x64 (+aa64 best-effort)
  from the official Fedora 43 packages and ships the EFI binaries
  byte-for-byte unmodified; Dockerfile fetch stage gained rpm2cpio/cpio.
- New grub_script renderer (Linux kernel entries only — signed GRUB only
  boots signed kernels; sanboot/wimboot have no signed equivalent and
  are omitted with an explanatory menu line).
- TFTP server gains a DynamicAsset hook for server-rendered names
  (grub.cfg); HTTP serves the same config under /ipxe/grub.cfg for
  native UEFI HTTP Boot chains. Arch-aware fallback walks back down the
  ladder where no shim exists (BIOS, IA32).

Boot rules + decision webhook (open 'Matrix Boot'):
- Ordered first-match-wins rules over MAC prefix + client arch (the DHCP
  proxy now bakes arch into the boot.ipxe chain URL), generalizing
  per-MAC pins. Persisted to boot_rules.json; GET/PUT /api/boot-rules;
  rules editor + webhook field on the Hosts tab.
- Optional pixiecore-style webhook: unmatched boots GET
  <url>?mac=&arch= and 200 {"target":"id"} chains to it. Fail-open
  with a 2s budget — a dead endpoint can never block PXE.
- Decision order: exact pin -> rules -> webhook -> menu. Empty config
  is byte-for-byte the previous behavior.

Tokenized answer files (the post-WDS/CVE-2026-0386 hardening):
- Every generated unattended URL (inst.ks / preseed url / autoinstall
  seed) now carries a 4h boot-scoped token; /unattended/{id} and the
  cloud-init seed routes require it (or an operator session) once an
  admin exists. Stops answer-file credential harvesting by anything
  else on the network. No toggle; setup-mode installs stay open.

Validation: clippy clean, fmt clean, 290 workspace tests green
(+18 new across boot_tokens, boot_rules, arch ladder, escalation,
grub renderer, and four new full-flow integration tests).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
Miles Ward
2026-06-09 20:17:18 -04:00
co-authored by Claude Opus 4.8
parent 7f25bb681c
commit 3a32d65fb7
21 changed files with 1396 additions and 68 deletions
+98 -1
View File
@@ -199,6 +199,101 @@
})[k] || (k || 'Unknown');
}
// v0.7.0: the Boot rules card — ordered first-match-wins rules
// (MAC prefix / architecture → target) plus the optional
// boot-decision webhook. Saved as one config because rule order
// matters. With no rules and no webhook, behavior is identical to
// before the feature existed.
function bootRulesCard(cfg, targetOptions) {
const archChoices = [
['', 'any arch'], ['bios', 'BIOS'], ['uefi-x64', 'UEFI x64'],
['uefi-ia32', 'UEFI IA32'], ['uefi-arm64', 'UEFI ARM64'],
];
const rules = (cfg.rules || []).map(r => Object.assign({}, r));
const tbody = el('tbody', {});
const msg = el('div', {class:'msg'});
const webhookInput = el('input', {type:'text', spellcheck:'false',
placeholder:'http://automation.example/boot-decision (optional)',
value: cfg.webhook_url || ''});
const targetSelect = (val) => el('select', {},
[el('option', {value:''}, '— target —')]
.concat(targetOptions.map(t =>
el('option', Object.assign({value: t.id}, t.id === val ? {selected:''} : {}), t.title))));
const redraw = () => {
tbody.innerHTML = '';
if (!rules.length) {
tbody.appendChild(el('tr', {}, el('td', {colspan:'6', class:'empty', style:'padding:14px'},
'No rules. Add one to route whole groups of machines (an OUI, an architecture) to a target.')));
}
rules.forEach((r, i) => {
const macIn = el('input', {type:'text', spellcheck:'false', placeholder:'aa:bb:cc (prefix)',
value: r.mac_prefix || '', oninput: e => { r.mac_prefix = e.target.value; }});
const archSel = el('select', {onchange: e => { r.arch = e.target.value; }},
archChoices.map(([v, label]) =>
el('option', Object.assign({value: v}, v === (r.arch || '') ? {selected:''} : {}), label)));
const tgtSel = targetSelect(r.target || '');
tgtSel.onchange = e => { r.target = e.target.value; };
const noteIn = el('input', {type:'text', placeholder:'note',
value: r.note || '', oninput: e => { r.note = e.target.value; }});
const enabled = el('input', {type:'checkbox', onchange: e => { r.enabled = e.target.checked; }});
enabled.checked = r.enabled !== false;
tbody.appendChild(el('tr', {}, [
el('td', {}, macIn),
el('td', {}, archSel),
el('td', {}, tgtSel),
el('td', {}, noteIn),
el('td', {style:'text-align:center'}, enabled),
el('td', {style:'text-align:right'},
el('button', {class:'danger', onclick: () => { rules.splice(i, 1); redraw(); }}, '✕')),
]));
});
};
redraw();
const addBtn = el('button', {class:'ghost', onclick: () => {
rules.push({mac_prefix:'', arch:'', target:'', enabled:true, note:''});
redraw();
}}, '+ Add rule');
const saveBtn = el('button', {onclick: async () => {
const bad = rules.find(r => r.enabled !== false && !r.target);
if (bad) { msg.textContent = 'Every enabled rule needs a target.'; msg.className = 'msg err'; return; }
const r = await putJSON('/api/boot-rules', {rules, webhook_url: webhookInput.value.trim()});
if (r.ok) { msg.textContent = 'Saved.'; msg.className = 'msg ok'; }
else { msg.textContent = 'Save failed: ' + await r.text(); msg.className = 'msg err'; }
}}, 'Save rules');
return el('div', {class:'card'}, [
el('header', {}, [
el('h2', {}, 'Boot rules'),
el('span', {class:'sub'}, 'first match wins · checked top to bottom'),
]),
el('div', {class:'body'}, [
el('table', {}, [
el('thead', {}, el('tr', {}, [
el('th',{},'MAC prefix'), el('th',{},'Arch'), el('th',{},'Target'),
el('th',{},'Note'), el('th',{},'On'), el('th',{},''),
])),
tbody,
]),
el('div', {style:'margin-top:12px'}, [addBtn, saveBtn]),
el('label', {class:'field', style:'margin-top:16px;display:block'}, [
el('span', {class:'name'}, 'Boot-decision webhook (optional)'),
webhookInput,
el('span', {class:'hint'},
'When no pin or rule matches, OpenPXE GETs this URL with ?mac=…&arch=… ' +
'A 200 reply of {"target": "<entry-id>"} chains to that target; anything ' +
'else (404, timeout, error) falls through to the menu — a dead endpoint ' +
'can never block PXE.'),
]),
msg,
el('p', {class:'msg', style:'margin-top:10px'},
'Decision order per boot: exact MAC pin → first matching rule → webhook → interactive menu.'),
]),
]);
}
// v0.5.2: build the shared "deployment profile" field group — auto
// hostname, auto IP, and an unattended-file picker — reused by the
// Hosts pin form and the Queue "Profile" modal. `files` is the
@@ -1252,10 +1347,11 @@
},
hosts: async () => {
const [{ hosts = [] }, isos, bootLogRes, unattRes] = await Promise.all([
const [{ hosts = [] }, isos, bootLogRes, unattRes, rulesCfg] = await Promise.all([
getJSON('/api/hosts'), getJSON('/api/isos'),
getJSON('/api/boot-log').catch(() => ({ events: [] })),
getJSON('/api/unattended').catch(() => ({ files: [] })),
getJSON('/api/boot-rules').catch(() => ({ rules: [], webhook_url: '' })),
]);
const bootEvents = bootLogRes.events || [];
const unattendedFiles = unattRes.files || [];
@@ -1381,6 +1477,7 @@
]),
table,
]),
bootRulesCard(rulesCfg, reserved.concat(targets)),
el('div', {class:'card'}, [
el('header', {}, [
el('h2', {}, 'Host log'),