Staging fetches ipxe-shimx64.efi from the ipxe/shim release (sha256 from GitHub), and uses it instead of the tarball's 2011-only shim when it is signed under the Microsoft UEFI CA 2023 and trusts the same iPXE CA; otherwise the tarball shim stays, with the reason shown. snponly.efi and snponly-shim.efi are managed alongside ipxe.efi and ipxe-shim.efi, each iPXE build says whether it drives USB itself (kills USB keyboards), and tftp-unique-root per-MAC folders are listed read-only.
501 lines
33 KiB
HTML
501 lines
33 KiB
HTML
{% extends "base.html" %}
|
|
{% import '_ui.html' as ui %}
|
|
{% block title %}iPXE boot files - PXE Manager{% endblock %}
|
|
|
|
{# Signer as a short chip; the long explanation goes in the row body (it is
|
|
a safety warning, so never hover-only). #}
|
|
{% macro sig_chip(info) -%}
|
|
{% if info.kind == 'microsoft-2011' %}{{ ui.chip('MS UEFI CA 2011 only', 'warn') }}
|
|
{% elif info.kind == 'microsoft-2023' %}{{ ui.chip('MS UEFI CA 2023', 'ok') }}
|
|
{% elif info.kind == 'microsoft' %}{{ ui.chip('Microsoft, other CA', 'neutral') }}
|
|
{% elif info.kind == 'ipxe' %}{{ ui.chip('iPXE Secure Boot CA', 'ok') }}
|
|
{% elif info.kind == 'bios' %}{{ ui.chip('Legacy BIOS', 'neutral') }}
|
|
{% elif info.kind == 'unsigned' %}{{ ui.chip('Unsigned', 'err') }}
|
|
{% elif info.kind == 'error' %}{{ ui.chip('Not checked', 'err') }}
|
|
{% else %}{{ ui.chip('Other signer', 'warn') }}{% endif %}
|
|
{%- endmacro %}
|
|
|
|
{% macro sig_text(info, chain_ok=none) -%}
|
|
{% if info.kind == 'microsoft-2011' %}<div class="notice">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.</div>
|
|
{% elif info.kind == 'microsoft-2023' %}<div class="sub">Signed under Microsoft UEFI CA 2023.</div>
|
|
{% elif info.kind == 'ipxe' %}<div class="sub">Chains to the iPXE Secure Boot CA, which the iPXE shim trusts.</div>
|
|
{% elif info.kind == 'bios' %}<div class="sub">Legacy BIOS has no Secure Boot; the file is unsigned by design.</div>
|
|
{% elif info.kind == 'unsigned' %}<div class="notice bad">Unsigned: Secure Boot firmware will refuse it.</div>
|
|
{% elif info.kind == 'error' %}<div class="notice bad">Signature not checked: {{ info.sig_error }}</div>
|
|
{% elif info.kind == 'microsoft' %}<div class="sub">Signed by Microsoft under a CA other than UEFI CA 2011 or 2023.</div>
|
|
{% elif info.kind %}<div class="notice">Signed by a signer that is neither Microsoft nor the iPXE CA.</div>{% endif %}
|
|
{% if chain_ok == false %}<div class="notice bad">Shim will refuse: the installed shim trusts <span class="mono">{{ info.trusts_needed }}</span>; this file is not signed under it, so the shim will not start it.</div>{% endif %}
|
|
{%- endmacro %}
|
|
|
|
{% macro chain(info) -%}
|
|
{% if info.signers %}
|
|
<div><span class="sub">Signer chain ({{ info.signers | length }})</span>
|
|
{% for subject, issuer in info.signers %}
|
|
<div><span class="mono">{{ subject }}</span> <span class="muted">issued by</span> <span class="mono">{{ issuer }}</span></div>
|
|
{% endfor %}
|
|
</div>
|
|
{% endif %}
|
|
{% if info.trusts %}<div class="sub">Starts second stages signed by <span class="mono">{{ info.trusts }}</span></div>{% endif %}
|
|
{% if info.sbat %}<div class="sub">SBAT: <span class="mono">{{ info.sbat }}</span></div>{% endif %}
|
|
{% if info.drivers == 'native' %}<div class="notice"><span>This iPXE build drives USB itself: it takes the USB controller from the firmware, so a USB keyboard stops working at the iPXE menu. <span class="mono">snponly.efi</span> uses the firmware's drivers instead.</span></div>
|
|
{% elif info.drivers == 'firmware' %}<div class="sub">Uses the firmware's network and USB drivers, so USB keyboards keep working.</div>{% endif %}
|
|
{%- endmacro %}
|
|
|
|
{% block extra_head %}
|
|
<style>
|
|
.sec-h { display: flex; flex-wrap: wrap; align-items: flex-end; justify-content: space-between; gap: 8px 16px; padding: 18px 24px 12px; }
|
|
.sec-h .sub { margin-top: 2px; }
|
|
summary.sec-h { flex-wrap: nowrap; }
|
|
.state-line { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; margin-top: 6px; }
|
|
.strip { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 14px; padding: 12px 20px; font-size: 13px; }
|
|
.pulse { width: 10px; height: 10px; border-radius: 5px; background: var(--run); flex: none; animation: pulse 1.4s ease-in-out infinite; }
|
|
@keyframes pulse { 50% { opacity: .35; } }
|
|
@media (prefers-reduced-motion: reduce) { .pulse { animation: none; } }
|
|
.bar { flex: 1 1 160px; height: 6px; border-radius: 3px; background: var(--track-empty); overflow: hidden; min-width: 60px; max-width: 360px; }
|
|
.bar > div { height: 100%; background: var(--run); }
|
|
.igrid { display: grid; grid-template-columns: 190px minmax(0, 1fr) 190px 14px; align-items: center; gap: 0 16px;
|
|
padding: 12px 24px; font-size: 14px; min-height: 24px; }
|
|
.igrid.picked { padding-left: 56px; }
|
|
.ihead { padding-top: 4px; padding-bottom: 6px; font-size: 12px; }
|
|
.ibody { padding: 2px 24px 16px 24px; font-size: 13px; display: flex; flex-direction: column; gap: 8px; }
|
|
.picked + .ibody, .ibody.picked { padding-left: 56px; }
|
|
.kv { display: grid; grid-template-columns: 120px minmax(0, 1fr); gap: 4px 16px; margin: 0; }
|
|
.kv dt { font-weight: 400; color: var(--muted); }
|
|
.kv dd { margin: 0; min-width: 0; overflow-wrap: anywhere; }
|
|
.i-row > .pick { position: absolute; left: 20px; top: 13px; z-index: 1; }
|
|
.i-row > .pick input { width: 18px; height: 18px; margin: 0; }
|
|
.name { display: flex; align-items: baseline; gap: 8px; min-width: 0; white-space: nowrap; }
|
|
.igrid .name { overflow: hidden; }
|
|
.rgrid { display: grid; grid-template-columns: 210px minmax(0, 1fr) 14px; align-items: center; gap: 0 16px; padding: 12px 24px; font-size: 14px; }
|
|
.bgrid { display: grid; grid-template-columns: 160px minmax(0, 1fr) minmax(0, 1fr) 14px; align-items: center; gap: 0 16px;
|
|
padding: 12px 80px 12px 24px; font-size: 14px; min-height: 24px; }
|
|
.list-foot { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 14px; padding: 14px 24px; border-top: 1px solid var(--line); }
|
|
.list-foot .sub { flex: 1 1 260px; }
|
|
.rel-h { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 12px; padding: 12px 24px; border-top: 1px solid var(--line-2); background: var(--surface-2); font-size: 13px; }
|
|
pre.cmd { white-space: pre-wrap; word-break: break-all; margin: 8px 0 4px; padding: 10px 12px; border-radius: 8px; background: var(--surface-2); border: 1px solid var(--line); font-size: 12px; color: var(--text); }
|
|
.howto .mono { overflow-wrap: anywhere; }
|
|
.howto { margin: 0; padding-left: 20px; display: flex; flex-direction: column; gap: 14px; font-size: 13px; line-height: 1.6; }
|
|
@container (max-width: 900px) {
|
|
.igrid { grid-template-columns: 205px minmax(0, 1fr) 170px 14px; }
|
|
.bgrid { grid-template-columns: 150px minmax(0, 1fr) 14px; }
|
|
.bgrid > :nth-child(3) { display: none; }
|
|
}
|
|
@container (max-width: 560px) {
|
|
.bgrid > :nth-child(3) { display: block; }
|
|
.igrid { grid-template-columns: minmax(0, 1fr) auto 14px; padding: 12px 16px; gap: 4px 10px; }
|
|
.igrid.picked { padding-left: 50px; }
|
|
.igrid .c-ver { grid-column: 1 / 2; grid-row: 2; font-size: 12px; color: var(--muted); }
|
|
.ihead { display: none; }
|
|
.ibody, .picked + .ibody { padding: 2px 16px 14px; }
|
|
.i-row > .pick { left: 16px; }
|
|
.kv { grid-template-columns: minmax(0, 1fr); }
|
|
.kv dd { margin-bottom: 6px; }
|
|
.rgrid { grid-template-columns: minmax(0, 1fr) 14px; padding: 12px 16px; gap: 4px 10px; }
|
|
.rgrid .c-who { grid-column: 1 / 2; grid-row: 2; }
|
|
.bgrid { grid-template-columns: minmax(0, 1fr) 14px; padding: 12px 70px 12px 16px; gap: 2px 10px; }
|
|
.bgrid > :nth-child(2), .bgrid > :nth-child(3) { grid-column: 1 / 2; }
|
|
.bgrid > .chev { grid-column: 2; grid-row: 1; }
|
|
.ui-row > .act { right: 12px; }
|
|
.sec-h, .list-foot, .rel-h { padding-left: 16px; padding-right: 16px; }
|
|
}
|
|
</style>
|
|
{% endblock %}
|
|
|
|
{% block content %}
|
|
<header class="ui-head">
|
|
<div>
|
|
<div class="eyebrow-t">Updates</div>
|
|
<h1>iPXE boot files</h1>
|
|
<div class="state-line">
|
|
{% if status == 'update' %}{{ ui.chip('Update available: ' ~ latest.tag, 'act') }}
|
|
{% elif status == 'current' %}{{ ui.chip('Up to date (' ~ latest.tag ~ ')', 'ok') }}
|
|
{% elif status == 'newer' %}{{ ui.chip('Ahead of ' ~ latest.tag, 'neutral') }}
|
|
{% elif latest %}{{ ui.chip('Latest: ' ~ latest.tag, 'neutral') }}
|
|
{% else %}{{ ui.chip('Upstream not checked', 'off') }}{% endif %}
|
|
{% if latest %}<span class="sub">Checked {{ latest.checked | local_dt }}</span>{% endif %}
|
|
</div>
|
|
</div>
|
|
<div class="actions">
|
|
<details class="more">
|
|
<summary class="fbtn" style="background: var(--surface); min-height: 36px">Speed cap
|
|
<strong class="mono">{{ ('%g MB/s' | format(settings.max_mbps)) if settings.max_mbps else 'none' }}</strong><span class="chev" aria-hidden="true">▾</span></summary>
|
|
<form class="menu" method="post" action="{{ url_for('ipxe.save_settings') }}" style="width: min(300px, calc(100vw - 32px)); padding: 14px; gap: 10px">
|
|
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
|
<label for="maxMbps" style="font-size: 13px; font-weight: 600">Download speed cap (MB/s)</label>
|
|
<div style="display: flex; gap: 8px"><input id="maxMbps" name="max_mbps" type="number" min="0" max="1000" step="0.5"
|
|
value="{{ '%g' | format(settings.max_mbps) }}" aria-describedby="cap-help" class="field mono" style="width: 90px">
|
|
<button type="submit" class="ui-btn pri">Save</button></div>
|
|
<span id="cap-help" class="sub" style="white-space: normal">Applies to every download on the Updates pages (Dell and iPXE); 0 means no cap. The site link is about 400 Mbps (50 MB/s).</span>
|
|
</form>
|
|
</details>
|
|
{% set can_stage = latest and latest.sha256 and job.state not in ('queued', 'running') %}
|
|
<form method="post" action="{{ url_for('ipxe.check') }}" style="margin: 0">
|
|
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
|
<button type="submit" class="ui-btn{{ ' pri' if status != 'update' }}">Check now</button>
|
|
</form>
|
|
<form method="post" action="{{ url_for('ipxe.stage') }}" style="margin: 0">
|
|
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
|
<button type="submit" class="ui-btn{{ ' pri' if status == 'update' }}" {% if not can_stage %}disabled{% endif %}>Download and verify</button>
|
|
</form>
|
|
</div>
|
|
</header>
|
|
|
|
<div class="ui-stack">
|
|
|
|
{# ---- Job ----------------------------------------------------------------- #}
|
|
{% if job.state %}
|
|
<div id="ipxeJob" data-state="{{ job.state }}">
|
|
{% if job.state in ('queued', 'running') %}
|
|
<div class="panel strip" role="status">
|
|
<span class="pulse" aria-hidden="true"></span>
|
|
<span id="ipxeJobText">{{ 'Starting' if job.state == 'queued' else (job.step | capitalize) }} {{ job.tag }}...</span>
|
|
<div class="bar" aria-hidden="true"><div id="ipxeJobBar" style="width: {{ job.percent or 0 }}%"></div></div>
|
|
</div>
|
|
{% elif job.state == 'error' %}
|
|
<div class="errbox"><strong>Download of {{ job.tag }} failed:</strong> <span class="mono">{{ job.error }}</span></div>
|
|
{% elif job.state == 'interrupted' %}
|
|
<div class="notice">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="notice">{{ job.tag }} downloaded, but {{ job.failed | join(', ') }} failed verification; see Downloaded and verified below.</div>
|
|
{% endif %}
|
|
</div>
|
|
{% endif %}
|
|
|
|
<details>
|
|
<summary class="disc"><span class="chev" aria-hidden="true">▾</span>What this page manages</summary>
|
|
<p class="sub" style="margin: 4px 0 0; max-width: 900px; line-height: 1.6">
|
|
The Secure Boot chain that dnsmasq hands out over TFTP: the Microsoft-signed shim (<span class="mono">ipxe-shim.efi</span>,
|
|
and the same file as <span class="mono">snponly-shim.efi</span>) starts <span class="mono">ipxe.efi</span>, which is signed by the
|
|
iPXE Secure Boot CA. <span class="mono">snponly.efi</span> is the same release built on the firmware's drivers (USB keyboards
|
|
keep working). Updates come from the upstream iPXE release tarball on GitHub, plus the dual-signed shim (Microsoft UEFI CA
|
|
2011 and 2023) from the separate ipxe/shim release; each download's 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>, the per-client folders, <span class="mono">dnsmasq.conf</span> and the Blancco files are
|
|
never changed here.
|
|
</p>
|
|
</details>
|
|
|
|
{# ---- Installed ----------------------------------------------------------- #}
|
|
<section class="panel ui-list" aria-labelledby="inst-h">
|
|
<div class="sec-h"><div><h2 id="inst-h" class="sec">Installed on the TFTP root</h2>
|
|
<div class="sub">One line per boot file. Open a row for its signer chain, SBAT and hash.</div></div></div>
|
|
<div class="igrid ihead ui-rowhead" aria-hidden="true"><span>File</span><span>Version</span><span>Signature</span><span></span></div>
|
|
{% for r in rows %}
|
|
{% set i = r.info %}
|
|
{% set chain_ok = i.chain_ok if i and i.chain_ok is defined else none %}
|
|
<div class="ui-row i-row">
|
|
<details>
|
|
<summary class="igrid">
|
|
<span class="name"><span class="mono">{{ r.name }}</span>{% if not r.managed %}<span class="sub">read-only here</span>{% endif %}</span>
|
|
<span class="tip c-ver" data-tip="{{ (i.version or '-') if i else 'Not present' }}"><span class="trunc mono">{% if i %}{{ i.version or '-' }}{% else %}<span class="muted">Not present</span>{% endif %}</span></span>
|
|
<span>{% if not i %}{{ ui.chip('Missing', 'off') }}{% elif chain_ok == false %}{{ ui.chip('Shim will refuse', 'err') }}{% else %}{{ sig_chip(i) }}{% endif %}</span>
|
|
<span class="chev" aria-hidden="true">▾</span>
|
|
</summary>
|
|
<div class="ibody">
|
|
{% if i %}
|
|
{{ sig_text(i, chain_ok) }}
|
|
<dl class="kv">
|
|
{% if i.release %}<dt>From release</dt><dd class="mono">{{ i.release }}</dd>{% endif %}
|
|
<dt>Size</dt><dd>{{ "{:,}".format(i.size) }} B</dd>
|
|
<dt>Modified</dt><dd class="mono">{{ i.modified | timestamp_fmt }}</dd>
|
|
<dt>sha256</dt><dd class="mono">{{ i.sha256 }}</dd>
|
|
</dl>
|
|
{{ chain(i) }}
|
|
{% else %}<div class="sub">Not present on the TFTP root.</div>{% endif %}
|
|
</div>
|
|
</details>
|
|
</div>
|
|
{% endfor %}
|
|
</section>
|
|
|
|
{# ---- Per-client folders ---------------------------------------------------- #}
|
|
{% if mac_folders %}
|
|
<section class="panel ui-list" aria-labelledby="mac-h">
|
|
<div class="sec-h"><div><h2 id="mac-h" class="sec">Per-client folders</h2>
|
|
<div class="sub">With <span class="mono">tftp-unique-root=mac</span>, a client whose MAC has a folder gets every file from it, with no fallback to the root. Read-only here: installs do not update them.</div></div></div>
|
|
{% for m in mac_folders %}
|
|
<div class="rel-h"><strong class="mono">{{ m.mac }}</strong><span class="sub mono">/srv/tftp/{{ m.dir }}/</span>
|
|
<span class="sub">{{ m.files | length }} file{{ 's' if m.files | length != 1 }}</span></div>
|
|
{% for f in m.files %}
|
|
{% set i = f.info %}
|
|
{% set chain_ok = i.chain_ok if i.chain_ok is defined else none %}
|
|
<div class="ui-row i-row">
|
|
<details>
|
|
<summary class="igrid">
|
|
<span class="name"><span class="mono">{{ f.name }}</span>{% if i.drivers == 'firmware' and f.name == 'ipxe.efi' %}<span class="sub">snponly build</span>{% endif %}</span>
|
|
<span class="tip c-ver" data-tip="{{ i.version or '-' }}"><span class="trunc mono">{{ i.version or '-' }}</span></span>
|
|
<span>{% if chain_ok == false %}{{ ui.chip('Shim will refuse', 'err') }}{% else %}{{ sig_chip(i) }}{% endif %}</span>
|
|
<span class="chev" aria-hidden="true">▾</span>
|
|
</summary>
|
|
<div class="ibody">
|
|
{{ sig_text(i, chain_ok) }}
|
|
<dl class="kv">
|
|
<dt>Path</dt><dd class="mono">/srv/tftp/{{ m.dir }}/{{ f.name }}</dd>
|
|
<dt>Size</dt><dd>{{ "{:,}".format(i.size) }} B</dd>
|
|
<dt>Modified</dt><dd class="mono">{{ i.modified | timestamp_fmt }}</dd>
|
|
<dt>sha256</dt><dd class="mono">{{ i.sha256 }}</dd>
|
|
</dl>
|
|
{{ chain(i) }}
|
|
</div>
|
|
</details>
|
|
</div>
|
|
{% endfor %}
|
|
{% endfor %}
|
|
</section>
|
|
{% endif %}
|
|
|
|
{# ---- Upstream ------------------------------------------------------------ #}
|
|
<section class="panel ui-list" aria-labelledby="up-h">
|
|
<div class="sec-h"><div><h2 id="up-h" class="sec">Upstream release</h2>
|
|
<div class="sub">The latest GitHub release of iPXE, as last checked.</div></div></div>
|
|
{% if latest %}
|
|
<div class="ui-row">
|
|
<details>
|
|
<summary class="igrid">
|
|
<span class="mono" style="font-weight: 600">{{ latest.tag }}</span>
|
|
<span class="tip c-ver" data-tip="{{ latest.name }}"><span class="trunc sub">{% if latest.name and latest.name != latest.tag %}{{ latest.name }}, {% endif %}published {{ latest.published | local_dt }}</span></span>
|
|
<span>{% if status == 'update' %}{{ ui.chip('Newer than installed', 'act') }}
|
|
{% elif status == 'current' %}{{ ui.chip('Installed', 'ok') }}
|
|
{% elif status == 'newer' %}{{ ui.chip('Installed is newer', 'neutral') }}
|
|
{% else %}{{ ui.chip('Installed version unknown', 'off') }}{% endif %}</span>
|
|
<span class="chev" aria-hidden="true">▾</span>
|
|
</summary>
|
|
<div class="ibody">
|
|
<dl class="kv">
|
|
<dt>Published</dt><dd>{{ latest.published | local_dt }}</dd>
|
|
<dt>Checked</dt><dd>{{ latest.checked | local_dt }}</dd>
|
|
{% if latest.size %}<dt>Tarball</dt><dd>{{ "%.1f"|format(latest.size / 1048576) }} MB</dd>{% endif %}
|
|
<dt>sha256</dt><dd class="mono">{{ latest.sha256 or 'not published' }}</dd>
|
|
<dt>Release page</dt><dd><a href="{{ latest.page }}" target="_blank" rel="noopener">{{ latest.page }}</a></dd>
|
|
</dl>
|
|
{% if not latest.sha256 %}<div class="notice">GitHub lists no sha256 for this release, so it cannot be downloaded here; install it by hand (see the instructions below).</div>{% endif %}
|
|
</div>
|
|
</details>
|
|
</div>
|
|
{% else %}
|
|
<div class="hidden-bar">Not checked yet. Check now asks the GitHub API for the latest release (a few seconds).</div>
|
|
{% endif %}
|
|
</section>
|
|
|
|
{# ---- Staged -------------------------------------------------------------- #}
|
|
{% if staged %}
|
|
<section class="panel ui-list" aria-labelledby="stg-h">
|
|
<div class="sec-h"><div><h2 id="stg-h" class="sec">Downloaded and verified</h2>
|
|
<div class="sub">Tick the files to install. A shim is only installed together with ipxe.efi from the same release; ipxe.pxe is for legacy BIOS clients.</div></div></div>
|
|
{% for s in staged %}
|
|
<form method="post" action="{{ url_for('ipxe.install') }}" data-tag="{{ s.tag }}" style="margin: 0"
|
|
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="rel-h">
|
|
<strong class="mono">{{ s.tag }}</strong>
|
|
<span class="sub">verified {{ s.staged | local_dt }}</span>
|
|
<span class="tip inl" data-tip="{{ s.tarball_sha256 }}"><span class="sub">tarball sha256 <span class="mono">{{ (s.tarball_sha256 or '')[:12] }}</span> matches GitHub</span></span>
|
|
</div>
|
|
{% for name, f in s.files.items() %}
|
|
{% set fid = 'inst-' ~ loop.index ~ '-' ~ s.tag %}
|
|
<div class="ui-row i-row">
|
|
<div class="pick"><input type="checkbox" class="form-check-input" name="names" value="{{ name }}" id="{{ fid }}"
|
|
{% if not f.ok %}disabled{% elif name in default_install %}checked{% endif %}>
|
|
<label for="{{ fid }}" class="vh">Install {{ name }} from {{ s.tag }}</label></div>
|
|
<details {% if not f.ok %}open{% endif %}>
|
|
<summary class="igrid picked">
|
|
<span class="name"><span class="mono">{{ name }}</span>{% if name not in default_install %}<span class="sub">optional</span>{% endif %}</span>
|
|
<span class="tip c-ver" data-tip="{{ f.version or '-' }}"><span class="trunc mono">{{ f.version or '-' }}{% if f.sha256 and f.sha256 == installed_sha[name] %} <span class="sub">(same as installed)</span>{% endif %}</span></span>
|
|
<span>{% if not f.ok %}{{ ui.chip('Failed', 'err') }}{% elif f.kind %}{{ sig_chip(f) }}{% else %}{{ ui.chip('OK', 'ok') }}{% endif %}</span>
|
|
<span class="chev" aria-hidden="true">▾</span>
|
|
</summary>
|
|
<div class="ibody picked">
|
|
{% if not f.ok %}<div class="errbox">{{ f.error }}</div>{% elif f.kind %}{{ sig_text(f) }}{% endif %}
|
|
{% if f.note %}<div class="notice">{{ f.note }}</div>{% endif %}
|
|
<dl class="kv">
|
|
<dt>Source</dt><dd class="mono">{{ f.source or '-' }}</dd>
|
|
{% if f.sha256 %}<dt>sha256</dt><dd class="mono">{{ f.sha256 }}</dd>{% endif %}
|
|
</dl>
|
|
{% if f.signers is defined %}{{ chain(f) }}{% endif %}
|
|
</div>
|
|
</details>
|
|
</div>
|
|
{% endfor %}
|
|
<div class="list-foot">
|
|
<button type="submit" class="ui-btn pri">Install selected</button>
|
|
<span class="sub">The current files are backed up first.</span>
|
|
</div>
|
|
</form>
|
|
{% endfor %}
|
|
</section>
|
|
{% endif %}
|
|
|
|
{# ---- dnsmasq routing ----------------------------------------------------- #}
|
|
<section class="panel ui-list" aria-labelledby="rt-h">
|
|
<div class="sec-h"><div><h2 id="rt-h" class="sec">Who gets which file</h2>
|
|
<div class="sub">Read from the dnsmasq configuration; never changed here.</div></div></div>
|
|
{% if routing.unique_root %}<div class="notice info" style="margin: 0 24px 10px"><span>dnsmasq has <span class="mono">tftp-unique-root=mac</span>: a client with its own folder (see Per-client folders) is served from it instead of the root, whatever file name this list shows.</span></div>{% endif %}
|
|
{% if routing.boots %}
|
|
{% for b in routing.boots %}
|
|
<div class="ui-row">
|
|
<details>
|
|
<summary class="rgrid">
|
|
<span class="name"><span class="tip" data-tip="{{ b.file }}"><span class="trunc mono">{{ b.file }}</span></span>
|
|
{% if b.managed %}{{ ui.chip('updated here', 'w11') }}{% elif b.read_only %}{{ ui.chip('never touched', 'neutral') }}{% elif b.other_copy %}{{ ui.chip('other copy, not updated here', 'warn') }}{% endif %}</span>
|
|
<span class="sub c-who">
|
|
{% if b.macs %}{{ b.macs | length }} tagged client{{ 's' if b.macs | length != 1 }} ({{ b.need | join(', ') }})
|
|
{%- elif b.need %}clients matching {{ b.need | join(' and ') }}
|
|
{% else %}every client not excluded{% endif -%}{% if b.exclude %}; not {{ b.exclude | join(', ') }}{% endif %}</span>
|
|
<span class="chev" aria-hidden="true">▾</span>
|
|
</summary>
|
|
<div class="ibody">
|
|
{% if b.other_copy %}<div class="notice">This page installs into the TFTP root only; this copy in a subfolder is not updated.</div>{% endif %}
|
|
<dl class="kv">
|
|
<dt>Requires tag</dt><dd class="mono">{{ b.need | join(', ') or '-' }}</dd>
|
|
<dt>Unless tagged</dt><dd class="mono">{{ b.exclude | join(', ') or '-' }}</dd>
|
|
{% if b.macs %}<dt>Clients</dt><dd class="mono">{{ b.macs | join(', ') }}</dd>{% endif %}
|
|
<dt>Line</dt><dd class="mono">{{ b.line }}</dd>
|
|
</dl>
|
|
</div>
|
|
</details>
|
|
</div>
|
|
{% endfor %}
|
|
<details style="border-top: 1px solid var(--line)">
|
|
<summary class="disc" style="padding: 0 24px; min-height: 44px"><span class="chev" aria-hidden="true">▾</span>Relevant dnsmasq lines ({{ routing.lines | length }})</summary>
|
|
<pre class="cmd mono" style="margin: 0 24px 16px">{% for l in routing.lines %}{{ l }}
|
|
{% endfor %}</pre>
|
|
</details>
|
|
{% else %}
|
|
<div class="hidden-bar">No dhcp-boot lines found in the dnsmasq configuration.</div>
|
|
{% endif %}
|
|
</section>
|
|
|
|
{# ---- Backups ------------------------------------------------------------- #}
|
|
<section class="panel ui-list" aria-labelledby="bk-h">
|
|
<div class="sec-h"><div><h2 id="bk-h" class="sec">Backups</h2>
|
|
<div class="sub">One is made automatically before every install and rollback. Restore is in each row's menu.</div></div></div>
|
|
{% for b in backups %}
|
|
<div class="ui-row">
|
|
<details>
|
|
<summary class="bgrid">
|
|
<span class="mono">{{ b.id }}</span>
|
|
<span class="tip" data-tip="{{ b.reason or '-' }}"><span class="trunc">{{ b.reason or '-' }}</span></span>
|
|
<span class="tip" data-tip="{{ b.files | join(', ') or 'empty' }}"><span class="trunc sub mono">{{ b.files | join(', ') or 'empty' }}</span></span>
|
|
<span class="chev" aria-hidden="true">▾</span>
|
|
</summary>
|
|
<div class="ibody">
|
|
{% if b.reason %}<div>{{ b.reason }}</div>{% endif %}
|
|
{% for f in b.files %}<div><span class="mono">{{ f }}</span> <span class="muted">{{ b.versions[f] or '' }}</span></div>{% else %}<div class="muted">Empty backup.</div>{% endfor %}
|
|
</div>
|
|
</details>
|
|
{% if b.files %}
|
|
<div class="act">
|
|
{% call ui.more('backup ' ~ b.id) %}
|
|
{# 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') }}"
|
|
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">Restore these files</button>
|
|
</form>
|
|
{% endcall %}
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
{% else %}
|
|
<div class="hidden-bar">No backups yet. One is made automatically before every install and rollback.</div>
|
|
{% endfor %}
|
|
</section>
|
|
|
|
{# ---- How-to -------------------------------------------------------------- #}
|
|
<section class="panel" aria-labelledby="how-h">
|
|
<details>
|
|
<summary class="sec-h" style="align-items: center">
|
|
<div><h2 id="how-h" class="sec" style="font-size: 15px">Find and install the latest signed build by hand</h2>
|
|
<div class="sub">For when the buttons above cannot reach GitHub, or to check what they do.</div></div>
|
|
<span class="chev" aria-hidden="true">▾</span>
|
|
</summary>
|
|
<div style="padding: 0 24px 20px">
|
|
<p class="sub" style="margin: 0 0 12px">All commands run on the PXE server as root.</p>
|
|
<ol class="howto">
|
|
<li><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>).
|
|
The tarball's shim is signed by Microsoft's 2011 CA only; the dual-signed shim (2011 and 2023) is the asset
|
|
<span class="mono">ipxe-shimx64.efi</span> of the separate <a href="https://github.com/ipxe/shim/releases" target="_blank" rel="noopener" class="mono">ipxe/shim</a> release.</li>
|
|
<li><strong>Download and check the sha256.</strong> It must equal the digest on the release page.
|
|
<pre class="cmd mono">cd /tmp
|
|
curl -fLO {{ tarball_url }}
|
|
curl -fLO https://github.com/ipxe/shim/releases/latest/download/ipxe-shimx64.efi
|
|
sha256sum ipxeboot.tar.gz ipxe-shimx64.efi</pre>
|
|
<span class="sub">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><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="cmd mono">tar xzf ipxeboot.tar.gz
|
|
ls -l ipxeboot/x86_64-sb/</pre></li>
|
|
<li><strong>Check the signatures.</strong>
|
|
<pre class="cmd mono">sbverify --list ipxe-shimx64.efi
|
|
sbverify --list ipxeboot/x86_64-sb/ipxe.efi
|
|
sbverify --list ipxeboot/x86_64-sb/snponly.efi</pre>
|
|
The dual-signed shim shows <span class="mono">CN=Microsoft UEFI CA 2023</span> (sbverify lists the first signature only;
|
|
the 2011 one is nested inside it). Fall back to the tarball's <span class="mono">shimx64.efi</span>
|
|
(<span class="mono">CN=Microsoft Corporation UEFI CA 2011</span>) only if the dual-signed one is unavailable.
|
|
<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><strong>Back up the current files.</strong> A backup made in this folder also shows in the Backups list above, with Restore.
|
|
<pre class="cmd mono">B=/var/lib/pxe-webapp/updates/ipxe/backups/$(date +%Y%m%d-%H%M%S)
|
|
mkdir -p "$B" && cp -p /srv/tftp/ipxe-shim.efi /srv/tftp/snponly-shim.efi /srv/tftp/ipxe.efi /srv/tftp/snponly.efi "$B"/</pre></li>
|
|
<li><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="cmd mono">install -m 0644 ipxeboot/x86_64-sb/ipxe.efi /srv/tftp/.ipxe.efi.new && mv -f /srv/tftp/.ipxe.efi.new /srv/tftp/ipxe.efi
|
|
install -m 0644 ipxeboot/x86_64-sb/snponly.efi /srv/tftp/.snponly.efi.new && mv -f /srv/tftp/.snponly.efi.new /srv/tftp/snponly.efi
|
|
install -m 0644 ipxe-shimx64.efi /srv/tftp/.ipxe-shim.efi.new && mv -f /srv/tftp/.ipxe-shim.efi.new /srv/tftp/ipxe-shim.efi
|
|
install -m 0644 ipxe-shimx64.efi /srv/tftp/.snponly-shim.efi.new && mv -f /srv/tftp/.snponly-shim.efi.new /srv/tftp/snponly-shim.efi</pre>
|
|
<span class="sub">No dnsmasq restart is needed. Never replace <span class="mono">ipxe-bcm.efi</span>; it serves every other UEFI client.
|
|
A per-client folder keeps its own copies: update its <span class="mono">ipxe.efi</span> (the snponly build) and shim the same way.</span></li>
|
|
<li><strong>Who gets it.</strong> dnsmasq decides, in <span class="mono">/etc/dnsmasq.conf</span>: clients listed as
|
|
<span class="mono">dhcp-host=<mac>,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 the untagged <span class="mono">dhcp-boot=tag:!bios,...,ipxe-bcm.efi</span> line. Clients tagged
|
|
<span class="mono">ipxesnp</span> get <span class="mono">snponly-shim.efi</span>; since the shim asks for <span class="mono">ipxe.efi</span>
|
|
whatever it is called, each of them also needs a per-client folder whose <span class="mono">ipxe.efi</span> is the snponly build.
|
|
Who gets which file above shows the live lines. Test on one tagged machine before tagging more.</li>
|
|
<li><strong>Roll back.</strong> Use Restore in the Backups list, 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>
|
|
</details>
|
|
</section>
|
|
</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' : j.step === 'shim' ? 'Fetching the dual-signed shim for' : '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 %}
|