@tikoci/centrs 0.1.3 → 0.1.6
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 +348 -0
- package/GLOSSARY.txt +252 -0
- package/LICENSE +21 -0
- package/README.md +44 -12
- package/commands/AGENTS.md +14 -5
- package/commands/api/AGENTS.md +13 -1
- package/commands/api/README.md +56 -26
- package/commands/api/examples.md +46 -1
- package/commands/btest/README.md +21 -32
- package/commands/check/README.md +6 -1
- package/commands/discover/README.md +5 -11
- package/commands/execute/README.md +77 -36
- package/commands/execute/examples.md +120 -10
- package/commands/explain/README.md +2756 -0
- package/commands/explain/examples.md +702 -0
- package/commands/mcp/README.md +7 -4
- package/commands/mcp/examples.md +2 -2
- package/commands/retrieve/README.md +25 -27
- package/commands/retrieve/examples.md +16 -0
- package/commands/terminal/examples.md +7 -1
- package/commands/transfer/README.md +28 -20
- package/commands/transfer/examples.md +29 -0
- package/docs/CLI.md +353 -0
- package/docs/CONSTITUTION.md +138 -30
- package/docs/MATRIX.md +92 -18
- package/docs/errors/README.md +3 -0
- package/docs/errors/quickchr/machine-not-found.md +22 -0
- package/docs/errors/quickchr/machine-stopped.md +20 -0
- package/docs/errors/quickchr/package-unavailable.md +27 -0
- package/docs/errors/quickchr/unsupported-via.md +35 -0
- package/docs/errors/quickchr/unsupported.md +35 -0
- package/docs/errors/routeros/version-unsupported.md +26 -0
- package/docs/errors/transport/provider-trust.md +29 -0
- package/docs/errors/usage/stdin-ignored.md +28 -0
- package/docs/errors/validation/syntax.md +29 -4
- package/docs/errors/validation/unknown-attribute.md +37 -4
- package/package.json +56 -10
- package/src/api-fanout.ts +46 -18
- package/src/api.ts +351 -121
- package/src/cli/api.ts +26 -7
- package/src/cli/btest.ts +19 -3
- package/src/cli/execute.ts +25 -6
- package/src/cli/explain.ts +503 -0
- package/src/cli/retrieve.ts +33 -1
- package/src/cli/selection.ts +71 -11
- package/src/cli/settings.ts +2 -2
- package/src/cli/terminal.ts +1 -1
- package/src/cli/transfer.ts +23 -6
- package/src/cli.ts +15 -2
- package/src/core/envelope.ts +58 -2
- package/src/core/error-catalog.ts +42 -1
- package/src/core/execute-command.ts +148 -0
- package/src/core/inspect.ts +36 -4
- package/src/core/preflight.ts +306 -0
- package/src/core/routeros-string.ts +24 -0
- package/src/core/version.ts +4 -0
- package/src/core/yaml.ts +86 -0
- package/src/errors.ts +1 -0
- package/src/execute-fanout.ts +47 -18
- package/src/execute.ts +339 -307
- package/src/explain/arg-tokens.ts +151 -0
- package/src/explain/args.ts +1749 -0
- package/src/explain/blocks.ts +175 -0
- package/src/explain/brace-slots.ts +143 -0
- package/src/explain/brace-tokens.ts +39 -0
- package/src/explain/catalog.ts +1343 -0
- package/src/explain/coordinates.ts +488 -0
- package/src/explain/defects.ts +218 -0
- package/src/explain/escape-tokens.ts +77 -0
- package/src/explain/highlight-projection.ts +308 -0
- package/src/explain/is-known-menu.ts +17 -0
- package/src/explain/menus.ts +668 -0
- package/src/explain/operator-tokens.ts +316 -0
- package/src/explain/operators.ts +465 -0
- package/src/explain/path-tokens.ts +150 -0
- package/src/explain/pathresolve.ts +1569 -0
- package/src/explain/quoted-string.ts +331 -0
- package/src/explain/scope-brace.ts +461 -0
- package/src/explain/segment.ts +1165 -0
- package/src/explain/separator.ts +187 -0
- package/src/explain/split-owner.ts +194 -0
- package/src/explain/string-tokens.ts +77 -0
- package/src/explain/symbol-values.ts +556 -0
- package/src/explain/symbols.ts +1617 -0
- package/src/explain/token-ranges.ts +72 -0
- package/src/explain/transport.ts +514 -0
- package/src/explain/value-tokens.ts +83 -0
- package/src/explain/values.ts +357 -0
- package/src/explain/verbs.ts +64 -0
- package/src/explain/verbsplit.ts +955 -0
- package/src/explain/write.ts +856 -0
- package/src/explain.ts +2602 -0
- package/src/index.ts +225 -6
- package/src/mcp/server.ts +2 -1
- package/src/offline-gate.ts +148 -0
- package/src/protocols/mac-telnet-console.ts +61 -17
- package/src/resolver/facts.ts +1 -1
- package/src/resolver/index.ts +23 -4
- package/src/resolver/quickchr-provider.ts +597 -0
- package/src/resolver/selection.ts +99 -12
- package/src/resolver/service-endpoint.ts +155 -0
- package/src/resolver/target.ts +1 -1
- package/src/retrieve-fanout.ts +27 -13
- package/src/retrieve.ts +82 -77
- package/src/settings.ts +2 -1
- package/src/transfer-fanout.ts +43 -26
- package/src/transfer.ts +88 -45
package/src/api.ts
CHANGED
|
@@ -21,21 +21,26 @@ import type {
|
|
|
21
21
|
CommonSettingsMeta,
|
|
22
22
|
SettingSource as CoreSettingSource,
|
|
23
23
|
EnvelopeValidationMeta,
|
|
24
|
+
EnvelopeValidationStage,
|
|
24
25
|
Tip,
|
|
26
|
+
ValidationTrace,
|
|
25
27
|
Warning,
|
|
26
28
|
} from "./core/envelope.ts";
|
|
27
29
|
import { buildTip } from "./core/envelope.ts";
|
|
28
30
|
import {
|
|
29
|
-
|
|
30
|
-
inspectChildren,
|
|
31
|
+
inspectArgumentNames,
|
|
31
32
|
inspectChildrenOrEmpty,
|
|
32
|
-
inspectCompletions,
|
|
33
|
-
isArgumentNode,
|
|
34
33
|
isCommandNode,
|
|
35
34
|
pathTokens,
|
|
36
35
|
} from "./core/inspect.ts";
|
|
36
|
+
import { toYaml } from "./core/yaml.ts";
|
|
37
37
|
import { CentrsError, serializeCentrsError } from "./errors.ts";
|
|
38
38
|
import { promptForWriteConfirmation } from "./execute.ts";
|
|
39
|
+
import {
|
|
40
|
+
assertOfflineSyntax,
|
|
41
|
+
isOfflineGateRejection,
|
|
42
|
+
OFFLINE_GATE_SOURCE,
|
|
43
|
+
} from "./offline-gate.ts";
|
|
39
44
|
import {
|
|
40
45
|
type ApiVerb,
|
|
41
46
|
createProtocolAdapter,
|
|
@@ -45,11 +50,13 @@ import {
|
|
|
45
50
|
type RouterOsProtocol,
|
|
46
51
|
} from "./protocols/index.ts";
|
|
47
52
|
import {
|
|
53
|
+
assertNoQuickchrOverrideConflict,
|
|
48
54
|
type CdbResolution,
|
|
49
55
|
isIpTransport,
|
|
50
56
|
loadEnvFileDefaults,
|
|
51
57
|
parseDuration,
|
|
52
58
|
parseResolvePolicy,
|
|
59
|
+
quickchrConnection,
|
|
53
60
|
type ResolvedAuth,
|
|
54
61
|
type ResolvedSetting,
|
|
55
62
|
type ResolvedTarget,
|
|
@@ -57,11 +64,11 @@ import {
|
|
|
57
64
|
resolveBooleanSetting,
|
|
58
65
|
resolveCdb,
|
|
59
66
|
resolveMacTarget,
|
|
67
|
+
resolveQuickchrTarget,
|
|
60
68
|
resolveStringSetting,
|
|
61
69
|
resolveTarget,
|
|
62
70
|
toCoreSource,
|
|
63
71
|
} from "./resolver/index.ts";
|
|
64
|
-
import { toYaml } from "./retrieve.ts";
|
|
65
72
|
|
|
66
73
|
export const apiOutputFormats = ["json", "yaml", "text"] as const;
|
|
67
74
|
export type ApiOutputFormat = (typeof apiOutputFormats)[number];
|
|
@@ -72,6 +79,12 @@ export type ApiMethod = (typeof apiMethods)[number];
|
|
|
72
79
|
|
|
73
80
|
export interface ApiRequest {
|
|
74
81
|
targetInput?: string;
|
|
82
|
+
/**
|
|
83
|
+
* quickchr machine name (`--quickchr <name>`): resolve host/port/auth from
|
|
84
|
+
* the live VM descriptor instead of CDB/env (`docs/CONSTITUTION.md` →
|
|
85
|
+
* Resolution providers). Conflicts with host/port/username/password.
|
|
86
|
+
*/
|
|
87
|
+
quickchr?: string;
|
|
75
88
|
/** REST-style endpoint path, leniently normalized (`ip/address`, `/rest/ip/address`, `"ip address"`, `ip/address/*1`, `ip/address/listen`). */
|
|
76
89
|
endpoint: string;
|
|
77
90
|
/** HTTP method (`-X`), default `GET`, case-insensitive. */
|
|
@@ -88,7 +101,11 @@ export interface ApiRequest {
|
|
|
88
101
|
rawQuery?: readonly string[];
|
|
89
102
|
/** `--proplist` / `--attribute` property projection. */
|
|
90
103
|
proplist?: readonly string[];
|
|
91
|
-
/**
|
|
104
|
+
/**
|
|
105
|
+
* `--raw`: bare RouterOS body passthrough. DEFAULTS `--validate` to false; an
|
|
106
|
+
* explicit `validate` still wins, and its failures render through the `--raw`
|
|
107
|
+
* error contract (#154).
|
|
108
|
+
*/
|
|
92
109
|
raw?: boolean;
|
|
93
110
|
/** `--listen`: native-api-only open-ended follow (later phase; rejected here). */
|
|
94
111
|
listen?: boolean;
|
|
@@ -115,6 +132,23 @@ export interface ApiRequest {
|
|
|
115
132
|
confirm?: (prompt: string) => Promise<boolean>;
|
|
116
133
|
}
|
|
117
134
|
|
|
135
|
+
/**
|
|
136
|
+
* The `--raw` / `--validate` precedence for a summary built BEFORE (or instead
|
|
137
|
+
* of) full resolution — a usage error, or a fan-out member that never reached
|
|
138
|
+
* `resolveApiRequest`.
|
|
139
|
+
*
|
|
140
|
+
* Ambient sources are deliberately out of reach here: this runs where the CDB
|
|
141
|
+
* and config tiers have not been loaded, so it reports the two layers it CAN
|
|
142
|
+
* see. That is why it is a shared helper rather than an inlined expression —
|
|
143
|
+
* both call sites used to spell `request.raw ? false : …`, which contradicted
|
|
144
|
+
* the resolved request by reporting `validate: false` for an explicit
|
|
145
|
+
* `--raw --validate=true` (#154).
|
|
146
|
+
*/
|
|
147
|
+
function summarizedValidate(request: ApiRequest): boolean {
|
|
148
|
+
if (request.validate !== undefined) return request.validate;
|
|
149
|
+
return !(request.raw ?? false);
|
|
150
|
+
}
|
|
151
|
+
|
|
118
152
|
export interface NormalizedApiEndpoint {
|
|
119
153
|
/** Canonical slash path with the leading `/rest` and any trailing id/`listen` removed. */
|
|
120
154
|
path: string;
|
|
@@ -238,19 +272,25 @@ export async function apiEnvelope(
|
|
|
238
272
|
env: Record<string, string | undefined> = Bun.env,
|
|
239
273
|
): Promise<ApiEnvelope> {
|
|
240
274
|
let resolved: ResolvedApiRequest | undefined;
|
|
275
|
+
// The gate finishes inside `runResolvedApi`, but the error envelope is built
|
|
276
|
+
// out here after the throw has unwound past it. Without the trace, a
|
|
277
|
+
// `transport/*` or `routeros/*` fault on the request — both stages already
|
|
278
|
+
// settled — reported `/console/inspect` as the validator that failed.
|
|
279
|
+
const trace: ValidationTrace = {};
|
|
241
280
|
try {
|
|
242
281
|
resolved = await resolveApiRequest(request, env);
|
|
243
282
|
await assertApiWriteConfirmed(request, resolved);
|
|
244
|
-
return await runResolvedApi(resolved);
|
|
283
|
+
return await runResolvedApi(resolved, trace);
|
|
245
284
|
} catch (error) {
|
|
246
285
|
return resolved
|
|
247
|
-
? buildApiErrorEnvelopeFromResolved(resolved, error)
|
|
286
|
+
? buildApiErrorEnvelopeFromResolved(resolved, error, trace)
|
|
248
287
|
: buildApiErrorEnvelope(request, error, env);
|
|
249
288
|
}
|
|
250
289
|
}
|
|
251
290
|
|
|
252
291
|
export async function runResolvedApi(
|
|
253
292
|
resolved: ResolvedApiRequest,
|
|
293
|
+
trace: ValidationTrace = {},
|
|
254
294
|
): Promise<ApiSuccessEnvelope> {
|
|
255
295
|
assertListenCapability(resolved);
|
|
256
296
|
const backend = createProtocolAdapter({
|
|
@@ -270,18 +310,15 @@ export async function runResolvedApi(
|
|
|
270
310
|
let tips: Tip[] = [];
|
|
271
311
|
try {
|
|
272
312
|
if (resolved.validate.value) {
|
|
273
|
-
const result = await validateApiRequest(resolved, backend);
|
|
313
|
+
const result = await validateApiRequest(resolved, backend, trace);
|
|
274
314
|
validation = result.validation;
|
|
275
315
|
tips = result.tips;
|
|
276
316
|
} else {
|
|
277
|
-
validation =
|
|
278
|
-
enabled: false,
|
|
279
|
-
source: "disabled",
|
|
280
|
-
result: "skipped",
|
|
281
|
-
syntax: false,
|
|
282
|
-
semantic: false,
|
|
283
|
-
};
|
|
317
|
+
validation = disabledApiValidationMeta(resolved);
|
|
284
318
|
}
|
|
319
|
+
// Recorded BEFORE the request, so a transport drop or RouterOS fault below
|
|
320
|
+
// reports the gate that actually settled rather than a guess.
|
|
321
|
+
trace.validation = validation;
|
|
285
322
|
|
|
286
323
|
const result = await backend.apiRequest(buildProtocolApiRequest(resolved));
|
|
287
324
|
return {
|
|
@@ -542,32 +579,63 @@ export async function resolveApiRequest(
|
|
|
542
579
|
const query = buildApiQuery(request);
|
|
543
580
|
const proplist = buildApiProplist(request);
|
|
544
581
|
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
request.validate,
|
|
582
|
+
// A quickchr target is a named-live-provider: host/port/auth come from the
|
|
583
|
+
// live descriptor, bypassing CDB, `__default__`, and MAC resolution.
|
|
584
|
+
let quickchrResolution: Awaited<
|
|
585
|
+
ReturnType<typeof resolveQuickchrTarget>
|
|
586
|
+
> | null = null;
|
|
587
|
+
if (request.quickchr !== undefined) {
|
|
588
|
+
assertNoQuickchrOverrideConflict(request, request.quickchr);
|
|
589
|
+
quickchrResolution = await resolveQuickchrTarget(request.quickchr);
|
|
590
|
+
}
|
|
591
|
+
const cdbResolution = quickchrResolution
|
|
592
|
+
? undefined
|
|
593
|
+
: (override?.cdbResolution ??
|
|
594
|
+
(await resolveCdb(
|
|
595
|
+
{
|
|
596
|
+
targetInput: request.targetInput,
|
|
597
|
+
cdbFile: request.cdbFile,
|
|
598
|
+
cdbPassword: request.cdbPassword,
|
|
599
|
+
},
|
|
564
600
|
env,
|
|
565
|
-
"CENTRS_VALIDATE",
|
|
566
|
-
true,
|
|
567
|
-
"validate",
|
|
568
|
-
cdbResolution?.overrides.validate,
|
|
569
601
|
config,
|
|
570
|
-
);
|
|
602
|
+
)));
|
|
603
|
+
const via = resolveApiProtocol(request, env, listen, cdbResolution, config);
|
|
604
|
+
const format = resolveApiFormat(request, env, config);
|
|
605
|
+
// `--raw` is a PRECEDENCE LAYER, not an override (#154). It ranks below an
|
|
606
|
+
// explicit `--validate` and above every ambient source, so:
|
|
607
|
+
//
|
|
608
|
+
// --validate=true|false explicit CLI flag ← wins
|
|
609
|
+
// --raw → validate=false
|
|
610
|
+
// CENTRS_VALIDATE / CDB comment-kv / config
|
|
611
|
+
// default → validate=true
|
|
612
|
+
//
|
|
613
|
+
// It used to short-circuit the whole ladder, which silently discarded an
|
|
614
|
+
// explicit `--validate=true` — and env, CDB and config with it. `--raw
|
|
615
|
+
// --validate=true` is the intended way to debug `api` when centrs itself is
|
|
616
|
+
// suspect: the gate runs and a failure renders through `rawErrorPayload` as
|
|
617
|
+
// `{code,message}` on stderr with a nonzero exit, which is the `--raw` error
|
|
618
|
+
// contract for any other preflight failure. `--raw` still outranks the
|
|
619
|
+
// ambient sources rather than merely lowering the default, so it behaves the
|
|
620
|
+
// same on every machine regardless of a `CENTRS_VALIDATE` in the environment.
|
|
621
|
+
//
|
|
622
|
+
// The `--raw` arm must SHORT-CIRCUIT, not merely win a comparison: resolving
|
|
623
|
+
// the ambient layer first would parse `CENTRS_VALIDATE` even when `--raw`
|
|
624
|
+
// decides the value, so a malformed ambient value threw `settings/invalid-
|
|
625
|
+
// boolean` out of a call `--raw` had already settled — the same
|
|
626
|
+
// machine-dependence this layer exists to remove.
|
|
627
|
+
const validate: ResolvedSetting<boolean> =
|
|
628
|
+
raw && request.validate === undefined
|
|
629
|
+
? { value: false, source: { kind: "cli", key: "--raw" } }
|
|
630
|
+
: resolveBooleanSetting(
|
|
631
|
+
request.validate,
|
|
632
|
+
env,
|
|
633
|
+
"CENTRS_VALIDATE",
|
|
634
|
+
true,
|
|
635
|
+
"validate",
|
|
636
|
+
cdbResolution?.overrides.validate,
|
|
637
|
+
config,
|
|
638
|
+
);
|
|
571
639
|
const timeoutMs = resolveApiTimeout(
|
|
572
640
|
request.timeout,
|
|
573
641
|
env,
|
|
@@ -575,48 +643,70 @@ export async function resolveApiRequest(
|
|
|
575
643
|
cdbResolution?.overrides.timeoutMs,
|
|
576
644
|
config,
|
|
577
645
|
);
|
|
578
|
-
const macResolution =
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
646
|
+
const macResolution =
|
|
647
|
+
quickchrResolution === null && isIpTransport(via.value)
|
|
648
|
+
? await resolveMacTarget({
|
|
649
|
+
host: request.host,
|
|
650
|
+
targetInput: request.targetInput,
|
|
651
|
+
cdbTarget: cdbResolution?.target,
|
|
652
|
+
env,
|
|
653
|
+
config,
|
|
654
|
+
policy: parseResolvePolicy(
|
|
655
|
+
request.resolve ??
|
|
656
|
+
env["CENTRS_RESOLVE"] ??
|
|
657
|
+
config["CENTRS_RESOLVE"],
|
|
658
|
+
),
|
|
659
|
+
// api has no Layer-2 transport (rest-api/native-api only), so MAC
|
|
660
|
+
// remediation matches retrieve's (no mac-telnet suggestion).
|
|
661
|
+
operation: "retrieve",
|
|
662
|
+
})
|
|
663
|
+
: undefined;
|
|
664
|
+
const connection = quickchrResolution
|
|
665
|
+
? quickchrConnection(quickchrResolution, via.value)
|
|
666
|
+
: null;
|
|
667
|
+
const target = connection
|
|
668
|
+
? connection.target
|
|
669
|
+
: resolveTarget(
|
|
670
|
+
{
|
|
671
|
+
targetInput: request.targetInput,
|
|
672
|
+
host: request.host,
|
|
673
|
+
port: request.port,
|
|
674
|
+
macResolution,
|
|
675
|
+
},
|
|
583
676
|
env,
|
|
677
|
+
via.value,
|
|
678
|
+
cdbResolution,
|
|
584
679
|
config,
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
false,
|
|
616
|
-
"insecure",
|
|
617
|
-
cdbResolution?.overrides.insecure,
|
|
618
|
-
config,
|
|
619
|
-
);
|
|
680
|
+
);
|
|
681
|
+
const auth = connection
|
|
682
|
+
? connection.auth
|
|
683
|
+
: resolveAuth(
|
|
684
|
+
{ username: request.username, password: request.password },
|
|
685
|
+
env,
|
|
686
|
+
cdbResolution,
|
|
687
|
+
config,
|
|
688
|
+
);
|
|
689
|
+
// A quickchr TLS endpoint is trusted by provenance (self-signed VM cert) —
|
|
690
|
+
// `insecure` is forced with provider provenance and the provider-trust
|
|
691
|
+
// warning rides the envelope (see QuickchrConnection.insecure).
|
|
692
|
+
const insecure =
|
|
693
|
+
connection?.insecure === true
|
|
694
|
+
? {
|
|
695
|
+
value: true,
|
|
696
|
+
source: {
|
|
697
|
+
kind: "provider" as const,
|
|
698
|
+
key: `quickchr:${request.quickchr}`,
|
|
699
|
+
},
|
|
700
|
+
}
|
|
701
|
+
: resolveBooleanSetting(
|
|
702
|
+
request.insecure,
|
|
703
|
+
env,
|
|
704
|
+
"CENTRS_INSECURE",
|
|
705
|
+
false,
|
|
706
|
+
"insecure",
|
|
707
|
+
cdbResolution?.overrides.insecure,
|
|
708
|
+
config,
|
|
709
|
+
);
|
|
620
710
|
|
|
621
711
|
return {
|
|
622
712
|
endpoint: request.endpoint,
|
|
@@ -644,7 +734,9 @@ export async function resolveApiRequest(
|
|
|
644
734
|
insecure,
|
|
645
735
|
yes: request.yes ?? false,
|
|
646
736
|
verbose: request.verbose ?? false,
|
|
647
|
-
warnings:
|
|
737
|
+
warnings: connection
|
|
738
|
+
? [...connection.warnings]
|
|
739
|
+
: (cdbResolution?.warnings ?? []),
|
|
648
740
|
};
|
|
649
741
|
}
|
|
650
742
|
|
|
@@ -863,6 +955,9 @@ export function buildProtocolApiRequest(
|
|
|
863
955
|
return request;
|
|
864
956
|
}
|
|
865
957
|
|
|
958
|
+
/** The api device-stage validator: structured input is inspected, never `:parse`d. */
|
|
959
|
+
const API_DEVICE_STAGE_SOURCE = "/console/inspect request=child";
|
|
960
|
+
|
|
866
961
|
interface ApiValidationResult {
|
|
867
962
|
validation: EnvelopeValidationMeta;
|
|
868
963
|
tips: Tip[];
|
|
@@ -870,22 +965,58 @@ interface ApiValidationResult {
|
|
|
870
965
|
|
|
871
966
|
/**
|
|
872
967
|
* The structured-input gate. Because the input is a path (not a CLI string) the
|
|
873
|
-
* validator is `/console/inspect`, never `:put [:parse]`. Path existence
|
|
874
|
-
*
|
|
968
|
+
* validator is `/console/inspect`, never `:put [:parse]`. Path existence and
|
|
969
|
+
* add/set attribute validity both come from `request=child` — command-level
|
|
970
|
+
* `request=completion` is unsafe on RouterOS 7.12.2 (`core/inspect.ts`). A
|
|
875
971
|
* `/execute` script is a CLI string → `semantic: not-applicable`.
|
|
876
972
|
*/
|
|
877
973
|
async function validateApiRequest(
|
|
878
974
|
resolved: ResolvedApiRequest,
|
|
879
975
|
backend: ProtocolAdapter,
|
|
976
|
+
trace: ValidationTrace = {},
|
|
880
977
|
): Promise<ApiValidationResult> {
|
|
881
978
|
if (resolved.scriptMode) {
|
|
979
|
+
// `/execute` carries a CLI string, so `/console/inspect` has nothing to
|
|
980
|
+
// inspect — which until GH#354 meant the script was sent with NO gate at
|
|
981
|
+
// all (`syntax: false`, `semantic: "not-applicable"`, `result: "passed"`).
|
|
982
|
+
// The offline analyzer is the whole gate here: a syntax fault is now a
|
|
983
|
+
// byte span and no round trip, where before it reached the device.
|
|
984
|
+
const script = scriptOf(resolved);
|
|
985
|
+
const offline = script
|
|
986
|
+
? assertOfflineSyntax(script, {
|
|
987
|
+
surface: "api /execute",
|
|
988
|
+
via: resolved.via.value,
|
|
989
|
+
})
|
|
990
|
+
: undefined;
|
|
991
|
+
trace.offlineVerdict = offline?.verdict;
|
|
992
|
+
const offlineReason =
|
|
993
|
+
offline === undefined
|
|
994
|
+
? "no `script` field to analyze"
|
|
995
|
+
: offline.verdict === "warn"
|
|
996
|
+
? "analyzer abstained on part of the input"
|
|
997
|
+
: undefined;
|
|
882
998
|
return {
|
|
883
999
|
validation: {
|
|
884
1000
|
enabled: true,
|
|
885
|
-
source:
|
|
1001
|
+
source: offline === undefined ? "not-applicable" : OFFLINE_GATE_SOURCE,
|
|
886
1002
|
result: "passed",
|
|
887
|
-
syntax:
|
|
1003
|
+
syntax: offline !== undefined,
|
|
888
1004
|
semantic: "not-applicable",
|
|
1005
|
+
stages: [
|
|
1006
|
+
{
|
|
1007
|
+
stage: "offline",
|
|
1008
|
+
source: OFFLINE_GATE_SOURCE,
|
|
1009
|
+
result: offline === undefined ? "skipped" : "passed",
|
|
1010
|
+
...(offlineReason ? { reason: offlineReason } : {}),
|
|
1011
|
+
},
|
|
1012
|
+
{
|
|
1013
|
+
stage: "device",
|
|
1014
|
+
source: "/console/inspect request=child",
|
|
1015
|
+
result: "skipped",
|
|
1016
|
+
reason:
|
|
1017
|
+
"`/execute` is a CLI string, not a menu path; RouterOS re-validates it on the run",
|
|
1018
|
+
},
|
|
1019
|
+
],
|
|
889
1020
|
},
|
|
890
1021
|
tips: [],
|
|
891
1022
|
};
|
|
@@ -927,7 +1058,7 @@ async function validateApiRequest(
|
|
|
927
1058
|
// Only reject when inspect actually surfaced the command's arguments; if it
|
|
928
1059
|
// returns none, skip rather than false-reject a valid command.
|
|
929
1060
|
if (isCommand && Object.keys(resolved.body).length > 0) {
|
|
930
|
-
const available = await
|
|
1061
|
+
const available = await inspectArgumentNames(backend, tokens);
|
|
931
1062
|
if (available.length > 0) {
|
|
932
1063
|
const requested = Object.keys(resolved.body);
|
|
933
1064
|
const missing = requested.filter(
|
|
@@ -942,10 +1073,10 @@ async function validateApiRequest(
|
|
|
942
1073
|
context: {
|
|
943
1074
|
path: resolved.path,
|
|
944
1075
|
verb: resolved.verb,
|
|
945
|
-
|
|
1076
|
+
parameter: missing[0],
|
|
946
1077
|
requestedAttributes: requested,
|
|
947
1078
|
availableAttributes: available,
|
|
948
|
-
validationSource: "/console/inspect request=child
|
|
1079
|
+
validationSource: "/console/inspect request=child",
|
|
949
1080
|
},
|
|
950
1081
|
});
|
|
951
1082
|
}
|
|
@@ -970,7 +1101,7 @@ async function validateApiRequest(
|
|
|
970
1101
|
}
|
|
971
1102
|
|
|
972
1103
|
if (resolved.verb === "add" || resolved.verb === "set") {
|
|
973
|
-
const available = await
|
|
1104
|
+
const available = await inspectArgumentNames(backend, [
|
|
974
1105
|
...tokens,
|
|
975
1106
|
resolved.verb,
|
|
976
1107
|
]);
|
|
@@ -987,17 +1118,17 @@ async function validateApiRequest(
|
|
|
987
1118
|
context: {
|
|
988
1119
|
path: resolved.path,
|
|
989
1120
|
verb: resolved.verb,
|
|
990
|
-
|
|
1121
|
+
parameter: missing[0],
|
|
991
1122
|
requestedAttributes: requested,
|
|
992
1123
|
availableAttributes: available,
|
|
993
|
-
validationSource: "/console/inspect request=child
|
|
1124
|
+
validationSource: "/console/inspect request=child",
|
|
994
1125
|
},
|
|
995
1126
|
});
|
|
996
1127
|
}
|
|
997
1128
|
return {
|
|
998
1129
|
validation: {
|
|
999
1130
|
enabled: true,
|
|
1000
|
-
source: "/console/inspect request=child
|
|
1131
|
+
source: "/console/inspect request=child",
|
|
1001
1132
|
result: "passed",
|
|
1002
1133
|
syntax: false,
|
|
1003
1134
|
semantic: true,
|
|
@@ -1019,6 +1150,21 @@ async function validateApiRequest(
|
|
|
1019
1150
|
};
|
|
1020
1151
|
}
|
|
1021
1152
|
|
|
1153
|
+
/**
|
|
1154
|
+
* The `/execute` script body, when there is one to analyze.
|
|
1155
|
+
*
|
|
1156
|
+
* A missing or blank `script` is not this gate's error to raise:
|
|
1157
|
+
* {@link buildProtocolApiRequest} already rejects it with an actionable
|
|
1158
|
+
* `input/invalid-command` naming the three ways to pass one. Reporting a syntax
|
|
1159
|
+
* fault for an absent field would be the wrong diagnosis.
|
|
1160
|
+
*/
|
|
1161
|
+
function scriptOf(resolved: ResolvedApiRequest): string | undefined {
|
|
1162
|
+
const script = resolved.body["script"];
|
|
1163
|
+
return typeof script === "string" && script.trim().length > 0
|
|
1164
|
+
? script
|
|
1165
|
+
: undefined;
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1022
1168
|
function unknownPathError(resolved: ResolvedApiRequest): CentrsError {
|
|
1023
1169
|
return new CentrsError({
|
|
1024
1170
|
code: "validation/unknown-path",
|
|
@@ -1033,23 +1179,6 @@ function unknownPathError(resolved: ResolvedApiRequest): CentrsError {
|
|
|
1033
1179
|
});
|
|
1034
1180
|
}
|
|
1035
1181
|
|
|
1036
|
-
async function inspectApiAttributes(
|
|
1037
|
-
backend: ProtocolAdapter,
|
|
1038
|
-
commandTokens: readonly string[],
|
|
1039
|
-
): Promise<string[]> {
|
|
1040
|
-
const children = await inspectChildren(backend, commandTokens);
|
|
1041
|
-
const childAttributes = children
|
|
1042
|
-
.filter(isArgumentNode)
|
|
1043
|
-
.map((child) => child.name)
|
|
1044
|
-
.filter(
|
|
1045
|
-
(name): name is string => typeof name === "string" && name.length > 0,
|
|
1046
|
-
);
|
|
1047
|
-
const completionRows = await inspectCompletions(backend, commandTokens);
|
|
1048
|
-
return [
|
|
1049
|
-
...new Set([...childAttributes, ...extractCompletionNames(completionRows)]),
|
|
1050
|
-
].sort();
|
|
1051
|
-
}
|
|
1052
|
-
|
|
1053
1182
|
async function assertApiWriteConfirmed(
|
|
1054
1183
|
request: ApiRequest,
|
|
1055
1184
|
resolved: ResolvedApiRequest,
|
|
@@ -1256,7 +1385,7 @@ export function buildApiErrorEnvelope(
|
|
|
1256
1385
|
: false,
|
|
1257
1386
|
listen: request.listen ?? false,
|
|
1258
1387
|
yes: request.yes ?? false,
|
|
1259
|
-
validate:
|
|
1388
|
+
validate: summarizedValidate(request),
|
|
1260
1389
|
raw: request.raw ?? false,
|
|
1261
1390
|
format: resolveErrorFormat(request, env),
|
|
1262
1391
|
},
|
|
@@ -1272,6 +1401,7 @@ export function buildApiErrorEnvelope(
|
|
|
1272
1401
|
export function buildApiErrorEnvelopeFromResolved(
|
|
1273
1402
|
resolved: ResolvedApiRequest,
|
|
1274
1403
|
error: unknown,
|
|
1404
|
+
trace?: ValidationTrace,
|
|
1275
1405
|
): ApiErrorEnvelope {
|
|
1276
1406
|
const centrsError = asApiError(error);
|
|
1277
1407
|
return {
|
|
@@ -1279,17 +1409,117 @@ export function buildApiErrorEnvelopeFromResolved(
|
|
|
1279
1409
|
error: serializeCentrsError(centrsError),
|
|
1280
1410
|
warnings: [...resolved.warnings],
|
|
1281
1411
|
tips: [],
|
|
1282
|
-
meta: metaFromResolved(
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1412
|
+
meta: metaFromResolved(
|
|
1413
|
+
resolved,
|
|
1414
|
+
trace?.validation ??
|
|
1415
|
+
failedApiValidationMeta(resolved, centrsError, trace),
|
|
1416
|
+
),
|
|
1417
|
+
};
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
/**
|
|
1421
|
+
* `meta.validation` for an api error envelope. A stage-1 rejection opened no
|
|
1422
|
+
* connection, so the meta must not report `/console/inspect` as the validator
|
|
1423
|
+
* that failed (GH#354).
|
|
1424
|
+
*/
|
|
1425
|
+
function failedApiValidationMeta(
|
|
1426
|
+
resolved: ResolvedApiRequest,
|
|
1427
|
+
error: CentrsError,
|
|
1428
|
+
trace?: ValidationTrace,
|
|
1429
|
+
): EnvelopeValidationMeta {
|
|
1430
|
+
if (!resolved.validate.value) {
|
|
1431
|
+
return disabledApiValidationMeta(resolved);
|
|
1432
|
+
}
|
|
1433
|
+
if (isOfflineGateRejection(error)) {
|
|
1434
|
+
return {
|
|
1435
|
+
enabled: true,
|
|
1436
|
+
source: OFFLINE_GATE_SOURCE,
|
|
1437
|
+
result: "failed",
|
|
1286
1438
|
syntax: false,
|
|
1287
|
-
semantic:
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1439
|
+
semantic: "not-applicable",
|
|
1440
|
+
stages: [
|
|
1441
|
+
{ stage: "offline", source: OFFLINE_GATE_SOURCE, result: "failed" },
|
|
1442
|
+
{
|
|
1443
|
+
stage: "device",
|
|
1444
|
+
source: API_DEVICE_STAGE_SOURCE,
|
|
1445
|
+
result: "skipped",
|
|
1446
|
+
reason: "offline analysis rejected the script; no connection opened",
|
|
1447
|
+
},
|
|
1448
|
+
],
|
|
1449
|
+
};
|
|
1450
|
+
}
|
|
1451
|
+
// `stages` is only asserted when a `validation/*` error identifies the device
|
|
1452
|
+
// stage as the rejecter. Everything downstream — a RouterOS fault on the
|
|
1453
|
+
// request, a transport drop — passed the gate and is served by
|
|
1454
|
+
// `trace.validation` before this function is ever reached.
|
|
1455
|
+
const deviceRejected = error.code.startsWith("validation/");
|
|
1456
|
+
return {
|
|
1457
|
+
enabled: true,
|
|
1458
|
+
source: "/console/inspect",
|
|
1459
|
+
result: "failed",
|
|
1460
|
+
syntax: false,
|
|
1461
|
+
semantic: resolved.scriptMode ? "not-applicable" : false,
|
|
1462
|
+
...(deviceRejected
|
|
1463
|
+
? { stages: [offlineApiStage(resolved, trace), deviceStageFailed()] }
|
|
1464
|
+
: {}),
|
|
1465
|
+
};
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
/**
|
|
1469
|
+
* The offline-stage entry for an api gate that reached the device stage.
|
|
1470
|
+
*
|
|
1471
|
+
* Script mode is the only api shape stage 1 analyzes; a structured path request
|
|
1472
|
+
* is a path plus a body, not a CLI string, so its offline stage is `skipped`
|
|
1473
|
+
* with the reason spelled out rather than silently reported as passed.
|
|
1474
|
+
*/
|
|
1475
|
+
function offlineApiStage(
|
|
1476
|
+
resolved: ResolvedApiRequest,
|
|
1477
|
+
trace?: ValidationTrace,
|
|
1478
|
+
): EnvelopeValidationStage {
|
|
1479
|
+
if (!resolved.scriptMode) {
|
|
1480
|
+
return {
|
|
1481
|
+
stage: "offline",
|
|
1482
|
+
source: OFFLINE_GATE_SOURCE,
|
|
1483
|
+
result: "skipped",
|
|
1484
|
+
reason: "structured path request, not a CLI string (GH#354)",
|
|
1485
|
+
};
|
|
1486
|
+
}
|
|
1487
|
+
return {
|
|
1488
|
+
stage: "offline",
|
|
1489
|
+
source: OFFLINE_GATE_SOURCE,
|
|
1490
|
+
result: "passed",
|
|
1491
|
+
...(trace?.offlineVerdict === "warn"
|
|
1492
|
+
? { reason: "analyzer abstained on part of the input" }
|
|
1493
|
+
: {}),
|
|
1494
|
+
};
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
function deviceStageFailed(): EnvelopeValidationStage {
|
|
1498
|
+
return {
|
|
1499
|
+
stage: "device",
|
|
1500
|
+
source: API_DEVICE_STAGE_SOURCE,
|
|
1501
|
+
result: "failed",
|
|
1502
|
+
};
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
/**
|
|
1506
|
+
* `--validate=false` (including a bare `--raw`). Both stages are listed as
|
|
1507
|
+
* `skipped` rather than omitted, so a consumer reading `stages` never has to
|
|
1508
|
+
* special-case the disabled shape; `enabled: false` already carries the why.
|
|
1509
|
+
*/
|
|
1510
|
+
function disabledApiValidationMeta(
|
|
1511
|
+
resolved: ResolvedApiRequest,
|
|
1512
|
+
): EnvelopeValidationMeta {
|
|
1513
|
+
return {
|
|
1514
|
+
enabled: false,
|
|
1515
|
+
source: "disabled",
|
|
1516
|
+
result: "skipped",
|
|
1517
|
+
syntax: false,
|
|
1518
|
+
semantic: resolved.scriptMode ? "not-applicable" : false,
|
|
1519
|
+
stages: [
|
|
1520
|
+
{ stage: "offline", source: OFFLINE_GATE_SOURCE, result: "skipped" },
|
|
1521
|
+
{ stage: "device", source: API_DEVICE_STAGE_SOURCE, result: "skipped" },
|
|
1522
|
+
],
|
|
1293
1523
|
};
|
|
1294
1524
|
}
|
|
1295
1525
|
|
|
@@ -1482,7 +1712,7 @@ export function apiRequestSummaryFromRequest(
|
|
|
1482
1712
|
write: parsed ? isApiMutating(parsed, normalized.path) : false,
|
|
1483
1713
|
listen,
|
|
1484
1714
|
yes: request.yes ?? false,
|
|
1485
|
-
validate:
|
|
1715
|
+
validate: summarizedValidate(request),
|
|
1486
1716
|
raw: request.raw ?? false,
|
|
1487
1717
|
format: resolveErrorFormat(request, env),
|
|
1488
1718
|
query: buildApiQuery(request),
|