feat: TypeScript bastion rewrite (initial scaffold)
Full rewrite of the bash bastion.sh into a TypeScript application: - Fastify HTTP server with typed routes (dispatch, kickstart, API) - Commander CLI (serve, install, list, reprovision) - Kickstart templates as TypeScript template literals (no more heredoc hell) - dnsmasq management via execa subprocess - Merged machine list view (hardware + install info in one table) - Containerized via podman-compose (Dockerfile + docker-compose.yml) - All partition logic preserved (LVM, reprovision detection, role-based) Not yet tested end-to-end — needs VM validation before replacing bash version. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
3
bastion/.gitignore
vendored
Normal file
3
bastion/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.tsbuildinfo
|
||||
39
bastion/package.json
Normal file
39
bastion/package.json
Normal 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
1954
bastion/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
44
bastion/src/cli/commands/install.ts
Normal file
44
bastion/src/cli/commands/install.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
101
bastion/src/cli/commands/list.ts
Normal file
101
bastion/src/cli/commands/list.ts
Normal 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("");
|
||||
});
|
||||
}
|
||||
86
bastion/src/cli/commands/reprovision.ts
Normal file
86
bastion/src/cli/commands/reprovision.ts
Normal 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.");
|
||||
}
|
||||
});
|
||||
}
|
||||
40
bastion/src/cli/commands/serve.ts
Normal file
40
bastion/src/cli/commands/serve.ts
Normal 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
28
bastion/src/cli/index.ts
Normal 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();
|
||||
67
bastion/src/server/config.ts
Normal file
67
bastion/src/server/config.ts
Normal 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
177
bastion/src/server/main.ts
Normal 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("");
|
||||
}
|
||||
164
bastion/src/server/routes/api.ts
Normal file
164
bastion/src/server/routes/api.ts
Normal 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 });
|
||||
});
|
||||
}
|
||||
64
bastion/src/server/routes/dispatch.ts
Normal file
64
bastion/src/server/routes/dispatch.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
34
bastion/src/server/routes/kickstart.ts
Normal file
34
bastion/src/server/routes/kickstart.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
61
bastion/src/server/server.ts
Normal file
61
bastion/src/server/server.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
70
bastion/src/server/services/dnsmasq.ts
Normal file
70
bastion/src/server/services/dnsmasq.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
44
bastion/src/server/services/kickstart-generator.ts
Normal file
44
bastion/src/server/services/kickstart-generator.ts
Normal 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);
|
||||
}
|
||||
17
bastion/src/server/services/logger.ts
Normal file
17
bastion/src/server/services/logger.ts
Normal 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()],
|
||||
});
|
||||
158
bastion/src/server/services/network.ts
Normal file
158
bastion/src/server/services/network.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
92
bastion/src/server/services/state.ts
Normal file
92
bastion/src/server/services/state.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
93
bastion/src/templates/boot.ipxe.ts
Normal file
93
bastion/src/templates/boot.ipxe.ts
Normal 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
|
||||
`;
|
||||
}
|
||||
118
bastion/src/templates/discover.ks.ts
Normal file
118
bastion/src/templates/discover.ks.ts
Normal 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
|
||||
`;
|
||||
}
|
||||
88
bastion/src/templates/dnsmasq.conf.ts
Normal file
88
bastion/src/templates/dnsmasq.conf.ts
Normal 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
|
||||
`;
|
||||
}
|
||||
365
bastion/src/templates/install.ks.ts
Normal file
365
bastion/src/templates/install.ks.ts
Normal 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
|
||||
`;
|
||||
}
|
||||
33
bastion/stack/.env.example
Normal file
33
bastion/stack/.env.example
Normal 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
37
bastion/stack/Dockerfile
Normal 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"]
|
||||
21
bastion/stack/docker-compose.yml
Normal file
21
bastion/stack/docker-compose.yml
Normal 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
27
bastion/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user