From 572afb2624cac6f33bb46996d669f8e09dc96b06 Mon Sep 17 00:00:00 2001 From: Michal Date: Tue, 11 Aug 2026 13:03:53 +0100 Subject: [PATCH] fix(bastion): refuse to serve an x86-only kernel to an arm64 client The install guard catches an unsupported OS/architecture pair when the machine's architecture is already known, but a machine queued before it was ever discovered reaches dispatch with nothing having checked. Serving it the x86-only Ubuntu kernel is exactly the failure this work exists to fix, so stop with a legible reason on the console instead -- a machine handed a kernel it cannot execute fails later and far less clearly. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015nRFZXpKwUVE4SRSHw6GjF --- bastion/src/bastion/src/routes/dispatch.ts | 19 ++++++++++-- .../src/bastion/src/templates/boot.ipxe.ts | 28 +++++++++++++++++ .../src/bastion/tests/arch-dispatch.test.ts | 30 +++++++++++++++++++ .../integration/arm-pxe-provision.test.ts | 7 +++-- 4 files changed, 79 insertions(+), 5 deletions(-) diff --git a/bastion/src/bastion/src/routes/dispatch.ts b/bastion/src/bastion/src/routes/dispatch.ts index 7392c68..85e44aa 100644 --- a/bastion/src/bastion/src/routes/dispatch.ts +++ b/bastion/src/bastion/src/routes/dispatch.ts @@ -5,8 +5,8 @@ // - unknown -> discovery mode (collect hardware, POST to bastion) import type { FastifyInstance } from "fastify"; -import type { Arch, BastionConfig, BastionState } from "@lab/shared"; -import { normalizeArch, fedoraMirrorFor } from "@lab/shared"; +import type { Arch, BastionConfig, BastionState, OsId } from "@lab/shared"; +import { normalizeArch, fedoraMirrorFor, osSupportsArch } from "@lab/shared"; import type { StateManager } from "../services/state.js"; import { renderDiscoverIpxe, @@ -14,6 +14,7 @@ import { renderDebugIpxe, renderPxeBootDebugIpxe, renderLocalBootIpxe, + renderUnsupportedIpxe, } from "../templates/boot.ipxe.js"; import { renderUbuntuInstallIpxe } from "../templates/ubuntu-boot.ipxe.js"; import { renderDebugKickstart } from "../templates/debug.ks.js"; @@ -215,6 +216,20 @@ echo "===============================" let script: string; if (os.startsWith("ubuntu")) { + // Last line of defence. The install guard refuses this combination when the + // machine's architecture is already known, but a machine queued before it was + // discovered can reach here. Serving the x86-only Ubuntu kernel to an arm64 + // client is precisely the bug this work exists to fix, so stop instead. + if (!osSupportsArch(os as OsId, arch)) { + logger.error(`INSTALL BLOCKED: ${mac} -> ${hostname} -- ${os} has no ${arch} artifacts`); + script = renderUnsupportedIpxe({ + hostname, + mac, + reason: `${os} publishes no ${arch} netboot artifacts`, + action: `labctl provision install ${mac} ${hostname} --os fedora-43`, + }); + return reply.type("text/plain").send(script); + } script = renderUbuntuInstallIpxe({ mac, hostname, diff --git a/bastion/src/bastion/src/templates/boot.ipxe.ts b/bastion/src/bastion/src/templates/boot.ipxe.ts index c6d9ffb..318bc88 100644 --- a/bastion/src/bastion/src/templates/boot.ipxe.ts +++ b/bastion/src/bastion/src/templates/boot.ipxe.ts @@ -212,6 +212,34 @@ boot `; } +/** + * iPXE script for a request we refuse to serve. + * + * Better a machine that stops with a legible reason on its console than one handed a + * kernel it cannot execute, which fails much later and much less clearly. + */ +export function renderUnsupportedIpxe(params: { + mac: string; + hostname: string; + reason: string; + action?: string; +}): string { + return `#!ipxe + +echo +echo ============================================= +echo Lab PXE Bastion - CANNOT BOOT THIS MACHINE +echo Target: ${params.hostname} +echo MAC: ${params.mac} +echo +echo ${params.reason} +${params.action !== undefined ? `echo\necho Try: ${params.action}\n` : ""}echo ============================================= +echo +sleep 10 +exit 1 +`; +} + /** * iPXE script for already-installed machines -- exits to boot from local disk. */ diff --git a/bastion/src/bastion/tests/arch-dispatch.test.ts b/bastion/src/bastion/tests/arch-dispatch.test.ts index 3d3c648..b9c6054 100644 --- a/bastion/src/bastion/tests/arch-dispatch.test.ts +++ b/bastion/src/bastion/tests/arch-dispatch.test.ts @@ -141,6 +141,36 @@ describe("aarch64 dispatch", () => { expect(res.body).not.toContain("nomodeset"); }); + it("refuses to serve the x86-only Ubuntu kernel to an arm64 client", async () => { + // A machine queued for Ubuntu before it was discovered as aarch64 reaches dispatch + // with no guard having run. Serving it /ubuntu-vmlinuz is the original bug. + state.update((s) => { + s.install_queue[mac] = { + hostname: "arm-node", disk: "", role: "worker", + os: "ubuntu-26.04", queued_at: new Date().toISOString(), + }; + }); + + const res = await app.inject({ method: "GET", url: `/dispatch?mac=${mac}&arch=arm64` }); + expect(res.statusCode).toBe(200); + expect(res.body).toContain("CANNOT BOOT THIS MACHINE"); + expect(res.body).toContain("no aarch64 netboot artifacts"); + expect(res.body).not.toContain("ubuntu-vmlinuz"); + }); + + it("still serves Ubuntu to an x86_64 client", async () => { + state.update((s) => { + s.install_queue[mac] = { + hostname: "x86-node", disk: "", role: "worker", + os: "ubuntu-26.04", queued_at: new Date().toISOString(), + }; + }); + + const res = await app.inject({ method: "GET", url: `/dispatch?mac=${mac}&arch=x86_64` }); + expect(res.body).toContain("ubuntu-vmlinuz"); + expect(res.body).not.toContain("CANNOT BOOT"); + }); + it("serves a rescue kernel for the recorded architecture, not the requester's", async () => { // The Spark case: machine known to be aarch64, queued for rescue. state.update((s) => { diff --git a/bastion/tests/integration/arm-pxe-provision.test.ts b/bastion/tests/integration/arm-pxe-provision.test.ts index f0be42f..44b4d6f 100644 --- a/bastion/tests/integration/arm-pxe-provision.test.ts +++ b/bastion/tests/integration/arm-pxe-provision.test.ts @@ -170,9 +170,10 @@ async function startHarness(vmName: string, httpPort: number, pubKey: string): P const { loadConfig } = await import("../../src/bastion/src/config.js"); const { generateDnsmasqConf, startDnsmasq, stopDnsmasq } = await import("../../src/bastion/src/services/dnsmasq.js"); const { generateDiscoverKickstart } = await import("../../src/bastion/src/services/kickstart-generator.js"); - const { renderBootIpxe } = await import("../../src/bastion/src/templates/boot.ipxe.js"); - const { kernelPath, initrdPath } = await import("../../src/bastion/src/templates/boot.ipxe.js"); - const { SUPPORTED_ARCHES, fedoraMirrorFor } = await import("@lab/shared"); + const { renderBootIpxe, kernelPath, initrdPath } = await import("../../src/bastion/src/templates/boot.ipxe.js"); + // Relative, not "@lab/shared": these tests run from the repo root against sources, + // where the workspace package alias is not resolvable. + const { SUPPORTED_ARCHES, fedoraMirrorFor } = await import("../../src/shared/src/hardware/index.js"); const config = loadConfig({ bastionDir: testDir,