@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
package/dist/index.js
CHANGED
package/dist/presets/index.js
CHANGED
|
@@ -3,3 +3,5 @@ import type { Device } from "../../schema";
|
|
|
3
3
|
export declare function lagIds(device: Device): Map<string, number>;
|
|
4
4
|
/** The IPv4 networks of a port's or an interface's addresses. */
|
|
5
5
|
export declare function ipv4Networks(device: Device, iface: string): string[];
|
|
6
|
+
/** Fields neither switch platform, VRP nor Raisecom ROS, expresses: a service's own sources or port, a user's description or keys, the IP stack's host settings, a point-to-point address, and offload per interface. */
|
|
7
|
+
export declare function switchUnsupported(device: Device): string[];
|
|
@@ -14,6 +14,16 @@ export declare function isCidr(text: string): boolean;
|
|
|
14
14
|
export declare function network(cidr: string): string;
|
|
15
15
|
/** Is the address or prefix `inner` inside `outer`? */
|
|
16
16
|
export declare function contains(outer: string, inner: string): boolean;
|
|
17
|
+
/** A number back to the address it stands for. */
|
|
18
|
+
export declare const formatAddress: (value: bigint, family: Family) => string;
|
|
19
|
+
/** The first and last address of a prefix, as numbers. */
|
|
20
|
+
export declare function rangeOf(cidr: string): {
|
|
21
|
+
first: bigint;
|
|
22
|
+
last: bigint;
|
|
23
|
+
family: Family;
|
|
24
|
+
};
|
|
25
|
+
/** The fewest prefixes that cover every address from `first` to `last` and nothing else. */
|
|
26
|
+
export declare function prefixesBetween(first: bigint, last: bigint, family: Family): string[];
|
|
17
27
|
/** `/25` to `255.255.255.128`. */
|
|
18
28
|
export declare const netmask: (length: number) => string;
|
|
19
29
|
/** `/24` to `0.0.0.255`, the inverted mask some ACLs take. */
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { Device } from "../schema";
|
|
2
|
+
/** One address or range in a segment, and what holds it. */
|
|
3
|
+
export type Use = {
|
|
4
|
+
/** An address, or `first-last` for a range. */
|
|
5
|
+
address: string;
|
|
6
|
+
what: string;
|
|
7
|
+
};
|
|
8
|
+
/** A prefix our devices have addresses in. */
|
|
9
|
+
export type Segment = {
|
|
10
|
+
prefix: string;
|
|
11
|
+
uses: Use[];
|
|
12
|
+
/** How many addresses no one uses, as a number written out, since an IPv6 count does not fit a JavaScript number. */
|
|
13
|
+
free: string;
|
|
14
|
+
/** The lowest free address, when there is one. */
|
|
15
|
+
next?: string;
|
|
16
|
+
};
|
|
17
|
+
/** A prefix in use without being a segment: a loopback's address, or a block routed elsewhere. */
|
|
18
|
+
export type Block = {
|
|
19
|
+
prefix: string;
|
|
20
|
+
what: string;
|
|
21
|
+
};
|
|
22
|
+
/** Space the network owns: a prefix it originates in BGP, or one asked for. Everything in use inside it, and what is free. */
|
|
23
|
+
export type Space = {
|
|
24
|
+
prefix: string;
|
|
25
|
+
/** Why it is listed, such as `originated by edge-01`. */
|
|
26
|
+
owner: string;
|
|
27
|
+
segments: Segment[];
|
|
28
|
+
blocks: Block[];
|
|
29
|
+
/** The fewest prefixes that cover what nothing uses. */
|
|
30
|
+
free: string[];
|
|
31
|
+
};
|
|
32
|
+
export type AddressReport = {
|
|
33
|
+
spaces: Space[];
|
|
34
|
+
/** What is in use outside every space. Left out when a prefix is asked for. */
|
|
35
|
+
elsewhere: {
|
|
36
|
+
segments: Segment[];
|
|
37
|
+
blocks: Block[];
|
|
38
|
+
};
|
|
39
|
+
};
|
|
40
|
+
/** Every address in use, by segment, under the space the network owns. With `within`, only what falls inside that prefix, as the one space. */
|
|
41
|
+
export declare function addressesOf(devices: readonly Device[], within?: string): AddressReport;
|
|
42
|
+
/** Free prefixes of one length inside the free space, up to `limit`, and how many there are in all. */
|
|
43
|
+
export declare function freeOfLength(free: readonly string[], length: number, limit: number): {
|
|
44
|
+
prefixes: string[];
|
|
45
|
+
total: bigint;
|
|
46
|
+
};
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Every TypeScript file of the project, relative to its root, in name order. */
|
|
2
|
+
export declare function projectSources(root: string, directory?: string): string[];
|
|
3
|
+
/** Whether a file's text declares the device: it calls defineDevice and gives it the name, written out whole. */
|
|
4
|
+
export declare function declaresDevice(text: string, name: string): boolean;
|
|
5
|
+
/** The project files that declare the device, relative to the root. */
|
|
6
|
+
export declare function deviceFiles(root: string, name: string, sources?: string[]): string[];
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type { Device } from "../schema";
|
|
1
|
+
import type { Device, Routed } from "../schema";
|
|
2
|
+
import type { Finding } from "./define";
|
|
2
3
|
/** One view of the network. Every edge belongs to one. */
|
|
3
4
|
export type Layer = "l1" | "l2" | "l3" | "bgp" | "ospf" | "tunnels";
|
|
4
5
|
export declare const LAYERS: readonly Layer[];
|
|
@@ -37,7 +38,36 @@ export type Graph = {
|
|
|
37
38
|
nodes: GraphNode[];
|
|
38
39
|
edges: GraphEdge[];
|
|
39
40
|
};
|
|
41
|
+
/** How a device compares with its last snapshot, found offline as `diff` finds it. */
|
|
42
|
+
export type Drift = {
|
|
43
|
+
status: "matches" | "differs" | "no snapshot" | "cannot plan";
|
|
44
|
+
/** The plan `diff` would show, when it differs. */
|
|
45
|
+
lines: string[];
|
|
46
|
+
};
|
|
47
|
+
/** The graph with what `validate` found and how each device drawn compares with its snapshot. */
|
|
48
|
+
export type Annotated = Graph & {
|
|
49
|
+
findings: Finding[];
|
|
50
|
+
/** By device name. */
|
|
51
|
+
drift: Record<string, Drift>;
|
|
52
|
+
};
|
|
53
|
+
/** An address one of the devices holds on a port or interface. */
|
|
54
|
+
export type Holder = {
|
|
55
|
+
device: Device;
|
|
56
|
+
/** The port or interface. */
|
|
57
|
+
name: string;
|
|
58
|
+
/** With its length. */
|
|
59
|
+
address: string;
|
|
60
|
+
/** The far end of a point-to-point address. */
|
|
61
|
+
peer?: string;
|
|
62
|
+
vrrp: NonNullable<Routed<string>["vrrp"]>;
|
|
63
|
+
};
|
|
40
64
|
export declare const deviceId: (name: string) => string;
|
|
65
|
+
/** An address as a key, so two spellings of one address compare equal. Undefined when it is not an address. */
|
|
66
|
+
export declare function keyOf(address: string | undefined): string | undefined;
|
|
67
|
+
/** Ports, then interfaces, that can hold an address. */
|
|
68
|
+
export declare function routedOf(device: Device): [string, Routed<string>][];
|
|
69
|
+
/** Every valid address the devices hold. An invalid one is left for `validate` to report. */
|
|
70
|
+
export declare function holdersOf(devices: readonly Device[]): Holder[];
|
|
41
71
|
/** The network as a graph. It reads the config only, never a device, and works on a config that does not validate. */
|
|
42
72
|
export declare function graphOf(devices: readonly Device[]): Graph;
|
|
43
73
|
/** Only the edges on these layers, and among those only the ones that touch these devices when any are named. Every device named stays. */
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** The schema files in reading order, with the title each gets. */
|
|
2
|
+
export declare const SCHEMA_FILES: readonly [readonly ["device.ts", "Device"], readonly ["interfaces.ts", "Ports and interfaces"], readonly ["routing.ts", "Routing"], readonly ["policy.ts", "Route policies"], readonly ["firewall.ts", "Firewall and ACLs"], readonly ["system.ts", "System and management"], readonly ["common.ts", "Common values"]];
|
|
3
|
+
export type Field = {
|
|
4
|
+
path: string;
|
|
5
|
+
type: string;
|
|
6
|
+
description: string;
|
|
7
|
+
};
|
|
8
|
+
export type TypeDoc = {
|
|
9
|
+
name: string;
|
|
10
|
+
description: string;
|
|
11
|
+
definition?: string;
|
|
12
|
+
includes?: string[];
|
|
13
|
+
fields: Field[];
|
|
14
|
+
};
|
|
15
|
+
/** 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. */
|
|
16
|
+
export declare function schemaTypes(sources: readonly string[]): TypeDoc[][];
|
package/package.json
CHANGED
|
@@ -25,3 +25,35 @@ export function ipv4Networks(device: Device, iface: string): string[] {
|
|
|
25
25
|
|
|
26
26
|
return addresses.filter((address) => familyOf(address) === "ipv4").map((address) => network(address));
|
|
27
27
|
}
|
|
28
|
+
|
|
29
|
+
/** Services the switch platforms run. Each takes its sources from `management.allow`, on the protocol's own port. */
|
|
30
|
+
const SWITCH_SERVICES = ["ssh", "telnet", "snmp"] as const;
|
|
31
|
+
|
|
32
|
+
/** Fields neither switch platform, VRP nor Raisecom ROS, expresses: a service's own sources or port, a user's description or keys, the IP stack's host settings, a point-to-point address, and offload per interface. */
|
|
33
|
+
export function switchUnsupported(device: Device): string[] {
|
|
34
|
+
const found: string[] = [];
|
|
35
|
+
|
|
36
|
+
for (const service of SWITCH_SERVICES) {
|
|
37
|
+
const settings = device.management?.[service];
|
|
38
|
+
if (settings?.allow) found.push(`management.${service}.allow, every service takes management.allow`);
|
|
39
|
+
if (settings?.port) found.push(`management.${service}.port`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
for (const [name, user] of Object.entries(device.users ?? {})) {
|
|
43
|
+
if (user.description) found.push(`users.${name}.description`);
|
|
44
|
+
if (user.ssh_keys?.length) found.push(`users.${name}.ssh_keys`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (device.system?.ip) found.push("system.ip");
|
|
48
|
+
|
|
49
|
+
for (const [name, port] of Object.entries(device.ports ?? {})) {
|
|
50
|
+
if (port?.hardware_offload !== undefined) found.push(`ports.${name}.hardware_offload`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
for (const [name, iface] of Object.entries(device.interfaces ?? {})) {
|
|
54
|
+
if (iface.addresses?.some((entry) => typeof entry !== "string")) found.push(`interfaces.${name}.addresses with a peer`);
|
|
55
|
+
if (iface.hardware_offload !== undefined) found.push(`interfaces.${name}.hardware_offload`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return found;
|
|
59
|
+
}
|
|
@@ -9,7 +9,7 @@ import { offset } from "../../../core/time";
|
|
|
9
9
|
import type { Device, Port, SnmpUser, Switched, Vrrp } from "../../../schema";
|
|
10
10
|
import { connect, readUntil, send, shell } from "../../../transport/ssh";
|
|
11
11
|
import { portsOf, spell } from "../catalog";
|
|
12
|
-
import { ipv4Networks, lagIds } from "../derive";
|
|
12
|
+
import { ipv4Networks, lagIds, switchUnsupported } from "../derive";
|
|
13
13
|
import { compare, type Dialect, entries, type Group, scrub, steps, vlanListChange } from "../lines";
|
|
14
14
|
import type { DeviceAdapter, Plan, Step } from "../types";
|
|
15
15
|
|
|
@@ -387,7 +387,14 @@ export function unsupported(device: Device): string[] {
|
|
|
387
387
|
continue;
|
|
388
388
|
}
|
|
389
389
|
|
|
390
|
-
|
|
390
|
+
for (const field of ["vrf", "description", "mtu", "ipv6_ra"] as const) {
|
|
391
|
+
if (iface[field] !== undefined) found.push(`interfaces.${name}.${field}`);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
for (const entry of iface.addresses ?? []) {
|
|
395
|
+
const address = typeof entry === "string" ? entry : entry.address;
|
|
396
|
+
if (familyOf(address) === "ipv6") found.push(`interfaces.${name}.addresses ${address}, only IPv4 here`);
|
|
397
|
+
}
|
|
391
398
|
|
|
392
399
|
for (const group of iface.vrrp ?? []) {
|
|
393
400
|
if (familyOf(group.address) === "ipv6") found.push(`interfaces.${name}.vrrp for IPv6`);
|
|
@@ -412,7 +419,15 @@ export function unsupported(device: Device): string[] {
|
|
|
412
419
|
}
|
|
413
420
|
|
|
414
421
|
for (const route of routing?.static ?? []) {
|
|
415
|
-
if (!route.via || route.vrf
|
|
422
|
+
if (!route.via || route.vrf || familyOf(route.prefix) === "ipv6") {
|
|
423
|
+
found.push(`routing.static ${route.prefix}, only an IPv4 next hop in the default table`);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
if (route.description || route.distance) found.push(`routing.static ${route.prefix} description and distance`);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
for (const [name, vlan] of Object.entries(device.vlans ?? {})) {
|
|
430
|
+
if (vlan.description) found.push(`vlans.${name}.description`);
|
|
416
431
|
}
|
|
417
432
|
|
|
418
433
|
const UNSUPPORTED_SECTIONS = [
|
|
@@ -443,6 +458,9 @@ export function unsupported(device: Device): string[] {
|
|
|
443
458
|
found.push(`management.snmp.users.${name}.auth ${user.auth}, the platform authenticates with SHA1 or MD5`);
|
|
444
459
|
}
|
|
445
460
|
|
|
461
|
+
if (management.snmp?.contact || management.snmp?.location) found.push("management.snmp.contact and location");
|
|
462
|
+
if (management.ssh?.weak_crypto) found.push("management.ssh.weak_crypto");
|
|
463
|
+
|
|
446
464
|
if (device.lldp && device.lldp !== true) found.push("lldp.interfaces, LLDP is per device");
|
|
447
465
|
if (device.stp?.mode && device.stp.mode !== "mstp") found.push("stp.mode other than mstp");
|
|
448
466
|
|
|
@@ -456,6 +474,8 @@ export function unsupported(device: Device): string[] {
|
|
|
456
474
|
found.push("system.dns, logging, release_channel, banner and ntp.serve");
|
|
457
475
|
}
|
|
458
476
|
|
|
477
|
+
found.push(...switchUnsupported(device));
|
|
478
|
+
|
|
459
479
|
return found;
|
|
460
480
|
}
|
|
461
481
|
|
|
@@ -99,6 +99,14 @@ export function unsupported(device: Device): string[] {
|
|
|
99
99
|
}
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
+
for (const [name, iface] of Object.entries(device.interfaces ?? {})) {
|
|
103
|
+
if (iface.type !== "lag") continue;
|
|
104
|
+
|
|
105
|
+
for (const field of ["storm_control", "stp"] as const) {
|
|
106
|
+
if (iface[field] !== undefined) found.push(`interfaces.${name}.${field}, on a LAG`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
102
110
|
const bgp = device.routing?.bgp;
|
|
103
111
|
|
|
104
112
|
for (const [name, neighbor] of Object.entries(bgp?.neighbors ?? {})) {
|
|
@@ -113,6 +121,8 @@ export function unsupported(device: Device): string[] {
|
|
|
113
121
|
if (management?.console) found.push("management.console, the console uses the device's users");
|
|
114
122
|
if (management?.privilege_password) found.push("management.privilege_password, the platform has no privileged mode");
|
|
115
123
|
|
|
124
|
+
if (management?.snmp?.port) found.push("management.snmp.port");
|
|
125
|
+
|
|
116
126
|
if (management?.ssh?.auth_timeout || management?.ssh?.auth_retries) {
|
|
117
127
|
found.push("management.ssh.auth_timeout and auth_retries");
|
|
118
128
|
}
|
|
@@ -9,7 +9,7 @@ import { offset } from "../../../core/time";
|
|
|
9
9
|
import type { Device, Interface, Port, Vrrp } from "../../../schema";
|
|
10
10
|
import { connect, readUntil, send, shell } from "../../../transport/ssh";
|
|
11
11
|
import { portsOf, spell } from "../catalog";
|
|
12
|
-
import { ipv4Networks, lagIds } from "../derive";
|
|
12
|
+
import { ipv4Networks, lagIds, switchUnsupported } from "../derive";
|
|
13
13
|
import { compare, type Dialect, scrub, steps, vlanListChange } from "../lines";
|
|
14
14
|
import type { DeviceAdapter, Plan } from "../types";
|
|
15
15
|
|
|
@@ -608,9 +608,14 @@ export function unsupported(device: Device): string[] {
|
|
|
608
608
|
for (const [name, iface] of Object.entries(device.interfaces ?? {})) {
|
|
609
609
|
if (iface.type !== "vlan" && iface.type !== "lag") found.push(`interfaces.${name}, a ${iface.type}`);
|
|
610
610
|
if (iface.type === "lag" && iface.addresses?.length) found.push(`interfaces.${name}.addresses, LAGs are switched`);
|
|
611
|
+
if (iface.mtu !== undefined) found.push(`interfaces.${name}.mtu`);
|
|
611
612
|
for (const group of iface.vrrp ?? []) if (familyOf(group.address) === "ipv6") found.push(`interfaces.${name}.vrrp for IPv6`);
|
|
612
613
|
}
|
|
613
614
|
|
|
615
|
+
for (const [name, vrf] of Object.entries(device.vrfs ?? {})) {
|
|
616
|
+
if (vrf.description) found.push(`vrfs.${name}.description`);
|
|
617
|
+
}
|
|
618
|
+
|
|
614
619
|
const routing = device.routing;
|
|
615
620
|
if (routing?.bgp) found.push("routing.bgp");
|
|
616
621
|
if (routing?.rpki) found.push("routing.rpki");
|
|
@@ -644,6 +649,10 @@ export function unsupported(device: Device): string[] {
|
|
|
644
649
|
found.push("system.dns, logging, release_channel and banner");
|
|
645
650
|
}
|
|
646
651
|
|
|
652
|
+
if (system.transceiver_monitoring !== undefined) found.push("system.transceiver_monitoring");
|
|
653
|
+
|
|
654
|
+
found.push(...switchUnsupported(device));
|
|
655
|
+
|
|
647
656
|
const flow = device.flow_export;
|
|
648
657
|
if (flow && flow.protocol !== "sflow") found.push(`flow_export.protocol ${flow.protocol}, the platform exports sFlow`);
|
|
649
658
|
const sampling = flow?.protocol === "sflow" ? flow.sampling : undefined;
|
package/src/cli/graph.ts
CHANGED
|
@@ -1,20 +1,6 @@
|
|
|
1
1
|
// What `circuit graph` prints: the graph of the network with what `validate` found and how each device compares with its last snapshot, as Mermaid, DOT or JSON.
|
|
2
2
|
|
|
3
|
-
import type
|
|
4
|
-
import { deviceId, type Graph, type GraphNode } from "../core/graph";
|
|
5
|
-
|
|
6
|
-
/** How a device compares with its last snapshot, found offline as `diff` finds it. */
|
|
7
|
-
export type Drift = {
|
|
8
|
-
status: "matches" | "differs" | "no snapshot" | "cannot plan";
|
|
9
|
-
/** The plan `diff` would show, when it differs. */
|
|
10
|
-
lines: string[];
|
|
11
|
-
};
|
|
12
|
-
|
|
13
|
-
export type Annotated = Graph & {
|
|
14
|
-
findings: Finding[];
|
|
15
|
-
/** By device name. */
|
|
16
|
-
drift: Record<string, Drift>;
|
|
17
|
-
};
|
|
3
|
+
import { type Annotated, deviceId, type GraphNode } from "../core/graph";
|
|
18
4
|
|
|
19
5
|
/** `error` when validate found an error on the device, `warning` for a warning or a difference from its snapshot. */
|
|
20
6
|
export function markOf(graph: Annotated, node: GraphNode): "error" | "warning" | undefined {
|
package/src/cli/index.ts
CHANGED
|
@@ -3,20 +3,24 @@
|
|
|
3
3
|
|
|
4
4
|
import { createPrivateKey, createPublicKey } from "node:crypto";
|
|
5
5
|
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
6
|
-
import { basename, dirname, join, resolve } from "node:path";
|
|
6
|
+
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
|
7
7
|
import { adapters } from "../adapters/devices";
|
|
8
8
|
import type { ApplyOptions, DeviceAdapter, Plan } from "../adapters/devices/types";
|
|
9
9
|
import { registries } from "../adapters/registries";
|
|
10
10
|
import { planLibreNms, sendLibreNms } from "../adapters/registries/librenms";
|
|
11
11
|
import { planPeeringDb, sendPeeringDb } from "../adapters/registries/peeringdb";
|
|
12
12
|
import { prometheusTargets, snmpExporterAuths } from "../adapters/registries/prometheus";
|
|
13
|
-
import { network as prefixOf } from "../core/addr";
|
|
13
|
+
import { isCidr, network as prefixOf } from "../core/addr";
|
|
14
|
+
import { addressesOf, freeOfLength, type Segment } from "../core/addresses";
|
|
14
15
|
import type { Network } from "../core/define";
|
|
15
|
-
import { graphOf, LAYERS, only, type Layer } from "../core/graph";
|
|
16
|
+
import { type Annotated, type Drift, graphOf, LAYERS, only, type Layer } from "../core/graph";
|
|
16
17
|
import { isSecret, referenceOf, resolve as reveal, sourceOf, useRoot } from "../core/secrets";
|
|
17
18
|
import { validate } from "../core/validate";
|
|
18
19
|
import type { Device } from "../schema";
|
|
19
|
-
import {
|
|
20
|
+
import { dot, mermaid } from "./graph";
|
|
21
|
+
import { applyRename, planRename, type RenamePlan } from "./rename";
|
|
22
|
+
import { deviceFiles, projectSources } from "../core/files";
|
|
23
|
+
import { studioData } from "../studio/data";
|
|
20
24
|
|
|
21
25
|
const HELP = `usage: circuit <command> [device...] [options]
|
|
22
26
|
|
|
@@ -37,12 +41,22 @@ const HELP = `usage: circuit <command> [device...] [options]
|
|
|
37
41
|
prometheus every device with SNMP, as Prometheus targets for snmp_exporter
|
|
38
42
|
--auths snmp_exporter's auths for them instead, with secrets as \${NAME}
|
|
39
43
|
secrets every secret the config uses, and whether each can be read
|
|
44
|
+
rename <device> <new-name>
|
|
45
|
+
give a device a new name in every file that names it, with --confirm
|
|
46
|
+
--file also rename the file that declares it, to <new-name>.ts
|
|
47
|
+
addresses [prefix] every address the config uses, by prefix, and what is still free
|
|
48
|
+
--length=N free prefixes of this length, instead of the fewest that cover the free space
|
|
49
|
+
--format=json as JSON
|
|
40
50
|
graph [device...] the network as a graph, from the config alone, without contacting a device
|
|
41
51
|
--layer=l1 l1, l2, l3, bgp, ospf or tunnels, several with commas, or all
|
|
42
52
|
--format=mermaid mermaid, dot or json
|
|
43
|
-
studio the
|
|
44
|
-
--port=4600 the port it listens on
|
|
53
|
+
studio Circuit Studio: the network in the browser, drawn again whenever the config changes
|
|
54
|
+
--port=4600 the port it listens on
|
|
45
55
|
--no-open do not open the browser
|
|
56
|
+
--host=0.0.0.0 listen on a server's address instead, with --allow-host
|
|
57
|
+
--allow-host=studio.example.com
|
|
58
|
+
the names it is reached by, several with commas
|
|
59
|
+
--json print what Studio shows, as JSON, instead
|
|
46
60
|
|
|
47
61
|
--config <path> the network's config file, circuit.config.ts in the working directory by default`;
|
|
48
62
|
|
|
@@ -426,6 +440,20 @@ function driftOf(device: Device): Drift {
|
|
|
426
440
|
}
|
|
427
441
|
}
|
|
428
442
|
|
|
443
|
+
/** The graph on these layers, around these devices when any are named, with what validate found and how each device drawn compares with its snapshot. */
|
|
444
|
+
function annotatedGraph(layers: readonly Layer[], deviceNames: readonly string[]): Annotated {
|
|
445
|
+
const graph = only(graphOf(network.devices), layers, deviceNames);
|
|
446
|
+
const shown = new Set(graph.nodes.map((node) => node.label));
|
|
447
|
+
|
|
448
|
+
return {
|
|
449
|
+
...graph,
|
|
450
|
+
findings: validate(network).filter((finding) => !finding.device || shown.has(finding.device)),
|
|
451
|
+
drift: Object.fromEntries(
|
|
452
|
+
network.devices.filter((device) => shown.has(device.name)).map((device) => [device.name, driftOf(device)])
|
|
453
|
+
),
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
|
|
429
457
|
function graphCommand(): void {
|
|
430
458
|
const requested = (flagValue("layer") ?? "l1").split(",");
|
|
431
459
|
const layers = requested.includes("all") ? [...LAYERS] : (requested as Layer[]);
|
|
@@ -435,18 +463,7 @@ function graphCommand(): void {
|
|
|
435
463
|
|
|
436
464
|
const format = flagValue("format") ?? "mermaid";
|
|
437
465
|
const devices = selected(true);
|
|
438
|
-
|
|
439
|
-
const graph = only(graphOf(network.devices), layers, names.length ? devices.map((device) => device.name) : []);
|
|
440
|
-
const shown = new Set(graph.nodes.map((node) => node.label));
|
|
441
|
-
|
|
442
|
-
// Each device drawn is compared with its snapshot; findings are kept for those, and for the network as a whole.
|
|
443
|
-
const annotated: Annotated = {
|
|
444
|
-
...graph,
|
|
445
|
-
findings: validate(network).filter((finding) => !finding.device || shown.has(finding.device)),
|
|
446
|
-
drift: Object.fromEntries(
|
|
447
|
-
network.devices.filter((device) => shown.has(device.name)).map((device) => [device.name, driftOf(device)])
|
|
448
|
-
),
|
|
449
|
-
};
|
|
466
|
+
const annotated = annotatedGraph(layers, names.length ? devices.map((device) => device.name) : []);
|
|
450
467
|
|
|
451
468
|
if (format === "mermaid") process.stdout.write(mermaid(annotated));
|
|
452
469
|
else if (format === "dot") process.stdout.write(dot(annotated));
|
|
@@ -454,12 +471,127 @@ function graphCommand(): void {
|
|
|
454
471
|
else fail(`no format ${format}; known: mermaid, dot, json`);
|
|
455
472
|
}
|
|
456
473
|
|
|
474
|
+
/** One segment and what uses each address in it. */
|
|
475
|
+
function printSegment(segment: Segment, indent: string): void {
|
|
476
|
+
const next = segment.next ? `, next ${segment.next}` : "";
|
|
477
|
+
console.log(`${indent}${segment.prefix}, ${segment.free} free${next}`);
|
|
478
|
+
|
|
479
|
+
const width = Math.max(...segment.uses.map((use) => use.address.length)) + 2;
|
|
480
|
+
for (const use of segment.uses) console.log(`${indent} ${use.address.padEnd(width)}${use.what}`);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function addressesCommand(): void {
|
|
484
|
+
const [within] = names;
|
|
485
|
+
if (within && !isCidr(within)) fail(`${within} is not a prefix, such as 10.0.0.0/16`);
|
|
486
|
+
|
|
487
|
+
const lengthText = flagValue("length");
|
|
488
|
+
const length = lengthText === undefined ? undefined : Number(lengthText);
|
|
489
|
+
if (length !== undefined && !(Number.isInteger(length) && length >= 0 && length <= 128))
|
|
490
|
+
fail("--length takes a prefix length, such as 24");
|
|
491
|
+
|
|
492
|
+
const report = addressesOf(network.devices, within);
|
|
493
|
+
|
|
494
|
+
if (flagValue("format") === "json") {
|
|
495
|
+
console.log(JSON.stringify(report, null, 4));
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
for (const space of report.spaces) {
|
|
500
|
+
console.log(`${space.prefix}, ${space.owner}`);
|
|
501
|
+
for (const segment of space.segments) printSegment(segment, " ");
|
|
502
|
+
for (const block of space.blocks) console.log(` ${block.prefix} ${block.what}`);
|
|
503
|
+
|
|
504
|
+
if (length === undefined) {
|
|
505
|
+
console.log(` free: ${space.free.join(", ") || "none"}`);
|
|
506
|
+
} else {
|
|
507
|
+
const { prefixes, total } = freeOfLength(space.free, length, 16);
|
|
508
|
+
const more = total > BigInt(prefixes.length) ? `, and ${total - BigInt(prefixes.length)} more` : "";
|
|
509
|
+
console.log(` free /${length}: ${prefixes.join(", ") || "none"}${more}`);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
console.log("");
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const { segments, blocks } = report.elsewhere;
|
|
516
|
+
if (segments.length || blocks.length) {
|
|
517
|
+
console.log("Outside the space the network originates");
|
|
518
|
+
for (const segment of segments) printSegment(segment, " ");
|
|
519
|
+
for (const block of blocks) console.log(` ${block.prefix} ${block.what}`);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
if (!report.spaces.length && !segments.length && !blocks.length) console.log("The config holds no addresses.");
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function renameCommand(): void {
|
|
526
|
+
const [from, to] = names;
|
|
527
|
+
if (!from || !to) fail("usage: circuit rename <device> <new-name> [--file] [--confirm]");
|
|
528
|
+
|
|
529
|
+
const device = network.devices.find((candidate) => candidate.name === from);
|
|
530
|
+
if (!device) fail(`no device ${from}; known: ${network.devices.map((candidate) => candidate.name).join(", ")}`);
|
|
531
|
+
if (network.devices.some((candidate) => candidate.name === to)) fail(`${to} is already a device`);
|
|
532
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(to)) fail(`${to} is not a hostname: letters, digits, dots, dashes and underscores`);
|
|
533
|
+
|
|
534
|
+
const snapshot = statePath(device);
|
|
535
|
+
const snapshots = existsSync(snapshot) ? [relative(root, snapshot)] : [];
|
|
536
|
+
|
|
537
|
+
let plan: RenamePlan;
|
|
538
|
+
try {
|
|
539
|
+
plan = planRename(root, from, to, { file: flags.includes("--file"), snapshots });
|
|
540
|
+
} catch (error) {
|
|
541
|
+
fail((error as Error).message);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
for (const change of plan.changes) {
|
|
545
|
+
console.log(`${change.file}:${change.line}`);
|
|
546
|
+
console.log(` - ${change.before.trim()}`);
|
|
547
|
+
console.log(` + ${change.after.trim()}`);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
for (const move of plan.moves) console.log(`move ${move.from} to ${move.to}`);
|
|
551
|
+
|
|
552
|
+
if (plan.mentions.length) {
|
|
553
|
+
console.log(`\nStill naming ${from}, left as they are: change them by hand if they mean the device.`);
|
|
554
|
+
for (const mention of plan.mentions) console.log(` ${mention.file}:${mention.line} ${mention.text.slice(0, 160)}`);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
if (!confirmed) {
|
|
558
|
+
console.log("\nNothing written. Re-run with --confirm.");
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
applyRename(root, plan);
|
|
563
|
+
console.log(`\nRenamed. Run circuit validate, then circuit apply ${to}: the device keeps its old hostname until then.`);
|
|
564
|
+
}
|
|
565
|
+
|
|
457
566
|
async function studioCommand(): Promise<void> {
|
|
567
|
+
if (flags.includes("--json")) {
|
|
568
|
+
const sources = projectSources(root);
|
|
569
|
+
const data = studioData(network.devices, annotatedGraph(LAYERS, []), {
|
|
570
|
+
render: (device) => adapterOf(device).render(device),
|
|
571
|
+
snapshot: (device) => (existsSync(statePath(device)) ? readFileSync(statePath(device), "utf8") : undefined),
|
|
572
|
+
files: (device) => ({
|
|
573
|
+
// Written with forward slashes on every system, as git and the page take them.
|
|
574
|
+
source: deviceFiles(root, device.name, sources)[0]?.split(sep).join("/"),
|
|
575
|
+
snapshot: existsSync(statePath(device)) ? relative(root, statePath(device)).split(sep).join("/") : undefined,
|
|
576
|
+
}),
|
|
577
|
+
});
|
|
578
|
+
console.log(JSON.stringify(data));
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
|
|
458
582
|
const port = Number(flagValue("port") ?? 4600);
|
|
459
583
|
if (!Number.isInteger(port) || port < 1 || port > 65535) fail("--port takes a number from 1 to 65535");
|
|
460
584
|
|
|
461
|
-
const { studio } = await import("
|
|
462
|
-
|
|
585
|
+
const { isLoopback, studio } = await import("../studio/server");
|
|
586
|
+
const host = flagValue("host") ?? "127.0.0.1";
|
|
587
|
+
const allowHosts = (flagValue("allow-host") ?? "").split(",").filter(Boolean);
|
|
588
|
+
|
|
589
|
+
// Off this machine, Studio must be told the names it is reached by: every other name is refused.
|
|
590
|
+
if (!isLoopback(host) && !allowHosts.length) {
|
|
591
|
+
fail(`Studio on ${host} needs --allow-host=<name>, the name people reach it by, such as studio.example.com`);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
await studio({ configPath, root, port, open: !flags.includes("--no-open"), host, allowHosts });
|
|
463
595
|
}
|
|
464
596
|
|
|
465
597
|
const commands: Record<string, () => void | Promise<void>> = {
|
|
@@ -475,6 +607,8 @@ const commands: Record<string, () => void | Promise<void>> = {
|
|
|
475
607
|
librenms: librenmsCommand,
|
|
476
608
|
prometheus: prometheusCommand,
|
|
477
609
|
secrets: secretsCommand,
|
|
610
|
+
rename: renameCommand,
|
|
611
|
+
addresses: addressesCommand,
|
|
478
612
|
graph: graphCommand,
|
|
479
613
|
studio: studioCommand,
|
|
480
614
|
};
|