"""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). Upstream publishes the signed set as one tarball on every release: 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 release asset, which is checked before anything is unpacked, and every EFI file's Authenticode chain is read with sbverify before it can be installed. 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. Staging runs as a background job (python3 -m services.ipxe_update stage); install and rollback are a few MB of copying and run inline. """ from __future__ import annotations import fcntl import glob import json import os import re import shutil import struct import subprocess import sys import tarfile import tempfile from datetime import datetime import config from services import updates_common as uc GITHUB_API = "https://api.github.com/repos/ipxe/ipxe/releases/latest" 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" JOB_UNIT = "pxe-webapp-ipxe-stage" # TFTP name -> path inside the tarball, and whether it must carry a # Secure Boot signature (and from whom). MANAGED = { "ipxe-shim.efi": ("ipxeboot/x86_64-sb/shimx64.efi", "microsoft"), "ipxe.efi": ("ipxeboot/x86_64-sb/ipxe.efi", "ipxe"), "ipxe.pxe": ("ipxeboot/x86_64/ipxe.pxe", None), } # Installed by default; ipxe.pxe (legacy BIOS) only when asked. DEFAULT_INSTALL = ("ipxe-shim.efi", "ipxe.efi") # Shown on the page for reference, never written. READ_ONLY = ("ipxe-bcm.efi",) NEED_LABEL = {"microsoft": "a Microsoft UEFI CA", "ipxe": "the iPXE Secure Boot CA"} # Issuer CNs, as sbverify prints them. MS_CA_2011 = "Microsoft Corporation UEFI CA 2011" MS_CA_2023 = "Microsoft UEFI CA 2023" IPXE_CA = "iPXE Secure Boot CA" def _dir(): return uc.state_dir("ipxe") def _job_file(): return os.path.join(_dir(), "job.json") # --- reading binaries ------------------------------------------------------- def signers(path): """[(subject CN, issuer CN), ...] from the Authenticode chain, [] if none. Raises FileNotFoundError when sbverify is not installed.""" r = subprocess.run(["sbverify", "--list", path], capture_output=True, text=True, timeout=60) pairs, subject = [], None for line in r.stdout.splitlines(): line = line.strip() m = re.match(r"- subject: .*?CN=([^/]+)", line) if m: subject = m.group(1).strip() continue m = re.match(r"issuer: .*?CN=([^/]+)", line) if m and subject is not None: pairs.append((subject, m.group(1).strip())) subject = None return pairs def signature_kind(pairs): """microsoft-2023 | microsoft-2011 | microsoft | ipxe | other | unsigned. A file signed under both Microsoft CAs counts as 2023.""" issuers = {i for _, i in pairs} names = " ".join(s + " " + i for s, i in pairs) if MS_CA_2023 in issuers: return "microsoft-2023" if MS_CA_2011 in issuers: return "microsoft-2011" if "Microsoft" in names: return "microsoft" if IPXE_CA in issuers: return "ipxe" return "unsigned" if not pairs else "other" def _sbat(data): """The SBAT CSV a shim or iPXE build carries, as {component: [fields]}.""" i = data.find(b"sbat,1,SBAT Version,") if i < 0: return {} text = data[i:i + 4096].split(b"\0", 1)[0].decode("ascii", errors="replace") out = {} for line in text.splitlines(): fields = line.split(",") if len(fields) >= 2 and fields[0] != "sbat": out[fields[0]] = fields return out def version_of(path): try: with open(path, "rb") as f: data = f.read() except OSError: return "" # A shim says so in its build ident; the shim's SBAT also names iPXE # (shim.ipxe), so test this first. m = re.search(rb"\$Version: ([^ $]{1,30})", data) if m: return "shim " + m.group(1).decode(errors="replace") sbat = _sbat(data) if "ipxe" in sbat and len(sbat["ipxe"]) >= 5: return "iPXE " + sbat["ipxe"][4] m = re.search(rb"([0-9]+\.[0-9]+\.[0-9]+\+? ?\(g[0-9a-f]+\))", data) if m: return "iPXE " + m.group(1).decode(errors="replace") return "" def sbat_generations(path): """'shim 4, shim.ipxe 1': the generation numbers a revocation compares.""" try: with open(path, "rb") as f: sbat = _sbat(f.read()) except OSError: return "" return ", ".join(f"{k} {v[1]}" for k, v in sbat.items()) def vendor_cert_cn(path): """Subject CN of the certificate a shim trusts for its second stage (the PE .vendor_cert section), or None. iPXE's shim embeds the iPXE Secure Boot CA, so ipxe.efi must chain to that name.""" try: with open(path, "rb") as f: d = f.read() pe = struct.unpack_from(" 2: continue if s.startswith("conf-file="): out += _conf_lines(s.split("=", 1)[1].strip(), depth + 1, seen) elif s.startswith("conf-dir="): parts = s.split("=", 1)[1].split(",") out += _conf_dir_lines(parts[0].strip(), [p.strip() for p in parts[1:]], depth, seen) if depth == 0: implicit = os.path.join(os.path.dirname(path), "dnsmasq.d") if os.path.isdir(implicit): out += _conf_dir_lines(implicit, list(DEBIAN_CONF_DIR_EXCLUDE), depth, seen) return out def dnsmasq_routing(): """Which boot file each class of client gets, read from dnsmasq.conf. Read-only: this module never edits dnsmasq.""" tag_macs, boots, raw = {}, [], [] for src, s in _conf_lines(config.DNSMASQ_CONF): key, _, val = s.partition("=") key = key.strip() if key == "dhcp-host": fields = [f.strip() for f in val.split(",")] macs = [f.lower() for f in fields if MAC_RE.match(f)] for f in fields: if f.startswith("set:"): tag_macs.setdefault(f[4:], []).extend(macs) elif key == "dhcp-boot": fields = [f.strip() for f in val.split(",")] need = [f[4:] for f in fields if f.startswith("tag:") and not f.startswith("tag:!")] exclude = [f[5:] for f in fields if f.startswith("tag:!")] rest = [f for f in fields if not f.startswith("tag:")] 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"): if key != "dhcp-host" or "set:" in val: raw.append(s) for b in boots: b["macs"] = sorted({m for t in b["need"] for m in tag_macs.get(t, [])}) # A path like ipxe2/ipxe-shim.efi is another copy, not the file in # the TFTP root that this page installs. rel = b["file"].lstrip("/") b["managed"] = rel in MANAGED 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" for m in b["macs"]})} # --- upstream --------------------------------------------------------------- def _version_tuple(text): m = re.search(r"(\d+)\.(\d+)(?:\.(\d+))?", text or "") return tuple(int(x or 0) for x in m.groups()) if m else None def compare(tag, installed_version): """'update' | 'current' | 'newer' (installed is ahead) | 'unknown'.""" a, b = _version_tuple(tag), _version_tuple(installed_version) if not a or not b: return "unknown" return "update" if a > b else "current" if a == b else "newer" def check_latest(timeout=20): """Latest GitHub release: tag, date, tarball URL and sha256. Cached in latest.json so the page and the stage job agree on what to fetch.""" try: rel = json.loads(uc.fetch_text(GITHUB_API, timeout=timeout)) except ValueError: raise RuntimeError("GitHub returned something that is not JSON (proxy login page?)") asset = next((a for a in rel.get("assets", []) if a.get("name") == TARBALL), None) if not rel.get("tag_name"): raise RuntimeError("GitHub answer has no release tag") if not asset: raise RuntimeError(f"release {rel.get('tag_name')} has no {TARBALL}") digest = str(asset.get("digest") or "") page = str(rel.get("html_url") or "") # shown as a link on the page info = { "tag": rel.get("tag_name"), "name": rel.get("name") or "", "published": rel.get("published_at"), "page": page if page.startswith("https://") else RELEASES_PAGE, "url": asset.get("browser_download_url"), "size": asset.get("size"), "sha256": digest.split(":", 1)[1].lower() if digest.startswith("sha256:") else "", "checked": uc.now(), } uc.write_json(os.path.join(_dir(), "latest.json"), info) return info def last_check(): return uc.read_json(os.path.join(_dir(), "latest.json")) def _safe_tag(tag): tag = re.sub(r"[^\w.-]", "_", str(tag or "")) if not tag or tag in (".", ".."): raise ValueError("bad release tag") return tag def _resolve(members, name): """Follow symlinks/hardlinks inside the archive to a regular member.""" for _ in range(8): m = members.get(name) if m is None or m.isfile(): return m if m.issym(): name = os.path.normpath(os.path.join(os.path.dirname(name), m.linkname)) elif m.islnk(): name = os.path.normpath(m.linkname) else: return None return None def _progress(**fields): data = uc.read_json(_job_file(), {}) or {} data.update(fields, updated=uc.now()) uc.write_json(_job_file(), data) def stage(info=None): """Download the tarball, verify it, unpack the managed files to staged// and check each one's signature. Returns the staged record.""" info = info or last_check() or check_latest() if not info.get("sha256"): raise RuntimeError("GitHub did not publish a sha256 for this release; stage it by hand") folder = os.path.join(_dir(), "staged", _safe_tag(info["tag"])) os.makedirs(folder, exist_ok=True) tar_path = os.path.join(folder, TARBALL) _progress(step="download", tag=info["tag"], size=info.get("size"), tarball=tar_path) if os.path.isfile(tar_path) and uc.file_hash(tar_path).lower() != info["sha256"].lower(): # A partial is resumed. A wrong file at full size or more cannot be # (curl fails with 416/36 on every retry), so start it over. size = info.get("size") or 0 if not size or os.path.getsize(tar_path) >= size: os.remove(tar_path) if not (os.path.isfile(tar_path) and uc.file_hash(tar_path).lower() == info["sha256"].lower()): # Room for a slow cap: twice the time the cap allows, at least 10 min. cap = float(uc.settings().get("max_mbps") or 0) size = float(info.get("size") or 0) timeout = max(600, int(2 * size / (cap * 1024 * 1024))) if cap and size else 600 uc.download(info["url"], tar_path, expect=info["sha256"], timeout=timeout) _progress(step="verify") files = {} with tarfile.open(tar_path) as tar: members = {os.path.normpath(m.name[2:] if m.name.startswith("./") else m.name): m for m in tar.getmembers()} for name, (inner, need) in MANAGED.items(): m = _resolve(members, inner) if m is None: files[name] = {"ok": False, "error": f"{inner} not in the tarball"} continue dest = os.path.join(folder, name) tmp = dest + ".part" with tar.extractfile(m) as src, open(tmp, "wb") as out: shutil.copyfileobj(src, out) os.replace(tmp, dest) d = describe(dest) if d["sig_error"]: ok, error = False, d["sig_error"] elif need and not d["kind"].startswith(need): ok, error = False, f"expected a signature under {NEED_LABEL[need]}, found {d['kind']}" elif not d["size"]: ok, error = False, "empty file" 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") 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 staged(tag=None): root = os.path.join(_dir(), "staged") if not os.path.isdir(root): return [] out = [] for t in sorted(os.listdir(root), reverse=True): rec = uc.read_json(os.path.join(root, t, "staged.json")) if not (isinstance(rec, dict) and rec.get("tag") and isinstance(rec.get("files"), dict) and rec["files"]): continue # a hand-edited or half-written record has nothing to install if tag is None or rec.get("tag") == tag: # Where it is now, not the path recorded at staging time. rec["folder"] = os.path.join(root, t) out.append(rec) out.sort(key=lambda r: r.get("staged") or "", reverse=True) return out # --- background stage job --------------------------------------------------- def job_state(): """Last stage job's progress, with 'running' from systemd and, while downloading, a percent from the partial file size.""" data = uc.read_json(_job_file(), {}) or {} try: running = uc.job_running(JOB_UNIT) except OSError: running = False data["running"] = running if data.get("state") in ("queued", "running") and not running: # Give systemd-run a moment to bring the unit up before calling it dead. try: age = datetime.now().timestamp() - os.path.getmtime(_job_file()) except OSError: age = 0 if age > 20: data["state"] = "interrupted" if data.get("step") == "download" and data.get("size") and data.get("tarball"): try: done = os.path.getsize(data["tarball"]) data["percent"] = min(100, int(100 * done / data["size"])) except OSError: data["percent"] = 0 return data def start_stage(): """Queue the stage job (systemd-run). The page polls job_state().""" if uc.job_running(JOB_UNIT): raise RuntimeError("a download is already running") info = last_check() if not info: raise RuntimeError("check for updates first") uc.write_json(_job_file(), {"state": "queued", "tag": info["tag"], "started": uc.now(), "updated": uc.now()}) try: uc.start_job(JOB_UNIT, ["services.ipxe_update", "stage"]) except Exception as ex: uc.write_json(_job_file(), {"state": "error", "tag": info["tag"], "error": str(ex), "updated": uc.now()}) raise def run_stage_job(): """Entry point of the background job. Never raises; the outcome goes to job.json.""" _progress(state="running", step="start", error="", started=uc.now()) try: rec = stage(last_check()) bad = [n for n, f in rec["files"].items() if not f.get("ok")] _progress(state="done", step="done", tag=rec["tag"], finished=uc.now(), failed=bad) return 0 except Exception as ex: # job boundary: record any failure for the page _progress(state="error", error=str(ex)[:500], finished=uc.now()) return 1 # --- install / rollback ----------------------------------------------------- def _new_folder(root): """A fresh timestamped folder; never reuses one (a rollback in the same second as its backup must not overwrite the files it restores).""" base = os.path.join(root, datetime.now().strftime("%Y%m%d-%H%M%S")) folder, n = base, 1 while True: try: os.makedirs(folder) return folder except FileExistsError: folder, n = f"{base}-{n}", n + 1 def _backup(names, reason): folder = _new_folder(os.path.join(_dir(), "backups")) saved, absent = [], [] for name in names: src = os.path.join(config.TFTP_ROOT, name) if os.path.isfile(src): shutil.copy2(src, os.path.join(folder, name)) saved.append(name) else: absent.append(name) uc.write_json(os.path.join(folder, "backup.json"), {"reason": reason, "files": saved, "absent": absent, "at": uc.now()}) return folder class _Lock: """One install or rollback at a time: two at once would back up each other's half-placed files.""" def __enter__(self): self.f = open(os.path.join(_dir(), ".install.lock"), "w") try: fcntl.flock(self.f, fcntl.LOCK_EX | fcntl.LOCK_NB) except OSError: self.f.close() raise RuntimeError("another install or rollback is running; try again") from None return self def __exit__(self, *exc): self.f.close() def _prepare(src, name, sha256=None): """Copy src next to its TFTP name under a temporary name, fsynced, mode 0644 and with the current file's owner. With sha256, the copy is checked. Returns the temporary path; nothing is live yet.""" dest = os.path.join(config.TFTP_ROOT, name) fd, tmp = tempfile.mkstemp(dir=config.TFTP_ROOT, prefix=f".{name}.", suffix=".new") try: with open(src, "rb") as s, os.fdopen(fd, "wb") as out: shutil.copyfileobj(s, out) out.flush() os.fsync(out.fileno()) if sha256 and uc.file_hash(tmp).lower() != sha256.lower(): raise RuntimeError(f"{name}: copy does not match its sha256, nothing replaced") os.chmod(tmp, 0o644) try: st = os.stat(dest) os.chown(tmp, st.st_uid, st.st_gid) except OSError: pass # no current file, or not root (local tests) return tmp except BaseException: os.remove(tmp) raise def _place_all(items): """[(src, name, sha256)] into the TFTP root. Every copy is made and checked first, then each is renamed into place (atomic per file, so a booting client never reads half a file). A failed copy leaves all the live files as they were.""" ready = [] try: for src, name, sha256 in items: ready.append((_prepare(src, name, sha256), name)) except BaseException: for tmp, _ in ready: os.remove(tmp) raise for tmp, name in ready: os.replace(tmp, os.path.join(config.TFTP_ROOT, name)) def install(tag, names=DEFAULT_INSTALL): """Copy staged files for `tag` into the TFTP root after backing up the current ones. Refuses a set with a failed signature check, and refuses to install ipxe-shim.efi without its matching ipxe.efi (the shim only trusts the iPXE CA the same release signs with). Returns the backup folder.""" rec = next(iter(staged(tag)), None) if not rec: raise ValueError(f"{tag} is not staged") 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 uc.job_running(JOB_UNIT): raise RuntimeError("a download is running; wait for it to finish") with _Lock(): return _install(tag, rec, names) def _install(tag, rec, names): for n in names: f = rec["files"].get(n) or {} if not f.get("ok"): raise ValueError(f"{n}: {f.get('error') or 'not staged'}") 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. 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") 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() for n in names: log[n] = {"tag": tag, "sha256": rec["files"][n]["sha256"], "at": uc.now()} uc.write_json(os.path.join(_dir(), "installed.json"), log) return backup def backups(): root = os.path.join(_dir(), "backups") if not os.path.isdir(root): return [] out = [] for b in sorted(os.listdir(root), reverse=True): folder = os.path.join(root, b) if not os.path.isdir(folder): continue meta = uc.read_json(os.path.join(folder, "backup.json"), {}) meta = meta if isinstance(meta, dict) else {} files = [f for f in MANAGED if os.path.isfile(os.path.join(folder, f))] out.append({"id": b, "reason": meta.get("reason", ""), "at": meta.get("at", ""), "files": files, "absent": meta.get("absent", []), "versions": {f: version_of(os.path.join(folder, f)) for f in files}}) return out def rollback(backup_id): """Put the files of one backup back, after backing up what is there now.""" bid = os.path.basename(str(backup_id or "")) folder = os.path.join(_dir(), "backups", bid) if not bid or bid.startswith(".") or not os.path.isdir(folder): raise ValueError("no such backup") files = [f for f in MANAGED if os.path.isfile(os.path.join(folder, f))] if not files: raise ValueError("that backup holds no boot files") with _Lock(): _backup(files, f"before rolling back to {bid}") _place_all([(os.path.join(folder, f), f, uc.file_hash(os.path.join(folder, f))) for f in files]) log = _installed_log() for f in files: log.pop(f, None) uc.write_json(os.path.join(_dir(), "installed.json"), log) return files if __name__ == "__main__": if sys.argv[1:2] == ["stage"]: sys.exit(run_stage_job()) print("usage: python3 -m services.ipxe_update stage", file=sys.stderr) sys.exit(2)