@takodotid/circuit 0.18.4 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -0
- package/dist/chunks/{index-ex1wcst7.js → index-dczpxzzm.js} +1 -1
- package/dist/chunks/studio-femhydvd.js +764 -0
- package/dist/cli/index.js +548 -9
- package/dist/index.js +1 -1
- package/dist/presets/index.js +1 -1
- package/dist/types/core/graph.d.ts +44 -0
- package/package.json +1 -1
- package/src/adapters/devices/routeros/TRAPS.md +1 -0
- package/src/adapters/devices/routeros/plan.ts +1 -0
- package/src/cli/graph.ts +103 -0
- package/src/cli/index.ts +69 -0
- package/src/cli/studio-page.ts +876 -0
- package/src/cli/studio.ts +126 -0
- package/src/core/graph.ts +613 -0
- package/templates/project/AGENTS.md +10 -9
- package/templates/project/README.md +1 -0
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { Device } from "../schema";
|
|
2
|
+
/** One view of the network. Every edge belongs to one. */
|
|
3
|
+
export type Layer = "l1" | "l2" | "l3" | "bgp" | "ospf" | "tunnels";
|
|
4
|
+
export declare const LAYERS: readonly Layer[];
|
|
5
|
+
/** One of our devices, a segment several of them share, or something outside the network. */
|
|
6
|
+
export type GraphNode = {
|
|
7
|
+
/** Unique among the nodes, such as `device:edge-01` or `as:64501`. */
|
|
8
|
+
id: string;
|
|
9
|
+
kind: "device" | "segment" | "outside";
|
|
10
|
+
/** A word or two, drawn on the node. */
|
|
11
|
+
label: string;
|
|
12
|
+
/** One fact per line, for a closer look. */
|
|
13
|
+
detail: string[];
|
|
14
|
+
};
|
|
15
|
+
/** A VLAN a cable carries. */
|
|
16
|
+
export type CarriedVlan = {
|
|
17
|
+
name: string;
|
|
18
|
+
id: number;
|
|
19
|
+
/** Carried without a tag: the access VLAN, or a trunk's native VLAN. */
|
|
20
|
+
untagged: boolean;
|
|
21
|
+
};
|
|
22
|
+
export type GraphEdge = {
|
|
23
|
+
layer: Layer;
|
|
24
|
+
/** Node ids. */
|
|
25
|
+
from: string;
|
|
26
|
+
to: string;
|
|
27
|
+
/** A few words, drawn on the edge. */
|
|
28
|
+
label: string;
|
|
29
|
+
/** One fact per line, for a closer look. */
|
|
30
|
+
detail: string[];
|
|
31
|
+
/** Only one end declares it: the far device does not declare the cable, session or tunnel back. */
|
|
32
|
+
oneSided?: true;
|
|
33
|
+
/** On `l2`, the VLANs the cable carries. */
|
|
34
|
+
vlans?: CarriedVlan[];
|
|
35
|
+
};
|
|
36
|
+
export type Graph = {
|
|
37
|
+
nodes: GraphNode[];
|
|
38
|
+
edges: GraphEdge[];
|
|
39
|
+
};
|
|
40
|
+
export declare const deviceId: (name: string) => string;
|
|
41
|
+
/** The network as a graph. It reads the config only, never a device, and works on a config that does not validate. */
|
|
42
|
+
export declare function graphOf(devices: readonly Device[]): Graph;
|
|
43
|
+
/** Only the edges on these layers, and among those only the ones that touch these devices when any are named. Every device named stays. */
|
|
44
|
+
export declare function only(graph: Graph, layers: readonly Layer[], deviceNames?: readonly string[]): Graph;
|
package/package.json
CHANGED
|
@@ -15,6 +15,7 @@ Behaviour that looks like something else. Each one has been hit on a real device
|
|
|
15
15
|
- Incoming GRE is subject to the input chain. Accept it before testing the tunnel.
|
|
16
16
|
- A WireGuard peer's name is unique on the device, and a new key under the same name is refused as a new peer, `entry with this name already exists`. The plan finds a peer by name, so a new key is a `set`.
|
|
17
17
|
- `monitor-traffic` on a VLAN interface counts only what the CPU handles, and not consistently: during a flood a VLAN showed 14 Mbps leaving while its physical port sent 0.45 Mbps. Measure on the physical port, `/interface ethernet print stats`, which the switch chip counts.
|
|
18
|
+
- A VLAN interface on the bridge adds a dynamic bridge VLAN entry for its VLAN, "added by vlan on bridge", beside the static one. A selector by `vlan-ids` then finds both, and a `set` on the pair fails with `no such item`. Selectors on bridge VLANs skip dynamic entries.
|
|
18
19
|
- Switch-chip rules have no address lists and no connection state. A drop is an empty `new-dst-ports`.
|
|
19
20
|
- A switch-chip rule's `rate` did not limit traffic the CPU routes. On a CCR2216 on 7.23.5, under a 12 Gbps flood, a rule at `2G` and then at `100M` changed nothing that reached the CPU, while a drop with the same match left 3 Mbps. Drop what you can recognise instead of limiting it.
|
|
20
21
|
- The switch chip reads a UDP fragment after the first as port 0. `src-port=0` drops the rest of a fragmented flood whose first fragments another rule dropped by port; without it they reach the CPU, which holds them waiting for a first fragment that never comes.
|
package/src/cli/graph.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
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
|
+
|
|
3
|
+
import type { Finding } from "../core/define";
|
|
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
|
+
};
|
|
18
|
+
|
|
19
|
+
/** `error` when validate found an error on the device, `warning` for a warning or a difference from its snapshot. */
|
|
20
|
+
export function markOf(graph: Annotated, node: GraphNode): "error" | "warning" | undefined {
|
|
21
|
+
if (node.kind !== "device") return undefined;
|
|
22
|
+
|
|
23
|
+
const findings = graph.findings.filter((finding) => finding.device && deviceId(finding.device) === node.id);
|
|
24
|
+
if (findings.some((finding) => finding.level === "error")) return "error";
|
|
25
|
+
|
|
26
|
+
const drift = graph.drift[node.label]?.status;
|
|
27
|
+
if (findings.length || drift === "differs" || drift === "cannot plan") return "warning";
|
|
28
|
+
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Mermaid, which GitHub draws wherever Markdown is shown. */
|
|
33
|
+
export function mermaid(graph: Annotated): string {
|
|
34
|
+
const ids = new Map(graph.nodes.map((node, index) => [node.id, `n${index}`]));
|
|
35
|
+
// Lines of text, each escaped, as one quoted label.
|
|
36
|
+
const text = (...lines: string[]) =>
|
|
37
|
+
`"${lines.map((line) => line.replace(/"/g, "#quot;").replace(/</g, "#lt;").replace(/>/g, "#gt;")).join("<br/>")}"`;
|
|
38
|
+
|
|
39
|
+
const shapes: Record<GraphNode["kind"], (label: string) => string> = {
|
|
40
|
+
device: (label) => `[${label}]`,
|
|
41
|
+
segment: (label) => `([${label}])`,
|
|
42
|
+
outside: (label) => `{{${label}}}`,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const lines = ["graph LR"];
|
|
46
|
+
|
|
47
|
+
for (const node of graph.nodes) {
|
|
48
|
+
const label = node.kind === "device" ? text(node.label, node.detail[0] ?? "") : text(node.label);
|
|
49
|
+
lines.push(` ${ids.get(node.id)}${shapes[node.kind](label)}`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
for (const edge of graph.edges) {
|
|
53
|
+
const arrow = edge.oneSided ? "-.-" : "---";
|
|
54
|
+
lines.push(` ${ids.get(edge.from)} ${arrow}|${text(edge.label)}| ${ids.get(edge.to)}`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
lines.push(
|
|
58
|
+
" classDef error stroke:#d1242f,stroke-width:3px",
|
|
59
|
+
" classDef warning stroke:#bf8700,stroke-width:3px",
|
|
60
|
+
" classDef outside stroke-dasharray:4 3"
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
for (const node of graph.nodes) {
|
|
64
|
+
const mark = markOf(graph, node) ?? (node.kind === "outside" ? "outside" : undefined);
|
|
65
|
+
if (mark) lines.push(` class ${ids.get(node.id)} ${mark}`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return lines.join("\n") + "\n";
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** DOT, which Graphviz turns into an image: `circuit graph --format=dot | dot -Tsvg > network.svg`. */
|
|
72
|
+
export function dot(graph: Annotated): string {
|
|
73
|
+
const text = (value: string) => `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
74
|
+
const colors = { error: "#d1242f", warning: "#bf8700" };
|
|
75
|
+
|
|
76
|
+
const shapes: Record<GraphNode["kind"], string> = {
|
|
77
|
+
device: "shape=box, style=rounded",
|
|
78
|
+
segment: "shape=ellipse",
|
|
79
|
+
outside: "shape=box, style=dashed",
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const lines = [
|
|
83
|
+
"graph network {",
|
|
84
|
+
" rankdir=LR;",
|
|
85
|
+
' node [fontname="Helvetica"];',
|
|
86
|
+
' edge [fontname="Helvetica", fontsize=10];',
|
|
87
|
+
];
|
|
88
|
+
|
|
89
|
+
for (const node of graph.nodes) {
|
|
90
|
+
const label = node.kind === "device" ? `${node.label}\n${node.detail[0] ?? ""}` : node.label;
|
|
91
|
+
const mark = markOf(graph, node);
|
|
92
|
+
const color = mark ? `, color=${text(colors[mark])}, penwidth=2` : "";
|
|
93
|
+
lines.push(` ${text(node.id)} [label=${text(label)}, ${shapes[node.kind]}${color}];`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
for (const edge of graph.edges) {
|
|
97
|
+
const style = edge.oneSided ? ", style=dashed" : "";
|
|
98
|
+
lines.push(` ${text(edge.from)} -- ${text(edge.to)} [label=${text(edge.label)}${style}];`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
lines.push("}");
|
|
102
|
+
return lines.join("\n") + "\n";
|
|
103
|
+
}
|
package/src/cli/index.ts
CHANGED
|
@@ -12,9 +12,11 @@ import { planPeeringDb, sendPeeringDb } from "../adapters/registries/peeringdb";
|
|
|
12
12
|
import { prometheusTargets, snmpExporterAuths } from "../adapters/registries/prometheus";
|
|
13
13
|
import { network as prefixOf } from "../core/addr";
|
|
14
14
|
import type { Network } from "../core/define";
|
|
15
|
+
import { graphOf, LAYERS, only, type Layer } from "../core/graph";
|
|
15
16
|
import { isSecret, referenceOf, resolve as reveal, sourceOf, useRoot } from "../core/secrets";
|
|
16
17
|
import { validate } from "../core/validate";
|
|
17
18
|
import type { Device } from "../schema";
|
|
19
|
+
import { type Annotated, dot, type Drift, mermaid } from "./graph";
|
|
18
20
|
|
|
19
21
|
const HELP = `usage: circuit <command> [device...] [options]
|
|
20
22
|
|
|
@@ -35,6 +37,12 @@ const HELP = `usage: circuit <command> [device...] [options]
|
|
|
35
37
|
prometheus every device with SNMP, as Prometheus targets for snmp_exporter
|
|
36
38
|
--auths snmp_exporter's auths for them instead, with secrets as \${NAME}
|
|
37
39
|
secrets every secret the config uses, and whether each can be read
|
|
40
|
+
graph [device...] the network as a graph, from the config alone, without contacting a device
|
|
41
|
+
--layer=l1 l1, l2, l3, bgp, ospf or tunnels, several with commas, or all
|
|
42
|
+
--format=mermaid mermaid, dot or json
|
|
43
|
+
studio the graph in the browser, drawn again whenever the config changes
|
|
44
|
+
--port=4600 the port it listens on, on this machine only
|
|
45
|
+
--no-open do not open the browser
|
|
38
46
|
|
|
39
47
|
--config <path> the network's config file, circuit.config.ts in the working directory by default`;
|
|
40
48
|
|
|
@@ -395,6 +403,65 @@ ${references.length} secret(s), ${missing} missing.`);
|
|
|
395
403
|
process.exit(missing ? 1 : 0);
|
|
396
404
|
}
|
|
397
405
|
|
|
406
|
+
/** The value of `--name=value`, or undefined when it is not given. */
|
|
407
|
+
const flagValue = (name: string) => flags.find((flag) => flag.startsWith(`--${name}=`))?.slice(name.length + 3);
|
|
408
|
+
|
|
409
|
+
/** How a device compares with its last snapshot, offline, as `diff` plans it. */
|
|
410
|
+
function driftOf(device: Device): Drift {
|
|
411
|
+
const path = statePath(device);
|
|
412
|
+
if (!existsSync(path)) return { status: "no snapshot", lines: [] };
|
|
413
|
+
|
|
414
|
+
try {
|
|
415
|
+
const adapter = adapterOf(device);
|
|
416
|
+
const plan = adapter.plan(adapter.render(device), readFileSync(path, "utf8"), options);
|
|
417
|
+
|
|
418
|
+
const lines = [
|
|
419
|
+
...plan.steps.flatMap((step) => [step.title, ...step.show.map((line) => ` ${line}`)]),
|
|
420
|
+
...plan.problems.map((problem) => `PROBLEM ${problem}`),
|
|
421
|
+
];
|
|
422
|
+
|
|
423
|
+
return { status: lines.length ? "differs" : "matches", lines };
|
|
424
|
+
} catch (error) {
|
|
425
|
+
return { status: "cannot plan", lines: [(error as Error).message] };
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function graphCommand(): void {
|
|
430
|
+
const requested = (flagValue("layer") ?? "l1").split(",");
|
|
431
|
+
const layers = requested.includes("all") ? [...LAYERS] : (requested as Layer[]);
|
|
432
|
+
|
|
433
|
+
const unknown = layers.filter((layer) => !LAYERS.includes(layer));
|
|
434
|
+
if (unknown.length) fail(`no layer ${unknown.join(", ")}; known: ${LAYERS.join(", ")}, all`);
|
|
435
|
+
|
|
436
|
+
const format = flagValue("format") ?? "mermaid";
|
|
437
|
+
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
|
+
};
|
|
450
|
+
|
|
451
|
+
if (format === "mermaid") process.stdout.write(mermaid(annotated));
|
|
452
|
+
else if (format === "dot") process.stdout.write(dot(annotated));
|
|
453
|
+
else if (format === "json") console.log(JSON.stringify(annotated, null, 4));
|
|
454
|
+
else fail(`no format ${format}; known: mermaid, dot, json`);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
async function studioCommand(): Promise<void> {
|
|
458
|
+
const port = Number(flagValue("port") ?? 4600);
|
|
459
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) fail("--port takes a number from 1 to 65535");
|
|
460
|
+
|
|
461
|
+
const { studio } = await import("./studio");
|
|
462
|
+
await studio({ configPath, root, port, open: !flags.includes("--no-open") });
|
|
463
|
+
}
|
|
464
|
+
|
|
398
465
|
const commands: Record<string, () => void | Promise<void>> = {
|
|
399
466
|
validate: validateCommand,
|
|
400
467
|
build: buildCommand,
|
|
@@ -408,6 +475,8 @@ const commands: Record<string, () => void | Promise<void>> = {
|
|
|
408
475
|
librenms: librenmsCommand,
|
|
409
476
|
prometheus: prometheusCommand,
|
|
410
477
|
secrets: secretsCommand,
|
|
478
|
+
graph: graphCommand,
|
|
479
|
+
studio: studioCommand,
|
|
411
480
|
};
|
|
412
481
|
|
|
413
482
|
const run = commands[command];
|