diff --git a/bastion/src/bastion/tests/arch-dispatch.test.ts b/bastion/src/bastion/tests/arch-dispatch.test.ts new file mode 100644 index 0000000..3d3c648 --- /dev/null +++ b/bastion/src/bastion/tests/arch-dispatch.test.ts @@ -0,0 +1,261 @@ +// aarch64 support in the PXE dispatch path. +// +// The x86_64 side is pinned separately by ipxe-x86-regression.test.ts. + +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdirSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import type { BastionConfig, BastionState, HardwareInfo } from "@lab/shared"; +import { createApp } from "../src/server.js"; +import { resolveArch } from "../src/routes/dispatch.js"; +import { renderDnsmasqConf } from "../src/templates/dnsmasq.conf.js"; +import type { FastifyInstance } from "fastify"; +import type { StateManager } from "../src/services/state.js"; + +function createTestConfig(testDir: string): BastionConfig { + return { + fedoraVersion: "43", + arch: "x86_64", + httpPort: 0, + timezone: "Europe/London", + locale: "en_GB.UTF-8", + bastionDir: testDir, + domain: "test.local", + dhcpMode: "proxy", + dhcpRangeStart: "", + dhcpRangeEnd: "", + ubuntuVersion: "26.04", + ubuntuMirror: "https://releases.ubuntu.com/26.04", + iface: "eth0", + serverIp: "10.0.0.1", + network: "10.0.0.0", + gateway: "10.0.0.1", + sshKeys: ["ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITEST test@test"], + adminUser: "testadmin", + syslogPort: 15514, + skipDnsmasq: true, + skipArtifacts: true, + fedoraMirror: "https://download.fedoraproject.org/pub/fedora/linux/releases/43/Everything/x86_64/os", + tftpDir: join(testDir, "tftp"), + httpDir: join(testDir, "http"), + stateFile: join(testDir, "state.json"), + }; +} + +function hardware(mac: string, over: Partial = {}): HardwareInfo { + return { + mac, + product: "TestBox", + board: "TestBoard", + serial: "SN123", + manufacturer: "TestCorp", + cpu_model: "Test CPU", + cpu_cores: 4, + memory_gb: 16, + arch: "x86_64", + disks: [], + nics: [], + first_seen: new Date().toISOString(), + last_seen: new Date().toISOString(), + ...over, + }; +} + +const emptyState = (): BastionState => ({ + discovered: {}, install_queue: {}, installed: {}, debug: {}, +}); + +describe("architecture resolution", () => { + const config = createTestConfig("/tmp/unused"); + const mac = "aa:bb:cc:dd:ee:ff"; + + it("prefers the tracked record over what the client reports", () => { + const state = emptyState(); + state.discovered[mac] = hardware(mac, { arch: "aarch64" }); + // Client claims x86_64; the machine record says otherwise and wins. + expect(resolveArch(state, mac, "x86_64", config)).toBe("aarch64"); + }); + + it("falls back to the architecture reported at boot", () => { + expect(resolveArch(emptyState(), mac, "arm64", config)).toBe("aarch64"); + }); + + it("normalises iPXE's arm64 spelling to aarch64", () => { + expect(resolveArch(emptyState(), mac, "arm64", config)).toBe("aarch64"); + expect(resolveArch(emptyState(), mac, "x86_64", config)).toBe("x86_64"); + }); + + it("falls back to the configured default for unknown architectures", () => { + expect(resolveArch(emptyState(), mac, "riscv64", config)).toBe("x86_64"); + expect(resolveArch(emptyState(), mac, undefined, config)).toBe("x86_64"); + }); + + it("reads arch from the installed record for already-provisioned machines", () => { + const state = emptyState(); + state.installed[mac] = { + hostname: "spark", role: "worker", ip: "10.0.0.5", + installed_at: new Date().toISOString(), arch: "aarch64", + }; + expect(resolveArch(state, mac, undefined, config)).toBe("aarch64"); + }); +}); + +describe("aarch64 dispatch", () => { + let testDir: string; + let app: FastifyInstance; + let state: StateManager; + const mac = "aa:bb:cc:dd:ee:ff"; + + beforeEach(() => { + testDir = join(tmpdir(), `bastion-arch-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(join(testDir, "http"), { recursive: true }); + mkdirSync(join(testDir, "tftp"), { recursive: true }); + const result = createApp(createTestConfig(testDir)); + app = result.app; + state = result.state; + }); + + afterEach(async () => { + await app.close(); + rmSync(testDir, { recursive: true, force: true }); + }); + + it("serves the aarch64 kernel and initrd to an arm64 client", async () => { + const res = await app.inject({ method: "GET", url: `/dispatch?mac=${mac}&arch=arm64` }); + expect(res.statusCode).toBe(200); + expect(res.body).toContain("/vmlinuz-aarch64"); + expect(res.body).toContain("/initrd-aarch64.img"); + expect(res.body).not.toContain("/vmlinuz "); + }); + + it("points an arm64 client at the aarch64 Fedora mirror", async () => { + const res = await app.inject({ method: "GET", url: `/dispatch?mac=${mac}&arch=arm64` }); + expect(res.body).toContain("Everything/aarch64/os"); + expect(res.body).not.toContain("Everything/x86_64/os"); + }); + + it("uses serial console arguments and not nomodeset on arm64", async () => { + const res = await app.inject({ method: "GET", url: `/dispatch?mac=${mac}&arch=arm64` }); + expect(res.body).toContain("console=ttyAMA0,115200"); + expect(res.body).not.toContain("nomodeset"); + }); + + 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) => { + s.discovered[mac] = hardware(mac, { arch: "aarch64" }); + s.debug[mac] = { hostname: "spark-2935", queued_at: new Date().toISOString() }; + }); + + const res = await app.inject({ method: "GET", url: `/dispatch?mac=${mac}` }); + expect(res.body).toContain("DEBUG/RESCUE MODE"); + expect(res.body).toContain("/vmlinuz-aarch64"); + expect(res.body).toContain("inst.rescue"); + expect(res.body).toContain("inst.sshd"); + }); +}); + +describe("--pxe-boot root device", () => { + let testDir: string; + let app: FastifyInstance; + let state: StateManager; + const mac = "aa:bb:cc:dd:ee:ff"; + + beforeEach(() => { + testDir = join(tmpdir(), `bastion-root-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(join(testDir, "http"), { recursive: true }); + mkdirSync(join(testDir, "tftp"), { recursive: true }); + const result = createApp(createTestConfig(testDir)); + app = result.app; + state = result.state; + }); + + afterEach(async () => { + await app.close(); + rmSync(testDir, { recursive: true, force: true }); + }); + + it("uses the root device recorded on the machine", async () => { + state.update((s) => { + s.installed[mac] = { + hostname: "worker-1", role: "worker", ip: "10.0.0.50", + installed_at: new Date().toISOString(), + root_device: "/dev/mapper/otherVG-root", + root_args: "rd.lvm.lv=otherVG/root", + }; + s.debug[mac] = { hostname: "worker-1", queued_at: new Date().toISOString(), pxeBoot: true }; + }); + + const res = await app.inject({ method: "GET", url: `/dispatch?mac=${mac}` }); + expect(res.body).toContain("PXE BOOT (debug)"); + expect(res.body).toContain("root=/dev/mapper/otherVG-root"); + expect(res.body).toContain("rd.lvm.lv=otherVG/root"); + // The old hardcoded layout must not leak back in. + expect(res.body).not.toContain("labvg"); + }); + + it("falls back to rescue rather than guessing when no root device is known", async () => { + state.update((s) => { + s.installed[mac] = { + hostname: "spark-2935", role: "worker", ip: "192.168.8.12", + installed_at: new Date().toISOString(), arch: "aarch64", + }; + s.debug[mac] = { hostname: "spark-2935", queued_at: new Date().toISOString(), pxeBoot: true }; + }); + + const res = await app.inject({ method: "GET", url: `/dispatch?mac=${mac}` }); + expect(res.body).toContain("DEBUG/RESCUE MODE"); + expect(res.body).toContain("no root device is recorded"); + expect(res.body).toContain("debug-setup.sh"); + expect(res.body).not.toContain("root="); + // And it is still the right architecture. + expect(res.body).toContain("/vmlinuz-aarch64"); + }); + + it("records a root device reported from a rescue shell without erasing hardware info", async () => { + state.update((s) => { + s.discovered[mac] = hardware(mac, { product: "DGX Spark", manufacturer: "NVIDIA", arch: "aarch64" }); + }); + + const res = await app.inject({ + method: "POST", + url: "/api/discover", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ mac, root_device: "/dev/nvme0n1p2" }), + }); + expect(res.statusCode).toBe(200); + + const hw = state.load().discovered[mac]; + expect(hw?.root_device).toBe("/dev/nvme0n1p2"); + // The partial report must not blank what we already knew. + expect(hw?.product).toBe("DGX Spark"); + expect(hw?.cpu_cores).toBe(4); + expect(hw?.arch).toBe("aarch64"); + }); +}); + +describe("dnsmasq architecture detection", () => { + const conf = renderDnsmasqConf(createTestConfig("/tmp/unused")); + + it("maps DHCP option 93 values to per-architecture bootloaders", () => { + // 11 = ARM 64-bit UEFI + expect(conf).toContain("dhcp-match=set:efi-arm64,option:client-arch,11"); + expect(conf).toContain("dhcp-boot=tag:efi-arm64,tag:!ipxe,ipxe-arm64.efi"); + // 7 / 9 = x64 UEFI, 0 = x86 BIOS + expect(conf).toContain("dhcp-match=set:efi-x86_64,option:client-arch,7"); + expect(conf).toContain("dhcp-match=set:efi-x86_64,option:client-arch,9"); + expect(conf).toContain("dhcp-match=set:bios,option:client-arch,0"); + }); + + it("matches arm64 UEFI HTTP boot on 19, not 20", () => { + // IANA: 19 = arm uefi 64 boot from http, 20 = pc/at bios boot from http. + expect(conf).toContain("dhcp-match=set:httpboot-arm64,option:client-arch,19"); + expect(conf).not.toContain("dhcp-match=set:httpboot-arm64,option:client-arch,20"); + expect(conf).toContain("dhcp-match=set:httpboot-x86_64,option:client-arch,16"); + }); + + it("offers an arm64 PXE service directive in proxy mode", () => { + expect(conf).toContain('pxe-service=tag:!ipxe,ARM64_EFI,"PXE Boot",ipxe-arm64.efi'); + }); +}); diff --git a/bastion/src/bastion/tests/install-guard.test.ts b/bastion/src/bastion/tests/install-guard.test.ts new file mode 100644 index 0000000..5b8d23e --- /dev/null +++ b/bastion/src/bastion/tests/install-guard.test.ts @@ -0,0 +1,194 @@ +// Installs must never reach a machine running a vendor OS we cannot restore. +// +// This is the guardrail that stops someone reinstalling a DGX Spark at 2am. Rescue is +// deliberately still allowed for the same machines -- that is the whole point. + +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdirSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import type { BastionConfig, BastionState, HardwareInfo } from "@lab/shared"; +import { classifyOnboard } from "@lab/shared"; +import { createApp } from "../src/server.js"; +import { checkInstallAllowed } from "../src/services/install-guard.js"; +import type { FastifyInstance } from "fastify"; +import type { StateManager } from "../src/services/state.js"; + +// The real machines this exists to protect. +const SPARK_2935 = "4c:bb:47:7f:29:35"; +const SPARK_3A1C = "48:21:0b:96:3a:1c"; +const ORDINARY = "aa:bb:cc:dd:ee:ff"; + +function createTestConfig(testDir: string): BastionConfig { + return { + fedoraVersion: "43", arch: "x86_64", httpPort: 0, + timezone: "Europe/London", locale: "en_GB.UTF-8", bastionDir: testDir, + domain: "test.local", dhcpMode: "proxy", dhcpRangeStart: "", dhcpRangeEnd: "", + ubuntuVersion: "26.04", ubuntuMirror: "https://releases.ubuntu.com/26.04", + iface: "eth0", serverIp: "10.0.0.1", network: "10.0.0.0", gateway: "10.0.0.1", + sshKeys: [], adminUser: "testadmin", syslogPort: 15514, + skipDnsmasq: true, skipArtifacts: true, + fedoraMirror: "https://download.fedoraproject.org/pub/fedora/linux/releases/43/Everything/x86_64/os", + tftpDir: join(testDir, "tftp"), httpDir: join(testDir, "http"), + stateFile: join(testDir, "state.json"), + }; +} + +function hardware(mac: string, over: Partial = {}): HardwareInfo { + return { + mac, product: "TestBox", board: "TestBoard", serial: "SN1", + manufacturer: "TestCorp", cpu_model: "Test CPU", cpu_cores: 4, memory_gb: 16, + arch: "x86_64", disks: [], nics: [], + first_seen: new Date().toISOString(), last_seen: new Date().toISOString(), + ...over, + }; +} + +const emptyState = (): BastionState => ({ + discovered: {}, install_queue: {}, installed: {}, debug: {}, +}); + +describe("classifyOnboard", () => { + it("recognises a DGX Spark from its DMI identity", () => { + expect(classifyOnboard({ + mac: ORDINARY, manufacturer: "NVIDIA", product: "NVIDIA DGX Spark", board: "GB10", + })).toEqual({ onboard: "ssh", vendor_os: "dgx-os" }); + }); + + it("recognises the known Sparks even with no DMI recorded", () => { + // Neither Spark has hardware info in bastion state today. A DMI-only rule would + // fail open on exactly the machines this protects. + expect(classifyOnboard({ mac: SPARK_2935 }).onboard).toBe("ssh"); + expect(classifyOnboard({ mac: SPARK_3A1C }).onboard).toBe("ssh"); + }); + + it("treats ordinary hardware as PXE-installable", () => { + expect(classifyOnboard({ + mac: ORDINARY, manufacturer: "Beelink", product: "SER9", board: "SER9", + })).toEqual({ onboard: "pxe" }); + }); + + it("does not override an explicit classification already on the record", () => { + expect(classifyOnboard({ + mac: SPARK_2935, onboard: "pxe", + })).toEqual({ onboard: "pxe" }); + }); +}); + +describe("checkInstallAllowed", () => { + it("refuses a DGX Spark and explains why", () => { + const state = emptyState(); + state.installed[SPARK_2935] = { + hostname: "spark-2935", role: "worker", ip: "192.168.8.12", + installed_at: new Date().toISOString(), arch: "aarch64", + }; + + const result = checkInstallAllowed(state, SPARK_2935, "fedora-43"); + expect(result.allowed).toBe(false); + if (result.allowed === false) { + expect(result.error).toContain("spark-2935"); + expect(result.error).toContain("DGX OS"); + expect(result.error).toContain("provision debug"); + } + }); + + it("refuses a Spark that is only known by MAC", () => { + expect(checkInstallAllowed(emptyState(), SPARK_3A1C, "fedora-43").allowed).toBe(false); + }); + + it("allows an ordinary discovered machine", () => { + const state = emptyState(); + state.discovered[ORDINARY] = hardware(ORDINARY); + expect(checkInstallAllowed(state, ORDINARY, "fedora-43").allowed).toBe(true); + }); + + it("allows Fedora on aarch64", () => { + const state = emptyState(); + state.discovered[ORDINARY] = hardware(ORDINARY, { arch: "aarch64" }); + expect(checkInstallAllowed(state, ORDINARY, "fedora-43").allowed).toBe(true); + }); + + it("refuses Ubuntu on aarch64 -- no netboot artifacts are published", () => { + const state = emptyState(); + state.discovered[ORDINARY] = hardware(ORDINARY, { arch: "aarch64" }); + const result = checkInstallAllowed(state, ORDINARY, "ubuntu-26.04"); + expect(result.allowed).toBe(false); + if (result.allowed === false) { + expect(result.error).toContain("aarch64"); + } + }); + + it("allows Ubuntu on x86_64", () => { + const state = emptyState(); + state.discovered[ORDINARY] = hardware(ORDINARY, { arch: "x86_64" }); + expect(checkInstallAllowed(state, ORDINARY, "ubuntu-26.04").allowed).toBe(true); + }); +}); + +describe("install route enforces the guard", () => { + let testDir: string; + let app: FastifyInstance; + let state: StateManager; + + beforeEach(() => { + testDir = join(tmpdir(), `bastion-guard-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(join(testDir, "http"), { recursive: true }); + mkdirSync(join(testDir, "tftp"), { recursive: true }); + const result = createApp(createTestConfig(testDir)); + app = result.app; + state = result.state; + }); + + afterEach(async () => { + await app.close(); + rmSync(testDir, { recursive: true, force: true }); + }); + + it("rejects POST /api/install for a Spark and queues nothing", async () => { + const res = await app.inject({ + method: "POST", + url: "/api/install", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ mac: SPARK_2935, hostname: "spark-2935", role: "worker" }), + }); + + expect(res.statusCode).toBe(409); + expect(JSON.parse(res.body).error).toContain("Refusing to install"); + expect(state.load().install_queue[SPARK_2935]).toBeUndefined(); + }); + + it("still serves rescue to a Spark -- debug is never guarded", async () => { + state.update((s) => { + s.installed[SPARK_2935] = { + hostname: "spark-2935", role: "worker", ip: "192.168.8.12", + installed_at: new Date().toISOString(), arch: "aarch64", + }; + s.debug[SPARK_2935] = { hostname: "spark-2935", queued_at: new Date().toISOString() }; + }); + + const res = await app.inject({ method: "GET", url: `/dispatch?mac=${SPARK_2935}` }); + expect(res.statusCode).toBe(200); + expect(res.body).toContain("DEBUG/RESCUE MODE"); + expect(res.body).toContain("/vmlinuz-aarch64"); + }); + + it("a Spark that PXE boots unqueued gets discovery, never an install", async () => { + const res = await app.inject({ method: "GET", url: `/dispatch?mac=${SPARK_2935}&arch=arm64` }); + expect(res.body).toContain("DISCOVERY MODE"); + expect(res.body).not.toContain("INSTALLING"); + }); + + it("still accepts an ordinary machine", async () => { + state.update((s) => { s.discovered[ORDINARY] = hardware(ORDINARY); }); + + const res = await app.inject({ + method: "POST", + url: "/api/install", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ mac: ORDINARY, hostname: "worker-1", role: "worker" }), + }); + + expect(res.statusCode).toBe(200); + expect(state.load().install_queue[ORDINARY]).toBeDefined(); + }); +});