@tikoci/centrs 0.1.9-next.23 → 0.1.9-next.27
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 +163 -0
- package/GLOSSARY.txt +23 -1
- package/README.md +30 -18
- package/commands/api/AGENTS.md +21 -3
- package/commands/api/README.md +18 -7
- package/commands/api/examples.md +1 -1
- package/commands/btest/README.md +9 -0
- package/commands/btest/examples.md +7 -0
- package/commands/devices/README.md +72 -3
- package/commands/devices/examples.md +56 -1
- package/commands/execute/README.md +37 -6
- package/commands/execute/examples.md +24 -5
- package/commands/explain/README.md +675 -64
- package/commands/explain/examples.md +199 -42
- package/commands/retrieve/README.md +35 -3
- package/commands/retrieve/examples.md +114 -1
- package/commands/transfer/README.md +33 -4
- package/commands/transfer/examples.md +46 -0
- package/docs/CLI.md +30 -8
- package/docs/CONSTITUTION.md +14 -9
- package/docs/MATRIX.md +37 -22
- package/docs/errors/cdb/encrypt-failed.md +10 -0
- package/docs/errors/cdb/password-required.md +5 -4
- package/docs/errors/cdb/plaintext-backups.md +11 -0
- package/docs/errors/cmr/caller-policy-not-enforced.md +5 -3
- package/docs/errors/input/invalid-routeros-path.md +25 -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 +9 -5
- package/src/api-fanout.ts +2 -0
- package/src/api.ts +46 -39
- package/src/btest.ts +7 -0
- package/src/cli/api.ts +18 -1
- package/src/cli/btest.ts +9 -0
- package/src/cli/devices.ts +76 -8
- package/src/cli/execute.ts +9 -0
- package/src/cli/explain.ts +206 -47
- package/src/cli/retrieve.ts +9 -0
- package/src/cli/terminal.ts +9 -0
- package/src/cli/transfer.ts +10 -1
- package/src/core/envelope.ts +10 -3
- package/src/core/error-catalog.ts +28 -2
- 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/menu-path.ts +89 -0
- package/src/core/routeros-errors.ts +25 -0
- package/src/devices.ts +154 -9
- package/src/execute-cmr.ts +2 -8
- package/src/execute-fanout.ts +2 -0
- package/src/execute.ts +55 -20
- package/src/explain/args.ts +115 -10
- package/src/explain/blocks.ts +2 -2
- package/src/explain/catalog.ts +22 -12
- package/src/explain/group-faults.ts +162 -29
- package/src/explain/operators.ts +2 -1
- package/src/explain/pasted-prompt.ts +198 -0
- package/src/explain/pathresolve.ts +2 -2
- package/src/explain/scope-brace.ts +226 -11
- package/src/explain/segment.ts +138 -3
- package/src/explain/symbols.ts +148 -8
- package/src/explain/values.ts +55 -4
- package/src/explain/verbsplit.ts +2 -2
- package/src/explain/write.ts +2 -2
- 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 +397 -43
- package/src/index.ts +10 -0
- package/src/protocols/adapter.ts +90 -10
- package/src/protocols/btest-session.ts +10 -30
- package/src/protocols/ec-srp5.ts +19 -10
- package/src/protocols/mac-telnet-console.ts +2 -2
- package/src/protocols/mac-telnet.ts +38 -10
- package/src/protocols/native-api.ts +13 -12
- package/src/resolver/cdb.ts +58 -2
- package/src/resolver/index.ts +1 -0
- package/src/resolver/quickchr-provider.ts +5 -0
- package/src/resolver/selection.ts +11 -0
- package/src/retrieve-fanout.ts +2 -0
- 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 +88 -27
- package/src/settings.ts +1 -0
- package/src/terminal.ts +7 -0
- package/src/transfer-fanout.ts +2 -0
- package/src/transfer.ts +117 -27
package/src/protocols/adapter.ts
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* protocol data.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
+
import { assertRestRequestPath } from "../core/menu-path.ts";
|
|
17
18
|
import { mapRouterOsError } from "../core/routeros-errors.ts";
|
|
18
19
|
import { CentrsError } from "../errors.ts";
|
|
19
20
|
import { formatEndpoint } from "./endpoint.ts";
|
|
@@ -235,10 +236,10 @@ export interface ProtocolAdapter {
|
|
|
235
236
|
readonly protocol: RouterOsProtocol;
|
|
236
237
|
/** Capability flags describing what this adapter supports. */
|
|
237
238
|
readonly capabilities: ProtocolAdapterCapabilities;
|
|
238
|
-
/** `/console/inspect`
|
|
239
|
-
/**
|
|
239
|
+
/** `/console/inspect` discovery, completion, syntax help or byte highlight. */
|
|
240
|
+
/** `input` is CLI text for completion or highlight. */
|
|
240
241
|
inspect(
|
|
241
|
-
request: "child" | "completion",
|
|
242
|
+
request: "child" | "completion" | "highlight" | "syntax",
|
|
242
243
|
path: string,
|
|
243
244
|
input?: string,
|
|
244
245
|
): Promise<unknown[]>;
|
|
@@ -309,7 +310,7 @@ class RestAdapter implements ProtocolAdapter {
|
|
|
309
310
|
}
|
|
310
311
|
|
|
311
312
|
async inspect(
|
|
312
|
-
request: "child" | "completion",
|
|
313
|
+
request: "child" | "completion" | "highlight" | "syntax",
|
|
313
314
|
path: string,
|
|
314
315
|
input?: string,
|
|
315
316
|
): Promise<unknown[]> {
|
|
@@ -562,7 +563,13 @@ class RestAdapter implements ProtocolAdapter {
|
|
|
562
563
|
);
|
|
563
564
|
const data = parseResponseBody(text);
|
|
564
565
|
if (!response.ok) {
|
|
565
|
-
throw this.mapHttpFailure(
|
|
566
|
+
throw this.mapHttpFailure(
|
|
567
|
+
response.status,
|
|
568
|
+
text,
|
|
569
|
+
data,
|
|
570
|
+
path,
|
|
571
|
+
response.headers,
|
|
572
|
+
);
|
|
566
573
|
}
|
|
567
574
|
|
|
568
575
|
return { status: response.status, text, data };
|
|
@@ -581,19 +588,54 @@ class RestAdapter implements ProtocolAdapter {
|
|
|
581
588
|
text: string,
|
|
582
589
|
data: unknown,
|
|
583
590
|
path: string,
|
|
591
|
+
headers: Headers,
|
|
584
592
|
): CentrsError {
|
|
585
593
|
const { protocol, host, port } = this.config;
|
|
586
594
|
if (status === 401 || status === 403) {
|
|
595
|
+
if (!isRouterOsAuthReply(status, data, headers)) {
|
|
596
|
+
return new CentrsError({
|
|
597
|
+
code: "transport/edge-auth-failed",
|
|
598
|
+
summary: `An HTTP layer in front of RouterOS at ${formatEndpoint(host, port)} refused the request (HTTP ${status}); RouterOS did not answer it.`,
|
|
599
|
+
remediation:
|
|
600
|
+
"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.",
|
|
601
|
+
context: {
|
|
602
|
+
via: protocol,
|
|
603
|
+
host,
|
|
604
|
+
port,
|
|
605
|
+
path,
|
|
606
|
+
status,
|
|
607
|
+
...edgeEvidence(headers),
|
|
608
|
+
},
|
|
609
|
+
causeData: data ?? text,
|
|
610
|
+
});
|
|
611
|
+
}
|
|
587
612
|
return new CentrsError({
|
|
588
613
|
code: "transport/auth-failed",
|
|
589
614
|
summary: `RouterOS rejected the REST credentials for ${formatEndpoint(host, port)}.`,
|
|
590
615
|
remediation:
|
|
591
|
-
"Check `--username` / `--password` or the matching `CENTRS_*` environment variables
|
|
616
|
+
"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
617
|
context: { via: protocol, host, port, path, status },
|
|
593
618
|
causeData: data ?? text,
|
|
594
619
|
});
|
|
595
620
|
}
|
|
596
621
|
|
|
622
|
+
const detail = isPlainObject(data)
|
|
623
|
+
? readRecordString(data, "detail")
|
|
624
|
+
: undefined;
|
|
625
|
+
if (detail !== undefined) {
|
|
626
|
+
// RouterOS reports a policy refusal as HTTP 500 with a `(9)` detail.
|
|
627
|
+
// It is a deterministic router answer, not a dropped connection, so it
|
|
628
|
+
// must not reach the 5xx retry path below.
|
|
629
|
+
const mapped = mapRouterOsError(detail, {
|
|
630
|
+
transport: "rest-api",
|
|
631
|
+
httpStatus: status,
|
|
632
|
+
context: { via: protocol, host, port, path, status },
|
|
633
|
+
});
|
|
634
|
+
if (mapped.code === "routeros/permission-denied") {
|
|
635
|
+
return mapped;
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
597
639
|
if (status >= 500 && status <= 599) {
|
|
598
640
|
// A 5xx is a transient server/connection drop (RouterOS REST under
|
|
599
641
|
// fanout load drops a fraction of parallel POSTs). Classify it as a
|
|
@@ -610,9 +652,6 @@ class RestAdapter implements ProtocolAdapter {
|
|
|
610
652
|
});
|
|
611
653
|
}
|
|
612
654
|
|
|
613
|
-
const detail = isPlainObject(data)
|
|
614
|
-
? readRecordString(data, "detail")
|
|
615
|
-
: undefined;
|
|
616
655
|
const rawRouterOsError = detail ?? text;
|
|
617
656
|
return mapRouterOsError(rawRouterOsError, {
|
|
618
657
|
transport: "rest-api",
|
|
@@ -750,7 +789,7 @@ class NativeApiAdapter implements ProtocolAdapter {
|
|
|
750
789
|
}
|
|
751
790
|
|
|
752
791
|
async inspect(
|
|
753
|
-
request: "child" | "completion",
|
|
792
|
+
request: "child" | "completion" | "highlight" | "syntax",
|
|
754
793
|
path: string,
|
|
755
794
|
input?: string,
|
|
756
795
|
): Promise<unknown[]> {
|
|
@@ -1298,6 +1337,7 @@ function repliesToRecords(
|
|
|
1298
1337
|
}
|
|
1299
1338
|
|
|
1300
1339
|
function joinRestUrl(baseUrl: string, path: string): string {
|
|
1340
|
+
assertRestRequestPath(path);
|
|
1301
1341
|
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
|
|
1302
1342
|
return `${baseUrl}${normalizedPath}`;
|
|
1303
1343
|
}
|
|
@@ -1360,6 +1400,46 @@ function collectTransportSignalsInto(
|
|
|
1360
1400
|
}
|
|
1361
1401
|
}
|
|
1362
1402
|
|
|
1403
|
+
/**
|
|
1404
|
+
* True when a REST 401/403 reply has RouterOS's own shape. RouterOS answers
|
|
1405
|
+
* every auth refusal (wrong password, no `rest-api` policy, `address`
|
|
1406
|
+
* mismatch) with a JSON body `{"error":401,...}`, adding
|
|
1407
|
+
* `WWW-Authenticate: Basic` only when no credentials were sent. Measured on
|
|
1408
|
+
* CHR 7.12.1, 7.16.2, 7.23.8, 7.25, 7.26beta1 and 7.26beta3. Any other body,
|
|
1409
|
+
* or a challenge for another scheme (Cloudflare Access sends `Bearer`), came
|
|
1410
|
+
* from a layer in front of RouterOS. A `cf-ray` header alone proves nothing:
|
|
1411
|
+
* a tunnel passes RouterOS's own 401 through with it.
|
|
1412
|
+
*/
|
|
1413
|
+
function isRouterOsAuthReply(
|
|
1414
|
+
status: number,
|
|
1415
|
+
data: unknown,
|
|
1416
|
+
headers: Headers,
|
|
1417
|
+
): boolean {
|
|
1418
|
+
const challenge = headers.get("www-authenticate");
|
|
1419
|
+
if (challenge !== null && !/^\s*basic\b/i.test(challenge)) {
|
|
1420
|
+
return false;
|
|
1421
|
+
}
|
|
1422
|
+
return isPlainObject(data) && data["error"] === status;
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
/** Non-secret header facts that show which layer refused a request. */
|
|
1426
|
+
function edgeEvidence(headers: Headers): {
|
|
1427
|
+
challengeScheme?: string;
|
|
1428
|
+
server?: string;
|
|
1429
|
+
contentType?: string;
|
|
1430
|
+
cfRay?: "present";
|
|
1431
|
+
} {
|
|
1432
|
+
const evidence: ReturnType<typeof edgeEvidence> = {};
|
|
1433
|
+
const scheme = headers.get("www-authenticate")?.trim().split(/\s+/)[0];
|
|
1434
|
+
if (scheme) evidence.challengeScheme = scheme;
|
|
1435
|
+
const server = headers.get("server");
|
|
1436
|
+
if (server) evidence.server = server;
|
|
1437
|
+
const contentType = headers.get("content-type");
|
|
1438
|
+
if (contentType) evidence.contentType = contentType;
|
|
1439
|
+
if (headers.has("cf-ray")) evidence.cfRay = "present";
|
|
1440
|
+
return evidence;
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1363
1443
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
1364
1444
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1365
1445
|
}
|
|
@@ -72,7 +72,6 @@ import {
|
|
|
72
72
|
} from "./btest.ts";
|
|
73
73
|
import {
|
|
74
74
|
bytesToBigIntBE,
|
|
75
|
-
type EcSrp5Shared,
|
|
76
75
|
ecSrp5ClientConfirm,
|
|
77
76
|
ecSrp5ClientShared,
|
|
78
77
|
ecSrp5Id,
|
|
@@ -677,13 +676,11 @@ async function runClientEcSrp5(
|
|
|
677
676
|
|
|
678
677
|
const challenge = parseServerChallenge(await readAuthFrame(channel));
|
|
679
678
|
const validator = ecSrp5Id(username, password, challenge.salt);
|
|
680
|
-
const shared =
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
validator,
|
|
686
|
-
),
|
|
679
|
+
const shared = ecSrp5ClientShared(
|
|
680
|
+
keypair.privateKey,
|
|
681
|
+
challenge.serverPublicKey,
|
|
682
|
+
keypair.publicKey,
|
|
683
|
+
validator,
|
|
687
684
|
);
|
|
688
685
|
const clientCc = ecSrp5ClientConfirm(shared);
|
|
689
686
|
await channel.write(encodeConfirmation(clientCc));
|
|
@@ -785,13 +782,11 @@ async function runServerEcSrp5(
|
|
|
785
782
|
);
|
|
786
783
|
await channel.write(encodeServerChallenge(serverPublicKey, salt));
|
|
787
784
|
|
|
788
|
-
const shared =
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
serverPublicKey,
|
|
794
|
-
),
|
|
785
|
+
const shared = ecSrp5ServerShared(
|
|
786
|
+
keypair.privateKey,
|
|
787
|
+
validatorScalar,
|
|
788
|
+
hello.publicKey,
|
|
789
|
+
serverPublicKey,
|
|
795
790
|
);
|
|
796
791
|
const clientCc = await readAuthFrame(channel);
|
|
797
792
|
const expectedCc = ecSrp5ClientConfirm(shared);
|
|
@@ -804,21 +799,6 @@ async function runServerEcSrp5(
|
|
|
804
799
|
return hello.username;
|
|
805
800
|
}
|
|
806
801
|
|
|
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
|
-
|
|
822
802
|
function ecSrp5RandomSalt(): Uint8Array {
|
|
823
803
|
return crypto.getRandomValues(new Uint8Array(16));
|
|
824
804
|
}
|
package/src/protocols/ec-srp5.ts
CHANGED
|
@@ -16,9 +16,14 @@
|
|
|
16
16
|
* (`haakonnessjoen/MAC-Telnet`, © Yandex 2022), cross-checked against the WinBox
|
|
17
17
|
* EC-SRP5 reference (`Lab/winbox-terminal-protocol`) and `tikoci/m2ir`'s
|
|
18
18
|
* `winbox-ipc/ec-srp5-handshake.yaml` (derived from MarginResearch's PoC). The
|
|
19
|
-
* curve constants, point encoding
|
|
20
|
-
*
|
|
21
|
-
*
|
|
19
|
+
* curve constants, point encoding and identity hash match all three.
|
|
20
|
+
*
|
|
21
|
+
* The hash inputs do not: `mtwei.c` pads the REDP1 loop seed and shared `z` to
|
|
22
|
+
* 32 bytes (`BN_bn2binpad`), but RouterOS hashes them minimal-length (#565).
|
|
23
|
+
* Grounded in `libucrypto.so` 6.49.23/7.25 and live CHR A/B runs over WinBox,
|
|
24
|
+
* btest and MAC-Telnet, where the padded proof is rejected whenever the two
|
|
25
|
+
* forms differ. Public-key X and the REDP1 input stay fixed at 32 bytes.
|
|
26
|
+
* Reference vectors and provenance live in `test/fixtures/ec-srp5.json`.
|
|
22
27
|
*/
|
|
23
28
|
|
|
24
29
|
import { createHash, randomBytes } from "node:crypto";
|
|
@@ -131,6 +136,11 @@ export function bigIntToBytesBE(value: bigint, length: number): Uint8Array {
|
|
|
131
136
|
return out;
|
|
132
137
|
}
|
|
133
138
|
|
|
139
|
+
/** Minimal unsigned big-endian hash input; retain one byte for zero. */
|
|
140
|
+
function bigIntToBytesMinimal(value: bigint): Uint8Array {
|
|
141
|
+
return bigIntToBytesBE(value, Math.ceil(value.toString(16).length / 2));
|
|
142
|
+
}
|
|
143
|
+
|
|
134
144
|
export function sha256(...chunks: readonly Uint8Array[]): Uint8Array {
|
|
135
145
|
const hash = createHash("sha256");
|
|
136
146
|
for (const chunk of chunks) hash.update(chunk);
|
|
@@ -297,18 +307,18 @@ export function ecSrp5Id(
|
|
|
297
307
|
export function redp1(montgomeryX: Uint8Array, parity: number): Point {
|
|
298
308
|
let seed = bytesToBigIntBE(sha256(montgomeryX));
|
|
299
309
|
for (;;) {
|
|
300
|
-
const candidate = sha256(
|
|
310
|
+
const candidate = sha256(bigIntToBytesMinimal(seed));
|
|
301
311
|
const xWeier = mod(bytesToBigIntBE(candidate) + M2W, P);
|
|
302
312
|
const point = liftX(xWeier, parity);
|
|
303
313
|
if (point !== null) return point;
|
|
304
|
-
seed
|
|
314
|
+
seed += 1n;
|
|
305
315
|
}
|
|
306
316
|
}
|
|
307
317
|
|
|
308
318
|
/**
|
|
309
319
|
* Shared-secret material `(j, z)` common to both EC-SRP5 roles.
|
|
310
320
|
* j = SHA256(clientMontX ‖ serverMontX) (the two 32-byte Montgomery Xs)
|
|
311
|
-
* z = Montgomery-X of the agreed shared point
|
|
321
|
+
* z = minimal-length big-endian Montgomery-X of the agreed shared point
|
|
312
322
|
* The confirmation codes are derived from these (see {@link ecSrp5ClientConfirm}
|
|
313
323
|
* / {@link ecSrp5ServerConfirm}).
|
|
314
324
|
*/
|
|
@@ -360,14 +370,13 @@ export function ecSrp5ClientShared(
|
|
|
360
370
|
remediation: "The peer key may be invalid; confirm the device.",
|
|
361
371
|
});
|
|
362
372
|
}
|
|
363
|
-
const z =
|
|
373
|
+
const z = bigIntToBytesMinimal(mod(pt.x + W2M, P));
|
|
364
374
|
return { j, z };
|
|
365
375
|
}
|
|
366
376
|
|
|
367
377
|
/**
|
|
368
378
|
* Compute the 32-byte EC-SRP5 **client** authentication proof
|
|
369
|
-
* (`mtwei_docrypto`): `SHA256(j ‖ z)`.
|
|
370
|
-
* `mtwei.c` port — MAC-Telnet's MTWEI uses this directly.
|
|
379
|
+
* (`mtwei_docrypto`): `SHA256(j ‖ z)`. MAC-Telnet's MTWEI uses this directly.
|
|
371
380
|
*/
|
|
372
381
|
export function ecSrp5ClientProof(
|
|
373
382
|
privateKey: bigint,
|
|
@@ -445,7 +454,7 @@ export function ecSrp5ServerShared(
|
|
|
445
454
|
remediation: "The peer key may be invalid; confirm the client.",
|
|
446
455
|
});
|
|
447
456
|
}
|
|
448
|
-
const z =
|
|
457
|
+
const z = bigIntToBytesMinimal(mod(pt.x + W2M, P));
|
|
449
458
|
return { j, z };
|
|
450
459
|
}
|
|
451
460
|
|
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
* layer drives the RouterOS console the way a terminal would and reconstructs
|
|
7
7
|
* the "screen", then strips the echoed command and the trailing prompt.
|
|
8
8
|
*
|
|
9
|
-
* Everything here is grounded on stock CHR 7.23.1 (probes
|
|
10
|
-
* captured while wiring `execute / mac-telnet`):
|
|
9
|
+
* Everything here is grounded on stock CHR 7.23.1 (lab probes, history —
|
|
10
|
+
* captured while wiring `execute / mac-telnet` in PR #15, a9f1cbf):
|
|
11
11
|
*
|
|
12
12
|
* - **Terminal-size probe at login.** The console emits a multi-step ANSI
|
|
13
13
|
* cursor probe: `ESC[9999B`/`ESC[9999C` (move to find the edges), `ESC[H`,
|
|
@@ -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/resolver/cdb.ts
CHANGED
|
@@ -137,6 +137,44 @@ export interface CdbResolveInput {
|
|
|
137
137
|
targetInput?: string;
|
|
138
138
|
cdbFile?: string;
|
|
139
139
|
cdbPassword?: string;
|
|
140
|
+
/**
|
|
141
|
+
* `--match` record selector (`user=<name>`, `target=<addr>`, or a record
|
|
142
|
+
* type such as `ipUser`) for a `<router>` several CDB records share — the
|
|
143
|
+
* same selector `devices show --match` takes (#583). Given a `match`, the
|
|
144
|
+
* call must resolve a CDB record: no CDB, a literal host, or a
|
|
145
|
+
* `__default__`-only fallback fails `identity/no-match` instead of
|
|
146
|
+
* ignoring the selector.
|
|
147
|
+
*/
|
|
148
|
+
match?: string;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* `--match` picks one CDB record for one `<router>`; a fan-out selection
|
|
153
|
+
* already visits every selected record. Shared by every command's fan-out
|
|
154
|
+
* entry so the flag is never silently ignored (#583).
|
|
155
|
+
*/
|
|
156
|
+
export function assertNoMatchInFanout(match: string | undefined): void {
|
|
157
|
+
if (match === undefined) {
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
throw new CentrsError({
|
|
161
|
+
code: "usage/conflicting-flags",
|
|
162
|
+
summary:
|
|
163
|
+
"`--match` selects one CDB record for a single `<router>`; it cannot be combined with a fan-out selection.",
|
|
164
|
+
remediation:
|
|
165
|
+
"Drop `--match`, or target the one router positionally: `<router> --match user=<name>`. A fan-out already visits every selected record.",
|
|
166
|
+
context: { flag: "--match", match },
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function matchWithoutRecord(input: CdbResolveInput, reason: string): never {
|
|
171
|
+
throw new CentrsError({
|
|
172
|
+
code: "identity/no-match",
|
|
173
|
+
summary: `--match "${input.match}" needs a CDB record for "${input.targetInput ?? ""}", but ${reason}.`,
|
|
174
|
+
remediation:
|
|
175
|
+
"Run `centrs devices list` to see the saved records and their users, then pass the `<router>` they are saved under with --match, or drop --match for an ad-hoc host.",
|
|
176
|
+
context: { target: input.targetInput, match: input.match },
|
|
177
|
+
});
|
|
140
178
|
}
|
|
141
179
|
|
|
142
180
|
function warningFromDevices(warning: DevicesWarning): ResolverWarning {
|
|
@@ -164,6 +202,9 @@ export async function resolveCdb(
|
|
|
164
202
|
config: Record<string, string | undefined> = {},
|
|
165
203
|
): Promise<CdbResolution | undefined> {
|
|
166
204
|
if (!input.targetInput) {
|
|
205
|
+
if (input.match !== undefined) {
|
|
206
|
+
matchWithoutRecord(input, "no `<router>` was given");
|
|
207
|
+
}
|
|
167
208
|
return undefined;
|
|
168
209
|
}
|
|
169
210
|
// Before any lookup: a CDB miss echoes the target it was given (#478).
|
|
@@ -186,6 +227,9 @@ export async function resolveCdb(
|
|
|
186
227
|
env[ENV_CDB_PASSWORD] !== undefined ||
|
|
187
228
|
config[ENV_CDB_FILE] !== undefined;
|
|
188
229
|
if (!explicitCdb && !(await Bun.file(settings.cdbFile.value).exists())) {
|
|
230
|
+
if (input.match !== undefined) {
|
|
231
|
+
matchWithoutRecord(input, "no CDB file is in play");
|
|
232
|
+
}
|
|
189
233
|
return undefined;
|
|
190
234
|
}
|
|
191
235
|
|
|
@@ -201,7 +245,8 @@ export async function resolveCdb(
|
|
|
201
245
|
if (
|
|
202
246
|
!explicitCdb &&
|
|
203
247
|
error instanceof CentrsError &&
|
|
204
|
-
error.code === "cdb/not-found"
|
|
248
|
+
error.code === "cdb/not-found" &&
|
|
249
|
+
input.match === undefined
|
|
205
250
|
) {
|
|
206
251
|
return undefined;
|
|
207
252
|
}
|
|
@@ -211,7 +256,11 @@ export async function resolveCdb(
|
|
|
211
256
|
const defaults = findDefaultCreds(cdb);
|
|
212
257
|
|
|
213
258
|
try {
|
|
214
|
-
const envelope = showDevice({
|
|
259
|
+
const envelope = showDevice({
|
|
260
|
+
cdb,
|
|
261
|
+
target: input.targetInput,
|
|
262
|
+
match: input.match,
|
|
263
|
+
});
|
|
215
264
|
const entry = envelope.data.entry;
|
|
216
265
|
const warnings: ResolverWarning[] =
|
|
217
266
|
envelope.warnings.map(warningFromDevices);
|
|
@@ -236,6 +285,13 @@ export async function resolveCdb(
|
|
|
236
285
|
if (redactTargetInput(input.targetInput) !== input.targetInput) {
|
|
237
286
|
assertNoTargetCredentials(input.targetInput);
|
|
238
287
|
}
|
|
288
|
+
if (
|
|
289
|
+
error instanceof CentrsError &&
|
|
290
|
+
error.code === "cdb/not-found-target" &&
|
|
291
|
+
input.match !== undefined
|
|
292
|
+
) {
|
|
293
|
+
matchWithoutRecord(input, "no CDB record matches it");
|
|
294
|
+
}
|
|
239
295
|
if (error instanceof CentrsError && error.code === "cdb/not-found-target") {
|
|
240
296
|
// A `__default__` record supplies fallback credentials even when the
|
|
241
297
|
// target has no record of its own. On the CLI/API this fills creds for
|
package/src/resolver/index.ts
CHANGED
|
@@ -347,10 +347,15 @@ export function assertNoQuickchrOverrideConflict(
|
|
|
347
347
|
username?: string;
|
|
348
348
|
password?: string;
|
|
349
349
|
sshKey?: string;
|
|
350
|
+
/** `--match` picks a CDB record; a quickchr target has none (#583). */
|
|
351
|
+
match?: string;
|
|
350
352
|
},
|
|
351
353
|
machine: string,
|
|
352
354
|
): void {
|
|
353
355
|
const conflicts: string[] = [];
|
|
356
|
+
if (request.match !== undefined) {
|
|
357
|
+
conflicts.push("--match");
|
|
358
|
+
}
|
|
354
359
|
if (request.host !== undefined) {
|
|
355
360
|
conflicts.push("--host");
|
|
356
361
|
}
|
|
@@ -309,6 +309,17 @@ export async function expandSelection(
|
|
|
309
309
|
}
|
|
310
310
|
continue;
|
|
311
311
|
}
|
|
312
|
+
// The devices remediation names `--match`, which a fan-out refuses
|
|
313
|
+
// (it picks one record for one `<router>`, #583).
|
|
314
|
+
if (error instanceof CentrsError && error.code === "identity/ambiguous") {
|
|
315
|
+
throw new CentrsError({
|
|
316
|
+
code: error.code,
|
|
317
|
+
summary: error.summary,
|
|
318
|
+
remediation: `In a fan-out each positional must name one CDB record. Run "${positional}" on its own with --match user=<name>, --match target=<addr>, or --match <record-type>, or select the records with --group / --where instead.`,
|
|
319
|
+
context: error.context,
|
|
320
|
+
cause: error,
|
|
321
|
+
});
|
|
322
|
+
}
|
|
312
323
|
throw error;
|
|
313
324
|
}
|
|
314
325
|
}
|
package/src/retrieve-fanout.ts
CHANGED
|
@@ -56,6 +56,7 @@ import {
|
|
|
56
56
|
} from "./errors.ts";
|
|
57
57
|
import { plannedProtocols, type RouterOsProtocol } from "./protocols/index.ts";
|
|
58
58
|
import {
|
|
59
|
+
assertNoMatchInFanout,
|
|
59
60
|
expandSelection,
|
|
60
61
|
isDefaultRecordTarget,
|
|
61
62
|
loadEnvFileDefaults,
|
|
@@ -203,6 +204,7 @@ export async function retrieveFanout(
|
|
|
203
204
|
internals: RetrieveFanoutInternals = {},
|
|
204
205
|
options: RetrieveFanoutOptions = {},
|
|
205
206
|
): Promise<RetrieveFanoutEnvelope> {
|
|
207
|
+
assertNoMatchInFanout(request.match);
|
|
206
208
|
if (request.wait !== undefined)
|
|
207
209
|
throw new CentrsError({
|
|
208
210
|
code: "usage/fanout-not-supported",
|
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 ??
|