fix: PXE boot debugging — bisect root cause, syslog logging, serial console #3

Merged
michal merged 31 commits from wip/ks-debugging into main 2026-03-29 00:50:05 +00:00
27 changed files with 4025 additions and 0 deletions
Showing only changes of commit 177e993736 - Show all commits

3
bastion/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
node_modules/
dist/
*.tsbuildinfo

39
bastion/package.json Normal file
View File

@@ -0,0 +1,39 @@
{
"name": "lab-bastion",
"version": "0.1.0",
"private": true,
"description": "PXE bastion server for discover-first bare-metal provisioning",
"type": "module",
"bin": {
"bastion": "./dist/cli/index.js"
},
"main": "./dist/server/main.js",
"scripts": {
"build": "tsc",
"dev": "tsx src/cli/index.ts",
"start": "node dist/cli/index.js",
"test": "vitest",
"test:run": "vitest run",
"lint": "tsc --noEmit",
"clean": "rimraf dist"
},
"engines": {
"node": ">=20.0.0",
"pnpm": ">=9.0.0"
},
"packageManager": "pnpm@9.15.0",
"dependencies": {
"@fastify/static": "^8.0.0",
"commander": "^13.0.0",
"execa": "^9.5.0",
"fastify": "^5.0.0",
"winston": "^3.17.0"
},
"devDependencies": {
"@types/node": "^22.10.0",
"rimraf": "^6.0.0",
"tsx": "^4.21.0",
"typescript": "^5.7.0",
"vitest": "^3.0.0"
}
}

1954
bastion/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,44 @@
// CLI command: install
// Queue a discovered machine for Fedora installation.
import type { Command } from "commander";
export function registerInstallCommand(program: Command): void {
program
.command("install <mac> <hostname>")
.description("Queue a discovered machine for Fedora installation")
.option("--role <role>", "Machine role: worker or infra", "worker")
.option("--disk <device>", "Target disk device (auto-detect if omitted)")
.option("--port <port>", "Bastion HTTP port", "8080")
.action(async (mac: string, hostname: string, opts: {
role: string;
disk?: string;
port: string;
}) => {
const port = parseInt(opts.port, 10);
const payload: Record<string, string> = {
mac,
hostname,
role: opts.role,
};
if (opts.disk) {
payload["disk"] = opts.disk;
}
try {
const response = await fetch(`http://localhost:${port}/api/install`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const result = await response.json() as Record<string, unknown>;
console.log(JSON.stringify(result, null, 2));
console.log("");
console.log("Power on the machine to start Fedora installation.");
} catch {
console.error(`Cannot reach bastion at localhost:${port}. Is it running?`);
process.exit(1);
}
});
}

View File

@@ -0,0 +1,101 @@
// CLI command: list
// Merged view of all known machines with hardware + install info.
import type { Command } from "commander";
import type { BastionState } from "../../server/services/state.js";
const BOLD = "\x1b[1m";
const GREEN = "\x1b[0;32m";
const YELLOW = "\x1b[1;33m";
const CYAN = "\x1b[0;36m";
const RESET = "\x1b[0m";
function statusColor(status: string): string {
switch (status) {
case "installed": return GREEN;
case "queued":
case "installing": return YELLOW;
case "discovered": return CYAN;
default: return RESET;
}
}
export function registerListCommand(program: Command): void {
program
.command("list")
.description("List all known machines")
.option("--port <port>", "Bastion HTTP port", "8080")
.action(async (opts: { port: string }) => {
const port = parseInt(opts.port, 10);
let state: BastionState;
try {
const response = await fetch(`http://localhost:${port}/api/machines`);
state = (await response.json()) as BastionState;
} catch {
console.error(`Cannot reach bastion at localhost:${port}. Is it running?`);
process.exit(1);
}
// Collect all known MACs
const allMacs = new Set([
...Object.keys(state.discovered),
...Object.keys(state.install_queue),
...Object.keys(state.installed),
]);
console.log("");
if (allMacs.size === 0) {
console.log(" No machines known. PXE boot a machine to discover it.");
console.log("");
return;
}
console.log(
`${BOLD} ${"MAC".padEnd(20)} ${"HOSTNAME".padEnd(24)} ${"STATUS".padEnd(12)} ${"ROLE".padEnd(8)} ${"IP".padEnd(16)} ${"CPU".padEnd(24)} ${"CORES".padEnd(6)} ${"RAM".padEnd(6)} PRODUCT${RESET}`,
);
for (const mac of allMacs) {
const hw = state.discovered[mac];
const queued = state.install_queue[mac];
const inst = state.installed[mac];
// Determine status
let status = "discovered";
if (queued) {
status = queued.progress && queued.progress !== "waiting"
? "installing"
: "queued";
}
if (inst) status = "installed";
const hostname = inst?.hostname ?? queued?.hostname ?? "-";
const role = inst?.role ?? queued?.role ?? "-";
const ip = inst?.ip ?? "-";
const cpu = hw?.cpu_model ?? "-";
const cores = hw?.cpu_cores != null ? String(hw.cpu_cores) : "-";
const ram = hw?.memory_gb != null ? `${hw.memory_gb}GB` : "-";
const product = hw?.product ?? "-";
const color = statusColor(status);
console.log(
` ${mac.padEnd(20)} ${hostname.padEnd(24)} ${color}${status.padEnd(12)}${RESET} ${role.padEnd(8)} ${ip.padEnd(16)} ${cpu.substring(0, 23).padEnd(24)} ${cores.padEnd(6)} ${ram.padEnd(6)} ${product}`,
);
}
// Show install queue details if any
const queueEntries = Object.entries(state.install_queue);
if (queueEntries.length > 0) {
console.log("");
console.log(`${BOLD}PENDING${RESET}`);
for (const [mac, cfg] of queueEntries) {
const progress = cfg.progress ?? "waiting";
const detail = cfg.progress_detail ?? "";
console.log(` ${mac} ${progress}${detail ? ` - ${detail}` : ""}`);
}
}
console.log("");
});
}

View File

@@ -0,0 +1,86 @@
// CLI command: reprovision
// Queue a machine for reinstall and attempt SSH reboot into PXE.
import { execSync } from "node:child_process";
import type { Command } from "commander";
import type { BastionState } from "../../server/services/state.js";
export function registerReprovisionCommand(program: Command): void {
program
.command("reprovision <mac> <hostname>")
.description("Queue install + SSH reboot into PXE for reprovision")
.option("--role <role>", "Machine role: worker or infra", "worker")
.option("--disk <device>", "Target disk device (auto-detect if omitted)")
.option("--port <port>", "Bastion HTTP port", "8080")
.action(async (mac: string, hostname: string, opts: {
role: string;
disk?: string;
port: string;
}) => {
const port = parseInt(opts.port, 10);
// Queue the install
const payload: Record<string, string> = {
mac,
hostname,
role: opts.role,
};
if (opts.disk) {
payload["disk"] = opts.disk;
}
let state: BastionState;
try {
const installResponse = await fetch(`http://localhost:${port}/api/install`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const result = await installResponse.json() as Record<string, unknown>;
console.log(JSON.stringify(result, null, 2));
} catch {
console.error(`Cannot reach bastion at localhost:${port}. Is it running?`);
process.exit(1);
}
// Try to find IP from installed state and SSH in to trigger PXE reboot
try {
const machinesResponse = await fetch(`http://localhost:${port}/api/machines`);
state = (await machinesResponse.json()) as BastionState;
} catch {
console.log("");
console.log("Could not fetch machine state. Reboot the machine manually into PXE.");
return;
}
const installedEntry = state.installed[mac.toLowerCase().replace(/-/g, ":")];
const ip = installedEntry?.ip ?? "";
const adminUser = process.env["SUDO_USER"] ?? process.env["USER"] ?? "";
const effectiveUser = adminUser === "root" ? "" : adminUser;
if (ip && effectiveUser) {
console.log("");
console.log(`Attempting SSH reboot into PXE (${effectiveUser}@${ip})...`);
try {
const sshCmd = [
"ssh",
"-o", "StrictHostKeyChecking=no",
"-o", "ConnectTimeout=5",
`${effectiveUser}@${ip}`,
'sudo efibootmgr 2>/dev/null; PXE_ENTRY=$(sudo efibootmgr | grep -iE "pxe|network|ipv4" | head -1 | grep -oP "Boot\\K[0-9A-F]+"); if [ -n "$PXE_ENTRY" ]; then sudo efibootmgr --bootnext "$PXE_ENTRY" && echo "PXE set as next boot" && sudo reboot; else echo "No PXE boot entry found, rebooting anyway..." && sudo reboot; fi',
].join(" ");
execSync(sshCmd, { stdio: "inherit" });
console.log("");
console.log("Machine is rebooting into PXE. Install will start automatically.");
} catch {
console.log("");
console.log("SSH failed. Reboot the machine manually into PXE (e.g. via IPMI/KVM).");
}
} else {
console.log("");
console.log("No IP known for this machine. Reboot it manually into PXE.");
}
});
}

View File

@@ -0,0 +1,40 @@
// CLI command: serve
// Start the bastion server (HTTP + dnsmasq).
import type { Command } from "commander";
import { startBastion } from "../../server/main.js";
export function registerServeCommand(program: Command): void {
program
.command("serve")
.description("Start the bastion server (HTTP + dnsmasq PXE)")
.option("--port <port>", "HTTP port", "8080")
.option("--dir <dir>", "Bastion data directory", "/tmp/lab-bastion")
.option("--domain <domain>", "Internal domain for hostnames", "ad.itaz.eu")
.option("--dhcp-mode <mode>", "DHCP mode: proxy or full", "proxy")
.option("--fedora <version>", "Fedora version", "43")
.option("--arch <arch>", "Architecture", "x86_64")
.option("--timezone <tz>", "Timezone", "Europe/London")
.option("--locale <locale>", "Locale", "en_GB.UTF-8")
.action(async (opts: {
port: string;
dir: string;
domain: string;
dhcpMode: string;
fedora: string;
arch: string;
timezone: string;
locale: string;
}) => {
await startBastion({
httpPort: parseInt(opts.port, 10),
bastionDir: opts.dir,
domain: opts.domain,
dhcpMode: opts.dhcpMode as "proxy" | "full",
fedoraVersion: opts.fedora,
arch: opts.arch,
timezone: opts.timezone,
locale: opts.locale,
});
});
}

28
bastion/src/cli/index.ts Normal file
View File

@@ -0,0 +1,28 @@
#!/usr/bin/env node
// CLI entry point for lab-bastion.
// Commands: serve, install, list, reprovision
import { Command } from "commander";
import { registerServeCommand } from "./commands/serve.js";
import { registerInstallCommand } from "./commands/install.js";
import { registerListCommand } from "./commands/list.js";
import { registerReprovisionCommand } from "./commands/reprovision.js";
const program = new Command();
program
.name("bastion")
.description("Lab PXE Bastion -- discover-first bare-metal provisioning")
.version("0.1.0");
registerServeCommand(program);
registerInstallCommand(program);
registerListCommand(program);
registerReprovisionCommand(program);
// Default to serve if no command given
program.action(() => {
program.commands.find((c) => c.name() === "serve")?.parseAsync(process.argv);
});
program.parse();

View File

@@ -0,0 +1,67 @@
// Configuration from environment variables with sensible defaults.
export interface BastionConfig {
fedoraVersion: string;
arch: string;
httpPort: number;
timezone: string;
locale: string;
bastionDir: string;
domain: string;
dhcpMode: "proxy" | "full";
dhcpRangeStart: string;
dhcpRangeEnd: string;
// Derived at runtime
iface: string;
serverIp: string;
network: string;
gateway: string;
sshKeys: string[];
adminUser: string;
fedoraMirror: string;
tftpDir: string;
httpDir: string;
stateFile: string;
}
export function loadConfig(overrides: Partial<BastionConfig> = {}): BastionConfig {
const fedoraVersion = overrides.fedoraVersion ?? process.env["FEDORA_VERSION"] ?? "43";
const arch = overrides.arch ?? process.env["ARCH"] ?? "x86_64";
const httpPort = overrides.httpPort ?? parseInt(process.env["HTTP_PORT"] ?? "8080", 10);
const timezone = overrides.timezone ?? process.env["TIMEZONE"] ?? "Europe/London";
const locale = overrides.locale ?? process.env["LOCALE"] ?? "en_GB.UTF-8";
const bastionDir = overrides.bastionDir ?? process.env["BASTION_DIR"] ?? "/tmp/lab-bastion";
const domain = overrides.domain ?? process.env["DOMAIN"] ?? "ad.itaz.eu";
const dhcpMode = (overrides.dhcpMode ?? process.env["DHCP_MODE"] ?? "proxy") as "proxy" | "full";
const dhcpRangeStart = overrides.dhcpRangeStart ?? process.env["DHCP_RANGE_START"] ?? "";
const dhcpRangeEnd = overrides.dhcpRangeEnd ?? process.env["DHCP_RANGE_END"] ?? "";
const fedoraMirror = `https://download.fedoraproject.org/pub/fedora/linux/releases/${fedoraVersion}/Everything/${arch}/os`;
const tftpDir = `${bastionDir}/tftp`;
const httpDir = `${bastionDir}/http`;
const stateFile = `${bastionDir}/state.json`;
return {
fedoraVersion,
arch,
httpPort,
timezone,
locale,
bastionDir,
domain,
dhcpMode,
dhcpRangeStart,
dhcpRangeEnd,
// These are populated at runtime by the network service
iface: overrides.iface ?? "",
serverIp: overrides.serverIp ?? "",
network: overrides.network ?? "",
gateway: overrides.gateway ?? "",
sshKeys: overrides.sshKeys ?? [],
adminUser: overrides.adminUser ?? "",
fedoraMirror,
tftpDir,
httpDir,
stateFile,
};
}

177
bastion/src/server/main.ts Normal file
View File

@@ -0,0 +1,177 @@
// Entry point for the bastion server.
// Starts the Fastify HTTP server, dnsmasq, and handles graceful shutdown.
import { mkdirSync, writeFileSync, existsSync, copyFileSync, symlinkSync } from "node:fs";
import { execSync } from "node:child_process";
import { loadConfig, type BastionConfig } from "./config.js";
import { populateNetworkConfig } from "./services/network.js";
import { createApp } from "./server.js";
import { startDnsmasq, stopDnsmasq, generateDnsmasqConf } from "./services/dnsmasq.js";
import { generateDiscoverKickstart } from "./services/kickstart-generator.js";
import { renderBootIpxe } from "../templates/boot.ipxe.js";
import { logger } from "./services/logger.js";
function copyIfMissing(src: string, dest: string, label: string): void {
if (existsSync(dest)) {
logger.info(` ${label} -- cached`);
return;
}
if (!existsSync(src)) {
throw new Error(`${label}: source not found at ${src}`);
}
copyFileSync(src, dest);
logger.info(` ${label} -- copied from ${src}`);
}
function download(url: string, dest: string, label: string): void {
if (existsSync(dest)) {
logger.info(` ${label} -- cached`);
return;
}
logger.info(` ${label} -- downloading...`);
try {
execSync(`curl -# -L -f -o "${dest}" "${url}"`, { stdio: "inherit" });
} catch {
throw new Error(`Failed to download ${label} from ${url}`);
}
}
function symlinkSafe(target: string, linkPath: string): void {
try {
symlinkSync(target, linkPath);
} catch {
// Link may already exist
}
}
export async function startBastion(overrides: Partial<BastionConfig> = {}): Promise<void> {
// Load and populate config
let config = loadConfig(overrides);
config = populateNetworkConfig(config);
// Prepare directories
mkdirSync(config.tftpDir, { recursive: true });
mkdirSync(config.httpDir, { recursive: true });
// Prepare boot artifacts
logger.info(`Preparing boot artifacts (Fedora ${config.fedoraVersion} ${config.arch})...`);
copyIfMissing(
"/usr/share/ipxe/undionly.kpxe",
`${config.tftpDir}/undionly.kpxe`,
"iPXE BIOS",
);
copyIfMissing(
"/usr/share/ipxe/ipxe-snponly-x86_64.efi",
`${config.tftpDir}/ipxe.efi`,
"iPXE UEFI x86_64",
);
try {
copyIfMissing(
"/usr/share/ipxe/arm64-efi/snponly.efi",
`${config.tftpDir}/ipxe-arm64.efi`,
"iPXE UEFI arm64",
);
} catch {
logger.warn("arm64 iPXE not available -- skipping");
}
download(
`${config.fedoraMirror}/images/pxeboot/vmlinuz`,
`${config.httpDir}/vmlinuz`,
"Fedora kernel",
);
download(
`${config.fedoraMirror}/images/pxeboot/initrd.img`,
`${config.httpDir}/initrd.img`,
"Fedora initrd",
);
// Symlink iPXE binaries into HTTP dir for UEFI HTTP Boot
for (const name of ["ipxe.efi", "ipxe-arm64.efi"]) {
const src = `${config.tftpDir}/${name}`;
const dest = `${config.httpDir}/${name}`;
if (existsSync(src)) {
symlinkSafe(src, dest);
}
}
// Write discovery kickstart
const discoverKs = generateDiscoverKickstart(config);
writeFileSync(`${config.httpDir}/discover.ks`, discoverKs);
// Write iPXE boot script
const bootIpxe = renderBootIpxe({
serverIp: config.serverIp,
httpPort: config.httpPort,
});
writeFileSync(`${config.httpDir}/boot.ipxe`, bootIpxe);
// Generate dnsmasq config
generateDnsmasqConf(config);
// Start HTTP server
const { app } = createApp(config);
await app.listen({ port: config.httpPort, host: "0.0.0.0" });
logger.info(`HTTP server listening on :${config.httpPort}`);
// Start dnsmasq
const dnsmasqProc = await startDnsmasq(config);
// Print banner
printBanner(config);
// Graceful shutdown
const shutdown = async () => {
logger.info("Shutting down...");
stopDnsmasq();
await app.close();
logger.info(`State preserved in ${config.stateFile}`);
process.exit(0);
};
process.on("SIGINT", () => void shutdown());
process.on("SIGTERM", () => void shutdown());
// Wait for dnsmasq to exit
try {
await dnsmasqProc;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (!message.includes("was killed")) {
logger.error(`dnsmasq exited unexpectedly: ${message}`);
logger.error("Check if another DHCP/TFTP service is running.");
process.exit(1);
}
}
}
function printBanner(config: BastionConfig): void {
const dhcpInfo = config.dhcpMode === "full"
? `full (${config.dhcpRangeStart}-${config.dhcpRangeEnd})`
: "proxy (alongside existing DHCP)";
console.log("");
console.log("\x1b[36m\x1b[1m" + "=".repeat(60) + "\x1b[0m");
console.log("\x1b[36m\x1b[1m Lab PXE Bastion -- Discovery Mode\x1b[0m");
console.log("\x1b[36m\x1b[1m" + "=".repeat(60) + "\x1b[0m");
console.log("");
console.log(` Network: \x1b[1m${config.network}/24\x1b[0m via \x1b[1m${config.iface}\x1b[0m`);
console.log(` DHCP: \x1b[1m${dhcpInfo}\x1b[0m`);
console.log(` HTTP: \x1b[1mhttp://${config.serverIp}:${config.httpPort}/\x1b[0m`);
console.log(` OS: \x1b[1mFedora ${config.fedoraVersion} (${config.arch})\x1b[0m`);
console.log(` Domain: \x1b[1m${config.domain}\x1b[0m`);
console.log(` State: \x1b[1m${config.stateFile}\x1b[0m`);
console.log("");
console.log(" \x1b[33mPXE boot any machine on this network.\x1b[0m");
console.log(" \x1b[33mIt will be inventoried and rebooted automatically.\x1b[0m");
console.log("");
console.log(" Commands (from another terminal):");
console.log(" \x1b[1mbastion list\x1b[0m -- show machines");
console.log(" \x1b[1mbastion install <mac> <hostname>\x1b[0m -- queue install");
console.log("");
console.log(" Press \x1b[1mCtrl-C\x1b[0m to stop.");
console.log("");
console.log("\x1b[36m---- Waiting for PXE boot requests... ----\x1b[0m");
console.log("");
}

View File

@@ -0,0 +1,164 @@
// REST API routes for machine management.
// /api/machines - list all machines by state
// /api/install - queue a machine for install
// /api/progress - receive install progress callbacks from kickstart
// /api/discover - receive hardware discovery reports from PXE-booted machines
import type { FastifyInstance } from "fastify";
import type { StateManager, HardwareInfo, InstalledInfo } from "../services/state.js";
import { logger } from "../services/logger.js";
export function registerApiRoutes(
app: FastifyInstance,
state: StateManager,
): void {
// List all machines
app.get("/api/machines", async (_request, reply) => {
return reply.send(state.load());
});
// Queue a machine for install
app.post<{
Body: {
mac?: string;
hostname?: string;
disk?: string;
role?: string;
};
}>("/api/install", async (request, reply) => {
const { mac: rawMac, hostname, disk, role } = request.body ?? {};
const mac = (rawMac ?? "").toLowerCase().replace(/-/g, ":");
if (!mac) {
return reply.status(400).send({ error: "mac is required" });
}
const validRole = role ?? "worker";
if (validRole !== "worker" && validRole !== "infra") {
return reply.status(400).send({ error: "role must be 'worker' or 'infra'" });
}
state.update((s) => {
s.install_queue[mac] = {
hostname: hostname ?? "lab-node",
disk: disk ?? "",
role: validRole as "worker" | "infra",
queued_at: new Date().toISOString(),
};
});
logger.info(`INSTALL QUEUED: ${mac} -> hostname=${hostname ?? "lab-node"} role=${validRole}`);
return reply.send({
status: "queued",
mac,
hostname: hostname ?? "lab-node",
role: validRole,
message: `PXE boot the machine to start installation (role=${validRole})`,
});
});
// Receive install progress callbacks
app.post<{
Body: {
mac?: string;
stage?: string;
detail?: string;
};
}>("/api/progress", async (request, reply) => {
const { mac: rawMac, stage, detail } = request.body ?? {};
const mac = (rawMac ?? "unknown").toLowerCase();
const stageName = stage ?? "unknown";
const detailStr = detail ?? "";
logger.info(`Progress: ${mac} ${stageName}${detailStr ? ` -- ${detailStr}` : ""}`);
state.update((s) => {
const queueEntry = s.install_queue[mac];
if (queueEntry) {
queueEntry.progress = stageName;
queueEntry.progress_at = new Date().toISOString();
if (detailStr) {
queueEntry.progress_detail = detailStr;
}
// Move to installed on completion
if (stageName === "complete") {
const cfg = s.install_queue[mac];
delete s.install_queue[mac];
const ip = detailStr.startsWith("ready at ")
? detailStr.replace("ready at ", "").trim()
: "";
const installedInfo: InstalledInfo = {
hostname: cfg?.hostname ?? "?",
role: cfg?.role ?? "?",
ip,
installed_at: new Date().toISOString(),
};
s.installed[mac] = installedInfo;
logger.info(`INSTALL COMPLETE: ${mac} -> ${installedInfo.hostname} (${ip})`);
}
}
});
return reply.send({ status: "ok" });
});
// Receive discovery reports
app.post<{
Body: {
mac?: string;
product?: string;
board?: string;
serial?: string;
manufacturer?: string;
cpu_model?: string;
cpu_cores?: number;
memory_gb?: number;
arch?: string;
disks?: Array<{ name: string; size_gb: number; model: string }>;
nics?: Array<{ name: string; mac: string; state: string }>;
};
}>("/api/discover", async (request, reply) => {
const data = request.body;
if (!data) {
return reply.status(400).send({ error: "invalid JSON" });
}
const mac = (data.mac ?? "unknown").toLowerCase();
const now = new Date().toISOString();
const isNew = state.load().discovered[mac] === undefined;
state.update((s) => {
const existing = s.discovered[mac];
const hwInfo: HardwareInfo = {
mac,
product: data.product ?? "unknown",
board: data.board ?? "unknown",
serial: data.serial ?? "unknown",
manufacturer: data.manufacturer ?? "unknown",
cpu_model: data.cpu_model ?? "unknown",
cpu_cores: data.cpu_cores ?? 0,
memory_gb: data.memory_gb ?? 0,
arch: data.arch ?? "unknown",
disks: data.disks ?? [],
nics: data.nics ?? [],
first_seen: existing?.first_seen ?? now,
last_seen: now,
};
s.discovered[mac] = hwInfo;
});
const label = isNew ? "NEW MACHINE DISCOVERED" : "MACHINE RE-DISCOVERED";
const cpu = data.cpu_model ?? "?";
const cores = data.cpu_cores ?? "?";
const mem = data.memory_gb ?? "?";
logger.info(`${label}: ${mac} -- ${data.manufacturer ?? "?"} ${data.product ?? "?"} (${cpu}, ${cores} cores, ${mem}GB RAM)`);
return reply.send({ status: "ok", mac, new: isNew });
});
}

View File

@@ -0,0 +1,64 @@
// iPXE dispatch route.
// Routes PXE boot requests based on machine state:
// - install_queue -> install mode (serve Fedora installer + per-MAC kickstart)
// - installed -> exit (boot from local disk)
// - unknown -> discovery mode (collect hardware, POST to bastion)
import type { FastifyInstance } from "fastify";
import type { BastionConfig } from "../config.js";
import type { StateManager } from "../services/state.js";
import {
renderDiscoverIpxe,
renderInstallIpxe,
renderLocalBootIpxe,
} from "../../templates/boot.ipxe.js";
import { logger } from "../services/logger.js";
export function registerDispatchRoutes(
app: FastifyInstance,
config: BastionConfig,
state: StateManager,
): void {
app.get<{ Querystring: { mac?: string } }>("/dispatch", async (request, reply) => {
const mac = (request.query.mac ?? "").toLowerCase().replace(/-/g, ":");
const currentState = state.load();
const queueEntry = currentState.install_queue[mac];
if (queueEntry) {
const hostname = queueEntry.hostname ?? "lab-node";
logger.info(`INSTALL STARTED: ${mac} -> ${hostname}`);
const script = renderInstallIpxe({
mac,
hostname,
serverIp: config.serverIp,
httpPort: config.httpPort,
fedoraVersion: config.fedoraVersion,
fedoraMirror: config.fedoraMirror,
});
return reply.type("text/plain").send(script);
}
const installedEntry = currentState.installed[mac];
if (installedEntry) {
const hostname = installedEntry.hostname ?? "?";
logger.info(`PXE request from ${mac} (${hostname}) - already installed, booting local disk`);
const script = renderLocalBootIpxe(hostname);
return reply.type("text/plain").send(script);
}
// Unknown MAC -> discovery mode
logger.info(`PXE request from ${mac} -> discovery mode`);
const script = renderDiscoverIpxe({
mac,
serverIp: config.serverIp,
httpPort: config.httpPort,
fedoraMirror: config.fedoraMirror,
});
return reply.type("text/plain").send(script);
});
}

View File

@@ -0,0 +1,34 @@
// Kickstart generation routes.
// Serves per-MAC install kickstart and the static discovery kickstart.
import type { FastifyInstance } from "fastify";
import type { BastionConfig } from "../config.js";
import type { StateManager } from "../services/state.js";
import { generateInstallKickstart, generateDiscoverKickstart } from "../services/kickstart-generator.js";
export function registerKickstartRoutes(
app: FastifyInstance,
config: BastionConfig,
state: StateManager,
): void {
// Per-MAC install kickstart
app.get<{ Querystring: { mac?: string } }>("/ks", async (request, reply) => {
const mac = (request.query.mac ?? "").toLowerCase().replace(/-/g, ":");
const currentState = state.load();
const queueEntry = currentState.install_queue[mac];
const ks = generateInstallKickstart(config, {
hostname: queueEntry?.hostname ?? "lab-node",
disk: queueEntry?.disk ?? "",
role: queueEntry?.role ?? "worker",
});
return reply.type("text/plain").send(ks);
});
// Static discovery kickstart
app.get("/discover.ks", async (_request, reply) => {
const ks = generateDiscoverKickstart(config);
return reply.type("text/plain").send(ks);
});
}

View File

@@ -0,0 +1,61 @@
// Fastify application setup with all routes registered.
import Fastify from "fastify";
import fastifyStatic from "@fastify/static";
import { mkdirSync, existsSync } from "node:fs";
import type { BastionConfig } from "./config.js";
import { StateManager } from "./services/state.js";
import { logger } from "./services/logger.js";
import { registerDispatchRoutes } from "./routes/dispatch.js";
import { registerKickstartRoutes } from "./routes/kickstart.js";
import { registerApiRoutes } from "./routes/api.js";
export function createApp(config: BastionConfig) {
const app = Fastify({
logger: false, // We use winston instead
});
const state = new StateManager(config.stateFile);
state.init();
// Serve static files (vmlinuz, initrd.img, iPXE binaries) from the HTTP directory
mkdirSync(config.httpDir, { recursive: true });
app.register(fastifyStatic, {
root: config.httpDir,
prefix: "/",
decorateReply: false,
});
// Also serve TFTP files (iPXE EFI binaries) over HTTP for UEFI HTTP Boot
if (existsSync(config.tftpDir)) {
app.register(fastifyStatic, {
root: config.tftpDir,
prefix: "/tftp/",
decorateReply: false,
});
}
// Register route handlers
registerDispatchRoutes(app, config, state);
registerKickstartRoutes(app, config, state);
registerApiRoutes(app, state);
// Log all requests
app.addHook("onRequest", async (request) => {
logger.info(`HTTP: ${request.ip} ${request.method} ${request.url}`);
});
return { app, state };
}
export async function startServer(config: BastionConfig): Promise<void> {
const { app } = createApp(config);
try {
await app.listen({ port: config.httpPort, host: "0.0.0.0" });
logger.info(`HTTP server listening on :${config.httpPort}`);
} catch (err) {
logger.error(`Failed to start HTTP server: ${err instanceof Error ? err.message : String(err)}`);
throw err;
}
}

View File

@@ -0,0 +1,70 @@
// Generate dnsmasq configuration and manage the dnsmasq process lifecycle.
import { writeFileSync, mkdirSync } from "node:fs";
import { dirname } from "node:path";
import type { ResultPromise } from "execa";
import { execa } from "execa";
import type { BastionConfig } from "../config.js";
import { renderDnsmasqConf } from "../../templates/dnsmasq.conf.js";
import { logger } from "./logger.js";
type DnsmasqProcess = ResultPromise<{ stdout: "pipe"; stderr: "pipe" }>;
let dnsmasqProcess: DnsmasqProcess | null = null;
/**
* Generate the dnsmasq.conf file from the current configuration.
*/
export function generateDnsmasqConf(config: BastionConfig): string {
const confPath = `${config.bastionDir}/dnsmasq.conf`;
const content = renderDnsmasqConf(config);
mkdirSync(dirname(confPath), { recursive: true });
writeFileSync(confPath, content);
logger.info(`Generated dnsmasq config: ${confPath}`);
return confPath;
}
/**
* Start dnsmasq in the foreground as a child process.
*/
export async function startDnsmasq(config: BastionConfig): Promise<DnsmasqProcess> {
const confPath = generateDnsmasqConf(config);
logger.info(`Starting PXE server (${config.dhcpMode}DHCP on ${config.iface})...`);
const proc = execa("dnsmasq", ["--no-daemon", `--conf-file=${confPath}`], {
stdout: "pipe",
stderr: "pipe",
});
dnsmasqProcess = proc;
proc.stdout?.on("data", (data: Buffer) => {
const line = data.toString().trim();
if (line) logger.info(`dnsmasq: ${line}`);
});
proc.stderr?.on("data", (data: Buffer) => {
const line = data.toString().trim();
if (line) logger.info(`dnsmasq: ${line}`);
});
proc.on("exit", (code) => {
if (code !== null && code !== 0) {
logger.error(`dnsmasq exited with code ${code}. Check if another DHCP/TFTP service is running.`);
}
dnsmasqProcess = null;
});
return proc;
}
/**
* Stop the running dnsmasq process.
*/
export function stopDnsmasq(): void {
if (dnsmasqProcess) {
logger.info("Stopping dnsmasq...");
dnsmasqProcess.kill("SIGTERM");
dnsmasqProcess = null;
}
}

View File

@@ -0,0 +1,44 @@
// Generate kickstart content for discovery and install modes.
// Uses template literal functions -- no external template engine.
import type { BastionConfig } from "../config.js";
import { renderDiscoverKickstart } from "../../templates/discover.ks.js";
import { renderInstallKickstart, type InstallKickstartParams } from "../../templates/install.ks.js";
/**
* Generate a discovery kickstart that collects hardware info and POSTs to bastion.
*/
export function generateDiscoverKickstart(config: BastionConfig): string {
return renderDiscoverKickstart({
serverIp: config.serverIp,
httpPort: config.httpPort,
});
}
/**
* Generate an install kickstart with LVM partitioning, packages, and post-install configuration.
*/
export function generateInstallKickstart(
config: BastionConfig,
params: {
hostname: string;
disk: string;
role: "worker" | "infra";
},
): string {
const ksParams: InstallKickstartParams = {
hostname: params.hostname,
disk: params.disk,
role: params.role,
domain: config.domain,
fedoraVersion: config.fedoraVersion,
timezone: config.timezone,
locale: config.locale,
serverIp: config.serverIp,
httpPort: config.httpPort,
sshKeys: config.sshKeys,
adminUser: config.adminUser,
};
return renderInstallKickstart(ksParams);
}

View File

@@ -0,0 +1,17 @@
// Winston logger instance shared across the bastion application.
import winston from "winston";
export const logger = winston.createLogger({
level: "info",
format: winston.format.combine(
winston.format.timestamp({ format: "HH:mm:ss" }),
winston.format.printf(({ timestamp, level, message }) => {
const prefix = level === "error" ? "\x1b[31m[bastion]\x1b[0m"
: level === "warn" ? "\x1b[33m[bastion]\x1b[0m"
: "\x1b[32m[bastion]\x1b[0m";
return `${prefix} ${timestamp as string} ${message as string}`;
}),
),
transports: [new winston.transports.Console()],
});

View File

@@ -0,0 +1,158 @@
// Auto-detect network interface, IP, gateway, SSH keys, and admin user.
import { execSync } from "node:child_process";
import { readFileSync, existsSync, mkdirSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import type { BastionConfig } from "../config.js";
import { logger } from "./logger.js";
/**
* Detect the default network interface from the routing table.
*/
export function detectInterface(): string {
const output = execSync("ip route", { encoding: "utf-8" });
const match = output.match(/default\s+.*\s+dev\s+(\S+)/);
if (!match?.[1]) {
throw new Error("Cannot detect default network interface");
}
return match[1];
}
/**
* Detect the IPv4 address on a given interface.
*/
export function detectIp(iface: string): string {
const output = execSync(`ip -4 addr show ${iface}`, { encoding: "utf-8" });
const match = output.match(/inet\s+(\d+\.\d+\.\d+\.\d+)/);
if (!match?.[1]) {
throw new Error(`Cannot detect IP on interface ${iface}`);
}
return match[1];
}
/**
* Derive the /24 network address from an IP.
*/
export function deriveNetwork(ip: string): string {
const parts = ip.split(".");
return `${parts[0]}.${parts[1]}.${parts[2]}.0`;
}
/**
* Detect the default gateway.
*/
export function detectGateway(): string {
const output = execSync("ip route", { encoding: "utf-8" });
const match = output.match(/default\s+via\s+(\S+)/);
if (!match?.[1]) {
throw new Error("Cannot detect default gateway");
}
return match[1];
}
/**
* Collect SSH public keys from the current user's SSH directory.
* Sources: authorized_keys, then id_ed25519.pub, id_rsa.pub, id_ecdsa.pub (deduplicated).
*/
export function collectSshKeys(bastionDir: string): { keys: string[]; source: string } {
const realHome = process.env["SUDO_USER"]
? execSync(`getent passwd ${process.env["SUDO_USER"]}`, { encoding: "utf-8" })
.split(":")[5]
?.trim() ?? homedir()
: homedir();
const keys: string[] = [];
const fingerprints = new Set<string>();
let source = "";
// Read authorized_keys
const authKeysPath = join(realHome, ".ssh", "authorized_keys");
if (existsSync(authKeysPath)) {
const content = readFileSync(authKeysPath, "utf-8");
for (const line of content.split("\n")) {
const trimmed = line.trim();
if (trimmed && !trimmed.startsWith("#")) {
const fp = trimmed.split(/\s+/)[1];
if (fp && !fingerprints.has(fp)) {
keys.push(trimmed);
fingerprints.add(fp);
}
}
}
source = authKeysPath;
}
// Also include local pubkey files
const pubKeyFiles = ["id_ed25519.pub", "id_rsa.pub", "id_ecdsa.pub"];
for (const keyFile of pubKeyFiles) {
const keyPath = join(realHome, ".ssh", keyFile);
if (existsSync(keyPath)) {
const keyData = readFileSync(keyPath, "utf-8").trim();
const fp = keyData.split(/\s+/)[1];
if (fp && !fingerprints.has(fp)) {
keys.push(keyData);
fingerprints.add(fp);
source = source ? `${source} + ${keyPath}` : keyPath;
}
}
}
// Generate a keypair if no keys found
if (keys.length === 0) {
const generatedKey = join(bastionDir, "bastion_ed25519");
if (!existsSync(generatedKey)) {
mkdirSync(bastionDir, { recursive: true });
logger.warn("No SSH keys found -- generating ed25519 keypair...");
execSync(`ssh-keygen -t ed25519 -f "${generatedKey}" -N "" -C "bastion-generated@$(hostname)"`, {
encoding: "utf-8",
stdio: "pipe",
});
}
const pubKey = readFileSync(`${generatedKey}.pub`, "utf-8").trim();
keys.push(pubKey);
source = `${generatedKey} (generated)`;
logger.warn(`Using generated keypair: ${generatedKey}`);
logger.warn("Save this private key -- it is the only way to access installed machines.");
}
return { keys, source };
}
/**
* Detect the admin username (SUDO_USER or current user, excluding root).
*/
export function detectAdminUser(): string {
const user = process.env["SUDO_USER"] ?? process.env["USER"] ?? "";
return user === "root" ? "" : user;
}
/**
* Populate runtime network config fields on the config object.
*/
export function populateNetworkConfig(config: BastionConfig): BastionConfig {
const iface = config.iface || detectInterface();
const serverIp = config.serverIp || detectIp(iface);
const network = config.network || deriveNetwork(serverIp);
const gateway = config.gateway || detectGateway();
const { keys: sshKeys, source: sshSource } = config.sshKeys.length > 0
? { keys: config.sshKeys, source: "config" }
: collectSshKeys(config.bastionDir);
const adminUser = config.adminUser || detectAdminUser();
logger.info(`Interface: ${iface} IP: ${serverIp} Network: ${network}`);
logger.info(`SSH keys: ${sshKeys.length} key(s) from ${sshSource}`);
if (adminUser) {
logger.info(`Admin user: ${adminUser} (will be created on installed machines)`);
}
return {
...config,
iface,
serverIp,
network,
gateway,
sshKeys,
adminUser,
};
}

View File

@@ -0,0 +1,92 @@
// JSON file-backed state management for discovered machines, install queue, and installed machines.
import { readFileSync, writeFileSync, renameSync, mkdirSync } from "node:fs";
import { dirname } from "node:path";
export interface HardwareInfo {
mac: string;
product: string;
board: string;
serial: string;
manufacturer: string;
cpu_model: string;
cpu_cores: number;
memory_gb: number;
arch: string;
disks: Array<{ name: string; size_gb: number; model: string }>;
nics: Array<{ name: string; mac: string; state: string }>;
first_seen: string;
last_seen: string;
}
export interface InstallConfig {
hostname: string;
disk: string;
role: "worker" | "infra";
queued_at: string;
progress?: string;
progress_at?: string;
progress_detail?: string;
}
export interface InstalledInfo {
hostname: string;
role: string;
ip: string;
installed_at: string;
}
export interface BastionState {
discovered: Record<string, HardwareInfo>;
install_queue: Record<string, InstallConfig>;
installed: Record<string, InstalledInfo>;
}
const EMPTY_STATE: BastionState = {
discovered: {},
install_queue: {},
installed: {},
};
export class StateManager {
constructor(private readonly stateFile: string) {}
load(): BastionState {
try {
const raw = readFileSync(this.stateFile, "utf-8");
const parsed = JSON.parse(raw) as Partial<BastionState>;
return {
discovered: parsed.discovered ?? {},
install_queue: parsed.install_queue ?? {},
installed: parsed.installed ?? {},
};
} catch {
return { ...EMPTY_STATE };
}
}
save(state: BastionState): void {
mkdirSync(dirname(this.stateFile), { recursive: true });
const tmp = `${this.stateFile}.tmp`;
writeFileSync(tmp, JSON.stringify(state, null, 2));
renameSync(tmp, this.stateFile);
}
init(): void {
try {
readFileSync(this.stateFile, "utf-8");
} catch {
this.save({ ...EMPTY_STATE });
}
}
/**
* Atomically read, modify, and write state.
*/
update(fn: (state: BastionState) => void): BastionState {
const state = this.load();
fn(state);
this.save(state);
return state;
}
}

View File

@@ -0,0 +1,93 @@
// iPXE boot script templates for dispatch routing.
export interface BootIpxeParams {
serverIp: string;
httpPort: number;
}
/**
* Initial iPXE boot script that chains to the dispatch endpoint.
* This is what dnsmasq serves to iPXE clients via HTTP.
*/
export function renderBootIpxe(params: BootIpxeParams): string {
return `#!ipxe
echo
echo ============================================
echo Lab PXE Bastion
echo Contacting server for instructions...
echo ============================================
echo
chain http://${params.serverIp}:${params.httpPort}/dispatch?mac=\${net0/mac}
`;
}
/**
* iPXE script for discovery mode -- boots Fedora installer with discovery kickstart.
*/
export function renderDiscoverIpxe(params: {
mac: string;
serverIp: string;
httpPort: number;
fedoraMirror: string;
}): string {
return `#!ipxe
echo
echo =============================================
echo Lab PXE Bastion - DISCOVERY MODE
echo MAC: ${params.mac}
echo Collecting hardware info...
echo =============================================
echo
kernel http://${params.serverIp}:${params.httpPort}/vmlinuz inst.ks=http://${params.serverIp}:${params.httpPort}/discover.ks inst.stage2=${params.fedoraMirror} inst.text
initrd http://${params.serverIp}:${params.httpPort}/initrd.img
boot
`;
}
/**
* iPXE script for install mode -- boots Fedora installer with per-MAC kickstart.
*/
export function renderInstallIpxe(params: {
mac: string;
hostname: string;
serverIp: string;
httpPort: number;
fedoraVersion: string;
fedoraMirror: string;
}): string {
return `#!ipxe
echo
echo =============================================
echo Lab PXE Bastion - INSTALLING Fedora ${params.fedoraVersion}
echo Target: ${params.hostname}
echo MAC: ${params.mac}
echo =============================================
echo
kernel http://${params.serverIp}:${params.httpPort}/vmlinuz inst.ks=http://${params.serverIp}:${params.httpPort}/ks?mac=${params.mac} inst.repo=${params.fedoraMirror} inst.text
initrd http://${params.serverIp}:${params.httpPort}/initrd.img
boot
`;
}
/**
* iPXE script for already-installed machines -- exits to boot from local disk.
*/
export function renderLocalBootIpxe(hostname: string): string {
return `#!ipxe
echo
echo =============================================
echo Lab PXE Bastion - ${hostname}
echo Already installed, booting from local disk
echo =============================================
echo
sleep 3
exit
`;
}

View File

@@ -0,0 +1,118 @@
// Discovery kickstart template.
// Boots Fedora installer, collects hardware info, POSTs to bastion, reboots.
// Never touches the disk.
export interface DiscoverKickstartParams {
serverIp: string;
httpPort: number;
}
export function renderDiscoverKickstart(params: DiscoverKickstartParams): string {
const bastionUrl = `http://${params.serverIp}:${params.httpPort}`;
return `# Lab Bastion -- Discovery Mode
# Collects hardware inventory and reboots. Does NOT install anything.
%pre --erroronfail --log=/tmp/discover.log
#!/bin/bash
set -x
# -- Collect hardware info from /proc, /sys, and available tools --
MAC=$(ip link show | awk '/ether/ && !/00:00:00:00/ {print $2; exit}')
PRODUCT=$(cat /sys/class/dmi/id/product_name 2>/dev/null || echo "unknown")
BOARD=$(cat /sys/class/dmi/id/board_name 2>/dev/null || echo "unknown")
SERIAL=$(cat /sys/class/dmi/id/product_serial 2>/dev/null || echo "unknown")
MANUFACTURER=$(cat /sys/class/dmi/id/sys_vendor 2>/dev/null || echo "unknown")
CPUMODEL=$(grep -m1 'model name' /proc/cpuinfo | cut -d: -f2 | sed 's/^ //')
CPUCORES=$(grep -c '^processor' /proc/cpuinfo)
MEMGB=$(awk '/MemTotal/ {printf "%d", $2/1024/1024}' /proc/meminfo)
ARCHTYPE=$(uname -m)
# Disk info
DISKS_JSON=$(lsblk -Jb -o NAME,SIZE,TYPE,MODEL 2>/dev/null | python3 -c "
import sys, json
data = json.load(sys.stdin)
disks = [d for d in data.get('blockdevices', []) if d.get('type') == 'disk']
result = []
for d in disks:
size_gb = round(int(d.get('size', 0)) / 1073741824, 1)
result.append({
'name': d.get('name', '?'),
'size_gb': size_gb,
'model': (d.get('model') or 'unknown').strip()
})
print(json.dumps(result))
" 2>/dev/null || echo '[]')
# Network interfaces
NICS_JSON=$(ip -j link show 2>/dev/null | python3 -c "
import sys, json
nics = json.load(sys.stdin)
result = []
for n in nics:
if n.get('link_type') == 'loopback':
continue
result.append({
'name': n.get('ifname', '?'),
'mac': n.get('address', '?'),
'state': n.get('operstate', '?')
})
print(json.dumps(result))
" 2>/dev/null || echo '[]')
# -- Build and POST discovery payload --
PAYLOAD=$(python3 -c "
import json
print(json.dumps({
'mac': '$MAC',
'product': '$PRODUCT',
'board': '$BOARD',
'serial': '$SERIAL',
'manufacturer': '$MANUFACTURER',
'cpu_model': '$CPUMODEL',
'cpu_cores': int('$CPUCORES' or 0),
'memory_gb': int('$MEMGB' or 0),
'arch': '$ARCHTYPE',
'disks': $DISKS_JSON,
'nics': $NICS_JSON
}))
")
# POST to bastion
BASTION_URL="${bastionUrl}/api/discover"
if command -v curl >/dev/null 2>&1; then
curl -sf -X POST "$BASTION_URL" \\
-H "Content-Type: application/json" \\
-d "$PAYLOAD" || true
else
python3 -c "
import urllib.request
req = urllib.request.Request('$BASTION_URL',
data=b'''$PAYLOAD''',
headers={'Content-Type': 'application/json'})
try:
urllib.request.urlopen(req, timeout=10)
except Exception as e:
print(f'POST failed: {e}')
"
fi
# -- Reboot -- do NOT let Anaconda proceed --
echo ""
echo "=== Discovery complete, rebooting ==="
echo ""
sleep 3
echo 1 > /proc/sys/kernel/sysrq
echo b > /proc/sysrq-trigger
sleep 5
reboot -f
%end
# Anaconda should never get here, but just in case:
reboot
`;
}

View File

@@ -0,0 +1,88 @@
// dnsmasq configuration template.
// Supports proxy DHCP mode (alongside existing DHCP) and full DHCP mode.
// Handles UEFI HTTP Boot, iPXE chainloading, and PXE service directives.
import type { BastionConfig } from "../server/config.js";
export function renderDnsmasqConf(config: BastionConfig): string {
const {
iface,
serverIp,
httpPort,
network,
gateway,
dhcpMode,
tftpDir,
} = config;
// Derive DHCP range for full mode
let dhcpRangeStart = config.dhcpRangeStart;
let dhcpRangeEnd = config.dhcpRangeEnd;
if (dhcpMode === "full") {
const networkBase = network.replace(/\.0$/, "");
dhcpRangeStart = dhcpRangeStart || `${networkBase}.100`;
dhcpRangeEnd = dhcpRangeEnd || `${networkBase}.200`;
}
const dhcpSection = dhcpMode === "full"
? `# Full DHCP mode -- bastion is the only DHCP server on this network
dhcp-range=${dhcpRangeStart},${dhcpRangeEnd},255.255.255.0,12h
dhcp-option=3,${gateway}
dhcp-option=6,${gateway}`
: `# ProxyDHCP -- works alongside existing DHCP (UniFi etc)
dhcp-range=${network},proxy`;
return `# Lab PXE Bastion -- dnsmasq config
# Disable DNS (we only want DHCP/TFTP)
port=0
# Listen on the right interface
interface=${iface}
bind-dynamic
${dhcpSection}
# TFTP for initial PXE boot
enable-tftp
tftp-root=${tftpDir}
tftp-no-blocksize
# Detect client architecture -- PXE (TFTP) clients
dhcp-match=set:bios,option:client-arch,0
dhcp-match=set:efi-x86_64,option:client-arch,7
dhcp-match=set:efi-x86_64,option:client-arch,9
dhcp-match=set:efi-arm64,option:client-arch,11
# Detect client architecture -- UEFI HTTP Boot clients (no TFTP size limit)
dhcp-match=set:httpboot-x86_64,option:client-arch,16
dhcp-match=set:httpboot-arm64,option:client-arch,20
# Detect iPXE clients (already chainloaded)
dhcp-userclass=set:ipxe,iPXE
# UEFI HTTP Boot -> serve full iPXE EFI via HTTP (no TFTP size limit)
dhcp-boot=tag:httpboot-x86_64,http://${serverIp}:${httpPort}/ipxe-real.efi
dhcp-boot=tag:httpboot-arm64,http://${serverIp}:${httpPort}/ipxe-arm64.efi
# Echo vendor class back to HTTP Boot clients (required by UEFI HTTP Boot spec)
dhcp-option-force=tag:httpboot-x86_64,60,HTTPClient
dhcp-option-force=tag:httpboot-arm64,60,HTTPClient
# First PXE boot -> serve iPXE binary via TFTP (BIOS and UEFI fallback)
dhcp-boot=tag:bios,tag:!ipxe,undionly.kpxe
dhcp-boot=tag:efi-x86_64,tag:!ipxe,ipxe.efi
dhcp-boot=tag:efi-arm64,tag:!ipxe,ipxe-arm64.efi
# iPXE clients -> chain to boot script via HTTP
dhcp-boot=tag:ipxe,http://${serverIp}:${httpPort}/boot.ipxe
# PXE service directives (needed for proxy DHCP to respond properly)
pxe-service=tag:!ipxe,x86PC,"PXE Boot",undionly.kpxe
pxe-service=tag:!ipxe,X86-64_EFI,"PXE Boot",ipxe.efi
pxe-service=tag:!ipxe,BC_EFI,"PXE Boot",ipxe.efi
pxe-service=tag:!ipxe,ARM64_EFI,"PXE Boot",ipxe-arm64.efi
# Verbose logging
log-dhcp
`;
}

View File

@@ -0,0 +1,365 @@
// Install kickstart template.
// Full Fedora server install with LVM partitioning, %pre for reprovision detection,
// packages, and %post with SSH keys, user creation, k3s prereqs, progress callbacks.
export interface InstallKickstartParams {
hostname: string;
disk: string;
role: "worker" | "infra";
domain: string;
fedoraVersion: string;
timezone: string;
locale: string;
serverIp: string;
httpPort: number;
sshKeys: string[];
adminUser: string;
}
export function renderInstallKickstart(params: InstallKickstartParams): string {
const {
hostname,
disk,
role,
domain,
fedoraVersion,
timezone,
locale,
serverIp,
httpPort,
sshKeys,
adminUser,
} = params;
const fqdn = domain ? `${hostname}.${domain}` : hostname;
const vg = "labvg";
const now = new Date().toISOString();
const hasLonghorn = role === "worker";
// -- Auth section --
const auth = sshKeys.length > 0
? `rootpw --lock\nsshkey --username=root "${sshKeys[0]}"`
: "rootpw --plaintext changeme";
// -- Admin user directive --
const userDirective = adminUser
? `user --name=${adminUser} --groups=wheel --lock`
: "";
// -- SSH keys for %post --
const allKeys = sshKeys.join("\n");
let sshPostBlock = "";
if (sshKeys.length > 0) {
sshPostBlock = `
# Set up SSH keys for root
mkdir -p /root/.ssh && chmod 700 /root/.ssh
cat > /root/.ssh/authorized_keys << 'SSHKEYS'
${allKeys}
SSHKEYS
chmod 600 /root/.ssh/authorized_keys`;
}
if (adminUser && sshKeys.length > 0) {
sshPostBlock += `
# Set up SSH keys for ${adminUser}
ADMIN_HOME=$(getent passwd ${adminUser} | cut -d: -f6)
mkdir -p "$ADMIN_HOME/.ssh" && chmod 700 "$ADMIN_HOME/.ssh"
cp /root/.ssh/authorized_keys "$ADMIN_HOME/.ssh/authorized_keys"
chown -R ${adminUser}:${adminUser} "$ADMIN_HOME/.ssh"
chmod 600 "$ADMIN_HOME/.ssh/authorized_keys"
# Fix SELinux contexts for SSH
restorecon -R /root/.ssh "$ADMIN_HOME/.ssh" 2>/dev/null || true
# Passwordless sudo for ${adminUser}
echo '${adminUser} ALL=(ALL) NOPASSWD: ALL' > /etc/sudoers.d/${adminUser}
chmod 440 /etc/sudoers.d/${adminUser}`;
}
// -- Disk detection --
const diskLine = disk
? `DISK="${disk}"`
: `DISK=""
for d in /dev/nvme0n1 /dev/sda /dev/vda; do
[ -b "$d" ] && { DISK="$(basename $d)"; break; }
done
[ -z "$DISK" ] && { echo "ERROR: no disk found"; exit 1; }`;
// -- Longhorn LV for fresh install --
const longhornFreshLine = hasLonghorn
? `logvol /var/lib/longhorn --vgname=${vg} --name=longhorn --fstype=xfs --grow --size=1`
: "";
return `# Lab Bastion -- Fedora ${fedoraVersion} server install
# Generated: ${now}
# Target: ${fqdn} (role=${role})
text
reboot
lang ${locale}
keyboard uk
timezone ${timezone} --utc
network --bootproto=dhcp --activate --hostname=${fqdn}
${auth}
${userDirective}
bootloader --append="console=tty0 console=ttyS0,115200n8"
url --mirrorlist=https://mirrors.fedoraproject.org/mirrorlist?repo=fedora-$releasever&arch=$basearch
# Partitioning is generated dynamically by %pre (supports reprovision preservation)
%include /tmp/part.ks
%pre --log=/tmp/pre-partition.log
#!/bin/bash
set -x
# Progress callback helper
bastion_progress() {
local stage="$1" detail="\${2:-}"
local mac=$(ip link show | awk '/ether/ && !/00:00:00:00/ {print $2; exit}')
curl -sf -X POST "http://${serverIp}:${httpPort}/api/progress" \\
-H "Content-Type: application/json" \\
-d "{\\"mac\\":\\"$mac\\",\\"stage\\":\\"$stage\\",\\"detail\\":\\"$detail\\"}" 2>/dev/null || true
}
bastion_progress "partitioning" "preparing disk layout"
VG="${vg}"
${diskLine}
REPROVISION=no
# Check if VG exists (reprovision scenario)
if vgs $VG &>/dev/null; then
echo "=== Existing VG found - reprovision mode ==="
REPROVISION=yes
# Detect which data LVs to preserve
PRESERVE_LONGHORN=no; PRESERVE_SRV=no; PRESERVE_HOME=no
lvs $VG/longhorn &>/dev/null && PRESERVE_LONGHORN=yes
lvs $VG/srv &>/dev/null && PRESERVE_SRV=yes
lvs $VG/home &>/dev/null && PRESERVE_HOME=yes
echo "Preserving: longhorn=$PRESERVE_LONGHORN srv=$PRESERVE_SRV home=$PRESERVE_HOME"
# Remove only OS logical volumes (keep data LVs)
for lv in root var varlog swap; do
lvremove -f $VG/$lv 2>/dev/null || true
done
fi
if [ "$REPROVISION" = "yes" ]; then
# Find existing boot partitions by type
EFI_PART=$(blkid -t TYPE=vfat -o device /dev/\${DISK}* 2>/dev/null | head -1)
BOOT_PART=$(blkid -t TYPE=ext4 -o device /dev/\${DISK}* 2>/dev/null | head -1)
EFI_PART=\${EFI_PART:-/dev/\${DISK}1}
BOOT_PART=\${BOOT_PART:-/dev/\${DISK}2}
echo "Reusing EFI=$EFI_PART BOOT=$BOOT_PART"
# Build partition config reusing existing PV/VG
cat > /tmp/part.ks << PARTEOF
ignoredisk --only-use=$DISK
clearpart --none
part /boot/efi --onpart=$EFI_PART --fstype=efi
part /boot --onpart=$BOOT_PART --fstype=ext4
volgroup ${vg} --useexisting --noformat
logvol swap --vgname=${vg} --name=swap --fstype=swap --size=27648
logvol / --vgname=${vg} --name=root --fstype=xfs --size=33792
logvol /var --vgname=${vg} --name=var --fstype=xfs --size=102400
logvol /var/log --vgname=${vg} --name=varlog --fstype=xfs --size=10240
PARTEOF
# Preserve or recreate data LVs
if [ "$PRESERVE_HOME" = "yes" ]; then
echo "logvol /home --vgname=${vg} --name=home --useexisting --noformat" >> /tmp/part.ks
else
echo "logvol /home --vgname=${vg} --name=home --fstype=xfs --size=10240" >> /tmp/part.ks
fi
if [ "$PRESERVE_SRV" = "yes" ]; then
echo "logvol /srv --vgname=${vg} --name=srv --useexisting --noformat" >> /tmp/part.ks
else
echo "logvol /srv --vgname=${vg} --name=srv --fstype=xfs --size=20480" >> /tmp/part.ks
fi
if [ "$PRESERVE_LONGHORN" = "yes" ]; then
echo "logvol /var/lib/longhorn --vgname=${vg} --name=longhorn --useexisting --noformat" >> /tmp/part.ks
fi
else
# Fresh install
cat > /tmp/part.ks << PARTEOF
ignoredisk --only-use=$DISK
clearpart --all --initlabel --drives=$DISK
part /boot/efi --fstype=efi --size=600 --ondisk=$DISK
part /boot --fstype=ext4 --size=3072 --ondisk=$DISK
part pv.01 --size=1 --grow --ondisk=$DISK
volgroup ${vg} pv.01
logvol swap --vgname=${vg} --name=swap --fstype=swap --size=27648
logvol / --vgname=${vg} --name=root --fstype=xfs --size=33792
logvol /var --vgname=${vg} --name=var --fstype=xfs --size=102400
logvol /var/log --vgname=${vg} --name=varlog --fstype=xfs --size=10240
logvol /home --vgname=${vg} --name=home --fstype=xfs --size=10240
logvol /srv --vgname=${vg} --name=srv --fstype=xfs --size=20480
${longhornFreshLine}
PARTEOF
fi
echo "=== Generated partition config ==="
cat /tmp/part.ks
echo "==================================="
bastion_progress "partitioning" "layout ready, starting install"
%end
%packages
@core
openssh-server
vim-enhanced
tmux
git
curl
wget
python3
lshw
dmidecode
dnf-plugins-core
# Networking and diagnostics
NetworkManager
bind-utils
net-tools
iproute
iputils
traceroute
tcpdump
htop
iotop
strace
jq
# k3s prerequisites
container-selinux
iptables-nft
nftables
policycoreutils-python-utils
chrony
tar
socat
conntrack-tools
ethtool
# Boot management
efibootmgr
# Puppet prerequisites
ruby
ruby-libs
# Exclude desktop
-@workstation-product
-@gnome-desktop
-gnome-shell
-gdm
-PackageKit
-PackageKit-glib
%end
%post --log=/root/bastion-post-install.log
#!/bin/bash
set -x
# Progress callback helper
bastion_progress() {
local stage="$1" detail="\${2:-}"
local mac=$(ip link show | awk '/ether/ && !/00:00:00:00/ {print $2; exit}')
curl -sf -X POST "http://${serverIp}:${httpPort}/api/progress" \\
-H "Content-Type: application/json" \\
-d "{\\"mac\\":\\"$mac\\",\\"stage\\":\\"$stage\\",\\"detail\\":\\"$detail\\"}" 2>/dev/null || true
}
bastion_progress "post-install" "configuring system"
# -- SSH --
systemctl enable --now sshd
sed -i 's/^#\\?PermitRootLogin.*/PermitRootLogin prohibit-password/' /etc/ssh/sshd_config
sed -i 's/^#\\?PasswordAuthentication.*/PasswordAuthentication no/' /etc/ssh/sshd_config
${sshPostBlock}
# -- Hostname and domain --
hostnamectl set-hostname ${fqdn}
# -- tmpfs for /tmp --
echo "tmpfs /tmp tmpfs defaults,noatime,nosuid,nodev,size=4G 0 0" >> /etc/fstab
# -- Kernel modules for k3s --
cat > /etc/modules-load.d/k3s.conf << 'MODULES'
br_netfilter
overlay
ip_conntrack
MODULES
modprobe br_netfilter || true
modprobe overlay || true
# -- Sysctl for k3s networking --
cat > /etc/sysctl.d/90-k3s.conf << 'SYSCTL'
net.bridge.bridge-nf-call-iptables = 1
net.bridge.bridge-nf-call-ip6tables = 1
net.ipv4.ip_forward = 1
net.ipv6.conf.all.forwarding = 1
fs.inotify.max_user_instances = 524288
fs.inotify.max_user_watches = 1048576
SYSCTL
sysctl --system || true
# -- Disable firewalld (k3s manages its own iptables rules) --
systemctl disable --now firewalld || true
# -- Enable chronyd for time sync --
systemctl enable --now chronyd
# -- Set boot order: local disk first, PXE after --
if command -v efibootmgr >/dev/null 2>&1; then
FEDORA_ENTRY=$(efibootmgr | grep -i fedora | head -1 | grep -oP 'Boot\\K[0-9A-F]+')
if [ -n "$FEDORA_ENTRY" ]; then
CURRENT_ORDER=$(efibootmgr | grep BootOrder | cut -d: -f2 | tr -d ' ')
NEW_ORDER="$FEDORA_ENTRY,$(echo "$CURRENT_ORDER" | sed "s/$FEDORA_ENTRY,\\\\?//;s/,$//")"
efibootmgr -o "$NEW_ORDER" || true
echo "Boot order set: Fedora first ($NEW_ORDER)"
fi
fi
# -- Provisioning metadata --
cat > /etc/lab-provisioned << PROVEOF
hostname: ${fqdn}
role: ${role}
provisioned: $(date -Iseconds)
bastion: ${serverIp}
PROVEOF
cat > /root/README << 'README'
# Lab Node -- ${fqdn} (role: ${role})
#
# Next steps:
# 1. Install puppet agent:
# dnf install -y puppet-agent
#
# 2. Install k3s:
# curl -sfL https://get.k3s.io | sh -
#
# 3. Or join existing cluster:
# curl -sfL https://get.k3s.io | K3S_URL=https://<server>:6443 K3S_TOKEN=<token> sh -
README
IP_ADDR=$(ip -4 addr show | awk '/inet / && !/127.0.0/ {split($2,a,"/"); print a[1]; exit}')
bastion_progress "complete" "ready at $IP_ADDR"
%end
`;
}

View File

@@ -0,0 +1,33 @@
# Lab PXE Bastion -- Environment Configuration
#
# Copy this file to .env and adjust as needed.
# Fedora version to install
FEDORA_VERSION=43
# Target architecture
ARCH=x86_64
# HTTP server port
HTTP_PORT=8080
# System locale and timezone for installed machines
TIMEZONE=Europe/London
LOCALE=en_GB.UTF-8
# Data directory (inside container)
BASTION_DIR=/data
# Internal domain for hostnames (e.g., node1.ad.itaz.eu)
DOMAIN=ad.itaz.eu
# DHCP mode: "proxy" works alongside existing DHCP (e.g., UniFi)
# "full" means bastion is the only DHCP server
DHCP_MODE=proxy
# Only used in full DHCP mode -- auto-derived from network if empty
DHCP_RANGE_START=
DHCP_RANGE_END=
# Path to SSH keys directory on host (mounted read-only)
SSH_KEY_PATH=~/.ssh

37
bastion/stack/Dockerfile Normal file
View File

@@ -0,0 +1,37 @@
FROM fedora:43
# Install system dependencies
RUN dnf install -y \
dnsmasq \
ipxe-bootimgs-x86 \
ipxe-bootimgs-aarch64 \
curl \
openssh-clients \
&& dnf clean all
# Install Node.js 22
RUN dnf install -y nodejs npm && dnf clean all
RUN npm install -g pnpm@9
# Create app directory
WORKDIR /app
# Copy package files and install dependencies
COPY package.json pnpm-lock.yaml* ./
RUN pnpm install --frozen-lockfile 2>/dev/null || pnpm install
# Copy built application
COPY dist/ ./dist/
# Create data directories
RUN mkdir -p /data/state /data/tftp /data/http
ENV BASTION_DIR=/data
ENV HTTP_PORT=8080
EXPOSE 8080/tcp
EXPOSE 67/udp
EXPOSE 69/udp
EXPOSE 4011/udp
ENTRYPOINT ["node", "dist/cli/index.js", "serve"]

View File

@@ -0,0 +1,21 @@
services:
bastion:
build:
context: ..
dockerfile: stack/Dockerfile
network_mode: host
restart: unless-stopped
env_file: .env
volumes:
- bastion-state:/data/state
- bastion-tftp:/data/tftp
- bastion-http:/data/http
- ${SSH_KEY_PATH:-~/.ssh}:/root/.ssh:ro
cap_add:
- NET_ADMIN
- NET_RAW
volumes:
bastion-state:
bastion-tftp:
bastion-http:

27
bastion/tsconfig.json Normal file
View File

@@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"sourceMap": true,
"incremental": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"isolatedModules": true,
"resolveJsonModule": true,
"rootDir": "src",
"outDir": "dist",
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist"]
}