@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
package/src/protocols/adapter.ts
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
|
|
17
17
|
import { mapRouterOsError } from "../core/routeros-errors.ts";
|
|
18
18
|
import { CentrsError } from "../errors.ts";
|
|
19
|
+
import { formatEndpoint } from "./endpoint.ts";
|
|
19
20
|
import type { RouterOsProtocol } from "./index.ts";
|
|
20
21
|
import {
|
|
21
22
|
createUdpMacTelnetTransport,
|
|
@@ -28,6 +29,7 @@ import {
|
|
|
28
29
|
import { MacTelnetConsole } from "./mac-telnet-console.ts";
|
|
29
30
|
import {
|
|
30
31
|
type ApiReply,
|
|
32
|
+
byteStringBytes,
|
|
31
33
|
connectNativeApi,
|
|
32
34
|
type NativeApiCommand,
|
|
33
35
|
type NativeApiSession,
|
|
@@ -52,6 +54,8 @@ export interface ProtocolAdapterConfig {
|
|
|
52
54
|
password: string;
|
|
53
55
|
/** Per-command timeout in milliseconds. */
|
|
54
56
|
timeoutMs: number;
|
|
57
|
+
/** Abandon every operation, including validation and native connection/login. */
|
|
58
|
+
signal?: AbortSignal;
|
|
55
59
|
/** Target device MAC, for L2 transports (mac-telnet). */
|
|
56
60
|
mac?: string;
|
|
57
61
|
/** Client (in-packet source) MAC for mac-telnet; a synthetic one is used when omitted. */
|
|
@@ -67,9 +71,20 @@ export interface ProtocolAdapterConfig {
|
|
|
67
71
|
}
|
|
68
72
|
|
|
69
73
|
/** Projection/detail options for a menu `list`. */
|
|
70
|
-
export interface RetrieveListOptions {
|
|
74
|
+
export interface RetrieveListOptions extends RetrieveReadOptions {
|
|
71
75
|
proplist?: readonly string[];
|
|
72
76
|
detail?: boolean;
|
|
77
|
+
/** Row filter in REST `.query` form (no leading `?`); native prefixes each with `?`. */
|
|
78
|
+
query?: readonly string[];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Options every retrieve read takes. */
|
|
82
|
+
export interface RetrieveReadOptions {
|
|
83
|
+
/**
|
|
84
|
+
* Abandon the read: REST aborts the HTTP request at once. Native reads
|
|
85
|
+
* share the session, so they end when the adapter is closed.
|
|
86
|
+
*/
|
|
87
|
+
signal?: AbortSignal;
|
|
73
88
|
}
|
|
74
89
|
|
|
75
90
|
/** What an adapter can do. Lets callers pick a transport per capability. */
|
|
@@ -96,6 +111,14 @@ export interface ProtocolExecuteRequest {
|
|
|
96
111
|
queries?: readonly string[];
|
|
97
112
|
/** Raw CLI line for the `/rest/execute` script fallback. */
|
|
98
113
|
script?: string;
|
|
114
|
+
/**
|
|
115
|
+
* Attribute values and reply strings are byte strings, one code unit per
|
|
116
|
+
* byte (0–255), instead of UTF-8 text. RouterOS carries `/file` contents
|
|
117
|
+
* as raw bytes on both wires, so this is how `transfer` moves arbitrary
|
|
118
|
+
* bytes (#404); a non-ASCII value such as a file name must be passed as its
|
|
119
|
+
* UTF-8 byte string. Honored by REST and native-api; not with `script`.
|
|
120
|
+
*/
|
|
121
|
+
binary?: boolean;
|
|
99
122
|
}
|
|
100
123
|
|
|
101
124
|
/** Result of an `execute` round-trip. */
|
|
@@ -134,6 +157,21 @@ export interface ProtocolApiRequest {
|
|
|
134
157
|
proplist?: readonly string[];
|
|
135
158
|
/** Raw console line for the `/execute` script form (`run`); when set, `path`/`attributes` are ignored. */
|
|
136
159
|
script?: string;
|
|
160
|
+
/** `print` only: send the menu's `listen` command instead (the literal `<menu>/listen` endpoint). */
|
|
161
|
+
listen?: boolean;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* One reply of an incremental native command, as RouterOS sent it: an `!re`
|
|
166
|
+
* row, or an `!empty` (zero rows: an empty finite read, a zero-row
|
|
167
|
+
* `print interval=` tick, or a listen cancelled before it sent anything).
|
|
168
|
+
*/
|
|
169
|
+
export interface ProtocolStreamReply {
|
|
170
|
+
type: "re" | "empty";
|
|
171
|
+
/** The row's attributes; always `{}` for `!empty`. */
|
|
172
|
+
attributes: Record<string, string>;
|
|
173
|
+
/** The reply arrived after `options.signal` aborted (see `ApiReply.afterStop`). */
|
|
174
|
+
afterStop?: true;
|
|
137
175
|
}
|
|
138
176
|
|
|
139
177
|
/** Result of an `apiRequest` round-trip: the response body shaped for the envelope. */
|
|
@@ -142,7 +180,7 @@ export interface ProtocolApiResult {
|
|
|
142
180
|
data: unknown;
|
|
143
181
|
}
|
|
144
182
|
|
|
145
|
-
/** Options for
|
|
183
|
+
/** Options for incremental native command replies. */
|
|
146
184
|
export interface ProtocolListenOptions {
|
|
147
185
|
/** Abort the listen (sends `/cancel`); the generator then ends cleanly. */
|
|
148
186
|
signal?: AbortSignal;
|
|
@@ -152,6 +190,29 @@ export interface ProtocolListenOptions {
|
|
|
152
190
|
* after the follow is established, instead of a blind timer.
|
|
153
191
|
*/
|
|
154
192
|
onListening?: () => void;
|
|
193
|
+
/**
|
|
194
|
+
* How long to wait for RouterOS to acknowledge `/cancel` after `signal`
|
|
195
|
+
* aborts before ending the listen locally. Native API defaults to the
|
|
196
|
+
* adapter's `timeoutMs`, the same bound as any other reply wait.
|
|
197
|
+
*/
|
|
198
|
+
cancelGraceMs?: number;
|
|
199
|
+
/** Fired when that wait expires without the router's acknowledgement. */
|
|
200
|
+
onCancelUnacknowledged?: () => void;
|
|
201
|
+
/** Attributes carried by the command's terminal `!done`, not a row frame. */
|
|
202
|
+
onDone?: (attributes: Record<string, string>) => void;
|
|
203
|
+
/**
|
|
204
|
+
* Every reply (`re`, `empty`, `trap`, `done`), called synchronously in wire
|
|
205
|
+
* order as it arrives, before the generator yields it. Concurrent streams
|
|
206
|
+
* on one connection can be ordered against each other only this way; the
|
|
207
|
+
* generators' own interleaving depends on microtask scheduling.
|
|
208
|
+
*/
|
|
209
|
+
onReply?: (reply: ProtocolTappedReply) => void;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** A reply seen by {@link ProtocolListenOptions.onReply}. */
|
|
213
|
+
export interface ProtocolTappedReply {
|
|
214
|
+
type: "re" | "empty" | "trap" | "done";
|
|
215
|
+
attributes: Record<string, string>;
|
|
155
216
|
}
|
|
156
217
|
|
|
157
218
|
/**
|
|
@@ -165,9 +226,14 @@ export interface ProtocolAdapter {
|
|
|
165
226
|
/** Capability flags describing what this adapter supports. */
|
|
166
227
|
readonly capabilities: ProtocolAdapterCapabilities;
|
|
167
228
|
/** `/console/inspect` probe (`request=child` or `request=completion`). */
|
|
168
|
-
inspect
|
|
229
|
+
/** `/console/inspect`; `input` is the CLI-text form (`request=completion input=…`). */
|
|
230
|
+
inspect(
|
|
231
|
+
request: "child" | "completion",
|
|
232
|
+
path: string,
|
|
233
|
+
input?: string,
|
|
234
|
+
): Promise<unknown[]>;
|
|
169
235
|
/** Read a single record (singleton menu) as an object. */
|
|
170
|
-
getSingleton(path: string): Promise<unknown>;
|
|
236
|
+
getSingleton(path: string, options?: RetrieveReadOptions): Promise<unknown>;
|
|
171
237
|
/** Read a menu as an array of records, optionally projected/detailed. */
|
|
172
238
|
list(path: string, options: RetrieveListOptions): Promise<unknown[]>;
|
|
173
239
|
/** Run a CLI-shaped command (WP-1c write surface). */
|
|
@@ -175,15 +241,16 @@ export interface ProtocolAdapter {
|
|
|
175
241
|
/** Run a normalized structured `api` request (the gh-api passthrough surface). */
|
|
176
242
|
apiRequest(request: ProtocolApiRequest): Promise<ProtocolApiResult>;
|
|
177
243
|
/**
|
|
178
|
-
*
|
|
179
|
-
*
|
|
180
|
-
* `
|
|
181
|
-
* reject with
|
|
244
|
+
* Incremental native replies to the literal command (the same mapping as
|
|
245
|
+
* `apiRequest`; never rewritten into a `listen`). Yields each `!re` and
|
|
246
|
+
* `!empty` as it arrives, until natural completion, cancellation, or
|
|
247
|
+
* failure. REST and console transports reject with
|
|
248
|
+
* `transport/capability-unsupported`.
|
|
182
249
|
*/
|
|
183
|
-
|
|
250
|
+
stream(
|
|
184
251
|
request: ProtocolApiRequest,
|
|
185
252
|
options: ProtocolListenOptions,
|
|
186
|
-
): AsyncIterable<
|
|
253
|
+
): AsyncIterable<ProtocolStreamReply>;
|
|
187
254
|
/** Release any underlying connection. Safe to call when never connected. */
|
|
188
255
|
close(): Promise<void>;
|
|
189
256
|
}
|
|
@@ -228,28 +295,48 @@ class RestAdapter implements ProtocolAdapter {
|
|
|
228
295
|
async inspect(
|
|
229
296
|
request: "child" | "completion",
|
|
230
297
|
path: string,
|
|
298
|
+
input?: string,
|
|
231
299
|
): Promise<unknown[]> {
|
|
232
|
-
return this.restPost<unknown[]>(
|
|
300
|
+
return this.restPost<unknown[]>(
|
|
301
|
+
"/console/inspect",
|
|
302
|
+
input === undefined ? { request, path } : { request, input },
|
|
303
|
+
);
|
|
233
304
|
}
|
|
234
305
|
|
|
235
|
-
async getSingleton(
|
|
236
|
-
|
|
306
|
+
async getSingleton(
|
|
307
|
+
path: string,
|
|
308
|
+
options: RetrieveReadOptions = {},
|
|
309
|
+
): Promise<unknown> {
|
|
310
|
+
return this.restGet(path, options.signal);
|
|
237
311
|
}
|
|
238
312
|
|
|
239
313
|
async list(path: string, options: RetrieveListOptions): Promise<unknown[]> {
|
|
240
|
-
const
|
|
241
|
-
(options.proplist?.length ?? 0) > 0 ||
|
|
242
|
-
|
|
243
|
-
|
|
314
|
+
const hasBody =
|
|
315
|
+
(options.proplist?.length ?? 0) > 0 ||
|
|
316
|
+
(options.query?.length ?? 0) > 0 ||
|
|
317
|
+
options.detail === true;
|
|
318
|
+
if (!hasBody) {
|
|
319
|
+
return (await this.restGet(path, options.signal)) as unknown[];
|
|
244
320
|
}
|
|
245
|
-
const body: {
|
|
321
|
+
const body: {
|
|
322
|
+
".proplist"?: readonly string[];
|
|
323
|
+
".query"?: readonly string[];
|
|
324
|
+
detail?: string;
|
|
325
|
+
} = {};
|
|
246
326
|
if (options.proplist && options.proplist.length > 0) {
|
|
247
327
|
body[".proplist"] = options.proplist;
|
|
248
328
|
}
|
|
329
|
+
if (options.query && options.query.length > 0) {
|
|
330
|
+
body[".query"] = options.query;
|
|
331
|
+
}
|
|
249
332
|
if (options.detail) {
|
|
250
333
|
body.detail = "true";
|
|
251
334
|
}
|
|
252
|
-
return this.restPost<unknown[]>(
|
|
335
|
+
return this.restPost<unknown[]>(
|
|
336
|
+
`${path.replace(/\/$/, "")}/print`,
|
|
337
|
+
body,
|
|
338
|
+
options.signal,
|
|
339
|
+
);
|
|
253
340
|
}
|
|
254
341
|
|
|
255
342
|
async execute(
|
|
@@ -263,6 +350,21 @@ class RestAdapter implements ProtocolAdapter {
|
|
|
263
350
|
return normalizeRestExecute(data);
|
|
264
351
|
}
|
|
265
352
|
const target = `${request.path.replace(/\/$/, "")}/${request.command}`;
|
|
353
|
+
if (request.binary) {
|
|
354
|
+
// RouterOS stores the UTF-8 encoding of each JSON code point, so a
|
|
355
|
+
// `\u0080` escape lands as `c2 80`; only raw bytes in the body are
|
|
356
|
+
// exact. Replies carry 0x80–0xff unescaped too (CHR, #404).
|
|
357
|
+
const response = await this.fetchRest(
|
|
358
|
+
target,
|
|
359
|
+
{
|
|
360
|
+
method: "POST",
|
|
361
|
+
body: byteStringBytes(JSON.stringify(request.attributes ?? {})),
|
|
362
|
+
headers: { "Content-Type": "application/json" },
|
|
363
|
+
},
|
|
364
|
+
true,
|
|
365
|
+
);
|
|
366
|
+
return normalizeRestExecute(response.data);
|
|
367
|
+
}
|
|
266
368
|
const data = await this.restPost<unknown>(target, request.attributes ?? {});
|
|
267
369
|
return normalizeRestExecute(data);
|
|
268
370
|
}
|
|
@@ -325,7 +427,10 @@ class RestAdapter implements ProtocolAdapter {
|
|
|
325
427
|
return { data: result.ret ?? result.records };
|
|
326
428
|
}
|
|
327
429
|
return {
|
|
328
|
-
data: await this.requestRest("POST", base,
|
|
430
|
+
data: await this.requestRest("POST", base, {
|
|
431
|
+
...request.attributes,
|
|
432
|
+
...restQueryBody(request),
|
|
433
|
+
}),
|
|
329
434
|
};
|
|
330
435
|
}
|
|
331
436
|
default:
|
|
@@ -333,15 +438,15 @@ class RestAdapter implements ProtocolAdapter {
|
|
|
333
438
|
}
|
|
334
439
|
}
|
|
335
440
|
|
|
336
|
-
// REST cannot hold an open-ended follow (the 60s cap);
|
|
441
|
+
// REST cannot hold an open-ended follow (the 60s cap); streaming is native-only.
|
|
337
442
|
// biome-ignore lint/correctness/useYield: a throw-only async generator yields nothing.
|
|
338
|
-
async *
|
|
443
|
+
async *stream(): AsyncGenerator<ProtocolStreamReply> {
|
|
339
444
|
throw new CentrsError({
|
|
340
445
|
code: "transport/capability-unsupported",
|
|
341
446
|
summary: "REST cannot follow an open-ended `--stream` (60s cap).",
|
|
342
447
|
remediation:
|
|
343
448
|
"Open-ended follow is native-api only: use `--via native-api`, or drop `--stream` for a bounded one-shot.",
|
|
344
|
-
context: { protocol: this.protocol, capability: "
|
|
449
|
+
context: { protocol: this.protocol, capability: "stream" },
|
|
345
450
|
});
|
|
346
451
|
}
|
|
347
452
|
|
|
@@ -366,19 +471,21 @@ class RestAdapter implements ProtocolAdapter {
|
|
|
366
471
|
return response.data;
|
|
367
472
|
}
|
|
368
473
|
|
|
369
|
-
private async restGet(path: string): Promise<unknown> {
|
|
370
|
-
const response = await this.fetchRest(path, { method: "GET" });
|
|
474
|
+
private async restGet(path: string, signal?: AbortSignal): Promise<unknown> {
|
|
475
|
+
const response = await this.fetchRest(path, { method: "GET", signal });
|
|
371
476
|
return response.data;
|
|
372
477
|
}
|
|
373
478
|
|
|
374
479
|
private async restPost<T = unknown>(
|
|
375
480
|
path: string,
|
|
376
481
|
body: Record<string, unknown>,
|
|
482
|
+
signal?: AbortSignal,
|
|
377
483
|
): Promise<T> {
|
|
378
484
|
const response = await this.fetchRest(path, {
|
|
379
485
|
method: "POST",
|
|
380
486
|
body: JSON.stringify(body),
|
|
381
487
|
headers: { "Content-Type": "application/json" },
|
|
488
|
+
signal,
|
|
382
489
|
});
|
|
383
490
|
return response.data as T;
|
|
384
491
|
}
|
|
@@ -386,6 +493,7 @@ class RestAdapter implements ProtocolAdapter {
|
|
|
386
493
|
private async fetchRest(
|
|
387
494
|
path: string,
|
|
388
495
|
init: RequestInit,
|
|
496
|
+
binary = false,
|
|
389
497
|
): Promise<RestResponse> {
|
|
390
498
|
const url = joinRestUrl(this.config.baseUrl, path);
|
|
391
499
|
const headers = new Headers(init.headers);
|
|
@@ -410,13 +518,25 @@ class RestAdapter implements ProtocolAdapter {
|
|
|
410
518
|
} = {
|
|
411
519
|
...init,
|
|
412
520
|
headers,
|
|
413
|
-
signal
|
|
521
|
+
// A caller's signal abandons the request; the timeout still applies.
|
|
522
|
+
signal: AbortSignal.any([
|
|
523
|
+
controller.signal,
|
|
524
|
+
...[init.signal, this.config.signal].filter(
|
|
525
|
+
(signal): signal is AbortSignal =>
|
|
526
|
+
signal !== undefined && signal !== null,
|
|
527
|
+
),
|
|
528
|
+
]),
|
|
414
529
|
tls: { rejectUnauthorized: this.config.insecure !== true },
|
|
415
530
|
};
|
|
416
531
|
|
|
417
532
|
try {
|
|
418
533
|
const response = await fetch(url, fetchInit);
|
|
419
|
-
|
|
534
|
+
// A binary reply decodes as a byte string through Buffer's latin1;
|
|
535
|
+
// WHATWG latin1 is windows-1252 and loses 0x80–0x9f. Errors stay UTF-8
|
|
536
|
+
// text for the message.
|
|
537
|
+
const text = Buffer.from(await response.arrayBuffer()).toString(
|
|
538
|
+
binary && response.ok ? "latin1" : "utf8",
|
|
539
|
+
);
|
|
420
540
|
const data = parseResponseBody(text);
|
|
421
541
|
if (!response.ok) {
|
|
422
542
|
throw this.mapHttpFailure(response.status, text, data, path);
|
|
@@ -443,7 +563,7 @@ class RestAdapter implements ProtocolAdapter {
|
|
|
443
563
|
if (status === 401 || status === 403) {
|
|
444
564
|
return new CentrsError({
|
|
445
565
|
code: "transport/auth-failed",
|
|
446
|
-
summary: `RouterOS rejected the REST credentials for ${host
|
|
566
|
+
summary: `RouterOS rejected the REST credentials for ${formatEndpoint(host, port)}.`,
|
|
447
567
|
remediation:
|
|
448
568
|
"Check `--username` / `--password` or the matching `CENTRS_*` environment variables, and confirm the user has RouterOS REST access.",
|
|
449
569
|
context: { via: protocol, host, port, path, status },
|
|
@@ -489,7 +609,7 @@ class RestAdapter implements ProtocolAdapter {
|
|
|
489
609
|
) {
|
|
490
610
|
return new CentrsError({
|
|
491
611
|
code: "transport/timeout",
|
|
492
|
-
summary: `Timed out waiting for ${protocol} to respond from ${host
|
|
612
|
+
summary: `Timed out waiting for ${protocol} to respond from ${formatEndpoint(host, port)}.`,
|
|
493
613
|
remediation:
|
|
494
614
|
"Increase `--timeout` within the REST ceiling, or confirm the host and port are reachable.",
|
|
495
615
|
context: { via: protocol, host, port, path, timeoutMs },
|
|
@@ -510,7 +630,7 @@ class RestAdapter implements ProtocolAdapter {
|
|
|
510
630
|
) {
|
|
511
631
|
return new CentrsError({
|
|
512
632
|
code: "transport/connection-refused",
|
|
513
|
-
summary: `Connection refused by ${host
|
|
633
|
+
summary: `Connection refused by ${formatEndpoint(host, port)} over ${protocol}.`,
|
|
514
634
|
remediation:
|
|
515
635
|
"Check that the RouterOS REST service is enabled on that port and that any local forwarding or firewall rules are correct.",
|
|
516
636
|
context: { via: protocol, host, port, path },
|
|
@@ -542,7 +662,7 @@ class RestAdapter implements ProtocolAdapter {
|
|
|
542
662
|
if (messages.some((candidate) => candidate.includes("certificate"))) {
|
|
543
663
|
return new CentrsError({
|
|
544
664
|
code: "transport/tls-certificate",
|
|
545
|
-
summary: `TLS certificate validation failed for ${host
|
|
665
|
+
summary: `TLS certificate validation failed for ${formatEndpoint(host, port)}.`,
|
|
546
666
|
remediation:
|
|
547
667
|
"RouterOS ships a self-signed certificate by default. Install a trusted chain, or pass `--insecure` (`CENTRS_INSECURE=1`) to accept it; an HTTP URL also avoids TLS.",
|
|
548
668
|
context: { via: protocol, host, port, path },
|
|
@@ -552,7 +672,7 @@ class RestAdapter implements ProtocolAdapter {
|
|
|
552
672
|
|
|
553
673
|
return new CentrsError({
|
|
554
674
|
code: "transport/network",
|
|
555
|
-
summary: `Network request to ${host
|
|
675
|
+
summary: `Network request to ${formatEndpoint(host, port)} failed over ${protocol}.`,
|
|
556
676
|
remediation:
|
|
557
677
|
"Check the host, port, and service availability, then re-run with `--format json` if you need the structured cause data.",
|
|
558
678
|
context: { via: protocol, host, port, path },
|
|
@@ -561,6 +681,34 @@ class RestAdapter implements ProtocolAdapter {
|
|
|
561
681
|
}
|
|
562
682
|
}
|
|
563
683
|
|
|
684
|
+
/** One native sentence shape for one-shot and incremental API requests. */
|
|
685
|
+
function nativeCommandFor(request: ProtocolApiRequest): NativeApiCommand {
|
|
686
|
+
const base = request.path.replace(/\/$/, "");
|
|
687
|
+
const command: NativeApiCommand = {
|
|
688
|
+
command:
|
|
689
|
+
request.script !== undefined
|
|
690
|
+
? "/execute"
|
|
691
|
+
: request.verb === "run"
|
|
692
|
+
? base
|
|
693
|
+
: request.verb === "print" && request.listen
|
|
694
|
+
? `${base}/listen`
|
|
695
|
+
: `${base}/${request.verb}`,
|
|
696
|
+
attributes:
|
|
697
|
+
request.script !== undefined
|
|
698
|
+
? { script: request.script, "as-string": "" }
|
|
699
|
+
: { ...request.attributes },
|
|
700
|
+
};
|
|
701
|
+
if (request.verb === "set" || request.verb === "remove") {
|
|
702
|
+
command.attributes = { ...command.attributes, ".id": request.id ?? "" };
|
|
703
|
+
}
|
|
704
|
+
const queries = (request.query ?? []).map((word) => `?${word}`);
|
|
705
|
+
if (request.id && request.verb === "print")
|
|
706
|
+
queries.push(`?.id=${request.id}`);
|
|
707
|
+
if (queries.length > 0) command.queries = queries;
|
|
708
|
+
if (request.proplist?.length) command.proplist = request.proplist;
|
|
709
|
+
return command;
|
|
710
|
+
}
|
|
711
|
+
|
|
564
712
|
class NativeApiAdapter implements ProtocolAdapter {
|
|
565
713
|
readonly protocol: RouterOsProtocol;
|
|
566
714
|
readonly capabilities: ProtocolAdapterCapabilities = {
|
|
@@ -569,18 +717,23 @@ class NativeApiAdapter implements ProtocolAdapter {
|
|
|
569
717
|
inspect: true,
|
|
570
718
|
};
|
|
571
719
|
private session?: NativeApiSession;
|
|
720
|
+
private readonly onAbort = (): void => {
|
|
721
|
+
this.session?.close();
|
|
722
|
+
};
|
|
572
723
|
|
|
573
724
|
constructor(private readonly config: ProtocolAdapterConfig) {
|
|
574
725
|
this.protocol = config.protocol;
|
|
726
|
+
config.signal?.addEventListener("abort", this.onAbort, { once: true });
|
|
575
727
|
}
|
|
576
728
|
|
|
577
729
|
async inspect(
|
|
578
730
|
request: "child" | "completion",
|
|
579
731
|
path: string,
|
|
732
|
+
input?: string,
|
|
580
733
|
): Promise<unknown[]> {
|
|
581
734
|
const replies = await this.talk({
|
|
582
735
|
command: "/console/inspect",
|
|
583
|
-
attributes: { request, path },
|
|
736
|
+
attributes: input === undefined ? { request, path } : { request, input },
|
|
584
737
|
});
|
|
585
738
|
return repliesToRecords(replies);
|
|
586
739
|
}
|
|
@@ -603,6 +756,9 @@ class NativeApiAdapter implements ProtocolAdapter {
|
|
|
603
756
|
if (options.detail) {
|
|
604
757
|
command.attributes = { detail: "" };
|
|
605
758
|
}
|
|
759
|
+
if (options.query && options.query.length > 0) {
|
|
760
|
+
command.queries = options.query.map((word) => `?${word}`);
|
|
761
|
+
}
|
|
606
762
|
const replies = await this.talk(command);
|
|
607
763
|
return repliesToRecords(replies);
|
|
608
764
|
}
|
|
@@ -622,6 +778,9 @@ class NativeApiAdapter implements ProtocolAdapter {
|
|
|
622
778
|
if (request.queries && request.queries.length > 0) {
|
|
623
779
|
command.queries = request.queries;
|
|
624
780
|
}
|
|
781
|
+
if (request.binary) {
|
|
782
|
+
command.binary = true;
|
|
783
|
+
}
|
|
625
784
|
const replies = await this.talk(command);
|
|
626
785
|
return { records: repliesToRecords(replies) };
|
|
627
786
|
}
|
|
@@ -644,107 +803,60 @@ class NativeApiAdapter implements ProtocolAdapter {
|
|
|
644
803
|
}
|
|
645
804
|
|
|
646
805
|
async apiRequest(request: ProtocolApiRequest): Promise<ProtocolApiResult> {
|
|
647
|
-
const
|
|
806
|
+
const records = repliesToRecords(
|
|
807
|
+
await this.talk(nativeCommandFor(request)),
|
|
808
|
+
);
|
|
648
809
|
switch (request.verb) {
|
|
649
|
-
case "print":
|
|
650
|
-
const command: NativeApiCommand = { command: `${base}/print` };
|
|
651
|
-
const queries: string[] = [];
|
|
652
|
-
// REST `.query` words map 1:1 to native `?`-prefixed words (CHR-grounded).
|
|
653
|
-
for (const word of request.query ?? []) {
|
|
654
|
-
queries.push(`?${word}`);
|
|
655
|
-
}
|
|
656
|
-
// No native get-one-by-id shorthand: address a single row with `?.id=`.
|
|
657
|
-
if (request.id) {
|
|
658
|
-
queries.push(`?.id=${request.id}`);
|
|
659
|
-
}
|
|
660
|
-
if (queries.length > 0) {
|
|
661
|
-
command.queries = queries;
|
|
662
|
-
}
|
|
663
|
-
if (request.proplist && request.proplist.length > 0) {
|
|
664
|
-
command.proplist = request.proplist;
|
|
665
|
-
}
|
|
666
|
-
const records = repliesToRecords(await this.talk(command));
|
|
810
|
+
case "print":
|
|
667
811
|
return { data: request.id ? (records[0] ?? null) : records };
|
|
668
|
-
|
|
669
|
-
case "
|
|
670
|
-
|
|
671
|
-
await this.talk({
|
|
672
|
-
command: `${base}/add`,
|
|
673
|
-
attributes: request.attributes ?? {},
|
|
674
|
-
}),
|
|
675
|
-
);
|
|
676
|
-
return { data: restStyleMutationData(records) };
|
|
677
|
-
}
|
|
678
|
-
case "set": {
|
|
679
|
-
const records = repliesToRecords(
|
|
680
|
-
await this.talk({
|
|
681
|
-
command: `${base}/set`,
|
|
682
|
-
attributes: {
|
|
683
|
-
...(request.attributes ?? {}),
|
|
684
|
-
".id": request.id ?? "",
|
|
685
|
-
},
|
|
686
|
-
}),
|
|
687
|
-
);
|
|
688
|
-
return { data: restStyleMutationData(records) };
|
|
689
|
-
}
|
|
690
|
-
case "remove": {
|
|
691
|
-
const records = repliesToRecords(
|
|
692
|
-
await this.talk({
|
|
693
|
-
command: `${base}/remove`,
|
|
694
|
-
attributes: { ".id": request.id ?? "" },
|
|
695
|
-
}),
|
|
696
|
-
);
|
|
812
|
+
case "add":
|
|
813
|
+
case "set":
|
|
814
|
+
case "remove":
|
|
697
815
|
return { data: restStyleMutationData(records) };
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
}
|
|
704
|
-
const command: NativeApiCommand = { command: base };
|
|
705
|
-
if (request.attributes && Object.keys(request.attributes).length > 0) {
|
|
706
|
-
command.attributes = request.attributes;
|
|
707
|
-
}
|
|
708
|
-
return { data: repliesToRecords(await this.talk(command)) };
|
|
709
|
-
}
|
|
816
|
+
case "run":
|
|
817
|
+
return {
|
|
818
|
+
data:
|
|
819
|
+
request.script !== undefined ? restStyleRunData(records) : records,
|
|
820
|
+
};
|
|
710
821
|
default:
|
|
711
822
|
return exhaustiveApiVerb(request.verb);
|
|
712
823
|
}
|
|
713
824
|
}
|
|
714
825
|
|
|
715
|
-
async *
|
|
826
|
+
async *stream(
|
|
716
827
|
request: ProtocolApiRequest,
|
|
717
828
|
options: ProtocolListenOptions,
|
|
718
|
-
): AsyncGenerator<
|
|
719
|
-
|
|
720
|
-
const command: NativeApiCommand = { command: `${base}/listen` };
|
|
721
|
-
// A listen can filter which changes it follows with the same `?`-words /
|
|
722
|
-
// `=.proplist=` as print (REST `.query` word == native `?`-word minus `?`).
|
|
723
|
-
const queries = (request.query ?? []).map((word) => `?${word}`);
|
|
724
|
-
// An addressed row (`ip/address/*1 --stream`) follows just that row, the
|
|
725
|
-
// same `?.id=` mapping one-shot native reads use.
|
|
726
|
-
if (request.id) {
|
|
727
|
-
queries.push(`?.id=${request.id}`);
|
|
728
|
-
}
|
|
729
|
-
if (queries.length > 0) {
|
|
730
|
-
command.queries = queries;
|
|
731
|
-
}
|
|
732
|
-
if (request.proplist && request.proplist.length > 0) {
|
|
733
|
-
command.proplist = request.proplist;
|
|
734
|
-
}
|
|
829
|
+
): AsyncGenerator<ProtocolStreamReply> {
|
|
830
|
+
if (options.signal?.aborted) return;
|
|
735
831
|
const session = await this.connect();
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
832
|
+
const { onReply } = options;
|
|
833
|
+
for await (const reply of session.listen(nativeCommandFor(request), {
|
|
834
|
+
...options,
|
|
835
|
+
cancelGraceMs: options.cancelGraceMs ?? this.config.timeoutMs,
|
|
836
|
+
onReply: onReply
|
|
837
|
+
? (reply) =>
|
|
838
|
+
onReply({
|
|
839
|
+
type: reply.type.slice(1) as ProtocolTappedReply["type"],
|
|
840
|
+
attributes: { ...reply.attributes },
|
|
841
|
+
})
|
|
842
|
+
: undefined,
|
|
843
|
+
})) {
|
|
844
|
+
yield {
|
|
845
|
+
type: reply.type === "!empty" ? "empty" : "re",
|
|
846
|
+
attributes: { ...reply.attributes },
|
|
847
|
+
...(reply.afterStop ? { afterStop: true as const } : {}),
|
|
848
|
+
};
|
|
739
849
|
}
|
|
740
850
|
}
|
|
741
851
|
|
|
742
852
|
async close(): Promise<void> {
|
|
853
|
+
this.config.signal?.removeEventListener("abort", this.onAbort);
|
|
743
854
|
this.session?.close();
|
|
744
855
|
this.session = undefined;
|
|
745
856
|
}
|
|
746
857
|
|
|
747
858
|
private async connect(): Promise<NativeApiSession> {
|
|
859
|
+
this.config.signal?.throwIfAborted();
|
|
748
860
|
if (this.session) {
|
|
749
861
|
return this.session;
|
|
750
862
|
}
|
|
@@ -758,7 +870,12 @@ class NativeApiAdapter implements ProtocolAdapter {
|
|
|
758
870
|
// RouterOS's self-signed cert. (Previously native-api always accepted.)
|
|
759
871
|
rejectUnauthorized: this.config.insecure !== true,
|
|
760
872
|
timeoutMs: this.config.timeoutMs,
|
|
873
|
+
signal: this.config.signal,
|
|
761
874
|
});
|
|
875
|
+
if (this.config.signal?.aborted) {
|
|
876
|
+
session.close();
|
|
877
|
+
this.config.signal.throwIfAborted();
|
|
878
|
+
}
|
|
762
879
|
this.session = session;
|
|
763
880
|
return session;
|
|
764
881
|
}
|
|
@@ -886,8 +1003,8 @@ class MacTelnetAdapter implements ProtocolAdapter {
|
|
|
886
1003
|
}
|
|
887
1004
|
|
|
888
1005
|
// biome-ignore lint/correctness/useYield: a reject-only async generator yields nothing.
|
|
889
|
-
async *
|
|
890
|
-
throw this.unsupported("
|
|
1006
|
+
async *stream(): AsyncGenerator<ProtocolStreamReply> {
|
|
1007
|
+
throw this.unsupported("incremental native replies");
|
|
891
1008
|
}
|
|
892
1009
|
|
|
893
1010
|
async close(): Promise<void> {
|
|
@@ -1037,8 +1154,8 @@ class SshExecAdapter implements ProtocolAdapter {
|
|
|
1037
1154
|
}
|
|
1038
1155
|
|
|
1039
1156
|
// biome-ignore lint/correctness/useYield: a reject-only async generator yields nothing.
|
|
1040
|
-
async *
|
|
1041
|
-
throw this.unsupported("
|
|
1157
|
+
async *stream(): AsyncGenerator<ProtocolStreamReply> {
|
|
1158
|
+
throw this.unsupported("incremental native replies");
|
|
1042
1159
|
}
|
|
1043
1160
|
|
|
1044
1161
|
close(): Promise<void> {
|
|
@@ -80,6 +80,7 @@ import {
|
|
|
80
80
|
ecSrp5ServerPublicKey,
|
|
81
81
|
ecSrp5ServerShared,
|
|
82
82
|
} from "./ec-srp5.ts";
|
|
83
|
+
import { formatEndpoint } from "./endpoint.ts";
|
|
83
84
|
|
|
84
85
|
/** Default status-exchange cadence (RouterOS uses ~1s). */
|
|
85
86
|
export const BTEST_STATUS_INTERVAL_MS = 1000;
|
|
@@ -320,7 +321,7 @@ export function connectBtestControl(
|
|
|
320
321
|
reject(
|
|
321
322
|
new CentrsError({
|
|
322
323
|
code: "transport/connection-refused",
|
|
323
|
-
summary: `No btest server answered on ${host
|
|
324
|
+
summary: `No btest server answered on ${formatEndpoint(host, port)}.`,
|
|
324
325
|
remediation:
|
|
325
326
|
"Enable /tool/bandwidth-server on the device (or start `centrs btest server`) and check the host/--port.",
|
|
326
327
|
context: { host, port, cause: code },
|
|
@@ -332,7 +333,7 @@ export function connectBtestControl(
|
|
|
332
333
|
reject(
|
|
333
334
|
new CentrsError({
|
|
334
335
|
code: "transport/network",
|
|
335
|
-
summary: `Failed to open the btest control connection to ${host
|
|
336
|
+
summary: `Failed to open the btest control connection to ${formatEndpoint(host, port)}.`,
|
|
336
337
|
remediation: "Check the host, --port, and network reachability.",
|
|
337
338
|
context: { host, port, cause: code },
|
|
338
339
|
cause: error,
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `host:port` for messages and endpoint labels. An IPv6 host is bracketed
|
|
3
|
+
* (`[2001:db8::5]:9`), because a bare `2001:db8::5:9` reads as another address.
|
|
4
|
+
* Sockets still take the bare host.
|
|
5
|
+
*/
|
|
6
|
+
export function formatEndpoint(host: string, port: number): string {
|
|
7
|
+
const bracketed =
|
|
8
|
+
host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
|
|
9
|
+
return `${bracketed}:${port}`;
|
|
10
|
+
}
|