@takodotid/circuit 0.4.3
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/LICENSE +106 -0
- package/README.md +195 -0
- package/package.json +83 -0
- package/src/adapters/devices/catalog.ts +38 -0
- package/src/adapters/devices/derive.ts +27 -0
- package/src/adapters/devices/index.ts +7 -0
- package/src/adapters/devices/lines.ts +212 -0
- package/src/adapters/devices/raisecom-ros/TRAPS.md +22 -0
- package/src/adapters/devices/raisecom-ros/index.ts +603 -0
- package/src/adapters/devices/raisecom-ros/models.ts +7 -0
- package/src/adapters/devices/routeros/TRAPS.md +33 -0
- package/src/adapters/devices/routeros/export.ts +319 -0
- package/src/adapters/devices/routeros/index.ts +261 -0
- package/src/adapters/devices/routeros/models.ts +8 -0
- package/src/adapters/devices/routeros/plan.ts +370 -0
- package/src/adapters/devices/routeros/render.ts +1431 -0
- package/src/adapters/devices/types.ts +31 -0
- package/src/adapters/devices/vrp/TRAPS.md +22 -0
- package/src/adapters/devices/vrp/index.ts +742 -0
- package/src/adapters/devices/vrp/models.ts +4 -0
- package/src/adapters/registries/index.ts +19 -0
- package/src/adapters/registries/peeringdb.ts +169 -0
- package/src/cli/index.ts +295 -0
- package/src/core/addr.ts +145 -0
- package/src/core/define.ts +94 -0
- package/src/core/secrets.ts +68 -0
- package/src/core/time.ts +15 -0
- package/src/core/validate.ts +380 -0
- package/src/index.ts +6 -0
- package/src/schema/common.ts +32 -0
- package/src/schema/device.ts +95 -0
- package/src/schema/firewall.ts +128 -0
- package/src/schema/index.ts +7 -0
- package/src/schema/interfaces.ts +192 -0
- package/src/schema/policy.ts +88 -0
- package/src/schema/routing.ts +120 -0
- package/src/schema/system.ts +191 -0
- package/src/transport/ssh.ts +125 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
Business Source License 1.1
|
|
2
|
+
|
|
3
|
+
Parameters
|
|
4
|
+
|
|
5
|
+
Licensor: PT Hobimu Jadi Cuan
|
|
6
|
+
Licensed Work: Circuit. The Licensed Work is (c) 2026 PT Hobimu Jadi Cuan.
|
|
7
|
+
Additional Use Grant: You may make production use of the Licensed Work, provided
|
|
8
|
+
that the Total Finances of you and your Affiliates do not
|
|
9
|
+
exceed US$100,000 in the most recent twelve-month period.
|
|
10
|
+
|
|
11
|
+
"Total Finances" means the larger of (a) the aggregate gross
|
|
12
|
+
revenue and (b) the aggregate funds raised, including debt
|
|
13
|
+
and equity, of you and your Affiliates.
|
|
14
|
+
|
|
15
|
+
"Affiliates" means every entity that controls, is
|
|
16
|
+
controlled by, or is under common control with you, where
|
|
17
|
+
control means ownership of more than fifty percent of the
|
|
18
|
+
voting interests.
|
|
19
|
+
|
|
20
|
+
Production use beyond this grant needs a commercial license
|
|
21
|
+
from the Licensor: legal@tako.id.
|
|
22
|
+
Change Date: Four years from the date the Licensed Work is published.
|
|
23
|
+
Change License: Apache License, Version 2.0
|
|
24
|
+
|
|
25
|
+
For information about alternative licensing arrangements for the Licensed Work,
|
|
26
|
+
please contact legal@tako.id.
|
|
27
|
+
|
|
28
|
+
Notice
|
|
29
|
+
|
|
30
|
+
The Business Source License (this document, or the "License") is not an Open
|
|
31
|
+
Source license. However, the Licensed Work will eventually be made available
|
|
32
|
+
under an Open Source License, as stated in this License.
|
|
33
|
+
|
|
34
|
+
License text copyright (c) 2017 MariaDB Corporation Ab, All Rights Reserved.
|
|
35
|
+
"Business Source License" is a trademark of MariaDB Corporation Ab.
|
|
36
|
+
|
|
37
|
+
-----------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
Business Source License 1.1
|
|
40
|
+
|
|
41
|
+
Terms
|
|
42
|
+
|
|
43
|
+
The Licensor hereby grants you the right to copy, modify, create derivative
|
|
44
|
+
works, redistribute, and make non-production use of the Licensed Work. The
|
|
45
|
+
Licensor may make an Additional Use Grant, above, permitting limited
|
|
46
|
+
production use.
|
|
47
|
+
|
|
48
|
+
Effective on the Change Date, or the fourth anniversary of the first publicly
|
|
49
|
+
available distribution of a specific version of the Licensed Work under this
|
|
50
|
+
License, whichever comes first, the Licensor hereby grants you rights under
|
|
51
|
+
the terms of the Change License, and the rights granted in the paragraph
|
|
52
|
+
above terminate.
|
|
53
|
+
|
|
54
|
+
If your use of the Licensed Work does not comply with the requirements
|
|
55
|
+
currently in effect as described in this License, you must purchase a
|
|
56
|
+
commercial license from the Licensor, its affiliated entities, or authorized
|
|
57
|
+
resellers, or you must refrain from using the Licensed Work.
|
|
58
|
+
|
|
59
|
+
All copies of the original and modified Licensed Work, and derivative works
|
|
60
|
+
of the Licensed Work, are subject to this License. This License applies
|
|
61
|
+
separately for each version of the Licensed Work and the Change Date may vary
|
|
62
|
+
for each version of the Licensed Work released by Licensor.
|
|
63
|
+
|
|
64
|
+
You must conspicuously display this License on each original or modified copy
|
|
65
|
+
of the Licensed Work. If you receive the Licensed Work in original or
|
|
66
|
+
modified form from a third party, the terms and conditions set forth in this
|
|
67
|
+
License apply to your use of that work.
|
|
68
|
+
|
|
69
|
+
Any use of the Licensed Work in violation of this License will automatically
|
|
70
|
+
terminate your rights under this License for the current and all other
|
|
71
|
+
versions of the Licensed Work.
|
|
72
|
+
|
|
73
|
+
This License does not grant you any right in any trademark or logo of
|
|
74
|
+
Licensor or its affiliates (provided that you may use a trademark or logo of
|
|
75
|
+
Licensor as expressly required by this License).
|
|
76
|
+
|
|
77
|
+
TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON
|
|
78
|
+
AN "AS IS" BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS,
|
|
79
|
+
EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF
|
|
80
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND
|
|
81
|
+
TITLE.
|
|
82
|
+
|
|
83
|
+
MariaDB hereby grants you permission to use this License's text to license
|
|
84
|
+
your works, and to refer to it using the trademark "Business Source License",
|
|
85
|
+
as long as you comply with the Covenants of Licensor below.
|
|
86
|
+
|
|
87
|
+
Covenants of Licensor
|
|
88
|
+
|
|
89
|
+
In consideration of the right to use this License's text and the "Business
|
|
90
|
+
Source License" name and trademark, Licensor covenants to MariaDB, and to all
|
|
91
|
+
other recipients of the licensed work to be provided by Licensor:
|
|
92
|
+
|
|
93
|
+
1. To specify as the Change License the GPL Version 2.0 or any later version,
|
|
94
|
+
or a license that is compatible with GPL Version 2.0 or a later version,
|
|
95
|
+
where "compatible" means that software provided under the Change License can
|
|
96
|
+
be included in a program with software provided under GPL Version 2.0 or a
|
|
97
|
+
later version. Licensor may specify additional Change Licenses without
|
|
98
|
+
limitation.
|
|
99
|
+
|
|
100
|
+
2. To either: (a) specify an additional grant of rights to use that does not
|
|
101
|
+
impose any additional restriction on the right granted in this License, as
|
|
102
|
+
the Additional Use Grant; or (b) insert the text "None".
|
|
103
|
+
|
|
104
|
+
3. To specify a Change Date.
|
|
105
|
+
|
|
106
|
+
4. Not to modify this License in any other way.
|
package/README.md
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
# Circuit
|
|
2
|
+
|
|
3
|
+
Declarative, vendor-neutral network configuration. Describe each device in TypeScript, and Circuit renders it for the platform, compares it with what the device runs, and makes the device match.
|
|
4
|
+
|
|
5
|
+
Documentation: [circuit.tako.id](https://circuit.tako.id)
|
|
6
|
+
|
|
7
|
+
> [!WARNING]
|
|
8
|
+
> Circuit is in alpha. It runs a production network, but it is not yet stable: the schema, the CLI and the adapters can still change in breaking ways between releases. Pin an exact version and read the release notes before upgrading.
|
|
9
|
+
|
|
10
|
+
## Principles
|
|
11
|
+
|
|
12
|
+
1. **The config is the whole truth.** Whatever a device runs that its config does not say is removed, or returned to its default. There are no exceptions and nothing is left alone.
|
|
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:
|
|
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:
|
|
161
|
+
|
|
162
|
+
```bash
|
|
163
|
+
cd circuit && bun link
|
|
164
|
+
cd ../my-network && bun link @takodotid/circuit
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
`bun install` in the network's project returns it to the pinned release.
|
|
168
|
+
|
|
169
|
+
## Adding a platform
|
|
170
|
+
|
|
171
|
+
1. `src/adapters/devices/<platform>/models.ts`: each model's ports per speed class and the platform's spelling.
|
|
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.
|
|
176
|
+
|
|
177
|
+
## Documentation site
|
|
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
|
|
182
|
+
|
|
183
|
+
```bash
|
|
184
|
+
bun run release patch | minor | major
|
|
185
|
+
bun run release rc [patch | minor | major]
|
|
186
|
+
bun run release stable
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
The script commits the version and pushes a tag; the tag publishes the package and a GitHub release.
|
|
190
|
+
|
|
191
|
+
## License
|
|
192
|
+
|
|
193
|
+
[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: [legal@tako.id](mailto:legal@tako.id). Each version becomes Apache License 2.0 four years after it is published.
|
|
194
|
+
|
|
195
|
+
Maintained by the Tako Network Engineering Team. Copyright 2026 PT Hobimu Jadi Cuan.
|
package/package.json
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@takodotid/circuit",
|
|
3
|
+
"version": "0.4.3",
|
|
4
|
+
"description": "Declarative, vendor-neutral network configuration: describe each device in TypeScript, and Circuit makes the device match. Alpha.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"network",
|
|
7
|
+
"network-automation",
|
|
8
|
+
"infrastructure-as-code",
|
|
9
|
+
"declarative",
|
|
10
|
+
"netops",
|
|
11
|
+
"bgp",
|
|
12
|
+
"routeros",
|
|
13
|
+
"mikrotik",
|
|
14
|
+
"vrp",
|
|
15
|
+
"huawei",
|
|
16
|
+
"raisecom"
|
|
17
|
+
],
|
|
18
|
+
"homepage": "https://circuit.tako.id",
|
|
19
|
+
"bugs": {
|
|
20
|
+
"url": "https://github.com/takodotid/circuit/issues",
|
|
21
|
+
"email": "noc@tako.id"
|
|
22
|
+
},
|
|
23
|
+
"repository": {
|
|
24
|
+
"type": "git",
|
|
25
|
+
"url": "git+https://github.com/takodotid/circuit.git"
|
|
26
|
+
},
|
|
27
|
+
"license": "BUSL-1.1",
|
|
28
|
+
"author": {
|
|
29
|
+
"name": "Tako Network Engineering Team",
|
|
30
|
+
"email": "noc@tako.id",
|
|
31
|
+
"url": "https://tako.id"
|
|
32
|
+
},
|
|
33
|
+
"contributors": [
|
|
34
|
+
{
|
|
35
|
+
"name": "Rafly Maulana",
|
|
36
|
+
"email": "rafly@tako.id",
|
|
37
|
+
"url": "https://raflymaulana.com"
|
|
38
|
+
}
|
|
39
|
+
],
|
|
40
|
+
"type": "module",
|
|
41
|
+
"exports": {
|
|
42
|
+
".": "./src/index.ts"
|
|
43
|
+
},
|
|
44
|
+
"bin": {
|
|
45
|
+
"circuit": "./src/cli/index.ts"
|
|
46
|
+
},
|
|
47
|
+
"files": [
|
|
48
|
+
"src",
|
|
49
|
+
"LICENSE",
|
|
50
|
+
"README.md"
|
|
51
|
+
],
|
|
52
|
+
"engines": {
|
|
53
|
+
"bun": ">=1.3.5"
|
|
54
|
+
},
|
|
55
|
+
"publishConfig": {
|
|
56
|
+
"access": "public"
|
|
57
|
+
},
|
|
58
|
+
"scripts": {
|
|
59
|
+
"typecheck": "bunx tsc --noEmit",
|
|
60
|
+
"test": "bun test",
|
|
61
|
+
"format": "bunx prettier --write .",
|
|
62
|
+
"format:check": "bunx prettier --check .",
|
|
63
|
+
"docs:dev": "bun scripts/schema-reference.ts && vitepress dev docs",
|
|
64
|
+
"docs:build": "bun scripts/schema-reference.ts && vitepress build docs",
|
|
65
|
+
"docs:preview": "vitepress preview docs",
|
|
66
|
+
"release": "bun scripts/release.ts",
|
|
67
|
+
"prepare": "husky || true"
|
|
68
|
+
},
|
|
69
|
+
"dependencies": {
|
|
70
|
+
"@types/ssh2": "^1.15.6",
|
|
71
|
+
"ssh2": "^1.17.0"
|
|
72
|
+
},
|
|
73
|
+
"devDependencies": {
|
|
74
|
+
"@commitlint/cli": "^19.5.0",
|
|
75
|
+
"@commitlint/config-conventional": "^19.5.0",
|
|
76
|
+
"@types/bun": "^1.4.2",
|
|
77
|
+
"husky": "^9.1.6",
|
|
78
|
+
"prettier": "^3.9.9",
|
|
79
|
+
"typescript": "^7.0.2",
|
|
80
|
+
"vitepress": "^1.6.4"
|
|
81
|
+
},
|
|
82
|
+
"packageManager": "bun@1.3.5"
|
|
83
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Every model each platform supports. Types read from here, so a model or port name that does not exist fails to compile.
|
|
2
|
+
|
|
3
|
+
import { models as raisecom } from "./raisecom-ros/models";
|
|
4
|
+
import { models as routeros } from "./routeros/models";
|
|
5
|
+
import { models as vrp } from "./vrp/models";
|
|
6
|
+
|
|
7
|
+
export const catalog = { routeros, vrp, "raisecom-ros": raisecom } as const;
|
|
8
|
+
|
|
9
|
+
export type Catalog = typeof catalog;
|
|
10
|
+
export type PlatformName = keyof Catalog;
|
|
11
|
+
export type ModelName<Pl extends PlatformName> = keyof Catalog[Pl] & string;
|
|
12
|
+
|
|
13
|
+
type Upto<N extends number, A extends unknown[] = [], R = never> = A["length"] extends N
|
|
14
|
+
? R
|
|
15
|
+
: Upto<N, [...A, 0], R | `${[...A, 0]["length"]}`>;
|
|
16
|
+
type PortsOf<Pl extends PlatformName, M extends ModelName<Pl>> = Catalog[Pl][M] extends { ports: infer P } ? P : never;
|
|
17
|
+
|
|
18
|
+
/** `10g-1` to `10g-48` on a model with 48 10G ports. */
|
|
19
|
+
export type PortName<Pl extends PlatformName, M extends ModelName<Pl>> = {
|
|
20
|
+
[S in keyof PortsOf<Pl, M>]: `${S & string}-${Upto<PortsOf<Pl, M>[S] & number>}`;
|
|
21
|
+
}[keyof PortsOf<Pl, M>];
|
|
22
|
+
|
|
23
|
+
/** Every port a model has, in order. */
|
|
24
|
+
export function portsOf(platform: string, model: string): string[] {
|
|
25
|
+
const spec = (catalog as Record<string, Record<string, { ports: Record<string, number> }>>)[platform]?.[model];
|
|
26
|
+
if (!spec) throw new Error(`${platform} has no model ${model}`);
|
|
27
|
+
return Object.entries(spec.ports).flatMap(([speed, count]) => Array.from({ length: count }, (_, i) => `${speed}-${i + 1}`));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The platform's own name for a port. */
|
|
31
|
+
export function spell(platform: string, model: string, port: string): string {
|
|
32
|
+
const spec = (catalog as Record<string, Record<string, { spelling: Record<string, string> }>>)[platform]?.[model];
|
|
33
|
+
const parts = /^(\d+[gm])-(\d+)$/.exec(port);
|
|
34
|
+
const template = parts && spec?.spelling[parts[1]!];
|
|
35
|
+
if (!template) throw new Error(`${model} has no port ${port}`);
|
|
36
|
+
|
|
37
|
+
return template.replace("{n}", parts[2]!);
|
|
38
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Values more than one adapter derives from a device in the same way.
|
|
2
|
+
|
|
3
|
+
import { familyOf, network } from "../../core/addr";
|
|
4
|
+
import type { Device } from "../../schema";
|
|
5
|
+
|
|
6
|
+
/** Each LAG's number: its own `id`, or its position among the device's LAGs. */
|
|
7
|
+
export function lagIds(device: Device): Map<string, number> {
|
|
8
|
+
const numbers = new Map<string, number>();
|
|
9
|
+
let position = 0;
|
|
10
|
+
|
|
11
|
+
for (const [name, iface] of Object.entries(device.interfaces ?? {})) {
|
|
12
|
+
if (iface.type !== "lag") continue;
|
|
13
|
+
|
|
14
|
+
position++;
|
|
15
|
+
numbers.set(name, iface.id ?? position);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return numbers;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The IPv4 networks of a port's or an interface's addresses. */
|
|
22
|
+
export function ipv4Networks(device: Device, iface: string): string[] {
|
|
23
|
+
const settings = device.interfaces?.[iface] ?? device.ports?.[iface as keyof typeof device.ports];
|
|
24
|
+
const addresses = (settings?.addresses ?? []).map((entry) => (typeof entry === "string" ? entry : entry.address));
|
|
25
|
+
|
|
26
|
+
return addresses.filter((address) => familyOf(address) === "ipv4").map((address) => network(address));
|
|
27
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { raisecom } from "./raisecom-ros";
|
|
2
|
+
import { routeros } from "./routeros";
|
|
3
|
+
import type { DeviceAdapter } from "./types";
|
|
4
|
+
import { vrp } from "./vrp";
|
|
5
|
+
|
|
6
|
+
/** Every platform the framework can render and apply, by name. */
|
|
7
|
+
export const adapters: Record<string, DeviceAdapter> = { routeros, vrp, "raisecom-ros": raisecom };
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
// Convergence for platforms whose configuration is a list of lines in nested blocks, such as `interface X` holding its settings. A line the render does not hold is undone, so the device ends up holding exactly the render.
|
|
2
|
+
|
|
3
|
+
import type { Plan, Step } from "./types";
|
|
4
|
+
|
|
5
|
+
export type Dialect = {
|
|
6
|
+
/** A line at the margin that opens a block, for platforms that do not indent a block's lines. */
|
|
7
|
+
block: RegExp;
|
|
8
|
+
/** Whether a block's lines are indented under it. When they are, any line followed by deeper lines opens a block. */
|
|
9
|
+
indented: boolean;
|
|
10
|
+
/** A line at the margin that closes every open block. */
|
|
11
|
+
terminator: string;
|
|
12
|
+
/** Lines that describe the device rather than configure it, or that the platform prints whatever is set. */
|
|
13
|
+
ignore: (line: string) => boolean;
|
|
14
|
+
/** The command that removes a line. */
|
|
15
|
+
undo: (line: string) => string;
|
|
16
|
+
/** Leaves one level of block. */
|
|
17
|
+
leave: string;
|
|
18
|
+
/** Lines sent before the undos of their block, because the platform refuses an undo until they are in place. */
|
|
19
|
+
first?: RegExp;
|
|
20
|
+
/** A line in the form both sides compare in, for example a secret the device prints as a cipher. */
|
|
21
|
+
canon?: (line: string) => string;
|
|
22
|
+
/** Where a block is sent among the others, lowest first, so that nothing is referenced before it exists. In the order they differ when absent. */
|
|
23
|
+
order?: (path: string[]) => number;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/** One line, with the blocks it sits in, outermost first. */
|
|
27
|
+
export type Entry = { path: string[]; line: string };
|
|
28
|
+
|
|
29
|
+
/** How deep a line is indented. */
|
|
30
|
+
const indentOf = (raw: string) => raw.length - raw.trimStart().length;
|
|
31
|
+
|
|
32
|
+
export function entries(text: string, dialect: Dialect): Entry[] {
|
|
33
|
+
const lines = text
|
|
34
|
+
.replace(/\r/g, "")
|
|
35
|
+
.split("\n")
|
|
36
|
+
.filter((raw) => raw.trim() !== "");
|
|
37
|
+
const result: Entry[] = [];
|
|
38
|
+
const open: { indent: number; header: string }[] = [];
|
|
39
|
+
|
|
40
|
+
for (let i = 0; i < lines.length; i++) {
|
|
41
|
+
const raw = lines[i]!;
|
|
42
|
+
const indent = indentOf(raw);
|
|
43
|
+
const line = raw.trim().replace(/\s+/g, " ");
|
|
44
|
+
|
|
45
|
+
if (line === dialect.terminator) {
|
|
46
|
+
if (indent === 0) open.length = 0;
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (dialect.ignore(line)) continue;
|
|
51
|
+
|
|
52
|
+
if (dialect.indented) {
|
|
53
|
+
while (open.length && open.at(-1)!.indent >= indent) open.pop();
|
|
54
|
+
} else if (indent === 0 && dialect.block.test(line)) {
|
|
55
|
+
open.length = 0;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
result.push({ path: open.map((block) => block.header), line });
|
|
59
|
+
|
|
60
|
+
const isContent = (candidate: string) => candidate.trim() !== dialect.terminator && !dialect.ignore(candidate.trim());
|
|
61
|
+
const nextLine = lines.slice(i + 1).find(isContent);
|
|
62
|
+
|
|
63
|
+
const opensBlock = dialect.indented
|
|
64
|
+
? nextLine !== undefined && indentOf(nextLine) > indent
|
|
65
|
+
: indent === 0 && dialect.block.test(line);
|
|
66
|
+
|
|
67
|
+
if (opensBlock) open.push({ indent, header: line });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return result;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The part of a line that names what it sets: `rule 5`, a free-text keyword, or the line up to its value. Two lines with one head are one setting with two values. */
|
|
74
|
+
export function head(line: string): string {
|
|
75
|
+
const rule = /^rule \d+/.exec(line);
|
|
76
|
+
if (rule) return rule[0];
|
|
77
|
+
|
|
78
|
+
if (/^(description|name) /.test(line)) return line.split(" ")[0]!;
|
|
79
|
+
|
|
80
|
+
const words = line.split(" ");
|
|
81
|
+
return words.slice(0, Math.max(1, words.length - 1)).join(" ");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** What changes inside one block. */
|
|
85
|
+
export type Group = { path: string[]; remove: string[]; add: string[] };
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* What differs, by block.
|
|
89
|
+
*
|
|
90
|
+
* A device line replaced by a desired line with the same head is not undone; sending the new one is enough. A block the render does not hold goes with its header, and its lines with it.
|
|
91
|
+
*/
|
|
92
|
+
export function compare(desired: string, current: string, dialect: Dialect): Group[] {
|
|
93
|
+
const canon = dialect.canon ?? ((line: string) => line);
|
|
94
|
+
const keyOf = (entry: Entry) => [...entry.path, canon(entry.line)].join("\u0000");
|
|
95
|
+
const pathKey = (path: string[]) => path.join("\u0000");
|
|
96
|
+
|
|
97
|
+
const wanted = entries(desired, dialect);
|
|
98
|
+
const existing = entries(current, dialect);
|
|
99
|
+
const wantedKeys = new Set(wanted.map(keyOf));
|
|
100
|
+
const existingKeys = new Set(existing.map(keyOf));
|
|
101
|
+
const wantedBlocks = new Set(wanted.map((entry) => pathKey([...entry.path, entry.line])));
|
|
102
|
+
|
|
103
|
+
const groups = new Map<string, Group>();
|
|
104
|
+
const groupFor = (path: string[]) => {
|
|
105
|
+
const key = pathKey(path);
|
|
106
|
+
if (!groups.has(key)) groups.set(key, { path, remove: [], add: [] });
|
|
107
|
+
return groups.get(key)!;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
for (const entry of wanted) {
|
|
111
|
+
if (existingKeys.has(keyOf(entry))) continue;
|
|
112
|
+
|
|
113
|
+
// A header goes with its block's own step, never on its own.
|
|
114
|
+
const ownBlock = pathKey([...entry.path, entry.line]);
|
|
115
|
+
const opensBlock = wanted.some((other) => {
|
|
116
|
+
const deeper = other.path.length > entry.path.length;
|
|
117
|
+
return deeper && pathKey(other.path.slice(0, entry.path.length + 1)) === ownBlock;
|
|
118
|
+
});
|
|
119
|
+
if (opensBlock) continue;
|
|
120
|
+
|
|
121
|
+
groupFor(entry.path).add.push(entry.line);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Undone in reverse order, so a line is undone before the line it depends on.
|
|
125
|
+
for (const entry of [...existing].reverse()) {
|
|
126
|
+
if (wantedKeys.has(keyOf(entry))) continue;
|
|
127
|
+
|
|
128
|
+
// Inside a block the render does not hold: undoing the header removes it.
|
|
129
|
+
const insideRemovedBlock = entry.path.some((_, depth) => {
|
|
130
|
+
const enclosing = pathKey(entry.path.slice(0, depth + 1));
|
|
131
|
+
return !wantedBlocks.has(enclosing);
|
|
132
|
+
});
|
|
133
|
+
if (insideRemovedBlock) continue;
|
|
134
|
+
|
|
135
|
+
const replaced = wanted.some((other) => {
|
|
136
|
+
const sameBlock = pathKey(other.path) === pathKey(entry.path);
|
|
137
|
+
const isNew = !existingKeys.has(keyOf(other));
|
|
138
|
+
return sameBlock && isNew && head(other.line) === head(entry.line);
|
|
139
|
+
});
|
|
140
|
+
if (!replaced) groupFor(entry.path).remove.push(entry.line);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return [...groups.values()].filter((group) => group.add.length || group.remove.length);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Replace a changed VLAN list line with only what changed.
|
|
148
|
+
*
|
|
149
|
+
* A platform keeps its VLANs as one line, such as `vlan batch 10 20 to 30`. Undoing the old line would delete VLANs still in use, so the plan adds the new IDs and removes the gone ones instead.
|
|
150
|
+
*/
|
|
151
|
+
export function vlanListChange(
|
|
152
|
+
groups: Group[],
|
|
153
|
+
prefix: string,
|
|
154
|
+
parse: (line: string) => number[],
|
|
155
|
+
format: (ids: number[]) => string[],
|
|
156
|
+
removal: (ids: number[]) => string[]
|
|
157
|
+
): void {
|
|
158
|
+
const top = groups.find((group) => group.path.length === 0);
|
|
159
|
+
if (!top) return;
|
|
160
|
+
|
|
161
|
+
const old = top.remove.find((line) => line.startsWith(prefix));
|
|
162
|
+
const fresh = top.add.find((line) => line.startsWith(prefix));
|
|
163
|
+
if (!old && !fresh) return;
|
|
164
|
+
|
|
165
|
+
const before = old ? parse(old) : [];
|
|
166
|
+
const after = fresh ? parse(fresh) : [];
|
|
167
|
+
|
|
168
|
+
top.remove = top.remove.filter((line) => line !== old);
|
|
169
|
+
top.add = top.add.filter((line) => line !== fresh);
|
|
170
|
+
|
|
171
|
+
const added = after.filter((id) => !before.includes(id));
|
|
172
|
+
const removed = before.filter((id) => !after.includes(id));
|
|
173
|
+
|
|
174
|
+
// New VLANs exist before anything uses them; gone ones go after.
|
|
175
|
+
if (added.length) top.add.unshift(...format(added));
|
|
176
|
+
if (removed.length) top.remove.push(...removal(removed));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Commands a platform needs around one group, such as releasing a binding before a change and restoring it after. */
|
|
180
|
+
export type Around = (group: Group) => { before: string[]; after: string[] };
|
|
181
|
+
|
|
182
|
+
const nothingAround: Around = () => ({ before: [], after: [] });
|
|
183
|
+
|
|
184
|
+
/** The steps for the groups: enter each block of the path, undo, add, then leave each level. */
|
|
185
|
+
export function steps(groups: Group[], dialect: Dialect, around: Around = nothingAround): Plan["steps"] {
|
|
186
|
+
const rank = dialect.order ?? (() => 0);
|
|
187
|
+
const ordered = [...groups].sort((a, b) => rank(a.path) - rank(b.path));
|
|
188
|
+
|
|
189
|
+
return ordered.map((group): Step => {
|
|
190
|
+
const { before, after } = around(group);
|
|
191
|
+
|
|
192
|
+
const early = group.add.filter((line) => dialect.first?.test(line));
|
|
193
|
+
const late = group.add.filter((line) => !early.includes(line));
|
|
194
|
+
|
|
195
|
+
// A block header sent on its own, such as an empty `bfd`, enters its view; it is left at once so the next line is not sent into it.
|
|
196
|
+
const added = late.flatMap((line) => (dialect.block.test(line) ? [line, dialect.leave] : [line]));
|
|
197
|
+
|
|
198
|
+
const inner = [...early, ...group.remove.map(dialect.undo), ...added];
|
|
199
|
+
const leave = group.path.map(() => dialect.leave);
|
|
200
|
+
|
|
201
|
+
return {
|
|
202
|
+
title: group.path.join(" > ") || "(top level)",
|
|
203
|
+
show: [...group.remove.map((line) => `- ${line}`), ...group.add.map((line) => `+ ${line}`)],
|
|
204
|
+
send: [...before, ...group.path, ...inner, ...leave, ...after],
|
|
205
|
+
};
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const STORED_CREDENTIAL = /(password (?:irreversible-)?cipher|community read cipher|^snmp-server community)( +)\S+/gm;
|
|
210
|
+
|
|
211
|
+
/** A device prints stored credentials as ciphers. They never leave the read. */
|
|
212
|
+
export const scrub = (text: string) => text.replace(STORED_CREDENTIAL, "$1$2<redacted>");
|