@tikoci/centrs 0.1.1 → 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.
- package/CHANGELOG.md +48 -2
- package/GLOSSARY.txt +16 -0
- package/README.md +1 -1
- package/commands/AGENTS.md +3 -2
- package/commands/api/AGENTS.md +212 -0
- package/commands/api/README.md +159 -0
- package/commands/api/examples.md +325 -0
- package/commands/btest/README.md +56 -31
- package/commands/btest/examples.md +35 -10
- package/commands/execute/README.md +9 -3
- package/commands/retrieve/README.md +1 -1
- package/docs/CONSTITUTION.md +6 -6
- package/docs/MATRIX.md +46 -15
- package/docs/errors/routeros/btest-connection-count-single-stream.md +22 -0
- package/docs/errors/routeros/btest-udp-tx-size-ignored.md +15 -0
- package/docs/errors/usage/invalid-method.md +23 -0
- package/package.json +5 -5
- package/src/api.ts +1555 -0
- package/src/btest.ts +61 -1
- package/src/cli/api.ts +536 -0
- package/src/cli/common.ts +64 -0
- package/src/cli.ts +22 -0
- package/src/core/error-catalog.ts +14 -0
- package/src/core/fanout.ts +160 -0
- package/src/core/inspect.ts +170 -0
- package/src/execute.ts +14 -47
- package/src/index.ts +61 -8
- package/src/protocols/adapter.ts +328 -0
- package/src/protocols/btest-session.ts +279 -46
- package/src/protocols/btest.ts +6 -2
- package/src/protocols/native-api.ts +223 -30
- package/src/retrieve-fanout.ts +13 -141
- package/src/retrieve.ts +30 -87
- package/commands/stream/README.md +0 -70
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,52 @@ documenting cross-cutting shifts that affect contributors and consumers.
|
|
|
8
8
|
|
|
9
9
|
## Unreleased
|
|
10
10
|
|
|
11
|
+
## 0.1.2 — 2026-06-26
|
|
12
|
+
|
|
13
|
+
Patch release. `@tikoci/centrs@0.1.2`.
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- **`btest` TCP multi-connection fan-out is `CHR-passed`.** `centrs btest
|
|
18
|
+
--connection-count N` now realizes the full fan-out: the client reads the
|
|
19
|
+
session token from the primary's OK handshake and opens `N-1` additional TCP
|
|
20
|
+
data connections, each sending the grounded secondary-join
|
|
21
|
+
(`[token:u16 BE][0x02][0…]`). All connections drive into one shared
|
|
22
|
+
`BandwidthCounters` so throughput aggregates. Wire format confirmed
|
|
23
|
+
byte-for-byte against RouterOS 7.23.1 (the server sends a 4-byte HELLO per
|
|
24
|
+
connection then waits for all to join before streaming — sequential opens must
|
|
25
|
+
not block on a reply, or the first secondary deadlocks the server). Unauthenticated
|
|
26
|
+
TCP fan-out works end-to-end; authenticated (EC-SRP5) sessions stay single-stream
|
|
27
|
+
and warn when the realized `activeConnections` falls short of the requested count.
|
|
28
|
+
`BtestRunSummary` / `BtestClientData` gain an `activeConnections` field.
|
|
29
|
+
Grounded via unit tests (secondary-join byte format, loopback fan-out) and
|
|
30
|
+
integration example 11 on real CHR (4 connections open, data flows on each).
|
|
31
|
+
|
|
32
|
+
### Fixed
|
|
33
|
+
|
|
34
|
+
- **`btest` UDP client receive reported 0 bps.** The UDP socket was bound with
|
|
35
|
+
`connect()` (filtering by remote address/port), which dropped the
|
|
36
|
+
bandwidth-server's probe packets because they arrive from the server's data
|
|
37
|
+
port, not the control port. The socket is now unconnected; `--remote-udp-tx-size`
|
|
38
|
+
emits a warning when unset (needed by the server-push direction only).
|
|
39
|
+
UDP `receive` and `both` cycles are now CHR-gated on real RouterOS (rx ≈ 474 KB
|
|
40
|
+
in CI).
|
|
41
|
+
- **`btest --connection-count` was silently ignored.** The count was validated and
|
|
42
|
+
accepted but never encoded into command byte 3 of the client hello, so RouterOS
|
|
43
|
+
always saw `count=0` and opened a single stream. The value is now threaded through
|
|
44
|
+
`BtestClientSessionOptions` → `clientHandshake`. A
|
|
45
|
+
`routeros/btest-connection-count-single-stream` warning fires for
|
|
46
|
+
`connection-count > 1` sessions that fall back to one stream (e.g. EC-SRP5 auth).
|
|
47
|
+
- **`btest direction=both` TX was unbounded, starving RX.** In TCP `both` the client
|
|
48
|
+
ran no status reader, so TX had no feedback, saturated the link, and collapsed
|
|
49
|
+
server→client RX. The `tcpRxLoop` now demuxes embedded 12-byte status frames
|
|
50
|
+
in-place by their structural marker (`0x07 ?? 00 00`) and passes them through the
|
|
51
|
+
shared `applyStatusFeedback` / `adaptTxFromStatus` gate used by
|
|
52
|
+
`tcpStatusReaderLoop`. A per-frame plausibility guard (CPU byte ≤ 100; sane
|
|
53
|
+
sequence ceiling) filters false matches from `--random-data` bulk streams.
|
|
54
|
+
Grounded: a CHR `both` run fires 5 feedback events (server bytesReceived
|
|
55
|
+
78840 → 643860) and paces TX; RX is sustained instead of collapsing.
|
|
56
|
+
|
|
11
57
|
## 0.1.1 — 2026-06-22
|
|
12
58
|
|
|
13
59
|
Patch release. `@tikoci/centrs@0.1.1`.
|
|
@@ -214,8 +260,8 @@ First published pre-release (odd minor → npm `next`). `@tikoci/centrs@0.1.0`.
|
|
|
214
260
|
result envelope, error model + URL scheme, settings precedence, identity,
|
|
215
261
|
protocol selection, done = CHR-passed).
|
|
216
262
|
- `docs/MATRIX.md` — command×protocol grid; the only status surface.
|
|
217
|
-
- `commands/<name>/
|
|
218
|
-
executable example list that gates "done."
|
|
263
|
+
- `commands/<name>/README.md` and `commands/<name>/examples.md` —
|
|
264
|
+
per-command design and executable example list that gates "done."
|
|
219
265
|
- Done definition codified in
|
|
220
266
|
`.github/instructions/done-definition.instructions.md`: a feature is done
|
|
221
267
|
when its examples are green on real CHR via `bun run test:integration`.
|
package/GLOSSARY.txt
CHANGED
|
@@ -25,6 +25,11 @@
|
|
|
25
25
|
# https://github.com/tikoci/restraml
|
|
26
26
|
# %lsp-routeros-ts: RouterOS parser and language-server utilities.
|
|
27
27
|
# https://github.com/tikoci/lsp-routeros-ts
|
|
28
|
+
# Language Server Protocol — the "lsp" in lsp-routeros-ts, referenced as a
|
|
29
|
+
# grounding source for /console/inspect client patterns (e.g. commands/api).
|
|
30
|
+
lsp
|
|
31
|
+
# RouterOS maximum transmission unit property (e.g. mtu, actual-mtu).
|
|
32
|
+
mtu
|
|
28
33
|
# %donny: Dude/discovery grounding for RouterOS device inventories.
|
|
29
34
|
# https://github.com/tikoci/donny
|
|
30
35
|
# %m2ir: protocol IR (WinBox, nova messages) + the declarative schema-as-data
|
|
@@ -89,6 +94,8 @@ datagram
|
|
|
89
94
|
davidanson
|
|
90
95
|
DDNS
|
|
91
96
|
defconf
|
|
97
|
+
demux
|
|
98
|
+
demuxes
|
|
92
99
|
dogtail
|
|
93
100
|
donny
|
|
94
101
|
dotenv
|
|
@@ -146,6 +153,7 @@ quickchr
|
|
|
146
153
|
RAML
|
|
147
154
|
readcdb
|
|
148
155
|
remotehub
|
|
156
|
+
retarget
|
|
149
157
|
restraml
|
|
150
158
|
romon
|
|
151
159
|
ros
|
|
@@ -418,3 +426,11 @@ trippable
|
|
|
418
426
|
# btest integration-test fixtures.
|
|
419
427
|
btestuser
|
|
420
428
|
btestpass
|
|
429
|
+
|
|
430
|
+
# RouterOS scripting builtin referenced in commands/api grounding notes
|
|
431
|
+
# (:toarray coerces a comma string into an array; explains the inspect path form).
|
|
432
|
+
toarray
|
|
433
|
+
|
|
434
|
+
# Logic shorthand ("if and only if") used in api write-gate doc comments; cspell's
|
|
435
|
+
# markdown dictionary knows it but the TypeScript one does not.
|
|
436
|
+
iff
|
package/README.md
CHANGED
|
@@ -32,8 +32,8 @@ product; without them this would just be a worse `curl`.
|
|
|
32
32
|
| Command | Purpose |
|
|
33
33
|
| ---------- | ------- |
|
|
34
34
|
| `retrieve` | Read RouterOS state over REST/native API and SNMP OID/MIB values. |
|
|
35
|
-
| `stream` | Follow live RouterOS output (`print follow`/monitor/sniffer) as an NDJSON stream of envelopes. |
|
|
36
35
|
| `execute` | Run RouterOS CLI-shaped read/write commands (add/set/remove) over native API/REST/L2 surfaces. |
|
|
36
|
+
| `api` | Structured RouterOS API passthrough (gh-api style): one command per operation, structured in/out, can write, over REST or native API. Open-ended follow is `api … --stream` (native API; NDJSON). |
|
|
37
37
|
| `terminal` | Open an interactive console, primarily SSH or MAC-Telnet. |
|
|
38
38
|
| `check` | Probe reachability and management protocol availability. |
|
|
39
39
|
| `devices` | View and maintain the CDB-backed device registry (the only writer). |
|
package/commands/AGENTS.md
CHANGED
|
@@ -18,8 +18,9 @@ README's "Status" line must agree with it.
|
|
|
18
18
|
|
|
19
19
|
## Verb vocabulary
|
|
20
20
|
|
|
21
|
-
Commands are centrs **verbs** (`retrieve`, `
|
|
22
|
-
`discover`, `check`, `terminal`, `settings`), never one tool per RouterOS command.
|
|
21
|
+
Commands are centrs **verbs** (`retrieve`, `execute`, `api`, `devices`,
|
|
22
|
+
`discover`, `check`, `terminal`, `settings`), never one tool per RouterOS command. Open-ended follow
|
|
23
|
+
is folded into `api --stream` (no separate `stream` verb). Sub-verbs and their
|
|
23
24
|
aliases are canonical across CLI / API / MCP:
|
|
24
25
|
|
|
25
26
|
| Canonical | Aliases | Meaning |
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
# commands/api — local constraints + grounded RouterOS facts
|
|
2
|
+
|
|
3
|
+
This file is the **durable home** for the RouterOS API-behavior facts the `api`
|
|
4
|
+
command depends on. It exists so the knowledge survives context resets: a fresh
|
|
5
|
+
agent picking up any `api` phase reads this first. Add a fact here only with a
|
|
6
|
+
source (a doc URL, a `tikoci/*` project, or a CHR run); follow the global
|
|
7
|
+
grounding discipline — one CHR result is a signal, reproduce before treating it as
|
|
8
|
+
fact.
|
|
9
|
+
|
|
10
|
+
Read order for any `api` work: `docs/CONSTITUTION.md` → `commands/api/README.md` →
|
|
11
|
+
`commands/api/examples.md` → this file.
|
|
12
|
+
|
|
13
|
+
## Local constraints
|
|
14
|
+
|
|
15
|
+
- `api` is the structured one-command-per-operation surface (the verb trichotomy
|
|
16
|
+
in `README.md`). It must not grow code-block / multi-command behavior — that is
|
|
17
|
+
`execute`'s job.
|
|
18
|
+
- Reuse the shared cores, do not fork: `src/core/inspect.ts` (the
|
|
19
|
+
`/console/inspect` client) and `src/core/fanout.ts` (the fan-out engine).
|
|
20
|
+
- Honor `--via` literally; never silently downgrade transport.
|
|
21
|
+
|
|
22
|
+
## Grounded facts (doc sources)
|
|
23
|
+
|
|
24
|
+
REST API — <https://help.mikrotik.com/docs/spaces/ROS/pages/47579162/REST+API> ·
|
|
25
|
+
native API — <https://help.mikrotik.com/docs/spaces/ROS/pages/47579160/API> ·
|
|
26
|
+
inspect-client TS patterns — `tikoci/lsp-routeros-ts`
|
|
27
|
+
(`server/src/routeros.ts` `InspectRequest`, `server/src/validation.ts`).
|
|
28
|
+
|
|
29
|
+
- **HTTP method map (REST):** `GET`→print, `PUT`→add (**create**), `PATCH`→set,
|
|
30
|
+
`DELETE`→remove, `POST`→universal (run any console command). PUT — not POST — is
|
|
31
|
+
RouterOS's create.
|
|
32
|
+
- **Multi-frame results: bounded → array; open-ended → native `/listen` only.**
|
|
33
|
+
*Nuance (do not over-read the REST docs).* RouterOS REST **does** run
|
|
34
|
+
`duration=` / `monitor` commands — it regularizes the native `!re` frames into a
|
|
35
|
+
JSON **array of `.section` records** and returns the whole array in one response,
|
|
36
|
+
bounded by the **60 s** REST cap (a command asked to run longer terminates early
|
|
37
|
+
with an error). Native has **no** such cap. So a `duration=`/`monitor` command is
|
|
38
|
+
an **ordinary bounded `api` call** that returns a `.section` array on both
|
|
39
|
+
transports (native's `talk()` accumulates the `!re` until `!done`) — **not**
|
|
40
|
+
NDJSON. The only thing that needs the NDJSON streaming path is **open-ended
|
|
41
|
+
follow** (native `/listen`, which never sends `!done`); REST genuinely cannot do
|
|
42
|
+
open-ended follow. ⇒ `--listen` (open-ended) is native-only; `duration=` commands
|
|
43
|
+
are normal one-shot calls. (CONFIRMED below.)
|
|
44
|
+
- **`/listen` semantics.** Emits `!re` on change; a deleted/disappeared item's
|
|
45
|
+
`!re` carries the dead flag (docs say `=.dead=yes`; **CHR 7.23.1 sends
|
|
46
|
+
`.dead=true`** — see CONFIRMED below); it never self-terminates. Cancel with
|
|
47
|
+
`/cancel tag=<listen-tag>` (the `/cancel` carries its own `.tag`); the listener
|
|
48
|
+
then gets `!trap … message=interrupted` followed by `!done`.
|
|
49
|
+
- **Query.** REST `.query` words are **identical to native `?` words minus the
|
|
50
|
+
`?` prefix** (CONFIRMED below). Forms: eq `name=val`; comparison `>name=val` /
|
|
51
|
+
`<name=val` (operator **before** the name); has-property `name`; absence
|
|
52
|
+
`-name`; stack ops `#!` (NOT top), `#&` (AND), `#|` (OR). Multiple words with no
|
|
53
|
+
stack op are **implicitly AND-ed**. See the centrs mapping section below.
|
|
54
|
+
- **Proplist.** REST `.proplist` accepts `"name,type"` or `["name","type"]`.
|
|
55
|
+
Native: `=.proplist=name,type`.
|
|
56
|
+
- **Object id.** Set/remove by id: REST id-in-URL (`PATCH /rest/ip/address/*1`),
|
|
57
|
+
native `=.id=*1` word. **No native get-one-by-id shorthand** — use
|
|
58
|
+
`print ?.id=*1`. REST has the convenience `GET /rest/ip/address/*1` → a single
|
|
59
|
+
object; native maps it to `print ?.id=*1`.
|
|
60
|
+
- **`/console/inspect` request modes** (`InspectRequest` in lsp): `request ∈
|
|
61
|
+
{child, completion, highlight, syntax}`, with `input?`, `path?`, `.proplist?`,
|
|
62
|
+
`.query?`. `child` lists menus/args (path + attribute validity); `completion`
|
|
63
|
+
+ `input=` yields allowed values (tips/warnings); `highlight` + `input=` is
|
|
64
|
+
token-level error detection (lsp's primary validator); `syntax` is the parse
|
|
65
|
+
tree.
|
|
66
|
+
|
|
67
|
+
## Query mapping (centrs `--query` / `--raw-query`)
|
|
68
|
+
|
|
69
|
+
Two layers, both grounded above:
|
|
70
|
+
|
|
71
|
+
- **`--query` / `--filter`** — structured convenience, **AND-combined** (RouterOS
|
|
72
|
+
default), repeatable. Each maps to one query word:
|
|
73
|
+
- `name=value` → `name=value` (eq)
|
|
74
|
+
- `name!=value` → two words `name=value`, `#!` (eq then NOT-top)
|
|
75
|
+
- `name>value` / `name<value` → `>name=value` / `<name=value`
|
|
76
|
+
- `name` (no `=`) → `name` (has-property)
|
|
77
|
+
Multiple `--query` emit their words in order; RouterOS's implicit AND applies (no
|
|
78
|
+
`#&` injected). `--query` cannot express OR or arbitrary stacks — that is
|
|
79
|
+
`--raw-query`.
|
|
80
|
+
- **`--raw-query <word>`** — power-user escape hatch, repeatable. Each value is a
|
|
81
|
+
**verbatim RouterOS query word** (absence `-name`, OR `#|`, AND `#&`, NOT `#!`,
|
|
82
|
+
or any `>`/`<`/`=` form). Emitted as-is: a `.query` element over REST, a
|
|
83
|
+
`?`-prefixed word over native. centrs does **not** parse or reorder raw words —
|
|
84
|
+
the caller owns the stack. When `--query` and `--raw-query` are both given, the
|
|
85
|
+
structured words are emitted first, then the raw words, in order.
|
|
86
|
+
|
|
87
|
+
This keeps the common case (`--query type=ether`) clean while giving full
|
|
88
|
+
stack-language access without centrs modelling the whole query grammar.
|
|
89
|
+
|
|
90
|
+
## CONFIRMED ON CHR (Phase 0 grounding spikes — CHR 7.23.1, 2026-06-29)
|
|
91
|
+
|
|
92
|
+
Probes: scratchpad `api-grounding-spike.ts` (listen/CRUD/execute) and
|
|
93
|
+
`api-grounding-spike2.ts` (duration/monitor + query forms + inspect path). Raw
|
|
94
|
+
native codec for the listen probe; `fetch` for REST; `connectNativeApi().talk()`
|
|
95
|
+
for native.
|
|
96
|
+
|
|
97
|
+
- **CONFIRMED — native `/listen` + `/cancel`.** `/ip/address/listen .tag=2`
|
|
98
|
+
emits an `!re tag=2` carrying the **full new record** on add; on delete it emits
|
|
99
|
+
a **minimal** `!re tag=2 { ".id":"*3", ".dead":"true" }`. **Correction to the
|
|
100
|
+
docs:** the wire flag is **`.dead=true`**, not `=.dead=yes`. `/cancel =tag=2
|
|
101
|
+
.tag=3` yields `!trap tag=2 {category:2, message:interrupted}`, then `!done
|
|
102
|
+
tag=3` (the cancel) **and** `!done tag=2` (the listen closes). Modern plaintext
|
|
103
|
+
`/login` returns a bare `!done` (no challenge) on 7.23.1.
|
|
104
|
+
- **CONFIRMED — native CRUD by id.** `add` returns the new id in `=ret=`; `set`
|
|
105
|
+
and `remove` via `=.id=<id>` succeed with zero `!re` (only `!done`); get-one is
|
|
106
|
+
`print ?.id=<id>` → exactly one record. `numbers=` is **not** needed (that is
|
|
107
|
+
execute's CLI idiom, not the API's).
|
|
108
|
+
- **CONFIRMED — query + proplist.** Native `/interface/print ?type=ether` filters
|
|
109
|
+
server-side; `=.proplist=address,interface` returns only those keys. REST
|
|
110
|
+
`POST /rest/interface/print {".query":["type=ether"],".proplist":"name,type"}`
|
|
111
|
+
→ `200 [{"name":"ether1","type":"ether"}]` (a complete **array**, not a stream).
|
|
112
|
+
- **CONFIRMED — duration/monitor returns a bounded `.section` array (both
|
|
113
|
+
transports).** `POST /rest/interface/monitor-traffic {interface:ether1,
|
|
114
|
+
duration:"5s"}` → `200`, ~5.0 s, a **JSON array of 5 records** keyed
|
|
115
|
+
`.section:"0".."4"` (each `…-bits-per-second` etc.). The native
|
|
116
|
+
`/interface/monitor-traffic duration=2s` returns the same frames via `talk()` (2
|
|
117
|
+
records, each with `.section`) before `!done`. So duration commands are ordinary
|
|
118
|
+
bounded calls, **not** NDJSON. (Corrects the first spike's over-strong "REST does
|
|
119
|
+
not support continuous commands" reading; the `/rest/interface/monitor` 400 there
|
|
120
|
+
was just a wrong command path — `monitor-traffic` is the real menu.) The 60 s
|
|
121
|
+
unbounded cap stays doc-grounded — not worth a 60 s CI wait to reproduce.
|
|
122
|
+
- **CONFIRMED — `/console/inspect` `path` is array-typed; pass COMMA tokens, not a
|
|
123
|
+
slash command.** `request=child path=system,license` → children `license:dir,
|
|
124
|
+
export:cmd, generate-new-id:cmd, get:cmd, print:cmd, renew:cmd`; the **slash**
|
|
125
|
+
forms (`system/license`, `/system/license`) return **nothing**. (This is why the
|
|
126
|
+
first spike's license probe was empty — wrong path form.) **Why (rationale, not
|
|
127
|
+
yet re-spiked):** RouterOS's inspect `path` argument is internally an **array**
|
|
128
|
+
type — a comma string like `ip,address,set` is `:toarray`-split into
|
|
129
|
+
`["ip","address","set"]`, the menu walk inspect expects. A `/`-prefixed
|
|
130
|
+
command-style string is **not** an array and isn't split, so it matches no menu.
|
|
131
|
+
A JSON **array** body over REST (`{"path":["ip","address"]}`) should therefore
|
|
132
|
+
work too — **plausible but unconfirmed; do not rely on it.** The contract centrs
|
|
133
|
+
uses is the **comma-joined string** (confirmed; what both `retrieve` and
|
|
134
|
+
`execute` already build via their duplicate `join(",")` helpers — the Phase-1
|
|
135
|
+
`src/core/inspect.ts` is the single home for it). And **`renew` is a `cmd`
|
|
136
|
+
node** → `/system/license/renew` is a write/command, confirming the "streaming ≠
|
|
137
|
+
read-only" rule concretely.
|
|
138
|
+
- **CONFIRMED — `.query` forms (REST word == native `?`-word minus `?`).** On
|
|
139
|
+
`/interface/print` (2 interfaces: ether, loopback): eq `type=ether` → 1; ne
|
|
140
|
+
`["type=ether","#!"]` → loopback; OR `["type=ether","type=loopback","#|"]` → 2;
|
|
141
|
+
gt `[">actual-mtu=1000"]` → 2; has `["running"]` → 2; absence `["-running"]` →
|
|
142
|
+
0; implicit-AND `["type=ether","running"]` → 1. Identical results native
|
|
143
|
+
(`?`-prefixed). REST license singleton: `GET /rest/system/license` →
|
|
144
|
+
`{"level":"free","system-id":"…"}`.
|
|
145
|
+
- **CONFIRMED — `POST /rest/execute` modes.** With `"as-string":""` it runs
|
|
146
|
+
**synchronously** → `{"ret":"CHR"}`. **Without** `as-string` it is **fire-and-
|
|
147
|
+
forget**: it schedules a job and returns the **job id** (`{"ret":"*18"}`, HTTP
|
|
148
|
+
200) — a `:error` does **not** surface synchronously. ⇒ `api`'s script-POST must
|
|
149
|
+
send `as-string` (reuse execute's `restPost("/execute",{script,"as-string":""})`,
|
|
150
|
+
`adapter.ts:199`) so output/errors are synchronous; the bare async-job form is
|
|
151
|
+
not the default.
|
|
152
|
+
- **RESOLVED — `/system/license`** (was inconclusive in spike 1 due to the slash
|
|
153
|
+
path; see the COMMA-path finding above). Independently, `isApiMutating` keys the
|
|
154
|
+
write gate on the method/verb, so even an async/streamable write like
|
|
155
|
+
`/system/license/renew` confirms regardless.
|
|
156
|
+
|
|
157
|
+
## CONFIRMED ON CHR (Phase 2+3 integration — CHR 7.23.1, 2026-06-29)
|
|
158
|
+
|
|
159
|
+
Validated by `test/integration/api.test.ts` (rest, examples 1–20, 98 assertions)
|
|
160
|
+
and `test/integration/api-native.test.ts` (native, N1–N8, 31 assertions), both
|
|
161
|
+
green on CHR 7.23.1.
|
|
162
|
+
|
|
163
|
+
- **CONFIRMED — native `/execute` supports `=as-string=` for synchronous output.**
|
|
164
|
+
Native `talk /execute =script=…` **without** `as-string` is **fire-and-forget**:
|
|
165
|
+
it schedules a job and returns the **job id** in `=ret=` (observed `"*31"`), not
|
|
166
|
+
the script output — exactly the REST behavior. Adding `=as-string=` (empty value)
|
|
167
|
+
makes it run synchronously and return the captured output, identical to REST
|
|
168
|
+
`/rest/execute {as-string}`. **This corrects `src/execute.ts`'s long-standing
|
|
169
|
+
assumption that native script mode is unsupported** (execute still blocks it; the
|
|
170
|
+
`api` path proves native `/execute =as-string=` works). `api` always sends
|
|
171
|
+
`as-string` for a script run on both transports.
|
|
172
|
+
- **CONFIRMED — native CRUD re-mapping to rest-style.** `add` returns the new id
|
|
173
|
+
only in the `!done` `=ret=` word → centrs re-maps it to `{".id": ret}` (REST PUT
|
|
174
|
+
returns the full created object with `.id`; native gives just the id). `set`/
|
|
175
|
+
`remove` reply with a bare `!done` (no `!re`) → no body → centrs surfaces `null`.
|
|
176
|
+
get-one is `print ?.id=<id>` → exactly one record → returned as a single object
|
|
177
|
+
(not a 1-element array). All grounded in `restStyleMutationData` /
|
|
178
|
+
`restStyleRunData` (`src/protocols/adapter.ts`).
|
|
179
|
+
- **CONFIRMED — REST `monitor-traffic` over the `api` path returns a `.section`
|
|
180
|
+
array.** `POST /rest/interface/monitor-traffic {interface,duration}` →
|
|
181
|
+
`[{".section":"0",…}, {".section":"1",…}]` (re-confirms the spike-2 finding via
|
|
182
|
+
the real `api` REST adapter). It is a command (terminal verb `monitor-traffic`,
|
|
183
|
+
not `print`/`get`), so it is a `POST` and **write-classed** → needs `--yes`.
|
|
184
|
+
- **CONFIRMED — the inspect gate over both transports.** Path existence
|
|
185
|
+
(`request=child` empty ⇒ `validation/unknown-path`) and add/set attribute
|
|
186
|
+
validity (`request=child`+`completion` ⇒ `validation/unknown-attribute`) fire
|
|
187
|
+
identically over rest-api and native-api, **before** any write — no `:put
|
|
188
|
+
[:parse]` involved (api input is a path, not a CLI string). A `/execute` script
|
|
189
|
+
is a CLI string ⇒ `meta.validation.semantic = "not-applicable"`.
|
|
190
|
+
|
|
191
|
+
## CONFIRMED ON CHR (Phase 4 `--stream` follow — CHR 7.23.1, 2026-06-30)
|
|
192
|
+
|
|
193
|
+
Validated by `test/integration/api-listen.test.ts` (L1–L4) on CHR 7.23.1.
|
|
194
|
+
`--stream` is the primary flag; `--listen` is an alias; a `/listen` endpoint
|
|
195
|
+
infers both. Native-api only (REST's 60s cap → `transport/capability-unsupported`).
|
|
196
|
+
|
|
197
|
+
- **CONFIRMED — `NativeApiSession.listen()` + `/cancel` end-to-end.** Opening
|
|
198
|
+
`/ip/address/listen` (own `.tag`) and adding an address over REST yields an
|
|
199
|
+
`!re` frame; stopping (`--count`/`--duration`/Ctrl-C) sends `/cancel =tag=<t>
|
|
200
|
+
.tag=<n>` and the listen closes via the `interrupted` trap (category 2) then
|
|
201
|
+
`!done` — the generator ends **without** throwing (the interrupted trap is the
|
|
202
|
+
normal cancel, not an error). A non-interrupted trap or transport closure does
|
|
203
|
+
throw. Re-confirms the Phase-0 spike through the real `apiListen()` path.
|
|
204
|
+
- **CONFIRMED — deletion frames carry `.dead=true`.** Removing a pre-seeded
|
|
205
|
+
address over REST while listening emits a minimal `{ ".id", ".dead":"true" }`
|
|
206
|
+
`!re` (re-confirms `.dead=true`, not the docs' `=.dead=yes`).
|
|
207
|
+
- **Stream shape:** each `!re` → one NDJSON envelope frame
|
|
208
|
+
(`meta.operation.stream.kind="frame"`, string values, `.dead` preserved); the
|
|
209
|
+
stream ends with a summary envelope (`stream.kind="summary"`,
|
|
210
|
+
`data.stopReason` ∈ `count-reached`/`duration-elapsed`/`interrupted`/
|
|
211
|
+
`transport-error`, plus `frames`/`durationMs`). The CLI exit code keys on
|
|
212
|
+
whether the stream *started* cleanly, not on per-frame `ok`.
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# api
|
|
2
|
+
|
|
3
|
+
A structured RouterOS **API passthrough**, modeled on `gh api`. You give it a
|
|
4
|
+
REST-style endpoint path and an HTTP method; centrs fills in credentials from the
|
|
5
|
+
CDB / env / flags, validates the request through `/console/inspect`, runs the
|
|
6
|
+
single REST or native-API operation, and returns the result in the standard
|
|
7
|
+
envelope.
|
|
8
|
+
|
|
9
|
+
Status: `CHR-passed` over `rest-api` and `native-api` (single-target),
|
|
10
|
+
including open-ended `--stream` follow (native-api only), per `docs/MATRIX.md`;
|
|
11
|
+
multi-target fan-out is a later phase. This file describes intent and flags; the
|
|
12
|
+
matrix holds the cell states. Load-bearing rules — envelope, errors, settings
|
|
13
|
+
precedence, identity, validation, protocol selection — live in
|
|
14
|
+
[`docs/CONSTITUTION.md`](../../docs/CONSTITUTION.md); the `(constitution: …)`
|
|
15
|
+
notes below point there rather than restating them.
|
|
16
|
+
|
|
17
|
+
`api` absorbs the former `stream` command: open-ended follow is `api <router>
|
|
18
|
+
<endpoint> --stream` (or the `/listen` endpoint form). `--listen` is an accepted
|
|
19
|
+
alias of `--stream`.
|
|
20
|
+
|
|
21
|
+
## Where `api` sits — the verb trichotomy
|
|
22
|
+
|
|
23
|
+
centrs has three RouterOS-touching verbs; pick by shape of work, not by habit:
|
|
24
|
+
|
|
25
|
+
- **`execute`** — the CLI-like runner. The full RouterOS console surface
|
|
26
|
+
(multi-command / script blocks, console-text output, `:put [:parse]` gate). Most
|
|
27
|
+
powerful and lowest-level, but the caller authors a RouterOS CLI string and the
|
|
28
|
+
output is console text.
|
|
29
|
+
- **`retrieve`** — read-only data extraction. Discoverable, structured-out,
|
|
30
|
+
always read.
|
|
31
|
+
- **`api`** (this command) — the structured middle. Enforces the
|
|
32
|
+
*one-command-per-operation* semantics of the REST / native API (no code
|
|
33
|
+
blocks), **can write**, and forces structured input **and** output. For an AI
|
|
34
|
+
agent this is usually the friendliest write path: no CLI-string authoring,
|
|
35
|
+
structured in/out. It does **not** replace `execute` — console-only and
|
|
36
|
+
multi-command work still needs `execute`.
|
|
37
|
+
|
|
38
|
+
## Synopsis
|
|
39
|
+
|
|
40
|
+
```text
|
|
41
|
+
centrs api <router> <endpoint> [flags]
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
- `<router>` — IP, DNS, MAC, or CDB-resolved identity (constitution: identity).
|
|
45
|
+
- `<endpoint>` — a REST-style path, leniently normalized. All of
|
|
46
|
+
`ip/address`, `/ip/address`, `rest/ip/address`, `/rest/ip/address`,
|
|
47
|
+
`"ip address"`, `'ip address'` canonicalize to `/ip/address`. A trailing id
|
|
48
|
+
segment (`ip/address/*1`) addresses one row. A trailing `/listen` segment
|
|
49
|
+
infers `--stream` + `--via native-api`.
|
|
50
|
+
|
|
51
|
+
The HTTP method (`-X`, default `GET`) is honored **literally** against RouterOS's
|
|
52
|
+
REST mapping — `GET`→print/get, **`PUT`→add**, `PATCH`→set, `DELETE`→remove,
|
|
53
|
+
`POST`→run-any-command. Note the trap: **`PUT` is RouterOS's create**, not
|
|
54
|
+
`POST`. centrs never rewrites your method, but warns (`tip/rest-verb-mapping`)
|
|
55
|
+
when a bare-collection `POST` carries create-looking fields.
|
|
56
|
+
|
|
57
|
+
## Flags
|
|
58
|
+
|
|
59
|
+
| Flag | Behavior |
|
|
60
|
+
| ---- | -------- |
|
|
61
|
+
| `-X` / `--method <verb>` | HTTP method, default `GET`, case-insensitive. |
|
|
62
|
+
| `-f <key=value>` | String field, repeatable; assembled into the JSON body. Values pass through verbatim (no type-guessing). |
|
|
63
|
+
| `-d` / `--data <json>` | Raw JSON request body. Collides with `-f` → `usage/conflicting-flags`. |
|
|
64
|
+
| `--input <file\|->` | Read the raw JSON body from a file or stdin (`-`). |
|
|
65
|
+
| `--query` / `--filter <expr>` | RouterOS-side row filter, AND-combined, repeatable. `name=value` (eq), `name!=value` (ne), `name>value`/`name<value` (cmp), `name` (has-property). Maps to REST `.query` words / native `?` words. |
|
|
66
|
+
| `--raw-query <word>` | Verbatim RouterOS query word (repeatable) for OR / absence / stack expressions — e.g. `--raw-query type=ether --raw-query type=vlan --raw-query '#\|'` (OR). Emitted as-is; the caller owns the stack. |
|
|
67
|
+
| `--attribute` / `--proplist <a,b>` | Property projection → `.proplist`. |
|
|
68
|
+
| `--raw` | Strip the envelope; emit bare RouterOS JSON. Implies `--validate=false`; does **not** imply `--yes`. |
|
|
69
|
+
| `--yes` | Confirm a mutating (non-read) request in non-interactive runs. |
|
|
70
|
+
| `--stream` (alias `--listen`) | Native-api-only open-ended follow → NDJSON stream of envelopes, ending with a summary envelope. Inferred from a `/listen` endpoint. `--via rest-api --stream` errors `transport/capability-unsupported` (REST's 60 s cap cannot follow). |
|
|
71
|
+
| `--duration <dur>` / `--count <n>` | Bound a `--stream`: stop after a wall-clock window / after N frames. |
|
|
72
|
+
| `--validate[=false]` | Default `true`. Gate is `/console/inspect` (see Validation). |
|
|
73
|
+
| `--via <protocol>` | `rest-api` (default) or `native-api`. No silent downgrade. |
|
|
74
|
+
| `--format <json\|yaml\|text>` | Output format. Defaults to `json` for `api` (machine-first); `CENTRS_FORMAT` overrides. Under `--stream`, `json`/`yaml` emit one compact envelope per line (NDJSON); `text` emits a concise row per frame. |
|
|
75
|
+
| target/auth | `--host`, `--port`, `--username`/`--user`/`-u`, `--password`, `--insecure`, `--timeout`, `--cdb-file`, `--cdb-password`, `--resolve <none\|arp>` — same single-target resolver as `retrieve`/`execute`. |
|
|
76
|
+
|
|
77
|
+
Multi-target fan-out (`--group` / `--where` / `--all` / `--default` /
|
|
78
|
+
`--concurrency`, reusing `src/core/fanout.ts`) lands in a later phase. When it
|
|
79
|
+
does, `--stream` is single-session: N>1 targets → `usage/fanout-not-supported`.
|
|
80
|
+
|
|
81
|
+
## Validation
|
|
82
|
+
|
|
83
|
+
Default on. Because the input is a structured path (not a CLI string), the gate is
|
|
84
|
+
`/console/inspect` — **not** `:put [:parse]`:
|
|
85
|
+
|
|
86
|
+
- Path existence + per-verb attribute validity via `request=child`
|
|
87
|
+
(`validation/unknown-path`, `validation/unknown-attribute`).
|
|
88
|
+
- Value-level advice via `request=completion` (+`input=`) feeding `tips`/`warnings`.
|
|
89
|
+
- **Carve-out:** a script-shaped `POST /rest/execute` is a CLI string, not a path,
|
|
90
|
+
so the inspect gate is `not-applicable` (`meta.validation.semantic`); RouterOS
|
|
91
|
+
re-validates on the run.
|
|
92
|
+
|
|
93
|
+
`--validate=false` (and `--raw`, which implies it) skip the preflight; RouterOS
|
|
94
|
+
still re-validates writes server-side. Disabling validation to make a call pass is
|
|
95
|
+
forbidden (constitution: validation is the product).
|
|
96
|
+
|
|
97
|
+
## Write confirmation
|
|
98
|
+
|
|
99
|
+
Read-only **iff** the method is `GET`, or the endpoint terminal verb is
|
|
100
|
+
`print`/`get`, or it is `listen`. Everything else is treated as a write and needs
|
|
101
|
+
confirmation (`--yes` non-interactively, or a TTY prompt) → otherwise
|
|
102
|
+
`usage/confirmation-required`. Two consequences:
|
|
103
|
+
|
|
104
|
+
- A `POST …/print` paged read does **not** prompt (keyed on the verb, not the
|
|
105
|
+
wire HTTP method).
|
|
106
|
+
- Streaming does **not** imply read-only: an async-but-mutating command like
|
|
107
|
+
`/system/license/renew --stream` still confirms.
|
|
108
|
+
|
|
109
|
+
`--raw` does not bypass this gate.
|
|
110
|
+
|
|
111
|
+
## Output
|
|
112
|
+
|
|
113
|
+
Always rest-style JSON in the standard envelope (constitution: result envelope).
|
|
114
|
+
Over native-api the records are re-mapped to the same rest-style shape, with string
|
|
115
|
+
values (the binary API carries no JSON scalar types).
|
|
116
|
+
|
|
117
|
+
`--raw` is the bare-passthrough escape hatch (the envelope-lossless rule is waived
|
|
118
|
+
for it): success prints only the RouterOS body on stdout; a RouterOS error prints
|
|
119
|
+
the RouterOS error payload to stderr with a nonzero exit; a centrs-side failure
|
|
120
|
+
with no RouterOS response prints a compact `{code,message}` to stderr. Exit code is
|
|
121
|
+
`0` iff `ok`.
|
|
122
|
+
|
|
123
|
+
## Multi-frame results: bounded vs open-ended
|
|
124
|
+
|
|
125
|
+
RouterOS produces multi-frame output two ways, and `api` treats them differently:
|
|
126
|
+
|
|
127
|
+
- **Bounded `duration=` / `monitor` commands are ordinary `api` calls.** Both
|
|
128
|
+
transports return the accumulated frames as a normal **array** (REST regularizes
|
|
129
|
+
the native `!re` frames into `.section`-keyed records; native's reader collects
|
|
130
|
+
them until the command completes). No flag needed — e.g.
|
|
131
|
+
`centrs api $R interface/monitor-traffic -f interface=ether1 -f duration=5s`
|
|
132
|
+
returns a `.section` array. REST bounds these at the **60 s** cap (a longer
|
|
133
|
+
`duration=` terminates early with an error); native has no cap. This is **not**
|
|
134
|
+
NDJSON.
|
|
135
|
+
- **Open-ended follow is `--stream` (alias `--listen`), native-api only.** Only
|
|
136
|
+
the native API `/listen` command follows indefinitely (REST cannot — the 60 s
|
|
137
|
+
cap). Each `!re` becomes one rest-style envelope frame (one NDJSON line; a
|
|
138
|
+
deletion's `.dead=true` flag is preserved); the stream ends with a summary
|
|
139
|
+
envelope (`data.stopReason` ∈ `count-reached`/`duration-elapsed`/`interrupted`/
|
|
140
|
+
`transport-error`, plus `frames` and `durationMs`). `--duration`/`--count`
|
|
141
|
+
bound it; Ctrl-C stops and still emits the summary. `--via rest-api --stream` →
|
|
142
|
+
`transport/capability-unsupported`. The exit code reflects whether the stream
|
|
143
|
+
*started* cleanly, not whether every frame was `ok`.
|
|
144
|
+
|
|
145
|
+
## MCP (deferred — forward guidance)
|
|
146
|
+
|
|
147
|
+
`api` is **not** exposed over the MCP frontend yet: the MCP server is the
|
|
148
|
+
least-mature surface, and a raw passthrough sits awkwardly against its "scoped
|
|
149
|
+
verbs, never one tool per RouterOS command" model. But precisely because `api` is
|
|
150
|
+
structured and agent-friendly, it is the strongest *future* MCP candidate for an
|
|
151
|
+
agent driving RouterOS — to be revisited behind the per-device `mcp=ro|rw` +
|
|
152
|
+
`confirm:true` write gate (method-aware: read verbs `ro`, everything else `rw`) and
|
|
153
|
+
the CDB-as-allowlist authorization model (constitution: MCP surface).
|
|
154
|
+
|
|
155
|
+
## Definition of done
|
|
156
|
+
|
|
157
|
+
`CHR-passed` only when every example in `examples.md` runs green against a real CHR
|
|
158
|
+
through `bun run test:integration`. See
|
|
159
|
+
[`docs/CONSTITUTION.md`](../../docs/CONSTITUTION.md) for the full done rule.
|