@tikoci/centrs 0.1.9-next.24 → 0.1.9

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.
Files changed (71) hide show
  1. package/CHANGELOG.md +121 -6
  2. package/GLOSSARY.txt +9 -1
  3. package/README.md +43 -29
  4. package/commands/api/AGENTS.md +3 -2
  5. package/commands/api/README.md +8 -1
  6. package/commands/btest/README.md +9 -0
  7. package/commands/btest/examples.md +7 -0
  8. package/commands/devices/README.md +160 -30
  9. package/commands/devices/examples.md +168 -2
  10. package/commands/execute/README.md +13 -2
  11. package/commands/explain/README.md +223 -3
  12. package/commands/explain/examples.md +8 -1
  13. package/commands/retrieve/README.md +24 -3
  14. package/commands/retrieve/examples.md +84 -0
  15. package/commands/settings/README.md +6 -0
  16. package/commands/settings/examples.md +17 -0
  17. package/commands/transfer/README.md +25 -0
  18. package/commands/transfer/examples.md +46 -0
  19. package/docs/CLI.md +53 -11
  20. package/docs/MATRIX.md +5 -3
  21. package/docs/errors/cdb/encrypt-failed.md +10 -0
  22. package/docs/errors/cdb/password-required.md +5 -4
  23. package/docs/errors/cdb/plaintext-backups.md +11 -0
  24. package/docs/errors/input/empty-stdin.md +27 -0
  25. package/docs/errors/input/invalid-routeros-path.md +25 -3
  26. package/docs/errors/input/invalid-stdin.md +28 -0
  27. package/docs/errors/usage/stdin-is-tty.md +21 -0
  28. package/package.json +11 -7
  29. package/src/api-fanout.ts +2 -0
  30. package/src/api.ts +12 -0
  31. package/src/btest.ts +7 -0
  32. package/src/cli/api.ts +19 -2
  33. package/src/cli/btest.ts +9 -0
  34. package/src/cli/common.ts +1 -0
  35. package/src/cli/devices.ts +248 -15
  36. package/src/cli/execute.ts +12 -3
  37. package/src/cli/retrieve.ts +9 -0
  38. package/src/cli/settings.ts +28 -6
  39. package/src/cli/terminal.ts +9 -0
  40. package/src/cli/transfer.ts +10 -1
  41. package/src/core/error-catalog.ts +28 -2
  42. package/src/core/menu-path.ts +89 -0
  43. package/src/devices.ts +378 -33
  44. package/src/execute-fanout.ts +2 -0
  45. package/src/execute.ts +7 -0
  46. package/src/explain/blocks.ts +2 -2
  47. package/src/explain/catalog.ts +22 -12
  48. package/src/explain/pasted-prompt.ts +198 -0
  49. package/src/explain/pathresolve.ts +2 -2
  50. package/src/explain/segment.ts +2 -2
  51. package/src/explain/symbols.ts +2 -2
  52. package/src/explain/verbsplit.ts +2 -2
  53. package/src/explain/write.ts +2 -2
  54. package/src/explain.ts +2 -0
  55. package/src/index.ts +5 -0
  56. package/src/mcp/safety.ts +7 -6
  57. package/src/mcp/tools.ts +30 -68
  58. package/src/protocols/adapter.ts +2 -0
  59. package/src/protocols/btest-session.ts +10 -30
  60. package/src/protocols/ec-srp5.ts +19 -10
  61. package/src/protocols/mac-telnet-console.ts +2 -2
  62. package/src/resolver/cdb.ts +66 -9
  63. package/src/resolver/index.ts +1 -0
  64. package/src/resolver/quickchr-provider.ts +5 -0
  65. package/src/resolver/selection.ts +15 -4
  66. package/src/retrieve-fanout.ts +2 -0
  67. package/src/retrieve.ts +11 -0
  68. package/src/settings.ts +24 -8
  69. package/src/terminal.ts +7 -0
  70. package/src/transfer-fanout.ts +2 -0
  71. package/src/transfer.ts +79 -14
@@ -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, identity hash, and proof derivation match all
20
- * three, and the proof is accepted by real RouterOS over the MAC-Telnet L2
21
- * harness.
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(bigIntToBytesBE(seed, 32));
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 = mod(seed + 1n, 1n << 256n);
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 = bigIntToBytesBE(mod(pt.x + W2M, P), 32);
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)`. Unchanged in bytes from the original
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 = bigIntToBytesBE(mod(pt.x + W2M, P), 32);
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 in `.scratch/`,
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`,
@@ -4,7 +4,7 @@
4
4
  * This is the transport-agnostic seam every command (`retrieve`, and later
5
5
  * `execute` / `devices`) shares for turning a `<router>` argument into a
6
6
  * concrete target plus per-device option overrides. It reuses the devices CDB
7
- * primitives (`loadCdb` / `showDevice`) so decode/decrypt logic lives in one
7
+ * primitives (`loadCdb` / `lookupDeviceRecord`) so decode/decrypt logic lives in one
8
8
  * place, then layers the comment kv-soup contract on top.
9
9
  *
10
10
  * Comment-kv values arrive as RAW strings from {@link parseCommentKv}. This
@@ -32,8 +32,8 @@ import {
32
32
  type DevicesWarning,
33
33
  type LoadedCdb,
34
34
  loadCdb,
35
+ lookupDeviceRecord,
35
36
  resolveDevicesSettings,
36
- showDevice,
37
37
  } from "../devices.ts";
38
38
  import { CentrsError } from "../errors.ts";
39
39
  import { plannedProtocols, type RouterOsProtocol } from "../protocols/index.ts";
@@ -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,13 +256,18 @@ export async function resolveCdb(
211
256
  const defaults = findDefaultCreds(cdb);
212
257
 
213
258
  try {
214
- const envelope = showDevice({ cdb, target: input.targetInput });
215
- const entry = envelope.data.entry;
216
- const warnings: ResolverWarning[] =
217
- envelope.warnings.map(warningFromDevices);
259
+ // The raw record, not the `showDevice` envelope: that envelope withholds
260
+ // the password (#551), and dialing needs the stored credential.
261
+ const found = lookupDeviceRecord({
262
+ cdb,
263
+ target: input.targetInput,
264
+ match: input.match,
265
+ });
266
+ const entry = found.entry;
267
+ const warnings: ResolverWarning[] = found.warnings.map(warningFromDevices);
218
268
  const overrides = coerceCommentKv(
219
269
  entry.comment,
220
- entry.cdbRecordIndex,
270
+ found.cdbRecordIndex,
221
271
  warnings,
222
272
  );
223
273
  return {
@@ -226,7 +276,7 @@ export async function resolveCdb(
226
276
  mac: macFromComment(entry.comment, entry.target),
227
277
  username: entry.user,
228
278
  password: entry.password,
229
- recordIndex: entry.cdbRecordIndex,
279
+ recordIndex: found.cdbRecordIndex,
230
280
  overrides,
231
281
  warnings,
232
282
  ...(defaults ? { defaults } : {}),
@@ -236,6 +286,13 @@ export async function resolveCdb(
236
286
  if (redactTargetInput(input.targetInput) !== input.targetInput) {
237
287
  assertNoTargetCredentials(input.targetInput);
238
288
  }
289
+ if (
290
+ error instanceof CentrsError &&
291
+ error.code === "cdb/not-found-target" &&
292
+ input.match !== undefined
293
+ ) {
294
+ matchWithoutRecord(input, "no CDB record matches it");
295
+ }
239
296
  if (error instanceof CentrsError && error.code === "cdb/not-found-target") {
240
297
  // A `__default__` record supplies fallback credentials even when the
241
298
  // target has no record of its own. On the CLI/API this fills creds for
@@ -19,6 +19,7 @@
19
19
  */
20
20
 
21
21
  export {
22
+ assertNoMatchInFanout,
22
23
  type CdbGroupExpansion,
23
24
  type CdbGroupResolveInput,
24
25
  type CdbGroupTarget,
@@ -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
  }
@@ -12,7 +12,7 @@
12
12
  * Boundaries this module is careful about:
13
13
  * - The CDB is loaded + decrypted ONCE for the whole expansion.
14
14
  * - A positional is matched against the already-loaded entries with
15
- * {@link showDevice} (the identity matcher), NOT `resolveCdb` — `resolveCdb`
15
+ * {@link lookupDeviceRecord} (the identity matcher), NOT `resolveCdb` — `resolveCdb`
16
16
  * re-loads the CDB and, when a `__default__` record exists, returns a
17
17
  * synthetic resolution for ANY unmatched target, which would collide two
18
18
  * distinct ad-hoc literals on the default record's index. A non-matching
@@ -30,8 +30,8 @@
30
30
  import {
31
31
  type LoadedCdb,
32
32
  loadCdb,
33
+ lookupDeviceRecord,
33
34
  resolveDevicesSettings,
34
- showDevice,
35
35
  } from "../devices.ts";
36
36
  import { CentrsError } from "../errors.ts";
37
37
  import {
@@ -282,8 +282,8 @@ export async function expandSelection(
282
282
  for (const positional of selection.positionals) {
283
283
  assertNoTargetPassword(positional);
284
284
  try {
285
- const shown = showDevice({ cdb, target: positional });
286
- indices.add(shown.data.entry.cdbRecordIndex);
285
+ const shown = lookupDeviceRecord({ cdb, target: positional });
286
+ indices.add(shown.cdbRecordIndex);
287
287
  } catch (error) {
288
288
  // Preserve registered identities; mask credential-shaped lookup failures.
289
289
  if (redactTargetInput(positional) !== positional) {
@@ -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
  }
@@ -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
@@ -50,6 +50,11 @@ export type SettingsCommand = "print" | "get" | "set" | "reset";
50
50
  export interface SettingsOperationMeta {
51
51
  command: SettingsCommand;
52
52
  settingsFile: string;
53
+ /**
54
+ * The canonical attr name `get` resolved (`ssh-key` for `CENTRS_SSH_KEY`).
55
+ * `get`'s `data` is the bare entry, so this is the only place that names it.
56
+ */
57
+ attr?: string;
53
58
  }
54
59
 
55
60
  export type SettingsEnvelope<Data> = CentrsEnvelope<
@@ -65,12 +70,16 @@ export type SettingsErrorEnvelope = CentrsErrorEnvelope<SettingsOperationMeta>;
65
70
  function settingsMeta(
66
71
  command: SettingsCommand,
67
72
  settingsFile: string,
73
+ attr?: string,
68
74
  ): EnvelopeMeta<SettingsOperationMeta> {
69
75
  return {
70
76
  target: {},
71
77
  via: null,
72
78
  settings: {},
73
- operation: { command, settingsFile },
79
+ operation:
80
+ attr === undefined
81
+ ? { command, settingsFile }
82
+ : { command, settingsFile, attr },
74
83
  };
75
84
  }
76
85
 
@@ -153,6 +162,7 @@ const TRANSFER_VIA_VALUES = [
153
162
  "native",
154
163
  "native-api",
155
164
  "sftp",
165
+ "ssh",
156
166
  "scp",
157
167
  "fetch",
158
168
  "ftp",
@@ -831,7 +841,7 @@ export async function settingsGet(
831
841
  data,
832
842
  warnings: [],
833
843
  tips,
834
- meta: settingsMeta("get", settingsFile),
844
+ meta: settingsMeta("get", settingsFile, lookup.def.attr),
835
845
  };
836
846
  }
837
847
 
@@ -1000,6 +1010,12 @@ function formatValue(value: SettingsValue | null): string {
1000
1010
  return String(value);
1001
1011
  }
1002
1012
 
1013
+ /** One `key: value [source: kind (key)]` line, shared by `print` and `get`. */
1014
+ function formatEntryLine(key: string, entry: SettingsKeyEntry): string {
1015
+ const sourceKey = entry.source.key ? ` (${entry.source.key})` : "";
1016
+ return `${key}: ${formatValue(entry.value)} [source: ${entry.source.kind}${sourceKey}]`;
1017
+ }
1018
+
1003
1019
  function renderSettingsSuccessText(
1004
1020
  envelope: SettingsSuccessEnvelope<unknown>,
1005
1021
  ): string {
@@ -1007,16 +1023,16 @@ function renderSettingsSuccessText(
1007
1023
  const command = envelope.meta.operation?.command;
1008
1024
  const data = envelope.data as Record<string, unknown>;
1009
1025
 
1010
- if (command === "print" || command === "get") {
1026
+ if (command === "get") {
1027
+ // `get`'s data is one bare entry, not a key→entry map (#587).
1028
+ const attr = envelope.meta.operation?.attr ?? "(unknown)";
1029
+ lines.push(formatEntryLine(attr, data as unknown as SettingsKeyEntry));
1030
+ } else if (command === "print") {
1011
1031
  for (const [key, entry] of Object.entries(data)) {
1012
1032
  if (key === "unrecognized" || key === "defaultDevice") {
1013
1033
  continue;
1014
1034
  }
1015
- const typed = entry as SettingsKeyEntry;
1016
- const sourceKey = typed.source.key ? ` (${typed.source.key})` : "";
1017
- lines.push(
1018
- `${key}: ${formatValue(typed.value)} [source: ${typed.source.kind}${sourceKey}]`,
1019
- );
1035
+ lines.push(formatEntryLine(key, entry as SettingsKeyEntry));
1020
1036
  }
1021
1037
  const unrecognized = data["unrecognized"] as
1022
1038
  | readonly SettingsUnrecognizedEntry[]
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,
@@ -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
- if (requested === "sftp") {
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(remote: string, bytes: number): CentrsError {
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
- "Large uploads need sftp (lands with the SSH transport); the REST `/file` write cap is 60 KB.",
1781
- context: { remote, bytes, capBytes: REST_FILE_WRITE_CAP_BYTES },
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