@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/core/cmr.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { CentrsError } from "../errors.ts";
|
|
7
|
+
import type { ProtocolAdapter } from "../protocols/adapter.ts";
|
|
7
8
|
import type { Warning } from "./envelope.ts";
|
|
8
9
|
|
|
9
10
|
/** The controller command that runs a script on CMR clients. */
|
|
@@ -102,3 +103,80 @@ export function cmrScriptWarnings(script: string): Warning[] {
|
|
|
102
103
|
? [clientScriptNotValidated]
|
|
103
104
|
: [];
|
|
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
|
+
}
|
package/src/core/envelope.ts
CHANGED
|
@@ -15,6 +15,10 @@
|
|
|
15
15
|
* - `meta` is invariant across commands. Only `data` and `meta.operation` vary
|
|
16
16
|
* per command. Do not add command-specific keys beside `target`/`via`/
|
|
17
17
|
* `settings`/`validation`/`timing`/`truncated`; put them in `meta.operation`.
|
|
18
|
+
* - Shared meta is written once (#461, #462, #473). A fan-out target omits
|
|
19
|
+
* what its outer envelope already says ({@link FanoutMemberOperation}); a
|
|
20
|
+
* stream's data lines between `started` and `summary` carry only their
|
|
21
|
+
* `meta.operation` ({@link StreamLineEnvelope}).
|
|
18
22
|
* - CLI render must be lossless against the JSON envelope; `--format json|yaml`
|
|
19
23
|
* select serialization, not different shapes. Omit `undefined`; use `null`
|
|
20
24
|
* only where absence is meaningful and must round-trip (e.g. `via: null` when
|
|
@@ -112,10 +116,10 @@ export interface EnvelopeTargetMeta {
|
|
|
112
116
|
/**
|
|
113
117
|
* `execute --cmr`: the CMR client this result belongs to, as the controller
|
|
114
118
|
* 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
|
|
119
|
+
* device half of its `ids`. The client is never dialed directly; the
|
|
120
|
+
* controller is the fan-out's outer `meta.target`.
|
|
116
121
|
*/
|
|
117
122
|
cmr?: {
|
|
118
|
-
controller: string;
|
|
119
123
|
id: string;
|
|
120
124
|
deviceKey?: string;
|
|
121
125
|
address?: string;
|
|
@@ -287,9 +291,34 @@ export interface FanoutSummary {
|
|
|
287
291
|
failed: number;
|
|
288
292
|
}
|
|
289
293
|
|
|
294
|
+
/**
|
|
295
|
+
* A fan-out target's `meta.operation`. `request` and `auth` are omitted when
|
|
296
|
+
* they equal the outer `meta.operation`'s, and the target's `meta.settings`
|
|
297
|
+
* keeps only the keys whose source differs from the outer `meta.settings`, so
|
|
298
|
+
* a reader takes `target ?? outer` (per key for settings).
|
|
299
|
+
*/
|
|
300
|
+
export type FanoutMemberOperation<Operation> = Omit<
|
|
301
|
+
Operation,
|
|
302
|
+
"request" | "auth"
|
|
303
|
+
> &
|
|
304
|
+
Partial<Pick<Operation, Extract<keyof Operation, "request" | "auth">>>;
|
|
305
|
+
|
|
290
306
|
export interface FanoutData<Data = unknown, Operation = unknown> {
|
|
291
307
|
summary: FanoutSummary;
|
|
292
|
-
targets: readonly CentrsEnvelope<Data, Operation
|
|
308
|
+
targets: readonly CentrsEnvelope<Data, FanoutMemberOperation<Operation>>[];
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* A stream data line that carries only its own `meta.operation`; the rest of
|
|
313
|
+
* its meta is on the stream's `started` and `summary` lines (and `synced` for
|
|
314
|
+
* `retrieve --follow`).
|
|
315
|
+
*/
|
|
316
|
+
export interface StreamLineEnvelope<Data = unknown, Operation = unknown> {
|
|
317
|
+
ok: true;
|
|
318
|
+
data: Data;
|
|
319
|
+
warnings: readonly Warning[];
|
|
320
|
+
tips: readonly Tip[];
|
|
321
|
+
meta: { operation: Operation };
|
|
293
322
|
}
|
|
294
323
|
|
|
295
324
|
/** Normalize an optional warnings list to a readonly array. */
|
|
@@ -55,11 +55,13 @@ export const errorCatalog: readonly ErrorCatalogEntry[] = [
|
|
|
55
55
|
},
|
|
56
56
|
{
|
|
57
57
|
code: "cdb/empty-group",
|
|
58
|
-
summary:
|
|
58
|
+
summary:
|
|
59
|
+
"The named CDB group contains no records: a warning for a read, an error for a write-shaped fan-out.",
|
|
59
60
|
},
|
|
60
61
|
{
|
|
61
62
|
code: "cdb/empty-selection",
|
|
62
|
-
summary:
|
|
63
|
+
summary:
|
|
64
|
+
"The target selection matched no CDB records: a warning for a read, an error for a write-shaped fan-out.",
|
|
63
65
|
},
|
|
64
66
|
{
|
|
65
67
|
code: "cdb/file-exists",
|
|
@@ -268,6 +270,11 @@ export const errorCatalog: readonly ErrorCatalogEntry[] = [
|
|
|
268
270
|
summary:
|
|
269
271
|
"The operation was cancelled before the command was sent to a CMR client.",
|
|
270
272
|
},
|
|
273
|
+
{
|
|
274
|
+
code: "cmr/caller-policy-not-enforced",
|
|
275
|
+
summary:
|
|
276
|
+
"Warning: the controller login lacks a policy the CMR command runs with on the clients, and RouterOS does not check it.",
|
|
277
|
+
},
|
|
271
278
|
{
|
|
272
279
|
code: "cmr/client-script-not-validated",
|
|
273
280
|
summary:
|
|
@@ -281,7 +288,7 @@ export const errorCatalog: readonly ErrorCatalogEntry[] = [
|
|
|
281
288
|
{
|
|
282
289
|
code: "cmr/empty-selection",
|
|
283
290
|
summary:
|
|
284
|
-
"
|
|
291
|
+
"The CMR selection matched no device on the controller: for `--cmr`, a warning for a read and an error for a write; for a fleet command such as `/cmr/vlan/add`, a warning.",
|
|
285
292
|
},
|
|
286
293
|
{
|
|
287
294
|
code: "cmr/membership-changed",
|
|
@@ -313,6 +320,16 @@ export const errorCatalog: readonly ErrorCatalogEntry[] = [
|
|
|
313
320
|
summary:
|
|
314
321
|
"A CMR client refused a statement because CMR scripts run without the policy it needs; earlier statements already ran.",
|
|
315
322
|
},
|
|
323
|
+
{
|
|
324
|
+
code: "cmr/rule-selects-every-device",
|
|
325
|
+
summary:
|
|
326
|
+
"Warning: a CMR rule command has no device `labels` (and, for VLAN, no `port-labels`), so the rule applies to every device, the controller included.",
|
|
327
|
+
},
|
|
328
|
+
{
|
|
329
|
+
code: "cmr/selection-includes-controller",
|
|
330
|
+
summary:
|
|
331
|
+
"Warning: a CMR fleet command selects the controller's own row as well as its clients.",
|
|
332
|
+
},
|
|
316
333
|
{
|
|
317
334
|
code: "cmr/selection-incomplete",
|
|
318
335
|
summary:
|
|
@@ -611,6 +628,11 @@ export const errorCatalog: readonly ErrorCatalogEntry[] = [
|
|
|
611
628
|
code: "usage/conflicting-flags",
|
|
612
629
|
summary: "Two or more supplied flags conflict.",
|
|
613
630
|
},
|
|
631
|
+
{
|
|
632
|
+
code: "usage/flag-after-separator",
|
|
633
|
+
summary:
|
|
634
|
+
"A centrs flag was written after `--`, where every token is RouterOS input.",
|
|
635
|
+
},
|
|
614
636
|
{
|
|
615
637
|
code: "usage/invalid-concurrency",
|
|
616
638
|
summary: "The --concurrency value must be an integer >= 1.",
|
|
@@ -101,7 +101,7 @@ export function canonicalizeExecuteCommand(
|
|
|
101
101
|
|
|
102
102
|
export function isWriteShaped(command: CanonicalExecuteCommand): boolean {
|
|
103
103
|
if (command.mode === "structured") {
|
|
104
|
-
if (
|
|
104
|
+
if (Object.keys(command.attributes).some(isFileOutputAttribute)) {
|
|
105
105
|
return true;
|
|
106
106
|
}
|
|
107
107
|
return !READ_ONLY_EXECUTE_VERBS.has(command.verb.toLowerCase());
|
|
@@ -110,21 +110,35 @@ export function isWriteShaped(command: CanonicalExecuteCommand): boolean {
|
|
|
110
110
|
}
|
|
111
111
|
|
|
112
112
|
/**
|
|
113
|
-
* The
|
|
113
|
+
* The attributes that turn an otherwise read-only command into a device write.
|
|
114
114
|
*
|
|
115
115
|
* RouterOS routes command output to a file on the device when `file=` is
|
|
116
116
|
* supplied, so a read verb creates a file: `/ip/address/export file=address`
|
|
117
117
|
* writes `address.rsc`, and `/file/print file=test` writes `test.txt` (both
|
|
118
118
|
* from the RouterOS manual — "Configuration Export" and "Scripting Tips and
|
|
119
119
|
* Tricks", where that idiom is the documented way to create a file and needs
|
|
120
|
-
* the script `write` policy).
|
|
121
|
-
*
|
|
120
|
+
* the script `write` policy). `:serialize` spells it `file-name=` (its
|
|
121
|
+
* `/console/inspect` arguments, 7.13.5 through 7.26beta1). The verb and
|
|
122
|
+
* directive allowlists cannot see either, so they are checked independently.
|
|
122
123
|
*/
|
|
123
|
-
const
|
|
124
|
+
const FILE_OUTPUT_ATTRIBUTES: readonly string[] = ["file", "file-name"];
|
|
124
125
|
|
|
125
|
-
/**
|
|
126
|
+
/**
|
|
127
|
+
* RouterOS accepts argument prefixes (`fi=`, `file-n=`), including on
|
|
128
|
+
* `:serialize` (CHR 7.26beta1). An ambiguous prefix stays write-shaped too:
|
|
129
|
+
* only the device can disambiguate it, so the local gate fails closed.
|
|
130
|
+
*/
|
|
131
|
+
function isFileOutputAttribute(name: string): boolean {
|
|
132
|
+
return (
|
|
133
|
+
name.length > 0 &&
|
|
134
|
+
FILE_OUTPUT_ATTRIBUTES.some((full) => full.startsWith(name))
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** True for an output-file argument, including its abbreviated spellings. */
|
|
126
139
|
function isFileOutputToken(token: string): boolean {
|
|
127
|
-
|
|
140
|
+
const separator = token.indexOf("=");
|
|
141
|
+
return separator > 0 && isFileOutputAttribute(token.slice(0, separator));
|
|
128
142
|
}
|
|
129
143
|
|
|
130
144
|
/**
|
|
@@ -193,27 +207,59 @@ const WRITE_SHAPED_SCRIPT_DIRECTIVES: ReadonlySet<string> = new Set([
|
|
|
193
207
|
/**
|
|
194
208
|
* Script directives that are safe to run without device-write confirmation:
|
|
195
209
|
* output, control flow and pure value functions, all members of `explain`'s
|
|
196
|
-
* grounded root-cmd vocabulary (`ROOT_CMDS` in `src/explain/write.ts`)
|
|
197
|
-
* directive's `do={…}` or `[…]` body is its own
|
|
198
|
-
* its own.
|
|
199
|
-
* `:
|
|
200
|
-
*
|
|
210
|
+
* grounded root-cmd vocabulary (`ROOT_CMDS` in `src/explain/write.ts`) and
|
|
211
|
+
* present on 7.26beta1. A directive's `do={…}` or `[…]` body is its own
|
|
212
|
+
* command head and is checked on its own. That holds because a script-typed
|
|
213
|
+
* argument (`:do command=`, `:if do=`, `:retry command=`, `:time`, `:onerror
|
|
214
|
+
* in=`) runs only a `{…}` block: RouterOS refuses a quoted string or a variable
|
|
215
|
+
* holding one there as an invalid value (CHR 7.24.5 and 7.26beta1, #458), so
|
|
216
|
+
* no body can hide in text the gate blanks. The value functions (#458) take
|
|
217
|
+
* only values; `:serialize` alone can write, through `file-name=` (it creates
|
|
218
|
+
* the file on 7.26beta1), which the file-output check catches.
|
|
219
|
+
*
|
|
220
|
+
* Deliberately absent: `:execute` and `:parse` (run built code), `:global` and
|
|
221
|
+
* `:set` (outlive the script), and `:import`, `:password`, `:undo`, `:redo`
|
|
222
|
+
* (configuration changes). A directive missing here costs `--yes`; one wrongly
|
|
223
|
+
* here bypasses the gate.
|
|
201
224
|
*/
|
|
202
225
|
const READ_ONLY_SCRIPT_DIRECTIVES: ReadonlySet<string> = new Set([
|
|
203
226
|
":break",
|
|
204
227
|
":continue",
|
|
228
|
+
":convert",
|
|
229
|
+
":delay",
|
|
230
|
+
":deserialize",
|
|
205
231
|
":do",
|
|
206
232
|
":error",
|
|
233
|
+
":find",
|
|
207
234
|
":for",
|
|
208
235
|
":foreach",
|
|
209
236
|
":if",
|
|
237
|
+
":jobname",
|
|
210
238
|
":len",
|
|
211
239
|
":local",
|
|
240
|
+
":nothing",
|
|
212
241
|
":onerror",
|
|
213
242
|
":pick",
|
|
214
243
|
":put",
|
|
244
|
+
":range",
|
|
245
|
+
":retry",
|
|
215
246
|
":return",
|
|
247
|
+
":rndnum",
|
|
248
|
+
":rndstr",
|
|
249
|
+
":serialize",
|
|
250
|
+
":time",
|
|
251
|
+
":timestamp",
|
|
252
|
+
":toarray",
|
|
253
|
+
":tobool",
|
|
254
|
+
":tocrlf",
|
|
255
|
+
":toid",
|
|
256
|
+
":toip",
|
|
257
|
+
":toip6",
|
|
258
|
+
":tolf",
|
|
259
|
+
":tonsec",
|
|
260
|
+
":tonum",
|
|
216
261
|
":tostr",
|
|
262
|
+
":totime",
|
|
217
263
|
":typeof",
|
|
218
264
|
":while",
|
|
219
265
|
]);
|
|
@@ -249,7 +295,11 @@ function scriptContainsWriteShapedCommand(input: string): boolean {
|
|
|
249
295
|
// Every command the statement runs must be proven read-only, not just the
|
|
250
296
|
// first: `:foreach i in=[/ip address find] do={/user expire-password $i}`
|
|
251
297
|
// reads with `find` and writes with an unlearned mutator.
|
|
252
|
-
if (
|
|
298
|
+
if (
|
|
299
|
+
!commandHeads(statement).every(
|
|
300
|
+
(head) => !head.spacedFileOutput && isReadOnlyCommand(head.words),
|
|
301
|
+
)
|
|
302
|
+
) {
|
|
253
303
|
return true;
|
|
254
304
|
}
|
|
255
305
|
}
|
|
@@ -264,12 +314,23 @@ function scriptContainsWriteShapedCommand(input: string): boolean {
|
|
|
264
314
|
* expressions, not commands, so they do not start one. Each head reads only up
|
|
265
315
|
* to the next opener, which begins its own head.
|
|
266
316
|
*/
|
|
267
|
-
function commandHeads(
|
|
268
|
-
|
|
317
|
+
function commandHeads(
|
|
318
|
+
statement: string,
|
|
319
|
+
): { words: string[]; spacedFileOutput: boolean }[] {
|
|
320
|
+
const heads: { words: string[]; spacedFileOutput: boolean }[] = [];
|
|
269
321
|
const starts = [0];
|
|
322
|
+
// Match group ends once. Each command can then skip its nested bodies
|
|
323
|
+
// without walking them again; deeply nested reads stay linear in input size.
|
|
324
|
+
const closes = new Map<number, number>();
|
|
325
|
+
const open: number[] = [];
|
|
270
326
|
for (let index = 0; index < statement.length; index += 1) {
|
|
271
|
-
const char = statement[index];
|
|
327
|
+
const char = statement[index] ?? "";
|
|
272
328
|
if (char === "[" || char === "{") starts.push(index + 1);
|
|
329
|
+
if ("[{(".includes(char)) open.push(index);
|
|
330
|
+
else if ("]})".includes(char)) {
|
|
331
|
+
const start = open.pop();
|
|
332
|
+
if (start !== undefined) closes.set(start, index);
|
|
333
|
+
}
|
|
273
334
|
}
|
|
274
335
|
for (const start of starts) {
|
|
275
336
|
const nextOpener = statement.slice(start).search(/[[{(]/);
|
|
@@ -282,11 +343,58 @@ function commandHeads(statement: string): string[][] {
|
|
|
282
343
|
if (head === undefined) continue;
|
|
283
344
|
const words = commandWords(head, tokens.slice(1));
|
|
284
345
|
// A lone closer (`}` ending a multi-line block) runs nothing.
|
|
285
|
-
if (words.length > 0)
|
|
346
|
+
if (words.length > 0)
|
|
347
|
+
heads.push({
|
|
348
|
+
words,
|
|
349
|
+
spacedFileOutput: hasSpacedFileOutput(statement, start, words, closes),
|
|
350
|
+
});
|
|
286
351
|
}
|
|
287
352
|
return heads;
|
|
288
353
|
}
|
|
289
354
|
|
|
355
|
+
/**
|
|
356
|
+
* RouterOS also accepts `file value` / `file-name value` without `=`. These
|
|
357
|
+
* belong to print/export/serialize, not a loop variable or a pure function's
|
|
358
|
+
* value. Read the command's own tokens, skipping nested expressions and
|
|
359
|
+
* blocks: `:serialize value=[:tostr file] file-n "out"` has one file argument
|
|
360
|
+
* after its nested read. Quoted text is already masked by the statement split.
|
|
361
|
+
* Grounded on CHR 7.26beta1, with export also checked on long-term.
|
|
362
|
+
*/
|
|
363
|
+
function hasSpacedFileOutput(
|
|
364
|
+
statement: string,
|
|
365
|
+
start: number,
|
|
366
|
+
words: readonly string[],
|
|
367
|
+
closes: ReadonlyMap<number, number>,
|
|
368
|
+
): boolean {
|
|
369
|
+
let commandWords: number;
|
|
370
|
+
if (words[0] === ":serialize") {
|
|
371
|
+
commandWords = 1;
|
|
372
|
+
} else {
|
|
373
|
+
const depth = menuDepth(words);
|
|
374
|
+
if (words[depth] !== "print" && words[depth] !== "export") return false;
|
|
375
|
+
commandWords = depth + 1;
|
|
376
|
+
}
|
|
377
|
+
let own = "";
|
|
378
|
+
for (let index = start; index < statement.length; index += 1) {
|
|
379
|
+
const char = statement[index] ?? "";
|
|
380
|
+
if ("[{(".includes(char)) {
|
|
381
|
+
own += " ";
|
|
382
|
+
index = closes.get(index) ?? statement.length;
|
|
383
|
+
} else if ("]})".includes(char)) {
|
|
384
|
+
break;
|
|
385
|
+
} else own += char;
|
|
386
|
+
}
|
|
387
|
+
let consumed = 0;
|
|
388
|
+
for (const token of tokenizeRouterOsCli(own)) {
|
|
389
|
+
if (consumed < commandWords) {
|
|
390
|
+
consumed += token.split("/").filter(Boolean).length;
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
393
|
+
if (!token.includes("=") && isFileOutputAttribute(token)) return true;
|
|
394
|
+
}
|
|
395
|
+
return false;
|
|
396
|
+
}
|
|
397
|
+
|
|
290
398
|
/**
|
|
291
399
|
* The path segments and verb of one command, from its head token and the bare
|
|
292
400
|
* words after it. The space-separated spelling puts menu segments and the verb
|
|
@@ -299,6 +407,19 @@ function commandWords(head: string, following: readonly string[]): string[] {
|
|
|
299
407
|
const words: string[] = [];
|
|
300
408
|
const headWord = trimGroupingPunctuation(head);
|
|
301
409
|
if (headWord.startsWith(":")) return [headWord];
|
|
410
|
+
// `/serialize …` is the root command `:serialize`: at statement start the
|
|
411
|
+
// sigil is optional and `/` binds the root vocabulary (CHR 7.23.2, see
|
|
412
|
+
// `src/explain/symbols.ts`). Only the absolute one-segment form is read
|
|
413
|
+
// this way. A bare `delay` may be a relative menu command after earlier
|
|
414
|
+
// navigation, so it keeps the menu-verb rule and fails closed.
|
|
415
|
+
const root = /^\/([A-Za-z0-9-]+)$/.exec(headWord)?.[1]?.toLowerCase();
|
|
416
|
+
if (
|
|
417
|
+
root !== undefined &&
|
|
418
|
+
READ_ONLY_SCRIPT_DIRECTIVES.has(`:${root}`) &&
|
|
419
|
+
!isKnownMenuPath([root])
|
|
420
|
+
) {
|
|
421
|
+
return [`:${root}`];
|
|
422
|
+
}
|
|
302
423
|
words.push(...headWord.split("/").filter(Boolean));
|
|
303
424
|
if (headWord !== head) return words;
|
|
304
425
|
for (const token of following) {
|
|
@@ -325,12 +446,16 @@ function commandWords(head: string, following: readonly string[]): string[] {
|
|
|
325
446
|
function isReadOnlyCommand(words: readonly string[]): boolean {
|
|
326
447
|
const head = words[0] ?? "";
|
|
327
448
|
if (head.startsWith(":")) return READ_ONLY_SCRIPT_DIRECTIVES.has(head);
|
|
449
|
+
const verb = words[menuDepth(words)];
|
|
450
|
+
return verb !== undefined && READ_ONLY_EXECUTE_VERBS.has(verb.toLowerCase());
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function menuDepth(words: readonly string[]): number {
|
|
328
454
|
let depth = 0;
|
|
329
455
|
while (depth < words.length && isKnownMenuPath(words.slice(0, depth + 1))) {
|
|
330
456
|
depth += 1;
|
|
331
457
|
}
|
|
332
|
-
|
|
333
|
-
return verb !== undefined && READ_ONLY_EXECUTE_VERBS.has(verb.toLowerCase());
|
|
458
|
+
return depth;
|
|
334
459
|
}
|
|
335
460
|
|
|
336
461
|
function trimGroupingPunctuation(value: string): string {
|
package/src/core/fanout.ts
CHANGED
|
@@ -20,7 +20,9 @@ import type {
|
|
|
20
20
|
CommonSettingsMeta,
|
|
21
21
|
EnvelopeTargetMeta,
|
|
22
22
|
FanoutData,
|
|
23
|
+
FanoutMemberOperation,
|
|
23
24
|
FanoutSummary,
|
|
25
|
+
SettingSource,
|
|
24
26
|
ValidationTrace,
|
|
25
27
|
Warning,
|
|
26
28
|
} from "./envelope.ts";
|
|
@@ -310,29 +312,151 @@ export function commonVia(
|
|
|
310
312
|
return via ?? null;
|
|
311
313
|
}
|
|
312
314
|
|
|
315
|
+
/** The part of an outer fan-out `operation` its targets may share. */
|
|
316
|
+
interface SharedOperation {
|
|
317
|
+
request?: unknown;
|
|
318
|
+
auth?: unknown;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Write shared meta once (#461): the outer envelope keeps the run's settings,
|
|
323
|
+
* request and auth, and each target keeps only what differs from them. A
|
|
324
|
+
* settings key the outer envelope lacks, and the `auth`, move out when every
|
|
325
|
+
* target that reached an operation agrees on them. A target's `settings`
|
|
326
|
+
* keeps the keys whose source differs from the outer key's; its
|
|
327
|
+
* `request`/`auth` are dropped when equal to the outer ones.
|
|
328
|
+
*/
|
|
329
|
+
export function compactFanoutTargets<Data, Op, OuterOp extends object>(
|
|
330
|
+
settings: CommonSettingsMeta,
|
|
331
|
+
operation: OuterOp,
|
|
332
|
+
targets: readonly CentrsEnvelope<Data, Op>[],
|
|
333
|
+
): {
|
|
334
|
+
settings: CommonSettingsMeta;
|
|
335
|
+
operation: OuterOp;
|
|
336
|
+
targets: CentrsEnvelope<Data, FanoutMemberOperation<Op>>[];
|
|
337
|
+
} {
|
|
338
|
+
// A target that failed before it had an operation (a resolve failure)
|
|
339
|
+
// has no settings or auth to agree or disagree with.
|
|
340
|
+
const reached = targets.flatMap((target) => {
|
|
341
|
+
const inner = target.meta.operation as SharedOperation | undefined;
|
|
342
|
+
return inner ? [{ settings: target.meta.settings, auth: inner.auth }] : [];
|
|
343
|
+
});
|
|
344
|
+
const agreed = (value: (member: (typeof reached)[number]) => unknown) => {
|
|
345
|
+
const first = reached[0] && value(reached[0]);
|
|
346
|
+
return first !== undefined &&
|
|
347
|
+
reached.every((member) => Bun.deepEquals(value(member), first))
|
|
348
|
+
? first
|
|
349
|
+
: undefined;
|
|
350
|
+
};
|
|
351
|
+
const outerSettings: CommonSettingsMeta = { ...settings };
|
|
352
|
+
for (const key of Object.keys(reached[0]?.settings ?? {})) {
|
|
353
|
+
if (outerSettings[key] !== undefined) continue;
|
|
354
|
+
const source = agreed((member) => member.settings[key]);
|
|
355
|
+
if (source !== undefined) outerSettings[key] = source as SettingSource;
|
|
356
|
+
}
|
|
357
|
+
const outer = operation as SharedOperation;
|
|
358
|
+
const auth = outer.auth === undefined ? agreed((m) => m.auth) : undefined;
|
|
359
|
+
const shared: SharedOperation =
|
|
360
|
+
auth === undefined ? outer : { ...outer, auth };
|
|
361
|
+
return {
|
|
362
|
+
settings: outerSettings,
|
|
363
|
+
operation: shared as OuterOp,
|
|
364
|
+
targets: targets.map((target) => {
|
|
365
|
+
const own: CommonSettingsMeta = {};
|
|
366
|
+
// An `undefined` source is never written: JSON drops it, so a direct
|
|
367
|
+
// and a serialized reader would disagree on what the target inherits.
|
|
368
|
+
for (const [key, source] of Object.entries(target.meta.settings)) {
|
|
369
|
+
if (source !== undefined && !Bun.deepEquals(source, outerSettings[key]))
|
|
370
|
+
own[key] = source;
|
|
371
|
+
}
|
|
372
|
+
const meta = { ...target.meta, settings: own };
|
|
373
|
+
const inner = target.meta.operation as
|
|
374
|
+
| (Record<string, unknown> & SharedOperation)
|
|
375
|
+
| undefined;
|
|
376
|
+
if (inner) {
|
|
377
|
+
const { request, auth, ...rest } = inner;
|
|
378
|
+
// `auth` is shared only when every reached target has the same
|
|
379
|
+
// defined one, and every inner operation type requires `request`,
|
|
380
|
+
// so an omitted field always means "equal to the outer one".
|
|
381
|
+
const differs = (value: unknown, outer: unknown) =>
|
|
382
|
+
value !== undefined && !Bun.deepEquals(value, outer);
|
|
383
|
+
meta.operation = {
|
|
384
|
+
...rest,
|
|
385
|
+
...(differs(request, shared.request) ? { request } : {}),
|
|
386
|
+
...(differs(auth, shared.auth) ? { auth } : {}),
|
|
387
|
+
} as Op;
|
|
388
|
+
}
|
|
389
|
+
return { ...target, meta } as CentrsEnvelope<
|
|
390
|
+
Data,
|
|
391
|
+
FanoutMemberOperation<Op>
|
|
392
|
+
>;
|
|
393
|
+
}),
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* The inverse of {@link compactFanoutTargets}: a target's full meta, read as
|
|
399
|
+
* `target ?? outer` (per key for settings). A target that failed before it
|
|
400
|
+
* had an operation gets no operation.
|
|
401
|
+
*/
|
|
402
|
+
export function expandFanoutTarget<Data, Op>(
|
|
403
|
+
outer: { meta: { settings: CommonSettingsMeta; operation?: unknown } },
|
|
404
|
+
target: CentrsEnvelope<Data, FanoutMemberOperation<Op>>,
|
|
405
|
+
): CentrsEnvelope<Data, Op> {
|
|
406
|
+
const shared = (outer.meta.operation ?? {}) as SharedOperation;
|
|
407
|
+
const inner = target.meta.operation as
|
|
408
|
+
| (Record<string, unknown> & SharedOperation)
|
|
409
|
+
| undefined;
|
|
410
|
+
const settings: CommonSettingsMeta = { ...outer.meta.settings };
|
|
411
|
+
for (const [key, source] of Object.entries(target.meta.settings)) {
|
|
412
|
+
if (source !== undefined) settings[key] = source;
|
|
413
|
+
}
|
|
414
|
+
const operation = inner && {
|
|
415
|
+
...inner,
|
|
416
|
+
...(inner.request === undefined && shared.request !== undefined
|
|
417
|
+
? { request: shared.request }
|
|
418
|
+
: {}),
|
|
419
|
+
...(inner.auth === undefined && shared.auth !== undefined
|
|
420
|
+
? { auth: shared.auth }
|
|
421
|
+
: {}),
|
|
422
|
+
};
|
|
423
|
+
return {
|
|
424
|
+
...target,
|
|
425
|
+
meta: { ...target.meta, settings, ...(operation ? { operation } : {}) },
|
|
426
|
+
} as CentrsEnvelope<Data, Op>;
|
|
427
|
+
}
|
|
428
|
+
|
|
313
429
|
/**
|
|
314
430
|
* Assemble the locked fan-out success envelope: `data = { summary, targets }`,
|
|
315
431
|
* outer `ok: true` = orchestration succeeded. The command owns its outer
|
|
316
|
-
* `operation` meta shape and passes it in
|
|
432
|
+
* `operation` meta shape and passes it in; shared meta is hoisted out of the
|
|
433
|
+
* targets by {@link compactFanoutTargets}. `target` is the outer target, set
|
|
434
|
+
* only when the fan-out runs through one router (a CMR controller).
|
|
317
435
|
*/
|
|
318
|
-
export function buildFanoutEnvelope<Data, Op, OuterOp>(input: {
|
|
436
|
+
export function buildFanoutEnvelope<Data, Op, OuterOp extends object>(input: {
|
|
319
437
|
summary: FanoutSummary;
|
|
320
438
|
targets: readonly CentrsEnvelope<Data, Op>[];
|
|
321
439
|
warnings: readonly Warning[];
|
|
322
440
|
settings: CommonSettingsMeta;
|
|
323
441
|
via: RouterOsProtocol | null;
|
|
324
442
|
operation: OuterOp;
|
|
443
|
+
target?: EnvelopeTargetMeta;
|
|
325
444
|
}): CentrsSuccessEnvelope<FanoutData<Data, Op>, OuterOp> {
|
|
445
|
+
const { settings, operation, targets } = compactFanoutTargets(
|
|
446
|
+
input.settings,
|
|
447
|
+
input.operation,
|
|
448
|
+
input.targets,
|
|
449
|
+
);
|
|
326
450
|
return {
|
|
327
451
|
ok: true,
|
|
328
|
-
data: { summary: input.summary, targets
|
|
452
|
+
data: { summary: input.summary, targets },
|
|
329
453
|
warnings: input.warnings,
|
|
330
454
|
tips: [],
|
|
331
455
|
meta: {
|
|
332
|
-
target: {},
|
|
456
|
+
target: input.target ?? {},
|
|
333
457
|
via: input.via,
|
|
334
|
-
settings
|
|
335
|
-
operation
|
|
458
|
+
settings,
|
|
459
|
+
operation,
|
|
336
460
|
},
|
|
337
461
|
};
|
|
338
462
|
}
|
package/src/errors.ts
CHANGED
|
@@ -265,11 +265,12 @@ export function serializeUnknownError(error: unknown): unknown {
|
|
|
265
265
|
return serializeCentrsError(error);
|
|
266
266
|
}
|
|
267
267
|
|
|
268
|
+
// No `stack`: it is raw exception text with local install paths, and the
|
|
269
|
+
// constitution summarizes those rather than embedding them (#476).
|
|
268
270
|
if (error instanceof Error) {
|
|
269
271
|
return {
|
|
270
272
|
name: error.name,
|
|
271
273
|
message: error.message,
|
|
272
|
-
stack: error.stack,
|
|
273
274
|
code: extractErrorCode(error),
|
|
274
275
|
};
|
|
275
276
|
}
|