feat(labctl): install VyOS from a Pulumi-rendered bundle, and enable its API
Two halves of the same problem: a router should come up running the config that is declared for it, and should be manageable the moment it does. --vyos-bundle applies a bundle rendered by kubernetes-deployment verbatim, replacing the derived --vyos-bond/--vlan/... path rather than merging with it. Deriving a second opinion alongside a bundle is exactly 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. Passing both is rejected rather than silently resolved. Secret-valued nodes arrive as @secret: sentinels and are dropped, with a warning naming each one. Writing the sentinel text into config.boot would look configured while being wrong, which is worse than being absent -- the router comes up without its PPPoE credential and the first `pulumi up` supplies it. A bundle committed to git has to stay safe to read. The hostname is forced to the one the install was asked for. A bundle is exported from one router and reused for its peer, and taking the hostname from it would put two vyos001s on the network. --vyos-api-key enables the HTTP API at install, on both the bundle and the derived path, so every VyOS this bastion provisions is manageable from first boot. vyos001 and vyos002 predate this and had to be enabled by hand on a live firewall after their cutover -- which is the gap this closes. It is deliberately not part of the Pulumi model: a provider able to rewrite its own transport can revoke its own access. listen-address is always set, and the API is NOT enabled when no address is known -- under DHCP there is none at build time, and binding to every interface would publish a config-write endpoint on the WAN. It warns and leaves the router SSH-only instead. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH
This commit is contained in:
@@ -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<VyosInstallSpec["bundle"]>,
|
||||
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 <addr>; 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: "",
|
||||
|
||||
155
bastion/src/bastion/tests/vyos-bundle.test.ts
Normal file
155
bastion/src/bastion/tests/vyos-bundle.test.ts
Normal file
@@ -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<string, unknown> = {}) =>
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
@@ -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<string, VyosBundle> };
|
||||
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 ?? "<none>"}; this labctl understands 1`,
|
||||
);
|
||||
}
|
||||
const router = parsed.routers?.[hostname];
|
||||
if (router === undefined) {
|
||||
const known = Object.keys(parsed.routers ?? {}).join(", ") || "<none>";
|
||||
throw new InvalidArgumentError(
|
||||
`VyOS bundle ${path} has no entry for "${hostname}" (has: ${known})`,
|
||||
);
|
||||
}
|
||||
return router;
|
||||
}
|
||||
|
||||
/** Parse a repeated --vlan flag: "<id>:<cidr>[:<description>]". */
|
||||
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 <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)
|
||||
.option("--vyos-fresh-config", "VyOS: on reinstall, overwrite the preserved config with the generated one")
|
||||
.option(
|
||||
"--vyos-bundle <path>",
|
||||
"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 <key>",
|
||||
"VyOS: enable the HTTP API with this key so Pulumi can manage the router from first boot",
|
||||
)
|
||||
.option(
|
||||
"--vyos-api-listen <addr>",
|
||||
"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<string, string>;
|
||||
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);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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:<key>` 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. */
|
||||
|
||||
Reference in New Issue
Block a user