@tikoci/centrs 0.1.9-next.24 → 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 +57 -0
- package/GLOSSARY.txt +9 -1
- package/README.md +28 -16
- package/commands/api/AGENTS.md +3 -2
- package/commands/api/README.md +8 -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 +11 -0
- package/commands/explain/README.md +223 -3
- package/commands/explain/examples.md +8 -1
- package/commands/retrieve/README.md +22 -1
- package/commands/retrieve/examples.md +84 -0
- package/commands/transfer/README.md +25 -0
- package/commands/transfer/examples.md +46 -0
- package/docs/CLI.md +11 -3
- package/docs/MATRIX.md +5 -3
- 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/input/invalid-routeros-path.md +25 -3
- package/package.json +8 -4
- package/src/api-fanout.ts +2 -0
- package/src/api.ts +12 -0
- 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/retrieve.ts +9 -0
- package/src/cli/terminal.ts +9 -0
- package/src/cli/transfer.ts +10 -1
- package/src/core/error-catalog.ts +13 -2
- package/src/core/menu-path.ts +89 -0
- package/src/devices.ts +154 -9
- package/src/execute-fanout.ts +2 -0
- package/src/execute.ts +7 -0
- package/src/explain/blocks.ts +2 -2
- package/src/explain/catalog.ts +22 -12
- package/src/explain/pasted-prompt.ts +198 -0
- package/src/explain/pathresolve.ts +2 -2
- package/src/explain/segment.ts +2 -2
- package/src/explain/symbols.ts +2 -2
- package/src/explain/verbsplit.ts +2 -2
- package/src/explain/write.ts +2 -2
- package/src/explain.ts +2 -0
- package/src/index.ts +4 -0
- package/src/protocols/adapter.ts +2 -0
- 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/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.ts +11 -0
- package/src/settings.ts +1 -0
- package/src/terminal.ts +7 -0
- package/src/transfer-fanout.ts +2 -0
- package/src/transfer.ts +79 -14
package/src/index.ts
CHANGED
|
@@ -209,6 +209,7 @@ export {
|
|
|
209
209
|
addDevice,
|
|
210
210
|
buildDevicesErrorEnvelope,
|
|
211
211
|
type DevicesCommand,
|
|
212
|
+
type DevicesEncryptData,
|
|
212
213
|
type DevicesEnvelope,
|
|
213
214
|
type DevicesGroupSummary,
|
|
214
215
|
type DevicesInitData,
|
|
@@ -223,7 +224,10 @@ export {
|
|
|
223
224
|
type DevicesWhereClause,
|
|
224
225
|
defaultCdbPath,
|
|
225
226
|
devicesOutputFormats,
|
|
227
|
+
type EncryptCdbOptions,
|
|
226
228
|
editInteractiveOnlyError,
|
|
229
|
+
encryptCdb,
|
|
230
|
+
type InitCdbOptions,
|
|
227
231
|
initCdb,
|
|
228
232
|
type LoadCdbOptions,
|
|
229
233
|
type LoadedCdb,
|
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";
|
|
@@ -1336,6 +1337,7 @@ function repliesToRecords(
|
|
|
1336
1337
|
}
|
|
1337
1338
|
|
|
1338
1339
|
function joinRestUrl(baseUrl: string, path: string): string {
|
|
1340
|
+
assertRestRequestPath(path);
|
|
1339
1341
|
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
|
|
1340
1342
|
return `${baseUrl}${normalizedPath}`;
|
|
1341
1343
|
}
|
|
@@ -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`,
|
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.ts
CHANGED
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
isCommandNode,
|
|
21
21
|
pathTokens,
|
|
22
22
|
} from "./core/inspect.ts";
|
|
23
|
+
import { assertRouterOsMenuPath } from "./core/menu-path.ts";
|
|
23
24
|
import {
|
|
24
25
|
compileQueryWords,
|
|
25
26
|
type ParsedQuery,
|
|
@@ -101,6 +102,12 @@ export interface RetrieveRequest {
|
|
|
101
102
|
maxResultsBytes?: number;
|
|
102
103
|
cdbFile?: string;
|
|
103
104
|
cdbPassword?: string;
|
|
105
|
+
/**
|
|
106
|
+
* `--match` selector for a `<router>` several CDB records share
|
|
107
|
+
* (`user=<name>`, `target=<addr>`, or a record type): the same selector
|
|
108
|
+
* `devices show --match` takes. Single-target only (#583).
|
|
109
|
+
*/
|
|
110
|
+
match?: string;
|
|
104
111
|
/** Opt-in host ARP resolution for a MAC target (`none` default, or `arp`). */
|
|
105
112
|
resolve?: string;
|
|
106
113
|
/** CDB group selector — expands to a fanout over all matching records. */
|
|
@@ -920,6 +927,9 @@ export function validateRetrieveRequestShape(
|
|
|
920
927
|
context: { path: request.path },
|
|
921
928
|
});
|
|
922
929
|
}
|
|
930
|
+
// Every transport, even under --validate=false: a malformed segment can
|
|
931
|
+
// cut the appended `/print` off a REST URL and dispatch a write (#586).
|
|
932
|
+
assertRouterOsMenuPath(request.path);
|
|
923
933
|
|
|
924
934
|
const query = resolveRetrieveQuery(request);
|
|
925
935
|
if (query && request.listAttributes) {
|
|
@@ -1357,6 +1367,7 @@ export async function resolveRetrieveRequest(
|
|
|
1357
1367
|
targetInput: request.targetInput,
|
|
1358
1368
|
cdbFile: request.cdbFile,
|
|
1359
1369
|
cdbPassword: request.cdbPassword,
|
|
1370
|
+
match: request.match,
|
|
1360
1371
|
},
|
|
1361
1372
|
env,
|
|
1362
1373
|
config,
|
package/src/settings.ts
CHANGED
package/src/terminal.ts
CHANGED
|
@@ -81,6 +81,12 @@ export interface TerminalRequest {
|
|
|
81
81
|
insecure?: boolean;
|
|
82
82
|
cdbFile?: string;
|
|
83
83
|
cdbPassword?: string;
|
|
84
|
+
/**
|
|
85
|
+
* `--match` selector for a `<router>` several CDB records share
|
|
86
|
+
* (`user=<name>`, `target=<addr>`, or a record type): the same selector
|
|
87
|
+
* `devices show --match` takes. Single-target only (#583).
|
|
88
|
+
*/
|
|
89
|
+
match?: string;
|
|
84
90
|
format?: string;
|
|
85
91
|
verbose?: boolean;
|
|
86
92
|
}
|
|
@@ -154,6 +160,7 @@ export async function resolveTerminalRequest(
|
|
|
154
160
|
targetInput: request.targetInput,
|
|
155
161
|
cdbFile: request.cdbFile,
|
|
156
162
|
cdbPassword: request.cdbPassword,
|
|
163
|
+
match: request.match,
|
|
157
164
|
},
|
|
158
165
|
env,
|
|
159
166
|
config,
|
package/src/transfer-fanout.ts
CHANGED
|
@@ -48,6 +48,7 @@ import {
|
|
|
48
48
|
import { promptForWriteConfirmation } from "./execute.ts";
|
|
49
49
|
import { plannedProtocols, type RouterOsProtocol } from "./protocols/index.ts";
|
|
50
50
|
import {
|
|
51
|
+
assertNoMatchInFanout,
|
|
51
52
|
emptyWriteSelectionError,
|
|
52
53
|
expandSelection,
|
|
53
54
|
isDefaultRecordTarget,
|
|
@@ -265,6 +266,7 @@ export async function transferFanout(
|
|
|
265
266
|
options: TransferFanoutOptions = {},
|
|
266
267
|
): Promise<TransferFanoutEnvelope> {
|
|
267
268
|
validateTransferRequestShape(request);
|
|
269
|
+
assertNoMatchInFanout(request.match);
|
|
268
270
|
const config = await loadEnvFileDefaults(env);
|
|
269
271
|
const selectionSummary = summarizeSelection(selection);
|
|
270
272
|
const requestSummary: TransferFanoutRequestSummary = {
|
package/src/transfer.ts
CHANGED
|
@@ -113,6 +113,20 @@ export const transferMethods = [
|
|
|
113
113
|
] as const;
|
|
114
114
|
export type TransferMethod = (typeof transferMethods)[number];
|
|
115
115
|
|
|
116
|
+
/**
|
|
117
|
+
* The transfer method a CDB record's transport preference (`via=` in its
|
|
118
|
+
* comment, shared with execute) selects (#591). A transport missing here
|
|
119
|
+
* (mac-telnet, winbox, …) cannot carry files and fails `settings/invalid-via`
|
|
120
|
+
* when the record's preference is what decided the method.
|
|
121
|
+
*/
|
|
122
|
+
export const transferMethodForTransport: Partial<
|
|
123
|
+
Record<RouterOsProtocol, TransferMethod>
|
|
124
|
+
> = {
|
|
125
|
+
ssh: "sftp",
|
|
126
|
+
"rest-api": "rest",
|
|
127
|
+
"native-api": "native",
|
|
128
|
+
};
|
|
129
|
+
|
|
116
130
|
/** RouterOS `/file` contents write cap (`/file/set contents`). */
|
|
117
131
|
export const REST_FILE_WRITE_CAP_BYTES = 60_000;
|
|
118
132
|
/** Maximum `/file/read` chunk RouterOS accepts (`chunk-size` upper bound). */
|
|
@@ -158,6 +172,12 @@ export interface TransferRequest {
|
|
|
158
172
|
name?: string;
|
|
159
173
|
cdbFile?: string;
|
|
160
174
|
cdbPassword?: string;
|
|
175
|
+
/**
|
|
176
|
+
* `--match` selector for a `<router>` several CDB records share
|
|
177
|
+
* (`user=<name>`, `target=<addr>`, or a record type): the same selector
|
|
178
|
+
* `devices show --match` takes. Single-target only (#583).
|
|
179
|
+
*/
|
|
180
|
+
match?: string;
|
|
161
181
|
/** `download` fan-out: directory to write one file per target into. */
|
|
162
182
|
outDir?: string;
|
|
163
183
|
/** Fan-out write gate: confirm a mutating fan-out (upload/remove/mkdir/copy). */
|
|
@@ -410,7 +430,7 @@ async function runUpload(
|
|
|
410
430
|
isRestFamily(resolved.method) &&
|
|
411
431
|
bytes.byteLength > REST_FILE_WRITE_CAP_BYTES
|
|
412
432
|
) {
|
|
413
|
-
throw restWriteCapError(remote, bytes.byteLength);
|
|
433
|
+
throw restWriteCapError(remote, bytes.byteLength, resolved.via);
|
|
414
434
|
}
|
|
415
435
|
|
|
416
436
|
const existing = await backend.findFile(remote);
|
|
@@ -879,13 +899,6 @@ export async function resolveTransferRequest(
|
|
|
879
899
|
const direction: TransferDirection =
|
|
880
900
|
request.verb === "upload" ? "upload" : "download";
|
|
881
901
|
const sizeHint = uploadSizeHint(request);
|
|
882
|
-
const { method, protocol, via } = selectTransferMethod(
|
|
883
|
-
request,
|
|
884
|
-
direction,
|
|
885
|
-
sizeHint,
|
|
886
|
-
env,
|
|
887
|
-
config,
|
|
888
|
-
);
|
|
889
902
|
|
|
890
903
|
// A quickchr target is a named-live-provider: host/port/auth come from the
|
|
891
904
|
// live descriptor, bypassing CDB and the `__default__` ladder. For
|
|
@@ -908,10 +921,21 @@ export async function resolveTransferRequest(
|
|
|
908
921
|
targetInput: request.targetInput,
|
|
909
922
|
cdbFile: request.cdbFile,
|
|
910
923
|
cdbPassword: request.cdbPassword,
|
|
924
|
+
match: request.match,
|
|
911
925
|
},
|
|
912
926
|
env,
|
|
913
927
|
config,
|
|
914
928
|
)));
|
|
929
|
+
// After CDB resolution: a record's `via=` preference is a layer of the
|
|
930
|
+
// method choice, mapped from transport to transfer-method names (#591).
|
|
931
|
+
const { method, protocol, via } = selectTransferMethod(
|
|
932
|
+
request,
|
|
933
|
+
direction,
|
|
934
|
+
sizeHint,
|
|
935
|
+
env,
|
|
936
|
+
config,
|
|
937
|
+
cdbResolution?.overrides.via,
|
|
938
|
+
);
|
|
915
939
|
|
|
916
940
|
const format = resolveTransferFormat(request, env, config);
|
|
917
941
|
const validate = resolveBooleanSetting(
|
|
@@ -1041,6 +1065,7 @@ export function selectTransferMethod(
|
|
|
1041
1065
|
uploadBytes: number | undefined,
|
|
1042
1066
|
env: Record<string, string | undefined>,
|
|
1043
1067
|
config: Record<string, string | undefined> = {},
|
|
1068
|
+
cdbVia?: ResolvedSetting<RouterOsProtocol>,
|
|
1044
1069
|
): {
|
|
1045
1070
|
method: TransferMethod;
|
|
1046
1071
|
protocol: RouterOsProtocol;
|
|
@@ -1053,10 +1078,32 @@ export function selectTransferMethod(
|
|
|
1053
1078
|
"auto",
|
|
1054
1079
|
"via",
|
|
1055
1080
|
undefined,
|
|
1056
|
-
undefined
|
|
1081
|
+
cdbVia === undefined
|
|
1082
|
+
? undefined
|
|
1083
|
+
: {
|
|
1084
|
+
value: transferMethodForTransport[cdbVia.value] ?? cdbVia.value,
|
|
1085
|
+
source: cdbVia.source,
|
|
1086
|
+
},
|
|
1057
1087
|
config,
|
|
1058
1088
|
) as ResolvedSetting<string>;
|
|
1059
1089
|
const requested = via.value;
|
|
1090
|
+
if (
|
|
1091
|
+
via.source.kind === "comment-kv" &&
|
|
1092
|
+
cdbVia !== undefined &&
|
|
1093
|
+
transferMethodForTransport[cdbVia.value] === undefined
|
|
1094
|
+
) {
|
|
1095
|
+
throw new CentrsError({
|
|
1096
|
+
code: "settings/invalid-via",
|
|
1097
|
+
summary: `The CDB record prefers \`via=${cdbVia.value}\`, which cannot carry files.`,
|
|
1098
|
+
remediation:
|
|
1099
|
+
"Pass `--via sftp`, `--via rest` or `--via native` for this transfer (or set `CENTRS_TRANSFER_VIA`). The record's `via` stays in effect for execute.",
|
|
1100
|
+
context: {
|
|
1101
|
+
via: cdbVia.value,
|
|
1102
|
+
source: cdbVia.source.key,
|
|
1103
|
+
supported: Object.keys(transferMethodForTransport),
|
|
1104
|
+
},
|
|
1105
|
+
});
|
|
1106
|
+
}
|
|
1060
1107
|
|
|
1061
1108
|
if (requested === "auto") {
|
|
1062
1109
|
// Auto: REST family carries reads and small writes; a large upload exceeds
|
|
@@ -1078,7 +1125,8 @@ export function selectTransferMethod(
|
|
|
1078
1125
|
if (requested === "native" || requested === "native-api") {
|
|
1079
1126
|
return { method: "native", protocol: "native-api", via };
|
|
1080
1127
|
}
|
|
1081
|
-
|
|
1128
|
+
// `ssh` is the transport name `execute` and CDB `via=` use (#591).
|
|
1129
|
+
if (requested === "sftp" || requested === "ssh") {
|
|
1082
1130
|
return { method: "sftp", protocol: "ssh", via };
|
|
1083
1131
|
}
|
|
1084
1132
|
if (requested === "scp" || requested === "fetch") {
|
|
@@ -1772,13 +1820,30 @@ function targetExistsError(remote: string): CentrsError {
|
|
|
1772
1820
|
});
|
|
1773
1821
|
}
|
|
1774
1822
|
|
|
1775
|
-
function restWriteCapError(
|
|
1823
|
+
function restWriteCapError(
|
|
1824
|
+
remote: string,
|
|
1825
|
+
bytes: number,
|
|
1826
|
+
via: ResolvedSetting<string>,
|
|
1827
|
+
): CentrsError {
|
|
1828
|
+
// A pinned REST method is never hopped to sftp; name the pin so a record
|
|
1829
|
+
// or env preference the caller did not type is visible (#591 review).
|
|
1830
|
+
const pinnedBy =
|
|
1831
|
+
via.source.kind === "comment-kv"
|
|
1832
|
+
? ` The method came from the CDB record's \`via=\` (\`${via.source.key}\`).`
|
|
1833
|
+
: via.source.kind === "env" || via.source.kind === "config"
|
|
1834
|
+
? ` The method came from \`${via.source.key}=${via.value}\` (${via.source.kind}).`
|
|
1835
|
+
: "";
|
|
1776
1836
|
return new CentrsError({
|
|
1777
1837
|
code: "transport/unsupported-operation",
|
|
1778
1838
|
summary: `Cannot write ${bytes} bytes to ${remote} over REST/native (60 KB cap).`,
|
|
1779
|
-
remediation:
|
|
1780
|
-
|
|
1781
|
-
|
|
1839
|
+
remediation: `Pass \`--via sftp\` for this upload; the REST \`/file\` write cap is 60 KB.${pinnedBy}`,
|
|
1840
|
+
context: {
|
|
1841
|
+
remote,
|
|
1842
|
+
bytes,
|
|
1843
|
+
capBytes: REST_FILE_WRITE_CAP_BYTES,
|
|
1844
|
+
via: via.value,
|
|
1845
|
+
viaSource: via.source.key ?? via.source.kind,
|
|
1846
|
+
},
|
|
1782
1847
|
});
|
|
1783
1848
|
}
|
|
1784
1849
|
|