@tikoci/centrs 0.1.6 → 0.1.8-next.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +342 -0
- package/GLOSSARY.txt +32 -0
- package/README.md +10 -6
- package/SECURITY.md +1 -1
- package/commands/AGENTS.md +2 -1
- package/commands/api/AGENTS.md +55 -30
- package/commands/api/README.md +135 -42
- package/commands/api/examples.md +275 -19
- package/commands/devices/README.md +21 -4
- package/commands/devices/examples.md +33 -2
- package/commands/execute/README.md +60 -18
- package/commands/execute/examples.md +224 -0
- package/commands/explain/README.md +42 -5
- package/commands/mcp/README.md +2 -1
- package/commands/retrieve/README.md +348 -4
- package/commands/retrieve/examples.md +442 -5
- package/commands/terminal/README.md +14 -4
- package/commands/terminal/examples.md +22 -3
- package/commands/transfer/README.md +55 -9
- package/commands/transfer/examples.md +106 -7
- package/docs/CLI.md +29 -21
- package/docs/CONSTITUTION.md +41 -16
- package/docs/MATRIX.md +38 -1
- package/docs/errors/README.md +3 -1
- package/docs/errors/cdb/create-failed.md +20 -0
- package/docs/errors/cdb/file-exists.md +23 -0
- package/docs/errors/input/invalid-query.md +36 -0
- package/docs/errors/input/invalid-target.md +25 -3
- package/docs/errors/input/local-destination.md +20 -0
- package/docs/errors/input/unsupported-query.md +19 -0
- package/docs/errors/internal/byte-string.md +13 -0
- package/docs/errors/routeros/invalid-value.md +3 -2
- package/docs/errors/settings/invalid-timeout.md +6 -0
- package/docs/errors/transport/cancel-unacknowledged.md +24 -0
- package/docs/errors/transport/stream-overflow.md +21 -0
- package/docs/errors/usage/removed-flag.md +20 -0
- package/docs/errors/validation/menu-unavailable.md +22 -0
- package/docs/errors/validation/not-followable.md +23 -0
- package/docs/errors/validation/package-missing.md +29 -0
- package/docs/errors/validation/unknown-path.md +16 -4
- package/docs/errors/wait/deadline-exceeded.md +14 -0
- package/docs/errors/wait/interrupted.md +10 -0
- package/package.json +14 -11
- package/src/absent-menu.ts +280 -0
- package/src/api-fanout.ts +22 -12
- package/src/api.ts +603 -194
- package/src/btest.ts +19 -17
- package/src/cli/api.ts +126 -34
- package/src/cli/devices.ts +16 -3
- package/src/cli/execute.ts +18 -5
- package/src/cli/retrieve.ts +216 -22
- package/src/cli/terminal.ts +10 -5
- package/src/cli/transfer.ts +23 -8
- package/src/core/envelope.ts +14 -0
- package/src/core/error-catalog.ts +70 -1
- package/src/core/execute-command.ts +313 -6
- package/src/core/inspect.ts +65 -27
- package/src/core/query.ts +508 -0
- package/src/core/routeros-errors.ts +67 -1
- package/src/data/winbox-cdb-write.ts +22 -8
- package/src/devices.ts +148 -37
- package/src/discover.ts +7 -8
- package/src/errors.ts +116 -7
- package/src/execute-fanout.ts +14 -9
- package/src/execute.ts +128 -116
- package/src/explain/catalog.ts +64 -16
- package/src/explain/menus.ts +27 -6
- package/src/explain.ts +6 -6
- package/src/index.ts +31 -1
- package/src/mcp/tools.ts +10 -15
- package/src/protocols/adapter.ts +235 -118
- package/src/protocols/btest-session.ts +3 -2
- package/src/protocols/endpoint.ts +10 -0
- package/src/protocols/mac-telnet-console.ts +12 -7
- package/src/protocols/native-api.ts +252 -73
- package/src/protocols/sftp.ts +44 -14
- package/src/protocols/ssh.ts +42 -14
- package/src/resolver/cdb.ts +28 -7
- package/src/resolver/index.ts +1 -0
- package/src/resolver/target.ts +84 -18
- package/src/retrieve-fanout.ts +51 -12
- package/src/retrieve-follow.ts +862 -0
- package/src/retrieve-sample.ts +244 -0
- package/src/retrieve-stream.ts +115 -0
- package/src/retrieve-wait.ts +286 -0
- package/src/retrieve.ts +741 -109
- package/src/settings.ts +15 -15
- package/src/transfer-fanout.ts +40 -15
- package/src/transfer.ts +234 -32
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `--query` predicates: the inside of a RouterOS `print where …`, compiled to
|
|
3
|
+
* the API query stack (native `?` words, REST `.query` — the same words minus
|
|
4
|
+
* the `?`, `commands/api/AGENTS.md`).
|
|
5
|
+
*
|
|
6
|
+
* One grammar serves `retrieve --query`, `api --query` and, later, `--until`
|
|
7
|
+
* (#397, #364). It reads like `where`: `interface=ether1 and !disabled`,
|
|
8
|
+
* `(type=ether or type=vlan) and mtu>=1500`. The device evaluates the compiled
|
|
9
|
+
* stack, so comparisons keep RouterOS's per-property typing.
|
|
10
|
+
*
|
|
11
|
+
* Words are emitted in postfix order; `#!`, `#&`, `#|` pop their operands, and
|
|
12
|
+
* whatever remains is AND-ed by RouterOS. Mapping, grounded by the CHR
|
|
13
|
+
* `find where` vs `.query` differential (`test/integration/retrieve-query.test.ts`):
|
|
14
|
+
*
|
|
15
|
+
* | where | words |
|
|
16
|
+
* | -------------- | ----------------------------- |
|
|
17
|
+
* | `a=v` | `a=v` |
|
|
18
|
+
* | `a!=v` | `a=v` `#!` |
|
|
19
|
+
* | `a<v` | `<a=v` `-a` `#|` |
|
|
20
|
+
* | `a>v` | `>a=v` |
|
|
21
|
+
* | `a<=v` | `>a=v` `#!` |
|
|
22
|
+
* | `a>=v` | `<a=v` `-a` `#|` `#!` |
|
|
23
|
+
* | bare `a` | `a=yes` for a boolean, else `a` (is set) |
|
|
24
|
+
* | `!x` | x `#!` |
|
|
25
|
+
* | `x and y` | x y `#&` |
|
|
26
|
+
* | `x or y` | x y `#|` |
|
|
27
|
+
*
|
|
28
|
+
* Ordering follows `where`, where a row without the property is smaller than
|
|
29
|
+
* any value: `timeout<1d` includes static address-list entries, `timeout>1h`
|
|
30
|
+
* does not. API `<`/`>` skip such rows, hence the `-a` (absent) word.
|
|
31
|
+
*
|
|
32
|
+
* A bare name means what it means after `where`, which depends on the
|
|
33
|
+
* property: `where disabled` is `disabled=yes`, `where comment` is "comment is
|
|
34
|
+
* set" (an empty comment is stored as absent). The caller asks the device
|
|
35
|
+
* which names are booleans (`inspectWhereBooleans`) before emitting.
|
|
36
|
+
*
|
|
37
|
+
* Refused rather than approximated: `~` (regex) and `in` (prefix membership)
|
|
38
|
+
* have no API query word; `$`/`[`/`{` values need a script evaluator;
|
|
39
|
+
* `!a=v` is `(!a)=v` to RouterOS, which matches by accident, so it must be
|
|
40
|
+
* written `!(a=v)` or `a!=v`; `not` is not a RouterOS operator; and
|
|
41
|
+
* space-separated conditions must be joined with `and`/`or`.
|
|
42
|
+
*
|
|
43
|
+
* The device decides comparisons with the API's per-property typing, which is
|
|
44
|
+
* not always the CLI's: on CHR 7.23.7/7.24.5, `where dst-port=80` matches
|
|
45
|
+
* nothing, while the query matches the `"80"` that `retrieve` shows. On CHR
|
|
46
|
+
* 7.25beta3/7.25rc1/7.26beta1 the CLI agrees with the query; ordering
|
|
47
|
+
* on a text property (`dst-port`, address-list `address`) is text order.
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
import { CentrsError } from "../errors.ts";
|
|
51
|
+
|
|
52
|
+
export type QueryComparison = "=" | "!=" | "<" | ">" | "<=" | ">=";
|
|
53
|
+
|
|
54
|
+
export type QueryNode =
|
|
55
|
+
| { kind: "compare"; name: string; op: QueryComparison; value: string }
|
|
56
|
+
| { kind: "truthy"; name: string }
|
|
57
|
+
| { kind: "not"; operand: QueryNode }
|
|
58
|
+
| { kind: "and" | "or"; left: QueryNode; right: QueryNode };
|
|
59
|
+
|
|
60
|
+
export interface ParsedQuery {
|
|
61
|
+
/** One tree per `--query`; several are AND-ed. */
|
|
62
|
+
nodes: QueryNode[];
|
|
63
|
+
/** Every property the predicate reads, for inspect validation. */
|
|
64
|
+
names: string[];
|
|
65
|
+
/** Properties used bare (`disabled`, `!comment`), whose type decides the word. */
|
|
66
|
+
bareNames: string[];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
type Token =
|
|
70
|
+
| { kind: "name"; text: string; at: number }
|
|
71
|
+
| { kind: "op"; text: QueryComparison | "~" | "in"; at: number }
|
|
72
|
+
| { kind: "value"; text: string; at: number }
|
|
73
|
+
| { kind: "and" | "or" | "not" | "(" | ")"; at: number };
|
|
74
|
+
|
|
75
|
+
const NAME = /^\.?[A-Za-z][A-Za-z0-9.-]*/;
|
|
76
|
+
const BARE_VALUE_STOP = new Set([" ", "\t", "\r", "\n", "(", ")", '"']);
|
|
77
|
+
/**
|
|
78
|
+
* RouterOS string escapes, the device's set (`src/explain/quoted-string.ts`
|
|
79
|
+
* has the sweep). Hex is two UPPERCASE digits: `\ff` is `\f` then `f`.
|
|
80
|
+
*/
|
|
81
|
+
const ESCAPES: Record<string, string> = {
|
|
82
|
+
'"': '"',
|
|
83
|
+
"\\": "\\",
|
|
84
|
+
$: "$",
|
|
85
|
+
"?": "?",
|
|
86
|
+
_: " ",
|
|
87
|
+
a: "\x07",
|
|
88
|
+
b: "\b",
|
|
89
|
+
f: "\f",
|
|
90
|
+
n: "\n",
|
|
91
|
+
r: "\r",
|
|
92
|
+
t: "\t",
|
|
93
|
+
v: "\v",
|
|
94
|
+
};
|
|
95
|
+
const HEX_ESCAPE = /^[0-9A-F]{2}$/;
|
|
96
|
+
|
|
97
|
+
/** Parse one or more `--query` expressions offline. */
|
|
98
|
+
export function parseQueries(
|
|
99
|
+
expressions: readonly string[],
|
|
100
|
+
flag: "--query" | "--until" = "--query",
|
|
101
|
+
): ParsedQuery {
|
|
102
|
+
let nodes: QueryNode[];
|
|
103
|
+
try {
|
|
104
|
+
nodes = expressions.map(parseQuery);
|
|
105
|
+
} catch (error) {
|
|
106
|
+
if (flag === "--until" && error instanceof CentrsError)
|
|
107
|
+
throw queryErrorForFlag(error, flag);
|
|
108
|
+
throw error;
|
|
109
|
+
}
|
|
110
|
+
const names = new Set<string>();
|
|
111
|
+
const bareNames = new Set<string>();
|
|
112
|
+
const visit = (node: QueryNode): void => {
|
|
113
|
+
switch (node.kind) {
|
|
114
|
+
case "truthy":
|
|
115
|
+
names.add(node.name);
|
|
116
|
+
bareNames.add(node.name);
|
|
117
|
+
return;
|
|
118
|
+
case "compare":
|
|
119
|
+
names.add(node.name);
|
|
120
|
+
return;
|
|
121
|
+
case "not":
|
|
122
|
+
visit(node.operand);
|
|
123
|
+
return;
|
|
124
|
+
default:
|
|
125
|
+
visit(node.left);
|
|
126
|
+
visit(node.right);
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
nodes.forEach(visit);
|
|
130
|
+
return { nodes, names: [...names], bareNames: [...bareNames] };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Keep shared grammar diagnostics attached to the caller's predicate flag. */
|
|
134
|
+
export function queryErrorForFlag(
|
|
135
|
+
error: CentrsError,
|
|
136
|
+
flag: "--until",
|
|
137
|
+
): CentrsError {
|
|
138
|
+
return new CentrsError({
|
|
139
|
+
code: error.code,
|
|
140
|
+
summary: error.summary.replace("--query", flag),
|
|
141
|
+
remediation: error.remediation?.replace("--query", flag),
|
|
142
|
+
context: { ...error.context, flag },
|
|
143
|
+
position: error.position,
|
|
144
|
+
cause: error.cause,
|
|
145
|
+
causeData: error.causeData,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Query words (no `?` prefix) in stack order. `booleans` names the bare
|
|
151
|
+
* properties the device reports as booleans; any other bare name means "is set".
|
|
152
|
+
*/
|
|
153
|
+
export function compileQueryWords(
|
|
154
|
+
parsed: ParsedQuery,
|
|
155
|
+
booleans: ReadonlySet<string>,
|
|
156
|
+
): string[] {
|
|
157
|
+
const words: string[] = [];
|
|
158
|
+
parsed.nodes.forEach((node, index) => {
|
|
159
|
+
emit(node, words, booleans);
|
|
160
|
+
if (index > 0) words.push("#&");
|
|
161
|
+
});
|
|
162
|
+
return words;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function parseQuery(expression: string): QueryNode {
|
|
166
|
+
const tokens = tokenize(expression);
|
|
167
|
+
let position = 0;
|
|
168
|
+
const peek = () => tokens[position];
|
|
169
|
+
|
|
170
|
+
const fail = (summary: string, at: number): never => {
|
|
171
|
+
throw invalidQuery(expression, summary, at);
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
const parseOr = (): QueryNode => {
|
|
175
|
+
let left = parseAnd();
|
|
176
|
+
while (peek()?.kind === "or") {
|
|
177
|
+
position++;
|
|
178
|
+
left = { kind: "or", left, right: parseAnd() };
|
|
179
|
+
}
|
|
180
|
+
return left;
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
const parseAnd = (): QueryNode => {
|
|
184
|
+
let left = parseUnary();
|
|
185
|
+
while (peek()?.kind === "and") {
|
|
186
|
+
position++;
|
|
187
|
+
left = { kind: "and", left, right: parseUnary() };
|
|
188
|
+
}
|
|
189
|
+
return left;
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
const parseUnary = (): QueryNode => {
|
|
193
|
+
const token = peek();
|
|
194
|
+
if (token === undefined) {
|
|
195
|
+
return fail(
|
|
196
|
+
"The expression ends where a condition was expected.",
|
|
197
|
+
expression.length,
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
if (token.kind === "not") {
|
|
201
|
+
position++;
|
|
202
|
+
const next = tokens[position];
|
|
203
|
+
if (next?.kind === "name" && tokens[position + 1]?.kind === "op") {
|
|
204
|
+
return fail(
|
|
205
|
+
`RouterOS reads \`!${next.text}…\` as \`(!${next.text})…\`; write \`!(${next.text}…)\` or use \`!=\`.`,
|
|
206
|
+
token.at,
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
return { kind: "not", operand: parseUnary() };
|
|
210
|
+
}
|
|
211
|
+
if (token.kind === "(") {
|
|
212
|
+
position++;
|
|
213
|
+
const inner = parseOr();
|
|
214
|
+
const close = peek();
|
|
215
|
+
if (close?.kind !== ")") {
|
|
216
|
+
return fail("A `(` is never closed.", token.at);
|
|
217
|
+
}
|
|
218
|
+
position++;
|
|
219
|
+
return inner;
|
|
220
|
+
}
|
|
221
|
+
if (token.kind !== "name") {
|
|
222
|
+
return fail("Expected a property name.", token.at);
|
|
223
|
+
}
|
|
224
|
+
if (token.text === "not") {
|
|
225
|
+
return fail("RouterOS has no `not` operator; use `!`.", token.at);
|
|
226
|
+
}
|
|
227
|
+
position++;
|
|
228
|
+
const op = peek();
|
|
229
|
+
if (op?.kind !== "op") {
|
|
230
|
+
return { kind: "truthy", name: token.text };
|
|
231
|
+
}
|
|
232
|
+
position++;
|
|
233
|
+
if (op.text === "~" || op.text === "in") {
|
|
234
|
+
throw unsupportedOperator(expression, op.text, op.at);
|
|
235
|
+
}
|
|
236
|
+
const value = peek();
|
|
237
|
+
if (value?.kind !== "value") {
|
|
238
|
+
return fail(`\`${op.text}\` needs a value after it.`, op.at);
|
|
239
|
+
}
|
|
240
|
+
position++;
|
|
241
|
+
return {
|
|
242
|
+
kind: "compare",
|
|
243
|
+
name: token.text,
|
|
244
|
+
op: op.text,
|
|
245
|
+
value: value.text,
|
|
246
|
+
};
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
const node = parseOr();
|
|
250
|
+
const rest = peek();
|
|
251
|
+
if (rest !== undefined) {
|
|
252
|
+
fail(
|
|
253
|
+
rest.kind === ")"
|
|
254
|
+
? "A `)` has no matching `(`."
|
|
255
|
+
: "Join conditions with `and` or `or`.",
|
|
256
|
+
rest.at,
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
return node;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function emit(
|
|
263
|
+
node: QueryNode,
|
|
264
|
+
words: string[],
|
|
265
|
+
booleans: ReadonlySet<string>,
|
|
266
|
+
): void {
|
|
267
|
+
switch (node.kind) {
|
|
268
|
+
case "truthy":
|
|
269
|
+
words.push(booleans.has(node.name) ? `${node.name}=yes` : node.name);
|
|
270
|
+
return;
|
|
271
|
+
case "compare": {
|
|
272
|
+
const { name, value } = node;
|
|
273
|
+
switch (node.op) {
|
|
274
|
+
case "=":
|
|
275
|
+
words.push(`${name}=${value}`);
|
|
276
|
+
return;
|
|
277
|
+
case "!=":
|
|
278
|
+
words.push(`${name}=${value}`, "#!");
|
|
279
|
+
return;
|
|
280
|
+
// `where` counts a row without the property as smaller than any
|
|
281
|
+
// value; API `<`/`>` skip such rows, so `<` adds them back (`-a`).
|
|
282
|
+
case "<":
|
|
283
|
+
words.push(`<${name}=${value}`, `-${name}`, "#|");
|
|
284
|
+
return;
|
|
285
|
+
case ">":
|
|
286
|
+
words.push(`>${name}=${value}`);
|
|
287
|
+
return;
|
|
288
|
+
case "<=":
|
|
289
|
+
words.push(`>${name}=${value}`, "#!");
|
|
290
|
+
return;
|
|
291
|
+
case ">=":
|
|
292
|
+
words.push(`<${name}=${value}`, `-${name}`, "#|", "#!");
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
case "not":
|
|
298
|
+
emit(node.operand, words, booleans);
|
|
299
|
+
words.push("#!");
|
|
300
|
+
return;
|
|
301
|
+
case "and":
|
|
302
|
+
case "or":
|
|
303
|
+
emit(node.left, words, booleans);
|
|
304
|
+
emit(node.right, words, booleans);
|
|
305
|
+
words.push(node.kind === "and" ? "#&" : "#|");
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function tokenize(expression: string): Token[] {
|
|
311
|
+
const tokens: Token[] = [];
|
|
312
|
+
let index = 0;
|
|
313
|
+
// After a comparison operator the next token is a value, never a name.
|
|
314
|
+
let wantValue = false;
|
|
315
|
+
while (index < expression.length) {
|
|
316
|
+
const char = expression[index] as string;
|
|
317
|
+
if (/\s/.test(char)) {
|
|
318
|
+
// RouterOS: `comment= x` is "expected comment value". `in` is a word,
|
|
319
|
+
// so a space follows it.
|
|
320
|
+
const last = tokens.at(-1);
|
|
321
|
+
if (wantValue && !(last?.kind === "op" && last.text === "in")) {
|
|
322
|
+
throw invalidQuery(
|
|
323
|
+
expression,
|
|
324
|
+
"A value must follow its operator without a space; quote a value that starts with one.",
|
|
325
|
+
index,
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
index++;
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
const at = index;
|
|
332
|
+
if (wantValue) {
|
|
333
|
+
wantValue = false;
|
|
334
|
+
if (char === '"') {
|
|
335
|
+
const { text, end } = readQuoted(expression, index);
|
|
336
|
+
tokens.push({ kind: "value", text, at });
|
|
337
|
+
index = end;
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
let end = index;
|
|
341
|
+
while (
|
|
342
|
+
end < expression.length &&
|
|
343
|
+
!BARE_VALUE_STOP.has(expression[end] as string)
|
|
344
|
+
) {
|
|
345
|
+
end++;
|
|
346
|
+
}
|
|
347
|
+
const text = expression.slice(index, end);
|
|
348
|
+
if (text.length === 0) {
|
|
349
|
+
break;
|
|
350
|
+
}
|
|
351
|
+
rejectScriptValue(expression, text, at);
|
|
352
|
+
// RouterOS chains it: `comment=x=yes` is `(comment=x)=yes`, and
|
|
353
|
+
// `comment=x!=y` (or `<=`, `>=`) matches nothing.
|
|
354
|
+
const chained = text.indexOf("=");
|
|
355
|
+
if (chained >= 0) {
|
|
356
|
+
throw invalidQuery(
|
|
357
|
+
expression,
|
|
358
|
+
`RouterOS reads a second \`=\` as another comparison; quote the value: ${expression.slice(tokens.at(-2)?.at ?? 0, at)}"${text}".`,
|
|
359
|
+
at + chained,
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
tokens.push({ kind: "value", text, at });
|
|
363
|
+
index = end;
|
|
364
|
+
continue;
|
|
365
|
+
}
|
|
366
|
+
const two = expression.slice(index, index + 2);
|
|
367
|
+
if (two === "&&" || two === "||") {
|
|
368
|
+
tokens.push({ kind: two === "&&" ? "and" : "or", at });
|
|
369
|
+
index += 2;
|
|
370
|
+
continue;
|
|
371
|
+
}
|
|
372
|
+
if (two === "!=" || two === "<=" || two === ">=") {
|
|
373
|
+
tokens.push({ kind: "op", text: two, at });
|
|
374
|
+
index += 2;
|
|
375
|
+
wantValue = true;
|
|
376
|
+
continue;
|
|
377
|
+
}
|
|
378
|
+
if (char === "=" || char === "<" || char === ">" || char === "~") {
|
|
379
|
+
tokens.push({ kind: "op", text: char, at });
|
|
380
|
+
index++;
|
|
381
|
+
wantValue = true;
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
384
|
+
if (char === "!" || char === "(" || char === ")") {
|
|
385
|
+
tokens.push({ kind: char === "!" ? "not" : char, at });
|
|
386
|
+
index++;
|
|
387
|
+
continue;
|
|
388
|
+
}
|
|
389
|
+
const name = NAME.exec(expression.slice(index))?.[0];
|
|
390
|
+
if (name === undefined) {
|
|
391
|
+
throw invalidQuery(
|
|
392
|
+
expression,
|
|
393
|
+
`Unexpected \`${char}\`; expected a property name, \`!\` or \`(\`.`,
|
|
394
|
+
at,
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
if (name === "and" || name === "or") {
|
|
398
|
+
tokens.push({ kind: name, at });
|
|
399
|
+
} else if (name === "in") {
|
|
400
|
+
tokens.push({ kind: "op", text: "in", at });
|
|
401
|
+
wantValue = true;
|
|
402
|
+
} else {
|
|
403
|
+
tokens.push({ kind: "name", text: name, at });
|
|
404
|
+
}
|
|
405
|
+
index += name.length;
|
|
406
|
+
}
|
|
407
|
+
return tokens;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function readQuoted(
|
|
411
|
+
expression: string,
|
|
412
|
+
start: number,
|
|
413
|
+
): { text: string; end: number } {
|
|
414
|
+
let text = "";
|
|
415
|
+
let index = start + 1;
|
|
416
|
+
while (index < expression.length) {
|
|
417
|
+
const char = expression[index] as string;
|
|
418
|
+
if (char === '"') {
|
|
419
|
+
return { text, end: index + 1 };
|
|
420
|
+
}
|
|
421
|
+
if (char === "$") {
|
|
422
|
+
throw scriptValue(expression, start);
|
|
423
|
+
}
|
|
424
|
+
if (char !== "\\") {
|
|
425
|
+
text += char;
|
|
426
|
+
index++;
|
|
427
|
+
continue;
|
|
428
|
+
}
|
|
429
|
+
const next = expression[index + 1] ?? "";
|
|
430
|
+
const hex = expression.slice(index + 1, index + 3);
|
|
431
|
+
if (next in ESCAPES) {
|
|
432
|
+
text += ESCAPES[next];
|
|
433
|
+
index += 2;
|
|
434
|
+
} else if (/[ \t\r\n]/.test(next)) {
|
|
435
|
+
// A backslash before whitespace joins the lines: it and the whole
|
|
436
|
+
// whitespace run after it are dropped (`"x\<LF> y"` is `xy`).
|
|
437
|
+
index++;
|
|
438
|
+
while (/[ \t\r\n]/.test(expression[index] ?? "")) index++;
|
|
439
|
+
} else if (HEX_ESCAPE.test(hex)) {
|
|
440
|
+
const byte = Number.parseInt(hex, 16);
|
|
441
|
+
if (byte > 0x7f) {
|
|
442
|
+
// RouterOS stores the raw byte; centrs sends values as UTF-8.
|
|
443
|
+
throw invalidQuery(
|
|
444
|
+
expression,
|
|
445
|
+
`\`\\${hex}\` is a byte above 7F, which a query value cannot carry; write the character itself.`,
|
|
446
|
+
index,
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
text += String.fromCharCode(byte);
|
|
450
|
+
index += 3;
|
|
451
|
+
} else {
|
|
452
|
+
throw invalidQuery(
|
|
453
|
+
expression,
|
|
454
|
+
`\`\\${next}\` is not a RouterOS string escape.`,
|
|
455
|
+
index,
|
|
456
|
+
);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
throw invalidQuery(expression, "A quoted value is never closed.", start);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function rejectScriptValue(expression: string, text: string, at: number): void {
|
|
463
|
+
if (text.startsWith("$") || text.startsWith("[") || text.startsWith("{")) {
|
|
464
|
+
throw scriptValue(expression, at);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function invalidQuery(
|
|
469
|
+
expression: string,
|
|
470
|
+
summary: string,
|
|
471
|
+
at: number,
|
|
472
|
+
): CentrsError {
|
|
473
|
+
return new CentrsError({
|
|
474
|
+
code: "input/invalid-query",
|
|
475
|
+
summary: `Invalid --query: ${summary}`,
|
|
476
|
+
remediation:
|
|
477
|
+
'Write the query as you would after `print where`: `name=ether1`, `mtu>=1500 and !disabled`, `(type=ether or type=vlan)`. Quote values with spaces: `comment="uplink a"`.',
|
|
478
|
+
context: { query: expression, offset: at },
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function unsupportedOperator(
|
|
483
|
+
expression: string,
|
|
484
|
+
operator: "~" | "in",
|
|
485
|
+
at: number,
|
|
486
|
+
): CentrsError {
|
|
487
|
+
return new CentrsError({
|
|
488
|
+
code: "input/unsupported-query",
|
|
489
|
+
summary:
|
|
490
|
+
operator === "~"
|
|
491
|
+
? "`~` (regex) has no RouterOS API query word, so --query cannot run it on the router."
|
|
492
|
+
: "`in` (prefix membership) has no RouterOS API query word, so --query cannot run it on the router.",
|
|
493
|
+
remediation:
|
|
494
|
+
"Read the rows without the condition and filter them yourself, or run the console form with `centrs execute <router> '<menu>/print where …'` (console text, not rows).",
|
|
495
|
+
context: { query: expression, operator, offset: at },
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function scriptValue(expression: string, at: number): CentrsError {
|
|
500
|
+
return new CentrsError({
|
|
501
|
+
code: "input/unsupported-query",
|
|
502
|
+
summary:
|
|
503
|
+
"--query values are literal: `$variables`, `[commands]` and `{arrays}` need RouterOS's script evaluator.",
|
|
504
|
+
remediation:
|
|
505
|
+
"Put the literal value in the query (escape a `$` inside quotes as `\\$`), or run the console form with `centrs execute`.",
|
|
506
|
+
context: { query: expression, offset: at },
|
|
507
|
+
});
|
|
508
|
+
}
|
|
@@ -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
|
*
|
|
@@ -260,7 +326,7 @@ export function mapRouterOsError(
|
|
|
260
326
|
? `RouterOS reported an error: ${trimmed}`
|
|
261
327
|
: "RouterOS reported an unspecified error.",
|
|
262
328
|
remediation:
|
|
263
|
-
"
|
|
329
|
+
"RouterOS refused the request, and its message is the reason. If the message names part of the input (a path, parameter or value), fix that; otherwise the router may be busy or the operation unavailable right now.",
|
|
264
330
|
context: { ...baseContext, ...opts.context },
|
|
265
331
|
...(position ? { position } : {}),
|
|
266
332
|
causeData: raw,
|