@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.
- package/CHANGELOG.md +121 -6
- package/GLOSSARY.txt +9 -1
- package/README.md +43 -29
- 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 +160 -30
- package/commands/devices/examples.md +168 -2
- package/commands/execute/README.md +13 -2
- package/commands/explain/README.md +223 -3
- package/commands/explain/examples.md +8 -1
- package/commands/retrieve/README.md +24 -3
- package/commands/retrieve/examples.md +84 -0
- package/commands/settings/README.md +6 -0
- package/commands/settings/examples.md +17 -0
- package/commands/transfer/README.md +25 -0
- package/commands/transfer/examples.md +46 -0
- package/docs/CLI.md +53 -11
- 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/empty-stdin.md +27 -0
- package/docs/errors/input/invalid-routeros-path.md +25 -3
- package/docs/errors/input/invalid-stdin.md +28 -0
- package/docs/errors/usage/stdin-is-tty.md +21 -0
- package/package.json +11 -7
- package/src/api-fanout.ts +2 -0
- package/src/api.ts +12 -0
- package/src/btest.ts +7 -0
- package/src/cli/api.ts +19 -2
- package/src/cli/btest.ts +9 -0
- package/src/cli/common.ts +1 -0
- package/src/cli/devices.ts +248 -15
- package/src/cli/execute.ts +12 -3
- package/src/cli/retrieve.ts +9 -0
- package/src/cli/settings.ts +28 -6
- package/src/cli/terminal.ts +9 -0
- package/src/cli/transfer.ts +10 -1
- package/src/core/error-catalog.ts +28 -2
- package/src/core/menu-path.ts +89 -0
- package/src/devices.ts +378 -33
- 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 +5 -0
- package/src/mcp/safety.ts +7 -6
- package/src/mcp/tools.ts +30 -68
- 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 +66 -9
- package/src/resolver/index.ts +1 -0
- package/src/resolver/quickchr-provider.ts +5 -0
- package/src/resolver/selection.ts +15 -4
- package/src/retrieve-fanout.ts +2 -0
- package/src/retrieve.ts +11 -0
- package/src/settings.ts +24 -8
- package/src/terminal.ts +7 -0
- package/src/transfer-fanout.ts +2 -0
- package/src/transfer.ts +79 -14
package/src/devices.ts
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
decryptWinBoxCdb,
|
|
20
20
|
type EncryptedWinBoxCdbFile,
|
|
21
21
|
encodeOpenWinBoxCdb,
|
|
22
|
+
encryptWinBoxCdb,
|
|
22
23
|
parseWinBoxCdb,
|
|
23
24
|
type WinBoxCdbEntry,
|
|
24
25
|
type WinBoxCdbField,
|
|
@@ -93,8 +94,16 @@ export interface DevicesListItem {
|
|
|
93
94
|
location?: DeviceLocation;
|
|
94
95
|
}
|
|
95
96
|
|
|
97
|
+
/**
|
|
98
|
+
* A CDB record as `devices show` / `add` / `set` return it. The record password
|
|
99
|
+
* is withheld by default (#551): `passwordSet` says whether one is stored, and
|
|
100
|
+
* only {@link DevicesShowItemWithPassword} (the `showPassword: true` opt-in)
|
|
101
|
+
* carries the value. Not to be confused with `savedPassword`, the WinBox
|
|
102
|
+
* "keep password" flag.
|
|
103
|
+
*/
|
|
96
104
|
export interface DevicesShowItem extends DevicesListItem {
|
|
97
|
-
password
|
|
105
|
+
/** True when the record stores a non-empty password. Always present. */
|
|
106
|
+
passwordSet: boolean;
|
|
98
107
|
comment: string;
|
|
99
108
|
commentMirror: string;
|
|
100
109
|
profile: string;
|
|
@@ -104,6 +113,11 @@ export interface DevicesShowItem extends DevicesListItem {
|
|
|
104
113
|
flags: number;
|
|
105
114
|
}
|
|
106
115
|
|
|
116
|
+
/** {@link DevicesShowItem} plus the cleartext record password (`showPassword: true`). */
|
|
117
|
+
export interface DevicesShowItemWithPassword extends DevicesShowItem {
|
|
118
|
+
password: string;
|
|
119
|
+
}
|
|
120
|
+
|
|
107
121
|
export interface DevicesGroupSummary {
|
|
108
122
|
group: string;
|
|
109
123
|
members: number;
|
|
@@ -112,6 +126,7 @@ export interface DevicesGroupSummary {
|
|
|
112
126
|
|
|
113
127
|
export type DevicesCommand =
|
|
114
128
|
| "init"
|
|
129
|
+
| "encrypt"
|
|
115
130
|
| "list"
|
|
116
131
|
| "show"
|
|
117
132
|
| "groups"
|
|
@@ -165,7 +180,7 @@ export interface LoadedCdb {
|
|
|
165
180
|
entries: readonly WinBoxCdbEntry[];
|
|
166
181
|
settings: DevicesSettings;
|
|
167
182
|
warnings: readonly DevicesWarning[];
|
|
168
|
-
/** True when the source file on disk was encrypted
|
|
183
|
+
/** True when the source file on disk was encrypted. */
|
|
169
184
|
encrypted: boolean;
|
|
170
185
|
}
|
|
171
186
|
|
|
@@ -232,7 +247,7 @@ function isAlreadyExistsError(cause: unknown): boolean {
|
|
|
232
247
|
}
|
|
233
248
|
|
|
234
249
|
/**
|
|
235
|
-
* Create an empty
|
|
250
|
+
* Create an empty CDB at `path` without clobbering an existing file.
|
|
236
251
|
* The bytes are written to a private temp file first and then hard-linked
|
|
237
252
|
* into place: `link` fails with EEXIST instead of replacing, and `path` never
|
|
238
253
|
* exists half-written, so a concurrent creator that loses the race reads a
|
|
@@ -242,14 +257,20 @@ function isAlreadyExistsError(cause: unknown): boolean {
|
|
|
242
257
|
* file. Mode `0o600` keeps the CDB (device credentials) unreadable by other
|
|
243
258
|
* local users instead of falling back to the process umask default.
|
|
244
259
|
*/
|
|
245
|
-
async function createEmptyCdbNoClobber(
|
|
260
|
+
async function createEmptyCdbNoClobber(
|
|
261
|
+
path: string,
|
|
262
|
+
password?: string,
|
|
263
|
+
): Promise<boolean> {
|
|
246
264
|
await mkdir(dirname(path), { recursive: true });
|
|
247
265
|
const tempPath = `${path}.init.${randomUUID()}`;
|
|
248
266
|
const handle = await open(tempPath, "wx", 0o600);
|
|
249
267
|
try {
|
|
250
268
|
try {
|
|
251
269
|
await handle.chmod(0o600);
|
|
252
|
-
|
|
270
|
+
const bytes = encodeOpenWinBoxCdb([]);
|
|
271
|
+
await handle.write(
|
|
272
|
+
password === undefined ? bytes : encryptWinBoxCdb(bytes, password),
|
|
273
|
+
);
|
|
253
274
|
await handle.sync();
|
|
254
275
|
} finally {
|
|
255
276
|
await handle.close();
|
|
@@ -270,11 +291,19 @@ export interface DevicesInitData {
|
|
|
270
291
|
cdbFile: string;
|
|
271
292
|
/** False when a readable CDB was already there; nothing was written. */
|
|
272
293
|
created: boolean;
|
|
294
|
+
/** Actual mode of the newly created or verified existing CDB. */
|
|
295
|
+
encrypted: boolean;
|
|
273
296
|
recordCount: number;
|
|
274
297
|
}
|
|
275
298
|
|
|
299
|
+
export interface InitCdbOptions extends LoadCdbOptions {
|
|
300
|
+
/** Require encryption; an existing open CDB is refused, never converted. */
|
|
301
|
+
encrypt?: boolean;
|
|
302
|
+
}
|
|
303
|
+
|
|
276
304
|
/**
|
|
277
|
-
* `devices init
|
|
305
|
+
* `devices init`: create an empty CDB (open by default, encrypted with
|
|
306
|
+
* `encrypt: true`) at the resolved path, the
|
|
278
307
|
* explicit counterpart of the default path's first-run auto-create. Never
|
|
279
308
|
* clobbers: like `git init`, re-running on an existing, readable CDB succeeds
|
|
280
309
|
* with a `cdb/file-exists` warning and writes nothing, so a workspace bootstrap
|
|
@@ -282,15 +311,29 @@ export interface DevicesInitData {
|
|
|
282
311
|
* (not a CDB, or encrypted without a password) fails with the load error.
|
|
283
312
|
*/
|
|
284
313
|
export async function initCdb(
|
|
285
|
-
options:
|
|
314
|
+
options: InitCdbOptions,
|
|
286
315
|
): Promise<CentrsSuccessEnvelope<DevicesInitData, DevicesOperationMeta>> {
|
|
287
316
|
const settings = resolveDevicesSettings(options);
|
|
288
317
|
const cdbFile = settings.cdbFile.value;
|
|
318
|
+
if (options.encrypt) requireEncryptionPassword(settings);
|
|
289
319
|
const existingResult = async () => {
|
|
290
320
|
const existing = await loadCdb(options);
|
|
321
|
+
if (options.encrypt && !existing.encrypted) {
|
|
322
|
+
throw new CentrsError({
|
|
323
|
+
code: "input/invalid-command",
|
|
324
|
+
summary: `An open CDB already exists at ${cdbFile}; encrypted initialization wrote nothing.`,
|
|
325
|
+
remediation: `Run \`centrs devices encrypt --cdb-file ${cdbFile}\` to convert the existing CDB explicitly.`,
|
|
326
|
+
context: { cdbFile },
|
|
327
|
+
});
|
|
328
|
+
}
|
|
291
329
|
return {
|
|
292
330
|
ok: true as const,
|
|
293
|
-
data: {
|
|
331
|
+
data: {
|
|
332
|
+
cdbFile,
|
|
333
|
+
created: false,
|
|
334
|
+
encrypted: existing.encrypted,
|
|
335
|
+
recordCount: existing.entries.length,
|
|
336
|
+
},
|
|
294
337
|
warnings: [
|
|
295
338
|
...existing.warnings,
|
|
296
339
|
{
|
|
@@ -310,7 +353,10 @@ export async function initCdb(
|
|
|
310
353
|
}
|
|
311
354
|
let created: boolean;
|
|
312
355
|
try {
|
|
313
|
-
created = await createEmptyCdbNoClobber(
|
|
356
|
+
created = await createEmptyCdbNoClobber(
|
|
357
|
+
cdbFile,
|
|
358
|
+
options.encrypt ? settings.cdbPassword.value : undefined,
|
|
359
|
+
);
|
|
314
360
|
} catch (cause) {
|
|
315
361
|
throw new CentrsError({
|
|
316
362
|
code: "cdb/create-failed",
|
|
@@ -326,13 +372,113 @@ export async function initCdb(
|
|
|
326
372
|
}
|
|
327
373
|
return {
|
|
328
374
|
ok: true,
|
|
329
|
-
data: {
|
|
375
|
+
data: {
|
|
376
|
+
cdbFile,
|
|
377
|
+
created: true,
|
|
378
|
+
encrypted: options.encrypt === true,
|
|
379
|
+
recordCount: 0,
|
|
380
|
+
},
|
|
330
381
|
warnings: [],
|
|
331
382
|
tips: [],
|
|
332
383
|
meta: devicesMeta("init", settings),
|
|
333
384
|
};
|
|
334
385
|
}
|
|
335
386
|
|
|
387
|
+
function requireEncryptionPassword(settings: DevicesSettings): void {
|
|
388
|
+
if (
|
|
389
|
+
!settings.cdbPassword.provided ||
|
|
390
|
+
settings.cdbPassword.value.length === 0
|
|
391
|
+
) {
|
|
392
|
+
throw new CentrsError({
|
|
393
|
+
code: "cdb/password-required",
|
|
394
|
+
summary: "CDB encryption requires a non-empty password.",
|
|
395
|
+
remediation:
|
|
396
|
+
"Pass --cdb-password or set CENTRS_CDB_PASSWORD to a non-empty CDB password.",
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
export interface EncryptCdbOptions extends LoadCdbOptions {
|
|
402
|
+
/**
|
|
403
|
+
* Skip the plaintext pre-conversion snapshot. Existing backups are still
|
|
404
|
+
* retained untouched; without the snapshot a forgotten password is final.
|
|
405
|
+
*/
|
|
406
|
+
skipBackup?: boolean;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
export interface DevicesEncryptData {
|
|
410
|
+
cdbFile: string;
|
|
411
|
+
encrypted: true;
|
|
412
|
+
/** False when the CDB was already encrypted and the password verified. */
|
|
413
|
+
changed: boolean;
|
|
414
|
+
recordCount: number;
|
|
415
|
+
/** Plaintext pre-conversion snapshot; absent when unchanged or `skipBackup`. */
|
|
416
|
+
backupPath?: string;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Explicitly convert an existing open CDB with a fresh salt and atomic replacement.
|
|
421
|
+
* Retain all backups: converting the active file does not protect old plaintext.
|
|
422
|
+
* Close WinBox and other writers first; this is not a cross-process transaction (#581).
|
|
423
|
+
* Already-encrypted files are verified with the supplied password and left untouched.
|
|
424
|
+
* `skipBackup` omits the new plaintext snapshot; it never deletes older backups.
|
|
425
|
+
*/
|
|
426
|
+
export async function encryptCdb(
|
|
427
|
+
options: EncryptCdbOptions,
|
|
428
|
+
): Promise<CentrsSuccessEnvelope<DevicesEncryptData, DevicesOperationMeta>> {
|
|
429
|
+
const settings = resolveDevicesSettings(options);
|
|
430
|
+
requireEncryptionPassword(settings);
|
|
431
|
+
const cdbFile = settings.cdbFile.value;
|
|
432
|
+
// Pin even the default path so conversion never implicitly creates a file.
|
|
433
|
+
const cdb = await loadCdb({ ...options, cdbFile });
|
|
434
|
+
let backupPath: string | undefined;
|
|
435
|
+
if (!cdb.encrypted) {
|
|
436
|
+
try {
|
|
437
|
+
const written = await writeWinBoxCdb(
|
|
438
|
+
cdbFile,
|
|
439
|
+
cdb.entries.map((entry) => entry.record),
|
|
440
|
+
{
|
|
441
|
+
encryptWith: { password: settings.cdbPassword.value },
|
|
442
|
+
backupRetention: -1,
|
|
443
|
+
skipBackup: options.skipBackup === true,
|
|
444
|
+
},
|
|
445
|
+
);
|
|
446
|
+
backupPath = written.backupPath;
|
|
447
|
+
} catch (cause) {
|
|
448
|
+
throw new CentrsError({
|
|
449
|
+
code: "cdb/encrypt-failed",
|
|
450
|
+
summary: `Could not encrypt the CDB at ${cdbFile}.`,
|
|
451
|
+
remediation:
|
|
452
|
+
"Check directory permissions and available disk space; retain the original and any backups before retrying.",
|
|
453
|
+
context: { cdbFile },
|
|
454
|
+
cause,
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
return {
|
|
459
|
+
ok: true,
|
|
460
|
+
data: {
|
|
461
|
+
cdbFile,
|
|
462
|
+
encrypted: true,
|
|
463
|
+
changed: !cdb.encrypted,
|
|
464
|
+
recordCount: cdb.entries.length,
|
|
465
|
+
...(backupPath ? { backupPath } : {}),
|
|
466
|
+
},
|
|
467
|
+
warnings: [
|
|
468
|
+
...cdb.warnings.filter(
|
|
469
|
+
(warning) => warning.code !== "cdb/password-not-needed",
|
|
470
|
+
),
|
|
471
|
+
{
|
|
472
|
+
code: "cdb/plaintext-backups",
|
|
473
|
+
message:
|
|
474
|
+
"Backups may contain plaintext credentials. Encryption protects only the active CDB; no backups were deleted or encrypted.",
|
|
475
|
+
},
|
|
476
|
+
],
|
|
477
|
+
tips: [],
|
|
478
|
+
meta: devicesMeta("encrypt", settings),
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
|
|
336
482
|
export async function loadCdb(options: LoadCdbOptions): Promise<LoadedCdb> {
|
|
337
483
|
const settings = resolveDevicesSettings(options);
|
|
338
484
|
const warnings: DevicesWarning[] = [];
|
|
@@ -481,10 +627,19 @@ export async function loadCdb(options: LoadCdbOptions): Promise<LoadedCdb> {
|
|
|
481
627
|
for (const entry of entries) {
|
|
482
628
|
const tags = unknownTcodeTags(entry.record);
|
|
483
629
|
if (tags.length > 0) {
|
|
630
|
+
// `passwordSet` reports the password centrs decoded and would dial
|
|
631
|
+
// with. When the undecoded tail may hold one, say so here rather than
|
|
632
|
+
// let `passwordSet: false` read as certain.
|
|
633
|
+
const mayContainPassword =
|
|
634
|
+
passwordBearingRawTailIndex(entry.record) !== -1;
|
|
484
635
|
warnings.push({
|
|
485
636
|
code: "cdb/unknown-field",
|
|
486
|
-
message: `Entry ${entry.target || "(unnamed)"} contains ${tags.length} field(s) with unknown tcodes; preserved verbatim
|
|
487
|
-
context: {
|
|
637
|
+
message: `Entry ${entry.target || "(unnamed)"} contains ${tags.length} field(s) with unknown tcodes; preserved verbatim.${mayContainPassword ? " The undecoded bytes may contain the record password, so passwordSet may under-report it." : ""}`,
|
|
638
|
+
context: {
|
|
639
|
+
target: entry.target,
|
|
640
|
+
tags,
|
|
641
|
+
...(mayContainPassword ? { mayContainPassword: true } : {}),
|
|
642
|
+
},
|
|
488
643
|
});
|
|
489
644
|
}
|
|
490
645
|
}
|
|
@@ -661,13 +816,24 @@ export interface ShowDeviceArgs {
|
|
|
661
816
|
*/
|
|
662
817
|
match?: string;
|
|
663
818
|
env?: Record<string, string | undefined>;
|
|
819
|
+
/**
|
|
820
|
+
* Return the record password in cleartext (`--show-password`). Default
|
|
821
|
+
* `false`: the entry carries `passwordSet` and no `password`, and the
|
|
822
|
+
* `explain` record has its password field value replaced.
|
|
823
|
+
*/
|
|
824
|
+
showPassword?: boolean;
|
|
664
825
|
}
|
|
665
826
|
|
|
666
|
-
export interface DevicesShowEnvelopeData
|
|
667
|
-
|
|
827
|
+
export interface DevicesShowEnvelopeData<
|
|
828
|
+
Entry extends DevicesShowItem = DevicesShowItem,
|
|
829
|
+
> {
|
|
830
|
+
entry: Entry;
|
|
668
831
|
record?: WinBoxCdbRecord;
|
|
669
832
|
}
|
|
670
833
|
|
|
834
|
+
/** Placeholder written over a withheld password in an `explain` record. */
|
|
835
|
+
export const REDACTED_PASSWORD_VALUE = "<redacted>";
|
|
836
|
+
|
|
671
837
|
/**
|
|
672
838
|
* Canonical form for `<router>` comparison: a MAC is normalized (case /
|
|
673
839
|
* separator / zero-pad insensitive via {@link normalizeMac}); everything else is
|
|
@@ -798,28 +964,150 @@ function describeMatches(matches: readonly EntryMatch[]): {
|
|
|
798
964
|
}));
|
|
799
965
|
}
|
|
800
966
|
|
|
967
|
+
/**
|
|
968
|
+
* The raw CDB record `<router>` resolves to, password included. INTERNAL: for
|
|
969
|
+
* callers that dial or gate on the record (the resolver, the MCP allowlist),
|
|
970
|
+
* never for output. It is deliberately absent from `src/index.ts`; the public
|
|
971
|
+
* {@link showDevice} envelope withholds the password by default (#551).
|
|
972
|
+
*/
|
|
973
|
+
export interface DeviceRecordLookup {
|
|
974
|
+
entry: WinBoxCdbEntry;
|
|
975
|
+
cdbRecordIndex: number;
|
|
976
|
+
/** CDB load warnings plus the record's comment-kv / `via` warnings. */
|
|
977
|
+
warnings: DevicesWarning[];
|
|
978
|
+
tips: Tip[];
|
|
979
|
+
target: EnvelopeTargetMeta;
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
/**
|
|
983
|
+
* Resolve `<router>` to its raw CDB record (see {@link DeviceRecordLookup}).
|
|
984
|
+
* Same matcher, `--match` selection and errors as {@link showDevice}.
|
|
985
|
+
*/
|
|
986
|
+
export function lookupDeviceRecord(
|
|
987
|
+
args: Omit<ShowDeviceArgs, "explain" | "showPassword">,
|
|
988
|
+
): DeviceRecordLookup {
|
|
989
|
+
const match = requireSingleMatch(args.cdb.entries, args.target, args.match);
|
|
990
|
+
const resolved = resolveDeviceShowTargetMeta(match.entry, match.index, args);
|
|
991
|
+
return {
|
|
992
|
+
entry: match.entry,
|
|
993
|
+
cdbRecordIndex: match.index,
|
|
994
|
+
warnings: [...args.cdb.warnings, ...resolved.warnings],
|
|
995
|
+
tips: credentialsMissingTips(match.entry, args.cdb.entries),
|
|
996
|
+
target: resolved.target,
|
|
997
|
+
};
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
export function showDevice(
|
|
1001
|
+
args: ShowDeviceArgs & { showPassword: true },
|
|
1002
|
+
): CentrsSuccessEnvelope<
|
|
1003
|
+
DevicesShowEnvelopeData<DevicesShowItemWithPassword>,
|
|
1004
|
+
DevicesOperationMeta
|
|
1005
|
+
>;
|
|
1006
|
+
export function showDevice(
|
|
1007
|
+
args: ShowDeviceArgs,
|
|
1008
|
+
): CentrsSuccessEnvelope<DevicesShowEnvelopeData, DevicesOperationMeta>;
|
|
801
1009
|
export function showDevice(
|
|
802
1010
|
args: ShowDeviceArgs,
|
|
803
1011
|
): CentrsSuccessEnvelope<DevicesShowEnvelopeData, DevicesOperationMeta> {
|
|
804
|
-
const
|
|
1012
|
+
const found = lookupDeviceRecord(args);
|
|
1013
|
+
const showPassword = args.showPassword === true;
|
|
805
1014
|
const data: DevicesShowEnvelopeData = {
|
|
806
|
-
entry:
|
|
1015
|
+
entry: projectShowItem(
|
|
1016
|
+
entryToShowItem(found.entry, found.cdbRecordIndex),
|
|
1017
|
+
showPassword,
|
|
1018
|
+
),
|
|
807
1019
|
};
|
|
808
1020
|
if (args.explain) {
|
|
809
|
-
data.record =
|
|
1021
|
+
data.record = showPassword
|
|
1022
|
+
? found.entry.record
|
|
1023
|
+
: redactRecordPassword(found.entry.record);
|
|
810
1024
|
}
|
|
811
1025
|
|
|
812
|
-
const resolved = resolveDeviceShowTargetMeta(match.entry, match.index, args);
|
|
813
|
-
|
|
814
1026
|
return {
|
|
815
1027
|
ok: true,
|
|
816
1028
|
data,
|
|
817
|
-
warnings:
|
|
818
|
-
tips:
|
|
819
|
-
meta: devicesMeta("show", args.cdb.settings,
|
|
1029
|
+
warnings: found.warnings,
|
|
1030
|
+
tips: found.tips,
|
|
1031
|
+
meta: devicesMeta("show", args.cdb.settings, found.target),
|
|
1032
|
+
};
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
/**
|
|
1036
|
+
* The shared output redaction for a device entry (#551): drop `password`, keep
|
|
1037
|
+
* `passwordSet`. The CLI, the library envelopes and MCP all return entries
|
|
1038
|
+
* through this, so they withhold the secret the same way.
|
|
1039
|
+
*/
|
|
1040
|
+
export function redactDeviceEntry(
|
|
1041
|
+
entry: DevicesShowItemWithPassword,
|
|
1042
|
+
): DevicesShowItem {
|
|
1043
|
+
const { password, ...rest } = entry;
|
|
1044
|
+
return { ...rest, passwordSet: password.length > 0 };
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
function projectShowItem(
|
|
1048
|
+
entry: DevicesShowItemWithPassword,
|
|
1049
|
+
showPassword: boolean,
|
|
1050
|
+
): DevicesShowItem {
|
|
1051
|
+
return showPassword ? entry : redactDeviceEntry(entry);
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
/**
|
|
1055
|
+
* Replace the password field value in an `explain` record. The record is a
|
|
1056
|
+
* debugging view of the CDB bytes; it must not become a side door around the
|
|
1057
|
+
* entry redaction.
|
|
1058
|
+
*/
|
|
1059
|
+
function redactRecordPassword(record: WinBoxCdbRecord): WinBoxCdbRecord {
|
|
1060
|
+
const tailIndex = passwordBearingRawTailIndex(record);
|
|
1061
|
+
if (tailIndex === -1 && !record.fields.some(isPasswordField)) {
|
|
1062
|
+
return record;
|
|
1063
|
+
}
|
|
1064
|
+
return {
|
|
1065
|
+
...record,
|
|
1066
|
+
fields: record.fields.map((field, index) => {
|
|
1067
|
+
if (index === tailIndex) {
|
|
1068
|
+
const length =
|
|
1069
|
+
field.value instanceof Uint8Array ? field.value.length : 0;
|
|
1070
|
+
return { ...field, value: redactedRawTailValue(length) };
|
|
1071
|
+
}
|
|
1072
|
+
return isPasswordField(field)
|
|
1073
|
+
? { ...field, value: REDACTED_PASSWORD_VALUE }
|
|
1074
|
+
: field;
|
|
1075
|
+
}),
|
|
820
1076
|
};
|
|
821
1077
|
}
|
|
822
1078
|
|
|
1079
|
+
/**
|
|
1080
|
+
* Index of a `rawTail` field whose bytes may hold the password, or -1. The
|
|
1081
|
+
* decoder folds everything after an unknown tcode into one opaque tail, so
|
|
1082
|
+
* when no decoded password field (tag 3) precedes the tail, the password — if
|
|
1083
|
+
* the record has one — is somewhere inside those bytes (or the tail is tag 3
|
|
1084
|
+
* itself).
|
|
1085
|
+
*/
|
|
1086
|
+
function passwordBearingRawTailIndex(record: WinBoxCdbRecord): number {
|
|
1087
|
+
for (const [index, field] of record.fields.entries()) {
|
|
1088
|
+
if (field.rawTail) {
|
|
1089
|
+
return index;
|
|
1090
|
+
}
|
|
1091
|
+
if (field.tag === winBoxCdbFieldTag.password) {
|
|
1092
|
+
return -1;
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
return -1;
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
function redactedRawTailValue(length: number): string {
|
|
1099
|
+
return `<redacted: ${length} bytes, may contain password>`;
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
function isPasswordField(field: WinBoxCdbField): boolean {
|
|
1103
|
+
return (
|
|
1104
|
+
field.tag === winBoxCdbFieldTag.password &&
|
|
1105
|
+
!field.rawTail &&
|
|
1106
|
+
typeof field.value === "string" &&
|
|
1107
|
+
field.value.length > 0
|
|
1108
|
+
);
|
|
1109
|
+
}
|
|
1110
|
+
|
|
823
1111
|
function entryToListItem(
|
|
824
1112
|
entry: WinBoxCdbEntry,
|
|
825
1113
|
cdbRecordIndex: number,
|
|
@@ -873,7 +1161,7 @@ interface ResolvedDeviceTargetMeta {
|
|
|
873
1161
|
function resolveDeviceShowTargetMeta(
|
|
874
1162
|
entry: WinBoxCdbEntry,
|
|
875
1163
|
cdbRecordIndex: number,
|
|
876
|
-
args: ShowDeviceArgs,
|
|
1164
|
+
args: Pick<ShowDeviceArgs, "target" | "via" | "env">,
|
|
877
1165
|
): ResolvedDeviceTargetMeta {
|
|
878
1166
|
const parsed = parseCommentKv(entry.comment);
|
|
879
1167
|
const warnings: DevicesWarning[] = parsed.warnings.map((warning) => ({
|
|
@@ -989,14 +1277,15 @@ function parseProtocolOption(
|
|
|
989
1277
|
function entryToShowItem(
|
|
990
1278
|
entry: WinBoxCdbEntry,
|
|
991
1279
|
cdbRecordIndex: number,
|
|
992
|
-
):
|
|
993
|
-
const item:
|
|
1280
|
+
): DevicesShowItemWithPassword {
|
|
1281
|
+
const item: DevicesShowItemWithPassword = {
|
|
994
1282
|
target: entry.target,
|
|
995
1283
|
recordType: entry.recordType,
|
|
996
1284
|
recordTypeName: recordTypeName(entry.recordType),
|
|
997
1285
|
group: entry.group,
|
|
998
1286
|
user: entry.user,
|
|
999
1287
|
password: entry.password,
|
|
1288
|
+
passwordSet: entry.password.length > 0,
|
|
1000
1289
|
session: entry.session,
|
|
1001
1290
|
comment: entry.comment,
|
|
1002
1291
|
commentMirror: entry.commentMirror,
|
|
@@ -1067,7 +1356,9 @@ export function listGroups(
|
|
|
1067
1356
|
* resulting record (absent for `remove`); `preservedUnknownTags` lists any
|
|
1068
1357
|
* unknown/`rawTail` field tags carried over verbatim from the prior record.
|
|
1069
1358
|
*/
|
|
1070
|
-
export interface DevicesMutationData
|
|
1359
|
+
export interface DevicesMutationData<
|
|
1360
|
+
Entry extends DevicesShowItem = DevicesShowItem,
|
|
1361
|
+
> {
|
|
1071
1362
|
action: "add" | "edit" | "set" | "remove";
|
|
1072
1363
|
target: string;
|
|
1073
1364
|
cdbRecordIndex: number;
|
|
@@ -1076,13 +1367,13 @@ export interface DevicesMutationData {
|
|
|
1076
1367
|
backupPath?: string;
|
|
1077
1368
|
prunedBackups: readonly string[];
|
|
1078
1369
|
preservedUnknownTags?: readonly number[];
|
|
1079
|
-
|
|
1370
|
+
/** The resulting record; the password is withheld unless `showPassword`. */
|
|
1371
|
+
entry?: Entry;
|
|
1080
1372
|
}
|
|
1081
1373
|
|
|
1082
|
-
export type DevicesMutationEnvelope
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
>;
|
|
1374
|
+
export type DevicesMutationEnvelope<
|
|
1375
|
+
Entry extends DevicesShowItem = DevicesShowItem,
|
|
1376
|
+
> = CentrsSuccessEnvelope<DevicesMutationData<Entry>, DevicesOperationMeta>;
|
|
1086
1377
|
|
|
1087
1378
|
const KNOWN_FIELD_TAGS = new Set<number>(Object.values(winBoxCdbFieldTag));
|
|
1088
1379
|
|
|
@@ -1450,8 +1741,16 @@ export interface AddDeviceArgs {
|
|
|
1450
1741
|
force?: boolean;
|
|
1451
1742
|
strict?: boolean;
|
|
1452
1743
|
writeOptions?: WriteWinBoxCdbOptions;
|
|
1744
|
+
/** Return the stored password in `data.entry` (`--show-password`). Default `false`. */
|
|
1745
|
+
showPassword?: boolean;
|
|
1453
1746
|
}
|
|
1454
1747
|
|
|
1748
|
+
export function addDevice(
|
|
1749
|
+
args: AddDeviceArgs & { showPassword: true },
|
|
1750
|
+
): Promise<DevicesMutationEnvelope<DevicesShowItemWithPassword>>;
|
|
1751
|
+
export function addDevice(
|
|
1752
|
+
args: AddDeviceArgs,
|
|
1753
|
+
): Promise<DevicesMutationEnvelope>;
|
|
1455
1754
|
export async function addDevice(
|
|
1456
1755
|
args: AddDeviceArgs,
|
|
1457
1756
|
): Promise<DevicesMutationEnvelope> {
|
|
@@ -1532,7 +1831,10 @@ export async function addDevice(
|
|
|
1532
1831
|
args.target,
|
|
1533
1832
|
cdbRecordIndex,
|
|
1534
1833
|
existing !== undefined,
|
|
1535
|
-
|
|
1834
|
+
projectShowItem(
|
|
1835
|
+
entryToShowItem(decodeWinBoxCdbEntry(record), cdbRecordIndex),
|
|
1836
|
+
args.showPassword === true,
|
|
1837
|
+
),
|
|
1536
1838
|
preserved,
|
|
1537
1839
|
warnings,
|
|
1538
1840
|
);
|
|
@@ -1553,6 +1855,8 @@ export interface SetDeviceArgs {
|
|
|
1553
1855
|
savedPassword?: boolean;
|
|
1554
1856
|
strict?: boolean;
|
|
1555
1857
|
writeOptions?: WriteWinBoxCdbOptions;
|
|
1858
|
+
/** Return the stored password in `data.entry` (`--show-password`). Default `false`. */
|
|
1859
|
+
showPassword?: boolean;
|
|
1556
1860
|
}
|
|
1557
1861
|
|
|
1558
1862
|
/**
|
|
@@ -1562,6 +1866,12 @@ export interface SetDeviceArgs {
|
|
|
1562
1866
|
* {@link requireSingleMatch}; a missing target throws `cdb/not-found-target`.
|
|
1563
1867
|
* Unchanged fields and the comment's free-form prose are preserved verbatim.
|
|
1564
1868
|
*/
|
|
1869
|
+
export function setDevice(
|
|
1870
|
+
args: SetDeviceArgs & { showPassword: true },
|
|
1871
|
+
): Promise<DevicesMutationEnvelope<DevicesShowItemWithPassword>>;
|
|
1872
|
+
export function setDevice(
|
|
1873
|
+
args: SetDeviceArgs,
|
|
1874
|
+
): Promise<DevicesMutationEnvelope>;
|
|
1565
1875
|
export async function setDevice(
|
|
1566
1876
|
args: SetDeviceArgs,
|
|
1567
1877
|
): Promise<DevicesMutationEnvelope> {
|
|
@@ -1638,7 +1948,10 @@ export async function setDevice(
|
|
|
1638
1948
|
args.target,
|
|
1639
1949
|
match.index,
|
|
1640
1950
|
true,
|
|
1641
|
-
|
|
1951
|
+
projectShowItem(
|
|
1952
|
+
entryToShowItem(decodeWinBoxCdbEntry(record), match.index),
|
|
1953
|
+
args.showPassword === true,
|
|
1954
|
+
),
|
|
1642
1955
|
preserved,
|
|
1643
1956
|
warnings,
|
|
1644
1957
|
);
|
|
@@ -1859,6 +2172,18 @@ function renderText(envelope: DevicesEnvelope<unknown>): string {
|
|
|
1859
2172
|
? `created ${data.cdbFile}`
|
|
1860
2173
|
: `exists ${data.cdbFile} (${data.recordCount} record(s))`,
|
|
1861
2174
|
);
|
|
2175
|
+
lines.push(`encrypted: ${data.encrypted}`);
|
|
2176
|
+
break;
|
|
2177
|
+
}
|
|
2178
|
+
case "encrypt": {
|
|
2179
|
+
const data = envelope.data as DevicesEncryptData;
|
|
2180
|
+
lines.push(
|
|
2181
|
+
data.changed
|
|
2182
|
+
? `encrypted ${data.cdbFile}`
|
|
2183
|
+
: `verified encrypted ${data.cdbFile} (unchanged)`,
|
|
2184
|
+
);
|
|
2185
|
+
lines.push(`records: ${data.recordCount}`);
|
|
2186
|
+
if (data.backupPath) lines.push(`backup: ${data.backupPath}`);
|
|
1862
2187
|
break;
|
|
1863
2188
|
}
|
|
1864
2189
|
case "list":
|
|
@@ -1923,6 +2248,7 @@ function renderMutationText(lines: string[], data: DevicesMutationData): void {
|
|
|
1923
2248
|
}
|
|
1924
2249
|
if (data.entry) {
|
|
1925
2250
|
lines.push(`user: ${data.entry.user || "-"}`);
|
|
2251
|
+
lines.push(passwordLine(data.entry));
|
|
1926
2252
|
lines.push(`group: ${data.entry.group || "-"}`);
|
|
1927
2253
|
lines.push(`comment: ${data.entry.comment || "-"}`);
|
|
1928
2254
|
}
|
|
@@ -1959,6 +2285,7 @@ function renderShowText(lines: string[], data: DevicesShowEnvelopeData): void {
|
|
|
1959
2285
|
lines.push(`record-type: ${entry.recordTypeName} (${entry.recordType})`);
|
|
1960
2286
|
lines.push(`cdb-index: ${entry.cdbRecordIndex}`);
|
|
1961
2287
|
lines.push(`user: ${entry.user || "-"}`);
|
|
2288
|
+
lines.push(passwordLine(entry));
|
|
1962
2289
|
lines.push(`saved-password: ${entry.savedPassword ? "yes" : "no"}`);
|
|
1963
2290
|
lines.push(`group: ${entry.group || "-"}`);
|
|
1964
2291
|
lines.push(`profile: ${entry.profile || "-"}`);
|
|
@@ -1984,9 +2311,27 @@ function renderShowText(lines: string[], data: DevicesShowEnvelopeData): void {
|
|
|
1984
2311
|
}
|
|
1985
2312
|
}
|
|
1986
2313
|
|
|
2314
|
+
/**
|
|
2315
|
+
* The `password:` text line. The cleartext value appears only when the entry
|
|
2316
|
+
* carries it (`--show-password`); otherwise it reads `(set, hidden)` or
|
|
2317
|
+
* `(not set)`.
|
|
2318
|
+
*/
|
|
2319
|
+
function passwordLine(entry: DevicesShowItem): string {
|
|
2320
|
+
const shown = (entry as Partial<DevicesShowItemWithPassword>).password;
|
|
2321
|
+
if (typeof shown === "string") {
|
|
2322
|
+
// An empty password reads `(not set)`, never `-`, which is a valid password.
|
|
2323
|
+
return `password: ${shown === "" ? "(not set)" : shown}`;
|
|
2324
|
+
}
|
|
2325
|
+
return `password: ${entry.passwordSet ? "(set, hidden)" : "(not set)"}`;
|
|
2326
|
+
}
|
|
2327
|
+
|
|
1987
2328
|
function describeField(field: WinBoxCdbField): string {
|
|
1988
2329
|
const head = `tag=${field.tag} marker=0x${field.marker.toString(16).padStart(2, "0")} tcode=0x${field.tcode.toString(16).padStart(2, "0")}`;
|
|
1989
2330
|
if (field.rawTail) {
|
|
2331
|
+
if (typeof field.value === "string") {
|
|
2332
|
+
// Withheld by `redactRecordPassword`: the tail may hold the password.
|
|
2333
|
+
return `${head} rawTail ${field.value}`;
|
|
2334
|
+
}
|
|
1990
2335
|
const bytes = field.value instanceof Uint8Array ? field.value : null;
|
|
1991
2336
|
const preview = bytes
|
|
1992
2337
|
? Array.from(bytes.subarray(0, 16))
|
package/src/execute-fanout.ts
CHANGED
|
@@ -60,6 +60,7 @@ import {
|
|
|
60
60
|
} from "./execute.ts";
|
|
61
61
|
import { plannedProtocols, type RouterOsProtocol } from "./protocols/index.ts";
|
|
62
62
|
import {
|
|
63
|
+
assertNoMatchInFanout,
|
|
63
64
|
emptyWriteSelectionError,
|
|
64
65
|
expandSelection,
|
|
65
66
|
isDefaultRecordTarget,
|
|
@@ -206,6 +207,7 @@ export async function executeFanout(
|
|
|
206
207
|
internals: ExecuteFanoutInternals = {},
|
|
207
208
|
options: ExecuteFanoutOptions = {},
|
|
208
209
|
): Promise<ExecuteFanoutEnvelope> {
|
|
210
|
+
assertNoMatchInFanout(request.match);
|
|
209
211
|
// Validate + canonicalize the command ONCE, before selection expansion: a bad
|
|
210
212
|
// command shape is an outer error regardless of how many targets resolve, and
|
|
211
213
|
// write-ness is target-independent.
|
package/src/execute.ts
CHANGED
|
@@ -120,6 +120,12 @@ export interface ExecuteRequest {
|
|
|
120
120
|
maxResultsBytes?: number;
|
|
121
121
|
cdbFile?: string;
|
|
122
122
|
cdbPassword?: string;
|
|
123
|
+
/**
|
|
124
|
+
* `--match` selector for a `<router>` several CDB records share
|
|
125
|
+
* (`user=<name>`, `target=<addr>`, or a record type): the same selector
|
|
126
|
+
* `devices show --match` takes. Single-target only (#583).
|
|
127
|
+
*/
|
|
128
|
+
match?: string;
|
|
123
129
|
/** Opt-in host ARP resolution for a MAC target (`none` default, or `arp`). */
|
|
124
130
|
resolve?: string;
|
|
125
131
|
stdinIsTty?: boolean;
|
|
@@ -367,6 +373,7 @@ export async function resolveExecuteRequest(
|
|
|
367
373
|
targetInput: request.targetInput,
|
|
368
374
|
cdbFile: request.cdbFile,
|
|
369
375
|
cdbPassword: request.cdbPassword,
|
|
376
|
+
match: request.match,
|
|
370
377
|
},
|
|
371
378
|
env,
|
|
372
379
|
config,
|
package/src/explain/blocks.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Block / scope-brace classification for `explain` (centrs canonicalizer).
|
|
3
3
|
*
|
|
4
|
-
* Ratified by the phase-0 lab, question Q2 (#185)
|
|
5
|
-
*
|
|
4
|
+
* Ratified by the phase-0 lab, question Q2 (#185); the lab probe it was
|
|
5
|
+
* promoted from is history (see #185), not a runnable method. Q2 asks: can an offline,
|
|
6
6
|
* schema-free brace tracker tell a `{…}` SCOPE (`do=`/`else=`/`on-error=`, which
|
|
7
7
|
* IL lowers to `name=;(evl …)`) from a `name={…}` LITERAL value (braces
|
|
8
8
|
* stripped, body kept as RAW text, never lowered)? Descending into a literal
|