diff --git a/bastion/src/bastion/src/templates/vyos-config-spec.ts b/bastion/src/bastion/src/templates/vyos-config-spec.ts index aaac55b..8992390 100644 --- a/bastion/src/bastion/src/templates/vyos-config-spec.ts +++ b/bastion/src/bastion/src/templates/vyos-config-spec.ts @@ -52,6 +52,134 @@ function normalizeDiskPath(value: string | undefined): string { return raw.startsWith("/dev/") ? raw : `/dev/${raw}`; } +/** Sentinel marking a value that lives in Pulumi config, not in the bundle. */ +const SECRET_PREFIX = "@secret:"; + +/** + * Enable the VyOS HTTP API so the router is manageable the moment it boots. + * + * This belongs at install time rather than in the Pulumi model: the model is + * applied THROUGH this API, so a router that lacks it cannot be brought under + * management without a hand-run change on a live firewall. It is also why the + * model excludes `service https` outright -- a provider able to rewrite its own + * transport can lock itself out permanently. + * + * `listen-address` is always set. Leaving it unbound would expose a + * config-write endpoint on every segment the router touches, the WAN included. + */ +function apiSets(apiKey: string, listenAddress: string): VyosSetOp[] { + const sets: VyosSetOp[] = [ + { path: ["service", "https", "api", "keys", "id", "pulumi", "key"], value: apiKey }, + { path: ["service", "https", "api", "rest"] }, + ]; + if (listenAddress !== "") { + sets.push({ path: ["service", "https", "listen-address"], value: listenAddress }); + } + return sets; +} + +/** Tag nodes introduced by the API config, needed by the installer's ConfigTree. */ +const API_TAGS: string[][] = [["service", "https", "api", "keys", "id"]]; + +/** + * The address to bind the API to: an explicit choice, else the management + * address with its prefix length stripped. Under DHCP there is no address to + * bind at build time, so the caller must pass one or the listener stays unbound + * and the API is not enabled at all. + */ +function apiListenAddress(spec: VyosInstallSpec, mgmtAddress: string): string { + if (spec.apiListenAddress !== undefined && spec.apiListenAddress !== "") { + return spec.apiListenAddress; + } + return mgmtAddress.includes("/") ? (mgmtAddress.split("/")[0] ?? "") : ""; +} + +/** + * Use a Pulumi-rendered bundle as the router's config verbatim. + * + * Secret-valued nodes are dropped rather than installed with their sentinel + * text: writing `@secret:pppoePassword` into config.boot would look configured + * while being wrong, which is worse than being absent. The router comes up + * without those values and the first `pulumi up` fills them in. + * + * `system host-name` is forced to the hostname the install was asked for. The + * bundle carries the name of whichever router it was exported from, and + * installing vyos001's hostname onto vyos002 would collide on the network. + */ +function buildFromBundle( + params: { hostname: string; defaultPassword: string; disk?: string | undefined }, + spec: VyosInstallSpec, + bundle: NonNullable, + mgmtAddress: string, +): VyosConfigSpec { + const sets: VyosSetOp[] = []; + const dropped: string[] = []; + for (const op of bundle.sets) { + if (op.value !== undefined && op.value.startsWith(SECRET_PREFIX)) { + dropped.push(op.path.join(" ")); + continue; + } + if (op.path.length === 2 && op.path[0] === "system" && op.path[1] === "host-name") { + continue; + } + sets.push({ + path: op.path, + ...(op.value === undefined ? {} : { value: op.value }), + ...(op.replace === undefined ? {} : { replace: op.replace }), + }); + } + sets.unshift({ path: ["system", "host-name"], value: params.hostname }); + + if (dropped.length > 0) { + console.warn( + `vyos ${params.hostname}: ${dropped.length} secret-valued node(s) left unset by the ` + + `bundle; run \`pulumi up\` to supply them: ${dropped.join(", ")}`, + ); + } + + const tags = [...bundle.tags]; + const api = enableApi(spec, params.hostname, mgmtAddress); + if (api.length > 0) { + sets.push(...api); + tags.push(...API_TAGS); + } + + return { + hostname: params.hostname, + imageName: "", + password: spec.password ?? params.defaultPassword, + console: "K", + disk: normalizeDiskPath(params.disk), + reportAddress: mgmtAddress.includes("/") ? (mgmtAddress.split("/")[0] ?? "") : "", + raid: false, + freshConfig: spec.freshConfig ?? false, + sets, + tags, + }; +} + +/** + * The API config for this install, or nothing when it cannot be enabled safely. + * + * Refusing to enable it unbound is deliberate. Under DHCP there is no address + * known at build time, and the alternative -- binding to every interface -- + * would publish a config-write endpoint on the WAN. Better to leave the router + * SSH-only and say so than to open it everywhere. + */ +function enableApi(spec: VyosInstallSpec, hostname: string, mgmtAddress: string): VyosSetOp[] { + if (spec.apiKey === undefined || spec.apiKey === "") return []; + const listen = apiListenAddress(spec, mgmtAddress); + if (listen === "") { + console.warn( + `vyos ${hostname}: --vyos-api-key given but no address to bind to ` + + `(management is "${mgmtAddress}"). Pass --vyos-api-listen ; the HTTP API ` + + `has NOT been enabled, so Pulumi cannot manage this router yet.`, + ); + return []; + } + return apiSets(spec.apiKey, listen); +} + export function buildVyosConfigSpec(params: { hostname: string; spec?: VyosInstallSpec | undefined; @@ -62,6 +190,14 @@ export function buildVyosConfigSpec(params: { const spec = params.spec ?? {}; const mgmt = spec.mgmtInterface ?? "eth0"; const mgmtAddress = spec.mgmtAddress ?? "dhcp"; + + // A rendered bundle replaces the derived config entirely. Deriving a second + // opinion alongside it is the drift the bundle exists to prevent: Pulumi and + // labctl would each believe they knew the router's config, and the box would + // end up with whichever ran last. + if (spec.bundle !== undefined) { + return buildFromBundle(params, spec, spec.bundle, mgmtAddress); + } const bondMembers = spec.bondMembers ?? []; const vlans = spec.vlans ?? []; @@ -192,6 +328,14 @@ export function buildVyosConfigSpec(params: { }); } + // Enabled here too, not just for bundle installs: every VyOS this bastion + // provisions should be manageable from first boot. + const api = enableApi(spec, params.hostname, mgmtAddress); + if (api.length > 0) { + sets.push(...api); + tags.push(...API_TAGS); + } + return { hostname: params.hostname, imageName: "", diff --git a/bastion/src/bastion/tests/vyos-bundle.test.ts b/bastion/src/bastion/tests/vyos-bundle.test.ts new file mode 100644 index 0000000..c8f2f98 --- /dev/null +++ b/bastion/src/bastion/tests/vyos-bundle.test.ts @@ -0,0 +1,155 @@ +import { describe, it, expect, vi } from "vitest"; +import type { VyosBundle } from "@lab/shared"; +import { buildVyosConfigSpec } from "../src/templates/vyos-config-spec.js"; + +/** + * A bundle is what makes "one config, two apply paths" true rather than + * aspirational: `pulumi up` POSTs the subtree model to a running router, labctl + * writes the same model into config.boot during a PXE install. These tests pin + * the properties that keep the two honest. + */ +const bundle: VyosBundle = { + sets: [ + { path: ["system", "host-name"], value: "vyos001" }, + { path: ["interfaces", "bonding", "bond0", "address"], value: "192.168.1.252/24" }, + { path: ["interfaces", "bonding", "bond0", "member", "interface"], value: "eth1", replace: false }, + { path: ["interfaces", "bonding", "bond0", "vif", "53", "disable"] }, + { path: ["interfaces", "pppoe", "pppoe0", "authentication", "password"], value: "@secret:pppoePassword" }, + { path: ["interfaces", "pppoe", "pppoe0", "mtu"], value: "1492" }, + ], + tags: [["interfaces", "bonding", "bond0"], ["interfaces", "ethernet"]], +}; + +const build = (hostname: string, extra: Record = {}) => + buildVyosConfigSpec({ + hostname, + spec: { bundle, ...extra }, + defaultPassword: "changeme", + }); + +describe("vyos config spec from a Pulumi bundle", () => { + it("applies non-secret nodes verbatim, preserving valuelessness and replace:false", () => { + const spec = build("vyos001"); + + expect(spec.sets).toContainEqual({ + path: ["interfaces", "bonding", "bond0", "address"], + value: "192.168.1.252/24", + }); + // A multi-value node must keep replace:false or the second bond member + // overwrites the first. + expect(spec.sets).toContainEqual({ + path: ["interfaces", "bonding", "bond0", "member", "interface"], + value: "eth1", + replace: false, + }); + // A valueless node must not acquire a value on the way through. + expect(spec.sets).toContainEqual({ + path: ["interfaces", "bonding", "bond0", "vif", "53", "disable"], + }); + expect(spec.tags).toEqual(bundle.tags); + }); + + it("drops secret-valued nodes instead of installing the sentinel text", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const spec = build("vyos001"); + + const values = spec.sets.map((s) => s.value ?? ""); + expect(values.some((v) => v.startsWith("@secret:"))).toBe(false); + expect(spec.sets.some((s) => s.path.includes("authentication"))).toBe(false); + // Silently dropping the WAN credential would leave someone debugging a dead + // PPPoE link, so it has to be said out loud. + expect(warn).toHaveBeenCalledWith(expect.stringContaining("pulumi up")); + warn.mockRestore(); + }); + + it("forces the hostname the install was asked for, not the bundle's", () => { + // The bundle is exported from one router and reused for its peer; taking the + // hostname from it would put two vyos001s on the network. + const spec = build("vyos002"); + const hostnames = spec.sets.filter( + (s) => s.path.length === 2 && s.path[0] === "system" && s.path[1] === "host-name", + ); + expect(hostnames).toEqual([{ path: ["system", "host-name"], value: "vyos002" }]); + }); + + it("still honours installer inputs, which are not router config", () => { + const spec = buildVyosConfigSpec({ + hostname: "vyos001", + spec: { bundle, password: "s3cret", freshConfig: true }, + defaultPassword: "changeme", + disk: "nvme0n1", + }); + expect(spec.password).toBe("s3cret"); + expect(spec.freshConfig).toBe(true); + expect(spec.disk).toBe("/dev/nvme0n1"); + }); + + it("enables the HTTP API at install so Pulumi can manage the router from first boot", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const spec = buildVyosConfigSpec({ + hostname: "vyos001", + spec: { bundle, apiKey: "k3y", apiListenAddress: "10.0.1.252" }, + defaultPassword: "changeme", + }); + + expect(spec.sets).toContainEqual({ + path: ["service", "https", "api", "keys", "id", "pulumi", "key"], + value: "k3y", + }); + expect(spec.sets).toContainEqual({ path: ["service", "https", "api", "rest"] }); + expect(spec.sets).toContainEqual({ + path: ["service", "https", "listen-address"], + value: "10.0.1.252", + }); + // The key id is a tag node; without this the installer's ConfigTree rejects it. + expect(spec.tags).toContainEqual(["service", "https", "api", "keys", "id"]); + warn.mockRestore(); + }); + + it("binds the API to the static management address when none is given", () => { + const spec = buildVyosConfigSpec({ + hostname: "vyos001", + spec: { apiKey: "k3y", mgmtAddress: "192.168.1.252/24" }, + defaultPassword: "changeme", + }); + expect(spec.sets).toContainEqual({ + path: ["service", "https", "listen-address"], + value: "192.168.1.252", + }); + }); + + it("refuses to enable the API unbound rather than exposing it on the WAN", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + // Management is DHCP, so there is no address to bind at build time. Binding + // to everything would put a config-write endpoint on the WAN. + const spec = buildVyosConfigSpec({ + hostname: "vyos001", + spec: { apiKey: "k3y", mgmtAddress: "dhcp" }, + defaultPassword: "changeme", + }); + expect(spec.sets.some((s) => s.path[0] === "service" && s.path[1] === "https")).toBe(false); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("has NOT been enabled")); + warn.mockRestore(); + }); + + it("does not enable the API when no key is supplied", () => { + const spec = buildVyosConfigSpec({ + hostname: "vyos001", + spec: { mgmtAddress: "192.168.1.252/24" }, + defaultPassword: "changeme", + }); + expect(spec.sets.some((s) => s.path[0] === "service" && s.path[1] === "https")).toBe(false); + }); + + it("ignores the derived path entirely when a bundle is present", () => { + // Belt and braces: even if topology flags reach this far (the CLI rejects + // them), the bundle must win rather than merge. + const spec = buildVyosConfigSpec({ + hostname: "vyos001", + spec: { bundle, bondMembers: ["eth2", "eth3"], vlans: [{ id: 99, address: "10.9.9.1/24" }] }, + defaultPassword: "changeme", + }); + expect(spec.sets.some((s) => s.path.includes("99"))).toBe(false); + expect(spec.sets.filter((s) => s.value === "eth2" || s.value === "eth3")).toEqual([]); + }); +}); diff --git a/bastion/src/cli/src/commands/install.ts b/bastion/src/cli/src/commands/install.ts index 9ea6786..694031b 100644 --- a/bastion/src/cli/src/commands/install.ts +++ b/bastion/src/cli/src/commands/install.ts @@ -1,11 +1,42 @@ // CLI command: provision install // Queue a discovered machine for OS installation via labd. +import { readFileSync } from "node:fs"; 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 type { VyosBundle, VyosInstallSpec, VyosVlanSpec } from "@lab/shared"; import { getLabdClient } from "../api/config.js"; +/** + * Load one router's config out of a Pulumi-rendered bundle. + * + * The bundle is produced by `kubernetes-deployment` (npm run vyos:bundle) and + * holds every router it manages, keyed by name. Selecting by hostname here is + * what keeps bring-up and `pulumi up` describing the same box: labctl replays + * the declared config rather than deriving its own. + */ +export function loadVyosBundle(path: string, hostname: string): VyosBundle { + let parsed: { version?: number; routers?: Record }; + try { + parsed = JSON.parse(readFileSync(path, "utf8")); + } catch (e) { + throw new InvalidArgumentError(`Cannot read VyOS bundle ${path}: ${(e as Error).message}`); + } + if (parsed.version !== 1) { + throw new InvalidArgumentError( + `VyOS bundle ${path} has version ${parsed.version ?? ""}; this labctl understands 1`, + ); + } + const router = parsed.routers?.[hostname]; + if (router === undefined) { + const known = Object.keys(parsed.routers ?? {}).join(", ") || ""; + throw new InvalidArgumentError( + `VyOS bundle ${path} has no entry for "${hostname}" (has: ${known})`, + ); + } + return router; +} + /** Parse a repeated --vlan flag: ":[:]". */ export function parseVlan(value: string, previous: VyosVlanSpec[] = []): VyosVlanSpec[] { const parts = value.split(":"); @@ -88,6 +119,20 @@ export function registerInstallCommand(parent: Command): void { .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) .option("--vyos-fresh-config", "VyOS: on reinstall, overwrite the preserved config with the generated one") + .option( + "--vyos-bundle ", + "VyOS: apply a Pulumi-rendered bundle verbatim (kubernetes-deployment/infra/vyos/vyos-bundle.json). " + + "Replaces the derived --vyos-bond/--vlan/... config; secret values are left unset for `pulumi up`.", + ) + .option( + "--vyos-api-key ", + "VyOS: enable the HTTP API with this key so Pulumi can manage the router from first boot", + ) + .option( + "--vyos-api-listen ", + "VyOS: address the HTTP API binds to (default: the static management address). " + + "Required when management is DHCP; the API is never bound to all interfaces.", + ) .action(async (mac: string, hostname: string, opts: { role: string; os: string; @@ -104,6 +149,9 @@ export function registerInstallCommand(parent: Command): void { vyosPassword?: string; vyosHwid?: Record; vyosFreshConfig?: boolean; + vyosBundle?: string; + vyosApiKey?: string; + vyosApiListen?: string; }) => { if (!isValidOsId(opts.os)) { console.error(`Unknown OS: ${opts.os}. Supported: ${SUPPORTED_OS.join(", ")}`); @@ -159,9 +207,31 @@ export function registerInstallCommand(parent: Command): void { ...(opts.vyosMgmtVlan !== undefined && opts.vyosMgmtVlan !== "" ? { mgmtVlan: parseVlan(opts.vyosMgmtVlan)[0] as VyosVlanSpec } : {}), ...(opts.vyosFreshConfig === true ? { freshConfig: true } : {}), + ...(opts.vyosBundle !== undefined && opts.vyosBundle !== "" + ? { bundle: loadVyosBundle(opts.vyosBundle, hostname) } : {}), + ...(opts.vyosApiKey !== undefined && opts.vyosApiKey !== "" + ? { apiKey: opts.vyosApiKey } : {}), + ...(opts.vyosApiListen !== undefined && opts.vyosApiListen !== "" + ? { apiListenAddress: opts.vyosApiListen } : {}), }; const hasVyosOptions = Object.keys(vyos).length > 0; + // A bundle already describes the whole router. Accepting derived topology + // flags alongside it would silently discard them (the bundle wins in + // buildVyosConfigSpec), so say so rather than appear to honour both. + if (vyos.bundle !== undefined) { + const derived = ["mgmtInterface", "mgmtAddress", "bondMembers", "bondAddress", + "bondVrrp", "vrrpPriority", "vlans", "mgmtVlan"] as const; + const conflicting = derived.filter((k) => vyos[k] !== undefined); + if (conflicting.length > 0) { + console.error( + `--vyos-bundle describes the whole router; these would be ignored: ${conflicting.join(", ")}`, + ); + console.error("Remove them, or change the bundle in kubernetes-deployment and re-render."); + process.exit(1); + } + } + if (hasVyosOptions && !opts.os.startsWith("vyos")) { console.error(`VyOS options require --os vyos-rolling (got --os ${opts.os})`); process.exit(1); diff --git a/bastion/src/shared/src/index.ts b/bastion/src/shared/src/index.ts index 24accf0..1a3239b 100644 --- a/bastion/src/shared/src/index.ts +++ b/bastion/src/shared/src/index.ts @@ -10,6 +10,8 @@ export type { BastionConfig, VyosVlanSpec, VyosInstallSpec, + VyosBundle, + VyosBundleSetOp, } from "./types/index.js"; export { SUPPORTED_OS, SUPPORTED_ROLES, ROLE_REGISTRY, isValidOsId } from "./types/index.js"; diff --git a/bastion/src/shared/src/types/index.ts b/bastion/src/shared/src/types/index.ts index fd2d53c..73e471c 100644 --- a/bastion/src/shared/src/types/index.ts +++ b/bastion/src/shared/src/types/index.ts @@ -9,6 +9,8 @@ export type { BastionState, VyosVlanSpec, VyosInstallSpec, + VyosBundle, + VyosBundleSetOp, } 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 72f5ee0..93b30bb 100644 --- a/bastion/src/shared/src/types/state.ts +++ b/bastion/src/shared/src/types/state.ts @@ -88,6 +88,33 @@ export interface VyosVlanSpec { vrrp?: string; } +/** One config node in a rendered bundle. Mirrors VyosSetOp on the bastion side. */ +export interface VyosBundleSetOp { + path: string[]; + value?: string; + /** false appends to a multi-value node (e.g. bond members) instead of replacing. */ + replace?: boolean; +} + +/** + * A router's complete desired config, rendered from the Pulumi model. + * + * Produced by `kubernetes-deployment/scripts/vyos-render-bundle.ts` from the + * same subtree model `pulumi up` applies. The point is that labctl never + * authors VyOS config: bring-up replays what Pulumi already declares, so a + * freshly installed router and a `pulumi up` cannot disagree. + * + * Secret values arrive as `@secret:` sentinels and are DROPPED at install + * time -- the bundle is committed to git and must stay safe to read. The router + * comes up on the LAN without its PPPoE credential; the first `pulumi up` + * supplies it. That handoff is deliberate. + */ +export interface VyosBundle { + sets: VyosBundleSetOp[]; + /** Paths that are VyOS tag nodes — the installer's ConfigTree needs them marked. */ + tags: string[][]; +} + /** * VyOS-specific install parameters. Rendered into the config.boot that the * installer adopts, so the router comes up already configured. @@ -96,6 +123,31 @@ export interface VyosVlanSpec { * PXE cannot run over LACP, so the install-time NIC has to stay unbonded. */ export interface VyosInstallSpec { + /** + * A complete rendered config for this router. When present it REPLACES the + * derived interface/VLAN/VRRP config below -- the bundle already describes + * all of it, and deriving a second opinion is exactly the drift this exists + * to prevent. The remaining install parameters (password, disk, console) are + * still honoured because they are installer inputs, not router config. + */ + bundle?: VyosBundle; + /** + * Key for the VyOS HTTP API, enabled at install so the router is manageable + * from the moment it boots. + * + * Without this the box comes up reachable only over SSH, and enabling the API + * later is a hand-run config change on a live firewall -- which is exactly the + * gap that left vyos001/vyos002 unmanageable by Pulumi after their cutover. + * The API is deliberately NOT part of the Pulumi model: a provider that + * manages its own transport can revoke its own access. + */ + apiKey?: string; + /** + * Address the API listens on. Defaults to the management address when static. + * Never left unbound: an unrestricted listener puts a config-write endpoint on + * every segment the router touches, including the WAN. + */ + apiListenAddress?: string; /** 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. */