@takodotid/circuit 0.43.1 → 1.0.0-rc.2
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 +115 -37
- package/package.json +21 -58
- package/src/checks/across.ts +402 -0
- package/src/checks/built-in.ts +256 -0
- package/src/checks/device.ts +436 -0
- package/src/checks/index.ts +53 -0
- package/src/cli/advisories.ts +108 -0
- package/src/cli/app.ts +71 -0
- package/src/cli/editor-types.generated.ts +30 -0
- package/src/cli/editor-types.ts +69 -0
- package/src/cli/format.ts +124 -0
- package/src/cli/git-hooks.ts +79 -0
- package/src/cli/index.ts +350 -1170
- package/src/cli/migrate.ts +554 -0
- package/src/cli/project-commands.ts +656 -0
- package/src/cli/releases.ts +374 -0
- package/src/cli/remember.ts +339 -0
- package/src/cli/rename.ts +27 -33
- package/src/cli/secrets-password.ts +60 -0
- package/src/cli/secrets.ts +258 -0
- package/src/cli/setup.ts +300 -0
- package/src/cli/signing.ts +23 -0
- package/src/cli/text-editor.ts +268 -0
- package/src/cli/ui.ts +298 -0
- package/src/cli/version.ts +2 -0
- package/src/cli/versions.ts +158 -0
- package/src/config/address.ts +102 -0
- package/src/config/circuit.d.ts +30 -0
- package/src/config/common.ts +65 -0
- package/src/config/communities.ts +479 -0
- package/src/config/define.ts +684 -0
- package/src/config/device.ts +164 -0
- package/src/config/firewall.ts +150 -0
- package/src/config/index.ts +22 -0
- package/src/{schema → config}/interfaces.ts +121 -85
- package/src/config/keys.ts +434 -0
- package/src/config/management.ts +61 -0
- package/src/config/merge.ts +335 -0
- package/src/config/models.ts +70 -0
- package/src/config/monitoring.ts +64 -0
- package/src/config/network.ts +180 -0
- package/src/config/presets.ts +149 -0
- package/src/config/routing.ts +401 -0
- package/src/config/services.ts +51 -0
- package/src/config/switching.ts +84 -0
- package/src/config/system.ts +103 -0
- package/src/{schema → config}/time-zones.ts +1 -1
- package/src/core/address.ts +1 -1
- package/src/core/cabling.ts +8 -43
- package/src/core/graph.ts +40 -0
- package/src/{integrations → core}/registries.ts +5 -3
- package/src/core/secrets-file.ts +539 -0
- package/src/core/secrets.ts +83 -64
- package/src/core/topology.ts +103 -146
- package/src/core/utils.ts +12 -1
- package/src/model.ts +83 -0
- package/src/platforms/derive.ts +64 -37
- package/src/platforms/finding.ts +9 -0
- package/src/platforms/fortios/firewall.ts +76 -64
- package/src/platforms/fortios/index.ts +189 -125
- package/src/platforms/fortios/names.ts +51 -18
- package/src/platforms/fortios/render.ts +69 -62
- package/src/platforms/fortios/routing.ts +89 -58
- package/src/platforms/fortios/vdoms.ts +47 -16
- package/src/platforms/junos/TRAPS.md +2 -2
- package/src/platforms/junos/bgp.ts +63 -42
- package/src/platforms/junos/index.ts +122 -89
- package/src/platforms/junos/render.ts +139 -104
- package/src/platforms/platform.ts +10 -8
- package/src/platforms/raisecom-ros/TRAPS.md +1 -1
- package/src/platforms/raisecom-ros/index.ts +89 -70
- package/src/platforms/raisecom-ros/render.ts +49 -45
- package/src/platforms/routeros/TRAPS.md +3 -3
- package/src/platforms/routeros/index.ts +121 -60
- package/src/platforms/routeros/render.ts +214 -195
- package/src/platforms/vrp/TRAPS.md +1 -1
- package/src/platforms/vrp/index.ts +74 -50
- package/src/platforms/vrp/render.ts +67 -56
- package/src/project/project.ts +725 -0
- package/src/studio/app/env.d.ts +9 -0
- package/src/studio/app/index.html +18 -0
- package/src/studio/app/src/App.vue +95 -0
- package/src/studio/app/src/components/AddressesView.vue +38 -0
- package/src/studio/app/src/components/CablesView.vue +29 -0
- package/src/studio/app/src/components/CompareView.vue +160 -0
- package/src/studio/app/src/components/DataTable.vue +49 -0
- package/src/studio/app/src/components/DeviceView.vue +237 -0
- package/src/studio/app/src/components/DevicesView.vue +32 -0
- package/src/studio/app/src/components/Faceplate.vue +75 -0
- package/src/studio/app/src/components/GraphView.vue +282 -0
- package/src/studio/app/src/components/LineDiff.vue +24 -0
- package/src/studio/app/src/components/ProblemsView.vue +42 -0
- package/src/studio/app/src/components/SearchView.vue +72 -0
- package/src/studio/app/src/components/SecretsView.vue +112 -0
- package/src/studio/app/src/components/SessionsView.vue +35 -0
- package/src/studio/app/src/components/Settings.vue +95 -0
- package/src/studio/app/src/components/ToolsView.vue +118 -0
- package/src/studio/app/src/components/VlansView.vue +50 -0
- package/src/studio/app/src/lib/csv.ts +19 -0
- package/src/studio/{public/diff.js → app/src/lib/diff.ts} +40 -35
- package/src/studio/app/src/lib/format.ts +38 -0
- package/src/studio/app/src/lib/net.ts +68 -0
- package/src/studio/app/src/lib/secrets-crypto.ts +35 -0
- package/src/studio/app/src/lib/store.ts +71 -0
- package/src/studio/app/src/lib/tools.ts +217 -0
- package/src/studio/app/src/main.ts +9 -0
- package/src/studio/{public → app/src}/style.css +14 -4
- package/src/studio/app/tsconfig.json +17 -0
- package/src/studio/app/vite.config.ts +16 -0
- package/src/studio/assets.generated.ts +3 -0
- package/src/studio/data.ts +96 -188
- package/src/studio/git.ts +27 -43
- package/src/studio/server.ts +196 -107
- package/src/studio/types.ts +141 -0
- package/src/transport/console.ts +3 -3
- package/src/transport/ssh.ts +1 -1
- package/dist/chunks/index-3b343xva.js +0 -177
- package/dist/chunks/index-6wb41q67.js +0 -62
- package/dist/chunks/index-n7w3tg5y.js +0 -149
- package/dist/chunks/index-yzw86rc8.js +0 -921
- package/dist/chunks/new-w3zys5y4.js +0 -172
- package/dist/chunks/server-cxr9ynxc.js +0 -259
- package/dist/cli/index.js +0 -12922
- package/dist/helpers/index.js +0 -881
- package/dist/index.js +0 -28
- package/dist/types/core/address-space.d.ts +0 -62
- package/dist/types/core/address.d.ts +0 -44
- package/dist/types/core/cabling.d.ts +0 -41
- package/dist/types/core/cross-checks.d.ts +0 -28
- package/dist/types/core/define.d.ts +0 -79
- package/dist/types/core/project-files.d.ts +0 -12
- package/dist/types/core/schema-docs.d.ts +0 -25
- package/dist/types/core/secrets.d.ts +0 -62
- package/dist/types/core/topology.d.ts +0 -96
- package/dist/types/core/utils.d.ts +0 -10
- package/dist/types/core/validator.d.ts +0 -23
- package/dist/types/helpers/bgp.d.ts +0 -195
- package/dist/types/helpers/cables.d.ts +0 -69
- package/dist/types/helpers/checks.d.ts +0 -36
- package/dist/types/helpers/exchange.d.ts +0 -55
- package/dist/types/helpers/filters.d.ts +0 -51
- package/dist/types/helpers/index.d.ts +0 -14
- package/dist/types/helpers/route-server.d.ts +0 -52
- package/dist/types/helpers/upstreams.d.ts +0 -192
- package/dist/types/index.d.ts +0 -10
- package/dist/types/integrations/librenms.d.ts +0 -60
- package/dist/types/integrations/netbox.d.ts +0 -89
- package/dist/types/integrations/peeringdb.d.ts +0 -24
- package/dist/types/integrations/prometheus.d.ts +0 -24
- package/dist/types/integrations/registries.d.ts +0 -8
- package/dist/types/platforms/bgp-wait.d.ts +0 -24
- package/dist/types/platforms/catalog.d.ts +0 -109
- package/dist/types/platforms/derive.d.ts +0 -17
- package/dist/types/platforms/fortios/firewall.d.ts +0 -47
- package/dist/types/platforms/fortios/index.d.ts +0 -140
- package/dist/types/platforms/fortios/models.d.ts +0 -14
- package/dist/types/platforms/fortios/names.d.ts +0 -52
- package/dist/types/platforms/fortios/plan.d.ts +0 -114
- package/dist/types/platforms/fortios/render.d.ts +0 -77
- package/dist/types/platforms/fortios/routing.d.ts +0 -56
- package/dist/types/platforms/fortios/tree.d.ts +0 -37
- package/dist/types/platforms/fortios/vdoms.d.ts +0 -50
- package/dist/types/platforms/index.d.ts +0 -13
- package/dist/types/platforms/junos/bgp.d.ts +0 -44
- package/dist/types/platforms/junos/index.d.ts +0 -114
- package/dist/types/platforms/junos/models.d.ts +0 -14
- package/dist/types/platforms/junos/render.d.ts +0 -154
- package/dist/types/platforms/line-diff.d.ts +0 -67
- package/dist/types/platforms/platform.d.ts +0 -135
- package/dist/types/platforms/raisecom-ros/index.d.ts +0 -71
- package/dist/types/platforms/raisecom-ros/models.d.ts +0 -14
- package/dist/types/platforms/raisecom-ros/render.d.ts +0 -41
- package/dist/types/platforms/routeros/export.d.ts +0 -59
- package/dist/types/platforms/routeros/index.d.ts +0 -116
- package/dist/types/platforms/routeros/models.d.ts +0 -18
- package/dist/types/platforms/routeros/plan.d.ts +0 -58
- package/dist/types/platforms/routeros/render.d.ts +0 -250
- package/dist/types/platforms/vrp/index.d.ts +0 -83
- package/dist/types/platforms/vrp/models.d.ts +0 -12
- package/dist/types/platforms/vrp/render.d.ts +0 -96
- package/dist/types/schema/common.d.ts +0 -30
- package/dist/types/schema/device.d.ts +0 -126
- package/dist/types/schema/firewall.d.ts +0 -131
- package/dist/types/schema/index.d.ts +0 -8
- package/dist/types/schema/interfaces.d.ts +0 -205
- package/dist/types/schema/policy.d.ts +0 -107
- package/dist/types/schema/routing.d.ts +0 -161
- package/dist/types/schema/system.d.ts +0 -198
- package/dist/types/schema/time-zones.d.ts +0 -2
- package/dist/types/transport/console.d.ts +0 -115
- package/dist/types/transport/path-check.d.ts +0 -32
- package/dist/types/transport/ssh.d.ts +0 -27
- package/src/cli/graph-format.ts +0 -91
- package/src/cli/new.ts +0 -234
- package/src/core/address-space.ts +0 -320
- package/src/core/cross-checks.ts +0 -369
- package/src/core/define.ts +0 -124
- package/src/core/project-files.ts +0 -35
- package/src/core/schema-docs.ts +0 -174
- package/src/core/validator.ts +0 -508
- package/src/helpers/bgp.ts +0 -473
- package/src/helpers/cables.ts +0 -74
- package/src/helpers/checks.ts +0 -201
- package/src/helpers/exchange.ts +0 -123
- package/src/helpers/filters.ts +0 -107
- package/src/helpers/index.ts +0 -25
- package/src/helpers/route-server.ts +0 -212
- package/src/helpers/upstreams.ts +0 -369
- package/src/index.ts +0 -13
- package/src/integrations/librenms.ts +0 -291
- package/src/integrations/netbox.ts +0 -620
- package/src/integrations/peeringdb.ts +0 -173
- package/src/integrations/prometheus.ts +0 -90
- package/src/schema/common.ts +0 -41
- package/src/schema/device.ts +0 -145
- package/src/schema/firewall.ts +0 -140
- package/src/schema/index.ts +0 -8
- package/src/schema/policy.ts +0 -115
- package/src/schema/routing.ts +0 -173
- package/src/schema/system.ts +0 -203
- package/src/studio/public/app.js +0 -186
- package/src/studio/public/compare.js +0 -250
- package/src/studio/public/dom.js +0 -138
- package/src/studio/public/evaluate.js +0 -687
- package/src/studio/public/export.js +0 -130
- package/src/studio/public/graph.js +0 -1541
- package/src/studio/public/index.html +0 -83
- package/src/studio/public/net.js +0 -134
- package/src/studio/public/tables.js +0 -1289
- package/src/studio/public/tools.js +0 -610
- package/templates/colocation/checks.ts +0 -53
- package/templates/colocation/circuit.config.ts +0 -9
- package/templates/colocation/edge-01.ts +0 -133
- package/templates/colocation/routing.ts +0 -13
- package/templates/colocation/tenancy.ts +0 -76
- package/templates/colocation/tenants/acme.ts +0 -19
- package/templates/colocation/tenants/index.ts +0 -6
- package/templates/colocation/tor-01.ts +0 -33
- package/templates/edge-router/checks.ts +0 -20
- package/templates/edge-router/circuit.config.ts +0 -8
- package/templates/edge-router/edge-01.ts +0 -110
- package/templates/edge-router/routing.ts +0 -13
- package/templates/project/AGENTS.md +0 -51
- package/templates/project/CLAUDE.md +0 -1
- package/templates/project/README.md +0 -16
- package/templates/project/gitignore +0 -5
- package/templates/project/tsconfig.json +0 -13
- package/templates/single-site/checks.ts +0 -26
- package/templates/single-site/circuit.config.ts +0 -8
- package/templates/single-site/router.ts +0 -86
- package/templates/single-site/site.ts +0 -18
- package/templates/single-site/switch.ts +0 -34
- /package/src/studio/{public → app/public}/favicon.svg +0 -0
- /package/src/studio/{public → app/public}/special-gothic-latin-ext.woff2 +0 -0
- /package/src/studio/{public → app/public}/special-gothic-latin.woff2 +0 -0
- /package/src/studio/{public → app/public}/special-gothic-license.txt +0 -0
package/src/studio/server.ts
CHANGED
|
@@ -1,48 +1,41 @@
|
|
|
1
|
-
// `circuit studio`: the
|
|
1
|
+
// `circuit studio`: the project in the browser, on this machine, or on a server for a team. The page is built by Vite from `app/` and carried inside Circuit (`assets.generated.ts`). What it shows comes from /api/data: gathered by Circuit in a new process, so the project is always read fresh, and gathered again each time a file of the project changes.
|
|
2
2
|
|
|
3
3
|
import { execFile, spawn } from "node:child_process";
|
|
4
|
-
import {
|
|
5
|
-
import { createServer, type ServerResponse } from "node:http";
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
4
|
+
import { watch } from "node:fs";
|
|
5
|
+
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
|
6
|
+
import { SecretsFile } from "../core/secrets-file";
|
|
7
|
+
import { Secrets } from "../core/secrets";
|
|
8
|
+
import { Platforms } from "../platforms";
|
|
9
|
+
import { Releases } from "../cli/releases";
|
|
10
|
+
import { STUDIO_ASSETS } from "./assets.generated";
|
|
8
11
|
import { Git } from "./git";
|
|
12
|
+
import type { LiveRead, SecretEntry, SecretsFileListing, SecretsListing, StudioData } from "./types";
|
|
9
13
|
|
|
10
14
|
export type StudioOptions = {
|
|
11
|
-
/** The
|
|
12
|
-
configPath: string;
|
|
13
|
-
/** The directory watched for changes: the config's own. */
|
|
15
|
+
/** The project's folder. */
|
|
14
16
|
root: string;
|
|
15
17
|
port: number;
|
|
16
|
-
/** Open the browser once the server listens. */
|
|
17
|
-
open: boolean;
|
|
18
18
|
/** The address to listen on: 127.0.0.1 on a machine of one's own, or a server's address. */
|
|
19
19
|
host: string;
|
|
20
20
|
/** Names besides this machine's own that requests may be addressed to, such as `studio.example.com` behind a proxy. */
|
|
21
21
|
allowHosts: string[];
|
|
22
|
+
/** How the project is read: by default, Circuit itself in a new process. Tests give their own. */
|
|
23
|
+
gather?: (folder: string) => Promise<string>;
|
|
22
24
|
};
|
|
23
25
|
|
|
24
26
|
export class StudioServer {
|
|
25
|
-
/**
|
|
26
|
-
private static readonly IGNORED = /(^|[\\/])(node_modules|\.git|\.circuit
|
|
27
|
+
/** Folders whose changes never change what Studio shows. */
|
|
28
|
+
private static readonly IGNORED = /(^|[\\/])(node_modules|\.git|\.circuit)([\\/]|$)/;
|
|
27
29
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
".css": "text/css; charset=utf-8",
|
|
31
|
-
".js": "text/javascript; charset=utf-8",
|
|
32
|
-
".svg": "image/svg+xml",
|
|
33
|
-
".woff2": "font/woff2",
|
|
34
|
-
};
|
|
30
|
+
/** The header the page sends with a request that reaches a device, which a page on another site cannot send without asking first. */
|
|
31
|
+
static readonly REQUEST_HEADER = "x-circuit-studio";
|
|
35
32
|
|
|
36
33
|
/** Whether an address to listen on is this machine's own, which nothing else on the network can reach. */
|
|
37
34
|
static isLoopback(host: string): boolean {
|
|
38
35
|
return host === "localhost" || host === "::1" || /^127\.\d+\.\d+\.\d+$/.test(host);
|
|
39
36
|
}
|
|
40
37
|
|
|
41
|
-
/**
|
|
42
|
-
* Whether a request's Host header names Studio, so a web page elsewhere cannot read the network through a name it points here.
|
|
43
|
-
*
|
|
44
|
-
* Studio answers to this machine's own names at its port, and to each name it was told to, with or without a port.
|
|
45
|
-
*/
|
|
38
|
+
/** Whether a request's Host header names Studio, so a web page elsewhere cannot read the project through a name it points here. */
|
|
46
39
|
static hostAllowed(header: string | undefined, port: number, allowHosts: readonly string[]): boolean {
|
|
47
40
|
if (!header) return false;
|
|
48
41
|
if (header === `127.0.0.1:${port}` || header === `localhost:${port}` || header === `[::1]:${port}`) return true;
|
|
@@ -54,23 +47,32 @@ export class StudioServer {
|
|
|
54
47
|
});
|
|
55
48
|
}
|
|
56
49
|
|
|
57
|
-
/**
|
|
58
|
-
private static
|
|
59
|
-
|
|
60
|
-
|
|
50
|
+
/** Circuit itself, to run again: the binary, or Bun with Circuit's sources. */
|
|
51
|
+
private static self(): { command: string; args: string[] } {
|
|
52
|
+
return Releases.binary()
|
|
53
|
+
? { command: process.execPath, args: [] }
|
|
54
|
+
: { command: process.execPath, args: [...process.execArgv, process.argv[1]!] };
|
|
55
|
+
}
|
|
61
56
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
57
|
+
/** What Studio shows, as JSON, from `circuit studio-data` run as a new process, or what stopped it. A new process reads every file again: one process imports a file once. */
|
|
58
|
+
static gather(folder: string): Promise<string> {
|
|
59
|
+
const { command, args } = StudioServer.self();
|
|
65
60
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
61
|
+
return new Promise((done) => {
|
|
62
|
+
execFile(
|
|
63
|
+
command,
|
|
64
|
+
[...args, "studio-data", folder],
|
|
65
|
+
{ maxBuffer: 256 * 1024 * 1024, env: { ...process.env, CIRCUIT_LOCAL: "1" } },
|
|
66
|
+
(error, stdout, stderr) => {
|
|
67
|
+
if (!error) return done(stdout);
|
|
68
|
+
done(JSON.stringify({ error: (stderr || error.message).trim() }));
|
|
69
|
+
}
|
|
70
|
+
);
|
|
69
71
|
});
|
|
70
72
|
}
|
|
71
73
|
|
|
72
74
|
/** Open the address in the browser. A machine without one only misses the convenience. */
|
|
73
|
-
|
|
75
|
+
static openBrowser(url: string): void {
|
|
74
76
|
const [command, args] =
|
|
75
77
|
process.platform === "win32"
|
|
76
78
|
? ["cmd", ["/c", "start", "", url]]
|
|
@@ -83,20 +85,97 @@ export class StudioServer {
|
|
|
83
85
|
child.unref();
|
|
84
86
|
}
|
|
85
87
|
|
|
86
|
-
/**
|
|
87
|
-
static
|
|
88
|
-
const
|
|
88
|
+
/** Each secrets file the networks use, as the page may see it: names, how each is kept, ciphertexts. Never a value in plain text, and never the password. */
|
|
89
|
+
static secretsListing(root: string, data: StudioData): SecretsListing {
|
|
90
|
+
const files = [...new Set(data.networks.map((network) => network.secretsFile))];
|
|
91
|
+
return {
|
|
92
|
+
files: files.map((file) =>
|
|
93
|
+
StudioServer.fileListing(
|
|
94
|
+
root,
|
|
95
|
+
file,
|
|
96
|
+
data.networks.filter((network) => network.secretsFile === file)
|
|
97
|
+
)
|
|
98
|
+
),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
89
101
|
|
|
90
|
-
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
const
|
|
102
|
+
private static fileListing(root: string, name: string, networks: StudioData["networks"]): SecretsFileListing {
|
|
103
|
+
const file = SecretsFile.at(root, name);
|
|
104
|
+
const values = file.values();
|
|
105
|
+
const needed = [...new Set(networks.flatMap((network) => network.secrets))];
|
|
106
|
+
const names = [...new Set([...file.names(), ...needed])].sort();
|
|
107
|
+
|
|
108
|
+
const entries = names.map((entry): SecretEntry => {
|
|
109
|
+
const value = values[entry];
|
|
110
|
+
const isNeeded = needed.includes(entry);
|
|
111
|
+
if (value === undefined) return { name: entry, kind: "missing", needed: isNeeded };
|
|
112
|
+
|
|
113
|
+
const kind = SecretsFile.kindOf(value);
|
|
114
|
+
if (kind === "encrypted") return { name: entry, kind, ciphertext: value, needed: isNeeded };
|
|
115
|
+
if (kind === "1password") {
|
|
116
|
+
const [vault = "", item = "", ...field] = value.slice("op://".length).split("/");
|
|
117
|
+
return { name: entry, kind, onePassword: { vault, item, field: field.join("/") }, needed: isNeeded };
|
|
118
|
+
}
|
|
94
119
|
|
|
95
|
-
|
|
120
|
+
return { name: entry, kind, needed: isNeeded };
|
|
121
|
+
});
|
|
96
122
|
|
|
97
|
-
|
|
123
|
+
const passwordLocation = file.passwordLocation();
|
|
124
|
+
return {
|
|
125
|
+
file: name,
|
|
126
|
+
networks: networks.map((network) => network.name),
|
|
127
|
+
...(passwordLocation ? { passwordLocation } : {}),
|
|
128
|
+
entries,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Read a device now and plan against what it runs, as `circuit diff` does. Nothing is kept. */
|
|
133
|
+
static async readDevice(root: string, data: StudioData, name: string): Promise<LiveRead> {
|
|
134
|
+
const device = data.devices.find((candidate) => candidate.name === name);
|
|
135
|
+
if (!device) throw new Error(`no device ${name}`);
|
|
136
|
+
|
|
137
|
+
const adapter = Platforms.find(device.platform);
|
|
138
|
+
if (!adapter) throw new Error(`no platform ${device.platform}`);
|
|
139
|
+
|
|
140
|
+
Secrets.useRoot(root);
|
|
141
|
+
Secrets.useFile(data.networks.find((network) => network.devices.includes(name))?.secretsFile);
|
|
142
|
+
const locked = Secrets.lockedFor(device);
|
|
143
|
+
if (locked)
|
|
144
|
+
throw new Error(
|
|
145
|
+
`${locked.name} is encrypted, and Studio has no password for it. Studio reads a device when the password comes from 1Password or ${SecretsFile.PASSWORD}; or run circuit diff ${name}, which asks for it.`
|
|
146
|
+
);
|
|
147
|
+
const options = { secrets: false, rollback: 10, ...(device.connection.host ? {} : { console: true }) };
|
|
148
|
+
const running = await adapter.read(device, data.devices, options);
|
|
149
|
+
const rendered = adapter.render(device);
|
|
150
|
+
const plan = adapter.plan(rendered, running, options);
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
device: name,
|
|
154
|
+
running,
|
|
155
|
+
rendered,
|
|
156
|
+
steps: plan.steps.map((step) => ({ title: step.title, show: step.show })),
|
|
157
|
+
problems: plan.problems,
|
|
158
|
+
readAt: new Date().toISOString(),
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Serve Studio until the process stops, and gather what it shows again whenever a file of the project changes. Resolves with its address once it listens. */
|
|
163
|
+
static async start(options: StudioOptions): Promise<string> {
|
|
164
|
+
const { root, port } = options;
|
|
165
|
+
const gather = options.gather ?? StudioServer.gather;
|
|
166
|
+
const local = StudioServer.isLoopback(options.host);
|
|
167
|
+
|
|
168
|
+
let data = await gather(root);
|
|
169
|
+
|
|
170
|
+
// Another commit's project is read once: a commit does not change. Each is kept as it is being read, so two asks share one read.
|
|
98
171
|
const past = new Map<string, Promise<string>>();
|
|
99
172
|
|
|
173
|
+
const parsed = (): StudioData => {
|
|
174
|
+
const value = JSON.parse(data) as StudioData | { error: string };
|
|
175
|
+
if ("error" in value) throw new Error("the project has errors, so Studio cannot use it");
|
|
176
|
+
return value;
|
|
177
|
+
};
|
|
178
|
+
|
|
100
179
|
/** Answer with JSON, or with an error the page shows. */
|
|
101
180
|
const answer = async (response: ServerResponse, work: () => Promise<unknown>) => {
|
|
102
181
|
let body: string;
|
|
@@ -108,13 +187,14 @@ export class StudioServer {
|
|
|
108
187
|
}
|
|
109
188
|
response.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" }).end(body);
|
|
110
189
|
};
|
|
190
|
+
|
|
111
191
|
const listeners = new Set<ServerResponse>();
|
|
112
192
|
const tell = (message: string) => {
|
|
113
193
|
for (const listener of listeners) listener.write(`data: ${message}\n\n`);
|
|
114
194
|
};
|
|
115
195
|
|
|
116
|
-
const
|
|
117
|
-
// Studio shows one team's network: every answer asks search engines to leave it out
|
|
196
|
+
const handle = (request: IncomingMessage, response: ServerResponse) => {
|
|
197
|
+
// Studio shows one team's network: every answer asks search engines to leave it out.
|
|
118
198
|
response.setHeader("x-robots-tag", "noindex, nofollow, noarchive");
|
|
119
199
|
|
|
120
200
|
if (!StudioServer.hostAllowed(request.headers.host, port, options.allowHosts)) {
|
|
@@ -125,109 +205,118 @@ export class StudioServer {
|
|
|
125
205
|
const url = new URL(request.url ?? "/", "http://studio");
|
|
126
206
|
const path = url.pathname;
|
|
127
207
|
|
|
128
|
-
|
|
129
|
-
|
|
208
|
+
if (path === "/api/data") {
|
|
209
|
+
response.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" }).end(data);
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Changes are told as they happen; a proxy such as nginx is asked not to hold them back.
|
|
214
|
+
if (path === "/api/events") {
|
|
215
|
+
response.writeHead(200, {
|
|
216
|
+
"content-type": "text/event-stream",
|
|
217
|
+
"cache-control": "no-store",
|
|
218
|
+
connection: "keep-alive",
|
|
219
|
+
"x-accel-buffering": "no",
|
|
220
|
+
});
|
|
221
|
+
response.write(": connected\n\n");
|
|
222
|
+
listeners.add(response);
|
|
223
|
+
request.on("close", () => listeners.delete(response));
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (path === "/api/secrets") {
|
|
228
|
+
void answer(response, async () => StudioServer.secretsListing(root, parsed()));
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// The commits that changed a device's file.
|
|
233
|
+
if (path === "/api/history") {
|
|
130
234
|
void answer(response, async () => {
|
|
131
235
|
const name = url.searchParams.get("device") ?? "";
|
|
132
|
-
const
|
|
133
|
-
if (!
|
|
134
|
-
return Git.history(
|
|
135
|
-
root,
|
|
136
|
-
[files.source, files.snapshot].filter((file): file is string => Boolean(file))
|
|
137
|
-
);
|
|
236
|
+
const file = parsed().files[name];
|
|
237
|
+
if (!file) throw new Error(`no device ${name}`);
|
|
238
|
+
return Git.history(root, [file]);
|
|
138
239
|
});
|
|
139
240
|
return;
|
|
140
241
|
}
|
|
141
242
|
|
|
142
243
|
// What Studio can compare with: branches, tags and the latest commits.
|
|
143
|
-
if (path === "/refs") {
|
|
244
|
+
if (path === "/api/refs") {
|
|
144
245
|
void answer(response, () => Git.references(root));
|
|
145
246
|
return;
|
|
146
247
|
}
|
|
147
248
|
|
|
148
|
-
// The
|
|
149
|
-
if (path === "/compare") {
|
|
249
|
+
// The project as it was at a branch, tag or commit.
|
|
250
|
+
if (path === "/api/compare") {
|
|
150
251
|
void answer(response, async () => {
|
|
151
252
|
const asked = url.searchParams.get("ref") ?? "";
|
|
152
253
|
const commit = await Git.commitOf(root, asked);
|
|
153
254
|
if (!commit) throw new Error(`${asked} names no commit here`);
|
|
154
255
|
|
|
155
|
-
if (!past.has(commit.commit))
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
if (network.startsWith('{"error"')) past.delete(commit.commit);
|
|
256
|
+
if (!past.has(commit.commit)) past.set(commit.commit, Git.projectAt(root, commit.commit, gather));
|
|
257
|
+
const project = await past.get(commit.commit)!;
|
|
258
|
+
if (project.startsWith('{"error"')) past.delete(commit.commit);
|
|
159
259
|
|
|
160
|
-
return `{"commit":${JSON.stringify(commit)},"network":${
|
|
260
|
+
return `{"commit":${JSON.stringify(commit)},"network":${project}}`;
|
|
161
261
|
});
|
|
162
262
|
return;
|
|
163
263
|
}
|
|
164
264
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
response
|
|
265
|
+
// Reading a device reaches the device, so it takes a POST with Studio's own header, which a page on another site cannot send unasked.
|
|
266
|
+
if (path === "/api/read") {
|
|
267
|
+
if (request.method !== "POST" || request.headers[StudioServer.REQUEST_HEADER] !== "1") {
|
|
268
|
+
response.writeHead(403).end();
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
void answer(response, async () => {
|
|
272
|
+
if (!local)
|
|
273
|
+
throw new Error("Studio on a server does not read devices. Run circuit diff, or Studio on your own machine.");
|
|
274
|
+
return StudioServer.readDevice(root, parsed(), url.searchParams.get("device") ?? "");
|
|
275
|
+
});
|
|
172
276
|
return;
|
|
173
277
|
}
|
|
174
278
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
response.writeHead(200, {
|
|
178
|
-
"content-type": "text/event-stream",
|
|
179
|
-
"cache-control": "no-store",
|
|
180
|
-
connection: "keep-alive",
|
|
181
|
-
"x-accel-buffering": "no",
|
|
182
|
-
});
|
|
183
|
-
response.write(": connected\n\n");
|
|
184
|
-
|
|
185
|
-
listeners.add(response);
|
|
186
|
-
request.on("close", () => listeners.delete(response));
|
|
279
|
+
if (path === "/robots.txt") {
|
|
280
|
+
response.writeHead(200, { "content-type": "text/plain; charset=utf-8" }).end("User-agent: *\nDisallow: /\n");
|
|
187
281
|
return;
|
|
188
282
|
}
|
|
189
283
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
const type = StudioServer.CONTENT_TYPES[extname(name)];
|
|
193
|
-
const file = join(pages, name);
|
|
194
|
-
|
|
195
|
-
if (!/^[a-z-]+\.[a-z0-9]+$/.test(name) || !type || !existsSync(file)) {
|
|
284
|
+
const asset = STUDIO_ASSETS[path === "/" ? "index.html" : path.slice(1)];
|
|
285
|
+
if (!asset) {
|
|
196
286
|
response.writeHead(404).end();
|
|
197
287
|
return;
|
|
198
288
|
}
|
|
199
289
|
|
|
200
|
-
|
|
201
|
-
|
|
290
|
+
const cache = path.startsWith("/assets/") ? "public, max-age=31536000, immutable" : "no-store";
|
|
291
|
+
response.writeHead(200, { "content-type": asset.type, "cache-control": cache }).end(Buffer.from(asset.body, "base64"));
|
|
292
|
+
};
|
|
202
293
|
|
|
203
|
-
|
|
204
|
-
let timer: NodeJS.Timeout | undefined;
|
|
294
|
+
const server = createServer(handle);
|
|
205
295
|
|
|
206
|
-
|
|
296
|
+
// Several files often change at once, such as on a save or a checkout: the project is read again once they settle.
|
|
297
|
+
let timer: NodeJS.Timeout | undefined;
|
|
298
|
+
const watcher = watch(root, { recursive: true }, (_event, filename) => {
|
|
207
299
|
if (filename && StudioServer.IGNORED.test(filename)) return;
|
|
208
300
|
|
|
209
301
|
clearTimeout(timer);
|
|
210
302
|
timer = setTimeout(async () => {
|
|
211
|
-
data = await
|
|
303
|
+
data = await gather(root);
|
|
212
304
|
tell("changed");
|
|
213
|
-
console.log(`${new Date().toLocaleTimeString()} ${filename ?? "a file"} changed, read again`);
|
|
214
305
|
}, 300);
|
|
215
306
|
});
|
|
307
|
+
server.on("close", () => watcher.close());
|
|
216
308
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
console.error(message);
|
|
223
|
-
process.exit(1);
|
|
309
|
+
await new Promise<void>((done, fail) => {
|
|
310
|
+
server.once("error", (error: NodeJS.ErrnoException) =>
|
|
311
|
+
fail(new Error(error.code === "EADDRINUSE" ? `port ${port} is in use; choose another with --port` : error.message))
|
|
312
|
+
);
|
|
313
|
+
server.listen(port, options.host, () => done());
|
|
224
314
|
});
|
|
225
315
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
const url = local ? `http://127.0.0.1:${port}` : `http://${options.allowHosts[0] ?? options.host}`;
|
|
229
|
-
console.log(`Circuit Studio at ${url}, listening on ${options.host}:${port}, watching ${root}. Stop it with Ctrl+C.`);
|
|
230
|
-
if (options.open && local) StudioServer.openBrowser(url);
|
|
231
|
-
});
|
|
316
|
+
StudioServer.servers.add(server);
|
|
317
|
+
return local ? `http://127.0.0.1:${port}` : `http://${options.allowHosts[0] ?? options.host}:${port}`;
|
|
232
318
|
}
|
|
319
|
+
|
|
320
|
+
/** Every server started, so a test can stop them. */
|
|
321
|
+
static readonly servers = new Set<ReturnType<typeof createServer>>();
|
|
233
322
|
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// What Studio's server sends its page. Types only, so the page shares them without taking any of Circuit's code.
|
|
2
|
+
|
|
3
|
+
import type { Graph } from "../core/graph";
|
|
4
|
+
import type { Device } from "../model";
|
|
5
|
+
import type { Finding } from "../platforms/finding";
|
|
6
|
+
|
|
7
|
+
/** One network of the project, without its devices. */
|
|
8
|
+
export type StudioNetwork = {
|
|
9
|
+
name: string;
|
|
10
|
+
asn?: number;
|
|
11
|
+
/** By name. */
|
|
12
|
+
sites: Record<string, { description?: string; number?: number }>;
|
|
13
|
+
devices: string[];
|
|
14
|
+
/** Where its secrets are, relative to the project. */
|
|
15
|
+
secretsFile: string;
|
|
16
|
+
/** The secrets its devices and integrations name with `secret("NAME")`. */
|
|
17
|
+
secrets: string[];
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/** One address a device holds. */
|
|
21
|
+
export type AddressRow = {
|
|
22
|
+
device: string;
|
|
23
|
+
/** The port or interface. */
|
|
24
|
+
holder: string;
|
|
25
|
+
/** With its length. */
|
|
26
|
+
address: string;
|
|
27
|
+
/** The prefix it is in. */
|
|
28
|
+
prefix: string;
|
|
29
|
+
family: "ipv4" | "ipv6";
|
|
30
|
+
/** The far end of a point-to-point address. */
|
|
31
|
+
peer?: string;
|
|
32
|
+
vrf?: string;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** One BGP session, with what it takes from its neighbor group, and what is at its far end. */
|
|
36
|
+
export type Session = {
|
|
37
|
+
device: string;
|
|
38
|
+
name: string;
|
|
39
|
+
address: string;
|
|
40
|
+
group?: string;
|
|
41
|
+
remoteAs?: number;
|
|
42
|
+
role?: string;
|
|
43
|
+
import?: string;
|
|
44
|
+
export?: string;
|
|
45
|
+
description?: string;
|
|
46
|
+
/** One of our devices, by name, or another network, as `AS64501`. */
|
|
47
|
+
far?: string;
|
|
48
|
+
/** The far device has no session back. */
|
|
49
|
+
oneSided?: true;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/** One cable, from the end that names it first. */
|
|
53
|
+
export type Cable = {
|
|
54
|
+
device: string;
|
|
55
|
+
port: string;
|
|
56
|
+
farDevice: string;
|
|
57
|
+
farPort: string;
|
|
58
|
+
/** The LAG each end's port is in. */
|
|
59
|
+
lag?: string;
|
|
60
|
+
farLag?: string;
|
|
61
|
+
description?: string;
|
|
62
|
+
/** What is between the two ends, such as a carrier's local loop. */
|
|
63
|
+
through?: string;
|
|
64
|
+
/** VLANs carried, as `name (id)`, from the port or its LAG. */
|
|
65
|
+
vlans: string[];
|
|
66
|
+
/** The far end does not name this cable back. */
|
|
67
|
+
oneSided?: true;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/** Everything Studio shows of the project, read fresh each time a file changes. */
|
|
71
|
+
export type StudioData = {
|
|
72
|
+
/** The project's folder name. */
|
|
73
|
+
project: string;
|
|
74
|
+
networks: StudioNetwork[];
|
|
75
|
+
devices: Device[];
|
|
76
|
+
/** Each device's file, from the project's folder. */
|
|
77
|
+
files: Record<string, string>;
|
|
78
|
+
/** Every port each device's model has, in order, so Studio can show the ones the project leaves shut. */
|
|
79
|
+
ports: Record<string, string[]>;
|
|
80
|
+
/** What each device would run, as `circuit apply` would send it whole, or why it cannot be rendered. */
|
|
81
|
+
rendered: Record<string, string>;
|
|
82
|
+
/** What `circuit validate` finds, and what reading the project warns of. */
|
|
83
|
+
findings: Finding[];
|
|
84
|
+
graph: Graph;
|
|
85
|
+
addresses: AddressRow[];
|
|
86
|
+
sessions: Session[];
|
|
87
|
+
cables: Cable[];
|
|
88
|
+
/** Every secret the project names with `secret("NAME")`, by name. */
|
|
89
|
+
secrets: string[];
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/** What reading the project stopped on, instead of the data. */
|
|
93
|
+
export type StudioError = { error: string };
|
|
94
|
+
|
|
95
|
+
/** One commit, as Studio lists it. */
|
|
96
|
+
export type Commit = {
|
|
97
|
+
commit: string;
|
|
98
|
+
short: string;
|
|
99
|
+
author: string;
|
|
100
|
+
/** ISO 8601, with the author's time zone. */
|
|
101
|
+
date: string;
|
|
102
|
+
subject: string;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/** How one name in a secrets file is kept. The value is sent only when it is encrypted: a value in plain text never leaves the server. */
|
|
106
|
+
export type SecretEntry = {
|
|
107
|
+
name: string;
|
|
108
|
+
kind: "encrypted" | "1password" | "empty" | "not encrypted" | "missing";
|
|
109
|
+
/** The `encrypted:...` value, for the page to decrypt with the password a person types. */
|
|
110
|
+
ciphertext?: string;
|
|
111
|
+
/** Where 1Password keeps it. */
|
|
112
|
+
onePassword?: { vault: string; item: string; field: string };
|
|
113
|
+
/** The config names it with `secret("NAME")`. */
|
|
114
|
+
needed: boolean;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
/** One secrets file as the page may see it: names, how each is kept, ciphertexts. Never a value in plain text, and never the password. */
|
|
118
|
+
export type SecretsFileListing = {
|
|
119
|
+
file: string;
|
|
120
|
+
/** The networks that keep their secrets here. */
|
|
121
|
+
networks: string[];
|
|
122
|
+
/** Where the password comes from, without its value. Absent when a person types it. */
|
|
123
|
+
passwordLocation?: string;
|
|
124
|
+
entries: SecretEntry[];
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
export type SecretsListing = { files: SecretsFileListing[] };
|
|
128
|
+
|
|
129
|
+
/** What `Read from the device now` found: the device's own config, and the plan from it to the project's. */
|
|
130
|
+
export type LiveRead = {
|
|
131
|
+
device: string;
|
|
132
|
+
/** What the device runs now, secrets removed. */
|
|
133
|
+
running: string;
|
|
134
|
+
/** What the project says it should run. */
|
|
135
|
+
rendered: string;
|
|
136
|
+
/** The plan `circuit diff` would show. */
|
|
137
|
+
steps: { title: string; show: string[] }[];
|
|
138
|
+
problems: string[];
|
|
139
|
+
/** When it was read, ISO 8601. */
|
|
140
|
+
readAt: string;
|
|
141
|
+
};
|
package/src/transport/console.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { EventEmitter } from "node:events";
|
|
|
5
5
|
import { Duplex } from "node:stream";
|
|
6
6
|
import { Client, type ClientChannel } from "ssh2";
|
|
7
7
|
import { Secrets } from "../core/secrets";
|
|
8
|
-
import type { Console, Device, Jump } from "../
|
|
8
|
+
import type { Console, Device, Jump } from "../model";
|
|
9
9
|
import { Ssh } from "./ssh";
|
|
10
10
|
|
|
11
11
|
/** The relay, in Python's standard library only: the serial port in raw mode, locked against a second session, joined to standard input and output. */
|
|
@@ -440,7 +440,7 @@ export class SerialConsole {
|
|
|
440
440
|
|
|
441
441
|
/** A CLI on a console or in an SSH shell that runs one command at a time and returns what it printed, as an SSH exec would: no echo, no prompt. */
|
|
442
442
|
export class ConsoleShell {
|
|
443
|
-
/** The quiet to wait for after the prompt when output can hold a line that looks like it, as `circuit
|
|
443
|
+
/** The quiet to wait for after the prompt when output can hold a line that looks like it, as `circuit exec` prints whatever the device has. */
|
|
444
444
|
static readonly SETTLE = 1_000;
|
|
445
445
|
|
|
446
446
|
constructor(
|
|
@@ -476,7 +476,7 @@ export class ConsoleShell {
|
|
|
476
476
|
}
|
|
477
477
|
}
|
|
478
478
|
|
|
479
|
-
/** One line for `circuit console`: what to send, the quiet that ends its answer, and the text the last answer must end with for the line to be sent. */
|
|
479
|
+
/** One line for `circuit access-console`: what to send, the quiet that ends its answer, and the text the last answer must end with for the line to be sent. */
|
|
480
480
|
export type ScriptLine = { text: string; quiet: number; onlyAfter?: string };
|
|
481
481
|
|
|
482
482
|
/**
|
package/src/transport/ssh.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// SSH sessions to devices. Adapters choose how to talk to their platform; this holds what they share.
|
|
2
2
|
|
|
3
3
|
import { Client, type ClientChannel } from "ssh2";
|
|
4
|
-
import type { Device } from "../
|
|
4
|
+
import type { Device } from "../model";
|
|
5
5
|
|
|
6
6
|
export type Login = {
|
|
7
7
|
host: string;
|