Updates group: - Dell drivers and BIOS: check Dell's catalogs against all three driver maps and models.txt, download with a shared speed cap, repack, preview, apply with backups and rollback. - iPXE boot files: check GitHub for the signed Secure Boot set, verify the sha256 and each file's signer, install atomically, roll back. Includes the by-hand procedure. - Blancco: upload a release, stage it, test-boot it from the menu, activate, roll back. - BPRT sync: validate a batch uploaded from the MediaCreator Lite cache (scripts/windows/Sync-BPRT.ps1 or a zip), show the diff, swap with the site overlay files kept. Images: one sidebar entry per image with Overview, Drivers, Unattend and OS and Packages tabs. Every save shows a diff first, keeps the file's format, makes a backup and can be undone. Driver lint runs inline. Enrollment: upload standard/engineer MCL packages, one per tenant/purpose, with archive and restore. Hardware coverage counts engineer as Win11.
725 lines
29 KiB
Python
725 lines
29 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 (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("<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 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,
|
|
"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.efi 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"]
|
|
return rows
|
|
|
|
|
|
# --- 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"):
|
|
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/<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)
|
|
# 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)
|