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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user