@tikoci/centrs 0.1.9-next.0 → 0.1.9-next.22
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 +177 -40
- package/GLOSSARY.txt +8 -0
- package/README.md +12 -8
- package/SECURITY.md +11 -3
- package/commands/api/README.md +39 -15
- package/commands/api/examples.md +44 -6
- package/commands/execute/README.md +108 -14
- package/commands/execute/examples.md +118 -3
- package/commands/explain/README.md +2 -1
- package/commands/mcp/README.md +11 -0
- package/commands/mcp/examples.md +6 -0
- package/commands/retrieve/README.md +30 -13
- package/commands/retrieve/examples.md +10 -6
- package/commands/transfer/examples.md +9 -0
- package/docs/CLI.md +8 -8
- package/docs/CONSTITUTION.md +25 -2
- package/docs/MATRIX.md +5 -4
- package/docs/errors/cdb/empty-group.md +19 -3
- package/docs/errors/cdb/empty-selection.md +9 -3
- package/docs/errors/cmr/caller-policy-not-enforced.md +46 -0
- package/docs/errors/cmr/empty-selection.md +17 -3
- package/docs/errors/cmr/rule-selects-every-device.md +32 -0
- package/docs/errors/cmr/selection-includes-controller.md +27 -0
- package/docs/errors/usage/flag-after-separator.md +34 -0
- package/package.json +7 -5
- package/src/api-fanout.ts +11 -1
- package/src/api.ts +205 -65
- package/src/btest.ts +7 -2
- package/src/cli/api.ts +30 -16
- package/src/cli/common.ts +180 -1
- package/src/cli/devices.ts +0 -2
- package/src/cli/discover.ts +0 -1
- package/src/cli/execute.ts +44 -20
- package/src/cli/explain.ts +2 -0
- package/src/cli/mcp.ts +0 -1
- package/src/cli/retrieve.ts +40 -18
- package/src/cli/settings.ts +0 -3
- package/src/cli/terminal.ts +0 -2
- package/src/cli/transfer.ts +10 -8
- package/src/cli/version.ts +87 -0
- package/src/cli.ts +5 -3
- package/src/core/cmr-fleet.ts +486 -0
- package/src/core/cmr.ts +78 -0
- package/src/core/envelope.ts +32 -3
- package/src/core/error-catalog.ts +25 -3
- package/src/core/execute-command.ts +144 -19
- package/src/core/fanout.ts +130 -6
- package/src/errors.ts +2 -1
- package/src/execute-cmr.ts +95 -78
- package/src/execute-fanout.ts +11 -1
- package/src/execute.ts +105 -23
- package/src/index.ts +18 -1
- package/src/mcp/safety.ts +11 -0
- package/src/mcp/tools.ts +4 -1
- package/src/resolver/cdb.ts +13 -1
- package/src/resolver/index.ts +4 -0
- package/src/resolver/selection.ts +37 -1
- package/src/resolver/target.ts +96 -8
- package/src/retrieve-fanout.ts +4 -1
- package/src/retrieve-follow.ts +29 -33
- package/src/retrieve-sample.ts +14 -8
- package/src/retrieve-stream.ts +38 -6
- package/src/retrieve.ts +73 -59
- package/src/transfer-fanout.ts +8 -1
- package/src/transfer.ts +15 -19
package/src/execute-cmr.ts
CHANGED
|
@@ -39,9 +39,21 @@
|
|
|
39
39
|
* - `freeze-frame-interval=20ms` brings a trivial call from ~1 s to ~25 ms;
|
|
40
40
|
* `strategy`/`fail-policy` are pinned and `order` is never sent (it adds
|
|
41
41
|
* `pending` and policy-`skipped` statuses).
|
|
42
|
+
* - The caller's policy is not checked against the script (#456):
|
|
43
|
+
* `run-script` needs only `read` + `api` on the controller login, and a
|
|
44
|
+
* `!write !reboot` login wrote to and rebooted clients, the controller's
|
|
45
|
+
* own row included. The write gate here is the only one; a write run by
|
|
46
|
+
* such a login carries `cmr/caller-policy-not-enforced`.
|
|
42
47
|
*/
|
|
43
48
|
|
|
44
|
-
import {
|
|
49
|
+
import {
|
|
50
|
+
assertNotCmrDashboardIl,
|
|
51
|
+
CMR_RUN_SCRIPT_PATH,
|
|
52
|
+
CMR_SCRIPT_POLICIES,
|
|
53
|
+
type CmrCallerPolicy,
|
|
54
|
+
cmrCallerPolicyWarning,
|
|
55
|
+
readCmrCallerPolicy,
|
|
56
|
+
} from "./core/cmr.ts";
|
|
45
57
|
import type {
|
|
46
58
|
CentrsErrorEnvelope,
|
|
47
59
|
CentrsSuccessEnvelope,
|
|
@@ -55,6 +67,7 @@ import type {
|
|
|
55
67
|
import { notReachedReason } from "./core/envelope.ts";
|
|
56
68
|
import { isWriteShaped } from "./core/execute-command.ts";
|
|
57
69
|
import {
|
|
70
|
+
buildFanoutEnvelope,
|
|
58
71
|
NOT_SENT_TRANSPORT_CODES,
|
|
59
72
|
resolveFanoutConcurrency,
|
|
60
73
|
runBoundedPool,
|
|
@@ -94,7 +107,7 @@ import {
|
|
|
94
107
|
type RouterOsProtocol,
|
|
95
108
|
} from "./protocols/index.ts";
|
|
96
109
|
import { classifyParseResult } from "./protocols/mac-telnet-console.ts";
|
|
97
|
-
import { toCoreSource } from "./resolver/index.ts";
|
|
110
|
+
import { redactTargetInput, toCoreSource } from "./resolver/index.ts";
|
|
98
111
|
|
|
99
112
|
/** The device-stage validator identity for a CMR client. */
|
|
100
113
|
export const CMR_VALIDATION_SOURCE = ":put [:parse ...] via cmr run-script";
|
|
@@ -184,6 +197,8 @@ export interface CmrController {
|
|
|
184
197
|
devices(): Promise<CmrDevice[]>;
|
|
185
198
|
/** Explain a missing `/cmr/device`; each read is best effort. */
|
|
186
199
|
absence?(): Promise<CmrAbsence>;
|
|
200
|
+
/** The login's own group policy; `undefined` when it cannot be read. */
|
|
201
|
+
callerPolicy?(): Promise<CmrCallerPolicy | undefined>;
|
|
187
202
|
runScript(
|
|
188
203
|
call: CmrScriptCall,
|
|
189
204
|
signal: AbortSignal,
|
|
@@ -204,6 +219,8 @@ export interface ExecuteCmrOperationMeta {
|
|
|
204
219
|
concurrency: number;
|
|
205
220
|
summary: FanoutSummary;
|
|
206
221
|
request: ExecuteRequestSummary;
|
|
222
|
+
/** Hoisted from the members: every member runs under the controller login. */
|
|
223
|
+
auth?: { username?: string; passwordProvided: boolean };
|
|
207
224
|
cmr: {
|
|
208
225
|
labels: string;
|
|
209
226
|
/** `/cmr/device` rows considered. */
|
|
@@ -279,6 +296,7 @@ export async function executeCmr(
|
|
|
279
296
|
);
|
|
280
297
|
const nonce = (internals.nonce ?? defaultNonce)();
|
|
281
298
|
|
|
299
|
+
const warnings: Warning[] = [...resolved.warnings];
|
|
282
300
|
try {
|
|
283
301
|
// 1. Inventory and per-row preflight (membership + client validation).
|
|
284
302
|
const before = await readInventoryBeforeDispatch(controller, signal);
|
|
@@ -332,24 +350,23 @@ export async function executeCmr(
|
|
|
332
350
|
before.forEach((device, index) => {
|
|
333
351
|
const outcome = preflight[index];
|
|
334
352
|
if (!outcome?.selected || !outcome.row) return;
|
|
335
|
-
members.push(
|
|
336
|
-
judgePreflight(
|
|
337
|
-
device,
|
|
338
|
-
outcome,
|
|
339
|
-
resolved,
|
|
340
|
-
controllerName,
|
|
341
|
-
offline,
|
|
342
|
-
nonce,
|
|
343
|
-
),
|
|
344
|
-
);
|
|
353
|
+
members.push(judgePreflight(device, outcome, resolved, offline, nonce));
|
|
345
354
|
});
|
|
346
|
-
const warnings: Warning[] = [...resolved.warnings];
|
|
347
355
|
if (members.length === 0) {
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
356
|
+
const message = `No CMR device on the controller matches \`${labels}\`. Labels are case-sensitive and RouterOS accepts unknown ones silently.`;
|
|
357
|
+
const context = { labels, inventory: before.length };
|
|
358
|
+
// #460: RouterOS refuses an empty selection (`no devices selected`);
|
|
359
|
+
// a write here must not exit 0 on a mistyped label.
|
|
360
|
+
if (write) {
|
|
361
|
+
throw new CentrsError({
|
|
362
|
+
code: "cmr/empty-selection",
|
|
363
|
+
summary: `${message} Nothing ran: a write-shaped command must select at least one client.`,
|
|
364
|
+
remediation:
|
|
365
|
+
"Check the labels with `centrs retrieve <controller> /cmr/device` (`labels`, `auto-labels`), then re-run. A read over the same labels succeeds with a warning instead.",
|
|
366
|
+
context: { ...context, write: true },
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
warnings.push({ code: "cmr/empty-selection", message, context });
|
|
353
370
|
}
|
|
354
371
|
|
|
355
372
|
// 2. Bind: a member whose `ids` changed since S1 was validated as another key.
|
|
@@ -361,7 +378,6 @@ export async function executeCmr(
|
|
|
361
378
|
member.result = memberError(
|
|
362
379
|
member,
|
|
363
380
|
resolved,
|
|
364
|
-
controllerName,
|
|
365
381
|
notDispatched(
|
|
366
382
|
"cmr/membership-changed",
|
|
367
383
|
`CMR device ${member.device.id} (${member.device.identity}) changed or left the inventory between validation and dispatch.`,
|
|
@@ -371,9 +387,14 @@ export async function executeCmr(
|
|
|
371
387
|
}
|
|
372
388
|
}
|
|
373
389
|
|
|
374
|
-
// 3. One write gate, naming the blast radius.
|
|
390
|
+
// 3. One write gate, naming the blast radius. The login's policy is read
|
|
391
|
+
// once, best effort: a failed read only loses the warning.
|
|
375
392
|
const runnable = members.filter((m) => !m.result);
|
|
376
393
|
if (write && runnable.length > 0) {
|
|
394
|
+
const caller = await controller.callerPolicy?.().catch(() => undefined);
|
|
395
|
+
const notEnforced =
|
|
396
|
+
caller && cmrCallerPolicyWarning(caller, CMR_SCRIPT_POLICIES);
|
|
397
|
+
if (notEnforced) warnings.push(notEnforced);
|
|
377
398
|
await confirmCmrWrite(
|
|
378
399
|
request,
|
|
379
400
|
signal,
|
|
@@ -381,6 +402,7 @@ export async function executeCmr(
|
|
|
381
402
|
controllerName,
|
|
382
403
|
runnable.length,
|
|
383
404
|
members.length - runnable.length,
|
|
405
|
+
notEnforced,
|
|
384
406
|
);
|
|
385
407
|
}
|
|
386
408
|
|
|
@@ -427,47 +449,56 @@ export async function executeCmr(
|
|
|
427
449
|
? "bound"
|
|
428
450
|
: "drifted";
|
|
429
451
|
member.result = applyMemberBudget(
|
|
430
|
-
judgeDispatch(member, outcome, resolved,
|
|
452
|
+
judgeDispatch(member, outcome, resolved, binding),
|
|
431
453
|
member,
|
|
432
454
|
resolved,
|
|
433
|
-
controllerName,
|
|
434
455
|
);
|
|
435
456
|
}
|
|
436
457
|
|
|
437
458
|
const targets = members.map(
|
|
438
459
|
(member) =>
|
|
439
|
-
member.result ??
|
|
440
|
-
memberError(member, resolved, controllerName, internalUndecided()),
|
|
460
|
+
member.result ?? memberError(member, resolved, internalUndecided()),
|
|
441
461
|
);
|
|
442
462
|
const summary = summarizeFanout(targets);
|
|
443
|
-
return
|
|
444
|
-
|
|
445
|
-
|
|
463
|
+
return buildFanoutEnvelope<
|
|
464
|
+
unknown,
|
|
465
|
+
ExecuteOperationMeta,
|
|
466
|
+
ExecuteCmrOperationMeta
|
|
467
|
+
>({
|
|
468
|
+
summary,
|
|
469
|
+
targets,
|
|
446
470
|
warnings,
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
selected: members.length,
|
|
461
|
-
dispatched: sentCount,
|
|
462
|
-
},
|
|
471
|
+
settings: settingsMeta(resolved),
|
|
472
|
+
via,
|
|
473
|
+
target: controllerTargetMeta(resolved),
|
|
474
|
+
operation: {
|
|
475
|
+
kind: "cmr",
|
|
476
|
+
concurrency,
|
|
477
|
+
summary,
|
|
478
|
+
request: requestSummary(resolved),
|
|
479
|
+
cmr: {
|
|
480
|
+
labels,
|
|
481
|
+
inventory: before.length,
|
|
482
|
+
selected: members.length,
|
|
483
|
+
dispatched: sentCount,
|
|
463
484
|
},
|
|
464
485
|
},
|
|
465
|
-
};
|
|
486
|
+
});
|
|
487
|
+
} catch (error) {
|
|
488
|
+
// An outer error keeps the warnings gathered before it, such as
|
|
489
|
+
// `cmr/caller-policy-not-enforced` on a refused confirmation.
|
|
490
|
+
if (error instanceof CentrsError && warnings.length > 0) {
|
|
491
|
+
OUTER_ERROR_WARNINGS.set(error, [...warnings]);
|
|
492
|
+
}
|
|
493
|
+
throw error;
|
|
466
494
|
} finally {
|
|
467
495
|
await controller.close();
|
|
468
496
|
}
|
|
469
497
|
}
|
|
470
498
|
|
|
499
|
+
/** Warnings an outer `executeCmr` error carried; read by {@link buildExecuteCmrErrorEnvelope}. */
|
|
500
|
+
const OUTER_ERROR_WARNINGS = new WeakMap<CentrsError, Warning[]>();
|
|
501
|
+
|
|
471
502
|
// ---------------------------------------------------------------------------
|
|
472
503
|
// Preflight and dispatch judgement
|
|
473
504
|
// ---------------------------------------------------------------------------
|
|
@@ -476,7 +507,6 @@ function judgePreflight(
|
|
|
476
507
|
device: CmrDevice,
|
|
477
508
|
outcome: CmrScriptOutcome,
|
|
478
509
|
resolved: ResolvedExecuteRequest,
|
|
479
|
-
controllerName: string,
|
|
480
510
|
offline: OfflineGateResult | undefined,
|
|
481
511
|
nonce: string,
|
|
482
512
|
): Member {
|
|
@@ -496,7 +526,6 @@ function judgePreflight(
|
|
|
496
526
|
member.result = memberError(
|
|
497
527
|
member,
|
|
498
528
|
resolved,
|
|
499
|
-
controllerName,
|
|
500
529
|
disconnectedError(device, row),
|
|
501
530
|
);
|
|
502
531
|
return member;
|
|
@@ -510,7 +539,6 @@ function judgePreflight(
|
|
|
510
539
|
member.result = memberError(
|
|
511
540
|
member,
|
|
512
541
|
resolved,
|
|
513
|
-
controllerName,
|
|
514
542
|
notDispatched(
|
|
515
543
|
"cmr/validation-incomplete",
|
|
516
544
|
`The validation result from CMR device ${device.identity} was incomplete (${why}), so the command was not sent to it.`,
|
|
@@ -540,7 +568,7 @@ function judgePreflight(
|
|
|
540
568
|
} catch (error) {
|
|
541
569
|
const rejected = error as CentrsError;
|
|
542
570
|
member.validation = deviceRejected(resolved, offline, rejected);
|
|
543
|
-
member.result = memberError(member, resolved,
|
|
571
|
+
member.result = memberError(member, resolved, rejected);
|
|
544
572
|
return member;
|
|
545
573
|
}
|
|
546
574
|
// Positive evidence: every accepted `:parse` return seen (structured,
|
|
@@ -555,7 +583,6 @@ function judgeDispatch(
|
|
|
555
583
|
member: Member,
|
|
556
584
|
outcome: CmrScriptOutcome,
|
|
557
585
|
resolved: ResolvedExecuteRequest,
|
|
558
|
-
controllerName: string,
|
|
559
586
|
binding: "bound" | "drifted" | "unverified",
|
|
560
587
|
): ExecuteEnvelope {
|
|
561
588
|
const device = member.device;
|
|
@@ -563,7 +590,6 @@ function judgeDispatch(
|
|
|
563
590
|
return memberError(
|
|
564
591
|
member,
|
|
565
592
|
resolved,
|
|
566
|
-
controllerName,
|
|
567
593
|
notDispatched(
|
|
568
594
|
"cmr/membership-changed",
|
|
569
595
|
`CMR device ${device.identity} no longer matched the labels at dispatch, so the command was not sent to it.`,
|
|
@@ -573,21 +599,11 @@ function judgeDispatch(
|
|
|
573
599
|
}
|
|
574
600
|
const row = outcome.row;
|
|
575
601
|
if (!outcome.sent && !(row && FINAL_STATUSES.has(row.status))) {
|
|
576
|
-
return memberError(
|
|
577
|
-
member,
|
|
578
|
-
resolved,
|
|
579
|
-
controllerName,
|
|
580
|
-
outcome.error ?? cancelledError(),
|
|
581
|
-
);
|
|
602
|
+
return memberError(member, resolved, outcome.error ?? cancelledError());
|
|
582
603
|
}
|
|
583
604
|
// CMR itself declined to send: nothing ran, whichever client this was.
|
|
584
605
|
if (row?.status === "skipped") {
|
|
585
|
-
return memberError(
|
|
586
|
-
member,
|
|
587
|
-
resolved,
|
|
588
|
-
controllerName,
|
|
589
|
-
disconnectedError(device, row),
|
|
590
|
-
);
|
|
606
|
+
return memberError(member, resolved, disconnectedError(device, row));
|
|
591
607
|
}
|
|
592
608
|
// A sent call whose member cannot be re-bound after dispatch (its `ids`
|
|
593
609
|
// changed, or S3 was not read) has no attributable result.
|
|
@@ -595,7 +611,6 @@ function judgeDispatch(
|
|
|
595
611
|
return memberError(
|
|
596
612
|
member,
|
|
597
613
|
resolved,
|
|
598
|
-
controllerName,
|
|
599
614
|
unknownOutcome(
|
|
600
615
|
device,
|
|
601
616
|
undefined,
|
|
@@ -610,7 +625,6 @@ function judgeDispatch(
|
|
|
610
625
|
return memberError(
|
|
611
626
|
member,
|
|
612
627
|
resolved,
|
|
613
|
-
controllerName,
|
|
614
628
|
unknownOutcome(device, outcome.error, row),
|
|
615
629
|
);
|
|
616
630
|
}
|
|
@@ -619,7 +633,6 @@ function judgeDispatch(
|
|
|
619
633
|
return memberError(
|
|
620
634
|
member,
|
|
621
635
|
resolved,
|
|
622
|
-
controllerName,
|
|
623
636
|
unknownOutcome(device, undefined, row),
|
|
624
637
|
);
|
|
625
638
|
}
|
|
@@ -629,7 +642,6 @@ function judgeDispatch(
|
|
|
629
642
|
return memberError(
|
|
630
643
|
member,
|
|
631
644
|
resolved,
|
|
632
|
-
controllerName,
|
|
633
645
|
new CentrsError({
|
|
634
646
|
code: "cmr/policy-denied",
|
|
635
647
|
summary: `${device.identity} refused a statement: CMR runs scripts with only the read, write, test and reboot policies (\`${denied[0].trim()}\`). Any statements before it already ran.`,
|
|
@@ -646,7 +658,6 @@ function judgeDispatch(
|
|
|
646
658
|
return memberError(
|
|
647
659
|
member,
|
|
648
660
|
resolved,
|
|
649
|
-
controllerName,
|
|
650
661
|
mapRouterOsError(row.output.trim(), {
|
|
651
662
|
transport:
|
|
652
663
|
resolved.via.value === "rest-api" ? "rest-api" : "native-api",
|
|
@@ -669,7 +680,7 @@ function judgeDispatch(
|
|
|
669
680
|
warnings,
|
|
670
681
|
tips: [],
|
|
671
682
|
meta: {
|
|
672
|
-
...memberMeta(member, resolved
|
|
683
|
+
...memberMeta(member, resolved),
|
|
673
684
|
...(clipped
|
|
674
685
|
? {
|
|
675
686
|
truncated: {
|
|
@@ -692,7 +703,6 @@ function applyMemberBudget(
|
|
|
692
703
|
envelope: ExecuteEnvelope,
|
|
693
704
|
member: Member,
|
|
694
705
|
resolved: ResolvedExecuteRequest,
|
|
695
|
-
controllerName: string,
|
|
696
706
|
): ExecuteEnvelope {
|
|
697
707
|
const budget = resolved.maxResultsBytes?.value;
|
|
698
708
|
if (!budget || !envelope.ok) return envelope;
|
|
@@ -707,7 +717,6 @@ function applyMemberBudget(
|
|
|
707
717
|
return memberError(
|
|
708
718
|
member,
|
|
709
719
|
resolved,
|
|
710
|
-
controllerName,
|
|
711
720
|
new CentrsError({
|
|
712
721
|
code: "input/max-results-exceeded",
|
|
713
722
|
summary: `The command ran on ${device.identity}, but its result (${requiredBytes} bytes) exceeded the requested ${budget}-byte budget, so it is not returned.`,
|
|
@@ -942,7 +951,6 @@ async function noCmrError(
|
|
|
942
951
|
function memberError(
|
|
943
952
|
member: Member,
|
|
944
953
|
resolved: ResolvedExecuteRequest,
|
|
945
|
-
controllerName: string,
|
|
946
954
|
error: CentrsError,
|
|
947
955
|
): ExecuteEnvelope {
|
|
948
956
|
return {
|
|
@@ -950,21 +958,19 @@ function memberError(
|
|
|
950
958
|
error: serializeCentrsError(error),
|
|
951
959
|
warnings: [],
|
|
952
960
|
tips: [],
|
|
953
|
-
meta: memberMeta(member, resolved
|
|
961
|
+
meta: memberMeta(member, resolved),
|
|
954
962
|
};
|
|
955
963
|
}
|
|
956
964
|
|
|
957
965
|
function memberMeta(
|
|
958
966
|
member: Member,
|
|
959
967
|
resolved: ResolvedExecuteRequest,
|
|
960
|
-
controllerName: string,
|
|
961
968
|
): ExecuteEnvelope["meta"] {
|
|
962
969
|
const device = member.device;
|
|
963
970
|
const target: EnvelopeTargetMeta = {
|
|
964
971
|
input: device.identity,
|
|
965
972
|
identity: device.identity,
|
|
966
973
|
cmr: {
|
|
967
|
-
controller: controllerName,
|
|
968
974
|
id: device.id,
|
|
969
975
|
deviceKey: device.ids.split("/")[0] || undefined,
|
|
970
976
|
address: device.address,
|
|
@@ -1106,9 +1112,11 @@ async function confirmCmrWrite(
|
|
|
1106
1112
|
controllerName: string,
|
|
1107
1113
|
runnable: number,
|
|
1108
1114
|
excluded: number,
|
|
1115
|
+
notEnforced: Warning | undefined,
|
|
1109
1116
|
): Promise<void> {
|
|
1110
1117
|
if (resolved.yes) return;
|
|
1111
1118
|
const scope = `${runnable} CMR client(s) via controller ${controllerName}${excluded > 0 ? ` (${excluded} excluded)` : ""}`;
|
|
1119
|
+
const policyNote = notEnforced ? ` ${notEnforced.message}` : "";
|
|
1112
1120
|
if (request.stdinIsTty ?? process.stdin.isTTY) {
|
|
1113
1121
|
// The prompt reads stdin and cannot see `signal`, so race it: a cancel at
|
|
1114
1122
|
// the prompt returns here and the dispatch loop reports every member
|
|
@@ -1119,7 +1127,7 @@ async function confirmCmrWrite(
|
|
|
1119
1127
|
});
|
|
1120
1128
|
const confirmed = await Promise.race([
|
|
1121
1129
|
(request.confirm ?? promptForWriteConfirmation)(
|
|
1122
|
-
`Run write-shaped \`${resolved.command}\` on ${scope}
|
|
1130
|
+
`Run write-shaped \`${resolved.command}\` on ${scope}?${policyNote} Type yes to continue: `,
|
|
1123
1131
|
),
|
|
1124
1132
|
cancelled,
|
|
1125
1133
|
]);
|
|
@@ -1127,7 +1135,7 @@ async function confirmCmrWrite(
|
|
|
1127
1135
|
}
|
|
1128
1136
|
throw new CentrsError({
|
|
1129
1137
|
code: "usage/confirmation-required",
|
|
1130
|
-
summary: `A write-shaped \`${resolved.command}\` would run on ${scope} and needs explicit confirmation
|
|
1138
|
+
summary: `A write-shaped \`${resolved.command}\` would run on ${scope} and needs explicit confirmation.${policyNote}`,
|
|
1131
1139
|
remediation:
|
|
1132
1140
|
"Pass `--yes` after reviewing the selection, or answer `yes` at the TTY prompt. Clients that failed validation are never sent the command.",
|
|
1133
1141
|
context: {
|
|
@@ -1135,6 +1143,7 @@ async function confirmCmrWrite(
|
|
|
1135
1143
|
routers: runnable,
|
|
1136
1144
|
excluded,
|
|
1137
1145
|
controller: controllerName,
|
|
1146
|
+
...(notEnforced ? { callerPolicy: notEnforced.context } : {}),
|
|
1138
1147
|
},
|
|
1139
1148
|
});
|
|
1140
1149
|
}
|
|
@@ -1205,6 +1214,10 @@ function adapterController(
|
|
|
1205
1214
|
controllerAddress: client?.["controller-address"] || undefined,
|
|
1206
1215
|
};
|
|
1207
1216
|
},
|
|
1217
|
+
callerPolicy: () =>
|
|
1218
|
+
resolved.auth.username
|
|
1219
|
+
? readCmrCallerPolicy(shared, resolved.auth.username)
|
|
1220
|
+
: Promise.resolve(undefined),
|
|
1208
1221
|
close: () => shared.close(),
|
|
1209
1222
|
};
|
|
1210
1223
|
}
|
|
@@ -1357,9 +1370,13 @@ export function buildExecuteCmrErrorEnvelope(
|
|
|
1357
1370
|
return {
|
|
1358
1371
|
ok: false,
|
|
1359
1372
|
error: serializeCentrsError(centrsError),
|
|
1360
|
-
warnings: [],
|
|
1373
|
+
warnings: OUTER_ERROR_WARNINGS.get(centrsError) ?? [],
|
|
1361
1374
|
tips: [],
|
|
1362
|
-
meta: {
|
|
1375
|
+
meta: {
|
|
1376
|
+
target: { input: redactTargetInput(request.targetInput) },
|
|
1377
|
+
via,
|
|
1378
|
+
settings: {},
|
|
1379
|
+
},
|
|
1363
1380
|
};
|
|
1364
1381
|
}
|
|
1365
1382
|
|
package/src/execute-fanout.ts
CHANGED
|
@@ -60,9 +60,11 @@ import {
|
|
|
60
60
|
} from "./execute.ts";
|
|
61
61
|
import { plannedProtocols, type RouterOsProtocol } from "./protocols/index.ts";
|
|
62
62
|
import {
|
|
63
|
+
emptyWriteSelectionError,
|
|
63
64
|
expandSelection,
|
|
64
65
|
isDefaultRecordTarget,
|
|
65
66
|
loadEnvFileDefaults,
|
|
67
|
+
redactTargetInput,
|
|
66
68
|
type SelectionExpansion,
|
|
67
69
|
type SelectionMember,
|
|
68
70
|
type SelectionResolveInput,
|
|
@@ -85,6 +87,8 @@ export interface ExecuteFanoutOperationMeta {
|
|
|
85
87
|
concurrency: number;
|
|
86
88
|
summary: FanoutSummary;
|
|
87
89
|
request: ExecuteRequestSummary;
|
|
90
|
+
/** Hoisted from the targets when every target used the same login (#461). */
|
|
91
|
+
auth?: { username?: string; passwordProvided: boolean };
|
|
88
92
|
selection: ExecuteSelectionSummary;
|
|
89
93
|
}
|
|
90
94
|
|
|
@@ -125,7 +129,7 @@ function summarizeSelection(
|
|
|
125
129
|
where: selection.where.map((clause) => `${clause.key}=${clause.value}`),
|
|
126
130
|
all: selection.all,
|
|
127
131
|
default: selection.default,
|
|
128
|
-
positionals: selection.positionals,
|
|
132
|
+
positionals: selection.positionals.map((value) => redactTargetInput(value)),
|
|
129
133
|
quickchr: selection.quickchr,
|
|
130
134
|
};
|
|
131
135
|
}
|
|
@@ -225,6 +229,12 @@ export async function executeFanout(
|
|
|
225
229
|
const concurrency = resolveFanoutConcurrency(options.concurrency, via);
|
|
226
230
|
|
|
227
231
|
if (expansion.empty) {
|
|
232
|
+
if (global.summary.write) {
|
|
233
|
+
throw emptyWriteSelectionError(
|
|
234
|
+
expansion,
|
|
235
|
+
`\`${global.summary.command}\``,
|
|
236
|
+
);
|
|
237
|
+
}
|
|
228
238
|
return executeFanoutEnvelope({
|
|
229
239
|
selection: selectionSummary,
|
|
230
240
|
concurrency,
|
package/src/execute.ts
CHANGED
|
@@ -4,6 +4,13 @@ import {
|
|
|
4
4
|
cmrRunScriptWarnings,
|
|
5
5
|
cmrScriptWarnings,
|
|
6
6
|
} from "./core/cmr.ts";
|
|
7
|
+
import {
|
|
8
|
+
type CmrFleetRead,
|
|
9
|
+
cmrFleetCommand,
|
|
10
|
+
cmrFleetGate,
|
|
11
|
+
cmrFleetReadable,
|
|
12
|
+
readCmrFleet,
|
|
13
|
+
} from "./core/cmr-fleet.ts";
|
|
7
14
|
import type {
|
|
8
15
|
CentrsEnvelope,
|
|
9
16
|
CentrsErrorEnvelope,
|
|
@@ -65,6 +72,7 @@ import {
|
|
|
65
72
|
type ResolvedAuth,
|
|
66
73
|
type ResolvedSetting,
|
|
67
74
|
type ResolvedTarget,
|
|
75
|
+
redactTargetInput,
|
|
68
76
|
resolveAuth,
|
|
69
77
|
resolveBooleanSetting,
|
|
70
78
|
resolveCdb,
|
|
@@ -171,6 +179,8 @@ export interface ResolvedExecuteRequest {
|
|
|
171
179
|
yes: boolean;
|
|
172
180
|
verbose: boolean;
|
|
173
181
|
warnings: readonly Warning[];
|
|
182
|
+
/** A CMR fleet command's reach, read before the write gate (#453). */
|
|
183
|
+
cmrFleet?: CmrFleetRead;
|
|
174
184
|
}
|
|
175
185
|
|
|
176
186
|
export async function execute(
|
|
@@ -202,6 +212,7 @@ export async function executeEnvelope(
|
|
|
202
212
|
const trace: ValidationTrace = {};
|
|
203
213
|
try {
|
|
204
214
|
resolved = await resolveExecuteRequest(request, env);
|
|
215
|
+
resolved = await withCmrFleetScope(resolved);
|
|
205
216
|
await assertWriteConfirmed(request, resolved);
|
|
206
217
|
return await runResolvedExecute(resolved, trace);
|
|
207
218
|
} catch (error) {
|
|
@@ -573,7 +584,7 @@ export function buildExecuteErrorEnvelope(
|
|
|
573
584
|
warnings: [],
|
|
574
585
|
tips: [],
|
|
575
586
|
meta: {
|
|
576
|
-
target: { input: request.targetInput },
|
|
587
|
+
target: { input: redactTargetInput(request.targetInput) },
|
|
577
588
|
via: requestedVia,
|
|
578
589
|
settings: {},
|
|
579
590
|
operation: {
|
|
@@ -798,7 +809,7 @@ export function renderExecuteEnvelope(
|
|
|
798
809
|
return toYaml(envelope);
|
|
799
810
|
case "text":
|
|
800
811
|
return envelope.ok
|
|
801
|
-
? renderExecuteSuccessText(envelope
|
|
812
|
+
? renderExecuteSuccessText(envelope)
|
|
802
813
|
: formatCentrsErrorText(envelope.error, options);
|
|
803
814
|
default:
|
|
804
815
|
return exhaustiveOutputFormat(format);
|
|
@@ -1074,9 +1085,13 @@ async function assertWriteConfirmed(
|
|
|
1074
1085
|
if (!isWriteShaped(resolved.canonical) || resolved.yes) {
|
|
1075
1086
|
return;
|
|
1076
1087
|
}
|
|
1088
|
+
const fleet = resolved.cmrFleet
|
|
1089
|
+
? cmrFleetGate(resolved.cmrFleet, targetName(resolved))
|
|
1090
|
+
: undefined;
|
|
1077
1091
|
if (request.stdinIsTty ?? process.stdin.isTTY) {
|
|
1078
1092
|
const confirmed = await (request.confirm ?? promptForWriteConfirmation)(
|
|
1079
|
-
|
|
1093
|
+
fleet?.prompt ??
|
|
1094
|
+
`Run write-shaped RouterOS command over ${resolved.via.value}? Type yes to continue: `,
|
|
1080
1095
|
);
|
|
1081
1096
|
if (confirmed) {
|
|
1082
1097
|
return;
|
|
@@ -1085,6 +1100,7 @@ async function assertWriteConfirmed(
|
|
|
1085
1100
|
throw new CentrsError({
|
|
1086
1101
|
code: "usage/confirmation-required",
|
|
1087
1102
|
summary:
|
|
1103
|
+
fleet?.summary ??
|
|
1088
1104
|
"Write-shaped RouterOS execute commands require explicit confirmation.",
|
|
1089
1105
|
remediation:
|
|
1090
1106
|
"Pass `--yes` in non-interactive automation, or answer `yes` at the TTY prompt after reviewing the command.",
|
|
@@ -1093,10 +1109,75 @@ async function assertWriteConfirmed(
|
|
|
1093
1109
|
path: resolved.canonical.path,
|
|
1094
1110
|
verb: resolved.canonical.verb,
|
|
1095
1111
|
via: resolved.via.value,
|
|
1112
|
+
...fleet?.context,
|
|
1096
1113
|
},
|
|
1097
1114
|
});
|
|
1098
1115
|
}
|
|
1099
1116
|
|
|
1117
|
+
function targetName(resolved: ResolvedExecuteRequest): string {
|
|
1118
|
+
return (
|
|
1119
|
+
resolved.target.identity ?? resolved.target.input ?? resolved.target.host
|
|
1120
|
+
);
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
/**
|
|
1124
|
+
* #453: a write-shaped CMR fleet command (`/cmr/device/reboot labels=all`) is
|
|
1125
|
+
* one request to the controller that acts on every device it selects. Read
|
|
1126
|
+
* the controller's inventory before the gate so the gate names that reach,
|
|
1127
|
+
* and add the fleet warnings, with or without `--yes`. Structured input only:
|
|
1128
|
+
* a script's selectors (`[find …]`, positionals) are not resolved.
|
|
1129
|
+
*/
|
|
1130
|
+
async function withCmrFleetScope(
|
|
1131
|
+
resolved: ResolvedExecuteRequest,
|
|
1132
|
+
): Promise<ResolvedExecuteRequest> {
|
|
1133
|
+
const { canonical } = resolved;
|
|
1134
|
+
if (canonical.mode !== "structured" || !isWriteShaped(canonical)) {
|
|
1135
|
+
return resolved;
|
|
1136
|
+
}
|
|
1137
|
+
const command = cmrFleetCommand(
|
|
1138
|
+
`${canonical.path}/${canonical.verb}`,
|
|
1139
|
+
canonical.attributes,
|
|
1140
|
+
);
|
|
1141
|
+
if (!command) return resolved;
|
|
1142
|
+
if (!cmrFleetReadable(resolved.via.value)) {
|
|
1143
|
+
return {
|
|
1144
|
+
...resolved,
|
|
1145
|
+
cmrFleet: {
|
|
1146
|
+
scope: {
|
|
1147
|
+
command: command.command,
|
|
1148
|
+
unresolved: `${resolved.via.value} has no structured read`,
|
|
1149
|
+
},
|
|
1150
|
+
warnings: [],
|
|
1151
|
+
},
|
|
1152
|
+
};
|
|
1153
|
+
}
|
|
1154
|
+
const adapter = createProtocolAdapter({
|
|
1155
|
+
protocol: resolved.via.value,
|
|
1156
|
+
host: resolved.target.host,
|
|
1157
|
+
port: resolved.target.port,
|
|
1158
|
+
tls: resolved.target.tls,
|
|
1159
|
+
baseUrl: resolved.target.baseUrl,
|
|
1160
|
+
username: resolved.auth.username,
|
|
1161
|
+
password: resolved.auth.password,
|
|
1162
|
+
timeoutMs: resolved.timeoutMs.value,
|
|
1163
|
+
insecure: resolved.insecure.value,
|
|
1164
|
+
});
|
|
1165
|
+
try {
|
|
1166
|
+
const cmrFleet = await readCmrFleet(
|
|
1167
|
+
command,
|
|
1168
|
+
adapter,
|
|
1169
|
+
resolved.auth.username,
|
|
1170
|
+
);
|
|
1171
|
+
return {
|
|
1172
|
+
...resolved,
|
|
1173
|
+
cmrFleet,
|
|
1174
|
+
warnings: [...resolved.warnings, ...cmrFleet.warnings],
|
|
1175
|
+
};
|
|
1176
|
+
} finally {
|
|
1177
|
+
await adapter.close();
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1100
1181
|
export async function promptForWriteConfirmation(
|
|
1101
1182
|
prompt: string,
|
|
1102
1183
|
): Promise<boolean> {
|
|
@@ -1450,27 +1531,28 @@ function applyMaxResultsBudget(
|
|
|
1450
1531
|
return envelope;
|
|
1451
1532
|
}
|
|
1452
1533
|
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1534
|
+
/** Text mode renders only the data; the CLI writes the rest to stderr (#470). */
|
|
1535
|
+
function renderExecuteSuccessText(envelope: ExecuteSuccessEnvelope): string {
|
|
1536
|
+
return typeof envelope.data === "string"
|
|
1537
|
+
? envelope.data
|
|
1538
|
+
: JSON.stringify(envelope.data, null, 2);
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
/**
|
|
1542
|
+
* The `--verbose` header of a text-mode execute result (stderr, #470). A
|
|
1543
|
+
* failure after resolution still names its target; one before has no
|
|
1544
|
+
* transport (`via: null`) and no header.
|
|
1545
|
+
*/
|
|
1546
|
+
export function executeVerboseLines(envelope: ExecuteEnvelope): string[] {
|
|
1547
|
+
if (envelope.meta.via === null) return [];
|
|
1548
|
+
const lines = [
|
|
1549
|
+
`target: ${envelope.meta.target.input ?? envelope.meta.target.host}`,
|
|
1550
|
+
`via: ${envelope.meta.via}`,
|
|
1551
|
+
];
|
|
1552
|
+
if (envelope.meta.validation?.source) {
|
|
1553
|
+
lines.push(`validation: ${envelope.meta.validation.source}`);
|
|
1472
1554
|
}
|
|
1473
|
-
return lines
|
|
1555
|
+
return lines;
|
|
1474
1556
|
}
|
|
1475
1557
|
|
|
1476
1558
|
function syntaxCause(error: unknown): unknown {
|