@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/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-ex1wcst7.js";
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, parse, format, removal) {
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 ? parse(old) : [];
364
- const after = fresh ? parse(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 parse(text) {
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 = parse(desired);
1416
- const have = parse(current);
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
@@ -6,7 +6,7 @@ import {
6
6
  isCidr,
7
7
  isGlobal,
8
8
  network
9
- } from "./chunks/index-ex1wcst7.js";
9
+ } from "./chunks/index-dczpxzzm.js";
10
10
  import"./chunks/index-fq57hya4.js";
11
11
 
12
12
  // src/core/define.ts
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  contains
3
- } from "../chunks/index-ex1wcst7.js";
3
+ } from "../chunks/index-dczpxzzm.js";
4
4
  import"../chunks/index-fq57hya4.js";
5
5
 
6
6
  // src/presets/index.ts