@takodotid/circuit 0.19.0 → 0.21.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/README.md +2 -2
- package/dist/chunks/index-n4zpd25n.js +936 -0
- package/dist/chunks/{index-dczpxzzm.js → index-v8zgr7wm.js} +33 -14
- package/dist/chunks/{new-7n8qvtjd.js → new-ny89s7y7.js} +4 -6
- package/dist/chunks/server-zapfzhv4.js +244 -0
- package/dist/cli/index.js +855 -803
- package/dist/index.js +20 -21
- package/dist/presets/index.js +20 -21
- package/dist/types/adapters/devices/derive.d.ts +2 -0
- package/dist/types/core/across.d.ts +4 -0
- package/dist/types/core/addr.d.ts +10 -0
- package/dist/types/core/addresses.d.ts +46 -0
- package/dist/types/core/files.d.ts +6 -0
- package/dist/types/core/graph.d.ts +31 -1
- package/dist/types/core/schema-docs.d.ts +16 -0
- package/package.json +7 -7
- package/src/adapters/devices/derive.ts +32 -0
- package/src/adapters/devices/raisecom-ros/index.ts +23 -3
- package/src/adapters/devices/routeros/index.ts +10 -0
- package/src/adapters/devices/vrp/index.ts +10 -1
- package/src/cli/graph.ts +1 -15
- package/src/cli/index.ts +154 -20
- package/src/cli/rename.ts +129 -0
- package/src/core/across.ts +322 -0
- package/src/core/addr.ts +29 -0
- package/src/core/addresses.ts +316 -0
- package/src/core/files.ts +32 -0
- package/src/core/graph.ts +27 -6
- package/src/core/schema-docs.ts +166 -0
- package/src/core/validate.ts +2 -52
- package/src/studio/data.ts +255 -0
- package/src/studio/git.ts +119 -0
- package/src/studio/public/app.js +165 -0
- package/src/studio/public/compare.js +243 -0
- package/src/studio/public/diff.js +97 -0
- package/src/studio/public/dom.js +108 -0
- package/src/studio/public/evaluate.js +648 -0
- package/src/studio/public/export.js +122 -0
- package/src/studio/public/favicon.svg +12 -0
- package/src/studio/public/graph.js +983 -0
- package/src/studio/public/index.html +71 -0
- package/src/studio/public/net.js +128 -0
- package/src/studio/public/special-gothic-latin-ext.woff2 +0 -0
- package/src/studio/public/special-gothic-latin.woff2 +0 -0
- package/src/studio/public/special-gothic-license.txt +93 -0
- package/src/studio/public/style.css +1334 -0
- package/src/studio/public/tables.js +1223 -0
- package/src/studio/public/tools.js +578 -0
- package/src/studio/server.ts +219 -0
- package/templates/project/AGENTS.md +2 -0
- package/dist/chunks/index-fq57hya4.js +0 -20
- package/dist/chunks/studio-femhydvd.js +0 -764
- package/src/cli/studio-page.ts +0 -876
- package/src/cli/studio.ts +0 -126
package/dist/cli/index.js
CHANGED
|
@@ -1,26 +1,34 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
} from "../chunks/index-
|
|
3
|
+
portsOf,
|
|
4
|
+
spell,
|
|
5
|
+
LAYERS,
|
|
6
|
+
deviceId,
|
|
7
|
+
keyOf,
|
|
8
|
+
holdersOf,
|
|
9
|
+
graphOf,
|
|
10
|
+
only,
|
|
11
|
+
addressesOf,
|
|
12
|
+
freeOfLength,
|
|
13
|
+
studioData
|
|
14
|
+
} from "../chunks/index-n4zpd25n.js";
|
|
15
15
|
import {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
16
|
+
familyOf2,
|
|
17
|
+
split,
|
|
18
|
+
isAddress2,
|
|
19
|
+
isCidr2,
|
|
20
|
+
network,
|
|
21
|
+
contains2,
|
|
22
|
+
netmask,
|
|
23
|
+
wildcard,
|
|
24
|
+
host2,
|
|
25
|
+
isGlobal2
|
|
26
|
+
} from "../chunks/index-v8zgr7wm.js";
|
|
19
27
|
|
|
20
28
|
// src/cli/index.ts
|
|
21
29
|
import { createPrivateKey, createPublicKey } from "node:crypto";
|
|
22
|
-
import { existsSync as
|
|
23
|
-
import { basename, dirname, join as
|
|
30
|
+
import { existsSync as existsSync3, mkdirSync, readdirSync as readdirSync2, readFileSync as readFileSync4, rmSync, writeFileSync as writeFileSync2 } from "node:fs";
|
|
31
|
+
import { basename as basename2, dirname as dirname2, join as join4, relative as relative2, resolve as resolve3, sep } from "node:path";
|
|
24
32
|
|
|
25
33
|
// src/core/secrets.ts
|
|
26
34
|
import { spawnSync } from "node:child_process";
|
|
@@ -86,10 +94,10 @@ function valueOf(reference) {
|
|
|
86
94
|
const fullPath = join(root, path);
|
|
87
95
|
if (!existsSync(fullPath))
|
|
88
96
|
throw new Error(`secret file ${path} does not exist`);
|
|
89
|
-
const
|
|
90
|
-
if (!
|
|
97
|
+
const value = readFileSync(fullPath, "utf8").trimEnd();
|
|
98
|
+
if (!value)
|
|
91
99
|
throw new Error(`secret file ${path} is empty`);
|
|
92
|
-
return
|
|
100
|
+
return value;
|
|
93
101
|
}
|
|
94
102
|
const value = process.env[reference] || dotenv()[reference];
|
|
95
103
|
if (!value)
|
|
@@ -119,9 +127,9 @@ function offset(zone) {
|
|
|
119
127
|
// src/transport/ssh.ts
|
|
120
128
|
import { Client } from "ssh2";
|
|
121
129
|
function connect(login) {
|
|
122
|
-
return new Promise((
|
|
130
|
+
return new Promise((resolve, reject) => {
|
|
123
131
|
const client = new Client;
|
|
124
|
-
client.on("ready", () =>
|
|
132
|
+
client.on("ready", () => resolve(client)).on("error", reject).connect({
|
|
125
133
|
host: login.host,
|
|
126
134
|
port: login.port ?? 22,
|
|
127
135
|
username: login.user,
|
|
@@ -132,29 +140,29 @@ function connect(login) {
|
|
|
132
140
|
});
|
|
133
141
|
}
|
|
134
142
|
function exec(client, command, timeout = 120000) {
|
|
135
|
-
return new Promise((
|
|
143
|
+
return new Promise((resolve, reject) => {
|
|
136
144
|
client.exec(command, (error, stream) => {
|
|
137
145
|
if (error)
|
|
138
146
|
return reject(error);
|
|
139
147
|
let output = "";
|
|
140
148
|
const append = (chunk) => output += chunk.toString("utf8");
|
|
141
|
-
const timer = setTimeout(() =>
|
|
149
|
+
const timer = setTimeout(() => resolve(output.trim()), timeout);
|
|
142
150
|
stream.on("data", append);
|
|
143
151
|
stream.stderr.on("data", append);
|
|
144
152
|
stream.on("close", () => {
|
|
145
153
|
clearTimeout(timer);
|
|
146
|
-
|
|
154
|
+
resolve(output.trim());
|
|
147
155
|
});
|
|
148
156
|
});
|
|
149
157
|
});
|
|
150
158
|
}
|
|
151
159
|
function shell(client, columns = 200, rows = 1000) {
|
|
152
|
-
return new Promise((
|
|
153
|
-
client.shell({ cols: columns, rows }, (error, channel) => error ? reject(error) :
|
|
160
|
+
return new Promise((resolve, reject) => {
|
|
161
|
+
client.shell({ cols: columns, rows }, (error, channel) => error ? reject(error) : resolve(channel));
|
|
154
162
|
});
|
|
155
163
|
}
|
|
156
164
|
function readUntil(channel, done, timeout, settle = 400) {
|
|
157
|
-
return new Promise((
|
|
165
|
+
return new Promise((resolve) => {
|
|
158
166
|
let output = "";
|
|
159
167
|
let finished = false;
|
|
160
168
|
const finish = () => {
|
|
@@ -162,7 +170,7 @@ function readUntil(channel, done, timeout, settle = 400) {
|
|
|
162
170
|
return;
|
|
163
171
|
finished = true;
|
|
164
172
|
channel.removeListener("data", onData);
|
|
165
|
-
|
|
173
|
+
resolve(output);
|
|
166
174
|
};
|
|
167
175
|
const onData = (chunk) => {
|
|
168
176
|
output += chunk.toString("utf8");
|
|
@@ -176,12 +184,12 @@ function readUntil(channel, done, timeout, settle = 400) {
|
|
|
176
184
|
function send(channel, line, timeout = 20000, settle = 1500) {
|
|
177
185
|
channel.write(line + `
|
|
178
186
|
`);
|
|
179
|
-
return new Promise((
|
|
187
|
+
return new Promise((resolve) => {
|
|
180
188
|
let output = "";
|
|
181
189
|
let timer;
|
|
182
190
|
const finish = () => {
|
|
183
191
|
channel.removeListener("data", onData);
|
|
184
|
-
|
|
192
|
+
resolve(output);
|
|
185
193
|
};
|
|
186
194
|
const onData = (chunk) => {
|
|
187
195
|
output += chunk.toString("utf8");
|
|
@@ -193,7 +201,7 @@ function send(channel, line, timeout = 20000, settle = 1500) {
|
|
|
193
201
|
});
|
|
194
202
|
}
|
|
195
203
|
function upload(client, path, content) {
|
|
196
|
-
return new Promise((
|
|
204
|
+
return new Promise((resolve, reject) => {
|
|
197
205
|
client.sftp((error, sftp) => {
|
|
198
206
|
if (error)
|
|
199
207
|
return reject(error);
|
|
@@ -202,50 +210,12 @@ function upload(client, path, content) {
|
|
|
202
210
|
if (writeError)
|
|
203
211
|
reject(writeError);
|
|
204
212
|
else
|
|
205
|
-
|
|
213
|
+
resolve();
|
|
206
214
|
});
|
|
207
215
|
});
|
|
208
216
|
});
|
|
209
217
|
}
|
|
210
218
|
|
|
211
|
-
// src/adapters/devices/raisecom-ros/models.ts
|
|
212
|
-
var models = {
|
|
213
|
-
"RAX721-C-6C48": {
|
|
214
|
-
ports: { "100g": 6, "25g": 24, "10g": 24 },
|
|
215
|
-
spelling: { "100g": "hundredgige 1/1/{n}", "25g": "twenty-fivegige 1/2/{n}", "10g": "tengigabitethernet 1/3/{n}" }
|
|
216
|
-
}
|
|
217
|
-
};
|
|
218
|
-
|
|
219
|
-
// src/adapters/devices/routeros/models.ts
|
|
220
|
-
var models2 = {
|
|
221
|
-
"CCR2216-1G-12XS-2XQ": {
|
|
222
|
-
ports: { "100g": 2, "25g": 12, "1g": 1 },
|
|
223
|
-
spelling: { "100g": "qsfp28-{n}-1", "25g": "sfp28-{n}", "1g": "ether{n}" }
|
|
224
|
-
}
|
|
225
|
-
};
|
|
226
|
-
|
|
227
|
-
// src/adapters/devices/vrp/models.ts
|
|
228
|
-
var models3 = {
|
|
229
|
-
"CE6855-48S6Q-HI": { ports: { "10g": 48, "40g": 6 }, spelling: { "10g": "10GE1/0/{n}", "40g": "40GE1/0/{n}" } }
|
|
230
|
-
};
|
|
231
|
-
|
|
232
|
-
// src/adapters/devices/catalog.ts
|
|
233
|
-
var catalog = { routeros: models2, vrp: models3, "raisecom-ros": models };
|
|
234
|
-
function portsOf(platform, model) {
|
|
235
|
-
const spec = catalog[platform]?.[model];
|
|
236
|
-
if (!spec)
|
|
237
|
-
throw new Error(`${platform} has no model ${model}`);
|
|
238
|
-
return Object.entries(spec.ports).flatMap(([speed, count]) => Array.from({ length: count }, (_, i) => `${speed}-${i + 1}`));
|
|
239
|
-
}
|
|
240
|
-
function spell(platform, model, port) {
|
|
241
|
-
const spec = catalog[platform]?.[model];
|
|
242
|
-
const parts = /^(\d+[gm])-(\d+)$/.exec(port);
|
|
243
|
-
const template = parts && spec?.spelling[parts[1]];
|
|
244
|
-
if (!template)
|
|
245
|
-
throw new Error(`${model} has no port ${port}`);
|
|
246
|
-
return template.replace("{n}", parts[2]);
|
|
247
|
-
}
|
|
248
|
-
|
|
249
219
|
// src/adapters/devices/derive.ts
|
|
250
220
|
function lagIds(device) {
|
|
251
221
|
const numbers = new Map;
|
|
@@ -261,7 +231,37 @@ function lagIds(device) {
|
|
|
261
231
|
function ipv4Networks(device, iface) {
|
|
262
232
|
const settings = device.interfaces?.[iface] ?? device.ports?.[iface];
|
|
263
233
|
const addresses = (settings?.addresses ?? []).map((entry) => typeof entry === "string" ? entry : entry.address);
|
|
264
|
-
return addresses.filter((address) =>
|
|
234
|
+
return addresses.filter((address) => familyOf2(address) === "ipv4").map((address) => network(address));
|
|
235
|
+
}
|
|
236
|
+
var SWITCH_SERVICES = ["ssh", "telnet", "snmp"];
|
|
237
|
+
function switchUnsupported(device) {
|
|
238
|
+
const found = [];
|
|
239
|
+
for (const service of SWITCH_SERVICES) {
|
|
240
|
+
const settings = device.management?.[service];
|
|
241
|
+
if (settings?.allow)
|
|
242
|
+
found.push(`management.${service}.allow, every service takes management.allow`);
|
|
243
|
+
if (settings?.port)
|
|
244
|
+
found.push(`management.${service}.port`);
|
|
245
|
+
}
|
|
246
|
+
for (const [name, user] of Object.entries(device.users ?? {})) {
|
|
247
|
+
if (user.description)
|
|
248
|
+
found.push(`users.${name}.description`);
|
|
249
|
+
if (user.ssh_keys?.length)
|
|
250
|
+
found.push(`users.${name}.ssh_keys`);
|
|
251
|
+
}
|
|
252
|
+
if (device.system?.ip)
|
|
253
|
+
found.push("system.ip");
|
|
254
|
+
for (const [name, port] of Object.entries(device.ports ?? {})) {
|
|
255
|
+
if (port?.hardware_offload !== undefined)
|
|
256
|
+
found.push(`ports.${name}.hardware_offload`);
|
|
257
|
+
}
|
|
258
|
+
for (const [name, iface] of Object.entries(device.interfaces ?? {})) {
|
|
259
|
+
if (iface.addresses?.some((entry) => typeof entry !== "string"))
|
|
260
|
+
found.push(`interfaces.${name}.addresses with a peer`);
|
|
261
|
+
if (iface.hardware_offload !== undefined)
|
|
262
|
+
found.push(`interfaces.${name}.hardware_offload`);
|
|
263
|
+
}
|
|
264
|
+
return found;
|
|
265
265
|
}
|
|
266
266
|
|
|
267
267
|
// src/adapters/devices/lines.ts
|
|
@@ -353,7 +353,7 @@ function compare(desired, current, dialect) {
|
|
|
353
353
|
}
|
|
354
354
|
return [...groups.values()].filter((group) => group.add.length || group.remove.length);
|
|
355
355
|
}
|
|
356
|
-
function vlanListChange(groups, prefix,
|
|
356
|
+
function vlanListChange(groups, prefix, parse, format, removal) {
|
|
357
357
|
const top = groups.find((group) => group.path.length === 0);
|
|
358
358
|
if (!top)
|
|
359
359
|
return;
|
|
@@ -361,8 +361,8 @@ function vlanListChange(groups, prefix, parse2, format, removal) {
|
|
|
361
361
|
const fresh = top.add.find((line) => line.startsWith(prefix));
|
|
362
362
|
if (!old && !fresh)
|
|
363
363
|
return;
|
|
364
|
-
const before = old ?
|
|
365
|
-
const after = fresh ?
|
|
364
|
+
const before = old ? parse(old) : [];
|
|
365
|
+
const after = fresh ? parse(fresh) : [];
|
|
366
366
|
top.remove = top.remove.filter((line) => line !== old);
|
|
367
367
|
top.add = top.add.filter((line) => line !== fresh);
|
|
368
368
|
const added = after.filter((id) => !before.includes(id));
|
|
@@ -500,7 +500,7 @@ function render(device) {
|
|
|
500
500
|
}
|
|
501
501
|
if (allow.length) {
|
|
502
502
|
lines.push(`access-list ${MANAGEMENT_ACL}`, " description management");
|
|
503
|
-
allow.forEach((cidr, index) => lines.push(` rule ${(index + 1) * 10} permit ${
|
|
503
|
+
allow.forEach((cidr, index) => lines.push(` rule ${(index + 1) * 10} permit ${host2(cidr)} ${netmask(split(cidr).len)}`));
|
|
504
504
|
lines.push(" rule 100 deny any", "!");
|
|
505
505
|
}
|
|
506
506
|
for (const [name, user] of Object.entries(device.users ?? {}))
|
|
@@ -525,8 +525,8 @@ function render(device) {
|
|
|
525
525
|
lines.push(`interface vlan ${vlanId(iface.vlan)}`);
|
|
526
526
|
for (const entry of iface.addresses ?? []) {
|
|
527
527
|
const address = typeof entry === "string" ? entry : entry.address;
|
|
528
|
-
if (
|
|
529
|
-
lines.push(`ip address ${
|
|
528
|
+
if (familyOf2(address) === "ipv4")
|
|
529
|
+
lines.push(`ip address ${host2(address)} ${netmask(split(address).len)}`);
|
|
530
530
|
}
|
|
531
531
|
const ospfSettings = Object.values(ospf?.areas ?? {}).find((area) => area.interfaces[name])?.interfaces[name];
|
|
532
532
|
if (ospfSettings?.network === "point-to-point")
|
|
@@ -545,7 +545,7 @@ function render(device) {
|
|
|
545
545
|
for (const [areaId, area] of Object.entries(ospf.areas)) {
|
|
546
546
|
for (const iface of Object.keys(area.interfaces)) {
|
|
547
547
|
for (const prefix of ipv4Networks(device, iface)) {
|
|
548
|
-
lines.push(`network ${
|
|
548
|
+
lines.push(`network ${host2(prefix)} ${wildcard(split(prefix).len)} area ${areaId}`);
|
|
549
549
|
}
|
|
550
550
|
}
|
|
551
551
|
}
|
|
@@ -580,7 +580,7 @@ function render(device) {
|
|
|
580
580
|
lines.push(`snmp-server access-list ${MANAGEMENT_ACL}`);
|
|
581
581
|
for (const route of device.routing?.static ?? []) {
|
|
582
582
|
const { addr, len } = split(route.prefix);
|
|
583
|
-
if (
|
|
583
|
+
if (familyOf2(addr) === "ipv4" && route.via)
|
|
584
584
|
lines.push(`ip route ${addr} ${netmask(len)} ${route.via}`);
|
|
585
585
|
}
|
|
586
586
|
if (management.telnet) {
|
|
@@ -716,10 +716,17 @@ function unsupported(device) {
|
|
|
716
716
|
found.push(`interfaces.${name}, a ${iface.type}`);
|
|
717
717
|
continue;
|
|
718
718
|
}
|
|
719
|
-
|
|
720
|
-
|
|
719
|
+
for (const field of ["vrf", "description", "mtu", "ipv6_ra"]) {
|
|
720
|
+
if (iface[field] !== undefined)
|
|
721
|
+
found.push(`interfaces.${name}.${field}`);
|
|
722
|
+
}
|
|
723
|
+
for (const entry of iface.addresses ?? []) {
|
|
724
|
+
const address = typeof entry === "string" ? entry : entry.address;
|
|
725
|
+
if (familyOf2(address) === "ipv6")
|
|
726
|
+
found.push(`interfaces.${name}.addresses ${address}, only IPv4 here`);
|
|
727
|
+
}
|
|
721
728
|
for (const group of iface.vrrp ?? []) {
|
|
722
|
-
if (
|
|
729
|
+
if (familyOf2(group.address) === "ipv6")
|
|
723
730
|
found.push(`interfaces.${name}.vrrp for IPv6`);
|
|
724
731
|
}
|
|
725
732
|
}
|
|
@@ -740,8 +747,15 @@ function unsupported(device) {
|
|
|
740
747
|
}
|
|
741
748
|
}
|
|
742
749
|
for (const route of routing?.static ?? []) {
|
|
743
|
-
if (!route.via || route.vrf)
|
|
750
|
+
if (!route.via || route.vrf || familyOf2(route.prefix) === "ipv6") {
|
|
744
751
|
found.push(`routing.static ${route.prefix}, only an IPv4 next hop in the default table`);
|
|
752
|
+
}
|
|
753
|
+
if (route.description || route.distance)
|
|
754
|
+
found.push(`routing.static ${route.prefix} description and distance`);
|
|
755
|
+
}
|
|
756
|
+
for (const [name, vlan] of Object.entries(device.vlans ?? {})) {
|
|
757
|
+
if (vlan.description)
|
|
758
|
+
found.push(`vlans.${name}.description`);
|
|
745
759
|
}
|
|
746
760
|
const UNSUPPORTED_SECTIONS = [
|
|
747
761
|
"firewall",
|
|
@@ -768,6 +782,10 @@ function unsupported(device) {
|
|
|
768
782
|
if (!SNMP_AUTH[user.auth])
|
|
769
783
|
found.push(`management.snmp.users.${name}.auth ${user.auth}, the platform authenticates with SHA1 or MD5`);
|
|
770
784
|
}
|
|
785
|
+
if (management.snmp?.contact || management.snmp?.location)
|
|
786
|
+
found.push("management.snmp.contact and location");
|
|
787
|
+
if (management.ssh?.weak_crypto)
|
|
788
|
+
found.push("management.ssh.weak_crypto");
|
|
771
789
|
if (device.lldp && device.lldp !== true)
|
|
772
790
|
found.push("lldp.interfaces, LLDP is per device");
|
|
773
791
|
if (device.stp?.mode && device.stp.mode !== "mstp")
|
|
@@ -780,6 +798,7 @@ function unsupported(device) {
|
|
|
780
798
|
if (system.dns || system.logging || system.release_channel || system.banner || system.ntp?.serve) {
|
|
781
799
|
found.push("system.dns, logging, release_channel, banner and ntp.serve");
|
|
782
800
|
}
|
|
801
|
+
found.push(...switchUnsupported(device));
|
|
783
802
|
return found;
|
|
784
803
|
}
|
|
785
804
|
var REFUSED = /^(%|Error|Incomplete|Unrecognized)|can not|cannot |is in use|Wrong parameter|invalid|unsuccessful/im;
|
|
@@ -961,7 +980,7 @@ function parseLine(menu, line) {
|
|
|
961
980
|
}
|
|
962
981
|
return { menu, verb, selector, flags, attrs };
|
|
963
982
|
}
|
|
964
|
-
function
|
|
983
|
+
function parse(text) {
|
|
965
984
|
const commands = [];
|
|
966
985
|
const generated = new Set;
|
|
967
986
|
let menu = "";
|
|
@@ -972,9 +991,9 @@ function parse2(text) {
|
|
|
972
991
|
menu = line;
|
|
973
992
|
continue;
|
|
974
993
|
}
|
|
975
|
-
const
|
|
976
|
-
if (
|
|
977
|
-
generated.add(
|
|
994
|
+
const marker = /^# generated (\S+)$/.exec(line);
|
|
995
|
+
if (marker)
|
|
996
|
+
generated.add(marker[1]);
|
|
978
997
|
if (menu && /^(add|set)( |$)/.test(line))
|
|
979
998
|
commands.push(parseLine(menu, line));
|
|
980
999
|
}
|
|
@@ -1064,12 +1083,12 @@ function normalValue(command, property, value) {
|
|
|
1064
1083
|
if (property === "tagged" || property === "untagged" || property === "slaves")
|
|
1065
1084
|
return value.split(",").sort().join(",");
|
|
1066
1085
|
const onInterface = property === "address" && (command.menu === "/ip address" || command.menu === "/ipv6 address");
|
|
1067
|
-
const
|
|
1086
|
+
const isAddress = /(^|-)address$/.test(property) && !property.endsWith("mac-address");
|
|
1068
1087
|
if (onInterface && !value.includes("/"))
|
|
1069
1088
|
return `${value}/${value.includes(":") ? 64 : 32}`;
|
|
1070
|
-
if (!onInterface &&
|
|
1089
|
+
if (!onInterface && isAddress && /^[\d.]+\/32$/.test(value))
|
|
1071
1090
|
return value.slice(0, -3);
|
|
1072
|
-
if (!onInterface &&
|
|
1091
|
+
if (!onInterface && isAddress && value.includes(":") && !value.includes("/") && !value.includes(","))
|
|
1073
1092
|
return `${value}/128`;
|
|
1074
1093
|
return value;
|
|
1075
1094
|
}
|
|
@@ -1398,13 +1417,13 @@ function planOrdered(menu, wanted, existing) {
|
|
|
1398
1417
|
const inKeptChain = `[:typeof [:find $keep [${menu} get $id chain]]] != "nil"`;
|
|
1399
1418
|
removeOld = `:local keep {${keep}}; :foreach id in=$${OLD_RULES} do={ :if (${inKeptChain}) do={ ${menu} remove $id } }`;
|
|
1400
1419
|
}
|
|
1401
|
-
const
|
|
1420
|
+
const send = [
|
|
1402
1421
|
`:global ${OLD_RULES} [${menu} find${where}]`,
|
|
1403
1422
|
...wanted.map((item) => line(menu, "add", "", item.flags, item.attrs)),
|
|
1404
1423
|
`:global ${OLD_RULES}; ${removeOld}; /system script environment remove [find name="${OLD_RULES}"]`
|
|
1405
1424
|
];
|
|
1406
1425
|
const show = [...existing.map((item) => `- ${canonical(item)}`), ...wanted.map((item) => `+ ${canonical(item)}`)];
|
|
1407
|
-
result.first.push({ title: `${menu}, in the order of the config`, show, send
|
|
1426
|
+
result.first.push({ title: `${menu}, in the order of the config`, show, send });
|
|
1408
1427
|
if (hasChains) {
|
|
1409
1428
|
for (const chain of droppedChains) {
|
|
1410
1429
|
const selector = `[ find chain=${quote(chain)}${DYNAMIC.has(menu) ? " and !dynamic" : ""} ]`;
|
|
@@ -1414,8 +1433,8 @@ function planOrdered(menu, wanted, existing) {
|
|
|
1414
1433
|
return result;
|
|
1415
1434
|
}
|
|
1416
1435
|
function plan2(desired, current, options) {
|
|
1417
|
-
const want =
|
|
1418
|
-
const have =
|
|
1436
|
+
const want = parse(desired);
|
|
1437
|
+
const have = parse(current);
|
|
1419
1438
|
const owned = (command) => !(command.menu.endsWith("address-list") && want.generated.has(command.attrs.list ?? ""));
|
|
1420
1439
|
const wantedByMenu = byMenu(want.commands.filter(owned));
|
|
1421
1440
|
const existingByMenu = byMenu(have.commands.filter(owned));
|
|
@@ -1460,22 +1479,22 @@ class Output {
|
|
|
1460
1479
|
`;
|
|
1461
1480
|
}
|
|
1462
1481
|
}
|
|
1463
|
-
function quote2(
|
|
1464
|
-
const plain = /^[\w.:/@-]+$/.test(
|
|
1482
|
+
function quote2(value) {
|
|
1483
|
+
const plain = /^[\w.:/@-]+$/.test(value);
|
|
1465
1484
|
if (plain)
|
|
1466
|
-
return
|
|
1467
|
-
const escaped =
|
|
1485
|
+
return value;
|
|
1486
|
+
const escaped = value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/\$/g, "\\$");
|
|
1468
1487
|
return `"${escaped}"`;
|
|
1469
1488
|
}
|
|
1470
1489
|
function command(verb, properties) {
|
|
1471
|
-
const
|
|
1472
|
-
for (const [name,
|
|
1473
|
-
if (
|
|
1490
|
+
const words = [verb];
|
|
1491
|
+
for (const [name, value] of Object.entries(properties)) {
|
|
1492
|
+
if (value === undefined || value === false)
|
|
1474
1493
|
continue;
|
|
1475
|
-
const written = typeof
|
|
1476
|
-
|
|
1494
|
+
const written = typeof value === "number" ? String(value) : quote2(value);
|
|
1495
|
+
words.push(`${name}=${written}`);
|
|
1477
1496
|
}
|
|
1478
|
-
return
|
|
1497
|
+
return words.join(" ");
|
|
1479
1498
|
}
|
|
1480
1499
|
var yesNo = (on) => on ? "yes" : "no";
|
|
1481
1500
|
var familySuffix = (family) => family === "ipv4" ? "V4" : "V6";
|
|
@@ -1738,7 +1757,7 @@ function splitEndpoint(endpoint) {
|
|
|
1738
1757
|
function vrrp(out, ctx) {
|
|
1739
1758
|
for (const { name, settings } of ctx.routed()) {
|
|
1740
1759
|
for (const group of settings.vrrp ?? []) {
|
|
1741
|
-
const ipv6 =
|
|
1760
|
+
const ipv6 = familyOf2(group.address) === "ipv6";
|
|
1742
1761
|
out.add("/interface vrrp", command("add", {
|
|
1743
1762
|
interface: name,
|
|
1744
1763
|
interval: group.interval ? duration(group.interval) : undefined,
|
|
@@ -1864,7 +1883,7 @@ function addresses(out, ctx) {
|
|
|
1864
1883
|
for (const entry of settings.addresses ?? []) {
|
|
1865
1884
|
const address = typeof entry === "string" ? entry : entry.address;
|
|
1866
1885
|
const peer = typeof entry === "string" ? undefined : entry.peer;
|
|
1867
|
-
if (
|
|
1886
|
+
if (familyOf2(address) === "ipv4") {
|
|
1868
1887
|
out.add("/ip address", command("add", { address, comment: settings.description, interface: name, network: peer }));
|
|
1869
1888
|
} else {
|
|
1870
1889
|
out.add("/ipv6 address", command("add", { address, advertise: yesNo(settings.ipv6_ra), comment: settings.description, interface: name }));
|
|
@@ -1873,7 +1892,7 @@ function addresses(out, ctx) {
|
|
|
1873
1892
|
for (const group of settings.vrrp ?? []) {
|
|
1874
1893
|
const vrrpInterface = vrrpName(name, group.id);
|
|
1875
1894
|
const comment = `VRRP ${group.id} on ${name}`;
|
|
1876
|
-
if (
|
|
1895
|
+
if (familyOf2(group.address) === "ipv4") {
|
|
1877
1896
|
out.add("/ip address", command("add", { address: `${group.address}/32`, comment, interface: vrrpInterface }));
|
|
1878
1897
|
} else {
|
|
1879
1898
|
out.add("/ipv6 address", command("add", {
|
|
@@ -1915,30 +1934,30 @@ function dhcp(out, ctx) {
|
|
|
1915
1934
|
}
|
|
1916
1935
|
}
|
|
1917
1936
|
function dns(out, ctx) {
|
|
1918
|
-
const
|
|
1919
|
-
out.add("/ip dns", command("set", { "allow-remote-requests": yesNo(
|
|
1937
|
+
const dns = ctx.device.system?.dns;
|
|
1938
|
+
out.add("/ip dns", command("set", { "allow-remote-requests": yesNo(dns?.serve), servers: dns?.servers.join(",") ?? "" }));
|
|
1920
1939
|
}
|
|
1921
1940
|
function addressLists(out, ctx) {
|
|
1922
1941
|
const { device } = ctx;
|
|
1923
|
-
const
|
|
1942
|
+
const entries = [];
|
|
1924
1943
|
for (const [list, members] of Object.entries(device.firewall?.address_sets ?? {})) {
|
|
1925
1944
|
for (const address of members)
|
|
1926
|
-
|
|
1945
|
+
entries.push({ list, address });
|
|
1927
1946
|
}
|
|
1928
1947
|
for (const [list, set] of Object.entries(device.prefix_sets ?? {})) {
|
|
1929
1948
|
if ("prefixes" in set)
|
|
1930
1949
|
for (const address of set.prefixes)
|
|
1931
|
-
|
|
1950
|
+
entries.push({ list, address });
|
|
1932
1951
|
else
|
|
1933
1952
|
out.add("/ip firewall address-list", `# generated ${list}`);
|
|
1934
1953
|
}
|
|
1935
1954
|
for (const prefix of device.routing?.bgp?.networks ?? []) {
|
|
1936
|
-
|
|
1955
|
+
entries.push({ list: NETWORKS[familyOf2(host2(prefix))], address: prefix });
|
|
1937
1956
|
}
|
|
1938
1957
|
for (const family of ["ipv4", "ipv6"]) {
|
|
1939
1958
|
const menu = family === "ipv4" ? "/ip firewall address-list" : "/ipv6 firewall address-list";
|
|
1940
|
-
for (const { list, address } of
|
|
1941
|
-
if (
|
|
1959
|
+
for (const { list, address } of entries) {
|
|
1960
|
+
if (familyOf2(host2(address)) !== family)
|
|
1942
1961
|
continue;
|
|
1943
1962
|
out.add(menu, command("add", { address, list }));
|
|
1944
1963
|
}
|
|
@@ -1958,7 +1977,7 @@ function renderFirewall(out, ctx) {
|
|
|
1958
1977
|
function untracked(out, ctx, family) {
|
|
1959
1978
|
const menu = family === "ipv4" ? "/ip firewall raw" : "/ipv6 firewall raw";
|
|
1960
1979
|
for (const prefix of ctx.device.firewall?.untracked ?? []) {
|
|
1961
|
-
if (
|
|
1980
|
+
if (familyOf2(prefix) !== family)
|
|
1962
1981
|
continue;
|
|
1963
1982
|
for (const [direction, property] of [
|
|
1964
1983
|
["to", "dst-address"],
|
|
@@ -1975,8 +1994,8 @@ function families(ctx, match) {
|
|
|
1975
1994
|
if (match?.family)
|
|
1976
1995
|
candidates = [match.family];
|
|
1977
1996
|
else if (address)
|
|
1978
|
-
candidates = [
|
|
1979
|
-
const setHas = (set, family) => !set || (sets[set] ?? []).some((member) =>
|
|
1997
|
+
candidates = [familyOf2(address)];
|
|
1998
|
+
const setHas = (set, family) => !set || (sets[set] ?? []).some((member) => familyOf2(member) === family);
|
|
1980
1999
|
return candidates.filter((family) => setHas(match?.src_set, family) && setHas(match?.dst_set, family));
|
|
1981
2000
|
}
|
|
1982
2001
|
function matchProperties(ctx, match, family) {
|
|
@@ -2027,7 +2046,7 @@ function nat(out, ctx, family) {
|
|
|
2027
2046
|
for (const rule of rules ?? []) {
|
|
2028
2047
|
if (!families(ctx, rule.match).includes(family))
|
|
2029
2048
|
continue;
|
|
2030
|
-
if (rule.action !== "masquerade" &&
|
|
2049
|
+
if (rule.action !== "masquerade" && familyOf2(rule.to) !== family)
|
|
2031
2050
|
continue;
|
|
2032
2051
|
const action = rule.action === "masquerade" ? {
|
|
2033
2052
|
action: "masquerade"
|
|
@@ -2049,7 +2068,7 @@ function switchAcls(out, ctx) {
|
|
|
2049
2068
|
continue;
|
|
2050
2069
|
for (const rule of ctx.device.acls?.[settings.acl] ?? []) {
|
|
2051
2070
|
const match = rule.match ?? {};
|
|
2052
|
-
const ipv6 = match.family === "ipv6" || [match.src, match.dst].some((
|
|
2071
|
+
const ipv6 = match.family === "ipv6" || [match.src, match.dst].some((address) => address && familyOf2(address) === "ipv6");
|
|
2053
2072
|
const address = (prefix) => prefix && !ipv6 ? dottedMask(prefix) : prefix;
|
|
2054
2073
|
for (const srcPort of each(match.src_port)) {
|
|
2055
2074
|
for (const dstPort of each(match.dst_port)) {
|
|
@@ -2094,7 +2113,7 @@ function renderRouting(out, ctx) {
|
|
|
2094
2113
|
}
|
|
2095
2114
|
function staticRoutes(out, ctx) {
|
|
2096
2115
|
for (const route of ctx.device.routing?.static ?? []) {
|
|
2097
|
-
const menu =
|
|
2116
|
+
const menu = familyOf2(host2(route.prefix)) === "ipv4" ? "/ip route" : "/ipv6 route";
|
|
2098
2117
|
const verb = route.blackhole ? "add blackhole" : "add";
|
|
2099
2118
|
const gateway = route.via ?? (route.interface ? ctx.name(route.interface) : undefined);
|
|
2100
2119
|
out.add(menu, command(verb, {
|
|
@@ -2125,21 +2144,21 @@ function settingsOf(ctx, neighbor) {
|
|
|
2125
2144
|
return { ...group, ...neighbor };
|
|
2126
2145
|
}
|
|
2127
2146
|
function familiesOf(ctx, neighbor) {
|
|
2128
|
-
return settingsOf(ctx, neighbor).families ?? [
|
|
2147
|
+
return settingsOf(ctx, neighbor).families ?? [familyOf2(neighbor.address)];
|
|
2129
2148
|
}
|
|
2130
2149
|
var chainName = (policy, family) => `${policy}-${familySuffix(family)}`;
|
|
2131
2150
|
function appliesTo(ctx, rule, family) {
|
|
2132
2151
|
const match = rule.match;
|
|
2133
2152
|
if (match?.family && match.family !== family)
|
|
2134
2153
|
return false;
|
|
2135
|
-
if (match?.prefix &&
|
|
2154
|
+
if (match?.prefix && familyOf2(host2(match.prefix)) !== family)
|
|
2136
2155
|
return false;
|
|
2137
2156
|
if (match?.prefix_set) {
|
|
2138
2157
|
const set = ctx.device.prefix_sets?.[match.prefix_set];
|
|
2139
2158
|
if (!set)
|
|
2140
2159
|
return false;
|
|
2141
2160
|
const firstPrefix = "source" in set ? undefined : set.prefixes[0];
|
|
2142
|
-
const setFamily = "source" in set ? set.source.family : firstPrefix &&
|
|
2161
|
+
const setFamily = "source" in set ? set.source.family : firstPrefix && familyOf2(host2(firstPrefix));
|
|
2143
2162
|
if (setFamily !== family)
|
|
2144
2163
|
return false;
|
|
2145
2164
|
}
|
|
@@ -2170,35 +2189,35 @@ function conditions(rule) {
|
|
|
2170
2189
|
}
|
|
2171
2190
|
function actions(rule, family) {
|
|
2172
2191
|
const set = rule.set ?? {};
|
|
2173
|
-
const
|
|
2192
|
+
const steps = [];
|
|
2174
2193
|
if (set.local_pref !== undefined)
|
|
2175
|
-
|
|
2194
|
+
steps.push(`set bgp-local-pref ${set.local_pref}`);
|
|
2176
2195
|
if (set.med !== undefined)
|
|
2177
|
-
|
|
2196
|
+
steps.push(`set bgp-med ${set.med}`);
|
|
2178
2197
|
if (set.prepend !== undefined)
|
|
2179
|
-
|
|
2198
|
+
steps.push(`set bgp-path-prepend ${set.prepend}`);
|
|
2180
2199
|
for (const pattern of set.remove_communities ?? [])
|
|
2181
|
-
|
|
2200
|
+
steps.push(`delete bgp-communities regexp "${communityPattern(pattern)}"`);
|
|
2182
2201
|
if (set.communities)
|
|
2183
|
-
|
|
2202
|
+
steps.push(`set bgp-communities ${set.communities.join(",")}`);
|
|
2184
2203
|
if (set.add_communities)
|
|
2185
|
-
|
|
2204
|
+
steps.push(`append bgp-communities ${set.add_communities.join(",")}`);
|
|
2186
2205
|
for (const pattern of set.remove_large_communities ?? []) {
|
|
2187
|
-
|
|
2206
|
+
steps.push(`delete bgp-large-communities regexp "${communityPattern(pattern)}"`);
|
|
2188
2207
|
}
|
|
2189
2208
|
if (set.large_communities)
|
|
2190
|
-
|
|
2209
|
+
steps.push(`set bgp-large-communities ${set.large_communities.join(",")}`);
|
|
2191
2210
|
if (set.add_large_communities)
|
|
2192
|
-
|
|
2193
|
-
if (set.next_hop &&
|
|
2194
|
-
|
|
2195
|
-
if (set.preferred_source &&
|
|
2196
|
-
|
|
2211
|
+
steps.push(`append bgp-large-communities ${set.add_large_communities.join(",")}`);
|
|
2212
|
+
if (set.next_hop && familyOf2(set.next_hop) === family)
|
|
2213
|
+
steps.push(`set gw ${set.next_hop}`);
|
|
2214
|
+
if (set.preferred_source && familyOf2(set.preferred_source) === family)
|
|
2215
|
+
steps.push(`set pref-src ${set.preferred_source}`);
|
|
2197
2216
|
if (set.blackhole)
|
|
2198
|
-
|
|
2217
|
+
steps.push("set blackhole yes");
|
|
2199
2218
|
if (rule.action)
|
|
2200
|
-
|
|
2201
|
-
return
|
|
2219
|
+
steps.push(rule.action);
|
|
2220
|
+
return steps;
|
|
2202
2221
|
}
|
|
2203
2222
|
function ruleText(rule, family) {
|
|
2204
2223
|
const parts = [];
|
|
@@ -2207,24 +2226,24 @@ function ruleText(rule, family) {
|
|
|
2207
2226
|
if (rule.match?.rpki)
|
|
2208
2227
|
parts.push(`rpki-verify ${RPKI_GROUP}`);
|
|
2209
2228
|
const tests = conditions(rule);
|
|
2210
|
-
const
|
|
2229
|
+
const steps = actions(rule, family);
|
|
2211
2230
|
if (tests.length)
|
|
2212
|
-
parts.push(`if (${tests.join(" && ")})${
|
|
2231
|
+
parts.push(`if (${tests.join(" && ")})${steps.length ? ` { ${steps.join("; ")} }` : ""}`);
|
|
2213
2232
|
else
|
|
2214
|
-
parts.push(...
|
|
2233
|
+
parts.push(...steps);
|
|
2215
2234
|
return parts.join("; ");
|
|
2216
2235
|
}
|
|
2217
2236
|
function policies(out, ctx) {
|
|
2218
2237
|
const bgp = ctx.device.routing.bgp;
|
|
2219
|
-
const
|
|
2238
|
+
const policies = ctx.device.policies ?? {};
|
|
2220
2239
|
const wanted = new Map;
|
|
2221
2240
|
const want = (policy, family) => {
|
|
2222
|
-
const
|
|
2223
|
-
if (
|
|
2241
|
+
const families = wanted.get(policy) ?? new Set;
|
|
2242
|
+
if (families.has(family))
|
|
2224
2243
|
return;
|
|
2225
|
-
|
|
2226
|
-
wanted.set(policy,
|
|
2227
|
-
for (const rule of
|
|
2244
|
+
families.add(family);
|
|
2245
|
+
wanted.set(policy, families);
|
|
2246
|
+
for (const rule of policies[policy] ?? [])
|
|
2228
2247
|
if (rule.call && appliesTo(ctx, rule, family))
|
|
2229
2248
|
want(rule.call, family);
|
|
2230
2249
|
};
|
|
@@ -2240,13 +2259,13 @@ function policies(out, ctx) {
|
|
|
2240
2259
|
}
|
|
2241
2260
|
}
|
|
2242
2261
|
}
|
|
2243
|
-
const called = new Set(Object.values(
|
|
2262
|
+
const called = new Set(Object.values(policies).flatMap((rules) => rules.flatMap((rule) => rule.call ? [rule.call] : [])));
|
|
2244
2263
|
for (const policy of [...wanted.keys()].sort()) {
|
|
2245
2264
|
for (const family of ["ipv4", "ipv6"]) {
|
|
2246
2265
|
if (!wanted.get(policy).has(family))
|
|
2247
2266
|
continue;
|
|
2248
2267
|
const chain = chainName(policy, family);
|
|
2249
|
-
const rules = (
|
|
2268
|
+
const rules = (policies[policy] ?? []).filter((rule) => appliesTo(ctx, rule, family));
|
|
2250
2269
|
for (const rule of rules) {
|
|
2251
2270
|
const text = ruleText(rule, family);
|
|
2252
2271
|
if (text)
|
|
@@ -2271,9 +2290,9 @@ function bgpConnections(out, ctx) {
|
|
|
2271
2290
|
for (const [name, neighbor] of Object.entries(bgp.neighbors ?? {})) {
|
|
2272
2291
|
const settings = settingsOf(ctx, neighbor);
|
|
2273
2292
|
const [family] = familiesOf(ctx, neighbor);
|
|
2274
|
-
const addressFamily =
|
|
2293
|
+
const addressFamily = familyOf2(neighbor.address);
|
|
2275
2294
|
const internal = settings.remote_as === bgp.asn;
|
|
2276
|
-
const originates = bgp.networks?.some((prefix) =>
|
|
2295
|
+
const originates = bgp.networks?.some((prefix) => familyOf2(host2(prefix)) === family);
|
|
2277
2296
|
const role = settings.role ? ROLES[settings.role] : internal ? "ibgp" : "ebgp";
|
|
2278
2297
|
out.add("/routing bgp connection", command("add", {
|
|
2279
2298
|
afi: addressFamily === family ? undefined : family === "ipv6" ? "ipv6" : "ip",
|
|
@@ -2299,19 +2318,19 @@ function bgpConnections(out, ctx) {
|
|
|
2299
2318
|
}
|
|
2300
2319
|
}
|
|
2301
2320
|
function ospf(out, ctx) {
|
|
2302
|
-
const
|
|
2303
|
-
if (!
|
|
2321
|
+
const ospf = ctx.device.routing?.ospf;
|
|
2322
|
+
if (!ospf)
|
|
2304
2323
|
return;
|
|
2305
|
-
for (const family of
|
|
2324
|
+
for (const family of ospf.families ?? ["ipv4"]) {
|
|
2306
2325
|
const version = family === "ipv4" ? 2 : 3;
|
|
2307
2326
|
const instance = `ospf-v${version}`;
|
|
2308
2327
|
out.add("/routing ospf instance", command("add", {
|
|
2309
2328
|
name: instance,
|
|
2310
|
-
redistribute:
|
|
2329
|
+
redistribute: ospf.redistribute?.join(","),
|
|
2311
2330
|
"router-id": ctx.device.routing?.router_id,
|
|
2312
2331
|
version
|
|
2313
2332
|
}));
|
|
2314
|
-
for (const [areaId, area] of Object.entries(
|
|
2333
|
+
for (const [areaId, area] of Object.entries(ospf.areas)) {
|
|
2315
2334
|
const areaName = `${instance}-${areaId}`;
|
|
2316
2335
|
out.add("/routing ospf area", command("add", { "area-id": areaId, instance, name: areaName }));
|
|
2317
2336
|
for (const [iface, settings] of Object.entries(area.interfaces)) {
|
|
@@ -2388,19 +2407,19 @@ function layerTwo(out, ctx) {
|
|
|
2388
2407
|
}
|
|
2389
2408
|
function snmp(out, ctx) {
|
|
2390
2409
|
const management = ctx.device.management ?? {};
|
|
2391
|
-
const
|
|
2392
|
-
if (!
|
|
2410
|
+
const snmp = management.snmp;
|
|
2411
|
+
if (!snmp) {
|
|
2393
2412
|
out.add("/snmp", command("set", { enabled: "no" }));
|
|
2394
2413
|
out.add("/snmp community", command("set [ find default=yes ]", { addresses: "::/0", name: "public" }));
|
|
2395
2414
|
return;
|
|
2396
2415
|
}
|
|
2397
|
-
const allow = (
|
|
2398
|
-
out.add("/snmp", command("set", { contact:
|
|
2399
|
-
if (
|
|
2400
|
-
out.add("/snmp community", command("set [ find default=yes ]", { addresses: allow, name: marker(
|
|
2416
|
+
const allow = (snmp.allow ?? management.allow ?? []).join(",");
|
|
2417
|
+
out.add("/snmp", command("set", { contact: snmp.contact ?? "", enabled: "yes", location: snmp.location ?? "" }));
|
|
2418
|
+
if (snmp.community)
|
|
2419
|
+
out.add("/snmp community", command("set [ find default=yes ]", { addresses: allow, name: marker(snmp.community) }));
|
|
2401
2420
|
else
|
|
2402
2421
|
out.add("/snmp community", command("set [ find default=yes ]", { addresses: "::/0", disabled: "yes", name: "public" }));
|
|
2403
|
-
for (const [name, user] of Object.entries(
|
|
2422
|
+
for (const [name, user] of Object.entries(snmp.users ?? {})) {
|
|
2404
2423
|
out.add("/snmp community", command("add", {
|
|
2405
2424
|
addresses: allow,
|
|
2406
2425
|
"authentication-password": marker(user.auth_password),
|
|
@@ -2467,15 +2486,15 @@ function renderSystem(out, ctx) {
|
|
|
2467
2486
|
flowExport(out, ctx);
|
|
2468
2487
|
}
|
|
2469
2488
|
function logging(out, ctx) {
|
|
2470
|
-
const
|
|
2471
|
-
const local =
|
|
2489
|
+
const logging = ctx.device.system?.logging;
|
|
2490
|
+
const local = logging?.local;
|
|
2472
2491
|
out.add("/system logging action", command("set 1", {
|
|
2473
2492
|
"disk-file-count": local?.files ?? 2,
|
|
2474
2493
|
"disk-lines-per-file": local?.lines_per_file ?? 1000
|
|
2475
2494
|
}));
|
|
2476
2495
|
for (const topic of local ? topicsFrom(local.level) : [])
|
|
2477
2496
|
out.add("/system logging", command("add", { action: "disk", topics: topic }));
|
|
2478
|
-
(
|
|
2497
|
+
(logging?.remote ?? []).forEach((collector, index) => {
|
|
2479
2498
|
const action = `remote${index + 1}`;
|
|
2480
2499
|
out.add("/system logging action", command("add", {
|
|
2481
2500
|
name: action,
|
|
@@ -2553,17 +2572,17 @@ async function read2(device) {
|
|
|
2553
2572
|
try {
|
|
2554
2573
|
await checkVersion(client, device);
|
|
2555
2574
|
const config = await exec(client, "/export", 300000);
|
|
2556
|
-
const
|
|
2557
|
-
const
|
|
2558
|
-
const listed =
|
|
2559
|
-
`).every((
|
|
2560
|
-
if (
|
|
2561
|
-
throw new Error(`${device.name}: certificates could not be listed: ${
|
|
2575
|
+
const users = await exec(client, "/user export");
|
|
2576
|
+
const certificates = (await exec(client, LIST_CERTIFICATES)).trim();
|
|
2577
|
+
const listed = certificates.split(`
|
|
2578
|
+
`).every((line) => line.trim().startsWith("add fingerprint="));
|
|
2579
|
+
if (certificates && !listed) {
|
|
2580
|
+
throw new Error(`${device.name}: certificates could not be listed: ${certificates.slice(0, 120)}`);
|
|
2562
2581
|
}
|
|
2563
|
-
const userMenu =
|
|
2582
|
+
const userMenu = users.slice(users.indexOf(`
|
|
2564
2583
|
/`)).trim();
|
|
2565
|
-
const certificateMenu =
|
|
2566
|
-
${
|
|
2584
|
+
const certificateMenu = certificates ? `/certificate
|
|
2585
|
+
${certificates}` : "";
|
|
2567
2586
|
const text = [config.trim(), userMenu, certificateMenu].filter(Boolean).join(`
|
|
2568
2587
|
`);
|
|
2569
2588
|
return withoutTimestamp(text) + `
|
|
@@ -2588,11 +2607,19 @@ function unsupported2(device) {
|
|
|
2588
2607
|
found.push(`ports.${name}.storm_control with a different percent per kind, the platform takes one`);
|
|
2589
2608
|
}
|
|
2590
2609
|
}
|
|
2610
|
+
for (const [name, iface] of Object.entries(device.interfaces ?? {})) {
|
|
2611
|
+
if (iface.type !== "lag")
|
|
2612
|
+
continue;
|
|
2613
|
+
for (const field of ["storm_control", "stp"]) {
|
|
2614
|
+
if (iface[field] !== undefined)
|
|
2615
|
+
found.push(`interfaces.${name}.${field}, on a LAG`);
|
|
2616
|
+
}
|
|
2617
|
+
}
|
|
2591
2618
|
const bgp = device.routing?.bgp;
|
|
2592
2619
|
for (const [name, neighbor] of Object.entries(bgp?.neighbors ?? {})) {
|
|
2593
2620
|
const group = neighbor.group ? bgp?.groups?.[neighbor.group] : undefined;
|
|
2594
|
-
const
|
|
2595
|
-
if (
|
|
2621
|
+
const families = neighbor.families ?? group?.families ?? [familyOf2(neighbor.address)];
|
|
2622
|
+
if (families.length > 1)
|
|
2596
2623
|
found.push(`routing.bgp.neighbors.${name}.families, one family per session`);
|
|
2597
2624
|
}
|
|
2598
2625
|
const management = device.management;
|
|
@@ -2600,6 +2627,8 @@ function unsupported2(device) {
|
|
|
2600
2627
|
found.push("management.console, the console uses the device's users");
|
|
2601
2628
|
if (management?.privilege_password)
|
|
2602
2629
|
found.push("management.privilege_password, the platform has no privileged mode");
|
|
2630
|
+
if (management?.snmp?.port)
|
|
2631
|
+
found.push("management.snmp.port");
|
|
2603
2632
|
if (management?.ssh?.auth_timeout || management?.ssh?.auth_retries) {
|
|
2604
2633
|
found.push("management.ssh.auth_timeout and auth_retries");
|
|
2605
2634
|
}
|
|
@@ -2616,8 +2645,8 @@ function unsupported2(device) {
|
|
|
2616
2645
|
}
|
|
2617
2646
|
var ROLLBACK_SCHEDULER = "apply-rollback";
|
|
2618
2647
|
var escapeForString = (text) => text.replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/\$/g, "\\$");
|
|
2619
|
-
async function refusal(client,
|
|
2620
|
-
const compiled = (await exec(client, `:put [:parse "${escapeForString(
|
|
2648
|
+
async function refusal(client, command) {
|
|
2649
|
+
const compiled = (await exec(client, `:put [:parse "${escapeForString(command)}"]`)).trim();
|
|
2621
2650
|
const refused = !compiled.startsWith("(") || /syntax error|bad parameter|no such|expected/.test(compiled);
|
|
2622
2651
|
return refused ? compiled.slice(0, 200) : undefined;
|
|
2623
2652
|
}
|
|
@@ -2628,25 +2657,25 @@ async function installCertificate(client, device, name) {
|
|
|
2628
2657
|
await upload(client, `${name}.crt`, certificate.certificate);
|
|
2629
2658
|
await exec(client, `/certificate import file-name=${name}.crt name=${name} passphrase=""`);
|
|
2630
2659
|
if (certificate.private_key) {
|
|
2631
|
-
const
|
|
2632
|
-
const privateKey =
|
|
2633
|
-
` : Buffer.from(
|
|
2660
|
+
const value = resolve(certificate.private_key);
|
|
2661
|
+
const privateKey = value.includes("-----BEGIN") ? `${value}
|
|
2662
|
+
` : Buffer.from(value, "base64").toString("utf8");
|
|
2634
2663
|
await upload(client, `${name}.key`, privateKey);
|
|
2635
2664
|
await exec(client, `/certificate import file-name=${name}.key name=${name} passphrase=""`);
|
|
2636
2665
|
}
|
|
2637
2666
|
await exec(client, `/file remove [find name="${name}.crt" or name="${name}.key"]`);
|
|
2638
2667
|
}
|
|
2639
|
-
async function apply2(device, _network,
|
|
2668
|
+
async function apply2(device, _network, changes, options) {
|
|
2640
2669
|
const client = await login(device);
|
|
2641
2670
|
await checkVersion(client, device);
|
|
2642
|
-
const commands =
|
|
2643
|
-
for (const { title, command
|
|
2644
|
-
if (
|
|
2671
|
+
const commands = changes.steps.flatMap((step) => step.send.map((command) => ({ title: step.title, command })));
|
|
2672
|
+
for (const { title, command } of commands) {
|
|
2673
|
+
if (command.startsWith(INSTALL_CERTIFICATE))
|
|
2645
2674
|
continue;
|
|
2646
|
-
const reason = await refusal(client, substitute(
|
|
2675
|
+
const reason = await refusal(client, substitute(command));
|
|
2647
2676
|
if (reason) {
|
|
2648
2677
|
client.end();
|
|
2649
|
-
throw new Error(`${title}: the device will not parse ${
|
|
2678
|
+
throw new Error(`${title}: the device will not parse ${command.slice(0, 200)}
|
|
2650
2679
|
${reason}`);
|
|
2651
2680
|
}
|
|
2652
2681
|
}
|
|
@@ -2663,15 +2692,15 @@ async function apply2(device, _network, changes2, options) {
|
|
|
2663
2692
|
await exec(client, `/system backup save name=${backup} dont-encrypt=yes`, 120000);
|
|
2664
2693
|
await exec(client, `/system scheduler remove [find name="${ROLLBACK_SCHEDULER}"]`);
|
|
2665
2694
|
await exec(client, armRollback);
|
|
2666
|
-
for (const { title, command
|
|
2667
|
-
if (
|
|
2668
|
-
const certificateName =
|
|
2695
|
+
for (const { title, command } of commands) {
|
|
2696
|
+
if (command.startsWith(INSTALL_CERTIFICATE)) {
|
|
2697
|
+
const certificateName = command.slice(INSTALL_CERTIFICATE.length).trim();
|
|
2669
2698
|
await installCertificate(client, device, certificateName);
|
|
2670
2699
|
continue;
|
|
2671
2700
|
}
|
|
2672
|
-
const answer = (await exec(client, substitute(
|
|
2701
|
+
const answer = (await exec(client, substitute(command), 120000)).trim();
|
|
2673
2702
|
if (answer)
|
|
2674
|
-
throw new Error(`${title}: ${
|
|
2703
|
+
throw new Error(`${title}: ${command.slice(0, 160)}
|
|
2675
2704
|
device said: ${answer.slice(0, 300)}`);
|
|
2676
2705
|
}
|
|
2677
2706
|
} catch (error) {
|
|
@@ -2743,20 +2772,20 @@ var DEVICE_FACTS2 = [
|
|
|
2743
2772
|
"Info:"
|
|
2744
2773
|
];
|
|
2745
2774
|
var UNDO_BY_KEYWORD2 = /^(description|name|port default vlan|port link-type|port trunk pvid vlan|set authentication password|eth-trunk|ip binding vpn-instance)\b/;
|
|
2746
|
-
function undo2(
|
|
2747
|
-
if (
|
|
2748
|
-
return
|
|
2749
|
-
if (/^rule \d+/.test(
|
|
2750
|
-
return `undo ${
|
|
2751
|
-
const keyword = UNDO_BY_KEYWORD2.exec(
|
|
2775
|
+
function undo2(line) {
|
|
2776
|
+
if (line.startsWith("undo "))
|
|
2777
|
+
return line.slice("undo ".length);
|
|
2778
|
+
if (/^rule \d+/.test(line))
|
|
2779
|
+
return `undo ${line.split(" ").slice(0, 2).join(" ")}`;
|
|
2780
|
+
const keyword = UNDO_BY_KEYWORD2.exec(line);
|
|
2752
2781
|
if (keyword)
|
|
2753
2782
|
return `undo ${keyword[1]}`;
|
|
2754
|
-
if (
|
|
2755
|
-
return `undo ${
|
|
2756
|
-
return `undo ${
|
|
2783
|
+
if (line.startsWith("storm control "))
|
|
2784
|
+
return `undo ${line.replace(/ (min-rate|max-rate).*$/, "")}`;
|
|
2785
|
+
return `undo ${line}`;
|
|
2757
2786
|
}
|
|
2758
|
-
function canon(
|
|
2759
|
-
return
|
|
2787
|
+
function canon(line) {
|
|
2788
|
+
return line.replace(/(irreversible-cipher|cipher) \S+/g, "cipher <secret>").replace(/<secret:\w+>/g, "<secret>");
|
|
2760
2789
|
}
|
|
2761
2790
|
function order(path) {
|
|
2762
2791
|
const header = path[0] ?? "";
|
|
@@ -2778,7 +2807,7 @@ var dialect2 = {
|
|
|
2778
2807
|
block: /^(interface |vlan \d+$|acl number |aaa$|user-interface |ospf |ip vpn-instance |bfd$)/,
|
|
2779
2808
|
indented: true,
|
|
2780
2809
|
terminator: "#",
|
|
2781
|
-
ignore: (
|
|
2810
|
+
ignore: (line) => DEVICE_FACTS2.some((fact) => line.startsWith(fact)),
|
|
2782
2811
|
undo: undo2,
|
|
2783
2812
|
leave: "quit",
|
|
2784
2813
|
first: /^authentication-mode /,
|
|
@@ -2792,13 +2821,13 @@ var SNMP_GROUP2 = "circuit";
|
|
|
2792
2821
|
var USER_LEVEL = { admin: 3, operator: 2, "read-only": 1 };
|
|
2793
2822
|
var SNMP_AUTH3 = { sha1: "sha", sha256: "sha2-256" };
|
|
2794
2823
|
var SNMP_PRIVACY2 = { aes128: "aes128", des: "des56" };
|
|
2795
|
-
function batchIds(
|
|
2796
|
-
const
|
|
2824
|
+
function batchIds(line) {
|
|
2825
|
+
const words = line.replace(/^vlan batch /, "").split(" ");
|
|
2797
2826
|
const ids = [];
|
|
2798
|
-
for (let index = 0;index <
|
|
2799
|
-
const first = Number(
|
|
2800
|
-
if (
|
|
2801
|
-
const last = Number(
|
|
2827
|
+
for (let index = 0;index < words.length; index++) {
|
|
2828
|
+
const first = Number(words[index]);
|
|
2829
|
+
if (words[index + 1] === "to") {
|
|
2830
|
+
const last = Number(words[index + 2]);
|
|
2802
2831
|
for (let id = first;id <= last; id++)
|
|
2803
2832
|
ids.push(id);
|
|
2804
2833
|
index += 2;
|
|
@@ -2935,7 +2964,7 @@ function managementAcl(config, device) {
|
|
|
2935
2964
|
const allow = device.management?.allow ?? [];
|
|
2936
2965
|
if (!allow.length)
|
|
2937
2966
|
return;
|
|
2938
|
-
const rules = allow.map((cidr, index) => `rule ${(index + 1) * 5} permit source ${
|
|
2967
|
+
const rules = allow.map((cidr, index) => `rule ${(index + 1) * 5} permit source ${host2(cidr)} ${wildcard(split(cidr).len)}`);
|
|
2939
2968
|
config.block(`acl number ${MANAGEMENT_ACL2}`, ["description Management access", ...rules, `rule ${(allow.length + 1) * 5} deny`]);
|
|
2940
2969
|
}
|
|
2941
2970
|
function users2(config, device) {
|
|
@@ -3014,8 +3043,8 @@ function vlanInterface(device, name, iface) {
|
|
|
3014
3043
|
lines.push(`ip binding vpn-instance ${iface.vrf}`);
|
|
3015
3044
|
for (const entry of iface.addresses ?? []) {
|
|
3016
3045
|
const address = typeof entry === "string" ? entry : entry.address;
|
|
3017
|
-
if (
|
|
3018
|
-
lines.push(`ip address ${
|
|
3046
|
+
if (familyOf2(address) === "ipv4")
|
|
3047
|
+
lines.push(`ip address ${host2(address)} ${netmask(split(address).len)}`);
|
|
3019
3048
|
else
|
|
3020
3049
|
lines.push(`ipv6 address ${address}`);
|
|
3021
3050
|
}
|
|
@@ -3070,7 +3099,7 @@ function staticRoutes2(config, device, nameOf) {
|
|
|
3070
3099
|
const vrf = route.vrf ? ` vpn-instance ${route.vrf}` : "";
|
|
3071
3100
|
const preference = route.distance ? ` preference ${route.distance}` : "";
|
|
3072
3101
|
const description = route.description ? ` description ${route.description}` : "";
|
|
3073
|
-
if (
|
|
3102
|
+
if (familyOf2(addr) === "ipv4")
|
|
3074
3103
|
config.line(`ip route-static${vrf} ${addr} ${netmask(len)} ${nextHop}${preference}${description}`);
|
|
3075
3104
|
else
|
|
3076
3105
|
config.line(`ipv6 route-static${vrf} ${addr} ${len} ${nextHop}${preference}`);
|
|
@@ -3078,21 +3107,21 @@ function staticRoutes2(config, device, nameOf) {
|
|
|
3078
3107
|
config.separator();
|
|
3079
3108
|
}
|
|
3080
3109
|
function ospf2(config, device, nameOf) {
|
|
3081
|
-
const
|
|
3082
|
-
if (!
|
|
3110
|
+
const ospf = device.routing?.ospf;
|
|
3111
|
+
if (!ospf)
|
|
3083
3112
|
return;
|
|
3084
3113
|
const routerId = device.routing?.router_id;
|
|
3085
3114
|
const lines = [];
|
|
3086
|
-
for (const source of
|
|
3115
|
+
for (const source of ospf.redistribute ?? [])
|
|
3087
3116
|
lines.push(`import-route ${source === "connected" ? "direct" : "static"}`);
|
|
3088
3117
|
const areaLines = [];
|
|
3089
|
-
for (const [areaId, area] of Object.entries(
|
|
3118
|
+
for (const [areaId, area] of Object.entries(ospf.areas)) {
|
|
3090
3119
|
areaLines.push(`area ${areaId}`);
|
|
3091
3120
|
for (const [iface, settings] of Object.entries(area.interfaces)) {
|
|
3092
3121
|
if (settings?.passive)
|
|
3093
3122
|
lines.push(`silent-interface ${nameOf(iface)}`);
|
|
3094
3123
|
for (const prefix of ipv4Networks(device, iface))
|
|
3095
|
-
areaLines.push(` network ${
|
|
3124
|
+
areaLines.push(` network ${host2(prefix)} ${wildcard(split(prefix).len)}`);
|
|
3096
3125
|
}
|
|
3097
3126
|
}
|
|
3098
3127
|
config.block(`ospf ${OSPF_PROCESS2}${routerId ? ` router-id ${routerId}` : ""}`, [...lines, ...areaLines]);
|
|
@@ -3113,23 +3142,23 @@ function flowExport2(config, device) {
|
|
|
3113
3142
|
config.separator();
|
|
3114
3143
|
}
|
|
3115
3144
|
function snmp2(config, device) {
|
|
3116
|
-
const
|
|
3117
|
-
if (!
|
|
3145
|
+
const snmp = device.management?.snmp;
|
|
3146
|
+
if (!snmp)
|
|
3118
3147
|
return;
|
|
3119
|
-
const versions = [
|
|
3148
|
+
const versions = [snmp.community ? "v2c" : "", snmp.users ? "v3" : ""].filter(Boolean).join(" ");
|
|
3120
3149
|
const guarded = (device.management?.allow?.length ?? 0) > 0;
|
|
3121
3150
|
config.line("snmp-agent");
|
|
3122
3151
|
config.line(`snmp-agent sys-info version ${versions}`);
|
|
3123
|
-
if (
|
|
3124
|
-
config.line(`snmp-agent sys-info contact ${
|
|
3125
|
-
if (
|
|
3126
|
-
config.line(`snmp-agent sys-info location ${
|
|
3127
|
-
if (
|
|
3128
|
-
config.line(`snmp-agent community read cipher ${marker(
|
|
3129
|
-
if (
|
|
3152
|
+
if (snmp.contact)
|
|
3153
|
+
config.line(`snmp-agent sys-info contact ${snmp.contact}`);
|
|
3154
|
+
if (snmp.location)
|
|
3155
|
+
config.line(`snmp-agent sys-info location ${snmp.location}`);
|
|
3156
|
+
if (snmp.community)
|
|
3157
|
+
config.line(`snmp-agent community read cipher ${marker(snmp.community)}${guarded ? ` acl ${MANAGEMENT_ACL2}` : ""}`);
|
|
3158
|
+
if (snmp.users) {
|
|
3130
3159
|
config.line("snmp-agent mib-view included all iso");
|
|
3131
3160
|
config.line(`snmp-agent group v3 ${SNMP_GROUP2} privacy read-view all${guarded ? ` acl ${MANAGEMENT_ACL2}` : ""}`);
|
|
3132
|
-
for (const [name, user] of Object.entries(
|
|
3161
|
+
for (const [name, user] of Object.entries(snmp.users)) {
|
|
3133
3162
|
config.line(`snmp-agent usm-user v3 ${name} group ${SNMP_GROUP2}`);
|
|
3134
3163
|
config.line(`snmp-agent usm-user v3 ${name} authentication-mode ${SNMP_AUTH3[user.auth]} cipher ${marker(user.auth_password)}`);
|
|
3135
3164
|
config.line(`snmp-agent usm-user v3 ${name} privacy-mode ${SNMP_PRIVACY2[user.privacy]} cipher ${marker(user.privacy_password)}`);
|
|
@@ -3138,8 +3167,8 @@ function snmp2(config, device) {
|
|
|
3138
3167
|
config.separator();
|
|
3139
3168
|
}
|
|
3140
3169
|
function ssh(config, device) {
|
|
3141
|
-
const
|
|
3142
|
-
if (!
|
|
3170
|
+
const ssh = device.management?.ssh;
|
|
3171
|
+
if (!ssh)
|
|
3143
3172
|
return;
|
|
3144
3173
|
const guarded = (device.management?.allow?.length ?? 0) > 0;
|
|
3145
3174
|
config.line("stelnet server enable");
|
|
@@ -3152,7 +3181,7 @@ function ssh(config, device) {
|
|
|
3152
3181
|
config.line(`ssh server acl ${MANAGEMENT_ACL2}`);
|
|
3153
3182
|
config.line("ssh authorization-type default aaa");
|
|
3154
3183
|
config.separator();
|
|
3155
|
-
if (!
|
|
3184
|
+
if (!ssh.weak_crypto) {
|
|
3156
3185
|
config.line("ssh server cipher aes256_ctr aes128_ctr");
|
|
3157
3186
|
config.line("ssh server hmac sha2_256_96 sha2_256");
|
|
3158
3187
|
config.line("ssh server key-exchange dh_group_exchange_sha256 ecdh_sha2_nistp256 ecdh_sha2_nistp384 ecdh_sha2_nistp521");
|
|
@@ -3194,10 +3223,10 @@ async function read3(device) {
|
|
|
3194
3223
|
channel.end();
|
|
3195
3224
|
const lines = dump.replace(/\r/g, "").split(`
|
|
3196
3225
|
`);
|
|
3197
|
-
const start = lines.findIndex((
|
|
3226
|
+
const start = lines.findIndex((line) => line.startsWith("!Software Version"));
|
|
3198
3227
|
if (start < 0)
|
|
3199
3228
|
throw new Error(`${device.name} returned no configuration`);
|
|
3200
|
-
const body = lines.slice(start).filter((
|
|
3229
|
+
const body = lines.slice(start).filter((line) => !/^<.*>$/.test(line.trim()) && !line.startsWith("!Last configuration"));
|
|
3201
3230
|
return scrub(body.join(`
|
|
3202
3231
|
`).trim() + `
|
|
3203
3232
|
`);
|
|
@@ -3225,10 +3254,16 @@ function unsupported3(device) {
|
|
|
3225
3254
|
found.push(`interfaces.${name}, a ${iface.type}`);
|
|
3226
3255
|
if (iface.type === "lag" && iface.addresses?.length)
|
|
3227
3256
|
found.push(`interfaces.${name}.addresses, LAGs are switched`);
|
|
3257
|
+
if (iface.mtu !== undefined)
|
|
3258
|
+
found.push(`interfaces.${name}.mtu`);
|
|
3228
3259
|
for (const group of iface.vrrp ?? [])
|
|
3229
|
-
if (
|
|
3260
|
+
if (familyOf2(group.address) === "ipv6")
|
|
3230
3261
|
found.push(`interfaces.${name}.vrrp for IPv6`);
|
|
3231
3262
|
}
|
|
3263
|
+
for (const [name, vrf] of Object.entries(device.vrfs ?? {})) {
|
|
3264
|
+
if (vrf.description)
|
|
3265
|
+
found.push(`vrfs.${name}.description`);
|
|
3266
|
+
}
|
|
3232
3267
|
const routing = device.routing;
|
|
3233
3268
|
if (routing?.bgp)
|
|
3234
3269
|
found.push("routing.bgp");
|
|
@@ -3255,10 +3290,13 @@ function unsupported3(device) {
|
|
|
3255
3290
|
found.push("management.ssh.auth_timeout and auth_retries");
|
|
3256
3291
|
if (device.lldp && device.lldp !== true)
|
|
3257
3292
|
found.push("lldp.interfaces, use lldp on the ports instead");
|
|
3258
|
-
const
|
|
3259
|
-
if (
|
|
3293
|
+
const system = device.system ?? {};
|
|
3294
|
+
if (system.dns || system.logging || system.release_channel || system.banner) {
|
|
3260
3295
|
found.push("system.dns, logging, release_channel and banner");
|
|
3261
3296
|
}
|
|
3297
|
+
if (system.transceiver_monitoring !== undefined)
|
|
3298
|
+
found.push("system.transceiver_monitoring");
|
|
3299
|
+
found.push(...switchUnsupported(device));
|
|
3262
3300
|
const flow = device.flow_export;
|
|
3263
3301
|
if (flow && flow.protocol !== "sflow")
|
|
3264
3302
|
found.push(`flow_export.protocol ${flow.protocol}, the platform exports sFlow`);
|
|
@@ -3271,31 +3309,31 @@ function unsupported3(device) {
|
|
|
3271
3309
|
}
|
|
3272
3310
|
var REFUSED2 = /^\s*Error|Unrecognized command|Incomplete command|Wrong parameter|Too many parameters/m;
|
|
3273
3311
|
var INTERACTIVE = /^(snmp-agent usm-user v3 \S+ (?:authentication|privacy)-mode \S+) cipher <secret:([A-Za-z0-9_./:-]+)>$/;
|
|
3274
|
-
async function sendLine(channel,
|
|
3275
|
-
const interactive = INTERACTIVE.exec(
|
|
3312
|
+
async function sendLine(channel, line) {
|
|
3313
|
+
const interactive = INTERACTIVE.exec(line.trim());
|
|
3276
3314
|
if (interactive) {
|
|
3277
|
-
const [,
|
|
3315
|
+
const [, command, reference] = interactive;
|
|
3278
3316
|
const password = valueOfReference(reference);
|
|
3279
|
-
let
|
|
3280
|
-
|
|
3281
|
-
|
|
3282
|
-
return
|
|
3317
|
+
let answer = await send(channel, command, 15000, 900);
|
|
3318
|
+
answer += await send(channel, password, 15000, 900);
|
|
3319
|
+
answer += await send(channel, password, 15000, 900);
|
|
3320
|
+
return answer;
|
|
3283
3321
|
}
|
|
3284
|
-
let answer = await send(channel, substitute(
|
|
3322
|
+
let answer = await send(channel, substitute(line), 20000, 800);
|
|
3285
3323
|
if (/\[Y\/N\]/i.test(answer))
|
|
3286
3324
|
answer += await send(channel, "Y", 20000, 800);
|
|
3287
3325
|
return answer;
|
|
3288
3326
|
}
|
|
3289
|
-
async function apply3(device, _network,
|
|
3327
|
+
async function apply3(device, _network, plan) {
|
|
3290
3328
|
const client = await login2(device);
|
|
3291
3329
|
const channel = await openShell(client);
|
|
3292
3330
|
try {
|
|
3293
3331
|
await send(channel, "system-view", 1e4, 1500);
|
|
3294
|
-
for (const step of
|
|
3295
|
-
for (const
|
|
3296
|
-
const answer = await sendLine(channel,
|
|
3332
|
+
for (const step of plan.steps) {
|
|
3333
|
+
for (const line of step.send) {
|
|
3334
|
+
const answer = await sendLine(channel, line);
|
|
3297
3335
|
if (REFUSED2.test(answer))
|
|
3298
|
-
throw new Error(`${step.title}: ${
|
|
3336
|
+
throw new Error(`${step.title}: ${line}
|
|
3299
3337
|
device said: ${answer.trim().slice(0, 300)}`);
|
|
3300
3338
|
}
|
|
3301
3339
|
}
|
|
@@ -3331,19 +3369,19 @@ function plan3(desired, current) {
|
|
|
3331
3369
|
const groups = compare(desired, current, dialect2);
|
|
3332
3370
|
vlanListChange(groups, "vlan batch ", batchIds, (ids) => [`vlan batch ${vlanBatch(ids)}`], (ids) => [`vlan batch ${vlanBatch(ids)}`]);
|
|
3333
3371
|
const top = groups.find((group) => group.path.length === 0);
|
|
3334
|
-
const removedBatch = top?.remove.find((
|
|
3372
|
+
const removedBatch = top?.remove.find((line) => line.startsWith("vlan batch ")) ?? "";
|
|
3335
3373
|
const removedIds = removedBatch ? batchIds(removedBatch) : [];
|
|
3336
3374
|
const firstRemovals = [
|
|
3337
|
-
...removedIds.map((id) => `interface Vlanif${id}`).filter((
|
|
3338
|
-
...removedIds.map((id) => `vlan ${id}`).filter((
|
|
3375
|
+
...removedIds.map((id) => `interface Vlanif${id}`).filter((line) => top?.remove.includes(line)),
|
|
3376
|
+
...removedIds.map((id) => `vlan ${id}`).filter((line) => top?.remove.includes(line)),
|
|
3339
3377
|
...removedBatch ? [removedBatch] : []
|
|
3340
3378
|
];
|
|
3341
3379
|
if (top)
|
|
3342
|
-
top.remove = top.remove.filter((
|
|
3380
|
+
top.remove = top.remove.filter((line) => !firstRemovals.includes(line));
|
|
3343
3381
|
const planned = steps(groups.filter((group) => group.add.length || group.remove.length), dialect2);
|
|
3344
3382
|
if (firstRemovals.length) {
|
|
3345
|
-
const sent = firstRemovals.filter((
|
|
3346
|
-
planned.unshift({ title: "VLANs no longer used", show: firstRemovals.map((
|
|
3383
|
+
const sent = firstRemovals.filter((line) => !/^vlan \d+$/.test(line)).map(undo2);
|
|
3384
|
+
planned.unshift({ title: "VLANs no longer used", show: firstRemovals.map((line) => `- ${line}`), send: sent });
|
|
3347
3385
|
}
|
|
3348
3386
|
return { steps: planned, problems: [] };
|
|
3349
3387
|
}
|
|
@@ -3378,17 +3416,17 @@ var registries = { "ripe-stat": ripeStat };
|
|
|
3378
3416
|
// src/adapters/registries/librenms.ts
|
|
3379
3417
|
var AUTH = { sha1: "SHA", sha256: "SHA-256" };
|
|
3380
3418
|
var PRIVACY = { aes128: "AES", des: "DES" };
|
|
3381
|
-
function settingsOf2(
|
|
3382
|
-
if (!
|
|
3419
|
+
function settingsOf2(network) {
|
|
3420
|
+
if (!network.librenms)
|
|
3383
3421
|
throw new Error("the network has no librenms settings");
|
|
3384
3422
|
return {
|
|
3385
|
-
base: `${
|
|
3386
|
-
headers: { "X-Auth-Token": resolve(
|
|
3423
|
+
base: `${network.librenms.url.replace(/\/+$/, "")}/api/v0`,
|
|
3424
|
+
headers: { "X-Auth-Token": resolve(network.librenms.api_token), "Content-Type": "application/json" }
|
|
3387
3425
|
};
|
|
3388
3426
|
}
|
|
3389
3427
|
function credentials(device) {
|
|
3390
|
-
const
|
|
3391
|
-
const [name, user] = Object.entries(
|
|
3428
|
+
const snmp = device.management?.snmp;
|
|
3429
|
+
const [name, user] = Object.entries(snmp?.users ?? {})[0] ?? [];
|
|
3392
3430
|
if (name && user) {
|
|
3393
3431
|
return {
|
|
3394
3432
|
snmpver: "v3",
|
|
@@ -3400,26 +3438,26 @@ function credentials(device) {
|
|
|
3400
3438
|
cryptoalgo: PRIVACY[user.privacy]
|
|
3401
3439
|
};
|
|
3402
3440
|
}
|
|
3403
|
-
if (
|
|
3404
|
-
return { snmpver: "v2c", community: resolve(
|
|
3441
|
+
if (snmp?.community)
|
|
3442
|
+
return { snmpver: "v2c", community: resolve(snmp.community) };
|
|
3405
3443
|
return;
|
|
3406
3444
|
}
|
|
3407
|
-
async function planLibreNms(
|
|
3408
|
-
const { base, headers } = settingsOf2(
|
|
3445
|
+
async function planLibreNms(network) {
|
|
3446
|
+
const { base, headers } = settingsOf2(network);
|
|
3409
3447
|
const response = await fetch(`${base}/devices?type=all`, { headers, signal: AbortSignal.timeout(30000) });
|
|
3410
3448
|
if (!response.ok)
|
|
3411
3449
|
throw new Error(`LibreNMS answered ${response.status} to the device list`);
|
|
3412
3450
|
const monitored = new Set((await response.json()).devices.map((device) => device.hostname));
|
|
3413
|
-
const ours = new Set(
|
|
3414
|
-
const missing =
|
|
3451
|
+
const ours = new Set(network.devices.map((device) => device.connection.host));
|
|
3452
|
+
const missing = network.devices.filter((device) => !monitored.has(device.connection.host));
|
|
3415
3453
|
return {
|
|
3416
3454
|
additions: missing.filter((device) => device.management?.snmp),
|
|
3417
3455
|
withoutSnmp: missing.filter((device) => !device.management?.snmp),
|
|
3418
3456
|
unknown: [...monitored].filter((hostname) => !ours.has(hostname)).sort()
|
|
3419
3457
|
};
|
|
3420
3458
|
}
|
|
3421
|
-
async function sendLibreNms(
|
|
3422
|
-
const { base, headers } = settingsOf2(
|
|
3459
|
+
async function sendLibreNms(network, additions) {
|
|
3460
|
+
const { base, headers } = settingsOf2(network);
|
|
3423
3461
|
for (const device of additions) {
|
|
3424
3462
|
const body = { hostname: device.connection.host, display_template: device.name, ...credentials(device) };
|
|
3425
3463
|
const response = await fetch(`${base}/devices`, {
|
|
@@ -3438,55 +3476,55 @@ async function sendLibreNms(network2, additions) {
|
|
|
3438
3476
|
|
|
3439
3477
|
// src/adapters/registries/peeringdb.ts
|
|
3440
3478
|
var API = "https://www.peeringdb.com/api";
|
|
3441
|
-
var
|
|
3479
|
+
var addressesOf2 = (iface) => (iface.addresses ?? []).map((entry) => typeof entry === "string" ? entry : entry.address);
|
|
3442
3480
|
function peersWithRouteServer(device, iface) {
|
|
3443
3481
|
const bgp = device.routing?.bgp;
|
|
3444
|
-
const subnets =
|
|
3482
|
+
const subnets = addressesOf2(iface);
|
|
3445
3483
|
return Object.values(bgp?.neighbors ?? {}).some((neighbor) => {
|
|
3446
3484
|
const group = neighbor.group ? bgp?.groups?.[neighbor.group] : undefined;
|
|
3447
3485
|
const role = neighbor.role ?? group?.role;
|
|
3448
|
-
return role === "rs" && subnets.some((subnet) =>
|
|
3486
|
+
return role === "rs" && subnets.some((subnet) => contains2(subnet, neighbor.address));
|
|
3449
3487
|
});
|
|
3450
3488
|
}
|
|
3451
|
-
function wanted(
|
|
3489
|
+
function wanted(network) {
|
|
3452
3490
|
const records = [];
|
|
3453
|
-
for (const device of
|
|
3491
|
+
for (const device of network.devices) {
|
|
3454
3492
|
for (const iface of Object.values(device.interfaces ?? {})) {
|
|
3455
3493
|
const exchange = iface.exchange;
|
|
3456
3494
|
if (!exchange?.peeringdb_ixlan)
|
|
3457
3495
|
continue;
|
|
3458
|
-
const published =
|
|
3496
|
+
const published = addressesOf2(iface).map(host2).filter(isGlobal2);
|
|
3459
3497
|
records.push({
|
|
3460
3498
|
name: exchange.name,
|
|
3461
3499
|
ixlan_id: exchange.peeringdb_ixlan,
|
|
3462
3500
|
speed: exchange.speed,
|
|
3463
|
-
ipaddr4: published.find((address) =>
|
|
3464
|
-
ipaddr6: published.find((address) =>
|
|
3501
|
+
ipaddr4: published.find((address) => familyOf2(address) === "ipv4") ?? null,
|
|
3502
|
+
ipaddr6: published.find((address) => familyOf2(address) === "ipv6") ?? null,
|
|
3465
3503
|
is_rs_peer: peersWithRouteServer(device, iface)
|
|
3466
3504
|
});
|
|
3467
3505
|
}
|
|
3468
3506
|
}
|
|
3469
3507
|
return records;
|
|
3470
3508
|
}
|
|
3471
|
-
async function planPeeringDb(
|
|
3472
|
-
if (!
|
|
3509
|
+
async function planPeeringDb(network) {
|
|
3510
|
+
if (!network.asn)
|
|
3473
3511
|
throw new Error("the network declares no asn");
|
|
3474
|
-
const response = await fetch(`${API}/netixlan?asn=${
|
|
3512
|
+
const response = await fetch(`${API}/netixlan?asn=${network.asn}`, {
|
|
3475
3513
|
headers: { Accept: "application/json" },
|
|
3476
3514
|
signal: AbortSignal.timeout(30000)
|
|
3477
3515
|
});
|
|
3478
3516
|
if (!response.ok)
|
|
3479
3517
|
throw new Error(`PeeringDB answered ${response.status}`);
|
|
3480
3518
|
const existing = (await response.json()).data;
|
|
3481
|
-
const desired = wanted(
|
|
3519
|
+
const desired = wanted(network);
|
|
3482
3520
|
const netId = existing[0]?.net_id;
|
|
3483
|
-
const
|
|
3521
|
+
const changes = [];
|
|
3484
3522
|
for (const record of desired) {
|
|
3485
3523
|
const current = existing.find((candidate) => candidate.ixlan_id === record.ixlan_id);
|
|
3486
3524
|
const body = {
|
|
3487
3525
|
net_id: netId,
|
|
3488
3526
|
ixlan_id: record.ixlan_id,
|
|
3489
|
-
asn:
|
|
3527
|
+
asn: network.asn,
|
|
3490
3528
|
speed: record.speed,
|
|
3491
3529
|
ipaddr4: record.ipaddr4,
|
|
3492
3530
|
ipaddr6: record.ipaddr6,
|
|
@@ -3494,27 +3532,27 @@ async function planPeeringDb(network2) {
|
|
|
3494
3532
|
operational: current?.operational ?? true
|
|
3495
3533
|
};
|
|
3496
3534
|
if (!current) {
|
|
3497
|
-
|
|
3535
|
+
changes.push({ summary: `+ ${record.name}: ${record.ipaddr4}, ${record.ipaddr6}, ${record.speed} Mbit/s`, body });
|
|
3498
3536
|
continue;
|
|
3499
3537
|
}
|
|
3500
3538
|
const differs = current.ipaddr4 !== record.ipaddr4 || current.ipaddr6 !== record.ipaddr6 || current.speed !== record.speed || current.is_rs_peer !== record.is_rs_peer;
|
|
3501
3539
|
if (differs) {
|
|
3502
3540
|
const before = `${current.ipaddr4}, ${current.ipaddr6}, ${current.speed} Mbit/s, route server ${current.is_rs_peer}`;
|
|
3503
3541
|
const after = `${record.ipaddr4}, ${record.ipaddr6}, ${record.speed} Mbit/s, route server ${record.is_rs_peer}`;
|
|
3504
|
-
|
|
3542
|
+
changes.push({ summary: `~ ${record.name}: ${before} to ${after}`, id: current.id, body });
|
|
3505
3543
|
}
|
|
3506
3544
|
}
|
|
3507
3545
|
const unknown = existing.filter((record) => !desired.some((candidate) => candidate.ixlan_id === record.ixlan_id)).map((record) => record.name);
|
|
3508
|
-
if (
|
|
3546
|
+
if (changes.some((change) => !change.id) && !netId) {
|
|
3509
3547
|
throw new Error("PeeringDB holds no presence for this network yet, so its record id is unknown; add the first one by hand");
|
|
3510
3548
|
}
|
|
3511
|
-
return { changes
|
|
3549
|
+
return { changes, unknown };
|
|
3512
3550
|
}
|
|
3513
|
-
async function sendPeeringDb(
|
|
3514
|
-
if (!
|
|
3551
|
+
async function sendPeeringDb(network, changes) {
|
|
3552
|
+
if (!network.peeringdb)
|
|
3515
3553
|
throw new Error("the network declares no peeringdb api_key");
|
|
3516
|
-
const apiKey = resolve(
|
|
3517
|
-
for (const change of
|
|
3554
|
+
const apiKey = resolve(network.peeringdb.api_key);
|
|
3555
|
+
for (const change of changes) {
|
|
3518
3556
|
const response = await fetch(`${API}/netixlan${change.id ? `/${change.id}` : ""}`, {
|
|
3519
3557
|
method: change.id ? "PUT" : "POST",
|
|
3520
3558
|
headers: { Authorization: `Api-Key ${apiKey}`, "Content-Type": "application/json" },
|
|
@@ -3532,13 +3570,13 @@ async function sendPeeringDb(network2, changes2) {
|
|
|
3532
3570
|
// src/adapters/registries/prometheus.ts
|
|
3533
3571
|
var AUTH2 = { sha1: "SHA", sha256: "SHA256" };
|
|
3534
3572
|
var PRIVACY2 = { aes128: "AES", des: "DES" };
|
|
3535
|
-
function prometheusTargets(
|
|
3573
|
+
function prometheusTargets(network) {
|
|
3536
3574
|
return {
|
|
3537
|
-
targets:
|
|
3575
|
+
targets: network.devices.filter((device) => device.management?.snmp).map((device) => ({
|
|
3538
3576
|
targets: [device.connection.host],
|
|
3539
3577
|
labels: { device: device.name, platform: device.platform, model: device.model, __param_auth: device.name }
|
|
3540
3578
|
})),
|
|
3541
|
-
withoutSnmp:
|
|
3579
|
+
withoutSnmp: network.devices.filter((device) => !device.management?.snmp)
|
|
3542
3580
|
};
|
|
3543
3581
|
}
|
|
3544
3582
|
function fromEnvironment(secret, notes, where) {
|
|
@@ -3547,17 +3585,17 @@ function fromEnvironment(secret, notes, where) {
|
|
|
3547
3585
|
notes.push(`${where} is read from a file; write its value in place of FILL_IN`);
|
|
3548
3586
|
return "FILL_IN";
|
|
3549
3587
|
}
|
|
3550
|
-
function snmpExporterAuths(
|
|
3588
|
+
function snmpExporterAuths(network) {
|
|
3551
3589
|
const notes = [];
|
|
3552
3590
|
const lines = [
|
|
3553
3591
|
"# Written by `circuit prometheus --auths`. Load it beside snmp.yml, with --config.expand-environment-variables.",
|
|
3554
3592
|
"auths:"
|
|
3555
3593
|
];
|
|
3556
|
-
for (const device of
|
|
3557
|
-
const
|
|
3558
|
-
if (!
|
|
3594
|
+
for (const device of network.devices) {
|
|
3595
|
+
const snmp = device.management?.snmp;
|
|
3596
|
+
if (!snmp)
|
|
3559
3597
|
continue;
|
|
3560
|
-
const [name, user] = Object.entries(
|
|
3598
|
+
const [name, user] = Object.entries(snmp.users ?? {})[0] ?? [];
|
|
3561
3599
|
lines.push(` ${device.name}:`);
|
|
3562
3600
|
if (name && user) {
|
|
3563
3601
|
lines.push(" version: 3", ` username: ${name}`, " security_level: authPriv", ` password: ${fromEnvironment(user.auth_password, notes, `${device.name}'s SNMP password`)}`, ` auth_protocol: ${AUTH2[user.auth]}`, ` priv_protocol: ${PRIVACY2[user.privacy]}`, ` priv_password: ${fromEnvironment(user.privacy_password, notes, `${device.name}'s SNMP privacy password`)}`);
|
|
@@ -3571,428 +3609,254 @@ function snmpExporterAuths(network2) {
|
|
|
3571
3609
|
`, notes };
|
|
3572
3610
|
}
|
|
3573
3611
|
|
|
3574
|
-
// src/core/
|
|
3575
|
-
|
|
3576
|
-
var deviceId = (name) => `device:${name}`;
|
|
3577
|
-
function keyOf(address) {
|
|
3578
|
-
if (!address || !isAddress(address))
|
|
3579
|
-
return;
|
|
3580
|
-
return `${familyOf(address)}:${parse(address)}`;
|
|
3581
|
-
}
|
|
3582
|
-
var isHostRoute = (cidr) => {
|
|
3583
|
-
const { len, family } = split(cidr);
|
|
3584
|
-
return len === (family === "ipv4" ? 32 : 128);
|
|
3585
|
-
};
|
|
3612
|
+
// src/core/validate.ts
|
|
3613
|
+
import { X509Certificate as X509Certificate2 } from "node:crypto";
|
|
3586
3614
|
|
|
3587
|
-
|
|
3588
|
-
|
|
3589
|
-
|
|
3590
|
-
|
|
3591
|
-
|
|
3592
|
-
|
|
3593
|
-
|
|
3594
|
-
|
|
3595
|
-
for (const line2 of detail)
|
|
3596
|
-
if (!existing.detail.includes(line2))
|
|
3597
|
-
existing.detail.push(line2);
|
|
3598
|
-
return id;
|
|
3599
|
-
}
|
|
3600
|
-
edge(edge) {
|
|
3601
|
-
this.edges.push(edge);
|
|
3602
|
-
return edge;
|
|
3615
|
+
// src/core/across.ts
|
|
3616
|
+
function checkAcross(devices) {
|
|
3617
|
+
const holders = holdersOf(devices);
|
|
3618
|
+
const owners = new Map;
|
|
3619
|
+
for (const holder of holders) {
|
|
3620
|
+
const key = keyOf(host2(holder.address));
|
|
3621
|
+
if (!owners.has(key))
|
|
3622
|
+
owners.set(key, holder);
|
|
3603
3623
|
}
|
|
3624
|
+
return [
|
|
3625
|
+
...checkLinks(devices),
|
|
3626
|
+
...checkAddresses(holders),
|
|
3627
|
+
...checkSessions(devices, owners),
|
|
3628
|
+
...checkVrrp(holders),
|
|
3629
|
+
...checkTunnels(devices, owners)
|
|
3630
|
+
];
|
|
3604
3631
|
}
|
|
3605
|
-
function
|
|
3606
|
-
const
|
|
3607
|
-
|
|
3608
|
-
|
|
3632
|
+
function carried(device, port) {
|
|
3633
|
+
const id = (vlan) => vlan ? device.vlans?.[vlan]?.id : undefined;
|
|
3634
|
+
return {
|
|
3635
|
+
access: id(port.access_vlan),
|
|
3636
|
+
native: id(port.native_vlan),
|
|
3637
|
+
trunk: (port.trunk_vlans ?? []).map(id).sort((a, b) => (a ?? 0) - (b ?? 0))
|
|
3638
|
+
};
|
|
3609
3639
|
}
|
|
3610
|
-
function
|
|
3611
|
-
|
|
3612
|
-
|
|
3613
|
-
if (!isCidr(address))
|
|
3614
|
-
return [];
|
|
3615
|
-
const peer = typeof entry === "string" ? undefined : entry.peer;
|
|
3616
|
-
return [{ device, name, address, peer, vrrp: settings.vrrp ?? [] }];
|
|
3617
|
-
})));
|
|
3618
|
-
}
|
|
3619
|
-
function describeDevice(device) {
|
|
3620
|
-
const addresses2 = routedOf(device).filter(([, settings]) => settings.addresses?.length).map(([name, settings]) => {
|
|
3621
|
-
const list = (settings.addresses ?? []).map((entry) => typeof entry === "string" ? entry : `${entry.address} to ${entry.peer}`);
|
|
3622
|
-
const vrf = settings.vrf ? `, VRF ${settings.vrf}` : "";
|
|
3623
|
-
return `${name}: ${list.join(", ")}${vrf}`;
|
|
3624
|
-
});
|
|
3625
|
-
return [`${device.platform} ${device.model}`, `reached at ${device.connection.host}`, ...addresses2];
|
|
3640
|
+
function vlanNames(device, settings) {
|
|
3641
|
+
const names = [settings.access_vlan, settings.native_vlan, ...settings.trunk_vlans ?? []].filter((name) => name !== undefined);
|
|
3642
|
+
return new Map(names.flatMap((name) => device.vlans?.[name] ? [[device.vlans[name].id, name]] : []));
|
|
3626
3643
|
}
|
|
3627
|
-
function
|
|
3628
|
-
const
|
|
3629
|
-
|
|
3630
|
-
return
|
|
3631
|
-
};
|
|
3632
|
-
const trunk = (settings?.trunk_vlans ?? []).filter((name) => name !== settings?.native_vlan);
|
|
3633
|
-
return [...vlan(settings?.access_vlan, true), ...vlan(settings?.native_vlan, true), ...trunk.flatMap((name) => vlan(name, false))].sort((a, b) => a.id - b.id);
|
|
3644
|
+
function carrierOf(device, portName, port) {
|
|
3645
|
+
const lag = port.lag ? device.interfaces?.[port.lag] : undefined;
|
|
3646
|
+
if (port.lag && lag?.type === "lag")
|
|
3647
|
+
return { name: port.lag, settings: lag };
|
|
3648
|
+
return { name: portName, settings: port };
|
|
3634
3649
|
}
|
|
3635
|
-
|
|
3636
|
-
|
|
3637
|
-
|
|
3638
|
-
|
|
3639
|
-
|
|
3640
|
-
|
|
3650
|
+
var plainAddresses = (settings, family) => (settings.addresses ?? []).filter((entry) => typeof entry === "string" && isCidr2(entry) && familyOf2(entry) === family);
|
|
3651
|
+
function addressDisagreement(here, there, far, farName) {
|
|
3652
|
+
for (const family of ["ipv4", "ipv6"]) {
|
|
3653
|
+
const ours = plainAddresses(here, family);
|
|
3654
|
+
const theirs = plainAddresses(there, family);
|
|
3655
|
+
if (!ours.length || !theirs.length)
|
|
3656
|
+
continue;
|
|
3657
|
+
const theirPrefixes = new Set(theirs.map(network));
|
|
3658
|
+
if (!ours.some((address) => theirPrefixes.has(network(address)))) {
|
|
3659
|
+
return `has ${ours.join(", ")}, and ${far.name} ${farName} has ${theirs.join(", ")}: not one prefix of one length`;
|
|
3660
|
+
}
|
|
3661
|
+
}
|
|
3662
|
+
const farHosts = new Set((there.addresses ?? []).map((entry) => keyOf(host2(typeof entry === "string" ? entry : entry.address))));
|
|
3663
|
+
for (const entry of here.addresses ?? []) {
|
|
3664
|
+
if (typeof entry === "string" || !keyOf(entry.peer) || farHosts.has(keyOf(entry.peer)))
|
|
3665
|
+
continue;
|
|
3666
|
+
return `${entry.address} names peer ${entry.peer}, which ${far.name} ${farName} does not hold`;
|
|
3667
|
+
}
|
|
3668
|
+
return;
|
|
3641
3669
|
}
|
|
3642
|
-
function
|
|
3670
|
+
function checkLinks(devices) {
|
|
3671
|
+
const findings = [];
|
|
3643
3672
|
const byName = new Map(devices.map((device) => [device.name, device]));
|
|
3644
|
-
const drawn = new Set;
|
|
3645
|
-
const bundles = new Map;
|
|
3646
3673
|
for (const device of devices) {
|
|
3647
3674
|
for (const [portName, port] of Object.entries(device.ports ?? {})) {
|
|
3648
3675
|
const link = port?.link;
|
|
3649
3676
|
if (!port || !link)
|
|
3650
3677
|
continue;
|
|
3651
|
-
const
|
|
3652
|
-
|
|
3653
|
-
|
|
3654
|
-
|
|
3655
|
-
|
|
3678
|
+
const report = (message, level = "error") => findings.push({
|
|
3679
|
+
level,
|
|
3680
|
+
device: device.name,
|
|
3681
|
+
message: `port ${portName} link: ${message}`
|
|
3682
|
+
});
|
|
3656
3683
|
const far = byName.get(link.device);
|
|
3657
|
-
|
|
3658
|
-
|
|
3659
|
-
const to = far ? deviceId(far.name) : graph.node(`missing:${link.device}`, "outside", link.device, ["named by a link, and not in the network"]);
|
|
3660
|
-
const cableLine = `${here} to ${there}`;
|
|
3661
|
-
const lag = port.lag;
|
|
3662
|
-
const farLag = farPort?.lag;
|
|
3663
|
-
const bundleKey = `${device.name} ${lag}|${link.device} ${farLag}`;
|
|
3664
|
-
const bundle = lag && farLag && linksBack ? bundles.get(bundleKey) : undefined;
|
|
3665
|
-
if (bundle) {
|
|
3666
|
-
bundle.members++;
|
|
3667
|
-
bundle.cable.detail.push(cableLine);
|
|
3668
|
-
bundle.cable.label = `${lag} to ${farLag}, ${bundle.members} cables`;
|
|
3684
|
+
if (!far) {
|
|
3685
|
+
report(`device ${link.device} is not in the network`);
|
|
3669
3686
|
continue;
|
|
3670
3687
|
}
|
|
3671
|
-
const
|
|
3672
|
-
|
|
3673
|
-
|
|
3674
|
-
from: deviceId(device.name),
|
|
3675
|
-
to,
|
|
3676
|
-
label: isBundle ? `${lag} to ${farLag}` : `${portName} to ${link.port}`,
|
|
3677
|
-
detail: [cableLine],
|
|
3678
|
-
...linksBack ? {} : { oneSided: true }
|
|
3679
|
-
});
|
|
3680
|
-
if (isBundle)
|
|
3681
|
-
bundles.set(bundleKey, { cable, members: 1 });
|
|
3682
|
-
const lagSettings = lag ? device.interfaces?.[lag] : undefined;
|
|
3683
|
-
const carrier = lagSettings?.type === "lag" ? lagSettings : lag ? undefined : port;
|
|
3684
|
-
const vlans = carriedBy(device, carrier);
|
|
3685
|
-
if (!vlans.length)
|
|
3688
|
+
const farPort = far.ports?.[link.port];
|
|
3689
|
+
if (!farPort) {
|
|
3690
|
+
report(`${link.device} does not declare port ${link.port}`);
|
|
3686
3691
|
continue;
|
|
3687
|
-
|
|
3688
|
-
|
|
3689
|
-
|
|
3690
|
-
to
|
|
3691
|
-
|
|
3692
|
-
|
|
3693
|
-
|
|
3694
|
-
|
|
3695
|
-
|
|
3696
|
-
|
|
3697
|
-
|
|
3698
|
-
|
|
3692
|
+
}
|
|
3693
|
+
const linksBack = farPort.link?.device === device.name && farPort.link.port === portName;
|
|
3694
|
+
if (!linksBack) {
|
|
3695
|
+
report(`${link.device} ${link.port} does not link back to it`);
|
|
3696
|
+
continue;
|
|
3697
|
+
}
|
|
3698
|
+
const here = JSON.stringify(carried(device, port));
|
|
3699
|
+
const there = JSON.stringify(carried(far, farPort));
|
|
3700
|
+
if (here !== there)
|
|
3701
|
+
report(`carries ${here}, and ${link.device} ${link.port} carries ${there}`);
|
|
3702
|
+
if (port.speed && farPort.speed && port.speed !== farPort.speed) {
|
|
3703
|
+
report(`speed ${port.speed}, and ${link.device} ${link.port} is ${farPort.speed}`);
|
|
3704
|
+
}
|
|
3705
|
+
const near = carrierOf(device, portName, port);
|
|
3706
|
+
const away = carrierOf(far, link.port, farPort);
|
|
3707
|
+
const mtu = near.settings.mtu ?? 1500;
|
|
3708
|
+
const farMtu = away.settings.mtu ?? 1500;
|
|
3709
|
+
if (mtu !== farMtu)
|
|
3710
|
+
report(`MTU ${mtu} on ${near.name}, and ${link.device} ${away.name} has ${farMtu}`);
|
|
3711
|
+
const disagreement = addressDisagreement(near.settings, away.settings, far, away.name);
|
|
3712
|
+
if (disagreement)
|
|
3713
|
+
report(`${near.name} ${disagreement}`);
|
|
3714
|
+
const farNames = vlanNames(far, away.settings);
|
|
3715
|
+
for (const [id, name] of vlanNames(device, near.settings)) {
|
|
3716
|
+
const farName = farNames.get(id);
|
|
3717
|
+
if (farName && farName !== name)
|
|
3718
|
+
report(`VLAN ${id} is ${name} here and ${farName} on ${link.device}`, "warning");
|
|
3719
|
+
}
|
|
3699
3720
|
}
|
|
3700
3721
|
}
|
|
3722
|
+
return findings;
|
|
3701
3723
|
}
|
|
3702
|
-
function
|
|
3703
|
-
const
|
|
3704
|
-
const
|
|
3705
|
-
|
|
3706
|
-
|
|
3707
|
-
|
|
3708
|
-
|
|
3709
|
-
|
|
3710
|
-
|
|
3711
|
-
if (pairs.has(ends))
|
|
3712
|
-
continue;
|
|
3713
|
-
pairs.add(ends);
|
|
3714
|
-
graph.edge({
|
|
3715
|
-
layer: "l3",
|
|
3716
|
-
from: deviceId(holder.device.name),
|
|
3717
|
-
to: deviceId(far.device.name),
|
|
3718
|
-
label: `${host(holder.address)} to ${host(far.address)}`,
|
|
3719
|
-
detail: [`${holder.device.name} ${holder.name} ${holder.address}`, `${far.device.name} ${far.name} ${far.address}`]
|
|
3720
|
-
});
|
|
3724
|
+
function checkAddresses(holders) {
|
|
3725
|
+
const findings = [];
|
|
3726
|
+
const first = new Map;
|
|
3727
|
+
const isLoopback = (holder) => holder.device.interfaces?.[holder.name]?.type === "loopback";
|
|
3728
|
+
for (const holder of holders) {
|
|
3729
|
+
const key = keyOf(host2(holder.address));
|
|
3730
|
+
const earlier = first.get(key);
|
|
3731
|
+
if (!earlier) {
|
|
3732
|
+
first.set(key, holder);
|
|
3721
3733
|
continue;
|
|
3722
3734
|
}
|
|
3723
|
-
if (
|
|
3735
|
+
if (earlier.device === holder.device)
|
|
3724
3736
|
continue;
|
|
3725
|
-
const
|
|
3726
|
-
|
|
3727
|
-
|
|
3728
|
-
|
|
3729
|
-
|
|
3730
|
-
|
|
3731
|
-
const memberLines = members.map((member) => `${member.device.name} ${member.name} ${member.address}`);
|
|
3732
|
-
const vrrpLines3 = members.flatMap((member) => member.vrrp.map((group) => `VRRP ${group.id} ${group.address} on ${member.device.name}, priority ${group.priority ?? 100}`));
|
|
3733
|
-
if (members.length === 2) {
|
|
3734
|
-
const [a, b] = members;
|
|
3735
|
-
graph.edge({
|
|
3736
|
-
layer: "l3",
|
|
3737
|
-
from: deviceId(a.device.name),
|
|
3738
|
-
to: deviceId(b.device.name),
|
|
3739
|
-
label: segment,
|
|
3740
|
-
detail: [...memberLines, ...vrrpLines3]
|
|
3741
|
-
});
|
|
3742
|
-
continue;
|
|
3743
|
-
}
|
|
3744
|
-
const id = graph.node(`segment:${segment}`, "segment", segment, [...memberLines, ...vrrpLines3]);
|
|
3745
|
-
for (const member of members) {
|
|
3746
|
-
graph.edge({
|
|
3747
|
-
layer: "l3",
|
|
3748
|
-
from: deviceId(member.device.name),
|
|
3749
|
-
to: id,
|
|
3750
|
-
label: `${member.name} ${host(member.address)}`,
|
|
3751
|
-
detail: [`${member.device.name} ${member.name} ${member.address}`]
|
|
3752
|
-
});
|
|
3753
|
-
}
|
|
3737
|
+
const anycast = isLoopback(earlier) && isLoopback(holder);
|
|
3738
|
+
findings.push({
|
|
3739
|
+
level: anycast ? "warning" : "error",
|
|
3740
|
+
device: holder.device.name,
|
|
3741
|
+
message: `${host2(holder.address)} on ${holder.name} is also on ${earlier.device.name} ${earlier.name}${anycast ? ", which is right only for anycast" : ""}`
|
|
3742
|
+
});
|
|
3754
3743
|
}
|
|
3744
|
+
return findings;
|
|
3755
3745
|
}
|
|
3756
|
-
function
|
|
3757
|
-
const
|
|
3746
|
+
function checkSessions(devices, owners) {
|
|
3747
|
+
const findings = [];
|
|
3748
|
+
const sessionsOf = (device) => {
|
|
3758
3749
|
const bgp = device.routing?.bgp;
|
|
3759
3750
|
return Object.entries(bgp?.neighbors ?? {}).map(([name, neighbor]) => {
|
|
3760
3751
|
const group = neighbor.group ? bgp?.groups?.[neighbor.group] : undefined;
|
|
3761
3752
|
return {
|
|
3762
|
-
device,
|
|
3763
3753
|
name,
|
|
3764
3754
|
address: neighbor.address,
|
|
3765
|
-
ownAs: bgp.asn,
|
|
3766
3755
|
remoteAs: neighbor.remote_as ?? group?.remote_as,
|
|
3767
|
-
|
|
3768
|
-
description: neighbor.description ?? group?.description,
|
|
3769
|
-
import: neighbor.import ?? group?.import,
|
|
3770
|
-
export: neighbor.export ?? group?.export
|
|
3756
|
+
localAddress: neighbor.local_address ?? group?.local_address
|
|
3771
3757
|
};
|
|
3772
3758
|
});
|
|
3773
|
-
});
|
|
3774
|
-
const describe2 = (session) => {
|
|
3775
|
-
const parts = [
|
|
3776
|
-
session.address,
|
|
3777
|
-
session.remoteAs ? `AS${session.remoteAs}` : "no remote AS",
|
|
3778
|
-
session.role,
|
|
3779
|
-
session.import ? `import ${session.import}` : "imports nothing",
|
|
3780
|
-
session.export ? `export ${session.export}` : "exports nothing"
|
|
3781
|
-
];
|
|
3782
|
-
return `${session.device.name} ${session.name}: ${parts.filter(Boolean).join(", ")}`;
|
|
3783
3759
|
};
|
|
3784
|
-
const
|
|
3785
|
-
|
|
3786
|
-
|
|
3787
|
-
|
|
3788
|
-
|
|
3789
|
-
|
|
3790
|
-
|
|
3791
|
-
|
|
3792
|
-
|
|
3793
|
-
|
|
3794
|
-
|
|
3795
|
-
|
|
3796
|
-
|
|
3797
|
-
|
|
3798
|
-
|
|
3799
|
-
|
|
3800
|
-
|
|
3801
|
-
|
|
3802
|
-
|
|
3760
|
+
for (const device of devices) {
|
|
3761
|
+
for (const session of sessionsOf(device)) {
|
|
3762
|
+
const far = owners.get(keyOf(session.address) ?? "")?.device;
|
|
3763
|
+
if (!far || far === device)
|
|
3764
|
+
continue;
|
|
3765
|
+
const report = (message) => findings.push({ level: "error", device: device.name, message: `neighbor ${session.name}: ${message}` });
|
|
3766
|
+
const farBgp = far.routing?.bgp;
|
|
3767
|
+
if (!farBgp) {
|
|
3768
|
+
report(`${session.address} is ${far.name}'s, which runs no BGP`);
|
|
3769
|
+
continue;
|
|
3770
|
+
}
|
|
3771
|
+
if (session.remoteAs !== undefined && session.remoteAs !== farBgp.asn) {
|
|
3772
|
+
report(`expects AS${session.remoteAs}, and ${far.name} is AS${farBgp.asn}`);
|
|
3773
|
+
}
|
|
3774
|
+
const back = sessionsOf(far).find((other) => owners.get(keyOf(other.address) ?? "")?.device === device && familyOf2(other.address) === familyOf2(session.address));
|
|
3775
|
+
if (!back) {
|
|
3776
|
+
report(`${far.name} has no session back to ${device.name}`);
|
|
3777
|
+
continue;
|
|
3778
|
+
}
|
|
3779
|
+
if (session.localAddress && keyOf(session.localAddress) !== keyOf(back.address)) {
|
|
3780
|
+
report(`starts from ${session.localAddress}, and ${far.name}'s session ${back.name} expects ${back.address}`);
|
|
3781
|
+
}
|
|
3803
3782
|
}
|
|
3804
|
-
const outside = session.remoteAs ? graph.node(`as:${session.remoteAs}`, "outside", `AS${session.remoteAs}`, session.description ? [session.description] : []) : graph.node(`address:${session.address}`, "outside", session.address, ["a BGP neighbor without a remote AS"]);
|
|
3805
|
-
const internal = session.remoteAs === session.ownAs;
|
|
3806
|
-
graph.edge({
|
|
3807
|
-
layer: "bgp",
|
|
3808
|
-
from: deviceId(session.device.name),
|
|
3809
|
-
to: outside,
|
|
3810
|
-
label: [session.name, session.role].filter(Boolean).join(", "),
|
|
3811
|
-
detail: [describe2(session), ...internal ? ["internal, and no device in the network holds the address"] : []],
|
|
3812
|
-
...internal ? { oneSided: true } : {}
|
|
3813
|
-
});
|
|
3814
3783
|
}
|
|
3784
|
+
return findings;
|
|
3815
3785
|
}
|
|
3816
|
-
function
|
|
3817
|
-
const
|
|
3818
|
-
|
|
3819
|
-
|
|
3820
|
-
|
|
3821
|
-
|
|
3822
|
-
|
|
3823
|
-
|
|
3824
|
-
|
|
3825
|
-
|
|
3826
|
-
|
|
3827
|
-
if (!isCidr(address) || isHostRoute(address))
|
|
3828
|
-
continue;
|
|
3829
|
-
const key = `${area} ${network(address)}`;
|
|
3830
|
-
const member = { device, name, line: `${device.name} ${name} ${address}${cost}` };
|
|
3831
|
-
byKey.set(key, [...byKey.get(key) ?? [], member]);
|
|
3832
|
-
}
|
|
3833
|
-
}
|
|
3786
|
+
function checkVrrp(holders) {
|
|
3787
|
+
const findings = [];
|
|
3788
|
+
const bySegment = new Map;
|
|
3789
|
+
for (const holder of holders) {
|
|
3790
|
+
const segment = network(holder.address);
|
|
3791
|
+
for (const group of holder.vrrp) {
|
|
3792
|
+
if (!keyOf(group.address) || familyOf2(group.address) !== familyOf2(holder.address))
|
|
3793
|
+
continue;
|
|
3794
|
+
if (!contains2(segment, group.address))
|
|
3795
|
+
continue;
|
|
3796
|
+
bySegment.set(segment, [...bySegment.get(segment) ?? [], { holder, id: group.id, address: group.address }]);
|
|
3834
3797
|
}
|
|
3835
3798
|
}
|
|
3836
|
-
for (const
|
|
3837
|
-
const
|
|
3838
|
-
|
|
3839
|
-
|
|
3840
|
-
|
|
3841
|
-
|
|
3842
|
-
|
|
3843
|
-
|
|
3844
|
-
|
|
3845
|
-
|
|
3846
|
-
|
|
3847
|
-
|
|
3799
|
+
for (const members of bySegment.values()) {
|
|
3800
|
+
for (const member of members) {
|
|
3801
|
+
const where = `${member.holder.device.name} ${member.holder.name}`;
|
|
3802
|
+
const others = members.filter((other) => other.holder.device !== member.holder.device);
|
|
3803
|
+
const sameId = others.find((other) => other.id === member.id && keyOf(other.address) !== keyOf(member.address));
|
|
3804
|
+
if (sameId) {
|
|
3805
|
+
findings.push({
|
|
3806
|
+
level: "error",
|
|
3807
|
+
device: member.holder.device.name,
|
|
3808
|
+
message: `VRRP ${member.id} on ${member.holder.name} is ${member.address}, and on ${sameId.holder.device.name} ${sameId.holder.name} it is ${sameId.address}`
|
|
3809
|
+
});
|
|
3810
|
+
}
|
|
3811
|
+
const sameAddress = others.find((other) => keyOf(other.address) === keyOf(member.address) && other.id !== member.id);
|
|
3812
|
+
if (sameAddress) {
|
|
3813
|
+
findings.push({
|
|
3814
|
+
level: "error",
|
|
3815
|
+
device: member.holder.device.name,
|
|
3816
|
+
message: `VRRP address ${member.address} is id ${member.id} on ${where}, and id ${sameAddress.id} on ${sameAddress.holder.device.name} ${sameAddress.holder.name}`
|
|
3848
3817
|
});
|
|
3849
3818
|
}
|
|
3850
3819
|
}
|
|
3851
3820
|
}
|
|
3821
|
+
return findings;
|
|
3852
3822
|
}
|
|
3853
|
-
|
|
3854
|
-
|
|
3823
|
+
function checkTunnels(devices, owners) {
|
|
3824
|
+
const findings = [];
|
|
3855
3825
|
const ends = devices.flatMap((device) => Object.entries(device.interfaces ?? {}).flatMap(([name, iface]) => iface.type === "gre" || iface.type === "vxlan" ? [{ device, name, iface }] : []));
|
|
3856
3826
|
const same = (a, b) => keyOf(a) !== undefined && keyOf(a) === keyOf(b);
|
|
3857
|
-
const mirrors = (a, b) => a.device !== b.device && a.iface.type === b.iface.type && same(a.iface.local, b.iface.remote) && same(a.iface.remote, b.iface.local) && (a.iface.type !== "vxlan" || b.iface.type === "vxlan" && a.iface.vni === b.iface.vni);
|
|
3858
|
-
const describe2 = (end) => {
|
|
3859
|
-
const vni = end.iface.type === "vxlan" ? `, VNI ${end.iface.vni}` : "";
|
|
3860
|
-
return `${end.device.name} ${end.name}: ${end.iface.local} to ${end.iface.remote}${vni}`;
|
|
3861
|
-
};
|
|
3862
|
-
const paired = new Set;
|
|
3863
3827
|
for (const end of ends) {
|
|
3864
|
-
|
|
3828
|
+
const far = owners.get(keyOf(end.iface.remote) ?? "")?.device;
|
|
3829
|
+
if (!far || far === end.device)
|
|
3865
3830
|
continue;
|
|
3831
|
+
const report = (message) => findings.push({ level: "error", device: end.device.name, message: `interface ${end.name}: ${message}` });
|
|
3866
3832
|
const kind = end.iface.type === "gre" ? "GRE" : "VXLAN";
|
|
3867
|
-
const mirror = ends.find((other) =>
|
|
3868
|
-
if (mirror) {
|
|
3869
|
-
|
|
3870
|
-
graph.edge({
|
|
3871
|
-
layer: "tunnels",
|
|
3872
|
-
from: deviceId(end.device.name),
|
|
3873
|
-
to: deviceId(mirror.device.name),
|
|
3874
|
-
label: `${kind} ${end.name} to ${mirror.name}`,
|
|
3875
|
-
detail: [describe2(end), describe2(mirror)]
|
|
3876
|
-
});
|
|
3877
|
-
continue;
|
|
3878
|
-
}
|
|
3879
|
-
const owner = owners.get(keyOf(end.iface.remote) ?? "")?.device;
|
|
3880
|
-
if (owner && owner !== end.device) {
|
|
3881
|
-
graph.edge({
|
|
3882
|
-
layer: "tunnels",
|
|
3883
|
-
from: deviceId(end.device.name),
|
|
3884
|
-
to: deviceId(owner.name),
|
|
3885
|
-
label: `${kind} ${end.name}`,
|
|
3886
|
-
detail: [describe2(end), `${owner.name} declares no tunnel back`],
|
|
3887
|
-
oneSided: true
|
|
3888
|
-
});
|
|
3833
|
+
const mirror = ends.find((other) => other.device === far && other.iface.type === end.iface.type && same(other.iface.local, end.iface.remote) && same(other.iface.remote, end.iface.local));
|
|
3834
|
+
if (!mirror) {
|
|
3835
|
+
report(`${end.iface.remote} is ${far.name}'s, which has no ${kind} tunnel from there back to ${end.iface.local}`);
|
|
3889
3836
|
continue;
|
|
3890
3837
|
}
|
|
3891
|
-
|
|
3892
|
-
graph.edge({
|
|
3893
|
-
layer: "tunnels",
|
|
3894
|
-
from: deviceId(end.device.name),
|
|
3895
|
-
to: outside,
|
|
3896
|
-
label: `${kind} ${end.name}`,
|
|
3897
|
-
detail: [describe2(end)]
|
|
3898
|
-
});
|
|
3899
|
-
}
|
|
3900
|
-
wireguard2(devices, owners, graph);
|
|
3901
|
-
}
|
|
3902
|
-
function wireguard2(devices, owners, graph) {
|
|
3903
|
-
const interfaces = devices.flatMap((device) => Object.entries(device.interfaces ?? {}).flatMap(([name, iface]) => iface.type === "wireguard" ? [{ device, name, iface }] : []));
|
|
3904
|
-
const farEndOf = (device, peer) => interfaces.find(({ device: other, iface }) => {
|
|
3905
|
-
if (other === device)
|
|
3906
|
-
return false;
|
|
3907
|
-
const tunnelAddresses = (iface.addresses ?? []).map((entry) => host(typeof entry === "string" ? entry : entry.address));
|
|
3908
|
-
const sendsFrom = tunnelAddresses.some((address) => isAddress(address) && peer.allowed_addresses.some((allowed) => isCidr(allowed) && contains(allowed, address)));
|
|
3909
|
-
const endpoint = peer.endpoint ? endpointHost(peer.endpoint) : undefined;
|
|
3910
|
-
const isEndpoint = endpoint !== undefined && owners.get(keyOf(endpoint) ?? "")?.device === other;
|
|
3911
|
-
return sendsFrom || isEndpoint;
|
|
3912
|
-
});
|
|
3913
|
-
const drawn = new Set;
|
|
3914
|
-
for (const { device, name, iface } of interfaces) {
|
|
3915
|
-
const clients = [];
|
|
3916
|
-
for (const peer of iface.peers ?? []) {
|
|
3917
|
-
const far = farEndOf(device, peer);
|
|
3918
|
-
if (far) {
|
|
3919
|
-
const ends = [`${device.name} ${name}`, `${far.device.name} ${far.name}`].sort().join("|");
|
|
3920
|
-
if (drawn.has(ends))
|
|
3921
|
-
continue;
|
|
3922
|
-
drawn.add(ends);
|
|
3923
|
-
graph.edge({
|
|
3924
|
-
layer: "tunnels",
|
|
3925
|
-
from: deviceId(device.name),
|
|
3926
|
-
to: deviceId(far.device.name),
|
|
3927
|
-
label: `WireGuard ${name} to ${far.name}`,
|
|
3928
|
-
detail: [`${device.name} ${name} peer ${peer.name}`]
|
|
3929
|
-
});
|
|
3930
|
-
continue;
|
|
3931
|
-
}
|
|
3932
|
-
if (!peer.endpoint) {
|
|
3933
|
-
clients.push(peer);
|
|
3934
|
-
continue;
|
|
3935
|
-
}
|
|
3936
|
-
const endpoint = endpointHost(peer.endpoint);
|
|
3937
|
-
const outside2 = graph.node(`address:${endpoint}`, "outside", peer.name, [`WireGuard endpoint ${peer.endpoint}`]);
|
|
3938
|
-
graph.edge({
|
|
3939
|
-
layer: "tunnels",
|
|
3940
|
-
from: deviceId(device.name),
|
|
3941
|
-
to: outside2,
|
|
3942
|
-
label: `WireGuard ${name}`,
|
|
3943
|
-
detail: [`${device.name} ${name} peer ${peer.name}, allowed ${peer.allowed_addresses.join(", ")}`]
|
|
3944
|
-
});
|
|
3945
|
-
}
|
|
3946
|
-
if (!clients.length)
|
|
3838
|
+
if (end.iface.type !== "vxlan" || mirror.iface.type !== "vxlan")
|
|
3947
3839
|
continue;
|
|
3948
|
-
|
|
3949
|
-
|
|
3950
|
-
|
|
3951
|
-
|
|
3952
|
-
|
|
3953
|
-
|
|
3954
|
-
detail: [`peers that dial in to ${device.name} ${name}`]
|
|
3955
|
-
});
|
|
3956
|
-
}
|
|
3957
|
-
}
|
|
3958
|
-
function graphOf(devices) {
|
|
3959
|
-
const graph = new Builder;
|
|
3960
|
-
for (const device of devices)
|
|
3961
|
-
graph.node(deviceId(device.name), "device", device.name, describeDevice(device));
|
|
3962
|
-
const holders2 = holdersOf(devices);
|
|
3963
|
-
const owners = new Map;
|
|
3964
|
-
for (const holder of holders2) {
|
|
3965
|
-
const key = keyOf(host(holder.address));
|
|
3966
|
-
if (!owners.has(key))
|
|
3967
|
-
owners.set(key, holder);
|
|
3840
|
+
if (end.iface.vni !== mirror.iface.vni)
|
|
3841
|
+
report(`VNI ${end.iface.vni}, and ${far.name} ${mirror.name} has ${mirror.iface.vni}`);
|
|
3842
|
+
const port = end.iface.port ?? 4789;
|
|
3843
|
+
const farPort = mirror.iface.port ?? 4789;
|
|
3844
|
+
if (port !== farPort)
|
|
3845
|
+
report(`UDP port ${port}, and ${far.name} ${mirror.name} has ${farPort}`);
|
|
3968
3846
|
}
|
|
3969
|
-
|
|
3970
|
-
segments(holders2, owners, graph);
|
|
3971
|
-
sessions(devices, owners, graph);
|
|
3972
|
-
adjacencies(devices, graph);
|
|
3973
|
-
tunnels2(devices, owners, graph);
|
|
3974
|
-
return { nodes: [...graph.nodes.values()], edges: graph.edges };
|
|
3975
|
-
}
|
|
3976
|
-
function only(graph, layers, deviceNames = []) {
|
|
3977
|
-
const named = new Set(deviceNames.map(deviceId));
|
|
3978
|
-
const touches = (edge) => !named.size || named.has(edge.from) || named.has(edge.to);
|
|
3979
|
-
const edges = graph.edges.filter((edge) => layers.includes(edge.layer) && touches(edge));
|
|
3980
|
-
const ends = new Set(edges.flatMap((edge) => [edge.from, edge.to]));
|
|
3981
|
-
const nodes = graph.nodes.filter((node) => (named.size ? named.has(node.id) : node.kind === "device") || ends.has(node.id));
|
|
3982
|
-
return { nodes, edges };
|
|
3847
|
+
return findings;
|
|
3983
3848
|
}
|
|
3984
3849
|
|
|
3985
3850
|
// src/core/validate.ts
|
|
3986
|
-
|
|
3987
|
-
function validate(network2) {
|
|
3851
|
+
function validate(network) {
|
|
3988
3852
|
const findings = [];
|
|
3989
3853
|
const seen = new Set;
|
|
3990
|
-
for (const device of
|
|
3854
|
+
for (const device of network.devices) {
|
|
3991
3855
|
if (seen.has(device.name))
|
|
3992
3856
|
findings.push({ level: "error", device: device.name, message: "declared twice" });
|
|
3993
3857
|
seen.add(device.name);
|
|
3994
3858
|
}
|
|
3995
|
-
for (const device of
|
|
3859
|
+
for (const device of network.devices) {
|
|
3996
3860
|
const report = (message) => findings.push({ level: "error", device: device.name, message });
|
|
3997
3861
|
checkDevice(device, report);
|
|
3998
3862
|
const adapter = adapters[device.platform];
|
|
@@ -4002,9 +3866,9 @@ function validate(network2) {
|
|
|
4002
3866
|
for (const field of adapter.unsupported(device))
|
|
4003
3867
|
report(`${device.platform} cannot express ${field}`);
|
|
4004
3868
|
}
|
|
4005
|
-
findings.push(...
|
|
4006
|
-
for (const check of
|
|
4007
|
-
findings.push(...check(
|
|
3869
|
+
findings.push(...checkAcross(network.devices));
|
|
3870
|
+
for (const check of network.checks ?? [])
|
|
3871
|
+
findings.push(...check(network.devices));
|
|
4008
3872
|
return findings;
|
|
4009
3873
|
}
|
|
4010
3874
|
|
|
@@ -4036,18 +3900,18 @@ class Names {
|
|
|
4036
3900
|
}
|
|
4037
3901
|
function checkDevice(device, report) {
|
|
4038
3902
|
const names = new Names(device, report);
|
|
4039
|
-
const cidr = (where,
|
|
4040
|
-
if (!
|
|
4041
|
-
report(`${where}: '${
|
|
3903
|
+
const cidr = (where, value) => {
|
|
3904
|
+
if (!isCidr2(value))
|
|
3905
|
+
report(`${where}: '${value}' is not an address with a length`);
|
|
4042
3906
|
};
|
|
4043
|
-
const ip = (where,
|
|
4044
|
-
if (!
|
|
4045
|
-
report(`${where}: '${
|
|
3907
|
+
const ip = (where, value) => {
|
|
3908
|
+
if (!isAddress2(value))
|
|
3909
|
+
report(`${where}: '${value}' is not an address`);
|
|
4046
3910
|
};
|
|
4047
3911
|
if (!device.users?.[device.connection.user])
|
|
4048
3912
|
report(`connection user '${device.connection.user}' is not one of its users`);
|
|
4049
3913
|
checkVlans(device, report);
|
|
4050
|
-
|
|
3914
|
+
checkAddresses2(device, report, cidr, ip);
|
|
4051
3915
|
checkPorts(device, names, report);
|
|
4052
3916
|
checkInterfaces(device, names, report, ip);
|
|
4053
3917
|
checkRouting(device, names, report, cidr, ip);
|
|
@@ -4066,13 +3930,13 @@ function checkVlans(device, report) {
|
|
|
4066
3930
|
ids.set(vlan.id, name);
|
|
4067
3931
|
}
|
|
4068
3932
|
}
|
|
4069
|
-
function
|
|
3933
|
+
function checkAddresses2(device, report, cidr, ip) {
|
|
4070
3934
|
const holderOf = new Map;
|
|
4071
|
-
const
|
|
3935
|
+
const holders = [
|
|
4072
3936
|
...Object.entries(device.ports ?? {}).map(([name, settings]) => ({ where: `port ${name}`, settings })),
|
|
4073
3937
|
...Object.entries(device.interfaces ?? {}).map(([name, settings]) => ({ where: `interface ${name}`, settings }))
|
|
4074
3938
|
];
|
|
4075
|
-
for (const { where, settings } of
|
|
3939
|
+
for (const { where, settings } of holders) {
|
|
4076
3940
|
for (const entry of settings?.addresses ?? []) {
|
|
4077
3941
|
const address = typeof entry === "string" ? entry : entry.address;
|
|
4078
3942
|
cidr(where, address);
|
|
@@ -4135,7 +3999,7 @@ function checkInterfaces(device, names, report, ip) {
|
|
|
4135
3999
|
if (iface.type === "wireguard") {
|
|
4136
4000
|
for (const peer of iface.peers ?? []) {
|
|
4137
4001
|
for (const address of peer.allowed_addresses) {
|
|
4138
|
-
if (!
|
|
4002
|
+
if (!isCidr2(address))
|
|
4139
4003
|
report(`interface ${name} peer ${peer.name}: '${address}' is not an address with a length`);
|
|
4140
4004
|
}
|
|
4141
4005
|
}
|
|
@@ -4144,7 +4008,7 @@ function checkInterfaces(device, names, report, ip) {
|
|
|
4144
4008
|
}
|
|
4145
4009
|
function checkRouting(device, names, report, cidr, ip) {
|
|
4146
4010
|
const routing = device.routing;
|
|
4147
|
-
const
|
|
4011
|
+
const policies = device.policies ?? {};
|
|
4148
4012
|
for (const route of routing?.static ?? []) {
|
|
4149
4013
|
cidr("static route", route.prefix);
|
|
4150
4014
|
if (!route.via && !route.interface && !route.blackhole)
|
|
@@ -4155,7 +4019,7 @@ function checkRouting(device, names, report, cidr, ip) {
|
|
|
4155
4019
|
names.expect(`static route ${route.prefix} VRF`, route.vrf, route.vrf !== undefined && route.vrf in (device.vrfs ?? {}));
|
|
4156
4020
|
}
|
|
4157
4021
|
const bgp = routing?.bgp;
|
|
4158
|
-
const isPolicy = (policy) => policy !== undefined && (policy in
|
|
4022
|
+
const isPolicy = (policy) => policy !== undefined && (policy in policies);
|
|
4159
4023
|
for (const prefix of bgp?.networks ?? [])
|
|
4160
4024
|
cidr("BGP network", prefix);
|
|
4161
4025
|
for (const [name, group] of Object.entries(bgp?.groups ?? {})) {
|
|
@@ -4173,7 +4037,7 @@ function checkRouting(device, names, report, cidr, ip) {
|
|
|
4173
4037
|
names.expect(`neighbor ${name} policy`, policy, isPolicy(policy));
|
|
4174
4038
|
}
|
|
4175
4039
|
const local = neighbor.local_address ?? group?.local_address;
|
|
4176
|
-
if (local &&
|
|
4040
|
+
if (local && familyOf2(local) !== familyOf2(neighbor.address))
|
|
4177
4041
|
report(`neighbor ${name} local_address and address differ in family`);
|
|
4178
4042
|
}
|
|
4179
4043
|
for (const [areaId, area] of Object.entries(routing?.ospf?.areas ?? {})) {
|
|
@@ -4184,11 +4048,11 @@ function checkRouting(device, names, report, cidr, ip) {
|
|
|
4184
4048
|
}
|
|
4185
4049
|
}
|
|
4186
4050
|
function checkPolicies(device, names, report, cidr) {
|
|
4187
|
-
const
|
|
4051
|
+
const policies = device.policies ?? {};
|
|
4188
4052
|
const sets = device.prefix_sets ?? {};
|
|
4189
|
-
for (const [name, rules] of Object.entries(
|
|
4053
|
+
for (const [name, rules] of Object.entries(policies)) {
|
|
4190
4054
|
for (const rule of rules) {
|
|
4191
|
-
names.expect(`policy ${name} call`, rule.call, rule.call !== undefined && rule.call in
|
|
4055
|
+
names.expect(`policy ${name} call`, rule.call, rule.call !== undefined && rule.call in policies);
|
|
4192
4056
|
const set = rule.match?.prefix_set;
|
|
4193
4057
|
names.expect(`policy ${name} prefix set`, set, set !== undefined && set in sets);
|
|
4194
4058
|
if (rule.match?.prefix)
|
|
@@ -4198,11 +4062,11 @@ function checkPolicies(device, names, report, cidr) {
|
|
|
4198
4062
|
const visit = (policy, path) => {
|
|
4199
4063
|
if (path.includes(policy))
|
|
4200
4064
|
return report(`policy ${[...path, policy].join(" -> ")} calls itself`);
|
|
4201
|
-
for (const rule of
|
|
4065
|
+
for (const rule of policies[policy] ?? [])
|
|
4202
4066
|
if (rule.call)
|
|
4203
4067
|
visit(rule.call, [...path, policy]);
|
|
4204
4068
|
};
|
|
4205
|
-
for (const policy of Object.keys(
|
|
4069
|
+
for (const policy of Object.keys(policies))
|
|
4206
4070
|
visit(policy, []);
|
|
4207
4071
|
}
|
|
4208
4072
|
function checkFirewall(device, names, cidr) {
|
|
@@ -4210,7 +4074,7 @@ function checkFirewall(device, names, cidr) {
|
|
|
4210
4074
|
const sets = firewall?.address_sets ?? {};
|
|
4211
4075
|
for (const [name, members] of Object.entries(sets)) {
|
|
4212
4076
|
for (const member of members) {
|
|
4213
|
-
if (!
|
|
4077
|
+
if (!isAddress2(member))
|
|
4214
4078
|
cidr(`address set ${name}`, member);
|
|
4215
4079
|
}
|
|
4216
4080
|
}
|
|
@@ -4236,12 +4100,12 @@ function checkServices(device, names, report, cidr) {
|
|
|
4236
4100
|
for (const [name, server] of Object.entries(device.dhcp ?? {})) {
|
|
4237
4101
|
names.expectInterface(`DHCP ${name} interface`, server.interface);
|
|
4238
4102
|
cidr(`DHCP ${name}`, server.network);
|
|
4239
|
-
if (!
|
|
4103
|
+
if (!isCidr2(server.network))
|
|
4240
4104
|
continue;
|
|
4241
4105
|
const reserved = (server.reservations ?? []).map((reservation) => reservation.address);
|
|
4242
|
-
const
|
|
4243
|
-
for (const address of
|
|
4244
|
-
if (!
|
|
4106
|
+
const addresses = [server.gateway, ...server.pool, ...reserved];
|
|
4107
|
+
for (const address of addresses) {
|
|
4108
|
+
if (!contains2(server.network, address))
|
|
4245
4109
|
report(`DHCP ${name}: ${address} is outside ${server.network}`);
|
|
4246
4110
|
}
|
|
4247
4111
|
}
|
|
@@ -4263,8 +4127,8 @@ function checkServices(device, names, report, cidr) {
|
|
|
4263
4127
|
report("SNMP is on with neither a community nor a user");
|
|
4264
4128
|
}
|
|
4265
4129
|
function checkCertificates(device, names, report) {
|
|
4266
|
-
const
|
|
4267
|
-
for (const [name, certificate] of Object.entries(
|
|
4130
|
+
const certificates = device.certificates ?? {};
|
|
4131
|
+
for (const [name, certificate] of Object.entries(certificates)) {
|
|
4268
4132
|
try {
|
|
4269
4133
|
new X509Certificate2(certificate.certificate);
|
|
4270
4134
|
} catch {
|
|
@@ -4273,54 +4137,12 @@ function checkCertificates(device, names, report) {
|
|
|
4273
4137
|
}
|
|
4274
4138
|
for (const service of ["https", "api_tls"]) {
|
|
4275
4139
|
const name = device.management?.[service]?.certificate;
|
|
4276
|
-
names.expect(`${service} certificate`, name, name !== undefined && name in
|
|
4277
|
-
if (name &&
|
|
4140
|
+
names.expect(`${service} certificate`, name, name !== undefined && name in certificates);
|
|
4141
|
+
if (name && certificates[name] && !certificates[name].private_key) {
|
|
4278
4142
|
report(`${service} certificate ${name} has no private key to serve with`);
|
|
4279
4143
|
}
|
|
4280
4144
|
}
|
|
4281
4145
|
}
|
|
4282
|
-
function carried(device, port) {
|
|
4283
|
-
const id = (vlan) => vlan ? device.vlans?.[vlan]?.id : undefined;
|
|
4284
|
-
return {
|
|
4285
|
-
access: id(port.access_vlan),
|
|
4286
|
-
native: id(port.native_vlan),
|
|
4287
|
-
trunk: (port.trunk_vlans ?? []).map(id).sort((a, b) => (a ?? 0) - (b ?? 0))
|
|
4288
|
-
};
|
|
4289
|
-
}
|
|
4290
|
-
function checkLinks(devices) {
|
|
4291
|
-
const findings = [];
|
|
4292
|
-
const byName = new Map(devices.map((device) => [device.name, device]));
|
|
4293
|
-
for (const device of devices) {
|
|
4294
|
-
for (const [portName, port] of Object.entries(device.ports ?? {})) {
|
|
4295
|
-
const link = port?.link;
|
|
4296
|
-
if (!port || !link)
|
|
4297
|
-
continue;
|
|
4298
|
-
const report = (message) => findings.push({
|
|
4299
|
-
level: "error",
|
|
4300
|
-
device: device.name,
|
|
4301
|
-
message: `port ${portName} link: ${message}`
|
|
4302
|
-
});
|
|
4303
|
-
const far = byName.get(link.device);
|
|
4304
|
-
if (!far) {
|
|
4305
|
-
report(`device ${link.device} is not in the network`);
|
|
4306
|
-
continue;
|
|
4307
|
-
}
|
|
4308
|
-
const farPort = far.ports?.[link.port];
|
|
4309
|
-
if (!farPort) {
|
|
4310
|
-
report(`${link.device} does not declare port ${link.port}`);
|
|
4311
|
-
continue;
|
|
4312
|
-
}
|
|
4313
|
-
const linksBack = farPort.link?.device === device.name && farPort.link.port === portName;
|
|
4314
|
-
if (!linksBack)
|
|
4315
|
-
report(`${link.device} ${link.port} does not link back to it`);
|
|
4316
|
-
const here = JSON.stringify(carried(device, port));
|
|
4317
|
-
const there = JSON.stringify(carried(far, farPort));
|
|
4318
|
-
if (here !== there)
|
|
4319
|
-
report(`carries ${here}, and ${link.device} ${link.port} carries ${there}`);
|
|
4320
|
-
}
|
|
4321
|
-
}
|
|
4322
|
-
return findings;
|
|
4323
|
-
}
|
|
4324
4146
|
|
|
4325
4147
|
// src/cli/graph.ts
|
|
4326
4148
|
function markOf(graph, node) {
|
|
@@ -4336,7 +4158,7 @@ function markOf(graph, node) {
|
|
|
4336
4158
|
}
|
|
4337
4159
|
function mermaid(graph) {
|
|
4338
4160
|
const ids = new Map(graph.nodes.map((node, index) => [node.id, `n${index}`]));
|
|
4339
|
-
const text = (...
|
|
4161
|
+
const text = (...lines) => `"${lines.map((line) => line.replace(/"/g, "#quot;").replace(/</g, "#lt;").replace(/>/g, "#gt;")).join("<br/>")}"`;
|
|
4340
4162
|
const shapes = {
|
|
4341
4163
|
device: (label) => `[${label}]`,
|
|
4342
4164
|
segment: (label) => `([${label}])`,
|
|
@@ -4362,7 +4184,7 @@ function mermaid(graph) {
|
|
|
4362
4184
|
`;
|
|
4363
4185
|
}
|
|
4364
4186
|
function dot(graph) {
|
|
4365
|
-
const text = (
|
|
4187
|
+
const text = (value) => `"${value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`;
|
|
4366
4188
|
const colors = { error: "#d1242f", warning: "#bf8700" };
|
|
4367
4189
|
const shapes = {
|
|
4368
4190
|
device: "shape=box, style=rounded",
|
|
@@ -4392,6 +4214,116 @@ ${node.detail[0] ?? ""}` : node.label;
|
|
|
4392
4214
|
`;
|
|
4393
4215
|
}
|
|
4394
4216
|
|
|
4217
|
+
// src/cli/rename.ts
|
|
4218
|
+
import { existsSync as existsSync2, readFileSync as readFileSync3, renameSync, writeFileSync } from "node:fs";
|
|
4219
|
+
import { basename, dirname, extname, join as join3, resolve as resolve2 } from "node:path";
|
|
4220
|
+
|
|
4221
|
+
// src/core/files.ts
|
|
4222
|
+
import { readdirSync, readFileSync as readFileSync2 } from "node:fs";
|
|
4223
|
+
import { join as join2, relative } from "node:path";
|
|
4224
|
+
var SKIPPED = new Set(["node_modules", ".git", ".circuit", "dist"]);
|
|
4225
|
+
function projectSources(root, directory = root) {
|
|
4226
|
+
const entries = readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
|
|
4227
|
+
return entries.flatMap((entry) => {
|
|
4228
|
+
if (entry.isDirectory())
|
|
4229
|
+
return SKIPPED.has(entry.name) ? [] : projectSources(root, join2(directory, entry.name));
|
|
4230
|
+
return /\.(ts|mts|cts)$/.test(entry.name) ? [relative(root, join2(directory, entry.name))] : [];
|
|
4231
|
+
});
|
|
4232
|
+
}
|
|
4233
|
+
var FIRST_NAME = /^[\s\S]*?\bname\s*:\s*(["'`])(.*?)\1/;
|
|
4234
|
+
function declaresDevice(text, name) {
|
|
4235
|
+
return [...text.matchAll(/\bdefineDevice\s*\(/g)].some((call) => FIRST_NAME.exec(text.slice(call.index + call[0].length))?.[2] === name);
|
|
4236
|
+
}
|
|
4237
|
+
function deviceFiles(root, name, sources = projectSources(root)) {
|
|
4238
|
+
return sources.filter((file) => declaresDevice(readFileSync2(join2(root, file), "utf8"), name));
|
|
4239
|
+
}
|
|
4240
|
+
|
|
4241
|
+
// src/cli/rename.ts
|
|
4242
|
+
var escape = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
4243
|
+
function planRename(root, from, to, options) {
|
|
4244
|
+
const changes = [];
|
|
4245
|
+
const moves = [];
|
|
4246
|
+
const exact = new RegExp(`(["'\`])${escape(from)}\\1`, "g");
|
|
4247
|
+
const anywhere = new RegExp(`(^|[^A-Za-z0-9_.-])${escape(from)}(?![A-Za-z0-9_-])`);
|
|
4248
|
+
const sources = projectSources(root);
|
|
4249
|
+
const declaring = new Set;
|
|
4250
|
+
for (const file of sources) {
|
|
4251
|
+
const text = readFileSync3(join3(root, file), "utf8");
|
|
4252
|
+
const lines = text.split(`
|
|
4253
|
+
`);
|
|
4254
|
+
if (declaresDevice(text, from))
|
|
4255
|
+
declaring.add(file);
|
|
4256
|
+
for (const [index, before] of lines.entries()) {
|
|
4257
|
+
const after = before.replace(exact, (_match, quote) => `${quote}${to}${quote}`);
|
|
4258
|
+
if (after !== before)
|
|
4259
|
+
changes.push({ file, line: index + 1, before, after });
|
|
4260
|
+
}
|
|
4261
|
+
}
|
|
4262
|
+
if (!declaring.size) {
|
|
4263
|
+
throw new Error(`no file declares name: "${from}"; the name may be built from parts, such as a template string, so rename it by hand`);
|
|
4264
|
+
}
|
|
4265
|
+
for (const snapshot of options.snapshots) {
|
|
4266
|
+
moves.push({ from: snapshot, to: join3(dirname(snapshot), `${to}${extname(snapshot)}`) });
|
|
4267
|
+
}
|
|
4268
|
+
if (options.file) {
|
|
4269
|
+
if (declaring.size > 1)
|
|
4270
|
+
throw new Error(`${[...declaring].join(" and ")} both declare name: "${from}"; rename the file by hand`);
|
|
4271
|
+
const [file] = [...declaring];
|
|
4272
|
+
const target = join3(dirname(file), `${to}${extname(file)}`);
|
|
4273
|
+
if (existsSync2(join3(root, target)))
|
|
4274
|
+
throw new Error(`${target} already exists`);
|
|
4275
|
+
moves.push({ from: file, to: target });
|
|
4276
|
+
changes.push(...importChanges(root, sources, file, target, changes));
|
|
4277
|
+
}
|
|
4278
|
+
const mentions = sources.flatMap((file) => readFileSync3(join3(root, file), "utf8").split(`
|
|
4279
|
+
`).flatMap((original, index) => {
|
|
4280
|
+
const text = changes.find((change) => change.file === file && change.line === index + 1)?.after ?? original;
|
|
4281
|
+
return anywhere.test(text) ? [{ file, line: index + 1, text: text.trim() }] : [];
|
|
4282
|
+
}));
|
|
4283
|
+
return { changes, moves, mentions };
|
|
4284
|
+
}
|
|
4285
|
+
function importChanges(root, sources, file, target, earlier) {
|
|
4286
|
+
const result = [];
|
|
4287
|
+
const withoutExtension = (path) => path.replace(/\.(ts|mts|cts|js|mjs|cjs)$/, "");
|
|
4288
|
+
const moved = withoutExtension(resolve2(root, file));
|
|
4289
|
+
for (const source of sources) {
|
|
4290
|
+
const lines = readFileSync3(join3(root, source), "utf8").split(`
|
|
4291
|
+
`);
|
|
4292
|
+
for (const [index, original] of lines.entries()) {
|
|
4293
|
+
const line = index + 1;
|
|
4294
|
+
const changed = earlier.find((change) => change.file === source && change.line === line);
|
|
4295
|
+
const before = changed?.after ?? original;
|
|
4296
|
+
const after = before.replace(/(\bfrom\s+|\bimport\s*\(\s*)(["'])(\.{1,2}\/[^"']+)\2/g, (match, lead, quote, specifier) => {
|
|
4297
|
+
if (withoutExtension(resolve2(root, dirname(source), specifier)) !== moved)
|
|
4298
|
+
return match;
|
|
4299
|
+
const extension = specifier.match(/\.(ts|mts|cts|js|mjs|cjs)$/)?.[0] ?? "";
|
|
4300
|
+
const renamed = `${dirname(specifier)}/${basename(withoutExtension(target))}${extension}`;
|
|
4301
|
+
return `${lead}${quote}${renamed}${quote}`;
|
|
4302
|
+
});
|
|
4303
|
+
if (after === before)
|
|
4304
|
+
continue;
|
|
4305
|
+
if (changed)
|
|
4306
|
+
changed.after = after;
|
|
4307
|
+
else
|
|
4308
|
+
result.push({ file: source, line, before: original, after });
|
|
4309
|
+
}
|
|
4310
|
+
}
|
|
4311
|
+
return result;
|
|
4312
|
+
}
|
|
4313
|
+
function applyRename(root, plan) {
|
|
4314
|
+
const files = new Set(plan.changes.map((change) => change.file));
|
|
4315
|
+
for (const file of files) {
|
|
4316
|
+
const lines = readFileSync3(join3(root, file), "utf8").split(`
|
|
4317
|
+
`);
|
|
4318
|
+
for (const change of plan.changes.filter((each) => each.file === file))
|
|
4319
|
+
lines[change.line - 1] = change.after;
|
|
4320
|
+
writeFileSync(join3(root, file), lines.join(`
|
|
4321
|
+
`));
|
|
4322
|
+
}
|
|
4323
|
+
for (const move of plan.moves)
|
|
4324
|
+
renameSync(join3(root, move.from), join3(root, move.to));
|
|
4325
|
+
}
|
|
4326
|
+
|
|
4395
4327
|
// src/cli/index.ts
|
|
4396
4328
|
var HELP = `usage: circuit <command> [device...] [options]
|
|
4397
4329
|
|
|
@@ -4412,18 +4344,28 @@ var HELP = `usage: circuit <command> [device...] [options]
|
|
|
4412
4344
|
prometheus every device with SNMP, as Prometheus targets for snmp_exporter
|
|
4413
4345
|
--auths snmp_exporter's auths for them instead, with secrets as \${NAME}
|
|
4414
4346
|
secrets every secret the config uses, and whether each can be read
|
|
4347
|
+
rename <device> <new-name>
|
|
4348
|
+
give a device a new name in every file that names it, with --confirm
|
|
4349
|
+
--file also rename the file that declares it, to <new-name>.ts
|
|
4350
|
+
addresses [prefix] every address the config uses, by prefix, and what is still free
|
|
4351
|
+
--length=N free prefixes of this length, instead of the fewest that cover the free space
|
|
4352
|
+
--format=json as JSON
|
|
4415
4353
|
graph [device...] the network as a graph, from the config alone, without contacting a device
|
|
4416
4354
|
--layer=l1 l1, l2, l3, bgp, ospf or tunnels, several with commas, or all
|
|
4417
4355
|
--format=mermaid mermaid, dot or json
|
|
4418
|
-
studio the
|
|
4419
|
-
--port=4600 the port it listens on
|
|
4356
|
+
studio Circuit Studio: the network in the browser, drawn again whenever the config changes
|
|
4357
|
+
--port=4600 the port it listens on
|
|
4420
4358
|
--no-open do not open the browser
|
|
4359
|
+
--host=0.0.0.0 listen on a server's address instead, with --allow-host
|
|
4360
|
+
--allow-host=studio.example.com
|
|
4361
|
+
the names it is reached by, several with commas
|
|
4362
|
+
--json print what Studio shows, as JSON, instead
|
|
4421
4363
|
|
|
4422
4364
|
--config <path> the network's config file, circuit.config.ts in the working directory by default`;
|
|
4423
4365
|
var DEFAULT_CONFIG = "circuit.config.ts";
|
|
4424
4366
|
var [command2, ...args] = process.argv.slice(2);
|
|
4425
4367
|
if (command2 === "new") {
|
|
4426
|
-
const { create } = await import("../chunks/new-
|
|
4368
|
+
const { create } = await import("../chunks/new-ny89s7y7.js");
|
|
4427
4369
|
await create(args);
|
|
4428
4370
|
process.exit(0);
|
|
4429
4371
|
}
|
|
@@ -4434,12 +4376,12 @@ if (!command2 || command2 === "help" || command2 === "--help" || command2 === "-
|
|
|
4434
4376
|
var configAt = args.indexOf("--config");
|
|
4435
4377
|
var configArgument = configAt >= 0 ? args[configAt + 1] : args.find((arg) => arg.startsWith("--config="))?.slice("--config=".length);
|
|
4436
4378
|
var rest = args.filter((arg, index) => index !== configAt && (configAt < 0 || index !== configAt + 1) && !arg.startsWith("--config="));
|
|
4437
|
-
var configPath =
|
|
4438
|
-
if (!
|
|
4379
|
+
var configPath = resolve3(process.cwd(), configArgument ?? DEFAULT_CONFIG);
|
|
4380
|
+
if (!existsSync3(configPath)) {
|
|
4439
4381
|
console.error(configArgument ? `no config at ${configPath}` : `no ${DEFAULT_CONFIG} in ${process.cwd()}; name another with --config <path>`);
|
|
4440
4382
|
process.exit(1);
|
|
4441
4383
|
}
|
|
4442
|
-
var root2 =
|
|
4384
|
+
var root2 = dirname2(configPath);
|
|
4443
4385
|
useRoot(root2);
|
|
4444
4386
|
async function load(path) {
|
|
4445
4387
|
if (process.versions.bun)
|
|
@@ -4471,9 +4413,9 @@ function one() {
|
|
|
4471
4413
|
return device ?? fail("name a device");
|
|
4472
4414
|
}
|
|
4473
4415
|
var adapterOf = (device) => adapters[device.platform] ?? fail(`no adapter for ${device.platform}`);
|
|
4474
|
-
var generated =
|
|
4475
|
-
var stateDirectory =
|
|
4476
|
-
var statePath = (device) =>
|
|
4416
|
+
var generated = join4(root2, ".circuit");
|
|
4417
|
+
var stateDirectory = join4(generated, "state");
|
|
4418
|
+
var statePath = (device) => join4(stateDirectory, device.name + adapterOf(device).extension);
|
|
4477
4419
|
var GENERATED_README = `# Written by Circuit
|
|
4478
4420
|
|
|
4479
4421
|
Do not edit anything here: an edit changes nothing on a device, and Circuit overwrites it. Change the config instead.
|
|
@@ -4482,8 +4424,8 @@ Do not edit anything here: an edit changes nothing on a device, and Circuit over
|
|
|
4482
4424
|
`;
|
|
4483
4425
|
function writeState(device, text) {
|
|
4484
4426
|
mkdirSync(stateDirectory, { recursive: true });
|
|
4485
|
-
|
|
4486
|
-
|
|
4427
|
+
writeFileSync2(join4(generated, "README.md"), GENERATED_README);
|
|
4428
|
+
writeFileSync2(statePath(device), text);
|
|
4487
4429
|
}
|
|
4488
4430
|
function guard(devices) {
|
|
4489
4431
|
const relevant = new Set(devices.map((device) => device.name));
|
|
@@ -4498,18 +4440,18 @@ function guard(devices) {
|
|
|
4498
4440
|
fail(`
|
|
4499
4441
|
${errors} error(s).`);
|
|
4500
4442
|
}
|
|
4501
|
-
function show(device,
|
|
4502
|
-
const commands =
|
|
4503
|
-
const summary = commands ? `${commands} command(s) in ${
|
|
4443
|
+
function show(device, plan) {
|
|
4444
|
+
const commands = plan.steps.reduce((count, step) => count + step.send.length, 0);
|
|
4445
|
+
const summary = commands ? `${commands} command(s) in ${plan.steps.length} step(s)` : "matches";
|
|
4504
4446
|
console.log(`${device.name}: ${summary}`);
|
|
4505
|
-
for (const step of
|
|
4447
|
+
for (const step of plan.steps) {
|
|
4506
4448
|
console.log(` ${step.title}`);
|
|
4507
|
-
for (const
|
|
4508
|
-
console.log(` ${
|
|
4449
|
+
for (const line of step.show)
|
|
4450
|
+
console.log(` ${line.slice(0, 220)}`);
|
|
4509
4451
|
}
|
|
4510
|
-
for (const problem of
|
|
4452
|
+
for (const problem of plan.problems)
|
|
4511
4453
|
console.log(` PROBLEM ${problem}`);
|
|
4512
|
-
return commands +
|
|
4454
|
+
return commands + plan.problems.length;
|
|
4513
4455
|
}
|
|
4514
4456
|
function validateCommand() {
|
|
4515
4457
|
guard([...network2.devices]);
|
|
@@ -4525,13 +4467,13 @@ function diffCommand() {
|
|
|
4525
4467
|
let differences = 0;
|
|
4526
4468
|
for (const device of devices) {
|
|
4527
4469
|
const path = statePath(device);
|
|
4528
|
-
if (!
|
|
4470
|
+
if (!existsSync3(path)) {
|
|
4529
4471
|
console.log(`${device.name}: no snapshot, run snapshot first`);
|
|
4530
4472
|
continue;
|
|
4531
4473
|
}
|
|
4532
4474
|
const adapter = adapterOf(device);
|
|
4533
|
-
const
|
|
4534
|
-
differences += show(device,
|
|
4475
|
+
const plan = adapter.plan(adapter.render(device), readFileSync4(path, "utf8"), options);
|
|
4476
|
+
differences += show(device, plan);
|
|
4535
4477
|
}
|
|
4536
4478
|
process.exit(differences ? 1 : 0);
|
|
4537
4479
|
}
|
|
@@ -4542,13 +4484,13 @@ async function snapshotCommand() {
|
|
|
4542
4484
|
console.log(`${device.name}: ${text.split(`
|
|
4543
4485
|
`).length} lines`);
|
|
4544
4486
|
}
|
|
4545
|
-
if (names.length || !
|
|
4487
|
+
if (names.length || !existsSync3(stateDirectory))
|
|
4546
4488
|
return;
|
|
4547
|
-
const current = new Set(network2.devices.map((device) =>
|
|
4548
|
-
for (const file of
|
|
4489
|
+
const current = new Set(network2.devices.map((device) => basename2(statePath(device))));
|
|
4490
|
+
for (const file of readdirSync2(stateDirectory)) {
|
|
4549
4491
|
if (current.has(file))
|
|
4550
4492
|
continue;
|
|
4551
|
-
rmSync(
|
|
4493
|
+
rmSync(join4(stateDirectory, file));
|
|
4552
4494
|
console.log(`${file}: removed, no such device in the config`);
|
|
4553
4495
|
}
|
|
4554
4496
|
}
|
|
@@ -4558,10 +4500,10 @@ async function applyCommand() {
|
|
|
4558
4500
|
const adapter = adapterOf(device);
|
|
4559
4501
|
const live = await adapter.read(device, network2.devices);
|
|
4560
4502
|
writeState(device, live);
|
|
4561
|
-
const
|
|
4562
|
-
if (!show(device,
|
|
4503
|
+
const plan = adapter.plan(adapter.render(device), live, options);
|
|
4504
|
+
if (!show(device, plan))
|
|
4563
4505
|
return;
|
|
4564
|
-
if (
|
|
4506
|
+
if (plan.problems.length)
|
|
4565
4507
|
fail(`
|
|
4566
4508
|
Not sent: resolve the problems first.`);
|
|
4567
4509
|
if (!confirmed) {
|
|
@@ -4569,13 +4511,13 @@ Not sent: resolve the problems first.`);
|
|
|
4569
4511
|
Nothing sent. Re-run with --confirm.`);
|
|
4570
4512
|
return;
|
|
4571
4513
|
}
|
|
4572
|
-
await adapter.apply(device, network2.devices,
|
|
4514
|
+
await adapter.apply(device, network2.devices, plan, options);
|
|
4573
4515
|
writeState(device, await adapter.read(device, network2.devices));
|
|
4574
4516
|
console.log("snapshot updated");
|
|
4575
4517
|
}
|
|
4576
4518
|
async function refreshCommand() {
|
|
4577
4519
|
const device = one();
|
|
4578
|
-
const
|
|
4520
|
+
const fill = adapterOf(device).fill ?? fail(`${device.platform} cannot hold a fetched prefix set`);
|
|
4579
4521
|
for (const [name, set] of Object.entries(device.prefix_sets ?? {})) {
|
|
4580
4522
|
if (!("source" in set))
|
|
4581
4523
|
continue;
|
|
@@ -4586,7 +4528,7 @@ async function refreshCommand() {
|
|
|
4586
4528
|
}
|
|
4587
4529
|
console.log(`${name}: ${prefixes.length} prefixes from ${registry} ${query}`);
|
|
4588
4530
|
if (confirmed)
|
|
4589
|
-
await
|
|
4531
|
+
await fill(device, network2.devices, name, family, prefixes);
|
|
4590
4532
|
}
|
|
4591
4533
|
if (!confirmed)
|
|
4592
4534
|
console.log(`
|
|
@@ -4634,13 +4576,13 @@ function communitiesCommand() {
|
|
|
4634
4576
|
}
|
|
4635
4577
|
}
|
|
4636
4578
|
async function peeringdbCommand() {
|
|
4637
|
-
const
|
|
4638
|
-
for (const name of
|
|
4579
|
+
const plan = await planPeeringDb(network2);
|
|
4580
|
+
for (const name of plan.unknown) {
|
|
4639
4581
|
console.log(` PeeringDB lists ${name}, which the config does not. Remove it by hand if the network left.`);
|
|
4640
4582
|
}
|
|
4641
|
-
for (const change of
|
|
4583
|
+
for (const change of plan.changes)
|
|
4642
4584
|
console.log(` ${change.summary}`);
|
|
4643
|
-
if (!
|
|
4585
|
+
if (!plan.changes.length) {
|
|
4644
4586
|
console.log("PeeringDB matches the config.");
|
|
4645
4587
|
return;
|
|
4646
4588
|
}
|
|
@@ -4649,17 +4591,17 @@ async function peeringdbCommand() {
|
|
|
4649
4591
|
Nothing sent. Re-run with --confirm.`);
|
|
4650
4592
|
return;
|
|
4651
4593
|
}
|
|
4652
|
-
await sendPeeringDb(network2,
|
|
4594
|
+
await sendPeeringDb(network2, plan.changes);
|
|
4653
4595
|
}
|
|
4654
4596
|
async function librenmsCommand() {
|
|
4655
|
-
const
|
|
4656
|
-
for (const hostname of
|
|
4597
|
+
const plan = await planLibreNms(network2);
|
|
4598
|
+
for (const hostname of plan.unknown)
|
|
4657
4599
|
console.log(` LibreNMS monitors ${hostname}, which the config does not. Remove it by hand if it is gone.`);
|
|
4658
|
-
for (const device of
|
|
4600
|
+
for (const device of plan.withoutSnmp)
|
|
4659
4601
|
console.log(` ${device.name} has no SNMP in its config, so LibreNMS cannot poll it. Add management.snmp first.`);
|
|
4660
|
-
for (const device of
|
|
4602
|
+
for (const device of plan.additions)
|
|
4661
4603
|
console.log(` add ${device.name}, ${device.connection.host}`);
|
|
4662
|
-
if (!
|
|
4604
|
+
if (!plan.additions.length) {
|
|
4663
4605
|
console.log("LibreNMS monitors every device it can.");
|
|
4664
4606
|
return;
|
|
4665
4607
|
}
|
|
@@ -4668,7 +4610,7 @@ async function librenmsCommand() {
|
|
|
4668
4610
|
Nothing sent. Re-run with --confirm.`);
|
|
4669
4611
|
return;
|
|
4670
4612
|
}
|
|
4671
|
-
await sendLibreNms(network2,
|
|
4613
|
+
await sendLibreNms(network2, plan.additions);
|
|
4672
4614
|
}
|
|
4673
4615
|
function prometheusCommand() {
|
|
4674
4616
|
if (flags.includes("--auths")) {
|
|
@@ -4683,14 +4625,14 @@ function prometheusCommand() {
|
|
|
4683
4625
|
console.error(`${device.name} has no SNMP in its config; left out`);
|
|
4684
4626
|
console.log(JSON.stringify(targets, null, 4));
|
|
4685
4627
|
}
|
|
4686
|
-
function secretsIn(
|
|
4687
|
-
if (isSecret(
|
|
4688
|
-
found.add(referenceOf(
|
|
4689
|
-
else if (Array.isArray(
|
|
4690
|
-
for (const item of
|
|
4628
|
+
function secretsIn(value, found = new Set) {
|
|
4629
|
+
if (isSecret(value))
|
|
4630
|
+
found.add(referenceOf(value));
|
|
4631
|
+
else if (Array.isArray(value))
|
|
4632
|
+
for (const item of value)
|
|
4691
4633
|
secretsIn(item, found);
|
|
4692
|
-
else if (typeof
|
|
4693
|
-
for (const item of Object.values(
|
|
4634
|
+
else if (typeof value === "object" && value !== null)
|
|
4635
|
+
for (const item of Object.values(value))
|
|
4694
4636
|
secretsIn(item, found);
|
|
4695
4637
|
return found;
|
|
4696
4638
|
}
|
|
@@ -4714,20 +4656,29 @@ ${references.length} secret(s), ${missing} missing.`);
|
|
|
4714
4656
|
var flagValue = (name) => flags.find((flag) => flag.startsWith(`--${name}=`))?.slice(name.length + 3);
|
|
4715
4657
|
function driftOf(device) {
|
|
4716
4658
|
const path = statePath(device);
|
|
4717
|
-
if (!
|
|
4659
|
+
if (!existsSync3(path))
|
|
4718
4660
|
return { status: "no snapshot", lines: [] };
|
|
4719
4661
|
try {
|
|
4720
4662
|
const adapter = adapterOf(device);
|
|
4721
|
-
const
|
|
4663
|
+
const plan = adapter.plan(adapter.render(device), readFileSync4(path, "utf8"), options);
|
|
4722
4664
|
const lines = [
|
|
4723
|
-
...
|
|
4724
|
-
...
|
|
4665
|
+
...plan.steps.flatMap((step) => [step.title, ...step.show.map((line) => ` ${line}`)]),
|
|
4666
|
+
...plan.problems.map((problem) => `PROBLEM ${problem}`)
|
|
4725
4667
|
];
|
|
4726
4668
|
return { status: lines.length ? "differs" : "matches", lines };
|
|
4727
4669
|
} catch (error) {
|
|
4728
4670
|
return { status: "cannot plan", lines: [error.message] };
|
|
4729
4671
|
}
|
|
4730
4672
|
}
|
|
4673
|
+
function annotatedGraph(layers, deviceNames) {
|
|
4674
|
+
const graph = only(graphOf(network2.devices), layers, deviceNames);
|
|
4675
|
+
const shown = new Set(graph.nodes.map((node) => node.label));
|
|
4676
|
+
return {
|
|
4677
|
+
...graph,
|
|
4678
|
+
findings: validate(network2).filter((finding) => !finding.device || shown.has(finding.device)),
|
|
4679
|
+
drift: Object.fromEntries(network2.devices.filter((device) => shown.has(device.name)).map((device) => [device.name, driftOf(device)]))
|
|
4680
|
+
};
|
|
4681
|
+
}
|
|
4731
4682
|
function graphCommand() {
|
|
4732
4683
|
const requested = (flagValue("layer") ?? "l1").split(",");
|
|
4733
4684
|
const layers = requested.includes("all") ? [...LAYERS] : requested;
|
|
@@ -4736,13 +4687,7 @@ function graphCommand() {
|
|
|
4736
4687
|
fail(`no layer ${unknown.join(", ")}; known: ${LAYERS.join(", ")}, all`);
|
|
4737
4688
|
const format = flagValue("format") ?? "mermaid";
|
|
4738
4689
|
const devices = selected(true);
|
|
4739
|
-
const
|
|
4740
|
-
const shown = new Set(graph.nodes.map((node) => node.label));
|
|
4741
|
-
const annotated = {
|
|
4742
|
-
...graph,
|
|
4743
|
-
findings: validate(network2).filter((finding) => !finding.device || shown.has(finding.device)),
|
|
4744
|
-
drift: Object.fromEntries(network2.devices.filter((device) => shown.has(device.name)).map((device) => [device.name, driftOf(device)]))
|
|
4745
|
-
};
|
|
4690
|
+
const annotated = annotatedGraph(layers, names.length ? devices.map((device) => device.name) : []);
|
|
4746
4691
|
if (format === "mermaid")
|
|
4747
4692
|
process.stdout.write(mermaid(annotated));
|
|
4748
4693
|
else if (format === "dot")
|
|
@@ -4752,12 +4697,117 @@ function graphCommand() {
|
|
|
4752
4697
|
else
|
|
4753
4698
|
fail(`no format ${format}; known: mermaid, dot, json`);
|
|
4754
4699
|
}
|
|
4700
|
+
function printSegment(segment, indent) {
|
|
4701
|
+
const next = segment.next ? `, next ${segment.next}` : "";
|
|
4702
|
+
console.log(`${indent}${segment.prefix}, ${segment.free} free${next}`);
|
|
4703
|
+
const width = Math.max(...segment.uses.map((use) => use.address.length)) + 2;
|
|
4704
|
+
for (const use of segment.uses)
|
|
4705
|
+
console.log(`${indent} ${use.address.padEnd(width)}${use.what}`);
|
|
4706
|
+
}
|
|
4707
|
+
function addressesCommand() {
|
|
4708
|
+
const [within] = names;
|
|
4709
|
+
if (within && !isCidr2(within))
|
|
4710
|
+
fail(`${within} is not a prefix, such as 10.0.0.0/16`);
|
|
4711
|
+
const lengthText = flagValue("length");
|
|
4712
|
+
const length = lengthText === undefined ? undefined : Number(lengthText);
|
|
4713
|
+
if (length !== undefined && !(Number.isInteger(length) && length >= 0 && length <= 128))
|
|
4714
|
+
fail("--length takes a prefix length, such as 24");
|
|
4715
|
+
const report = addressesOf(network2.devices, within);
|
|
4716
|
+
if (flagValue("format") === "json") {
|
|
4717
|
+
console.log(JSON.stringify(report, null, 4));
|
|
4718
|
+
return;
|
|
4719
|
+
}
|
|
4720
|
+
for (const space of report.spaces) {
|
|
4721
|
+
console.log(`${space.prefix}, ${space.owner}`);
|
|
4722
|
+
for (const segment of space.segments)
|
|
4723
|
+
printSegment(segment, " ");
|
|
4724
|
+
for (const block of space.blocks)
|
|
4725
|
+
console.log(` ${block.prefix} ${block.what}`);
|
|
4726
|
+
if (length === undefined) {
|
|
4727
|
+
console.log(` free: ${space.free.join(", ") || "none"}`);
|
|
4728
|
+
} else {
|
|
4729
|
+
const { prefixes, total } = freeOfLength(space.free, length, 16);
|
|
4730
|
+
const more = total > BigInt(prefixes.length) ? `, and ${total - BigInt(prefixes.length)} more` : "";
|
|
4731
|
+
console.log(` free /${length}: ${prefixes.join(", ") || "none"}${more}`);
|
|
4732
|
+
}
|
|
4733
|
+
console.log("");
|
|
4734
|
+
}
|
|
4735
|
+
const { segments, blocks } = report.elsewhere;
|
|
4736
|
+
if (segments.length || blocks.length) {
|
|
4737
|
+
console.log("Outside the space the network originates");
|
|
4738
|
+
for (const segment of segments)
|
|
4739
|
+
printSegment(segment, " ");
|
|
4740
|
+
for (const block of blocks)
|
|
4741
|
+
console.log(` ${block.prefix} ${block.what}`);
|
|
4742
|
+
}
|
|
4743
|
+
if (!report.spaces.length && !segments.length && !blocks.length)
|
|
4744
|
+
console.log("The config holds no addresses.");
|
|
4745
|
+
}
|
|
4746
|
+
function renameCommand() {
|
|
4747
|
+
const [from, to] = names;
|
|
4748
|
+
if (!from || !to)
|
|
4749
|
+
fail("usage: circuit rename <device> <new-name> [--file] [--confirm]");
|
|
4750
|
+
const device = network2.devices.find((candidate) => candidate.name === from);
|
|
4751
|
+
if (!device)
|
|
4752
|
+
fail(`no device ${from}; known: ${network2.devices.map((candidate) => candidate.name).join(", ")}`);
|
|
4753
|
+
if (network2.devices.some((candidate) => candidate.name === to))
|
|
4754
|
+
fail(`${to} is already a device`);
|
|
4755
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(to))
|
|
4756
|
+
fail(`${to} is not a hostname: letters, digits, dots, dashes and underscores`);
|
|
4757
|
+
const snapshot = statePath(device);
|
|
4758
|
+
const snapshots = existsSync3(snapshot) ? [relative2(root2, snapshot)] : [];
|
|
4759
|
+
let plan;
|
|
4760
|
+
try {
|
|
4761
|
+
plan = planRename(root2, from, to, { file: flags.includes("--file"), snapshots });
|
|
4762
|
+
} catch (error) {
|
|
4763
|
+
fail(error.message);
|
|
4764
|
+
}
|
|
4765
|
+
for (const change of plan.changes) {
|
|
4766
|
+
console.log(`${change.file}:${change.line}`);
|
|
4767
|
+
console.log(` - ${change.before.trim()}`);
|
|
4768
|
+
console.log(` + ${change.after.trim()}`);
|
|
4769
|
+
}
|
|
4770
|
+
for (const move of plan.moves)
|
|
4771
|
+
console.log(`move ${move.from} to ${move.to}`);
|
|
4772
|
+
if (plan.mentions.length) {
|
|
4773
|
+
console.log(`
|
|
4774
|
+
Still naming ${from}, left as they are: change them by hand if they mean the device.`);
|
|
4775
|
+
for (const mention of plan.mentions)
|
|
4776
|
+
console.log(` ${mention.file}:${mention.line} ${mention.text.slice(0, 160)}`);
|
|
4777
|
+
}
|
|
4778
|
+
if (!confirmed) {
|
|
4779
|
+
console.log(`
|
|
4780
|
+
Nothing written. Re-run with --confirm.`);
|
|
4781
|
+
return;
|
|
4782
|
+
}
|
|
4783
|
+
applyRename(root2, plan);
|
|
4784
|
+
console.log(`
|
|
4785
|
+
Renamed. Run circuit validate, then circuit apply ${to}: the device keeps its old hostname until then.`);
|
|
4786
|
+
}
|
|
4755
4787
|
async function studioCommand() {
|
|
4788
|
+
if (flags.includes("--json")) {
|
|
4789
|
+
const sources = projectSources(root2);
|
|
4790
|
+
const data = studioData(network2.devices, annotatedGraph(LAYERS, []), {
|
|
4791
|
+
render: (device) => adapterOf(device).render(device),
|
|
4792
|
+
snapshot: (device) => existsSync3(statePath(device)) ? readFileSync4(statePath(device), "utf8") : undefined,
|
|
4793
|
+
files: (device) => ({
|
|
4794
|
+
source: deviceFiles(root2, device.name, sources)[0]?.split(sep).join("/"),
|
|
4795
|
+
snapshot: existsSync3(statePath(device)) ? relative2(root2, statePath(device)).split(sep).join("/") : undefined
|
|
4796
|
+
})
|
|
4797
|
+
});
|
|
4798
|
+
console.log(JSON.stringify(data));
|
|
4799
|
+
return;
|
|
4800
|
+
}
|
|
4756
4801
|
const port = Number(flagValue("port") ?? 4600);
|
|
4757
4802
|
if (!Number.isInteger(port) || port < 1 || port > 65535)
|
|
4758
4803
|
fail("--port takes a number from 1 to 65535");
|
|
4759
|
-
const { studio } = await import("../chunks/
|
|
4760
|
-
|
|
4804
|
+
const { isLoopback, studio } = await import("../chunks/server-zapfzhv4.js");
|
|
4805
|
+
const host = flagValue("host") ?? "127.0.0.1";
|
|
4806
|
+
const allowHosts = (flagValue("allow-host") ?? "").split(",").filter(Boolean);
|
|
4807
|
+
if (!isLoopback(host) && !allowHosts.length) {
|
|
4808
|
+
fail(`Studio on ${host} needs --allow-host=<name>, the name people reach it by, such as studio.example.com`);
|
|
4809
|
+
}
|
|
4810
|
+
await studio({ configPath, root: root2, port, open: !flags.includes("--no-open"), host, allowHosts });
|
|
4761
4811
|
}
|
|
4762
4812
|
var commands = {
|
|
4763
4813
|
validate: validateCommand,
|
|
@@ -4772,6 +4822,8 @@ var commands = {
|
|
|
4772
4822
|
librenms: librenmsCommand,
|
|
4773
4823
|
prometheus: prometheusCommand,
|
|
4774
4824
|
secrets: secretsCommand,
|
|
4825
|
+
rename: renameCommand,
|
|
4826
|
+
addresses: addressesCommand,
|
|
4775
4827
|
graph: graphCommand,
|
|
4776
4828
|
studio: studioCommand
|
|
4777
4829
|
};
|