diff --git a/bastion/src/bastion/src/config.ts b/bastion/src/bastion/src/config.ts index 4ebfe9b..1f4372b 100644 --- a/bastion/src/bastion/src/config.ts +++ b/bastion/src/bastion/src/config.ts @@ -20,6 +20,15 @@ export function loadConfig(overrides: Partial = {}): BastionConfi const ubuntuMirror = overrides.ubuntuMirror ?? process.env["UBUNTU_MIRROR"] ?? `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 tftpDir = `${bastionDir}/tftp`; const httpDir = `${bastionDir}/http`; @@ -38,6 +47,8 @@ export function loadConfig(overrides: Partial = {}): BastionConfi dhcpRangeEnd, ubuntuVersion, ubuntuMirror, + vyosIsoUrl, + vyosDefaultPassword, // These are populated at runtime by the network service iface: overrides.iface ?? "", serverIp: overrides.serverIp ?? "", diff --git a/bastion/src/bastion/src/main.ts b/bastion/src/bastion/src/main.ts index 7468f1f..2905650 100644 --- a/bastion/src/bastion/src/main.ts +++ b/bastion/src/bastion/src/main.ts @@ -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 { try { symlinkSync(target, linkPath); @@ -182,6 +301,17 @@ export async function startBastion(overrides: Partial = {}): Prom 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 for (const name of ["ipxe.efi", "ipxe-arm64.efi"]) { const src = `${config.tftpDir}/${name}`; @@ -261,6 +391,7 @@ export async function startBastion(overrides: Partial = {}): Prom role: msg.role as import("@lab/shared").Role, os: msg.os as import("@lab/shared").OsId, queued_at: new Date().toISOString(), + ...(msg.vyos ? { vyos: msg.vyos } : {}), }; }); return { status: "ok", data: { mac: msg.mac, hostname: msg.hostname } }; diff --git a/bastion/src/bastion/src/routes/api.ts b/bastion/src/bastion/src/routes/api.ts index 8a75c65..c6d231b 100644 --- a/bastion/src/bastion/src/routes/api.ts +++ b/bastion/src/bastion/src/routes/api.ts @@ -5,8 +5,8 @@ // /api/discover - receive hardware discovery reports from PXE-booted machines import type { FastifyInstance } from "fastify"; -import type { HardwareInfo, InstalledInfo, Role } from "@lab/shared"; -import { isValidOsId, SUPPORTED_ROLES } from "@lab/shared"; +import type { HardwareInfo, InstalledInfo, Role, VyosInstallSpec } from "@lab/shared"; +import { isValidOsId, SUPPORTED_ROLES, SUPPORTED_OS } from "@lab/shared"; import type { StateManager } from "../services/state.js"; import { logger } from "../services/logger.js"; import { triggerPostProvisionK3s } from "../services/post-provision.js"; @@ -34,9 +34,10 @@ export function registerApiRoutes( disk?: string; role?: string; os?: string; + vyos?: VyosInstallSpec; }; }>("/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, ":"); if (mac === "") { @@ -50,7 +51,7 @@ export function registerApiRoutes( const osId = os ?? "fedora-43"; 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) => { @@ -60,6 +61,7 @@ export function registerApiRoutes( role: validRole as Role, os: osId, queued_at: new Date().toISOString(), + ...(vyos ? { vyos } : {}), }; }); diff --git a/bastion/src/bastion/src/routes/dispatch.ts b/bastion/src/bastion/src/routes/dispatch.ts index 0ecc1c4..f4637eb 100644 --- a/bastion/src/bastion/src/routes/dispatch.ts +++ b/bastion/src/bastion/src/routes/dispatch.ts @@ -15,6 +15,7 @@ import { renderLocalBootIpxe, } from "../templates/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 { logger } from "../services/logger.js"; @@ -100,7 +101,14 @@ echo "===============================" logger.info(`INSTALL STARTED: ${mac} -> ${hostname} (${os})`); 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({ mac, hostname, diff --git a/bastion/src/bastion/src/routes/vyos.ts b/bastion/src/bastion/src/routes/vyos.ts new file mode 100644 index 0000000..f32bc97 --- /dev/null +++ b/bastion/src/bastion/src/routes/vyos.ts @@ -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); + }); +} diff --git a/bastion/src/bastion/src/server.ts b/bastion/src/bastion/src/server.ts index d3078d8..1c1aa47 100644 --- a/bastion/src/bastion/src/server.ts +++ b/bastion/src/bastion/src/server.ts @@ -12,6 +12,7 @@ import { registerDispatchRoutes } from "./routes/dispatch.js"; import { registerKickstartRoutes } from "./routes/kickstart.js"; import { registerApiRoutes } from "./routes/api.js"; import { registerAsahiRoutes } from "./routes/asahi.js"; +import { registerVyosRoutes } from "./routes/vyos.js"; export function createApp(config: BastionConfig): { app: ReturnType; state: StateManager; installLog: InstallLogBuffer; syslog: SyslogListener } { @@ -47,6 +48,7 @@ export function createApp(config: BastionConfig): { app: ReturnType { + 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, + }; +} diff --git a/bastion/src/bastion/src/templates/vyos-install.py.ts b/bastion/src/bastion/src/templates/vyos-install.py.ts new file mode 100644 index 0000000..2c22868 --- /dev/null +++ b/bastion/src/bastion/src/templates/vyos-install.py.ts @@ -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() +`; +} diff --git a/bastion/src/bastion/tests/dispatch.test.ts b/bastion/src/bastion/tests/dispatch.test.ts index 3d07ac4..edaea4d 100644 --- a/bastion/src/bastion/tests/dispatch.test.ts +++ b/bastion/src/bastion/tests/dispatch.test.ts @@ -22,6 +22,8 @@ function createTestConfig(testDir: string): BastionConfig { dhcpRangeEnd: "", ubuntuVersion: "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", serverIp: "10.0.0.1", network: "10.0.0.0", diff --git a/bastion/src/bastion/tests/vyos.test.ts b/bastion/src/bastion/tests/vyos.test.ts new file mode 100644 index 0000000..d098e6e --- /dev/null +++ b/bastion/src/bastion/tests/vyos.test.ts @@ -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 { + 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. 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(); + }); +}); diff --git a/bastion/src/cli/src/api/client.ts b/bastion/src/cli/src/api/client.ts index 52add1e..b6f5763 100644 --- a/bastion/src/cli/src/api/client.ts +++ b/bastion/src/cli/src/api/client.ts @@ -90,6 +90,7 @@ export class LabdClient { async installMachine(opts: { mac: string; hostname: string; disk?: string; role?: string; os?: string; + vyos?: import("@lab/shared").VyosInstallSpec; }): Promise<{ status: string; data?: unknown; error?: string }> { return this.request("POST", "/api/machines/install", { body: opts }); } diff --git a/bastion/src/cli/src/commands/install.ts b/bastion/src/cli/src/commands/install.ts index 6da3f11..73f894c 100644 --- a/bastion/src/cli/src/commands/install.ts +++ b/bastion/src/cli/src/commands/install.ts @@ -1,10 +1,30 @@ // CLI command: provision install // 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 type { VyosInstallSpec, VyosVlanSpec } from "@lab/shared"; import { getLabdClient } from "../api/config.js"; +/** Parse a repeated --vlan flag: ":[:]". */ +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 { const lines: string[] = ["", "Available roles:"]; for (const r of ROLE_REGISTRY) { @@ -15,6 +35,38 @@ function roleTable(): string { return lines.join("\n"); } +/** Parse a repeated --vlan-vip flag: ":" — VRRP VIP for a --vlan entry. */ +export function parseVlanVip( + value: string, + previous: Record = {}, +): Record { + 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 :, e.g. 3:192.168.3.254/24)`, + ); + } + return { ...previous, [id]: cidr }; +} + +/** Parse a repeated --vyos-hwid flag: "=". */ +export function parseHwId( + value: string, + previous: Record = {}, +): Record { + 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 =, e.g. eth2=64:62:66:25:96:47)`, + ); + } + return { ...previous, [iface]: mac }; +} + export function registerInstallCommand(parent: Command): void { parent .command("install ") @@ -24,10 +76,32 @@ export function registerInstallCommand(parent: Command): void { .addOption(new Option("--role ", "Machine role (see below)").choices([...SUPPORTED_ROLES]).default("worker")) .addOption(new Option("--os ", "Operating system").choices([...SUPPORTED_OS]).default("fedora-43")) .option("--disk ", "Target disk device (auto-detect if omitted)") + .option("--vyos-mgmt ", "VyOS: untagged interface the machine PXE boots from (default eth0)") + .option("--vyos-mgmt-address ", "VyOS: CIDR for the management interface, or 'dhcp' (default dhcp)") + .option("--vyos-bond ", "VyOS: comma-separated LACP bond members (must exclude the PXE NIC)") + .option("--vyos-bond-address ", "VyOS: address on the untagged bond (trunk native VLAN)") + .option("--vyos-bond-vrrp ", "VyOS: VRRP VIP floated on the untagged bond") + .option("--vlan-vip ", "VyOS: VRRP VIP for a --vlan entry (repeatable)", parseVlanVip) + .option("--vyos-vrrp-priority ", "VyOS: VRRP priority for all groups on this box (higher = master)") + .option("--vyos-mgmt-vlan ", "VyOS: tagged management VLAN on the PXE port") + .option("--vlan ", "VyOS: tagged VLAN sub-interface on the bond (repeatable)", parseVlan) + .option("--vyos-password ", "VyOS: password for the 'vyos' user") + .option("--vyos-hwid ", "VyOS: pin an interface name to a MAC via hw-id (repeatable)", parseHwId) .action(async (mac: string, hostname: string, opts: { role: string; os: string; disk?: string; + vyosMgmt?: string; + vyosMgmtAddress?: string; + vyosBond?: string; + vyosBondAddress?: string; + vyosBondVrrp?: string; + vlan?: VyosVlanSpec[]; + vlanVip?: Record; + vyosVrrpPriority?: string; + vyosMgmtVlan?: string; + vyosPassword?: string; + vyosHwid?: Record; }) => { if (!isValidOsId(opts.os)) { console.error(`Unknown OS: ${opts.os}. Supported: ${SUPPORTED_OS.join(", ")}`); @@ -39,6 +113,66 @@ export function registerInstallCommand(parent: Command): void { 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 { const result = await getLabdClient().installMachine({ mac, @@ -46,11 +180,14 @@ export function registerInstallCommand(parent: Command): void { role: opts.role, os: opts.os, ...(opts.disk ? { disk: opts.disk } : {}), + ...(hasVyosOptions ? { vyos } : {}), }); console.log(JSON.stringify(result, null, 2)); 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.`); const roleInfo = ROLE_REGISTRY.find(r => r.name === opts.role); diff --git a/bastion/src/cli/tests/install-vyos.test.ts b/bastion/src/cli/tests/install-vyos.test.ts new file mode 100644 index 0000000..2f1a62e --- /dev/null +++ b/bastion/src/cli/tests/install-vyos.test.ts @@ -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/); + }); +}); diff --git a/bastion/src/labd/src/routes/bastions.ts b/bastion/src/labd/src/routes/bastions.ts index d77a417..8792c5c 100644 --- a/bastion/src/labd/src/routes/bastions.ts +++ b/bastion/src/labd/src/routes/bastions.ts @@ -10,6 +10,7 @@ import type { FastifyInstance } from "fastify"; import type { DbClient } from "../server.js"; import { bastionRegistry } from "../services/bastion-registry.js"; import { generateRequestId } from "@lab/shared"; +import type { VyosInstallSpec } from "@lab/shared"; 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 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) => { - const { mac, hostname, disk, role, os } = request.body ?? {}; + const { mac, hostname, disk, role, os, vyos } = request.body ?? {}; if (!mac || !hostname) { 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, { type: "command-install", mac, hostname, disk: disk ?? "", role: role ?? "infra", os: os ?? "fedora-43", + ...(vyos ? { vyos } : {}), }); return reply.code(result.status === "ok" ? 200 : 500).send(result); } catch (err) { @@ -196,6 +198,7 @@ export function registerBastionRoutes(app: FastifyInstance, db: DbClient): void const result = await sendCommand(bastion.bastionId, { type: "command-install", mac, hostname, disk: disk ?? "", role: role ?? "infra", os: os ?? "fedora-43", + ...(vyos ? { vyos } : {}), }); return reply.code(result.status === "ok" ? 200 : 500).send(result); } catch (err) { diff --git a/bastion/src/shared/src/index.ts b/bastion/src/shared/src/index.ts index 443edbc..24accf0 100644 --- a/bastion/src/shared/src/index.ts +++ b/bastion/src/shared/src/index.ts @@ -8,6 +8,8 @@ export type { DebugConfig, BastionState, BastionConfig, + VyosVlanSpec, + VyosInstallSpec, } from "./types/index.js"; export { SUPPORTED_OS, SUPPORTED_ROLES, ROLE_REGISTRY, isValidOsId } from "./types/index.js"; diff --git a/bastion/src/shared/src/protocol/index.ts b/bastion/src/shared/src/protocol/index.ts index d7e3018..d7f626a 100644 --- a/bastion/src/shared/src/protocol/index.ts +++ b/bastion/src/shared/src/protocol/index.ts @@ -1,6 +1,7 @@ // Protocol types for agent-labd WebSocket communication. import { randomUUID } from "node:crypto"; +import type { VyosInstallSpec } from "../types/state.js"; // --- Agent -> labd messages --- @@ -108,7 +109,7 @@ export type BastionMessage = export type LabdBastionMessage = | { type: "bastion-enrolled"; bastionId: 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-role-update"; requestId: string; mac: string; role: string } | { type: "command-debug"; requestId: string; mac: string; pxeBoot?: boolean } diff --git a/bastion/src/shared/src/types/config.ts b/bastion/src/shared/src/types/config.ts index 7c1d72f..ed4a599 100644 --- a/bastion/src/shared/src/types/config.ts +++ b/bastion/src/shared/src/types/config.ts @@ -14,6 +14,10 @@ export interface BastionConfig { // Ubuntu support ubuntuVersion: 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) syslogPort: number; // Flags diff --git a/bastion/src/shared/src/types/index.ts b/bastion/src/shared/src/types/index.ts index 510ee31..fd2d53c 100644 --- a/bastion/src/shared/src/types/index.ts +++ b/bastion/src/shared/src/types/index.ts @@ -7,6 +7,8 @@ export type { InstalledInfo, DebugConfig, BastionState, + VyosVlanSpec, + VyosInstallSpec, } from "./state.js"; export { SUPPORTED_OS, SUPPORTED_ROLES, ROLE_REGISTRY, isValidOsId } from "./state.js"; diff --git a/bastion/src/shared/src/types/state.ts b/bastion/src/shared/src/types/state.ts index b8599fd..b8e1932 100644 --- a/bastion/src/shared/src/types/state.ts +++ b/bastion/src/shared/src/types/state.ts @@ -2,10 +2,10 @@ 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 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 { return (SUPPORTED_OS as readonly string[]).includes(value); @@ -75,11 +75,73 @@ export interface ProgressLogEntry { 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 names, so a name observed + * during discovery cannot be used directly. Pinning by MAC removes the guess + * about which physical port a given eth is. + */ + hwIds?: Record; +} + export interface InstallConfig { hostname: string; disk: string; role: Role; 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 queued_at: string; progress?: string;