@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.
Files changed (38) hide show
  1. package/LICENSE +106 -0
  2. package/README.md +195 -0
  3. package/package.json +83 -0
  4. package/src/adapters/devices/catalog.ts +38 -0
  5. package/src/adapters/devices/derive.ts +27 -0
  6. package/src/adapters/devices/index.ts +7 -0
  7. package/src/adapters/devices/lines.ts +212 -0
  8. package/src/adapters/devices/raisecom-ros/TRAPS.md +22 -0
  9. package/src/adapters/devices/raisecom-ros/index.ts +603 -0
  10. package/src/adapters/devices/raisecom-ros/models.ts +7 -0
  11. package/src/adapters/devices/routeros/TRAPS.md +33 -0
  12. package/src/adapters/devices/routeros/export.ts +319 -0
  13. package/src/adapters/devices/routeros/index.ts +261 -0
  14. package/src/adapters/devices/routeros/models.ts +8 -0
  15. package/src/adapters/devices/routeros/plan.ts +370 -0
  16. package/src/adapters/devices/routeros/render.ts +1431 -0
  17. package/src/adapters/devices/types.ts +31 -0
  18. package/src/adapters/devices/vrp/TRAPS.md +22 -0
  19. package/src/adapters/devices/vrp/index.ts +742 -0
  20. package/src/adapters/devices/vrp/models.ts +4 -0
  21. package/src/adapters/registries/index.ts +19 -0
  22. package/src/adapters/registries/peeringdb.ts +169 -0
  23. package/src/cli/index.ts +295 -0
  24. package/src/core/addr.ts +145 -0
  25. package/src/core/define.ts +94 -0
  26. package/src/core/secrets.ts +68 -0
  27. package/src/core/time.ts +15 -0
  28. package/src/core/validate.ts +380 -0
  29. package/src/index.ts +6 -0
  30. package/src/schema/common.ts +32 -0
  31. package/src/schema/device.ts +95 -0
  32. package/src/schema/firewall.ts +128 -0
  33. package/src/schema/index.ts +7 -0
  34. package/src/schema/interfaces.ts +192 -0
  35. package/src/schema/policy.ts +88 -0
  36. package/src/schema/routing.ts +120 -0
  37. package/src/schema/system.ts +191 -0
  38. 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>");