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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015nRFZXpKwUVE4SRSHw6GjF
This commit is contained in:
Michal
2026-08-11 13:03:53 +01:00
parent 3fab400a96
commit 572afb2624
4 changed files with 79 additions and 5 deletions

View File

@@ -5,8 +5,8 @@
// - unknown -> discovery mode (collect hardware, POST to bastion) // - unknown -> discovery mode (collect hardware, POST to bastion)
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
import type { Arch, BastionConfig, BastionState } from "@lab/shared"; import type { Arch, BastionConfig, BastionState, OsId } from "@lab/shared";
import { normalizeArch, fedoraMirrorFor } from "@lab/shared"; import { normalizeArch, fedoraMirrorFor, osSupportsArch } from "@lab/shared";
import type { StateManager } from "../services/state.js"; import type { StateManager } from "../services/state.js";
import { import {
renderDiscoverIpxe, renderDiscoverIpxe,
@@ -14,6 +14,7 @@ import {
renderDebugIpxe, renderDebugIpxe,
renderPxeBootDebugIpxe, renderPxeBootDebugIpxe,
renderLocalBootIpxe, renderLocalBootIpxe,
renderUnsupportedIpxe,
} from "../templates/boot.ipxe.js"; } from "../templates/boot.ipxe.js";
import { renderUbuntuInstallIpxe } from "../templates/ubuntu-boot.ipxe.js"; import { renderUbuntuInstallIpxe } from "../templates/ubuntu-boot.ipxe.js";
import { renderDebugKickstart } from "../templates/debug.ks.js"; import { renderDebugKickstart } from "../templates/debug.ks.js";
@@ -215,6 +216,20 @@ echo "==============================="
let script: string; let script: string;
if (os.startsWith("ubuntu")) { 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({ script = renderUbuntuInstallIpxe({
mac, mac,
hostname, hostname,

View File

@@ -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. * iPXE script for already-installed machines -- exits to boot from local disk.
*/ */

View File

@@ -141,6 +141,36 @@ describe("aarch64 dispatch", () => {
expect(res.body).not.toContain("nomodeset"); 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 () => { 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. // The Spark case: machine known to be aarch64, queued for rescue.
state.update((s) => { state.update((s) => {

View File

@@ -170,9 +170,10 @@ async function startHarness(vmName: string, httpPort: number, pubKey: string): P
const { loadConfig } = await import("../../src/bastion/src/config.js"); const { loadConfig } = await import("../../src/bastion/src/config.js");
const { generateDnsmasqConf, startDnsmasq, stopDnsmasq } = await import("../../src/bastion/src/services/dnsmasq.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 { generateDiscoverKickstart } = await import("../../src/bastion/src/services/kickstart-generator.js");
const { renderBootIpxe } = await import("../../src/bastion/src/templates/boot.ipxe.js"); const { renderBootIpxe, kernelPath, initrdPath } = await import("../../src/bastion/src/templates/boot.ipxe.js");
const { kernelPath, initrdPath } = await import("../../src/bastion/src/templates/boot.ipxe.js"); // Relative, not "@lab/shared": these tests run from the repo root against sources,
const { SUPPORTED_ARCHES, fedoraMirrorFor } = await import("@lab/shared"); // where the workspace package alias is not resolvable.
const { SUPPORTED_ARCHES, fedoraMirrorFor } = await import("../../src/shared/src/hardware/index.js");
const config = loadConfig({ const config = loadConfig({
bastionDir: testDir, bastionDir: testDir,