@tikoci/centrs 0.1.8 → 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 +216 -31
- package/GLOSSARY.txt +8 -0
- package/README.md +16 -8
- package/SECURITY.md +11 -3
- package/commands/api/README.md +44 -15
- package/commands/api/examples.md +44 -6
- package/commands/execute/README.md +218 -7
- package/commands/execute/examples.md +278 -1
- 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 +32 -14
- package/commands/retrieve/examples.md +10 -6
- package/commands/transfer/examples.md +9 -0
- package/docs/CLI.md +10 -8
- package/docs/CONSTITUTION.md +54 -3
- package/docs/MATRIX.md +20 -3
- 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/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 +33 -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/rule-selects-every-device.md +32 -0
- package/docs/errors/cmr/selection-includes-controller.md +27 -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/flag-after-separator.md +34 -0
- package/docs/errors/usage/missing-labels.md +19 -0
- package/package.json +7 -5
- package/src/api-fanout.ts +18 -2
- package/src/api.ts +230 -71
- 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 +176 -21
- 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 +182 -0
- package/src/core/envelope.ts +46 -1
- package/src/core/error-catalog.ts +95 -2
- package/src/core/execute-command.ts +144 -19
- package/src/core/fanout.ts +207 -14
- package/src/errors.ts +3 -1
- package/src/execute-cmr.ts +1442 -0
- package/src/execute-fanout.ts +15 -1
- package/src/execute.ts +173 -40
- package/src/index.ts +35 -1
- package/src/mcp/safety.ts +11 -0
- package/src/mcp/tools.ts +4 -1
- package/src/protocols/adapter.ts +54 -12
- 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
|
@@ -0,0 +1,486 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #453: name the fleet a CMR controller command reaches, before its write gate.
|
|
3
|
+
*
|
|
4
|
+
* A controller command such as `/cmr/device/reboot labels=all` is one request
|
|
5
|
+
* to one router, but it acts on every device its `labels`/`numbers` select,
|
|
6
|
+
* the controller's own row included. This module recognizes those commands,
|
|
7
|
+
* reads the controller's `/cmr/device` inventory (read-only), and resolves the
|
|
8
|
+
* selection host-side so the gate can say "N devices, the controller
|
|
9
|
+
* included" and warn on the manual's no-selector hazards. The command itself
|
|
10
|
+
* still runs exactly as typed; nothing here rewrites it.
|
|
11
|
+
*
|
|
12
|
+
* Grounded on a 7.26beta1 two-CHR lab (2026-10-15):
|
|
13
|
+
*
|
|
14
|
+
* - The controller's own row carries `controller=true`.
|
|
15
|
+
* - The manual's label rules evaluated over a row's `labels` ∪ `auto-labels`
|
|
16
|
+
* matched the device's own selection (a `run-script numbers=<id>
|
|
17
|
+
* labels=<expr>` probe per row) in every expression tried: `all`, `-x`,
|
|
18
|
+
* an identity, an architecture, an unknown label, `a,+b`, `a,-b`,
|
|
19
|
+
* `all,-a`, and a case change (labels are case-sensitive).
|
|
20
|
+
* - A row's `auto-labels` can be stale while the device is disconnected, so
|
|
21
|
+
* the result is the controller's current view, which the wording says.
|
|
22
|
+
* - Each row names its `upgrade-rule`, so `/cmr/upgrade/trigger` resolves
|
|
23
|
+
* exactly. `/cmr/upgrade/show-devices` is not reachable over REST (404).
|
|
24
|
+
* - A `/cmr/vlan` or `/cmr/wifi` item added without `labels` stores none.
|
|
25
|
+
* Per the manual, such a VLAN rule (no `port-labels` either) provisions
|
|
26
|
+
* every port of every connected device, the controller included, and an
|
|
27
|
+
* upgrade rule without labels covers every device.
|
|
28
|
+
*
|
|
29
|
+
* Not resolved, so the gate keeps its plain wording: script-shaped input (a
|
|
30
|
+
* positional or `[find …]` selector), an abbreviated selector argument, a
|
|
31
|
+
* console transport (no structured read), `/cmr/upgrade/job/run-next` (the
|
|
32
|
+
* job's device list is not in the inventory), and a failed read.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import type { ProtocolAdapter } from "../protocols/adapter.ts";
|
|
36
|
+
import {
|
|
37
|
+
CMR_SCRIPT_POLICIES,
|
|
38
|
+
type CmrCallerPolicy,
|
|
39
|
+
cmrCallerPolicyWarning,
|
|
40
|
+
readCmrCallerPolicy,
|
|
41
|
+
} from "./cmr.ts";
|
|
42
|
+
import type { Warning } from "./envelope.ts";
|
|
43
|
+
|
|
44
|
+
type FleetKind = "device" | "rule" | "trigger" | "job";
|
|
45
|
+
|
|
46
|
+
interface FleetEntry {
|
|
47
|
+
menu: readonly string[];
|
|
48
|
+
verbs: readonly string[];
|
|
49
|
+
kind: FleetKind;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const RULE_VERBS = ["add", "set", "remove", "enable", "disable"] as const;
|
|
53
|
+
|
|
54
|
+
/** Controller commands that act on the devices their selection names. */
|
|
55
|
+
const FLEET_COMMANDS: readonly FleetEntry[] = [
|
|
56
|
+
{
|
|
57
|
+
menu: ["cmr", "device"],
|
|
58
|
+
verbs: ["reboot", "upgrade", "pair", "run-script"],
|
|
59
|
+
kind: "device",
|
|
60
|
+
},
|
|
61
|
+
{ menu: ["cmr", "upgrade"], verbs: ["trigger"], kind: "trigger" },
|
|
62
|
+
{ menu: ["cmr", "upgrade", "job"], verbs: ["run-next"], kind: "job" },
|
|
63
|
+
{ menu: ["cmr", "upgrade"], verbs: RULE_VERBS, kind: "rule" },
|
|
64
|
+
{ menu: ["cmr", "vlan"], verbs: RULE_VERBS, kind: "rule" },
|
|
65
|
+
{ menu: ["cmr", "wifi"], verbs: RULE_VERBS, kind: "rule" },
|
|
66
|
+
{ menu: ["cmr", "wifi", "radio"], verbs: RULE_VERBS, kind: "rule" },
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The policies a fleet command runs with on the clients, where #456 measured
|
|
71
|
+
* that RouterOS does not check them against the controller login (7.26beta1):
|
|
72
|
+
* `run-script` needs only `read` + `api`, `reboot` only `read` + `test` +
|
|
73
|
+
* `api`. Other fleet commands were not measured, so they carry no warning.
|
|
74
|
+
*/
|
|
75
|
+
const RUNS_WITH: Readonly<Record<string, readonly string[]>> = {
|
|
76
|
+
"/cmr/device/run-script": CMR_SCRIPT_POLICIES,
|
|
77
|
+
"/cmr/device/reboot": ["reboot"],
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/** WiFi band labels select radios, not devices (manual: `+2ghz`, `+5ghz`, `+6ghz`). */
|
|
81
|
+
const BAND_LABEL = /^\+[256]ghz$/;
|
|
82
|
+
|
|
83
|
+
const SELECTOR_ARGUMENTS = ["labels", "port-labels", "numbers", "number"];
|
|
84
|
+
|
|
85
|
+
/** A recognized fleet command, before anything is read. */
|
|
86
|
+
export interface CmrFleetCommand {
|
|
87
|
+
/** Full spelling, e.g. `/cmr/device/reboot` (abbreviations expanded). */
|
|
88
|
+
command: string;
|
|
89
|
+
menu: string;
|
|
90
|
+
verb: string;
|
|
91
|
+
kind: FleetKind;
|
|
92
|
+
attributes: Readonly<Record<string, string>>;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** One `/cmr/device` row the selection reaches. */
|
|
96
|
+
export interface CmrFleetDevice {
|
|
97
|
+
id: string;
|
|
98
|
+
identity: string;
|
|
99
|
+
controller: boolean;
|
|
100
|
+
connected: boolean;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** What the gate says about a fleet command. */
|
|
104
|
+
export interface CmrFleetScope {
|
|
105
|
+
command: string;
|
|
106
|
+
/** The devices reached, by the controller's current inventory; absent when not resolved. */
|
|
107
|
+
devices?: CmrFleetDevice[];
|
|
108
|
+
/** Size of the controller's `/cmr/device` inventory. */
|
|
109
|
+
inventory?: number;
|
|
110
|
+
/** Why `devices` is absent. */
|
|
111
|
+
unresolved?: string;
|
|
112
|
+
/** A rule without a device selector (applies to every device). */
|
|
113
|
+
everyDevice?: boolean;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Recognize a fleet command from a structured path (`/cmr/device/reboot`,
|
|
118
|
+
* each segment possibly abbreviated as RouterOS accepts) and its attributes.
|
|
119
|
+
*/
|
|
120
|
+
export function cmrFleetCommand(
|
|
121
|
+
path: string,
|
|
122
|
+
attributes: Readonly<Record<string, string>>,
|
|
123
|
+
): CmrFleetCommand | undefined {
|
|
124
|
+
const segments = path
|
|
125
|
+
.split("/")
|
|
126
|
+
.filter((segment) => segment.length > 0)
|
|
127
|
+
.map((segment) => segment.toLowerCase());
|
|
128
|
+
for (const entry of FLEET_COMMANDS) {
|
|
129
|
+
if (segments.length !== entry.menu.length + 1) continue;
|
|
130
|
+
const menuMatches = entry.menu.every((name, k) =>
|
|
131
|
+
name.startsWith(segments[k] as string),
|
|
132
|
+
);
|
|
133
|
+
if (!menuMatches) continue;
|
|
134
|
+
const typed = segments.at(-1) as string;
|
|
135
|
+
const verbs = entry.verbs.filter((verb) => verb.startsWith(typed));
|
|
136
|
+
const verb = verbs.includes(typed) ? typed : verbs[0];
|
|
137
|
+
if (verb === undefined || (verbs.length > 1 && !verbs.includes(typed))) {
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
const menu = `/${entry.menu.join("/")}`;
|
|
141
|
+
return {
|
|
142
|
+
command: `${menu}/${verb}`,
|
|
143
|
+
menu,
|
|
144
|
+
verb,
|
|
145
|
+
kind: entry.kind,
|
|
146
|
+
attributes,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
return undefined;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Whether a comma-separated CMR `labels` expression selects a device with
|
|
154
|
+
* these labels. Plain labels OR together, `+label` must also match, `-label`
|
|
155
|
+
* excludes, `all` matches every device, and an expression with no plain label
|
|
156
|
+
* starts from every device (`-core` = `all,-core`). Case-sensitive.
|
|
157
|
+
*/
|
|
158
|
+
export function cmrLabelsSelect(
|
|
159
|
+
expression: string,
|
|
160
|
+
labels: ReadonlySet<string>,
|
|
161
|
+
): boolean {
|
|
162
|
+
let plain = false;
|
|
163
|
+
let matched = false;
|
|
164
|
+
for (const raw of expression.split(",")) {
|
|
165
|
+
const item = raw.trim();
|
|
166
|
+
if (item.length === 0) continue;
|
|
167
|
+
if (item.startsWith("+")) {
|
|
168
|
+
if (!labels.has(item.slice(1))) return false;
|
|
169
|
+
} else if (item.startsWith("-")) {
|
|
170
|
+
if (labels.has(item.slice(1))) return false;
|
|
171
|
+
} else {
|
|
172
|
+
plain = true;
|
|
173
|
+
if (item === "all" || labels.has(item)) matched = true;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return plain ? matched : true;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
type Row = Record<string, string>;
|
|
180
|
+
|
|
181
|
+
function labelSet(row: Row): Set<string> {
|
|
182
|
+
return new Set(
|
|
183
|
+
`${row["labels"] ?? ""},${row["auto-labels"] ?? ""}`
|
|
184
|
+
.split(",")
|
|
185
|
+
.map((label) => label.trim())
|
|
186
|
+
.filter((label) => label.length > 0),
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function toDevice(row: Row): CmrFleetDevice {
|
|
191
|
+
return {
|
|
192
|
+
id: row[".id"] ?? "",
|
|
193
|
+
identity: row["identity"] ?? "",
|
|
194
|
+
controller: row["controller"] === "true",
|
|
195
|
+
connected: row["connected"] === "true",
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function listItems(value: string): string[] {
|
|
200
|
+
return value
|
|
201
|
+
.split(",")
|
|
202
|
+
.map((item) => item.trim())
|
|
203
|
+
.filter((item) => item.length > 0);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** A `numbers=` item names a row by `.id` (any case) or by its `name`/`identity`. */
|
|
207
|
+
function rowNamed(row: Row, item: string, nameField: string): boolean {
|
|
208
|
+
return (
|
|
209
|
+
row[".id"]?.toLowerCase() === item.toLowerCase() || row[nameField] === item
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Drop band labels from a WiFi expression; they pick radios, not devices. */
|
|
214
|
+
function deviceLabels(command: CmrFleetCommand, labels: string): string {
|
|
215
|
+
if (!command.menu.startsWith("/cmr/wifi")) return labels;
|
|
216
|
+
return listItems(labels)
|
|
217
|
+
.filter((item) => !BAND_LABEL.test(item))
|
|
218
|
+
.join(",");
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function abbreviatedSelector(
|
|
222
|
+
attributes: Readonly<Record<string, string>>,
|
|
223
|
+
): string | undefined {
|
|
224
|
+
return Object.keys(attributes).find(
|
|
225
|
+
(key) =>
|
|
226
|
+
!SELECTOR_ARGUMENTS.includes(key) &&
|
|
227
|
+
SELECTOR_ARGUMENTS.some((name) => name.startsWith(key)),
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Resolve which inventory rows a fleet command reaches. `read` returns a
|
|
233
|
+
* menu's rows (`/cmr/device`, or the rule's own menu for `set`/`remove`).
|
|
234
|
+
*/
|
|
235
|
+
export async function resolveCmrFleetScope(
|
|
236
|
+
command: CmrFleetCommand,
|
|
237
|
+
read: (menu: string) => Promise<Row[]>,
|
|
238
|
+
): Promise<CmrFleetScope> {
|
|
239
|
+
const scope = (fields: Omit<CmrFleetScope, "command">): CmrFleetScope => ({
|
|
240
|
+
command: command.command,
|
|
241
|
+
...fields,
|
|
242
|
+
});
|
|
243
|
+
const abbreviated = abbreviatedSelector(command.attributes);
|
|
244
|
+
if (abbreviated !== undefined) {
|
|
245
|
+
return scope({
|
|
246
|
+
unresolved: `the abbreviated argument \`${abbreviated}=\` was not expanded`,
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
if (command.kind === "job") {
|
|
250
|
+
return scope({
|
|
251
|
+
unresolved: "an upgrade job's devices are not in the inventory",
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
const rows = await read("/cmr/device");
|
|
255
|
+
const inventory = rows.length;
|
|
256
|
+
const select = (labels: string | undefined, numbers?: string) => {
|
|
257
|
+
const ids = numbers === undefined ? undefined : listItems(numbers);
|
|
258
|
+
return rows
|
|
259
|
+
.filter(
|
|
260
|
+
(row) =>
|
|
261
|
+
(ids === undefined ||
|
|
262
|
+
ids.some((item) => rowNamed(row, item, "identity"))) &&
|
|
263
|
+
(labels === undefined || cmrLabelsSelect(labels, labelSet(row))),
|
|
264
|
+
)
|
|
265
|
+
.map(toDevice);
|
|
266
|
+
};
|
|
267
|
+
const { attributes } = command;
|
|
268
|
+
|
|
269
|
+
if (command.kind === "device") {
|
|
270
|
+
const labels = attributes["labels"]?.trim() || undefined;
|
|
271
|
+
const numbers = attributes["numbers"]?.trim() || undefined;
|
|
272
|
+
if (labels === undefined && numbers === undefined) {
|
|
273
|
+
return scope({ inventory, unresolved: "no `numbers` or `labels`" });
|
|
274
|
+
}
|
|
275
|
+
return scope({ inventory, devices: select(labels, numbers) });
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (command.kind === "trigger") {
|
|
279
|
+
const numbers = attributes["numbers"]?.trim();
|
|
280
|
+
if (!numbers) return scope({ inventory, unresolved: "no `numbers`" });
|
|
281
|
+
const rules = await read("/cmr/upgrade");
|
|
282
|
+
const names = new Set(
|
|
283
|
+
listItems(numbers).flatMap((item) =>
|
|
284
|
+
rules
|
|
285
|
+
.filter((rule) => rowNamed(rule, item, "name"))
|
|
286
|
+
.map((rule) => rule["name"] ?? ""),
|
|
287
|
+
),
|
|
288
|
+
);
|
|
289
|
+
return scope({
|
|
290
|
+
inventory,
|
|
291
|
+
devices: rows
|
|
292
|
+
.filter((row) => names.has(row["upgrade-rule"] ?? ""))
|
|
293
|
+
.map(toDevice),
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// A rule: `add` uses its own selectors; the others act on existing items,
|
|
298
|
+
// and a `set` that changes a selector reaches the old and the new state.
|
|
299
|
+
// Each state is judged on its own: one unscoped rule among several still
|
|
300
|
+
// reaches every device.
|
|
301
|
+
const states: { labels?: string; portLabels?: string }[] = [];
|
|
302
|
+
if (command.verb === "add") {
|
|
303
|
+
states.push({
|
|
304
|
+
labels: attributes["labels"],
|
|
305
|
+
portLabels: attributes["port-labels"],
|
|
306
|
+
});
|
|
307
|
+
} else {
|
|
308
|
+
const numbers = attributes["numbers"]?.trim();
|
|
309
|
+
if (!numbers) return scope({ inventory, unresolved: "no `numbers`" });
|
|
310
|
+
const items = await read(command.menu);
|
|
311
|
+
const named = listItems(numbers).map((item) =>
|
|
312
|
+
items.find((rule) => rowNamed(rule, item, "name")),
|
|
313
|
+
);
|
|
314
|
+
if (named.some((rule) => rule === undefined)) {
|
|
315
|
+
return scope({
|
|
316
|
+
inventory,
|
|
317
|
+
unresolved: `\`numbers=${numbers}\` did not match a ${command.menu} item`,
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
for (const rule of named as Row[]) {
|
|
321
|
+
const old = { labels: rule["labels"], portLabels: rule["port-labels"] };
|
|
322
|
+
states.push(old);
|
|
323
|
+
if (
|
|
324
|
+
command.verb === "set" &&
|
|
325
|
+
("labels" in attributes || "port-labels" in attributes)
|
|
326
|
+
) {
|
|
327
|
+
states.push({
|
|
328
|
+
labels: attributes["labels"] ?? old.labels,
|
|
329
|
+
portLabels: attributes["port-labels"] ?? old.portLabels,
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
const reached = new Map<string, CmrFleetDevice>();
|
|
335
|
+
let everyDevice = false;
|
|
336
|
+
for (const state of states) {
|
|
337
|
+
const labels = deviceLabels(command, state.labels?.trim() ?? "");
|
|
338
|
+
// VLAN: no `labels` but `port-labels` still narrows which ports change.
|
|
339
|
+
const narrowedByPort =
|
|
340
|
+
command.menu === "/cmr/vlan" && (state.portLabels?.trim() ?? "") !== "";
|
|
341
|
+
if (labels.length === 0 && !narrowedByPort) everyDevice = true;
|
|
342
|
+
for (const device of select(labels || "all")) {
|
|
343
|
+
reached.set(device.id, device);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
return scope({ inventory, devices: [...reached.values()], everyDevice });
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* The gate's phrase for a scope, e.g. `2 CMR device(s) through controller
|
|
351
|
+
* ctl, the controller itself among them`.
|
|
352
|
+
*/
|
|
353
|
+
function describeReach(scope: CmrFleetScope, controller: string): string {
|
|
354
|
+
if (scope.devices === undefined) {
|
|
355
|
+
return `the CMR devices it selects through controller ${controller} (not resolved: ${scope.unresolved})`;
|
|
356
|
+
}
|
|
357
|
+
const self = scope.devices.some((device) => device.controller);
|
|
358
|
+
return `${scope.devices.length} CMR device(s) through controller ${controller}${self ? ", the controller itself among them" : ""}`;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** Warnings for a resolved scope: the controller in it, every device, nothing. */
|
|
362
|
+
export function cmrFleetWarnings(scope: CmrFleetScope): Warning[] {
|
|
363
|
+
const warnings: Warning[] = [];
|
|
364
|
+
if (scope.everyDevice) {
|
|
365
|
+
const subject = scope.command.endsWith("/add")
|
|
366
|
+
? `\`${scope.command}\` adds a rule`
|
|
367
|
+
: `\`${scope.command}\` acts on a rule`;
|
|
368
|
+
warnings.push({
|
|
369
|
+
code: "cmr/rule-selects-every-device",
|
|
370
|
+
message: scope.command.startsWith("/cmr/vlan/")
|
|
371
|
+
? `${subject} with no \`labels\` and no \`port-labels\`: it provisions every port of every connected device, the controller included, which cuts the fleet's management access.`
|
|
372
|
+
: `${subject} with no device \`labels\`: it applies to every device the controller manages, the controller included.`,
|
|
373
|
+
context: { command: scope.command, inventory: scope.inventory },
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
if (scope.devices === undefined) return warnings;
|
|
377
|
+
const controller = scope.devices.find((device) => device.controller);
|
|
378
|
+
if (controller && !scope.everyDevice) {
|
|
379
|
+
warnings.push({
|
|
380
|
+
code: "cmr/selection-includes-controller",
|
|
381
|
+
message: `\`${scope.command}\` selects the controller's own row (\`${controller.identity}\`) as well as ${scope.devices.length - 1} other device(s).`,
|
|
382
|
+
context: {
|
|
383
|
+
command: scope.command,
|
|
384
|
+
controller: controller.identity,
|
|
385
|
+
devices: scope.devices.length,
|
|
386
|
+
},
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
if (scope.devices.length === 0) {
|
|
390
|
+
warnings.push({
|
|
391
|
+
code: "cmr/empty-selection",
|
|
392
|
+
message: `\`${scope.command}\` selects no device in the controller's inventory now. Labels are case-sensitive and RouterOS accepts unknown ones silently.`,
|
|
393
|
+
context: { command: scope.command, inventory: scope.inventory },
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
return warnings;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/** The gate's error context for a fleet scope. */
|
|
400
|
+
export function cmrFleetContext(scope: CmrFleetScope): Record<string, unknown> {
|
|
401
|
+
return {
|
|
402
|
+
command: scope.command,
|
|
403
|
+
...(scope.devices === undefined
|
|
404
|
+
? { unresolved: scope.unresolved }
|
|
405
|
+
: {
|
|
406
|
+
devices: scope.devices.length,
|
|
407
|
+
connected: scope.devices.filter((device) => device.connected).length,
|
|
408
|
+
controllerIncluded: scope.devices.some((device) => device.controller),
|
|
409
|
+
identities: scope.devices.map((device) => device.identity),
|
|
410
|
+
}),
|
|
411
|
+
...(scope.inventory === undefined ? {} : { inventory: scope.inventory }),
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** Result of {@link readCmrFleet}: the scope and the warnings it raises. */
|
|
416
|
+
export interface CmrFleetRead {
|
|
417
|
+
scope: CmrFleetScope;
|
|
418
|
+
warnings: Warning[];
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* The write gate's wording for a fleet command: the TTY prompt and the
|
|
423
|
+
* refusal summary name the reach and repeat each warning, since a person
|
|
424
|
+
* answering the prompt never sees the envelope's `warnings`.
|
|
425
|
+
*/
|
|
426
|
+
export function cmrFleetGate(
|
|
427
|
+
fleet: CmrFleetRead,
|
|
428
|
+
controller: string,
|
|
429
|
+
): { prompt: string; summary: string; context: Record<string, unknown> } {
|
|
430
|
+
const reach = describeReach(fleet.scope, controller);
|
|
431
|
+
const notes = fleet.warnings
|
|
432
|
+
.filter((warning) => warning.code !== "cmr/selection-includes-controller")
|
|
433
|
+
.map((warning) => ` ${warning.message}`)
|
|
434
|
+
.join("");
|
|
435
|
+
return {
|
|
436
|
+
prompt: `Run write-shaped \`${fleet.scope.command}\` on ${reach}?${notes} Type yes to continue: `,
|
|
437
|
+
summary: `A write-shaped \`${fleet.scope.command}\` would reach ${reach}, and needs explicit confirmation.${notes}`,
|
|
438
|
+
context: { cmr: cmrFleetContext(fleet.scope) },
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/** Whether an adapter for this transport can do the structured scope read. */
|
|
443
|
+
export function cmrFleetReadable(via: string): boolean {
|
|
444
|
+
return via === "native-api" || via === "rest-api";
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Read a fleet command's scope (and, for `run-script`/`reboot`, the caller's
|
|
449
|
+
* policy) on the controller. Best effort: a failed read leaves the scope
|
|
450
|
+
* unresolved and the gate as strict as before.
|
|
451
|
+
*/
|
|
452
|
+
export async function readCmrFleet(
|
|
453
|
+
command: CmrFleetCommand,
|
|
454
|
+
adapter: Pick<ProtocolAdapter, "apiRequest">,
|
|
455
|
+
username: string | undefined,
|
|
456
|
+
): Promise<CmrFleetRead> {
|
|
457
|
+
const read = async (menu: string) => {
|
|
458
|
+
const result = await adapter.apiRequest({ verb: "print", path: menu });
|
|
459
|
+
return Array.isArray(result.data) ? (result.data as Row[]) : [];
|
|
460
|
+
};
|
|
461
|
+
let scope: CmrFleetScope;
|
|
462
|
+
let unreachable = false;
|
|
463
|
+
try {
|
|
464
|
+
scope = await resolveCmrFleetScope(command, read);
|
|
465
|
+
} catch (error) {
|
|
466
|
+
const code =
|
|
467
|
+
error instanceof Error && "code" in error ? String(error.code) : "error";
|
|
468
|
+
// The policy read would wait out the same dead controller (or login).
|
|
469
|
+
unreachable = code.startsWith("transport/") || code.startsWith("auth/");
|
|
470
|
+
scope = {
|
|
471
|
+
command: command.command,
|
|
472
|
+
unresolved: `the controller read failed (${code})`,
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
const warnings = cmrFleetWarnings(scope);
|
|
476
|
+
const runsWith = RUNS_WITH[command.command];
|
|
477
|
+
if (runsWith !== undefined && username && !unreachable) {
|
|
478
|
+
const caller: CmrCallerPolicy | undefined = await readCmrCallerPolicy(
|
|
479
|
+
adapter,
|
|
480
|
+
username,
|
|
481
|
+
);
|
|
482
|
+
const warning = caller && cmrCallerPolicyWarning(caller, runsWith);
|
|
483
|
+
if (warning) warnings.push(warning);
|
|
484
|
+
}
|
|
485
|
+
return { scope, warnings };
|
|
486
|
+
}
|
package/src/core/cmr.ts
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
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 { ProtocolAdapter } from "../protocols/adapter.ts";
|
|
8
|
+
import type { Warning } from "./envelope.ts";
|
|
9
|
+
|
|
10
|
+
/** The controller command that runs a script on CMR clients. */
|
|
11
|
+
export const CMR_RUN_SCRIPT_PATH = "/cmr/device/run-script";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The console-only live view. Grounded on 7.26beta1 CHR: over the native API
|
|
15
|
+
* `/cmr/device/dashboard` closed the API session; over REST it never returned,
|
|
16
|
+
* and every other REST call on the controller then waited 10–30 s.
|
|
17
|
+
*/
|
|
18
|
+
const CMR_DASHBOARD = ["cmr", "device", "dashboard"] as const;
|
|
19
|
+
const DASHBOARD_PATH = "/cmr/device/dashboard";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Whether a structured RouterOS path (an `api` endpoint, or a single
|
|
23
|
+
* `execute` command's path and verb) is the dashboard. Each segment may be an
|
|
24
|
+
* abbreviation, as RouterOS accepts (`/cmr/dev/dash`). This needs no router, so
|
|
25
|
+
* it refuses the plain spellings before anything is dialed.
|
|
26
|
+
*/
|
|
27
|
+
export function isCmrDashboardPath(path: string): boolean {
|
|
28
|
+
const segments = path
|
|
29
|
+
.split("/")
|
|
30
|
+
.filter((segment) => segment.length > 0)
|
|
31
|
+
.map((segment) => segment.toLowerCase());
|
|
32
|
+
return (
|
|
33
|
+
segments.length === CMR_DASHBOARD.length &&
|
|
34
|
+
segments.every((segment, k) =>
|
|
35
|
+
(CMR_DASHBOARD[k] as string).startsWith(segment),
|
|
36
|
+
)
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Whether a `:parse` return invokes the dashboard. The device resolves every
|
|
42
|
+
* spelling to one invocation: abbreviations, console spacing, a relative menu
|
|
43
|
+
* change across `;` or a newline, and `:do`/`:if`/`[...]` bodies. Strings stay
|
|
44
|
+
* `=` values and comments are dropped (7.26beta1 CHR). An invocation follows
|
|
45
|
+
* the start of the IL, a `;` or another `(evl `. A string value sits after `=`.
|
|
46
|
+
*/
|
|
47
|
+
export function ilInvokesCmrDashboard(il: string): boolean {
|
|
48
|
+
return /(?:^|;|\(evl )\(evl \/cmr\/device\/dashboard/.test(il);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function consoleOnlyError(input: string): CentrsError {
|
|
52
|
+
return new CentrsError({
|
|
53
|
+
code: "cmr/console-only",
|
|
54
|
+
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.`,
|
|
55
|
+
remediation:
|
|
56
|
+
"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`.",
|
|
57
|
+
context: { path: DASHBOARD_PATH, input },
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Refuse a structured dashboard path before anything is dialed. */
|
|
62
|
+
export function assertNotCmrDashboardPath(path: string): void {
|
|
63
|
+
if (isCmrDashboardPath(path)) throw consoleOnlyError(path);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Refuse a command whose accepted `:parse` return invokes the dashboard. */
|
|
67
|
+
export function assertNotCmrDashboardIl(il: string, command: string): void {
|
|
68
|
+
if (ilInvokesCmrDashboard(il)) throw consoleOnlyError(command);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function withoutTrailingSlashes(path: string): string {
|
|
72
|
+
// A loop, not `/\/+$/`: that regex is quadratic on a long run of `/`.
|
|
73
|
+
let end = path.length;
|
|
74
|
+
while (end > 0 && path[end - 1] === "/") end -= 1;
|
|
75
|
+
return path.slice(0, end);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const clientScriptNotValidated: Warning = {
|
|
79
|
+
code: "cmr/client-script-not-validated",
|
|
80
|
+
message:
|
|
81
|
+
"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.",
|
|
82
|
+
context: { path: CMR_RUN_SCRIPT_PATH },
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* A caller-addressed `run-script` is validated as a controller request only:
|
|
87
|
+
* `/console/inspect` checks its arguments, and the `script=` value is an opaque
|
|
88
|
+
* string to the controller's `:parse`. The script itself then runs without validation
|
|
89
|
+
* on every selected client, so say so next to the `passed` gate.
|
|
90
|
+
*/
|
|
91
|
+
export function cmrRunScriptWarnings(commandPath: string): Warning[] {
|
|
92
|
+
if (withoutTrailingSlashes(commandPath) !== CMR_RUN_SCRIPT_PATH) return [];
|
|
93
|
+
return [clientScriptNotValidated];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Script-shaped `execute` input is opaque to the canonicalizer, so a
|
|
98
|
+
* `run-script` inside it (console spacing, a `:do {}` body) is found by text.
|
|
99
|
+
* A false positive costs only a warning.
|
|
100
|
+
*/
|
|
101
|
+
export function cmrScriptWarnings(script: string): Warning[] {
|
|
102
|
+
return /cmr[\s/]+device[\s/]+run-script\b/.test(script)
|
|
103
|
+
? [clientScriptNotValidated]
|
|
104
|
+
: [];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The policies CMR gives every `run-script` script on a client (manual; 7.26beta1). */
|
|
108
|
+
export const CMR_SCRIPT_POLICIES = ["read", "write", "test", "reboot"] as const;
|
|
109
|
+
|
|
110
|
+
/** The controller login's own group and its granted policies. */
|
|
111
|
+
export interface CmrCallerPolicy {
|
|
112
|
+
username: string;
|
|
113
|
+
group: string;
|
|
114
|
+
/** Granted policy names; `!name` entries are dropped. */
|
|
115
|
+
policy: readonly string[];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Read the session user's group policy on the controller, or `undefined` when
|
|
120
|
+
* it cannot be read (a RADIUS login has no `/user` row). Grounded on
|
|
121
|
+
* 7.26beta1 over REST and native API: a login with only `read` and `api`
|
|
122
|
+
* (`!sensitive !policy` too) can read its own `/user` row and `/user/group`.
|
|
123
|
+
*/
|
|
124
|
+
export async function readCmrCallerPolicy(
|
|
125
|
+
adapter: Pick<ProtocolAdapter, "apiRequest">,
|
|
126
|
+
username: string,
|
|
127
|
+
): Promise<CmrCallerPolicy | undefined> {
|
|
128
|
+
const first = async (path: string, name: string) => {
|
|
129
|
+
const result = await adapter.apiRequest({
|
|
130
|
+
verb: "print",
|
|
131
|
+
path,
|
|
132
|
+
query: [`name=${name}`],
|
|
133
|
+
});
|
|
134
|
+
return Array.isArray(result.data)
|
|
135
|
+
? (result.data[0] as Record<string, string> | undefined)
|
|
136
|
+
: undefined;
|
|
137
|
+
};
|
|
138
|
+
try {
|
|
139
|
+
const group = (await first("/user", username))?.["group"];
|
|
140
|
+
if (!group) return undefined;
|
|
141
|
+
const policy = (await first("/user/group", group))?.["policy"];
|
|
142
|
+
if (policy === undefined) return undefined;
|
|
143
|
+
return {
|
|
144
|
+
username,
|
|
145
|
+
group,
|
|
146
|
+
policy: policy
|
|
147
|
+
.split(",")
|
|
148
|
+
.map((name) => name.trim())
|
|
149
|
+
.filter((name) => name.length > 0 && !name.startsWith("!")),
|
|
150
|
+
};
|
|
151
|
+
} catch {
|
|
152
|
+
return undefined;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* #456: RouterOS does not hold a CMR fleet command to the caller's policy. On
|
|
158
|
+
* 7.26beta1 `run-script` needs only `read` + `api` on the controller login,
|
|
159
|
+
* yet the script runs on each client (the controller's own row included) with
|
|
160
|
+
* {@link CMR_SCRIPT_POLICIES}. A "reader" login wrote and rebooted clients
|
|
161
|
+
* through it. Warn when the login lacks a policy the command will run with,
|
|
162
|
+
* so a caller does not take the login for the limit; centrs' own write gate
|
|
163
|
+
* is the only one. MikroTik says 7.26beta2 refuses this for a `read` group
|
|
164
|
+
* (forum topic 273582, post 139); not yet checked on a beta2 build.
|
|
165
|
+
*/
|
|
166
|
+
export function cmrCallerPolicyWarning(
|
|
167
|
+
caller: CmrCallerPolicy,
|
|
168
|
+
runsWith: readonly string[],
|
|
169
|
+
): Warning | undefined {
|
|
170
|
+
const missing = runsWith.filter((name) => !caller.policy.includes(name));
|
|
171
|
+
if (missing.length === 0) return undefined;
|
|
172
|
+
return {
|
|
173
|
+
code: "cmr/caller-policy-not-enforced",
|
|
174
|
+
message: `The controller login \`${caller.username}\` (group \`${caller.group}\`) lacks ${missing.join(", ")}, but CMR runs this on the clients with ${runsWith.join(", ")}. RouterOS 7.26beta1 does not check the caller's policy here, so the login does not limit what a CMR command can change (MikroTik says 7.26beta2 will).`,
|
|
175
|
+
context: {
|
|
176
|
+
username: caller.username,
|
|
177
|
+
group: caller.group,
|
|
178
|
+
missing,
|
|
179
|
+
runsWith: [...runsWith],
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
}
|