Webapp: Updates pages, image page tabs with review-before-save, BPRT sync
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.
This commit is contained in:
724
webapp/services/ipxe_update.py
Normal file
724
webapp/services/ipxe_update.py
Normal file
@@ -0,0 +1,724 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user