@tikoci/centrs 0.1.7 → 0.1.8
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 +256 -0
- package/GLOSSARY.txt +32 -0
- package/README.md +2 -2
- package/SECURITY.md +1 -1
- package/commands/AGENTS.md +2 -1
- package/commands/api/AGENTS.md +55 -30
- package/commands/api/README.md +123 -41
- package/commands/api/examples.md +205 -17
- package/commands/devices/README.md +11 -3
- package/commands/execute/README.md +31 -3
- package/commands/execute/examples.md +136 -1
- package/commands/explain/README.md +30 -4
- package/commands/mcp/README.md +5 -1
- package/commands/mcp/examples.md +18 -1
- package/commands/retrieve/README.md +348 -4
- package/commands/retrieve/examples.md +422 -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 +28 -20
- package/docs/CONSTITUTION.md +30 -10
- package/docs/MATRIX.md +38 -1
- package/docs/errors/README.md +3 -1
- package/docs/errors/input/invalid-query.md +36 -0
- 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/settings/invalid-timeout.md +6 -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 +511 -186
- package/src/btest.ts +19 -17
- package/src/cli/api.ts +126 -34
- package/src/cli/execute.ts +18 -5
- package/src/cli/retrieve.ts +223 -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 +56 -1
- package/src/core/inspect.ts +43 -5
- package/src/core/query.ts +508 -0
- package/src/core/routeros-errors.ts +1 -1
- package/src/devices.ts +6 -9
- package/src/discover.ts +7 -8
- package/src/errors.ts +116 -7
- package/src/execute-fanout.ts +13 -8
- package/src/execute.ts +106 -65
- package/src/explain/catalog.ts +58 -10
- package/src/explain/menus.ts +23 -2
- package/src/explain.ts +6 -6
- package/src/index.ts +29 -1
- package/src/mcp/safety.ts +43 -18
- package/src/mcp/tools.ts +14 -17
- package/src/protocols/adapter.ts +224 -118
- package/src/protocols/btest-session.ts +3 -2
- package/src/protocols/endpoint.ts +10 -0
- package/src/protocols/mac-telnet-console.ts +6 -3
- package/src/protocols/native-api.ts +223 -70
- 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 +53 -7
- 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 +732 -108
- package/src/settings.ts +15 -15
- package/src/transfer-fanout.ts +40 -15
- package/src/transfer.ts +234 -32
|
@@ -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,17 +718,17 @@ 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.
|
|
638
732
|
*
|
|
639
733
|
* After an abort, `cancelGraceMs` bounds the wait for that acknowledgement: a
|
|
640
734
|
* peer that never answers `/cancel` would otherwise hold the generator open
|
|
@@ -649,8 +743,19 @@ export class NativeApiSession {
|
|
|
649
743
|
onListening?: () => void;
|
|
650
744
|
cancelGraceMs?: number;
|
|
651
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;
|
|
652
754
|
} = {},
|
|
653
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;
|
|
654
759
|
if (this.closed) {
|
|
655
760
|
throw (
|
|
656
761
|
this.closeError ??
|
|
@@ -669,6 +774,13 @@ export class NativeApiSession {
|
|
|
669
774
|
let ended = false;
|
|
670
775
|
let failure: CentrsError | undefined;
|
|
671
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
|
+
};
|
|
672
784
|
const signalWake = (): void => {
|
|
673
785
|
const resume = wake;
|
|
674
786
|
wake = undefined;
|
|
@@ -678,13 +790,21 @@ export class NativeApiSession {
|
|
|
678
790
|
this.subscriptions.set(tag, {
|
|
679
791
|
command: command.command,
|
|
680
792
|
push: (reply) => {
|
|
681
|
-
|
|
793
|
+
options.onReply?.(reply);
|
|
794
|
+
queue.push(
|
|
795
|
+
options.signal?.aborted ? { ...reply, afterStop: true } : reply,
|
|
796
|
+
);
|
|
682
797
|
signalWake();
|
|
683
798
|
},
|
|
684
799
|
trap: (reply) => {
|
|
800
|
+
options.onReply?.(reply);
|
|
685
801
|
trap = reply;
|
|
802
|
+
trapAcknowledgesCancel =
|
|
803
|
+
isInterruptedTrap(reply) && !!options.signal?.aborted;
|
|
686
804
|
},
|
|
687
|
-
settle: () => {
|
|
805
|
+
settle: (reply) => {
|
|
806
|
+
options.onReply?.(reply);
|
|
807
|
+
options.onDone?.(reply.attributes);
|
|
688
808
|
ended = true;
|
|
689
809
|
signalWake();
|
|
690
810
|
},
|
|
@@ -756,12 +876,6 @@ export class NativeApiSession {
|
|
|
756
876
|
});
|
|
757
877
|
}
|
|
758
878
|
}
|
|
759
|
-
if (failure) {
|
|
760
|
-
throw failure;
|
|
761
|
-
}
|
|
762
|
-
if (trap && !isInterruptedTrap(trap)) {
|
|
763
|
-
throw this.trapToError(command.command, trap);
|
|
764
|
-
}
|
|
765
879
|
} finally {
|
|
766
880
|
clearTimeout(graceTimer);
|
|
767
881
|
options.signal?.removeEventListener("abort", onAbort);
|
|
@@ -770,6 +884,9 @@ export class NativeApiSession {
|
|
|
770
884
|
if (!ended && !this.closed) {
|
|
771
885
|
this.cancel(tag);
|
|
772
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();
|
|
773
890
|
}
|
|
774
891
|
}
|
|
775
892
|
|
|
@@ -852,6 +969,8 @@ export interface ConnectNativeApiOptions {
|
|
|
852
969
|
rejectUnauthorized?: boolean;
|
|
853
970
|
/** Connection timeout in milliseconds. */
|
|
854
971
|
timeoutMs?: number;
|
|
972
|
+
/** Cancel TCP/TLS connection and login before dispatching any command. */
|
|
973
|
+
signal?: AbortSignal;
|
|
855
974
|
}
|
|
856
975
|
|
|
857
976
|
interface ConnectedNativeApi {
|
|
@@ -867,58 +986,83 @@ export async function connectNativeApi(
|
|
|
867
986
|
): Promise<ConnectedNativeApi> {
|
|
868
987
|
const port =
|
|
869
988
|
options.port ?? (options.tls ? NATIVE_API_TLS_PORT : NATIVE_API_PORT);
|
|
870
|
-
const endpoint =
|
|
989
|
+
const endpoint = formatEndpoint(options.host, port);
|
|
871
990
|
const timeoutMs = options.timeoutMs ?? 15_000;
|
|
991
|
+
options.signal?.throwIfAborted();
|
|
872
992
|
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
data(_socket, data) {
|
|
886
|
-
session?.handleData(new Uint8Array(data));
|
|
887
|
-
},
|
|
888
|
-
close() {
|
|
889
|
-
session?.handleClose();
|
|
890
|
-
},
|
|
891
|
-
error(_socket, error) {
|
|
892
|
-
session?.handleClose(error);
|
|
893
|
-
},
|
|
894
|
-
},
|
|
895
|
-
}).catch((error: unknown) => {
|
|
896
|
-
throw mapConnectError(error, endpoint);
|
|
897
|
-
});
|
|
898
|
-
|
|
899
|
-
const socket = await withTimeout(connectPromise, timeoutMs, endpoint);
|
|
900
|
-
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({
|
|
901
1005
|
endpoint,
|
|
902
1006
|
sink: {
|
|
903
1007
|
write(bytes) {
|
|
904
1008
|
socket.write(bytes);
|
|
905
1009
|
},
|
|
906
1010
|
close() {
|
|
907
|
-
socket.
|
|
1011
|
+
socket.destroy();
|
|
908
1012
|
},
|
|
909
1013
|
},
|
|
910
1014
|
});
|
|
1015
|
+
socket.on("data", (data: Buffer) => session.handleData(data));
|
|
1016
|
+
socket.on("close", () => session.handleClose());
|
|
1017
|
+
socket.on("error", (error) => session.handleClose(error));
|
|
911
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 });
|
|
912
1051
|
try {
|
|
1052
|
+
options.signal?.throwIfAborted();
|
|
1053
|
+
await withTimeout(connectPromise, timeoutMs, endpoint, options.signal);
|
|
913
1054
|
await withTimeout(
|
|
914
1055
|
session.login(options.username, options.password),
|
|
915
1056
|
timeoutMs,
|
|
916
1057
|
endpoint,
|
|
1058
|
+
options.signal,
|
|
917
1059
|
);
|
|
918
1060
|
} catch (error) {
|
|
919
|
-
//
|
|
1061
|
+
// Destroy even a pending connection when its deadline or login fails.
|
|
920
1062
|
session.close();
|
|
921
1063
|
throw error;
|
|
1064
|
+
} finally {
|
|
1065
|
+
options.signal?.removeEventListener("abort", onAbort);
|
|
922
1066
|
}
|
|
923
1067
|
return { session };
|
|
924
1068
|
}
|
|
@@ -927,9 +1071,17 @@ async function withTimeout<T>(
|
|
|
927
1071
|
promise: Promise<T>,
|
|
928
1072
|
timeoutMs: number,
|
|
929
1073
|
endpoint: string,
|
|
1074
|
+
signal?: AbortSignal,
|
|
930
1075
|
): Promise<T> {
|
|
931
1076
|
let handle: ReturnType<typeof setTimeout> | undefined;
|
|
1077
|
+
let onAbort: (() => void) | undefined;
|
|
932
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 });
|
|
933
1085
|
handle = setTimeout(() => {
|
|
934
1086
|
reject(
|
|
935
1087
|
new CentrsError({
|
|
@@ -944,6 +1096,7 @@ async function withTimeout<T>(
|
|
|
944
1096
|
try {
|
|
945
1097
|
return await Promise.race([promise, timeout]);
|
|
946
1098
|
} finally {
|
|
1099
|
+
if (onAbort) signal?.removeEventListener("abort", onAbort);
|
|
947
1100
|
if (handle !== undefined) {
|
|
948
1101
|
clearTimeout(handle);
|
|
949
1102
|
}
|
package/src/protocols/sftp.ts
CHANGED
|
@@ -26,7 +26,13 @@
|
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
28
|
import { CentrsError } from "../errors.ts";
|
|
29
|
-
import {
|
|
29
|
+
import { formatEndpoint } from "./endpoint.ts";
|
|
30
|
+
import {
|
|
31
|
+
batchSshAuthRemediation,
|
|
32
|
+
isSshAuthFailure,
|
|
33
|
+
sshCommonOptions,
|
|
34
|
+
sshUserHost,
|
|
35
|
+
} from "./ssh.ts";
|
|
30
36
|
|
|
31
37
|
/** A directory entry as parsed from an `sftp` `ls -l` long-name line. */
|
|
32
38
|
export interface SftpFileEntry {
|
|
@@ -150,6 +156,11 @@ export class SftpClient {
|
|
|
150
156
|
argv(): string[] {
|
|
151
157
|
// `sftp` takes the port as `-P` (vs `ssh -p`); the `-i`/`-o` trust + batch
|
|
152
158
|
// options are shared with the ssh execute client via `sshCommonOptions`.
|
|
159
|
+
// Unlike `ssh`, `sftp` reads `host:path`, so an IPv6 host must be bracketed
|
|
160
|
+
// (`x@::1` is "Missing hostname"; `ssh x@[::1]` fails to resolve).
|
|
161
|
+
const { host: bare } = this.config;
|
|
162
|
+
const host =
|
|
163
|
+
bare.includes(":") && !bare.startsWith("[") ? `[${bare}]` : bare;
|
|
153
164
|
return [
|
|
154
165
|
this.config.binary ?? "sftp",
|
|
155
166
|
"-b",
|
|
@@ -157,7 +168,7 @@ export class SftpClient {
|
|
|
157
168
|
"-P",
|
|
158
169
|
String(this.config.port),
|
|
159
170
|
...sshCommonOptions(this.config),
|
|
160
|
-
sshUserHost(this.config),
|
|
171
|
+
sshUserHost({ ...this.config, host }),
|
|
161
172
|
];
|
|
162
173
|
}
|
|
163
174
|
|
|
@@ -204,25 +215,20 @@ export class SftpClient {
|
|
|
204
215
|
causeData: stderr,
|
|
205
216
|
});
|
|
206
217
|
}
|
|
207
|
-
if (
|
|
208
|
-
|
|
209
|
-
lower.includes("authentication failed") ||
|
|
210
|
-
lower.includes("no such identity") ||
|
|
211
|
-
lower.includes("too many authentication failures")
|
|
212
|
-
) {
|
|
218
|
+
if (isSshAuthFailure(lower)) {
|
|
219
|
+
const sshKeySelected = Boolean(this.config.sshKey);
|
|
213
220
|
return new CentrsError({
|
|
214
221
|
code: "auth/failed",
|
|
215
222
|
summary: `SSH authentication to ${host} failed.`,
|
|
216
|
-
remediation:
|
|
217
|
-
|
|
218
|
-
context,
|
|
223
|
+
remediation: batchSshAuthRemediation(sshKeySelected),
|
|
224
|
+
context: { ...context, sshKeySelected },
|
|
219
225
|
causeData: stderr,
|
|
220
226
|
});
|
|
221
227
|
}
|
|
222
228
|
if (lower.includes("connection refused")) {
|
|
223
229
|
return new CentrsError({
|
|
224
230
|
code: "transport/connection-refused",
|
|
225
|
-
summary: `SSH connection to ${host
|
|
231
|
+
summary: `SSH connection to ${formatEndpoint(host, port)} was refused.`,
|
|
226
232
|
remediation:
|
|
227
233
|
"Enable the RouterOS SSH service (`/ip service enable ssh`) and confirm the port / firewall.",
|
|
228
234
|
context,
|
|
@@ -236,7 +242,7 @@ export class SftpClient {
|
|
|
236
242
|
) {
|
|
237
243
|
return new CentrsError({
|
|
238
244
|
code: "transport/timeout",
|
|
239
|
-
summary: `SSH connection to ${host
|
|
245
|
+
summary: `SSH connection to ${formatEndpoint(host, port)} timed out.`,
|
|
240
246
|
remediation:
|
|
241
247
|
"Raise `--timeout`, or confirm the host and port are reachable.",
|
|
242
248
|
context,
|
|
@@ -257,6 +263,30 @@ export class SftpClient {
|
|
|
257
263
|
causeData: stderr,
|
|
258
264
|
});
|
|
259
265
|
}
|
|
266
|
+
// After login: RouterOS refused the operation for the user's policy
|
|
267
|
+
// (`dest open`, `remote mkdir`, `remote delete` — CHR 7.24.5, no `write`).
|
|
268
|
+
if (lower.includes("permission denied")) {
|
|
269
|
+
return new CentrsError({
|
|
270
|
+
code: "routeros/command-failed",
|
|
271
|
+
summary: `RouterOS refused SFTP ${op}${remote ? ` of ${remote}` : ""} on ${host}: permission denied.`,
|
|
272
|
+
remediation:
|
|
273
|
+
"The user logged in, but its group lacks the policy for this operation: upload, mkdir and remove need `write`. `/user print` shows the user's group and `/user group print` its policies.",
|
|
274
|
+
context,
|
|
275
|
+
causeData: stderr,
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
// SSH disconnect reason 7 (service not available): RouterOS refuses the
|
|
279
|
+
// SFTP subsystem to a user whose group lacks `ftp` (CHR 7.24.5).
|
|
280
|
+
if (/received disconnect from .* port \d+:7:/.test(lower)) {
|
|
281
|
+
return new CentrsError({
|
|
282
|
+
code: "routeros/session-closed",
|
|
283
|
+
summary: `${host} refused the SFTP session (SSH disconnect reason 7, service not available).`,
|
|
284
|
+
remediation:
|
|
285
|
+
"RouterOS does this when the user's group lacks the `ftp` policy SFTP needs. `/user print` shows the user's group and `/user group print` its policies.",
|
|
286
|
+
context,
|
|
287
|
+
causeData: stderr,
|
|
288
|
+
});
|
|
289
|
+
}
|
|
260
290
|
if (lower.includes("no such file") || lower.includes("not found")) {
|
|
261
291
|
return new CentrsError({
|
|
262
292
|
code: "routeros/command-failed",
|
|
@@ -269,7 +299,7 @@ export class SftpClient {
|
|
|
269
299
|
}
|
|
270
300
|
return new CentrsError({
|
|
271
301
|
code: "transport/network",
|
|
272
|
-
summary: `SFTP ${op} failed against ${host
|
|
302
|
+
summary: `SFTP ${op} failed against ${formatEndpoint(host, port)} (exit ${result.exitCode}).`,
|
|
273
303
|
remediation:
|
|
274
304
|
"Re-run with `--format json` for the structured cause, and confirm SSH/SFTP access to the device.",
|
|
275
305
|
context,
|