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:
co-authored by
Claude Opus 4.8
parent
7f25bb681c
commit
3a32d65fb7
+98
-1
@@ -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'),
|
||||
|
||||
Reference in New Issue
Block a user