@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.
Files changed (82) hide show
  1. package/CHANGELOG.md +256 -0
  2. package/GLOSSARY.txt +32 -0
  3. package/README.md +2 -2
  4. package/SECURITY.md +1 -1
  5. package/commands/AGENTS.md +2 -1
  6. package/commands/api/AGENTS.md +55 -30
  7. package/commands/api/README.md +123 -41
  8. package/commands/api/examples.md +205 -17
  9. package/commands/devices/README.md +11 -3
  10. package/commands/execute/README.md +31 -3
  11. package/commands/execute/examples.md +136 -1
  12. package/commands/explain/README.md +30 -4
  13. package/commands/mcp/README.md +5 -1
  14. package/commands/mcp/examples.md +18 -1
  15. package/commands/retrieve/README.md +348 -4
  16. package/commands/retrieve/examples.md +422 -5
  17. package/commands/terminal/README.md +14 -4
  18. package/commands/terminal/examples.md +22 -3
  19. package/commands/transfer/README.md +55 -9
  20. package/commands/transfer/examples.md +106 -7
  21. package/docs/CLI.md +28 -20
  22. package/docs/CONSTITUTION.md +30 -10
  23. package/docs/MATRIX.md +38 -1
  24. package/docs/errors/README.md +3 -1
  25. package/docs/errors/input/invalid-query.md +36 -0
  26. package/docs/errors/input/local-destination.md +20 -0
  27. package/docs/errors/input/unsupported-query.md +19 -0
  28. package/docs/errors/internal/byte-string.md +13 -0
  29. package/docs/errors/settings/invalid-timeout.md +6 -0
  30. package/docs/errors/transport/stream-overflow.md +21 -0
  31. package/docs/errors/usage/removed-flag.md +20 -0
  32. package/docs/errors/validation/menu-unavailable.md +22 -0
  33. package/docs/errors/validation/not-followable.md +23 -0
  34. package/docs/errors/validation/package-missing.md +29 -0
  35. package/docs/errors/validation/unknown-path.md +16 -4
  36. package/docs/errors/wait/deadline-exceeded.md +14 -0
  37. package/docs/errors/wait/interrupted.md +10 -0
  38. package/package.json +14 -11
  39. package/src/absent-menu.ts +280 -0
  40. package/src/api-fanout.ts +22 -12
  41. package/src/api.ts +511 -186
  42. package/src/btest.ts +19 -17
  43. package/src/cli/api.ts +126 -34
  44. package/src/cli/execute.ts +18 -5
  45. package/src/cli/retrieve.ts +223 -22
  46. package/src/cli/terminal.ts +10 -5
  47. package/src/cli/transfer.ts +23 -8
  48. package/src/core/envelope.ts +14 -0
  49. package/src/core/error-catalog.ts +56 -1
  50. package/src/core/inspect.ts +43 -5
  51. package/src/core/query.ts +508 -0
  52. package/src/core/routeros-errors.ts +1 -1
  53. package/src/devices.ts +6 -9
  54. package/src/discover.ts +7 -8
  55. package/src/errors.ts +116 -7
  56. package/src/execute-fanout.ts +13 -8
  57. package/src/execute.ts +106 -65
  58. package/src/explain/catalog.ts +58 -10
  59. package/src/explain/menus.ts +23 -2
  60. package/src/explain.ts +6 -6
  61. package/src/index.ts +29 -1
  62. package/src/mcp/safety.ts +43 -18
  63. package/src/mcp/tools.ts +14 -17
  64. package/src/protocols/adapter.ts +224 -118
  65. package/src/protocols/btest-session.ts +3 -2
  66. package/src/protocols/endpoint.ts +10 -0
  67. package/src/protocols/mac-telnet-console.ts +6 -3
  68. package/src/protocols/native-api.ts +223 -70
  69. package/src/protocols/sftp.ts +44 -14
  70. package/src/protocols/ssh.ts +42 -14
  71. package/src/resolver/cdb.ts +28 -7
  72. package/src/resolver/index.ts +1 -0
  73. package/src/resolver/target.ts +53 -7
  74. package/src/retrieve-fanout.ts +51 -12
  75. package/src/retrieve-follow.ts +862 -0
  76. package/src/retrieve-sample.ts +244 -0
  77. package/src/retrieve-stream.ts +115 -0
  78. package/src/retrieve-wait.ts +286 -0
  79. package/src/retrieve.ts +732 -108
  80. package/src/settings.ts +15 -15
  81. package/src/transfer-fanout.ts +40 -15
  82. 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 `Bun.connect`.
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
- /** Encode a single word: length prefix followed by its UTF-8 bytes. */
148
- export function encodeWord(word: string): Uint8Array {
149
- const payload = utf8Encoder.encode(word);
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(words: readonly string[]): Uint8Array {
159
- const parts = words.map(encodeWord);
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 (arrays of decoded words) as they complete. Partial words and
174
- * partial length prefixes are buffered until the rest of the bytes arrive.
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: string[] = [];
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: string[][] = [];
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
- this.current.push(
208
- utf8Decoder.decode(this.buffer.subarray(wordStart, wordEnd)),
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
- * A long-lived `/listen` subscription. Unlike {@link PendingCommand} it never
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 the stream ends on `!done` (after a `/cancel`'s `interrupted`
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` change frame arrived. */
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: string[][];
485
+ let sentences: Uint8Array[][];
408
486
  try {
409
- sentences = this.reader.push(chunk);
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.dispatch(parseReply(words));
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
- * Open a `/listen` subscription and yield each `!re` change frame as it
628
- * arrives (the open-ended follow path; the listen command never sends `!done`
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 non-`interrupted` trap (e.g. a bad path) or `!fatal`/transport closure
637
- * throws. CHR-grounded (7.23.1): see `commands/api/AGENTS.md`.
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
- queue.push(reply);
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 = `${options.host}:${port}`;
989
+ const endpoint = formatEndpoint(options.host, port);
871
990
  const timeoutMs = options.timeoutMs ?? 15_000;
991
+ options.signal?.throwIfAborted();
872
992
 
873
- let session: NativeApiSession | undefined;
874
- const connectPromise = Bun.connect({
875
- hostname: options.host,
876
- port,
877
- ...(options.tls
878
- ? {
879
- tls: {
880
- rejectUnauthorized: options.rejectUnauthorized ?? false,
881
- },
882
- }
883
- : {}),
884
- socket: {
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.end();
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
- // Never leave a half-open socket when login times out or is rejected.
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
  }
@@ -26,7 +26,13 @@
26
26
  */
27
27
 
28
28
  import { CentrsError } from "../errors.ts";
29
- import { sshCommonOptions, sshUserHost } from "./ssh.ts";
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
- lower.includes("permission denied") ||
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
- "Provide a key the device trusts via `--ssh-key` / the ssh-agent, and confirm the RouterOS user has `ftp`/`ssh` policy. RouterOS refuses password login once an SSH key is set for the user.",
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}:${port} was refused.`,
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}:${port} timed out.`,
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}:${port} (exit ${result.exitCode}).`,
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,