@tikoci/centrs 0.1.9-next.23 → 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 +163 -0
- package/GLOSSARY.txt +23 -1
- package/README.md +30 -18
- package/commands/api/AGENTS.md +21 -3
- package/commands/api/README.md +18 -7
- package/commands/api/examples.md +1 -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 +37 -6
- package/commands/execute/examples.md +24 -5
- package/commands/explain/README.md +675 -64
- package/commands/explain/examples.md +199 -42
- package/commands/retrieve/README.md +35 -3
- package/commands/retrieve/examples.md +114 -1
- package/commands/transfer/README.md +33 -4
- package/commands/transfer/examples.md +46 -0
- package/docs/CLI.md +30 -8
- package/docs/CONSTITUTION.md +14 -9
- package/docs/MATRIX.md +37 -22
- 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/cmr/caller-policy-not-enforced.md +5 -3
- package/docs/errors/input/invalid-routeros-path.md +25 -3
- package/docs/errors/routeros/permission-denied.md +24 -0
- package/docs/errors/transport/edge-auth-failed.md +22 -0
- package/docs/errors/transport/write-outcome-unknown.md +3 -0
- package/docs/errors/usage/invalid-cursor.md +20 -0
- package/docs/errors/validation/unknown-path.md +9 -0
- package/package.json +9 -5
- package/src/api-fanout.ts +2 -0
- package/src/api.ts +46 -39
- 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/explain.ts +206 -47
- package/src/cli/retrieve.ts +9 -0
- package/src/cli/terminal.ts +9 -0
- package/src/cli/transfer.ts +10 -1
- package/src/core/envelope.ts +10 -3
- package/src/core/error-catalog.ts +28 -2
- package/src/core/execute-command.ts +53 -0
- package/src/core/fanout.ts +12 -2
- package/src/core/inspect.ts +4 -5
- package/src/core/menu-path.ts +89 -0
- package/src/core/routeros-errors.ts +25 -0
- package/src/devices.ts +154 -9
- package/src/execute-cmr.ts +2 -8
- package/src/execute-fanout.ts +2 -0
- package/src/execute.ts +55 -20
- package/src/explain/args.ts +115 -10
- package/src/explain/blocks.ts +2 -2
- package/src/explain/catalog.ts +22 -12
- package/src/explain/group-faults.ts +162 -29
- package/src/explain/operators.ts +2 -1
- package/src/explain/pasted-prompt.ts +198 -0
- package/src/explain/pathresolve.ts +2 -2
- package/src/explain/scope-brace.ts +226 -11
- package/src/explain/segment.ts +138 -3
- package/src/explain/symbols.ts +148 -8
- package/src/explain/values.ts +55 -4
- package/src/explain/verbsplit.ts +2 -2
- package/src/explain/write.ts +2 -2
- package/src/explain-live-facets.ts +739 -0
- package/src/explain-live-types.ts +490 -0
- package/src/explain-live.ts +593 -0
- package/src/explain.ts +397 -43
- package/src/index.ts +10 -0
- package/src/protocols/adapter.ts +90 -10
- 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/protocols/mac-telnet.ts +38 -10
- package/src/protocols/native-api.ts +13 -12
- 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-follow.ts +2 -1
- package/src/retrieve-sample.ts +2 -1
- package/src/retrieve-wait.ts +10 -6
- package/src/retrieve.ts +88 -27
- package/src/settings.ts +1 -0
- package/src/terminal.ts +7 -0
- package/src/transfer-fanout.ts +2 -0
- package/src/transfer.ts +117 -27
|
@@ -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
|
+
}
|
|
@@ -107,6 +107,31 @@ function cleanToken(token: string): string {
|
|
|
107
107
|
* rules here; keep the most specific patterns above broader ones.
|
|
108
108
|
*/
|
|
109
109
|
export const routerOsErrorRules: readonly RouterOsErrorRule[] = [
|
|
110
|
+
{
|
|
111
|
+
code: "routeros/permission-denied",
|
|
112
|
+
description:
|
|
113
|
+
"RouterOS refused the command because the user's group lacks a policy it needs.",
|
|
114
|
+
// Grounded on CHR 7.25. REST answers HTTP 500 with this as `detail`:
|
|
115
|
+
// `not enough permissions (9)` for a write without `write`, and
|
|
116
|
+
// `std failure: not allowed (9)` for any request (even a GET) when the
|
|
117
|
+
// group has `rest-api` but not `api` (fixed in 7.26beta3). The native API traps with the
|
|
118
|
+
// first string; `/execute as-string` returns it as text with a
|
|
119
|
+
// `(<path>; line N)` suffix. In a multi-statement script the refusal is
|
|
120
|
+
// the last line, after earlier statements' stdout (#549: `:put
|
|
121
|
+
// "p5-first"; /system/identity/set …` as a read-only user returns HTTP
|
|
122
|
+
// 200 with ret `p5-first\r\nnot enough permissions (9)
|
|
123
|
+
// (/system/identity/set; line 1)`, identical on native-api), so the
|
|
124
|
+
// trailed form matches too — with the location suffix required, so a
|
|
125
|
+
// script that merely prints the bare phrase stays ordinary output.
|
|
126
|
+
test: /not enough permissions|not allowed \(9\)/i,
|
|
127
|
+
resultTest:
|
|
128
|
+
/^(?:not enough permissions \(9\)(?: \(\/[^\s();]+; line \d+\))?|[\s\S]*\r?\nnot enough permissions \(9\) \(\/[^\s();]+; line \d+\))$/i,
|
|
129
|
+
build: (_match, raw) => ({
|
|
130
|
+
summary: `RouterOS refused the command for lack of permission: ${raw.trim()}`,
|
|
131
|
+
remediation:
|
|
132
|
+
"Grant the missing policy to the RouterOS user's group (`/user/group`): `read` for reads, `write` for changes. If every REST request fails with `not allowed (9)`, the group has `rest-api` without `api`; before RouterOS 7.26beta3, REST needs both. Retrying will not help.",
|
|
133
|
+
}),
|
|
134
|
+
},
|
|
110
135
|
{
|
|
111
136
|
code: "routeros/unknown-path",
|
|
112
137
|
description:
|
package/src/devices.ts
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
decryptWinBoxCdb,
|
|
20
20
|
type EncryptedWinBoxCdbFile,
|
|
21
21
|
encodeOpenWinBoxCdb,
|
|
22
|
+
encryptWinBoxCdb,
|
|
22
23
|
parseWinBoxCdb,
|
|
23
24
|
type WinBoxCdbEntry,
|
|
24
25
|
type WinBoxCdbField,
|
|
@@ -112,6 +113,7 @@ export interface DevicesGroupSummary {
|
|
|
112
113
|
|
|
113
114
|
export type DevicesCommand =
|
|
114
115
|
| "init"
|
|
116
|
+
| "encrypt"
|
|
115
117
|
| "list"
|
|
116
118
|
| "show"
|
|
117
119
|
| "groups"
|
|
@@ -165,7 +167,7 @@ export interface LoadedCdb {
|
|
|
165
167
|
entries: readonly WinBoxCdbEntry[];
|
|
166
168
|
settings: DevicesSettings;
|
|
167
169
|
warnings: readonly DevicesWarning[];
|
|
168
|
-
/** True when the source file on disk was encrypted
|
|
170
|
+
/** True when the source file on disk was encrypted. */
|
|
169
171
|
encrypted: boolean;
|
|
170
172
|
}
|
|
171
173
|
|
|
@@ -232,7 +234,7 @@ function isAlreadyExistsError(cause: unknown): boolean {
|
|
|
232
234
|
}
|
|
233
235
|
|
|
234
236
|
/**
|
|
235
|
-
* Create an empty
|
|
237
|
+
* Create an empty CDB at `path` without clobbering an existing file.
|
|
236
238
|
* The bytes are written to a private temp file first and then hard-linked
|
|
237
239
|
* into place: `link` fails with EEXIST instead of replacing, and `path` never
|
|
238
240
|
* exists half-written, so a concurrent creator that loses the race reads a
|
|
@@ -242,14 +244,20 @@ function isAlreadyExistsError(cause: unknown): boolean {
|
|
|
242
244
|
* file. Mode `0o600` keeps the CDB (device credentials) unreadable by other
|
|
243
245
|
* local users instead of falling back to the process umask default.
|
|
244
246
|
*/
|
|
245
|
-
async function createEmptyCdbNoClobber(
|
|
247
|
+
async function createEmptyCdbNoClobber(
|
|
248
|
+
path: string,
|
|
249
|
+
password?: string,
|
|
250
|
+
): Promise<boolean> {
|
|
246
251
|
await mkdir(dirname(path), { recursive: true });
|
|
247
252
|
const tempPath = `${path}.init.${randomUUID()}`;
|
|
248
253
|
const handle = await open(tempPath, "wx", 0o600);
|
|
249
254
|
try {
|
|
250
255
|
try {
|
|
251
256
|
await handle.chmod(0o600);
|
|
252
|
-
|
|
257
|
+
const bytes = encodeOpenWinBoxCdb([]);
|
|
258
|
+
await handle.write(
|
|
259
|
+
password === undefined ? bytes : encryptWinBoxCdb(bytes, password),
|
|
260
|
+
);
|
|
253
261
|
await handle.sync();
|
|
254
262
|
} finally {
|
|
255
263
|
await handle.close();
|
|
@@ -270,11 +278,19 @@ export interface DevicesInitData {
|
|
|
270
278
|
cdbFile: string;
|
|
271
279
|
/** False when a readable CDB was already there; nothing was written. */
|
|
272
280
|
created: boolean;
|
|
281
|
+
/** Actual mode of the newly created or verified existing CDB. */
|
|
282
|
+
encrypted: boolean;
|
|
273
283
|
recordCount: number;
|
|
274
284
|
}
|
|
275
285
|
|
|
286
|
+
export interface InitCdbOptions extends LoadCdbOptions {
|
|
287
|
+
/** Require encryption; an existing open CDB is refused, never converted. */
|
|
288
|
+
encrypt?: boolean;
|
|
289
|
+
}
|
|
290
|
+
|
|
276
291
|
/**
|
|
277
|
-
* `devices init
|
|
292
|
+
* `devices init`: create an empty CDB (open by default, encrypted with
|
|
293
|
+
* `encrypt: true`) at the resolved path, the
|
|
278
294
|
* explicit counterpart of the default path's first-run auto-create. Never
|
|
279
295
|
* clobbers: like `git init`, re-running on an existing, readable CDB succeeds
|
|
280
296
|
* with a `cdb/file-exists` warning and writes nothing, so a workspace bootstrap
|
|
@@ -282,15 +298,29 @@ export interface DevicesInitData {
|
|
|
282
298
|
* (not a CDB, or encrypted without a password) fails with the load error.
|
|
283
299
|
*/
|
|
284
300
|
export async function initCdb(
|
|
285
|
-
options:
|
|
301
|
+
options: InitCdbOptions,
|
|
286
302
|
): Promise<CentrsSuccessEnvelope<DevicesInitData, DevicesOperationMeta>> {
|
|
287
303
|
const settings = resolveDevicesSettings(options);
|
|
288
304
|
const cdbFile = settings.cdbFile.value;
|
|
305
|
+
if (options.encrypt) requireEncryptionPassword(settings);
|
|
289
306
|
const existingResult = async () => {
|
|
290
307
|
const existing = await loadCdb(options);
|
|
308
|
+
if (options.encrypt && !existing.encrypted) {
|
|
309
|
+
throw new CentrsError({
|
|
310
|
+
code: "input/invalid-command",
|
|
311
|
+
summary: `An open CDB already exists at ${cdbFile}; encrypted initialization wrote nothing.`,
|
|
312
|
+
remediation: `Run \`centrs devices encrypt --cdb-file ${cdbFile}\` to convert the existing CDB explicitly.`,
|
|
313
|
+
context: { cdbFile },
|
|
314
|
+
});
|
|
315
|
+
}
|
|
291
316
|
return {
|
|
292
317
|
ok: true as const,
|
|
293
|
-
data: {
|
|
318
|
+
data: {
|
|
319
|
+
cdbFile,
|
|
320
|
+
created: false,
|
|
321
|
+
encrypted: existing.encrypted,
|
|
322
|
+
recordCount: existing.entries.length,
|
|
323
|
+
},
|
|
294
324
|
warnings: [
|
|
295
325
|
...existing.warnings,
|
|
296
326
|
{
|
|
@@ -310,7 +340,10 @@ export async function initCdb(
|
|
|
310
340
|
}
|
|
311
341
|
let created: boolean;
|
|
312
342
|
try {
|
|
313
|
-
created = await createEmptyCdbNoClobber(
|
|
343
|
+
created = await createEmptyCdbNoClobber(
|
|
344
|
+
cdbFile,
|
|
345
|
+
options.encrypt ? settings.cdbPassword.value : undefined,
|
|
346
|
+
);
|
|
314
347
|
} catch (cause) {
|
|
315
348
|
throw new CentrsError({
|
|
316
349
|
code: "cdb/create-failed",
|
|
@@ -326,13 +359,113 @@ export async function initCdb(
|
|
|
326
359
|
}
|
|
327
360
|
return {
|
|
328
361
|
ok: true,
|
|
329
|
-
data: {
|
|
362
|
+
data: {
|
|
363
|
+
cdbFile,
|
|
364
|
+
created: true,
|
|
365
|
+
encrypted: options.encrypt === true,
|
|
366
|
+
recordCount: 0,
|
|
367
|
+
},
|
|
330
368
|
warnings: [],
|
|
331
369
|
tips: [],
|
|
332
370
|
meta: devicesMeta("init", settings),
|
|
333
371
|
};
|
|
334
372
|
}
|
|
335
373
|
|
|
374
|
+
function requireEncryptionPassword(settings: DevicesSettings): void {
|
|
375
|
+
if (
|
|
376
|
+
!settings.cdbPassword.provided ||
|
|
377
|
+
settings.cdbPassword.value.length === 0
|
|
378
|
+
) {
|
|
379
|
+
throw new CentrsError({
|
|
380
|
+
code: "cdb/password-required",
|
|
381
|
+
summary: "CDB encryption requires a non-empty password.",
|
|
382
|
+
remediation:
|
|
383
|
+
"Pass --cdb-password or set CENTRS_CDB_PASSWORD to a non-empty CDB password.",
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
export interface EncryptCdbOptions extends LoadCdbOptions {
|
|
389
|
+
/**
|
|
390
|
+
* Skip the plaintext pre-conversion snapshot. Existing backups are still
|
|
391
|
+
* retained untouched; without the snapshot a forgotten password is final.
|
|
392
|
+
*/
|
|
393
|
+
skipBackup?: boolean;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
export interface DevicesEncryptData {
|
|
397
|
+
cdbFile: string;
|
|
398
|
+
encrypted: true;
|
|
399
|
+
/** False when the CDB was already encrypted and the password verified. */
|
|
400
|
+
changed: boolean;
|
|
401
|
+
recordCount: number;
|
|
402
|
+
/** Plaintext pre-conversion snapshot; absent when unchanged or `skipBackup`. */
|
|
403
|
+
backupPath?: string;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Explicitly convert an existing open CDB with a fresh salt and atomic replacement.
|
|
408
|
+
* Retain all backups: converting the active file does not protect old plaintext.
|
|
409
|
+
* Close WinBox and other writers first; this is not a cross-process transaction (#581).
|
|
410
|
+
* Already-encrypted files are verified with the supplied password and left untouched.
|
|
411
|
+
* `skipBackup` omits the new plaintext snapshot; it never deletes older backups.
|
|
412
|
+
*/
|
|
413
|
+
export async function encryptCdb(
|
|
414
|
+
options: EncryptCdbOptions,
|
|
415
|
+
): Promise<CentrsSuccessEnvelope<DevicesEncryptData, DevicesOperationMeta>> {
|
|
416
|
+
const settings = resolveDevicesSettings(options);
|
|
417
|
+
requireEncryptionPassword(settings);
|
|
418
|
+
const cdbFile = settings.cdbFile.value;
|
|
419
|
+
// Pin even the default path so conversion never implicitly creates a file.
|
|
420
|
+
const cdb = await loadCdb({ ...options, cdbFile });
|
|
421
|
+
let backupPath: string | undefined;
|
|
422
|
+
if (!cdb.encrypted) {
|
|
423
|
+
try {
|
|
424
|
+
const written = await writeWinBoxCdb(
|
|
425
|
+
cdbFile,
|
|
426
|
+
cdb.entries.map((entry) => entry.record),
|
|
427
|
+
{
|
|
428
|
+
encryptWith: { password: settings.cdbPassword.value },
|
|
429
|
+
backupRetention: -1,
|
|
430
|
+
skipBackup: options.skipBackup === true,
|
|
431
|
+
},
|
|
432
|
+
);
|
|
433
|
+
backupPath = written.backupPath;
|
|
434
|
+
} catch (cause) {
|
|
435
|
+
throw new CentrsError({
|
|
436
|
+
code: "cdb/encrypt-failed",
|
|
437
|
+
summary: `Could not encrypt the CDB at ${cdbFile}.`,
|
|
438
|
+
remediation:
|
|
439
|
+
"Check directory permissions and available disk space; retain the original and any backups before retrying.",
|
|
440
|
+
context: { cdbFile },
|
|
441
|
+
cause,
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
return {
|
|
446
|
+
ok: true,
|
|
447
|
+
data: {
|
|
448
|
+
cdbFile,
|
|
449
|
+
encrypted: true,
|
|
450
|
+
changed: !cdb.encrypted,
|
|
451
|
+
recordCount: cdb.entries.length,
|
|
452
|
+
...(backupPath ? { backupPath } : {}),
|
|
453
|
+
},
|
|
454
|
+
warnings: [
|
|
455
|
+
...cdb.warnings.filter(
|
|
456
|
+
(warning) => warning.code !== "cdb/password-not-needed",
|
|
457
|
+
),
|
|
458
|
+
{
|
|
459
|
+
code: "cdb/plaintext-backups",
|
|
460
|
+
message:
|
|
461
|
+
"Backups may contain plaintext credentials. Encryption protects only the active CDB; no backups were deleted or encrypted.",
|
|
462
|
+
},
|
|
463
|
+
],
|
|
464
|
+
tips: [],
|
|
465
|
+
meta: devicesMeta("encrypt", settings),
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
|
|
336
469
|
export async function loadCdb(options: LoadCdbOptions): Promise<LoadedCdb> {
|
|
337
470
|
const settings = resolveDevicesSettings(options);
|
|
338
471
|
const warnings: DevicesWarning[] = [];
|
|
@@ -1859,6 +1992,18 @@ function renderText(envelope: DevicesEnvelope<unknown>): string {
|
|
|
1859
1992
|
? `created ${data.cdbFile}`
|
|
1860
1993
|
: `exists ${data.cdbFile} (${data.recordCount} record(s))`,
|
|
1861
1994
|
);
|
|
1995
|
+
lines.push(`encrypted: ${data.encrypted}`);
|
|
1996
|
+
break;
|
|
1997
|
+
}
|
|
1998
|
+
case "encrypt": {
|
|
1999
|
+
const data = envelope.data as DevicesEncryptData;
|
|
2000
|
+
lines.push(
|
|
2001
|
+
data.changed
|
|
2002
|
+
? `encrypted ${data.cdbFile}`
|
|
2003
|
+
: `verified encrypted ${data.cdbFile} (unchanged)`,
|
|
2004
|
+
);
|
|
2005
|
+
lines.push(`records: ${data.recordCount}`);
|
|
2006
|
+
if (data.backupPath) lines.push(`backup: ${data.backupPath}`);
|
|
1862
2007
|
break;
|
|
1863
2008
|
}
|
|
1864
2009
|
case "list":
|
package/src/execute-cmr.ts
CHANGED
|
@@ -1002,8 +1002,7 @@ function devicePassed(
|
|
|
1002
1002
|
source: CMR_VALIDATION_SOURCE,
|
|
1003
1003
|
result: "passed",
|
|
1004
1004
|
syntax: true,
|
|
1005
|
-
semantic:
|
|
1006
|
-
resolved.canonical.mode === "structured" ? true : "not-applicable",
|
|
1005
|
+
...(resolved.canonical.mode === "structured" ? { semantic: true } : {}),
|
|
1007
1006
|
stages: passedStages(
|
|
1008
1007
|
offline ?? { verdict: "pass", warnings: [] },
|
|
1009
1008
|
CMR_VALIDATION_SOURCE,
|
|
@@ -1023,12 +1022,7 @@ function deviceRejected(
|
|
|
1023
1022
|
enabled: true,
|
|
1024
1023
|
source: CMR_VALIDATION_SOURCE,
|
|
1025
1024
|
result: "failed",
|
|
1026
|
-
...(nameRejected
|
|
1027
|
-
? { syntax: true, semantic: false }
|
|
1028
|
-
: {
|
|
1029
|
-
syntax: false,
|
|
1030
|
-
...(structured ? {} : { semantic: "not-applicable" as const }),
|
|
1031
|
-
}),
|
|
1025
|
+
...(nameRejected ? { syntax: true, semantic: false } : { syntax: false }),
|
|
1032
1026
|
stages: [
|
|
1033
1027
|
offlineStagePassed(offline?.verdict),
|
|
1034
1028
|
{ stage: "device", source: CMR_VALIDATION_SOURCE, result: "failed" },
|
package/src/execute-fanout.ts
CHANGED
|
@@ -60,6 +60,7 @@ import {
|
|
|
60
60
|
} from "./execute.ts";
|
|
61
61
|
import { plannedProtocols, type RouterOsProtocol } from "./protocols/index.ts";
|
|
62
62
|
import {
|
|
63
|
+
assertNoMatchInFanout,
|
|
63
64
|
emptyWriteSelectionError,
|
|
64
65
|
expandSelection,
|
|
65
66
|
isDefaultRecordTarget,
|
|
@@ -206,6 +207,7 @@ export async function executeFanout(
|
|
|
206
207
|
internals: ExecuteFanoutInternals = {},
|
|
207
208
|
options: ExecuteFanoutOptions = {},
|
|
208
209
|
): Promise<ExecuteFanoutEnvelope> {
|
|
210
|
+
assertNoMatchInFanout(request.match);
|
|
209
211
|
// Validate + canonicalize the command ONCE, before selection expansion: a bad
|
|
210
212
|
// command shape is an outer error regardless of how many targets resolve, and
|
|
211
213
|
// write-ness is target-independent.
|
package/src/execute.ts
CHANGED
|
@@ -27,6 +27,7 @@ import { buildTip, notReachedReason } from "./core/envelope.ts";
|
|
|
27
27
|
import {
|
|
28
28
|
type CanonicalExecuteCommand,
|
|
29
29
|
canonicalizeExecuteCommand,
|
|
30
|
+
isBareMenuPathCommand,
|
|
30
31
|
isWriteShaped,
|
|
31
32
|
} from "./core/execute-command.ts";
|
|
32
33
|
import { sentWriteWarnings } from "./core/fanout.ts";
|
|
@@ -40,6 +41,7 @@ import {
|
|
|
40
41
|
SYNTAX_REJECTED_REMEDIATION,
|
|
41
42
|
serializeCentrsError,
|
|
42
43
|
} from "./errors.ts";
|
|
44
|
+
import { countExecutableStatements } from "./explain/segment.ts";
|
|
43
45
|
import {
|
|
44
46
|
assertOfflineSyntax,
|
|
45
47
|
isOfflineGateRejection,
|
|
@@ -118,6 +120,12 @@ export interface ExecuteRequest {
|
|
|
118
120
|
maxResultsBytes?: number;
|
|
119
121
|
cdbFile?: string;
|
|
120
122
|
cdbPassword?: string;
|
|
123
|
+
/**
|
|
124
|
+
* `--match` selector for a `<router>` several CDB records share
|
|
125
|
+
* (`user=<name>`, `target=<addr>`, or a record type): the same selector
|
|
126
|
+
* `devices show --match` takes. Single-target only (#583).
|
|
127
|
+
*/
|
|
128
|
+
match?: string;
|
|
121
129
|
/** Opt-in host ARP resolution for a MAC target (`none` default, or `arp`). */
|
|
122
130
|
resolve?: string;
|
|
123
131
|
stdinIsTty?: boolean;
|
|
@@ -162,6 +170,7 @@ export type ExecuteErrorEnvelope = CentrsErrorEnvelope<ExecuteOperationMeta>;
|
|
|
162
170
|
export {
|
|
163
171
|
type CanonicalExecuteCommand,
|
|
164
172
|
canonicalizeExecuteCommand,
|
|
173
|
+
isBareMenuPathCommand,
|
|
165
174
|
isWriteShaped,
|
|
166
175
|
};
|
|
167
176
|
|
|
@@ -364,6 +373,7 @@ export async function resolveExecuteRequest(
|
|
|
364
373
|
targetInput: request.targetInput,
|
|
365
374
|
cdbFile: request.cdbFile,
|
|
366
375
|
cdbPassword: request.cdbPassword,
|
|
376
|
+
match: request.match,
|
|
367
377
|
},
|
|
368
378
|
env,
|
|
369
379
|
config,
|
|
@@ -608,6 +618,22 @@ export function buildExecuteErrorEnvelope(
|
|
|
608
618
|
};
|
|
609
619
|
}
|
|
610
620
|
|
|
621
|
+
/**
|
|
622
|
+
* Whether the dispatch carried more than one statement (#549). Mirrors
|
|
623
|
+
* `runCommand`: the consoles run the raw CLI text whatever the canonical mode,
|
|
624
|
+
* while REST/native run structured input as one path-POST/talk sentence — a
|
|
625
|
+
* `;` inside a structured value never separates there, so the refusal means
|
|
626
|
+
* nothing ran. Executed control scopes count too: `:if … do={ A; B }` applies
|
|
627
|
+
* A before a refused B, while stored bodies never run at dispatch.
|
|
628
|
+
*/
|
|
629
|
+
function isMultiStatementDispatch(resolved: ResolvedExecuteRequest): boolean {
|
|
630
|
+
const runsRawText =
|
|
631
|
+
resolved.via.value === "mac-telnet" ||
|
|
632
|
+
resolved.via.value === "ssh" ||
|
|
633
|
+
resolved.canonical.mode === "script";
|
|
634
|
+
return runsRawText && countExecutableStatements(resolved.command) > 1;
|
|
635
|
+
}
|
|
636
|
+
|
|
611
637
|
export function buildExecuteErrorEnvelopeFromResolved(
|
|
612
638
|
resolved: ResolvedExecuteRequest,
|
|
613
639
|
error: unknown,
|
|
@@ -633,6 +659,7 @@ export function buildExecuteErrorEnvelopeFromResolved(
|
|
|
633
659
|
isWriteShaped(resolved.canonical),
|
|
634
660
|
centrsError,
|
|
635
661
|
trace,
|
|
662
|
+
isMultiStatementDispatch(resolved),
|
|
636
663
|
),
|
|
637
664
|
],
|
|
638
665
|
tips: resolvedExecuteTips(resolved),
|
|
@@ -664,20 +691,15 @@ function failedValidationMeta(
|
|
|
664
691
|
}
|
|
665
692
|
const deviceSource = deviceStageSource(resolved);
|
|
666
693
|
const structured = resolved.canonical.mode === "structured";
|
|
667
|
-
// A flat summary is present only when
|
|
668
|
-
//
|
|
669
|
-
//
|
|
670
|
-
// never asked, so `semantic` is left out rather than reported `false`.
|
|
671
|
-
const semanticNotReached = structured
|
|
672
|
-
? {}
|
|
673
|
-
: { semantic: "not-applicable" as const };
|
|
694
|
+
// A flat summary is present only when a stage gave that verdict (#535). A
|
|
695
|
+
// syntax rejection means the name-level check was never asked, so
|
|
696
|
+
// `semantic` is left out rather than reported `false`.
|
|
674
697
|
if (isOfflineGateRejection(error)) {
|
|
675
698
|
return {
|
|
676
699
|
enabled: true,
|
|
677
700
|
source: OFFLINE_GATE_SOURCE,
|
|
678
701
|
result: "failed",
|
|
679
702
|
syntax: false,
|
|
680
|
-
...semanticNotReached,
|
|
681
703
|
stages: [
|
|
682
704
|
{ stage: "offline", source: OFFLINE_GATE_SOURCE, result: "failed" },
|
|
683
705
|
{
|
|
@@ -705,9 +727,7 @@ function failedValidationMeta(
|
|
|
705
727
|
enabled: true,
|
|
706
728
|
source: deviceSource,
|
|
707
729
|
result: "failed",
|
|
708
|
-
...(nameRejected
|
|
709
|
-
? { syntax: true, semantic: false }
|
|
710
|
-
: { syntax: false, ...semanticNotReached }),
|
|
730
|
+
...(nameRejected ? { syntax: true, semantic: false } : { syntax: false }),
|
|
711
731
|
stages: [
|
|
712
732
|
offlineStagePassed(trace?.offlineVerdict),
|
|
713
733
|
{ stage: "device", source: deviceSource, result: "failed" },
|
|
@@ -781,15 +801,14 @@ export function offlineStagePassed(
|
|
|
781
801
|
* `validate=false`. Both stages are listed as `skipped` rather than omitted:
|
|
782
802
|
* the constitution's rule is that a gated surface always reports each stage, so
|
|
783
803
|
* a consumer reading `stages` never has to special-case the disabled shape.
|
|
784
|
-
* `reason` is left off — `enabled: false` already says why.
|
|
804
|
+
* `reason` is left off — `enabled: false` already says why. No stage answered,
|
|
805
|
+
* so there is no `syntax` / `semantic` summary (#535).
|
|
785
806
|
*/
|
|
786
807
|
export function disabledValidationMeta(): EnvelopeValidationMeta {
|
|
787
808
|
return {
|
|
788
809
|
enabled: false,
|
|
789
810
|
source: "disabled",
|
|
790
811
|
result: "skipped",
|
|
791
|
-
syntax: false,
|
|
792
|
-
semantic: false,
|
|
793
812
|
stages: [
|
|
794
813
|
{ stage: "offline", source: OFFLINE_GATE_SOURCE, result: "skipped" },
|
|
795
814
|
{ stage: "device", source: "device preflight", result: "skipped" },
|
|
@@ -862,7 +881,6 @@ async function validateExecuteCommand(
|
|
|
862
881
|
source: ":put [:parse]",
|
|
863
882
|
result: "passed",
|
|
864
883
|
syntax: true,
|
|
865
|
-
semantic: "not-applicable",
|
|
866
884
|
stages: passedStages(offline, ":put [:parse]"),
|
|
867
885
|
};
|
|
868
886
|
}
|
|
@@ -929,8 +947,7 @@ async function validateConsoleParseCommand(
|
|
|
929
947
|
source: `:put [:parse ...] over ${resolved.via.value}`,
|
|
930
948
|
result: "passed",
|
|
931
949
|
syntax: true,
|
|
932
|
-
semantic:
|
|
933
|
-
resolved.canonical.mode === "structured" ? true : "not-applicable",
|
|
950
|
+
...(resolved.canonical.mode === "structured" ? { semantic: true } : {}),
|
|
934
951
|
stages: passedStages(
|
|
935
952
|
offline,
|
|
936
953
|
`:put [:parse ...] over ${resolved.via.value}`,
|
|
@@ -1452,20 +1469,38 @@ function executeRequestSummary(
|
|
|
1452
1469
|
* downstream auth error.
|
|
1453
1470
|
*/
|
|
1454
1471
|
export function resolvedExecuteTips(resolved: ResolvedExecuteRequest): Tip[] {
|
|
1472
|
+
const tips: Tip[] = [];
|
|
1455
1473
|
if (
|
|
1456
1474
|
resolved.via.value === "mac-telnet" &&
|
|
1457
1475
|
resolved.auth.username === undefined &&
|
|
1458
1476
|
!resolved.auth.passwordProvided
|
|
1459
1477
|
) {
|
|
1460
|
-
|
|
1478
|
+
tips.push(
|
|
1461
1479
|
buildTip(
|
|
1462
1480
|
"tip/mac-telnet-no-credentials",
|
|
1463
1481
|
"No username or password was resolved for this mac-telnet target.",
|
|
1464
1482
|
"mac-telnet logs in with RouterOS credentials over MTWEI (MD5 is refused): pass --username/--password, set CENTRS_USERNAME/CENTRS_PASSWORD, or add a CDB record for this device.",
|
|
1465
1483
|
),
|
|
1466
|
-
|
|
1484
|
+
);
|
|
1485
|
+
}
|
|
1486
|
+
// A bare menu path runs — the write gate no longer stops it — but it
|
|
1487
|
+
// answers nothing: consoles navigate silently and the APIs refuse it with
|
|
1488
|
+
// `no such command`. Name the read that does answer. `canonical` is
|
|
1489
|
+
// always resolved in production; the guard keeps hermetic stubs total.
|
|
1490
|
+
if (
|
|
1491
|
+
resolved.canonical !== undefined &&
|
|
1492
|
+
isBareMenuPathCommand(resolved.canonical)
|
|
1493
|
+
) {
|
|
1494
|
+
const menu = `${resolved.canonical.path}/${resolved.canonical.verb}`;
|
|
1495
|
+
tips.push(
|
|
1496
|
+
buildTip(
|
|
1497
|
+
"tip/execute-bare-menu-path",
|
|
1498
|
+
`"${menu}" is a menu path, not a command: consoles navigate to it (no output) and the APIs refuse it with "no such command".`,
|
|
1499
|
+
`Append a verb such as "${menu}/print", or read rows with "centrs retrieve <router> ${menu}".`,
|
|
1500
|
+
),
|
|
1501
|
+
);
|
|
1467
1502
|
}
|
|
1468
|
-
return
|
|
1503
|
+
return tips;
|
|
1469
1504
|
}
|
|
1470
1505
|
|
|
1471
1506
|
function dataFromResult(result: ProtocolExecuteResult): unknown {
|