Webapp: iPXE page stages the dual-signed shim, knows snponly and per-MAC folders

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.
This commit is contained in:
cproudlock
2026-10-14 15:37:14 -04:00
parent 4db9dc093f
commit 81c2d85301
3 changed files with 218 additions and 52 deletions

View File

@@ -1,14 +1,26 @@
"""Keep the Secure Boot iPXE chain on the TFTP root current. """Keep the Secure Boot iPXE chain on the TFTP root current.
What dnsmasq serves (see /etc/dnsmasq.conf): What dnsmasq serves (see /etc/dnsmasq.conf):
* ipxe-shim.efi - Microsoft-signed shim (upstream x86_64-sb/shimx64.efi), * ipxe-shim.efi - Microsoft-signed shim, for the MACs tagged ipxe2. Over
for the MACs tagged ipxe2; it always loads ipxe.efi from TFTP it always loads ipxe.efi from the TFTP root (it
the TFTP ROOT, whatever folder it came from; does not derive the name from its own, whatever the
* ipxe.efi - iPXE signed by the iPXE Secure Boot CA, loaded by the shim; upstream docs say);
* snponly-shim.efi - the same shim under the name dnsmasq hands the
ipxesnp-tagged MACs;
* ipxe.efi - iPXE signed by the iPXE Secure Boot CA, loaded by the
shim. The full build drives USB itself, which kills a
USB keyboard at the iPXE menu;
* snponly.efi - the same release built to use the firmware's network
and USB drivers (keyboard keeps working). Served to a
client as ipxe.efi from its per-MAC folder;
* ipxe-bcm.efi - the old 2021 Broadcom-signed build, everyone else. * ipxe-bcm.efi - the old 2021 Broadcom-signed build, everyone else.
Never touched here; Never touched here;
* ipxe.pxe - legacy BIOS iPXE (unsigned; BIOS has no Secure Boot). * ipxe.pxe - legacy BIOS iPXE (unsigned; BIOS has no Secure Boot).
Per-MAC folders: with tftp-unique-root=mac, /srv/tftp/<aa-bb-cc-dd-ee-ff>/
wins over the root for that client, with NO fallback for a file missing
from it. They are shown here read-only and never written.
Upstream publishes the signed set as one tarball on every release: Upstream publishes the signed set as one tarball on every release:
https://github.com/ipxe/ipxe/releases/latest/download/ipxeboot.tar.gz, with https://github.com/ipxe/ipxe/releases/latest/download/ipxeboot.tar.gz, with
the signed x86 files in ipxeboot/x86_64-sb/. GitHub lists a sha256 digest per the signed x86 files in ipxeboot/x86_64-sb/. GitHub lists a sha256 digest per
@@ -18,10 +30,13 @@ The chain check is by certificate name (sbverify --list); the cryptographic
trust comes from the GitHub digest. Do not swap in `sbverify --cert`: it trust comes from the GitHub digest. Do not swap in `sbverify --cert`: it
prints "Signature verification OK" even for a missing or unrelated cert. prints "Signature verification OK" even for a missing or unrelated cert.
Why it matters: the shim and the Broadcom build are signed by Microsoft's Why it matters: the Broadcom build, and the shim in the iPXE tarball, are
UEFI CA 2011, whose signing period ended in 2026. Machines that only trust signed only by Microsoft's UEFI CA 2011, whose signing period ended in 2026.
the 2023 CA, or a revocation (DBX/SBAT) update, will refuse them; the fix is Machines that only trust the 2023 CA, or a revocation (DBX/SBAT) update,
a newer upstream release, installed here. will refuse them. iPXE publishes a shim dual-signed with the 2011 and 2023
CAs as a separate release (github.com/ipxe/shim); staging fetches it, checks
its digest and signatures, and uses it instead of the tarball's shim when it
trusts the same iPXE CA.
Staging runs as a background job (python3 -m services.ipxe_update stage); Staging runs as a background job (python3 -m services.ipxe_update stage);
install and rollback are a few MB of copying and run inline. install and rollback are a few MB of copying and run inline.
@@ -45,6 +60,8 @@ import config
from services import updates_common as uc from services import updates_common as uc
GITHUB_API = "https://api.github.com/repos/ipxe/ipxe/releases/latest" GITHUB_API = "https://api.github.com/repos/ipxe/ipxe/releases/latest"
SHIM_API = "https://api.github.com/repos/ipxe/shim/releases/latest"
SHIM_ASSET = "ipxe-shimx64.efi"
RELEASES_PAGE = "https://github.com/ipxe/ipxe/releases" RELEASES_PAGE = "https://github.com/ipxe/ipxe/releases"
LATEST_TARBALL_URL = "https://github.com/ipxe/ipxe/releases/latest/download/ipxeboot.tar.gz" LATEST_TARBALL_URL = "https://github.com/ipxe/ipxe/releases/latest/download/ipxeboot.tar.gz"
TARBALL = "ipxeboot.tar.gz" TARBALL = "ipxeboot.tar.gz"
@@ -54,11 +71,14 @@ JOB_UNIT = "pxe-webapp-ipxe-stage"
# Secure Boot signature (and from whom). # Secure Boot signature (and from whom).
MANAGED = { MANAGED = {
"ipxe-shim.efi": ("ipxeboot/x86_64-sb/shimx64.efi", "microsoft"), "ipxe-shim.efi": ("ipxeboot/x86_64-sb/shimx64.efi", "microsoft"),
"snponly-shim.efi": ("ipxeboot/x86_64-sb/shimx64.efi", "microsoft"),
"ipxe.efi": ("ipxeboot/x86_64-sb/ipxe.efi", "ipxe"), "ipxe.efi": ("ipxeboot/x86_64-sb/ipxe.efi", "ipxe"),
"snponly.efi": ("ipxeboot/x86_64-sb/snponly.efi", "ipxe"),
"ipxe.pxe": ("ipxeboot/x86_64/ipxe.pxe", None), "ipxe.pxe": ("ipxeboot/x86_64/ipxe.pxe", None),
} }
SHIMS = ("ipxe-shim.efi", "snponly-shim.efi")
# Installed by default; ipxe.pxe (legacy BIOS) only when asked. # Installed by default; ipxe.pxe (legacy BIOS) only when asked.
DEFAULT_INSTALL = ("ipxe-shim.efi", "ipxe.efi") DEFAULT_INSTALL = ("ipxe-shim.efi", "snponly-shim.efi", "ipxe.efi", "snponly.efi")
# Shown on the page for reference, never written. # Shown on the page for reference, never written.
READ_ONLY = ("ipxe-bcm.efi",) READ_ONLY = ("ipxe-bcm.efi",)
@@ -193,6 +213,20 @@ def vendor_cert_cn(path):
return None return None
def drivers_of(path):
"""'native' when an iPXE build carries its own USB host drivers (it takes
the USB controller from the firmware, so a USB keyboard stops working),
'firmware' when it has none (snponly), '' for anything else."""
try:
with open(path, "rb") as f:
data = f.read()
except OSError:
return ""
if b"iPXE" not in data or b"$Version: " in data:
return "" # not iPXE, or a shim
return "native" if re.search(rb"\0xhci\0|\0ehci\0", data) else "firmware"
def describe(path): def describe(path):
if not os.path.isfile(path): if not os.path.isfile(path):
return None return None
@@ -210,6 +244,7 @@ def describe(path):
"sbat": sbat_generations(path) if efi else "", "sbat": sbat_generations(path) if efi else "",
"signers": pairs, "kind": (signature_kind(pairs) if not error else "error") if efi else "bios", "signers": pairs, "kind": (signature_kind(pairs) if not error else "error") if efi else "bios",
"trusts": vendor_cert_cn(path) if efi else None, "trusts": vendor_cert_cn(path) if efi else None,
"drivers": drivers_of(path) if efi else "",
"sig_error": error, "sig_error": error,
} }
@@ -229,13 +264,44 @@ def installed():
if info and origin.get("sha256") == info["sha256"]: if info and origin.get("sha256") == info["sha256"]:
info["release"] = origin.get("tag") info["release"] = origin.get("tag")
rows.append({"name": name, "managed": name in MANAGED, "info": info}) rows.append({"name": name, "managed": name in MANAGED, "info": info})
# The shim only starts an ipxe.efi signed under the CA it embeds. # The shim only starts an iPXE signed under the CA it embeds.
by = {r["name"]: r["info"] for r in rows} by = {r["name"]: r["info"] for r in rows}
shim, ipxe = by.get("ipxe-shim.efi"), by.get("ipxe.efi") _check_chain(by.get("ipxe-shim.efi") or by.get("snponly-shim.efi"),
[by.get("ipxe.efi"), by.get("snponly.efi")])
return rows
def _check_chain(shim, ipxes):
for ipxe in ipxes:
if shim and ipxe and shim.get("trusts"): if shim and ipxe and shim.get("trusts"):
ipxe["chain_ok"] = shim["trusts"] in {i for _, i in ipxe["signers"]} ipxe["chain_ok"] = shim["trusts"] in {i for _, i in ipxe["signers"]}
ipxe["trusts_needed"] = shim["trusts"] ipxe["trusts_needed"] = shim["trusts"]
return rows
MAC_DIR_RE = re.compile(r"^[0-9a-f]{2}(-[0-9a-f]{2}){5}$")
def mac_folders():
"""Per-client TFTP folders (tftp-unique-root=mac): [{mac, files}], each
file described. Read-only; install never writes into them."""
out = []
try:
names = sorted(os.listdir(config.TFTP_ROOT))
except OSError:
return out
for d in names:
folder = os.path.join(config.TFTP_ROOT, d)
if not (MAC_DIR_RE.match(d) and os.path.isdir(folder)):
continue
files = []
for f in sorted(os.listdir(folder)):
info = describe(os.path.join(folder, f))
if info:
files.append({"name": f, "info": info})
by = {f["name"]: f["info"] for f in files}
_check_chain(next((by[n] for n in SHIMS if n in by), None), [by.get("ipxe.efi")])
out.append({"mac": d.replace("-", ":"), "dir": d, "files": files})
return out
# --- dnsmasq ---------------------------------------------------------------- # --- dnsmasq ----------------------------------------------------------------
@@ -322,7 +388,7 @@ def dnsmasq_routing():
boots.append({"file": rest[0] if rest else "", "need": need, "exclude": exclude, boots.append({"file": rest[0] if rest else "", "need": need, "exclude": exclude,
"line": s, "source": src}) "line": s, "source": src})
if key in ("dhcp-boot", "dhcp-host", "dhcp-match", "dhcp-userclass", "dhcp-vendorclass", if key in ("dhcp-boot", "dhcp-host", "dhcp-match", "dhcp-userclass", "dhcp-vendorclass",
"tag-if", "enable-tftp", "tftp-root"): "tag-if", "enable-tftp", "tftp-root", "tftp-unique-root"):
if key != "dhcp-host" or "set:" in val: if key != "dhcp-host" or "set:" in val:
raw.append(s) raw.append(s)
for b in boots: for b in boots:
@@ -334,7 +400,8 @@ def dnsmasq_routing():
b["read_only"] = rel in READ_ONLY b["read_only"] = rel in READ_ONLY
b["other_copy"] = rel not in MANAGED and os.path.basename(rel) in MANAGED b["other_copy"] = rel not in MANAGED and os.path.basename(rel) in MANAGED
return {"tags": tag_macs, "boots": boots, "lines": raw, return {"tags": tag_macs, "boots": boots, "lines": raw,
"shim_macs": sorted({m for b in boots if os.path.basename(b["file"]) == "ipxe-shim.efi" "unique_root": any(s.startswith("tftp-unique-root") for s in raw),
"shim_macs": sorted({m for b in boots if os.path.basename(b["file"]) in SHIMS
for m in b["macs"]})} for m in b["macs"]})}
@@ -459,8 +526,12 @@ def stage(info=None):
else: else:
ok, error = True, "" ok, error = True, ""
files[name] = dict(d, ok=ok, error=error, source=inner) files[name] = dict(d, ok=ok, error=error, source=inner)
# The shim from this release must trust the CA its ipxe.efi is signed by. _progress(step="shim")
shim, ipxe = files.get("ipxe-shim.efi") or {}, files.get("ipxe.efi") or {} _use_dual_signed_shim(folder, files)
# The shim must trust the CA this release's iPXE builds are signed by.
shim = files.get("ipxe-shim.efi") or {}
for name in ("ipxe.efi", "snponly.efi"):
ipxe = files.get(name) or {}
if shim.get("ok") and ipxe.get("ok") and shim.get("trusts"): if shim.get("ok") and ipxe.get("ok") and shim.get("trusts"):
if shim["trusts"] not in {i for _, i in ipxe["signers"]}: if shim["trusts"] not in {i for _, i in ipxe["signers"]}:
ipxe.update(ok=False, error=f"not signed under {shim['trusts']}, which this shim trusts") ipxe.update(ok=False, error=f"not signed under {shim['trusts']}, which this shim trusts")
@@ -470,6 +541,44 @@ def stage(info=None):
return record return record
def _use_dual_signed_shim(folder, files):
"""Swap the tarball's shim for the ipxe/shim release build when that one
is signed under the Microsoft UEFI CA 2023 and trusts the same iPXE CA.
Any problem leaves the tarball shim in place, with the reason noted."""
tar_shim = files.get("ipxe-shim.efi") or {}
if not tar_shim.get("ok") or tar_shim.get("kind") == "microsoft-2023":
return
try:
rel = json.loads(uc.fetch_text(SHIM_API, timeout=20))
asset = next((a for a in rel.get("assets", []) if a.get("name") == SHIM_ASSET), None)
digest = str((asset or {}).get("digest") or "")
if not asset or not digest.startswith("sha256:"):
raise RuntimeError(f"{rel.get('tag_name')} lists no sha256 for {SHIM_ASSET}")
sha = digest.split(":", 1)[1].lower()
dest = os.path.join(folder, SHIM_ASSET)
if not (os.path.isfile(dest) and uc.file_hash(dest).lower() == sha):
if os.path.exists(dest):
os.remove(dest)
uc.download(asset["browser_download_url"], dest, expect=sha, timeout=300)
d = describe(dest)
if d["kind"] != "microsoft-2023":
raise RuntimeError(f"{SHIM_ASSET} is {d['kind']}, not signed under {MS_CA_2023}")
if d.get("trusts") != tar_shim.get("trusts"):
raise RuntimeError(f"{SHIM_ASSET} trusts {d.get('trusts')}, the tarball's iPXE needs "
f"{tar_shim.get('trusts')}")
except Exception as ex: # optional upgrade: keep the tarball shim
note = f"dual-signed shim not used: {str(ex)[:200]}"
for n in SHIMS:
if n in files:
files[n]["note"] = note
return
for n in SHIMS:
if n in files:
shutil.copy2(dest, os.path.join(folder, n))
files[n] = dict(d, ok=True, error="", source=f"github.com/ipxe/shim {rel.get('tag_name')} "
f"{SHIM_ASSET} (dual-signed, 2011 + 2023)")
def staged(tag=None): def staged(tag=None):
root = os.path.join(_dir(), "staged") root = os.path.join(_dir(), "staged")
if not os.path.isdir(root): if not os.path.isdir(root):
@@ -647,8 +756,8 @@ def install(tag, names=DEFAULT_INSTALL):
names = [n for n in MANAGED if n in names] names = [n for n in MANAGED if n in names]
if not names: if not names:
raise ValueError("nothing selected") raise ValueError("nothing selected")
if "ipxe-shim.efi" in names and "ipxe.efi" not in names: if any(n in names for n in SHIMS) and "ipxe.efi" not in names:
raise ValueError("install ipxe-shim.efi together with ipxe.efi") raise ValueError("install a shim together with ipxe.efi")
if uc.job_running(JOB_UNIT): if uc.job_running(JOB_UNIT):
raise RuntimeError("a download is running; wait for it to finish") raise RuntimeError("a download is running; wait for it to finish")
with _Lock(): with _Lock():
@@ -663,12 +772,15 @@ def _install(tag, rec, names):
path = os.path.join(rec["folder"], n) path = os.path.join(rec["folder"], n)
if not os.path.isfile(path) or uc.file_hash(path) != f["sha256"]: if not os.path.isfile(path) or uc.file_hash(path) != f["sha256"]:
raise ValueError(f"{n}: staged copy changed since it was verified; download again") raise ValueError(f"{n}: staged copy changed since it was verified; download again")
if "ipxe.efi" in names and "ipxe-shim.efi" not in names: if not any(n in names for n in SHIMS):
# ipxe.efi alone must still be one the installed shim will start. # iPXE alone must still be one the installed shim will start.
trusts = vendor_cert_cn(os.path.join(config.TFTP_ROOT, "ipxe-shim.efi")) trusts = vendor_cert_cn(os.path.join(config.TFTP_ROOT, "ipxe-shim.efi"))
issuers = {i for _, i in rec["files"]["ipxe.efi"].get("signers", [])} for n in ("ipxe.efi", "snponly.efi"):
if n not in names:
continue
issuers = {i for _, i in rec["files"][n].get("signers", [])}
if trusts and trusts not in issuers: if trusts and trusts not in issuers:
raise ValueError(f"the installed shim trusts {trusts}; this ipxe.efi is not signed " raise ValueError(f"the installed shim trusts {trusts}; this {n} is not signed "
"under it, install the shim from the same release too") "under it, install the shim from the same release too")
backup = _backup(names, f"before installing {tag}") backup = _backup(names, f"before installing {tag}")
_place_all([(os.path.join(rec["folder"], n), n, rec["files"][n]["sha256"]) for n in names]) _place_all([(os.path.join(rec["folder"], n), n, rec["files"][n]["sha256"]) for n in names])

View File

@@ -37,6 +37,8 @@
{% endif %} {% endif %}
{% if info.trusts %}<div class="sub">Starts second stages signed by <span class="mono">{{ info.trusts }}</span></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.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 %} {%- endmacro %}
{% block extra_head %} {% block extra_head %}
@@ -162,11 +164,14 @@
<details> <details>
<summary class="disc"><span class="chev" aria-hidden="true">&#9662;</span>What this page manages</summary> <summary class="disc"><span class="chev" aria-hidden="true">&#9662;</span>What this page manages</summary>
<p class="sub" style="margin: 4px 0 0; max-width: 900px; line-height: 1.6"> <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>) 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 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 release tarball on GitHub; its sha256 is checked against GitHub's published digest and each file's signer chain is iPXE Secure Boot CA. <span class="mono">snponly.efi</span> is the same release built on the firmware's drivers (USB keyboards
read with <span class="mono">sbverify</span> before it can be installed. <span class="mono">ipxe-bcm.efi</span>, keep working). Updates come from the upstream iPXE release tarball on GitHub, plus the dual-signed shim (Microsoft UEFI CA
<span class="mono">dnsmasq.conf</span> and the Blancco files are never changed here. 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> </p>
</details> </details>
@@ -203,6 +208,42 @@
{% endfor %} {% endfor %}
</section> </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">&#9662;</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 ------------------------------------------------------------ #} {# ---- Upstream ------------------------------------------------------------ #}
<section class="panel ui-list" aria-labelledby="up-h"> <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="sec-h"><div><h2 id="up-h" class="sec">Upstream release</h2>
@@ -240,7 +281,7 @@
{% if staged %} {% if staged %}
<section class="panel ui-list" aria-labelledby="stg-h"> <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="sec-h"><div><h2 id="stg-h" class="sec">Downloaded and verified</h2>
<div class="sub">Tick the files to install. ipxe-shim.efi is only installed together with ipxe.efi from the same release; ipxe.pxe is for legacy BIOS clients.</div></div></div> <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 %} {% for s in staged %}
<form method="post" action="{{ url_for('ipxe.install') }}" data-tag="{{ s.tag }}" style="margin: 0" <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.');"> 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.');">
@@ -266,8 +307,9 @@
</summary> </summary>
<div class="ibody picked"> <div class="ibody picked">
{% if not f.ok %}<div class="errbox">{{ f.error }}</div>{% elif f.kind %}{{ sig_text(f) }}{% endif %} {% 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"> <dl class="kv">
<dt>From tarball</dt><dd class="mono">{{ f.source or '-' }}</dd> <dt>Source</dt><dd class="mono">{{ f.source or '-' }}</dd>
{% if f.sha256 %}<dt>sha256</dt><dd class="mono">{{ f.sha256 }}</dd>{% endif %} {% if f.sha256 %}<dt>sha256</dt><dd class="mono">{{ f.sha256 }}</dd>{% endif %}
</dl> </dl>
{% if f.signers is defined %}{{ chain(f) }}{% endif %} {% if f.signers is defined %}{{ chain(f) }}{% endif %}
@@ -288,6 +330,7 @@
<section class="panel ui-list" aria-labelledby="rt-h"> <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="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> <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 %} {% if routing.boots %}
{% for b in routing.boots %} {% for b in routing.boots %}
<div class="ui-row"> <div class="ui-row">
@@ -375,11 +418,14 @@
<li><strong>Find the release.</strong> The latest release is at <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 <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: 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> <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. <li><strong>Download and check the sha256.</strong> It must equal the digest on the release page.
<pre class="cmd mono">cd /tmp <pre class="cmd mono">cd /tmp
curl -fLO {{ tarball_url }} curl -fLO {{ tarball_url }}
sha256sum ipxeboot.tar.gz</pre> 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="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> <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>: <li><strong>Unpack.</strong> The Secure Boot files are in <span class="mono">ipxeboot/x86_64-sb/</span>:
@@ -389,27 +435,34 @@ sha256sum ipxeboot.tar.gz</pre>
<pre class="cmd mono">tar xzf ipxeboot.tar.gz <pre class="cmd mono">tar xzf ipxeboot.tar.gz
ls -l ipxeboot/x86_64-sb/</pre></li> ls -l ipxeboot/x86_64-sb/</pre></li>
<li><strong>Check the signatures.</strong> <li><strong>Check the signatures.</strong>
<pre class="cmd mono">sbverify --list ipxeboot/x86_64-sb/shimx64.efi <pre class="cmd mono">sbverify --list ipxe-shimx64.efi
sbverify --list ipxeboot/x86_64-sb/ipxe.efi</pre> sbverify --list ipxeboot/x86_64-sb/ipxe.efi
The shim must be issued by <span class="mono">CN=Microsoft Corporation UEFI CA 2011</span> or, on a newer build, sbverify --list ipxeboot/x86_64-sb/snponly.efi</pre>
<span class="mono">CN=Microsoft UEFI CA 2023</span> (prefer a 2023-signed shim when upstream ships one). 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">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> <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> 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. <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) <pre class="cmd mono">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> mkdir -p "$B" &amp;&amp; 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 <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 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: <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 &amp;&amp; mv -f /srv/tftp/.ipxe.efi.new /srv/tftp/ipxe.efi <pre class="cmd mono">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> install -m 0644 ipxeboot/x86_64-sb/snponly.efi /srv/tftp/.snponly.efi.new &amp;&amp; mv -f /srv/tftp/.snponly.efi.new /srv/tftp/snponly.efi
<span class="sub">No dnsmasq restart is needed. Never replace <span class="mono">ipxe-bcm.efi</span>; it serves every other UEFI client.</span></li> install -m 0644 ipxe-shimx64.efi /srv/tftp/.ipxe-shim.efi.new &amp;&amp; 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 &amp;&amp; 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 <li><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-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">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>. Who gets which file <span class="mono">ipxe-bcm.efi</span> through the untagged <span class="mono">dhcp-boot=tag:!bios,...,ipxe-bcm.efi</span> line. Clients tagged
above shows the live lines. Test on one tagged machine before tagging more.</li> <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 <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> <span class="mono">mv -f</span>).</li>
<li><strong>Normal log noise.</strong> While booting, the shim asks TFTP for optional files such as <li><strong>Normal log noise.</strong> While booting, the shim asks TFTP for optional files such as
@@ -433,7 +486,7 @@ install -m 0644 ipxeboot/x86_64-sb/shimx64.efi /srv/tftp/.ipxe-shim.efi.new &amp
.then(function (r) { return r.json(); }) .then(function (r) { return r.json(); })
.then(function (j) { .then(function (j) {
if (j.state !== 'queued' && j.state !== 'running') { location.reload(); return; } if (j.state !== 'queued' && j.state !== 'running') { location.reload(); return; }
var step = j.step === 'download' ? 'Downloading' : j.step === 'verify' ? 'Verifying' : 'Starting'; 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 (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)) + '%'; if (bar) bar.style.width = (j.step === 'verify' ? 100 : (j.percent || 0)) + '%';
setTimeout(tick, 2000); setTimeout(tick, 2000);

View File

@@ -32,6 +32,7 @@ def page():
rows=rows, rows=rows,
installed_sha={n: (i or {}).get("sha256") for n, i in by_name.items()}, installed_sha={n: (i or {}).get("sha256") for n, i in by_name.items()},
routing=ipxe_update.dnsmasq_routing(), routing=ipxe_update.dnsmasq_routing(),
mac_folders=ipxe_update.mac_folders(),
latest=latest, latest=latest,
status=status, status=status,
job=ipxe_update.job_state(), job=ipxe_update.job_state(),