Files
pxe-server/webapp/services/ipxe_update.py
cproudlock 81c2d85301 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.
2026-10-14 15:37:14 -04:00

837 lines
34 KiB
Python

"""Keep the Secure Boot iPXE chain on the TFTP root current.
What dnsmasq serves (see /etc/dnsmasq.conf):
* 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
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 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.
"""
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"
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"
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"),
"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", "snponly-shim.efi", "ipxe.efi", "snponly.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("<I", d, 0x3C)[0]
if d[pe:pe + 4] != b"PE\0\0":
return None
nsec = struct.unpack_from("<H", d, pe + 6)[0]
symptr, nsym = struct.unpack_from("<II", d, pe + 12)
optsz = struct.unpack_from("<H", d, pe + 20)[0]
strtab = symptr + nsym * 18
off = pe + 24 + optsz
for i in range(nsec):
raw, _vsize, _vaddr, _rsize, rptr = struct.unpack_from("<8sIIII", d, off + 40 * i)
name = raw.rstrip(b"\0")
if name.startswith(b"/") and symptr: # long name, in the COFF string table
o = strtab + int(name[1:])
name = d[o:d.index(b"\0", o)]
if name != b".vendor_cert":
continue
size, _dsize, cert_off, _doff = struct.unpack_from("<IIII", d, rptr)
cert = d[rptr + cert_off:rptr + cert_off + size]
# DER: every CN is OID 2.5.4.3 then a short string. Issuer comes
# before subject, so the last one is the subject.
cns = [cert[m.end() + 2:m.end() + 2 + cert[m.end() + 1]].decode(errors="replace")
for m in re.finditer(rb"\x06\x03\x55\x04\x03(?=[\x0c\x13\x16])", cert)]
return cns[-1] if cns else None
except (OSError, struct.error, ValueError, IndexError):
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):
if not os.path.isfile(path):
return None
st = os.stat(path)
efi = path.endswith(".efi")
pairs, error = [], ""
if efi:
try:
pairs = signers(path)
except (OSError, subprocess.SubprocessError) as ex:
error = f"sbverify failed: {ex}"
return {
"size": st.st_size, "modified": st.st_mtime,
"sha256": uc.file_hash(path), "version": version_of(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,
}
def _installed_log():
"""{name: {tag, sha256, at}}: which release each installed file came from."""
log = uc.read_json(os.path.join(_dir(), "installed.json"), {})
return log if isinstance(log, dict) else {}
def installed():
log = _installed_log()
rows = []
for name in list(MANAGED) + list(READ_ONLY):
info = describe(os.path.join(config.TFTP_ROOT, name))
origin = log.get(name) or {}
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 signed under the CA it embeds.
by = {r["name"]: r["info"] for r in rows}
_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}$")
# Debian/Ubuntu start dnsmasq with -7 /etc/dnsmasq.d,.dpkg-dist,.dpkg-old,
# .dpkg-new (CONFIG_DIR in /etc/default/dnsmasq), so that folder is read even
# when dnsmasq.conf never names it.
DEBIAN_CONF_DIR_EXCLUDE = (".dpkg-dist", ".dpkg-old", ".dpkg-new")
def _conf_dir_lines(folder, wanted, depth, seen):
"""dnsmasq conf-dir rules: '*.conf' entries are the only names read
(if any are given), other entries are suffixes to skip, and dot files
and '~' backups are always skipped."""
include = [w[1:] for w in wanted if w.startswith("*")]
exclude = [w for w in wanted if w and not w.startswith("*")]
out = []
for p in sorted(glob.glob(os.path.join(folder, "*"))):
base = os.path.basename(p)
if base.startswith(".") or base.endswith("~") or not os.path.isfile(p):
continue
if include and not any(base.endswith(w) for w in include):
continue
if any(base.endswith(w) for w in exclude):
continue
out += _conf_lines(p, depth + 1, seen)
return out
def _conf_lines(path, depth=0, seen=None):
"""Lines of dnsmasq.conf plus any conf-file/conf-dir it pulls in. Each
file is read once, so an include named twice does not list MACs twice."""
seen = set() if seen is None else seen
real = os.path.realpath(path)
if real in seen:
return []
seen.add(real)
try:
with open(path, encoding="utf-8", errors="replace") as f:
lines = f.read().splitlines()
except OSError:
return []
out = []
for line in lines:
s = line.split("#", 1)[0].strip()
if not s:
continue
out.append((path, s))
if depth > 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", "tftp-unique-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,
"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"]})}
# --- 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/<tag>/ 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)
_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):
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 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():
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 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"))
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()
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)