@tikoci/centrs 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/CHANGELOG.md +73 -2
  2. package/GLOSSARY.txt +16 -0
  3. package/README.md +1 -1
  4. package/commands/AGENTS.md +3 -2
  5. package/commands/api/AGENTS.md +212 -0
  6. package/commands/api/README.md +159 -0
  7. package/commands/api/examples.md +325 -0
  8. package/commands/btest/README.md +56 -31
  9. package/commands/btest/examples.md +35 -10
  10. package/commands/devices/README.md +26 -0
  11. package/commands/discover/README.md +17 -8
  12. package/commands/discover/examples.md +10 -5
  13. package/commands/execute/README.md +9 -3
  14. package/commands/retrieve/README.md +1 -1
  15. package/docs/CONSTITUTION.md +6 -6
  16. package/docs/MATRIX.md +46 -15
  17. package/docs/errors/routeros/btest-connection-count-single-stream.md +22 -0
  18. package/docs/errors/routeros/btest-udp-tx-size-ignored.md +15 -0
  19. package/docs/errors/usage/invalid-method.md +23 -0
  20. package/package.json +5 -5
  21. package/src/api.ts +1555 -0
  22. package/src/btest.ts +61 -1
  23. package/src/cli/api.ts +536 -0
  24. package/src/cli/btest.ts +45 -1
  25. package/src/cli/common.ts +64 -0
  26. package/src/cli/discover.ts +26 -0
  27. package/src/cli/execute.ts +30 -4
  28. package/src/cli/missing-target.ts +171 -0
  29. package/src/cli/retrieve.ts +32 -14
  30. package/src/cli/terminal.ts +18 -4
  31. package/src/cli/transfer.ts +37 -9
  32. package/src/cli.ts +22 -0
  33. package/src/core/error-catalog.ts +14 -0
  34. package/src/core/fanout.ts +160 -0
  35. package/src/core/inspect.ts +170 -0
  36. package/src/discover.ts +96 -23
  37. package/src/execute.ts +14 -47
  38. package/src/index.ts +61 -8
  39. package/src/protocols/adapter.ts +328 -0
  40. package/src/protocols/btest-session.ts +279 -46
  41. package/src/protocols/btest.ts +6 -2
  42. package/src/protocols/native-api.ts +223 -30
  43. package/src/retrieve-fanout.ts +13 -141
  44. package/src/retrieve.ts +30 -87
  45. package/commands/stream/README.md +0 -70
@@ -0,0 +1,325 @@
1
+ # api — examples
2
+
3
+ Each numbered example is an executable spec, asserted by CHR integration tests
4
+ (booted by `@tikoci/quickchr`): `test/integration/api.test.ts` (rest-api
5
+ examples), `test/integration/api-native.test.ts` (native-api examples, N…), and
6
+ `test/integration/api-listen.test.ts` (streaming examples, L…), with example N ↔
7
+ assertion N. If a line here is not exercised by a test, the test file is wrong; if
8
+ a line passes only with `--validate=false`, the implementation is wrong (see
9
+ [`docs/CONSTITUTION.md`](../../docs/CONSTITUTION.md)).
10
+
11
+ `$R` is `<host>:<rest-port>` resolved by quickchr. `$A` is `<host>` and
12
+ `$API_PORT` is the native API port (`chr.ports.api`). `$U` / `$P` are CHR
13
+ credentials provided by the harness. `$ID` is the `.id` returned by the preceding
14
+ add in the same transport section. All write-shaped examples pass `--yes`.
15
+
16
+ ## rest-api (`--via rest-api`, the default)
17
+
18
+ ### 1. GET a list
19
+
20
+ ```bash
21
+ centrs api $R ip/address --username $U --password $P
22
+ ```
23
+
24
+ Envelope: `ok: true`, `data` is an array of rest-style records,
25
+ `meta.via=rest-api`, `meta.validation.source` includes `/console/inspect request=child`.
26
+
27
+ ### 2. Endpoint normalization variants resolve to one path
28
+
29
+ ```bash
30
+ centrs api $R "/rest/ip/address" --username $U --password $P
31
+ centrs api $R "rest/ip/address" --username $U --password $P
32
+ centrs api $R "ip address" --username $U --password $P
33
+ ```
34
+
35
+ Each resolves to the same canonical path; `meta.request.path == "/ip/address"`
36
+ and the `data` array matches example 1.
37
+
38
+ ### 3. GET a singleton
39
+
40
+ ```bash
41
+ centrs api $R system/resource --username $U --password $P
42
+ ```
43
+
44
+ Envelope: `ok: true`, `data` is a single object (not an array) with `uptime`,
45
+ `version`, etc.
46
+
47
+ ### 4. PUT add (RouterOS create; documents the PUT≠POST trap)
48
+
49
+ ```bash
50
+ centrs api $R ip/address -X PUT -f address=198.51.100.10/32 -f interface=ether1 -f comment=centrs-api-rest --username $U --password $P --yes
51
+ ```
52
+
53
+ Envelope: `ok: true`, `data[".id"]` matches `/^\*[0-9A-F]+$/`, `meta.via=rest-api`.
54
+ A subsequent GET of `/ip/address` contains the new row.
55
+
56
+ ### 5. PATCH set by id-in-path
57
+
58
+ ```bash
59
+ centrs api $R ip/address/$ID -X PATCH -f comment=centrs-api-rest-set --username $U --password $P --yes
60
+ ```
61
+
62
+ Envelope: `ok: true`; re-reading `ip/address/$ID` shows the new comment.
63
+
64
+ ### 6. DELETE remove by id-in-path
65
+
66
+ ```bash
67
+ centrs api $R ip/address/$ID -X DELETE --username $U --password $P --yes
68
+ ```
69
+
70
+ Envelope: `ok: true`; a subsequent GET of `/ip/address` does not contain `$ID`.
71
+
72
+ ### 7. Raw JSON body with `-d`
73
+
74
+ ```bash
75
+ centrs api $R ip/address -X PUT -d '{"address":"198.51.100.11/32","interface":"ether1"}' --username $U --password $P --yes
76
+ ```
77
+
78
+ Envelope: `ok: true`, `data[".id"]` present.
79
+
80
+ ### 8. Body from stdin with `--input -`
81
+
82
+ ```bash
83
+ echo '{"address":"198.51.100.12/32","interface":"ether1"}' | centrs api $R ip/address -X PUT --input - --username $U --password $P --yes
84
+ ```
85
+
86
+ Envelope: `ok: true`, `data[".id"]` present.
87
+
88
+ ### 9. Server-side filter with `--query`
89
+
90
+ ```bash
91
+ centrs api $R interface --query type=ether --username $U --password $P
92
+ ```
93
+
94
+ Envelope: `ok: true`, every returned record has `type=ether`; the request carried
95
+ a REST `.query` (not client-side filtering).
96
+
97
+ ### 10. Projection with `--proplist`
98
+
99
+ ```bash
100
+ centrs api $R ip/address --proplist address,interface --username $U --password $P
101
+ ```
102
+
103
+ Envelope: `ok: true`, each record has only the requested properties (plus `.id`).
104
+
105
+ ### 11. Validation rejects an unknown attribute before the write
106
+
107
+ ```bash
108
+ centrs api $R ip/address -X PUT -f address=198.51.100.13/32 -f interface=ether1 -f no-such-arg=x --username $U --password $P --yes
109
+ ```
110
+
111
+ Envelope: `ok: false`, `error.code=validation/unknown-attribute`,
112
+ `meta.validation.source=/console/inspect`, and no address is added.
113
+
114
+ ### 12. Validation rejects an unknown path
115
+
116
+ ```bash
117
+ centrs api $R ip/no-such-menu --username $U --password $P
118
+ ```
119
+
120
+ Envelope: `ok: false`, `error.code=validation/unknown-path`, no request issued.
121
+
122
+ ### 13. `--raw` success prints the bare RouterOS body
123
+
124
+ ```bash
125
+ centrs api $R ip/address --raw --username $U --password $P
126
+ ```
127
+
128
+ stdout is the bare RouterOS JSON array (no `meta`/`warnings`); validation is
129
+ skipped; exit code 0.
130
+
131
+ ### 14. `--raw` RouterOS error → stderr + nonzero exit
132
+
133
+ ```bash
134
+ centrs api $R ip/address -X PUT -f address=not-an-ip -f interface=ether1 --raw --username $U --password $P --yes
135
+ ```
136
+
137
+ stdout is empty; stderr carries the RouterOS error payload as JSON; exit code is
138
+ nonzero.
139
+
140
+ ### 15. Mutating + non-TTY + no `--yes` is refused
141
+
142
+ ```bash
143
+ centrs api $R ip/address -X PUT -f address=198.51.100.14/32 -f interface=ether1 --username $U --password $P </dev/null
144
+ ```
145
+
146
+ Envelope: `ok: false`, `error.code=usage/confirmation-required`, no request
147
+ issued, no address added. (Holds under `--raw` too.)
148
+
149
+ ### 16. POST run a console command via `/rest/execute`
150
+
151
+ ```bash
152
+ centrs api $R execute -X POST -f script=':put [/system/identity/get name]' --username $U --password $P --yes
153
+ ```
154
+
155
+ Envelope: `ok: true`, `data` is string-shaped and contains the CHR identity;
156
+ `meta.validation.semantic=not-applicable` (script, not a path).
157
+
158
+ ### 17. `--via rest-api --listen` is rejected
159
+
160
+ ```bash
161
+ centrs api $R ip/address --listen --via rest-api --username $U --password $P
162
+ ```
163
+
164
+ Envelope: `ok: false`, `error.code=transport/capability-unsupported` (REST cannot
165
+ follow); no silent open-ended poll.
166
+
167
+ ### 18. `--query` not-equal maps to a negated stack word
168
+
169
+ ```bash
170
+ centrs api $R interface --query type!=ether --username $U --password $P
171
+ ```
172
+
173
+ Envelope: `ok: true`, no returned record has `type=ether`; the request carried
174
+ REST `.query` words `["type=ether","#!"]` (eq then NOT-top).
175
+
176
+ ### 19. `--raw-query` expresses OR
177
+
178
+ ```bash
179
+ centrs api $R interface --raw-query type=ether --raw-query type=loopback --raw-query '#|' --username $U --password $P
180
+ ```
181
+
182
+ Envelope: `ok: true`, returns the union of `ether` and `loopback` interfaces; the
183
+ raw words are passed through verbatim as `.query` (REST) / `?` words (native).
184
+
185
+ ### 20. Bounded `duration=` command returns a `.section` array (not a stream)
186
+
187
+ ```bash
188
+ centrs api $R interface/monitor-traffic -X POST -f interface=ether1 -f duration=5s --username $U --password $P --yes
189
+ ```
190
+
191
+ Envelope: `ok: true`, `data` is an **array** of `.section`-keyed records (one per
192
+ ~second), `meta.via=rest-api`. This is an ordinary bounded call — no `--listen`,
193
+ no NDJSON. `monitor-traffic` is a command (not `print`/`get`), so it is a
194
+ `POST` and write-classed (needs `--yes`); centrs never relaxes that to read-only.
195
+ (REST bounds it at the 60 s cap; native has no cap.)
196
+
197
+ ### 21. GET one row by id together with `--proplist`
198
+
199
+ ```bash
200
+ centrs api $R ip/address/$ID --proplist address --username $U --password $P
201
+ ```
202
+
203
+ Envelope: `ok: true`, `data` is a **single object** (not an array) carrying only
204
+ the projected property `address` (RouterOS omits `.id` unless it is in the
205
+ proplist). Combining an id with `--query`/`--proplist` folds the id into the REST
206
+ `.query` (`.id=$ID`) and rides a `POST …/print`, then unwraps the one row —
207
+ matching the native `?.id=` read. (Plain id-only reads still use the `GET …/$ID`
208
+ URL form; see example 5's re-read.)
209
+
210
+ ## native-api (`--via native-api`)
211
+
212
+ The same contract over the binary API. Validation still runs through
213
+ `/console/inspect`, issued as native commands. Values are strings.
214
+
215
+ ### N1. GET a list
216
+
217
+ ```bash
218
+ centrs api $A interface -X GET --via native-api --port $API_PORT --username $U --password $P
219
+ ```
220
+
221
+ Envelope: `ok: true`, `data` is an array of rest-style records (string values),
222
+ `meta.via=native-api`.
223
+
224
+ ### N2. PUT add (returns `.id`)
225
+
226
+ ```bash
227
+ centrs api $A ip/address -X PUT -f address=198.51.100.20/32 -f interface=ether1 --via native-api --port $API_PORT --username $U --password $P --yes
228
+ ```
229
+
230
+ Envelope: `ok: true`, `data[".id"]` present.
231
+
232
+ ### N3. PATCH set by id (id → `=.id=` word)
233
+
234
+ ```bash
235
+ centrs api $A ip/address/$ID -X PATCH -f comment=centrs-api-native-set --via native-api --port $API_PORT --username $U --password $P --yes
236
+ ```
237
+
238
+ Envelope: `ok: true`; re-reading shows the new comment.
239
+
240
+ ### N4. DELETE remove by id (id → `=.id=` word)
241
+
242
+ ```bash
243
+ centrs api $A ip/address/$ID -X DELETE --via native-api --port $API_PORT --username $U --password $P --yes
244
+ ```
245
+
246
+ Envelope: `ok: true`; the row is gone.
247
+
248
+ ### N5. GET one by id (→ `print ?.id=`)
249
+
250
+ ```bash
251
+ centrs api $A ip/address/$ID2 -X GET --via native-api --port $API_PORT --username $U --password $P
252
+ ```
253
+
254
+ Envelope: `ok: true`, `data` is a single object whose `.id` is `$ID2`.
255
+
256
+ ### N6. Validation rejects an unknown attribute over native
257
+
258
+ ```bash
259
+ centrs api $A ip/address -X PUT -f address=198.51.100.21/32 -f interface=ether1 -f no-such-arg=x --via native-api --port $API_PORT --username $U --password $P --yes
260
+ ```
261
+
262
+ Envelope: `ok: false`, `error.code=validation/unknown-attribute`, no row added —
263
+ caught by the inspect gate, not a native `!trap`.
264
+
265
+ ### N7. `--raw` over native
266
+
267
+ ```bash
268
+ centrs api $A interface --raw --via native-api --port $API_PORT --username $U --password $P
269
+ ```
270
+
271
+ stdout is the bare rest-style array (string values); exit code 0.
272
+
273
+ ### N8. POST run a console command via native `/execute`
274
+
275
+ ```bash
276
+ centrs api $A execute -X POST -f script=':put [/system/identity/get name]' --via native-api --port $API_PORT --username $U --password $P --yes
277
+ ```
278
+
279
+ Envelope: `ok: true`, `data` contains the CHR identity.
280
+
281
+ ## listen / `--stream` (native-api only)
282
+
283
+ Open-ended follow. `--stream` is the primary flag; `--listen` is an accepted
284
+ alias and a trailing `/listen` endpoint segment infers it. Output is NDJSON: one
285
+ envelope per change frame, then a terminating summary envelope.
286
+
287
+ ### L1. `--stream` streams a change as an NDJSON frame, then a summary
288
+
289
+ ```bash
290
+ centrs api $A ip/address --stream --count 1 --via native-api --port $API_PORT --username $U --password $P
291
+ ```
292
+
293
+ While listening, the harness adds an address over REST. stdout is NDJSON: at least
294
+ one envelope frame for the new row (`meta.operation.stream.kind=frame`), then a
295
+ final summary envelope (`meta.operation.stream.kind=summary`,
296
+ `data.stopReason=count-reached`, `data.frames>=1`). Exit code 0 (stream started
297
+ cleanly).
298
+
299
+ ### L2. A deletion frame carries `.dead`
300
+
301
+ ```bash
302
+ centrs api $A ip/address --stream --duration 3s --via native-api --port $API_PORT --username $U --password $P
303
+ ```
304
+
305
+ The harness removes a pre-seeded address; an emitted frame's record carries
306
+ `.dead=true` (a minimal `{ ".id", ".dead" }` record, per the CHR-grounded
307
+ `commands/api/AGENTS.md`).
308
+
309
+ ### L3. `/listen` endpoint infers native + streaming
310
+
311
+ ```bash
312
+ centrs api $A "ip/address/listen" --count 1 --port $API_PORT --username $U --password $P
313
+ ```
314
+
315
+ No `--stream` / `--via` given; the `/listen` endpoint infers `--stream` and
316
+ `--via native-api` (`meta.via=native-api`). Same NDJSON shape as L1.
317
+
318
+ ### L4. Bounded `--duration` reports its stop reason
319
+
320
+ ```bash
321
+ centrs api $A ip/address --stream --duration 2s --via native-api --port $API_PORT --username $U --password $P
322
+ ```
323
+
324
+ With no change during the window, the stream ends after ~2 s with a summary
325
+ envelope whose `data.stopReason=duration-elapsed`. Exit code 0.
@@ -17,7 +17,10 @@ on the centrs **server**; `test/integration/btest-client.test.ts` runs the centr
17
17
  **client** against a real RouterOS `/tool/bandwidth-server` over a host→guest
18
18
  `tcp:2000` forward — unauthenticated TCP receive, an **EC-SRP5 client proof
19
19
  verified by RouterOS's own verifier**, and a wrong-password reject. TCP
20
- multi-connection (`connection-count > 1`) data fan-out is a follow-up. See
20
+ multi-connection (`connection-count > 1`) data fan-out is implemented for
21
+ unauthenticated tests (the client joins the negotiated extra connections; the join
22
+ format is grounded against RouterOS 7.23.1); authenticated sessions stay
23
+ single-stream. See
21
24
  `docs/MATRIX.md` ("Peer measurement (`btest`)") for the cell states — that is the
22
25
  only status surface. v1 scope: **both** modes (client + server), **EC-SRP5 +
23
26
  unauthenticated** auth, **TCP and UDP** tests. Legacy pre-6.43 double-MD5 is out of
@@ -85,7 +88,7 @@ centrs btest server [--authenticate[=false]] [--user U] [--password P] \
85
88
  | `--direction <receive\|transmit\|both>` | `direction`, default `receive` | `receive` = download (server→client), `transmit` = upload. |
86
89
  | `--duration <dur>` | `duration` | Bounds the run; reuses `parseDuration`. Omit for open-ended (Ctrl-C). |
87
90
  | `--interval <dur>` | `interval`, default `1s` | Report cadence (`20ms`..`5s`). One report frame per interval. |
88
- | `--connection-count <n>` | `connection-count` (1..255) | **TCP only.** Parallel TCP data connections. |
91
+ | `--connection-count <n>` | `connection-count` (1..255) | **TCP only.** Parallel TCP data connections. centrs opens the negotiated extra connections (join format grounded against RouterOS 7.23.1) so throughput aggregates across streams. **Authenticated (EC-SRP5) sessions stay single-stream** — the post-auth token is not captured — and emit a `routeros/btest-connection-count-single-stream` warning. |
89
92
  | `--local-udp-tx-size <n>` | `local-udp-tx-size` (28..64000) | **UDP only.** Client→server packet size. |
90
93
  | `--remote-udp-tx-size <n>` | `remote-udp-tx-size` (28..64000) | **UDP only.** Server→client packet size. |
91
94
  | `--local-tx-speed <bps>` | `local-tx-speed` | Cap on client→server rate (bits/sec). |
@@ -120,10 +123,20 @@ test, data then flows on the server's allocated UDP ports
120
123
  from sequence gaps. For a TCP test, the bulk data rides `connection-count` TCP
121
124
  connections and goodput is measured on the data stream (TCP/IP headers excluded,
122
125
  matching RouterOS accounting). For multi-connection TCP the server's OK response
123
- carries a 2-byte **session token**; the extra connections reconnect and present
124
- that token to join the same test (no re-auth). Throughput and CPU are exchanged
126
+ carries a 2-byte **session token**; the client opens `connection-count − 1`
127
+ additional connections, each presenting a 16-byte join `[token:u16 BE][0x02][0 …]`
128
+ (grounded against RouterOS 7.23.1, direction-independent) to join the same test
129
+ without re-auth, and drives them into the shared counters so throughput aggregates.
130
+ Throughput and CPU are exchanged
125
131
  once per `interval` as a 12-byte **status message** on the TCP channel
126
- (`[0x07][0x80|cpu][00 00][seq u32 LE][bytesReceived u32 LE]`).
132
+ (`[0x07][0x80|cpu][00 00][seq u32 LE][bytesReceived u32 LE]`). The receiver of a
133
+ transmit (server for `transmit`, both peers for `both`) sends this so the
134
+ transmitter paces from the peer's `bytesReceived`. For `direction=both` the
135
+ status frames are **interleaved into the bulk data stream** (no length framing),
136
+ so the client demuxes them out of its receive loop by their structural marker —
137
+ without that feedback the client's TX saturates the link and starves
138
+ server→client RX. RouterOS clears the CPU high bit the centrs client sets, so the
139
+ marker matches on the two reserved zero bytes, not the high bit.
127
140
 
128
141
  ### Authentication (EC-SRP5)
129
142
 
@@ -141,7 +154,7 @@ will not offer EC-SRP5 against an `authenticate=yes` server) maps to
141
154
 
142
155
  btest runs over time, so it emits **live records** during the run plus a final
143
156
  **summary**. v1 keeps the formats simple and **defers a broader JSON-streaming
144
- decision** — it does *not* adopt the `stream` command's NDJSON-stream-of-envelopes
157
+ decision** — it does *not* adopt `api --stream`'s NDJSON-stream-of-envelopes
145
158
  contract yet (see Open questions):
146
159
 
147
160
  - **`text`** (default) — human-readable live reports during the run
@@ -214,10 +227,11 @@ Decided with the user. Both cells have direct gated `CHR-passed` evidence:
214
227
  maps onto the guest bandwidth server on 2000 (the same SLIRP inbound path that
215
228
  already carries REST/SSH — no firewall change). The centrs client dials
216
229
  `127.0.0.1:<host port>` and proves **TCP receive**, unauth and **EC-SRP5** (its
217
- client proof verified by RouterOS's real server verifier), plus a wrong-password
218
- reject. **TCP only** — UDP would need the server's datagrams to traverse SLIRP
219
- back to the host, which this forward does not provide, so UDP client→server stays
220
- loopback/transitive.
230
+ client proof verified by RouterOS's real server verifier), a wrong-password
231
+ reject, **TCP multi-connection fan-out** (`connection-count=4`), and **UDP
232
+ receive/both**. The UDP server→client return needs no UDP forward: it rides the
233
+ guest→host SLIRP gateway (`10.0.2.2:clientUdpPort`, the same path the server cell
234
+ uses), which works because the client socket is left unconnected (#86, #88).
221
235
 
222
236
  The fast/deterministic backstop under both is **centrs client ↔ centrs server
223
237
  loopback** (`127.0.0.1`, injected sockets) covering the full client↔server matrix,
@@ -226,32 +240,43 @@ EC-SRP5 both roles, and the option-grammar rejects.
226
240
  while implementing the EC-SRP5 framing and the server-side verifier; it is **not**
227
241
  an integration test (it cannot run in CI) and **not** part of the long-term plan.
228
242
 
229
- **Honest grounding caveat:** both cells are now directly CHR-passed. The remaining
230
- transitive-only paths are **UDP client→CHR-server** (the SLIRP reverse-NAT path the
231
- TCP forward does not cover) and TCP **multi-connection** fan-out — both deferred,
232
- not yet gated.
243
+ **Honest grounding caveat:** both cells are now directly CHR-passed, **including
244
+ TCP multi-connection fan-out** — `test/integration/btest-client.test.ts` gates a
245
+ centrs client opening `connection-count=4` against a real RouterOS
246
+ `/tool/bandwidth-server`; RouterOS accepts all 3 secondary joins and data flows on
247
+ every connection (`activeConnections: 4`). The *throughput increase* multiple
248
+ streams give is a WAN/latency property and is not observable over the near-zero-
249
+ latency SLIRP loopback, so the gate asserts the fan-out, not a higher number (the
250
+ per-connection drive is asserted deterministically by the loopback unit test).
251
+ **UDP client receive/both is now CHR-gated too** — the server→client return rides
252
+ the guest→host SLIRP gateway, no UDP forward needed (#88). The remaining fan-out
253
+ edge is **authenticated** multi-connection (capturing the EC-SRP5 post-auth token),
254
+ still single-stream.
233
255
 
234
256
  ## Open questions
235
257
 
236
- - **JSON streaming is deliberately deferred.** btest does not adopt the `stream`
237
- command's NDJSON-stream-of-envelopes contract yet; v1 streams `text` / `csv` and
238
- returns a single summary envelope for `json` / `yaml`. Revisit a streaming-JSON
239
- shape — ideally shared with `stream` — once that contract is settled.
240
- - **UDP receive/both through SLIRP is unproven.** Before the integration suite
241
- relies on server→guest UDP, a **one-shot CHR smoke test** must confirm the path
242
- works — i.e. that the RouterOS client originates an initial UDP/NAT probe that
243
- opens the SLIRP reverse mapping. Until then the gated UDP coverage is
244
- **transmit** (guest→host); TCP already covers both directions.
245
- - **UDP client→CHR-server and TCP multi-connection are not yet gated.** The direct
246
- client→CHR-server gate (`test/integration/btest-client.test.ts`) is **TCP only**;
247
- a UDP variant needs the server's datagrams to traverse SLIRP back to the host
248
- (the reverse-NAT path), and `connection-count > 1` needs the parallel-stream
249
- fan-out. Both remain loopback/transitive until gated. `btest.exe` (wine) is the
250
- coding-time grounding peer, not CI.
258
+ - **JSON streaming is deliberately deferred.** btest does not adopt
259
+ `api --stream`'s NDJSON-stream-of-envelopes contract yet; v1 streams `text` /
260
+ `csv` and returns a single summary envelope for `json` / `yaml`. Revisit a
261
+ streaming-JSON shape — ideally shared with `api --stream` — once it has settled.
262
+ - **UDP client receive/both is gated (#88).** The client cell
263
+ (`test/integration/btest-client.test.ts`) lands real UDP server→client throughput:
264
+ the return rides the guest→host SLIRP gateway (`10.0.2.2:clientUdpPort`), needing
265
+ no UDP forward and no quickchr change — it works because the client socket is left
266
+ unconnected (#86).
267
+ - **Only the server-cell host→guest UDP direction stays unproven.** The server cell
268
+ gates UDP *transmit* (guest→host, which SLIRP NATs cleanly); the reverse (centrs
269
+ server → CHR client, host→guest) would need a UDP hostfwd for the data ports, so it
270
+ is not yet asserted. `btest.exe` (wine) is the coding-time grounding peer, not CI.
271
+ - **Authenticated multi-connection stays single-stream.** Fan-out negotiates the
272
+ session token from the unauthenticated OK; capturing the EC-SRP5 post-auth token
273
+ to fan out authenticated tests is a follow-up (#103). centrs warns
274
+ (`routeros/btest-connection-count-single-stream`) when the realized connection
275
+ count falls short of the request. The **server-side** secondary-accept is its own
276
+ follow-up (#100).
251
277
 
252
278
  ## Out of scope (v1)
253
279
 
254
- Legacy pre-6.43 double-MD5 auth; a UDP client→CHR-server gated test (TCP is gated;
255
- UDP needs SLIRP reverse-NAT); the ≥3-router "test-through" topology RouterOS
280
+ Legacy pre-6.43 double-MD5 auth; the ≥3-router "test-through" topology RouterOS
256
281
  recommends for measuring a *transit* device (centrs is an endpoint peer); TUI/proxy
257
282
  btest frontends (later, over the stable core).
@@ -15,17 +15,19 @@ matrix's honest grounding split (`docs/MATRIX.md`, "Peer measurement (`btest`)")
15
15
  loss / direction against the CHR's own `/tool/bandwidth-test` status output
16
16
  (CHR 7.23.1, `bun run test:integration`). Examples **4** (wrong-credentials) and
17
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.
18
+ path is covered by the loopback unit test, and example 5 is the **server-side**
19
+ secondary-accept (distinct from the now-gated **client** fan-out in example 11),
20
+ still a documented follow-up — so they are not part of the asserted run.
20
21
  - **Gated `CHR-passed` (client cell): centrs client → CHR bandwidth-server** —
21
22
  examples **6** (TCP receive) and **8** (EC-SRP5 client role + wrong-password
22
23
  reject) are *also* asserted directly against a real RouterOS
23
24
  `/tool/bandwidth-server`, over a host→guest `tcp:2000` forward
24
25
  (`test/integration/btest-client.test.ts`, CHR 7.23.1). This is what makes the
25
26
  **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).
27
+ RouterOS's own server verifier. Example **11** (TCP **multi-connection**
28
+ fan-out), a TCP `direction=both` #85 regression guard, and **UDP receive/both**
29
+ (#88 — the server→client return over the guest→host SLIRP gateway) are gated in
30
+ the same suite.
29
31
  - **Unit / loopback (client cell + codec)** — examples 6–10. centrs client ↔
30
32
  centrs server on `127.0.0.1` with injected sockets; the EC-SRP5 both-roles math
31
33
  and the pre-socket option-grammar rejects (`test/unit/btest.test.ts`). These are
@@ -113,12 +115,13 @@ contains a `transport/auth-failed` entry (the EC-SRP5 proof did not verify) and
113
115
  `data.sessions` is empty — no session opened, no throughput. The CHR side reports
114
116
  an authentication failure.
115
117
 
116
- ### 5. Multi-connection TCP transmit
118
+ ### 5. Multi-connection TCP transmit (server-side accept)
117
119
 
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.
120
+ > *Designed, not yet gated* — this is the **server-side** fan-out: centrs's
121
+ > `btest server` accepting parallel secondary joins from a client. The session
122
+ > token is negotiated but centrs's server does not yet serve secondary TCP joins.
123
+ > Distinct from the **client** fan-out (example 11), which **is** implemented and
124
+ > gated. Tracked as a follow-up (#100); not part of the asserted run.
122
125
 
123
126
  ```bash
124
127
  centrs btest server --authenticate=false --bind 0.0.0.0 --duration 20s --format json
@@ -188,3 +191,25 @@ centrs btest client $SRV --protocol udp --local-udp-tx-size 99999
188
191
  `ok: false`, `error.code = "validation/option"` (UDP size must be `28..64000`), no
189
192
  socket opened. The same gate rejects `connection-count` outside `1..255`,
190
193
  `interval` outside `20ms..5s`, and (server) `max-sessions` outside `1..1000`.
194
+
195
+ ## centrs client TCP multi-connection (gated; client cell)
196
+
197
+ ### 11. Multi-connection TCP receive fan-out
198
+
199
+ ```bash
200
+ centrs btest client <router> --protocol tcp --direction receive --connection-count 4 --duration 4s --format json
201
+ ```
202
+
203
+ Against a real RouterOS `/tool/bandwidth-server` (`authenticate=no`), centrs reads
204
+ the session token from the primary's OK and opens the 3 extra TCP data
205
+ connections, each presenting the grounded join `[token:u16 BE][0x02][0 …]`. The
206
+ summary is `ok: true` with `data.activeConnections: 4` and non-zero aggregate
207
+ throughput — real RouterOS accepted all 3 secondary joins and data flowed on every
208
+ connection (the per-connection drive is also asserted deterministically by the
209
+ loopback unit test). The *throughput increase* multiple streams provide is a
210
+ high-latency/WAN-link property and is **not** observable over the near-zero-latency
211
+ SLIRP loopback (a single TCP stream already saturates it), so the gate asserts the
212
+ fan-out itself, not a higher number. Gated in
213
+ `test/integration/btest-client.test.ts` (CHR 7.23.1). An **authenticated**
214
+ `--connection-count > 1` run instead stays single-stream (`activeConnections: 1`)
215
+ and carries a `routeros/btest-connection-count-single-stream` warning.
@@ -403,3 +403,29 @@ When an item ships with CHR examples, fold it into the matrix and delete the row
403
403
  same envelope as the CDB. Provenance source label: `dude`.
404
404
  - The `centrs check` command will reuse the resolver and add a network probe;
405
405
  `devices` itself stays RouterOS-IO-free.
406
+
407
+ ### Missing-target guidance (and the MCP/proxy follow-up)
408
+
409
+ When a command that takes a `<router>` is called without one, the **CLI** turns
410
+ the bare usage error into a registry-aware tip (shared helper
411
+ `src/cli/missing-target.ts`): it reads the CDB best-effort and emits either
412
+ `tip/select-target` (lists a few saved handles to pass — identity over target) or
413
+ `tip/no-devices` (→ `centrs discover --save`) when the registry is empty. The
414
+ read is unencrypted-only and the CDB **password never threads into the tip path**,
415
+ so an encrypted registry falls back to generic guidance.
416
+
417
+ This guidance is currently **CLI-shaped on purpose** and is *not* yet wired into
418
+ the MCP/proxy frontends — that is deferred spec/review work. Two boundaries
419
+ should shape it when it lands, because the registry's role differs per frontend:
420
+
421
+ - The `tip/*` envelope channel is shared-core, but the specific remediation
422
+ *strings* (`centrs devices list`, `centrs discover --save`) are CLI verbs and
423
+ must **not** be emitted verbatim by MCP/proxy. Each frontend phrases the
424
+ next-step in its own vocabulary.
425
+ - On **MCP**, targets come from the **CDB allowlist**, not free-text `<router>`
426
+ (constitution: MCP surface). An unregistered/empty target is
427
+ `cdb/target-not-registered`, and the `centrs://devices` resource already
428
+ enumerates the allowlisted targets — so the MCP equivalent of "what can I
429
+ pass?" is "read the `centrs://devices` resource / register a device first,"
430
+ not "run discover." The **proxy** fronts the same CDB and should mirror the MCP
431
+ framing, not the CLI's `discover --save` nudge.
@@ -6,10 +6,10 @@ Status: `CHR-passed` (see `docs/MATRIX.md`). The MNDP codec, TTL cache, UDP
6
6
  listener, and `discover --save` are implemented; the real-L2 receive/decode/save
7
7
  path is green against RouterOS CHR 7.23.1 via `test/integration/discover.test.ts`
8
8
  (socket-connect bridge), and the network-independent paths stay unit-tested. The
9
- matrix remains the only status surface. The `identity=`/`mac=` lookup-key writing and
10
- MAC-keyed de-dupe described below are the decided target; the current `--save`
11
- de-dupes on `target` and keeps identity/MAC in the inert detail — that gap moves
12
- with the `devices` lookup-key work.
9
+ matrix remains the only status surface. `--save` now writes the `identity=`/`mac=`
10
+ lookup keys and de-dupes on the MAC (both described below), so a discovered
11
+ device resolves by its advertised identity, IP, or MAC — not just the stored
12
+ `target`.
13
13
 
14
14
  ## Synopsis
15
15
 
@@ -66,7 +66,14 @@ tested without a router (crafted packet fixtures + a loopback socket).
66
66
  `--save` without `--timeout` uses the default 15s window.
67
67
 
68
68
  Bare `centrs discover` is **read-only** — it returns the envelope and never
69
- writes the (WinBox-shared) CDB; `--save` is the explicit opt-in. The registry
69
+ writes the (WinBox-shared) CDB; `--save` is the explicit opt-in. When it finds
70
+ neighbors but was not given `--save`, the envelope carries a `tip/discover-save`
71
+ pointer so the read-only run advertises how to persist the result. In the
72
+ human-facing text format on an interactive terminal (TTY) `discover` also prints
73
+ a one-line "listening…" progress note to **stderr** so the listen window does not
74
+ look like a hang; `--format json|yaml` and piped/redirected runs stay silent
75
+ (stderr is exempt from the lossless-stdout rule — see
76
+ [`docs/CONSTITUTION.md`](../../docs/CONSTITUTION.md), Result envelope). The registry
70
77
  surface `centrs devices discover` is the inverse: invoking `devices` means you
71
78
  intend to populate it, so **`--save` is implied** there, and it is the home for
72
79
  the "set default credentials so centrs starts useful" onboarding nudge. Both
@@ -108,9 +115,11 @@ writer" holds.
108
115
  globally unique — `identity` is *not* the de-dupe key, because factory-default
109
116
  devices all report `MikroTik`. A neighbor whose MAC already names a CDB entry
110
117
  (as the `target` of a `macTarget` record or as a `mac=` lookup key on any
111
- record) is **skipped, never overwritten** — hand-curated records win. Only
112
- genuinely new devices are added. Writes reuse the `devices` atomic write path
113
- (`addDevice`); the CDB is reloaded between writes so each add sees prior ones.
118
+ record) is **skipped, never overwritten** — hand-curated records win. A
119
+ neighbor whose IP `target` already names an entry is likewise skipped, so a
120
+ curated record is never clobbered. Only genuinely new devices are added. Writes
121
+ reuse the `devices` atomic write path (`addDevice`); the CDB is reloaded between
122
+ writes so each add sees prior ones.
114
123
  - **Encrypted CDBs:** `--save` against an encrypted CDB decrypts the file under
115
124
  the loaded password, appends new neighbors, and re-encrypts with a fresh salt
116
125
  before the atomic rename. The backup beside the CDB is the verbatim prior
@@ -63,9 +63,11 @@ centrs discover --save --cdb-file $CDB
63
63
  ```
64
64
 
65
65
  New neighbors are added through the `devices` atomic write path with
66
- `group=discovered`. Each saved comment contains the `source=mndp` kv token and
67
- free-form provenance (`identity:`, `board:`, `version:`, `mac:`, …).
68
- `meta.operation.saved` reports `{ group, added, skipped, records }`.
66
+ `group=discovered`. Each saved comment carries the `source=mndp` kv token, the
67
+ `identity=`/`mac=` **lookup keys** (so the device resolves by its advertised
68
+ identity, IP, or MAC), and a free-form parenthesized detail (`board:`,
69
+ `version:`, `interface:`, `software-id:`). `meta.operation.saved` reports
70
+ `{ group, added, skipped, records }`.
69
71
 
70
72
  ### 5. Save with a custom group
71
73
 
@@ -81,8 +83,11 @@ New entries get `group=lab` instead of `discovered`.
81
83
  centrs discover --save --cdb-file $CDB
82
84
  ```
83
85
 
84
- A neighbor whose target (IPv4, else MAC) already names a CDB entry is skipped,
85
- never overwritten. The skip is reported in
86
+ A neighbor whose **MAC** already names a CDB entry — as a `macTarget` record's
87
+ target or a `mac=` lookup key on any record (and likewise any record sharing the
88
+ neighbor's IP target) — is skipped, never overwritten. MAC is the de-dupe key
89
+ because it is globally unique and always advertised; `identity` is not, since
90
+ factory-default devices all report `MikroTik`. The skip is reported in
86
91
  `meta.operation.saved.records[].action = "skipped-existing"`; hand-curated
87
92
  records win.
88
93