feat(bastion): unattended VyOS network install with HA (bond + VRRP)
Some checks failed
CI/CD / lint (pull_request) Failing after 10s
CI/CD / test (pull_request) Failing after 10s
CI/CD / typecheck (pull_request) Failing after 23s
CI/CD / build (pull_request) Has been skipped
CI/CD / publish-rpm (pull_request) Has been skipped
CI/CD / publish-deb (pull_request) Has been skipped
Some checks failed
CI/CD / lint (pull_request) Failing after 10s
CI/CD / test (pull_request) Failing after 10s
CI/CD / typecheck (pull_request) Failing after 23s
CI/CD / build (pull_request) Has been skipped
CI/CD / publish-rpm (pull_request) Has been skipped
CI/CD / publish-deb (pull_request) Has been skipped
VyOS ships no unattended installer (install_image() is unconditionally interactive; --no-prompt is wired only to 'add'), so the automation is injected through live-config's hooks component: iPXE boots the live kernel with fetch= and live-config.hooks=, the hook fetches a generated per-MAC Python driver, and the driver builds config.boot, stages the rootfs, and drives the interactive installer over a pty. Bastion: - vyos-boot.ipxe template (no 'nonetworking' — breaks the hook fetch; no console=ttyS0 — 30s/systemd-phase on UART-less boards) - /vyos/autoinstall.sh + /vyos/install.py routes (per-MAC driver with the config spec baked in as base64) - vyos-config-spec: bond0 (802.3ad) + tagged VLANs + VRRP groups (vrid = VLAN id) + sync group + hw-id pinning by MAC + SSH keys; config built from the image's own config.boot.default via vyos.configtree, version footer reattached via component_version - prepareVyosArtifacts: extract kernel/initrd/squashfs from the nightly ISO with xorriso; initrd picked by size from regular files only; ISO URL "latest" resolves the newest vyos-nightly-build GH release (downloads.vyos.io no longer serves direct ISOs) Verified end-to-end in a libvirt VM against the real nightly ISO — installed system boots with bond/VRRP/hw-id config applied and no migrations. Fixes found by the VM run, encoded in code comments: config.boot.default lives at /usr/share/vyos at hook time; fetch= boot has no medium so the rootfs is symlinked to the installer's expected path; reboot must be --force (the hook is a child of the still-starting live-config unit); installer disk answers are full /dev paths; zram0 passes the 2GB min-disk filter so the disk is always pinned. CLI/labd: vyos spec threaded through provision install (--vyos-* and --vlan/--vlan-vip flags with guards), labd install route, protocol command-install, and the bastion's direct /api/install. 268 unit tests pass; no new lint errors in touched files. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH
This commit is contained in:
@@ -20,6 +20,15 @@ export function loadConfig(overrides: Partial<BastionConfig> = {}): BastionConfi
|
|||||||
const ubuntuMirror = overrides.ubuntuMirror ?? process.env["UBUNTU_MIRROR"]
|
const ubuntuMirror = overrides.ubuntuMirror ?? process.env["UBUNTU_MIRROR"]
|
||||||
?? `https://releases.ubuntu.com/${ubuntuVersion}`;
|
?? `https://releases.ubuntu.com/${ubuntuVersion}`;
|
||||||
|
|
||||||
|
// "latest" resolves the newest nightly ISO from the vyos-nightly-build GitHub
|
||||||
|
// releases at startup. downloads.vyos.io no longer serves direct rolling ISOs
|
||||||
|
// (it returns the vyos.io site, and nightly builds sit behind a signup form);
|
||||||
|
// GitHub releases are the remaining free, unauthenticated direct source.
|
||||||
|
// LTS ISOs are subscription-only. Set VYOS_ISO_URL to pin a specific build.
|
||||||
|
const vyosIsoUrl = overrides.vyosIsoUrl ?? process.env["VYOS_ISO_URL"] ?? "latest";
|
||||||
|
const vyosDefaultPassword = overrides.vyosDefaultPassword
|
||||||
|
?? process.env["VYOS_DEFAULT_PASSWORD"] ?? "vyos";
|
||||||
|
|
||||||
const fedoraMirror = `https://download.fedoraproject.org/pub/fedora/linux/releases/${fedoraVersion}/Everything/${arch}/os`;
|
const fedoraMirror = `https://download.fedoraproject.org/pub/fedora/linux/releases/${fedoraVersion}/Everything/${arch}/os`;
|
||||||
const tftpDir = `${bastionDir}/tftp`;
|
const tftpDir = `${bastionDir}/tftp`;
|
||||||
const httpDir = `${bastionDir}/http`;
|
const httpDir = `${bastionDir}/http`;
|
||||||
@@ -38,6 +47,8 @@ export function loadConfig(overrides: Partial<BastionConfig> = {}): BastionConfi
|
|||||||
dhcpRangeEnd,
|
dhcpRangeEnd,
|
||||||
ubuntuVersion,
|
ubuntuVersion,
|
||||||
ubuntuMirror,
|
ubuntuMirror,
|
||||||
|
vyosIsoUrl,
|
||||||
|
vyosDefaultPassword,
|
||||||
// These are populated at runtime by the network service
|
// These are populated at runtime by the network service
|
||||||
iface: overrides.iface ?? "",
|
iface: overrides.iface ?? "",
|
||||||
serverIp: overrides.serverIp ?? "",
|
serverIp: overrides.serverIp ?? "",
|
||||||
|
|||||||
@@ -40,6 +40,125 @@ function download(url: string, dest: string, label: string): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pick the largest regular-file initrd from an `xorriso -lsl` listing.
|
||||||
|
*
|
||||||
|
* /live carries decoys: a 0-byte initrd.img placeholder on some images, or an
|
||||||
|
* initrd.img SYMLINK to the real version-suffixed file on others. Parsing is
|
||||||
|
* field-based (ls -l layout: perms links uid gid size month day time 'name')
|
||||||
|
* and considers only lines whose mode string marks a regular file — symlinks
|
||||||
|
* report their link size, not the target's, and must not win.
|
||||||
|
*/
|
||||||
|
export function pickLargestInitrd(
|
||||||
|
listing: string,
|
||||||
|
): { name: string; size: number } | undefined {
|
||||||
|
let best: { name: string; size: number } | undefined;
|
||||||
|
for (const line of listing.split("\n")) {
|
||||||
|
if (!line.startsWith("-")) continue; // regular files only
|
||||||
|
const quoted = /'([^']+)'/.exec(line);
|
||||||
|
const fields = line.trim().split(/\s+/);
|
||||||
|
const size = parseInt(fields[4] ?? "", 10);
|
||||||
|
const name = quoted?.[1] ?? "";
|
||||||
|
if (!name.startsWith("initrd")) continue;
|
||||||
|
if (!Number.isFinite(size) || size <= 0) continue;
|
||||||
|
if (best === undefined || size > best.size) {
|
||||||
|
best = { name, size };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
const VYOS_NIGHTLY_RELEASES =
|
||||||
|
"https://api.github.com/repos/vyos/vyos-nightly-build/releases/latest";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the configured VyOS ISO URL, expanding the "latest" sentinel.
|
||||||
|
*
|
||||||
|
* The nightly asset filename embeds a build date, so there is no stable
|
||||||
|
* "latest.iso" path to hardcode — the newest release has to be looked up.
|
||||||
|
* Any other value is used verbatim, which is how VYOS_ISO_URL pins a build
|
||||||
|
* or points at a locally mirrored copy.
|
||||||
|
*/
|
||||||
|
function resolveVyosIsoUrl(configured: string): string {
|
||||||
|
if (configured !== "latest") return configured;
|
||||||
|
|
||||||
|
const body = execSync(`curl -sSfL "${VYOS_NIGHTLY_RELEASES}"`, {
|
||||||
|
encoding: "utf-8",
|
||||||
|
stdio: ["pipe", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
const release = JSON.parse(body) as {
|
||||||
|
tag_name?: string;
|
||||||
|
assets?: Array<{ name: string; browser_download_url: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const asset = (release.assets ?? []).find((a) =>
|
||||||
|
/generic-amd64\.iso$/.test(a.name),
|
||||||
|
);
|
||||||
|
if (!asset) {
|
||||||
|
throw new Error(
|
||||||
|
`No generic-amd64 ISO asset in VyOS nightly release ${release.tag_name ?? "?"}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(` VyOS ISO resolved to ${asset.name} (${release.tag_name ?? "?"})`);
|
||||||
|
return asset.browser_download_url;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract VyOS netboot artifacts from the release ISO.
|
||||||
|
*
|
||||||
|
* VyOS publishes no netboot bundle, so kernel/initrd/squashfs have to come out
|
||||||
|
* of the ISO. xorriso is already in the bastion image (used for boot.iso) and
|
||||||
|
* extracts without root or a loop mount.
|
||||||
|
*
|
||||||
|
* The initrd needs care: /live contains an empty initrd.img placeholder
|
||||||
|
* alongside the real one, which carries a version-suffixed name. Booting the
|
||||||
|
* 0-byte file fails with no useful diagnostic, so pick the largest initrd*.
|
||||||
|
*/
|
||||||
|
export function prepareVyosArtifacts(config: BastionConfig): void {
|
||||||
|
const kernel = `${config.httpDir}/vyos-vmlinuz`;
|
||||||
|
const initrd = `${config.httpDir}/vyos-initrd`;
|
||||||
|
const squashfs = `${config.httpDir}/vyos-filesystem.squashfs`;
|
||||||
|
|
||||||
|
if (existsSync(kernel) && existsSync(initrd) && existsSync(squashfs)) {
|
||||||
|
logger.info(" VyOS netboot artifacts -- cached");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const iso = `${config.bastionDir}/vyos.iso`;
|
||||||
|
download(resolveVyosIsoUrl(config.vyosIsoUrl), iso, "VyOS ISO");
|
||||||
|
|
||||||
|
const extract = (isoPath: string, dest: string, label: string): void => {
|
||||||
|
execSync(
|
||||||
|
`xorriso -osirrox on -indev "${iso}" -extract "${isoPath}" "${dest}"`,
|
||||||
|
{ stdio: "pipe" },
|
||||||
|
);
|
||||||
|
logger.info(` ${label} -- extracted from ${isoPath}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
extract("/live/vmlinuz", kernel, "VyOS kernel");
|
||||||
|
extract("/live/filesystem.squashfs", squashfs, "VyOS squashfs");
|
||||||
|
|
||||||
|
// Pick the real initrd by size from the ISO's own directory listing.
|
||||||
|
const listing = execSync(`xorriso -indev "${iso}" -lsl /live/ --`, {
|
||||||
|
encoding: "utf-8",
|
||||||
|
stdio: ["pipe", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const best = pickLargestInitrd(listing);
|
||||||
|
if (best === undefined) {
|
||||||
|
throw new Error("No non-empty initrd found in /live on the VyOS ISO");
|
||||||
|
}
|
||||||
|
extract(`/live/${best.name}`, initrd, `VyOS initrd (${best.name}, ${best.size} bytes)`);
|
||||||
|
|
||||||
|
// The ISO is only needed to produce the three artifacts above.
|
||||||
|
try {
|
||||||
|
unlinkSync(iso);
|
||||||
|
} catch {
|
||||||
|
// Non-fatal: leaving it costs disk but nothing else.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function symlinkSafe(target: string, linkPath: string): void {
|
function symlinkSafe(target: string, linkPath: string): void {
|
||||||
try {
|
try {
|
||||||
symlinkSync(target, linkPath);
|
symlinkSync(target, linkPath);
|
||||||
@@ -182,6 +301,17 @@ export async function startBastion(overrides: Partial<BastionConfig> = {}): Prom
|
|||||||
logger.warn(`Ubuntu ${config.ubuntuVersion} artifacts not available -- Ubuntu provisioning disabled`);
|
logger.warn(`Ubuntu ${config.ubuntuVersion} artifacts not available -- Ubuntu provisioning disabled`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// VyOS netboot artifacts (non-fatal — same policy as Ubuntu)
|
||||||
|
try {
|
||||||
|
logger.info("Preparing VyOS netboot artifacts...");
|
||||||
|
prepareVyosArtifacts(config);
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn(
|
||||||
|
`VyOS artifacts not available -- VyOS provisioning disabled ` +
|
||||||
|
`(${err instanceof Error ? err.message : String(err)})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Symlink iPXE binaries into HTTP dir for UEFI HTTP Boot
|
// Symlink iPXE binaries into HTTP dir for UEFI HTTP Boot
|
||||||
for (const name of ["ipxe.efi", "ipxe-arm64.efi"]) {
|
for (const name of ["ipxe.efi", "ipxe-arm64.efi"]) {
|
||||||
const src = `${config.tftpDir}/${name}`;
|
const src = `${config.tftpDir}/${name}`;
|
||||||
@@ -261,6 +391,7 @@ export async function startBastion(overrides: Partial<BastionConfig> = {}): Prom
|
|||||||
role: msg.role as import("@lab/shared").Role,
|
role: msg.role as import("@lab/shared").Role,
|
||||||
os: msg.os as import("@lab/shared").OsId,
|
os: msg.os as import("@lab/shared").OsId,
|
||||||
queued_at: new Date().toISOString(),
|
queued_at: new Date().toISOString(),
|
||||||
|
...(msg.vyos ? { vyos: msg.vyos } : {}),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
return { status: "ok", data: { mac: msg.mac, hostname: msg.hostname } };
|
return { status: "ok", data: { mac: msg.mac, hostname: msg.hostname } };
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
// /api/discover - receive hardware discovery reports from PXE-booted machines
|
// /api/discover - receive hardware discovery reports from PXE-booted machines
|
||||||
|
|
||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import type { HardwareInfo, InstalledInfo, Role } from "@lab/shared";
|
import type { HardwareInfo, InstalledInfo, Role, VyosInstallSpec } from "@lab/shared";
|
||||||
import { isValidOsId, SUPPORTED_ROLES } from "@lab/shared";
|
import { isValidOsId, SUPPORTED_ROLES, SUPPORTED_OS } from "@lab/shared";
|
||||||
import type { StateManager } from "../services/state.js";
|
import type { StateManager } from "../services/state.js";
|
||||||
import { logger } from "../services/logger.js";
|
import { logger } from "../services/logger.js";
|
||||||
import { triggerPostProvisionK3s } from "../services/post-provision.js";
|
import { triggerPostProvisionK3s } from "../services/post-provision.js";
|
||||||
@@ -34,9 +34,10 @@ export function registerApiRoutes(
|
|||||||
disk?: string;
|
disk?: string;
|
||||||
role?: string;
|
role?: string;
|
||||||
os?: string;
|
os?: string;
|
||||||
|
vyos?: VyosInstallSpec;
|
||||||
};
|
};
|
||||||
}>("/api/install", async (request, reply) => {
|
}>("/api/install", async (request, reply) => {
|
||||||
const { mac: rawMac, hostname, disk, role, os } = request.body ?? {};
|
const { mac: rawMac, hostname, disk, role, os, vyos } = request.body ?? {};
|
||||||
const mac = (rawMac ?? "").toLowerCase().replace(/-/g, ":");
|
const mac = (rawMac ?? "").toLowerCase().replace(/-/g, ":");
|
||||||
|
|
||||||
if (mac === "") {
|
if (mac === "") {
|
||||||
@@ -50,7 +51,7 @@ export function registerApiRoutes(
|
|||||||
|
|
||||||
const osId = os ?? "fedora-43";
|
const osId = os ?? "fedora-43";
|
||||||
if (!isValidOsId(osId)) {
|
if (!isValidOsId(osId)) {
|
||||||
return reply.status(400).send({ error: `invalid os: '${osId}'. Supported: fedora-43, ubuntu-26.04` });
|
return reply.status(400).send({ error: `invalid os: '${osId}'. Supported: ${SUPPORTED_OS.join(", ")}` });
|
||||||
}
|
}
|
||||||
|
|
||||||
state.update((s) => {
|
state.update((s) => {
|
||||||
@@ -60,6 +61,7 @@ export function registerApiRoutes(
|
|||||||
role: validRole as Role,
|
role: validRole as Role,
|
||||||
os: osId,
|
os: osId,
|
||||||
queued_at: new Date().toISOString(),
|
queued_at: new Date().toISOString(),
|
||||||
|
...(vyos ? { vyos } : {}),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
renderLocalBootIpxe,
|
renderLocalBootIpxe,
|
||||||
} from "../templates/boot.ipxe.js";
|
} from "../templates/boot.ipxe.js";
|
||||||
import { renderUbuntuInstallIpxe } from "../templates/ubuntu-boot.ipxe.js";
|
import { renderUbuntuInstallIpxe } from "../templates/ubuntu-boot.ipxe.js";
|
||||||
|
import { renderVyosInstallIpxe } from "../templates/vyos-boot.ipxe.js";
|
||||||
import { renderDebugKickstart } from "../templates/debug.ks.js";
|
import { renderDebugKickstart } from "../templates/debug.ks.js";
|
||||||
import { logger } from "../services/logger.js";
|
import { logger } from "../services/logger.js";
|
||||||
|
|
||||||
@@ -100,7 +101,14 @@ echo "==============================="
|
|||||||
logger.info(`INSTALL STARTED: ${mac} -> ${hostname} (${os})`);
|
logger.info(`INSTALL STARTED: ${mac} -> ${hostname} (${os})`);
|
||||||
|
|
||||||
let script: string;
|
let script: string;
|
||||||
if (os.startsWith("ubuntu")) {
|
if (os.startsWith("vyos")) {
|
||||||
|
script = renderVyosInstallIpxe({
|
||||||
|
mac,
|
||||||
|
hostname,
|
||||||
|
serverIp: config.serverIp,
|
||||||
|
httpPort: config.httpPort,
|
||||||
|
});
|
||||||
|
} else if (os.startsWith("ubuntu")) {
|
||||||
script = renderUbuntuInstallIpxe({
|
script = renderUbuntuInstallIpxe({
|
||||||
mac,
|
mac,
|
||||||
hostname,
|
hostname,
|
||||||
|
|||||||
70
bastion/src/bastion/src/routes/vyos.ts
Normal file
70
bastion/src/bastion/src/routes/vyos.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
// VyOS network install routes.
|
||||||
|
//
|
||||||
|
// VyOS has no unattended installer, so the automation is injected via
|
||||||
|
// live-config's `hooks` component: the iPXE script passes
|
||||||
|
// live-config.hooks=<.../vyos/autoinstall.sh>, live-config wgets it and runs it
|
||||||
|
// as root, and that script fetches and executes the generated install driver.
|
||||||
|
|
||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import type { BastionConfig } from "@lab/shared";
|
||||||
|
import type { StateManager } from "../services/state.js";
|
||||||
|
import { buildVyosConfigSpec } from "../templates/vyos-config-spec.js";
|
||||||
|
import { renderVyosInstallPy } from "../templates/vyos-install.py.js";
|
||||||
|
import { logger } from "../services/logger.js";
|
||||||
|
|
||||||
|
function normalizeMac(value: string | undefined): string {
|
||||||
|
return (value ?? "").toLowerCase().replace(/-/g, ":");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerVyosRoutes(
|
||||||
|
app: FastifyInstance,
|
||||||
|
config: BastionConfig,
|
||||||
|
state: StateManager,
|
||||||
|
): void {
|
||||||
|
// live-config hook. Kept minimal: everything version-specific lives in the
|
||||||
|
// generated Python. wget is guaranteed present -- live-config used it to
|
||||||
|
// fetch this very script.
|
||||||
|
app.get<{ Querystring: { mac?: string } }>("/vyos/autoinstall.sh", async (request, reply) => {
|
||||||
|
const mac = normalizeMac(request.query.mac);
|
||||||
|
const base = `http://${config.serverIp}:${config.httpPort}`;
|
||||||
|
|
||||||
|
logger.info(`VYOS AUTOINSTALL HOOK served to ${mac || "unknown MAC"}`);
|
||||||
|
|
||||||
|
const script = `#!/bin/sh
|
||||||
|
# Lab PXE Bastion -- VyOS unattended install hook (run by live-config as root)
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
wget -q "${base}/vyos/install.py?mac=${mac}" -O /tmp/vyos-install.py
|
||||||
|
exec python3 /tmp/vyos-install.py
|
||||||
|
`;
|
||||||
|
return reply.type("text/plain").send(script);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Per-MAC install driver, with the machine's config spec baked in.
|
||||||
|
app.get<{ Querystring: { mac?: string } }>("/vyos/install.py", async (request, reply) => {
|
||||||
|
const mac = normalizeMac(request.query.mac);
|
||||||
|
const queueEntry = state.load().install_queue[mac];
|
||||||
|
|
||||||
|
const spec = buildVyosConfigSpec({
|
||||||
|
hostname: queueEntry?.hostname ?? "vyos",
|
||||||
|
spec: queueEntry?.vyos,
|
||||||
|
defaultPassword: config.vyosDefaultPassword,
|
||||||
|
sshKeys: config.sshKeys,
|
||||||
|
disk: queueEntry?.disk,
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`VYOS INSTALL DRIVER served to ${mac} (${spec.hostname}, ` +
|
||||||
|
`${spec.sets.length} config ops, disk="${spec.disk || "auto"}")`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const script = renderVyosInstallPy({
|
||||||
|
spec,
|
||||||
|
mac,
|
||||||
|
serverIp: config.serverIp,
|
||||||
|
httpPort: config.httpPort,
|
||||||
|
});
|
||||||
|
|
||||||
|
return reply.type("text/plain").send(script);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import { registerDispatchRoutes } from "./routes/dispatch.js";
|
|||||||
import { registerKickstartRoutes } from "./routes/kickstart.js";
|
import { registerKickstartRoutes } from "./routes/kickstart.js";
|
||||||
import { registerApiRoutes } from "./routes/api.js";
|
import { registerApiRoutes } from "./routes/api.js";
|
||||||
import { registerAsahiRoutes } from "./routes/asahi.js";
|
import { registerAsahiRoutes } from "./routes/asahi.js";
|
||||||
|
import { registerVyosRoutes } from "./routes/vyos.js";
|
||||||
|
|
||||||
|
|
||||||
export function createApp(config: BastionConfig): { app: ReturnType<typeof Fastify>; state: StateManager; installLog: InstallLogBuffer; syslog: SyslogListener } {
|
export function createApp(config: BastionConfig): { app: ReturnType<typeof Fastify>; state: StateManager; installLog: InstallLogBuffer; syslog: SyslogListener } {
|
||||||
@@ -47,6 +48,7 @@ export function createApp(config: BastionConfig): { app: ReturnType<typeof Fasti
|
|||||||
registerKickstartRoutes(app, config, state, syslog);
|
registerKickstartRoutes(app, config, state, syslog);
|
||||||
registerApiRoutes(app, state, installLog, syslog);
|
registerApiRoutes(app, state, installLog, syslog);
|
||||||
registerAsahiRoutes(app, config);
|
registerAsahiRoutes(app, config);
|
||||||
|
registerVyosRoutes(app, config, state);
|
||||||
// boot.iso is generated at startup and served as a static file from httpDir
|
// boot.iso is generated at startup and served as a static file from httpDir
|
||||||
// (static serving supports HTTP Range requests, required by JetKVM streaming)
|
// (static serving supports HTTP Range requests, required by JetKVM streaming)
|
||||||
|
|
||||||
|
|||||||
39
bastion/src/bastion/src/templates/vyos-boot.ipxe.ts
Normal file
39
bastion/src/bastion/src/templates/vyos-boot.ipxe.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
// iPXE boot script template for VyOS network install.
|
||||||
|
//
|
||||||
|
// VyOS ships no unattended installer: `install image` is unconditionally
|
||||||
|
// interactive (image_installer.py's install action takes no arguments, and
|
||||||
|
// --no-prompt is wired only to `add`). So PXE boots the *live* system and the
|
||||||
|
// automation is injected through live-config's `hooks` component, which fetches
|
||||||
|
// a script over HTTP and runs it as root late in live boot.
|
||||||
|
//
|
||||||
|
// Unlike the Fedora/Ubuntu paths this boots a live image rather than an
|
||||||
|
// installer, so there is no kickstart/autoinstall equivalent — see
|
||||||
|
// routes/vyos.ts for the hook that actually drives the install.
|
||||||
|
|
||||||
|
export function renderVyosInstallIpxe(params: {
|
||||||
|
mac: string;
|
||||||
|
hostname: string;
|
||||||
|
serverIp: string;
|
||||||
|
httpPort: number;
|
||||||
|
}): string {
|
||||||
|
const base = `http://${params.serverIp}:${params.httpPort}`;
|
||||||
|
|
||||||
|
// Deliberately NOT passing `nonetworking` (present in VyOS's own PXE docs):
|
||||||
|
// live-config's hook component needs networking up to fetch the hook over
|
||||||
|
// HTTP. Also no `console=ttyS0` — on hardware without a physical UART that
|
||||||
|
// costs 30s at every systemd boot phase.
|
||||||
|
return `#!ipxe
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo =============================================
|
||||||
|
echo Lab PXE Bastion - INSTALLING VyOS
|
||||||
|
echo Target: ${params.hostname}
|
||||||
|
echo MAC: ${params.mac}
|
||||||
|
echo =============================================
|
||||||
|
echo
|
||||||
|
|
||||||
|
kernel ${base}/vyos-vmlinuz boot=live nopersistence noautologin fetch=${base}/vyos-filesystem.squashfs live-config.hooks=${base}/vyos/autoinstall.sh?mac=${params.mac}
|
||||||
|
initrd ${base}/vyos-initrd
|
||||||
|
boot
|
||||||
|
`;
|
||||||
|
}
|
||||||
197
bastion/src/bastion/src/templates/vyos-config-spec.ts
Normal file
197
bastion/src/bastion/src/templates/vyos-config-spec.ts
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
// Builds the VyOS configuration spec applied by the autoinstall hook.
|
||||||
|
//
|
||||||
|
// We deliberately do NOT emit a config.boot file as text. A config.boot carries a
|
||||||
|
// `vyos-config-version` trailer; without a trailer matching the running image,
|
||||||
|
// VyOS runs its migration scripts from version 0 on first boot. Instead the hook
|
||||||
|
// loads the image's own /opt/vyatta/etc/config.boot.default through vyos.configtree
|
||||||
|
// and applies these set operations on top, so syntax and version trailer always
|
||||||
|
// match the exact image being installed.
|
||||||
|
|
||||||
|
import type { VyosInstallSpec } from "@lab/shared";
|
||||||
|
|
||||||
|
export interface VyosSetOp {
|
||||||
|
path: string[];
|
||||||
|
value?: string;
|
||||||
|
/** false appends to a multi-value node (e.g. bond members) instead of replacing. */
|
||||||
|
replace?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VyosConfigSpec {
|
||||||
|
hostname: string;
|
||||||
|
/** "" means accept the installer default (the running image's version string). */
|
||||||
|
imageName: string;
|
||||||
|
password: string;
|
||||||
|
console: "K" | "S";
|
||||||
|
/** Target disk name (e.g. "nvme0n1"); "" accepts the installer's first-disk default. */
|
||||||
|
disk: string;
|
||||||
|
/** Whether to accept RAID-1 when the installer finds more than one disk. */
|
||||||
|
raid: boolean;
|
||||||
|
sets: VyosSetOp[];
|
||||||
|
/** Paths that are VyOS tag nodes — must be marked as such in the ConfigTree. */
|
||||||
|
tags: string[][];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalise a target disk to the form the installer expects.
|
||||||
|
*
|
||||||
|
* find_disks() enumerates via `lsblk -Jbp` (-p = full paths), so its valid
|
||||||
|
* responses are "/dev/mmcblk0"-style. A bare "mmcblk0" is rejected by
|
||||||
|
* ask_input()'s valid_responses check and re-prompts forever.
|
||||||
|
*/
|
||||||
|
function normalizeDiskPath(value: string | undefined): string {
|
||||||
|
const raw = (value ?? "").trim();
|
||||||
|
if (raw === "") return "";
|
||||||
|
return raw.startsWith("/dev/") ? raw : `/dev/${raw}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildVyosConfigSpec(params: {
|
||||||
|
hostname: string;
|
||||||
|
spec?: VyosInstallSpec | undefined;
|
||||||
|
defaultPassword: string;
|
||||||
|
sshKeys?: string[] | undefined;
|
||||||
|
disk?: string | undefined;
|
||||||
|
}): VyosConfigSpec {
|
||||||
|
const spec = params.spec ?? {};
|
||||||
|
const mgmt = spec.mgmtInterface ?? "eth0";
|
||||||
|
const mgmtAddress = spec.mgmtAddress ?? "dhcp";
|
||||||
|
const bondMembers = spec.bondMembers ?? [];
|
||||||
|
const vlans = spec.vlans ?? [];
|
||||||
|
|
||||||
|
const sets: VyosSetOp[] = [];
|
||||||
|
const tags: string[][] = [
|
||||||
|
["interfaces", "ethernet"],
|
||||||
|
["system", "login", "user"],
|
||||||
|
];
|
||||||
|
|
||||||
|
const hwIds = spec.hwIds ?? {};
|
||||||
|
const pinHwId = (iface: string): void => {
|
||||||
|
const mac = hwIds[iface];
|
||||||
|
if (mac !== undefined && mac !== "") {
|
||||||
|
sets.push({ path: ["interfaces", "ethernet", iface, "hw-id"], value: mac });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
sets.push({ path: ["system", "host-name"], value: params.hostname });
|
||||||
|
|
||||||
|
// Management interface — the NIC that PXE booted, left untagged and unbonded.
|
||||||
|
sets.push({ path: ["interfaces", "ethernet", mgmt, "address"], value: mgmtAddress });
|
||||||
|
pinHwId(mgmt);
|
||||||
|
|
||||||
|
// Tagged management VLAN on the PXE port. Emitted regardless of bonding, so
|
||||||
|
// the box stays reachable on the management VLAN while still booting untagged
|
||||||
|
// on whichever VLAN the bastion's proxy DHCP serves.
|
||||||
|
const mgmtVlan = spec.mgmtVlan;
|
||||||
|
if (mgmtVlan !== undefined) {
|
||||||
|
tags.push(["interfaces", "ethernet", mgmt, "vif"]);
|
||||||
|
const vif = ["interfaces", "ethernet", mgmt, "vif", String(mgmtVlan.id)];
|
||||||
|
sets.push({ path: [...vif, "address"], value: mgmtVlan.address });
|
||||||
|
if (mgmtVlan.description !== undefined && mgmtVlan.description !== "") {
|
||||||
|
sets.push({ path: [...vif, "description"], value: mgmtVlan.description });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LACP bond. Members must exclude the PXE NIC; firmware PXE cannot run over LACP.
|
||||||
|
const bonded = bondMembers.length > 0;
|
||||||
|
if (bonded) {
|
||||||
|
tags.push(["interfaces", "bonding"]);
|
||||||
|
sets.push({ path: ["interfaces", "bonding", "bond0", "mode"], value: "802.3ad" });
|
||||||
|
sets.push({ path: ["interfaces", "bonding", "bond0", "hash-policy"], value: "layer2+3" });
|
||||||
|
for (const member of bondMembers) {
|
||||||
|
sets.push({
|
||||||
|
path: ["interfaces", "bonding", "bond0", "member", "interface"],
|
||||||
|
value: member,
|
||||||
|
replace: false,
|
||||||
|
});
|
||||||
|
pinHwId(member);
|
||||||
|
}
|
||||||
|
// Address on the trunk's native/untagged VLAN.
|
||||||
|
if (spec.bondAddress !== undefined && spec.bondAddress !== "") {
|
||||||
|
sets.push({ path: ["interfaces", "bonding", "bond0", "address"], value: spec.bondAddress });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// VRRP groups accumulate here; emitted (plus a sync group) after the VLANs.
|
||||||
|
// interface accepts dotted vifs (constraint regex `[0-9]+(.\d+)?`), address
|
||||||
|
// is a tag node (the VIP is the tag value itself), vrid range is 1-255.
|
||||||
|
const vrrpGroups: Array<{ name: string; iface: string; vrid: number; vip: string }> = [];
|
||||||
|
if (bonded && spec.bondVrrp !== undefined && spec.bondVrrp !== "") {
|
||||||
|
// vrid 1 for the untagged group: the native VLAN is never a vif, so this
|
||||||
|
// cannot collide with a vlan-id-derived vrid.
|
||||||
|
vrrpGroups.push({ name: "native", iface: "bond0", vrid: 1, vip: spec.bondVrrp });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tagged VLAN sub-interfaces hang off the bond when there is one, else off mgmt.
|
||||||
|
const parent = bonded
|
||||||
|
? ["interfaces", "bonding", "bond0"]
|
||||||
|
: ["interfaces", "ethernet", mgmt];
|
||||||
|
if (vlans.length > 0) {
|
||||||
|
tags.push([...parent, "vif"]);
|
||||||
|
const parentName = bonded ? "bond0" : mgmt;
|
||||||
|
for (const vlan of vlans) {
|
||||||
|
const vif = [...parent, "vif", String(vlan.id)];
|
||||||
|
sets.push({ path: [...vif, "address"], value: vlan.address });
|
||||||
|
if (vlan.description !== undefined && vlan.description !== "") {
|
||||||
|
sets.push({ path: [...vif, "description"], value: vlan.description });
|
||||||
|
}
|
||||||
|
if (vlan.vrrp !== undefined && vlan.vrrp !== "") {
|
||||||
|
vrrpGroups.push({
|
||||||
|
name: `vlan${vlan.id}`,
|
||||||
|
iface: `${parentName}.${vlan.id}`,
|
||||||
|
vrid: vlan.id,
|
||||||
|
vip: vlan.vrrp,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit VRRP groups plus one sync group so all VLANs fail over together —
|
||||||
|
// without it a single-link event could split mastership across the pair.
|
||||||
|
if (vrrpGroups.length > 0) {
|
||||||
|
tags.push(["high-availability", "vrrp", "group"]);
|
||||||
|
tags.push(["high-availability", "vrrp", "sync-group"]);
|
||||||
|
const priority = String(spec.vrrpPriority ?? 100);
|
||||||
|
for (const g of vrrpGroups) {
|
||||||
|
const base = ["high-availability", "vrrp", "group", g.name];
|
||||||
|
sets.push({ path: [...base, "interface"], value: g.iface });
|
||||||
|
sets.push({ path: [...base, "vrid"], value: String(g.vrid) });
|
||||||
|
sets.push({ path: [...base, "priority"], value: priority });
|
||||||
|
// address is a tag node: the VIP is the path's final segment, no value.
|
||||||
|
sets.push({ path: [...base, "address", g.vip] });
|
||||||
|
tags.push([...base, "address"]);
|
||||||
|
sets.push({
|
||||||
|
path: ["high-availability", "vrrp", "sync-group", "MAIN", "member"],
|
||||||
|
value: g.name,
|
||||||
|
replace: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sets.push({ path: ["service", "ssh", "port"], value: "22" });
|
||||||
|
|
||||||
|
const sshKeys = params.sshKeys ?? [];
|
||||||
|
if (sshKeys.length > 0) {
|
||||||
|
tags.push(["system", "login", "user", "vyos", "authentication", "public-keys"]);
|
||||||
|
sshKeys.forEach((entry, index) => {
|
||||||
|
const parts = entry.trim().split(/\s+/);
|
||||||
|
const type = parts[0] ?? "";
|
||||||
|
const key = parts[1] ?? "";
|
||||||
|
if (!type.startsWith("ssh-") && !type.startsWith("ecdsa-")) return;
|
||||||
|
if (!key) return;
|
||||||
|
const name = parts[2] ?? `lab-key-${index}`;
|
||||||
|
const base = ["system", "login", "user", "vyos", "authentication", "public-keys", name];
|
||||||
|
sets.push({ path: [...base, "type"], value: type });
|
||||||
|
sets.push({ path: [...base, "key"], value: key });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
hostname: params.hostname,
|
||||||
|
imageName: "",
|
||||||
|
password: spec.password ?? params.defaultPassword,
|
||||||
|
console: "K",
|
||||||
|
disk: normalizeDiskPath(params.disk),
|
||||||
|
raid: false,
|
||||||
|
sets,
|
||||||
|
tags,
|
||||||
|
};
|
||||||
|
}
|
||||||
299
bastion/src/bastion/src/templates/vyos-install.py.ts
Normal file
299
bastion/src/bastion/src/templates/vyos-install.py.ts
Normal file
@@ -0,0 +1,299 @@
|
|||||||
|
// Renders the Python program that performs the unattended VyOS install.
|
||||||
|
//
|
||||||
|
// It runs as root inside the live system, fetched and executed by live-config's
|
||||||
|
// `hooks` component (see vyos-boot.ipxe.ts). It does three things:
|
||||||
|
// 1. builds config.boot from the image's own default via vyos.configtree
|
||||||
|
// 2. drives the interactive `install image` through a pty
|
||||||
|
// 3. reports progress back to the bastion, then reboots
|
||||||
|
//
|
||||||
|
// A pty is used rather than piping stdin because the installer reads the
|
||||||
|
// password through getpass(), which opens /dev/tty directly and would ignore a
|
||||||
|
// pipe. Prompts are matched by text rather than replayed positionally: the
|
||||||
|
// installer skips the boot-config question when it finds a previous
|
||||||
|
// installation, so a fixed answer sequence desyncs on reinstall.
|
||||||
|
|
||||||
|
import type { VyosConfigSpec } from "./vyos-config-spec.js";
|
||||||
|
|
||||||
|
export function renderVyosInstallPy(params: {
|
||||||
|
spec: VyosConfigSpec;
|
||||||
|
mac: string;
|
||||||
|
serverIp: string;
|
||||||
|
httpPort: number;
|
||||||
|
}): string {
|
||||||
|
// Base64 so arbitrary values (passwords, descriptions, SSH keys) can never
|
||||||
|
// terminate the Python string literal that carries them.
|
||||||
|
const specB64 = Buffer.from(JSON.stringify(params.spec), "utf-8").toString("base64");
|
||||||
|
|
||||||
|
return `#!/usr/bin/env python3
|
||||||
|
"""Unattended VyOS install driver -- generated by the lab PXE bastion."""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import pty
|
||||||
|
import re
|
||||||
|
import select
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
SPEC = json.loads(base64.b64decode("${specB64}").decode("utf-8"))
|
||||||
|
BASTION = "http://${params.serverIp}:${params.httpPort}"
|
||||||
|
MAC = "${params.mac}"
|
||||||
|
|
||||||
|
INSTALLER = "/usr/libexec/vyos/op_mode/image_installer.py"
|
||||||
|
CONFIG_DIR = "/opt/vyatta/etc/config"
|
||||||
|
# The installer copies the rootfs from the boot MEDIUM path -- which only a
|
||||||
|
# CD/USB boot provides. With fetch= (HTTP netboot) nothing is mounted there
|
||||||
|
# (verified in VM: Errno 2), so the squashfs must be linked or re-fetched into
|
||||||
|
# place before 'install image' runs.
|
||||||
|
ROOTFS_EXPECTED = "/usr/lib/live/mount/medium/live/filesystem.squashfs"
|
||||||
|
SQUASHFS_URL = "http://${params.serverIp}:${params.httpPort}/vyos-filesystem.squashfs"
|
||||||
|
# The live-config hook runs BEFORE vyos-router creates the /opt/vyatta compat
|
||||||
|
# path, so the squashfs's own location must be tried too (verified in VM: only
|
||||||
|
# /usr/share/vyos/config.boot.default exists at hook time).
|
||||||
|
DEFAULT_CONFIG_CANDIDATES = [
|
||||||
|
"/opt/vyatta/etc/config.boot.default",
|
||||||
|
"/usr/share/vyos/config.boot.default",
|
||||||
|
]
|
||||||
|
STALL_TIMEOUT = 900 # seconds without installer output before giving up
|
||||||
|
|
||||||
|
|
||||||
|
def report(stage, detail=""):
|
||||||
|
"""Best-effort progress callback; never fatal."""
|
||||||
|
try:
|
||||||
|
body = json.dumps({"mac": MAC, "stage": stage, "detail": detail}).encode()
|
||||||
|
req = urllib.request.Request(
|
||||||
|
BASTION + "/api/progress",
|
||||||
|
data=body,
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
urllib.request.urlopen(req, timeout=5).read()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def build_config():
|
||||||
|
"""Apply our set operations onto the image's own default config.
|
||||||
|
|
||||||
|
Using config.boot.default as the base keeps the vyos-config-version trailer
|
||||||
|
consistent with the running image, so first boot does not run migrations.
|
||||||
|
"""
|
||||||
|
from vyos.configtree import ConfigTree
|
||||||
|
|
||||||
|
default_config = next(
|
||||||
|
(p for p in DEFAULT_CONFIG_CANDIDATES if os.path.exists(p)), None)
|
||||||
|
if default_config is None:
|
||||||
|
raise FileNotFoundError(
|
||||||
|
"no config.boot.default found (tried %s)" % ", ".join(DEFAULT_CONFIG_CANDIDATES))
|
||||||
|
print("base config: %s" % default_config)
|
||||||
|
|
||||||
|
with open(default_config) as handle:
|
||||||
|
config = ConfigTree(handle.read())
|
||||||
|
|
||||||
|
for op in SPEC["sets"]:
|
||||||
|
replace = op.get("replace", True)
|
||||||
|
if "value" in op and op["value"] is not None:
|
||||||
|
config.set(op["path"], value=op["value"], replace=replace)
|
||||||
|
else:
|
||||||
|
config.set(op["path"])
|
||||||
|
|
||||||
|
# Tag nodes must be marked after the nodes exist, as the installer itself does.
|
||||||
|
for tag in SPEC["tags"]:
|
||||||
|
try:
|
||||||
|
config.set_tag(tag)
|
||||||
|
except Exception as err:
|
||||||
|
print("warning: set_tag %s failed: %s" % (tag, err))
|
||||||
|
|
||||||
|
os.makedirs(CONFIG_DIR, exist_ok=True)
|
||||||
|
target = os.path.join(CONFIG_DIR, "config.boot")
|
||||||
|
|
||||||
|
# Re-attach the vyos-config-version footer: ConfigTree.to_string() emits
|
||||||
|
# only the config body, and a config without the footer is treated as
|
||||||
|
# ancient -- the boot migrator then runs every migration over it and (as
|
||||||
|
# observed in the VM test) crashes in system/31-to-32. Building the footer
|
||||||
|
# from the running system pins it to the exact image being installed.
|
||||||
|
body = config.to_string()
|
||||||
|
try:
|
||||||
|
from vyos.component_version import version_info_from_system
|
||||||
|
info = version_info_from_system()
|
||||||
|
info.update_config_body(body)
|
||||||
|
info.write(target)
|
||||||
|
print("wrote %s (footer: %s)" % (target, info.release))
|
||||||
|
except Exception as err:
|
||||||
|
print("warning: version footer failed (%s); writing bare config" % err)
|
||||||
|
with open(target, "w") as handle:
|
||||||
|
handle.write(body)
|
||||||
|
return target
|
||||||
|
|
||||||
|
|
||||||
|
def find_live_squashfs():
|
||||||
|
"""Locate the squashfs live-boot fetched, without walking into the mounted
|
||||||
|
rootfs or overlay (each would mean traversing the entire OS tree)."""
|
||||||
|
explicit = [
|
||||||
|
"/run/live/medium/live/filesystem.squashfs",
|
||||||
|
"/lib/live/mount/medium/live/filesystem.squashfs",
|
||||||
|
]
|
||||||
|
for path in explicit:
|
||||||
|
if os.path.isfile(path) and os.path.getsize(path) > 0:
|
||||||
|
return path
|
||||||
|
for root in ("/run/live", "/lib/live/mount", "/usr/lib/live/mount"):
|
||||||
|
for dirpath, dirs, files in os.walk(root):
|
||||||
|
depth = dirpath.count(os.sep) - root.count(os.sep)
|
||||||
|
dirs[:] = [d for d in dirs
|
||||||
|
if d not in ("rootfs", "overlay")
|
||||||
|
and not d.endswith(".squashfs")
|
||||||
|
and depth < 3]
|
||||||
|
if "filesystem.squashfs" in files:
|
||||||
|
path = os.path.join(dirpath, "filesystem.squashfs")
|
||||||
|
if os.path.isfile(path) and os.path.getsize(path) > 0:
|
||||||
|
return path
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_rootfs():
|
||||||
|
"""Make FILE_ROOTFS_SRC exist so the installer can copy the system image."""
|
||||||
|
if os.path.isfile(ROOTFS_EXPECTED) and os.path.getsize(ROOTFS_EXPECTED) > 0:
|
||||||
|
return
|
||||||
|
src = find_live_squashfs()
|
||||||
|
if src is None:
|
||||||
|
print("squashfs not in live mounts; re-fetching %s" % SQUASHFS_URL)
|
||||||
|
src = "/tmp/filesystem.squashfs"
|
||||||
|
urllib.request.urlretrieve(SQUASHFS_URL, src)
|
||||||
|
os.makedirs(os.path.dirname(ROOTFS_EXPECTED), exist_ok=True)
|
||||||
|
if os.path.lexists(ROOTFS_EXPECTED):
|
||||||
|
os.remove(ROOTFS_EXPECTED)
|
||||||
|
os.symlink(src, ROOTFS_EXPECTED)
|
||||||
|
print("rootfs source: %s -> %s" % (ROOTFS_EXPECTED, src))
|
||||||
|
|
||||||
|
|
||||||
|
def build_rules():
|
||||||
|
"""Prompt -> response table for the interactive installer."""
|
||||||
|
password = SPEC["password"].encode() + b"\\n"
|
||||||
|
image_name = SPEC["imageName"].encode() + b"\\n"
|
||||||
|
disk = SPEC["disk"].encode() + b"\\n"
|
||||||
|
console = SPEC["console"].encode() + b"\\n"
|
||||||
|
raid = (b"yes\\n" if SPEC["raid"] else b"no\\n")
|
||||||
|
|
||||||
|
return [
|
||||||
|
(re.compile(rb"Would you like to continue\\?"), b"yes\\n"),
|
||||||
|
(re.compile(rb"What would you like to name this image\\?"), image_name),
|
||||||
|
(re.compile(rb"Please confirm password for the .vyos. user:"), password),
|
||||||
|
(re.compile(rb"Please enter a password for the .vyos. user:"), password),
|
||||||
|
(re.compile(rb"What console should be used by default"), console),
|
||||||
|
(re.compile(rb"Would you like to configure RAID-1 mirroring"), raid),
|
||||||
|
(re.compile(rb"Installation will delete all data on (?:the drive|both drives)\\. Continue\\?"), b"yes\\n"),
|
||||||
|
(re.compile(rb"Which one should be used for installation\\?"), disk),
|
||||||
|
(re.compile(rb"Would you like to use all the free space on the drive\\?"), b"yes\\n"),
|
||||||
|
(re.compile(rb"Which file would you like as boot config\\?"), b"1\\n"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def run_installer():
|
||||||
|
"""Drive image_installer.py over a pty, answering prompts as they appear."""
|
||||||
|
rules = build_rules()
|
||||||
|
master, slave = pty.openpty()
|
||||||
|
|
||||||
|
proc = subprocess.Popen(
|
||||||
|
[INSTALLER, "--action", "install"],
|
||||||
|
stdin=slave,
|
||||||
|
stdout=slave,
|
||||||
|
stderr=slave,
|
||||||
|
close_fds=True,
|
||||||
|
preexec_fn=os.setsid,
|
||||||
|
)
|
||||||
|
os.close(slave)
|
||||||
|
|
||||||
|
buf = b""
|
||||||
|
transcript = b"" # rolling tail of everything the installer printed
|
||||||
|
last_output = time.time()
|
||||||
|
|
||||||
|
while True:
|
||||||
|
ready, _, _ = select.select([master], [], [], 1.0)
|
||||||
|
|
||||||
|
if ready:
|
||||||
|
try:
|
||||||
|
chunk = os.read(master, 4096)
|
||||||
|
except OSError:
|
||||||
|
break
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
|
||||||
|
sys.stdout.buffer.write(chunk)
|
||||||
|
sys.stdout.buffer.flush()
|
||||||
|
buf += chunk
|
||||||
|
transcript = (transcript + chunk)[-8000:]
|
||||||
|
last_output = time.time()
|
||||||
|
|
||||||
|
# Answer every prompt currently in the buffer, earliest first, so
|
||||||
|
# ordering is preserved even when the installer skips questions --
|
||||||
|
# and so a single chunk carrying two prompts gets both answers.
|
||||||
|
while True:
|
||||||
|
best = None
|
||||||
|
for pattern, response in rules:
|
||||||
|
found = pattern.search(buf)
|
||||||
|
if found and (best is None or found.start() < best[0].start()):
|
||||||
|
best = (found, response)
|
||||||
|
if best is None:
|
||||||
|
break
|
||||||
|
found, response = best
|
||||||
|
os.write(master, response)
|
||||||
|
transcript = (transcript + b"\\n>>> answered: " + response)[-8000:]
|
||||||
|
buf = buf[found.end():]
|
||||||
|
|
||||||
|
# Bound memory if the installer emits a lot without prompting.
|
||||||
|
if len(buf) > 65536:
|
||||||
|
buf = buf[-8192:]
|
||||||
|
|
||||||
|
elif proc.poll() is not None:
|
||||||
|
break
|
||||||
|
|
||||||
|
if time.time() - last_output > STALL_TIMEOUT:
|
||||||
|
proc.kill()
|
||||||
|
raise SystemExit("installer produced no output for %ds" % STALL_TIMEOUT)
|
||||||
|
|
||||||
|
os.close(master)
|
||||||
|
return proc.wait(), transcript.decode("utf-8", "replace")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
report("vyos-install", "building config.boot")
|
||||||
|
try:
|
||||||
|
build_config()
|
||||||
|
except Exception as err:
|
||||||
|
report("error", "config generation failed: %s" % err)
|
||||||
|
raise
|
||||||
|
|
||||||
|
report("vyos-install", "staging rootfs for installer")
|
||||||
|
try:
|
||||||
|
ensure_rootfs()
|
||||||
|
except Exception as err:
|
||||||
|
report("error", "rootfs staging failed: %s" % err)
|
||||||
|
raise
|
||||||
|
|
||||||
|
report("vyos-install", "running install image")
|
||||||
|
code, transcript = run_installer()
|
||||||
|
|
||||||
|
if code != 0:
|
||||||
|
# Surface the installer's last words in bastion progress -- the console
|
||||||
|
# they were printed on is usually invisible during unattended installs.
|
||||||
|
report("error", "install image exited %d | tail: %s" % (code, transcript[-4000:]))
|
||||||
|
raise SystemExit(code)
|
||||||
|
|
||||||
|
# "complete" is the stage the bastion uses to move a machine out of the
|
||||||
|
# install queue into installed state -- see routes/api.ts.
|
||||||
|
report("complete", "VyOS installed, rebooting")
|
||||||
|
os.system("sync")
|
||||||
|
# --force: this driver is a child of live-config.service, whose start job is
|
||||||
|
# still running -- a normal reboot deadlocks waiting for it (verified in VM:
|
||||||
|
# shutdown blocked >1min on "start job is running for live-config"). The
|
||||||
|
# installer has already unmounted and cleaned the target, so an immediate
|
||||||
|
# reboot is safe.
|
||||||
|
os.system("systemctl reboot --force")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
`;
|
||||||
|
}
|
||||||
@@ -22,6 +22,8 @@ function createTestConfig(testDir: string): BastionConfig {
|
|||||||
dhcpRangeEnd: "",
|
dhcpRangeEnd: "",
|
||||||
ubuntuVersion: "26.04",
|
ubuntuVersion: "26.04",
|
||||||
ubuntuMirror: "https://releases.ubuntu.com/26.04",
|
ubuntuMirror: "https://releases.ubuntu.com/26.04",
|
||||||
|
vyosIsoUrl: "https://downloads.vyos.io/rolling/current/generic/vyos-rolling-latest.iso",
|
||||||
|
vyosDefaultPassword: "vyos",
|
||||||
iface: "eth0",
|
iface: "eth0",
|
||||||
serverIp: "10.0.0.1",
|
serverIp: "10.0.0.1",
|
||||||
network: "10.0.0.0",
|
network: "10.0.0.0",
|
||||||
|
|||||||
365
bastion/src/bastion/tests/vyos.test.ts
Normal file
365
bastion/src/bastion/tests/vyos.test.ts
Normal file
@@ -0,0 +1,365 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||||
|
import { mkdirSync, rmSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import type { BastionConfig } from "@lab/shared";
|
||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import { createApp } from "../src/server.js";
|
||||||
|
import type { StateManager } from "../src/services/state.js";
|
||||||
|
import { buildVyosConfigSpec } from "../src/templates/vyos-config-spec.js";
|
||||||
|
|
||||||
|
function createTestConfig(testDir: string): BastionConfig {
|
||||||
|
return {
|
||||||
|
fedoraVersion: "43",
|
||||||
|
arch: "x86_64",
|
||||||
|
httpPort: 0,
|
||||||
|
timezone: "Europe/London",
|
||||||
|
locale: "en_GB.UTF-8",
|
||||||
|
bastionDir: testDir,
|
||||||
|
domain: "test.local",
|
||||||
|
dhcpMode: "proxy",
|
||||||
|
dhcpRangeStart: "",
|
||||||
|
dhcpRangeEnd: "",
|
||||||
|
ubuntuVersion: "26.04",
|
||||||
|
ubuntuMirror: "https://releases.ubuntu.com/26.04",
|
||||||
|
vyosIsoUrl: "https://example.invalid/vyos.iso",
|
||||||
|
vyosDefaultPassword: "test-pw",
|
||||||
|
iface: "eth0",
|
||||||
|
serverIp: "10.0.0.1",
|
||||||
|
network: "10.0.0.0",
|
||||||
|
gateway: "10.0.0.1",
|
||||||
|
sshKeys: ["ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITEST lab@test"],
|
||||||
|
adminUser: "testadmin",
|
||||||
|
syslogPort: 15515,
|
||||||
|
skipDnsmasq: true,
|
||||||
|
skipArtifacts: true,
|
||||||
|
fedoraMirror: "https://example.invalid/fedora",
|
||||||
|
tftpDir: join(testDir, "tftp"),
|
||||||
|
httpDir: join(testDir, "http"),
|
||||||
|
stateFile: join(testDir, "state.json"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pull the base64 spec back out of the generated Python driver. */
|
||||||
|
function decodeSpecFrom(python: string): Record<string, unknown> {
|
||||||
|
const match = /base64\.b64decode\("([^"]+)"\)/.exec(python);
|
||||||
|
if (!match?.[1]) throw new Error("no base64 spec found in generated driver");
|
||||||
|
return JSON.parse(Buffer.from(match[1], "base64").toString("utf-8"));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("vyos config spec", () => {
|
||||||
|
it("puts VLANs on the bond when members are given", () => {
|
||||||
|
const spec = buildVyosConfigSpec({
|
||||||
|
hostname: "fw1",
|
||||||
|
defaultPassword: "pw",
|
||||||
|
spec: {
|
||||||
|
mgmtInterface: "eth0",
|
||||||
|
mgmtAddress: "10.0.8.2/24",
|
||||||
|
bondMembers: ["eth2", "eth3"],
|
||||||
|
vlans: [{ id: 10, address: "10.0.10.1/24", description: "k8s" }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const paths = spec.sets.map((s) => s.path.join(" "));
|
||||||
|
expect(paths).toContain("interfaces bonding bond0 mode");
|
||||||
|
expect(paths).toContain("interfaces bonding bond0 vif 10 address");
|
||||||
|
// VLANs must hang off the bond, not the management NIC.
|
||||||
|
expect(paths).not.toContain("interfaces ethernet eth0 vif 10 address");
|
||||||
|
|
||||||
|
// Bond members are a multi-value node — appending, not replacing, is what
|
||||||
|
// keeps the second member from overwriting the first.
|
||||||
|
const members = spec.sets.filter(
|
||||||
|
(s) => s.path.join(" ") === "interfaces bonding bond0 member interface",
|
||||||
|
);
|
||||||
|
expect(members.map((m) => m.value)).toEqual(["eth2", "eth3"]);
|
||||||
|
expect(members.every((m) => m.replace === false)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to VLANs on the management NIC when unbonded", () => {
|
||||||
|
const spec = buildVyosConfigSpec({
|
||||||
|
hostname: "fw2",
|
||||||
|
defaultPassword: "pw",
|
||||||
|
spec: { mgmtInterface: "eth1", vlans: [{ id: 20, address: "10.0.20.1/24" }] },
|
||||||
|
});
|
||||||
|
|
||||||
|
const paths = spec.sets.map((s) => s.path.join(" "));
|
||||||
|
expect(paths).toContain("interfaces ethernet eth1 vif 20 address");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalises the target disk to a full /dev path", () => {
|
||||||
|
// find_disks() enumerates with `lsblk -Jbp`, so valid responses are full
|
||||||
|
// paths; a bare name fails valid_responses and re-prompts forever.
|
||||||
|
expect(buildVyosConfigSpec({ hostname: "fw3", defaultPassword: "pw", disk: "/dev/mmcblk0" }).disk)
|
||||||
|
.toBe("/dev/mmcblk0");
|
||||||
|
expect(buildVyosConfigSpec({ hostname: "fw3", defaultPassword: "pw", disk: "mmcblk0" }).disk)
|
||||||
|
.toBe("/dev/mmcblk0");
|
||||||
|
expect(buildVyosConfigSpec({ hostname: "fw3", defaultPassword: "pw" }).disk).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults to dhcp on eth0 and never opts into RAID", () => {
|
||||||
|
const spec = buildVyosConfigSpec({ hostname: "fw4", defaultPassword: "pw" });
|
||||||
|
const address = spec.sets.find(
|
||||||
|
(s) => s.path.join(" ") === "interfaces ethernet eth0 address",
|
||||||
|
);
|
||||||
|
expect(address?.value).toBe("dhcp");
|
||||||
|
// The installer's RAID prompt defaults to yes; a second disk must not
|
||||||
|
// silently produce a mirror.
|
||||||
|
expect(spec.raid).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("vyos routes", () => {
|
||||||
|
let testDir: string;
|
||||||
|
let app: FastifyInstance;
|
||||||
|
let state: StateManager;
|
||||||
|
const mac = "aa:bb:cc:11:22:33";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
testDir = join(tmpdir(), `bastion-vyos-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||||
|
mkdirSync(join(testDir, "http"), { recursive: true });
|
||||||
|
mkdirSync(join(testDir, "tftp"), { recursive: true });
|
||||||
|
|
||||||
|
const result = createApp(createTestConfig(testDir));
|
||||||
|
app = result.app;
|
||||||
|
state = result.state;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await app.close();
|
||||||
|
rmSync(testDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dispatches a queued vyos machine to the live-boot script", async () => {
|
||||||
|
state.update((s) => {
|
||||||
|
s.install_queue[mac] = {
|
||||||
|
hostname: "fw1",
|
||||||
|
disk: "/dev/nvme0n1",
|
||||||
|
role: "worker",
|
||||||
|
os: "vyos-rolling",
|
||||||
|
queued_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.inject({ method: "GET", url: `/dispatch?mac=${mac}` });
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
|
||||||
|
expect(response.body).toContain("/vyos-vmlinuz");
|
||||||
|
expect(response.body).toContain("fetch=http://10.0.0.1:0/vyos-filesystem.squashfs");
|
||||||
|
expect(response.body).toContain(`live-config.hooks=http://10.0.0.1:0/vyos/autoinstall.sh?mac=${mac}`);
|
||||||
|
|
||||||
|
// `nonetworking` appears in VyOS's own PXE docs but breaks the hook fetch,
|
||||||
|
// and console=ttyS0 costs 30s per systemd phase on boards with no UART.
|
||||||
|
expect(response.body).not.toContain("nonetworking");
|
||||||
|
expect(response.body).not.toContain("console=ttyS0");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serves a hook that fetches and executes the install driver", async () => {
|
||||||
|
const response = await app.inject({ method: "GET", url: `/vyos/autoinstall.sh?mac=${mac}` });
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.body).toContain(`/vyos/install.py?mac=${mac}`);
|
||||||
|
expect(response.body).toContain("python3 /tmp/vyos-install.py");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bakes the machine's config into the generated install driver", async () => {
|
||||||
|
state.update((s) => {
|
||||||
|
s.install_queue[mac] = {
|
||||||
|
hostname: "fw1",
|
||||||
|
disk: "/dev/nvme0n1",
|
||||||
|
role: "worker",
|
||||||
|
os: "vyos-rolling",
|
||||||
|
queued_at: new Date().toISOString(),
|
||||||
|
vyos: {
|
||||||
|
mgmtInterface: "eth0",
|
||||||
|
mgmtAddress: "10.0.8.2/24",
|
||||||
|
bondMembers: ["eth2", "eth3"],
|
||||||
|
vlans: [{ id: 10, address: "10.0.10.1/24" }],
|
||||||
|
password: "s3cret",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.inject({ method: "GET", url: `/vyos/install.py?mac=${mac}` });
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
|
||||||
|
// Builds config from the image's own default so the vyos-config-version
|
||||||
|
// trailer matches and first boot skips migrations.
|
||||||
|
expect(response.body).toContain("/opt/vyatta/etc/config.boot.default");
|
||||||
|
expect(response.body).toContain("/usr/libexec/vyos/op_mode/image_installer.py");
|
||||||
|
// "complete" is what moves the machine out of the install queue.
|
||||||
|
expect(response.body).toContain('report("complete"');
|
||||||
|
|
||||||
|
const spec = decodeSpecFrom(response.body);
|
||||||
|
expect(spec["hostname"]).toBe("fw1");
|
||||||
|
expect(spec["password"]).toBe("s3cret");
|
||||||
|
expect(spec["disk"]).toBe("/dev/nvme0n1");
|
||||||
|
|
||||||
|
const paths = (spec["sets"] as Array<{ path: string[] }>).map((s) => s.path.join(" "));
|
||||||
|
expect(paths).toContain("interfaces bonding bond0 vif 10 address");
|
||||||
|
expect(paths).toContain("system host-name");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the bastion default password when none is set", async () => {
|
||||||
|
state.update((s) => {
|
||||||
|
s.install_queue[mac] = {
|
||||||
|
hostname: "fw9",
|
||||||
|
disk: "",
|
||||||
|
role: "worker",
|
||||||
|
os: "vyos-rolling",
|
||||||
|
queued_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.inject({ method: "GET", url: `/vyos/install.py?mac=${mac}` });
|
||||||
|
const spec = decodeSpecFrom(response.body);
|
||||||
|
expect(spec["password"]).toBe("test-pw");
|
||||||
|
// Empty disk means "accept the installer's first-disk default".
|
||||||
|
expect(spec["disk"]).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("vyos hw-id pinning", () => {
|
||||||
|
it("emits hw-id for the mgmt interface and each bond member", () => {
|
||||||
|
// Discovery sees enp2s0/enp1s0f0np0 under Fedora, but VyOS enumerates its
|
||||||
|
// own eth<N>. Pinning by MAC is what makes the mapping deterministic.
|
||||||
|
const spec = buildVyosConfigSpec({
|
||||||
|
hostname: "fw1",
|
||||||
|
defaultPassword: "pw",
|
||||||
|
spec: {
|
||||||
|
mgmtInterface: "eth2",
|
||||||
|
bondMembers: ["eth0", "eth1"],
|
||||||
|
hwIds: {
|
||||||
|
eth2: "64:62:66:25:96:47",
|
||||||
|
eth0: "64:62:66:25:96:45",
|
||||||
|
eth1: "64:62:66:25:96:46",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const hw = spec.sets.filter((s) => s.path[s.path.length - 1] === "hw-id");
|
||||||
|
expect(hw.map((s) => [s.path[2], s.value])).toEqual([
|
||||||
|
["eth2", "64:62:66:25:96:47"],
|
||||||
|
["eth0", "64:62:66:25:96:45"],
|
||||||
|
["eth1", "64:62:66:25:96:46"],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits hw-id entirely when no mapping is given", () => {
|
||||||
|
const spec = buildVyosConfigSpec({ hostname: "fw1", defaultPassword: "pw" });
|
||||||
|
expect(spec.sets.some((s) => s.path.includes("hw-id"))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("vyos management VLAN", () => {
|
||||||
|
it("puts the mgmt VLAN on the PXE port while the bond carries routed VLANs", () => {
|
||||||
|
// Trunked PXE port: boots untagged on the VLAN the bastion serves, stays
|
||||||
|
// reachable on the tagged management VLAN.
|
||||||
|
const spec = buildVyosConfigSpec({
|
||||||
|
hostname: "vyos001",
|
||||||
|
defaultPassword: "pw",
|
||||||
|
spec: {
|
||||||
|
mgmtInterface: "eth2",
|
||||||
|
mgmtAddress: "dhcp",
|
||||||
|
mgmtVlan: { id: 3, address: "192.168.3.4/24", description: "kvm" },
|
||||||
|
bondMembers: ["eth0", "eth1"],
|
||||||
|
vlans: [{ id: 2, address: "192.168.8.2/23" }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const paths = spec.sets.map((s) => s.path.join(" "));
|
||||||
|
expect(paths).toContain("interfaces ethernet eth2 vif 3 address");
|
||||||
|
expect(paths).toContain("interfaces bonding bond0 vif 2 address");
|
||||||
|
// The mgmt VLAN must not land on the bond.
|
||||||
|
expect(paths).not.toContain("interfaces bonding bond0 vif 3 address");
|
||||||
|
expect(spec.tags.map((t) => t.join(" "))).toContain("interfaces ethernet eth2 vif");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("vyos VRRP HA", () => {
|
||||||
|
const haSpec = {
|
||||||
|
mgmtInterface: "eth2",
|
||||||
|
mgmtAddress: "dhcp",
|
||||||
|
bondMembers: ["eth0", "eth1"],
|
||||||
|
bondAddress: "192.168.1.252/24",
|
||||||
|
bondVrrp: "192.168.1.254/24",
|
||||||
|
vrrpPriority: 200,
|
||||||
|
vlans: [
|
||||||
|
{ id: 3, address: "192.168.3.4/24", vrrp: "192.168.3.254/24" },
|
||||||
|
{ id: 200, address: "192.168.2.252/24" }, // no VIP on this one
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
it("emits a vrrp group per VIP with vrid = VLAN id and dotted vif interface", () => {
|
||||||
|
const spec = buildVyosConfigSpec({ hostname: "fw1", defaultPassword: "pw", spec: haSpec });
|
||||||
|
const paths = spec.sets.map((s) => `${s.path.join(" ")}${s.value !== undefined ? "=" + s.value : ""}`);
|
||||||
|
|
||||||
|
expect(paths).toContain("interfaces bonding bond0 address=192.168.1.252/24");
|
||||||
|
// untagged bond group: vrid 1, interface bond0 itself
|
||||||
|
expect(paths).toContain("high-availability vrrp group native interface=bond0");
|
||||||
|
expect(paths).toContain("high-availability vrrp group native vrid=1");
|
||||||
|
// address is a tag node -- VIP is the final path segment, no value
|
||||||
|
expect(paths).toContain("high-availability vrrp group native address 192.168.1.254/24");
|
||||||
|
// VLAN group: vrid = VLAN id, dotted vif
|
||||||
|
expect(paths).toContain("high-availability vrrp group vlan3 interface=bond0.3");
|
||||||
|
expect(paths).toContain("high-availability vrrp group vlan3 vrid=3");
|
||||||
|
expect(paths).toContain("high-availability vrrp group vlan3 address 192.168.3.254/24");
|
||||||
|
// VLAN without a VIP gets no group
|
||||||
|
expect(paths.some((p) => p.includes("group vlan200"))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies the box-wide priority and one sync group over all groups", () => {
|
||||||
|
const spec = buildVyosConfigSpec({ hostname: "fw1", defaultPassword: "pw", spec: haSpec });
|
||||||
|
const prio = spec.sets.filter((s) => s.path[s.path.length - 1] === "priority"
|
||||||
|
&& s.path[0] === "high-availability");
|
||||||
|
expect(prio).toHaveLength(2);
|
||||||
|
expect(prio.every((s) => s.value === "200")).toBe(true);
|
||||||
|
|
||||||
|
// sync group binds the pair: all groups fail over together
|
||||||
|
const members = spec.sets.filter(
|
||||||
|
(s) => s.path.join(" ") === "high-availability vrrp sync-group MAIN member",
|
||||||
|
);
|
||||||
|
expect(members.map((m) => m.value)).toEqual(["native", "vlan3"]);
|
||||||
|
expect(members.every((m) => m.replace === false)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits no high-availability nodes when no VIPs are given", () => {
|
||||||
|
const spec = buildVyosConfigSpec({
|
||||||
|
hostname: "fw1",
|
||||||
|
defaultPassword: "pw",
|
||||||
|
spec: { bondMembers: ["eth0", "eth1"], vlans: [{ id: 3, address: "192.168.3.4/24" }] },
|
||||||
|
});
|
||||||
|
expect(spec.sets.some((s) => s.path[0] === "high-availability")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("pickLargestInitrd", async () => {
|
||||||
|
const { pickLargestInitrd } = await import("../src/main.js");
|
||||||
|
|
||||||
|
// Verbatim from `xorriso -lsl /live/` on vyos-2026.08.05-0033-rolling.
|
||||||
|
const realListing = `total 8
|
||||||
|
-r--r--r-- 1 0 0 22255 Aug 5 01:33 'filesystem.packages'
|
||||||
|
-r--r--r-- 1 0 0 6 Aug 5 01:33 'filesystem.packages-remove'
|
||||||
|
-r--r--r-- 1 0 0 541192192 Aug 5 01:33 'filesystem.squashfs'
|
||||||
|
-r--r--r-- 1 0 0 50352547 Aug 5 01:33 'initrd.img'
|
||||||
|
-r--r--r-- 1 0 0 50352547 Aug 5 01:33 'initrd.img-6.18.41-vyos'
|
||||||
|
-r--r--r-- 1 0 0 20 Aug 5 01:33 'packages.txt'
|
||||||
|
-r--r--r-- 1 0 0 9135104 Aug 2 19:54 'vmlinuz'
|
||||||
|
-r--r--r-- 1 0 0 9135104 Aug 2 19:54 'vmlinuz-6.18.41-vyos'
|
||||||
|
`;
|
||||||
|
|
||||||
|
it("picks a full-size initrd from a real nightly listing", () => {
|
||||||
|
expect(pickLargestInitrd(realListing)).toEqual({ name: "initrd.img", size: 50352547 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores 0-byte decoys and symlinks (which report link size, not target size)", () => {
|
||||||
|
const listing = `total 8
|
||||||
|
-r--r--r-- 1 0 0 0 Aug 5 01:33 'initrd.img'
|
||||||
|
lrwxrwxrwx 1 0 0 24 Aug 5 01:33 'initrd.img-link' -> 'initrd.img-6.18.41-vyos'
|
||||||
|
-r--r--r-- 1 0 0 50352547 Aug 5 01:33 'initrd.img-6.18.41-vyos'
|
||||||
|
`;
|
||||||
|
expect(pickLargestInitrd(listing)).toEqual({ name: "initrd.img-6.18.41-vyos", size: 50352547 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined when only decoys exist", () => {
|
||||||
|
expect(pickLargestInitrd("-r--r--r-- 1 0 0 0 Aug 5 01:33 'initrd.img'\n")).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -90,6 +90,7 @@ export class LabdClient {
|
|||||||
|
|
||||||
async installMachine(opts: {
|
async installMachine(opts: {
|
||||||
mac: string; hostname: string; disk?: string; role?: string; os?: string;
|
mac: string; hostname: string; disk?: string; role?: string; os?: string;
|
||||||
|
vyos?: import("@lab/shared").VyosInstallSpec;
|
||||||
}): Promise<{ status: string; data?: unknown; error?: string }> {
|
}): Promise<{ status: string; data?: unknown; error?: string }> {
|
||||||
return this.request("POST", "/api/machines/install", { body: opts });
|
return this.request("POST", "/api/machines/install", { body: opts });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,30 @@
|
|||||||
// CLI command: provision install
|
// CLI command: provision install
|
||||||
// Queue a discovered machine for OS installation via labd.
|
// Queue a discovered machine for OS installation via labd.
|
||||||
|
|
||||||
import { Command, Option } from "commander";
|
import { Command, Option, InvalidArgumentError } from "commander";
|
||||||
import { isValidOsId, SUPPORTED_OS, SUPPORTED_ROLES, ROLE_REGISTRY } from "@lab/shared";
|
import { isValidOsId, SUPPORTED_OS, SUPPORTED_ROLES, ROLE_REGISTRY } from "@lab/shared";
|
||||||
|
import type { VyosInstallSpec, VyosVlanSpec } from "@lab/shared";
|
||||||
import { getLabdClient } from "../api/config.js";
|
import { getLabdClient } from "../api/config.js";
|
||||||
|
|
||||||
|
/** Parse a repeated --vlan flag: "<id>:<cidr>[:<description>]". */
|
||||||
|
export function parseVlan(value: string, previous: VyosVlanSpec[] = []): VyosVlanSpec[] {
|
||||||
|
const parts = value.split(":");
|
||||||
|
const id = Number(parts[0]);
|
||||||
|
const address = parts[1] ?? "";
|
||||||
|
// InvalidArgumentError makes commander print a clean message instead of
|
||||||
|
// dumping a stack trace at the operator.
|
||||||
|
if (!Number.isInteger(id) || id < 1 || id > 4094) {
|
||||||
|
throw new InvalidArgumentError(`Invalid VLAN id in "${value}" (expected 1-4094)`);
|
||||||
|
}
|
||||||
|
if (!address.includes("/")) {
|
||||||
|
throw new InvalidArgumentError(
|
||||||
|
`Invalid VLAN address in "${value}" (expected CIDR, e.g. 10.0.10.1/24)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const description = parts.slice(2).join(":");
|
||||||
|
return [...previous, { id, address, ...(description ? { description } : {}) }];
|
||||||
|
}
|
||||||
|
|
||||||
function roleTable(): string {
|
function roleTable(): string {
|
||||||
const lines: string[] = ["", "Available roles:"];
|
const lines: string[] = ["", "Available roles:"];
|
||||||
for (const r of ROLE_REGISTRY) {
|
for (const r of ROLE_REGISTRY) {
|
||||||
@@ -15,6 +35,38 @@ function roleTable(): string {
|
|||||||
return lines.join("\n");
|
return lines.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Parse a repeated --vlan-vip flag: "<id>:<cidr>" — VRRP VIP for a --vlan entry. */
|
||||||
|
export function parseVlanVip(
|
||||||
|
value: string,
|
||||||
|
previous: Record<number, string> = {},
|
||||||
|
): Record<number, string> {
|
||||||
|
const index = value.indexOf(":");
|
||||||
|
const id = Number(index === -1 ? Number.NaN : value.slice(0, index));
|
||||||
|
const cidr = index === -1 ? "" : value.slice(index + 1).trim();
|
||||||
|
if (!Number.isInteger(id) || id < 1 || id > 4094 || !cidr.includes("/")) {
|
||||||
|
throw new InvalidArgumentError(
|
||||||
|
`Invalid VLAN VIP "${value}" (expected <id>:<cidr>, e.g. 3:192.168.3.254/24)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { ...previous, [id]: cidr };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse a repeated --vyos-hwid flag: "<iface>=<mac>". */
|
||||||
|
export function parseHwId(
|
||||||
|
value: string,
|
||||||
|
previous: Record<string, string> = {},
|
||||||
|
): Record<string, string> {
|
||||||
|
const index = value.indexOf("=");
|
||||||
|
const iface = index === -1 ? "" : value.slice(0, index).trim();
|
||||||
|
const mac = index === -1 ? "" : value.slice(index + 1).trim().toLowerCase();
|
||||||
|
if (iface === "" || !/^([0-9a-f]{2}:){5}[0-9a-f]{2}$/.test(mac)) {
|
||||||
|
throw new InvalidArgumentError(
|
||||||
|
`Invalid hw-id "${value}" (expected <iface>=<mac>, e.g. eth2=64:62:66:25:96:47)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { ...previous, [iface]: mac };
|
||||||
|
}
|
||||||
|
|
||||||
export function registerInstallCommand(parent: Command): void {
|
export function registerInstallCommand(parent: Command): void {
|
||||||
parent
|
parent
|
||||||
.command("install <mac> <hostname>")
|
.command("install <mac> <hostname>")
|
||||||
@@ -24,10 +76,32 @@ export function registerInstallCommand(parent: Command): void {
|
|||||||
.addOption(new Option("--role <role>", "Machine role (see below)").choices([...SUPPORTED_ROLES]).default("worker"))
|
.addOption(new Option("--role <role>", "Machine role (see below)").choices([...SUPPORTED_ROLES]).default("worker"))
|
||||||
.addOption(new Option("--os <os>", "Operating system").choices([...SUPPORTED_OS]).default("fedora-43"))
|
.addOption(new Option("--os <os>", "Operating system").choices([...SUPPORTED_OS]).default("fedora-43"))
|
||||||
.option("--disk <device>", "Target disk device (auto-detect if omitted)")
|
.option("--disk <device>", "Target disk device (auto-detect if omitted)")
|
||||||
|
.option("--vyos-mgmt <iface>", "VyOS: untagged interface the machine PXE boots from (default eth0)")
|
||||||
|
.option("--vyos-mgmt-address <addr>", "VyOS: CIDR for the management interface, or 'dhcp' (default dhcp)")
|
||||||
|
.option("--vyos-bond <ifaces>", "VyOS: comma-separated LACP bond members (must exclude the PXE NIC)")
|
||||||
|
.option("--vyos-bond-address <cidr>", "VyOS: address on the untagged bond (trunk native VLAN)")
|
||||||
|
.option("--vyos-bond-vrrp <cidr>", "VyOS: VRRP VIP floated on the untagged bond")
|
||||||
|
.option("--vlan-vip <id:cidr>", "VyOS: VRRP VIP for a --vlan entry (repeatable)", parseVlanVip)
|
||||||
|
.option("--vyos-vrrp-priority <n>", "VyOS: VRRP priority for all groups on this box (higher = master)")
|
||||||
|
.option("--vyos-mgmt-vlan <id:cidr[:desc]>", "VyOS: tagged management VLAN on the PXE port")
|
||||||
|
.option("--vlan <id:cidr[:desc]>", "VyOS: tagged VLAN sub-interface on the bond (repeatable)", parseVlan)
|
||||||
|
.option("--vyos-password <password>", "VyOS: password for the 'vyos' user")
|
||||||
|
.option("--vyos-hwid <iface=mac>", "VyOS: pin an interface name to a MAC via hw-id (repeatable)", parseHwId)
|
||||||
.action(async (mac: string, hostname: string, opts: {
|
.action(async (mac: string, hostname: string, opts: {
|
||||||
role: string;
|
role: string;
|
||||||
os: string;
|
os: string;
|
||||||
disk?: string;
|
disk?: string;
|
||||||
|
vyosMgmt?: string;
|
||||||
|
vyosMgmtAddress?: string;
|
||||||
|
vyosBond?: string;
|
||||||
|
vyosBondAddress?: string;
|
||||||
|
vyosBondVrrp?: string;
|
||||||
|
vlan?: VyosVlanSpec[];
|
||||||
|
vlanVip?: Record<number, string>;
|
||||||
|
vyosVrrpPriority?: string;
|
||||||
|
vyosMgmtVlan?: string;
|
||||||
|
vyosPassword?: string;
|
||||||
|
vyosHwid?: Record<string, string>;
|
||||||
}) => {
|
}) => {
|
||||||
if (!isValidOsId(opts.os)) {
|
if (!isValidOsId(opts.os)) {
|
||||||
console.error(`Unknown OS: ${opts.os}. Supported: ${SUPPORTED_OS.join(", ")}`);
|
console.error(`Unknown OS: ${opts.os}. Supported: ${SUPPORTED_OS.join(", ")}`);
|
||||||
@@ -39,6 +113,66 @@ export function registerInstallCommand(parent: Command): void {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const bondMembers = opts.vyosBond !== undefined && opts.vyosBond !== ""
|
||||||
|
? opts.vyosBond.split(",").map((s) => s.trim()).filter((s) => s.length > 0)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
// Attach --vlan-vip entries to their --vlan definitions. A VIP for a VLAN
|
||||||
|
// that was never defined is a typo that would otherwise vanish silently.
|
||||||
|
const vips = opts.vlanVip ?? {};
|
||||||
|
const vlans = (opts.vlan ?? []).map((v) =>
|
||||||
|
vips[v.id] !== undefined ? { ...v, vrrp: vips[v.id] as string } : v,
|
||||||
|
);
|
||||||
|
for (const id of Object.keys(vips)) {
|
||||||
|
if (!vlans.some((v) => String(v.id) === id)) {
|
||||||
|
console.error(`--vlan-vip ${id}:... has no matching --vlan ${id}:... entry`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const vrrpPriority = opts.vyosVrrpPriority !== undefined && opts.vyosVrrpPriority !== ""
|
||||||
|
? Number(opts.vyosVrrpPriority)
|
||||||
|
: undefined;
|
||||||
|
if (vrrpPriority !== undefined
|
||||||
|
&& (!Number.isInteger(vrrpPriority) || vrrpPriority < 1 || vrrpPriority > 255)) {
|
||||||
|
console.error(`--vyos-vrrp-priority must be an integer 1-255 (got ${opts.vyosVrrpPriority})`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const vyos: VyosInstallSpec = {
|
||||||
|
...(opts.vyosMgmt !== undefined && opts.vyosMgmt !== ""
|
||||||
|
? { mgmtInterface: opts.vyosMgmt } : {}),
|
||||||
|
...(opts.vyosMgmtAddress !== undefined && opts.vyosMgmtAddress !== ""
|
||||||
|
? { mgmtAddress: opts.vyosMgmtAddress } : {}),
|
||||||
|
...(bondMembers.length > 0 ? { bondMembers } : {}),
|
||||||
|
...(opts.vyosBondAddress !== undefined && opts.vyosBondAddress !== ""
|
||||||
|
? { bondAddress: opts.vyosBondAddress } : {}),
|
||||||
|
...(opts.vyosBondVrrp !== undefined && opts.vyosBondVrrp !== ""
|
||||||
|
? { bondVrrp: opts.vyosBondVrrp } : {}),
|
||||||
|
...(vrrpPriority !== undefined ? { vrrpPriority } : {}),
|
||||||
|
...(vlans.length > 0 ? { vlans } : {}),
|
||||||
|
...(opts.vyosPassword !== undefined && opts.vyosPassword !== ""
|
||||||
|
? { password: opts.vyosPassword } : {}),
|
||||||
|
...(opts.vyosHwid !== undefined && Object.keys(opts.vyosHwid).length > 0
|
||||||
|
? { hwIds: opts.vyosHwid } : {}),
|
||||||
|
...(opts.vyosMgmtVlan !== undefined && opts.vyosMgmtVlan !== ""
|
||||||
|
? { mgmtVlan: parseVlan(opts.vyosMgmtVlan)[0] as VyosVlanSpec } : {}),
|
||||||
|
};
|
||||||
|
const hasVyosOptions = Object.keys(vyos).length > 0;
|
||||||
|
|
||||||
|
if (hasVyosOptions && !opts.os.startsWith("vyos")) {
|
||||||
|
console.error(`VyOS options require --os vyos-rolling (got --os ${opts.os})`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Firmware PXE cannot run over LACP, so the NIC that boots the installer
|
||||||
|
// must stay out of the bond — otherwise the next reinstall has no path in.
|
||||||
|
const mgmt = vyos.mgmtInterface ?? "eth0";
|
||||||
|
if (bondMembers.includes(mgmt)) {
|
||||||
|
console.error(`--vyos-bond must not include the PXE/management interface "${mgmt}"`);
|
||||||
|
console.error("PXE cannot boot over an LACP bond; keep that NIC unbonded.");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await getLabdClient().installMachine({
|
const result = await getLabdClient().installMachine({
|
||||||
mac,
|
mac,
|
||||||
@@ -46,11 +180,14 @@ export function registerInstallCommand(parent: Command): void {
|
|||||||
role: opts.role,
|
role: opts.role,
|
||||||
os: opts.os,
|
os: opts.os,
|
||||||
...(opts.disk ? { disk: opts.disk } : {}),
|
...(opts.disk ? { disk: opts.disk } : {}),
|
||||||
|
...(hasVyosOptions ? { vyos } : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(JSON.stringify(result, null, 2));
|
console.log(JSON.stringify(result, null, 2));
|
||||||
console.log("");
|
console.log("");
|
||||||
const osLabel = opts.os.startsWith("ubuntu") ? "Ubuntu" : "Fedora";
|
const osLabel = opts.os.startsWith("ubuntu")
|
||||||
|
? "Ubuntu"
|
||||||
|
: opts.os.startsWith("vyos") ? "VyOS" : "Fedora";
|
||||||
console.log(`Power on the machine to start ${osLabel} installation.`);
|
console.log(`Power on the machine to start ${osLabel} installation.`);
|
||||||
|
|
||||||
const roleInfo = ROLE_REGISTRY.find(r => r.name === opts.role);
|
const roleInfo = ROLE_REGISTRY.find(r => r.name === opts.role);
|
||||||
|
|||||||
35
bastion/src/cli/tests/install-vyos.test.ts
Normal file
35
bastion/src/cli/tests/install-vyos.test.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
// Tests for VyOS install option parsing.
|
||||||
|
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { parseVlan } from "../src/commands/install.js";
|
||||||
|
|
||||||
|
describe("parseVlan", () => {
|
||||||
|
it("parses id and CIDR", () => {
|
||||||
|
expect(parseVlan("10:10.0.10.1/24")).toEqual([{ id: 10, address: "10.0.10.1/24" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accumulates across repeated flags", () => {
|
||||||
|
const first = parseVlan("10:10.0.10.1/24");
|
||||||
|
const both = parseVlan("20:10.0.20.1/24", first);
|
||||||
|
expect(both).toHaveLength(2);
|
||||||
|
expect(both[1]).toEqual({ id: 20, address: "10.0.20.1/24" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a description, including one containing colons", () => {
|
||||||
|
expect(parseVlan("30:10.0.30.1/24:mgmt:secondary")).toEqual([
|
||||||
|
{ id: 30, address: "10.0.30.1/24", description: "mgmt:secondary" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an address that is not CIDR", () => {
|
||||||
|
// A bare address would produce a VyOS config that fails to commit on first
|
||||||
|
// boot, long after the operator has stopped watching.
|
||||||
|
expect(() => parseVlan("10:10.0.10.1")).toThrow(/CIDR/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects out-of-range and non-numeric VLAN ids", () => {
|
||||||
|
expect(() => parseVlan("0:10.0.10.1/24")).toThrow(/1-4094/);
|
||||||
|
expect(() => parseVlan("4095:10.0.10.1/24")).toThrow(/1-4094/);
|
||||||
|
expect(() => parseVlan("abc:10.0.10.1/24")).toThrow(/1-4094/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,6 +10,7 @@ import type { FastifyInstance } from "fastify";
|
|||||||
import type { DbClient } from "../server.js";
|
import type { DbClient } from "../server.js";
|
||||||
import { bastionRegistry } from "../services/bastion-registry.js";
|
import { bastionRegistry } from "../services/bastion-registry.js";
|
||||||
import { generateRequestId } from "@lab/shared";
|
import { generateRequestId } from "@lab/shared";
|
||||||
|
import type { VyosInstallSpec } from "@lab/shared";
|
||||||
|
|
||||||
const COMMAND_TIMEOUT_MS = 15_000;
|
const COMMAND_TIMEOUT_MS = 15_000;
|
||||||
|
|
||||||
@@ -163,9 +164,9 @@ export function registerBastionRoutes(app: FastifyInstance, db: DbClient): void
|
|||||||
|
|
||||||
// Queue install — route to correct bastion by MAC
|
// Queue install — route to correct bastion by MAC
|
||||||
app.post<{
|
app.post<{
|
||||||
Body: { mac?: string; hostname?: string; disk?: string; role?: string; os?: string };
|
Body: { mac?: string; hostname?: string; disk?: string; role?: string; os?: string; vyos?: VyosInstallSpec };
|
||||||
}>("/api/machines/install", async (request, reply) => {
|
}>("/api/machines/install", async (request, reply) => {
|
||||||
const { mac, hostname, disk, role, os } = request.body ?? {};
|
const { mac, hostname, disk, role, os, vyos } = request.body ?? {};
|
||||||
if (!mac || !hostname) {
|
if (!mac || !hostname) {
|
||||||
return reply.code(400).send({ error: "mac and hostname are required" });
|
return reply.code(400).send({ error: "mac and hostname are required" });
|
||||||
}
|
}
|
||||||
@@ -183,6 +184,7 @@ export function registerBastionRoutes(app: FastifyInstance, db: DbClient): void
|
|||||||
const result = await sendCommand(all[0]!.bastionId, {
|
const result = await sendCommand(all[0]!.bastionId, {
|
||||||
type: "command-install",
|
type: "command-install",
|
||||||
mac, hostname, disk: disk ?? "", role: role ?? "infra", os: os ?? "fedora-43",
|
mac, hostname, disk: disk ?? "", role: role ?? "infra", os: os ?? "fedora-43",
|
||||||
|
...(vyos ? { vyos } : {}),
|
||||||
});
|
});
|
||||||
return reply.code(result.status === "ok" ? 200 : 500).send(result);
|
return reply.code(result.status === "ok" ? 200 : 500).send(result);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -196,6 +198,7 @@ export function registerBastionRoutes(app: FastifyInstance, db: DbClient): void
|
|||||||
const result = await sendCommand(bastion.bastionId, {
|
const result = await sendCommand(bastion.bastionId, {
|
||||||
type: "command-install",
|
type: "command-install",
|
||||||
mac, hostname, disk: disk ?? "", role: role ?? "infra", os: os ?? "fedora-43",
|
mac, hostname, disk: disk ?? "", role: role ?? "infra", os: os ?? "fedora-43",
|
||||||
|
...(vyos ? { vyos } : {}),
|
||||||
});
|
});
|
||||||
return reply.code(result.status === "ok" ? 200 : 500).send(result);
|
return reply.code(result.status === "ok" ? 200 : 500).send(result);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ export type {
|
|||||||
DebugConfig,
|
DebugConfig,
|
||||||
BastionState,
|
BastionState,
|
||||||
BastionConfig,
|
BastionConfig,
|
||||||
|
VyosVlanSpec,
|
||||||
|
VyosInstallSpec,
|
||||||
} from "./types/index.js";
|
} from "./types/index.js";
|
||||||
|
|
||||||
export { SUPPORTED_OS, SUPPORTED_ROLES, ROLE_REGISTRY, isValidOsId } from "./types/index.js";
|
export { SUPPORTED_OS, SUPPORTED_ROLES, ROLE_REGISTRY, isValidOsId } from "./types/index.js";
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// Protocol types for agent-labd WebSocket communication.
|
// Protocol types for agent-labd WebSocket communication.
|
||||||
|
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
|
import type { VyosInstallSpec } from "../types/state.js";
|
||||||
|
|
||||||
// --- Agent -> labd messages ---
|
// --- Agent -> labd messages ---
|
||||||
|
|
||||||
@@ -108,7 +109,7 @@ export type BastionMessage =
|
|||||||
export type LabdBastionMessage =
|
export type LabdBastionMessage =
|
||||||
| { type: "bastion-enrolled"; bastionId: string }
|
| { type: "bastion-enrolled"; bastionId: string }
|
||||||
| { type: "bastion-heartbeat-ack"; serverTime: string }
|
| { type: "bastion-heartbeat-ack"; serverTime: string }
|
||||||
| { type: "command-install"; requestId: string; mac: string; hostname: string; disk?: string; role: string; os: string }
|
| { type: "command-install"; requestId: string; mac: string; hostname: string; disk?: string; role: string; os: string; vyos?: VyosInstallSpec }
|
||||||
| { type: "command-forget"; requestId: string; mac: string }
|
| { type: "command-forget"; requestId: string; mac: string }
|
||||||
| { type: "command-role-update"; requestId: string; mac: string; role: string }
|
| { type: "command-role-update"; requestId: string; mac: string; role: string }
|
||||||
| { type: "command-debug"; requestId: string; mac: string; pxeBoot?: boolean }
|
| { type: "command-debug"; requestId: string; mac: string; pxeBoot?: boolean }
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ export interface BastionConfig {
|
|||||||
// Ubuntu support
|
// Ubuntu support
|
||||||
ubuntuVersion: string;
|
ubuntuVersion: string;
|
||||||
ubuntuMirror: string;
|
ubuntuMirror: string;
|
||||||
|
// VyOS support — netboot artifacts are extracted from the ISO at startup.
|
||||||
|
// LTS ISOs are subscription-only, so this defaults to a rolling release.
|
||||||
|
vyosIsoUrl: string;
|
||||||
|
vyosDefaultPassword: string;
|
||||||
// Syslog listener for install logs (Anaconda logging --host)
|
// Syslog listener for install logs (Anaconda logging --host)
|
||||||
syslogPort: number;
|
syslogPort: number;
|
||||||
// Flags
|
// Flags
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ export type {
|
|||||||
InstalledInfo,
|
InstalledInfo,
|
||||||
DebugConfig,
|
DebugConfig,
|
||||||
BastionState,
|
BastionState,
|
||||||
|
VyosVlanSpec,
|
||||||
|
VyosInstallSpec,
|
||||||
} from "./state.js";
|
} from "./state.js";
|
||||||
|
|
||||||
export { SUPPORTED_OS, SUPPORTED_ROLES, ROLE_REGISTRY, isValidOsId } from "./state.js";
|
export { SUPPORTED_OS, SUPPORTED_ROLES, ROLE_REGISTRY, isValidOsId } from "./state.js";
|
||||||
|
|||||||
@@ -2,10 +2,10 @@
|
|||||||
|
|
||||||
export type ProvisionStackType = "dhcpproxy" | "iso" | "cloud-init";
|
export type ProvisionStackType = "dhcpproxy" | "iso" | "cloud-init";
|
||||||
|
|
||||||
export type OsId = "fedora-43" | "ubuntu-26.04";
|
export type OsId = "fedora-43" | "ubuntu-26.04" | "vyos-rolling";
|
||||||
export type Arch = "x86_64" | "aarch64";
|
export type Arch = "x86_64" | "aarch64";
|
||||||
|
|
||||||
export const SUPPORTED_OS: readonly OsId[] = ["fedora-43", "ubuntu-26.04"] as const;
|
export const SUPPORTED_OS: readonly OsId[] = ["fedora-43", "ubuntu-26.04", "vyos-rolling"] as const;
|
||||||
|
|
||||||
export function isValidOsId(value: string): value is OsId {
|
export function isValidOsId(value: string): value is OsId {
|
||||||
return (SUPPORTED_OS as readonly string[]).includes(value);
|
return (SUPPORTED_OS as readonly string[]).includes(value);
|
||||||
@@ -75,11 +75,73 @@ export interface ProgressLogEntry {
|
|||||||
timestamp: string;
|
timestamp: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A tagged VLAN sub-interface on the bond (or on the mgmt NIC when unbonded). */
|
||||||
|
export interface VyosVlanSpec {
|
||||||
|
id: number;
|
||||||
|
address: string; // CIDR, e.g. "10.0.10.1/24"
|
||||||
|
description?: string;
|
||||||
|
/**
|
||||||
|
* VRRP virtual address (CIDR) floated on this VLAN. Emitted as a
|
||||||
|
* high-availability vrrp group with vrid = VLAN id, so the same spec on both
|
||||||
|
* HA peers (with different priorities) produces a matching group pair.
|
||||||
|
*/
|
||||||
|
vrrp?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* VyOS-specific install parameters. Rendered into the config.boot that the
|
||||||
|
* installer adopts, so the router comes up already configured.
|
||||||
|
*
|
||||||
|
* NOTE: bondMembers must NOT include the interface PXE booted from. Firmware
|
||||||
|
* PXE cannot run over LACP, so the install-time NIC has to stay unbonded.
|
||||||
|
*/
|
||||||
|
export interface VyosInstallSpec {
|
||||||
|
/** Interfaces aggregated into bond0 with LACP (802.3ad). Omit for no bond. */
|
||||||
|
bondMembers?: string[];
|
||||||
|
/** CIDR address on bond0 itself — the switch trunk's native/untagged VLAN. */
|
||||||
|
bondAddress?: string;
|
||||||
|
/** VRRP virtual address (CIDR) floated on the untagged bond (vrid 1). */
|
||||||
|
bondVrrp?: string;
|
||||||
|
/**
|
||||||
|
* VRRP priority for every group on this box. Higher wins mastership.
|
||||||
|
* The HA pair differs ONLY here (e.g. 200 on the primary, 100 on the
|
||||||
|
* standby) — addresses differ per box, VIPs and vrids match.
|
||||||
|
*/
|
||||||
|
vrrpPriority?: number;
|
||||||
|
/** Tagged VLAN sub-interfaces, created on bond0 when bonded, else on mgmtInterface. */
|
||||||
|
vlans?: VyosVlanSpec[];
|
||||||
|
/** Untagged interface the machine PXE booted from. Defaults to "eth0". */
|
||||||
|
mgmtInterface?: string;
|
||||||
|
/** CIDR address for mgmtInterface, or "dhcp". Defaults to "dhcp". */
|
||||||
|
mgmtAddress?: string;
|
||||||
|
/**
|
||||||
|
* Tagged management VLAN on mgmtInterface, separate from the routed VLANs
|
||||||
|
* carried by the bond.
|
||||||
|
*
|
||||||
|
* Needed when the PXE port is a trunk: it boots untagged on the VLAN the
|
||||||
|
* bastion's proxy DHCP serves, and carries the management VLAN tagged so the
|
||||||
|
* router stays reachable there without giving up reinstallability.
|
||||||
|
*/
|
||||||
|
mgmtVlan?: VyosVlanSpec;
|
||||||
|
/** Password for the "vyos" user. Falls back to the bastion default. */
|
||||||
|
password?: string;
|
||||||
|
/**
|
||||||
|
* VyOS interface name -> MAC, emitted as `hw-id` so names bind deterministically.
|
||||||
|
*
|
||||||
|
* Discovery runs under Fedora and reports predictable names (enp2s0,
|
||||||
|
* enp1s0f0np0), but VyOS enumerates its own eth<N> names, so a name observed
|
||||||
|
* during discovery cannot be used directly. Pinning by MAC removes the guess
|
||||||
|
* about which physical port a given eth<N> is.
|
||||||
|
*/
|
||||||
|
hwIds?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface InstallConfig {
|
export interface InstallConfig {
|
||||||
hostname: string;
|
hostname: string;
|
||||||
disk: string;
|
disk: string;
|
||||||
role: Role;
|
role: Role;
|
||||||
os?: OsId; // defaults to "fedora-43" for backward compat
|
os?: OsId; // defaults to "fedora-43" for backward compat
|
||||||
|
vyos?: VyosInstallSpec; // only consulted when os is "vyos-rolling"
|
||||||
arch?: Arch; // detected from HardwareInfo or overridden
|
arch?: Arch; // detected from HardwareInfo or overridden
|
||||||
queued_at: string;
|
queued_at: string;
|
||||||
progress?: string;
|
progress?: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user