@tikoci/centrs 0.1.8 → 0.1.9-next.0
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 +48 -0
- package/README.md +4 -0
- package/commands/api/README.md +5 -0
- package/commands/execute/README.md +117 -0
- package/commands/execute/examples.md +162 -0
- package/commands/retrieve/README.md +2 -1
- package/docs/CLI.md +2 -0
- package/docs/CONSTITUTION.md +29 -1
- package/docs/MATRIX.md +19 -3
- package/docs/errors/cmr/cancelled.md +15 -0
- package/docs/errors/cmr/client-disconnected.md +19 -0
- package/docs/errors/cmr/client-script-not-validated.md +22 -0
- package/docs/errors/cmr/console-only.md +29 -0
- package/docs/errors/cmr/empty-selection.md +19 -0
- package/docs/errors/cmr/membership-changed.md +20 -0
- package/docs/errors/cmr/not-a-controller.md +26 -0
- package/docs/errors/cmr/outcome-unknown.md +29 -0
- package/docs/errors/cmr/output-truncated.md +16 -0
- package/docs/errors/cmr/policy-denied.md +31 -0
- package/docs/errors/cmr/selection-incomplete.md +18 -0
- package/docs/errors/cmr/validation-incomplete.md +23 -0
- package/docs/errors/routeros/version-unsupported.md +4 -0
- package/docs/errors/transport/write-outcome-unknown.md +24 -0
- package/docs/errors/usage/missing-labels.md +19 -0
- package/package.json +1 -1
- package/src/api-fanout.ts +7 -1
- package/src/api.ts +25 -6
- package/src/cli/execute.ts +132 -1
- package/src/core/cmr.ts +104 -0
- package/src/core/envelope.ts +16 -0
- package/src/core/error-catalog.ts +71 -0
- package/src/core/fanout.ts +77 -8
- package/src/errors.ts +1 -0
- package/src/execute-cmr.ts +1425 -0
- package/src/execute-fanout.ts +4 -0
- package/src/execute.ts +68 -17
- package/src/index.ts +17 -0
- package/src/protocols/adapter.ts +54 -12
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# `cmr/outcome-unknown`
|
|
2
|
+
|
|
3
|
+
`execute --cmr` sent the command to a CMR client, but no final status came back
|
|
4
|
+
for it. The command **may have run, may still be running, or may not have
|
|
5
|
+
run**.
|
|
6
|
+
|
|
7
|
+
## Typical trigger
|
|
8
|
+
|
|
9
|
+
- CMR reported `failed` with `timed out (6)` (the `timeout=` centrs passes, from
|
|
10
|
+
`--timeout`) or `std failure: timeout (13)` (CMR's own ~60 s ceiling per call).
|
|
11
|
+
- centrs stopped waiting: its local deadline passed, the operation was
|
|
12
|
+
cancelled, or the controller connection dropped after the request was sent.
|
|
13
|
+
- The client changed its CMR key (`ids`) during the call, or the controller's
|
|
14
|
+
inventory could not be re-read afterwards to confirm which client answered.
|
|
15
|
+
|
|
16
|
+
On RouterOS 7.26beta1, a timed-out or cancelled `run-script` **kept running on
|
|
17
|
+
the client**, and its side effects landed afterwards (#441, #450). A timeout is
|
|
18
|
+
not a rollback.
|
|
19
|
+
|
|
20
|
+
## Fix
|
|
21
|
+
|
|
22
|
+
- Check the client's state before doing anything else. Read the menu the
|
|
23
|
+
command changed, directly or through the controller.
|
|
24
|
+
- Do not re-run a command that is not idempotent until you know it did not run.
|
|
25
|
+
centrs never retries a CMR dispatch.
|
|
26
|
+
- For long work, raise `--timeout` (up to 55 s per call). Anything needing more
|
|
27
|
+
than CMR's ~60 s ceiling belongs in a script or scheduler entry on the client.
|
|
28
|
+
|
|
29
|
+
See [`commands/execute/README.md`](../../../commands/execute/README.md) → CMR.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# `cmr/output-truncated`
|
|
2
|
+
|
|
3
|
+
A warning: CMR clipped a client's command output at its cap (about 60 KiB on
|
|
4
|
+
7.26beta1) and appended `...(+N B)`. The command **completed**; only the
|
|
5
|
+
returned text is incomplete. The envelope's `meta.truncated` records it.
|
|
6
|
+
|
|
7
|
+
## Typical trigger
|
|
8
|
+
|
|
9
|
+
A command that prints a lot, such as a large `print` or `/export`, run with
|
|
10
|
+
`execute --cmr`.
|
|
11
|
+
|
|
12
|
+
## Fix
|
|
13
|
+
|
|
14
|
+
Narrow the output on the client: `print` with `where` and `proplist=`, or
|
|
15
|
+
`:put` only the fields you need. For a full configuration, save it to a file on
|
|
16
|
+
the client instead of printing it.
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# `cmr/policy-denied`
|
|
2
|
+
|
|
3
|
+
A CMR client ran the command and refused one of its statements with
|
|
4
|
+
`not enough permissions (9)`.
|
|
5
|
+
|
|
6
|
+
CMR runs `run-script` scripts on each client with only the `read`, `write`,
|
|
7
|
+
`test` and `reboot` policies, and without a user session. Validation cannot
|
|
8
|
+
catch this: the client's `:parse` accepts the command, and the policy check
|
|
9
|
+
happens only when it runs.
|
|
10
|
+
|
|
11
|
+
The refused statement did not run, and the script stopped there. Statements
|
|
12
|
+
before it **did** run: CMR does not roll them back. Their output, followed by
|
|
13
|
+
the diagnostic, is in `context.output`.
|
|
14
|
+
|
|
15
|
+
## Typical trigger
|
|
16
|
+
|
|
17
|
+
- `/user add`, `/user group add` (needs `policy`).
|
|
18
|
+
- `/tool sniffer` (needs `sniff`).
|
|
19
|
+
|
|
20
|
+
Reading a sensitive value is not this error: it succeeds and returns `*` or an
|
|
21
|
+
empty value.
|
|
22
|
+
|
|
23
|
+
## Fix
|
|
24
|
+
|
|
25
|
+
Check what the earlier statements changed. Then run only the refused
|
|
26
|
+
statement, and the ones after it, on the device directly with a user that has
|
|
27
|
+
the policy:
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
centrs execute <device> -- '<command>'
|
|
31
|
+
```
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# `cmr/selection-incomplete`
|
|
2
|
+
|
|
3
|
+
centrs asked the controller, for every `/cmr/device` row, whether it matches
|
|
4
|
+
`--labels`. At least one row got no answer, so the selection is not known and
|
|
5
|
+
**nothing was run**.
|
|
6
|
+
|
|
7
|
+
## Typical trigger
|
|
8
|
+
|
|
9
|
+
A per-device preflight call timed out, was cancelled, or lost its connection
|
|
10
|
+
before the controller either refused it (`no devices selected`, so not a
|
|
11
|
+
member) or returned a row (a member).
|
|
12
|
+
|
|
13
|
+
## Fix
|
|
14
|
+
|
|
15
|
+
- Check that the controller is reachable and responsive, then retry.
|
|
16
|
+
- Raise `--timeout` if the controller is slow.
|
|
17
|
+
- centrs never runs a partial selection, because a smaller set that reports
|
|
18
|
+
success would hide the clients it skipped.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# `cmr/validation-incomplete`
|
|
2
|
+
|
|
3
|
+
The validation result from a CMR client was incomplete, so the command was
|
|
4
|
+
**not** sent to it. The other members are unaffected.
|
|
5
|
+
|
|
6
|
+
## Typical trigger
|
|
7
|
+
|
|
8
|
+
centrs validates on each client with `:put [:parse <command>]; :put "<nonce>"`
|
|
9
|
+
through `run-script`. The result is rejected before it is classified when:
|
|
10
|
+
|
|
11
|
+
- the status was not `success` (for example `timed out (6)`);
|
|
12
|
+
- the output was clipped by CMR's ~60 KiB cap (`...(+N B)`);
|
|
13
|
+
- the nonce line that proves the output is complete is missing;
|
|
14
|
+
- the `:parse` return was empty or was not parse output (IL).
|
|
15
|
+
|
|
16
|
+
An empty or clipped result is not a pass. Without the completeness check,
|
|
17
|
+
missing output would have read as "no diagnostic".
|
|
18
|
+
|
|
19
|
+
## Fix
|
|
20
|
+
|
|
21
|
+
- Make sure the client is connected and responsive, then retry.
|
|
22
|
+
- Very large commands can exceed the output cap once `:parse` echoes them back.
|
|
23
|
+
Split the work into smaller commands.
|
|
@@ -9,6 +9,10 @@ error.
|
|
|
9
9
|
Distinct from `routeros/unsupported-capability`, where the chosen *protocol*
|
|
10
10
|
cannot carry the operation at all regardless of device version.
|
|
11
11
|
|
|
12
|
+
`execute --cmr` raises it with `capability: cmr-controller` when the target has
|
|
13
|
+
no `/cmr/device` and runs RouterOS older than 7.26beta1, where CMR first
|
|
14
|
+
shipped. That check reads the version only after the missing menu, not before.
|
|
15
|
+
|
|
12
16
|
## Context fields
|
|
13
17
|
|
|
14
18
|
- `capability` — stable slug of the missing operation.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# `transport/write-outcome-unknown`
|
|
2
|
+
|
|
3
|
+
A warning on a failed write. The request had already been sent when the
|
|
4
|
+
transport failed (a dropped connection, a REST 5xx, a timeout), so the router
|
|
5
|
+
may have run it. The error beside this warning says how the call ended, not
|
|
6
|
+
whether the write happened.
|
|
7
|
+
|
|
8
|
+
## Typical trigger
|
|
9
|
+
|
|
10
|
+
- A connection closed or reset after an `execute` or `api` write was sent.
|
|
11
|
+
- A REST 5xx or a timeout on a write.
|
|
12
|
+
|
|
13
|
+
centrs never retries a write that it has sent, in single-target or fan-out
|
|
14
|
+
mode, because replaying an `add`, a script run or a counter change would apply
|
|
15
|
+
it twice (#448). In fan-out, a write is retried only when a retryable
|
|
16
|
+
transport failure (`transport/network` or `transport/connection-closed`) ends
|
|
17
|
+
it *before* it was sent, for example during remote validation or a native API
|
|
18
|
+
login. Any other failure, sent or not, is not retried.
|
|
19
|
+
|
|
20
|
+
## Fix
|
|
21
|
+
|
|
22
|
+
Check the router's state before running the write again. For example, read
|
|
23
|
+
back the menu you changed with `centrs retrieve`. Re-run only what did not
|
|
24
|
+
apply.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# `usage/missing-labels`
|
|
2
|
+
|
|
3
|
+
`centrs execute --cmr` was given no `--labels` expression.
|
|
4
|
+
|
|
5
|
+
## Typical trigger
|
|
6
|
+
|
|
7
|
+
`--cmr` runs the command on the CMR clients a label expression selects, and the
|
|
8
|
+
expression is required so the blast radius is always explicit.
|
|
9
|
+
|
|
10
|
+
## Fix
|
|
11
|
+
|
|
12
|
+
Pass RouterOS label syntax: a comma list where plain labels are OR, `+label`
|
|
13
|
+
must match, `-label` excludes, and `all` selects every device, including the
|
|
14
|
+
controller:
|
|
15
|
+
|
|
16
|
+
```sh
|
|
17
|
+
centrs execute ctl --cmr --labels all -- ':put [/system/identity/get name]'
|
|
18
|
+
centrs execute ctl --cmr --labels site-a,+edge,-ctl --yes -- '/ip/dns/set servers=192.0.2.53'
|
|
19
|
+
```
|
package/package.json
CHANGED
package/src/api-fanout.ts
CHANGED
|
@@ -47,6 +47,7 @@ import {
|
|
|
47
47
|
buildFanoutResolveFailure,
|
|
48
48
|
commonVia,
|
|
49
49
|
defaultFanoutSleep,
|
|
50
|
+
mayRetryUnsentWrite,
|
|
50
51
|
resolveFanoutConcurrency,
|
|
51
52
|
runFanout,
|
|
52
53
|
summarizeFanout,
|
|
@@ -105,7 +106,10 @@ export interface ApiFanoutInternals {
|
|
|
105
106
|
env: Record<string, string | undefined>,
|
|
106
107
|
config?: Record<string, string | undefined>,
|
|
107
108
|
) => Promise<SelectionExpansion>;
|
|
108
|
-
execute?: (
|
|
109
|
+
execute?: (
|
|
110
|
+
resolved: ResolvedApiRequest,
|
|
111
|
+
trace: ValidationTrace,
|
|
112
|
+
) => Promise<ApiSuccessEnvelope>;
|
|
109
113
|
sleep?: (ms: number) => Promise<void>;
|
|
110
114
|
}
|
|
111
115
|
|
|
@@ -318,6 +322,8 @@ export async function apiFanout(
|
|
|
318
322
|
),
|
|
319
323
|
member,
|
|
320
324
|
),
|
|
325
|
+
mayRetry: (resolved) =>
|
|
326
|
+
mayRetryUnsentWrite(requestSummary.write, traces.get(resolved)),
|
|
321
327
|
sleep,
|
|
322
328
|
});
|
|
323
329
|
|
package/src/api.ts
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
* structured validation, confirmation, and result-envelope surface.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
+
import { assertNotCmrDashboardPath, cmrRunScriptWarnings } from "./core/cmr.ts";
|
|
18
19
|
import type {
|
|
19
20
|
CentrsEnvelope,
|
|
20
21
|
CentrsErrorEnvelope,
|
|
@@ -28,6 +29,7 @@ import type {
|
|
|
28
29
|
Warning,
|
|
29
30
|
} from "./core/envelope.ts";
|
|
30
31
|
import { buildTip, notReachedReason } from "./core/envelope.ts";
|
|
32
|
+
import { sentWriteWarnings } from "./core/fanout.ts";
|
|
31
33
|
import {
|
|
32
34
|
inspectArgumentNames,
|
|
33
35
|
inspectChildrenOrEmpty,
|
|
@@ -393,6 +395,11 @@ export async function runResolvedApi(
|
|
|
393
395
|
resolved === request
|
|
394
396
|
? protocolRequest
|
|
395
397
|
: buildProtocolApiRequest(resolved),
|
|
398
|
+
{
|
|
399
|
+
onSent: () => {
|
|
400
|
+
trace.dispatched = true;
|
|
401
|
+
},
|
|
402
|
+
},
|
|
396
403
|
);
|
|
397
404
|
const routerOsFailure = apiRouterOsFailureFromResult(resolved, result);
|
|
398
405
|
if (routerOsFailure) {
|
|
@@ -563,7 +570,10 @@ async function* streamResolvedApi(
|
|
|
563
570
|
const protocolRequest = buildProtocolApiRequest(resolved);
|
|
564
571
|
for await (const reply of backend.stream(protocolRequest, {
|
|
565
572
|
signal: controller.signal,
|
|
566
|
-
onListening
|
|
573
|
+
onListening: () => {
|
|
574
|
+
trace.dispatched = true;
|
|
575
|
+
onListening?.();
|
|
576
|
+
},
|
|
567
577
|
onCancelUnacknowledged: () => {
|
|
568
578
|
cancelUnacknowledged = true;
|
|
569
579
|
},
|
|
@@ -602,7 +612,7 @@ async function* streamResolvedApi(
|
|
|
602
612
|
done,
|
|
603
613
|
);
|
|
604
614
|
} catch (error) {
|
|
605
|
-
const envelope = buildApiErrorEnvelopeFromResolved(resolved, error);
|
|
615
|
+
const envelope = buildApiErrorEnvelopeFromResolved(resolved, error, trace);
|
|
606
616
|
envelope.meta.validation = validation;
|
|
607
617
|
envelope.warnings = [...envelope.warnings, ...cancellationWarnings()];
|
|
608
618
|
envelope.tips = [...envelope.tips, ...tips];
|
|
@@ -1029,9 +1039,10 @@ export async function resolveApiRequest(
|
|
|
1029
1039
|
insecure,
|
|
1030
1040
|
yes: request.yes ?? false,
|
|
1031
1041
|
verbose: request.verbose ?? false,
|
|
1032
|
-
warnings:
|
|
1033
|
-
?
|
|
1034
|
-
|
|
1042
|
+
warnings: [
|
|
1043
|
+
...(connection ? connection.warnings : (cdbResolution?.warnings ?? [])),
|
|
1044
|
+
...(verb === "run" ? cmrRunScriptWarnings(normalized.path) : []),
|
|
1045
|
+
],
|
|
1035
1046
|
};
|
|
1036
1047
|
}
|
|
1037
1048
|
|
|
@@ -1718,7 +1729,14 @@ export function buildApiErrorEnvelopeFromResolved(
|
|
|
1718
1729
|
return {
|
|
1719
1730
|
ok: false,
|
|
1720
1731
|
error: serializeCentrsError(centrsError),
|
|
1721
|
-
warnings: [
|
|
1732
|
+
warnings: [
|
|
1733
|
+
...resolved.warnings,
|
|
1734
|
+
...sentWriteWarnings(
|
|
1735
|
+
isApiMutating(resolved.method, resolved.path),
|
|
1736
|
+
centrsError,
|
|
1737
|
+
trace,
|
|
1738
|
+
),
|
|
1739
|
+
],
|
|
1722
1740
|
tips: [],
|
|
1723
1741
|
meta: metaFromResolved(
|
|
1724
1742
|
resolved,
|
|
@@ -2020,6 +2038,7 @@ export function validateApiRequestShape(request: ApiRequest): void {
|
|
|
2020
2038
|
});
|
|
2021
2039
|
}
|
|
2022
2040
|
const normalized = normalizeApiEndpoint(request.endpoint);
|
|
2041
|
+
assertNotCmrDashboardPath(normalized.path);
|
|
2023
2042
|
if (pathTokens(normalized.path).length === 0) {
|
|
2024
2043
|
throw new CentrsError({
|
|
2025
2044
|
code: "input/invalid-path",
|
package/src/cli/execute.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { fanoutExitCode } from "../core/fanout.ts";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
asCentrsError,
|
|
4
|
+
CentrsError,
|
|
5
|
+
formatCentrsErrorText,
|
|
6
|
+
} from "../errors.ts";
|
|
3
7
|
import {
|
|
4
8
|
buildExecuteErrorEnvelope,
|
|
5
9
|
type ExecuteOutputFormat,
|
|
@@ -8,6 +12,11 @@ import {
|
|
|
8
12
|
executeOutputFormats,
|
|
9
13
|
renderExecuteEnvelope,
|
|
10
14
|
} from "../execute.ts";
|
|
15
|
+
import {
|
|
16
|
+
buildExecuteCmrErrorEnvelope,
|
|
17
|
+
executeCmr,
|
|
18
|
+
renderExecuteCmrEnvelope,
|
|
19
|
+
} from "../execute-cmr.ts";
|
|
11
20
|
import {
|
|
12
21
|
buildExecuteFanoutErrorEnvelope,
|
|
13
22
|
executeFanout,
|
|
@@ -54,6 +63,17 @@ export const executeCommand: CliCommandMetadata = {
|
|
|
54
63
|
"Pin the protocol selector; no silent downgrade when set. A bare MAC target defaults to mac-telnet.",
|
|
55
64
|
},
|
|
56
65
|
...selectionCommandOptions,
|
|
66
|
+
{
|
|
67
|
+
flag: "--cmr",
|
|
68
|
+
description:
|
|
69
|
+
"Run on CMR clients through their controller: the <target> is the controller (RouterOS 7.26beta1+, `cmr` package). Needs `--labels`; native-api or rest-api only.",
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
flag: "--labels",
|
|
73
|
+
valueName: "<expr>",
|
|
74
|
+
description:
|
|
75
|
+
"With `--cmr`: RouterOS label expression selecting clients (comma list; plain = OR, `+` AND, `-` AND NOT, `all`).",
|
|
76
|
+
},
|
|
57
77
|
{
|
|
58
78
|
flag: "--host",
|
|
59
79
|
valueName: "<host|url>",
|
|
@@ -147,6 +167,8 @@ export const executeCommand: CliCommandMetadata = {
|
|
|
147
167
|
|
|
148
168
|
interface ExecuteCliArgs extends ExecuteRequest {
|
|
149
169
|
help?: boolean;
|
|
170
|
+
cmr?: boolean;
|
|
171
|
+
labels?: string;
|
|
150
172
|
format?: ExecuteOutputFormat;
|
|
151
173
|
selectionFlags?: SelectionFlags;
|
|
152
174
|
targetPositionals?: readonly string[];
|
|
@@ -194,6 +216,12 @@ export function parseExecuteCliArgs(args: readonly string[]): ExecuteCliArgs {
|
|
|
194
216
|
case "--yes":
|
|
195
217
|
parsed.yes = true;
|
|
196
218
|
break;
|
|
219
|
+
case "--cmr":
|
|
220
|
+
parsed.cmr = true;
|
|
221
|
+
break;
|
|
222
|
+
case "--labels":
|
|
223
|
+
parsed.labels = expectValue(args, ++index, arg);
|
|
224
|
+
break;
|
|
197
225
|
case "--validate":
|
|
198
226
|
parsed.validate = true;
|
|
199
227
|
break;
|
|
@@ -322,6 +350,14 @@ export async function runExecuteCli(args: readonly string[]): Promise<number> {
|
|
|
322
350
|
selectionFlags.quickchr[0] ?? "",
|
|
323
351
|
);
|
|
324
352
|
}
|
|
353
|
+
if (parsed.cmr || parsed.labels !== undefined) {
|
|
354
|
+
return await runExecuteCmrCli(
|
|
355
|
+
parsed,
|
|
356
|
+
selectionFlags,
|
|
357
|
+
targetPositionals,
|
|
358
|
+
args,
|
|
359
|
+
);
|
|
360
|
+
}
|
|
325
361
|
if (isFanoutMode(selectionFlags, targetPositionals.length)) {
|
|
326
362
|
return await runExecuteFanoutCli(
|
|
327
363
|
parsed,
|
|
@@ -450,6 +486,101 @@ async function runExecuteFanoutCli(
|
|
|
450
486
|
}
|
|
451
487
|
}
|
|
452
488
|
|
|
489
|
+
/**
|
|
490
|
+
* `execute --cmr`: the single target is the CMR controller; `--labels` picks
|
|
491
|
+
* the clients. Fan-out selectors would mix two selection sources, so they are
|
|
492
|
+
* refused rather than combined (#450).
|
|
493
|
+
*/
|
|
494
|
+
async function runExecuteCmrCli(
|
|
495
|
+
parsed: ExecuteCliArgs,
|
|
496
|
+
selectionFlags: SelectionFlags,
|
|
497
|
+
targetPositionals: readonly string[],
|
|
498
|
+
args: readonly string[],
|
|
499
|
+
): Promise<number> {
|
|
500
|
+
const format = inferExecuteFormat(args, parsed);
|
|
501
|
+
const controller = new AbortController();
|
|
502
|
+
// First Ctrl-C cancels (unsent members become `cmr/cancelled`); a second
|
|
503
|
+
// one exits at once in case a controller call does not end promptly.
|
|
504
|
+
const onSigint = (): void => {
|
|
505
|
+
if (controller.signal.aborted) process.exit(130);
|
|
506
|
+
controller.abort();
|
|
507
|
+
};
|
|
508
|
+
process.on("SIGINT", onSigint);
|
|
509
|
+
try {
|
|
510
|
+
if (!parsed.cmr) {
|
|
511
|
+
throw new CentrsError({
|
|
512
|
+
code: "usage/conflicting-flags",
|
|
513
|
+
summary:
|
|
514
|
+
"`--labels` selects CMR clients and only applies with `--cmr`.",
|
|
515
|
+
remediation:
|
|
516
|
+
"Add `--cmr` and name the controller as the target, or drop `--labels`.",
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
if (
|
|
520
|
+
hasTargetSelector({ ...selectionFlags, quickchr: [] }) ||
|
|
521
|
+
selectionFlags.quickchr.length > 1 ||
|
|
522
|
+
targetPositionals.length > 1
|
|
523
|
+
) {
|
|
524
|
+
throw new CentrsError({
|
|
525
|
+
code: "usage/conflicting-flags",
|
|
526
|
+
summary:
|
|
527
|
+
"`--cmr` takes one controller target; CDB fan-out selectors and several targets cannot be combined with it.",
|
|
528
|
+
remediation:
|
|
529
|
+
"Name one controller (a positional, `--host` or one `--quickchr`) and pick clients with `--labels`.",
|
|
530
|
+
context: {
|
|
531
|
+
positionals: targetPositionals.length,
|
|
532
|
+
quickchr: selectionFlags.quickchr.length,
|
|
533
|
+
},
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
if (selectionFlags.quickchr.length === 1) {
|
|
537
|
+
parsed.quickchr = selectionFlags.quickchr[0];
|
|
538
|
+
}
|
|
539
|
+
if (
|
|
540
|
+
!parsed.targetInput &&
|
|
541
|
+
parsed.quickchr === undefined &&
|
|
542
|
+
parsed.host === undefined
|
|
543
|
+
) {
|
|
544
|
+
throw missingTargetError({
|
|
545
|
+
command: "execute",
|
|
546
|
+
summary:
|
|
547
|
+
"`centrs execute --cmr` requires the CMR controller as its <target>.",
|
|
548
|
+
remediation:
|
|
549
|
+
'Name the controller, e.g. `centrs execute ctl --cmr --labels all -- ":put ok"`.',
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
const envelope = await executeCmr({
|
|
553
|
+
...parsed,
|
|
554
|
+
labels: parsed.labels ?? "",
|
|
555
|
+
concurrency: selectionFlags.concurrency,
|
|
556
|
+
signal: controller.signal,
|
|
557
|
+
});
|
|
558
|
+
const resolvedFormat = envelope.meta.operation?.request.format ?? format;
|
|
559
|
+
console.log(
|
|
560
|
+
renderExecuteCmrEnvelope(envelope, resolvedFormat, {
|
|
561
|
+
verbose: parsed.verbose ?? false,
|
|
562
|
+
}),
|
|
563
|
+
);
|
|
564
|
+
return fanoutExitCode(envelope);
|
|
565
|
+
} catch (error) {
|
|
566
|
+
console.error(
|
|
567
|
+
renderExecuteCmrEnvelope(
|
|
568
|
+
buildExecuteCmrErrorEnvelope(
|
|
569
|
+
{ targetInput: parsed.targetInput, via: parsed.via },
|
|
570
|
+
error,
|
|
571
|
+
),
|
|
572
|
+
format,
|
|
573
|
+
{
|
|
574
|
+
verbose: parsed.verbose ?? false,
|
|
575
|
+
},
|
|
576
|
+
),
|
|
577
|
+
);
|
|
578
|
+
return 1;
|
|
579
|
+
} finally {
|
|
580
|
+
process.off("SIGINT", onSigint);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
453
584
|
function inferExecuteFormat(
|
|
454
585
|
args: readonly string[],
|
|
455
586
|
parsed?: ExecuteCliArgs,
|
package/src/core/cmr.ts
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CMR facts shared by the surfaces that can reach `/cmr/device/run-script`
|
|
3
|
+
* directly (`api`, plain `execute`) and by `execute --cmr` (#450).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { CentrsError } from "../errors.ts";
|
|
7
|
+
import type { Warning } from "./envelope.ts";
|
|
8
|
+
|
|
9
|
+
/** The controller command that runs a script on CMR clients. */
|
|
10
|
+
export const CMR_RUN_SCRIPT_PATH = "/cmr/device/run-script";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The console-only live view. Grounded on 7.26beta1 CHR: over the native API
|
|
14
|
+
* `/cmr/device/dashboard` closed the API session; over REST it never returned,
|
|
15
|
+
* and every other REST call on the controller then waited 10–30 s.
|
|
16
|
+
*/
|
|
17
|
+
const CMR_DASHBOARD = ["cmr", "device", "dashboard"] as const;
|
|
18
|
+
const DASHBOARD_PATH = "/cmr/device/dashboard";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Whether a structured RouterOS path (an `api` endpoint, or a single
|
|
22
|
+
* `execute` command's path and verb) is the dashboard. Each segment may be an
|
|
23
|
+
* abbreviation, as RouterOS accepts (`/cmr/dev/dash`). This needs no router, so
|
|
24
|
+
* it refuses the plain spellings before anything is dialed.
|
|
25
|
+
*/
|
|
26
|
+
export function isCmrDashboardPath(path: string): boolean {
|
|
27
|
+
const segments = path
|
|
28
|
+
.split("/")
|
|
29
|
+
.filter((segment) => segment.length > 0)
|
|
30
|
+
.map((segment) => segment.toLowerCase());
|
|
31
|
+
return (
|
|
32
|
+
segments.length === CMR_DASHBOARD.length &&
|
|
33
|
+
segments.every((segment, k) =>
|
|
34
|
+
(CMR_DASHBOARD[k] as string).startsWith(segment),
|
|
35
|
+
)
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Whether a `:parse` return invokes the dashboard. The device resolves every
|
|
41
|
+
* spelling to one invocation: abbreviations, console spacing, a relative menu
|
|
42
|
+
* change across `;` or a newline, and `:do`/`:if`/`[...]` bodies. Strings stay
|
|
43
|
+
* `=` values and comments are dropped (7.26beta1 CHR). An invocation follows
|
|
44
|
+
* the start of the IL, a `;` or another `(evl `. A string value sits after `=`.
|
|
45
|
+
*/
|
|
46
|
+
export function ilInvokesCmrDashboard(il: string): boolean {
|
|
47
|
+
return /(?:^|;|\(evl )\(evl \/cmr\/device\/dashboard/.test(il);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function consoleOnlyError(input: string): CentrsError {
|
|
51
|
+
return new CentrsError({
|
|
52
|
+
code: "cmr/console-only",
|
|
53
|
+
summary: `\`${DASHBOARD_PATH}\` (in any spelling) is an interactive console view, not an API command. Over the native API it closes the session; over REST it holds the controller's REST service for every caller until it times out.`,
|
|
54
|
+
remediation:
|
|
55
|
+
"Open it in WinBox 4 or WebFig, or run it in `centrs terminal <controller>`. For the data behind it, read `/cmr/device`, `/cmr/alert` or `/cmr/upgrade/job` with `centrs retrieve`.",
|
|
56
|
+
context: { path: DASHBOARD_PATH, input },
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Refuse a structured dashboard path before anything is dialed. */
|
|
61
|
+
export function assertNotCmrDashboardPath(path: string): void {
|
|
62
|
+
if (isCmrDashboardPath(path)) throw consoleOnlyError(path);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Refuse a command whose accepted `:parse` return invokes the dashboard. */
|
|
66
|
+
export function assertNotCmrDashboardIl(il: string, command: string): void {
|
|
67
|
+
if (ilInvokesCmrDashboard(il)) throw consoleOnlyError(command);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function withoutTrailingSlashes(path: string): string {
|
|
71
|
+
// A loop, not `/\/+$/`: that regex is quadratic on a long run of `/`.
|
|
72
|
+
let end = path.length;
|
|
73
|
+
while (end > 0 && path[end - 1] === "/") end -= 1;
|
|
74
|
+
return path.slice(0, end);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const clientScriptNotValidated: Warning = {
|
|
78
|
+
code: "cmr/client-script-not-validated",
|
|
79
|
+
message:
|
|
80
|
+
"Validation covered the controller request only; the `script=` sent to CMR clients was not validated on them. Use `centrs execute <controller> --cmr --labels <expr> -- '<command>'` to validate on each client first.",
|
|
81
|
+
context: { path: CMR_RUN_SCRIPT_PATH },
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* A caller-addressed `run-script` is validated as a controller request only:
|
|
86
|
+
* `/console/inspect` checks its arguments, and the `script=` value is an opaque
|
|
87
|
+
* string to the controller's `:parse`. The script itself then runs without validation
|
|
88
|
+
* on every selected client, so say so next to the `passed` gate.
|
|
89
|
+
*/
|
|
90
|
+
export function cmrRunScriptWarnings(commandPath: string): Warning[] {
|
|
91
|
+
if (withoutTrailingSlashes(commandPath) !== CMR_RUN_SCRIPT_PATH) return [];
|
|
92
|
+
return [clientScriptNotValidated];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Script-shaped `execute` input is opaque to the canonicalizer, so a
|
|
97
|
+
* `run-script` inside it (console spacing, a `:do {}` body) is found by text.
|
|
98
|
+
* A false positive costs only a warning.
|
|
99
|
+
*/
|
|
100
|
+
export function cmrScriptWarnings(script: string): Warning[] {
|
|
101
|
+
return /cmr[\s/]+device[\s/]+run-script\b/.test(script)
|
|
102
|
+
? [clientScriptNotValidated]
|
|
103
|
+
: [];
|
|
104
|
+
}
|
package/src/core/envelope.ts
CHANGED
|
@@ -109,6 +109,17 @@ export interface EnvelopeTargetMeta {
|
|
|
109
109
|
source?: SettingSource;
|
|
110
110
|
/** Provenance per resolved field once the resolver is wired (WP-0c). */
|
|
111
111
|
sources?: Record<string, SettingSource>;
|
|
112
|
+
/**
|
|
113
|
+
* `execute --cmr`: the CMR client this result belongs to, as the controller
|
|
114
|
+
* knows it. `id` is the controller's `/cmr/device` row; `deviceKey` is the
|
|
115
|
+
* device half of its `ids`. The client is never dialed directly.
|
|
116
|
+
*/
|
|
117
|
+
cmr?: {
|
|
118
|
+
controller: string;
|
|
119
|
+
id: string;
|
|
120
|
+
deviceKey?: string;
|
|
121
|
+
address?: string;
|
|
122
|
+
};
|
|
112
123
|
}
|
|
113
124
|
|
|
114
125
|
/**
|
|
@@ -153,6 +164,11 @@ export interface ValidationTrace {
|
|
|
153
164
|
* to judge.
|
|
154
165
|
*/
|
|
155
166
|
offlineVerdict?: "pass" | "warn";
|
|
167
|
+
/**
|
|
168
|
+
* Set immediately before the RouterOS request is sent. A failure after this
|
|
169
|
+
* point may have run on the router, so a write must not be replayed (#448).
|
|
170
|
+
*/
|
|
171
|
+
dispatched?: boolean;
|
|
156
172
|
}
|
|
157
173
|
|
|
158
174
|
export interface EnvelopeValidationMeta {
|