@tikoci/centrs 0.1.9-next.22 → 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 +38 -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 +43 -5
- package/commands/explain/README.md +873 -182
- package/commands/explain/examples.md +447 -38
- 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 +18 -9
- package/docs/MATRIX.md +51 -26
- 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 +5 -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 +181 -15
- package/src/explain/blocks.ts +3 -4
- package/src/explain/brace-slots.ts +5 -0
- package/src/explain/catalog.ts +13 -6
- package/src/explain/defects.ts +1 -0
- package/src/explain/group-faults.ts +1060 -0
- package/src/explain/highlight-projection.ts +10 -8
- package/src/explain/operators.ts +22 -20
- package/src/explain/pathresolve.ts +102 -20
- package/src/explain/quoted-string.ts +41 -6
- package/src/explain/run-tokens.ts +223 -0
- package/src/explain/scope-brace.ts +319 -7
- package/src/explain/segment.ts +177 -4
- package/src/explain/spaced-args.ts +116 -0
- package/src/explain/symbols.ts +163 -7
- package/src/explain/transport.ts +13 -0
- package/src/explain/values.ts +55 -4
- package/src/explain/verbsplit.ts +14 -188
- package/src/explain/write.ts +8 -1
- 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 +772 -60
- package/src/index.ts +6 -0
- package/src/offline-gate.ts +30 -9
- package/src/protocols/adapter.ts +88 -10
- package/src/protocols/btest-session.ts +30 -10
- package/src/protocols/mac-telnet-console.ts +16 -0
- 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/index.ts
CHANGED
|
@@ -480,6 +480,7 @@ export {
|
|
|
480
480
|
type ExplainEvidence,
|
|
481
481
|
type ExplainFailOn,
|
|
482
482
|
type ExplainInput,
|
|
483
|
+
type ExplainLiveData,
|
|
483
484
|
type ExplainMenuReading,
|
|
484
485
|
type ExplainOperationMeta,
|
|
485
486
|
type ExplainOutputFormat,
|
|
@@ -514,6 +515,11 @@ export {
|
|
|
514
515
|
explainOutputFormats,
|
|
515
516
|
renderExplainEnvelope,
|
|
516
517
|
} from "./explain.ts";
|
|
518
|
+
export {
|
|
519
|
+
EXPLAIN_LIVE_LIMITS,
|
|
520
|
+
type ExplainLiveRequest,
|
|
521
|
+
explainLiveEnvelope,
|
|
522
|
+
} from "./explain-live.ts";
|
|
517
523
|
export {
|
|
518
524
|
assertOfflineSyntax,
|
|
519
525
|
isOfflineGateRejection,
|
package/src/offline-gate.ts
CHANGED
|
@@ -25,6 +25,15 @@
|
|
|
25
25
|
* module never reports success on its behalf. `verdict: "warn"` is an
|
|
26
26
|
* ABSTENTION, not a rejection, and passes through for the same reason.
|
|
27
27
|
*
|
|
28
|
+
* **One warning is refused by code: {@link REFUSED_WARNING_CODES}.** It is not
|
|
29
|
+
* an abstention but a known device behaviour. A `}` or `]` that closes nothing
|
|
30
|
+
* at the top level makes RouterOS accept the script and silently stop reading
|
|
31
|
+
* there, so `/system identity set name=a}` sets `a` and `:put 1};:put 2` runs
|
|
32
|
+
* one command (#504, CHR 7.24.5 and 7.23.8). `explain` reports it as a warning
|
|
33
|
+
* because nothing is rejected; the gate refuses it because the script the
|
|
34
|
+
* device would run is not the one that was written. The list is explicit and
|
|
35
|
+
* stays small: a code joins it only for the same reason.
|
|
36
|
+
*
|
|
28
37
|
* `--validate=false` disables BOTH stages — one flag, one meaning. That was
|
|
29
38
|
* settled with the `--raw` precedence layer on GH#154 and is why callers gate
|
|
30
39
|
* this on the same resolved `validate` setting as the device stage; the escape
|
|
@@ -39,11 +48,20 @@
|
|
|
39
48
|
*/
|
|
40
49
|
|
|
41
50
|
import { CentrsError } from "./errors.ts";
|
|
42
|
-
import {
|
|
51
|
+
import {
|
|
52
|
+
DISCARDED_CLOSE_CODE,
|
|
53
|
+
type ExplainDiagnostic,
|
|
54
|
+
explainCommand,
|
|
55
|
+
} from "./explain.ts";
|
|
43
56
|
|
|
44
57
|
/** Validator identity for `meta.validation` and error context. */
|
|
45
58
|
export const OFFLINE_GATE_SOURCE = "offline explain (canonicalizer)";
|
|
46
59
|
|
|
60
|
+
/** Warning codes the gate refuses as it refuses an error (module header). */
|
|
61
|
+
export const REFUSED_WARNING_CODES: ReadonlySet<string> = new Set([
|
|
62
|
+
DISCARDED_CLOSE_CODE,
|
|
63
|
+
]);
|
|
64
|
+
|
|
47
65
|
/** Which stage of the two-stage gate produced a verdict. */
|
|
48
66
|
export type ValidationStageName = "offline" | "device";
|
|
49
67
|
|
|
@@ -61,8 +79,8 @@ export interface OfflineGateFinding {
|
|
|
61
79
|
export interface OfflineGateResult {
|
|
62
80
|
/**
|
|
63
81
|
* `pass` — the analyzer read the whole input. `warn` — it ABSTAINED on part
|
|
64
|
-
* of it (an unresolved menu, say). Both proceed to the device stage;
|
|
65
|
-
* `fail
|
|
82
|
+
* of it (an unresolved menu, say). Both proceed to the device stage; a
|
|
83
|
+
* `fail`, or a warning in {@link REFUSED_WARNING_CODES}, throws.
|
|
66
84
|
*/
|
|
67
85
|
verdict: "pass" | "warn";
|
|
68
86
|
/** Warning-severity diagnostics, kept so a caller can surface the abstention. */
|
|
@@ -95,25 +113,28 @@ export function assertOfflineSyntax(
|
|
|
95
113
|
): OfflineGateResult {
|
|
96
114
|
const analysis = explainCommand(command);
|
|
97
115
|
const errors = analysis.diagnostics.filter(
|
|
98
|
-
(diagnostic) =>
|
|
116
|
+
(diagnostic) =>
|
|
117
|
+
diagnostic.severity === "error" ||
|
|
118
|
+
REFUSED_WARNING_CODES.has(diagnostic.code),
|
|
99
119
|
);
|
|
100
120
|
const warnings = analysis.diagnostics
|
|
101
121
|
.filter((diagnostic) => diagnostic.severity === "warning")
|
|
102
122
|
.map(findingOf);
|
|
103
123
|
|
|
104
|
-
if (
|
|
124
|
+
if (errors.length === 0) {
|
|
105
125
|
return { verdict: analysis.verdict === "warn" ? "warn" : "pass", warnings };
|
|
106
126
|
}
|
|
107
127
|
|
|
108
|
-
// A `fail` verdict always carries at least one error diagnostic, but read it
|
|
109
|
-
// defensively: an empty list would otherwise produce an error that says
|
|
110
|
-
// nothing about what was wrong, which is worse than a generic summary.
|
|
111
128
|
const findings = errors.map(findingOf);
|
|
112
129
|
const first = findings[0];
|
|
113
130
|
throw new CentrsError({
|
|
114
131
|
code: "validation/syntax",
|
|
115
132
|
summary: first
|
|
116
|
-
?
|
|
133
|
+
? `${
|
|
134
|
+
REFUSED_WARNING_CODES.has(first.code)
|
|
135
|
+
? "Refused by offline analysis"
|
|
136
|
+
: "RouterOS syntax rejected by offline analysis"
|
|
137
|
+
}: ${first.message}`
|
|
117
138
|
: "RouterOS syntax rejected by offline analysis.",
|
|
118
139
|
remediation:
|
|
119
140
|
"Run `centrs explain '<command>'` to see the offending bytes in context, " +
|
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
|
}
|
|
@@ -72,6 +72,7 @@ import {
|
|
|
72
72
|
} from "./btest.ts";
|
|
73
73
|
import {
|
|
74
74
|
bytesToBigIntBE,
|
|
75
|
+
type EcSrp5Shared,
|
|
75
76
|
ecSrp5ClientConfirm,
|
|
76
77
|
ecSrp5ClientShared,
|
|
77
78
|
ecSrp5Id,
|
|
@@ -676,11 +677,13 @@ async function runClientEcSrp5(
|
|
|
676
677
|
|
|
677
678
|
const challenge = parseServerChallenge(await readAuthFrame(channel));
|
|
678
679
|
const validator = ecSrp5Id(username, password, challenge.salt);
|
|
679
|
-
const shared =
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
680
|
+
const shared = btestShared(
|
|
681
|
+
ecSrp5ClientShared(
|
|
682
|
+
keypair.privateKey,
|
|
683
|
+
challenge.serverPublicKey,
|
|
684
|
+
keypair.publicKey,
|
|
685
|
+
validator,
|
|
686
|
+
),
|
|
684
687
|
);
|
|
685
688
|
const clientCc = ecSrp5ClientConfirm(shared);
|
|
686
689
|
await channel.write(encodeConfirmation(clientCc));
|
|
@@ -782,11 +785,13 @@ async function runServerEcSrp5(
|
|
|
782
785
|
);
|
|
783
786
|
await channel.write(encodeServerChallenge(serverPublicKey, salt));
|
|
784
787
|
|
|
785
|
-
const shared =
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
788
|
+
const shared = btestShared(
|
|
789
|
+
ecSrp5ServerShared(
|
|
790
|
+
keypair.privateKey,
|
|
791
|
+
validatorScalar,
|
|
792
|
+
hello.publicKey,
|
|
793
|
+
serverPublicKey,
|
|
794
|
+
),
|
|
790
795
|
);
|
|
791
796
|
const clientCc = await readAuthFrame(channel);
|
|
792
797
|
const expectedCc = ecSrp5ClientConfirm(shared);
|
|
@@ -799,6 +804,21 @@ async function runServerEcSrp5(
|
|
|
799
804
|
return hello.username;
|
|
800
805
|
}
|
|
801
806
|
|
|
807
|
+
/**
|
|
808
|
+
* The shared secret as btest hashes it: `z` without its leading zero bytes
|
|
809
|
+
* (OpenSSL `BN_bn2bin`), where MAC-Telnet's `mtwei.c` pads it to 32
|
|
810
|
+
* (`BN_bn2binpad`), so the shared math in `ec-srp5.ts` keeps 32 bytes.
|
|
811
|
+
*
|
|
812
|
+
* Grounded on CHR 7.24.5 `/tool/bandwidth-server`: over 800 client handshakes,
|
|
813
|
+
* every one whose `z` began with 0x00 (9) failed with the padded form and no
|
|
814
|
+
* other did. Stripped, 600 of 600 verified, 4 of them with a leading zero.
|
|
815
|
+
*/
|
|
816
|
+
export function btestShared(shared: EcSrp5Shared): EcSrp5Shared {
|
|
817
|
+
let start = 0;
|
|
818
|
+
while (start < shared.z.length - 1 && shared.z[start] === 0) start++;
|
|
819
|
+
return start === 0 ? shared : { j: shared.j, z: shared.z.subarray(start) };
|
|
820
|
+
}
|
|
821
|
+
|
|
802
822
|
function ecSrp5RandomSalt(): Uint8Array {
|
|
803
823
|
return crypto.getRandomValues(new Uint8Array(16));
|
|
804
824
|
}
|
|
@@ -639,6 +639,22 @@ const BARE_BAD_PARAMETER =
|
|
|
639
639
|
const BARE_SYNTAX =
|
|
640
640
|
/^[ \t]*(?:syntax error|bad command name|(?:expected|missing) [a-z]+(?: [a-z]+)*)[^\n]*\(line \d+ column \d+\)/im;
|
|
641
641
|
|
|
642
|
+
/**
|
|
643
|
+
* Whether the printed value of `:parse` carries a rejection, by the same
|
|
644
|
+
* position-anchored grammar {@link classifyParseResult} throws on. For probes
|
|
645
|
+
* and censuses that need the verdict as a boolean. On the schema-5 corpus it
|
|
646
|
+
* agrees with the upstream `response_class` on every one of 5,640 channel and
|
|
647
|
+
* legacy observations (#498).
|
|
648
|
+
*/
|
|
649
|
+
export function isParseRejection(output: string): boolean {
|
|
650
|
+
return (
|
|
651
|
+
WRAPPED_BAD_PARAMETER.test(output) ||
|
|
652
|
+
BARE_BAD_PARAMETER.test(output) ||
|
|
653
|
+
WRAPPED_BAD_COMMAND.test(output) ||
|
|
654
|
+
BARE_SYNTAX.test(output)
|
|
655
|
+
);
|
|
656
|
+
}
|
|
657
|
+
|
|
642
658
|
/**
|
|
643
659
|
* Classify the printed value of `:put [:parse "<cli>"]`. Grounded on CHR 7.23.3
|
|
644
660
|
* (see GH#230): `:parse` *never* throws — it returns a value whose text carries
|
|
@@ -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 ??
|