206 lines
8.3 KiB
TypeScript
206 lines
8.3 KiB
TypeScript
|
|
import * as pulumi from "@pulumi/pulumi";
|
||
|
|
import * as https from "https";
|
||
|
|
|
||
|
|
/**
|
||
|
|
* A VyOS config subtree, managed as ONE Pulumi resource.
|
||
|
|
*
|
||
|
|
* Why a dynamic provider rather than the community Terraform providers: they
|
||
|
|
* batch correctly (one `vyos_config_block_tree` becomes a single POST to
|
||
|
|
* /configure, so one commit) but they never send `confirm_time`. VyOS's own API
|
||
|
|
* supports it -- `ConfigureListModel.confirm_time`, and "A non-zero confirm_time
|
||
|
|
* will start commit-confirm timer on commit" in rest/routers.py -- so a config
|
||
|
|
* push that breaks your access to the router can undo itself. On a gateway that
|
||
|
|
* is the difference between a mistake and an outage, and it is worth ~150 lines
|
||
|
|
* to keep.
|
||
|
|
*
|
||
|
|
* The unit of change is a SUBTREE, not a line. `protocols bgp` is one resource;
|
||
|
|
* so is `service dhcp-server`. That keeps one Pulumi resource == one commit,
|
||
|
|
* rather than turning 300 config lines into 300 commits with no ordering
|
||
|
|
* guarantee and no way to revert them as a unit.
|
||
|
|
*/
|
||
|
|
|
||
|
|
export interface VyosConfigTreeArgs {
|
||
|
|
/** Router address, e.g. "10.0.1.252". */
|
||
|
|
host: pulumi.Input<string>;
|
||
|
|
/** API key from `set service https api keys id <n> key <k>`. */
|
||
|
|
apiKey: pulumi.Input<string>;
|
||
|
|
/** Subtree root as a path array, e.g. ["protocols", "bgp"]. */
|
||
|
|
path: pulumi.Input<string[]>;
|
||
|
|
/**
|
||
|
|
* Desired state of the subtree: `set` command suffixes relative to `path`,
|
||
|
|
* each already split into path components with the value last.
|
||
|
|
*/
|
||
|
|
commands: pulumi.Input<string[][]>;
|
||
|
|
/**
|
||
|
|
* Minutes before an unconfirmed commit reverts itself. 0 disables it.
|
||
|
|
* Non-zero is strongly preferred for anything reachable only through the
|
||
|
|
* router being changed.
|
||
|
|
*/
|
||
|
|
confirmMinutes?: pulumi.Input<number>;
|
||
|
|
/** Persist to config.boot after a successful confirm. */
|
||
|
|
save?: pulumi.Input<boolean>;
|
||
|
|
}
|
||
|
|
|
||
|
|
interface Inputs {
|
||
|
|
host: string;
|
||
|
|
apiKey: string;
|
||
|
|
path: string[];
|
||
|
|
commands: string[][];
|
||
|
|
confirmMinutes: number;
|
||
|
|
save: boolean;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Kept inside the module so the dynamic provider can serialise it.
|
||
|
|
function apiCall(
|
||
|
|
host: string, apiKey: string, endpoint: string,
|
||
|
|
form: Record<string, string>,
|
||
|
|
): Promise<any> {
|
||
|
|
const body = Object.entries(form)
|
||
|
|
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
|
||
|
|
.join("&");
|
||
|
|
return new Promise((resolve, reject) => {
|
||
|
|
const req = https.request({
|
||
|
|
host, port: 443, path: `/${endpoint}`, method: "POST",
|
||
|
|
// VyOS ships a self-signed certificate by default. Verification is
|
||
|
|
// disabled deliberately; if this ever leaves a trusted segment,
|
||
|
|
// install a real certificate and turn it back on.
|
||
|
|
rejectUnauthorized: false,
|
||
|
|
headers: {
|
||
|
|
"Content-Type": "application/x-www-form-urlencoded",
|
||
|
|
"Content-Length": Buffer.byteLength(body),
|
||
|
|
},
|
||
|
|
timeout: 120_000,
|
||
|
|
}, (res) => {
|
||
|
|
let data = "";
|
||
|
|
res.on("data", (c) => (data += c));
|
||
|
|
res.on("end", () => {
|
||
|
|
try {
|
||
|
|
const parsed = JSON.parse(data);
|
||
|
|
if (parsed.success === false) {
|
||
|
|
reject(new Error(`VyOS API: ${parsed.error ?? data}`));
|
||
|
|
} else {
|
||
|
|
resolve(parsed);
|
||
|
|
}
|
||
|
|
} catch {
|
||
|
|
reject(new Error(`VyOS API returned non-JSON: ${data.slice(0, 300)}`));
|
||
|
|
}
|
||
|
|
});
|
||
|
|
});
|
||
|
|
req.on("error", reject);
|
||
|
|
req.on("timeout", () => { req.destroy(); reject(new Error("VyOS API timed out")); });
|
||
|
|
req.write(body);
|
||
|
|
req.end();
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Replace a subtree: delete it, then set the desired state, in ONE request so
|
||
|
|
* it is a single commit. Deleting first makes the result the declared state
|
||
|
|
* rather than a merge with whatever was there -- which is what makes `pulumi
|
||
|
|
* up` converge instead of accumulating.
|
||
|
|
*/
|
||
|
|
async function applyTree(i: Inputs, desired: string[][]): Promise<void> {
|
||
|
|
const commands: any[] = [{ op: "delete", path: i.path }];
|
||
|
|
for (const c of desired) {
|
||
|
|
commands.push({ op: "set", path: [...i.path, ...c] });
|
||
|
|
}
|
||
|
|
|
||
|
|
// The payload MUST be {commands: [...], confirm_time: N}, not a bare array.
|
||
|
|
// VyOS only reads confirm_time when the body parses as ConfigureListModel
|
||
|
|
// (`if isinstance(data, (ConfigureModel, ConfigureListModel, ...))` in
|
||
|
|
// rest/routers.py). A bare array is accepted and committed happily -- with
|
||
|
|
// NO timer armed, silently discarding the safety net. Verified both ways:
|
||
|
|
// bare array gives no "Initialized commit-confirm" in the response, the
|
||
|
|
// object form returns "Initialized commit-confirm; N minutes to confirm".
|
||
|
|
const payload: any = { commands };
|
||
|
|
if (i.confirmMinutes > 0) payload.confirm_time = i.confirmMinutes;
|
||
|
|
|
||
|
|
const res = await apiCall(i.host, i.apiKey, "configure",
|
||
|
|
{ key: i.apiKey, data: JSON.stringify(payload) });
|
||
|
|
|
||
|
|
if (i.confirmMinutes > 0) {
|
||
|
|
// Refuse to continue if the timer did not actually arm -- otherwise a
|
||
|
|
// silent fallback to an unprotected commit is exactly the failure this
|
||
|
|
// resource exists to prevent.
|
||
|
|
const said = String((res && res.data) || "");
|
||
|
|
if (!said.includes("commit-confirm")) {
|
||
|
|
throw new Error(
|
||
|
|
"commit-confirm was requested but VyOS did not arm a timer " +
|
||
|
|
`(response: ${said.trim() || "<empty>"}). Refusing to proceed.`);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (i.confirmMinutes > 0) {
|
||
|
|
// The commit landed but is on a timer. Reaching the API again proves
|
||
|
|
// the box is still answering *after* the change -- the only evidence
|
||
|
|
// worth confirming on. If this throws we deliberately do NOT confirm,
|
||
|
|
// and the router reverts itself.
|
||
|
|
await apiCall(i.host, i.apiKey, "retrieve", {
|
||
|
|
key: i.apiKey,
|
||
|
|
data: JSON.stringify({ op: "showConfig", path: [] }),
|
||
|
|
});
|
||
|
|
// There is no /confirm endpoint -- confirm is an op on /configure, and
|
||
|
|
// it requires a `path` field even though it ignores it (the Union
|
||
|
|
// resolves to ConfigureModel, which mandates path). Without it the API
|
||
|
|
// answers "missing 'path' field" and the timer keeps running.
|
||
|
|
await apiCall(i.host, i.apiKey, "configure", {
|
||
|
|
key: i.apiKey,
|
||
|
|
data: JSON.stringify({ op: "confirm", path: [] }),
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
if (i.save) {
|
||
|
|
await apiCall(i.host, i.apiKey, "config-file", {
|
||
|
|
key: i.apiKey, data: JSON.stringify({ op: "save" }),
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const provider: pulumi.dynamic.ResourceProvider = {
|
||
|
|
async create(inputs: Inputs) {
|
||
|
|
await applyTree(inputs, inputs.commands);
|
||
|
|
return { id: `${inputs.host}:${inputs.path.join("/")}`, outs: inputs };
|
||
|
|
},
|
||
|
|
|
||
|
|
async update(_id, _old: Inputs, news: Inputs) {
|
||
|
|
await applyTree(news, news.commands);
|
||
|
|
return { outs: news };
|
||
|
|
},
|
||
|
|
|
||
|
|
async delete(_id, props: Inputs) {
|
||
|
|
const form: Record<string, string> = {
|
||
|
|
key: props.apiKey,
|
||
|
|
data: JSON.stringify([{ op: "delete", path: props.path }]),
|
||
|
|
};
|
||
|
|
if (props.confirmMinutes > 0) form.confirm_time = String(props.confirmMinutes);
|
||
|
|
await apiCall(props.host, props.apiKey, "configure", form);
|
||
|
|
if (props.confirmMinutes > 0) {
|
||
|
|
await apiCall(props.host, props.apiKey, "configure",
|
||
|
|
{ key: props.apiKey, data: JSON.stringify({ op: "confirm", path: [] }) });
|
||
|
|
}
|
||
|
|
},
|
||
|
|
|
||
|
|
async diff(_id, olds: Inputs, news: Inputs) {
|
||
|
|
const changed =
|
||
|
|
JSON.stringify(olds.commands) !== JSON.stringify(news.commands) ||
|
||
|
|
JSON.stringify(olds.path) !== JSON.stringify(news.path) ||
|
||
|
|
olds.host !== news.host;
|
||
|
|
return {
|
||
|
|
changes: changed,
|
||
|
|
// Changing which subtree or which router is a different resource.
|
||
|
|
replaces: olds.host !== news.host ||
|
||
|
|
JSON.stringify(olds.path) !== JSON.stringify(news.path) ? ["path"] : [],
|
||
|
|
};
|
||
|
|
},
|
||
|
|
};
|
||
|
|
|
||
|
|
export class VyosConfigTree extends pulumi.dynamic.Resource {
|
||
|
|
constructor(name: string, args: VyosConfigTreeArgs, opts?: pulumi.CustomResourceOptions) {
|
||
|
|
super(provider, name, {
|
||
|
|
confirmMinutes: 5,
|
||
|
|
save: true,
|
||
|
|
...args,
|
||
|
|
}, opts);
|
||
|
|
}
|
||
|
|
}
|