@tikoci/centrs 0.1.7 → 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 +235 -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/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 +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 +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/tools.ts +9 -14
- 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 +727 -108
- 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;
|
|
@@ -160,6 +198,21 @@ export interface ProtocolListenOptions {
|
|
|
160
198
|
cancelGraceMs?: number;
|
|
161
199
|
/** Fired when that wait expires without the router's acknowledgement. */
|
|
162
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>;
|
|
163
216
|
}
|
|
164
217
|
|
|
165
218
|
/**
|
|
@@ -173,9 +226,14 @@ export interface ProtocolAdapter {
|
|
|
173
226
|
/** Capability flags describing what this adapter supports. */
|
|
174
227
|
readonly capabilities: ProtocolAdapterCapabilities;
|
|
175
228
|
/** `/console/inspect` probe (`request=child` or `request=completion`). */
|
|
176
|
-
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[]>;
|
|
177
235
|
/** Read a single record (singleton menu) as an object. */
|
|
178
|
-
getSingleton(path: string): Promise<unknown>;
|
|
236
|
+
getSingleton(path: string, options?: RetrieveReadOptions): Promise<unknown>;
|
|
179
237
|
/** Read a menu as an array of records, optionally projected/detailed. */
|
|
180
238
|
list(path: string, options: RetrieveListOptions): Promise<unknown[]>;
|
|
181
239
|
/** Run a CLI-shaped command (WP-1c write surface). */
|
|
@@ -183,15 +241,16 @@ export interface ProtocolAdapter {
|
|
|
183
241
|
/** Run a normalized structured `api` request (the gh-api passthrough surface). */
|
|
184
242
|
apiRequest(request: ProtocolApiRequest): Promise<ProtocolApiResult>;
|
|
185
243
|
/**
|
|
186
|
-
*
|
|
187
|
-
*
|
|
188
|
-
* `
|
|
189
|
-
* 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`.
|
|
190
249
|
*/
|
|
191
|
-
|
|
250
|
+
stream(
|
|
192
251
|
request: ProtocolApiRequest,
|
|
193
252
|
options: ProtocolListenOptions,
|
|
194
|
-
): AsyncIterable<
|
|
253
|
+
): AsyncIterable<ProtocolStreamReply>;
|
|
195
254
|
/** Release any underlying connection. Safe to call when never connected. */
|
|
196
255
|
close(): Promise<void>;
|
|
197
256
|
}
|
|
@@ -236,28 +295,48 @@ class RestAdapter implements ProtocolAdapter {
|
|
|
236
295
|
async inspect(
|
|
237
296
|
request: "child" | "completion",
|
|
238
297
|
path: string,
|
|
298
|
+
input?: string,
|
|
239
299
|
): Promise<unknown[]> {
|
|
240
|
-
return this.restPost<unknown[]>(
|
|
300
|
+
return this.restPost<unknown[]>(
|
|
301
|
+
"/console/inspect",
|
|
302
|
+
input === undefined ? { request, path } : { request, input },
|
|
303
|
+
);
|
|
241
304
|
}
|
|
242
305
|
|
|
243
|
-
async getSingleton(
|
|
244
|
-
|
|
306
|
+
async getSingleton(
|
|
307
|
+
path: string,
|
|
308
|
+
options: RetrieveReadOptions = {},
|
|
309
|
+
): Promise<unknown> {
|
|
310
|
+
return this.restGet(path, options.signal);
|
|
245
311
|
}
|
|
246
312
|
|
|
247
313
|
async list(path: string, options: RetrieveListOptions): Promise<unknown[]> {
|
|
248
|
-
const
|
|
249
|
-
(options.proplist?.length ?? 0) > 0 ||
|
|
250
|
-
|
|
251
|
-
|
|
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[];
|
|
252
320
|
}
|
|
253
|
-
const body: {
|
|
321
|
+
const body: {
|
|
322
|
+
".proplist"?: readonly string[];
|
|
323
|
+
".query"?: readonly string[];
|
|
324
|
+
detail?: string;
|
|
325
|
+
} = {};
|
|
254
326
|
if (options.proplist && options.proplist.length > 0) {
|
|
255
327
|
body[".proplist"] = options.proplist;
|
|
256
328
|
}
|
|
329
|
+
if (options.query && options.query.length > 0) {
|
|
330
|
+
body[".query"] = options.query;
|
|
331
|
+
}
|
|
257
332
|
if (options.detail) {
|
|
258
333
|
body.detail = "true";
|
|
259
334
|
}
|
|
260
|
-
return this.restPost<unknown[]>(
|
|
335
|
+
return this.restPost<unknown[]>(
|
|
336
|
+
`${path.replace(/\/$/, "")}/print`,
|
|
337
|
+
body,
|
|
338
|
+
options.signal,
|
|
339
|
+
);
|
|
261
340
|
}
|
|
262
341
|
|
|
263
342
|
async execute(
|
|
@@ -271,6 +350,21 @@ class RestAdapter implements ProtocolAdapter {
|
|
|
271
350
|
return normalizeRestExecute(data);
|
|
272
351
|
}
|
|
273
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
|
+
}
|
|
274
368
|
const data = await this.restPost<unknown>(target, request.attributes ?? {});
|
|
275
369
|
return normalizeRestExecute(data);
|
|
276
370
|
}
|
|
@@ -333,7 +427,10 @@ class RestAdapter implements ProtocolAdapter {
|
|
|
333
427
|
return { data: result.ret ?? result.records };
|
|
334
428
|
}
|
|
335
429
|
return {
|
|
336
|
-
data: await this.requestRest("POST", base,
|
|
430
|
+
data: await this.requestRest("POST", base, {
|
|
431
|
+
...request.attributes,
|
|
432
|
+
...restQueryBody(request),
|
|
433
|
+
}),
|
|
337
434
|
};
|
|
338
435
|
}
|
|
339
436
|
default:
|
|
@@ -341,15 +438,15 @@ class RestAdapter implements ProtocolAdapter {
|
|
|
341
438
|
}
|
|
342
439
|
}
|
|
343
440
|
|
|
344
|
-
// 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.
|
|
345
442
|
// biome-ignore lint/correctness/useYield: a throw-only async generator yields nothing.
|
|
346
|
-
async *
|
|
443
|
+
async *stream(): AsyncGenerator<ProtocolStreamReply> {
|
|
347
444
|
throw new CentrsError({
|
|
348
445
|
code: "transport/capability-unsupported",
|
|
349
446
|
summary: "REST cannot follow an open-ended `--stream` (60s cap).",
|
|
350
447
|
remediation:
|
|
351
448
|
"Open-ended follow is native-api only: use `--via native-api`, or drop `--stream` for a bounded one-shot.",
|
|
352
|
-
context: { protocol: this.protocol, capability: "
|
|
449
|
+
context: { protocol: this.protocol, capability: "stream" },
|
|
353
450
|
});
|
|
354
451
|
}
|
|
355
452
|
|
|
@@ -374,19 +471,21 @@ class RestAdapter implements ProtocolAdapter {
|
|
|
374
471
|
return response.data;
|
|
375
472
|
}
|
|
376
473
|
|
|
377
|
-
private async restGet(path: string): Promise<unknown> {
|
|
378
|
-
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 });
|
|
379
476
|
return response.data;
|
|
380
477
|
}
|
|
381
478
|
|
|
382
479
|
private async restPost<T = unknown>(
|
|
383
480
|
path: string,
|
|
384
481
|
body: Record<string, unknown>,
|
|
482
|
+
signal?: AbortSignal,
|
|
385
483
|
): Promise<T> {
|
|
386
484
|
const response = await this.fetchRest(path, {
|
|
387
485
|
method: "POST",
|
|
388
486
|
body: JSON.stringify(body),
|
|
389
487
|
headers: { "Content-Type": "application/json" },
|
|
488
|
+
signal,
|
|
390
489
|
});
|
|
391
490
|
return response.data as T;
|
|
392
491
|
}
|
|
@@ -394,6 +493,7 @@ class RestAdapter implements ProtocolAdapter {
|
|
|
394
493
|
private async fetchRest(
|
|
395
494
|
path: string,
|
|
396
495
|
init: RequestInit,
|
|
496
|
+
binary = false,
|
|
397
497
|
): Promise<RestResponse> {
|
|
398
498
|
const url = joinRestUrl(this.config.baseUrl, path);
|
|
399
499
|
const headers = new Headers(init.headers);
|
|
@@ -418,13 +518,25 @@ class RestAdapter implements ProtocolAdapter {
|
|
|
418
518
|
} = {
|
|
419
519
|
...init,
|
|
420
520
|
headers,
|
|
421
|
-
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
|
+
]),
|
|
422
529
|
tls: { rejectUnauthorized: this.config.insecure !== true },
|
|
423
530
|
};
|
|
424
531
|
|
|
425
532
|
try {
|
|
426
533
|
const response = await fetch(url, fetchInit);
|
|
427
|
-
|
|
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
|
+
);
|
|
428
540
|
const data = parseResponseBody(text);
|
|
429
541
|
if (!response.ok) {
|
|
430
542
|
throw this.mapHttpFailure(response.status, text, data, path);
|
|
@@ -451,7 +563,7 @@ class RestAdapter implements ProtocolAdapter {
|
|
|
451
563
|
if (status === 401 || status === 403) {
|
|
452
564
|
return new CentrsError({
|
|
453
565
|
code: "transport/auth-failed",
|
|
454
|
-
summary: `RouterOS rejected the REST credentials for ${host
|
|
566
|
+
summary: `RouterOS rejected the REST credentials for ${formatEndpoint(host, port)}.`,
|
|
455
567
|
remediation:
|
|
456
568
|
"Check `--username` / `--password` or the matching `CENTRS_*` environment variables, and confirm the user has RouterOS REST access.",
|
|
457
569
|
context: { via: protocol, host, port, path, status },
|
|
@@ -497,7 +609,7 @@ class RestAdapter implements ProtocolAdapter {
|
|
|
497
609
|
) {
|
|
498
610
|
return new CentrsError({
|
|
499
611
|
code: "transport/timeout",
|
|
500
|
-
summary: `Timed out waiting for ${protocol} to respond from ${host
|
|
612
|
+
summary: `Timed out waiting for ${protocol} to respond from ${formatEndpoint(host, port)}.`,
|
|
501
613
|
remediation:
|
|
502
614
|
"Increase `--timeout` within the REST ceiling, or confirm the host and port are reachable.",
|
|
503
615
|
context: { via: protocol, host, port, path, timeoutMs },
|
|
@@ -518,7 +630,7 @@ class RestAdapter implements ProtocolAdapter {
|
|
|
518
630
|
) {
|
|
519
631
|
return new CentrsError({
|
|
520
632
|
code: "transport/connection-refused",
|
|
521
|
-
summary: `Connection refused by ${host
|
|
633
|
+
summary: `Connection refused by ${formatEndpoint(host, port)} over ${protocol}.`,
|
|
522
634
|
remediation:
|
|
523
635
|
"Check that the RouterOS REST service is enabled on that port and that any local forwarding or firewall rules are correct.",
|
|
524
636
|
context: { via: protocol, host, port, path },
|
|
@@ -550,7 +662,7 @@ class RestAdapter implements ProtocolAdapter {
|
|
|
550
662
|
if (messages.some((candidate) => candidate.includes("certificate"))) {
|
|
551
663
|
return new CentrsError({
|
|
552
664
|
code: "transport/tls-certificate",
|
|
553
|
-
summary: `TLS certificate validation failed for ${host
|
|
665
|
+
summary: `TLS certificate validation failed for ${formatEndpoint(host, port)}.`,
|
|
554
666
|
remediation:
|
|
555
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.",
|
|
556
668
|
context: { via: protocol, host, port, path },
|
|
@@ -560,7 +672,7 @@ class RestAdapter implements ProtocolAdapter {
|
|
|
560
672
|
|
|
561
673
|
return new CentrsError({
|
|
562
674
|
code: "transport/network",
|
|
563
|
-
summary: `Network request to ${host
|
|
675
|
+
summary: `Network request to ${formatEndpoint(host, port)} failed over ${protocol}.`,
|
|
564
676
|
remediation:
|
|
565
677
|
"Check the host, port, and service availability, then re-run with `--format json` if you need the structured cause data.",
|
|
566
678
|
context: { via: protocol, host, port, path },
|
|
@@ -569,6 +681,34 @@ class RestAdapter implements ProtocolAdapter {
|
|
|
569
681
|
}
|
|
570
682
|
}
|
|
571
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
|
+
|
|
572
712
|
class NativeApiAdapter implements ProtocolAdapter {
|
|
573
713
|
readonly protocol: RouterOsProtocol;
|
|
574
714
|
readonly capabilities: ProtocolAdapterCapabilities = {
|
|
@@ -577,18 +717,23 @@ class NativeApiAdapter implements ProtocolAdapter {
|
|
|
577
717
|
inspect: true,
|
|
578
718
|
};
|
|
579
719
|
private session?: NativeApiSession;
|
|
720
|
+
private readonly onAbort = (): void => {
|
|
721
|
+
this.session?.close();
|
|
722
|
+
};
|
|
580
723
|
|
|
581
724
|
constructor(private readonly config: ProtocolAdapterConfig) {
|
|
582
725
|
this.protocol = config.protocol;
|
|
726
|
+
config.signal?.addEventListener("abort", this.onAbort, { once: true });
|
|
583
727
|
}
|
|
584
728
|
|
|
585
729
|
async inspect(
|
|
586
730
|
request: "child" | "completion",
|
|
587
731
|
path: string,
|
|
732
|
+
input?: string,
|
|
588
733
|
): Promise<unknown[]> {
|
|
589
734
|
const replies = await this.talk({
|
|
590
735
|
command: "/console/inspect",
|
|
591
|
-
attributes: { request, path },
|
|
736
|
+
attributes: input === undefined ? { request, path } : { request, input },
|
|
592
737
|
});
|
|
593
738
|
return repliesToRecords(replies);
|
|
594
739
|
}
|
|
@@ -611,6 +756,9 @@ class NativeApiAdapter implements ProtocolAdapter {
|
|
|
611
756
|
if (options.detail) {
|
|
612
757
|
command.attributes = { detail: "" };
|
|
613
758
|
}
|
|
759
|
+
if (options.query && options.query.length > 0) {
|
|
760
|
+
command.queries = options.query.map((word) => `?${word}`);
|
|
761
|
+
}
|
|
614
762
|
const replies = await this.talk(command);
|
|
615
763
|
return repliesToRecords(replies);
|
|
616
764
|
}
|
|
@@ -630,6 +778,9 @@ class NativeApiAdapter implements ProtocolAdapter {
|
|
|
630
778
|
if (request.queries && request.queries.length > 0) {
|
|
631
779
|
command.queries = request.queries;
|
|
632
780
|
}
|
|
781
|
+
if (request.binary) {
|
|
782
|
+
command.binary = true;
|
|
783
|
+
}
|
|
633
784
|
const replies = await this.talk(command);
|
|
634
785
|
return { records: repliesToRecords(replies) };
|
|
635
786
|
}
|
|
@@ -652,110 +803,60 @@ class NativeApiAdapter implements ProtocolAdapter {
|
|
|
652
803
|
}
|
|
653
804
|
|
|
654
805
|
async apiRequest(request: ProtocolApiRequest): Promise<ProtocolApiResult> {
|
|
655
|
-
const
|
|
806
|
+
const records = repliesToRecords(
|
|
807
|
+
await this.talk(nativeCommandFor(request)),
|
|
808
|
+
);
|
|
656
809
|
switch (request.verb) {
|
|
657
|
-
case "print":
|
|
658
|
-
const command: NativeApiCommand = { command: `${base}/print` };
|
|
659
|
-
const queries: string[] = [];
|
|
660
|
-
// REST `.query` words map 1:1 to native `?`-prefixed words (CHR-grounded).
|
|
661
|
-
for (const word of request.query ?? []) {
|
|
662
|
-
queries.push(`?${word}`);
|
|
663
|
-
}
|
|
664
|
-
// No native get-one-by-id shorthand: address a single row with `?.id=`.
|
|
665
|
-
if (request.id) {
|
|
666
|
-
queries.push(`?.id=${request.id}`);
|
|
667
|
-
}
|
|
668
|
-
if (queries.length > 0) {
|
|
669
|
-
command.queries = queries;
|
|
670
|
-
}
|
|
671
|
-
if (request.proplist && request.proplist.length > 0) {
|
|
672
|
-
command.proplist = request.proplist;
|
|
673
|
-
}
|
|
674
|
-
const records = repliesToRecords(await this.talk(command));
|
|
810
|
+
case "print":
|
|
675
811
|
return { data: request.id ? (records[0] ?? null) : records };
|
|
676
|
-
|
|
677
|
-
case "
|
|
678
|
-
|
|
679
|
-
await this.talk({
|
|
680
|
-
command: `${base}/add`,
|
|
681
|
-
attributes: request.attributes ?? {},
|
|
682
|
-
}),
|
|
683
|
-
);
|
|
684
|
-
return { data: restStyleMutationData(records) };
|
|
685
|
-
}
|
|
686
|
-
case "set": {
|
|
687
|
-
const records = repliesToRecords(
|
|
688
|
-
await this.talk({
|
|
689
|
-
command: `${base}/set`,
|
|
690
|
-
attributes: {
|
|
691
|
-
...(request.attributes ?? {}),
|
|
692
|
-
".id": request.id ?? "",
|
|
693
|
-
},
|
|
694
|
-
}),
|
|
695
|
-
);
|
|
696
|
-
return { data: restStyleMutationData(records) };
|
|
697
|
-
}
|
|
698
|
-
case "remove": {
|
|
699
|
-
const records = repliesToRecords(
|
|
700
|
-
await this.talk({
|
|
701
|
-
command: `${base}/remove`,
|
|
702
|
-
attributes: { ".id": request.id ?? "" },
|
|
703
|
-
}),
|
|
704
|
-
);
|
|
812
|
+
case "add":
|
|
813
|
+
case "set":
|
|
814
|
+
case "remove":
|
|
705
815
|
return { data: restStyleMutationData(records) };
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
}
|
|
712
|
-
const command: NativeApiCommand = { command: base };
|
|
713
|
-
if (request.attributes && Object.keys(request.attributes).length > 0) {
|
|
714
|
-
command.attributes = request.attributes;
|
|
715
|
-
}
|
|
716
|
-
return { data: repliesToRecords(await this.talk(command)) };
|
|
717
|
-
}
|
|
816
|
+
case "run":
|
|
817
|
+
return {
|
|
818
|
+
data:
|
|
819
|
+
request.script !== undefined ? restStyleRunData(records) : records,
|
|
820
|
+
};
|
|
718
821
|
default:
|
|
719
822
|
return exhaustiveApiVerb(request.verb);
|
|
720
823
|
}
|
|
721
824
|
}
|
|
722
825
|
|
|
723
|
-
async *
|
|
826
|
+
async *stream(
|
|
724
827
|
request: ProtocolApiRequest,
|
|
725
828
|
options: ProtocolListenOptions,
|
|
726
|
-
): AsyncGenerator<
|
|
727
|
-
|
|
728
|
-
const command: NativeApiCommand = { command: `${base}/listen` };
|
|
729
|
-
// A listen can filter which changes it follows with the same `?`-words /
|
|
730
|
-
// `=.proplist=` as print (REST `.query` word == native `?`-word minus `?`).
|
|
731
|
-
const queries = (request.query ?? []).map((word) => `?${word}`);
|
|
732
|
-
// An addressed row (`ip/address/*1 --stream`) follows just that row, the
|
|
733
|
-
// same `?.id=` mapping one-shot native reads use.
|
|
734
|
-
if (request.id) {
|
|
735
|
-
queries.push(`?.id=${request.id}`);
|
|
736
|
-
}
|
|
737
|
-
if (queries.length > 0) {
|
|
738
|
-
command.queries = queries;
|
|
739
|
-
}
|
|
740
|
-
if (request.proplist && request.proplist.length > 0) {
|
|
741
|
-
command.proplist = request.proplist;
|
|
742
|
-
}
|
|
829
|
+
): AsyncGenerator<ProtocolStreamReply> {
|
|
830
|
+
if (options.signal?.aborted) return;
|
|
743
831
|
const session = await this.connect();
|
|
744
|
-
|
|
832
|
+
const { onReply } = options;
|
|
833
|
+
for await (const reply of session.listen(nativeCommandFor(request), {
|
|
745
834
|
...options,
|
|
746
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,
|
|
747
843
|
})) {
|
|
748
|
-
|
|
749
|
-
|
|
844
|
+
yield {
|
|
845
|
+
type: reply.type === "!empty" ? "empty" : "re",
|
|
846
|
+
attributes: { ...reply.attributes },
|
|
847
|
+
...(reply.afterStop ? { afterStop: true as const } : {}),
|
|
848
|
+
};
|
|
750
849
|
}
|
|
751
850
|
}
|
|
752
851
|
|
|
753
852
|
async close(): Promise<void> {
|
|
853
|
+
this.config.signal?.removeEventListener("abort", this.onAbort);
|
|
754
854
|
this.session?.close();
|
|
755
855
|
this.session = undefined;
|
|
756
856
|
}
|
|
757
857
|
|
|
758
858
|
private async connect(): Promise<NativeApiSession> {
|
|
859
|
+
this.config.signal?.throwIfAborted();
|
|
759
860
|
if (this.session) {
|
|
760
861
|
return this.session;
|
|
761
862
|
}
|
|
@@ -769,7 +870,12 @@ class NativeApiAdapter implements ProtocolAdapter {
|
|
|
769
870
|
// RouterOS's self-signed cert. (Previously native-api always accepted.)
|
|
770
871
|
rejectUnauthorized: this.config.insecure !== true,
|
|
771
872
|
timeoutMs: this.config.timeoutMs,
|
|
873
|
+
signal: this.config.signal,
|
|
772
874
|
});
|
|
875
|
+
if (this.config.signal?.aborted) {
|
|
876
|
+
session.close();
|
|
877
|
+
this.config.signal.throwIfAborted();
|
|
878
|
+
}
|
|
773
879
|
this.session = session;
|
|
774
880
|
return session;
|
|
775
881
|
}
|
|
@@ -897,8 +1003,8 @@ class MacTelnetAdapter implements ProtocolAdapter {
|
|
|
897
1003
|
}
|
|
898
1004
|
|
|
899
1005
|
// biome-ignore lint/correctness/useYield: a reject-only async generator yields nothing.
|
|
900
|
-
async *
|
|
901
|
-
throw this.unsupported("
|
|
1006
|
+
async *stream(): AsyncGenerator<ProtocolStreamReply> {
|
|
1007
|
+
throw this.unsupported("incremental native replies");
|
|
902
1008
|
}
|
|
903
1009
|
|
|
904
1010
|
async close(): Promise<void> {
|
|
@@ -1048,8 +1154,8 @@ class SshExecAdapter implements ProtocolAdapter {
|
|
|
1048
1154
|
}
|
|
1049
1155
|
|
|
1050
1156
|
// biome-ignore lint/correctness/useYield: a reject-only async generator yields nothing.
|
|
1051
|
-
async *
|
|
1052
|
-
throw this.unsupported("
|
|
1157
|
+
async *stream(): AsyncGenerator<ProtocolStreamReply> {
|
|
1158
|
+
throw this.unsupported("incremental native replies");
|
|
1053
1159
|
}
|
|
1054
1160
|
|
|
1055
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
|
+
}
|
|
@@ -42,9 +42,10 @@
|
|
|
42
42
|
* (name-level) gate — no `/console/inspect` table parsing needed.
|
|
43
43
|
*/
|
|
44
44
|
|
|
45
|
+
import { absentMenuError } from "../absent-menu.ts";
|
|
45
46
|
import { parseRouterOsPosition } from "../core/routeros-errors.ts";
|
|
46
47
|
import { routerOsStringLiteral } from "../core/routeros-string.ts";
|
|
47
|
-
import { CentrsError } from "../errors.ts";
|
|
48
|
+
import { CentrsError, SYNTAX_REJECTED_REMEDIATION } from "../errors.ts";
|
|
48
49
|
import {
|
|
49
50
|
type MacAddress,
|
|
50
51
|
type MacTelnetDatagramSink,
|
|
@@ -681,11 +682,13 @@ export function classifyParseResult(
|
|
|
681
682
|
});
|
|
682
683
|
}
|
|
683
684
|
if (WRAPPED_BAD_COMMAND.test(output) || BARE_SYNTAX.test(output)) {
|
|
685
|
+
// An absent menu reads as a syntax error; name it instead (GH#361).
|
|
686
|
+
const absent = absentMenuError(cli, output, position, via);
|
|
687
|
+
if (absent) throw absent;
|
|
684
688
|
throw new CentrsError({
|
|
685
689
|
code: "validation/syntax",
|
|
686
690
|
summary: "RouterOS rejected the command syntax while parsing it.",
|
|
687
|
-
remediation:
|
|
688
|
-
"Fix the RouterOS CLI syntax (quotes, brackets, attribute form), then retry.",
|
|
691
|
+
remediation: SYNTAX_REJECTED_REMEDIATION,
|
|
689
692
|
context: {
|
|
690
693
|
command: cli,
|
|
691
694
|
validationSource: `:put [:parse ...] over ${via}`,
|