@takodotid/circuit 0.34.3 → 0.36.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 +14 -15
- package/dist/chunks/index-20kcjxjg.js +174 -0
- package/dist/chunks/index-2r4bbhac.js +977 -0
- package/dist/chunks/index-n7w3tg5y.js +149 -0
- package/dist/chunks/new-w3zys5y4.js +172 -0
- package/dist/chunks/server-gzsf5me5.js +259 -0
- package/dist/cli/index.js +11345 -9024
- package/dist/helpers/index.js +594 -0
- package/dist/index.js +9 -28
- package/dist/types/core/{addresses.d.ts → address-space.d.ts} +23 -7
- package/dist/types/core/address.d.ts +44 -0
- package/dist/types/core/cabling.d.ts +41 -0
- package/dist/types/core/cross-checks.d.ts +28 -0
- package/dist/types/core/define.d.ts +25 -3
- package/dist/types/core/project-files.d.ts +12 -0
- package/dist/types/core/schema-docs.d.ts +13 -4
- package/dist/types/core/secrets.d.ts +62 -21
- package/dist/types/core/{graph.d.ts → topology.d.ts} +32 -12
- package/dist/types/core/utils.d.ts +8 -0
- package/dist/types/core/validator.d.ts +19 -0
- package/dist/types/helpers/bgp.d.ts +97 -0
- package/dist/types/helpers/checks.d.ts +32 -0
- package/dist/types/helpers/exchange.d.ts +49 -0
- package/dist/types/helpers/filters.d.ts +51 -0
- package/dist/types/helpers/index.d.ts +10 -0
- package/dist/types/helpers/route-server.d.ts +52 -0
- package/dist/types/index.d.ts +5 -3
- package/dist/types/integrations/librenms.d.ts +60 -0
- package/dist/types/integrations/netbox.d.ts +89 -0
- package/dist/types/integrations/peeringdb.d.ts +24 -0
- package/dist/types/integrations/prometheus.d.ts +24 -0
- package/dist/types/integrations/registries.d.ts +8 -0
- package/dist/types/platforms/bgp-wait.d.ts +18 -0
- package/dist/types/platforms/catalog.d.ts +109 -0
- package/dist/types/platforms/derive.d.ts +17 -0
- package/dist/types/platforms/fortios/firewall.d.ts +45 -0
- package/dist/types/platforms/fortios/index.d.ts +134 -0
- package/dist/types/platforms/fortios/names.d.ts +52 -0
- package/dist/types/platforms/fortios/plan.d.ts +104 -0
- package/dist/types/platforms/fortios/render.d.ts +75 -0
- package/dist/types/platforms/fortios/routing.d.ts +56 -0
- package/dist/types/platforms/fortios/tree.d.ts +37 -0
- package/dist/types/platforms/index.d.ts +13 -0
- package/dist/types/platforms/junos/bgp.d.ts +44 -0
- package/dist/types/platforms/junos/index.d.ts +114 -0
- package/dist/types/platforms/junos/render.d.ts +154 -0
- package/dist/types/platforms/line-diff.d.ts +67 -0
- package/dist/types/platforms/platform.d.ts +120 -0
- package/dist/types/platforms/raisecom-ros/index.d.ts +71 -0
- package/dist/types/platforms/raisecom-ros/render.d.ts +41 -0
- package/dist/types/platforms/routeros/export.d.ts +59 -0
- package/dist/types/platforms/routeros/index.d.ts +96 -0
- package/dist/types/{adapters/devices → platforms}/routeros/models.d.ts +4 -0
- package/dist/types/platforms/routeros/plan.d.ts +54 -0
- package/dist/types/platforms/routeros/render.d.ts +240 -0
- package/dist/types/platforms/vrp/index.d.ts +83 -0
- package/dist/types/platforms/vrp/render.d.ts +96 -0
- package/dist/types/schema/common.d.ts +12 -6
- package/dist/types/schema/index.d.ts +1 -0
- package/dist/types/schema/interfaces.d.ts +14 -6
- package/dist/types/schema/policy.d.ts +18 -10
- package/dist/types/schema/routing.d.ts +14 -1
- package/dist/types/schema/system.d.ts +16 -6
- package/dist/types/schema/time-zones.d.ts +2 -0
- package/dist/types/transport/console.d.ts +79 -23
- package/dist/types/transport/path-check.d.ts +32 -0
- package/dist/types/transport/ssh.d.ts +17 -15
- package/package.json +18 -12
- package/src/cli/graph-format.ts +91 -0
- package/src/cli/index.ts +1074 -484
- package/src/cli/new.ts +208 -175
- package/src/cli/rename.ts +97 -91
- package/src/core/address-space.ts +320 -0
- package/src/core/address.ts +202 -0
- package/src/core/cabling.ts +154 -0
- package/src/core/cross-checks.ts +364 -0
- package/src/core/define.ts +26 -3
- package/src/core/project-files.ts +35 -0
- package/src/core/schema-docs.ts +138 -130
- package/src/core/secrets.ts +170 -76
- package/src/core/topology.ts +656 -0
- package/src/core/utils.ts +37 -0
- package/src/core/validator.ts +407 -0
- package/src/helpers/bgp.ts +301 -0
- package/src/helpers/checks.ts +178 -0
- package/src/helpers/exchange.ts +117 -0
- package/src/helpers/filters.ts +107 -0
- package/src/helpers/index.ts +12 -0
- package/src/helpers/route-server.ts +212 -0
- package/src/index.ts +6 -3
- package/src/integrations/librenms.ts +291 -0
- package/src/integrations/netbox.ts +620 -0
- package/src/integrations/peeringdb.ts +173 -0
- package/src/integrations/prometheus.ts +90 -0
- package/src/integrations/registries.ts +25 -0
- package/src/platforms/bgp-wait.ts +41 -0
- package/src/platforms/catalog.ts +69 -0
- package/src/platforms/derive.ts +117 -0
- package/src/{adapters/devices → platforms}/fortios/TRAPS.md +6 -0
- package/src/platforms/fortios/firewall.ts +377 -0
- package/src/platforms/fortios/index.ts +1060 -0
- package/src/platforms/fortios/names.ts +130 -0
- package/src/platforms/fortios/plan.ts +929 -0
- package/src/platforms/fortios/render.ts +779 -0
- package/src/platforms/fortios/routing.ts +476 -0
- package/src/platforms/fortios/tree.ts +129 -0
- package/src/platforms/index.ts +36 -0
- package/src/{adapters/devices → platforms}/junos/TRAPS.md +8 -0
- package/src/platforms/junos/bgp.ts +293 -0
- package/src/platforms/junos/index.ts +1006 -0
- package/src/platforms/junos/render.ts +967 -0
- package/src/platforms/line-diff.ts +244 -0
- package/src/platforms/platform.ts +122 -0
- package/src/platforms/raisecom-ros/TRAPS.md +40 -0
- package/src/platforms/raisecom-ros/index.ts +731 -0
- package/src/platforms/raisecom-ros/render.ts +328 -0
- package/src/{adapters/devices → platforms}/routeros/TRAPS.md +16 -2
- package/src/platforms/routeros/export.ts +342 -0
- package/src/platforms/routeros/index.ts +636 -0
- package/src/{adapters/devices → platforms}/routeros/models.ts +2 -0
- package/src/platforms/routeros/plan.ts +444 -0
- package/src/platforms/routeros/render.ts +1483 -0
- package/src/platforms/vrp/TRAPS.md +45 -0
- package/src/platforms/vrp/index.ts +652 -0
- package/src/platforms/vrp/render.ts +615 -0
- package/src/schema/common.ts +15 -6
- package/src/schema/index.ts +1 -0
- package/src/schema/interfaces.ts +15 -5
- package/src/schema/policy.ts +22 -10
- package/src/schema/routing.ts +16 -1
- package/src/schema/system.ts +16 -6
- package/src/schema/time-zones.ts +482 -0
- package/src/studio/data.ts +172 -168
- package/src/studio/git.ts +95 -90
- package/src/studio/public/app.js +161 -150
- package/src/studio/public/compare.js +219 -212
- package/src/studio/public/diff.js +77 -75
- package/src/studio/public/dom.js +118 -108
- package/src/studio/public/evaluate.js +584 -545
- package/src/studio/public/export.js +120 -118
- package/src/studio/public/graph.js +1343 -1266
- package/src/studio/public/net.js +115 -109
- package/src/studio/public/tables.js +1135 -1083
- package/src/studio/public/tools.js +561 -529
- package/src/studio/server.ts +202 -196
- package/src/transport/console.ts +363 -271
- package/src/transport/path-check.ts +91 -0
- package/src/transport/ssh.ts +98 -94
- package/templates/colocation/checks.ts +11 -6
- package/templates/colocation/edge-01.ts +9 -9
- package/templates/colocation/routing.ts +2 -2
- package/templates/colocation/tenancy.ts +3 -3
- package/templates/colocation/tor-01.ts +3 -3
- package/templates/edge-router/checks.ts +4 -4
- package/templates/edge-router/edge-01.ts +5 -5
- package/templates/edge-router/routing.ts +2 -2
- package/templates/project/AGENTS.md +5 -1
- package/templates/single-site/checks.ts +2 -2
- package/dist/chunks/index-47debt16.js +0 -956
- package/dist/chunks/index-v8zgr7wm.js +0 -128
- package/dist/chunks/new-ny89s7y7.js +0 -166
- package/dist/chunks/server-tjbb3rqc.js +0 -251
- package/dist/presets/index.js +0 -394
- package/dist/types/adapters/devices/catalog.d.ts +0 -86
- package/dist/types/adapters/devices/derive.d.ts +0 -7
- package/dist/types/adapters/devices/fortios/firewall.d.ts +0 -23
- package/dist/types/adapters/devices/fortios/index.d.ts +0 -58
- package/dist/types/adapters/devices/fortios/names.d.ts +0 -48
- package/dist/types/adapters/devices/fortios/plan.d.ts +0 -28
- package/dist/types/adapters/devices/fortios/render.d.ts +0 -38
- package/dist/types/adapters/devices/fortios/routing.d.ts +0 -32
- package/dist/types/adapters/devices/fortios/tree.d.ts +0 -33
- package/dist/types/adapters/devices/index.d.ts +0 -3
- package/dist/types/adapters/devices/junos/bgp.d.ts +0 -11
- package/dist/types/adapters/devices/junos/index.d.ts +0 -42
- package/dist/types/adapters/devices/junos/render.d.ts +0 -52
- package/dist/types/adapters/devices/lines.d.ts +0 -56
- package/dist/types/adapters/devices/raisecom-ros/index.d.ts +0 -28
- package/dist/types/adapters/devices/routeros/export.d.ts +0 -39
- package/dist/types/adapters/devices/routeros/index.d.ts +0 -30
- package/dist/types/adapters/devices/routeros/plan.d.ts +0 -4
- package/dist/types/adapters/devices/routeros/render.d.ts +0 -100
- package/dist/types/adapters/devices/types.d.ts +0 -40
- package/dist/types/adapters/devices/vrp/index.d.ts +0 -27
- package/dist/types/adapters/registries/index.d.ts +0 -3
- package/dist/types/adapters/registries/librenms.d.ts +0 -15
- package/dist/types/adapters/registries/peeringdb.d.ts +0 -16
- package/dist/types/adapters/registries/prometheus.d.ts +0 -16
- package/dist/types/core/across.d.ts +0 -4
- package/dist/types/core/addr.d.ts +0 -34
- package/dist/types/core/files.d.ts +0 -6
- package/dist/types/core/merge.d.ts +0 -4
- package/dist/types/core/time.d.ts +0 -2
- package/dist/types/core/validate.d.ts +0 -2
- package/dist/types/presets/index.d.ts +0 -183
- package/src/adapters/devices/catalog.ts +0 -41
- package/src/adapters/devices/derive.ts +0 -59
- package/src/adapters/devices/fortios/firewall.ts +0 -362
- package/src/adapters/devices/fortios/index.ts +0 -923
- package/src/adapters/devices/fortios/names.ts +0 -115
- package/src/adapters/devices/fortios/plan.ts +0 -888
- package/src/adapters/devices/fortios/render.ts +0 -768
- package/src/adapters/devices/fortios/routing.ts +0 -444
- package/src/adapters/devices/fortios/tree.ts +0 -121
- package/src/adapters/devices/index.ts +0 -9
- package/src/adapters/devices/junos/bgp.ts +0 -269
- package/src/adapters/devices/junos/index.ts +0 -862
- package/src/adapters/devices/junos/render.ts +0 -872
- package/src/adapters/devices/lines.ts +0 -212
- package/src/adapters/devices/raisecom-ros/TRAPS.md +0 -26
- package/src/adapters/devices/raisecom-ros/index.ts +0 -700
- package/src/adapters/devices/routeros/export.ts +0 -321
- package/src/adapters/devices/routeros/index.ts +0 -346
- package/src/adapters/devices/routeros/plan.ts +0 -398
- package/src/adapters/devices/routeros/render.ts +0 -1490
- package/src/adapters/devices/types.ts +0 -38
- package/src/adapters/devices/vrp/TRAPS.md +0 -26
- package/src/adapters/devices/vrp/index.ts +0 -865
- package/src/adapters/registries/index.ts +0 -19
- package/src/adapters/registries/librenms.ts +0 -92
- package/src/adapters/registries/peeringdb.ts +0 -169
- package/src/adapters/registries/prometheus.ts +0 -71
- package/src/cli/graph.ts +0 -89
- package/src/core/across.ts +0 -350
- package/src/core/addr.ts +0 -174
- package/src/core/addresses.ts +0 -316
- package/src/core/files.ts +0 -32
- package/src/core/graph.ts +0 -640
- package/src/core/merge.ts +0 -19
- package/src/core/time.ts +0 -15
- package/src/core/validate.ts +0 -348
- package/src/presets/index.ts +0 -579
- /package/dist/types/{adapters/devices → platforms}/fortios/models.d.ts +0 -0
- /package/dist/types/{adapters/devices → platforms}/junos/models.d.ts +0 -0
- /package/dist/types/{adapters/devices → platforms}/raisecom-ros/models.d.ts +0 -0
- /package/dist/types/{adapters/devices → platforms}/vrp/models.d.ts +0 -0
- /package/src/{adapters/devices → platforms}/fortios/models.ts +0 -0
- /package/src/{adapters/devices → platforms}/junos/models.ts +0 -0
- /package/src/{adapters/devices → platforms}/raisecom-ros/models.ts +0 -0
- /package/src/{adapters/devices → platforms}/vrp/models.ts +0 -0
|
@@ -0,0 +1,656 @@
|
|
|
1
|
+
// The network as a graph, read from the config alone: cables, the VLANs they carry, shared segments, BGP sessions, OSPF adjacencies and tunnels. `circuit graph` prints it and `circuit studio` draws it.
|
|
2
|
+
|
|
3
|
+
import type { Device, Gre, Routed, Switched, Vxlan, WireGuardPeer } from "../schema";
|
|
4
|
+
import type { Finding } from "./define";
|
|
5
|
+
import { Address } from "./address";
|
|
6
|
+
|
|
7
|
+
/** One view of the network. Every edge belongs to one. */
|
|
8
|
+
export type Layer = "l1" | "l2" | "l3" | "bgp" | "ospf" | "tunnels";
|
|
9
|
+
|
|
10
|
+
/** One of our devices, a segment several of them share, or something outside the network. */
|
|
11
|
+
export type GraphNode = {
|
|
12
|
+
/** Unique among the nodes, such as `device:edge-01` or `as:64501`. */
|
|
13
|
+
id: string;
|
|
14
|
+
kind: "device" | "segment" | "outside";
|
|
15
|
+
/** A word or two, drawn on the node. */
|
|
16
|
+
label: string;
|
|
17
|
+
/** One fact per line, for a closer look. */
|
|
18
|
+
detail: string[];
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/** A VLAN a cable carries. */
|
|
22
|
+
export type CarriedVlan = {
|
|
23
|
+
name: string;
|
|
24
|
+
id: number;
|
|
25
|
+
/** Carried without a tag: the access VLAN, or a trunk's native VLAN. */
|
|
26
|
+
untagged: boolean;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type GraphEdge = {
|
|
30
|
+
layer: Layer;
|
|
31
|
+
/** Node ids. */
|
|
32
|
+
from: string;
|
|
33
|
+
to: string;
|
|
34
|
+
/** A few words, drawn on the edge. */
|
|
35
|
+
label: string;
|
|
36
|
+
/** One fact per line, for a closer look. */
|
|
37
|
+
detail: string[];
|
|
38
|
+
/** Only one end declares it: the far device does not declare the cable, session or tunnel back. */
|
|
39
|
+
oneSided?: true;
|
|
40
|
+
/** On `l2`, the VLANs the cable carries. */
|
|
41
|
+
vlans?: CarriedVlan[];
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export type Graph = {
|
|
45
|
+
nodes: GraphNode[];
|
|
46
|
+
edges: GraphEdge[];
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** How a device compares with its last snapshot, found offline as `diff` finds it. */
|
|
50
|
+
export type Drift = {
|
|
51
|
+
status: "matches" | "differs" | "no snapshot" | "cannot plan";
|
|
52
|
+
/** The plan `diff` would show, when it differs. */
|
|
53
|
+
lines: string[];
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/** The graph with what `validate` found and how each device drawn compares with its snapshot. */
|
|
57
|
+
export type Annotated = Graph & {
|
|
58
|
+
findings: Finding[];
|
|
59
|
+
/** By device name. */
|
|
60
|
+
drift: Record<string, Drift>;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/** An address one of the devices holds on a port or interface. */
|
|
64
|
+
export type Holder = {
|
|
65
|
+
device: Device;
|
|
66
|
+
/** The port or interface. */
|
|
67
|
+
name: string;
|
|
68
|
+
/** With its length. */
|
|
69
|
+
address: string;
|
|
70
|
+
/** The far end of a point-to-point address. */
|
|
71
|
+
peer?: string;
|
|
72
|
+
vrrp: NonNullable<Routed<string>["vrrp"]>;
|
|
73
|
+
/** The VRF the address is in. The default table when absent. */
|
|
74
|
+
vrf?: string;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
class Builder {
|
|
78
|
+
readonly nodes = new Map<string, GraphNode>();
|
|
79
|
+
readonly edges: GraphEdge[] = [];
|
|
80
|
+
|
|
81
|
+
/** Add a node, or add the lines it does not hold yet to the one with that id. */
|
|
82
|
+
node(id: string, kind: GraphNode["kind"], label: string, detail: string[] = []): string {
|
|
83
|
+
const existing = this.nodes.get(id);
|
|
84
|
+
|
|
85
|
+
if (!existing) this.nodes.set(id, { id, kind, label, detail: [...detail] });
|
|
86
|
+
else for (const line of detail) if (!existing.detail.includes(line)) existing.detail.push(line);
|
|
87
|
+
|
|
88
|
+
return id;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
edge(edge: GraphEdge): GraphEdge {
|
|
92
|
+
this.edges.push(edge);
|
|
93
|
+
return edge;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export class Topology {
|
|
98
|
+
static readonly LAYERS: readonly Layer[] = ["l1", "l2", "l3", "bgp", "ospf", "tunnels"];
|
|
99
|
+
|
|
100
|
+
static deviceId(name: string): string {
|
|
101
|
+
return `device:${name}`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** An address as a key, so two spellings of one address compare equal. Undefined when it is not an address. */
|
|
105
|
+
static keyOf(address: string | undefined): string | undefined {
|
|
106
|
+
if (!address || !Address.isValid(address)) return undefined;
|
|
107
|
+
return `${Address.family(address)}:${Address.toNumber(address)}`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
private static isHostRoute(cidr: string): boolean {
|
|
111
|
+
const { len, family } = Address.split(cidr);
|
|
112
|
+
return len === Address.bits(family);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Ports, then interfaces, that can hold an address. */
|
|
116
|
+
static routedOf(device: Device): [string, Routed<string>][] {
|
|
117
|
+
const ports = Object.entries(device.ports ?? {}).flatMap(([name, port]) =>
|
|
118
|
+
port ? [[name, port] as [string, Routed<string>]] : []
|
|
119
|
+
);
|
|
120
|
+
const interfaces = Object.entries(device.interfaces ?? {}) as [string, Routed<string>][];
|
|
121
|
+
|
|
122
|
+
return [...ports, ...interfaces];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Every valid address the devices hold. An invalid one is left for `validate` to report. */
|
|
126
|
+
static holders(devices: readonly Device[]): Holder[] {
|
|
127
|
+
return devices.flatMap((device) =>
|
|
128
|
+
Topology.routedOf(device).flatMap(([name, settings]) =>
|
|
129
|
+
(settings.addresses ?? []).flatMap((entry) => {
|
|
130
|
+
const address = typeof entry === "string" ? entry : entry.address;
|
|
131
|
+
if (!Address.isCidr(address)) return [];
|
|
132
|
+
|
|
133
|
+
const peer = typeof entry === "string" ? undefined : entry.peer;
|
|
134
|
+
return [{ device, name, address, peer, vrrp: settings.vrrp ?? [], vrf: settings.vrf }];
|
|
135
|
+
})
|
|
136
|
+
)
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private static describeDevice(device: Device): string[] {
|
|
141
|
+
const addresses = Topology.routedOf(device)
|
|
142
|
+
.filter(([, settings]) => settings.addresses?.length)
|
|
143
|
+
.map(([name, settings]) => {
|
|
144
|
+
const list = (settings.addresses ?? []).map((entry) =>
|
|
145
|
+
typeof entry === "string" ? entry : `${entry.address} to ${entry.peer}`
|
|
146
|
+
);
|
|
147
|
+
const vrf = settings.vrf ? `, VRF ${settings.vrf}` : "";
|
|
148
|
+
return `${name}: ${list.join(", ")}${vrf}`;
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
return [
|
|
152
|
+
`${device.platform} ${device.model}`,
|
|
153
|
+
device.connection.host ? `reached at ${device.connection.host}` : "reached through its console",
|
|
154
|
+
...addresses,
|
|
155
|
+
];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** The VLANs a port or LAG carries, by id. */
|
|
159
|
+
private static carriedBy(device: Device, settings: Switched<string> | undefined): CarriedVlan[] {
|
|
160
|
+
const vlan = (name: string | undefined, untagged: boolean): CarriedVlan[] => {
|
|
161
|
+
const id = name ? device.vlans?.[name]?.id : undefined;
|
|
162
|
+
return name && id !== undefined ? [{ name, id, untagged }] : [];
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const trunk = (settings?.trunk_vlans ?? []).filter((name) => name !== settings?.native_vlan);
|
|
166
|
+
|
|
167
|
+
return [
|
|
168
|
+
...vlan(settings?.access_vlan, true),
|
|
169
|
+
...vlan(settings?.native_vlan, true),
|
|
170
|
+
...trunk.flatMap((name) => vlan(name, false)),
|
|
171
|
+
].sort((a, b) => a.id - b.id);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
private static vlanLabel(vlans: CarriedVlan[]): string {
|
|
175
|
+
if (vlans.length === 1) return `${vlans[0]!.name}${vlans[0]!.untagged ? ", untagged" : ""}`;
|
|
176
|
+
if (vlans.length <= 3) return vlans.map((vlan) => vlan.name).join(", ");
|
|
177
|
+
|
|
178
|
+
return `${vlans.length} VLANs`;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** `l1`, every cable, with a LAG's member cables as one edge; and `l2`, the VLANs each one carries. */
|
|
182
|
+
private static cables(devices: readonly Device[], graph: Builder): void {
|
|
183
|
+
const byName = new Map(devices.map((device) => [device.name, device]));
|
|
184
|
+
const drawn = new Set<string>();
|
|
185
|
+
const bundles = new Map<string, { cable: GraphEdge; members: number }>();
|
|
186
|
+
|
|
187
|
+
for (const device of devices) {
|
|
188
|
+
for (const [portName, port] of Object.entries(device.ports ?? {})) {
|
|
189
|
+
const link = port?.link;
|
|
190
|
+
if (!port || !link) continue;
|
|
191
|
+
|
|
192
|
+
// The far end drew this cable already when both ends name each other.
|
|
193
|
+
const here = `${device.name} ${portName}`;
|
|
194
|
+
const there = `${link.device} ${link.port}`;
|
|
195
|
+
if (drawn.has(`${there}|${here}`)) continue;
|
|
196
|
+
drawn.add(`${here}|${there}`);
|
|
197
|
+
|
|
198
|
+
const far = byName.get(link.device);
|
|
199
|
+
const farPort = far?.ports?.[link.port];
|
|
200
|
+
const linksBack = farPort?.link?.device === device.name && farPort.link.port === portName;
|
|
201
|
+
|
|
202
|
+
const to = far
|
|
203
|
+
? Topology.deviceId(far.name)
|
|
204
|
+
: graph.node(`missing:${link.device}`, "outside", link.device, ["named by a link, and not in the network"]);
|
|
205
|
+
|
|
206
|
+
const cableLine = `${here} to ${there}`;
|
|
207
|
+
|
|
208
|
+
// A LAG's member cables to one LAG on the far side are one edge.
|
|
209
|
+
const lag = port.lag;
|
|
210
|
+
const farLag = farPort?.lag;
|
|
211
|
+
const bundleKey = `${device.name} ${lag}|${link.device} ${farLag}`;
|
|
212
|
+
const bundle = lag && farLag && linksBack ? bundles.get(bundleKey) : undefined;
|
|
213
|
+
|
|
214
|
+
if (bundle) {
|
|
215
|
+
bundle.members++;
|
|
216
|
+
bundle.cable.detail.push(cableLine);
|
|
217
|
+
bundle.cable.label = `${lag} to ${farLag}, ${bundle.members} cables`;
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const isBundle = Boolean(lag && farLag && linksBack);
|
|
222
|
+
|
|
223
|
+
const cable = graph.edge({
|
|
224
|
+
layer: "l1",
|
|
225
|
+
from: Topology.deviceId(device.name),
|
|
226
|
+
to,
|
|
227
|
+
label: isBundle ? `${lag} to ${farLag}` : `${portName} to ${link.port}`,
|
|
228
|
+
detail: [cableLine],
|
|
229
|
+
...(linksBack ? {} : { oneSided: true as const }),
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
if (isBundle) bundles.set(bundleKey, { cable, members: 1 });
|
|
233
|
+
|
|
234
|
+
// What carries the VLANs is the LAG when the port is in one.
|
|
235
|
+
const lagSettings = lag ? device.interfaces?.[lag] : undefined;
|
|
236
|
+
const carrier = lagSettings?.type === "lag" ? lagSettings : lag ? undefined : port;
|
|
237
|
+
const vlans = Topology.carriedBy(device, carrier);
|
|
238
|
+
if (!vlans.length) continue;
|
|
239
|
+
|
|
240
|
+
graph.edge({
|
|
241
|
+
layer: "l2",
|
|
242
|
+
from: Topology.deviceId(device.name),
|
|
243
|
+
to,
|
|
244
|
+
label: Topology.vlanLabel(vlans),
|
|
245
|
+
detail: [
|
|
246
|
+
`${device.name} ${lag ?? portName}`,
|
|
247
|
+
...vlans.map((vlan) => `${vlan.name}, VLAN ${vlan.id}${vlan.untagged ? ", untagged" : ""}`),
|
|
248
|
+
],
|
|
249
|
+
vlans,
|
|
250
|
+
...(linksBack ? {} : { oneSided: true as const }),
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** `l3`: prefixes several devices hold an address in, and point-to-point addresses whose far end is another device. */
|
|
257
|
+
private static segments(holders: Holder[], owners: Map<string, Holder>, graph: Builder): void {
|
|
258
|
+
const bySegment = new Map<string, Holder[]>();
|
|
259
|
+
const pairs = new Set<string>();
|
|
260
|
+
|
|
261
|
+
for (const holder of holders) {
|
|
262
|
+
if (holder.peer) {
|
|
263
|
+
const far = owners.get(Topology.keyOf(holder.peer) ?? "");
|
|
264
|
+
if (!far || far.device === holder.device) continue;
|
|
265
|
+
|
|
266
|
+
const ends = [`${holder.device.name} ${holder.name}`, `${far.device.name} ${far.name}`].sort().join("|");
|
|
267
|
+
if (pairs.has(ends)) continue;
|
|
268
|
+
pairs.add(ends);
|
|
269
|
+
|
|
270
|
+
graph.edge({
|
|
271
|
+
layer: "l3",
|
|
272
|
+
from: Topology.deviceId(holder.device.name),
|
|
273
|
+
to: Topology.deviceId(far.device.name),
|
|
274
|
+
label: `${Address.host(holder.address)} to ${Address.host(far.address)}`,
|
|
275
|
+
detail: [`${holder.device.name} ${holder.name} ${holder.address}`, `${far.device.name} ${far.name} ${far.address}`],
|
|
276
|
+
});
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// A host route, such as a loopback's, is no segment.
|
|
281
|
+
if (Topology.isHostRoute(holder.address)) continue;
|
|
282
|
+
|
|
283
|
+
const segment = Address.prefixOf(holder.address);
|
|
284
|
+
bySegment.set(segment, [...(bySegment.get(segment) ?? []), holder]);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
for (const [segment, members] of bySegment) {
|
|
288
|
+
if (new Set(members.map((member) => member.device.name)).size < 2) continue;
|
|
289
|
+
|
|
290
|
+
const memberLines = members.map((member) => `${member.device.name} ${member.name} ${member.address}`);
|
|
291
|
+
const vrrpLines = members.flatMap((member) =>
|
|
292
|
+
member.vrrp.map((group) => `VRRP ${group.id} ${group.address} on ${member.device.name}, priority ${group.priority ?? 100}`)
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
// Two ends are a line between them; more share a segment drawn as its own node.
|
|
296
|
+
if (members.length === 2) {
|
|
297
|
+
const [a, b] = members as [Holder, Holder];
|
|
298
|
+
|
|
299
|
+
graph.edge({
|
|
300
|
+
layer: "l3",
|
|
301
|
+
from: Topology.deviceId(a.device.name),
|
|
302
|
+
to: Topology.deviceId(b.device.name),
|
|
303
|
+
label: segment,
|
|
304
|
+
detail: [...memberLines, ...vrrpLines],
|
|
305
|
+
});
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const id = graph.node(`segment:${segment}`, "segment", segment, [...memberLines, ...vrrpLines]);
|
|
310
|
+
|
|
311
|
+
for (const member of members) {
|
|
312
|
+
graph.edge({
|
|
313
|
+
layer: "l3",
|
|
314
|
+
from: Topology.deviceId(member.device.name),
|
|
315
|
+
to: id,
|
|
316
|
+
label: `${member.name} ${Address.host(member.address)}`,
|
|
317
|
+
detail: [`${member.device.name} ${member.name} ${member.address}`],
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** `bgp`: sessions between our devices, matched by the address each names, and to every AS outside. */
|
|
324
|
+
private static sessions(devices: readonly Device[], owners: Map<string, Holder>, graph: Builder): void {
|
|
325
|
+
const all = devices.flatMap((device) => {
|
|
326
|
+
const bgp = device.routing?.bgp;
|
|
327
|
+
|
|
328
|
+
return Object.entries(bgp?.neighbors ?? {}).map(([name, neighbor]) => {
|
|
329
|
+
const group = neighbor.group ? bgp?.groups?.[neighbor.group] : undefined;
|
|
330
|
+
|
|
331
|
+
return {
|
|
332
|
+
device,
|
|
333
|
+
name,
|
|
334
|
+
address: neighbor.address,
|
|
335
|
+
ownAs: bgp!.asn,
|
|
336
|
+
remoteAs: neighbor.remote_as ?? group?.remote_as,
|
|
337
|
+
role: neighbor.role ?? group?.role,
|
|
338
|
+
description: neighbor.description ?? group?.description,
|
|
339
|
+
import: neighbor.import ?? group?.import,
|
|
340
|
+
export: neighbor.export ?? group?.export,
|
|
341
|
+
};
|
|
342
|
+
});
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
type Session = (typeof all)[number];
|
|
346
|
+
|
|
347
|
+
const describe = (session: Session) => {
|
|
348
|
+
const parts = [
|
|
349
|
+
session.address,
|
|
350
|
+
session.remoteAs ? `AS${session.remoteAs}` : "no remote AS",
|
|
351
|
+
session.role,
|
|
352
|
+
session.import ? `import ${session.import}` : "imports nothing",
|
|
353
|
+
session.export ? `export ${session.export}` : "exports nothing",
|
|
354
|
+
];
|
|
355
|
+
|
|
356
|
+
return `${session.device.name} ${session.name}: ${parts.filter(Boolean).join(", ")}`;
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
const ownerOf = (session: Session) => owners.get(Topology.keyOf(session.address) ?? "")?.device;
|
|
360
|
+
const paired = new Set<Session>();
|
|
361
|
+
|
|
362
|
+
for (const session of all) {
|
|
363
|
+
if (paired.has(session)) continue;
|
|
364
|
+
|
|
365
|
+
const far = ownerOf(session);
|
|
366
|
+
|
|
367
|
+
if (far && far !== session.device) {
|
|
368
|
+
// The far device's session back: one of the same family that names an address this device holds.
|
|
369
|
+
const back = all.find(
|
|
370
|
+
(other) =>
|
|
371
|
+
other.device === far &&
|
|
372
|
+
!paired.has(other) &&
|
|
373
|
+
ownerOf(other) === session.device &&
|
|
374
|
+
Address.family(other.address) === Address.family(session.address)
|
|
375
|
+
);
|
|
376
|
+
if (back) paired.add(back);
|
|
377
|
+
|
|
378
|
+
graph.edge({
|
|
379
|
+
layer: "bgp",
|
|
380
|
+
from: Topology.deviceId(session.device.name),
|
|
381
|
+
to: Topology.deviceId(far.name),
|
|
382
|
+
label: session.remoteAs === session.ownAs ? "iBGP" : "eBGP",
|
|
383
|
+
detail: [describe(session), ...(back ? [describe(back)] : [`${far.name} has no session back`])],
|
|
384
|
+
...(back ? {} : { oneSided: true as const }),
|
|
385
|
+
});
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const outside = session.remoteAs
|
|
390
|
+
? graph.node(`as:${session.remoteAs}`, "outside", `AS${session.remoteAs}`, session.description ? [session.description] : [])
|
|
391
|
+
: graph.node(`address:${session.address}`, "outside", session.address, ["a BGP neighbor without a remote AS"]);
|
|
392
|
+
|
|
393
|
+
// A session inside our own AS should reach one of our devices.
|
|
394
|
+
const internal = session.remoteAs === session.ownAs;
|
|
395
|
+
|
|
396
|
+
graph.edge({
|
|
397
|
+
layer: "bgp",
|
|
398
|
+
from: Topology.deviceId(session.device.name),
|
|
399
|
+
to: outside,
|
|
400
|
+
label: [session.name, session.role].filter(Boolean).join(", "),
|
|
401
|
+
detail: [describe(session), ...(internal ? ["internal, and no device in the network holds the address"] : [])],
|
|
402
|
+
...(internal ? { oneSided: true as const } : {}),
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/** `ospf`: two devices with OSPF on interfaces in one area and one prefix, neither passive. */
|
|
408
|
+
private static adjacencies(devices: readonly Device[], graph: Builder): void {
|
|
409
|
+
const byKey = new Map<string, { device: Device; name: string; line: string }[]>();
|
|
410
|
+
|
|
411
|
+
for (const device of devices) {
|
|
412
|
+
for (const [area, { interfaces }] of Object.entries(device.routing?.ospf?.areas ?? {})) {
|
|
413
|
+
for (const [name, settings] of Object.entries(interfaces)) {
|
|
414
|
+
if (settings?.passive) continue;
|
|
415
|
+
|
|
416
|
+
const routed = (device.ports?.[name] ?? device.interfaces?.[name]) as Routed<string> | undefined;
|
|
417
|
+
const cost = settings?.cost ? `, cost ${settings.cost}` : "";
|
|
418
|
+
|
|
419
|
+
for (const entry of routed?.addresses ?? []) {
|
|
420
|
+
const address = typeof entry === "string" ? entry : entry.address;
|
|
421
|
+
if (!Address.isCidr(address) || Topology.isHostRoute(address)) continue;
|
|
422
|
+
|
|
423
|
+
const key = `${area} ${Address.prefixOf(address)}`;
|
|
424
|
+
const member = { device, name, line: `${device.name} ${name} ${address}${cost}` };
|
|
425
|
+
byKey.set(key, [...(byKey.get(key) ?? []), member]);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
for (const [key, members] of byKey) {
|
|
432
|
+
const [area] = key.split(" ");
|
|
433
|
+
|
|
434
|
+
for (const [index, a] of members.entries()) {
|
|
435
|
+
for (const b of members.slice(index + 1)) {
|
|
436
|
+
if (a.device === b.device) continue;
|
|
437
|
+
|
|
438
|
+
graph.edge({
|
|
439
|
+
layer: "ospf",
|
|
440
|
+
from: Topology.deviceId(a.device.name),
|
|
441
|
+
to: Topology.deviceId(b.device.name),
|
|
442
|
+
label: `area ${area}`,
|
|
443
|
+
detail: [a.line, b.line],
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/** `host:port` or `[host]:port` to the host. */
|
|
451
|
+
private static endpointHost(endpoint: string): string {
|
|
452
|
+
return endpoint.replace(/:\d+$/, "").replace(/^\[(.*)\]$/, "$1");
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/** `tunnels`: GRE and VXLAN tunnels matched by their endpoints, and WireGuard peers matched to the device at the far end. */
|
|
456
|
+
private static tunnels(devices: readonly Device[], owners: Map<string, Holder>, graph: Builder): void {
|
|
457
|
+
const ends = devices.flatMap((device) =>
|
|
458
|
+
Object.entries(device.interfaces ?? {}).flatMap(([name, iface]) =>
|
|
459
|
+
iface.type === "gre" || iface.type === "vxlan" ? [{ device, name, iface: iface as Gre<string> | Vxlan<string> }] : []
|
|
460
|
+
)
|
|
461
|
+
);
|
|
462
|
+
|
|
463
|
+
type End = (typeof ends)[number];
|
|
464
|
+
|
|
465
|
+
const same = (a: string, b: string) => Topology.keyOf(a) !== undefined && Topology.keyOf(a) === Topology.keyOf(b);
|
|
466
|
+
const mirrors = (a: End, b: End) =>
|
|
467
|
+
a.device !== b.device &&
|
|
468
|
+
a.iface.type === b.iface.type &&
|
|
469
|
+
same(a.iface.local, b.iface.remote) &&
|
|
470
|
+
same(a.iface.remote, b.iface.local) &&
|
|
471
|
+
(a.iface.type !== "vxlan" || (b.iface.type === "vxlan" && a.iface.vni === b.iface.vni));
|
|
472
|
+
|
|
473
|
+
const describe = (end: End) => {
|
|
474
|
+
const vni = end.iface.type === "vxlan" ? `, VNI ${end.iface.vni}` : "";
|
|
475
|
+
return `${end.device.name} ${end.name}: ${end.iface.local} to ${end.iface.remote}${vni}`;
|
|
476
|
+
};
|
|
477
|
+
|
|
478
|
+
const paired = new Set<End>();
|
|
479
|
+
|
|
480
|
+
for (const end of ends) {
|
|
481
|
+
if (paired.has(end)) continue;
|
|
482
|
+
|
|
483
|
+
const kind = end.iface.type === "gre" ? "GRE" : "VXLAN";
|
|
484
|
+
const mirror = ends.find((other) => !paired.has(other) && mirrors(end, other));
|
|
485
|
+
|
|
486
|
+
if (mirror) {
|
|
487
|
+
paired.add(mirror);
|
|
488
|
+
|
|
489
|
+
graph.edge({
|
|
490
|
+
layer: "tunnels",
|
|
491
|
+
from: Topology.deviceId(end.device.name),
|
|
492
|
+
to: Topology.deviceId(mirror.device.name),
|
|
493
|
+
label: `${kind} ${end.name} to ${mirror.name}`,
|
|
494
|
+
detail: [describe(end), describe(mirror)],
|
|
495
|
+
});
|
|
496
|
+
continue;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// The far endpoint is one of our devices, which declares no tunnel back.
|
|
500
|
+
const owner = owners.get(Topology.keyOf(end.iface.remote) ?? "")?.device;
|
|
501
|
+
|
|
502
|
+
if (owner && owner !== end.device) {
|
|
503
|
+
graph.edge({
|
|
504
|
+
layer: "tunnels",
|
|
505
|
+
from: Topology.deviceId(end.device.name),
|
|
506
|
+
to: Topology.deviceId(owner.name),
|
|
507
|
+
label: `${kind} ${end.name}`,
|
|
508
|
+
detail: [describe(end), `${owner.name} declares no tunnel back`],
|
|
509
|
+
oneSided: true,
|
|
510
|
+
});
|
|
511
|
+
continue;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
const outside = graph.node(
|
|
515
|
+
`address:${end.iface.remote}`,
|
|
516
|
+
"outside",
|
|
517
|
+
end.iface.remote,
|
|
518
|
+
end.iface.description ? [end.iface.description] : []
|
|
519
|
+
);
|
|
520
|
+
|
|
521
|
+
graph.edge({
|
|
522
|
+
layer: "tunnels",
|
|
523
|
+
from: Topology.deviceId(end.device.name),
|
|
524
|
+
to: outside,
|
|
525
|
+
label: `${kind} ${end.name}`,
|
|
526
|
+
detail: [describe(end)],
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
Topology.wireguard(devices, owners, graph);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
private static wireguard(devices: readonly Device[], owners: Map<string, Holder>, graph: Builder): void {
|
|
534
|
+
const interfaces = devices.flatMap((device) =>
|
|
535
|
+
Object.entries(device.interfaces ?? {}).flatMap(([name, iface]) =>
|
|
536
|
+
iface.type === "wireguard" ? [{ device, name, iface }] : []
|
|
537
|
+
)
|
|
538
|
+
);
|
|
539
|
+
|
|
540
|
+
// A peer is another of our devices when it may send from that device's tunnel address, or when its endpoint is that device's.
|
|
541
|
+
const farEndOf = (device: Device, peer: WireGuardPeer) =>
|
|
542
|
+
interfaces.find(({ device: other, iface }) => {
|
|
543
|
+
if (other === device) return false;
|
|
544
|
+
|
|
545
|
+
const tunnelAddresses = (iface.addresses ?? []).map((entry) =>
|
|
546
|
+
Address.host(typeof entry === "string" ? entry : entry.address)
|
|
547
|
+
);
|
|
548
|
+
const sendsFrom = tunnelAddresses.some(
|
|
549
|
+
(address) =>
|
|
550
|
+
Address.isValid(address) &&
|
|
551
|
+
peer.allowed_addresses.some((allowed) => Address.isCidr(allowed) && Address.contains(allowed, address))
|
|
552
|
+
);
|
|
553
|
+
|
|
554
|
+
const endpoint = peer.endpoint ? Topology.endpointHost(peer.endpoint) : undefined;
|
|
555
|
+
const isEndpoint = endpoint !== undefined && owners.get(Topology.keyOf(endpoint) ?? "")?.device === other;
|
|
556
|
+
|
|
557
|
+
return sendsFrom || isEndpoint;
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
const drawn = new Set<string>();
|
|
561
|
+
|
|
562
|
+
for (const { device, name, iface } of interfaces) {
|
|
563
|
+
const clients: WireGuardPeer[] = [];
|
|
564
|
+
|
|
565
|
+
for (const peer of iface.peers ?? []) {
|
|
566
|
+
const far = farEndOf(device, peer);
|
|
567
|
+
|
|
568
|
+
if (far) {
|
|
569
|
+
const ends = [`${device.name} ${name}`, `${far.device.name} ${far.name}`].sort().join("|");
|
|
570
|
+
if (drawn.has(ends)) continue;
|
|
571
|
+
drawn.add(ends);
|
|
572
|
+
|
|
573
|
+
graph.edge({
|
|
574
|
+
layer: "tunnels",
|
|
575
|
+
from: Topology.deviceId(device.name),
|
|
576
|
+
to: Topology.deviceId(far.device.name),
|
|
577
|
+
label: `WireGuard ${name} to ${far.name}`,
|
|
578
|
+
detail: [`${device.name} ${name} peer ${peer.name}`],
|
|
579
|
+
});
|
|
580
|
+
continue;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
if (!peer.endpoint) {
|
|
584
|
+
clients.push(peer);
|
|
585
|
+
continue;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
const endpoint = Topology.endpointHost(peer.endpoint);
|
|
589
|
+
const outside = graph.node(`address:${endpoint}`, "outside", peer.name, [`WireGuard endpoint ${peer.endpoint}`]);
|
|
590
|
+
|
|
591
|
+
graph.edge({
|
|
592
|
+
layer: "tunnels",
|
|
593
|
+
from: Topology.deviceId(device.name),
|
|
594
|
+
to: outside,
|
|
595
|
+
label: `WireGuard ${name}`,
|
|
596
|
+
detail: [`${device.name} ${name} peer ${peer.name}, allowed ${peer.allowed_addresses.join(", ")}`],
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
if (!clients.length) continue;
|
|
601
|
+
|
|
602
|
+
// Peers that dial in are drawn as one node, so a VPN with many clients stays readable.
|
|
603
|
+
const outside = graph.node(
|
|
604
|
+
`clients:${device.name}:${name}`,
|
|
605
|
+
"outside",
|
|
606
|
+
`${clients.length} ${clients.length === 1 ? "client" : "clients"}`,
|
|
607
|
+
clients.map((peer) => `${peer.name}: ${peer.allowed_addresses.join(", ")}`)
|
|
608
|
+
);
|
|
609
|
+
|
|
610
|
+
graph.edge({
|
|
611
|
+
layer: "tunnels",
|
|
612
|
+
from: Topology.deviceId(device.name),
|
|
613
|
+
to: outside,
|
|
614
|
+
label: `WireGuard ${name}`,
|
|
615
|
+
detail: [`peers that dial in to ${device.name} ${name}`],
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/** The network as a graph. It reads the config only, never a device, and works on a config that does not validate. */
|
|
621
|
+
static of(devices: readonly Device[]): Graph {
|
|
622
|
+
const graph = new Builder();
|
|
623
|
+
|
|
624
|
+
for (const device of devices) graph.node(Topology.deviceId(device.name), "device", device.name, Topology.describeDevice(device));
|
|
625
|
+
|
|
626
|
+
const holders = Topology.holders(devices);
|
|
627
|
+
|
|
628
|
+
// Who holds each address, for matching a session or tunnel to the device at its far end. The first holder wins.
|
|
629
|
+
const owners = new Map<string, Holder>();
|
|
630
|
+
for (const holder of holders) {
|
|
631
|
+
const key = Topology.keyOf(Address.host(holder.address))!;
|
|
632
|
+
if (!owners.has(key)) owners.set(key, holder);
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
Topology.cables(devices, graph);
|
|
636
|
+
Topology.segments(holders, owners, graph);
|
|
637
|
+
Topology.sessions(devices, owners, graph);
|
|
638
|
+
Topology.adjacencies(devices, graph);
|
|
639
|
+
Topology.tunnels(devices, owners, graph);
|
|
640
|
+
|
|
641
|
+
return { nodes: [...graph.nodes.values()], edges: graph.edges };
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/** Only the edges on these layers, and among those only the ones that touch these devices when any are named. Every device named stays. */
|
|
645
|
+
static only(graph: Graph, layers: readonly Layer[], deviceNames: readonly string[] = []): Graph {
|
|
646
|
+
const named = new Set(deviceNames.map(Topology.deviceId));
|
|
647
|
+
const touches = (edge: GraphEdge) => !named.size || named.has(edge.from) || named.has(edge.to);
|
|
648
|
+
|
|
649
|
+
const edges = graph.edges.filter((edge) => layers.includes(edge.layer) && touches(edge));
|
|
650
|
+
const ends = new Set(edges.flatMap((edge) => [edge.from, edge.to]));
|
|
651
|
+
|
|
652
|
+
const nodes = graph.nodes.filter((node) => (named.size ? named.has(node.id) : node.kind === "device") || ends.has(node.id));
|
|
653
|
+
|
|
654
|
+
return { nodes, edges };
|
|
655
|
+
}
|
|
656
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Small helpers that stand alone: combining records from several sources, and reading a time zone's offset.
|
|
2
|
+
|
|
3
|
+
type Merged<T extends readonly object[]> = T extends readonly [infer Head, ...infer Tail extends readonly object[]]
|
|
4
|
+
? Head & Merged<Tail>
|
|
5
|
+
: unknown;
|
|
6
|
+
|
|
7
|
+
export class Utils {
|
|
8
|
+
/** Combine records into one, such as a device's own interfaces and those a module derives for it. Unlike spreading them, a name two of them hold is an error rather than one silently replacing the other. */
|
|
9
|
+
static merge<const T extends readonly Record<string, unknown>[]>(...records: T): Merged<T> {
|
|
10
|
+
const result: Record<string, unknown> = {};
|
|
11
|
+
|
|
12
|
+
for (const record of records) {
|
|
13
|
+
for (const [key, value] of Object.entries(record)) {
|
|
14
|
+
if (key in result) throw new Error(`Utils.merge: ${key} is declared twice`);
|
|
15
|
+
result[key] = value;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return result as Merged<T>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Minutes east of UTC an IANA zone is at now, for platforms that take an offset rather than a zone. */
|
|
23
|
+
static timeZoneOffset(zone: string): number {
|
|
24
|
+
const name = new Intl.DateTimeFormat("en-US", {
|
|
25
|
+
timeZone: zone,
|
|
26
|
+
timeZoneName: "longOffset",
|
|
27
|
+
})
|
|
28
|
+
.formatToParts(new Date())
|
|
29
|
+
.find((entry) => entry.type === "timeZoneName")!.value;
|
|
30
|
+
const found = /GMT([+-])(\d{2}):(\d{2})/.exec(name);
|
|
31
|
+
if (!found) return 0;
|
|
32
|
+
|
|
33
|
+
const [, sign, hours, minutes] = found;
|
|
34
|
+
const sizeInMinutes = Number(hours) * 60 + Number(minutes);
|
|
35
|
+
return sign === "-" ? -sizeInMinutes : sizeInMinutes;
|
|
36
|
+
}
|
|
37
|
+
}
|