@takodotid/circuit 0.18.5 → 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 +546 -8
- 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/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 = "";
|
|
@@ -1413,8 +1414,8 @@ function planOrdered(menu, wanted, existing) {
|
|
|
1413
1414
|
return result;
|
|
1414
1415
|
}
|
|
1415
1416
|
function plan2(desired, current, options) {
|
|
1416
|
-
const want =
|
|
1417
|
-
const have =
|
|
1417
|
+
const want = parse2(desired);
|
|
1418
|
+
const have = parse2(current);
|
|
1418
1419
|
const owned = (command) => !(command.menu.endsWith("address-list") && want.generated.has(command.attrs.list ?? ""));
|
|
1419
1420
|
const wantedByMenu = byMenu(want.commands.filter(owned));
|
|
1420
1421
|
const existingByMenu = byMenu(have.commands.filter(owned));
|
|
@@ -3570,6 +3571,417 @@ function snmpExporterAuths(network2) {
|
|
|
3570
3571
|
`, notes };
|
|
3571
3572
|
}
|
|
3572
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
|
+
|
|
3573
3985
|
// src/core/validate.ts
|
|
3574
3986
|
import { X509Certificate as X509Certificate2 } from "node:crypto";
|
|
3575
3987
|
function validate(network2) {
|
|
@@ -3910,6 +4322,76 @@ function checkLinks(devices) {
|
|
|
3910
4322
|
return findings;
|
|
3911
4323
|
}
|
|
3912
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
|
+
|
|
3913
4395
|
// src/cli/index.ts
|
|
3914
4396
|
var HELP = `usage: circuit <command> [device...] [options]
|
|
3915
4397
|
|
|
@@ -3930,6 +4412,12 @@ var HELP = `usage: circuit <command> [device...] [options]
|
|
|
3930
4412
|
prometheus every device with SNMP, as Prometheus targets for snmp_exporter
|
|
3931
4413
|
--auths snmp_exporter's auths for them instead, with secrets as \${NAME}
|
|
3932
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
|
|
3933
4421
|
|
|
3934
4422
|
--config <path> the network's config file, circuit.config.ts in the working directory by default`;
|
|
3935
4423
|
var DEFAULT_CONFIG = "circuit.config.ts";
|
|
@@ -4223,6 +4711,54 @@ function secretsCommand() {
|
|
|
4223
4711
|
${references.length} secret(s), ${missing} missing.`);
|
|
4224
4712
|
process.exit(missing ? 1 : 0);
|
|
4225
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
|
+
}
|
|
4226
4762
|
var commands = {
|
|
4227
4763
|
validate: validateCommand,
|
|
4228
4764
|
build: buildCommand,
|
|
@@ -4235,7 +4771,9 @@ var commands = {
|
|
|
4235
4771
|
peeringdb: peeringdbCommand,
|
|
4236
4772
|
librenms: librenmsCommand,
|
|
4237
4773
|
prometheus: prometheusCommand,
|
|
4238
|
-
secrets: secretsCommand
|
|
4774
|
+
secrets: secretsCommand,
|
|
4775
|
+
graph: graphCommand,
|
|
4776
|
+
studio: studioCommand
|
|
4239
4777
|
};
|
|
4240
4778
|
var run = commands[command2];
|
|
4241
4779
|
if (run)
|
package/dist/index.js
CHANGED
package/dist/presets/index.js
CHANGED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { Device } from "../schema";
|
|
2
|
+
/** One view of the network. Every edge belongs to one. */
|
|
3
|
+
export type Layer = "l1" | "l2" | "l3" | "bgp" | "ospf" | "tunnels";
|
|
4
|
+
export declare const LAYERS: readonly Layer[];
|
|
5
|
+
/** One of our devices, a segment several of them share, or something outside the network. */
|
|
6
|
+
export type GraphNode = {
|
|
7
|
+
/** Unique among the nodes, such as `device:edge-01` or `as:64501`. */
|
|
8
|
+
id: string;
|
|
9
|
+
kind: "device" | "segment" | "outside";
|
|
10
|
+
/** A word or two, drawn on the node. */
|
|
11
|
+
label: string;
|
|
12
|
+
/** One fact per line, for a closer look. */
|
|
13
|
+
detail: string[];
|
|
14
|
+
};
|
|
15
|
+
/** A VLAN a cable carries. */
|
|
16
|
+
export type CarriedVlan = {
|
|
17
|
+
name: string;
|
|
18
|
+
id: number;
|
|
19
|
+
/** Carried without a tag: the access VLAN, or a trunk's native VLAN. */
|
|
20
|
+
untagged: boolean;
|
|
21
|
+
};
|
|
22
|
+
export type GraphEdge = {
|
|
23
|
+
layer: Layer;
|
|
24
|
+
/** Node ids. */
|
|
25
|
+
from: string;
|
|
26
|
+
to: string;
|
|
27
|
+
/** A few words, drawn on the edge. */
|
|
28
|
+
label: string;
|
|
29
|
+
/** One fact per line, for a closer look. */
|
|
30
|
+
detail: string[];
|
|
31
|
+
/** Only one end declares it: the far device does not declare the cable, session or tunnel back. */
|
|
32
|
+
oneSided?: true;
|
|
33
|
+
/** On `l2`, the VLANs the cable carries. */
|
|
34
|
+
vlans?: CarriedVlan[];
|
|
35
|
+
};
|
|
36
|
+
export type Graph = {
|
|
37
|
+
nodes: GraphNode[];
|
|
38
|
+
edges: GraphEdge[];
|
|
39
|
+
};
|
|
40
|
+
export declare const deviceId: (name: string) => string;
|
|
41
|
+
/** The network as a graph. It reads the config only, never a device, and works on a config that does not validate. */
|
|
42
|
+
export declare function graphOf(devices: readonly Device[]): Graph;
|
|
43
|
+
/** Only the edges on these layers, and among those only the ones that touch these devices when any are named. Every device named stays. */
|
|
44
|
+
export declare function only(graph: Graph, layers: readonly Layer[], deviceNames?: readonly string[]): Graph;
|