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,13 +1,25 @@
"""Keep the Secure Boot iPXE chain on the TFTP root current.
What dnsmasq serves (see /etc/dnsmasq.conf):
* ipxe-shim.efi - Microsoft-signed shim (upstream x86_64-sb/shimx64.efi),
for the MACs tagged ipxe2; it always loads ipxe.efi from
the TFTP ROOT, whatever folder it came from;
* ipxe.efi - iPXE signed by the iPXE Secure Boot CA, loaded by the shim;
* ipxe-bcm.efi - the old 2021 Broadcom-signed build, everyone else.
Never touched here;
* ipxe.pxe - legacy BIOS iPXE (unsigned; BIOS has no Secure Boot).
* ipxe-shim.efi - Microsoft-signed shim, for the MACs tagged ipxe2. Over
TFTP it always loads ipxe.efi from the TFTP root (it
does not derive the name from its own, whatever the
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.
Never touched here;
* 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:
https://github.com/ipxe/ipxe/releases/latest/download/ipxeboot.tar.gz, with
@@ -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
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
UEFI CA 2011, whose signing period ended in 2026. Machines that only trust
the 2023 CA, or a revocation (DBX/SBAT) update, will refuse them; the fix is
a newer upstream release, installed here.
Why it matters: the Broadcom build, and the shim in the iPXE tarball, are
signed only by Microsoft's UEFI CA 2011, whose signing period ended in 2026.
Machines that only trust the 2023 CA, or a revocation (DBX/SBAT) update,
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);
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
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"
LATEST_TARBALL_URL = "https://github.com/ipxe/ipxe/releases/latest/download/ipxeboot.tar.gz"
TARBALL = "ipxeboot.tar.gz"
@@ -54,11 +71,14 @@ JOB_UNIT = "pxe-webapp-ipxe-stage"
# Secure Boot signature (and from whom).
MANAGED = {
"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"),
"snponly.efi": ("ipxeboot/x86_64-sb/snponly.efi", "ipxe"),
"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.
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.
READ_ONLY = ("ipxe-bcm.efi",)
@@ -193,6 +213,20 @@ def vendor_cert_cn(path):
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):
if not os.path.isfile(path):
return None
@@ -210,6 +244,7 @@ def describe(path):
"sbat": sbat_generations(path) if efi else "",
"signers": pairs, "kind": (signature_kind(pairs) if not error else "error") if efi else "bios",
"trusts": vendor_cert_cn(path) if efi else None,
"drivers": drivers_of(path) if efi else "",
"sig_error": error,
}
@@ -229,15 +264,46 @@ def installed():
if info and origin.get("sha256") == info["sha256"]:
info["release"] = origin.get("tag")
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}
shim, ipxe = by.get("ipxe-shim.efi"), by.get("ipxe.efi")
if shim and ipxe and shim.get("trusts"):
ipxe["chain_ok"] = shim["trusts"] in {i for _, i in ipxe["signers"]}
ipxe["trusts_needed"] = shim["trusts"]
_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"):
ipxe["chain_ok"] = shim["trusts"] in {i for _, i in ipxe["signers"]}
ipxe["trusts_needed"] = shim["trusts"]
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 ----------------------------------------------------------------
MAC_RE = re.compile(r"^(?:[0-9a-fA-F*]{1,2}[:-]){5}[0-9a-fA-F*]{1,2}$")
@@ -322,7 +388,7 @@ def dnsmasq_routing():
boots.append({"file": rest[0] if rest else "", "need": need, "exclude": exclude,
"line": s, "source": src})
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:
raw.append(s)
for b in boots:
@@ -334,7 +400,8 @@ def dnsmasq_routing():
b["read_only"] = rel in READ_ONLY
b["other_copy"] = rel not in MANAGED and os.path.basename(rel) in MANAGED
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"]})}
@@ -459,17 +526,59 @@ def stage(info=None):
else:
ok, error = True, ""
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.
shim, ipxe = files.get("ipxe-shim.efi") or {}, files.get("ipxe.efi") or {}
if shim.get("ok") and ipxe.get("ok") and shim.get("trusts"):
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")
_progress(step="shim")
_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["trusts"] not in {i for _, i in ipxe["signers"]}:
ipxe.update(ok=False, error=f"not signed under {shim['trusts']}, which this shim trusts")
record = {"tag": info["tag"], "published": info.get("published"), "folder": folder,
"tarball_sha256": info["sha256"], "files": files, "staged": uc.now()}
uc.write_json(os.path.join(folder, "staged.json"), 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):
root = os.path.join(_dir(), "staged")
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]
if not names:
raise ValueError("nothing selected")
if "ipxe-shim.efi" in names and "ipxe.efi" not in names:
raise ValueError("install ipxe-shim.efi together with ipxe.efi")
if any(n in names for n in SHIMS) and "ipxe.efi" not in names:
raise ValueError("install a shim together with ipxe.efi")
if uc.job_running(JOB_UNIT):
raise RuntimeError("a download is running; wait for it to finish")
with _Lock():
@@ -663,13 +772,16 @@ def _install(tag, rec, names):
path = os.path.join(rec["folder"], n)
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")
if "ipxe.efi" in names and "ipxe-shim.efi" not in names:
# ipxe.efi alone must still be one the installed shim will start.
if not any(n in names for n in SHIMS):
# iPXE alone must still be one the installed shim will start.
trusts = vendor_cert_cn(os.path.join(config.TFTP_ROOT, "ipxe-shim.efi"))
issuers = {i for _, i in rec["files"]["ipxe.efi"].get("signers", [])}
if trusts and trusts not in issuers:
raise ValueError(f"the installed shim trusts {trusts}; this ipxe.efi is not signed "
"under it, install the shim from the same release too")
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:
raise ValueError(f"the installed shim trusts {trusts}; this {n} is not signed "
"under it, install the shim from the same release too")
backup = _backup(names, f"before installing {tag}")
_place_all([(os.path.join(rec["folder"], n), n, rec["files"][n]["sha256"]) for n in names])
log = _installed_log()