@tikoci/centrs 0.1.9-next.23 → 0.1.9-next.24
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 +106 -0
- package/GLOSSARY.txt +14 -0
- package/README.md +2 -2
- package/commands/api/AGENTS.md +18 -1
- package/commands/api/README.md +10 -6
- package/commands/api/examples.md +1 -1
- package/commands/execute/README.md +26 -6
- package/commands/execute/examples.md +24 -5
- package/commands/explain/README.md +452 -61
- package/commands/explain/examples.md +191 -41
- package/commands/retrieve/README.md +13 -2
- package/commands/retrieve/examples.md +30 -1
- package/commands/transfer/README.md +8 -4
- package/docs/CLI.md +19 -5
- package/docs/CONSTITUTION.md +14 -9
- package/docs/MATRIX.md +34 -21
- package/docs/errors/cmr/caller-policy-not-enforced.md +5 -3
- package/docs/errors/routeros/permission-denied.md +24 -0
- package/docs/errors/transport/edge-auth-failed.md +22 -0
- package/docs/errors/transport/write-outcome-unknown.md +3 -0
- package/docs/errors/usage/invalid-cursor.md +20 -0
- package/docs/errors/validation/unknown-path.md +9 -0
- package/package.json +2 -2
- package/src/api.ts +34 -39
- package/src/cli/explain.ts +206 -47
- package/src/core/envelope.ts +10 -3
- package/src/core/error-catalog.ts +15 -0
- package/src/core/execute-command.ts +53 -0
- package/src/core/fanout.ts +12 -2
- package/src/core/inspect.ts +4 -5
- package/src/core/routeros-errors.ts +25 -0
- package/src/execute-cmr.ts +2 -8
- package/src/execute.ts +48 -20
- package/src/explain/args.ts +115 -10
- package/src/explain/group-faults.ts +162 -29
- package/src/explain/operators.ts +2 -1
- package/src/explain/scope-brace.ts +226 -11
- package/src/explain/segment.ts +136 -1
- package/src/explain/symbols.ts +146 -6
- package/src/explain/values.ts +55 -4
- package/src/explain-live-facets.ts +739 -0
- package/src/explain-live-types.ts +490 -0
- package/src/explain-live.ts +593 -0
- package/src/explain.ts +395 -43
- package/src/index.ts +6 -0
- package/src/protocols/adapter.ts +88 -10
- package/src/protocols/mac-telnet.ts +38 -10
- package/src/protocols/native-api.ts +13 -12
- package/src/retrieve-follow.ts +2 -1
- package/src/retrieve-sample.ts +2 -1
- package/src/retrieve-wait.ts +10 -6
- package/src/retrieve.ts +77 -27
- package/src/transfer.ts +38 -13
package/src/protocols/adapter.ts
CHANGED
|
@@ -235,10 +235,10 @@ export interface ProtocolAdapter {
|
|
|
235
235
|
readonly protocol: RouterOsProtocol;
|
|
236
236
|
/** Capability flags describing what this adapter supports. */
|
|
237
237
|
readonly capabilities: ProtocolAdapterCapabilities;
|
|
238
|
-
/** `/console/inspect`
|
|
239
|
-
/**
|
|
238
|
+
/** `/console/inspect` discovery, completion, syntax help or byte highlight. */
|
|
239
|
+
/** `input` is CLI text for completion or highlight. */
|
|
240
240
|
inspect(
|
|
241
|
-
request: "child" | "completion",
|
|
241
|
+
request: "child" | "completion" | "highlight" | "syntax",
|
|
242
242
|
path: string,
|
|
243
243
|
input?: string,
|
|
244
244
|
): Promise<unknown[]>;
|
|
@@ -309,7 +309,7 @@ class RestAdapter implements ProtocolAdapter {
|
|
|
309
309
|
}
|
|
310
310
|
|
|
311
311
|
async inspect(
|
|
312
|
-
request: "child" | "completion",
|
|
312
|
+
request: "child" | "completion" | "highlight" | "syntax",
|
|
313
313
|
path: string,
|
|
314
314
|
input?: string,
|
|
315
315
|
): Promise<unknown[]> {
|
|
@@ -562,7 +562,13 @@ class RestAdapter implements ProtocolAdapter {
|
|
|
562
562
|
);
|
|
563
563
|
const data = parseResponseBody(text);
|
|
564
564
|
if (!response.ok) {
|
|
565
|
-
throw this.mapHttpFailure(
|
|
565
|
+
throw this.mapHttpFailure(
|
|
566
|
+
response.status,
|
|
567
|
+
text,
|
|
568
|
+
data,
|
|
569
|
+
path,
|
|
570
|
+
response.headers,
|
|
571
|
+
);
|
|
566
572
|
}
|
|
567
573
|
|
|
568
574
|
return { status: response.status, text, data };
|
|
@@ -581,19 +587,54 @@ class RestAdapter implements ProtocolAdapter {
|
|
|
581
587
|
text: string,
|
|
582
588
|
data: unknown,
|
|
583
589
|
path: string,
|
|
590
|
+
headers: Headers,
|
|
584
591
|
): CentrsError {
|
|
585
592
|
const { protocol, host, port } = this.config;
|
|
586
593
|
if (status === 401 || status === 403) {
|
|
594
|
+
if (!isRouterOsAuthReply(status, data, headers)) {
|
|
595
|
+
return new CentrsError({
|
|
596
|
+
code: "transport/edge-auth-failed",
|
|
597
|
+
summary: `An HTTP layer in front of RouterOS at ${formatEndpoint(host, port)} refused the request (HTTP ${status}); RouterOS did not answer it.`,
|
|
598
|
+
remediation:
|
|
599
|
+
"An auth proxy, tunnel edge, or WAF wants its own credentials, and centrs sends only RouterOS Basic auth. Point `--host` at an address that reaches RouterOS directly, or at a local proxy that authenticates to that layer for you. Changing the RouterOS username or password will not help.",
|
|
600
|
+
context: {
|
|
601
|
+
via: protocol,
|
|
602
|
+
host,
|
|
603
|
+
port,
|
|
604
|
+
path,
|
|
605
|
+
status,
|
|
606
|
+
...edgeEvidence(headers),
|
|
607
|
+
},
|
|
608
|
+
causeData: data ?? text,
|
|
609
|
+
});
|
|
610
|
+
}
|
|
587
611
|
return new CentrsError({
|
|
588
612
|
code: "transport/auth-failed",
|
|
589
613
|
summary: `RouterOS rejected the REST credentials for ${formatEndpoint(host, port)}.`,
|
|
590
614
|
remediation:
|
|
591
|
-
"Check `--username` / `--password` or the matching `CENTRS_*` environment variables
|
|
615
|
+
"Check `--username` / `--password` or the matching `CENTRS_*` environment variables. RouterOS answers the same 401 when the user's group lacks the `rest-api` policy or the user's `address` excludes this client.",
|
|
592
616
|
context: { via: protocol, host, port, path, status },
|
|
593
617
|
causeData: data ?? text,
|
|
594
618
|
});
|
|
595
619
|
}
|
|
596
620
|
|
|
621
|
+
const detail = isPlainObject(data)
|
|
622
|
+
? readRecordString(data, "detail")
|
|
623
|
+
: undefined;
|
|
624
|
+
if (detail !== undefined) {
|
|
625
|
+
// RouterOS reports a policy refusal as HTTP 500 with a `(9)` detail.
|
|
626
|
+
// It is a deterministic router answer, not a dropped connection, so it
|
|
627
|
+
// must not reach the 5xx retry path below.
|
|
628
|
+
const mapped = mapRouterOsError(detail, {
|
|
629
|
+
transport: "rest-api",
|
|
630
|
+
httpStatus: status,
|
|
631
|
+
context: { via: protocol, host, port, path, status },
|
|
632
|
+
});
|
|
633
|
+
if (mapped.code === "routeros/permission-denied") {
|
|
634
|
+
return mapped;
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
597
638
|
if (status >= 500 && status <= 599) {
|
|
598
639
|
// A 5xx is a transient server/connection drop (RouterOS REST under
|
|
599
640
|
// fanout load drops a fraction of parallel POSTs). Classify it as a
|
|
@@ -610,9 +651,6 @@ class RestAdapter implements ProtocolAdapter {
|
|
|
610
651
|
});
|
|
611
652
|
}
|
|
612
653
|
|
|
613
|
-
const detail = isPlainObject(data)
|
|
614
|
-
? readRecordString(data, "detail")
|
|
615
|
-
: undefined;
|
|
616
654
|
const rawRouterOsError = detail ?? text;
|
|
617
655
|
return mapRouterOsError(rawRouterOsError, {
|
|
618
656
|
transport: "rest-api",
|
|
@@ -750,7 +788,7 @@ class NativeApiAdapter implements ProtocolAdapter {
|
|
|
750
788
|
}
|
|
751
789
|
|
|
752
790
|
async inspect(
|
|
753
|
-
request: "child" | "completion",
|
|
791
|
+
request: "child" | "completion" | "highlight" | "syntax",
|
|
754
792
|
path: string,
|
|
755
793
|
input?: string,
|
|
756
794
|
): Promise<unknown[]> {
|
|
@@ -1360,6 +1398,46 @@ function collectTransportSignalsInto(
|
|
|
1360
1398
|
}
|
|
1361
1399
|
}
|
|
1362
1400
|
|
|
1401
|
+
/**
|
|
1402
|
+
* True when a REST 401/403 reply has RouterOS's own shape. RouterOS answers
|
|
1403
|
+
* every auth refusal (wrong password, no `rest-api` policy, `address`
|
|
1404
|
+
* mismatch) with a JSON body `{"error":401,...}`, adding
|
|
1405
|
+
* `WWW-Authenticate: Basic` only when no credentials were sent. Measured on
|
|
1406
|
+
* CHR 7.12.1, 7.16.2, 7.23.8, 7.25, 7.26beta1 and 7.26beta3. Any other body,
|
|
1407
|
+
* or a challenge for another scheme (Cloudflare Access sends `Bearer`), came
|
|
1408
|
+
* from a layer in front of RouterOS. A `cf-ray` header alone proves nothing:
|
|
1409
|
+
* a tunnel passes RouterOS's own 401 through with it.
|
|
1410
|
+
*/
|
|
1411
|
+
function isRouterOsAuthReply(
|
|
1412
|
+
status: number,
|
|
1413
|
+
data: unknown,
|
|
1414
|
+
headers: Headers,
|
|
1415
|
+
): boolean {
|
|
1416
|
+
const challenge = headers.get("www-authenticate");
|
|
1417
|
+
if (challenge !== null && !/^\s*basic\b/i.test(challenge)) {
|
|
1418
|
+
return false;
|
|
1419
|
+
}
|
|
1420
|
+
return isPlainObject(data) && data["error"] === status;
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
/** Non-secret header facts that show which layer refused a request. */
|
|
1424
|
+
function edgeEvidence(headers: Headers): {
|
|
1425
|
+
challengeScheme?: string;
|
|
1426
|
+
server?: string;
|
|
1427
|
+
contentType?: string;
|
|
1428
|
+
cfRay?: "present";
|
|
1429
|
+
} {
|
|
1430
|
+
const evidence: ReturnType<typeof edgeEvidence> = {};
|
|
1431
|
+
const scheme = headers.get("www-authenticate")?.trim().split(/\s+/)[0];
|
|
1432
|
+
if (scheme) evidence.challengeScheme = scheme;
|
|
1433
|
+
const server = headers.get("server");
|
|
1434
|
+
if (server) evidence.server = server;
|
|
1435
|
+
const contentType = headers.get("content-type");
|
|
1436
|
+
if (contentType) evidence.contentType = contentType;
|
|
1437
|
+
if (headers.has("cf-ray")) evidence.cfRay = "present";
|
|
1438
|
+
return evidence;
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1363
1441
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
1364
1442
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1365
1443
|
}
|
|
@@ -546,6 +546,39 @@ async function egressAddressFor(
|
|
|
546
546
|
});
|
|
547
547
|
}
|
|
548
548
|
|
|
549
|
+
/**
|
|
550
|
+
* Pure egress-MAC selection over an injected interface list: the decision
|
|
551
|
+
* {@link resolveEgressMac} makes, without touching the host's NICs, so unit
|
|
552
|
+
* tests can pin it with stubbed interfaces instead of depending on the
|
|
553
|
+
* runner's network setup.
|
|
554
|
+
*
|
|
555
|
+
* A broadcast host owns no route: `connect()` to one reports local
|
|
556
|
+
* `0.0.0.0`, so match it to the interface whose directed broadcast it is
|
|
557
|
+
* instead. Without this, an explicit `--host <broadcast>` falls back to a
|
|
558
|
+
* synthetic source MAC, which RouterOS's mac-server silently ignores
|
|
559
|
+
* (orca 2026-10-26: 30s login timeout vs 7.26beta3 hardware despite
|
|
560
|
+
* mac-server=all and proven L2 adjacency; CHR answers synthetic MACs, so
|
|
561
|
+
* the integration suite stayed green). The limited `255.255.255.255`
|
|
562
|
+
* belongs to no one interface and still falls through to undefined — the
|
|
563
|
+
* default-broadcast path sprays discovery instead. On a tie (two NICs on
|
|
564
|
+
* one subnet sharing a broadcast) the first match wins, which may not be
|
|
565
|
+
* the route's pick; the discovery spray remains the way to probe every NIC.
|
|
566
|
+
*/
|
|
567
|
+
export function selectEgressMac(
|
|
568
|
+
host: string,
|
|
569
|
+
interfaces: readonly BroadcastInterface[],
|
|
570
|
+
localAddr: string | undefined,
|
|
571
|
+
): MacAddress | undefined {
|
|
572
|
+
const owner = interfaces.find((ifc) => ifc.broadcast === host);
|
|
573
|
+
if (owner !== undefined) {
|
|
574
|
+
return owner.mac;
|
|
575
|
+
}
|
|
576
|
+
if (!localAddr || localAddr === "0.0.0.0") {
|
|
577
|
+
return undefined;
|
|
578
|
+
}
|
|
579
|
+
return interfaces.find((ifc) => ifc.address === localAddr)?.mac;
|
|
580
|
+
}
|
|
581
|
+
|
|
549
582
|
/**
|
|
550
583
|
* Resolve the **real MAC of the interface that egresses toward `host:port`**, for
|
|
551
584
|
* use as the in-packet source MAC.
|
|
@@ -561,16 +594,11 @@ export async function resolveEgressMac(
|
|
|
561
594
|
host: string,
|
|
562
595
|
port: number,
|
|
563
596
|
): Promise<MacAddress | undefined> {
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
if (ifc.address === localAddr) {
|
|
570
|
-
return ifc.mac;
|
|
571
|
-
}
|
|
572
|
-
}
|
|
573
|
-
return undefined;
|
|
597
|
+
return selectEgressMac(
|
|
598
|
+
host,
|
|
599
|
+
listBroadcastInterfaces(),
|
|
600
|
+
await egressAddressFor(host, port),
|
|
601
|
+
);
|
|
574
602
|
}
|
|
575
603
|
|
|
576
604
|
/** Which interface (source MAC + delivery host) reaches a target over MAC-Telnet. */
|
|
@@ -345,19 +345,20 @@ export function readAttribute(
|
|
|
345
345
|
/**
|
|
346
346
|
* Classify a native-API `!trap` as an authentication failure.
|
|
347
347
|
*
|
|
348
|
-
*
|
|
349
|
-
*
|
|
350
|
-
*
|
|
351
|
-
*
|
|
352
|
-
*
|
|
353
|
-
*
|
|
348
|
+
* Only the login exchange (`/login`) can fail authentication: a trap there
|
|
349
|
+
* means bad credentials (or a dead RADIUS backend), whatever its text —
|
|
350
|
+
* grounded on live RouterOS CHR output ("invalid user name or password",
|
|
351
|
+
* "could not authenticate - radius timeout"). Traps from any later sentence
|
|
352
|
+
* are RouterOS command answers and classify under `routeros/*` regardless of
|
|
353
|
+
* text: matching login-like words on every command misreported `/user/add
|
|
354
|
+
* name=-2` ("invalid user name") as an auth failure though login had succeeded
|
|
355
|
+
* (#540), while REST mapped the same string to `routeros/command-failed`.
|
|
354
356
|
*/
|
|
355
|
-
export function isNativeAuthFailure(
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
);
|
|
357
|
+
export function isNativeAuthFailure(
|
|
358
|
+
command: string,
|
|
359
|
+
_message: string,
|
|
360
|
+
): boolean {
|
|
361
|
+
return command === "/login";
|
|
361
362
|
}
|
|
362
363
|
|
|
363
364
|
/**
|
package/src/retrieve-follow.ts
CHANGED
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
import {
|
|
28
28
|
buildRetrieveErrorEnvelope,
|
|
29
29
|
buildRetrieveErrorEnvelopeFromResolved,
|
|
30
|
+
notReachedValidationMeta,
|
|
30
31
|
type ResolvedRetrieveRequest,
|
|
31
32
|
type RetrieveFollowCounts,
|
|
32
33
|
type RetrieveFollowLine,
|
|
@@ -176,7 +177,7 @@ export async function* retrieveFollow(
|
|
|
176
177
|
if (options.signal?.aborted) {
|
|
177
178
|
yield interruptedSummary(
|
|
178
179
|
resolved,
|
|
179
|
-
|
|
180
|
+
notReachedValidationMeta(resolved, "interrupted"),
|
|
180
181
|
startedAt,
|
|
181
182
|
);
|
|
182
183
|
return;
|
package/src/retrieve-sample.ts
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
buildRetrieveErrorEnvelope,
|
|
17
17
|
buildRetrieveErrorEnvelopeFromResolved,
|
|
18
18
|
executeRetrieve,
|
|
19
|
+
notReachedValidationMeta,
|
|
19
20
|
type ResolvedRetrieveRequest,
|
|
20
21
|
type RetrieveRequest,
|
|
21
22
|
type RetrieveSampleStopReason,
|
|
@@ -85,7 +86,7 @@ export async function* retrieveSample(
|
|
|
85
86
|
if (options.signal?.aborted) {
|
|
86
87
|
yield summaryEnvelope(
|
|
87
88
|
resolved,
|
|
88
|
-
|
|
89
|
+
notReachedValidationMeta(resolved, "interrupted"),
|
|
89
90
|
{ stopReason: "interrupted", samples: 0 },
|
|
90
91
|
startedAt,
|
|
91
92
|
);
|
package/src/retrieve-wait.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
/** Bounded readiness/condition reads; the router evaluates the shared predicate. */
|
|
2
|
+
import type { EnvelopeValidationMeta } from "./core/envelope.ts";
|
|
2
3
|
import { parseQueries, queryErrorForFlag } from "./core/query.ts";
|
|
3
4
|
import { CentrsError } from "./errors.ts";
|
|
4
5
|
import {
|
|
@@ -17,6 +18,7 @@ import {
|
|
|
17
18
|
type RetrieveRequest,
|
|
18
19
|
type RetrieveWaitMeta,
|
|
19
20
|
resolveRetrieveRequest,
|
|
21
|
+
stoppedBeforeGateAnswer,
|
|
20
22
|
validateRetrieveRead,
|
|
21
23
|
} from "./retrieve.ts";
|
|
22
24
|
|
|
@@ -72,6 +74,9 @@ export async function retrieveWait(
|
|
|
72
74
|
let observations = 0;
|
|
73
75
|
let backend: ProtocolAdapter | undefined;
|
|
74
76
|
let prepared: Awaited<ReturnType<typeof validateRetrieveRead>> | undefined;
|
|
77
|
+
// A retry drops `prepared` with its backend, but a gate that passed once
|
|
78
|
+
// stays passed: a later failure that is not a gate answer keeps it (#537).
|
|
79
|
+
let settled: EnvelopeValidationMeta | undefined;
|
|
75
80
|
let lastError: unknown;
|
|
76
81
|
const meta = (
|
|
77
82
|
stopReason: RetrieveWaitMeta["stopReason"],
|
|
@@ -105,12 +110,10 @@ export async function retrieveWait(
|
|
|
105
110
|
)
|
|
106
111
|
error = queryErrorForFlag(error, "--until");
|
|
107
112
|
const envelope = buildRetrieveErrorEnvelopeFromResolved(resolved, error);
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
envelope.
|
|
111
|
-
|
|
112
|
-
result: "skipped",
|
|
113
|
-
};
|
|
113
|
+
const validation =
|
|
114
|
+
prepared?.validation ??
|
|
115
|
+
(stoppedBeforeGateAnswer(envelope.error.code) ? settled : undefined);
|
|
116
|
+
if (validation) envelope.meta.validation = validation;
|
|
114
117
|
if (envelope.meta.operation) envelope.meta.operation.wait = meta(reason);
|
|
115
118
|
return envelope;
|
|
116
119
|
};
|
|
@@ -151,6 +154,7 @@ export async function retrieveWait(
|
|
|
151
154
|
signal: controller.signal,
|
|
152
155
|
});
|
|
153
156
|
prepared ??= await validateRetrieveRead(observationRequest, backend);
|
|
157
|
+
settled = prepared.validation;
|
|
154
158
|
if (
|
|
155
159
|
settings.untilEmpty &&
|
|
156
160
|
(prepared.inspection?.singleton ??
|
package/src/retrieve.ts
CHANGED
|
@@ -455,12 +455,10 @@ export async function runResolvedRetrieve(
|
|
|
455
455
|
await inspectRetrievePath(resolved, backend),
|
|
456
456
|
backend,
|
|
457
457
|
);
|
|
458
|
-
const validation
|
|
459
|
-
|
|
460
|
-
source: "live /console/inspect request=child+completion",
|
|
461
|
-
result: "passed",
|
|
458
|
+
const validation = retrieveValidationPassed(
|
|
459
|
+
"live /console/inspect request=child+completion",
|
|
462
460
|
availableAttributes,
|
|
463
|
-
|
|
461
|
+
);
|
|
464
462
|
trace.validation = validation;
|
|
465
463
|
const envelope = buildSuccessEnvelope(
|
|
466
464
|
resolved,
|
|
@@ -533,14 +531,26 @@ export async function validateRetrieveRead(
|
|
|
533
531
|
return {
|
|
534
532
|
inspection,
|
|
535
533
|
query,
|
|
536
|
-
validation:
|
|
537
|
-
|
|
538
|
-
source: availableAttributes
|
|
534
|
+
validation: retrieveValidationPassed(
|
|
535
|
+
availableAttributes
|
|
539
536
|
? "live /console/inspect request=child+completion"
|
|
540
537
|
: "live /console/inspect request=child",
|
|
541
|
-
result: "passed",
|
|
542
538
|
availableAttributes,
|
|
543
|
-
|
|
539
|
+
),
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/** retrieve's gate is the device stage alone; a path read has no CLI string to analyze. */
|
|
544
|
+
function retrieveValidationPassed(
|
|
545
|
+
source: string,
|
|
546
|
+
availableAttributes: readonly string[] | undefined,
|
|
547
|
+
): EnvelopeValidationMeta {
|
|
548
|
+
return {
|
|
549
|
+
enabled: true,
|
|
550
|
+
source,
|
|
551
|
+
result: "passed",
|
|
552
|
+
availableAttributes,
|
|
553
|
+
stages: [{ stage: "device", source, result: "passed" }],
|
|
544
554
|
};
|
|
545
555
|
}
|
|
546
556
|
|
|
@@ -625,29 +635,20 @@ const RETRIEVE_DEVICE_STAGE_SOURCE = "live /console/inspect";
|
|
|
625
635
|
* an inspect answer: `validation/*`, and the singleton `--query` refusal
|
|
626
636
|
* (`usage/conflicting-flags`). A `transport/*` or `auth/*` failure means the
|
|
627
637
|
* device never answered; a `routeros/*` error is a device answer that is not
|
|
628
|
-
* a verdict; `internal/*` is a centrs defect.
|
|
638
|
+
* a verdict; `internal/*` is a centrs defect. A `wait/*` error ends a `--wait`
|
|
639
|
+
* whose gate never settled; one that settled reports its own result instead
|
|
640
|
+
* (#537). Those five report no `result`.
|
|
629
641
|
*/
|
|
630
642
|
function unsettledValidationMeta(
|
|
631
643
|
resolved: ResolvedRetrieveRequest,
|
|
632
644
|
error: CentrsError,
|
|
633
645
|
): EnvelopeValidationMeta {
|
|
646
|
+
if (stoppedBeforeGateAnswer(error.code)) {
|
|
647
|
+
return notReachedValidationMeta(resolved, error.code);
|
|
648
|
+
}
|
|
634
649
|
if (!resolved.validate.value) {
|
|
635
650
|
return { enabled: false, source: "disabled" };
|
|
636
651
|
}
|
|
637
|
-
if (/^(transport|auth|routeros|internal)\//.test(error.code)) {
|
|
638
|
-
return {
|
|
639
|
-
enabled: true,
|
|
640
|
-
source: RETRIEVE_DEVICE_STAGE_SOURCE,
|
|
641
|
-
stages: [
|
|
642
|
-
{
|
|
643
|
-
stage: "device",
|
|
644
|
-
source: RETRIEVE_DEVICE_STAGE_SOURCE,
|
|
645
|
-
result: "skipped",
|
|
646
|
-
reason: notReachedReason(error.code),
|
|
647
|
-
},
|
|
648
|
-
],
|
|
649
|
-
};
|
|
650
|
-
}
|
|
651
652
|
return {
|
|
652
653
|
enabled: true,
|
|
653
654
|
source: RETRIEVE_DEVICE_STAGE_SOURCE,
|
|
@@ -662,6 +663,37 @@ function unsettledValidationMeta(
|
|
|
662
663
|
};
|
|
663
664
|
}
|
|
664
665
|
|
|
666
|
+
/** Whether an error code stops a run without being a gate answer (GH#435, #537). */
|
|
667
|
+
export function stoppedBeforeGateAnswer(code: string): boolean {
|
|
668
|
+
return /^(transport|auth|routeros|internal|wait)\//.test(code);
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/**
|
|
672
|
+
* `meta.validation` when the gate was never reached. `cause` is the error code
|
|
673
|
+
* that stopped it, or `interrupted` for a caller's cancellation, which has no
|
|
674
|
+
* code (#537).
|
|
675
|
+
*/
|
|
676
|
+
export function notReachedValidationMeta(
|
|
677
|
+
resolved: ResolvedRetrieveRequest,
|
|
678
|
+
cause: string,
|
|
679
|
+
): EnvelopeValidationMeta {
|
|
680
|
+
if (!resolved.validate.value) {
|
|
681
|
+
return { enabled: false, source: "disabled" };
|
|
682
|
+
}
|
|
683
|
+
return {
|
|
684
|
+
enabled: true,
|
|
685
|
+
source: RETRIEVE_DEVICE_STAGE_SOURCE,
|
|
686
|
+
stages: [
|
|
687
|
+
{
|
|
688
|
+
stage: "device",
|
|
689
|
+
source: RETRIEVE_DEVICE_STAGE_SOURCE,
|
|
690
|
+
result: "skipped",
|
|
691
|
+
reason: notReachedReason(cause),
|
|
692
|
+
},
|
|
693
|
+
],
|
|
694
|
+
};
|
|
695
|
+
}
|
|
696
|
+
|
|
665
697
|
export function renderRetrieveEnvelope(
|
|
666
698
|
envelope: RetrieveEnvelope,
|
|
667
699
|
format: RetrieveOutputFormat,
|
|
@@ -1499,8 +1531,7 @@ export async function inspectRetrievePath(
|
|
|
1499
1531
|
throw new CentrsError({
|
|
1500
1532
|
code: "validation/unknown-path",
|
|
1501
1533
|
summary: `RouterOS path ${resolved.path} does not expose a retrieve command.`,
|
|
1502
|
-
remediation:
|
|
1503
|
-
"Check the slash-prefixed RouterOS path, or use a known readable path such as `/system/resource`, `/system/identity`, `/ip/address`, or `/interface`.",
|
|
1534
|
+
remediation: retrieveUnknownPathRemediation(resolved.path),
|
|
1504
1535
|
context: {
|
|
1505
1536
|
path: resolved.path,
|
|
1506
1537
|
validationSource: "/console/inspect request=child",
|
|
@@ -1526,6 +1557,25 @@ export async function inspectRetrievePath(
|
|
|
1526
1557
|
};
|
|
1527
1558
|
}
|
|
1528
1559
|
|
|
1560
|
+
/**
|
|
1561
|
+
* The `validation/unknown-path` remediation for a retrieve read. One path
|
|
1562
|
+
* earns a targeted hint: `/system/routerboard` exists only on MikroTik
|
|
1563
|
+
* hardware, so its absence on CHR and x86 systems is expected rather than a
|
|
1564
|
+
* typo — and the caller's likely want, the device's license ID, lives at
|
|
1565
|
+
* `/system/license` instead (`system-id` on CHR, `software-id` on x86; manual
|
|
1566
|
+
* "RouterOS License Keys", CLI Reference `/system/license`). The example
|
|
1567
|
+
* reads the whole object because a projected attribute would fail on one
|
|
1568
|
+
* platform or the other. The hint is static on purpose: on hardware the menu
|
|
1569
|
+
* always validates, so this branch only fires where the menu is genuinely
|
|
1570
|
+
* absent, and no extra round trip is needed to say so.
|
|
1571
|
+
*/
|
|
1572
|
+
export function retrieveUnknownPathRemediation(path: string): string {
|
|
1573
|
+
if (path.replace(/\/$/, "").toLowerCase() === "/system/routerboard") {
|
|
1574
|
+
return "This device exposes no /system/routerboard menu — expected on CHR and x86 systems, which have no routerboard. For this device's license ID, read /system/license instead: `centrs retrieve <router> /system/license` (system-id on CHR, software-id on x86).";
|
|
1575
|
+
}
|
|
1576
|
+
return "Check the slash-prefixed RouterOS path, or use a known readable path such as `/system/resource`, `/system/identity`, `/ip/address`, or `/interface`.";
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1529
1579
|
/**
|
|
1530
1580
|
* Inspect the menu's attribute names and reject requested ones it lacks with
|
|
1531
1581
|
* `validation/unknown-attribute`. Shared by one-shot retrieve and `--follow`.
|
package/src/transfer.ts
CHANGED
|
@@ -1401,13 +1401,26 @@ function buildSuccessEnvelope(
|
|
|
1401
1401
|
data,
|
|
1402
1402
|
warnings,
|
|
1403
1403
|
tips: [],
|
|
1404
|
-
meta: metaFromResolved(
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
:
|
|
1409
|
-
|
|
1410
|
-
|
|
1404
|
+
meta: metaFromResolved(
|
|
1405
|
+
resolved,
|
|
1406
|
+
operationData,
|
|
1407
|
+
!hasExistenceProbe(resolved)
|
|
1408
|
+
? { enabled: false }
|
|
1409
|
+
: resolved.validate.value
|
|
1410
|
+
? {
|
|
1411
|
+
enabled: true,
|
|
1412
|
+
source: TRANSFER_DEVICE_STAGE_SOURCE,
|
|
1413
|
+
result: "passed",
|
|
1414
|
+
stages: [
|
|
1415
|
+
{
|
|
1416
|
+
stage: "device",
|
|
1417
|
+
source: TRANSFER_DEVICE_STAGE_SOURCE,
|
|
1418
|
+
result: "passed",
|
|
1419
|
+
},
|
|
1420
|
+
],
|
|
1421
|
+
}
|
|
1422
|
+
: { enabled: false, source: "disabled", result: "skipped" },
|
|
1423
|
+
),
|
|
1411
1424
|
};
|
|
1412
1425
|
}
|
|
1413
1426
|
|
|
@@ -1491,6 +1504,16 @@ function asTransferError(error: unknown): CentrsError {
|
|
|
1491
1504
|
* see which host, user and credential sources were tried. Secrets never enter
|
|
1492
1505
|
* it — auth meta is `username` + `passwordProvided` only.
|
|
1493
1506
|
*/
|
|
1507
|
+
const TRANSFER_DEVICE_STAGE_SOURCE = "device /file existence probe";
|
|
1508
|
+
|
|
1509
|
+
/**
|
|
1510
|
+
* `list` reads `/file` and has no precondition probe, so it is not a gate:
|
|
1511
|
+
* like `explain`, it reports `{ enabled: false }` and no verdict (#535).
|
|
1512
|
+
*/
|
|
1513
|
+
function hasExistenceProbe(resolved: ResolvedTransferRequest): boolean {
|
|
1514
|
+
return resolved.verb !== "list";
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1494
1517
|
export function buildTransferErrorEnvelopeFromResolved(
|
|
1495
1518
|
resolved: ResolvedTransferRequest,
|
|
1496
1519
|
error: unknown,
|
|
@@ -1509,12 +1532,14 @@ export function buildTransferErrorEnvelopeFromResolved(
|
|
|
1509
1532
|
remote: resolved.remote,
|
|
1510
1533
|
local: resolved.local,
|
|
1511
1534
|
}),
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1535
|
+
hasExistenceProbe(resolved)
|
|
1536
|
+
? {
|
|
1537
|
+
enabled: resolved.validate.value,
|
|
1538
|
+
source: resolved.validate.value
|
|
1539
|
+
? TRANSFER_DEVICE_STAGE_SOURCE
|
|
1540
|
+
: "disabled",
|
|
1541
|
+
}
|
|
1542
|
+
: { enabled: false },
|
|
1518
1543
|
),
|
|
1519
1544
|
};
|
|
1520
1545
|
}
|