@takodotid/circuit 0.19.0 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/chunks/index-jtzfw679.js +936 -0
- package/dist/chunks/{index-dczpxzzm.js → index-xm9nma2t.js} +20 -1
- package/dist/chunks/server-5shmsmse.js +246 -0
- package/dist/cli/index.js +544 -487
- package/dist/index.js +1 -1
- package/dist/presets/index.js +1 -1
- package/dist/types/adapters/devices/derive.d.ts +2 -0
- package/dist/types/core/across.d.ts +4 -0
- package/dist/types/core/addr.d.ts +10 -0
- package/dist/types/core/addresses.d.ts +46 -0
- package/dist/types/core/files.d.ts +6 -0
- package/dist/types/core/graph.d.ts +31 -1
- package/dist/types/core/schema-docs.d.ts +16 -0
- package/package.json +1 -1
- package/src/adapters/devices/derive.ts +32 -0
- package/src/adapters/devices/raisecom-ros/index.ts +23 -3
- package/src/adapters/devices/routeros/index.ts +10 -0
- package/src/adapters/devices/vrp/index.ts +10 -1
- package/src/cli/graph.ts +1 -15
- package/src/cli/index.ts +154 -20
- package/src/cli/rename.ts +129 -0
- package/src/core/across.ts +322 -0
- package/src/core/addr.ts +29 -0
- package/src/core/addresses.ts +316 -0
- package/src/core/files.ts +30 -0
- package/src/core/graph.ts +27 -6
- package/src/core/schema-docs.ts +166 -0
- package/src/core/validate.ts +2 -52
- package/src/studio/data.ts +255 -0
- package/src/studio/git.ts +119 -0
- package/src/studio/public/app.js +147 -0
- package/src/studio/public/compare.js +243 -0
- package/src/studio/public/diff.js +97 -0
- package/src/studio/public/dom.js +108 -0
- package/src/studio/public/evaluate.js +648 -0
- package/src/studio/public/export.js +122 -0
- package/src/studio/public/favicon.svg +6 -0
- package/src/studio/public/graph.js +914 -0
- package/src/studio/public/index.html +67 -0
- package/src/studio/public/net.js +128 -0
- package/src/studio/public/style.css +693 -0
- package/src/studio/public/tables.js +1223 -0
- package/src/studio/public/tools.js +578 -0
- package/src/studio/server.ts +218 -0
- package/templates/project/AGENTS.md +2 -0
- package/dist/chunks/studio-femhydvd.js +0 -764
- package/src/cli/studio-page.ts +0 -876
- package/src/cli/studio.ts +0 -126
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
// Every address the config uses, by prefix, and what is still free: what `circuit addresses` prints. It reads the config only, so an address a device gave out by itself, such as a DHCP lease, is not known.
|
|
2
|
+
|
|
3
|
+
import type { Device, Family } from "../schema";
|
|
4
|
+
import {
|
|
5
|
+
contains,
|
|
6
|
+
familyOf,
|
|
7
|
+
formatAddress,
|
|
8
|
+
host,
|
|
9
|
+
isAddress,
|
|
10
|
+
isCidr,
|
|
11
|
+
network as prefixOf,
|
|
12
|
+
parse,
|
|
13
|
+
prefixesBetween,
|
|
14
|
+
rangeOf,
|
|
15
|
+
split,
|
|
16
|
+
} from "./addr";
|
|
17
|
+
import { holdersOf } from "./graph";
|
|
18
|
+
|
|
19
|
+
/** One address or range in a segment, and what holds it. */
|
|
20
|
+
export type Use = {
|
|
21
|
+
/** An address, or `first-last` for a range. */
|
|
22
|
+
address: string;
|
|
23
|
+
what: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/** A prefix our devices have addresses in. */
|
|
27
|
+
export type Segment = {
|
|
28
|
+
prefix: string;
|
|
29
|
+
uses: Use[];
|
|
30
|
+
/** How many addresses no one uses, as a number written out, since an IPv6 count does not fit a JavaScript number. */
|
|
31
|
+
free: string;
|
|
32
|
+
/** The lowest free address, when there is one. */
|
|
33
|
+
next?: string;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** A prefix in use without being a segment: a loopback's address, or a block routed elsewhere. */
|
|
37
|
+
export type Block = {
|
|
38
|
+
prefix: string;
|
|
39
|
+
what: string;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** Space the network owns: a prefix it originates in BGP, or one asked for. Everything in use inside it, and what is free. */
|
|
43
|
+
export type Space = {
|
|
44
|
+
prefix: string;
|
|
45
|
+
/** Why it is listed, such as `originated by edge-01`. */
|
|
46
|
+
owner: string;
|
|
47
|
+
segments: Segment[];
|
|
48
|
+
blocks: Block[];
|
|
49
|
+
/** The fewest prefixes that cover what nothing uses. */
|
|
50
|
+
free: string[];
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export type AddressReport = {
|
|
54
|
+
spaces: Space[];
|
|
55
|
+
/** What is in use outside every space. Left out when a prefix is asked for. */
|
|
56
|
+
elsewhere: { segments: Segment[]; blocks: Block[] };
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/** A use as a range of numbers, for counting. */
|
|
60
|
+
type Taken = { first: bigint; last: bigint };
|
|
61
|
+
|
|
62
|
+
const bits = (family: Family) => (family === "ipv4" ? 32 : 128);
|
|
63
|
+
const isHostRoute = (cidr: string) => split(cidr).len === bits(split(cidr).family);
|
|
64
|
+
const numberOf = (address: string) => parse(address)!;
|
|
65
|
+
|
|
66
|
+
/** Addresses a segment can give out: IPv4 leaves out the network and broadcast addresses below /31, and IPv6 the subnet-router anycast address. */
|
|
67
|
+
function usable(prefix: string): Taken {
|
|
68
|
+
const { first, last, family } = rangeOf(prefix);
|
|
69
|
+
const { len } = split(prefix);
|
|
70
|
+
|
|
71
|
+
if (family === "ipv4" && len <= 30) return { first: first + 1n, last: last - 1n };
|
|
72
|
+
if (family === "ipv6" && len < 127) return { first: first + 1n, last };
|
|
73
|
+
|
|
74
|
+
return { first, last };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Ranges merged, so overlapping ones count once. */
|
|
78
|
+
function merged(ranges: Taken[]): Taken[] {
|
|
79
|
+
const sorted = [...ranges].sort((a, b) => (a.first < b.first ? -1 : a.first > b.first ? 1 : 0));
|
|
80
|
+
const result: Taken[] = [];
|
|
81
|
+
|
|
82
|
+
for (const range of sorted) {
|
|
83
|
+
const previous = result.at(-1);
|
|
84
|
+
if (previous && range.first <= previous.last + 1n) previous.last = range.last > previous.last ? range.last : previous.last;
|
|
85
|
+
else result.push({ ...range });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return result;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** What is inside `within` and not in any of the ranges. */
|
|
92
|
+
function gaps(within: Taken, taken: Taken[]): Taken[] {
|
|
93
|
+
const result: Taken[] = [];
|
|
94
|
+
let cursor = within.first;
|
|
95
|
+
|
|
96
|
+
for (const range of merged(taken)) {
|
|
97
|
+
if (range.last < within.first || range.first > within.last) continue;
|
|
98
|
+
if (range.first > cursor) result.push({ first: cursor, last: range.first - 1n });
|
|
99
|
+
if (range.last + 1n > cursor) cursor = range.last + 1n;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (cursor <= within.last) result.push({ first: cursor, last: within.last });
|
|
103
|
+
return result;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** `12` or, for a count too long to read, `about 2^64`, to the nearest power of two. */
|
|
107
|
+
function count(value: bigint): string {
|
|
108
|
+
if (value <= 1n << 20n) return String(value);
|
|
109
|
+
|
|
110
|
+
const power = value.toString(2).length - 1;
|
|
111
|
+
const nearer = value - (1n << BigInt(power)) >= 1n << BigInt(power - 1) ? power + 1 : power;
|
|
112
|
+
return `about 2^${nearer}`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const byAddress = (a: Use, b: Use) => {
|
|
116
|
+
const first = (use: Use) => numberOf(use.address.split("-")[0]!);
|
|
117
|
+
return first(a) < first(b) ? -1 : first(a) > first(b) ? 1 : 0;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
/** IPv4 before IPv6, then by first address. */
|
|
121
|
+
function comparePrefixes(a: string, b: string): number {
|
|
122
|
+
const [ra, rb] = [rangeOf(a), rangeOf(b)];
|
|
123
|
+
if (ra.family !== rb.family) return ra.family === "ipv4" ? -1 : 1;
|
|
124
|
+
return ra.first < rb.first ? -1 : ra.first > rb.first ? 1 : 0;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const byPrefix = (a: { prefix: string }, b: { prefix: string }) => comparePrefixes(a.prefix, b.prefix);
|
|
128
|
+
|
|
129
|
+
/** Every address in use, by segment, under the space the network owns. With `within`, only what falls inside that prefix, as the one space. */
|
|
130
|
+
export function addressesOf(devices: readonly Device[], within?: string): AddressReport {
|
|
131
|
+
const segments = new Map<string, { uses: Use[]; taken: Taken[] }>();
|
|
132
|
+
const blocks: Block[] = [];
|
|
133
|
+
|
|
134
|
+
const segmentOf = (address: string) => [...segments.keys()].find((prefix) => contains(prefix, address));
|
|
135
|
+
|
|
136
|
+
// Segments come from the addresses on interfaces; a host route, such as a loopback's, is a block of its own.
|
|
137
|
+
for (const holder of holdersOf(devices)) {
|
|
138
|
+
const vrf = holder.device.ports?.[holder.name]?.vrf ?? holder.device.interfaces?.[holder.name]?.vrf;
|
|
139
|
+
const what = `${holder.device.name} ${holder.name}${vrf ? `, VRF ${vrf}` : ""}`;
|
|
140
|
+
|
|
141
|
+
if (isHostRoute(holder.address) || holder.peer) {
|
|
142
|
+
blocks.push({ prefix: `${host(holder.address)}/${bits(familyOf(holder.address))}`, what });
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const prefix = prefixOf(holder.address);
|
|
147
|
+
const segment = segments.get(prefix) ?? { uses: [], taken: [] };
|
|
148
|
+
const value = numberOf(host(holder.address));
|
|
149
|
+
|
|
150
|
+
segment.uses.push({ address: host(holder.address), what });
|
|
151
|
+
segment.taken.push({ first: value, last: value });
|
|
152
|
+
segments.set(prefix, segment);
|
|
153
|
+
|
|
154
|
+
for (const group of holder.vrrp) {
|
|
155
|
+
if (!isAddress(group.address) || !contains(prefix, group.address)) continue;
|
|
156
|
+
if (segment.uses.some((use) => use.address === group.address)) continue;
|
|
157
|
+
|
|
158
|
+
const holders = devices.filter((device) =>
|
|
159
|
+
Object.values({ ...device.ports, ...device.interfaces }).some((settings) =>
|
|
160
|
+
settings?.vrrp?.some((other) => other.id === group.id && other.address === group.address)
|
|
161
|
+
)
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
segment.uses.push({ address: group.address, what: `VRRP ${group.id} on ${holders.map((device) => device.name).join(", ")}` });
|
|
165
|
+
segment.taken.push({ first: numberOf(group.address), last: numberOf(group.address) });
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// A use inside a segment is an address taken; outside every segment, a block of its own. An address one of our interfaces holds is listed once, as that interface.
|
|
170
|
+
const take = (address: string, what: string, first = address, last = address) => {
|
|
171
|
+
const prefix = segmentOf(first);
|
|
172
|
+
if (!prefix) return false;
|
|
173
|
+
|
|
174
|
+
const segment = segments.get(prefix)!;
|
|
175
|
+
if (segment.uses.some((use) => use.address === address)) return true;
|
|
176
|
+
|
|
177
|
+
segment.uses.push({ address, what });
|
|
178
|
+
segment.taken.push({ first: numberOf(first), last: numberOf(last) });
|
|
179
|
+
return true;
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
for (const device of devices) {
|
|
183
|
+
for (const [name, server] of Object.entries(device.dhcp ?? {})) {
|
|
184
|
+
const [start, end] = server.pool;
|
|
185
|
+
if (isAddress(start) && isAddress(end)) take(`${start}-${end}`, `DHCP pool ${name} on ${device.name}`, start, end);
|
|
186
|
+
|
|
187
|
+
for (const reservation of server.reservations ?? []) {
|
|
188
|
+
if (isAddress(reservation.address))
|
|
189
|
+
take(reservation.address, `reserved for ${reservation.description ?? reservation.mac} by ${device.name}`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
for (const route of device.routing?.static ?? []) {
|
|
194
|
+
if (route.via && isAddress(route.via)) take(route.via, `next hop for ${route.prefix} on ${device.name}`);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
for (const [name, neighbor] of Object.entries(device.routing?.bgp?.neighbors ?? {})) {
|
|
198
|
+
if (isAddress(neighbor.address)) take(neighbor.address, `BGP neighbor ${name} of ${device.name}`);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
for (const [name, iface] of Object.entries(device.interfaces ?? {})) {
|
|
202
|
+
if (iface.type !== "wireguard") continue;
|
|
203
|
+
|
|
204
|
+
for (const peer of iface.peers ?? []) {
|
|
205
|
+
for (const allowed of peer.allowed_addresses) {
|
|
206
|
+
if (!isCidr(allowed)) continue;
|
|
207
|
+
|
|
208
|
+
const what = `WireGuard peer ${peer.name} on ${device.name} ${name}`;
|
|
209
|
+
if (isHostRoute(allowed) && take(host(allowed), what)) continue;
|
|
210
|
+
if (!segmentOf(host(allowed))) blocks.push({ prefix: prefixOf(allowed), what });
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Space the network owns: what it originates in BGP, or the prefix asked for.
|
|
217
|
+
const owners = new Map<string, string[]>();
|
|
218
|
+
for (const device of devices) {
|
|
219
|
+
for (const prefix of device.routing?.bgp?.networks ?? []) {
|
|
220
|
+
if (!isCidr(prefix)) continue;
|
|
221
|
+
owners.set(prefixOf(prefix), [...(owners.get(prefixOf(prefix)) ?? []), device.name]);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// A static route to a prefix no segment is in hands that block to whoever is at the next hop. A route covering segments or owned space, such as an aggregate, is not a use.
|
|
226
|
+
for (const device of devices) {
|
|
227
|
+
for (const route of device.routing?.static ?? []) {
|
|
228
|
+
if (!isCidr(route.prefix) || split(route.prefix).len === 0) continue;
|
|
229
|
+
|
|
230
|
+
const overlapsSegment = [...segments.keys()].some((prefix) => contains(route.prefix, prefix) || contains(prefix, route.prefix));
|
|
231
|
+
const coversOwned = [...owners.keys()].some((owned) => contains(route.prefix, owned));
|
|
232
|
+
if (overlapsSegment || coversOwned) continue;
|
|
233
|
+
|
|
234
|
+
const how = route.blackhole ? "discarded" : route.via ? `routed via ${route.via}` : `routed out ${route.interface}`;
|
|
235
|
+
blocks.push({ prefix: prefixOf(route.prefix), what: `${how} by ${device.name}` });
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const asked = within ? prefixOf(within) : undefined;
|
|
240
|
+
const spacePrefixes = asked ? [asked] : [...owners.keys()];
|
|
241
|
+
|
|
242
|
+
// A segment or block belongs to the smallest space it is inside.
|
|
243
|
+
const spaceOf = (prefix: string) =>
|
|
244
|
+
spacePrefixes.filter((space) => contains(space, prefix)).sort((a, b) => split(b).len - split(a).len)[0];
|
|
245
|
+
|
|
246
|
+
const toSegment = (prefix: string): Segment => {
|
|
247
|
+
const { uses, taken } = segments.get(prefix)!;
|
|
248
|
+
const free = gaps(usable(prefix), taken);
|
|
249
|
+
const total = free.reduce((sum, range) => sum + range.last - range.first + 1n, 0n);
|
|
250
|
+
const family = familyOf(prefix);
|
|
251
|
+
|
|
252
|
+
return {
|
|
253
|
+
prefix,
|
|
254
|
+
uses: uses.sort(byAddress),
|
|
255
|
+
free: count(total),
|
|
256
|
+
...(free[0] ? { next: formatAddress(free[0].first, family) } : {}),
|
|
257
|
+
};
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
const uniqueBlocks = blocks.filter(
|
|
261
|
+
(block, index) => blocks.findIndex((other) => other.prefix === block.prefix && other.what === block.what) === index
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
const spaces: Space[] = spacePrefixes.sort(comparePrefixes).map((space) => {
|
|
265
|
+
const inside = (prefix: string) => spaceOf(prefix) === space;
|
|
266
|
+
const spaceSegments = [...segments.keys()].filter(inside).sort(comparePrefixes);
|
|
267
|
+
const spaceBlocks = uniqueBlocks.filter((block) => inside(block.prefix)).sort(byPrefix);
|
|
268
|
+
|
|
269
|
+
// Smaller spaces inside this one are in use too.
|
|
270
|
+
const nested = spacePrefixes.filter((other) => other !== space && contains(space, other));
|
|
271
|
+
const taken = [...spaceSegments, ...spaceBlocks.map((block) => block.prefix), ...nested].map((prefix) => rangeOf(prefix));
|
|
272
|
+
const family = familyOf(space);
|
|
273
|
+
|
|
274
|
+
return {
|
|
275
|
+
prefix: space,
|
|
276
|
+
owner: asked && !owners.has(space) ? "asked for" : `originated by ${(owners.get(space) ?? []).join(", ")}`,
|
|
277
|
+
segments: spaceSegments.map(toSegment),
|
|
278
|
+
blocks: spaceBlocks,
|
|
279
|
+
free: gaps(rangeOf(space), taken).flatMap((range) => prefixesBetween(range.first, range.last, family)),
|
|
280
|
+
};
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
const outside = (prefix: string) => !spaceOf(prefix);
|
|
284
|
+
|
|
285
|
+
return {
|
|
286
|
+
spaces,
|
|
287
|
+
elsewhere: asked
|
|
288
|
+
? { segments: [], blocks: [] }
|
|
289
|
+
: {
|
|
290
|
+
segments: [...segments.keys()].filter(outside).sort(comparePrefixes).map(toSegment),
|
|
291
|
+
blocks: uniqueBlocks.filter((block) => outside(block.prefix)).sort(byPrefix),
|
|
292
|
+
},
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** Free prefixes of one length inside the free space, up to `limit`, and how many there are in all. */
|
|
297
|
+
export function freeOfLength(free: readonly string[], length: number, limit: number): { prefixes: string[]; total: bigint } {
|
|
298
|
+
const prefixes: string[] = [];
|
|
299
|
+
let total = 0n;
|
|
300
|
+
|
|
301
|
+
for (const prefix of free) {
|
|
302
|
+
const { len, family } = split(prefix);
|
|
303
|
+
if (len > length || length > bits(family)) continue;
|
|
304
|
+
|
|
305
|
+
const { first } = rangeOf(prefix);
|
|
306
|
+
const step = 1n << BigInt(bits(family) - length);
|
|
307
|
+
const fits = 1n << BigInt(length - len);
|
|
308
|
+
total += fits;
|
|
309
|
+
|
|
310
|
+
for (let index = 0n; index < fits && prefixes.length < limit; index++) {
|
|
311
|
+
prefixes.push(`${formatAddress(first + index * step, family)}/${length}`);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return { prefixes, total };
|
|
316
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// A project's own TypeScript files, and which of them declares a device: for renaming a device everywhere, and for a device's history.
|
|
2
|
+
|
|
3
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
4
|
+
import { join, relative } from "node:path";
|
|
5
|
+
|
|
6
|
+
/** Directories that hold no config of the project's own. */
|
|
7
|
+
const SKIPPED = new Set(["node_modules", ".git", ".circuit", "dist"]);
|
|
8
|
+
|
|
9
|
+
/** Every TypeScript file of the project, relative to its root, in name order. */
|
|
10
|
+
export function projectSources(root: string, directory = root): string[] {
|
|
11
|
+
const entries = readdirSync(directory, { withFileTypes: true }).sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
12
|
+
|
|
13
|
+
return entries.flatMap((entry) => {
|
|
14
|
+
if (entry.isDirectory()) return SKIPPED.has(entry.name) ? [] : projectSources(root, join(directory, entry.name));
|
|
15
|
+
return /\.(ts|mts|cts)$/.test(entry.name) ? [relative(root, join(directory, entry.name))] : [];
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const escape = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
20
|
+
|
|
21
|
+
/** Whether a file's text declares the device: it calls defineDevice and gives it the name, written out whole. */
|
|
22
|
+
export function declaresDevice(text: string, name: string): boolean {
|
|
23
|
+
const declares = new RegExp(`\\bname\\s*:\\s*(["'\`])${escape(name)}\\1`);
|
|
24
|
+
return text.includes("defineDevice") && declares.test(text);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The project files that declare the device, relative to the root. */
|
|
28
|
+
export function deviceFiles(root: string, name: string, sources = projectSources(root)): string[] {
|
|
29
|
+
return sources.filter((file) => declaresDevice(readFileSync(join(root, file), "utf8"), name));
|
|
30
|
+
}
|
package/src/core/graph.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
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
2
|
|
|
3
3
|
import type { Device, Gre, Routed, Switched, Vxlan, WireGuardPeer } from "../schema";
|
|
4
|
+
import type { Finding } from "./define";
|
|
4
5
|
import { contains, familyOf, host, isAddress, isCidr, network as prefixOf, parse, split } from "./addr";
|
|
5
6
|
|
|
6
7
|
/** One view of the network. Every edge belongs to one. */
|
|
@@ -47,8 +48,22 @@ export type Graph = {
|
|
|
47
48
|
edges: GraphEdge[];
|
|
48
49
|
};
|
|
49
50
|
|
|
51
|
+
/** How a device compares with its last snapshot, found offline as `diff` finds it. */
|
|
52
|
+
export type Drift = {
|
|
53
|
+
status: "matches" | "differs" | "no snapshot" | "cannot plan";
|
|
54
|
+
/** The plan `diff` would show, when it differs. */
|
|
55
|
+
lines: string[];
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/** The graph with what `validate` found and how each device drawn compares with its snapshot. */
|
|
59
|
+
export type Annotated = Graph & {
|
|
60
|
+
findings: Finding[];
|
|
61
|
+
/** By device name. */
|
|
62
|
+
drift: Record<string, Drift>;
|
|
63
|
+
};
|
|
64
|
+
|
|
50
65
|
/** An address one of the devices holds on a port or interface. */
|
|
51
|
-
type Holder = {
|
|
66
|
+
export type Holder = {
|
|
52
67
|
device: Device;
|
|
53
68
|
/** The port or interface. */
|
|
54
69
|
name: string;
|
|
@@ -62,7 +77,7 @@ type Holder = {
|
|
|
62
77
|
export const deviceId = (name: string) => `device:${name}`;
|
|
63
78
|
|
|
64
79
|
/** An address as a key, so two spellings of one address compare equal. Undefined when it is not an address. */
|
|
65
|
-
function keyOf(address: string | undefined): string | undefined {
|
|
80
|
+
export function keyOf(address: string | undefined): string | undefined {
|
|
66
81
|
if (!address || !isAddress(address)) return undefined;
|
|
67
82
|
return `${familyOf(address)}:${parse(address)}`;
|
|
68
83
|
}
|
|
@@ -93,7 +108,7 @@ class Builder {
|
|
|
93
108
|
}
|
|
94
109
|
|
|
95
110
|
/** Ports, then interfaces, that can hold an address. */
|
|
96
|
-
function routedOf(device: Device): [string, Routed<string>][] {
|
|
111
|
+
export function routedOf(device: Device): [string, Routed<string>][] {
|
|
97
112
|
const ports = Object.entries(device.ports ?? {}).flatMap(([name, port]) => (port ? [[name, port] as [string, Routed<string>]] : []));
|
|
98
113
|
const interfaces = Object.entries(device.interfaces ?? {}) as [string, Routed<string>][];
|
|
99
114
|
|
|
@@ -101,7 +116,7 @@ function routedOf(device: Device): [string, Routed<string>][] {
|
|
|
101
116
|
}
|
|
102
117
|
|
|
103
118
|
/** Every valid address the devices hold. An invalid one is left for `validate` to report. */
|
|
104
|
-
function holdersOf(devices: readonly Device[]): Holder[] {
|
|
119
|
+
export function holdersOf(devices: readonly Device[]): Holder[] {
|
|
105
120
|
return devices.flatMap((device) =>
|
|
106
121
|
routedOf(device).flatMap(([name, settings]) =>
|
|
107
122
|
(settings.addresses ?? []).flatMap((entry) => {
|
|
@@ -337,8 +352,14 @@ function sessions(devices: readonly Device[], owners: Map<string, Holder>, graph
|
|
|
337
352
|
const far = ownerOf(session);
|
|
338
353
|
|
|
339
354
|
if (far && far !== session.device) {
|
|
340
|
-
// The far device's session back: one that names an address this device holds.
|
|
341
|
-
const back = all.find(
|
|
355
|
+
// The far device's session back: one of the same family that names an address this device holds.
|
|
356
|
+
const back = all.find(
|
|
357
|
+
(other) =>
|
|
358
|
+
other.device === far &&
|
|
359
|
+
!paired.has(other) &&
|
|
360
|
+
ownerOf(other) === session.device &&
|
|
361
|
+
familyOf(other.address) === familyOf(session.address)
|
|
362
|
+
);
|
|
342
363
|
if (back) paired.add(back);
|
|
343
364
|
|
|
344
365
|
graph.edge({
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// The schema's own comments, read as data: each type, what it is, and each field with its type and description. The schema reference page and Studio both show them.
|
|
2
|
+
|
|
3
|
+
/** The schema files in reading order, with the title each gets. */
|
|
4
|
+
export const SCHEMA_FILES = [
|
|
5
|
+
["device.ts", "Device"],
|
|
6
|
+
["interfaces.ts", "Ports and interfaces"],
|
|
7
|
+
["routing.ts", "Routing"],
|
|
8
|
+
["policy.ts", "Route policies"],
|
|
9
|
+
["firewall.ts", "Firewall and ACLs"],
|
|
10
|
+
["system.ts", "System and management"],
|
|
11
|
+
["common.ts", "Common values"],
|
|
12
|
+
] as const;
|
|
13
|
+
|
|
14
|
+
export type Field = { path: string; type: string; description: string };
|
|
15
|
+
export type TypeDoc = { name: string; description: string; definition?: string; includes?: string[]; fields: Field[] };
|
|
16
|
+
|
|
17
|
+
/** The text of a JSDoc comment, without its markers, as one paragraph. */
|
|
18
|
+
function commentText(lines: string[]): string {
|
|
19
|
+
return lines
|
|
20
|
+
.map((line) => line.replace(/^\s*\/\*\*\s?|\s*\*\/\s*$|^\s*\*\s?/g, "").trim())
|
|
21
|
+
.filter(Boolean)
|
|
22
|
+
.join(" ");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** What a type parameter stands for, where it is not one of the device's names. */
|
|
26
|
+
const PARAMETER_MEANING: Record<string, string> = { Pl: "platform", M: "model" };
|
|
27
|
+
|
|
28
|
+
/** A type as a reader needs it: "name" for each of the device's names, and no parameter list on the schema's own types. */
|
|
29
|
+
function withoutGenerics(type: string, generics: Set<string>): string {
|
|
30
|
+
let result = type;
|
|
31
|
+
while (/NoInfer<([^<>]*)>/.test(result)) {
|
|
32
|
+
result = result.replace(/NoInfer<([^<>]*)>/g, (_, inner: string) => (inner.includes("|") ? `(${inner})` : inner));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
for (const name of generics) {
|
|
36
|
+
result = result.replace(new RegExp(`\\b${name}<[^<>]*(?:<[^<>]*>[^<>]*)*>`, "g"), name);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return result
|
|
40
|
+
.replace(/\b(Pl|M|N|V|I|P|S|G|A|AS|U|F|C|L|K)\b/g, (parameter) => PARAMETER_MEANING[parameter] ?? "name")
|
|
41
|
+
.replace(/\bname(?: \| name)+\b/g, "name")
|
|
42
|
+
.replace(/\(name\)/g, "name");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function parse(source: string, generics: Set<string>): TypeDoc[] {
|
|
46
|
+
const lines = source.split("\n");
|
|
47
|
+
const types: TypeDoc[] = [];
|
|
48
|
+
|
|
49
|
+
let comment: string[] = [];
|
|
50
|
+
let inComment = false;
|
|
51
|
+
let current: TypeDoc | undefined;
|
|
52
|
+
let depth = 0;
|
|
53
|
+
const parents: string[] = [];
|
|
54
|
+
let header: string | undefined;
|
|
55
|
+
|
|
56
|
+
/** Reads a whole declaration: a type it aliases, the types whose fields it also has, and whether a body opens. */
|
|
57
|
+
function finishHeader(): void {
|
|
58
|
+
const text = header!;
|
|
59
|
+
header = undefined;
|
|
60
|
+
|
|
61
|
+
// The part after `=`, with the generic parameter list before it left out.
|
|
62
|
+
const body = text.slice(text.indexOf("> =") >= 0 ? text.indexOf("> =") + 3 : text.indexOf("=") + 1).trim();
|
|
63
|
+
|
|
64
|
+
if (!body.includes("{")) {
|
|
65
|
+
current!.definition = body.replace(/;$/, "").replace(/^\|\s*/, "");
|
|
66
|
+
} else {
|
|
67
|
+
const before = body.slice(0, body.indexOf("{"));
|
|
68
|
+
const included = before
|
|
69
|
+
.split("&")
|
|
70
|
+
.map((part) => part.trim())
|
|
71
|
+
.filter((part) => /^\w/.test(part));
|
|
72
|
+
if (included.length) current!.includes = included;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
depth = (text.match(/{/g) ?? []).length - (text.match(/}/g) ?? []).length;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
for (const line of lines) {
|
|
79
|
+
const trimmed = line.trim();
|
|
80
|
+
|
|
81
|
+
if (trimmed.startsWith("/**")) {
|
|
82
|
+
comment = [trimmed];
|
|
83
|
+
inComment = !trimmed.endsWith("*/");
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (inComment) {
|
|
88
|
+
comment.push(trimmed);
|
|
89
|
+
if (trimmed.endsWith("*/")) inComment = false;
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// A declaration can span lines before its body opens or it ends.
|
|
94
|
+
if (header !== undefined) {
|
|
95
|
+
header += ` ${trimmed}`;
|
|
96
|
+
if (trimmed.endsWith("{") || trimmed.endsWith(";")) finishHeader();
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// A type the schema does not export is documented too, since exported types take its fields.
|
|
101
|
+
const declaration = /^(?:export )?type (\w+)(<)?/.exec(trimmed);
|
|
102
|
+
if (declaration && depth === 0) {
|
|
103
|
+
if (declaration[2]) generics.add(declaration[1]!);
|
|
104
|
+
|
|
105
|
+
current = { name: declaration[1]!, description: commentText(comment), fields: [] };
|
|
106
|
+
types.push(current);
|
|
107
|
+
comment = [];
|
|
108
|
+
|
|
109
|
+
header = trimmed;
|
|
110
|
+
if (trimmed.endsWith("{") || trimmed.endsWith(";")) finishHeader();
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (!current) continue;
|
|
115
|
+
|
|
116
|
+
const field = /^(?:readonly\s+)?("?[\w-]+"?)(\?)?:\s*(.*)$/.exec(trimmed);
|
|
117
|
+
if (field && depth >= 1) {
|
|
118
|
+
const [, name, optional, rest] = field;
|
|
119
|
+
const path = [...parents, name!.replace(/"/g, "")].join(".");
|
|
120
|
+
const opensObject = rest!.trim().endsWith("{");
|
|
121
|
+
const type = opensObject ? "object" : rest!.replace(/;$/, "").trim();
|
|
122
|
+
|
|
123
|
+
current.fields.push({ path: `${path}${optional ? "?" : ""}`, type, description: commentText(comment) });
|
|
124
|
+
comment = [];
|
|
125
|
+
|
|
126
|
+
if (opensObject) {
|
|
127
|
+
parents.push(name!.replace(/"/g, ""));
|
|
128
|
+
depth++;
|
|
129
|
+
}
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const opens = (trimmed.match(/{/g) ?? []).length;
|
|
134
|
+
const closes = (trimmed.match(/}/g) ?? []).length;
|
|
135
|
+
|
|
136
|
+
for (let index = 0; index < closes - opens; index++) {
|
|
137
|
+
if (parents.length >= depth - 1 && parents.length > 0) parents.pop();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
depth += opens - closes;
|
|
141
|
+
if (depth <= 0) {
|
|
142
|
+
depth = 0;
|
|
143
|
+
parents.length = 0;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
comment = [];
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return types;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Every type in each schema file, in the order given. Every file is read first, so a type is known to take parameters wherever it is used. */
|
|
153
|
+
export function schemaTypes(sources: readonly string[]): TypeDoc[][] {
|
|
154
|
+
const generics = new Set<string>();
|
|
155
|
+
const parsed = sources.map((source) => parse(source, generics));
|
|
156
|
+
|
|
157
|
+
for (const types of parsed) {
|
|
158
|
+
for (const type of types) {
|
|
159
|
+
if (type.definition) type.definition = withoutGenerics(type.definition, generics);
|
|
160
|
+
if (type.includes) type.includes = type.includes.map((name) => withoutGenerics(name, generics));
|
|
161
|
+
for (const field of type.fields) field.type = withoutGenerics(field.type, generics);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return parsed;
|
|
166
|
+
}
|
package/src/core/validate.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { X509Certificate } from "node:crypto";
|
|
|
4
4
|
import { adapters } from "../adapters/devices";
|
|
5
5
|
import { portsOf } from "../adapters/devices/catalog";
|
|
6
6
|
import type { Device } from "../schema";
|
|
7
|
+
import { checkAcross } from "./across";
|
|
7
8
|
import { contains, familyOf, isAddress, isCidr } from "./addr";
|
|
8
9
|
import type { Finding, Network } from "./define";
|
|
9
10
|
|
|
@@ -28,7 +29,7 @@ export function validate(network: Network): Finding[] {
|
|
|
28
29
|
else for (const field of adapter.unsupported(device)) report(`${device.platform} cannot express ${field}`);
|
|
29
30
|
}
|
|
30
31
|
|
|
31
|
-
findings.push(...
|
|
32
|
+
findings.push(...checkAcross(network.devices));
|
|
32
33
|
for (const check of network.checks ?? []) findings.push(...check(network.devices));
|
|
33
34
|
return findings;
|
|
34
35
|
}
|
|
@@ -327,54 +328,3 @@ function checkCertificates(device: Device, names: Names, report: Report): void {
|
|
|
327
328
|
}
|
|
328
329
|
}
|
|
329
330
|
}
|
|
330
|
-
|
|
331
|
-
/** The VLAN ids a port carries, by how it carries them, so two ends can be compared across devices. */
|
|
332
|
-
function carried(device: Device, port: NonNullable<NonNullable<Device["ports"]>[string]>) {
|
|
333
|
-
const id = (vlan: string | undefined) => (vlan ? device.vlans?.[vlan]?.id : undefined);
|
|
334
|
-
return {
|
|
335
|
-
access: id(port.access_vlan),
|
|
336
|
-
native: id(port.native_vlan),
|
|
337
|
-
trunk: (port.trunk_vlans ?? []).map(id).sort((a, b) => (a ?? 0) - (b ?? 0)),
|
|
338
|
-
};
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
/** A link names a device and port that exist, the far end links back, and both ends carry the same VLANs the same way. */
|
|
342
|
-
function checkLinks(devices: readonly Device[]): Finding[] {
|
|
343
|
-
const findings: Finding[] = [];
|
|
344
|
-
const byName = new Map(devices.map((device) => [device.name, device]));
|
|
345
|
-
|
|
346
|
-
for (const device of devices) {
|
|
347
|
-
for (const [portName, port] of Object.entries(device.ports ?? {})) {
|
|
348
|
-
const link = port?.link;
|
|
349
|
-
if (!port || !link) continue;
|
|
350
|
-
|
|
351
|
-
const report = (message: string) =>
|
|
352
|
-
findings.push({
|
|
353
|
-
level: "error",
|
|
354
|
-
device: device.name,
|
|
355
|
-
message: `port ${portName} link: ${message}`,
|
|
356
|
-
});
|
|
357
|
-
|
|
358
|
-
const far = byName.get(link.device);
|
|
359
|
-
if (!far) {
|
|
360
|
-
report(`device ${link.device} is not in the network`);
|
|
361
|
-
continue;
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
const farPort = far.ports?.[link.port];
|
|
365
|
-
if (!farPort) {
|
|
366
|
-
report(`${link.device} does not declare port ${link.port}`);
|
|
367
|
-
continue;
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
const linksBack = farPort.link?.device === device.name && farPort.link.port === portName;
|
|
371
|
-
if (!linksBack) report(`${link.device} ${link.port} does not link back to it`);
|
|
372
|
-
|
|
373
|
-
const here = JSON.stringify(carried(device, port));
|
|
374
|
-
const there = JSON.stringify(carried(far, farPort));
|
|
375
|
-
if (here !== there) report(`carries ${here}, and ${link.device} ${link.port} carries ${there}`);
|
|
376
|
-
}
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
return findings;
|
|
380
|
-
}
|