@tikoci/centrs 0.1.6 → 0.1.8-next.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +342 -0
- package/GLOSSARY.txt +32 -0
- package/README.md +10 -6
- package/SECURITY.md +1 -1
- package/commands/AGENTS.md +2 -1
- package/commands/api/AGENTS.md +55 -30
- package/commands/api/README.md +135 -42
- package/commands/api/examples.md +275 -19
- package/commands/devices/README.md +21 -4
- package/commands/devices/examples.md +33 -2
- package/commands/execute/README.md +60 -18
- package/commands/execute/examples.md +224 -0
- package/commands/explain/README.md +42 -5
- package/commands/mcp/README.md +2 -1
- package/commands/retrieve/README.md +348 -4
- package/commands/retrieve/examples.md +442 -5
- package/commands/terminal/README.md +14 -4
- package/commands/terminal/examples.md +22 -3
- package/commands/transfer/README.md +55 -9
- package/commands/transfer/examples.md +106 -7
- package/docs/CLI.md +29 -21
- package/docs/CONSTITUTION.md +41 -16
- package/docs/MATRIX.md +38 -1
- package/docs/errors/README.md +3 -1
- package/docs/errors/cdb/create-failed.md +20 -0
- package/docs/errors/cdb/file-exists.md +23 -0
- package/docs/errors/input/invalid-query.md +36 -0
- package/docs/errors/input/invalid-target.md +25 -3
- package/docs/errors/input/local-destination.md +20 -0
- package/docs/errors/input/unsupported-query.md +19 -0
- package/docs/errors/internal/byte-string.md +13 -0
- package/docs/errors/routeros/invalid-value.md +3 -2
- package/docs/errors/settings/invalid-timeout.md +6 -0
- package/docs/errors/transport/cancel-unacknowledged.md +24 -0
- package/docs/errors/transport/stream-overflow.md +21 -0
- package/docs/errors/usage/removed-flag.md +20 -0
- package/docs/errors/validation/menu-unavailable.md +22 -0
- package/docs/errors/validation/not-followable.md +23 -0
- package/docs/errors/validation/package-missing.md +29 -0
- package/docs/errors/validation/unknown-path.md +16 -4
- package/docs/errors/wait/deadline-exceeded.md +14 -0
- package/docs/errors/wait/interrupted.md +10 -0
- package/package.json +14 -11
- package/src/absent-menu.ts +280 -0
- package/src/api-fanout.ts +22 -12
- package/src/api.ts +603 -194
- package/src/btest.ts +19 -17
- package/src/cli/api.ts +126 -34
- package/src/cli/devices.ts +16 -3
- package/src/cli/execute.ts +18 -5
- package/src/cli/retrieve.ts +216 -22
- package/src/cli/terminal.ts +10 -5
- package/src/cli/transfer.ts +23 -8
- package/src/core/envelope.ts +14 -0
- package/src/core/error-catalog.ts +70 -1
- package/src/core/execute-command.ts +313 -6
- package/src/core/inspect.ts +65 -27
- package/src/core/query.ts +508 -0
- package/src/core/routeros-errors.ts +67 -1
- package/src/data/winbox-cdb-write.ts +22 -8
- package/src/devices.ts +148 -37
- package/src/discover.ts +7 -8
- package/src/errors.ts +116 -7
- package/src/execute-fanout.ts +14 -9
- package/src/execute.ts +128 -116
- package/src/explain/catalog.ts +64 -16
- package/src/explain/menus.ts +27 -6
- package/src/explain.ts +6 -6
- package/src/index.ts +31 -1
- package/src/mcp/tools.ts +10 -15
- package/src/protocols/adapter.ts +235 -118
- package/src/protocols/btest-session.ts +3 -2
- package/src/protocols/endpoint.ts +10 -0
- package/src/protocols/mac-telnet-console.ts +12 -7
- package/src/protocols/native-api.ts +252 -73
- package/src/protocols/sftp.ts +44 -14
- package/src/protocols/ssh.ts +42 -14
- package/src/resolver/cdb.ts +28 -7
- package/src/resolver/index.ts +1 -0
- package/src/resolver/target.ts +84 -18
- package/src/retrieve-fanout.ts +51 -12
- package/src/retrieve-follow.ts +862 -0
- package/src/retrieve-sample.ts +244 -0
- package/src/retrieve-stream.ts +115 -0
- package/src/retrieve-wait.ts +286 -0
- package/src/retrieve.ts +741 -109
- package/src/settings.ts +15 -15
- package/src/transfer-fanout.ts +40 -15
- package/src/transfer.ts +234 -32
|
@@ -42,9 +42,10 @@
|
|
|
42
42
|
* (name-level) gate — no `/console/inspect` table parsing needed.
|
|
43
43
|
*/
|
|
44
44
|
|
|
45
|
+
import { absentMenuError } from "../absent-menu.ts";
|
|
45
46
|
import { parseRouterOsPosition } from "../core/routeros-errors.ts";
|
|
46
47
|
import { routerOsStringLiteral } from "../core/routeros-string.ts";
|
|
47
|
-
import { CentrsError } from "../errors.ts";
|
|
48
|
+
import { CentrsError, SYNTAX_REJECTED_REMEDIATION } from "../errors.ts";
|
|
48
49
|
import {
|
|
49
50
|
type MacAddress,
|
|
50
51
|
type MacTelnetDatagramSink,
|
|
@@ -627,14 +628,16 @@ export function parseScriptFor(cli: string): string {
|
|
|
627
628
|
* with RouterOS's position suffix. All 425 corpus rows of this shape are
|
|
628
629
|
* the entire return; none appear embedded. Requiring both line-start and
|
|
629
630
|
* the `(line N column M)` suffix is what separates a real verdict from
|
|
630
|
-
* echoed body text.
|
|
631
|
+
* echoed body text. The pinned 7.23.5/7.24.2/7.25beta3 captures add no
|
|
632
|
+
* new heads beyond `syntax error`, `expected …` and `missing …`
|
|
633
|
+
* (`missing closing brace`, `missing value for where`; GH#375).
|
|
631
634
|
*/
|
|
632
635
|
const WRAPPED_BAD_PARAMETER = /\(evl\s+bad parameter\s+(\S+)/i;
|
|
633
636
|
const WRAPPED_BAD_COMMAND = /\(<%%\s*bad command name\b/i;
|
|
634
637
|
const BARE_BAD_PARAMETER =
|
|
635
638
|
/^[ \t]*bad parameter\s+(\S+)[^\n]*\(line \d+ column \d+\)/im;
|
|
636
639
|
const BARE_SYNTAX =
|
|
637
|
-
/^[ \t]*(?:syntax error|bad command name|expected [a-z]+(?: [a-z]+)*)[^\n]*\(line \d+ column \d+\)/im;
|
|
640
|
+
/^[ \t]*(?:syntax error|bad command name|(?:expected|missing) [a-z]+(?: [a-z]+)*)[^\n]*\(line \d+ column \d+\)/im;
|
|
638
641
|
|
|
639
642
|
/**
|
|
640
643
|
* Classify the printed value of `:put [:parse "<cli>"]`. Grounded on CHR 7.23.3
|
|
@@ -646,8 +649,8 @@ const BARE_SYNTAX =
|
|
|
646
649
|
* RouterOS emits them (see the grammar note above): wrapped as
|
|
647
650
|
* `(evl bad parameter <name> (line N column M) /path)` /
|
|
648
651
|
* `(<%% bad command name <name> (line N column M) a;b;c)`, or bare and
|
|
649
|
-
* line-initial as `expected … (line N column M)` / `
|
|
650
|
-
* M)`. A single `:parse` covers both the syntax and the unknown-attribute
|
|
652
|
+
* line-initial as `expected … (line N column M)` / `missing … (line N column
|
|
653
|
+
* M)` / `syntax error (line N column M)`. A single `:parse` covers both the syntax and the unknown-attribute
|
|
651
654
|
* (name-level) gate on every transport — REST and native API expose the same text
|
|
652
655
|
* in the `ret` value (HTTP 200 / `as-string`), the console transports print it.
|
|
653
656
|
*
|
|
@@ -679,11 +682,13 @@ export function classifyParseResult(
|
|
|
679
682
|
});
|
|
680
683
|
}
|
|
681
684
|
if (WRAPPED_BAD_COMMAND.test(output) || BARE_SYNTAX.test(output)) {
|
|
685
|
+
// An absent menu reads as a syntax error; name it instead (GH#361).
|
|
686
|
+
const absent = absentMenuError(cli, output, position, via);
|
|
687
|
+
if (absent) throw absent;
|
|
682
688
|
throw new CentrsError({
|
|
683
689
|
code: "validation/syntax",
|
|
684
690
|
summary: "RouterOS rejected the command syntax while parsing it.",
|
|
685
|
-
remediation:
|
|
686
|
-
"Fix the RouterOS CLI syntax (quotes, brackets, attribute form), then retry.",
|
|
691
|
+
remediation: SYNTAX_REJECTED_REMEDIATION,
|
|
687
692
|
context: {
|
|
688
693
|
command: cli,
|
|
689
694
|
validationSource: `:put [:parse ...] over ${via}`,
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* 2. A **session** (`NativeApiSession`) driven over an injectable byte stream
|
|
10
10
|
* (`NativeApiByteSink`), so scripted server bytes can drive it in tests and
|
|
11
11
|
* a real socket drives it in integration.
|
|
12
|
-
* 3. A thin **TCP/TLS adapter** (`connectNativeApi`) built on `
|
|
12
|
+
* 3. A thin **TCP/TLS adapter** (`connectNativeApi`) built on Bun's built-in `node:net` / `node:tls` sockets.
|
|
13
13
|
*
|
|
14
14
|
* Wire format grounded against the MikroTik wiki API page and the canonical
|
|
15
15
|
* `go-routeros` and `librouteros` implementations. Default ports: 8728 (plain),
|
|
@@ -20,8 +20,11 @@
|
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
22
|
import { createHash } from "node:crypto";
|
|
23
|
+
import { createConnection, isIP } from "node:net";
|
|
24
|
+
import { connect as connectTls } from "node:tls";
|
|
23
25
|
import { mapRouterOsError } from "../core/routeros-errors.ts";
|
|
24
26
|
import { CentrsError } from "../errors.ts";
|
|
27
|
+
import { formatEndpoint } from "./endpoint.ts";
|
|
25
28
|
|
|
26
29
|
const utf8Encoder = new TextEncoder();
|
|
27
30
|
const utf8Decoder = new TextDecoder();
|
|
@@ -144,9 +147,32 @@ export function decodeLength(
|
|
|
144
147
|
return { value: value >>> 0, size };
|
|
145
148
|
}
|
|
146
149
|
|
|
147
|
-
/**
|
|
148
|
-
|
|
149
|
-
|
|
150
|
+
/**
|
|
151
|
+
* The bytes of a *byte string*: a string whose code units are each one byte
|
|
152
|
+
* (0–255), the shape `binary` commands carry file contents in. A code unit
|
|
153
|
+
* above 0xff has no byte, and `Buffer`'s latin1 encoder would silently keep
|
|
154
|
+
* only its low byte, so it is refused instead.
|
|
155
|
+
*/
|
|
156
|
+
export function byteStringBytes(value: string): Buffer {
|
|
157
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
158
|
+
if (value.charCodeAt(index) > 0xff) {
|
|
159
|
+
throw new CentrsError({
|
|
160
|
+
code: "internal/byte-string",
|
|
161
|
+
summary: `A binary RouterOS word holds a character above U+00FF at offset ${index}.`,
|
|
162
|
+
remediation:
|
|
163
|
+
'This is a centrs bug; a binary command must carry each byte as one code unit (Buffer#toString("latin1")).',
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return Buffer.from(value, "latin1");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Encode a single word: length prefix followed by its UTF-8 bytes, or, for a
|
|
172
|
+
* `binary` word, its byte-string bytes (see {@link byteStringBytes}).
|
|
173
|
+
*/
|
|
174
|
+
export function encodeWord(word: string, binary = false): Uint8Array {
|
|
175
|
+
const payload = binary ? byteStringBytes(word) : utf8Encoder.encode(word);
|
|
150
176
|
const prefix = encodeLength(payload.length);
|
|
151
177
|
const out = new Uint8Array(prefix.length + payload.length);
|
|
152
178
|
out.set(prefix, 0);
|
|
@@ -155,8 +181,11 @@ export function encodeWord(word: string): Uint8Array {
|
|
|
155
181
|
}
|
|
156
182
|
|
|
157
183
|
/** Encode a sentence: each word framed, terminated by a zero-length word. */
|
|
158
|
-
export function encodeSentence(
|
|
159
|
-
|
|
184
|
+
export function encodeSentence(
|
|
185
|
+
words: readonly string[],
|
|
186
|
+
binary = false,
|
|
187
|
+
): Uint8Array {
|
|
188
|
+
const parts = words.map((word) => encodeWord(word, binary));
|
|
160
189
|
const total = parts.reduce((sum, part) => sum + part.length, 0) + 1;
|
|
161
190
|
const out = new Uint8Array(total);
|
|
162
191
|
let cursor = 0;
|
|
@@ -168,17 +197,42 @@ export function encodeSentence(words: readonly string[]): Uint8Array {
|
|
|
168
197
|
return out;
|
|
169
198
|
}
|
|
170
199
|
|
|
200
|
+
/**
|
|
201
|
+
* Decode one received word. RouterOS sends words as raw bytes: UTF-8 for the
|
|
202
|
+
* text centrs reads, but `/file` contents are arbitrary bytes, which a UTF-8
|
|
203
|
+
* decode replaces with U+FFFD (#404). A `binary` word decodes as a byte string
|
|
204
|
+
* through `Buffer`'s latin1, never WHATWG `TextDecoder("latin1")`, which is
|
|
205
|
+
* windows-1252 and maps 0x80 to U+20AC.
|
|
206
|
+
*/
|
|
207
|
+
export function decodeWord(word: Uint8Array, binary = false): string {
|
|
208
|
+
return binary
|
|
209
|
+
? Buffer.from(word.buffer, word.byteOffset, word.byteLength).toString(
|
|
210
|
+
"latin1",
|
|
211
|
+
)
|
|
212
|
+
: utf8Decoder.decode(word);
|
|
213
|
+
}
|
|
214
|
+
|
|
171
215
|
/**
|
|
172
216
|
* Incremental sentence reader. Accumulates TCP chunks and yields whole
|
|
173
|
-
* sentences
|
|
174
|
-
*
|
|
217
|
+
* sentences as they complete. Partial words and partial length prefixes are
|
|
218
|
+
* buffered until the rest of the bytes arrive.
|
|
175
219
|
*/
|
|
176
220
|
export class SentenceReader {
|
|
177
221
|
private buffer: Uint8Array = new Uint8Array(0);
|
|
178
|
-
private current:
|
|
222
|
+
private current: Uint8Array[] = [];
|
|
179
223
|
|
|
180
|
-
/** Push a chunk and return any sentences completed by it. */
|
|
224
|
+
/** Push a chunk and return any sentences completed by it, UTF-8 decoded. */
|
|
181
225
|
push(chunk: Uint8Array): string[][] {
|
|
226
|
+
return this.pushRaw(chunk).map((words) =>
|
|
227
|
+
words.map((word) => decodeWord(word)),
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Push a chunk and return any completed sentences as raw word bytes, so the
|
|
233
|
+
* caller picks each sentence's decoding (see {@link decodeWord}).
|
|
234
|
+
*/
|
|
235
|
+
pushRaw(chunk: Uint8Array): Uint8Array[][] {
|
|
182
236
|
if (chunk.length > 0) {
|
|
183
237
|
const next = new Uint8Array(this.buffer.length + chunk.length);
|
|
184
238
|
next.set(this.buffer, 0);
|
|
@@ -186,7 +240,7 @@ export class SentenceReader {
|
|
|
186
240
|
this.buffer = next;
|
|
187
241
|
}
|
|
188
242
|
|
|
189
|
-
const sentences:
|
|
243
|
+
const sentences: Uint8Array[][] = [];
|
|
190
244
|
let offset = 0;
|
|
191
245
|
for (;;) {
|
|
192
246
|
const header = decodeLength(this.buffer, offset);
|
|
@@ -204,9 +258,8 @@ export class SentenceReader {
|
|
|
204
258
|
this.current = [];
|
|
205
259
|
continue;
|
|
206
260
|
}
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
);
|
|
261
|
+
// Copy: the buffer is re-sliced and replaced as chunks arrive.
|
|
262
|
+
this.current.push(this.buffer.slice(wordStart, wordEnd));
|
|
210
263
|
}
|
|
211
264
|
|
|
212
265
|
if (offset > 0) {
|
|
@@ -232,6 +285,11 @@ export interface ApiReply {
|
|
|
232
285
|
tag?: string;
|
|
233
286
|
/** Raw words exactly as received, including the leading reply word. */
|
|
234
287
|
words: readonly string[];
|
|
288
|
+
/**
|
|
289
|
+
* Set by `listen()` on a reply that ARRIVED after its signal aborted (the
|
|
290
|
+
* replies a `/cancel` drains), however long it then waited in the queue.
|
|
291
|
+
*/
|
|
292
|
+
afterStop?: true;
|
|
235
293
|
}
|
|
236
294
|
|
|
237
295
|
/** Parse a raw sentence (array of words) into a structured reply. */
|
|
@@ -263,6 +321,19 @@ export function parseReply(words: readonly string[]): ApiReply {
|
|
|
263
321
|
return { type, attributes, tag, words };
|
|
264
322
|
}
|
|
265
323
|
|
|
324
|
+
const TAG_PREFIX = utf8Encoder.encode(".tag=");
|
|
325
|
+
|
|
326
|
+
/** The `.tag=` value of a raw sentence; ASCII, so readable before decoding. */
|
|
327
|
+
function sentenceTag(words: readonly Uint8Array[]): string | undefined {
|
|
328
|
+
for (let index = 1; index < words.length; index += 1) {
|
|
329
|
+
const word = words[index] as Uint8Array;
|
|
330
|
+
if (TAG_PREFIX.every((byte, at) => word[at] === byte)) {
|
|
331
|
+
return decodeWord(word.subarray(TAG_PREFIX.length));
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
return undefined;
|
|
335
|
+
}
|
|
336
|
+
|
|
266
337
|
/** Read an attribute by name (variable key avoids index-signature pitfalls). */
|
|
267
338
|
export function readAttribute(
|
|
268
339
|
reply: ApiReply,
|
|
@@ -338,10 +409,18 @@ export interface NativeApiCommand {
|
|
|
338
409
|
queries?: readonly string[];
|
|
339
410
|
/** `.proplist` restriction (comma-joined when an array). */
|
|
340
411
|
proplist?: readonly string[] | string;
|
|
412
|
+
/**
|
|
413
|
+
* Every word, sent and received, is a byte string (see
|
|
414
|
+
* {@link byteStringBytes}), not UTF-8 text. Only `transfer`'s `/file`
|
|
415
|
+
* contents need it (#404); a non-ASCII value such as a file name must be
|
|
416
|
+
* passed as its UTF-8 byte string.
|
|
417
|
+
*/
|
|
418
|
+
binary?: boolean;
|
|
341
419
|
}
|
|
342
420
|
|
|
343
421
|
interface PendingCommand {
|
|
344
422
|
command: string;
|
|
423
|
+
binary: boolean;
|
|
345
424
|
records: ApiReply[];
|
|
346
425
|
trap?: ApiReply;
|
|
347
426
|
resolve: (replies: ApiReply[]) => void;
|
|
@@ -349,19 +428,18 @@ interface PendingCommand {
|
|
|
349
428
|
}
|
|
350
429
|
|
|
351
430
|
/**
|
|
352
|
-
*
|
|
431
|
+
* An incremental command subscription. Unlike {@link PendingCommand} it never
|
|
353
432
|
* buffers to a single resolve — each `!re` is pushed to the consumer as it
|
|
354
|
-
* arrives, and
|
|
355
|
-
* trap) or fails on `!fatal`/transport closure.
|
|
433
|
+
* arrives, and ends on natural `!done`, acknowledged cancellation, or failure.
|
|
356
434
|
*/
|
|
357
435
|
interface StreamingSubscription {
|
|
358
436
|
command: string;
|
|
359
|
-
/** An `!re`
|
|
437
|
+
/** An `!re` row or an `!empty` reply arrived. */
|
|
360
438
|
push: (reply: ApiReply) => void;
|
|
361
439
|
/** An `!trap` arrived (an `interrupted` trap is the normal `/cancel` path). */
|
|
362
440
|
trap: (reply: ApiReply) => void;
|
|
363
441
|
/** `!done` arrived — the listen closed. */
|
|
364
|
-
settle: () => void;
|
|
442
|
+
settle: (reply: ApiReply) => void;
|
|
365
443
|
/** `!fatal` or transport closure — the stream cannot continue. */
|
|
366
444
|
fail: (error: CentrsError) => void;
|
|
367
445
|
}
|
|
@@ -404,9 +482,9 @@ export class NativeApiSession {
|
|
|
404
482
|
|
|
405
483
|
/** Feed inbound transport bytes into the parser. */
|
|
406
484
|
handleData(chunk: Uint8Array): void {
|
|
407
|
-
let sentences:
|
|
485
|
+
let sentences: Uint8Array[][];
|
|
408
486
|
try {
|
|
409
|
-
sentences = this.reader.
|
|
487
|
+
sentences = this.reader.pushRaw(chunk);
|
|
410
488
|
} catch (error) {
|
|
411
489
|
this.failAll(
|
|
412
490
|
error instanceof CentrsError
|
|
@@ -425,10 +503,21 @@ export class NativeApiSession {
|
|
|
425
503
|
if (words.length === 0) {
|
|
426
504
|
continue;
|
|
427
505
|
}
|
|
428
|
-
this.
|
|
506
|
+
const binary = this.isBinaryReply(words);
|
|
507
|
+
this.dispatch(parseReply(words.map((word) => decodeWord(word, binary))));
|
|
429
508
|
}
|
|
430
509
|
}
|
|
431
510
|
|
|
511
|
+
/** A binary command's data replies; its `!trap` message stays UTF-8 text. */
|
|
512
|
+
private isBinaryReply(words: readonly Uint8Array[]): boolean {
|
|
513
|
+
const tag = sentenceTag(words);
|
|
514
|
+
return (
|
|
515
|
+
tag !== undefined &&
|
|
516
|
+
this.pending.get(tag)?.binary === true &&
|
|
517
|
+
decodeWord(words[0] as Uint8Array) !== "!trap"
|
|
518
|
+
);
|
|
519
|
+
}
|
|
520
|
+
|
|
432
521
|
/** Notify the session the transport closed (optionally with an error). */
|
|
433
522
|
handleClose(error?: Error): void {
|
|
434
523
|
if (this.closed) {
|
|
@@ -458,6 +547,9 @@ export class NativeApiSession {
|
|
|
458
547
|
context: { attributes: reply.attributes },
|
|
459
548
|
});
|
|
460
549
|
this.failAll(this.closeError);
|
|
550
|
+
// Fatal ends the session locally too: a peer that sends !fatal but
|
|
551
|
+
// leaves TCP open must not keep the CLI alive after its error summary.
|
|
552
|
+
this.sink.close();
|
|
461
553
|
return;
|
|
462
554
|
}
|
|
463
555
|
|
|
@@ -496,17 +588,18 @@ export class NativeApiSession {
|
|
|
496
588
|
const subscription = this.subscriptions.get(tag);
|
|
497
589
|
if (subscription !== undefined) {
|
|
498
590
|
switch (reply.type) {
|
|
591
|
+
// `!empty` is a reply in its own right (a zero-row tick, an empty
|
|
592
|
+
// finite read), so a stream consumer sees it like a row (#402).
|
|
499
593
|
case "!re":
|
|
594
|
+
case "!empty":
|
|
500
595
|
subscription.push(reply);
|
|
501
596
|
return;
|
|
502
597
|
case "!trap":
|
|
503
598
|
subscription.trap(reply);
|
|
504
599
|
return;
|
|
505
|
-
case "!empty":
|
|
506
|
-
return;
|
|
507
600
|
case "!done":
|
|
508
601
|
this.subscriptions.delete(tag);
|
|
509
|
-
subscription.settle();
|
|
602
|
+
subscription.settle(reply);
|
|
510
603
|
return;
|
|
511
604
|
default:
|
|
512
605
|
return;
|
|
@@ -601,12 +694,13 @@ export class NativeApiSession {
|
|
|
601
694
|
return new Promise<ApiReply[]>((resolve, reject) => {
|
|
602
695
|
this.pending.set(tag, {
|
|
603
696
|
command: command.command,
|
|
697
|
+
binary: command.binary === true,
|
|
604
698
|
records: [],
|
|
605
699
|
resolve,
|
|
606
700
|
reject,
|
|
607
701
|
});
|
|
608
702
|
try {
|
|
609
|
-
this.sink.write(encodeSentence(words));
|
|
703
|
+
this.sink.write(encodeSentence(words, command.binary));
|
|
610
704
|
} catch (error) {
|
|
611
705
|
this.pending.delete(tag);
|
|
612
706
|
reject(
|
|
@@ -624,22 +718,44 @@ export class NativeApiSession {
|
|
|
624
718
|
}
|
|
625
719
|
|
|
626
720
|
/**
|
|
627
|
-
*
|
|
628
|
-
*
|
|
629
|
-
* on its own). The generator ends cleanly when the listen is cancelled:
|
|
721
|
+
* Stream a native command and yield each `!re` and `!empty` as it arrives. Finite commands
|
|
722
|
+
* end on `!done`; `/listen` needs cancellation. The generator ends cleanly:
|
|
630
723
|
* - via `options.signal` aborting — the subscription stays registered, so the
|
|
631
724
|
* resulting `interrupted` trap + `!done` are drained and the loop ends
|
|
632
725
|
* without throwing;
|
|
633
726
|
* - via the consumer breaking out of the `for await` — `finally` sends
|
|
634
727
|
* `/cancel` best-effort (the subscription is already torn down, so the
|
|
635
728
|
* trap/`!done` are not awaited) and the generator returns.
|
|
636
|
-
* A
|
|
637
|
-
*
|
|
729
|
+
* A trap throws unless it acknowledges our cancellation; unsolicited
|
|
730
|
+
* interruption, `!fatal`, and transport closure are failures. CHR grounding
|
|
731
|
+
* lives in `commands/api/AGENTS.md` and the command examples.
|
|
732
|
+
*
|
|
733
|
+
* After an abort, `cancelGraceMs` bounds the wait for that acknowledgement: a
|
|
734
|
+
* peer that never answers `/cancel` would otherwise hold the generator open
|
|
735
|
+
* forever (#385). On expiry the generator ends without the router's
|
|
736
|
+
* `!done` and `onCancelUnacknowledged` fires, so the caller can close the
|
|
737
|
+
* session and report that the stop was local, not acknowledged.
|
|
638
738
|
*/
|
|
639
739
|
async *listen(
|
|
640
740
|
command: NativeApiCommand,
|
|
641
|
-
options: {
|
|
741
|
+
options: {
|
|
742
|
+
signal?: AbortSignal;
|
|
743
|
+
onListening?: () => void;
|
|
744
|
+
cancelGraceMs?: number;
|
|
745
|
+
onCancelUnacknowledged?: () => void;
|
|
746
|
+
/** Terminal attributes, distinct from the streamed `!re` rows. */
|
|
747
|
+
onDone?: (attributes: Record<string, string>) => void;
|
|
748
|
+
/**
|
|
749
|
+
* Every reply for this tag (`!re`, `!empty`, `!trap`, `!done`), called
|
|
750
|
+
* synchronously as it is routed, before the generator yields it. Taps
|
|
751
|
+
* on several concurrent commands see one connection's wire order.
|
|
752
|
+
*/
|
|
753
|
+
onReply?: (reply: ApiReply) => void;
|
|
754
|
+
} = {},
|
|
642
755
|
): AsyncGenerator<ApiReply, void, void> {
|
|
756
|
+
// A cancelled caller must not dispatch a command, especially now that
|
|
757
|
+
// this path streams writes as well as read-only change subscriptions.
|
|
758
|
+
if (options.signal?.aborted) return;
|
|
643
759
|
if (this.closed) {
|
|
644
760
|
throw (
|
|
645
761
|
this.closeError ??
|
|
@@ -658,6 +774,13 @@ export class NativeApiSession {
|
|
|
658
774
|
let ended = false;
|
|
659
775
|
let failure: CentrsError | undefined;
|
|
660
776
|
let trap: ApiReply | undefined;
|
|
777
|
+
let trapAcknowledgesCancel = false;
|
|
778
|
+
const assertNoFailure = (): void => {
|
|
779
|
+
if (failure) throw failure;
|
|
780
|
+
if (trap && !trapAcknowledgesCancel) {
|
|
781
|
+
throw this.trapToError(command.command, trap);
|
|
782
|
+
}
|
|
783
|
+
};
|
|
661
784
|
const signalWake = (): void => {
|
|
662
785
|
const resume = wake;
|
|
663
786
|
wake = undefined;
|
|
@@ -667,13 +790,21 @@ export class NativeApiSession {
|
|
|
667
790
|
this.subscriptions.set(tag, {
|
|
668
791
|
command: command.command,
|
|
669
792
|
push: (reply) => {
|
|
670
|
-
|
|
793
|
+
options.onReply?.(reply);
|
|
794
|
+
queue.push(
|
|
795
|
+
options.signal?.aborted ? { ...reply, afterStop: true } : reply,
|
|
796
|
+
);
|
|
671
797
|
signalWake();
|
|
672
798
|
},
|
|
673
799
|
trap: (reply) => {
|
|
800
|
+
options.onReply?.(reply);
|
|
674
801
|
trap = reply;
|
|
802
|
+
trapAcknowledgesCancel =
|
|
803
|
+
isInterruptedTrap(reply) && !!options.signal?.aborted;
|
|
675
804
|
},
|
|
676
|
-
settle: () => {
|
|
805
|
+
settle: (reply) => {
|
|
806
|
+
options.onReply?.(reply);
|
|
807
|
+
options.onDone?.(reply.attributes);
|
|
677
808
|
ended = true;
|
|
678
809
|
signalWake();
|
|
679
810
|
},
|
|
@@ -702,10 +833,24 @@ export class NativeApiSession {
|
|
|
702
833
|
// can act on a real barrier rather than a blind timer.
|
|
703
834
|
options.onListening?.();
|
|
704
835
|
|
|
705
|
-
|
|
836
|
+
let graceTimer: ReturnType<typeof setTimeout> | undefined;
|
|
837
|
+
const onAbort = (): void => {
|
|
838
|
+
this.cancel(tag);
|
|
839
|
+
if (options.cancelGraceMs === undefined) return;
|
|
840
|
+
graceTimer = setTimeout(() => {
|
|
841
|
+
if (ended) return;
|
|
842
|
+
// Stop routing this tag and drop anything queued: a peer that ignores
|
|
843
|
+
// `/cancel` but keeps sending frames must not keep the loop draining.
|
|
844
|
+
this.subscriptions.delete(tag);
|
|
845
|
+
queue.length = 0;
|
|
846
|
+
ended = true;
|
|
847
|
+
options.onCancelUnacknowledged?.();
|
|
848
|
+
signalWake();
|
|
849
|
+
}, options.cancelGraceMs);
|
|
850
|
+
};
|
|
706
851
|
if (options.signal) {
|
|
707
852
|
if (options.signal.aborted) {
|
|
708
|
-
|
|
853
|
+
onAbort();
|
|
709
854
|
} else {
|
|
710
855
|
options.signal.addEventListener("abort", onAbort, { once: true });
|
|
711
856
|
}
|
|
@@ -731,19 +876,17 @@ export class NativeApiSession {
|
|
|
731
876
|
});
|
|
732
877
|
}
|
|
733
878
|
}
|
|
734
|
-
if (failure) {
|
|
735
|
-
throw failure;
|
|
736
|
-
}
|
|
737
|
-
if (trap && !isInterruptedTrap(trap)) {
|
|
738
|
-
throw this.trapToError(command.command, trap);
|
|
739
|
-
}
|
|
740
879
|
} finally {
|
|
880
|
+
clearTimeout(graceTimer);
|
|
741
881
|
options.signal?.removeEventListener("abort", onAbort);
|
|
742
882
|
this.subscriptions.delete(tag);
|
|
743
883
|
// Consumer broke out early (e.g. --count reached): tell the router to stop.
|
|
744
884
|
if (!ended && !this.closed) {
|
|
745
885
|
this.cancel(tag);
|
|
746
886
|
}
|
|
887
|
+
// Consumer return (including --count) must not hide a trap/close
|
|
888
|
+
// already received alongside the last row in the same TCP chunk.
|
|
889
|
+
assertNoFailure();
|
|
747
890
|
}
|
|
748
891
|
}
|
|
749
892
|
|
|
@@ -826,6 +969,8 @@ export interface ConnectNativeApiOptions {
|
|
|
826
969
|
rejectUnauthorized?: boolean;
|
|
827
970
|
/** Connection timeout in milliseconds. */
|
|
828
971
|
timeoutMs?: number;
|
|
972
|
+
/** Cancel TCP/TLS connection and login before dispatching any command. */
|
|
973
|
+
signal?: AbortSignal;
|
|
829
974
|
}
|
|
830
975
|
|
|
831
976
|
interface ConnectedNativeApi {
|
|
@@ -841,58 +986,83 @@ export async function connectNativeApi(
|
|
|
841
986
|
): Promise<ConnectedNativeApi> {
|
|
842
987
|
const port =
|
|
843
988
|
options.port ?? (options.tls ? NATIVE_API_TLS_PORT : NATIVE_API_PORT);
|
|
844
|
-
const endpoint =
|
|
989
|
+
const endpoint = formatEndpoint(options.host, port);
|
|
845
990
|
const timeoutMs = options.timeoutMs ?? 15_000;
|
|
991
|
+
options.signal?.throwIfAborted();
|
|
846
992
|
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
data(_socket, data) {
|
|
860
|
-
session?.handleData(new Uint8Array(data));
|
|
861
|
-
},
|
|
862
|
-
close() {
|
|
863
|
-
session?.handleClose();
|
|
864
|
-
},
|
|
865
|
-
error(_socket, error) {
|
|
866
|
-
session?.handleClose(error);
|
|
867
|
-
},
|
|
868
|
-
},
|
|
869
|
-
}).catch((error: unknown) => {
|
|
870
|
-
throw mapConnectError(error, endpoint);
|
|
871
|
-
});
|
|
872
|
-
|
|
873
|
-
const socket = await withTimeout(connectPromise, timeoutMs, endpoint);
|
|
874
|
-
session = new NativeApiSession({
|
|
993
|
+
// Own the socket before TCP/TLS completes: aborting only the connect promise
|
|
994
|
+
// leaves a pending connection alive and can prevent the CLI from exiting.
|
|
995
|
+
const socket = options.tls
|
|
996
|
+
? connectTls({
|
|
997
|
+
host: options.host,
|
|
998
|
+
port,
|
|
999
|
+
rejectUnauthorized: options.rejectUnauthorized ?? false,
|
|
1000
|
+
...(isIP(options.host) ? {} : { servername: options.host }),
|
|
1001
|
+
})
|
|
1002
|
+
: createConnection({ host: options.host, port });
|
|
1003
|
+
socket.setNoDelay(true);
|
|
1004
|
+
const session = new NativeApiSession({
|
|
875
1005
|
endpoint,
|
|
876
1006
|
sink: {
|
|
877
1007
|
write(bytes) {
|
|
878
1008
|
socket.write(bytes);
|
|
879
1009
|
},
|
|
880
1010
|
close() {
|
|
881
|
-
socket.
|
|
1011
|
+
socket.destroy();
|
|
882
1012
|
},
|
|
883
1013
|
},
|
|
884
1014
|
});
|
|
1015
|
+
socket.on("data", (data: Buffer) => session.handleData(data));
|
|
1016
|
+
socket.on("close", () => session.handleClose());
|
|
1017
|
+
socket.on("error", (error) => session.handleClose(error));
|
|
885
1018
|
|
|
1019
|
+
const readyEvent = options.tls ? "secureConnect" : "connect";
|
|
1020
|
+
const connectPromise = new Promise<void>((resolve, reject) => {
|
|
1021
|
+
const cleanup = () => {
|
|
1022
|
+
socket.removeListener(readyEvent, onConnect);
|
|
1023
|
+
socket.removeListener("error", onError);
|
|
1024
|
+
socket.removeListener("close", onClose);
|
|
1025
|
+
};
|
|
1026
|
+
const onConnect = () => {
|
|
1027
|
+
cleanup();
|
|
1028
|
+
resolve();
|
|
1029
|
+
};
|
|
1030
|
+
const onError = (error: Error) => {
|
|
1031
|
+
cleanup();
|
|
1032
|
+
reject(mapConnectError(error, endpoint));
|
|
1033
|
+
};
|
|
1034
|
+
const onClose = () => {
|
|
1035
|
+
cleanup();
|
|
1036
|
+
reject(
|
|
1037
|
+
new CentrsError({
|
|
1038
|
+
code: "transport/connection-closed",
|
|
1039
|
+
summary: `The RouterOS API connection to ${endpoint} closed before it was ready.`,
|
|
1040
|
+
remediation:
|
|
1041
|
+
"Confirm the API service and network path are available.",
|
|
1042
|
+
}),
|
|
1043
|
+
);
|
|
1044
|
+
};
|
|
1045
|
+
socket.once(readyEvent, onConnect);
|
|
1046
|
+
socket.once("error", onError);
|
|
1047
|
+
socket.once("close", onClose);
|
|
1048
|
+
});
|
|
1049
|
+
const onAbort = (): void => session.close();
|
|
1050
|
+
options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
886
1051
|
try {
|
|
1052
|
+
options.signal?.throwIfAborted();
|
|
1053
|
+
await withTimeout(connectPromise, timeoutMs, endpoint, options.signal);
|
|
887
1054
|
await withTimeout(
|
|
888
1055
|
session.login(options.username, options.password),
|
|
889
1056
|
timeoutMs,
|
|
890
1057
|
endpoint,
|
|
1058
|
+
options.signal,
|
|
891
1059
|
);
|
|
892
1060
|
} catch (error) {
|
|
893
|
-
//
|
|
1061
|
+
// Destroy even a pending connection when its deadline or login fails.
|
|
894
1062
|
session.close();
|
|
895
1063
|
throw error;
|
|
1064
|
+
} finally {
|
|
1065
|
+
options.signal?.removeEventListener("abort", onAbort);
|
|
896
1066
|
}
|
|
897
1067
|
return { session };
|
|
898
1068
|
}
|
|
@@ -901,9 +1071,17 @@ async function withTimeout<T>(
|
|
|
901
1071
|
promise: Promise<T>,
|
|
902
1072
|
timeoutMs: number,
|
|
903
1073
|
endpoint: string,
|
|
1074
|
+
signal?: AbortSignal,
|
|
904
1075
|
): Promise<T> {
|
|
905
1076
|
let handle: ReturnType<typeof setTimeout> | undefined;
|
|
1077
|
+
let onAbort: (() => void) | undefined;
|
|
906
1078
|
const timeout = new Promise<never>((_resolve, reject) => {
|
|
1079
|
+
onAbort = () =>
|
|
1080
|
+
reject(
|
|
1081
|
+
signal?.reason ?? new DOMException("Operation cancelled", "AbortError"),
|
|
1082
|
+
);
|
|
1083
|
+
if (signal?.aborted) onAbort();
|
|
1084
|
+
else signal?.addEventListener("abort", onAbort, { once: true });
|
|
907
1085
|
handle = setTimeout(() => {
|
|
908
1086
|
reject(
|
|
909
1087
|
new CentrsError({
|
|
@@ -918,6 +1096,7 @@ async function withTimeout<T>(
|
|
|
918
1096
|
try {
|
|
919
1097
|
return await Promise.race([promise, timeout]);
|
|
920
1098
|
} finally {
|
|
1099
|
+
if (onAbort) signal?.removeEventListener("abort", onAbort);
|
|
921
1100
|
if (handle !== undefined) {
|
|
922
1101
|
clearTimeout(handle);
|
|
923
1102
|
}
|