@tikoci/centrs 0.1.8 → 0.1.9-next.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +216 -31
- package/GLOSSARY.txt +8 -0
- package/README.md +16 -8
- package/SECURITY.md +11 -3
- package/commands/api/README.md +44 -15
- package/commands/api/examples.md +44 -6
- package/commands/execute/README.md +218 -7
- package/commands/execute/examples.md +278 -1
- package/commands/explain/README.md +2 -1
- package/commands/mcp/README.md +11 -0
- package/commands/mcp/examples.md +6 -0
- package/commands/retrieve/README.md +32 -14
- package/commands/retrieve/examples.md +10 -6
- package/commands/transfer/examples.md +9 -0
- package/docs/CLI.md +10 -8
- package/docs/CONSTITUTION.md +54 -3
- package/docs/MATRIX.md +20 -3
- package/docs/errors/cdb/empty-group.md +19 -3
- package/docs/errors/cdb/empty-selection.md +9 -3
- package/docs/errors/cmr/caller-policy-not-enforced.md +46 -0
- package/docs/errors/cmr/cancelled.md +15 -0
- package/docs/errors/cmr/client-disconnected.md +19 -0
- package/docs/errors/cmr/client-script-not-validated.md +22 -0
- package/docs/errors/cmr/console-only.md +29 -0
- package/docs/errors/cmr/empty-selection.md +33 -0
- package/docs/errors/cmr/membership-changed.md +20 -0
- package/docs/errors/cmr/not-a-controller.md +26 -0
- package/docs/errors/cmr/outcome-unknown.md +29 -0
- package/docs/errors/cmr/output-truncated.md +16 -0
- package/docs/errors/cmr/policy-denied.md +31 -0
- package/docs/errors/cmr/rule-selects-every-device.md +32 -0
- package/docs/errors/cmr/selection-includes-controller.md +27 -0
- package/docs/errors/cmr/selection-incomplete.md +18 -0
- package/docs/errors/cmr/validation-incomplete.md +23 -0
- package/docs/errors/routeros/version-unsupported.md +4 -0
- package/docs/errors/transport/write-outcome-unknown.md +24 -0
- package/docs/errors/usage/flag-after-separator.md +34 -0
- package/docs/errors/usage/missing-labels.md +19 -0
- package/package.json +7 -5
- package/src/api-fanout.ts +18 -2
- package/src/api.ts +230 -71
- package/src/btest.ts +7 -2
- package/src/cli/api.ts +30 -16
- package/src/cli/common.ts +180 -1
- package/src/cli/devices.ts +0 -2
- package/src/cli/discover.ts +0 -1
- package/src/cli/execute.ts +176 -21
- package/src/cli/explain.ts +2 -0
- package/src/cli/mcp.ts +0 -1
- package/src/cli/retrieve.ts +40 -18
- package/src/cli/settings.ts +0 -3
- package/src/cli/terminal.ts +0 -2
- package/src/cli/transfer.ts +10 -8
- package/src/cli/version.ts +87 -0
- package/src/cli.ts +5 -3
- package/src/core/cmr-fleet.ts +486 -0
- package/src/core/cmr.ts +182 -0
- package/src/core/envelope.ts +46 -1
- package/src/core/error-catalog.ts +95 -2
- package/src/core/execute-command.ts +144 -19
- package/src/core/fanout.ts +207 -14
- package/src/errors.ts +3 -1
- package/src/execute-cmr.ts +1442 -0
- package/src/execute-fanout.ts +15 -1
- package/src/execute.ts +173 -40
- package/src/index.ts +35 -1
- package/src/mcp/safety.ts +11 -0
- package/src/mcp/tools.ts +4 -1
- package/src/protocols/adapter.ts +54 -12
- package/src/resolver/cdb.ts +13 -1
- package/src/resolver/index.ts +4 -0
- package/src/resolver/selection.ts +37 -1
- package/src/resolver/target.ts +96 -8
- package/src/retrieve-fanout.ts +4 -1
- package/src/retrieve-follow.ts +29 -33
- package/src/retrieve-sample.ts +14 -8
- package/src/retrieve-stream.ts +38 -6
- package/src/retrieve.ts +73 -59
- package/src/transfer-fanout.ts +8 -1
- package/src/transfer.ts +15 -19
|
@@ -47,12 +47,23 @@ selection**, examples F1–F5). `romon` and `winbox-terminal` remain
|
|
|
47
47
|
write-shaped operation. In script mode every command head in a statement
|
|
48
48
|
(the statement start and each `[…]` or `{…}` body) must be a read: its verb
|
|
49
49
|
is the first word past the known menu prefix, so `/ip address print` is a
|
|
50
|
-
read and `/user expire-password print` is not (examples W3).
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
50
|
+
read and `/user expire-password print` is not (examples W3). Script
|
|
51
|
+
directives that only output, branch or compute a value are reads, including
|
|
52
|
+
the pure functions `:serialize`, `:deserialize`, `:convert`, `:delay`,
|
|
53
|
+
`:find`, `:timestamp`, `:rndnum`/`:rndstr` and the `:to*` converters, and the
|
|
54
|
+
`{…}`-bodied `:retry` and `:time` (example W4, #458). The absolute
|
|
55
|
+
`/serialize` spelling counts too; a bare `serialize` keeps the menu-verb rule,
|
|
56
|
+
because after navigation it could name a menu command. RouterOS runs only a
|
|
57
|
+
`{…}` block in a script-typed argument, so every body is visible to the gate.
|
|
58
|
+
Root mutators such as `/import` and indirect
|
|
59
|
+
execution through `:execute` are write-shaped too, as are `:global`, `:set`
|
|
60
|
+
and `:parse`, and so is any command carrying `file=` or `file-name=`
|
|
61
|
+
(including argument prefixes such as `fi=` and `file-n=`, and the spaced
|
|
62
|
+
`file-name value` spelling under print/export/serialize, example W5):
|
|
63
|
+
RouterOS redirects output into a device file, so
|
|
64
|
+
`/ip/address/export file=address` creates `address.rsc`,
|
|
65
|
+
`/file/print file=test` creates `test.txt` and `:serialize … file-name=x`
|
|
66
|
+
creates `x`, even though `export`, `print` and `:serialize` are reads. The shared
|
|
56
67
|
`rosetta`/`lsp-routeros-ts`
|
|
57
68
|
canonicalizer is for canonicalization only — never the structured predicate
|
|
58
69
|
(widening `structured` is a product regression). Pinned by
|
|
@@ -180,7 +191,9 @@ duplicate that table. Behavior notes the generated reference cannot carry:
|
|
|
180
191
|
- `--` — use it when the command contains tokens that would otherwise be
|
|
181
192
|
claimed as centrs flags; otherwise the command must be quoted as a single
|
|
182
193
|
argument. In fan-out, positional targets come BEFORE `--` and the command
|
|
183
|
-
AFTER it (see **Target selection**).
|
|
194
|
+
AFTER it (see **Target selection**). centrs flags go before `--` too: one of
|
|
195
|
+
execute's own long flags after it (a trailing `--json`) is refused before
|
|
196
|
+
dialing as `usage/flag-after-separator`, with the corrected line (#459).
|
|
184
197
|
|
|
185
198
|
### Designed, not implemented
|
|
186
199
|
|
|
@@ -265,6 +278,204 @@ the command alone (`canonicalizeExecuteCommand` + `isWriteShaped`), so the gate
|
|
|
265
278
|
fires before any target is dialed. Per-target validation still runs because
|
|
266
279
|
RouterOS schemas can differ by version.
|
|
267
280
|
|
|
281
|
+
A sent write is **never retried** (#448). Fan-out retries a target only for
|
|
282
|
+
`transport/network` and `transport/connection-closed`, and for a write only
|
|
283
|
+
when the failure came before the request was sent (for example while it was
|
|
284
|
+
being validated). A write that failed after it was sent may have run, so its
|
|
285
|
+
error envelope, single-target or fan-out, carries the warning
|
|
286
|
+
`transport/write-outcome-unknown`.
|
|
287
|
+
|
|
288
|
+
## CMR clients through their controller (`--cmr`)
|
|
289
|
+
|
|
290
|
+
`execute <controller> --cmr --labels <expr> -- '<command>'` runs one command on
|
|
291
|
+
the CMR clients (RouterOS 7.26beta1+) that a RouterOS label expression selects
|
|
292
|
+
on the controller (#450). The `<controller>` is an ordinary single target: a
|
|
293
|
+
positional or CDB identity, one `--quickchr`, or `--host`. It is resolved and
|
|
294
|
+
authenticated as usual, and **only the controller's credentials are used**. A
|
|
295
|
+
client is never dialed, so a client behind NAT or reachable only through its
|
|
296
|
+
controller works the same way. CMR is a selection source, not a protocol:
|
|
297
|
+
`--via` pins the **controller** transport (native-api, the default, or
|
|
298
|
+
rest-api). A CMR failure never falls back to dialing a client directly.
|
|
299
|
+
|
|
300
|
+
CMR is beta-only: it first shipped in 7.26beta1, and the controller also needs
|
|
301
|
+
the optional `cmr` package. When the target has no `/cmr/device`, centrs reads
|
|
302
|
+
its version to say why. A router older than 7.26beta1 is
|
|
303
|
+
`routeros/version-unsupported` (`capability: cmr-controller`). A newer one
|
|
304
|
+
without the package is `cmr/not-a-controller`, which also names the controller
|
|
305
|
+
when the target is itself a CMR client.
|
|
306
|
+
|
|
307
|
+
- `--labels` is required and passed to RouterOS unchanged: a comma list where
|
|
308
|
+
plain labels are OR, `+label` must match, `-label` excludes, and `all` also
|
|
309
|
+
selects the controller. CDB selectors (`--group`/`--where`/`--all`/
|
|
310
|
+
`--default`/geo), several targets, and `--via mac-telnet|ssh` are
|
|
311
|
+
`usage/conflicting-flags`. `--labels` without `--cmr` is refused the same way.
|
|
312
|
+
Labels that no device has are accepted silently by RouterOS. An empty
|
|
313
|
+
selection is `ok: true` with the warning `cmr/empty-selection` for a read,
|
|
314
|
+
and the outer error `cmr/empty-selection` for a write-shaped command, with
|
|
315
|
+
or without `--yes` (#460), as RouterOS refuses it (`no devices selected`). A
|
|
316
|
+
mistyped label then exits 1 instead of looking like a successful bulk write.
|
|
317
|
+
A device's
|
|
318
|
+
identity selects it only while the controller's `/cmr auto-labels` includes
|
|
319
|
+
`identity` (the default `all` does). On 7.26beta1, after `auto-labels=version`
|
|
320
|
+
a client's row still *showed* its identity label but no longer matched it.
|
|
321
|
+
- **One `run-script` call per device.** On 7.26beta1, `run-script` result rows
|
|
322
|
+
carry no device id, and their `device` key (`identity@address`) changed
|
|
323
|
+
*during* a call when a client dropped. So centrs never matches rows to
|
|
324
|
+
devices by name. Every controller call selects exactly one `/cmr/device` row
|
|
325
|
+
with `numbers=<.id>`, together with `labels=<expr>`, so a device that left
|
|
326
|
+
the labels is refused up front instead of run.
|
|
327
|
+
- **Preflight, per inventory row:** the call sends `:put [:parse <command>]`
|
|
328
|
+
followed by `:put "<nonce>"`, or the nonce alone with `--no-validate`.
|
|
329
|
+
- A refusal (`no devices selected`) means the device is not a member.
|
|
330
|
+
- Any row means it is a member; its validation result must be **complete**
|
|
331
|
+
before it is classified:
|
|
332
|
+
- status `success`;
|
|
333
|
+
- the nonce as the last line;
|
|
334
|
+
- no `...(+N B)` clip marker;
|
|
335
|
+
- a non-empty return that is IL.
|
|
336
|
+
- A device with no answer at all (timeout, lost connection) makes the
|
|
337
|
+
selection incomplete: `cmr/selection-incomplete`, and nothing runs. A
|
|
338
|
+
partial selection is never run as if it were the whole one.
|
|
339
|
+
- **Binding:** `/cmr/device` is read before preflight, between preflight and
|
|
340
|
+
the write gate, again for each member just before its own call (the prompt
|
|
341
|
+
and the fan-out queue can both wait), and after dispatch. A device whose row
|
|
342
|
+
is gone or whose `ids` key changed is not sent the command
|
|
343
|
+
(`cmr/membership-changed`); a replacement row is never adopted, even when it
|
|
344
|
+
matches the labels. If that per-member read fails, the member is not sent the
|
|
345
|
+
command and its error says so (`context.dispatched: false`). The per-member
|
|
346
|
+
read is host-side, not atomic with the call: on 7.26beta1 a client script
|
|
347
|
+
cannot check its own key (`/cmr/client get local-id` reads `*****` inside
|
|
348
|
+
`run-script`) and `numbers=` does not take `ids`. After dispatch, a sent result is reported
|
|
349
|
+
as `cmr/outcome-unknown` if the key changed during the call, or if the
|
|
350
|
+
inventory cannot be re-read (cancelled or unreadable) to confirm which client
|
|
351
|
+
answered. A CMR `skipped` stays `cmr/client-disconnected`, because nothing was
|
|
352
|
+
sent.
|
|
353
|
+
- **Write gate:** one `--yes`/TTY confirmation, naming how many clients will run
|
|
354
|
+
it, how many were excluded, and through which controller. Clients that failed
|
|
355
|
+
validation are never sent the command.
|
|
356
|
+
- **The controller login is not the limit (#456).** On 7.26beta1, RouterOS
|
|
357
|
+
does not check the caller's policy against a CMR script. `run-script`
|
|
358
|
+
needed only `read` + `api` on the controller login, and
|
|
359
|
+
`/cmr/device/reboot` only `read` + `test` + `api`. A login refused a direct
|
|
360
|
+
write (`not enough permissions (9)`) still wrote to clients and to the
|
|
361
|
+
controller's own row through `run-script`, and rebooted a client through
|
|
362
|
+
both. So a "reader" login on a 7.26beta1 controller is not read-only, and
|
|
363
|
+
centrs' write gate (and `mcp=ro` on MCP) is the only gate. MikroTik
|
|
364
|
+
[says](https://forum.mikrotik.com/t/7-26beta-development-is-released/273582/139) 7.26beta2 refuses this for a `read` group; that is not yet
|
|
365
|
+
checked on a beta2 build. For a write,
|
|
366
|
+
centrs reads the login's own `/user` row and `/user/group` once (both are
|
|
367
|
+
readable with just `read` + `api`). When the login lacks `write`, `test` or
|
|
368
|
+
`reboot`, the result and the confirmation error carry
|
|
369
|
+
`cmr/caller-policy-not-enforced` naming what it lacks. A failed read only
|
|
370
|
+
loses the warning.
|
|
371
|
+
- **Dispatch:** one call per validated member, sending the input unchanged with
|
|
372
|
+
`timeout=` set from `--timeout` (capped at 55 s, under CMR's ~60 s per-call
|
|
373
|
+
ceiling). Every call pins `strategy=parallel fail-policy=continue
|
|
374
|
+
freeze-frame-interval=20ms`. **Never retried.**
|
|
375
|
+
- **Outcomes** are a `FanoutData` envelope in inventory order, with exit codes
|
|
376
|
+
0/2/1. The outer `meta.target` is the controller, and the settings,
|
|
377
|
+
request and login every member shares are written once on the outer
|
|
378
|
+
envelope ([constitution](../../docs/CONSTITUTION.md#result-envelope)). Each
|
|
379
|
+
member's `meta.target.cmr` carries `{id, deviceKey, address}`. `success` → `data.ret` holds the client's output; a clipped
|
|
380
|
+
output adds `cmr/output-truncated` and `meta.truncated`. `--max-results`
|
|
381
|
+
applies per member, as in plain execute fan-out: a member whose rendered
|
|
382
|
+
envelope (with its full meta, as it would render alone) exceeds it (UTF-8 bytes) becomes `input/max-results-exceeded`, and
|
|
383
|
+
that error says the command already ran (`context.dispatched: true`). A `failed` runtime
|
|
384
|
+
error maps through the usual `routeros/*` table. `skipped` →
|
|
385
|
+
`cmr/client-disconnected`.
|
|
386
|
+
- **What a client script may do:** CMR runs it with only the `read`, `write`,
|
|
387
|
+
`test` and `reboot` policies and no user session. A command that needs
|
|
388
|
+
another policy (`/user add`, `/tool sniffer`) passes `:parse` on the client
|
|
389
|
+
and is refused when it runs: `cmr/policy-denied`. The script stops at that
|
|
390
|
+
statement, but statements before it already ran and are not rolled back;
|
|
391
|
+
their output is in `context.output`. Sensitive
|
|
392
|
+
values read back as `*` or empty. Multi-line commands run every line (the
|
|
393
|
+
manual says single-line; 7.26beta1 ran both LF and CRLF scripts in full), and
|
|
394
|
+
`/export` works.
|
|
395
|
+
- **Unknown outcomes:** a CMR timeout (`timed out (6)`, `std failure: timeout
|
|
396
|
+
(13)`), a local deadline, a cancellation, or a lost call after sending all
|
|
397
|
+
give `cmr/outcome-unknown`. A cancellation before anything is sent, including
|
|
398
|
+
one that makes an inventory read fail, is `cmr/cancelled`. On 7.26beta1 the client script **kept running**
|
|
399
|
+
in each of these cases.
|
|
400
|
+
- **Validation metadata** reports what ran: `source: ":put [:parse ...] via cmr
|
|
401
|
+
run-script"`, the same claim the console gate makes. There is no client
|
|
402
|
+
`/console/inspect` stage.
|
|
403
|
+
- **No progress stream.** `run-script` returns a client's output only when its
|
|
404
|
+
script ends, and no `/cmr` menu records a running script. To watch a long
|
|
405
|
+
CMR job, follow what it changes on a client:
|
|
406
|
+
`centrs retrieve <client> /ip/address --follow` while
|
|
407
|
+
`execute <controller> --cmr` adds addresses. That only works when the client
|
|
408
|
+
can also be reached directly; otherwise re-run a read through `--cmr`.
|
|
409
|
+
- **Not on MCP** until delegated authorization is designed (#449). That design
|
|
410
|
+
must not assume RouterOS refuses a fleet write for a `!write` login (see
|
|
411
|
+
above). `retrieve --cmr` is deferred (#441).
|
|
412
|
+
|
|
413
|
+
`/cmr/device/dashboard` is an interactive console view, refused as
|
|
414
|
+
`cmr/console-only`. A plain path, abbreviated or not (`/cmr/dev/dash`), is
|
|
415
|
+
refused before dialing. Any other spelling is found in the `:parse` return
|
|
416
|
+
during validation, which resolves console spacing, a relative menu change
|
|
417
|
+
(`/cmr/device; dashboard`), blocks and `api POST /execute` scripts, and ignores
|
|
418
|
+
strings and comments. With `--validate=false` only the plain path is refused.
|
|
419
|
+
On 7.26beta1 it closed a native API session, and over REST it held the
|
|
420
|
+
controller's REST service for every caller for 10–30 s.
|
|
421
|
+
|
|
422
|
+
Calling `/cmr/device/run-script` yourself, through `api` or plain `execute`,
|
|
423
|
+
validates only the controller request. The envelope then carries the warning
|
|
424
|
+
`cmr/client-script-not-validated`.
|
|
425
|
+
|
|
426
|
+
### Fleet commands run directly (#453)
|
|
427
|
+
|
|
428
|
+
A controller's fleet commands are one request to one router that act on many
|
|
429
|
+
devices: `/cmr/device/{reboot,upgrade,pair,run-script}`,
|
|
430
|
+
`/cmr/upgrade/trigger`, `/cmr/upgrade/job/run-next`, and `add`, `set`,
|
|
431
|
+
`remove`, `enable` or `disable` on `/cmr/vlan`, `/cmr/wifi`,
|
|
432
|
+
`/cmr/wifi/radio` and `/cmr/upgrade`. They are write-shaped, so they need
|
|
433
|
+
`--yes`. Before that gate, plain `execute` (and `api`) reads the controller's
|
|
434
|
+
`/cmr/device` inventory, read-only, and resolves which rows the command
|
|
435
|
+
reaches, where it can (see "Not resolved" below). The command still runs
|
|
436
|
+
exactly as typed.
|
|
437
|
+
|
|
438
|
+
- **The gate names the reach.** The prompt and the `usage/confirmation-required`
|
|
439
|
+
summary say how many CMR devices the command reaches and whether the
|
|
440
|
+
controller's own row (`controller=true`) is one of them. `error.context.cmr`
|
|
441
|
+
has `devices`, `connected`, `controllerIncluded`, `identities` and
|
|
442
|
+
`inventory`.
|
|
443
|
+
- **How the selection is resolved.** `numbers` matches a row's `.id` or
|
|
444
|
+
identity. `labels` follows the manual's rules over the row's `labels` and
|
|
445
|
+
`auto-labels`: plain labels are OR, `+label` must match, `-label` excludes,
|
|
446
|
+
`all` selects every row, and an expression with only exclusions starts from
|
|
447
|
+
every row. On a 7.26beta1 lab this matched the device's own selection
|
|
448
|
+
(a `run-script` probe per row) for every expression tried; the lab test
|
|
449
|
+
re-checks it (CMR-D9). It is the controller's current view: a disconnected
|
|
450
|
+
device's `auto-labels` can be stale. `/cmr/upgrade/trigger` reaches the rows
|
|
451
|
+
whose `upgrade-rule` it names. A rule's `set`, `remove`, `enable` or
|
|
452
|
+
`disable` reads the rule's own `labels`; a `set` that changes `labels`
|
|
453
|
+
reaches the old and the new devices. WiFi band labels (`+2ghz`, `+5ghz`,
|
|
454
|
+
`+6ghz`) pick radios, not devices, so they are left out.
|
|
455
|
+
- **Warnings, with or without `--yes`:**
|
|
456
|
+
- `cmr/selection-includes-controller`: the selection includes the
|
|
457
|
+
controller's own row (`labels=all` does, per the manual).
|
|
458
|
+
- `cmr/rule-selects-every-device`: a rule command with no device `labels`
|
|
459
|
+
(for `/cmr/vlan`, no `port-labels` either). The manual warns that such a
|
|
460
|
+
VLAN rule provisions every port of every connected device, the controller
|
|
461
|
+
included, and cuts the fleet's management access.
|
|
462
|
+
- `cmr/empty-selection`: by the controller's inventory now, nothing is
|
|
463
|
+
selected. RouterOS refuses this for a `/cmr/device` command; it accepts
|
|
464
|
+
a rule whose labels match nothing.
|
|
465
|
+
- `cmr/caller-policy-not-enforced` for `run-script` and `reboot`, as for
|
|
466
|
+
`--cmr` above (#456).
|
|
467
|
+
- **Not resolved:** an abbreviated selector argument (`lab=all`),
|
|
468
|
+
`--via ssh` or `mac-telnet`, `/cmr/upgrade/job/run-next` (a job's devices
|
|
469
|
+
are not in the inventory), and a failed read. The gate still names the
|
|
470
|
+
fleet command and says why its reach is unknown, for example `would reach
|
|
471
|
+
the CMR devices it selects through controller ctl (not resolved: an upgrade
|
|
472
|
+
job's devices are not in the inventory)`, and `error.context.cmr` has
|
|
473
|
+
`unresolved` instead of the counts. A failed read never blocks the command,
|
|
474
|
+
and after a transport or login failure the policy read is skipped. Two
|
|
475
|
+
cases are not recognized as fleet commands at all and keep the plain
|
|
476
|
+
wording: script-shaped input (a positional id or `[find …]`) and CDB fan-out
|
|
477
|
+
to several controllers, which has its own gate.
|
|
478
|
+
|
|
268
479
|
## mac-telnet L2 validation
|
|
269
480
|
|
|
270
481
|
Decided (2026-06-06): the real-L2 integration path is `@tikoci/quickchr`'s
|
|
@@ -573,6 +573,47 @@ argument happens to be `print`, so it returns `usage/confirmation-required`.
|
|
|
573
573
|
Every command head in a statement is checked, including `[…]` and `do={…}`
|
|
574
574
|
bodies.
|
|
575
575
|
|
|
576
|
+
### W4. Pure script functions are reads; an output file is not (#458)
|
|
577
|
+
|
|
578
|
+
```bash
|
|
579
|
+
centrs execute $R ':put [:serialize to=json [/system/resource/print as-value]]' --via rest-api --username $U --password $P </dev/null
|
|
580
|
+
```
|
|
581
|
+
|
|
582
|
+
Envelope: `ok: true` without `--yes`; `data.ret` parses as JSON whose
|
|
583
|
+
`version` is the router's. `:delay`, `:convert`, `:deserialize`, `:find`,
|
|
584
|
+
`:timestamp`, `:rndnum`, `:rndstr`, the `:to*` converters, `:retry` and `:time` are reads
|
|
585
|
+
too; any command inside their arguments or `{…}` bodies is still checked.
|
|
586
|
+
RouterOS runs only a `{…}` block in a script-typed argument and refuses a quoted
|
|
587
|
+
string there, so no body hides in text. `:serialize … file-name=centrs-w4`
|
|
588
|
+
creates a device file, so it returns `usage/confirmation-required`, as `file=`
|
|
589
|
+
does under a read verb. `:global`, `:set`, `:execute` and `:parse` stay writes.
|
|
590
|
+
|
|
591
|
+
### W5. Abbreviated output-file arguments keep the write gate
|
|
592
|
+
|
|
593
|
+
```bash
|
|
594
|
+
centrs execute $R '/system identity export fi=centrs-w5' --via rest-api --username $U --password $P </dev/null
|
|
595
|
+
centrs execute $R ':serialize to=json value=1 file-n=centrs-w5.json' --via rest-api --username $U --password $P </dev/null
|
|
596
|
+
```
|
|
597
|
+
|
|
598
|
+
Both return `usage/confirmation-required` and create no file. The same
|
|
599
|
+
commands with `--yes` pass validation and create `centrs-w5.rsc` and
|
|
600
|
+
`centrs-w5.json`, respectively. Native API has the same result. Through
|
|
601
|
+
`centrs_execute`, an `mcp=ro` record returns `cdb/write-not-permitted` with or
|
|
602
|
+
without `confirm: true`, and creates no file.
|
|
603
|
+
|
|
604
|
+
RouterOS also accepts `fi "centrs-w5"` and `file-n "centrs-w5.json"`, without
|
|
605
|
+
`=`. They keep the same confirmation and MCP policy gates, including when
|
|
606
|
+
serialize's file argument follows a nested value expression.
|
|
607
|
+
|
|
608
|
+
`test/integration/execute-write-gate.test.ts` proves the refusal, absence and
|
|
609
|
+
confirmed creation. The export control runs on the selected channel; the
|
|
610
|
+
serialize control pins CHR 7.26beta1, where its file output was reproduced.
|
|
611
|
+
The export uses CLI script spelling: structured attribute validation requires
|
|
612
|
+
full argument names and rejects `fi` as `validation/unknown-attribute`.
|
|
613
|
+
This is a grounding version, not an introduction boundary. Unknown or
|
|
614
|
+
ambiguous output-file prefixes stay write-shaped until the device resolves
|
|
615
|
+
them.
|
|
616
|
+
|
|
576
617
|
### R1. A post-validation `/execute` rejection fails the overall envelope
|
|
577
618
|
|
|
578
619
|
```bash
|
|
@@ -622,6 +663,16 @@ centrs execute --group no-such-group --via rest-api --cdb-file $CDB --json -- /s
|
|
|
622
663
|
`ok: true`, `data.summary = { total: 0, ok: 0, failed: 0 }`,
|
|
623
664
|
`warnings` include `cdb/empty-group`. Exit `0`.
|
|
624
665
|
|
|
666
|
+
The same group with a write is an outer error, with or without `--yes`, so a
|
|
667
|
+
misspelled group cannot pass for a successful bulk write (#460):
|
|
668
|
+
|
|
669
|
+
```bash
|
|
670
|
+
centrs execute --group no-such-group --via rest-api --cdb-file $CDB --yes --json -- '/system/identity/set name=x'
|
|
671
|
+
```
|
|
672
|
+
|
|
673
|
+
`ok: false`, `error.code` `cdb/empty-group`, `error.context.write` `true`.
|
|
674
|
+
Exit `1`. Nothing is dialed.
|
|
675
|
+
|
|
625
676
|
### F3. `--where` device-class selector (subset)
|
|
626
677
|
|
|
627
678
|
```bash
|
|
@@ -763,9 +814,235 @@ transport.
|
|
|
763
814
|
### Q3. Repeated `--quickchr` fans out; an unknown machine is an inner failure
|
|
764
815
|
|
|
765
816
|
```bash
|
|
766
|
-
centrs execute --quickchr $NAME --quickchr no-such-machine -- ':put 1'
|
|
817
|
+
centrs execute --quickchr $NAME --quickchr no-such-machine --json -- ':put 1'
|
|
767
818
|
```
|
|
768
819
|
|
|
769
820
|
Envelope: outer `ok: true`, `data.summary = { total: 2, ok: 1, failed: 1 }`;
|
|
770
821
|
the unknown machine's inner error is `quickchr/machine-not-found`. Exit code 2
|
|
771
822
|
(partial).
|
|
823
|
+
|
|
824
|
+
## CMR clients through a controller (#450)
|
|
825
|
+
|
|
826
|
+
`test/integration/execute-cmr.test.ts` runs these on a two-CHR CMR lab
|
|
827
|
+
(`test/integration/cmr-lab.ts`): `$CTL` is a 7.26beta1 controller with the
|
|
828
|
+
`cmr` package, paired with one client over a socket link. Examples CMR1–CMR7
|
|
829
|
+
and CMR10–CMR18 run over **both** `--via native-api` and `--via rest-api` to
|
|
830
|
+
the controller, with validation on. The same test first asserts the device
|
|
831
|
+
behaviour the design depends on (CMR-D1–D9 in the test file).
|
|
832
|
+
|
|
833
|
+
### CMR0. A router without CMR
|
|
834
|
+
|
|
835
|
+
```bash
|
|
836
|
+
centrs execute <router> --cmr --labels all -- ':put x'
|
|
837
|
+
```
|
|
838
|
+
|
|
839
|
+
On RouterOS older than 7.26beta1 this is `routeros/version-unsupported` with
|
|
840
|
+
`context.capability` `cmr-controller` and the running version. On 7.26beta1 or
|
|
841
|
+
later without the `cmr` package it is `cmr/not-a-controller` with the version.
|
|
842
|
+
Nothing is dispatched. `test/integration/execute.test.ts` runs this on the
|
|
843
|
+
channel's CHR and expects the code its version calls for.
|
|
844
|
+
|
|
845
|
+
### CMR1. A read on every client
|
|
846
|
+
|
|
847
|
+
```bash
|
|
848
|
+
centrs execute --quickchr $CTL --cmr --labels all -- ':put [/system/identity/get name]'
|
|
849
|
+
```
|
|
850
|
+
|
|
851
|
+
`data.summary` is `{total: 2, ok: 2}`. Each target's `data.ret` is that
|
|
852
|
+
device's own identity. `meta.validation.source` is `:put [:parse ...] via cmr
|
|
853
|
+
run-script` with `result: passed`, and `meta.target.cmr.id` is its
|
|
854
|
+
`/cmr/device` row.
|
|
855
|
+
|
|
856
|
+
### CMR2. Per-client validation: a controller-only menu
|
|
857
|
+
|
|
858
|
+
```bash
|
|
859
|
+
centrs execute --quickchr $CTL --cmr --labels all -- /cmr/device/print
|
|
860
|
+
```
|
|
861
|
+
|
|
862
|
+
The controller runs it. The client has no `cmr` package and is rejected on
|
|
863
|
+
the client itself with `validation/package-missing`, so it is not sent the
|
|
864
|
+
command (`meta.operation.cmr.dispatched` is 1). Exit code 2.
|
|
865
|
+
|
|
866
|
+
### CMR3. A confirmed write
|
|
867
|
+
|
|
868
|
+
```bash
|
|
869
|
+
centrs execute --quickchr $CTL --cmr --labels all --yes -- '/system/note/set note="centrs-cmr3"'
|
|
870
|
+
```
|
|
871
|
+
|
|
872
|
+
Both targets are `ok`, and both routers' `/system/note` reads `centrs-cmr3`.
|
|
873
|
+
|
|
874
|
+
### CMR4. An unconfirmed write is refused before dispatch
|
|
875
|
+
|
|
876
|
+
```bash
|
|
877
|
+
centrs execute --quickchr $CTL --cmr --labels all -- '/system/note/set note="centrs-cmr4"'
|
|
878
|
+
```
|
|
879
|
+
|
|
880
|
+
Outer `usage/confirmation-required` naming the client count and the
|
|
881
|
+
controller. No note changes.
|
|
882
|
+
|
|
883
|
+
### CMR5. A command every client rejects is never sent
|
|
884
|
+
|
|
885
|
+
```bash
|
|
886
|
+
centrs execute --quickchr $CTL --cmr --labels all --yes -- '/system/note/set note="x" bogus-arg=1'
|
|
887
|
+
```
|
|
888
|
+
|
|
889
|
+
Both targets are `validation/unknown-attribute`, `dispatched` is 0, and no note
|
|
890
|
+
changes.
|
|
891
|
+
|
|
892
|
+
### CMR6. A CMR timeout is an unknown outcome
|
|
893
|
+
|
|
894
|
+
```bash
|
|
895
|
+
centrs execute --quickchr $CTL --cmr --labels <client> --timeout 2s --yes -- ':delay 4s; :log info "<marker>"'
|
|
896
|
+
```
|
|
897
|
+
|
|
898
|
+
The target is `cmr/outcome-unknown`, and `<marker>` still appears in the
|
|
899
|
+
client's log afterwards.
|
|
900
|
+
|
|
901
|
+
### CMR7. Labels that match nothing
|
|
902
|
+
|
|
903
|
+
```bash
|
|
904
|
+
centrs execute --quickchr $CTL --cmr --labels no-such-label -- ':put x'
|
|
905
|
+
```
|
|
906
|
+
|
|
907
|
+
`ok: true`, `data.summary.total` is 0, warning `cmr/empty-selection`, exit
|
|
908
|
+
code 0.
|
|
909
|
+
|
|
910
|
+
The same labels with a write are an outer error, with or without `--yes`, as
|
|
911
|
+
RouterOS itself refuses an empty selection (#460):
|
|
912
|
+
|
|
913
|
+
```bash
|
|
914
|
+
centrs execute --quickchr $CTL --cmr --labels no-such-label --yes -- '/system/note/set note="x"'
|
|
915
|
+
```
|
|
916
|
+
|
|
917
|
+
`ok: false`, `error.code` `cmr/empty-selection`, `error.context.write` `true`,
|
|
918
|
+
exit code 1. No note changes.
|
|
919
|
+
|
|
920
|
+
### CMR8. CLI exit code for a mixed result
|
|
921
|
+
|
|
922
|
+
CMR2 through `runCli` exits `2`.
|
|
923
|
+
|
|
924
|
+
### CMR9. A client that is down
|
|
925
|
+
|
|
926
|
+
With the client VM stopped and the controller showing it `connected=false`:
|
|
927
|
+
|
|
928
|
+
```bash
|
|
929
|
+
centrs execute --quickchr $CTL --cmr --labels <client> -- ':put x'
|
|
930
|
+
```
|
|
931
|
+
|
|
932
|
+
The target is `cmr/client-disconnected` and `dispatched` is 0.
|
|
933
|
+
|
|
934
|
+
### CMR10. Cancelling after dispatch is an unknown outcome
|
|
935
|
+
|
|
936
|
+
A library caller aborts `signal` two seconds into:
|
|
937
|
+
|
|
938
|
+
```bash
|
|
939
|
+
centrs execute --quickchr $CTL --cmr --labels <client> --timeout 20s --yes -- ':delay 6s; :log info "<marker>"'
|
|
940
|
+
```
|
|
941
|
+
|
|
942
|
+
The target is `cmr/outcome-unknown` with `dispatched` 1. `<marker>` is not in
|
|
943
|
+
the client's log when the call returns, and appears afterwards: cancelling
|
|
944
|
+
the controller call did not stop the client script.
|
|
945
|
+
|
|
946
|
+
### CMR11. A command needing a policy CMR scripts lack
|
|
947
|
+
|
|
948
|
+
```bash
|
|
949
|
+
centrs execute --quickchr $CTL --cmr --labels <client> --yes -- '/user/add name=<user> group=read password=<x>'
|
|
950
|
+
```
|
|
951
|
+
|
|
952
|
+
Validation passes on the client (`:parse` accepts it), then the client refuses
|
|
953
|
+
it when it runs: the target is `cmr/policy-denied`, and `<user>` does not exist
|
|
954
|
+
on the client afterwards.
|
|
955
|
+
|
|
956
|
+
### CMR12. A multi-line command
|
|
957
|
+
|
|
958
|
+
```bash
|
|
959
|
+
centrs execute --quickchr $CTL --cmr --labels <client> -- $':put centrs-l1\n:put centrs-l2'
|
|
960
|
+
```
|
|
961
|
+
|
|
962
|
+
The target is `ok` and `data.ret` holds both lines.
|
|
963
|
+
|
|
964
|
+
### CMR11b. A refused statement after others have run
|
|
965
|
+
|
|
966
|
+
```bash
|
|
967
|
+
centrs execute --quickchr $CTL --cmr --labels <client> --yes -- $':put before\n/system/note/set note="<m>-before"\n/user/add name=<user> group=read password=<x>\n/system/note/set note="<m>-after"'
|
|
968
|
+
```
|
|
969
|
+
|
|
970
|
+
The target is `cmr/policy-denied`, and its `context.output` starts with
|
|
971
|
+
`before` followed by the `not enough permissions (9)` line. The client's note
|
|
972
|
+
is `<m>-before`: the statement before the refused one ran and stays, and the
|
|
973
|
+
script stopped at the refused one. `<user>` does not exist.
|
|
974
|
+
|
|
975
|
+
### CMR14. `--cmr` pointed at a client
|
|
976
|
+
|
|
977
|
+
```bash
|
|
978
|
+
centrs execute --quickchr <client> --cmr --labels all -- ':put x'
|
|
979
|
+
```
|
|
980
|
+
|
|
981
|
+
`cmr/not-a-controller`: the client runs 7.26beta1 but has no `cmr` package.
|
|
982
|
+
`context.controllerIdentity` names the controller it is paired to.
|
|
983
|
+
|
|
984
|
+
### CMR15. A controller login without `write` or `reboot`
|
|
985
|
+
|
|
986
|
+
The controller has a group with `read,test,api,rest-api,!write,!reboot,!sensitive,!policy`
|
|
987
|
+
and a user `$READER` in it. That login cannot write on the controller
|
|
988
|
+
directly, but:
|
|
989
|
+
|
|
990
|
+
```bash
|
|
991
|
+
centrs execute 127.0.0.1 --port $API --username $READER --password $PW --cmr --labels <client> --yes -- '/system/note/set note="<marker>"'
|
|
992
|
+
```
|
|
993
|
+
|
|
994
|
+
The target is `ok`, the client's note reads `<marker>`, and the envelope warns
|
|
995
|
+
`cmr/caller-policy-not-enforced` with `context.missing` `["write", "reboot"]`:
|
|
996
|
+
CMR ran the script with `read,write,test,reboot` (#456). The same write as the
|
|
997
|
+
lab's admin login has no such warning.
|
|
998
|
+
|
|
999
|
+
### CMR13. The dashboard behind a relative menu change
|
|
1000
|
+
|
|
1001
|
+
```bash
|
|
1002
|
+
centrs execute --quickchr $CTL -- '/cmr/dev; dash'
|
|
1003
|
+
centrs execute --quickchr $CTL -- ':put "CMR device dashboard"'
|
|
1004
|
+
```
|
|
1005
|
+
|
|
1006
|
+
The first is `cmr/console-only`: the controller's `:parse` resolves it to
|
|
1007
|
+
`/cmr/device/;(evl /cmr/device/dashboard)`, so it is refused before it runs.
|
|
1008
|
+
The second is `ok` with `data.ret` `CMR device dashboard`: a string that spells
|
|
1009
|
+
the words is not a command.
|
|
1010
|
+
|
|
1011
|
+
### CMR16. A fleet command names the devices it reaches (#453)
|
|
1012
|
+
|
|
1013
|
+
```bash
|
|
1014
|
+
centrs execute --quickchr $CTL -- '/cmr/device/reboot labels=all'
|
|
1015
|
+
```
|
|
1016
|
+
|
|
1017
|
+
Without `--yes` (no TTY) this is `usage/confirmation-required`. Its summary
|
|
1018
|
+
says it would reach 2 CMR devices, the controller itself among them, and
|
|
1019
|
+
`error.context.cmr` is `{devices: 2, controllerIncluded: true}` with both
|
|
1020
|
+
identities. The envelope warns `cmr/selection-includes-controller`.
|
|
1021
|
+
Nothing reboots.
|
|
1022
|
+
|
|
1023
|
+
### CMR17. A rule with no labels
|
|
1024
|
+
|
|
1025
|
+
```bash
|
|
1026
|
+
centrs execute --quickchr $CTL --yes -- '/cmr/vlan/add vlan-ids=4001 disabled=yes comment=<marker>'
|
|
1027
|
+
```
|
|
1028
|
+
|
|
1029
|
+
`ok: true` with the warning `cmr/rule-selects-every-device`: the rule has no
|
|
1030
|
+
`labels` and no `port-labels`. It is added disabled, so nothing is
|
|
1031
|
+
provisioned. Removing it by its `.id` warns the same way, read from the rule:
|
|
1032
|
+
|
|
1033
|
+
```bash
|
|
1034
|
+
centrs execute --quickchr $CTL --yes -- '/cmr/vlan/remove numbers=<id>'
|
|
1035
|
+
```
|
|
1036
|
+
|
|
1037
|
+
### CMR18. `run-script` called directly by a login without `write` or `reboot`
|
|
1038
|
+
|
|
1039
|
+
With CMR15's restricted login:
|
|
1040
|
+
|
|
1041
|
+
```bash
|
|
1042
|
+
centrs execute 127.0.0.1 --port $API --username $READER --password $PW --yes -- '/cmr/device/run-script labels=<client> script=":put centrs-cmr18"'
|
|
1043
|
+
```
|
|
1044
|
+
|
|
1045
|
+
`ok: true`, with the warnings `cmr/caller-policy-not-enforced`
|
|
1046
|
+
(`context.missing` `["write", "reboot"]`) and
|
|
1047
|
+
`cmr/client-script-not-validated`. The same command as the lab's admin login
|
|
1048
|
+
has neither the policy warning nor `cmr/selection-includes-controller`.
|
|
@@ -392,7 +392,8 @@ bare menu path. The positional grammar stays **target-first like every other
|
|
|
392
392
|
router-taking command** (amended in PR review — the shared resolver/selection
|
|
393
393
|
helpers assume it): one positional means offline and it *is* the input; two
|
|
394
394
|
positionals mean live with the router first; `--` is accepted before the
|
|
395
|
-
input
|
|
395
|
+
input, and one of explain's own long flags after it is refused as
|
|
396
|
+
`usage/flag-after-separator` (#459); **`--file` replaces the input positional in either form**, so with it
|
|
396
397
|
every positional is a target. Adding a
|
|
397
398
|
router therefore never reinterprets a previously valid offline invocation.
|
|
398
399
|
|
package/commands/mcp/README.md
CHANGED
|
@@ -178,6 +178,17 @@ MCP server applies it as a hard allowlist:
|
|
|
178
178
|
regardless of `confirm`; a local CDB mutation through `centrs_devices` or
|
|
179
179
|
`centrs_discover` `save` fails with `usage/confirmation-required` unless
|
|
180
180
|
explicitly confirmed.
|
|
181
|
+
- **`mcp=ro` does not rest on RouterOS policy.** On a 7.26beta1 CMR
|
|
182
|
+
controller, a login without `write` or `reboot` wrote to a client and to the
|
|
183
|
+
controller's own row through `/cmr/device/run-script`, and rebooted a client
|
|
184
|
+
through `run-script` and `/cmr/device/reboot`. That is the CHR reproduction
|
|
185
|
+
on #456; the integration test re-proves the `run-script` write (CMR15).
|
|
186
|
+
MikroTik [says](https://forum.mikrotik.com/t/7-26beta-development-is-released/273582/139) 7.26beta2 will refuse this for a `read` group, but
|
|
187
|
+
centrs' classification does not depend on it: every CMR fleet command
|
|
188
|
+
(`run-script`, `reboot`, `upgrade`, `pair`, `/cmr/upgrade/trigger`, …) is
|
|
189
|
+
write-shaped over every path, pinned by `test/unit/write-gate-tables.test.ts`,
|
|
190
|
+
and `mcp=ro` refuses them whatever the controller login's group is. Any
|
|
191
|
+
future MCP route to CMR clients (#449) must keep that assumption.
|
|
181
192
|
- **`--allow-adhoc-targets`** (off by default) is reserved for one-off lab/power
|
|
182
193
|
user flows. Current RouterOS-facing tools expose no inline host+credential
|
|
183
194
|
schema, so the CDB allowlist remains the only active target source.
|
package/commands/mcp/examples.md
CHANGED
|
@@ -131,6 +131,12 @@ With `$NAME` registered `mcp=ro` (default):
|
|
|
131
131
|
Envelope: `ok: false`, `error.code = "cdb/write-not-permitted"`. The router is not
|
|
132
132
|
mutated even though `confirm` is true — the CDB policy is authoritative.
|
|
133
133
|
|
|
134
|
+
Output-file arguments count as writes even when abbreviated:
|
|
135
|
+
`:serialize to=json value=1 file-n=out.json` and
|
|
136
|
+
`/system identity export fi=out` are refused with or without `confirm`.
|
|
137
|
+
The CHR refusal and file read-back controls are
|
|
138
|
+
[execute example W5](../execute/examples.md#w5-abbreviated-output-file-arguments-keep-the-write-gate).
|
|
139
|
+
|
|
134
140
|
### 8. Write against an `mcp=rw` target succeeds with `confirm: true`
|
|
135
141
|
|
|
136
142
|
With `$NAME` registered `mcp=rw`:
|