@tikoci/centrs 0.1.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.
Files changed (182) hide show
  1. package/CHANGELOG.md +207 -0
  2. package/GLOSSARY.txt +420 -0
  3. package/README.md +196 -0
  4. package/SECURITY.md +30 -0
  5. package/commands/AGENTS.md +50 -0
  6. package/commands/btest/README.md +257 -0
  7. package/commands/btest/examples.md +190 -0
  8. package/commands/check/README.md +59 -0
  9. package/commands/devices/README.md +405 -0
  10. package/commands/devices/examples.md +488 -0
  11. package/commands/discover/README.md +170 -0
  12. package/commands/discover/examples.md +98 -0
  13. package/commands/execute/README.md +208 -0
  14. package/commands/execute/examples.md +353 -0
  15. package/commands/mcp/README.md +260 -0
  16. package/commands/mcp/examples.md +158 -0
  17. package/commands/retrieve/README.md +209 -0
  18. package/commands/retrieve/examples.md +306 -0
  19. package/commands/settings/README.md +69 -0
  20. package/commands/stream/README.md +70 -0
  21. package/commands/terminal/README.md +177 -0
  22. package/commands/terminal/examples.md +144 -0
  23. package/commands/transfer/README.md +315 -0
  24. package/commands/transfer/examples.md +368 -0
  25. package/docs/CONSTITUTION.md +417 -0
  26. package/docs/MATRIX.md +224 -0
  27. package/docs/errors/README.md +74 -0
  28. package/docs/errors/auth/failed.md +9 -0
  29. package/docs/errors/cdb/already-exists.md +9 -0
  30. package/docs/errors/cdb/created.md +9 -0
  31. package/docs/errors/cdb/decrypt-failed.md +9 -0
  32. package/docs/errors/cdb/empty-group.md +9 -0
  33. package/docs/errors/cdb/invalid-option.md +9 -0
  34. package/docs/errors/cdb/invalid-record.md +9 -0
  35. package/docs/errors/cdb/not-found-target.md +9 -0
  36. package/docs/errors/cdb/not-found.md +9 -0
  37. package/docs/errors/cdb/override-applied.md +9 -0
  38. package/docs/errors/cdb/parse-failed.md +9 -0
  39. package/docs/errors/cdb/password-not-needed.md +9 -0
  40. package/docs/errors/cdb/password-required.md +9 -0
  41. package/docs/errors/cdb/reserved-key.md +9 -0
  42. package/docs/errors/cdb/reserved-option.md +9 -0
  43. package/docs/errors/cdb/target-not-registered.md +9 -0
  44. package/docs/errors/cdb/unknown-field.md +9 -0
  45. package/docs/errors/cdb/unknown-option.md +9 -0
  46. package/docs/errors/cdb/write-not-permitted.md +9 -0
  47. package/docs/errors/discover/broadcast-unavailable.md +9 -0
  48. package/docs/errors/discover/failed.md +9 -0
  49. package/docs/errors/identity/ambiguous.md +9 -0
  50. package/docs/errors/identity/no-match.md +9 -0
  51. package/docs/errors/input/invalid-command.md +9 -0
  52. package/docs/errors/input/invalid-match.md +9 -0
  53. package/docs/errors/input/invalid-path.md +9 -0
  54. package/docs/errors/input/invalid-routeros-path.md +9 -0
  55. package/docs/errors/input/invalid-target.md +9 -0
  56. package/docs/errors/input/local-file-not-found.md +9 -0
  57. package/docs/errors/input/mac-address.md +9 -0
  58. package/docs/errors/input/max-results-exceeded.md +9 -0
  59. package/docs/errors/internal/devices-failed.md +9 -0
  60. package/docs/errors/internal/mcp-start.md +9 -0
  61. package/docs/errors/internal/mcp-tool.md +9 -0
  62. package/docs/errors/internal/native-api-length.md +9 -0
  63. package/docs/errors/internal/unhandled.md +9 -0
  64. package/docs/errors/internal/unreachable.md +9 -0
  65. package/docs/errors/mndp/encode-failed.md +9 -0
  66. package/docs/errors/mndp/listen-failed.md +9 -0
  67. package/docs/errors/mndp/malformed.md +9 -0
  68. package/docs/errors/routeros/api-fatal.md +9 -0
  69. package/docs/errors/routeros/api-protocol.md +9 -0
  70. package/docs/errors/routeros/api-trap.md +9 -0
  71. package/docs/errors/routeros/btest-protocol.md +12 -0
  72. package/docs/errors/routeros/btest-too-many-sessions.md +9 -0
  73. package/docs/errors/routeros/command-failed.md +14 -0
  74. package/docs/errors/routeros/ec-srp5-protocol.md +11 -0
  75. package/docs/errors/routeros/error.md +9 -0
  76. package/docs/errors/routeros/invalid-value.md +14 -0
  77. package/docs/errors/routeros/mac-telnet-error.md +9 -0
  78. package/docs/errors/routeros/mac-telnet-not-ready.md +9 -0
  79. package/docs/errors/routeros/mac-telnet-protocol.md +9 -0
  80. package/docs/errors/routeros/mac-telnet-unsupported-auth.md +9 -0
  81. package/docs/errors/routeros/protocol-not-implemented.md +9 -0
  82. package/docs/errors/routeros/request-failed.md +9 -0
  83. package/docs/errors/routeros/session-closed.md +13 -0
  84. package/docs/errors/routeros/unknown-attribute.md +13 -0
  85. package/docs/errors/routeros/unknown-path.md +15 -0
  86. package/docs/errors/routeros/unsupported-capability.md +9 -0
  87. package/docs/errors/settings/invalid-boolean.md +9 -0
  88. package/docs/errors/settings/invalid-format.md +9 -0
  89. package/docs/errors/settings/invalid-integer.md +9 -0
  90. package/docs/errors/settings/invalid-timeout.md +9 -0
  91. package/docs/errors/settings/invalid-via.md +9 -0
  92. package/docs/errors/settings/unsafe-protocol-blocked.md +9 -0
  93. package/docs/errors/target/mac-not-in-arp.md +14 -0
  94. package/docs/errors/target/mac-required.md +23 -0
  95. package/docs/errors/target/mac-unresolved.md +17 -0
  96. package/docs/errors/target/unresolved.md +9 -0
  97. package/docs/errors/transport/auth-failed.md +9 -0
  98. package/docs/errors/transport/auto-method.md +9 -0
  99. package/docs/errors/transport/capability-unsupported.md +22 -0
  100. package/docs/errors/transport/checksum-unavailable.md +9 -0
  101. package/docs/errors/transport/connection-closed.md +9 -0
  102. package/docs/errors/transport/connection-refused.md +9 -0
  103. package/docs/errors/transport/dns.md +9 -0
  104. package/docs/errors/transport/host-key-mismatch.md +9 -0
  105. package/docs/errors/transport/incomplete-transfer.md +9 -0
  106. package/docs/errors/transport/insecure-trust.md +9 -0
  107. package/docs/errors/transport/local-tool-missing.md +11 -0
  108. package/docs/errors/transport/network.md +9 -0
  109. package/docs/errors/transport/timeout.md +9 -0
  110. package/docs/errors/transport/tls-certificate.md +9 -0
  111. package/docs/errors/transport/unreachable.md +9 -0
  112. package/docs/errors/transport/unsupported-operation.md +9 -0
  113. package/docs/errors/usage/confirmation-required.md +9 -0
  114. package/docs/errors/usage/conflicting-flags.md +9 -0
  115. package/docs/errors/usage/invalid-concurrency.md +9 -0
  116. package/docs/errors/usage/missing-group.md +9 -0
  117. package/docs/errors/usage/not-implemented.md +9 -0
  118. package/docs/errors/usage/target-exists.md +9 -0
  119. package/docs/errors/usage/timeout-out-of-range.md +9 -0
  120. package/docs/errors/validation/not-implemented.md +9 -0
  121. package/docs/errors/validation/option.md +9 -0
  122. package/docs/errors/validation/syntax.md +9 -0
  123. package/docs/errors/validation/unknown-attribute.md +9 -0
  124. package/docs/errors/validation/unknown-path.md +9 -0
  125. package/package.json +96 -0
  126. package/src/AGENTS.md +7 -0
  127. package/src/btest.ts +906 -0
  128. package/src/cli/btest.ts +455 -0
  129. package/src/cli/common.ts +53 -0
  130. package/src/cli/devices.ts +527 -0
  131. package/src/cli/discover.ts +186 -0
  132. package/src/cli/execute.ts +333 -0
  133. package/src/cli/mcp.ts +109 -0
  134. package/src/cli/retrieve.ts +383 -0
  135. package/src/cli/terminal.ts +303 -0
  136. package/src/cli/transfer.ts +471 -0
  137. package/src/cli.ts +115 -0
  138. package/src/core/envelope.ts +221 -0
  139. package/src/core/error-catalog.ts +469 -0
  140. package/src/core/routeros-errors.ts +268 -0
  141. package/src/data/AGENTS.md +11 -0
  142. package/src/data/dude-db.ts +0 -0
  143. package/src/data/mndp-cache.ts +132 -0
  144. package/src/data/mndp.ts +405 -0
  145. package/src/data/winbox-cdb-write.ts +200 -0
  146. package/src/data/winbox-cdb.ts +913 -0
  147. package/src/devices-schema.ts +107 -0
  148. package/src/devices.ts +1717 -0
  149. package/src/discover.ts +802 -0
  150. package/src/errors.ts +189 -0
  151. package/src/execute.ts +1350 -0
  152. package/src/index.ts +353 -0
  153. package/src/mcp/config.ts +66 -0
  154. package/src/mcp/resources.ts +98 -0
  155. package/src/mcp/safety.ts +119 -0
  156. package/src/mcp/server.ts +225 -0
  157. package/src/mcp/tools.ts +657 -0
  158. package/src/mcp.ts +33 -0
  159. package/src/protocols/AGENTS.md +14 -0
  160. package/src/protocols/adapter.ts +866 -0
  161. package/src/protocols/btest-session.ts +1493 -0
  162. package/src/protocols/btest.ts +447 -0
  163. package/src/protocols/ec-srp5.ts +466 -0
  164. package/src/protocols/index.ts +118 -0
  165. package/src/protocols/mac-telnet-console.ts +655 -0
  166. package/src/protocols/mac-telnet.ts +1299 -0
  167. package/src/protocols/mtwei.ts +47 -0
  168. package/src/protocols/native-api.ts +796 -0
  169. package/src/protocols/sftp.ts +427 -0
  170. package/src/protocols/ssh.ts +297 -0
  171. package/src/resolver/cdb.ts +580 -0
  172. package/src/resolver/comment-kv.ts +360 -0
  173. package/src/resolver/index.ts +74 -0
  174. package/src/resolver/mac.ts +227 -0
  175. package/src/resolver/settings.ts +228 -0
  176. package/src/resolver/target.ts +442 -0
  177. package/src/retrieve-fanout.ts +533 -0
  178. package/src/retrieve.ts +1215 -0
  179. package/src/terminal.ts +444 -0
  180. package/src/transfer.ts +1554 -0
  181. package/src/tui.ts +22 -0
  182. package/src/webproxy.ts +16 -0
@@ -0,0 +1,190 @@
1
+ # btest — examples
2
+
3
+ Each numbered example is an executable spec. btest is a peer protocol (no router
4
+ *command* is run), so the examples are validated by two peers — matching the
5
+ matrix's honest grounding split (`docs/MATRIX.md`, "Peer measurement (`btest`)"):
6
+
7
+ - **Gated `CHR-passed` (server cell): CHR client → centrs server** — examples
8
+ **1–3** are the asserted set. `@tikoci/quickchr` boots a CHR; its
9
+ `/tool/bandwidth-test` dials the host at the SLIRP gateway `$GW` (`10.0.2.2`)
10
+ where the centrs server listens on `--bind 0.0.0.0`. No hostfwd (guest→host).
11
+ Gated UDP coverage is **transmit** (guest-originated, so SLIRP NATs it cleanly);
12
+ UDP **receive/both** through SLIRP is a non-asserting smoke cycle (README, Open
13
+ questions). `test/integration/btest.test.ts` drives the CHR side with
14
+ centrs's already-`CHR-passed` `execute`/REST path and cross-checks throughput /
15
+ loss / direction against the CHR's own `/tool/bandwidth-test` status output
16
+ (CHR 7.23.1, `bun run test:integration`). Examples **4** (wrong-credentials) and
17
+ **5** (multi-connection TCP) are **designed but not yet gated** — the auth-reject
18
+ path is covered by the loopback unit test, and the multi-stream fan-out is a
19
+ documented follow-up — so they are not part of the asserted run.
20
+ - **Gated `CHR-passed` (client cell): centrs client → CHR bandwidth-server** —
21
+ examples **6** (TCP receive) and **8** (EC-SRP5 client role + wrong-password
22
+ reject) are *also* asserted directly against a real RouterOS
23
+ `/tool/bandwidth-server`, over a host→guest `tcp:2000` forward
24
+ (`test/integration/btest-client.test.ts`, CHR 7.23.1). This is what makes the
25
+ **client** cell `CHR-passed`: centrs's EC-SRP5 **client proof** is verified by
26
+ RouterOS's own server verifier. TCP only — the EC-SRP5 example is run over TCP
27
+ receive here (the loopback variant below uses UDP); UDP client→server stays
28
+ loopback (README, Open questions).
29
+ - **Unit / loopback (client cell + codec)** — examples 6–10. centrs client ↔
30
+ centrs server on `127.0.0.1` with injected sockets; the EC-SRP5 both-roles math
31
+ and the pre-socket option-grammar rejects (`test/unit/btest.test.ts`). These are
32
+ deterministic and need no router, and remain the fast backstop under the gated
33
+ client-cell coverage above.
34
+
35
+ `btest.exe` (via wine) is **not** in this example set: it cannot run in CI, so it
36
+ is a **coding-time grounding aid** only — run by hand against the centrs client and
37
+ server while developing the EC-SRP5 framing and server verifier — not a gated
38
+ example or integration test.
39
+
40
+ `$GW` is `10.0.2.2` (the host as seen from the CHR). `$U` / `$P` are the
41
+ credentials shared by the centrs server and the CHR client. `$SRV` is the centrs
42
+ server endpoint a client dials (the loopback server in unit tests). The real
43
+ `~/.config/tikoci/winbox.cdb` is never touched.
44
+
45
+ Output formats under test (see `README.md`, Output): `text` (default, live human
46
+ reports), `--csv` (streaming CSV records), and `--format json` (a single summary
47
+ envelope at completion, with `data.sessions[]` for the server / `data.reports[]`
48
+ for the client). The examples below assert on `json` (the structured summary) or
49
+ `csv` (the stream); `text` is the human render of the same data.
50
+
51
+ ## CHR client → centrs server (gated; server cell)
52
+
53
+ These examples show the **`centrs btest server`** command and the result it
54
+ produces; `test/integration/btest.test.ts` separately drives the CHR's
55
+ `/tool/bandwidth-test` against `$GW`.
56
+
57
+ ### 1. Unauthenticated TCP receive
58
+
59
+ ```bash
60
+ centrs btest server --authenticate=false --bind 0.0.0.0 --duration 20s --format json
61
+ ```
62
+
63
+ While the server runs, the CHR executes `/tool/bandwidth-test $GW protocol=tcp
64
+ direction=receive duration=10s`. At completion the **summary envelope** is
65
+ `ok: true`, `meta.via: "btest"`, with `data.sessions[0]` =
66
+ `{ client, protocol: "tcp", direction: "transmit", user: "" }` (the server
67
+ transmits on the client's `receive`) and a non-zero aggregate tx rate;
68
+ `data.stopReason: "duration-elapsed"`. The server's measured tx is within
69
+ tolerance of the CHR's reported `rx-total-average`.
70
+
71
+ ### 2. Unauthenticated UDP transmit (CSV stream)
72
+
73
+ ```bash
74
+ centrs btest server --authenticate=false --bind 0.0.0.0 --duration 20s --csv
75
+ ```
76
+
77
+ CHR runs `/tool/bandwidth-test $GW protocol=udp direction=transmit
78
+ local-udp-tx-size=1000 duration=10s` — client→server, which the guest originates,
79
+ so SLIRP NATs the UDP flow to the host cleanly. stdout is a CSV stream: a header
80
+ row then one record per session event, with at least one row carrying
81
+ `protocol=udp`, `direction=receive` (the server receives the client's transmit),
82
+ non-zero `rx_bps`, and a populated `lost_packets` column. UDP data rode the
83
+ allocated ports (`allocate-udp-ports-from` base). UDP **receive/both** (server→guest)
84
+ is gated behind the SLIRP smoke test and is not asserted here yet.
85
+
86
+ ### 3. EC-SRP5-authenticated TCP receive
87
+
88
+ ```bash
89
+ centrs btest server --authenticate --user $U --password $P --bind 0.0.0.0 --duration 20s --format json
90
+ ```
91
+
92
+ CHR runs `/tool/bandwidth-test $GW protocol=tcp direction=receive user=$U
93
+ password=$P duration=10s`. The EC-SRP5 **server role** verifies the client proof;
94
+ the summary `data.sessions[0]` carries `user: "$U"`, `protocol: "tcp"`,
95
+ `direction: "transmit"` (server transmits on the client's `receive`), `ok: true`,
96
+ and non-zero throughput. This is the example that exercises the net-new
97
+ server-side EC-SRP5 verifier against real RouterOS (TCP keeps the gated path off
98
+ the SLIRP UDP-reverse-NAT question).
99
+
100
+ ### 4. Wrong credentials are rejected
101
+
102
+ > *Designed, not yet gated* — the auth-reject path is asserted by the loopback
103
+ > unit test (example 8's mismatched-password case); this CHR variant is a
104
+ > documented follow-up.
105
+
106
+ ```bash
107
+ centrs btest server --authenticate --user $U --password $P --bind 0.0.0.0 --duration 20s --format json
108
+ ```
109
+
110
+ CHR runs `/tool/bandwidth-test $GW user=$U password=wrong …`. The server keeps
111
+ listening and finishes its run, so the summary is `ok: true` but `warnings[]`
112
+ contains a `transport/auth-failed` entry (the EC-SRP5 proof did not verify) and
113
+ `data.sessions` is empty — no session opened, no throughput. The CHR side reports
114
+ an authentication failure.
115
+
116
+ ### 5. Multi-connection TCP transmit
117
+
118
+ > *Designed, not yet gated* — the server's parallel-stream fan-out for
119
+ > `connection-count > 1` is not built yet (the session token is negotiated, but
120
+ > secondary TCP joins are not served). Documented follow-up; not part of the
121
+ > asserted run.
122
+
123
+ ```bash
124
+ centrs btest server --authenticate=false --bind 0.0.0.0 --duration 20s --format json
125
+ ```
126
+
127
+ CHR runs `/tool/bandwidth-test $GW protocol=tcp direction=transmit
128
+ connection-count=4 duration=10s`. The intended summary `data.sessions[0]` shows
129
+ `protocol: "tcp"`, `direction: "receive"` (server receives the client's
130
+ `transmit`) and `connectionCount: 4`; the server accepts the parallel TCP data
131
+ connections and the aggregate goodput is non-zero. Will confirm `connection-count`
132
+ is honored end to end once the fan-out lands.
133
+
134
+ ## centrs client ↔ centrs server loopback (unit; client cell + codec)
135
+
136
+ ### 6. Client TCP receive against a centrs server
137
+
138
+ ```bash
139
+ centrs btest client $SRV --protocol tcp --direction receive --duration 3s --format json
140
+ ```
141
+
142
+ With a centrs server on `$SRV` (`127.0.0.1:2000` in-test, `--authenticate=false`),
143
+ the summary envelope is `ok: true`, `meta.via: "btest"`, with `data.reports[]`
144
+ holding the per-`interval` samples (each carrying `rxBps` / `rxAvgBps`,
145
+ `direction: "receive"`) and aggregate `rxTotalAvgBps`; `data.stopReason:
146
+ "duration-elapsed"`.
147
+
148
+ ### 7. Client UDP both directions (CSV stream)
149
+
150
+ ```bash
151
+ centrs btest client $SRV --protocol udp --direction both --local-udp-tx-size 1000 --remote-udp-tx-size 1000 --duration 3s --csv
152
+ ```
153
+
154
+ stdout is a CSV stream: a header then one row per `--interval` report, each with
155
+ non-zero `tx_bps` and `rx_bps`, a `lost_packets` column, and non-zero per-interval
156
+ `tx_bytes`/`rx_bytes`. Proves the client drives the same codec the CHR validated in
157
+ examples 1–3, and exercises the streaming CSV path.
158
+
159
+ ### 8. EC-SRP5 handshake, client and server roles
160
+
161
+ ```bash
162
+ centrs btest client $SRV --protocol udp --direction receive --user $U --password $P --duration 3s --format json
163
+ ```
164
+
165
+ Against a centrs server started with `--authenticate --user $U --password $P`, the
166
+ client's EC-SRP5 proof verifies against the server's verifier: `ok: true`,
167
+ throughput non-zero. A run with a mismatched `--password` instead returns
168
+ `ok: false`, `error.code = "transport/auth-failed"`, and no throughput. Exercises
169
+ both EC-SRP5 roles in one process without a router.
170
+
171
+ ### 9. Option-grammar reject: `connection-count` with UDP
172
+
173
+ ```bash
174
+ centrs btest client $SRV --protocol udp --connection-count 4
175
+ ```
176
+
177
+ `ok: false`, `error.code = "validation/option"`,
178
+ `error.context.option = "connection-count"`, and **no socket is opened**.
179
+ `connection-count` applies only to `protocol=tcp`. This is the product claim: btest
180
+ validates its option grammar before touching the network, with no `:parse` step.
181
+
182
+ ### 10. Option-grammar reject: out-of-range UDP size
183
+
184
+ ```bash
185
+ centrs btest client $SRV --protocol udp --local-udp-tx-size 99999
186
+ ```
187
+
188
+ `ok: false`, `error.code = "validation/option"` (UDP size must be `28..64000`), no
189
+ socket opened. The same gate rejects `connection-count` outside `1..255`,
190
+ `interval` outside `20ms..5s`, and (server) `max-sessions` outside `1..1000`.
@@ -0,0 +1,59 @@
1
+ # check
2
+
3
+ Probe a router and report which management paths are usable.
4
+
5
+ Status: `not-started`. This file is a stub.
6
+
7
+ ## Intent
8
+
9
+ - TCP reachability per protocol (rest-api, native-api, ssh).
10
+ - Auth check on whichever protocols answered.
11
+ - Optional `--via` to limit the probe surface.
12
+ - L2 probing (mac-telnet reachability / ARP) is **opt-in, off by default** —
13
+ enabled by a MAC target, `--via mac-telnet`, or `--l2`. It needs L2 adjacency
14
+ and is slower, so IP-level probing (rest/native/ssh) stays the default.
15
+ - Output is the [standard envelope](../../docs/CONSTITUTION.md#result-envelope);
16
+ `data` is a per-protocol probe result map.
17
+ - `--fix` (opt-in, write-shaped): once `check` has authenticated over *one*
18
+ protocol, it reads the live device and writes back through the `devices` layer
19
+ (the only CDB writer), gated like any write (`--yes` / `confirm`). It refreshes
20
+ two things in one authenticated pass:
21
+ - **Connection metadata** — most usefully ports. Reached over MAC/mac-telnet,
22
+ it reads `/ip/service` to learn the real REST/native-API ports and writes
23
+ `port=` into the record's comment-kv so later calls connect directly.
24
+ - **Derived facts** — `board`, `version`, `software-id`, plus the `updated=`
25
+ stamp, from the same session (the same keys `devices add/set --check` and
26
+ `discover --save` populate). These stay queryable-but-stale facts that never
27
+ override a live read (see `commands/devices/README.md`, Derived facts).
28
+
29
+ `--fix` is **always explicit**, never implicit; plain `check` (no `--fix`) is a
30
+ pure read-only probe that writes nothing.
31
+
32
+ ## Default service ports (grounding)
33
+
34
+ centrs assumes RouterOS defaults when a port is not overridden (CDB `port=` /
35
+ `--port`). Default `/ip/service` ports: `ftp` 21, `ssh` 22, `telnet` 23,
36
+ `www` 80 (often moved, e.g. 7080), `www-ssl` 443, `winbox` 8291, `api` 8728,
37
+ `api-ssl` 8729. (native API rides `api`/`api-ssl`; REST rides `www`/`www-ssl`.)
38
+
39
+ Once `check` (or any command) has authenticated access over *one* protocol, it
40
+ can read the live service map to discover non-default ports for *other*
41
+ protocols — e.g. reach a box over SSH, then learn the REST port without the user
42
+ specifying it, and "upgrade" the connection:
43
+
44
+ ```routeros
45
+ :put [:serialize to=json options=json.pretty \
46
+ [ip/service/print detail as-value where !dynamic ]]
47
+ ```
48
+
49
+ ## Open questions
50
+
51
+ - `--fix` scope **decided** (above): connection metadata (ports) **plus** the
52
+ derived facts (`board`/`version`/`software-id`/`updated=`), refreshed in one
53
+ authenticated pass through the `devices` write layer; always explicit opt-in,
54
+ never implicit. Plain `check` stays a read-only probe that writes nothing.
55
+ - L2 probing opt-in **decided** (Intent above): off by default; enabled by a MAC
56
+ target, `--via mac-telnet`, or `--l2`.
57
+
58
+ Defer until `retrieve` is `CHR-passed`. `check` overlaps with discovery and is
59
+ easier to design once name resolution lands.
@@ -0,0 +1,405 @@
1
+ # devices
2
+
3
+ List, inspect, and edit RouterOS targets known to centrs. `devices` is the
4
+ user-facing surface over the device registry described in
5
+ `docs/CONSTITUTION.md`. It is the only command that may *write* to the CDB.
6
+
7
+ Status: `CHR-passed` for the surface described by `examples.md`. The read subset
8
+ (`list`/`show`/`groups`) and the CDB mutation surface (`add`/`edit`/`set`/
9
+ `remove`), ambiguity / `--match`, and the provenance/override examples are green
10
+ via `bun run test:integration` (`test/integration/devices.test.ts`,
11
+ fixture-backed — `devices` does no network IO, so its evidence is the
12
+ integration run, not a booted CHR). Encrypted-CDB **writes** round-trip through
13
+ decrypt → mutate → re-encrypt under the loaded password; encrypted **reads**
14
+ work with `--cdb-password`. See `docs/MATRIX.md` for the matrix row.
15
+
16
+ The redesign this README describes is now implemented: the `identity`/`mac`/`ip`
17
+ comment lookup keys and broadened `--match` selectors (`user=`/`target=`), the
18
+ `identity` field rename (was `name`), the symmetric `add`/`set` model (`edit`
19
+ reserved for a future interactive editor), the `(target, user)` record identity,
20
+ the `--profile-none`/`--profile-own` sentinels, the `__default__` fallback
21
+ record, and the top-level `tips[]` channel are all live (examples 10–16, 32–40;
22
+ `__default__` is resolver-level, unit-tested in `test/unit/resolver.test.ts`).
23
+ Treat this README as the spec, `docs/MATRIX.md` as the implemented status.
24
+
25
+ `devices` does not use a transport in the protocol sense and does not contact a
26
+ RouterOS device in phase 1. Its sources are:
27
+
28
+ - explicit CLI input,
29
+ - environment variables (`CENTRS_*`),
30
+ - the CDB at `~/.config/tikoci/winbox.cdb` (resolved under `$HOME`, not
31
+ `$XDG_CONFIG_HOME`; override with `--cdb-file` / `CENTRS_CDB_FILE`; if
32
+ encrypted, decrypt with `--cdb-password` / `CENTRS_CDB_PASSWORD`, or
33
+ `Bun.secret()` on the CLI when that lands),
34
+ - local ARP cache for MAC → IP resolution when explicitly enabled,
35
+ - (later) the MNDP cache and `dude.db` import via `tikoci/donny`.
36
+
37
+ ## Identity model
38
+
39
+ The CDB record's natural identity is the **(target, user)** pair, matching
40
+ WinBox: saving the same address under a different user creates a second record,
41
+ not an update. The `target` is the connectable literal WinBox/REST/native use,
42
+ and is one of:
43
+
44
+ - an IPv4 address (`192.0.2.5`, optionally `host:port`),
45
+ - an IPv6 address (`2001:db8::5`, optionally `[2001:db8::5]:port`),
46
+ - a MAC (`AA:BB:CC:DD:EE:FF`, case-insensitive, separators normalized),
47
+ - a DNS name (`edge1.lan`).
48
+
49
+ WinBox has no separate "name" field, so a human handle is overlaid in the
50
+ comment as **lookup keys** (constitution: identity and CDB). centrs resolves
51
+ `<router>` against the `target` field **and** these comment-kv keys:
52
+
53
+ | Lookup key | Meaning |
54
+ | ----------- | ---------------------------------------------------------------------- |
55
+ | `identity=` | The device `/system/identity`. Human handle; deliberately may repeat. |
56
+ | `mac=` | A MAC, when `target` is an IP/DNS name rather than the MAC. |
57
+ | `ip=` | An IP, when `target` is an identity/DNS name rather than the IP. |
58
+
59
+ Whichever identifiers are not the `target` ride the comment as lookup keys, so
60
+ one record is resolvable by identity **or** IP **or** MAC **or** DNS-name
61
+ regardless of which is stored as `target`. These three keys are the *only*
62
+ sanctioned exception to "the comment is free text"; every other token stays
63
+ inert. (Earlier drafts said "the comment is not a lookup key" — superseded.)
64
+
65
+ When `<router>` matches an entry (by `target` or a lookup key), credentials and
66
+ other CDB-resident fields load from that entry. If nothing matches, the call
67
+ still proceeds but `--username` / `--password` (or `CENTRS_USERNAME` /
68
+ `CENTRS_PASSWORD`, or a `__default__` record) become mandatory.
69
+
70
+ ### Ambiguity
71
+
72
+ Because identity is `(target, user)`, one address legitimately maps to several
73
+ records — the same host saved under two users, or `ipAdmin` + `ipUser` for one
74
+ host. That is not an error in itself; it only becomes ambiguous when `<router>`
75
+ alone cannot pick one (the caller named an address that several records share,
76
+ or a duplicated `identity=`):
77
+
78
+ - **TTY:** centrs prompts interactively, listing the matches and asking which
79
+ to use.
80
+ - **Non-TTY:** centrs errors with `identity/ambiguous`. The error envelope
81
+ lists all matching entries (by `cdbRecordIndex`, `target`, `user`, and
82
+ `recordType`). The caller pins the choice with `--match` by `user`
83
+ (`--match user=admin`), by record-type (`--match ipUser`), or by exact target.
84
+
85
+ `--prefer-family=ipv4|ipv6` is a non-interactive way to break v4/v6 ties
86
+ deterministically; with it set, no prompt is raised. It emits a warning
87
+ when it actually selected between candidates.
88
+
89
+ ## Comment kv-soup (per-device overrides)
90
+
91
+ The CDB `comment` field is free text. centrs additionally parses tokens
92
+ shaped like `key=value` out of it. Recognized keys (allowlist) fall in two
93
+ groups: **lookup keys** affect how `<router>` resolves (see "Identity model");
94
+ **override keys** are per-device defaults for centrs settings.
95
+
96
+ | Key | Group | Effect |
97
+ | ---------- | -------- | ---------------------------------------------------------------------- |
98
+ | `identity` | lookup | Device `/system/identity`; a resolvable handle. May repeat across records. |
99
+ | `mac` | lookup | A MAC, when `target` is not the MAC. |
100
+ | `ip` | lookup | An IP, when `target` is not the IP. |
101
+ | `via` | override | Default transport for this device (`rest-api`, `native-api`, `ssh`, …). |
102
+ | `validate` | override | `true` / `false`. `false` is the escape hatch; CLI overrides. |
103
+ | `timeout` | override | Default request timeout in ms. Per-transport caps still apply. |
104
+ | `port` | override | Non-default transport port. Omitted when equal to the protocol default. |
105
+ | `ssh-key` | override | Per-device private key file path for SSH transports. |
106
+ | `source` | override | Provenance marker for discovered/imported records (`mndp`, `dude`, …). |
107
+ | `mcp` | override | MCP write policy for this device: `ro` (default) or `rw`. See `commands/mcp/`. |
108
+
109
+ Rules:
110
+
111
+ - First-class CDB fields (`user`, `password`, `group`, `profile`, `session`)
112
+ must **not** appear in the kv-soup — they already have CDB tags. This has two
113
+ distinct outcomes by path: a reserved token found while *parsing* an existing
114
+ comment is inert and only warns `cdb/reserved-option` (centrs won't act on it,
115
+ but won't reject a hand-written comment); explicitly passing one as a `devices
116
+ set` positional (`user=x`) is a hard `cdb/reserved-key` error, because you are
117
+ asking centrs to *write* it — use the dedicated flag (`--user`) instead.
118
+ - `ssh-key` stores a path only, never private key material. Key contents belong
119
+ in the user's filesystem / SSH agent and are always treated as sensitive if an
120
+ error needs to mention them. It joins the comment-kv allowlist *with* the SSH
121
+ transport (see `commands/terminal/README.md`), not before — a key path with no
122
+ working SSH transport is the kind of half-feature we avoid.
123
+ - Unknown keys → `cdb/unknown-option` warning. The call still succeeds.
124
+ - Tokens are shell-word tokenized. Values with spaces require double quotes
125
+ (`"…"`), with `\"` and `\\` escapes. `=` inside a quoted value is literal.
126
+ - An **empty value removes** the token: `devices set <t> via=` deletes the `via`
127
+ override (matching the internal upsert/remove model in `comment-kv.ts`). There
128
+ is no current need to store a literally-empty value; revisit if one arises.
129
+ - `commentMirror` is kept in sync with `comment` on every write.
130
+
131
+ Precedence (lowest → highest): built-in defaults → project config →
132
+ comment-kv → env (`CENTRS_*`) → CLI flag / API arg. `meta.settings` reports
133
+ the winner and source per setting.
134
+
135
+ ### Derived facts (queryable, may be stale)
136
+
137
+ Beyond lookup keys and setting overrides, a record may carry **derived facts**
138
+ learned from a probe — `board`, `version`, `software-id` (from MNDP or `check`),
139
+ plus an `updated=<iso>` stamp marking when they were last refreshed. These are
140
+ *facts, not settings*: they are queryable/selectable (e.g. `--where board=RB5009`
141
+ to fan a command across a device class) but must **never override a live read**,
142
+ and they can go stale. `devices add`/`set --check` and `check --fix` refresh them
143
+ from a live device; `discover --save` seeds them. `software-id` is a
144
+ **verification** key
145
+ ("is this still the same box?"), not a resolution key (you don't type it to reach
146
+ a device); `license-id` is convenience metadata to match by hand against
147
+ MikroTik's CHR licensing page. The `--where` selector plus this derived-fact
148
+ allowlist are the planned home for "operate on a class of devices," distinct from
149
+ `group`. (Promoting these keys to the comment-kv allowlist is a tracked code
150
+ follow-up.)
151
+
152
+ ## Groups
153
+
154
+ Groups are CDB-native: each entry's `group` field is a single string. There
155
+ is no group-definition record. A group is the set of all entries whose
156
+ `group` value matches by exact, case-sensitive string compare. An entry with
157
+ `group=""` belongs to no group.
158
+
159
+ `--group <G>` on any command resolves to the membership set and fans out.
160
+ `discover --save` writes MNDP records with `group=discovered`
161
+ unless the caller supplies another group.
162
+
163
+ ### Promotion (discovered → managed)
164
+
165
+ There is no `promote` verb. Moving one device out of `group=discovered` and
166
+ giving it credentials is `devices set <t> --new-group prod --user … --password
167
+ …`. A **bulk** promotion (a whole discovered class at once) is the same call with
168
+ a target selector picking the records:
169
+
170
+ ```bash
171
+ devices set --group discovered --new-group prod \
172
+ --user admin --password … --force
173
+ ```
174
+
175
+ `--group` (and `--where`/`--all`/positionals) always **selects** which records to
176
+ modify; `--new-group` always **writes** the group field. The two are distinct
177
+ flags, so there is no select-vs-set overload — this resolves the former wrinkle.
178
+ (Code follow-up: the CLI still overloads `--group` as the `add`/`set` field
179
+ setter today; `examples.md` reflects that current flag until the `--new-group`
180
+ split lands.)
181
+
182
+ ## Fanout (multi-target invocations)
183
+
184
+ Any command may receive multiple positional targets, one or more `--group`
185
+ flags, `--all`, `--default`, and/or `--where <attr>=<value>` (device-class) — see
186
+ `docs/CONSTITUTION.md` (Target selection grammar). The resolved member set is
187
+ de-duplicated by CDB record index.
188
+
189
+ - Each member runs in parallel up to `--concurrency` (default
190
+ `max(1, floor(os.cpus().length / 2))`).
191
+ - `--fail-fast` aborts pending members on the first failure.
192
+ - The outer envelope's `data` is an array of inner envelopes, one per member,
193
+ in the resolved member order (CDB record index — see `devices` group
194
+ expansion), not completion order, so repeated runs produce stable diffs.
195
+ Members still execute in parallel and may *complete* out of order; progress
196
+ is surfaced via `meta`, but the `data` array is always reassembled in the
197
+ deterministic resolved order. `ok` of the outer envelope is `true` iff every
198
+ inner envelope is `ok: true`.
199
+ - Commands that are not safe to fan out (e.g. `terminal`) reject N > 1 with
200
+ `usage/fanout-not-supported`.
201
+
202
+ ## Subcommands
203
+
204
+ ```text
205
+ centrs devices list [--group G] [--format json|yaml|text]
206
+ centrs devices show <target> [--explain] [--via <protocol>] [--match <type>]
207
+ centrs devices groups [--members]
208
+ centrs devices discover [--timeout 15s] [--group G] # = discover --save (save implied)
209
+ centrs devices add <target> [--user U] [--password P] [--new-group G]
210
+ [--profile P|--profile-none|--profile-own]
211
+ [--session S] [--comment "text k=v"]
212
+ [--record-type ipAdmin|ipUser|macTarget|...]
213
+ centrs devices set <selector…> [--user U] [--password P] [--new-group G]
214
+ [--profile P|--profile-none|--profile-own]
215
+ [--session S] [k=v ...]
216
+ centrs devices remove <selector…>
217
+ centrs devices edit <target> # future: clack/TUI wizard
218
+ ```
219
+
220
+ `add` and `set` are deliberately symmetric: both take the same first-class
221
+ flags and the same `k=v` comment tokens. The only difference is existence —
222
+ `add` creates (and refuses an existing target), `set` modifies (and refuses a
223
+ missing target). `edit` is reserved for a future interactive wizard (the
224
+ clack-style prompt used by `@tikoci/quickchr`); it is **not** a separate
225
+ field-editing verb, and invoking it today returns `usage/not-implemented`. There
226
+ is no `update`.
227
+
228
+ - `list` (alias `print`) shows resolved targets, their record type, group, and
229
+ a one-line provenance summary. No network IO.
230
+ - `show` (alias `get`) returns a single resolved target with the full per-field
231
+ source map in `meta.target`. `--explain` adds the raw CDB record dump under
232
+ `data.record`; `--via <protocol>` reports the protocol that would be selected
233
+ for the target without connecting.
234
+ - `groups` lists distinct non-empty group strings with member counts.
235
+ `--members` expands to the full membership per group.
236
+ - `discover` runs MNDP discovery with `--save` **implied** (you invoked the
237
+ registry surface, so you mean to populate it) — equivalent to `centrs discover
238
+ --save` with the same `group=discovered` / `source=mndp` provenance. It is the
239
+ natural onboarding step and where centrs steers toward setting fallback
240
+ credentials (`__default__`). See `commands/discover/README.md`. Still routes
241
+ through the atomic write layer below, so `devices` remains the only writer.
242
+ - `add` creates a record from first-class flags + comment tokens. Against an
243
+ existing `(target, user)` it errors `cdb/already-exists` unless `--force`
244
+ overwrites; the same `target` under a *different* `--user` is a new record,
245
+ not a collision.
246
+ - `set` modifies existing record(s). First-class fields change via flags
247
+ (`--user`, `--password`, `--new-group`, `--profile[-none|-own]`, `--session`);
248
+ comment lookup/override keys change via `k=v` positionals. **Which** records to
249
+ modify is the target selector — positionals, `--group`, `--where`, `--all`
250
+ (constitution: target selection) — so `--group` here *selects* and
251
+ `--new-group` *writes* the group field (no overload). Against a missing target
252
+ it errors `cdb/not-found-target`; a multi-record `set` needs `--force`. Writing
253
+ a first-class field name as a `k=v` positional (e.g. `user=x`) stays refused
254
+ with `cdb/reserved-key` — the flag is the only path. Unknown comment keys warn
255
+ (`cdb/unknown-option`), or error under `--strict`.
256
+ - `remove` (aliases `rm`, `delete`) removes a single entry. Group-wide deletes
257
+ (`--group G`) require `--force` (writes are large).
258
+
259
+ `--profile <name>` sets a named WinBox profile; `--profile-none` /
260
+ `--profile-own` write the WinBox sentinels `<none>` / `<own>`. `profile` and
261
+ `session` are **preserved for WinBox compatibility only** — centrs round-trips
262
+ them and lets you edit them, but never acts on them itself. Unset, `profile`
263
+ defaults to `<none>` (or is inherited from the `__default__` record).
264
+
265
+ Grounding: `profile` is WinBox's "Workspace" (a saved window layout). `<none>`
266
+ means start with a clean/empty layout; `<own>` means reuse the last session's
267
+ layout *for that router* (per-router, not global). Both are meaningless to
268
+ centrs's headless use, which is exactly why they are preserve-only.
269
+
270
+ ## Default device (`__default__`)
271
+
272
+ A reserved record (`target=__default__`) holds global fallback creds + comment
273
+ overrides for any device whose own record leaves them unset. Create/edit it like
274
+ any other record (`devices add __default__ --user admin --password …`); WinBox
275
+ can edit it too. Precedence is per-field: args → env → device record →
276
+ `__default__` → built-in default (constitution: identity and CDB).
277
+
278
+ - `devices list` shows it; tag it `group=default` for clarity if you like.
279
+ - On the CLI/API it can supply creds even for a target with no CDB record. On
280
+ **MCP** it never widens the allowlist — an unregistered target is still
281
+ rejected; `__default__` only fills missing creds for a registered device.
282
+
283
+ ## CDB write strategy
284
+
285
+ All writes go through one helper (`writeWinBoxCdb`) that:
286
+
287
+ 1. Snapshots the current file to `winbox.cdb.bak.<timestamp>` beside it (the
288
+ timestamp is a filesystem-safe, lexicographically-sortable ISO variant).
289
+ 2. Keeps the last 5 backups, deletes older ones.
290
+ 3. Builds the new record set with unknown fields preserved verbatim (see
291
+ below).
292
+ 4. Writes to `winbox.cdb.tmp.<pid>.<rand>` in the same directory and `fsync`s
293
+ it (then best-effort `fsync`s the directory).
294
+ 5. `rename()`s over the original (atomic on POSIX), so a crash never leaves a
295
+ partially-written CDB.
296
+
297
+ Encrypted CDBs: the write layer decrypts on load, mutates the open record set,
298
+ then re-encrypts with the same password loaded from settings before the atomic
299
+ rename. A fresh salt is rolled on every write; the backup is the verbatim prior
300
+ ciphertext.
301
+
302
+ Refusals:
303
+
304
+ - Writing to an encrypted CDB without `--cdb-password` /
305
+ `CENTRS_CDB_PASSWORD` still fails earlier at load with
306
+ `cdb/password-required`.
307
+ - Writing to an unencrypted CDB with `--cdb-password` succeeds with the
308
+ existing `cdb/password-not-needed` warning. centrs does **not** silently
309
+ upgrade the file to encrypted.
310
+
311
+ ### Unknown CDB fields
312
+
313
+ The current decoder knows tags 1–4, 6, 8, 9, 11, 12 and a fixed set of tcodes.
314
+ WinBox writes additional fields (RoMON IPv6 addresses, future settings) that
315
+ must round-trip.
316
+
317
+ - On read: any field with an unknown tag is kept verbatim on
318
+ `WinBoxCdbRecord.fields`. A field with an unknown tcode is captured as a
319
+ `rawTail` field — its `value` is a `Uint8Array` carrying the remainder of
320
+ the record (its own bytes plus any subsequent fields) verbatim, since the
321
+ decoder cannot know the value length without the schema.
322
+ - On write: known fields are re-encoded normally. A `rawTail` field is
323
+ emitted as its header (tag, marker, tcode) followed by `value` as raw
324
+ bytes. This round-trips WinBox-authored records that contain unknown
325
+ tcodes byte-for-byte. `devices add/edit/set/remove` will emit a
326
+ `cdb/unknown-field` warning when it preserves a record that carries
327
+ `rawTail` fields, listing the affected tags.
328
+
329
+ ## Envelope shape (target provenance)
330
+
331
+ Every centrs envelope (not just `devices show`) carries:
332
+
333
+ ```ts
334
+ meta.target = {
335
+ target: "edge1.lan", // what the user typed, normalized
336
+ resolvedTarget: "edge1.lan", // what we connect to (post DNS, post --match)
337
+ addressFamily: "ipv4" | "ipv6" | "mac" | "dns",
338
+ mac?: "AA:BB:CC:DD:EE:FF",
339
+ user: "admin",
340
+ group?: "prod-edge",
341
+ via: "rest-api",
342
+ cdbRecordIndex?: 7, // index into CDB record array, if any
343
+ sources: {
344
+ user: "cdb",
345
+ password: "cdb",
346
+ via: "comment-kv",
347
+ validate: "cli",
348
+ timeout: "default",
349
+ // …per resolved setting
350
+ }
351
+ }
352
+ ```
353
+
354
+ For fanout calls, each inner envelope carries its own `meta.target`. The
355
+ outer envelope's `meta.target` is replaced with `meta.targets` (the resolved
356
+ member list and the group(s) that expanded into it).
357
+
358
+ ## Open questions (remaining work beyond the current `CHR-passed`)
359
+
360
+ `devices` is `CHR-passed` for the currently-implemented reads and writes against
361
+ both unencrypted and encrypted CDBs. The comment-kv *grammar* is settled — see
362
+ "Comment kv-soup" above and `test/unit/comment-kv.test.ts`. The decided redesign
363
+ (lookup keys, identity rename, symmetric `add`/`set`, `(target, user)` identity,
364
+ profile sentinels, `__default__`, `tips[]`) is now implemented; what remains:
365
+
366
+ **Still genuinely open:**
367
+
368
+ | Question | Affects | Notes |
369
+ | --- | --- | --- |
370
+ | ARP resolver test scheme | retrieve / execute when target is a MAC | Need deterministic fixtures per OS plus one live same-L2 proof before relying on ARP in integration. |
371
+
372
+ When an item ships with CHR examples, fold it into the matrix and delete the row.
373
+
374
+ ## Residual risks
375
+
376
+ - **Encrypted-CDB writes have not been verified against WinBox.** centrs
377
+ decrypts → mutates → re-encrypts with a fresh salt, and the bytes round-trip
378
+ through centrs' own decoder. The "open in WinBox after a centrs write"
379
+ manual proof still needs to happen; until then, treat encrypted writes as
380
+ centrs-only and keep the backup the write layer leaves behind.
381
+ - **Unknown-tcode preservation is opaque past the first unknown tcode.** The
382
+ decoder captures one `rawTail` blob; fields that follow the unknown tcode
383
+ inside the same record are kept inside that blob and cannot be surfaced
384
+ one-by-one. Acceptable for round-trip writes (the bytes survive); not
385
+ sufficient if a future cell needs to expose those individual fields.
386
+ - **MAC → IP without CDB.** Resolver order is CDB first, then local ARP when
387
+ the caller opts into IP-level access. For `execute`, an unresolved MAC
388
+ defaults to mac-telnet instead. MNDP arrives through `discover --save` and
389
+ records provenance under `group=discovered`; it is not authoritative
390
+ inventory.
391
+ - **Comment kv-soup collisions with human prose.** Users may have existing
392
+ comments that contain `=` for non-kv reasons. The parser must require a
393
+ bare-word key with no leading whitespace inside the token and treat
394
+ free-form text outside `key=value` tokens as inert.
395
+
396
+ ## Notes for future cells
397
+
398
+ - `devices` proper has no transport, but several flags on other commands
399
+ depend on its resolver (`--group`, `--match`, `--prefer-family`,
400
+ multi-target positionals). Those flags ship with their host command, not
401
+ with `devices`.
402
+ - `dude.db` import lives in `tikoci/donny` and feeds the resolver via the
403
+ same envelope as the CDB. Provenance source label: `dude`.
404
+ - The `centrs check` command will reuse the resolver and add a network probe;
405
+ `devices` itself stays RouterOS-IO-free.