Files
pxe-server/webapp/templates/updates_ipxe.html
cproudlock 397063f987 Webapp: Updates pages, image page tabs with review-before-save, BPRT sync
Updates group:
- Dell drivers and BIOS: check Dell's catalogs against all three driver maps
  and models.txt, download with a shared speed cap, repack, preview, apply
  with backups and rollback.
- iPXE boot files: check GitHub for the signed Secure Boot set, verify the
  sha256 and each file's signer, install atomically, roll back. Includes the
  by-hand procedure.
- Blancco: upload a release, stage it, test-boot it from the menu, activate,
  roll back.
- BPRT sync: validate a batch uploaded from the MediaCreator Lite cache
  (scripts/windows/Sync-BPRT.ps1 or a zip), show the diff, swap with the site
  overlay files kept.

Images: one sidebar entry per image with Overview, Drivers, Unattend and OS and
Packages tabs. Every save shows a diff first, keeps the file's format, makes a
backup and can be undone. Driver lint runs inline.

Enrollment: upload standard/engineer MCL packages, one per tenant/purpose,
with archive and restore. Hardware coverage counts engineer as Win11.
2026-10-13 16:58:55 -04:00

346 lines
22 KiB
HTML

{% extends "base.html" %}
{% block title %}iPXE Boot Files - PXE Server Manager{% endblock %}
{% macro sig_badge(info, chain_ok=none) -%}
{% if info.kind == 'microsoft-2011' %}<span class="badge badge-warning" title="Signed only under Microsoft Corporation UEFI CA 2011. That CA's signing period ended in 2026: machines that trust only the 2023 CA, or a DBX/SBAT revocation update, will refuse this file.">Microsoft UEFI CA 2011 only</span>
{% elif info.kind == 'microsoft-2023' %}<span class="badge badge-success" title="Signed under Microsoft UEFI CA 2023">Microsoft UEFI CA 2023</span>
{% elif info.kind == 'microsoft' %}<span class="badge badge-info">Microsoft (other CA)</span>
{% elif info.kind == 'ipxe' %}<span class="badge badge-success" title="Chains to the iPXE Secure Boot CA, which the iPXE shim trusts">iPXE Secure Boot CA</span>
{% elif info.kind == 'bios' %}<span class="badge badge-secondary" title="Legacy BIOS has no Secure Boot">Legacy BIOS, unsigned</span>
{% elif info.kind == 'unsigned' %}<span class="badge badge-danger" title="Secure Boot firmware will refuse it">Unsigned</span>
{% elif info.kind == 'error' %}<span class="badge badge-danger" title="{{ info.sig_error }}">Not checked</span>
{% else %}<span class="badge badge-warning">Other signer</span>{% endif %}
{% if chain_ok == false %}<span class="badge badge-danger" title="The installed shim trusts {{ info.trusts_needed }}; this file is not signed under it, so the shim will refuse to start it">Shim will refuse</span>{% endif %}
{%- endmacro %}
{% macro chain(info) -%}
{% if info.signers %}
<details class="mt-1">
<summary class="small text-muted" style="cursor:pointer;">Signer chain ({{ info.signers | length }})</summary>
{% for subject, issuer in info.signers %}
<div class="small"><span class="mono">{{ subject }}</span> <span class="text-muted">issued by</span> <span class="mono">{{ issuer }}</span></div>
{% endfor %}
</details>
{% endif %}
{% if info.trusts %}<div class="small text-muted">Starts second stages signed by <span class="mono">{{ info.trusts }}</span></div>{% endif %}
{% if info.sbat %}<div class="small text-muted">SBAT: <span class="mono">{{ info.sbat }}</span></div>{% endif %}
{%- endmacro %}
{% block content %}
<div class="page-header">
<h1><i class="bi bi-shield-lock"></i> iPXE Boot Files</h1>
<div class="header-actions">
{% if status == 'update' %}<span class="badge badge-warning badge-lg">Update available: {{ latest.tag }}</span>
{% elif status == 'current' %}<span class="badge badge-success badge-lg">Up to date ({{ latest.tag }})</span>
{% elif status == 'newer' %}<span class="badge badge-info badge-lg">Ahead of {{ latest.tag }}</span>
{% elif latest %}<span class="badge badge-secondary badge-lg">Latest: {{ latest.tag }}</span>{% endif %}
<form method="post" action="{{ url_for('ipxe.check') }}" style="display:inline;">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="pxe-btn pxe-btn-secondary"><i class="bi bi-arrow-repeat"></i> Check for updates</button>
</form>
</div>
</div>
<p class="text-muted">
The Secure Boot chain that dnsmasq hands out over TFTP: the Microsoft-signed shim (<span class="mono">ipxe-shim.efi</span>)
starts <span class="mono">ipxe.efi</span>, which is signed by the iPXE Secure Boot CA. Updates come from the upstream
iPXE release tarball on GitHub; its sha256 is checked against GitHub's published digest and each file's signer chain is
read with <span class="mono">sbverify</span> before it can be installed. <span class="mono">ipxe-bcm.efi</span>,
<span class="mono">dnsmasq.conf</span> and the Blancco files are never changed here.
</p>
{# ---- Upstream ------------------------------------------------------------ #}
<div class="section-card">
<div class="section-title"><i class="bi bi-cloud-arrow-down"></i> Upstream release</div>
<div class="row g-3 align-items-end">
<div class="col-lg-7">
{% if latest %}
<div>
<strong class="mono">{{ latest.tag }}</strong>
{% if latest.name and latest.name != latest.tag %}<span class="text-muted">{{ latest.name }}</span>{% endif %}
{% if status == 'update' %}<span class="badge badge-warning">newer than installed</span>
{% elif status == 'current' %}<span class="badge badge-success">installed</span>
{% elif status == 'newer' %}<span class="badge badge-info">installed build is newer</span>
{% else %}<span class="badge badge-secondary">installed version unknown</span>{% endif %}
</div>
<div class="small text-muted">
Published {{ latest.published | local_dt }} | checked {{ latest.checked | local_dt }}
{% if latest.size %}| {{ "%.1f"|format(latest.size / 1048576) }} MB{% endif %}
| <a href="{{ latest.page }}" target="_blank" rel="noopener">release page</a>
</div>
<div class="small">sha256 <span class="mono" style="white-space: normal; word-break: break-all;">{{ latest.sha256 or 'not published' }}</span></div>
{% else %}
<p class="text-muted mb-0">Not checked yet. <strong>Check for updates</strong> asks the GitHub API for the latest release (a few seconds).</p>
{% endif %}
</div>
<div class="col-lg-5 d-flex flex-wrap gap-2 justify-content-lg-end align-items-end">
<form method="post" action="{{ url_for('ipxe.save_settings') }}" class="d-flex gap-2 align-items-end">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<div class="form-field mb-0">
<label for="maxMbps" class="form-label">Speed cap (MB/s)</label>
<input type="number" class="form-control form-control-sm" id="maxMbps" name="max_mbps"
min="0" max="1000" step="0.5" value="{{ '%g'|format(settings.max_mbps) }}" style="width: 7rem;">
</div>
<button type="submit" class="pxe-btn pxe-btn-ghost pxe-btn-sm" title="Shared by every download on the Updates pages; 0 = no cap. The site link is about 400 Mbps (50 MB/s).">Save</button>
</form>
<form method="post" action="{{ url_for('ipxe.stage') }}">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="pxe-btn pxe-btn-primary"
{% if not latest or not latest.sha256 or job.state in ('queued', 'running') %}disabled{% endif %}>
<i class="bi bi-download"></i> Download and verify
</button>
</form>
</div>
</div>
<div class="form-text">Speed cap applies to every download on the Updates pages (Dell and iPXE); 0 means no cap.</div>
{% if job.state %}
<div id="ipxeJob" class="mt-3" data-state="{{ job.state }}">
{% if job.state in ('queued', 'running') %}
<div class="d-flex align-items-center gap-2 mb-1">
<span class="spinner-border spinner-border-sm" role="status"></span>
<span id="ipxeJobText">{{ 'Starting' if job.state == 'queued' else (job.step | capitalize) }} {{ job.tag }}...</span>
</div>
<div class="progress" style="height: 0.5rem;">
<div id="ipxeJobBar" class="progress-bar" style="width: {{ job.percent or 0 }}%;"></div>
</div>
{% elif job.state == 'error' %}
<div class="alert alert-danger mb-0"><strong>Download of {{ job.tag }} failed:</strong> <span class="mono">{{ job.error }}</span></div>
{% elif job.state == 'interrupted' %}
<div class="alert alert-warning mb-0">The download of {{ job.tag }} stopped without finishing (server restart?). Start it again; it resumes the partial file.</div>
{% elif job.state == 'done' and job.failed %}
<div class="alert alert-warning mb-0">{{ job.tag }} downloaded, but {{ job.failed | join(', ') }} failed verification; see below.</div>
{% endif %}
</div>
{% endif %}
</div>
{# ---- Installed ----------------------------------------------------------- #}
<div class="section-card">
<div class="section-title"><i class="bi bi-hdd"></i> Installed on the TFTP root</div>
<div class="table-container">
<table class="data-table">
<thead>
<tr><th>File</th><th>Version</th><th>Size</th><th>Modified</th><th>sha256</th><th>Signature</th></tr>
</thead>
<tbody>
{% for r in rows %}
{% set i = r.info %}
<tr>
<td class="text-nowrap">
<span class="mono">{{ r.name }}</span>
{% if not r.managed %}<div class="small text-muted">read-only here</div>{% endif %}
</td>
{% if i %}
<td class="mono">{{ i.version or '-' }}{% if i.release %}<div class="small text-muted">from {{ i.release }}</div>{% endif %}</td>
<td class="text-nowrap">{{ "{:,}".format(i.size) }} B</td>
<td class="mono text-nowrap">{{ i.modified | timestamp_fmt }}</td>
<td class="mono" title="{{ i.sha256 }}">{{ i.sha256[:12] }}</td>
<td class="wrap">{{ sig_badge(i, i.chain_ok if i.chain_ok is defined else none) }}{{ chain(i) }}</td>
{% else %}
<td colspan="5" class="text-muted">Not present</td>
{% endif %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{# ---- Staged -------------------------------------------------------------- #}
{% if staged %}
<div class="section-card">
<div class="section-title"><i class="bi bi-box-seam"></i> Downloaded and verified</div>
{% for s in staged %}
<form method="post" action="{{ url_for('ipxe.install') }}" class="{{ 'mt-4' if not loop.first }}" data-tag="{{ s.tag }}"
onsubmit="var n=[].slice.call(this.querySelectorAll('input[name=names]:checked')).map(function(c){return c.value;}); if(!n.length){alert('Select at least one file.');return false;} return confirm('Install ' + n.join(', ') + ' from ' + this.dataset.tag + ' into the TFTP root? The current files are backed up first and every client that boots from now on gets the new ones.');">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="tag" value="{{ s.tag }}">
<div class="d-flex flex-wrap align-items-center gap-2 mb-2">
<strong class="mono">{{ s.tag }}</strong>
<span class="small text-muted">verified {{ s.staged | local_dt }} | tarball sha256 <span class="mono" title="{{ s.tarball_sha256 }}">{{ (s.tarball_sha256 or '')[:12] }}</span> matches GitHub</span>
</div>
<div class="table-container">
<table class="data-table">
<thead>
<tr><th style="width:2rem;"></th><th>File</th><th>From tarball</th><th>Version</th><th>sha256</th><th>Check</th></tr>
</thead>
<tbody>
{% for name, f in s.files.items() %}
<tr>
<td><input type="checkbox" class="form-check-input" name="names" value="{{ name }}" id="inst-{{ loop.index }}-{{ s.tag }}"
{% if not f.ok %}disabled{% elif name in default_install %}checked{% endif %}></td>
<td class="text-nowrap"><label for="inst-{{ loop.index }}-{{ s.tag }}" class="mono">{{ name }}</label>
{% if name not in default_install %}<div class="small text-muted">optional</div>{% endif %}</td>
<td class="mono small">{{ f.source or '-' }}</td>
<td class="mono">{{ f.version or '-' }}</td>
<td class="mono" title="{{ f.sha256 }}">{{ (f.sha256 or '')[:12] }}
{% if f.sha256 and f.sha256 == installed_sha[name] %}<div class="small text-muted">same as installed</div>{% endif %}</td>
<td class="wrap">
{% if f.ok %}<span class="badge badge-success">OK</span> {% if f.kind %}{{ sig_badge(f) }}{% endif %}
{% else %}<span class="badge badge-danger">Failed</span> <div class="small">{{ f.error }}</div>{% endif %}
{% if f.signers is defined %}{{ chain(f) }}{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="d-flex flex-wrap align-items-center gap-2 mt-2">
<button type="submit" class="pxe-btn pxe-btn-primary"><i class="bi bi-box-arrow-in-down"></i> Install selected</button>
<span class="form-text">ipxe-shim.efi is only installed together with ipxe.efi from the same release. ipxe.pxe is for legacy BIOS clients.</span>
</div>
</form>
{% endfor %}
</div>
{% endif %}
{# ---- dnsmasq routing ----------------------------------------------------- #}
<div class="section-card">
<div class="section-title"><i class="bi bi-diagram-3"></i> Who gets which file (dnsmasq, read-only)</div>
{% if routing.boots %}
<div class="table-container">
<table class="data-table">
<thead><tr><th>Boot file</th><th>Requires tag</th><th>Unless tagged</th><th>Clients</th></tr></thead>
<tbody>
{% for b in routing.boots %}
<tr>
<td class="mono">{{ b.file }}
{% if b.managed %}<span class="badge badge-info">updated here</span>{% elif b.read_only %}<span class="badge badge-secondary">never touched</span>
{% elif b.other_copy %}<span class="badge badge-warning" title="This page installs into the TFTP root only; this copy in a subfolder is not updated">other copy, not updated here</span>{% endif %}</td>
<td class="mono">{{ b.need | join(', ') or '-' }}</td>
<td class="mono">{{ b.exclude | join(', ') or '-' }}</td>
<td class="wrap">
{% if b.macs %}<span class="mono">{{ b.macs | join(', ') }}</span>
{% elif b.need %}<span class="text-muted">clients matching {{ b.need | join(' and ') }}</span>
{% else %}<span class="text-muted">every client not excluded</span>{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<details class="mt-2">
<summary class="eyebrow" style="cursor:pointer;">Relevant dnsmasq lines ({{ routing.lines | length }})</summary>
<pre class="mono mb-0 mt-2" style="white-space: pre-wrap; background: var(--bg); padding: 0.75rem; border-radius: 0.25rem;">{% for l in routing.lines %}{{ l }}
{% endfor %}</pre>
</details>
{% else %}
<p class="text-muted mb-0">No dhcp-boot lines found in the dnsmasq configuration.</p>
{% endif %}
</div>
{# ---- Backups ------------------------------------------------------------- #}
<div class="section-card">
<div class="section-title"><i class="bi bi-clock-history"></i> Backups</div>
{% if backups %}
<div class="table-container">
<table class="data-table">
<thead><tr><th>Backup</th><th>Reason</th><th>Files</th><th class="text-end"></th></tr></thead>
<tbody>
{% for b in backups %}
<tr>
<td class="mono text-nowrap">{{ b.id }}</td>
<td>{{ b.reason or '-' }}</td>
<td>
{% for f in b.files %}<div class="small"><span class="mono">{{ f }}</span> <span class="text-muted">{{ b.versions[f] or '' }}</span></div>{% endfor %}
{% if not b.files %}<span class="text-muted">empty</span>{% endif %}
</td>
<td class="text-end text-nowrap">
{% if b.files %}
{# The id is a folder name (hand-made backups too): keep it out of the JS source. #}
<form method="post" action="{{ url_for('ipxe.rollback') }}" style="display:inline;"
data-confirm="Put back {{ b.files | join(', ') }} from backup {{ b.id }}? The current files are backed up first."
onsubmit="return confirm(this.dataset.confirm);">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="backup" value="{{ b.id }}">
<button type="submit" class="pxe-btn pxe-btn-ghost btn-row-action"><i class="bi bi-arrow-counterclockwise"></i> Rollback</button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-muted mb-0">No backups yet. One is made automatically before every install and rollback.</p>
{% endif %}
</div>
{# ---- How-to -------------------------------------------------------------- #}
<div class="section-card">
<div class="section-title"><i class="bi bi-journal-text"></i> How to find and install the latest signed build by hand</div>
<p>Use this when the buttons above cannot reach GitHub, or to check what they do. All commands run on the PXE server as root.</p>
<ol>
<li class="mb-3"><strong>Find the release.</strong> The latest release is at
<a href="{{ releases_page }}" target="_blank" rel="noopener" class="mono">{{ releases_page }}</a>. The signed boot files are the
asset <span class="mono">ipxeboot.tar.gz</span>; GitHub shows its sha256 next to the asset (or ask the API:
<span class="mono">curl -s https://api.github.com/repos/ipxe/ipxe/releases/latest | grep -B2 -A2 '"digest"'</span>).</li>
<li class="mb-3"><strong>Download and check the sha256.</strong> It must equal the digest on the release page.
<pre class="mono mt-2 mb-1" style="white-space: pre-wrap; background: var(--bg); padding: 0.75rem; border-radius: 0.25rem;">cd /tmp
curl -fLO {{ tarball_url }}
sha256sum ipxeboot.tar.gz</pre>
<span class="small text-muted">GitHub goes through Zscaler. A certificate error from curl means the Zscaler root CA is missing from
<span class="mono">/usr/local/share/ca-certificates/</span> (the playbook installs it).</span></li>
<li class="mb-3"><strong>Unpack.</strong> The Secure Boot files are in <span class="mono">ipxeboot/x86_64-sb/</span>:
<span class="mono">shimx64.efi</span> (the shim), <span class="mono">ipxe.efi</span> and <span class="mono">snponly.efi</span>.
<span class="mono">ipxe-shim.efi</span> and <span class="mono">snponly-shim.efi</span> there are only symlinks to
<span class="mono">shimx64.efi</span>. The legacy BIOS build is <span class="mono">ipxeboot/x86_64/ipxe.pxe</span>.
<pre class="mono mt-2 mb-0" style="white-space: pre-wrap; background: var(--bg); padding: 0.75rem; border-radius: 0.25rem;">tar xzf ipxeboot.tar.gz
ls -l ipxeboot/x86_64-sb/</pre></li>
<li class="mb-3"><strong>Check the signatures.</strong>
<pre class="mono mt-2 mb-1" style="white-space: pre-wrap; background: var(--bg); padding: 0.75rem; border-radius: 0.25rem;">sbverify --list ipxeboot/x86_64-sb/shimx64.efi
sbverify --list ipxeboot/x86_64-sb/ipxe.efi</pre>
The shim must be issued by <span class="mono">CN=Microsoft Corporation UEFI CA 2011</span> or, on a newer build,
<span class="mono">CN=Microsoft UEFI CA 2023</span> (prefer a 2023-signed shim when upstream ships one).
<span class="mono">ipxe.efi</span> must show <span class="mono">CN=iPXE Secure Boot Intermediate ...</span> issued by
<span class="mono">CN=iPXE Secure Boot CA</span>. The shim's <span class="mono">warning: data remaining ... gaps between PE/COFF sections?</span>
is normal. Do not rely on <span class="mono">sbverify --cert</span>: it reports OK even for the wrong certificate.</li>
<li class="mb-3"><strong>Back up the current files.</strong> A backup made in this folder also shows in the Backups list above, with Rollback.
<pre class="mono mt-2 mb-0" style="white-space: pre-wrap; background: var(--bg); padding: 0.75rem; border-radius: 0.25rem;">B=/var/lib/pxe-webapp/updates/ipxe/backups/$(date +%Y%m%d-%H%M%S)
mkdir -p "$B" &amp;&amp; cp -p /srv/tftp/ipxe-shim.efi /srv/tftp/ipxe.efi "$B"/</pre></li>
<li class="mb-3"><strong>Install both files, from the same release.</strong> The shim only starts an <span class="mono">ipxe.efi</span> signed under the
CA it carries, and it always loads <span class="mono">ipxe.efi</span> from the TFTP root, so the name must stay
<span class="mono">ipxe.efi</span>. Copy to a temporary name and rename, so a client that is booting never reads half a file:
<pre class="mono mt-2 mb-1" style="white-space: pre-wrap; background: var(--bg); padding: 0.75rem; border-radius: 0.25rem;">install -m 0644 ipxeboot/x86_64-sb/ipxe.efi /srv/tftp/.ipxe.efi.new &amp;&amp; mv -f /srv/tftp/.ipxe.efi.new /srv/tftp/ipxe.efi
install -m 0644 ipxeboot/x86_64-sb/shimx64.efi /srv/tftp/.ipxe-shim.efi.new &amp;&amp; mv -f /srv/tftp/.ipxe-shim.efi.new /srv/tftp/ipxe-shim.efi</pre>
<span class="small text-muted">No dnsmasq restart is needed. Never replace <span class="mono">ipxe-bcm.efi</span>; it serves every other UEFI client.</span></li>
<li class="mb-3"><strong>Who gets it.</strong> dnsmasq decides, in <span class="mono">/etc/dnsmasq.conf</span>: clients listed as
<span class="mono">dhcp-host=&lt;mac&gt;,set:ipxe2</span> get the shim through
<span class="mono">dhcp-boot=tag:ipxe2,tag:!ipxe,tag:!bios,ipxe-shim.efi</span>; every other UEFI client gets
<span class="mono">ipxe-bcm.efi</span> through <span class="mono">dhcp-boot=tag:!bios,tag:!ipxe2,ipxe-bcm.efi</span>. The table above shows the
live lines. Test on one tagged machine before tagging more.</li>
<li class="mb-3"><strong>Roll back.</strong> Use Rollback above, or copy the backed-up files back the same way (temporary name, then
<span class="mono">mv -f</span>).</li>
<li><strong>Normal log noise.</strong> While booting, the shim asks TFTP for optional files such as
<span class="mono">revocations_*.efi</span> and <span class="mono">shim_certificate_0.efi</span>. The "not found" lines for them in the
dnsmasq log are expected and harmless.</li>
</ol>
</div>
{% if job.state in ('queued', 'running') %}
<script>
// Poll the background download; reload once it finishes to show the result.
(function () {
var URL = "{{ url_for('ipxe.status') }}";
var text = document.getElementById('ipxeJobText');
var bar = document.getElementById('ipxeJobBar');
function tick() {
fetch(URL, { credentials: 'same-origin' })
.then(function (r) { return r.json(); })
.then(function (j) {
if (j.state !== 'queued' && j.state !== 'running') { location.reload(); return; }
var step = j.step === 'download' ? 'Downloading' : j.step === 'verify' ? 'Verifying' : 'Starting';
if (text) text.textContent = step + ' ' + (j.tag || '') + (j.step === 'download' && j.percent != null ? ' (' + j.percent + '%)' : '') + '...';
if (bar) bar.style.width = (j.step === 'verify' ? 100 : (j.percent || 0)) + '%';
setTimeout(tick, 2000);
})
.catch(function () { setTimeout(tick, 5000); });
}
setTimeout(tick, 1500);
})();
</script>
{% endif %}
{% endblock %}