@takodotid/circuit 0.5.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -174
- package/package.json +2 -1
- package/src/cli/index.ts +73 -12
- package/src/cli/new.ts +153 -0
- package/src/core/define.ts +2 -4
- package/src/core/merge.ts +19 -0
- package/src/core/secrets.ts +62 -15
- package/src/index.ts +1 -0
- package/src/presets/index.ts +208 -3
- package/src/schema/common.ts +3 -3
- package/templates/colocation/checks.ts +38 -0
- package/templates/colocation/circuit.config.ts +9 -0
- package/templates/colocation/edge-01.ts +131 -0
- package/templates/colocation/routing.ts +13 -0
- package/templates/colocation/tenancy.ts +97 -0
- package/templates/colocation/tenants/acme.ts +19 -0
- package/templates/colocation/tenants/index.ts +5 -0
- package/templates/colocation/tor-01.ts +33 -0
- package/templates/edge-router/checks.ts +20 -0
- package/templates/edge-router/circuit.config.ts +8 -0
- package/templates/edge-router/edge-01.ts +108 -0
- package/templates/edge-router/routing.ts +13 -0
- package/templates/project/AGENTS.md +44 -0
- package/templates/project/CLAUDE.md +1 -0
- package/templates/project/README.md +15 -0
- package/templates/project/gitignore +5 -0
- package/templates/project/tsconfig.json +13 -0
- package/templates/single-site/checks.ts +26 -0
- package/templates/single-site/circuit.config.ts +8 -0
- package/templates/single-site/router.ts +89 -0
- package/templates/single-site/site.ts +18 -0
- package/templates/single-site/switch.ts +34 -0
package/README.md
CHANGED
|
@@ -1,195 +1,35 @@
|
|
|
1
1
|
# Circuit
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Write how each router and switch should be configured, in one TypeScript file per device. Circuit shows you what would change, then makes the device match.
|
|
4
4
|
|
|
5
5
|
Documentation: [circuit.tako.id](https://circuit.tako.id)
|
|
6
6
|
|
|
7
7
|
> [!WARNING]
|
|
8
|
-
> Circuit is in alpha. It runs a production network, but
|
|
8
|
+
> Circuit is in alpha. It runs a production network, but the way you write config, the commands and the supported devices can still change between releases. Install an exact version, and read the release notes before you upgrade.
|
|
9
9
|
|
|
10
|
-
##
|
|
10
|
+
## Start
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
2. **Absent means off.** A field is written when it has a value. An undeclared port is shut down, an undeclared service is disabled, an undeclared feature is not running. There is no `enabled: false` and no placeholder.
|
|
14
|
-
3. **Neutral words only.** The schema says `access_vlan`, `https`, `hardware_offload`. Each adapter translates, and a field a platform cannot express fails validation instead of being skipped.
|
|
15
|
-
4. **Names are checked.** A device declares its VLANs, interfaces, policies and the rest; every reference to one is type-checked, so a typo does not compile and the editor suggests the names that exist. Port names come from the hardware model.
|
|
16
|
-
5. **Secrets are references.** `secret("NAME")` is resolved from the environment or `.env.local`, and `secretFile("path")` from a file kept out of git, only when a command is sent. Rendered config, plans and snapshots never hold a secret.
|
|
17
|
-
|
|
18
|
-
## Install
|
|
19
|
-
|
|
20
|
-
Circuit runs on [Bun](https://bun.sh) and ships as TypeScript source, on npm and GitHub Packages.
|
|
21
|
-
|
|
22
|
-
```bash
|
|
23
|
-
bun add --exact @takodotid/circuit
|
|
24
|
-
```
|
|
25
|
-
|
|
26
|
-
Add `"net": "circuit"` to the project's `scripts` to run it as `bun net`. Every release is on the [releases page](https://github.com/takodotid/circuit/releases); release candidates are tagged `-rc.N` and published under the `rc` dist-tag.
|
|
27
|
-
|
|
28
|
-
## A device
|
|
29
|
-
|
|
30
|
-
```ts
|
|
31
|
-
import { defineDevice, secret } from "@takodotid/circuit";
|
|
32
|
-
|
|
33
|
-
export default defineDevice({
|
|
34
|
-
name: "sw1",
|
|
35
|
-
platform: "vrp",
|
|
36
|
-
model: "CE6855-48S6Q-HI",
|
|
37
|
-
connection: { host: "10.0.0.2", user: "operator" },
|
|
38
|
-
users: { operator: { role: "admin", password: secret("SW1_PASSWORD") } },
|
|
39
|
-
management: { allow: ["10.0.0.0/24"], ssh: {} },
|
|
40
|
-
vlans: { mgmt: { id: 99 }, servers: { id: 100 } },
|
|
41
|
-
ports: {
|
|
42
|
-
"10g-1": { description: "server-1", access_vlan: "servers" },
|
|
43
|
-
"40g-1": { description: "uplink", trunk_vlans: ["mgmt", "servers"] },
|
|
44
|
-
},
|
|
45
|
-
interfaces: { mgmt: { type: "vlan", vlan: "mgmt", addresses: ["10.0.0.2/24"] } },
|
|
46
|
-
routing: { static: [{ prefix: "0.0.0.0/0", via: "10.0.0.1" }] },
|
|
47
|
-
});
|
|
48
|
-
```
|
|
49
|
-
|
|
50
|
-
Ports are named `<speed>-<position>`: `10g-1` is the first 10G cage. The model decides which exist; the adapter knows what the platform calls each one. Everything a device can hold is in `src/schema/`, with a comment on every field.
|
|
51
|
-
|
|
52
|
-
A network is a list of devices in `circuit.config.ts` at the root of the project, or any file named with `--config <path>`:
|
|
53
|
-
|
|
54
|
-
```ts
|
|
55
|
-
import { defineNetwork } from "@takodotid/circuit";
|
|
56
|
-
|
|
57
|
-
export default defineNetwork({ devices: [sw1, r1], checks, state: "state" });
|
|
58
|
-
```
|
|
59
|
-
|
|
60
|
-
`checks` are rules of your own design, run by `validate` beside Circuit's. `state` is where device snapshots are kept, committed, so history shows what each device ran. The CLI writes a `README.md` there saying the directory is generated.
|
|
61
|
-
|
|
62
|
-
Config is plain data. Shared values are ordinary imports, and anything repetitive can be a function; the result is still plain data.
|
|
63
|
-
|
|
64
|
-
## Commands
|
|
65
|
-
|
|
66
|
-
| Command | What it does | Touches a device |
|
|
67
|
-
| -------------------------------- | ------------------------------------------------------------------------ | ---------------------- |
|
|
68
|
-
| `validate` | Checks the config and every rule in `checks` | no |
|
|
69
|
-
| `build <device>` | Prints the whole configuration the device should run | no |
|
|
70
|
-
| `diff [device...]` | The plan against the last snapshot | no |
|
|
71
|
-
| `snapshot [device...]` | Reads what each device runs into the state directory | reads |
|
|
72
|
-
| `apply <device>` | Reads the device live and prints the plan; `--confirm` sends it | yes, with `--confirm` |
|
|
73
|
-
| `refresh <device>` | Fills prefix sets fetched from a registry; `--confirm` sends | yes, with `--confirm` |
|
|
74
|
-
| `wireguard <device> <if> <peer>` | A client config for one peer, its private key left for the peer to fill | no |
|
|
75
|
-
| `communities` | The network's BGP communities, for a looking glass or bgp.tools | no |
|
|
76
|
-
| `peeringdb` | Brings PeeringDB's exchange records in line with the config; `--confirm` | PeeringDB, `--confirm` |
|
|
77
|
-
|
|
78
|
-
`apply` also takes `--secrets`, to send every secret when rotating one, and `--rollback=N`, the minutes before a device that can restore itself does, 10 by default. Secrets cannot be read back, so without `--secrets` they are sent only when the object holding them is new.
|
|
79
|
-
|
|
80
|
-
A change goes:
|
|
81
|
-
|
|
82
|
-
1. Edit the device's file.
|
|
83
|
-
2. `validate`, then `diff <device>` to see the change against the last snapshot.
|
|
84
|
-
3. `apply <device>` and read every line of the plan. A `~ set` on a settings menu may turn something off.
|
|
85
|
-
4. `apply <device> --confirm`.
|
|
86
|
-
5. Commit the config with the snapshot `apply` wrote.
|
|
87
|
-
|
|
88
|
-
## Publishing the network
|
|
89
|
-
|
|
90
|
-
Some of the config describes the network to others rather than configuring a device. Devices ignore it.
|
|
91
|
-
|
|
92
|
-
```ts
|
|
93
|
-
interfaces: {
|
|
94
|
-
"ix-lan": {
|
|
95
|
-
type: "vlan",
|
|
96
|
-
vlan: "ix",
|
|
97
|
-
addresses: ["192.0.2.10/24", "2001:db8:1::10/64"],
|
|
98
|
-
exchange: { name: "Example IX", speed: 10_000, peeringdb_ixlan: 1234 },
|
|
99
|
-
},
|
|
100
|
-
},
|
|
101
|
-
```
|
|
102
|
-
|
|
103
|
-
```ts
|
|
104
|
-
export default defineNetwork({
|
|
105
|
-
devices,
|
|
106
|
-
state: "state",
|
|
107
|
-
asn: 64500,
|
|
108
|
-
communities: [{ community: "64500:0:nnn", description: "Do not announce to AS$0" }],
|
|
109
|
-
peeringdb: { api_key: secret("PEERINGDB_API_KEY") },
|
|
110
|
-
});
|
|
111
|
-
```
|
|
112
|
-
|
|
113
|
-
`peeringdb` compares every interface with a `peeringdb_ixlan` against PeeringDB's records for `asn`: speed, the interface's globally reachable addresses, and whether a BGP neighbor on it is a route server. A record PeeringDB holds that the config does not is reported, never deleted.
|
|
114
|
-
|
|
115
|
-
`communities` prints one per line, `community,description`, in the format the NLNOG Ring looking glass defines and bgp.tools reads: `nnn` is any number, `x` one digit, `a-b` a range, and `$0`, `$1` in the description are what each wildcard matched.
|
|
116
|
-
|
|
117
|
-
## How a device is converged
|
|
118
|
-
|
|
119
|
-
Each adapter guards the apply its platform allows:
|
|
120
|
-
|
|
121
|
-
| Platform | Commit | If it goes wrong |
|
|
122
|
-
| -------------- | -------------------------- | ------------------------------------------------------------------------- |
|
|
123
|
-
| `routeros` | each command as sent | backup saved, a scheduler restores it unless a fresh login disarms it |
|
|
124
|
-
| `vrp` | `commit`, then `save` | nothing applies until commit; save only after a fresh login proves access |
|
|
125
|
-
| `raisecom-ros` | each command, then `write` | write only after a fresh login proves access |
|
|
126
|
-
|
|
127
|
-
Plans run in two phases: additions and changes in dependency order, then removals in reverse, so nothing is removed while something still uses it. An ordered list such as a firewall chain is replaced whole, the old rules deciding until the new ones are in place.
|
|
128
|
-
|
|
129
|
-
## Platforms
|
|
130
|
-
|
|
131
|
-
`src/adapters/devices/<platform>/` holds the model catalog, the renderer, the reader and the apply. `TRAPS.md` beside each lists the platform behaviour it works around.
|
|
132
|
-
|
|
133
|
-
Not every platform can express every field. What one cannot is reported by `validate`, never skipped:
|
|
134
|
-
|
|
135
|
-
| Feature | routeros | vrp | raisecom-ros |
|
|
136
|
-
| ----------------------------------------- | --------------------- | --------------------- | ----------------------- |
|
|
137
|
-
| VLANs, access and trunk ports, links | yes | yes | yes |
|
|
138
|
-
| Per-port STP, storm control, LLDP | yes, storm in percent | yes, storm in percent | off only, broadcast pps |
|
|
139
|
-
| LAGs | yes | yes | yes |
|
|
140
|
-
| Routed ports, loopbacks, tunnels | yes | VLAN interfaces only | VLAN interfaces only |
|
|
141
|
-
| VRRP, VRFs, DHCP relay | yes | IPv4 | VRRP, IPv4 |
|
|
142
|
-
| Static routes | yes | yes | IPv4 next hop |
|
|
143
|
-
| OSPF and BFD | yes | IPv4 | OSPF, IPv4 |
|
|
144
|
-
| BGP, policies, prefix sets, RPKI | yes | no | no |
|
|
145
|
-
| Firewall, NAT, hardware ACLs, DHCP server | yes | no | no |
|
|
146
|
-
| Flow export | NetFlow 9, IPFIX | sFlow | no |
|
|
147
|
-
| SNMP | v2c, v3 with SHA1 | v2c, v3 | v2c, v3 with SHA1 |
|
|
148
|
-
| Certificates, web and API | yes | no | no |
|
|
149
|
-
|
|
150
|
-
`examples/` holds a device per platform using every field its adapter supports. `bun run test` validates them and checks each plans nothing against its own render. Their commands were compiled on real devices without being run.
|
|
151
|
-
|
|
152
|
-
## Versions
|
|
153
|
-
|
|
154
|
-
An adapter renders for one major version of its platform and refuses another when it connects. `routeros` is RouterOS 7; RouterOS 6 has a different BGP and filter language and would be an adapter of its own. Within a major version, a property the device does not know is caught before anything is sent: RouterOS compiles every command first, and VRP stages them in a candidate that is cleared on the first refusal. A behaviour that differs between minor versions belongs in the adapter, with a note in its `TRAPS.md`.
|
|
155
|
-
|
|
156
|
-
Circuit follows semantic versioning. A release is a git tag, `v<major>.<minor>.<patch>`.
|
|
157
|
-
|
|
158
|
-
## Developing Circuit beside a network
|
|
159
|
-
|
|
160
|
-
Link the working copy into the network's project, so a change here is live there without a release:
|
|
12
|
+
You need [Bun](https://bun.sh). Then:
|
|
161
13
|
|
|
162
14
|
```bash
|
|
163
|
-
|
|
164
|
-
cd ../my-network && bun link @takodotid/circuit
|
|
15
|
+
bunx @takodotid/circuit new
|
|
165
16
|
```
|
|
166
17
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
## Adding a platform
|
|
18
|
+
It asks which kind of network you have, creates a project with example devices, and installs Circuit. [Getting started](https://circuit.tako.id/guide/getting-started) walks through the first run.
|
|
170
19
|
|
|
171
|
-
|
|
172
|
-
2. An adapter implementing `DeviceAdapter` in `src/adapters/devices/types.ts`. `lines.ts` converges block-structured configurations; `routeros/` shows a command-structured one.
|
|
173
|
-
3. Register it in `src/adapters/devices/index.ts` and the catalog in `src/adapters/devices/catalog.ts`.
|
|
174
|
-
4. `unsupported()` lists every field it cannot express. Silence is not an option.
|
|
175
|
-
5. An example in `examples/` using every field it supports.
|
|
20
|
+
Circuit supports MikroTik RouterOS 7, Huawei VRP and Raisecom ROS. Releases are on the [releases page](https://github.com/takodotid/circuit/releases), on npm and on GitHub Packages.
|
|
176
21
|
|
|
177
|
-
##
|
|
178
|
-
|
|
179
|
-
`docs/` is the site at [circuit.tako.id](https://circuit.tako.id), built with VitePress: `bun run docs:dev` serves it locally. It is not part of the package.
|
|
180
|
-
|
|
181
|
-
## Releases
|
|
22
|
+
## Working on Circuit
|
|
182
23
|
|
|
183
24
|
```bash
|
|
184
|
-
bun
|
|
185
|
-
bun run
|
|
186
|
-
bun run
|
|
25
|
+
bun install
|
|
26
|
+
bun run typecheck
|
|
27
|
+
bun run test # every example and template validates, and plans nothing against its own render
|
|
28
|
+
bun run docs:dev # the documentation site, from docs/
|
|
187
29
|
```
|
|
188
30
|
|
|
189
|
-
|
|
31
|
+
[Contributing](https://circuit.tako.id/contributing) explains the layout, how to add a platform, and how releases are cut. `AGENTS.md` has the rules for changing the code.
|
|
190
32
|
|
|
191
33
|
## License
|
|
192
34
|
|
|
193
|
-
[Business Source License 1.1](LICENSE)
|
|
194
|
-
|
|
195
|
-
Maintained by the Tako Network Engineering Team. Copyright 2026 PT Hobimu Jadi Cuan.
|
|
35
|
+
[Business Source License 1.1](LICENSE): free in production for an organization whose yearly revenue and funding, with its affiliates, are each under US$100,000. Above that, production use needs a commercial license from [legal@tako.id](mailto:legal@tako.id). Each version becomes Apache License 2.0 four years after it is published. See [License](https://circuit.tako.id/license).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@takodotid/circuit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Declarative, vendor-neutral network configuration: describe each device in TypeScript, and Circuit makes the device match. Alpha.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"network",
|
|
@@ -47,6 +47,7 @@
|
|
|
47
47
|
},
|
|
48
48
|
"files": [
|
|
49
49
|
"src",
|
|
50
|
+
"templates",
|
|
50
51
|
"LICENSE",
|
|
51
52
|
"README.md"
|
|
52
53
|
],
|
package/src/cli/index.ts
CHANGED
|
@@ -1,21 +1,22 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
-
// The command line.
|
|
2
|
+
// The command line. `new` starts a project; every other command loads the network from `circuit.config.ts` in the working directory, or the file `--config` names.
|
|
3
3
|
|
|
4
4
|
import { createPrivateKey, createPublicKey } from "node:crypto";
|
|
5
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
6
|
-
import { dirname, join, resolve } from "node:path";
|
|
5
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { basename, dirname, join, resolve } 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 { planPeeringDb, sendPeeringDb } from "../adapters/registries/peeringdb";
|
|
11
11
|
import { network as prefixOf } from "../core/addr";
|
|
12
12
|
import type { Network } from "../core/define";
|
|
13
|
-
import { resolve as reveal } from "../core/secrets";
|
|
13
|
+
import { isSecret, referenceOf, resolve as reveal, sourceOf, useRoot } from "../core/secrets";
|
|
14
14
|
import { validate } from "../core/validate";
|
|
15
15
|
import type { Device } from "../schema";
|
|
16
16
|
|
|
17
17
|
const HELP = `usage: circuit <command> [device...] [options]
|
|
18
18
|
|
|
19
|
+
new [directory] start a project from a pattern, asking what it needs
|
|
19
20
|
validate check the config, then every rule in checks
|
|
20
21
|
build <device> print the configuration the device should run, without contacting it
|
|
21
22
|
diff [device...] what apply would send, planned against the last snapshot, offline
|
|
@@ -28,6 +29,7 @@ const HELP = `usage: circuit <command> [device...] [options]
|
|
|
28
29
|
a client config for one peer, its private key left for the peer to fill
|
|
29
30
|
communities the network's BGP communities, one per line, for a looking glass or bgp.tools
|
|
30
31
|
peeringdb bring PeeringDB's exchange records in line with the config, with --confirm
|
|
32
|
+
secrets every secret the config uses, and whether each can be read
|
|
31
33
|
|
|
32
34
|
--config <path> the network's config file, circuit.config.ts in the working directory by default`;
|
|
33
35
|
|
|
@@ -35,6 +37,17 @@ const DEFAULT_CONFIG = "circuit.config.ts";
|
|
|
35
37
|
|
|
36
38
|
const [command, ...args] = process.argv.slice(2);
|
|
37
39
|
|
|
40
|
+
if (command === "new") {
|
|
41
|
+
const { create } = await import("./new");
|
|
42
|
+
await create(args);
|
|
43
|
+
process.exit(0);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (!command || command === "help" || command === "--help" || command === "-h") {
|
|
47
|
+
console.log(HELP);
|
|
48
|
+
process.exit(0);
|
|
49
|
+
}
|
|
50
|
+
|
|
38
51
|
// `--config <path>` and `--config=<path>` both name the config file; neither is a device name.
|
|
39
52
|
const configAt = args.indexOf("--config");
|
|
40
53
|
const configArgument = configAt >= 0 ? args[configAt + 1] : args.find((arg) => arg.startsWith("--config="))?.slice("--config=".length);
|
|
@@ -49,8 +62,9 @@ if (!existsSync(configPath)) {
|
|
|
49
62
|
process.exit(1);
|
|
50
63
|
}
|
|
51
64
|
|
|
52
|
-
/** The directory of the config file.
|
|
65
|
+
/** The directory of the config file. `.circuit/`, `.env` and secret files are found from here. */
|
|
53
66
|
const root = dirname(configPath);
|
|
67
|
+
useRoot(root);
|
|
54
68
|
|
|
55
69
|
const network = (await import(configPath)).default as Network;
|
|
56
70
|
|
|
@@ -83,20 +97,22 @@ function one(): Device {
|
|
|
83
97
|
}
|
|
84
98
|
|
|
85
99
|
const adapterOf = (device: Device): DeviceAdapter => adapters[device.platform] ?? fail(`no adapter for ${device.platform}`);
|
|
86
|
-
|
|
100
|
+
/** What Circuit writes, beside the config. */
|
|
101
|
+
const generated = join(root, ".circuit");
|
|
102
|
+
const stateDirectory = join(generated, "state");
|
|
87
103
|
const statePath = (device: Device) => join(stateDirectory, device.name + adapterOf(device).extension);
|
|
88
104
|
|
|
89
|
-
const
|
|
105
|
+
const GENERATED_README = `# Written by Circuit
|
|
90
106
|
|
|
91
|
-
|
|
107
|
+
Do not edit anything here: an edit changes nothing on a device, and Circuit overwrites it. Change the config instead.
|
|
92
108
|
|
|
93
|
-
|
|
109
|
+
\`state/\` holds what each device ran when Circuit last read it, with secrets removed. \`circuit snapshot\` and \`circuit apply\` write it, and \`circuit diff\` plans against it. Commit it, so the history of this directory is the history of the network.
|
|
94
110
|
`;
|
|
95
111
|
|
|
96
112
|
/** Record what a device runs, beside a note that the directory is generated. */
|
|
97
113
|
function writeState(device: Device, text: string): void {
|
|
98
114
|
mkdirSync(stateDirectory, { recursive: true });
|
|
99
|
-
writeFileSync(join(
|
|
115
|
+
writeFileSync(join(generated, "README.md"), GENERATED_README);
|
|
100
116
|
writeFileSync(statePath(device), text);
|
|
101
117
|
}
|
|
102
118
|
|
|
@@ -168,6 +184,17 @@ async function snapshotCommand(): Promise<void> {
|
|
|
168
184
|
writeState(device, text);
|
|
169
185
|
console.log(`${device.name}: ${text.split("\n").length} lines`);
|
|
170
186
|
}
|
|
187
|
+
|
|
188
|
+
// With every device read, a snapshot of a device the config no longer has, such as one renamed, is removed.
|
|
189
|
+
if (names.length || !existsSync(stateDirectory)) return;
|
|
190
|
+
|
|
191
|
+
const current = new Set(network.devices.map((device) => basename(statePath(device))));
|
|
192
|
+
for (const file of readdirSync(stateDirectory)) {
|
|
193
|
+
if (current.has(file)) continue;
|
|
194
|
+
|
|
195
|
+
rmSync(join(stateDirectory, file));
|
|
196
|
+
console.log(`${file}: removed, no such device in the config`);
|
|
197
|
+
}
|
|
171
198
|
}
|
|
172
199
|
|
|
173
200
|
async function applyCommand(): Promise<void> {
|
|
@@ -290,6 +317,36 @@ async function peeringdbCommand(): Promise<void> {
|
|
|
290
317
|
await sendPeeringDb(network, plan.changes);
|
|
291
318
|
}
|
|
292
319
|
|
|
320
|
+
/** Every secret reference in a value, however deep. */
|
|
321
|
+
function secretsIn(value: unknown, found = new Set<string>()): Set<string> {
|
|
322
|
+
if (isSecret(value)) found.add(referenceOf(value));
|
|
323
|
+
else if (Array.isArray(value)) for (const item of value) secretsIn(item, found);
|
|
324
|
+
else if (typeof value === "object" && value !== null) for (const item of Object.values(value)) secretsIn(item, found);
|
|
325
|
+
|
|
326
|
+
return found;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function secretsCommand(): void {
|
|
330
|
+
const references = [...secretsIn([network.devices, network.peeringdb])].sort();
|
|
331
|
+
let missing = 0;
|
|
332
|
+
|
|
333
|
+
for (const reference of references) {
|
|
334
|
+
const name = reference.replace(/^file:/, "");
|
|
335
|
+
|
|
336
|
+
try {
|
|
337
|
+
reveal(reference);
|
|
338
|
+
console.log(` ok ${name}, from ${sourceOf(reference)}`);
|
|
339
|
+
} catch (error) {
|
|
340
|
+
missing++;
|
|
341
|
+
console.log(` missing ${name}: ${(error as Error).message}`);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
console.log(`
|
|
346
|
+
${references.length} secret(s), ${missing} missing.`);
|
|
347
|
+
process.exit(missing ? 1 : 0);
|
|
348
|
+
}
|
|
349
|
+
|
|
293
350
|
const commands: Record<string, () => void | Promise<void>> = {
|
|
294
351
|
validate: validateCommand,
|
|
295
352
|
build: buildCommand,
|
|
@@ -300,8 +357,12 @@ const commands: Record<string, () => void | Promise<void>> = {
|
|
|
300
357
|
wireguard: wireguardCommand,
|
|
301
358
|
communities: communitiesCommand,
|
|
302
359
|
peeringdb: peeringdbCommand,
|
|
360
|
+
secrets: secretsCommand,
|
|
303
361
|
};
|
|
304
362
|
|
|
305
|
-
const run = commands[command
|
|
363
|
+
const run = commands[command];
|
|
306
364
|
if (run) await run();
|
|
307
|
-
else
|
|
365
|
+
else
|
|
366
|
+
fail(`no command ${command}
|
|
367
|
+
|
|
368
|
+
${HELP}`);
|
package/src/cli/new.ts
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// `circuit new`: start a project from one of the patterns in templates/. In a terminal it asks what it needs; the same answers can be given as options, for a script or an AI agent.
|
|
2
|
+
|
|
3
|
+
import { cpSync, existsSync, readdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { basename, join, relative, resolve } from "node:path";
|
|
5
|
+
import { createInterface } from "node:readline/promises";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
|
|
8
|
+
const TEMPLATES = fileURLToPath(new URL("../../templates", import.meta.url));
|
|
9
|
+
const circuit = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8"));
|
|
10
|
+
|
|
11
|
+
const PATTERNS = {
|
|
12
|
+
"single-site": { title: "A single site", fits: "a home, an office or an internal network: a router and a switch, no BGP", asn: false },
|
|
13
|
+
"edge-router": { title: "An edge router", fits: "your own AS number, with an IP transit and an internet exchange", asn: true },
|
|
14
|
+
colocation: {
|
|
15
|
+
title: "Colocation with tenants",
|
|
16
|
+
fits: "a provider whose customers each get their own VLANs, addresses and ports",
|
|
17
|
+
asn: true,
|
|
18
|
+
},
|
|
19
|
+
} as const;
|
|
20
|
+
|
|
21
|
+
type Pattern = keyof typeof PATTERNS;
|
|
22
|
+
|
|
23
|
+
/** The AS number the templates use, reserved for documentation by RFC 5398. */
|
|
24
|
+
const EXAMPLE_ASN = "64500";
|
|
25
|
+
|
|
26
|
+
const USAGE = `usage: circuit new [directory] [options]
|
|
27
|
+
|
|
28
|
+
--pattern <name> single-site, edge-router or colocation
|
|
29
|
+
--asn <number> your AS number, for edge-router and colocation
|
|
30
|
+
--1password write .env with 1Password references instead of empty values
|
|
31
|
+
--no-install do not run bun install`;
|
|
32
|
+
|
|
33
|
+
function fail(message: string): never {
|
|
34
|
+
console.error(message);
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The value of `--name value` or `--name=value`. */
|
|
39
|
+
function option(args: string[], name: string): string | undefined {
|
|
40
|
+
const at = args.indexOf(`--${name}`);
|
|
41
|
+
if (at >= 0) return args[at + 1];
|
|
42
|
+
return args.find((arg) => arg.startsWith(`--${name}=`))?.slice(name.length + 3);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Every file under a directory, relative to it. */
|
|
46
|
+
function filesIn(directory: string): string[] {
|
|
47
|
+
return readdirSync(directory, { recursive: true, withFileTypes: true })
|
|
48
|
+
.filter((entry) => entry.isFile())
|
|
49
|
+
.map((entry) => relative(directory, join(entry.parentPath, entry.name)));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function create(args: string[]): Promise<void> {
|
|
53
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
54
|
+
console.log(USAGE);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const valued = new Set(["--pattern", "--asn"]);
|
|
59
|
+
const positional = args.filter((arg, index) => !arg.startsWith("--") && !valued.has(args[index - 1] ?? ""));
|
|
60
|
+
|
|
61
|
+
const interactive = process.stdin.isTTY === true;
|
|
62
|
+
const terminal = interactive ? createInterface({ input: process.stdin, output: process.stdout }) : undefined;
|
|
63
|
+
|
|
64
|
+
// The answer typed, or the fallback when nothing is typed or nobody is there to type.
|
|
65
|
+
const ask = async (question: string, fallback: string) => {
|
|
66
|
+
if (!terminal) return fallback;
|
|
67
|
+
const answer = (await terminal.question(`${question} (${fallback}): `)).trim();
|
|
68
|
+
return answer || fallback;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const directory = positional[0] ?? (await ask("Directory for the project", "my-network"));
|
|
72
|
+
const target = resolve(process.cwd(), directory);
|
|
73
|
+
if (existsSync(target) && readdirSync(target).length) fail(`${target} is not empty`);
|
|
74
|
+
|
|
75
|
+
let pattern = option(args, "pattern") as Pattern | undefined;
|
|
76
|
+
if (!pattern) {
|
|
77
|
+
const names = Object.keys(PATTERNS) as Pattern[];
|
|
78
|
+
if (terminal) {
|
|
79
|
+
console.log("\nWhich is closest to your network?");
|
|
80
|
+
names.forEach((name, index) => console.log(` ${index + 1}. ${PATTERNS[name].title}: ${PATTERNS[name].fits}`));
|
|
81
|
+
}
|
|
82
|
+
pattern = names[Number(await ask("Number", "1")) - 1];
|
|
83
|
+
}
|
|
84
|
+
if (!pattern || !(pattern in PATTERNS)) fail(`no pattern ${pattern}; there are ${Object.keys(PATTERNS).join(", ")}`);
|
|
85
|
+
|
|
86
|
+
let asn = option(args, "asn");
|
|
87
|
+
if (!asn && PATTERNS[pattern].asn) {
|
|
88
|
+
asn = await ask("Your AS number, or press enter to keep the documentation example", EXAMPLE_ASN);
|
|
89
|
+
}
|
|
90
|
+
if (asn && !/^\d+$/.test(asn)) fail(`${asn} is not an AS number`);
|
|
91
|
+
|
|
92
|
+
const onePassword =
|
|
93
|
+
args.includes("--1password") || (await ask("Keep the secrets in 1Password? y or n", "n")).toLowerCase().startsWith("y");
|
|
94
|
+
terminal?.close();
|
|
95
|
+
|
|
96
|
+
// The pattern's files, then what every project has. npm leaves out a file named .gitignore, so it travels without the dot.
|
|
97
|
+
cpSync(join(TEMPLATES, pattern), target, { recursive: true });
|
|
98
|
+
cpSync(join(TEMPLATES, "project"), target, { recursive: true });
|
|
99
|
+
renameSync(join(target, "gitignore"), join(target, ".gitignore"));
|
|
100
|
+
|
|
101
|
+
const sources = filesIn(target).filter((file) => file.endsWith(".ts"));
|
|
102
|
+
const secrets = new Set<string>();
|
|
103
|
+
|
|
104
|
+
for (const file of sources) {
|
|
105
|
+
const path = join(target, file);
|
|
106
|
+
let text = readFileSync(path, "utf8");
|
|
107
|
+
|
|
108
|
+
if (asn && asn !== EXAMPLE_ASN) text = text.replaceAll(new RegExp(`\\b${EXAMPLE_ASN}\\b`, "g"), asn);
|
|
109
|
+
for (const [, name] of text.matchAll(/secret\("([A-Z0-9_]+)"\)/g)) secrets.add(name!);
|
|
110
|
+
|
|
111
|
+
writeFileSync(path, text);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const env = [
|
|
115
|
+
"# Secrets for Circuit. Never commit this file.",
|
|
116
|
+
"# A value is the secret itself, or a 1Password reference, op://vault/item/field, read with the 1Password CLI.",
|
|
117
|
+
...[...secrets].sort().map((name) => (onePassword ? `${name}=op://Network/${name}/password` : `${name}=`)),
|
|
118
|
+
"",
|
|
119
|
+
];
|
|
120
|
+
writeFileSync(join(target, ".env"), env.join("\n"));
|
|
121
|
+
|
|
122
|
+
const name = basename(target)
|
|
123
|
+
.toLowerCase()
|
|
124
|
+
.replace(/[^a-z0-9-]+/g, "-");
|
|
125
|
+
const packageJson = {
|
|
126
|
+
name,
|
|
127
|
+
private: true,
|
|
128
|
+
type: "module",
|
|
129
|
+
dependencies: { "@takodotid/circuit": circuit.version },
|
|
130
|
+
devDependencies: { "@types/bun": circuit.devDependencies["@types/bun"] },
|
|
131
|
+
};
|
|
132
|
+
writeFileSync(join(target, "package.json"), JSON.stringify(packageJson, null, 4) + "\n");
|
|
133
|
+
|
|
134
|
+
if (!args.includes("--no-install")) {
|
|
135
|
+
const install = Bun.spawnSync(["bun", "install"], { cwd: target, stdout: "inherit", stderr: "inherit" });
|
|
136
|
+
if (install.exitCode !== 0) console.log("bun install failed; run it again in the project.");
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const steps = [
|
|
140
|
+
`cd ${directory}`,
|
|
141
|
+
onePassword
|
|
142
|
+
? "Point each line of .env at the item that holds it in 1Password, then run: bun circuit secrets"
|
|
143
|
+
: "Fill in each password in .env, then run: bun circuit secrets",
|
|
144
|
+
"Change the names, addresses and ports in the device files to your own.",
|
|
145
|
+
...(PATTERNS[pattern].asn && (!asn || asn === EXAMPLE_ASN) ? ["Put your own AS number in routing.ts."] : []),
|
|
146
|
+
"bun circuit validate",
|
|
147
|
+
"bun circuit snapshot, then bun circuit diff, to see what would change on each device",
|
|
148
|
+
];
|
|
149
|
+
|
|
150
|
+
console.log(`\nCreated ${directory} from the pattern "${PATTERNS[pattern].title}".\n\nNext:`);
|
|
151
|
+
steps.forEach((step, index) => console.log(` ${index + 1}. ${step}`));
|
|
152
|
+
console.log(`\nWhat each file does: https://circuit.tako.id/patterns/${pattern}`);
|
|
153
|
+
}
|
package/src/core/define.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import type { ModelName, PlatformName, PortName } from "../adapters/devices/catalog";
|
|
2
2
|
import type { Device, Secret } from "../schema";
|
|
3
3
|
|
|
4
|
-
/** A value resolved by name when a device is applied, from the environment or `.env.
|
|
4
|
+
/** A value resolved by name when a device is applied, from the environment or `.env` beside the config, and through 1Password when that value is an `op://` reference. Never the value itself. */
|
|
5
5
|
export const secret = (name: string): Secret => ({ secret: name });
|
|
6
6
|
|
|
7
|
-
/** A value read from a file when a device is applied, such as a private key in PEM. The path is relative to the
|
|
7
|
+
/** A value read from a file when a device is applied, such as a private key in PEM. The path is relative to the config file; keep the file out of git. */
|
|
8
8
|
export const secretFile = (path: string): Secret => {
|
|
9
9
|
if (!/^[A-Za-z0-9_./-]+$/.test(path)) throw new Error(`secret file path ${path} may hold only letters, digits, _ . / and -`);
|
|
10
10
|
return { secret_file: path };
|
|
@@ -77,8 +77,6 @@ export type Network = {
|
|
|
77
77
|
devices: readonly Device[];
|
|
78
78
|
/** Rules of your own design. */
|
|
79
79
|
checks?: readonly Check[];
|
|
80
|
-
/** Where snapshots of what each device runs are kept, relative to the config file. */
|
|
81
|
-
state: string;
|
|
82
80
|
/** The AS the network operates, for what is published about it. */
|
|
83
81
|
asn?: number;
|
|
84
82
|
/** The BGP communities the network defines, printed by `circuit communities` for a looking glass or bgp.tools. */
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Combining records from several sources, such as a device's own interfaces and those a module derives for it.
|
|
2
|
+
|
|
3
|
+
type Merged<T extends readonly object[]> = T extends readonly [infer Head, ...infer Tail extends readonly object[]]
|
|
4
|
+
? Head & Merged<Tail>
|
|
5
|
+
: unknown;
|
|
6
|
+
|
|
7
|
+
/** Combine records into one. Unlike spreading them, a name two of them hold is an error rather than one silently replacing the other. */
|
|
8
|
+
export function merge<const T extends readonly Record<string, unknown>[]>(...records: T): Merged<T> {
|
|
9
|
+
const result: Record<string, unknown> = {};
|
|
10
|
+
|
|
11
|
+
for (const record of records) {
|
|
12
|
+
for (const [key, value] of Object.entries(record)) {
|
|
13
|
+
if (key in result) throw new Error(`merge: ${key} is declared twice`);
|
|
14
|
+
result[key] = value;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return result as Merged<T>;
|
|
19
|
+
}
|