@takodotid/circuit 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli/index.js CHANGED
@@ -3347,6 +3347,67 @@ async function ripeStat(query, family) {
3347
3347
  }
3348
3348
  var registries = { "ripe-stat": ripeStat };
3349
3349
 
3350
+ // src/adapters/registries/librenms.ts
3351
+ var AUTH = { sha1: "SHA", sha256: "SHA-256" };
3352
+ var PRIVACY = { aes128: "AES", des: "DES" };
3353
+ function settingsOf2(network2) {
3354
+ if (!network2.librenms)
3355
+ throw new Error("the network has no librenms settings");
3356
+ return {
3357
+ base: `${network2.librenms.url.replace(/\/+$/, "")}/api/v0`,
3358
+ headers: { "X-Auth-Token": resolve(network2.librenms.api_token), "Content-Type": "application/json" }
3359
+ };
3360
+ }
3361
+ function credentials(device) {
3362
+ const snmp3 = device.management?.snmp;
3363
+ const [name, user] = Object.entries(snmp3?.users ?? {})[0] ?? [];
3364
+ if (name && user) {
3365
+ return {
3366
+ snmpver: "v3",
3367
+ authlevel: "authPriv",
3368
+ authname: name,
3369
+ authpass: resolve(user.auth_password),
3370
+ authalgo: AUTH[user.auth],
3371
+ cryptopass: resolve(user.privacy_password),
3372
+ cryptoalgo: PRIVACY[user.privacy]
3373
+ };
3374
+ }
3375
+ if (snmp3?.community)
3376
+ return { snmpver: "v2c", community: resolve(snmp3.community) };
3377
+ return;
3378
+ }
3379
+ async function planLibreNms(network2) {
3380
+ const { base, headers } = settingsOf2(network2);
3381
+ const response = await fetch(`${base}/devices?type=all`, { headers, signal: AbortSignal.timeout(30000) });
3382
+ if (!response.ok)
3383
+ throw new Error(`LibreNMS answered ${response.status} to the device list`);
3384
+ const monitored = new Set((await response.json()).devices.map((device) => device.hostname));
3385
+ const ours = new Set(network2.devices.map((device) => device.connection.host));
3386
+ const missing = network2.devices.filter((device) => !monitored.has(device.connection.host));
3387
+ return {
3388
+ additions: missing.filter((device) => device.management?.snmp),
3389
+ withoutSnmp: missing.filter((device) => !device.management?.snmp),
3390
+ unknown: [...monitored].filter((hostname) => !ours.has(hostname)).sort()
3391
+ };
3392
+ }
3393
+ async function sendLibreNms(network2, additions) {
3394
+ const { base, headers } = settingsOf2(network2);
3395
+ for (const device of additions) {
3396
+ const body = { hostname: device.connection.host, display_template: device.name, ...credentials(device) };
3397
+ const response = await fetch(`${base}/devices`, {
3398
+ method: "POST",
3399
+ headers,
3400
+ body: JSON.stringify(body),
3401
+ signal: AbortSignal.timeout(60000)
3402
+ });
3403
+ const answer = await response.json().catch(() => ({}));
3404
+ if (!response.ok || answer.status !== "ok") {
3405
+ throw new Error(`${device.name}: LibreNMS refused it: ${answer.message ?? response.status}`);
3406
+ }
3407
+ console.log(` ${device.name}: ${answer.message}`);
3408
+ }
3409
+ }
3410
+
3350
3411
  // src/adapters/registries/peeringdb.ts
3351
3412
  var API = "https://www.peeringdb.com/api";
3352
3413
  var addressesOf = (iface) => (iface.addresses ?? []).map((entry) => typeof entry === "string" ? entry : entry.address);
@@ -3440,6 +3501,17 @@ async function sendPeeringDb(network2, changes2) {
3440
3501
  }
3441
3502
  }
3442
3503
 
3504
+ // src/adapters/registries/prometheus.ts
3505
+ function prometheusTargets(network2) {
3506
+ return {
3507
+ targets: network2.devices.filter((device) => device.management?.snmp).map((device) => ({
3508
+ targets: [device.connection.host],
3509
+ labels: { device: device.name, platform: device.platform, model: device.model }
3510
+ })),
3511
+ withoutSnmp: network2.devices.filter((device) => !device.management?.snmp)
3512
+ };
3513
+ }
3514
+
3443
3515
  // src/core/validate.ts
3444
3516
  import { X509Certificate as X509Certificate2 } from "node:crypto";
3445
3517
  function validate(network2) {
@@ -3796,6 +3868,8 @@ var HELP = `usage: circuit <command> [device...] [options]
3796
3868
  a client config for one peer, its private key left for the peer to fill
3797
3869
  communities the network's BGP communities, one per line, for a looking glass or bgp.tools
3798
3870
  peeringdb bring PeeringDB's exchange records in line with the config, with --confirm
3871
+ librenms add every device LibreNMS does not monitor yet, with --confirm
3872
+ prometheus every device with SNMP, as Prometheus targets for snmp_exporter
3799
3873
  secrets every secret the config uses, and whether each can be read
3800
3874
 
3801
3875
  --config <path> the network's config file, circuit.config.ts in the working directory by default`;
@@ -4030,6 +4104,31 @@ Nothing sent. Re-run with --confirm.`);
4030
4104
  }
4031
4105
  await sendPeeringDb(network2, plan4.changes);
4032
4106
  }
4107
+ async function librenmsCommand() {
4108
+ const plan4 = await planLibreNms(network2);
4109
+ for (const hostname of plan4.unknown)
4110
+ console.log(` LibreNMS monitors ${hostname}, which the config does not. Remove it by hand if it is gone.`);
4111
+ for (const device of plan4.withoutSnmp)
4112
+ console.log(` ${device.name} has no SNMP in its config, so LibreNMS cannot poll it. Add management.snmp first.`);
4113
+ for (const device of plan4.additions)
4114
+ console.log(` add ${device.name}, ${device.connection.host}`);
4115
+ if (!plan4.additions.length) {
4116
+ console.log("LibreNMS monitors every device it can.");
4117
+ return;
4118
+ }
4119
+ if (!confirmed) {
4120
+ console.log(`
4121
+ Nothing sent. Re-run with --confirm.`);
4122
+ return;
4123
+ }
4124
+ await sendLibreNms(network2, plan4.additions);
4125
+ }
4126
+ function prometheusCommand() {
4127
+ const { targets, withoutSnmp } = prometheusTargets(network2);
4128
+ for (const device of withoutSnmp)
4129
+ console.error(`${device.name} has no SNMP in its config; left out`);
4130
+ console.log(JSON.stringify(targets, null, 4));
4131
+ }
4033
4132
  function secretsIn(value2, found = new Set) {
4034
4133
  if (isSecret(value2))
4035
4134
  found.add(referenceOf(value2));
@@ -4042,7 +4141,7 @@ function secretsIn(value2, found = new Set) {
4042
4141
  return found;
4043
4142
  }
4044
4143
  function secretsCommand() {
4045
- const references = [...secretsIn([network2.devices, network2.peeringdb])].sort();
4144
+ const references = [...secretsIn([network2.devices, network2.peeringdb, network2.librenms])].sort();
4046
4145
  let missing = 0;
4047
4146
  for (const reference of references) {
4048
4147
  const name = reference.replace(/^file:/, "");
@@ -4068,6 +4167,8 @@ var commands = {
4068
4167
  wireguard: wireguardCommand,
4069
4168
  communities: communitiesCommand,
4070
4169
  peeringdb: peeringdbCommand,
4170
+ librenms: librenmsCommand,
4171
+ prometheus: prometheusCommand,
4071
4172
  secrets: secretsCommand
4072
4173
  };
4073
4174
  var run = commands[command2];
@@ -0,0 +1,15 @@
1
+ import type { Network } from "../../core/define";
2
+ import type { Device } from "../../schema";
3
+ /** What `librenms` would do. */
4
+ export type LibreNmsPlan = {
5
+ /** Devices to add. */
6
+ additions: Device[];
7
+ /** Devices that cannot be added: their config turns SNMP off. */
8
+ withoutSnmp: Device[];
9
+ /** Hostnames LibreNMS holds that no device in the config has. */
10
+ unknown: string[];
11
+ };
12
+ /** Compare the config with what LibreNMS monitors. A device is known to LibreNMS by its management address. */
13
+ export declare function planLibreNms(network: Network): Promise<LibreNmsPlan>;
14
+ /** Add each device, named after its `name` and polled with the credentials from its config. */
15
+ export declare function sendLibreNms(network: Network, additions: readonly Device[]): Promise<void>;
@@ -0,0 +1,11 @@
1
+ import type { Network } from "../../core/define";
2
+ import type { Device } from "../../schema";
3
+ export type PrometheusTarget = {
4
+ targets: string[];
5
+ labels: Record<string, string>;
6
+ };
7
+ /** The targets, and the devices left out because their config turns SNMP off. */
8
+ export declare function prometheusTargets(network: Network): {
9
+ targets: PrometheusTarget[];
10
+ withoutSnmp: Device[];
11
+ };
@@ -45,6 +45,13 @@ export type Network = {
45
45
  /** An API key with write access to the network's record. */
46
46
  api_key: Secret;
47
47
  };
48
+ /** Keeps LibreNMS monitoring every device: `circuit librenms`. */
49
+ librenms?: {
50
+ /** Where LibreNMS is, such as `https://librenms.example.com`. */
51
+ url: string;
52
+ /** An API token that can add devices. */
53
+ api_token: Secret;
54
+ };
48
55
  };
49
56
  /** Declare the network, in `circuit.config.ts`. */
50
57
  export declare const defineNetwork: (network: Network) => Network;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@takodotid/circuit",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "Declarative, vendor-neutral network configuration: describe each device in TypeScript, and Circuit makes the device match. Alpha.",
5
5
  "keywords": [
6
6
  "network",
@@ -0,0 +1,90 @@
1
+ // Keeps LibreNMS in step with the config: every device Circuit manages is monitored there, polled with the SNMP credentials its config gives it. A device LibreNMS holds that the config does not is reported, never deleted.
2
+
3
+ import type { Network } from "../../core/define";
4
+ import { resolve } from "../../core/secrets";
5
+ import type { Device } from "../../schema";
6
+
7
+ /** What `librenms` would do. */
8
+ export type LibreNmsPlan = {
9
+ /** Devices to add. */
10
+ additions: Device[];
11
+ /** Devices that cannot be added: their config turns SNMP off. */
12
+ withoutSnmp: Device[];
13
+ /** Hostnames LibreNMS holds that no device in the config has. */
14
+ unknown: string[];
15
+ };
16
+
17
+ /** LibreNMS's names for the algorithms. */
18
+ const AUTH = { sha1: "SHA", sha256: "SHA-256" } as const;
19
+ const PRIVACY = { aes128: "AES", des: "DES" } as const;
20
+
21
+ function settingsOf(network: Network) {
22
+ if (!network.librenms) throw new Error("the network has no librenms settings");
23
+
24
+ return {
25
+ base: `${network.librenms.url.replace(/\/+$/, "")}/api/v0`,
26
+ // The header every version of API v0 accepts.
27
+ headers: { "X-Auth-Token": resolve(network.librenms.api_token), "Content-Type": "application/json" },
28
+ };
29
+ }
30
+
31
+ /** How LibreNMS polls a device: its first SNMP version 3 user, or else its version 2c community. */
32
+ function credentials(device: Device): Record<string, string> | undefined {
33
+ const snmp = device.management?.snmp;
34
+ const [name, user] = Object.entries(snmp?.users ?? {})[0] ?? [];
35
+
36
+ if (name && user) {
37
+ return {
38
+ snmpver: "v3",
39
+ authlevel: "authPriv",
40
+ authname: name,
41
+ authpass: resolve(user.auth_password),
42
+ authalgo: AUTH[user.auth],
43
+ cryptopass: resolve(user.privacy_password),
44
+ cryptoalgo: PRIVACY[user.privacy],
45
+ };
46
+ }
47
+
48
+ if (snmp?.community) return { snmpver: "v2c", community: resolve(snmp.community) };
49
+ return undefined;
50
+ }
51
+
52
+ /** Compare the config with what LibreNMS monitors. A device is known to LibreNMS by its management address. */
53
+ export async function planLibreNms(network: Network): Promise<LibreNmsPlan> {
54
+ const { base, headers } = settingsOf(network);
55
+
56
+ const response = await fetch(`${base}/devices?type=all`, { headers, signal: AbortSignal.timeout(30_000) });
57
+ if (!response.ok) throw new Error(`LibreNMS answered ${response.status} to the device list`);
58
+
59
+ const monitored = new Set(((await response.json()) as { devices: { hostname: string }[] }).devices.map((device) => device.hostname));
60
+ const ours = new Set(network.devices.map((device) => device.connection.host));
61
+ const missing = network.devices.filter((device) => !monitored.has(device.connection.host));
62
+
63
+ return {
64
+ additions: missing.filter((device) => device.management?.snmp),
65
+ withoutSnmp: missing.filter((device) => !device.management?.snmp),
66
+ unknown: [...monitored].filter((hostname) => !ours.has(hostname)).sort(),
67
+ };
68
+ }
69
+
70
+ /** Add each device, named after its `name` and polled with the credentials from its config. */
71
+ export async function sendLibreNms(network: Network, additions: readonly Device[]): Promise<void> {
72
+ const { base, headers } = settingsOf(network);
73
+
74
+ for (const device of additions) {
75
+ const body = { hostname: device.connection.host, display_template: device.name, ...credentials(device) };
76
+ const response = await fetch(`${base}/devices`, {
77
+ method: "POST",
78
+ headers,
79
+ body: JSON.stringify(body),
80
+ signal: AbortSignal.timeout(60_000),
81
+ });
82
+ const answer = (await response.json().catch(() => ({}))) as { status?: string; message?: string };
83
+
84
+ if (!response.ok || answer.status !== "ok") {
85
+ throw new Error(`${device.name}: LibreNMS refused it: ${answer.message ?? response.status}`);
86
+ }
87
+
88
+ console.log(` ${device.name}: ${answer.message}`);
89
+ }
90
+ }
@@ -0,0 +1,19 @@
1
+ // Prometheus targets for snmp_exporter, in its file-based discovery format: one entry per device that answers SNMP, labelled with what the config knows about it.
2
+
3
+ import type { Network } from "../../core/define";
4
+ import type { Device } from "../../schema";
5
+
6
+ export type PrometheusTarget = { targets: string[]; labels: Record<string, string> };
7
+
8
+ /** The targets, and the devices left out because their config turns SNMP off. */
9
+ export function prometheusTargets(network: Network): { targets: PrometheusTarget[]; withoutSnmp: Device[] } {
10
+ return {
11
+ targets: network.devices
12
+ .filter((device) => device.management?.snmp)
13
+ .map((device) => ({
14
+ targets: [device.connection.host],
15
+ labels: { device: device.name, platform: device.platform, model: device.model },
16
+ })),
17
+ withoutSnmp: network.devices.filter((device) => !device.management?.snmp),
18
+ };
19
+ }
package/src/cli/index.ts CHANGED
@@ -7,7 +7,9 @@ import { basename, dirname, join, resolve } from "node:path";
7
7
  import { adapters } from "../adapters/devices";
8
8
  import type { ApplyOptions, DeviceAdapter, Plan } from "../adapters/devices/types";
9
9
  import { registries } from "../adapters/registries";
10
+ import { planLibreNms, sendLibreNms } from "../adapters/registries/librenms";
10
11
  import { planPeeringDb, sendPeeringDb } from "../adapters/registries/peeringdb";
12
+ import { prometheusTargets } from "../adapters/registries/prometheus";
11
13
  import { network as prefixOf } from "../core/addr";
12
14
  import type { Network } from "../core/define";
13
15
  import { isSecret, referenceOf, resolve as reveal, sourceOf, useRoot } from "../core/secrets";
@@ -29,6 +31,8 @@ const HELP = `usage: circuit <command> [device...] [options]
29
31
  a client config for one peer, its private key left for the peer to fill
30
32
  communities the network's BGP communities, one per line, for a looking glass or bgp.tools
31
33
  peeringdb bring PeeringDB's exchange records in line with the config, with --confirm
34
+ librenms add every device LibreNMS does not monitor yet, with --confirm
35
+ prometheus every device with SNMP, as Prometheus targets for snmp_exporter
32
36
  secrets every secret the config uses, and whether each can be read
33
37
 
34
38
  --config <path> the network's config file, circuit.config.ts in the working directory by default`;
@@ -325,6 +329,34 @@ async function peeringdbCommand(): Promise<void> {
325
329
  await sendPeeringDb(network, plan.changes);
326
330
  }
327
331
 
332
+ async function librenmsCommand(): Promise<void> {
333
+ const plan = await planLibreNms(network);
334
+
335
+ for (const hostname of plan.unknown)
336
+ console.log(` LibreNMS monitors ${hostname}, which the config does not. Remove it by hand if it is gone.`);
337
+ for (const device of plan.withoutSnmp)
338
+ console.log(` ${device.name} has no SNMP in its config, so LibreNMS cannot poll it. Add management.snmp first.`);
339
+ for (const device of plan.additions) console.log(` add ${device.name}, ${device.connection.host}`);
340
+
341
+ if (!plan.additions.length) {
342
+ console.log("LibreNMS monitors every device it can.");
343
+ return;
344
+ }
345
+
346
+ if (!confirmed) {
347
+ console.log("\nNothing sent. Re-run with --confirm.");
348
+ return;
349
+ }
350
+
351
+ await sendLibreNms(network, plan.additions);
352
+ }
353
+
354
+ function prometheusCommand(): void {
355
+ const { targets, withoutSnmp } = prometheusTargets(network);
356
+ for (const device of withoutSnmp) console.error(`${device.name} has no SNMP in its config; left out`);
357
+ console.log(JSON.stringify(targets, null, 4));
358
+ }
359
+
328
360
  /** Every secret reference in a value, however deep. */
329
361
  function secretsIn(value: unknown, found = new Set<string>()): Set<string> {
330
362
  if (isSecret(value)) found.add(referenceOf(value));
@@ -335,7 +367,7 @@ function secretsIn(value: unknown, found = new Set<string>()): Set<string> {
335
367
  }
336
368
 
337
369
  function secretsCommand(): void {
338
- const references = [...secretsIn([network.devices, network.peeringdb])].sort();
370
+ const references = [...secretsIn([network.devices, network.peeringdb, network.librenms])].sort();
339
371
  let missing = 0;
340
372
 
341
373
  for (const reference of references) {
@@ -365,6 +397,8 @@ const commands: Record<string, () => void | Promise<void>> = {
365
397
  wireguard: wireguardCommand,
366
398
  communities: communitiesCommand,
367
399
  peeringdb: peeringdbCommand,
400
+ librenms: librenmsCommand,
401
+ prometheus: prometheusCommand,
368
402
  secrets: secretsCommand,
369
403
  };
370
404
 
@@ -86,6 +86,13 @@ export type Network = {
86
86
  /** An API key with write access to the network's record. */
87
87
  api_key: Secret;
88
88
  };
89
+ /** Keeps LibreNMS monitoring every device: `circuit librenms`. */
90
+ librenms?: {
91
+ /** Where LibreNMS is, such as `https://librenms.example.com`. */
92
+ url: string;
93
+ /** An API token that can add devices. */
94
+ api_token: Secret;
95
+ };
89
96
  };
90
97
 
91
98
  /** Declare the network, in `circuit.config.ts`. */