@takodotid/circuit 0.18.4 → 0.19.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 +18 -0
- package/dist/chunks/{index-ex1wcst7.js → index-dczpxzzm.js} +1 -1
- package/dist/chunks/studio-femhydvd.js +764 -0
- package/dist/cli/index.js +548 -9
- package/dist/index.js +1 -1
- package/dist/presets/index.js +1 -1
- package/dist/types/core/graph.d.ts +44 -0
- package/package.json +1 -1
- package/src/adapters/devices/routeros/TRAPS.md +1 -0
- package/src/adapters/devices/routeros/plan.ts +1 -0
- package/src/cli/graph.ts +103 -0
- package/src/cli/index.ts +69 -0
- package/src/cli/studio-page.ts +876 -0
- package/src/cli/studio.ts +126 -0
- package/src/core/graph.ts +613 -0
- package/templates/project/AGENTS.md +10 -9
- package/templates/project/README.md +1 -0
package/dist/cli/index.js
CHANGED
|
@@ -8,9 +8,10 @@ import {
|
|
|
8
8
|
isGlobal,
|
|
9
9
|
netmask,
|
|
10
10
|
network,
|
|
11
|
+
parse,
|
|
11
12
|
split,
|
|
12
13
|
wildcard
|
|
13
|
-
} from "../chunks/index-
|
|
14
|
+
} from "../chunks/index-dczpxzzm.js";
|
|
14
15
|
import {
|
|
15
16
|
__require,
|
|
16
17
|
__toESM
|
|
@@ -352,7 +353,7 @@ function compare(desired, current, dialect) {
|
|
|
352
353
|
}
|
|
353
354
|
return [...groups.values()].filter((group) => group.add.length || group.remove.length);
|
|
354
355
|
}
|
|
355
|
-
function vlanListChange(groups, prefix,
|
|
356
|
+
function vlanListChange(groups, prefix, parse2, format, removal) {
|
|
356
357
|
const top = groups.find((group) => group.path.length === 0);
|
|
357
358
|
if (!top)
|
|
358
359
|
return;
|
|
@@ -360,8 +361,8 @@ function vlanListChange(groups, prefix, parse, format, removal) {
|
|
|
360
361
|
const fresh = top.add.find((line) => line.startsWith(prefix));
|
|
361
362
|
if (!old && !fresh)
|
|
362
363
|
return;
|
|
363
|
-
const before = old ?
|
|
364
|
-
const after = fresh ?
|
|
364
|
+
const before = old ? parse2(old) : [];
|
|
365
|
+
const after = fresh ? parse2(fresh) : [];
|
|
365
366
|
top.remove = top.remove.filter((line) => line !== old);
|
|
366
367
|
top.add = top.add.filter((line) => line !== fresh);
|
|
367
368
|
const added = after.filter((id) => !before.includes(id));
|
|
@@ -960,7 +961,7 @@ function parseLine(menu, line) {
|
|
|
960
961
|
}
|
|
961
962
|
return { menu, verb, selector, flags, attrs };
|
|
962
963
|
}
|
|
963
|
-
function
|
|
964
|
+
function parse2(text) {
|
|
964
965
|
const commands = [];
|
|
965
966
|
const generated = new Set;
|
|
966
967
|
let menu = "";
|
|
@@ -1207,7 +1208,8 @@ var DYNAMIC = new Set([
|
|
|
1207
1208
|
"/ipv6 firewall mangle",
|
|
1208
1209
|
"/ip firewall raw",
|
|
1209
1210
|
"/ipv6 firewall raw",
|
|
1210
|
-
"/ip dhcp-server lease"
|
|
1211
|
+
"/ip dhcp-server lease",
|
|
1212
|
+
"/interface bridge vlan"
|
|
1211
1213
|
]);
|
|
1212
1214
|
var quote = (value) => `"${value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/\$/g, "\\$")}"`;
|
|
1213
1215
|
var value = (raw) => /^[\w.:/@,!-]+$/.test(raw) ? raw : quote(raw);
|
|
@@ -1412,8 +1414,8 @@ function planOrdered(menu, wanted, existing) {
|
|
|
1412
1414
|
return result;
|
|
1413
1415
|
}
|
|
1414
1416
|
function plan2(desired, current, options) {
|
|
1415
|
-
const want =
|
|
1416
|
-
const have =
|
|
1417
|
+
const want = parse2(desired);
|
|
1418
|
+
const have = parse2(current);
|
|
1417
1419
|
const owned = (command) => !(command.menu.endsWith("address-list") && want.generated.has(command.attrs.list ?? ""));
|
|
1418
1420
|
const wantedByMenu = byMenu(want.commands.filter(owned));
|
|
1419
1421
|
const existingByMenu = byMenu(have.commands.filter(owned));
|
|
@@ -3569,6 +3571,417 @@ function snmpExporterAuths(network2) {
|
|
|
3569
3571
|
`, notes };
|
|
3570
3572
|
}
|
|
3571
3573
|
|
|
3574
|
+
// src/core/graph.ts
|
|
3575
|
+
var LAYERS = ["l1", "l2", "l3", "bgp", "ospf", "tunnels"];
|
|
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
|
+
};
|
|
3586
|
+
|
|
3587
|
+
class Builder {
|
|
3588
|
+
nodes = new Map;
|
|
3589
|
+
edges = [];
|
|
3590
|
+
node(id, kind, label, detail = []) {
|
|
3591
|
+
const existing = this.nodes.get(id);
|
|
3592
|
+
if (!existing)
|
|
3593
|
+
this.nodes.set(id, { id, kind, label, detail: [...detail] });
|
|
3594
|
+
else
|
|
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;
|
|
3603
|
+
}
|
|
3604
|
+
}
|
|
3605
|
+
function routedOf(device) {
|
|
3606
|
+
const ports = Object.entries(device.ports ?? {}).flatMap(([name, port]) => port ? [[name, port]] : []);
|
|
3607
|
+
const interfaces = Object.entries(device.interfaces ?? {});
|
|
3608
|
+
return [...ports, ...interfaces];
|
|
3609
|
+
}
|
|
3610
|
+
function holdersOf(devices) {
|
|
3611
|
+
return devices.flatMap((device) => routedOf(device).flatMap(([name, settings]) => (settings.addresses ?? []).flatMap((entry) => {
|
|
3612
|
+
const address = typeof entry === "string" ? entry : entry.address;
|
|
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];
|
|
3626
|
+
}
|
|
3627
|
+
function carriedBy(device, settings) {
|
|
3628
|
+
const vlan = (name, untagged) => {
|
|
3629
|
+
const id = name ? device.vlans?.[name]?.id : undefined;
|
|
3630
|
+
return name && id !== undefined ? [{ name, id, untagged }] : [];
|
|
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);
|
|
3634
|
+
}
|
|
3635
|
+
function vlanLabel(vlans) {
|
|
3636
|
+
if (vlans.length === 1)
|
|
3637
|
+
return `${vlans[0].name}${vlans[0].untagged ? ", untagged" : ""}`;
|
|
3638
|
+
if (vlans.length <= 3)
|
|
3639
|
+
return vlans.map((vlan) => vlan.name).join(", ");
|
|
3640
|
+
return `${vlans.length} VLANs`;
|
|
3641
|
+
}
|
|
3642
|
+
function cables(devices, graph) {
|
|
3643
|
+
const byName = new Map(devices.map((device) => [device.name, device]));
|
|
3644
|
+
const drawn = new Set;
|
|
3645
|
+
const bundles = new Map;
|
|
3646
|
+
for (const device of devices) {
|
|
3647
|
+
for (const [portName, port] of Object.entries(device.ports ?? {})) {
|
|
3648
|
+
const link = port?.link;
|
|
3649
|
+
if (!port || !link)
|
|
3650
|
+
continue;
|
|
3651
|
+
const here = `${device.name} ${portName}`;
|
|
3652
|
+
const there = `${link.device} ${link.port}`;
|
|
3653
|
+
if (drawn.has(`${there}|${here}`))
|
|
3654
|
+
continue;
|
|
3655
|
+
drawn.add(`${here}|${there}`);
|
|
3656
|
+
const far = byName.get(link.device);
|
|
3657
|
+
const farPort = far?.ports?.[link.port];
|
|
3658
|
+
const linksBack = farPort?.link?.device === device.name && farPort.link.port === portName;
|
|
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`;
|
|
3669
|
+
continue;
|
|
3670
|
+
}
|
|
3671
|
+
const isBundle = Boolean(lag && farLag && linksBack);
|
|
3672
|
+
const cable = graph.edge({
|
|
3673
|
+
layer: "l1",
|
|
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)
|
|
3686
|
+
continue;
|
|
3687
|
+
graph.edge({
|
|
3688
|
+
layer: "l2",
|
|
3689
|
+
from: deviceId(device.name),
|
|
3690
|
+
to,
|
|
3691
|
+
label: vlanLabel(vlans),
|
|
3692
|
+
detail: [
|
|
3693
|
+
`${device.name} ${lag ?? portName}`,
|
|
3694
|
+
...vlans.map((vlan) => `${vlan.name}, VLAN ${vlan.id}${vlan.untagged ? ", untagged" : ""}`)
|
|
3695
|
+
],
|
|
3696
|
+
vlans,
|
|
3697
|
+
...linksBack ? {} : { oneSided: true }
|
|
3698
|
+
});
|
|
3699
|
+
}
|
|
3700
|
+
}
|
|
3701
|
+
}
|
|
3702
|
+
function segments(holders2, owners, graph) {
|
|
3703
|
+
const bySegment = new Map;
|
|
3704
|
+
const pairs = new Set;
|
|
3705
|
+
for (const holder of holders2) {
|
|
3706
|
+
if (holder.peer) {
|
|
3707
|
+
const far = owners.get(keyOf(holder.peer) ?? "");
|
|
3708
|
+
if (!far || far.device === holder.device)
|
|
3709
|
+
continue;
|
|
3710
|
+
const ends = [`${holder.device.name} ${holder.name}`, `${far.device.name} ${far.name}`].sort().join("|");
|
|
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
|
+
});
|
|
3721
|
+
continue;
|
|
3722
|
+
}
|
|
3723
|
+
if (isHostRoute(holder.address))
|
|
3724
|
+
continue;
|
|
3725
|
+
const segment = network(holder.address);
|
|
3726
|
+
bySegment.set(segment, [...bySegment.get(segment) ?? [], holder]);
|
|
3727
|
+
}
|
|
3728
|
+
for (const [segment, members] of bySegment) {
|
|
3729
|
+
if (new Set(members.map((member) => member.device.name)).size < 2)
|
|
3730
|
+
continue;
|
|
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
|
+
}
|
|
3754
|
+
}
|
|
3755
|
+
}
|
|
3756
|
+
function sessions(devices, owners, graph) {
|
|
3757
|
+
const all = devices.flatMap((device) => {
|
|
3758
|
+
const bgp = device.routing?.bgp;
|
|
3759
|
+
return Object.entries(bgp?.neighbors ?? {}).map(([name, neighbor]) => {
|
|
3760
|
+
const group = neighbor.group ? bgp?.groups?.[neighbor.group] : undefined;
|
|
3761
|
+
return {
|
|
3762
|
+
device,
|
|
3763
|
+
name,
|
|
3764
|
+
address: neighbor.address,
|
|
3765
|
+
ownAs: bgp.asn,
|
|
3766
|
+
remoteAs: neighbor.remote_as ?? group?.remote_as,
|
|
3767
|
+
role: neighbor.role ?? group?.role,
|
|
3768
|
+
description: neighbor.description ?? group?.description,
|
|
3769
|
+
import: neighbor.import ?? group?.import,
|
|
3770
|
+
export: neighbor.export ?? group?.export
|
|
3771
|
+
};
|
|
3772
|
+
});
|
|
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
|
+
};
|
|
3784
|
+
const ownerOf = (session) => owners.get(keyOf(session.address) ?? "")?.device;
|
|
3785
|
+
const paired = new Set;
|
|
3786
|
+
for (const session of all) {
|
|
3787
|
+
if (paired.has(session))
|
|
3788
|
+
continue;
|
|
3789
|
+
const far = ownerOf(session);
|
|
3790
|
+
if (far && far !== session.device) {
|
|
3791
|
+
const back = all.find((other) => other.device === far && !paired.has(other) && ownerOf(other) === session.device);
|
|
3792
|
+
if (back)
|
|
3793
|
+
paired.add(back);
|
|
3794
|
+
graph.edge({
|
|
3795
|
+
layer: "bgp",
|
|
3796
|
+
from: deviceId(session.device.name),
|
|
3797
|
+
to: deviceId(far.name),
|
|
3798
|
+
label: session.remoteAs === session.ownAs ? "iBGP" : "eBGP",
|
|
3799
|
+
detail: [describe2(session), ...back ? [describe2(back)] : [`${far.name} has no session back`]],
|
|
3800
|
+
...back ? {} : { oneSided: true }
|
|
3801
|
+
});
|
|
3802
|
+
continue;
|
|
3803
|
+
}
|
|
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
|
+
}
|
|
3815
|
+
}
|
|
3816
|
+
function adjacencies(devices, graph) {
|
|
3817
|
+
const byKey = new Map;
|
|
3818
|
+
for (const device of devices) {
|
|
3819
|
+
for (const [area, { interfaces }] of Object.entries(device.routing?.ospf?.areas ?? {})) {
|
|
3820
|
+
for (const [name, settings] of Object.entries(interfaces)) {
|
|
3821
|
+
if (settings?.passive)
|
|
3822
|
+
continue;
|
|
3823
|
+
const routed = device.ports?.[name] ?? device.interfaces?.[name];
|
|
3824
|
+
const cost = settings?.cost ? `, cost ${settings.cost}` : "";
|
|
3825
|
+
for (const entry of routed?.addresses ?? []) {
|
|
3826
|
+
const address = typeof entry === "string" ? entry : entry.address;
|
|
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
|
+
}
|
|
3834
|
+
}
|
|
3835
|
+
}
|
|
3836
|
+
for (const [key, members] of byKey) {
|
|
3837
|
+
const [area] = key.split(" ");
|
|
3838
|
+
for (const [index, a] of members.entries()) {
|
|
3839
|
+
for (const b of members.slice(index + 1)) {
|
|
3840
|
+
if (a.device === b.device)
|
|
3841
|
+
continue;
|
|
3842
|
+
graph.edge({
|
|
3843
|
+
layer: "ospf",
|
|
3844
|
+
from: deviceId(a.device.name),
|
|
3845
|
+
to: deviceId(b.device.name),
|
|
3846
|
+
label: `area ${area}`,
|
|
3847
|
+
detail: [a.line, b.line]
|
|
3848
|
+
});
|
|
3849
|
+
}
|
|
3850
|
+
}
|
|
3851
|
+
}
|
|
3852
|
+
}
|
|
3853
|
+
var endpointHost = (endpoint) => endpoint.replace(/:\d+$/, "").replace(/^\[(.*)\]$/, "$1");
|
|
3854
|
+
function tunnels2(devices, owners, graph) {
|
|
3855
|
+
const ends = devices.flatMap((device) => Object.entries(device.interfaces ?? {}).flatMap(([name, iface]) => iface.type === "gre" || iface.type === "vxlan" ? [{ device, name, iface }] : []));
|
|
3856
|
+
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
|
+
for (const end of ends) {
|
|
3864
|
+
if (paired.has(end))
|
|
3865
|
+
continue;
|
|
3866
|
+
const kind = end.iface.type === "gre" ? "GRE" : "VXLAN";
|
|
3867
|
+
const mirror = ends.find((other) => !paired.has(other) && mirrors(end, other));
|
|
3868
|
+
if (mirror) {
|
|
3869
|
+
paired.add(mirror);
|
|
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
|
+
});
|
|
3889
|
+
continue;
|
|
3890
|
+
}
|
|
3891
|
+
const outside = graph.node(`address:${end.iface.remote}`, "outside", end.iface.remote, end.iface.description ? [end.iface.description] : []);
|
|
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)
|
|
3947
|
+
continue;
|
|
3948
|
+
const outside = graph.node(`clients:${device.name}:${name}`, "outside", `${clients.length} ${clients.length === 1 ? "client" : "clients"}`, clients.map((peer) => `${peer.name}: ${peer.allowed_addresses.join(", ")}`));
|
|
3949
|
+
graph.edge({
|
|
3950
|
+
layer: "tunnels",
|
|
3951
|
+
from: deviceId(device.name),
|
|
3952
|
+
to: outside,
|
|
3953
|
+
label: `WireGuard ${name}`,
|
|
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);
|
|
3968
|
+
}
|
|
3969
|
+
cables(devices, graph);
|
|
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 };
|
|
3983
|
+
}
|
|
3984
|
+
|
|
3572
3985
|
// src/core/validate.ts
|
|
3573
3986
|
import { X509Certificate as X509Certificate2 } from "node:crypto";
|
|
3574
3987
|
function validate(network2) {
|
|
@@ -3909,6 +4322,76 @@ function checkLinks(devices) {
|
|
|
3909
4322
|
return findings;
|
|
3910
4323
|
}
|
|
3911
4324
|
|
|
4325
|
+
// src/cli/graph.ts
|
|
4326
|
+
function markOf(graph, node) {
|
|
4327
|
+
if (node.kind !== "device")
|
|
4328
|
+
return;
|
|
4329
|
+
const findings = graph.findings.filter((finding) => finding.device && deviceId(finding.device) === node.id);
|
|
4330
|
+
if (findings.some((finding) => finding.level === "error"))
|
|
4331
|
+
return "error";
|
|
4332
|
+
const drift = graph.drift[node.label]?.status;
|
|
4333
|
+
if (findings.length || drift === "differs" || drift === "cannot plan")
|
|
4334
|
+
return "warning";
|
|
4335
|
+
return;
|
|
4336
|
+
}
|
|
4337
|
+
function mermaid(graph) {
|
|
4338
|
+
const ids = new Map(graph.nodes.map((node, index) => [node.id, `n${index}`]));
|
|
4339
|
+
const text = (...lines2) => `"${lines2.map((line2) => line2.replace(/"/g, "#quot;").replace(/</g, "#lt;").replace(/>/g, "#gt;")).join("<br/>")}"`;
|
|
4340
|
+
const shapes = {
|
|
4341
|
+
device: (label) => `[${label}]`,
|
|
4342
|
+
segment: (label) => `([${label}])`,
|
|
4343
|
+
outside: (label) => `{{${label}}}`
|
|
4344
|
+
};
|
|
4345
|
+
const lines = ["graph LR"];
|
|
4346
|
+
for (const node of graph.nodes) {
|
|
4347
|
+
const label = node.kind === "device" ? text(node.label, node.detail[0] ?? "") : text(node.label);
|
|
4348
|
+
lines.push(` ${ids.get(node.id)}${shapes[node.kind](label)}`);
|
|
4349
|
+
}
|
|
4350
|
+
for (const edge of graph.edges) {
|
|
4351
|
+
const arrow = edge.oneSided ? "-.-" : "---";
|
|
4352
|
+
lines.push(` ${ids.get(edge.from)} ${arrow}|${text(edge.label)}| ${ids.get(edge.to)}`);
|
|
4353
|
+
}
|
|
4354
|
+
lines.push(" classDef error stroke:#d1242f,stroke-width:3px", " classDef warning stroke:#bf8700,stroke-width:3px", " classDef outside stroke-dasharray:4 3");
|
|
4355
|
+
for (const node of graph.nodes) {
|
|
4356
|
+
const mark = markOf(graph, node) ?? (node.kind === "outside" ? "outside" : undefined);
|
|
4357
|
+
if (mark)
|
|
4358
|
+
lines.push(` class ${ids.get(node.id)} ${mark}`);
|
|
4359
|
+
}
|
|
4360
|
+
return lines.join(`
|
|
4361
|
+
`) + `
|
|
4362
|
+
`;
|
|
4363
|
+
}
|
|
4364
|
+
function dot(graph) {
|
|
4365
|
+
const text = (value2) => `"${value2.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`;
|
|
4366
|
+
const colors = { error: "#d1242f", warning: "#bf8700" };
|
|
4367
|
+
const shapes = {
|
|
4368
|
+
device: "shape=box, style=rounded",
|
|
4369
|
+
segment: "shape=ellipse",
|
|
4370
|
+
outside: "shape=box, style=dashed"
|
|
4371
|
+
};
|
|
4372
|
+
const lines = [
|
|
4373
|
+
"graph network {",
|
|
4374
|
+
" rankdir=LR;",
|
|
4375
|
+
' node [fontname="Helvetica"];',
|
|
4376
|
+
' edge [fontname="Helvetica", fontsize=10];'
|
|
4377
|
+
];
|
|
4378
|
+
for (const node of graph.nodes) {
|
|
4379
|
+
const label = node.kind === "device" ? `${node.label}
|
|
4380
|
+
${node.detail[0] ?? ""}` : node.label;
|
|
4381
|
+
const mark = markOf(graph, node);
|
|
4382
|
+
const color = mark ? `, color=${text(colors[mark])}, penwidth=2` : "";
|
|
4383
|
+
lines.push(` ${text(node.id)} [label=${text(label)}, ${shapes[node.kind]}${color}];`);
|
|
4384
|
+
}
|
|
4385
|
+
for (const edge of graph.edges) {
|
|
4386
|
+
const style = edge.oneSided ? ", style=dashed" : "";
|
|
4387
|
+
lines.push(` ${text(edge.from)} -- ${text(edge.to)} [label=${text(edge.label)}${style}];`);
|
|
4388
|
+
}
|
|
4389
|
+
lines.push("}");
|
|
4390
|
+
return lines.join(`
|
|
4391
|
+
`) + `
|
|
4392
|
+
`;
|
|
4393
|
+
}
|
|
4394
|
+
|
|
3912
4395
|
// src/cli/index.ts
|
|
3913
4396
|
var HELP = `usage: circuit <command> [device...] [options]
|
|
3914
4397
|
|
|
@@ -3929,6 +4412,12 @@ var HELP = `usage: circuit <command> [device...] [options]
|
|
|
3929
4412
|
prometheus every device with SNMP, as Prometheus targets for snmp_exporter
|
|
3930
4413
|
--auths snmp_exporter's auths for them instead, with secrets as \${NAME}
|
|
3931
4414
|
secrets every secret the config uses, and whether each can be read
|
|
4415
|
+
graph [device...] the network as a graph, from the config alone, without contacting a device
|
|
4416
|
+
--layer=l1 l1, l2, l3, bgp, ospf or tunnels, several with commas, or all
|
|
4417
|
+
--format=mermaid mermaid, dot or json
|
|
4418
|
+
studio the graph in the browser, drawn again whenever the config changes
|
|
4419
|
+
--port=4600 the port it listens on, on this machine only
|
|
4420
|
+
--no-open do not open the browser
|
|
3932
4421
|
|
|
3933
4422
|
--config <path> the network's config file, circuit.config.ts in the working directory by default`;
|
|
3934
4423
|
var DEFAULT_CONFIG = "circuit.config.ts";
|
|
@@ -4222,6 +4711,54 @@ function secretsCommand() {
|
|
|
4222
4711
|
${references.length} secret(s), ${missing} missing.`);
|
|
4223
4712
|
process.exit(missing ? 1 : 0);
|
|
4224
4713
|
}
|
|
4714
|
+
var flagValue = (name) => flags.find((flag) => flag.startsWith(`--${name}=`))?.slice(name.length + 3);
|
|
4715
|
+
function driftOf(device) {
|
|
4716
|
+
const path = statePath(device);
|
|
4717
|
+
if (!existsSync2(path))
|
|
4718
|
+
return { status: "no snapshot", lines: [] };
|
|
4719
|
+
try {
|
|
4720
|
+
const adapter = adapterOf(device);
|
|
4721
|
+
const plan4 = adapter.plan(adapter.render(device), readFileSync2(path, "utf8"), options);
|
|
4722
|
+
const lines = [
|
|
4723
|
+
...plan4.steps.flatMap((step) => [step.title, ...step.show.map((line2) => ` ${line2}`)]),
|
|
4724
|
+
...plan4.problems.map((problem) => `PROBLEM ${problem}`)
|
|
4725
|
+
];
|
|
4726
|
+
return { status: lines.length ? "differs" : "matches", lines };
|
|
4727
|
+
} catch (error) {
|
|
4728
|
+
return { status: "cannot plan", lines: [error.message] };
|
|
4729
|
+
}
|
|
4730
|
+
}
|
|
4731
|
+
function graphCommand() {
|
|
4732
|
+
const requested = (flagValue("layer") ?? "l1").split(",");
|
|
4733
|
+
const layers = requested.includes("all") ? [...LAYERS] : requested;
|
|
4734
|
+
const unknown = layers.filter((layer) => !LAYERS.includes(layer));
|
|
4735
|
+
if (unknown.length)
|
|
4736
|
+
fail(`no layer ${unknown.join(", ")}; known: ${LAYERS.join(", ")}, all`);
|
|
4737
|
+
const format = flagValue("format") ?? "mermaid";
|
|
4738
|
+
const devices = selected(true);
|
|
4739
|
+
const graph = only(graphOf(network2.devices), layers, names.length ? devices.map((device) => device.name) : []);
|
|
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
|
+
};
|
|
4746
|
+
if (format === "mermaid")
|
|
4747
|
+
process.stdout.write(mermaid(annotated));
|
|
4748
|
+
else if (format === "dot")
|
|
4749
|
+
process.stdout.write(dot(annotated));
|
|
4750
|
+
else if (format === "json")
|
|
4751
|
+
console.log(JSON.stringify(annotated, null, 4));
|
|
4752
|
+
else
|
|
4753
|
+
fail(`no format ${format}; known: mermaid, dot, json`);
|
|
4754
|
+
}
|
|
4755
|
+
async function studioCommand() {
|
|
4756
|
+
const port = Number(flagValue("port") ?? 4600);
|
|
4757
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535)
|
|
4758
|
+
fail("--port takes a number from 1 to 65535");
|
|
4759
|
+
const { studio } = await import("../chunks/studio-femhydvd.js");
|
|
4760
|
+
await studio({ configPath, root: root2, port, open: !flags.includes("--no-open") });
|
|
4761
|
+
}
|
|
4225
4762
|
var commands = {
|
|
4226
4763
|
validate: validateCommand,
|
|
4227
4764
|
build: buildCommand,
|
|
@@ -4234,7 +4771,9 @@ var commands = {
|
|
|
4234
4771
|
peeringdb: peeringdbCommand,
|
|
4235
4772
|
librenms: librenmsCommand,
|
|
4236
4773
|
prometheus: prometheusCommand,
|
|
4237
|
-
secrets: secretsCommand
|
|
4774
|
+
secrets: secretsCommand,
|
|
4775
|
+
graph: graphCommand,
|
|
4776
|
+
studio: studioCommand
|
|
4238
4777
|
};
|
|
4239
4778
|
var run = commands[command2];
|
|
4240
4779
|
if (run)
|
package/dist/index.js
CHANGED