@tikoci/centrs 0.1.9-next.24 → 0.1.9-next.27
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 +57 -0
- package/GLOSSARY.txt +9 -1
- package/README.md +28 -16
- package/commands/api/AGENTS.md +3 -2
- package/commands/api/README.md +8 -1
- package/commands/btest/README.md +9 -0
- package/commands/btest/examples.md +7 -0
- package/commands/devices/README.md +72 -3
- package/commands/devices/examples.md +56 -1
- package/commands/execute/README.md +11 -0
- package/commands/explain/README.md +223 -3
- package/commands/explain/examples.md +8 -1
- package/commands/retrieve/README.md +22 -1
- package/commands/retrieve/examples.md +84 -0
- package/commands/transfer/README.md +25 -0
- package/commands/transfer/examples.md +46 -0
- package/docs/CLI.md +11 -3
- package/docs/MATRIX.md +5 -3
- package/docs/errors/cdb/encrypt-failed.md +10 -0
- package/docs/errors/cdb/password-required.md +5 -4
- package/docs/errors/cdb/plaintext-backups.md +11 -0
- package/docs/errors/input/invalid-routeros-path.md +25 -3
- package/package.json +8 -4
- package/src/api-fanout.ts +2 -0
- package/src/api.ts +12 -0
- package/src/btest.ts +7 -0
- package/src/cli/api.ts +18 -1
- package/src/cli/btest.ts +9 -0
- package/src/cli/devices.ts +76 -8
- package/src/cli/execute.ts +9 -0
- package/src/cli/retrieve.ts +9 -0
- package/src/cli/terminal.ts +9 -0
- package/src/cli/transfer.ts +10 -1
- package/src/core/error-catalog.ts +13 -2
- package/src/core/menu-path.ts +89 -0
- package/src/devices.ts +154 -9
- package/src/execute-fanout.ts +2 -0
- package/src/execute.ts +7 -0
- package/src/explain/blocks.ts +2 -2
- package/src/explain/catalog.ts +22 -12
- package/src/explain/pasted-prompt.ts +198 -0
- package/src/explain/pathresolve.ts +2 -2
- package/src/explain/segment.ts +2 -2
- package/src/explain/symbols.ts +2 -2
- package/src/explain/verbsplit.ts +2 -2
- package/src/explain/write.ts +2 -2
- package/src/explain.ts +2 -0
- package/src/index.ts +4 -0
- package/src/protocols/adapter.ts +2 -0
- package/src/protocols/btest-session.ts +10 -30
- package/src/protocols/ec-srp5.ts +19 -10
- package/src/protocols/mac-telnet-console.ts +2 -2
- package/src/resolver/cdb.ts +58 -2
- package/src/resolver/index.ts +1 -0
- package/src/resolver/quickchr-provider.ts +5 -0
- package/src/resolver/selection.ts +11 -0
- package/src/retrieve-fanout.ts +2 -0
- package/src/retrieve.ts +11 -0
- package/src/settings.ts +1 -0
- package/src/terminal.ts +7 -0
- package/src/transfer-fanout.ts +2 -0
- package/src/transfer.ts +79 -14
|
@@ -1,9 +1,31 @@
|
|
|
1
1
|
# `input/invalid-routeros-path`
|
|
2
2
|
|
|
3
|
-
The RouterOS path is malformed
|
|
3
|
+
The RouterOS path is malformed: not slash-prefixed, a segment that is not a
|
|
4
|
+
menu name, or characters a REST URL would reinterpret.
|
|
5
|
+
|
|
6
|
+
## Typical triggers
|
|
7
|
+
|
|
8
|
+
- A path without a leading slash: `retrieve <router> ip/address`.
|
|
9
|
+
- A `retrieve` or `api` path segment that is not a RouterOS menu name. Menu
|
|
10
|
+
names are letters, digits and hyphens (case folds) (`/interface/6to4`,
|
|
11
|
+
`/ip/dhcp-server`). The check runs on every transport and even with
|
|
12
|
+
`--validate=false`, because a read must only dispatch a read (#586).
|
|
13
|
+
- A REST request path containing `?`, `#`, `\`, a control character, or a
|
|
14
|
+
`.`/`..` segment, including percent-encoded forms such as `%2e%2e`. The
|
|
15
|
+
URL parser would turn these into a query, a fragment
|
|
16
|
+
or a different path, so the request would not reach the path as written.
|
|
17
|
+
Before this guard, `retrieve '/system/reboot#' --no-validate` posted
|
|
18
|
+
`/rest/system/reboot`.
|
|
19
|
+
|
|
20
|
+
`context.path` names the path; `context.invalidSegments` lists the segments
|
|
21
|
+
that failed the menu grammar.
|
|
4
22
|
|
|
5
23
|
## Fix
|
|
6
24
|
|
|
25
|
+
- Write the path as slash-separated menu names: `/ip/address`,
|
|
26
|
+
`/system/resource`.
|
|
27
|
+
- Select rows with `--query` (for example `--query 'name="a#b"'`), not with
|
|
28
|
+
characters in the path.
|
|
29
|
+
|
|
7
30
|
See [`docs/CONSTITUTION.md`](../../CONSTITUTION.md) for the centrs error
|
|
8
|
-
contract.
|
|
9
|
-
for `input/invalid-routeros-path`.
|
|
31
|
+
contract.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tikoci/centrs",
|
|
3
|
-
"version": "0.1.9-next.
|
|
3
|
+
"version": "0.1.9-next.27",
|
|
4
4
|
"main": "src/index.ts",
|
|
5
5
|
"module": "src/index.ts",
|
|
6
6
|
"repository": {
|
|
@@ -12,18 +12,18 @@
|
|
|
12
12
|
"packageManager": "bun@1.4.2",
|
|
13
13
|
"license": "MIT",
|
|
14
14
|
"devDependencies": {
|
|
15
|
-
"@biomejs/biome": "2.
|
|
15
|
+
"@biomejs/biome": "2.6.3",
|
|
16
16
|
"@secretlint/secretlint-rule-preset-recommend": "^13.0.7",
|
|
17
17
|
"@types/bun": "1.4.2",
|
|
18
18
|
"cloc": "^2.6.0-cloc",
|
|
19
19
|
"cspell": "^10.3.6",
|
|
20
20
|
"has-flag": "4.0.0",
|
|
21
|
-
"markdownlint-cli2": "^0.
|
|
21
|
+
"markdownlint-cli2": "^0.24.0",
|
|
22
22
|
"secretlint": "^13.0.7",
|
|
23
23
|
"typedoc": "^0.28.20"
|
|
24
24
|
},
|
|
25
25
|
"optionalDependencies": {
|
|
26
|
-
"@tikoci/quickchr": "^0.
|
|
26
|
+
"@tikoci/quickchr": "^0.5.2"
|
|
27
27
|
},
|
|
28
28
|
"peerDependencies": {
|
|
29
29
|
"typescript": "^6.0.3"
|
|
@@ -118,6 +118,8 @@
|
|
|
118
118
|
"explain:operator-readme:check": "bun run scripts/explain-operator-slice.ts --readme --check",
|
|
119
119
|
"explain:operator-slice": "bun run scripts/explain-operator-slice.ts",
|
|
120
120
|
"explain:probe:brace-slots": "bun run scripts/probes/explain-brace-slot-sweep.ts",
|
|
121
|
+
"explain:probe:context-matrix": "bun run scripts/probes/explain-context-matrix.ts",
|
|
122
|
+
"explain:probe:corrected-anchors": "bun run scripts/probes/explain-corrected-anchors.ts",
|
|
121
123
|
"explain:probe:escapes": "bun run scripts/probes/explain-escape-sweep.ts",
|
|
122
124
|
"explain:probe:highlight-recapture": "bun run scripts/probes/explain-highlight-recapture.ts",
|
|
123
125
|
"explain:probe:operators": "bun run scripts/probes/explain-operator-sweep.ts",
|
|
@@ -125,6 +127,8 @@
|
|
|
125
127
|
"explain:probe:symbol-arms-score": "bun run scripts/probes/explain-symbol-arms-score.ts",
|
|
126
128
|
"explain:probe:symbol-bad-sigil-replay": "bun run scripts/probes/explain-symbol-bad-sigil-replay.ts",
|
|
127
129
|
"explain:probe:symbol-classes-replay": "bun run scripts/probes/explain-symbol-classes-replay.ts",
|
|
130
|
+
"explain:probe:import-blocker-validity": "bun run scripts/probes/explain-import-blocker-validity.ts",
|
|
131
|
+
"explain:probe:import-dry-run": "bun run scripts/probes/explain-import-dry-run.ts",
|
|
128
132
|
"explain:probe:version-floors": "bun run scripts/probes/explain-version-floors.ts",
|
|
129
133
|
"explain:menus:check": "bun run scripts/gen-explain-menus.ts --check",
|
|
130
134
|
"explain:value-census": "bun run scripts/explain-value-census.ts",
|
package/src/api-fanout.ts
CHANGED
|
@@ -62,6 +62,7 @@ import {
|
|
|
62
62
|
import { promptForWriteConfirmation } from "./execute.ts";
|
|
63
63
|
import { plannedProtocols, type RouterOsProtocol } from "./protocols/index.ts";
|
|
64
64
|
import {
|
|
65
|
+
assertNoMatchInFanout,
|
|
65
66
|
emptyWriteSelectionError,
|
|
66
67
|
expandSelection,
|
|
67
68
|
isDefaultRecordTarget,
|
|
@@ -213,6 +214,7 @@ export async function apiFanout(
|
|
|
213
214
|
// missing endpoint or an invalid `-X` is an outer error regardless of how many
|
|
214
215
|
// targets the selection resolves to (so an empty selection cannot mask it).
|
|
215
216
|
validateApiRequestShape(request);
|
|
217
|
+
assertNoMatchInFanout(request.match);
|
|
216
218
|
const config = await loadEnvFileDefaults(env);
|
|
217
219
|
const method = parseApiMethod(request.method);
|
|
218
220
|
const requestSummary = apiRequestSummaryFromRequest(request, env);
|
package/src/api.ts
CHANGED
|
@@ -45,6 +45,7 @@ import {
|
|
|
45
45
|
isCommandNode,
|
|
46
46
|
pathTokens,
|
|
47
47
|
} from "./core/inspect.ts";
|
|
48
|
+
import { assertRouterOsMenuPath } from "./core/menu-path.ts";
|
|
48
49
|
import {
|
|
49
50
|
compileQueryWords,
|
|
50
51
|
type ParsedQuery,
|
|
@@ -160,6 +161,12 @@ export interface ApiRequest {
|
|
|
160
161
|
verbose?: boolean;
|
|
161
162
|
cdbFile?: string;
|
|
162
163
|
cdbPassword?: string;
|
|
164
|
+
/**
|
|
165
|
+
* `--match` selector for a `<router>` several CDB records share
|
|
166
|
+
* (`user=<name>`, `target=<addr>`, or a record type): the same selector
|
|
167
|
+
* `devices show --match` takes. Single-target only (#583).
|
|
168
|
+
*/
|
|
169
|
+
match?: string;
|
|
163
170
|
/** Opt-in host ARP resolution for a MAC target (`none` default, or `arp`). */
|
|
164
171
|
resolve?: string;
|
|
165
172
|
stdinIsTty?: boolean;
|
|
@@ -937,6 +944,7 @@ export async function resolveApiRequest(
|
|
|
937
944
|
targetInput: request.targetInput,
|
|
938
945
|
cdbFile: request.cdbFile,
|
|
939
946
|
cdbPassword: request.cdbPassword,
|
|
947
|
+
match: request.match,
|
|
940
948
|
},
|
|
941
949
|
env,
|
|
942
950
|
config,
|
|
@@ -2176,6 +2184,10 @@ export function validateApiRequestShape(request: ApiRequest): void {
|
|
|
2176
2184
|
"Pass a path with at least one menu segment, such as `ip/address`.",
|
|
2177
2185
|
});
|
|
2178
2186
|
}
|
|
2187
|
+
// Before transport choice and regardless of --validate: a `#`/`?` in the
|
|
2188
|
+
// path cut `/print` off the REST URL and a GET read posted to the bare
|
|
2189
|
+
// command, rebooting the router (#586).
|
|
2190
|
+
assertRouterOsMenuPath(normalized.path, { endpoint: request.endpoint });
|
|
2179
2191
|
}
|
|
2180
2192
|
|
|
2181
2193
|
/**
|
package/src/btest.ts
CHANGED
|
@@ -113,6 +113,12 @@ export interface BtestClientRequest {
|
|
|
113
113
|
natMode?: boolean;
|
|
114
114
|
cdbFile?: string;
|
|
115
115
|
cdbPassword?: string;
|
|
116
|
+
/**
|
|
117
|
+
* `--match` selector for a `<router>` several CDB records share
|
|
118
|
+
* (`user=<name>`, `target=<addr>`, or a record type): the same selector
|
|
119
|
+
* `devices show --match` takes. Single-target only (#583).
|
|
120
|
+
*/
|
|
121
|
+
match?: string;
|
|
116
122
|
env?: Record<string, string | undefined>;
|
|
117
123
|
signal?: AbortSignal;
|
|
118
124
|
/** Live per-interval frame callback (text/csv streaming). */
|
|
@@ -264,6 +270,7 @@ export async function btestClient(
|
|
|
264
270
|
targetInput: request.targetInput,
|
|
265
271
|
cdbFile: request.cdbFile,
|
|
266
272
|
cdbPassword: request.cdbPassword,
|
|
273
|
+
match: request.match,
|
|
267
274
|
},
|
|
268
275
|
env,
|
|
269
276
|
);
|
package/src/cli/api.ts
CHANGED
|
@@ -23,7 +23,10 @@ import {
|
|
|
23
23
|
formatCentrsErrorText,
|
|
24
24
|
} from "../errors.ts";
|
|
25
25
|
import { describeCentrs } from "../index.ts";
|
|
26
|
-
import {
|
|
26
|
+
import {
|
|
27
|
+
assertNoMatchInFanout,
|
|
28
|
+
assertNoQuickchrOverrideConflict,
|
|
29
|
+
} from "../resolver/index.ts";
|
|
27
30
|
import {
|
|
28
31
|
type CliCommandMetadata,
|
|
29
32
|
expectValue,
|
|
@@ -165,6 +168,12 @@ export const apiCommand: CliCommandMetadata = {
|
|
|
165
168
|
valueName: "<password>",
|
|
166
169
|
description: "Decrypt an encrypted WinBox CDB file.",
|
|
167
170
|
},
|
|
171
|
+
{
|
|
172
|
+
flag: "--match",
|
|
173
|
+
valueName: "<user=name|target=addr|record-type>",
|
|
174
|
+
description:
|
|
175
|
+
"Pick one of several CDB records saved for <router> (as `devices show --match`); single target only.",
|
|
176
|
+
},
|
|
168
177
|
{
|
|
169
178
|
flag: "--resolve",
|
|
170
179
|
valueName: "<none|arp>",
|
|
@@ -330,6 +339,9 @@ export function parseApiCliArgs(args: readonly string[]): ApiCliArgs {
|
|
|
330
339
|
case "--cdb-password":
|
|
331
340
|
parsed.cdbPassword = expectValue(args, ++index, arg);
|
|
332
341
|
break;
|
|
342
|
+
case "--match":
|
|
343
|
+
parsed.match = expectValue(args, ++index, arg);
|
|
344
|
+
break;
|
|
333
345
|
case "--resolve":
|
|
334
346
|
parsed.resolve = expectValue(args, ++index, arg);
|
|
335
347
|
break;
|
|
@@ -473,6 +485,11 @@ export async function runApiCli(args: readonly string[]): Promise<number> {
|
|
|
473
485
|
selectionFlags.quickchr[0] ?? "",
|
|
474
486
|
);
|
|
475
487
|
}
|
|
488
|
+
// `--match` + fan-out is refused here too, before `--input -` can block
|
|
489
|
+
// on stdin; `apiFanout` keeps the same check for library callers (#583).
|
|
490
|
+
if (isFanoutMode(selectionFlags, targetPositionals.length)) {
|
|
491
|
+
assertNoMatchInFanout(parsed.match);
|
|
492
|
+
}
|
|
476
493
|
|
|
477
494
|
// Resolve `--input` (file / stdin) before handing the body to the orchestrator.
|
|
478
495
|
if (parsed.inputPath !== undefined) {
|
package/src/cli/btest.ts
CHANGED
|
@@ -145,6 +145,12 @@ export const btestCommand: CliCommandMetadata = {
|
|
|
145
145
|
valueName: "<password>",
|
|
146
146
|
description: "(client) Decrypt an encrypted WinBox CDB file.",
|
|
147
147
|
},
|
|
148
|
+
{
|
|
149
|
+
flag: "--match",
|
|
150
|
+
valueName: "<user=name|target=addr|record-type>",
|
|
151
|
+
description:
|
|
152
|
+
"(client) Pick one of several CDB records saved for <router> (as `devices show --match`); `btest server` rejects it.",
|
|
153
|
+
},
|
|
148
154
|
{
|
|
149
155
|
flag: "--format",
|
|
150
156
|
valueName: `<${btestOutputFormats.join("|")}>`,
|
|
@@ -302,6 +308,9 @@ function parseClientArgs(args: readonly string[]): ClientCliArgs {
|
|
|
302
308
|
case "--cdb-password":
|
|
303
309
|
request.cdbPassword = expectValue(args, ++i, arg);
|
|
304
310
|
break;
|
|
311
|
+
case "--match":
|
|
312
|
+
request.match = expectValue(args, ++i, arg);
|
|
313
|
+
break;
|
|
305
314
|
case "--csv":
|
|
306
315
|
parsed.format = "csv";
|
|
307
316
|
break;
|
package/src/cli/devices.ts
CHANGED
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
describeCentrs,
|
|
23
23
|
devicesOutputFormats,
|
|
24
24
|
editInteractiveOnlyError,
|
|
25
|
+
encryptCdb,
|
|
25
26
|
initCdb,
|
|
26
27
|
listDevices,
|
|
27
28
|
listGroups,
|
|
@@ -48,10 +49,20 @@ import {
|
|
|
48
49
|
export const devicesCommand: CliCommandMetadata = {
|
|
49
50
|
name: "devices",
|
|
50
51
|
usage:
|
|
51
|
-
"centrs devices <init|list|show|groups|add|edit|set|remove> [args] [flags]",
|
|
52
|
+
"centrs devices <init|encrypt|list|show|groups|add|edit|set|remove> [args] [flags]",
|
|
52
53
|
summary:
|
|
53
54
|
"Inspect and mutate the CDB-backed device registry. `devices` is the only command that writes the CDB.",
|
|
54
55
|
options: [
|
|
56
|
+
{
|
|
57
|
+
flag: "--encrypt",
|
|
58
|
+
description:
|
|
59
|
+
"`init` only — require encrypted creation with a non-empty CDB password; refuse an existing open CDB.",
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
flag: "--no-backup",
|
|
63
|
+
description:
|
|
64
|
+
"`encrypt` only — skip the plaintext pre-conversion snapshot. Without that snapshot, a forgotten password cannot be recovered.",
|
|
65
|
+
},
|
|
55
66
|
{
|
|
56
67
|
flag: "--cdb-file",
|
|
57
68
|
valueName: "<path>",
|
|
@@ -62,7 +73,7 @@ export const devicesCommand: CliCommandMetadata = {
|
|
|
62
73
|
flag: "--cdb-password",
|
|
63
74
|
valueName: "<secret>",
|
|
64
75
|
description:
|
|
65
|
-
"Decrypt an encrypted CDB. Falls back to `CENTRS_CDB_PASSWORD`. Also used to re-encrypt on writes.",
|
|
76
|
+
"Decrypt an encrypted CDB. Falls back to `CENTRS_CDB_PASSWORD`. Also used for `init --encrypt`, `encrypt`, and to re-encrypt on writes.",
|
|
66
77
|
},
|
|
67
78
|
{
|
|
68
79
|
flag: "--via",
|
|
@@ -200,6 +211,7 @@ export const devicesCommand: CliCommandMetadata = {
|
|
|
200
211
|
|
|
201
212
|
type DevicesSubcommand =
|
|
202
213
|
| "init"
|
|
214
|
+
| "encrypt"
|
|
203
215
|
| "list"
|
|
204
216
|
| "show"
|
|
205
217
|
| "groups"
|
|
@@ -226,6 +238,8 @@ interface DevicesCliArgs {
|
|
|
226
238
|
subcommand?: DevicesSubcommand;
|
|
227
239
|
cdbFile?: string;
|
|
228
240
|
cdbPassword?: string;
|
|
241
|
+
encrypt?: boolean;
|
|
242
|
+
noBackup?: boolean;
|
|
229
243
|
group?: string;
|
|
230
244
|
where?: string[];
|
|
231
245
|
via?: string;
|
|
@@ -254,20 +268,44 @@ interface DevicesCliArgs {
|
|
|
254
268
|
bbox?: BboxPredicate;
|
|
255
269
|
}
|
|
256
270
|
|
|
271
|
+
/**
|
|
272
|
+
* Flags `init`/`encrypt` honor. They touch only the CDB file, never a record,
|
|
273
|
+
* so record, selection, and provenance flags would be silently ignored there.
|
|
274
|
+
*/
|
|
275
|
+
const CDB_FILE_FLAGS: Readonly<Record<"init" | "encrypt", readonly string[]>> =
|
|
276
|
+
{
|
|
277
|
+
init: ["--cdb-file", "--cdb-password", "--format", "--json", "--encrypt"],
|
|
278
|
+
encrypt: [
|
|
279
|
+
"--cdb-file",
|
|
280
|
+
"--cdb-password",
|
|
281
|
+
"--format",
|
|
282
|
+
"--json",
|
|
283
|
+
"--no-backup",
|
|
284
|
+
],
|
|
285
|
+
};
|
|
286
|
+
|
|
257
287
|
function parseDevicesCliArgs(args: readonly string[]): DevicesCliArgs {
|
|
258
288
|
const parsed: DevicesCliArgs = {};
|
|
259
289
|
const positional: string[] = [];
|
|
290
|
+
const suppliedFlags: string[] = [];
|
|
260
291
|
|
|
261
292
|
for (let index = 0; index < args.length; index += 1) {
|
|
262
293
|
const arg = args[index];
|
|
263
294
|
if (arg === undefined) {
|
|
264
295
|
continue;
|
|
265
296
|
}
|
|
297
|
+
if (arg.startsWith("-")) suppliedFlags.push(arg);
|
|
266
298
|
switch (arg) {
|
|
267
299
|
case "--help":
|
|
268
300
|
case "-h":
|
|
269
301
|
parsed.help = true;
|
|
270
302
|
break;
|
|
303
|
+
case "--encrypt":
|
|
304
|
+
parsed.encrypt = true;
|
|
305
|
+
break;
|
|
306
|
+
case "--no-backup":
|
|
307
|
+
parsed.noBackup = true;
|
|
308
|
+
break;
|
|
271
309
|
case "--cdb-file":
|
|
272
310
|
parsed.cdbFile = expectValue(args, ++index, arg);
|
|
273
311
|
break;
|
|
@@ -382,6 +420,7 @@ function parseDevicesCliArgs(args: readonly string[]): DevicesCliArgs {
|
|
|
382
420
|
|
|
383
421
|
const subcommands: readonly DevicesSubcommand[] = [
|
|
384
422
|
"init",
|
|
423
|
+
"encrypt",
|
|
385
424
|
"list",
|
|
386
425
|
"show",
|
|
387
426
|
"groups",
|
|
@@ -393,17 +432,23 @@ function parseDevicesCliArgs(args: readonly string[]): DevicesCliArgs {
|
|
|
393
432
|
const [rawSub, ...rest] = positional;
|
|
394
433
|
if (rawSub === undefined) {
|
|
395
434
|
throw new Error(
|
|
396
|
-
"`centrs devices` requires a subcommand: init, list, show, groups, add, edit, set, or remove.",
|
|
435
|
+
"`centrs devices` requires a subcommand: init, encrypt, list, show, groups, add, edit, set, or remove.",
|
|
397
436
|
);
|
|
398
437
|
}
|
|
399
438
|
// Resolve aliases (print/get/rm/delete) to the canonical verb before validating.
|
|
400
439
|
const sub = SUBCOMMAND_ALIASES[rawSub] ?? (rawSub as DevicesSubcommand);
|
|
401
440
|
if (!subcommands.includes(sub)) {
|
|
402
441
|
throw new Error(
|
|
403
|
-
`Unknown devices subcommand: ${rawSub}. Use init, list, show, groups, add, edit, set, or remove (aliases: print, get, rm, delete).`,
|
|
442
|
+
`Unknown devices subcommand: ${rawSub}. Use init, encrypt, list, show, groups, add, edit, set, or remove (aliases: print, get, rm, delete).`,
|
|
404
443
|
);
|
|
405
444
|
}
|
|
406
445
|
parsed.subcommand = sub;
|
|
446
|
+
if (parsed.encrypt && sub !== "init")
|
|
447
|
+
throw new Error(
|
|
448
|
+
"--encrypt is supported only by devices init; use devices encrypt for conversion.",
|
|
449
|
+
);
|
|
450
|
+
if (parsed.noBackup && sub !== "encrypt")
|
|
451
|
+
throw new Error("--no-backup is supported only by devices encrypt.");
|
|
407
452
|
|
|
408
453
|
// Reject flags that don't apply to the resolved subcommand instead of
|
|
409
454
|
// silently ignoring the user's input: `--where`/`--near`/`--bbox` select
|
|
@@ -446,6 +491,18 @@ function parseDevicesCliArgs(args: readonly string[]): DevicesCliArgs {
|
|
|
446
491
|
context: { subcommand: sub, flag: geoFlag },
|
|
447
492
|
});
|
|
448
493
|
}
|
|
494
|
+
if (sub === "init" || sub === "encrypt") {
|
|
495
|
+
const allowed = CDB_FILE_FLAGS[sub];
|
|
496
|
+
const ignored = suppliedFlags.find((flag) => !allowed.includes(flag));
|
|
497
|
+
if (ignored !== undefined) {
|
|
498
|
+
throw new CentrsError({
|
|
499
|
+
code: "input/invalid-command",
|
|
500
|
+
summary: `\`${ignored}\` is not supported by \`centrs devices ${sub}\`.`,
|
|
501
|
+
remediation: `\`centrs devices ${sub}\` accepts only ${allowed.map((flag) => `\`${flag}\``).join(", ")}. Edit records with \`centrs devices add\`/\`set\`.`,
|
|
502
|
+
context: { subcommand: sub, flag: ignored },
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
}
|
|
449
506
|
|
|
450
507
|
if (sub === "show" || sub === "edit" || sub === "remove") {
|
|
451
508
|
if (rest.length !== 1) {
|
|
@@ -511,7 +568,14 @@ function parseKvArg(token: string): CommentKvUpdate {
|
|
|
511
568
|
// canonical key a `--lon`/`--lat`/`--altitude` flag would use. A no-op for
|
|
512
569
|
// every non-geo key.
|
|
513
570
|
const key = canonicalizeGeoKey(token.slice(0, eq));
|
|
514
|
-
|
|
571
|
+
const value = parseKvValue(token.slice(eq + 1));
|
|
572
|
+
// An empty value removes the token (`devices set <t> via=` deletes the
|
|
573
|
+
// `via` override) instead of storing a literally-empty value, per
|
|
574
|
+
// `commands/devices/README.md` (Comment kv-soup). The core upsert/remove
|
|
575
|
+
// model lives in `applyCommentKv` (`src/resolver/comment-kv.ts`), which
|
|
576
|
+
// treats `value: null` as removal; the CLI lowers `key=` to that form
|
|
577
|
+
// here. There is currently no need to store a literally-empty value.
|
|
578
|
+
return { key, value: value === "" ? null : value };
|
|
515
579
|
}
|
|
516
580
|
|
|
517
581
|
/**
|
|
@@ -610,12 +674,16 @@ export async function runDevicesCli(args: readonly string[]): Promise<number> {
|
|
|
610
674
|
}
|
|
611
675
|
|
|
612
676
|
envSnapshot = Bun.env;
|
|
613
|
-
if (parsed.subcommand === "init") {
|
|
614
|
-
const
|
|
677
|
+
if (parsed.subcommand === "init" || parsed.subcommand === "encrypt") {
|
|
678
|
+
const options = {
|
|
615
679
|
cdbFile: parsed.cdbFile,
|
|
616
680
|
cdbPassword: parsed.cdbPassword,
|
|
617
681
|
env: envSnapshot,
|
|
618
|
-
}
|
|
682
|
+
};
|
|
683
|
+
const envelope =
|
|
684
|
+
parsed.subcommand === "init"
|
|
685
|
+
? await initCdb({ ...options, encrypt: parsed.encrypt })
|
|
686
|
+
: await encryptCdb({ ...options, skipBackup: parsed.noBackup });
|
|
619
687
|
console.log(renderDevicesEnvelope(envelope, parsed.format ?? "text"));
|
|
620
688
|
return 0;
|
|
621
689
|
}
|
package/src/cli/execute.ts
CHANGED
|
@@ -121,6 +121,12 @@ export const executeCommand: CliCommandMetadata = {
|
|
|
121
121
|
valueName: "<password>",
|
|
122
122
|
description: "Decrypt an encrypted WinBox CDB file.",
|
|
123
123
|
},
|
|
124
|
+
{
|
|
125
|
+
flag: "--match",
|
|
126
|
+
valueName: "<user=name|target=addr|record-type>",
|
|
127
|
+
description:
|
|
128
|
+
"Pick one of several CDB records saved for <router> (as `devices show --match`); single target only.",
|
|
129
|
+
},
|
|
124
130
|
{
|
|
125
131
|
flag: "--resolve",
|
|
126
132
|
valueName: "<none|arp>",
|
|
@@ -262,6 +268,9 @@ export function parseExecuteCliArgs(args: readonly string[]): ExecuteCliArgs {
|
|
|
262
268
|
case "--cdb-password":
|
|
263
269
|
parsed.cdbPassword = expectValue(args, ++index, arg);
|
|
264
270
|
break;
|
|
271
|
+
case "--match":
|
|
272
|
+
parsed.match = expectValue(args, ++index, arg);
|
|
273
|
+
break;
|
|
265
274
|
case "--resolve":
|
|
266
275
|
parsed.resolve = expectValue(args, ++index, arg);
|
|
267
276
|
break;
|
package/src/cli/retrieve.ts
CHANGED
|
@@ -199,6 +199,12 @@ export const retrieveCommand: CliCommandMetadata = {
|
|
|
199
199
|
valueName: "<password>",
|
|
200
200
|
description: "Decrypt an encrypted WinBox CDB file.",
|
|
201
201
|
},
|
|
202
|
+
{
|
|
203
|
+
flag: "--match",
|
|
204
|
+
valueName: "<user=name|target=addr|record-type>",
|
|
205
|
+
description:
|
|
206
|
+
"Pick one of several CDB records saved for <router> (as `devices show --match`); single target only.",
|
|
207
|
+
},
|
|
202
208
|
{
|
|
203
209
|
flag: "--resolve",
|
|
204
210
|
valueName: "<none|arp>",
|
|
@@ -537,6 +543,9 @@ function parseRetrieveCliArgs(args: readonly string[]): RetrieveCliArgs {
|
|
|
537
543
|
case "--cdb-password":
|
|
538
544
|
request.cdbPassword = expectValue(args, ++index, arg);
|
|
539
545
|
break;
|
|
546
|
+
case "--match":
|
|
547
|
+
request.match = expectValue(args, ++index, arg);
|
|
548
|
+
break;
|
|
540
549
|
case "--resolve":
|
|
541
550
|
request.resolve = expectValue(args, ++index, arg);
|
|
542
551
|
break;
|
package/src/cli/terminal.ts
CHANGED
|
@@ -97,6 +97,12 @@ export const terminalCommand: CliCommandMetadata = {
|
|
|
97
97
|
valueName: "<password>",
|
|
98
98
|
description: "Decrypt an encrypted WinBox CDB file.",
|
|
99
99
|
},
|
|
100
|
+
{
|
|
101
|
+
flag: "--match",
|
|
102
|
+
valueName: "<user=name|target=addr|record-type>",
|
|
103
|
+
description:
|
|
104
|
+
"Pick one of several CDB records saved for <router> (as `devices show --match`); single target only.",
|
|
105
|
+
},
|
|
100
106
|
{
|
|
101
107
|
flag: "--resolve",
|
|
102
108
|
valueName: "<none|arp>",
|
|
@@ -259,6 +265,9 @@ export function parseTerminalCliArgs(args: readonly string[]): ParsedTerminal {
|
|
|
259
265
|
case "--cdb-password":
|
|
260
266
|
flags.cdbPassword = expectValue(args, ++index, arg);
|
|
261
267
|
break;
|
|
268
|
+
case "--match":
|
|
269
|
+
flags.match = expectValue(args, ++index, arg);
|
|
270
|
+
break;
|
|
262
271
|
case "--resolve":
|
|
263
272
|
flags.resolve = expectValue(args, ++index, arg);
|
|
264
273
|
break;
|
package/src/cli/transfer.ts
CHANGED
|
@@ -73,7 +73,7 @@ export const transferCommand: CliCommandMetadata = {
|
|
|
73
73
|
flag: "--via",
|
|
74
74
|
valueName: "<method>",
|
|
75
75
|
description:
|
|
76
|
-
"Pin the method: rest, native, or sftp (large transfers); scp/fetch/ftp are not built yet. Auto picks the cheapest by size/direction.",
|
|
76
|
+
"Pin the method: rest, native, or sftp (large transfers; `ssh` is an alias); scp/fetch/ftp are not built yet. Auto picks the cheapest by size/direction, after a CDB record's `via=` (ssh→sftp).",
|
|
77
77
|
},
|
|
78
78
|
{
|
|
79
79
|
flag: "--ssh-key",
|
|
@@ -164,6 +164,12 @@ export const transferCommand: CliCommandMetadata = {
|
|
|
164
164
|
valueName: "<password>",
|
|
165
165
|
description: "Decrypt an encrypted WinBox CDB file.",
|
|
166
166
|
},
|
|
167
|
+
{
|
|
168
|
+
flag: "--match",
|
|
169
|
+
valueName: "<user=name|target=addr|record-type>",
|
|
170
|
+
description:
|
|
171
|
+
"Pick one of several CDB records saved for <router> (as `devices show --match`); single target only.",
|
|
172
|
+
},
|
|
167
173
|
{
|
|
168
174
|
flag: "--validate / --no-validate",
|
|
169
175
|
description: "Enable or disable the existence-probe preflight.",
|
|
@@ -414,6 +420,9 @@ export function parseTransferCliArgs(
|
|
|
414
420
|
case "--cdb-password":
|
|
415
421
|
flags.cdbPassword = expectValue(args, ++index, arg);
|
|
416
422
|
break;
|
|
423
|
+
case "--match":
|
|
424
|
+
flags.match = expectValue(args, ++index, arg);
|
|
425
|
+
break;
|
|
417
426
|
case "--validate":
|
|
418
427
|
flags.validate = true;
|
|
419
428
|
break;
|
|
@@ -53,6 +53,15 @@ export const errorCatalog: readonly ErrorCatalogEntry[] = [
|
|
|
53
53
|
summary:
|
|
54
54
|
"The encrypted CDB could not be decrypted (wrong or missing password).",
|
|
55
55
|
},
|
|
56
|
+
{
|
|
57
|
+
code: "cdb/encrypt-failed",
|
|
58
|
+
summary: "The CDB could not be rewritten with encryption.",
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
code: "cdb/plaintext-backups",
|
|
62
|
+
summary:
|
|
63
|
+
"CDB backups may still contain plaintext credentials after encryption.",
|
|
64
|
+
},
|
|
56
65
|
{
|
|
57
66
|
code: "cdb/empty-group",
|
|
58
67
|
summary:
|
|
@@ -95,7 +104,8 @@ export const errorCatalog: readonly ErrorCatalogEntry[] = [
|
|
|
95
104
|
},
|
|
96
105
|
{
|
|
97
106
|
code: "cdb/password-required",
|
|
98
|
-
summary:
|
|
107
|
+
summary:
|
|
108
|
+
"A CDB password is required for decryption or explicit encryption.",
|
|
99
109
|
},
|
|
100
110
|
{
|
|
101
111
|
code: "cdb/reserved-key",
|
|
@@ -187,7 +197,8 @@ export const errorCatalog: readonly ErrorCatalogEntry[] = [
|
|
|
187
197
|
},
|
|
188
198
|
{
|
|
189
199
|
code: "input/invalid-routeros-path",
|
|
190
|
-
summary:
|
|
200
|
+
summary:
|
|
201
|
+
"The RouterOS path is malformed: not slash-prefixed, a segment that is not a menu name, or characters a REST URL would reinterpret.",
|
|
191
202
|
},
|
|
192
203
|
{
|
|
193
204
|
code: "input/invalid-target",
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RouterOS menu-path grammar for the read surfaces (`retrieve`, `api`) and
|
|
3
|
+
* the REST URL guard every REST request passes through (#586).
|
|
4
|
+
*
|
|
5
|
+
* Every dir and command name in the RouterOS 7.9–7.26 command tree (rosetta
|
|
6
|
+
* v0.12.0, 579 dirs / 5370 commands) is letters, digits and hyphens,
|
|
7
|
+
* starting with a letter or digit (`/interface/6to4`); case folds. Anything else
|
|
8
|
+
* in a menu segment is not a menu, and some of it changes what the request
|
|
9
|
+
* addresses: in a REST URL, `#` and `?` turn the appended `/print` into a
|
|
10
|
+
* fragment or query, so `retrieve '/system/reboot#'` posted to
|
|
11
|
+
* `/rest/system/reboot` and rebooted the router (CHR 7.24.5).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { CentrsError } from "../errors.ts";
|
|
15
|
+
import { pathTokens } from "./inspect.ts";
|
|
16
|
+
|
|
17
|
+
const MENU_SEGMENT = /^[a-z0-9][a-z0-9-]*$/;
|
|
18
|
+
|
|
19
|
+
/** The menu segments of `path` that are not RouterOS menu or command names. */
|
|
20
|
+
export function invalidMenuSegments(path: string): string[] {
|
|
21
|
+
// Case folds, as RouterOS menus do (`src/explain/menus.ts` isMenuPath);
|
|
22
|
+
// letter case cannot change how a URL parses, so it is not a hazard.
|
|
23
|
+
return pathTokens(path).filter(
|
|
24
|
+
(segment) => !MENU_SEGMENT.test(segment.toLowerCase()),
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Reject a read-surface path whose segments are not RouterOS menu names.
|
|
30
|
+
* Runs before any network work and regardless of `--validate`: that flag
|
|
31
|
+
* skips command validation, not the guarantee that a read dispatches a read.
|
|
32
|
+
*/
|
|
33
|
+
export function assertRouterOsMenuPath(
|
|
34
|
+
path: string,
|
|
35
|
+
context: { readonly flag?: string; readonly endpoint?: string } = {},
|
|
36
|
+
): void {
|
|
37
|
+
const invalid = invalidMenuSegments(path);
|
|
38
|
+
if (invalid.length === 0) {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
throw new CentrsError({
|
|
42
|
+
code: "input/invalid-routeros-path",
|
|
43
|
+
summary: `RouterOS path ${JSON.stringify(path)} has a segment that is not a menu name: ${invalid.map((segment) => JSON.stringify(segment)).join(", ")}.`,
|
|
44
|
+
remediation:
|
|
45
|
+
"Write the menu path with letters, digits and hyphens between slashes, such as `/ip/address` or `/system/resource`. Filter rows with `--query`, not characters in the path.",
|
|
46
|
+
context: { path, invalidSegments: invalid, ...context },
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Characters and segments that make a WHATWG URL address something other
|
|
52
|
+
* than the path as written: `?` starts a query, `#` a fragment, `\` is read
|
|
53
|
+
* as `/`, tabs and newlines are stripped, and `.`/`..` segments collapse
|
|
54
|
+
* (also percent-encoded: `%2e%2e`, `.%2E`).
|
|
55
|
+
* RouterOS does not decode percent-escapes in REST paths (`reboot%23` is
|
|
56
|
+
* "no such command or directory" on CHR 7.24.5), so the rest is inert.
|
|
57
|
+
*/
|
|
58
|
+
function hasUrlStructuralCharacter(path: string): boolean {
|
|
59
|
+
for (const char of path) {
|
|
60
|
+
const code = char.charCodeAt(0);
|
|
61
|
+
if (char === "?" || char === "#" || char === "\\") return true;
|
|
62
|
+
if (code <= 0x1f || code === 0x7f) return true;
|
|
63
|
+
}
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** `.`/`..` and the percent-encoded forms WHATWG also collapses (`%2e%2e`, `.%2E`). */
|
|
68
|
+
const DOT_SEGMENT = /^(?:\.|%2e){1,2}$/i;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Refuse a REST request path that the URL parser would rewrite. This is the
|
|
72
|
+
* backstop under every REST caller (retrieve, api, execute, transfer, explain)
|
|
73
|
+
* so an appended `/print` can never be cut off before dispatch.
|
|
74
|
+
*/
|
|
75
|
+
export function assertRestRequestPath(path: string): void {
|
|
76
|
+
const dotSegment = path
|
|
77
|
+
.split("/")
|
|
78
|
+
.some((segment) => DOT_SEGMENT.test(segment));
|
|
79
|
+
if (!hasUrlStructuralCharacter(path) && !dotSegment) {
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
throw new CentrsError({
|
|
83
|
+
code: "input/invalid-routeros-path",
|
|
84
|
+
summary: `Refusing REST request path ${JSON.stringify(path)}: it contains characters the URL would reinterpret (?, #, \\, control characters, or a . / .. segment), so the request would not reach the path as written.`,
|
|
85
|
+
remediation:
|
|
86
|
+
"Remove the character from the path. Pass filters with `--query` and values as fields; names containing these characters can be selected with a query instead of the URL path.",
|
|
87
|
+
context: { path, via: "rest-api" },
|
|
88
|
+
});
|
|
89
|
+
}
|