@tikoci/centrs 0.1.6 → 0.1.7
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 +108 -1
- package/README.md +8 -4
- package/commands/api/README.md +21 -10
- package/commands/api/examples.md +70 -2
- package/commands/devices/README.md +10 -1
- package/commands/devices/examples.md +33 -2
- package/commands/execute/README.md +29 -15
- package/commands/execute/examples.md +89 -0
- package/commands/explain/README.md +12 -1
- package/commands/mcp/README.md +2 -1
- package/commands/retrieve/examples.md +20 -0
- package/docs/CLI.md +1 -1
- package/docs/CONSTITUTION.md +11 -6
- package/docs/errors/cdb/create-failed.md +20 -0
- package/docs/errors/cdb/file-exists.md +23 -0
- package/docs/errors/input/invalid-target.md +25 -3
- package/docs/errors/routeros/invalid-value.md +3 -2
- package/docs/errors/transport/cancel-unacknowledged.md +24 -0
- package/package.json +1 -1
- package/src/api.ts +109 -25
- package/src/cli/devices.ts +16 -3
- package/src/core/error-catalog.ts +14 -0
- package/src/core/execute-command.ts +313 -6
- package/src/core/inspect.ts +22 -22
- package/src/core/routeros-errors.ts +66 -0
- package/src/data/winbox-cdb-write.ts +22 -8
- package/src/devices.ts +142 -28
- package/src/execute-fanout.ts +1 -1
- package/src/execute.ts +22 -51
- package/src/explain/catalog.ts +8 -8
- package/src/explain/menus.ts +4 -4
- package/src/index.ts +2 -0
- package/src/mcp/tools.ts +1 -1
- package/src/protocols/adapter.ts +12 -1
- package/src/protocols/mac-telnet-console.ts +6 -4
- package/src/protocols/native-api.ts +29 -3
- package/src/resolver/target.ts +32 -12
- package/src/retrieve.ts +17 -4
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
* re-couples `explain`'s browser entry to the transport graph.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
+
import { isKnownMenuPath } from "../explain/is-known-menu.ts";
|
|
17
|
+
|
|
16
18
|
export interface CanonicalExecuteCommand {
|
|
17
19
|
mode: "structured" | "script";
|
|
18
20
|
input: string;
|
|
@@ -24,8 +26,10 @@ export interface CanonicalExecuteCommand {
|
|
|
24
26
|
|
|
25
27
|
/**
|
|
26
28
|
* The **script-vs-structured execution gate** — centrs's load-bearing
|
|
27
|
-
* discriminator
|
|
28
|
-
*
|
|
29
|
+
* discriminator for which validation and transport path runs. Write-shape
|
|
30
|
+
* detection is intentionally independent: a raw-script fallback must not bypass
|
|
31
|
+
* confirmation merely because it cannot be represented as attributes. centrs
|
|
32
|
+
* owns this gate; the shared,
|
|
29
33
|
* prose-tolerant, multi-command canonicalizer that `rosetta` / `lsp-routeros-ts`
|
|
30
34
|
* publish is for *canonicalization*, never the structured-mode predicate —
|
|
31
35
|
* widening what counts as `structured` is a product regression. Behavior is
|
|
@@ -96,10 +100,313 @@ export function canonicalizeExecuteCommand(
|
|
|
96
100
|
}
|
|
97
101
|
|
|
98
102
|
export function isWriteShaped(command: CanonicalExecuteCommand): boolean {
|
|
99
|
-
|
|
100
|
-
command.
|
|
101
|
-
|
|
102
|
-
|
|
103
|
+
if (command.mode === "structured") {
|
|
104
|
+
if (FILE_OUTPUT_ATTRIBUTE in command.attributes) {
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
return !READ_ONLY_EXECUTE_VERBS.has(command.verb.toLowerCase());
|
|
108
|
+
}
|
|
109
|
+
return scriptContainsWriteShapedCommand(command.input);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The attribute that turns an otherwise read-only verb into a device write.
|
|
114
|
+
*
|
|
115
|
+
* RouterOS routes command output to a file on the device when `file=` is
|
|
116
|
+
* supplied, so a read verb creates a file: `/ip/address/export file=address`
|
|
117
|
+
* writes `address.rsc`, and `/file/print file=test` writes `test.txt` (both
|
|
118
|
+
* from the RouterOS manual — "Configuration Export" and "Scripting Tips and
|
|
119
|
+
* Tricks", where that idiom is the documented way to create a file and needs
|
|
120
|
+
* the script `write` policy). The verb allowlist cannot see that, so the
|
|
121
|
+
* attribute is checked independently of it.
|
|
122
|
+
*/
|
|
123
|
+
const FILE_OUTPUT_ATTRIBUTE = "file";
|
|
124
|
+
|
|
125
|
+
/** True for a `file=<name>` token — the write-shaping output redirect. */
|
|
126
|
+
function isFileOutputToken(token: string): boolean {
|
|
127
|
+
return token.startsWith(`${FILE_OUTPUT_ATTRIBUTE}=`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Commands that are safe to execute without write confirmation.
|
|
132
|
+
*
|
|
133
|
+
* This is deliberately an allowlist: RouterOS publishes no mutation bit for a
|
|
134
|
+
* command, and treating a newly introduced verb as read-only would silently
|
|
135
|
+
* widen the execution boundary. False positives require `--yes`; false
|
|
136
|
+
* negatives mutate a router. API uses the same conservative rule (only
|
|
137
|
+
* print/get/listen are read-classed).
|
|
138
|
+
*/
|
|
139
|
+
const READ_ONLY_EXECUTE_VERBS: ReadonlySet<string> = new Set([
|
|
140
|
+
"check",
|
|
141
|
+
"check-installation",
|
|
142
|
+
"export",
|
|
143
|
+
"find",
|
|
144
|
+
"get",
|
|
145
|
+
"monitor",
|
|
146
|
+
"monitor-traffic",
|
|
147
|
+
"ping",
|
|
148
|
+
"print",
|
|
149
|
+
"scan",
|
|
150
|
+
"sniff",
|
|
151
|
+
"torch",
|
|
152
|
+
"traceroute",
|
|
153
|
+
]);
|
|
154
|
+
|
|
155
|
+
/** Known mutators disambiguate the space-separated CLI spelling from a menu. */
|
|
156
|
+
const KNOWN_WRITE_EXECUTE_VERBS: ReadonlySet<string> = new Set([
|
|
157
|
+
"add",
|
|
158
|
+
"comment",
|
|
159
|
+
"delete",
|
|
160
|
+
"disable",
|
|
161
|
+
"edit",
|
|
162
|
+
"enable",
|
|
163
|
+
"flush",
|
|
164
|
+
"format",
|
|
165
|
+
"format-drive",
|
|
166
|
+
"import",
|
|
167
|
+
"install",
|
|
168
|
+
"move",
|
|
169
|
+
"password",
|
|
170
|
+
"reboot",
|
|
171
|
+
"redo",
|
|
172
|
+
"remove",
|
|
173
|
+
"reset",
|
|
174
|
+
"reset-configuration",
|
|
175
|
+
"reset-counters",
|
|
176
|
+
"reset-counters-all",
|
|
177
|
+
"run",
|
|
178
|
+
"set",
|
|
179
|
+
"shutdown",
|
|
180
|
+
"sign",
|
|
181
|
+
"start",
|
|
182
|
+
"stop",
|
|
183
|
+
"unset",
|
|
184
|
+
"upgrade",
|
|
185
|
+
"undo",
|
|
186
|
+
]);
|
|
187
|
+
|
|
188
|
+
/** Script directives that can launch a command outside the visible statement. */
|
|
189
|
+
const WRITE_SHAPED_SCRIPT_DIRECTIVES: ReadonlySet<string> = new Set([
|
|
190
|
+
":execute",
|
|
191
|
+
]);
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Script directives that are safe to run without device-write confirmation:
|
|
195
|
+
* output, control flow and pure value functions, all members of `explain`'s
|
|
196
|
+
* grounded root-cmd vocabulary (`ROOT_CMDS` in `src/explain/write.ts`). A
|
|
197
|
+
* directive's `do={…}` or `[…]` body is its own command head and is checked on
|
|
198
|
+
* its own. Deliberately absent: `:execute` and `:parse` (run built code),
|
|
199
|
+
* `:global` and `:set` (outlive the script), and `:import`, `:password`,
|
|
200
|
+
* `:undo`, `:redo` (configuration changes).
|
|
201
|
+
*/
|
|
202
|
+
const READ_ONLY_SCRIPT_DIRECTIVES: ReadonlySet<string> = new Set([
|
|
203
|
+
":break",
|
|
204
|
+
":continue",
|
|
205
|
+
":do",
|
|
206
|
+
":error",
|
|
207
|
+
":for",
|
|
208
|
+
":foreach",
|
|
209
|
+
":if",
|
|
210
|
+
":len",
|
|
211
|
+
":local",
|
|
212
|
+
":onerror",
|
|
213
|
+
":pick",
|
|
214
|
+
":put",
|
|
215
|
+
":return",
|
|
216
|
+
":tostr",
|
|
217
|
+
":typeof",
|
|
218
|
+
":while",
|
|
219
|
+
]);
|
|
220
|
+
|
|
221
|
+
function scriptContainsWriteShapedCommand(input: string): boolean {
|
|
222
|
+
for (const statement of splitUnquotedStatements(input)) {
|
|
223
|
+
const tokens = tokenizeRouterOsCli(statement);
|
|
224
|
+
// `file=` redirects output to a device file, so it is a write even under a
|
|
225
|
+
// read verb. Checked before the verb rules for the same reason the
|
|
226
|
+
// structured branch checks it independently of the allowlist.
|
|
227
|
+
if (
|
|
228
|
+
tokens.some((token) => isFileOutputToken(trimGroupingPunctuation(token)))
|
|
229
|
+
) {
|
|
230
|
+
return true;
|
|
231
|
+
}
|
|
232
|
+
// A script may navigate to a menu on one line and use a relative verb on
|
|
233
|
+
// the next. Recognize grounded mutators even when no slash appears in that
|
|
234
|
+
// statement; quoted words were masked before tokenization.
|
|
235
|
+
if (
|
|
236
|
+
tokens.some((token) =>
|
|
237
|
+
KNOWN_WRITE_EXECUTE_VERBS.has(trimGroupingPunctuation(token)),
|
|
238
|
+
)
|
|
239
|
+
) {
|
|
240
|
+
return true;
|
|
241
|
+
}
|
|
242
|
+
if (
|
|
243
|
+
tokens.some((token) =>
|
|
244
|
+
WRITE_SHAPED_SCRIPT_DIRECTIVES.has(token.replace(/^[{[(]+/, "")),
|
|
245
|
+
)
|
|
246
|
+
) {
|
|
247
|
+
return true;
|
|
248
|
+
}
|
|
249
|
+
// Every command the statement runs must be proven read-only, not just the
|
|
250
|
+
// first: `:foreach i in=[/ip address find] do={/user expire-password $i}`
|
|
251
|
+
// reads with `find` and writes with an unlearned mutator.
|
|
252
|
+
if (!commandHeads(statement).every(isReadOnlyCommand)) {
|
|
253
|
+
return true;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return false;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* The commands a statement runs: one at its start and one after every `[` or
|
|
261
|
+
* `{`, wherever that opener sits — an argument value (`in=[/ip address find]`,
|
|
262
|
+
* `do={/user expire-password $i}`), a sigil (`$[…]`), a concatenation
|
|
263
|
+
* (`"x".[…]`) or a token of its own (`do={ /user … }`). Parentheses group
|
|
264
|
+
* expressions, not commands, so they do not start one. Each head reads only up
|
|
265
|
+
* to the next opener, which begins its own head.
|
|
266
|
+
*/
|
|
267
|
+
function commandHeads(statement: string): string[][] {
|
|
268
|
+
const heads: string[][] = [];
|
|
269
|
+
const starts = [0];
|
|
270
|
+
for (let index = 0; index < statement.length; index += 1) {
|
|
271
|
+
const char = statement[index];
|
|
272
|
+
if (char === "[" || char === "{") starts.push(index + 1);
|
|
273
|
+
}
|
|
274
|
+
for (const start of starts) {
|
|
275
|
+
const nextOpener = statement.slice(start).search(/[[{(]/);
|
|
276
|
+
const segment =
|
|
277
|
+
nextOpener < 0
|
|
278
|
+
? statement.slice(start)
|
|
279
|
+
: statement.slice(start, start + nextOpener);
|
|
280
|
+
const tokens = tokenizeRouterOsCli(segment);
|
|
281
|
+
const head = tokens[0];
|
|
282
|
+
if (head === undefined) continue;
|
|
283
|
+
const words = commandWords(head, tokens.slice(1));
|
|
284
|
+
// A lone closer (`}` ending a multi-line block) runs nothing.
|
|
285
|
+
if (words.length > 0) heads.push(words);
|
|
286
|
+
}
|
|
287
|
+
return heads;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* The path segments and verb of one command, from its head token and the bare
|
|
292
|
+
* words after it. The space-separated spelling puts menu segments and the verb
|
|
293
|
+
* in those words (`/ip firewall filter print`, `/interface 6to4 print`, `/ip
|
|
294
|
+
* firewall/filter print`). A word opening a bracket starts a nested command
|
|
295
|
+
* (`new-mutator [find]`) and a word trailing a closer ends this one (`:put [/ip
|
|
296
|
+
* address find]`), so the run stops at either.
|
|
297
|
+
*/
|
|
298
|
+
function commandWords(head: string, following: readonly string[]): string[] {
|
|
299
|
+
const words: string[] = [];
|
|
300
|
+
const headWord = trimGroupingPunctuation(head);
|
|
301
|
+
if (headWord.startsWith(":")) return [headWord];
|
|
302
|
+
words.push(...headWord.split("/").filter(Boolean));
|
|
303
|
+
if (headWord !== head) return words;
|
|
304
|
+
for (const token of following) {
|
|
305
|
+
if (/^[{[(]/.test(token)) break;
|
|
306
|
+
const word = trimGroupingPunctuation(token);
|
|
307
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9-]*(?:\/[A-Za-z0-9][A-Za-z0-9-]*)*$/.test(word))
|
|
308
|
+
break;
|
|
309
|
+
words.push(...word.split("/"));
|
|
310
|
+
if (word !== token) break;
|
|
311
|
+
}
|
|
312
|
+
return words;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* A command is read-only when it is a read-only script directive, or when the
|
|
317
|
+
* first word past the longest known menu prefix is a read verb. Walking the
|
|
318
|
+
* menu table rather than taking the first known verb keeps an unlearned
|
|
319
|
+
* command from borrowing a later argument: `/user expire-password print` names
|
|
320
|
+
* `expire-password` (a published command, absent from every allowlist) with
|
|
321
|
+
* the username `print`. The menu table is a floor, so an unknown menu fails
|
|
322
|
+
* closed here, as does a run of menus with no verb (navigation for a later
|
|
323
|
+
* relative command).
|
|
324
|
+
*/
|
|
325
|
+
function isReadOnlyCommand(words: readonly string[]): boolean {
|
|
326
|
+
const head = words[0] ?? "";
|
|
327
|
+
if (head.startsWith(":")) return READ_ONLY_SCRIPT_DIRECTIVES.has(head);
|
|
328
|
+
let depth = 0;
|
|
329
|
+
while (depth < words.length && isKnownMenuPath(words.slice(0, depth + 1))) {
|
|
330
|
+
depth += 1;
|
|
331
|
+
}
|
|
332
|
+
const verb = words[depth];
|
|
333
|
+
return verb !== undefined && READ_ONLY_EXECUTE_VERBS.has(verb.toLowerCase());
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function trimGroupingPunctuation(value: string): string {
|
|
337
|
+
let start = 0;
|
|
338
|
+
let end = value.length;
|
|
339
|
+
while (start < end && "{[(".includes(value[start] ?? "")) start += 1;
|
|
340
|
+
while (end > start && "}])".includes(value[end - 1] ?? "")) end -= 1;
|
|
341
|
+
return value.slice(start, end);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Split at real statement boundaries and blank quoted text, which does not
|
|
346
|
+
* execute — except a `$[…]` or `$(…)` substitution inside a double-quoted
|
|
347
|
+
* string, which is code: `:put "$[/system identity set name=x]"` runs the set.
|
|
348
|
+
* Its body is kept (with its opener, so it becomes a command head) and may nest
|
|
349
|
+
* strings of its own. The frame grammar is `explain`'s (`stepFrame` in
|
|
350
|
+
* `src/explain/quoted-string.ts`).
|
|
351
|
+
*/
|
|
352
|
+
function splitUnquotedStatements(input: string): string[] {
|
|
353
|
+
const statements: string[] = [];
|
|
354
|
+
let current = "";
|
|
355
|
+
// `"` / `'` are string frames; `]` / `)` are the closer a code frame inside
|
|
356
|
+
// a string is waiting for. An empty stack is top-level code.
|
|
357
|
+
const frames: string[] = [];
|
|
358
|
+
let escaped = false;
|
|
359
|
+
for (let index = 0; index < input.length; index += 1) {
|
|
360
|
+
const char = input[index] ?? "";
|
|
361
|
+
const top = frames.at(-1);
|
|
362
|
+
const inString = top === '"' || top === "'";
|
|
363
|
+
if (escaped) {
|
|
364
|
+
current += inString ? " " : char;
|
|
365
|
+
escaped = false;
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
if (char === "\\") {
|
|
369
|
+
escaped = true;
|
|
370
|
+
current += inString ? " " : char;
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
if (inString) {
|
|
374
|
+
if (char === top) {
|
|
375
|
+
frames.pop();
|
|
376
|
+
} else if (
|
|
377
|
+
top === '"' &&
|
|
378
|
+
char === "$" &&
|
|
379
|
+
(input[index + 1] === "[" || input[index + 1] === "(")
|
|
380
|
+
) {
|
|
381
|
+
const opener = input[index + 1] ?? "";
|
|
382
|
+
frames.push(opener === "[" ? "]" : ")");
|
|
383
|
+
current += ` ${opener}`;
|
|
384
|
+
index += 1;
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
current += " ";
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
390
|
+
if (char === '"' || char === "'") {
|
|
391
|
+
frames.push(char);
|
|
392
|
+
current += " ";
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
if (top !== undefined) {
|
|
396
|
+
// Inside a string's code frame: track nesting to find its closer.
|
|
397
|
+
if (char === top) frames.pop();
|
|
398
|
+
else if (char === "[") frames.push("]");
|
|
399
|
+
else if (char === "(") frames.push(")");
|
|
400
|
+
}
|
|
401
|
+
if (char === ";" || char === "\n" || char === "\r") {
|
|
402
|
+
statements.push(current);
|
|
403
|
+
current = "";
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
current += char;
|
|
407
|
+
}
|
|
408
|
+
statements.push(current);
|
|
409
|
+
return statements;
|
|
103
410
|
}
|
|
104
411
|
|
|
105
412
|
function tokenizeRouterOsCli(input: string): string[] {
|
package/src/core/inspect.ts
CHANGED
|
@@ -55,12 +55,20 @@ export interface InspectChildItem {
|
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
/** A row from `request=completion`: a candidate token for a path/argument. */
|
|
58
|
+
/**
|
|
59
|
+
* One `request=completion` row. These are the only fields RouterOS sends, on
|
|
60
|
+
* every build recorded from 7.9.2 to 7.25beta3 (lsp-routeros-ts
|
|
61
|
+
* `test-data/inspect-shapes.v*.json`). `text` is the candidate's help string,
|
|
62
|
+
* never its name.
|
|
63
|
+
*/
|
|
58
64
|
export interface InspectCompletionItem {
|
|
59
65
|
type?: string;
|
|
60
|
-
name?: string;
|
|
61
66
|
completion?: string;
|
|
62
|
-
|
|
67
|
+
show?: string;
|
|
68
|
+
style?: string;
|
|
63
69
|
text?: string;
|
|
70
|
+
offset?: string;
|
|
71
|
+
preference?: string;
|
|
64
72
|
}
|
|
65
73
|
|
|
66
74
|
/**
|
|
@@ -99,31 +107,23 @@ export function isCommandNode(child: InspectChildItem, name: string): boolean {
|
|
|
99
107
|
}
|
|
100
108
|
|
|
101
109
|
/**
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
110
|
+
* The candidate names from completion rows: `completion` of each
|
|
111
|
+
* `show: "true"` row that is not `syntax-meta`. `show: "false"` rows are
|
|
112
|
+
* syntax completions (`[`, `(`, `$`, `"`, `*`, `<value>`), not properties
|
|
113
|
+
* (CHR 7.23.7, on both `print,proplist` and `get,value-name`; #380). At an
|
|
114
|
+
* argument boundary RouterOS also offers a shown `=` ("argument value
|
|
115
|
+
* separator", `style: "syntax-meta"`) on every recorded build, 7.9.2 →
|
|
116
|
+
* 7.25beta3; no build offers a `name=value` completion. Returns
|
|
117
|
+
* the names in row order **without** de-duplication or sorting; callers that
|
|
118
|
+
* need a stable set wrap with `[...new Set(names)].sort()`.
|
|
108
119
|
*/
|
|
109
120
|
export function extractCompletionNames(
|
|
110
121
|
rows: readonly InspectCompletionItem[],
|
|
111
122
|
): string[] {
|
|
112
123
|
return rows
|
|
113
|
-
.
|
|
114
|
-
.
|
|
115
|
-
.
|
|
116
|
-
.filter((value) => value.length > 0);
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
/**
|
|
120
|
-
* The token before the first `=` in a RouterOS completion row. Plain string
|
|
121
|
-
* slicing (no regex) — `value.replace(/=.*$/, "")` triggers a CodeQL
|
|
122
|
-
* polynomial-ReDoS flag on this exported function's caller-supplied input.
|
|
123
|
-
*/
|
|
124
|
-
function nameBeforeEquals(value: string): string {
|
|
125
|
-
const eq = value.indexOf("=");
|
|
126
|
-
return eq === -1 ? value : value.slice(0, eq);
|
|
124
|
+
.filter((row) => row.show === "true" && row.style !== "syntax-meta")
|
|
125
|
+
.map((row) => row.completion?.trim() ?? "")
|
|
126
|
+
.filter((name) => name.length > 0);
|
|
127
127
|
}
|
|
128
128
|
|
|
129
129
|
/** `request=child` for a token path. */
|
|
@@ -88,6 +88,12 @@ export interface RouterOsErrorRule {
|
|
|
88
88
|
description: string;
|
|
89
89
|
/** Pattern matched against the trimmed RouterOS string. */
|
|
90
90
|
test: RegExp;
|
|
91
|
+
/**
|
|
92
|
+
* Anchored form safe for classifying a successful transport's returned text.
|
|
93
|
+
* Unlike an HTTP error body / native trap, `/execute as-string` also carries
|
|
94
|
+
* ordinary stdout, so substring matches would turn user output into failures.
|
|
95
|
+
*/
|
|
96
|
+
resultTest?: RegExp;
|
|
91
97
|
/** Builds the normalized error fields from the regex match and raw string. */
|
|
92
98
|
build: (match: RegExpMatchArray, raw: string) => RouterOsErrorRuleResult;
|
|
93
99
|
}
|
|
@@ -106,6 +112,8 @@ export const routerOsErrorRules: readonly RouterOsErrorRule[] = [
|
|
|
106
112
|
description:
|
|
107
113
|
"RouterOS did not recognize the command path or menu item ('no such ...').",
|
|
108
114
|
test: /no such command prefix|no such item|no such entry/i,
|
|
115
|
+
resultTest:
|
|
116
|
+
/^(?:failure:\s*)?(?:no such command prefix|no such item|no such entry)\b.*$/i,
|
|
109
117
|
build: (_match, raw) => ({
|
|
110
118
|
summary: `RouterOS does not recognize the path: ${raw.trim()}`,
|
|
111
119
|
remediation:
|
|
@@ -119,6 +127,7 @@ export const routerOsErrorRules: readonly RouterOsErrorRule[] = [
|
|
|
119
127
|
// (mac-telnet) says "bad parameter <name> (line N column M)" for the same
|
|
120
128
|
// fault. Grounded on CHR 7.23.1.
|
|
121
129
|
test: /(?:unknown|bad) parameter\s+(\S+)/i,
|
|
130
|
+
resultTest: /^(?:failure:\s*)?(?:unknown|bad) parameter\s+\S+.*$/i,
|
|
122
131
|
build: (match) => {
|
|
123
132
|
const parameter = cleanToken(match[1] ?? "");
|
|
124
133
|
return {
|
|
@@ -135,6 +144,7 @@ export const routerOsErrorRules: readonly RouterOsErrorRule[] = [
|
|
|
135
144
|
"RouterOS console rejected the command word ('bad command name …').",
|
|
136
145
|
// Console form (mac-telnet) of an unrecognized command/path word.
|
|
137
146
|
test: /bad command name/i,
|
|
147
|
+
resultTest: /^(?:failure:\s*)?(?:\(<%%\s*)?bad command name\b.*$/i,
|
|
138
148
|
build: (_match, raw) => ({
|
|
139
149
|
summary: `RouterOS does not recognize the command: ${raw.trim()}`,
|
|
140
150
|
remediation:
|
|
@@ -145,6 +155,7 @@ export const routerOsErrorRules: readonly RouterOsErrorRule[] = [
|
|
|
145
155
|
code: "routeros/invalid-value",
|
|
146
156
|
description: "RouterOS rejected the value supplied for an argument.",
|
|
147
157
|
test: /invalid value (?:for argument|of)\s+(\S+)/i,
|
|
158
|
+
resultTest: /^(?:failure:\s*)?invalid value (?:for argument|of)\s+\S+.*$/i,
|
|
148
159
|
build: (match) => {
|
|
149
160
|
const argument = cleanToken(match[1] ?? "");
|
|
150
161
|
return {
|
|
@@ -155,11 +166,24 @@ export const routerOsErrorRules: readonly RouterOsErrorRule[] = [
|
|
|
155
166
|
};
|
|
156
167
|
},
|
|
157
168
|
},
|
|
169
|
+
{
|
|
170
|
+
code: "routeros/invalid-value",
|
|
171
|
+
description:
|
|
172
|
+
"RouterOS rejected a value because it does not match the accepted domain.",
|
|
173
|
+
test: /input does not match/i,
|
|
174
|
+
resultTest: /^(?:failure:\s*)?input does not match\b.*$/i,
|
|
175
|
+
build: (_match, raw) => ({
|
|
176
|
+
summary: `RouterOS rejected the supplied value: ${raw.trim()}`,
|
|
177
|
+
remediation:
|
|
178
|
+
"Check the accepted values for this command on the target RouterOS version, then retry with a matching value.",
|
|
179
|
+
}),
|
|
180
|
+
},
|
|
158
181
|
{
|
|
159
182
|
code: "routeros/session-closed",
|
|
160
183
|
description:
|
|
161
184
|
"RouterOS closed the REST session before the request completed (60-second cap).",
|
|
162
185
|
test: /session closed/i,
|
|
186
|
+
resultTest: /^(?:failure:\s*)?session closed\b.*$/i,
|
|
163
187
|
build: () => ({
|
|
164
188
|
summary: "RouterOS closed the session before the request completed.",
|
|
165
189
|
remediation:
|
|
@@ -171,6 +195,7 @@ export const routerOsErrorRules: readonly RouterOsErrorRule[] = [
|
|
|
171
195
|
description:
|
|
172
196
|
"Generic RouterOS command failure carrying a `failure: <msg>` string.",
|
|
173
197
|
test: /^\s*failure:\s*(.+?)\s*$/i,
|
|
198
|
+
resultTest: /^\s*failure:\s*.+?\s*$/i,
|
|
174
199
|
build: (match) => {
|
|
175
200
|
const failure = (match[1] ?? "").trim();
|
|
176
201
|
return {
|
|
@@ -181,11 +206,27 @@ export const routerOsErrorRules: readonly RouterOsErrorRule[] = [
|
|
|
181
206
|
};
|
|
182
207
|
},
|
|
183
208
|
},
|
|
209
|
+
{
|
|
210
|
+
code: "routeros/command-failed",
|
|
211
|
+
description: "Generic RouterOS command failure carrying an error prefix.",
|
|
212
|
+
test: /^\s*error:\s*(.+?)\s*$/i,
|
|
213
|
+
resultTest: /^\s*error:\s*.+?\s*$/i,
|
|
214
|
+
build: (match) => {
|
|
215
|
+
const failure = (match[1] ?? "").trim();
|
|
216
|
+
return {
|
|
217
|
+
summary: `RouterOS command failed: ${failure}`,
|
|
218
|
+
remediation:
|
|
219
|
+
"Inspect the RouterOS error message, then adjust the script or request shape accordingly.",
|
|
220
|
+
context: { failure },
|
|
221
|
+
};
|
|
222
|
+
},
|
|
223
|
+
},
|
|
184
224
|
{
|
|
185
225
|
code: "routeros/command-failed",
|
|
186
226
|
description:
|
|
187
227
|
"RouterOS /execute as-string returned a script :error value with source location.",
|
|
188
228
|
test: /^\s*(.+?)\s*\(:error; line \d+\)\s*$/i,
|
|
229
|
+
resultTest: /^\s*.+?\s*\(:error; line \d+\)\s*$/i,
|
|
189
230
|
build: (match) => {
|
|
190
231
|
const failure = (match[1] ?? "").trim();
|
|
191
232
|
return {
|
|
@@ -203,6 +244,7 @@ export const routerOsErrorRules: readonly RouterOsErrorRule[] = [
|
|
|
203
244
|
code: "routeros/unknown-path",
|
|
204
245
|
description: "RouterOS reported a path-shaped 'not found'.",
|
|
205
246
|
test: /not found/i,
|
|
247
|
+
resultTest: /^\s*\/\S+\s+not found\s*$/i,
|
|
206
248
|
build: (_match, raw) => ({
|
|
207
249
|
summary: `RouterOS does not recognize the path: ${raw.trim()}`,
|
|
208
250
|
remediation:
|
|
@@ -211,6 +253,30 @@ export const routerOsErrorRules: readonly RouterOsErrorRule[] = [
|
|
|
211
253
|
},
|
|
212
254
|
];
|
|
213
255
|
|
|
256
|
+
/**
|
|
257
|
+
* Grounded result shapes that intentionally use the transport-specific
|
|
258
|
+
* catch-all mapping. They are errors, but do not carry enough information for a
|
|
259
|
+
* more specific normalized code.
|
|
260
|
+
*/
|
|
261
|
+
const ROUTEROS_RESULT_CATCH_ALL =
|
|
262
|
+
/^(?:syntax error|expected (?:end of command|command name|input value))\b[^\n]*$/i;
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Map text returned by a successful transport only when it matches a grounded,
|
|
266
|
+
* stdout-safe result shape. Returns `undefined` for ordinary script output.
|
|
267
|
+
*/
|
|
268
|
+
export function mapRouterOsResultError(
|
|
269
|
+
raw: string,
|
|
270
|
+
opts: MapRouterOsErrorOptions = {},
|
|
271
|
+
): CentrsError | undefined {
|
|
272
|
+
const trimmed = raw.trim();
|
|
273
|
+
if (trimmed.length === 0) return undefined;
|
|
274
|
+
const matched =
|
|
275
|
+
ROUTEROS_RESULT_CATCH_ALL.test(trimmed) ||
|
|
276
|
+
routerOsErrorRules.some((rule) => rule.resultTest?.test(trimmed));
|
|
277
|
+
return matched ? mapRouterOsError(raw, opts) : undefined;
|
|
278
|
+
}
|
|
279
|
+
|
|
214
280
|
/**
|
|
215
281
|
* Maps a raw RouterOS error string to a normalized {@link CentrsError}.
|
|
216
282
|
*
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
22
|
import {
|
|
23
|
+
chmod,
|
|
23
24
|
copyFile,
|
|
24
25
|
mkdir,
|
|
25
26
|
open,
|
|
@@ -137,10 +138,21 @@ export async function writeWinBoxCdb(
|
|
|
137
138
|
let backupPath: string | undefined;
|
|
138
139
|
const prunedBackups: string[] = [];
|
|
139
140
|
const targetExists = await pathExists(target);
|
|
141
|
+
// The backup and the replacement both carry the CDB's mode (a new CDB gets
|
|
142
|
+
// 0600): the file holds device credentials, and neither a plain open nor
|
|
143
|
+
// `copyFile` is guaranteed to keep it narrower than the umask default,
|
|
144
|
+
// e.g. 0644 (#382 review).
|
|
145
|
+
const mode = targetExists ? (await stat(target)).mode & 0o777 : 0o600;
|
|
140
146
|
if (targetExists && !options.skipBackup) {
|
|
141
147
|
const stamp = backupStamp(options.now ?? new Date());
|
|
142
148
|
backupPath = join(dir, `${backupPrefix(target)}${stamp}`);
|
|
143
149
|
await copyFile(target, backupPath);
|
|
150
|
+
try {
|
|
151
|
+
await chmod(backupPath, mode);
|
|
152
|
+
} catch (error) {
|
|
153
|
+
await unlink(backupPath).catch(() => undefined);
|
|
154
|
+
throw error;
|
|
155
|
+
}
|
|
144
156
|
|
|
145
157
|
if (retention >= 0) {
|
|
146
158
|
const backups = await listWinBoxCdbBackups(target);
|
|
@@ -159,15 +171,17 @@ export async function writeWinBoxCdb(
|
|
|
159
171
|
dir,
|
|
160
172
|
`${basename(target)}${TEMP_INFIX}${process.pid}.${Date.now().toString(36)}`,
|
|
161
173
|
);
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
await handle.write(bytes);
|
|
165
|
-
await handle.sync();
|
|
166
|
-
} finally {
|
|
167
|
-
await handle.close();
|
|
168
|
-
}
|
|
169
|
-
|
|
174
|
+
// Any failure before the rename removes the temp file: it holds the
|
|
175
|
+
// serialized CDB, credentials included.
|
|
170
176
|
try {
|
|
177
|
+
const handle = await open(tempPath, "w", mode);
|
|
178
|
+
try {
|
|
179
|
+
await handle.chmod(mode);
|
|
180
|
+
await handle.write(bytes);
|
|
181
|
+
await handle.sync();
|
|
182
|
+
} finally {
|
|
183
|
+
await handle.close();
|
|
184
|
+
}
|
|
171
185
|
await rename(tempPath, target);
|
|
172
186
|
} catch (error) {
|
|
173
187
|
await unlink(tempPath).catch(() => undefined);
|