@tikoci/centrs 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +207 -0
- package/GLOSSARY.txt +420 -0
- package/README.md +196 -0
- package/SECURITY.md +30 -0
- package/commands/AGENTS.md +50 -0
- package/commands/btest/README.md +257 -0
- package/commands/btest/examples.md +190 -0
- package/commands/check/README.md +59 -0
- package/commands/devices/README.md +405 -0
- package/commands/devices/examples.md +488 -0
- package/commands/discover/README.md +170 -0
- package/commands/discover/examples.md +98 -0
- package/commands/execute/README.md +208 -0
- package/commands/execute/examples.md +353 -0
- package/commands/mcp/README.md +260 -0
- package/commands/mcp/examples.md +158 -0
- package/commands/retrieve/README.md +209 -0
- package/commands/retrieve/examples.md +306 -0
- package/commands/settings/README.md +69 -0
- package/commands/stream/README.md +70 -0
- package/commands/terminal/README.md +177 -0
- package/commands/terminal/examples.md +144 -0
- package/commands/transfer/README.md +315 -0
- package/commands/transfer/examples.md +368 -0
- package/docs/CONSTITUTION.md +417 -0
- package/docs/MATRIX.md +224 -0
- package/docs/errors/README.md +74 -0
- package/docs/errors/auth/failed.md +9 -0
- package/docs/errors/cdb/already-exists.md +9 -0
- package/docs/errors/cdb/created.md +9 -0
- package/docs/errors/cdb/decrypt-failed.md +9 -0
- package/docs/errors/cdb/empty-group.md +9 -0
- package/docs/errors/cdb/invalid-option.md +9 -0
- package/docs/errors/cdb/invalid-record.md +9 -0
- package/docs/errors/cdb/not-found-target.md +9 -0
- package/docs/errors/cdb/not-found.md +9 -0
- package/docs/errors/cdb/override-applied.md +9 -0
- package/docs/errors/cdb/parse-failed.md +9 -0
- package/docs/errors/cdb/password-not-needed.md +9 -0
- package/docs/errors/cdb/password-required.md +9 -0
- package/docs/errors/cdb/reserved-key.md +9 -0
- package/docs/errors/cdb/reserved-option.md +9 -0
- package/docs/errors/cdb/target-not-registered.md +9 -0
- package/docs/errors/cdb/unknown-field.md +9 -0
- package/docs/errors/cdb/unknown-option.md +9 -0
- package/docs/errors/cdb/write-not-permitted.md +9 -0
- package/docs/errors/discover/broadcast-unavailable.md +9 -0
- package/docs/errors/discover/failed.md +9 -0
- package/docs/errors/identity/ambiguous.md +9 -0
- package/docs/errors/identity/no-match.md +9 -0
- package/docs/errors/input/invalid-command.md +9 -0
- package/docs/errors/input/invalid-match.md +9 -0
- package/docs/errors/input/invalid-path.md +9 -0
- package/docs/errors/input/invalid-routeros-path.md +9 -0
- package/docs/errors/input/invalid-target.md +9 -0
- package/docs/errors/input/local-file-not-found.md +9 -0
- package/docs/errors/input/mac-address.md +9 -0
- package/docs/errors/input/max-results-exceeded.md +9 -0
- package/docs/errors/internal/devices-failed.md +9 -0
- package/docs/errors/internal/mcp-start.md +9 -0
- package/docs/errors/internal/mcp-tool.md +9 -0
- package/docs/errors/internal/native-api-length.md +9 -0
- package/docs/errors/internal/unhandled.md +9 -0
- package/docs/errors/internal/unreachable.md +9 -0
- package/docs/errors/mndp/encode-failed.md +9 -0
- package/docs/errors/mndp/listen-failed.md +9 -0
- package/docs/errors/mndp/malformed.md +9 -0
- package/docs/errors/routeros/api-fatal.md +9 -0
- package/docs/errors/routeros/api-protocol.md +9 -0
- package/docs/errors/routeros/api-trap.md +9 -0
- package/docs/errors/routeros/btest-protocol.md +12 -0
- package/docs/errors/routeros/btest-too-many-sessions.md +9 -0
- package/docs/errors/routeros/command-failed.md +14 -0
- package/docs/errors/routeros/ec-srp5-protocol.md +11 -0
- package/docs/errors/routeros/error.md +9 -0
- package/docs/errors/routeros/invalid-value.md +14 -0
- package/docs/errors/routeros/mac-telnet-error.md +9 -0
- package/docs/errors/routeros/mac-telnet-not-ready.md +9 -0
- package/docs/errors/routeros/mac-telnet-protocol.md +9 -0
- package/docs/errors/routeros/mac-telnet-unsupported-auth.md +9 -0
- package/docs/errors/routeros/protocol-not-implemented.md +9 -0
- package/docs/errors/routeros/request-failed.md +9 -0
- package/docs/errors/routeros/session-closed.md +13 -0
- package/docs/errors/routeros/unknown-attribute.md +13 -0
- package/docs/errors/routeros/unknown-path.md +15 -0
- package/docs/errors/routeros/unsupported-capability.md +9 -0
- package/docs/errors/settings/invalid-boolean.md +9 -0
- package/docs/errors/settings/invalid-format.md +9 -0
- package/docs/errors/settings/invalid-integer.md +9 -0
- package/docs/errors/settings/invalid-timeout.md +9 -0
- package/docs/errors/settings/invalid-via.md +9 -0
- package/docs/errors/settings/unsafe-protocol-blocked.md +9 -0
- package/docs/errors/target/mac-not-in-arp.md +14 -0
- package/docs/errors/target/mac-required.md +23 -0
- package/docs/errors/target/mac-unresolved.md +17 -0
- package/docs/errors/target/unresolved.md +9 -0
- package/docs/errors/transport/auth-failed.md +9 -0
- package/docs/errors/transport/auto-method.md +9 -0
- package/docs/errors/transport/capability-unsupported.md +22 -0
- package/docs/errors/transport/checksum-unavailable.md +9 -0
- package/docs/errors/transport/connection-closed.md +9 -0
- package/docs/errors/transport/connection-refused.md +9 -0
- package/docs/errors/transport/dns.md +9 -0
- package/docs/errors/transport/host-key-mismatch.md +9 -0
- package/docs/errors/transport/incomplete-transfer.md +9 -0
- package/docs/errors/transport/insecure-trust.md +9 -0
- package/docs/errors/transport/local-tool-missing.md +11 -0
- package/docs/errors/transport/network.md +9 -0
- package/docs/errors/transport/timeout.md +9 -0
- package/docs/errors/transport/tls-certificate.md +9 -0
- package/docs/errors/transport/unreachable.md +9 -0
- package/docs/errors/transport/unsupported-operation.md +9 -0
- package/docs/errors/usage/confirmation-required.md +9 -0
- package/docs/errors/usage/conflicting-flags.md +9 -0
- package/docs/errors/usage/invalid-concurrency.md +9 -0
- package/docs/errors/usage/missing-group.md +9 -0
- package/docs/errors/usage/not-implemented.md +9 -0
- package/docs/errors/usage/target-exists.md +9 -0
- package/docs/errors/usage/timeout-out-of-range.md +9 -0
- package/docs/errors/validation/not-implemented.md +9 -0
- package/docs/errors/validation/option.md +9 -0
- package/docs/errors/validation/syntax.md +9 -0
- package/docs/errors/validation/unknown-attribute.md +9 -0
- package/docs/errors/validation/unknown-path.md +9 -0
- package/package.json +96 -0
- package/src/AGENTS.md +7 -0
- package/src/btest.ts +906 -0
- package/src/cli/btest.ts +455 -0
- package/src/cli/common.ts +53 -0
- package/src/cli/devices.ts +527 -0
- package/src/cli/discover.ts +186 -0
- package/src/cli/execute.ts +333 -0
- package/src/cli/mcp.ts +109 -0
- package/src/cli/retrieve.ts +383 -0
- package/src/cli/terminal.ts +303 -0
- package/src/cli/transfer.ts +471 -0
- package/src/cli.ts +115 -0
- package/src/core/envelope.ts +221 -0
- package/src/core/error-catalog.ts +469 -0
- package/src/core/routeros-errors.ts +268 -0
- package/src/data/AGENTS.md +11 -0
- package/src/data/dude-db.ts +0 -0
- package/src/data/mndp-cache.ts +132 -0
- package/src/data/mndp.ts +405 -0
- package/src/data/winbox-cdb-write.ts +200 -0
- package/src/data/winbox-cdb.ts +913 -0
- package/src/devices-schema.ts +107 -0
- package/src/devices.ts +1717 -0
- package/src/discover.ts +802 -0
- package/src/errors.ts +189 -0
- package/src/execute.ts +1350 -0
- package/src/index.ts +353 -0
- package/src/mcp/config.ts +66 -0
- package/src/mcp/resources.ts +98 -0
- package/src/mcp/safety.ts +119 -0
- package/src/mcp/server.ts +225 -0
- package/src/mcp/tools.ts +657 -0
- package/src/mcp.ts +33 -0
- package/src/protocols/AGENTS.md +14 -0
- package/src/protocols/adapter.ts +866 -0
- package/src/protocols/btest-session.ts +1493 -0
- package/src/protocols/btest.ts +447 -0
- package/src/protocols/ec-srp5.ts +466 -0
- package/src/protocols/index.ts +118 -0
- package/src/protocols/mac-telnet-console.ts +655 -0
- package/src/protocols/mac-telnet.ts +1299 -0
- package/src/protocols/mtwei.ts +47 -0
- package/src/protocols/native-api.ts +796 -0
- package/src/protocols/sftp.ts +427 -0
- package/src/protocols/ssh.ts +297 -0
- package/src/resolver/cdb.ts +580 -0
- package/src/resolver/comment-kv.ts +360 -0
- package/src/resolver/index.ts +74 -0
- package/src/resolver/mac.ts +227 -0
- package/src/resolver/settings.ts +228 -0
- package/src/resolver/target.ts +442 -0
- package/src/retrieve-fanout.ts +533 -0
- package/src/retrieve.ts +1215 -0
- package/src/terminal.ts +444 -0
- package/src/transfer.ts +1554 -0
- package/src/tui.ts +22 -0
- package/src/webproxy.ts +16 -0
package/src/retrieve.ts
ADDED
|
@@ -0,0 +1,1215 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
CentrsEnvelope,
|
|
3
|
+
CentrsErrorEnvelope,
|
|
4
|
+
CentrsSuccessEnvelope,
|
|
5
|
+
CommonSettingsMeta,
|
|
6
|
+
SettingSource as CoreSettingSource,
|
|
7
|
+
EnvelopeValidationMeta,
|
|
8
|
+
} from "./core/envelope.ts";
|
|
9
|
+
import { CentrsError, serializeCentrsError } from "./errors.ts";
|
|
10
|
+
import {
|
|
11
|
+
createProtocolAdapter,
|
|
12
|
+
type ProtocolAdapter,
|
|
13
|
+
} from "./protocols/adapter.ts";
|
|
14
|
+
import {
|
|
15
|
+
getProtocolPlan,
|
|
16
|
+
plannedProtocols,
|
|
17
|
+
type RouterOsProtocol,
|
|
18
|
+
} from "./protocols/index.ts";
|
|
19
|
+
import {
|
|
20
|
+
type CdbResolution,
|
|
21
|
+
isIpTransport,
|
|
22
|
+
parseDuration,
|
|
23
|
+
parseResolvePolicy,
|
|
24
|
+
type ResolvedAuth,
|
|
25
|
+
type ResolvedSetting,
|
|
26
|
+
type ResolvedTarget,
|
|
27
|
+
resolveAuth,
|
|
28
|
+
resolveBooleanSetting,
|
|
29
|
+
resolveCdb,
|
|
30
|
+
resolveMacTarget,
|
|
31
|
+
resolveOptionalIntegerSetting,
|
|
32
|
+
resolveStringSetting,
|
|
33
|
+
resolveTarget,
|
|
34
|
+
toCoreSource,
|
|
35
|
+
} from "./resolver/index.ts";
|
|
36
|
+
|
|
37
|
+
export const retrieveOutputFormats = ["text", "json", "yaml"] as const;
|
|
38
|
+
export type RetrieveOutputFormat = (typeof retrieveOutputFormats)[number];
|
|
39
|
+
|
|
40
|
+
export interface RetrieveRequest {
|
|
41
|
+
targetInput?: string;
|
|
42
|
+
path: string;
|
|
43
|
+
via?: string;
|
|
44
|
+
host?: string;
|
|
45
|
+
port?: number;
|
|
46
|
+
username?: string;
|
|
47
|
+
password?: string;
|
|
48
|
+
timeout?: string | number;
|
|
49
|
+
format?: string;
|
|
50
|
+
validate?: boolean;
|
|
51
|
+
verbose?: boolean;
|
|
52
|
+
attribute?: string | readonly string[];
|
|
53
|
+
attributes?: string | readonly string[];
|
|
54
|
+
allAttributes?: boolean;
|
|
55
|
+
listAttributes?: boolean;
|
|
56
|
+
filter?: string;
|
|
57
|
+
query?: string;
|
|
58
|
+
maxResultsBytes?: number;
|
|
59
|
+
cdbFile?: string;
|
|
60
|
+
cdbPassword?: string;
|
|
61
|
+
/** Opt-in host ARP resolution for a MAC target (`none` default, or `arp`). */
|
|
62
|
+
resolve?: string;
|
|
63
|
+
/** CDB group selector — expands to a fanout over all matching records. */
|
|
64
|
+
group?: string;
|
|
65
|
+
/** Bounded worker-pool size for group fanout (defaults are transport-aware). */
|
|
66
|
+
concurrency?: number;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface RetrieveWarning {
|
|
70
|
+
code: string;
|
|
71
|
+
message: string;
|
|
72
|
+
context?: Record<string, unknown>;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface RetrieveRequestSummary {
|
|
76
|
+
path: string;
|
|
77
|
+
attributes: readonly string[];
|
|
78
|
+
allAttributes: boolean;
|
|
79
|
+
listAttributes: boolean;
|
|
80
|
+
validate: boolean;
|
|
81
|
+
verbose: boolean;
|
|
82
|
+
timeoutMs: number;
|
|
83
|
+
format: RetrieveOutputFormat;
|
|
84
|
+
maxResultsBytes?: number;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface RetrieveOperationMeta {
|
|
88
|
+
kind: "attributes" | "data";
|
|
89
|
+
objectCount: number;
|
|
90
|
+
request: RetrieveRequestSummary;
|
|
91
|
+
auth: {
|
|
92
|
+
username?: string;
|
|
93
|
+
passwordProvided: boolean;
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export type RetrieveEnvelope = CentrsEnvelope<unknown, RetrieveOperationMeta>;
|
|
98
|
+
export type RetrieveSuccessEnvelope = CentrsSuccessEnvelope<
|
|
99
|
+
unknown,
|
|
100
|
+
RetrieveOperationMeta
|
|
101
|
+
>;
|
|
102
|
+
export type RetrieveErrorEnvelope = CentrsErrorEnvelope<RetrieveOperationMeta>;
|
|
103
|
+
|
|
104
|
+
interface InspectChildItem {
|
|
105
|
+
type?: string;
|
|
106
|
+
name?: string;
|
|
107
|
+
"node-type"?: string;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
interface InspectCompletionItem {
|
|
111
|
+
type?: string;
|
|
112
|
+
name?: string;
|
|
113
|
+
completion?: string;
|
|
114
|
+
value?: string;
|
|
115
|
+
text?: string;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
interface RetrieveInspection {
|
|
119
|
+
command: "get" | "print";
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export interface ResolvedRetrieveRequest {
|
|
123
|
+
path: string;
|
|
124
|
+
via: ResolvedSetting<RouterOsProtocol>;
|
|
125
|
+
target: ResolvedTarget;
|
|
126
|
+
auth: ResolvedAuth;
|
|
127
|
+
timeoutMs: ResolvedSetting<number>;
|
|
128
|
+
format: ResolvedSetting<RetrieveOutputFormat>;
|
|
129
|
+
validate: ResolvedSetting<boolean>;
|
|
130
|
+
maxResultsBytes?: ResolvedSetting<number>;
|
|
131
|
+
attributes: readonly string[];
|
|
132
|
+
allAttributes: boolean;
|
|
133
|
+
listAttributes: boolean;
|
|
134
|
+
verbose: boolean;
|
|
135
|
+
warnings: readonly RetrieveWarning[];
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export async function retrieve(
|
|
139
|
+
request: RetrieveRequest,
|
|
140
|
+
env: Record<string, string | undefined> = Bun.env,
|
|
141
|
+
): Promise<RetrieveSuccessEnvelope> {
|
|
142
|
+
const resolved = await resolveRetrieveRequest(request, env);
|
|
143
|
+
return runResolvedRetrieve(resolved);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The inspect → fetch → envelope tail shared by single-target `retrieve()` and
|
|
148
|
+
* group fanout. Takes an already-resolved request (one CDB record's identity +
|
|
149
|
+
* overrides) so fanout does not re-resolve or re-load the CDB per target. On
|
|
150
|
+
* success returns a `RetrieveSuccessEnvelope`; failures throw a `CentrsError`
|
|
151
|
+
* the caller maps with {@link buildRetrieveErrorEnvelopeFromResolved}.
|
|
152
|
+
*/
|
|
153
|
+
export async function runResolvedRetrieve(
|
|
154
|
+
resolved: ResolvedRetrieveRequest,
|
|
155
|
+
): Promise<RetrieveSuccessEnvelope> {
|
|
156
|
+
const warnings: RetrieveWarning[] = [...resolved.warnings];
|
|
157
|
+
let availableAttributes: string[] | undefined;
|
|
158
|
+
let inspection: RetrieveInspection | undefined;
|
|
159
|
+
const backend = createProtocolAdapter({
|
|
160
|
+
protocol: resolved.via.value,
|
|
161
|
+
host: resolved.target.host,
|
|
162
|
+
port: resolved.target.port,
|
|
163
|
+
tls: resolved.target.tls,
|
|
164
|
+
baseUrl: resolved.target.baseUrl,
|
|
165
|
+
username: resolved.auth.username,
|
|
166
|
+
password: resolved.auth.password,
|
|
167
|
+
timeoutMs: resolved.timeoutMs.value,
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
try {
|
|
171
|
+
if (resolved.listAttributes || resolved.validate.value) {
|
|
172
|
+
inspection = await inspectRetrievePath(resolved, backend);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (
|
|
176
|
+
resolved.listAttributes ||
|
|
177
|
+
(resolved.validate.value && resolved.attributes.length > 0)
|
|
178
|
+
) {
|
|
179
|
+
availableAttributes = await inspectAttributes(
|
|
180
|
+
resolved,
|
|
181
|
+
inspection ?? (await inspectRetrievePath(resolved, backend)),
|
|
182
|
+
backend,
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (resolved.listAttributes) {
|
|
187
|
+
const envelope = buildSuccessEnvelope(
|
|
188
|
+
resolved,
|
|
189
|
+
{
|
|
190
|
+
kind: "attributes",
|
|
191
|
+
data: availableAttributes ?? [],
|
|
192
|
+
},
|
|
193
|
+
{
|
|
194
|
+
enabled: true,
|
|
195
|
+
source: "live /console/inspect request=child+completion",
|
|
196
|
+
availableAttributes: availableAttributes ?? [],
|
|
197
|
+
},
|
|
198
|
+
warnings,
|
|
199
|
+
);
|
|
200
|
+
return applyMaxResultsBudget(envelope);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (
|
|
204
|
+
resolved.validate.value &&
|
|
205
|
+
availableAttributes &&
|
|
206
|
+
resolved.attributes.length > 0
|
|
207
|
+
) {
|
|
208
|
+
const missing = resolved.attributes.filter(
|
|
209
|
+
(attribute) => !availableAttributes?.includes(attribute),
|
|
210
|
+
);
|
|
211
|
+
if (missing.length > 0) {
|
|
212
|
+
throw new CentrsError({
|
|
213
|
+
code: "validation/unknown-attribute",
|
|
214
|
+
summary: `Unknown RouterOS attribute ${missing.join(", ")} for ${resolved.path}.`,
|
|
215
|
+
remediation:
|
|
216
|
+
"Check the attribute name, or use `--list-attributes` to inspect the available properties first.",
|
|
217
|
+
context: {
|
|
218
|
+
path: resolved.path,
|
|
219
|
+
requested: resolved.attributes,
|
|
220
|
+
availableAttributes,
|
|
221
|
+
},
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const data = await executeRetrieve(resolved, backend);
|
|
227
|
+
const envelope = buildSuccessEnvelope(
|
|
228
|
+
resolved,
|
|
229
|
+
{
|
|
230
|
+
kind: "data",
|
|
231
|
+
data,
|
|
232
|
+
},
|
|
233
|
+
{
|
|
234
|
+
enabled: resolved.validate.value,
|
|
235
|
+
source: resolved.validate.value
|
|
236
|
+
? availableAttributes
|
|
237
|
+
? "live /console/inspect request=child+completion"
|
|
238
|
+
: "live /console/inspect request=child"
|
|
239
|
+
: "disabled",
|
|
240
|
+
availableAttributes,
|
|
241
|
+
},
|
|
242
|
+
warnings,
|
|
243
|
+
);
|
|
244
|
+
return applyMaxResultsBudget(envelope);
|
|
245
|
+
} finally {
|
|
246
|
+
await backend.close();
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export function buildRetrieveErrorEnvelope(
|
|
251
|
+
request: RetrieveRequest,
|
|
252
|
+
error: unknown,
|
|
253
|
+
): RetrieveErrorEnvelope {
|
|
254
|
+
const centrsError =
|
|
255
|
+
error instanceof CentrsError
|
|
256
|
+
? error
|
|
257
|
+
: new CentrsError({
|
|
258
|
+
code: "internal/unhandled",
|
|
259
|
+
summary: "retrieve failed with an unexpected internal error.",
|
|
260
|
+
remediation:
|
|
261
|
+
"Re-run with `--format json` to capture the structured error details for debugging.",
|
|
262
|
+
cause: error,
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
const requestedVia = plannedProtocols.includes(
|
|
266
|
+
request.via as RouterOsProtocol,
|
|
267
|
+
)
|
|
268
|
+
? (request.via as RouterOsProtocol)
|
|
269
|
+
: null;
|
|
270
|
+
|
|
271
|
+
return {
|
|
272
|
+
ok: false,
|
|
273
|
+
error: serializeCentrsError(centrsError),
|
|
274
|
+
warnings: [],
|
|
275
|
+
tips: [],
|
|
276
|
+
meta: {
|
|
277
|
+
target: { input: request.targetInput },
|
|
278
|
+
via: requestedVia,
|
|
279
|
+
settings: {},
|
|
280
|
+
},
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Error envelope for a single fanout target. Unlike
|
|
286
|
+
* {@link buildRetrieveErrorEnvelope} (which only knows the raw request and so
|
|
287
|
+
* loses `recordIndex` / `baseUrl` / `name` / per-field sources), this preserves
|
|
288
|
+
* the resolved per-target identity, auth, settings, and operation meta so a
|
|
289
|
+
* failed inner envelope carries the same provenance as a successful one.
|
|
290
|
+
*/
|
|
291
|
+
export function buildRetrieveErrorEnvelopeFromResolved(
|
|
292
|
+
resolved: ResolvedRetrieveRequest,
|
|
293
|
+
error: unknown,
|
|
294
|
+
): RetrieveErrorEnvelope {
|
|
295
|
+
const centrsError =
|
|
296
|
+
error instanceof CentrsError
|
|
297
|
+
? error
|
|
298
|
+
: new CentrsError({
|
|
299
|
+
code: "internal/unhandled",
|
|
300
|
+
summary: "retrieve failed with an unexpected internal error.",
|
|
301
|
+
remediation:
|
|
302
|
+
"Re-run with `--format json` to capture the structured error details for debugging.",
|
|
303
|
+
cause: error,
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
const meta = metaFromResolved(resolved, {
|
|
307
|
+
enabled: resolved.validate.value,
|
|
308
|
+
source: resolved.validate.value ? "live /console/inspect" : "disabled",
|
|
309
|
+
result: "failed",
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
return {
|
|
313
|
+
ok: false,
|
|
314
|
+
error: serializeCentrsError(centrsError),
|
|
315
|
+
warnings: [...resolved.warnings],
|
|
316
|
+
tips: [],
|
|
317
|
+
meta,
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export function renderRetrieveEnvelope(
|
|
322
|
+
envelope: RetrieveEnvelope,
|
|
323
|
+
format: RetrieveOutputFormat,
|
|
324
|
+
options: { verbose?: boolean } = {},
|
|
325
|
+
): string {
|
|
326
|
+
switch (format) {
|
|
327
|
+
case "json":
|
|
328
|
+
return JSON.stringify(envelope, null, 2);
|
|
329
|
+
case "yaml":
|
|
330
|
+
return toYaml(envelope);
|
|
331
|
+
case "text":
|
|
332
|
+
return envelope.ok
|
|
333
|
+
? renderRetrieveSuccessText(envelope, options)
|
|
334
|
+
: renderRetrieveErrorText(envelope, options);
|
|
335
|
+
default:
|
|
336
|
+
return exhaustiveOutputFormat(format);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function renderRetrieveSuccessText(
|
|
341
|
+
envelope: RetrieveSuccessEnvelope,
|
|
342
|
+
options: { verbose?: boolean },
|
|
343
|
+
): string {
|
|
344
|
+
const lines: string[] = [];
|
|
345
|
+
const verbose = options.verbose ?? false;
|
|
346
|
+
const meta = envelope.meta;
|
|
347
|
+
const operation = meta.operation;
|
|
348
|
+
|
|
349
|
+
if (verbose) {
|
|
350
|
+
const target = meta.target;
|
|
351
|
+
lines.push(`target: ${target.input ?? target.host} -> ${target.baseUrl}`);
|
|
352
|
+
lines.push(`via: ${meta.via}`);
|
|
353
|
+
lines.push(
|
|
354
|
+
`sources: via=${formatCoreSource(meta.settings.via)}, host=${formatCoreSource(
|
|
355
|
+
meta.settings.host,
|
|
356
|
+
)}, port=${formatCoreSource(meta.settings.port)}, timeout=${formatCoreSource(
|
|
357
|
+
meta.settings.timeoutMs,
|
|
358
|
+
)}, format=${formatCoreSource(meta.settings.format)}, validate=${formatCoreSource(
|
|
359
|
+
meta.settings.validate,
|
|
360
|
+
)}`,
|
|
361
|
+
);
|
|
362
|
+
if (meta.validation?.enabled) {
|
|
363
|
+
lines.push(`validation: ${meta.validation.source}`);
|
|
364
|
+
}
|
|
365
|
+
lines.push("");
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
if (operation?.kind === "attributes") {
|
|
369
|
+
const attributes = Array.isArray(envelope.data)
|
|
370
|
+
? (envelope.data as readonly string[])
|
|
371
|
+
: [];
|
|
372
|
+
lines.push(...attributes);
|
|
373
|
+
} else if (typeof envelope.data === "string") {
|
|
374
|
+
lines.push(envelope.data);
|
|
375
|
+
} else {
|
|
376
|
+
lines.push(JSON.stringify(envelope.data, null, 2));
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
if (envelope.warnings.length > 0) {
|
|
380
|
+
lines.push("");
|
|
381
|
+
for (const warning of envelope.warnings) {
|
|
382
|
+
lines.push(`warning [${warning.code}]: ${warning.message}`);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
if (envelope.tips.length > 0) {
|
|
387
|
+
lines.push("");
|
|
388
|
+
lines.push("Tips:");
|
|
389
|
+
for (const item of envelope.tips) {
|
|
390
|
+
lines.push(` - [${item.code}] ${item.message}`);
|
|
391
|
+
if (item.fix) {
|
|
392
|
+
lines.push(` fix: ${item.fix}`);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
return lines.join("\n");
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function renderRetrieveErrorText(
|
|
401
|
+
envelope: RetrieveErrorEnvelope,
|
|
402
|
+
options: { verbose?: boolean },
|
|
403
|
+
): string {
|
|
404
|
+
const { error } = envelope;
|
|
405
|
+
const lines = [`[${error.code}] ${error.summary}`];
|
|
406
|
+
|
|
407
|
+
if (error.remediation) {
|
|
408
|
+
lines.push(`Fix: ${error.remediation}`);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
if (options.verbose && error.context) {
|
|
412
|
+
lines.push("");
|
|
413
|
+
lines.push(JSON.stringify(error.context, null, 2));
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
return lines.join("\n");
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function retrieveRequestSummary(
|
|
420
|
+
resolved: ResolvedRetrieveRequest,
|
|
421
|
+
): RetrieveRequestSummary {
|
|
422
|
+
return {
|
|
423
|
+
path: resolved.path,
|
|
424
|
+
attributes: resolved.attributes,
|
|
425
|
+
allAttributes: resolved.allAttributes,
|
|
426
|
+
listAttributes: resolved.listAttributes,
|
|
427
|
+
validate: resolved.validate.value,
|
|
428
|
+
verbose: resolved.verbose,
|
|
429
|
+
timeoutMs: resolved.timeoutMs.value,
|
|
430
|
+
format: resolved.format.value,
|
|
431
|
+
maxResultsBytes: resolved.maxResultsBytes?.value,
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Common `target` / `via` / `settings` meta for a resolved target. Shared by
|
|
437
|
+
* success and per-target error envelopes so both carry identical provenance.
|
|
438
|
+
*/
|
|
439
|
+
function metaFromResolved(
|
|
440
|
+
resolved: ResolvedRetrieveRequest,
|
|
441
|
+
validation: EnvelopeValidationMeta,
|
|
442
|
+
operation?: RetrieveOperationMeta,
|
|
443
|
+
): RetrieveEnvelope["meta"] {
|
|
444
|
+
const target = resolved.target;
|
|
445
|
+
const targetSources: Record<string, CoreSettingSource> = {};
|
|
446
|
+
for (const [field, source] of Object.entries(target.sources)) {
|
|
447
|
+
targetSources[field] = toCoreSource(source);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
return {
|
|
451
|
+
target: {
|
|
452
|
+
input: target.input,
|
|
453
|
+
host: target.host,
|
|
454
|
+
port: target.port,
|
|
455
|
+
baseUrl: target.baseUrl,
|
|
456
|
+
identity: target.identity,
|
|
457
|
+
recordIndex: target.recordIndex,
|
|
458
|
+
source: toCoreSource(target.source),
|
|
459
|
+
sources: targetSources,
|
|
460
|
+
},
|
|
461
|
+
via: resolved.via.value,
|
|
462
|
+
settings: {
|
|
463
|
+
via: toCoreSource(resolved.via.source),
|
|
464
|
+
host: toCoreSource(target.hostSource),
|
|
465
|
+
port: toCoreSource(target.portSource),
|
|
466
|
+
timeoutMs: toCoreSource(resolved.timeoutMs.source),
|
|
467
|
+
format: toCoreSource(resolved.format.source),
|
|
468
|
+
validate: toCoreSource(resolved.validate.source),
|
|
469
|
+
maxResultsBytes: resolved.maxResultsBytes
|
|
470
|
+
? toCoreSource(resolved.maxResultsBytes.source)
|
|
471
|
+
: undefined,
|
|
472
|
+
username: resolved.auth.usernameSource
|
|
473
|
+
? toCoreSource(resolved.auth.usernameSource)
|
|
474
|
+
: undefined,
|
|
475
|
+
password: resolved.auth.passwordSource
|
|
476
|
+
? toCoreSource(resolved.auth.passwordSource)
|
|
477
|
+
: undefined,
|
|
478
|
+
},
|
|
479
|
+
validation,
|
|
480
|
+
operation:
|
|
481
|
+
operation ??
|
|
482
|
+
({
|
|
483
|
+
kind: resolved.listAttributes ? "attributes" : "data",
|
|
484
|
+
objectCount: 0,
|
|
485
|
+
request: retrieveRequestSummary(resolved),
|
|
486
|
+
auth: {
|
|
487
|
+
username: resolved.auth.username,
|
|
488
|
+
passwordProvided: resolved.auth.passwordProvided,
|
|
489
|
+
},
|
|
490
|
+
} satisfies RetrieveOperationMeta),
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function buildSuccessEnvelope(
|
|
495
|
+
resolved: ResolvedRetrieveRequest,
|
|
496
|
+
result: { kind: "attributes" | "data"; data: unknown },
|
|
497
|
+
validation: EnvelopeValidationMeta,
|
|
498
|
+
warnings: readonly RetrieveWarning[],
|
|
499
|
+
): RetrieveSuccessEnvelope {
|
|
500
|
+
const operation: RetrieveOperationMeta = {
|
|
501
|
+
kind: result.kind,
|
|
502
|
+
objectCount: countResultObjects(result.data),
|
|
503
|
+
request: retrieveRequestSummary(resolved),
|
|
504
|
+
auth: {
|
|
505
|
+
username: resolved.auth.username,
|
|
506
|
+
passwordProvided: resolved.auth.passwordProvided,
|
|
507
|
+
},
|
|
508
|
+
};
|
|
509
|
+
|
|
510
|
+
return {
|
|
511
|
+
ok: true,
|
|
512
|
+
data: result.data,
|
|
513
|
+
warnings,
|
|
514
|
+
tips: [],
|
|
515
|
+
meta: metaFromResolved(resolved, validation, operation),
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function applyMaxResultsBudget(
|
|
520
|
+
envelope: RetrieveSuccessEnvelope,
|
|
521
|
+
): RetrieveSuccessEnvelope {
|
|
522
|
+
const operation = envelope.meta.operation;
|
|
523
|
+
if (!operation) {
|
|
524
|
+
return envelope;
|
|
525
|
+
}
|
|
526
|
+
const requestSummary = operation.request;
|
|
527
|
+
const rendered = renderRetrieveEnvelope(envelope, requestSummary.format, {
|
|
528
|
+
verbose: requestSummary.format === "text" && requestSummary.verbose,
|
|
529
|
+
});
|
|
530
|
+
const serializedBytes = byteLength(rendered);
|
|
531
|
+
|
|
532
|
+
if (
|
|
533
|
+
requestSummary.maxResultsBytes !== undefined &&
|
|
534
|
+
serializedBytes > requestSummary.maxResultsBytes
|
|
535
|
+
) {
|
|
536
|
+
throw new CentrsError({
|
|
537
|
+
code: "input/max-results-exceeded",
|
|
538
|
+
summary: `retrieve output exceeded the requested ${requestSummary.maxResultsBytes}-byte budget.`,
|
|
539
|
+
remediation:
|
|
540
|
+
"Increase `--max-results`, reduce the selected attributes, or switch to a more selective path.",
|
|
541
|
+
context: {
|
|
542
|
+
requiredBytes: serializedBytes,
|
|
543
|
+
maxResultsBytes: requestSummary.maxResultsBytes,
|
|
544
|
+
objectCount: operation.objectCount,
|
|
545
|
+
path: requestSummary.path,
|
|
546
|
+
},
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
return envelope;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* Validate the request-shape concerns that do not depend on a target (path
|
|
555
|
+
* form, unimplemented flags, mutually-exclusive output flags). Shared by
|
|
556
|
+
* single-target resolution and group fanout so both reject the same shapes
|
|
557
|
+
* before any network or CDB work. Returns the normalized attribute selection.
|
|
558
|
+
*/
|
|
559
|
+
export function validateRetrieveRequestShape(
|
|
560
|
+
request: RetrieveRequest,
|
|
561
|
+
): readonly string[] {
|
|
562
|
+
if (!request.path.startsWith("/")) {
|
|
563
|
+
throw new CentrsError({
|
|
564
|
+
code: "input/invalid-routeros-path",
|
|
565
|
+
summary: `RouterOS path must be slash-prefixed. Received: ${request.path}`,
|
|
566
|
+
remediation:
|
|
567
|
+
"Pass a RouterOS menu path such as `/system/resource` or `/ip/address`.",
|
|
568
|
+
context: { path: request.path },
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
if (request.filter !== undefined || request.query !== undefined) {
|
|
573
|
+
throw new CentrsError({
|
|
574
|
+
code: "validation/not-implemented",
|
|
575
|
+
summary:
|
|
576
|
+
"`--filter` and `--query` are not implemented yet for `retrieve`.",
|
|
577
|
+
remediation:
|
|
578
|
+
"Remove the flag for now, or narrow the request with `--attribute` and `--max-results` instead.",
|
|
579
|
+
context: {
|
|
580
|
+
filter: request.filter,
|
|
581
|
+
query: request.query,
|
|
582
|
+
},
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
const attributeSelections = normalizeAttributeSelection(request);
|
|
587
|
+
if (request.allAttributes && attributeSelections.length > 0) {
|
|
588
|
+
throw new CentrsError({
|
|
589
|
+
code: "usage/conflicting-flags",
|
|
590
|
+
summary:
|
|
591
|
+
"`--all-attributes` cannot be combined with `--attribute` or `--attributes`.",
|
|
592
|
+
remediation:
|
|
593
|
+
"Choose either an explicit projection (`--attribute`) or the full detail/all-attributes shape (`--all-attributes`).",
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
if (
|
|
598
|
+
request.listAttributes &&
|
|
599
|
+
(request.allAttributes || attributeSelections.length > 0)
|
|
600
|
+
) {
|
|
601
|
+
throw new CentrsError({
|
|
602
|
+
code: "usage/conflicting-flags",
|
|
603
|
+
summary:
|
|
604
|
+
"`--list-attributes` cannot be combined with output-shaping flags such as `--attribute` or `--all-attributes`.",
|
|
605
|
+
remediation:
|
|
606
|
+
"Run `--list-attributes` by itself, then make a second call with the attributes you want to retrieve.",
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
return attributeSelections;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* Build a {@link ResolvedRetrieveRequest} from the static request plus an
|
|
615
|
+
* already-resolved CDB record (or `undefined` for a literal target). Fanout
|
|
616
|
+
* reuses this per group member after loading + decrypting the CDB once, so the
|
|
617
|
+
* resolver settings ladder (env / cli / comment-kv) applies identically to a
|
|
618
|
+
* single target and to every target in a group.
|
|
619
|
+
*/
|
|
620
|
+
export function buildResolvedRetrieve(
|
|
621
|
+
request: RetrieveRequest,
|
|
622
|
+
env: Record<string, string | undefined>,
|
|
623
|
+
cdbResolution: CdbResolution | undefined,
|
|
624
|
+
attributeSelections: readonly string[],
|
|
625
|
+
macResolution?: { mac: string; ip: string },
|
|
626
|
+
): ResolvedRetrieveRequest {
|
|
627
|
+
const via = resolveProtocol(request, env, cdbResolution);
|
|
628
|
+
const format = resolveFormat(request, env);
|
|
629
|
+
const validate = resolveBooleanSetting(
|
|
630
|
+
request.validate,
|
|
631
|
+
env,
|
|
632
|
+
"CENTRS_VALIDATE",
|
|
633
|
+
true,
|
|
634
|
+
"validate",
|
|
635
|
+
cdbResolution?.overrides.validate,
|
|
636
|
+
);
|
|
637
|
+
const timeoutMs = resolveTimeoutSetting(
|
|
638
|
+
request.timeout,
|
|
639
|
+
env,
|
|
640
|
+
via.value,
|
|
641
|
+
cdbResolution?.overrides.timeoutMs,
|
|
642
|
+
);
|
|
643
|
+
const maxResultsBytes = resolveOptionalIntegerSetting(
|
|
644
|
+
request.maxResultsBytes,
|
|
645
|
+
env,
|
|
646
|
+
"CENTRS_MAX_RESULTS",
|
|
647
|
+
"max-results",
|
|
648
|
+
);
|
|
649
|
+
const target = resolveTarget(
|
|
650
|
+
{
|
|
651
|
+
targetInput: request.targetInput,
|
|
652
|
+
host: request.host,
|
|
653
|
+
port: request.port,
|
|
654
|
+
macResolution,
|
|
655
|
+
},
|
|
656
|
+
env,
|
|
657
|
+
via.value,
|
|
658
|
+
cdbResolution,
|
|
659
|
+
);
|
|
660
|
+
const auth = resolveAuth(
|
|
661
|
+
{ username: request.username, password: request.password },
|
|
662
|
+
env,
|
|
663
|
+
cdbResolution,
|
|
664
|
+
);
|
|
665
|
+
|
|
666
|
+
return {
|
|
667
|
+
path: request.path,
|
|
668
|
+
via,
|
|
669
|
+
target,
|
|
670
|
+
auth,
|
|
671
|
+
timeoutMs,
|
|
672
|
+
format,
|
|
673
|
+
validate,
|
|
674
|
+
maxResultsBytes,
|
|
675
|
+
attributes: attributeSelections,
|
|
676
|
+
allAttributes: request.allAttributes ?? false,
|
|
677
|
+
listAttributes: request.listAttributes ?? false,
|
|
678
|
+
verbose: request.verbose ?? false,
|
|
679
|
+
warnings: cdbResolution?.warnings ?? [],
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
export interface RetrieveGlobalContext {
|
|
684
|
+
via: RouterOsProtocol;
|
|
685
|
+
summary: RetrieveRequestSummary;
|
|
686
|
+
settings: CommonSettingsMeta;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/**
|
|
690
|
+
* Resolve the target-independent settings (protocol, format, validate, timeout,
|
|
691
|
+
* max-results) used for a group fanout's outer `meta.operation.request` summary
|
|
692
|
+
* and its transport-aware concurrency default. No CDB record is consulted; the
|
|
693
|
+
* global `--via` (default `rest-api`) decides the summary and concurrency base.
|
|
694
|
+
*/
|
|
695
|
+
export function resolveRetrieveGlobalContext(
|
|
696
|
+
request: RetrieveRequest,
|
|
697
|
+
env: Record<string, string | undefined>,
|
|
698
|
+
attributeSelections: readonly string[],
|
|
699
|
+
): RetrieveGlobalContext {
|
|
700
|
+
const via = resolveProtocol(request, env);
|
|
701
|
+
const format = resolveFormat(request, env);
|
|
702
|
+
const validate = resolveBooleanSetting(
|
|
703
|
+
request.validate,
|
|
704
|
+
env,
|
|
705
|
+
"CENTRS_VALIDATE",
|
|
706
|
+
true,
|
|
707
|
+
"validate",
|
|
708
|
+
);
|
|
709
|
+
const timeoutMs = resolveTimeoutSetting(request.timeout, env, via.value);
|
|
710
|
+
const maxResultsBytes = resolveOptionalIntegerSetting(
|
|
711
|
+
request.maxResultsBytes,
|
|
712
|
+
env,
|
|
713
|
+
"CENTRS_MAX_RESULTS",
|
|
714
|
+
"max-results",
|
|
715
|
+
);
|
|
716
|
+
|
|
717
|
+
return {
|
|
718
|
+
via: via.value,
|
|
719
|
+
summary: {
|
|
720
|
+
path: request.path,
|
|
721
|
+
attributes: attributeSelections,
|
|
722
|
+
allAttributes: request.allAttributes ?? false,
|
|
723
|
+
listAttributes: request.listAttributes ?? false,
|
|
724
|
+
validate: validate.value,
|
|
725
|
+
verbose: request.verbose ?? false,
|
|
726
|
+
timeoutMs: timeoutMs.value,
|
|
727
|
+
format: format.value,
|
|
728
|
+
maxResultsBytes: maxResultsBytes?.value,
|
|
729
|
+
},
|
|
730
|
+
settings: {
|
|
731
|
+
via: toCoreSource(via.source),
|
|
732
|
+
timeoutMs: toCoreSource(timeoutMs.source),
|
|
733
|
+
format: toCoreSource(format.source),
|
|
734
|
+
validate: toCoreSource(validate.source),
|
|
735
|
+
maxResultsBytes: maxResultsBytes
|
|
736
|
+
? toCoreSource(maxResultsBytes.source)
|
|
737
|
+
: undefined,
|
|
738
|
+
},
|
|
739
|
+
};
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
export async function resolveRetrieveRequest(
|
|
743
|
+
request: RetrieveRequest,
|
|
744
|
+
env: Record<string, string | undefined>,
|
|
745
|
+
): Promise<ResolvedRetrieveRequest> {
|
|
746
|
+
const attributeSelections = validateRetrieveRequestShape(request);
|
|
747
|
+
|
|
748
|
+
const cdbResolution = await resolveCdb(
|
|
749
|
+
{
|
|
750
|
+
targetInput: request.targetInput,
|
|
751
|
+
cdbFile: request.cdbFile,
|
|
752
|
+
cdbPassword: request.cdbPassword,
|
|
753
|
+
},
|
|
754
|
+
env,
|
|
755
|
+
);
|
|
756
|
+
|
|
757
|
+
const macResolution = await resolveMacForRetrieve(
|
|
758
|
+
request,
|
|
759
|
+
env,
|
|
760
|
+
cdbResolution,
|
|
761
|
+
);
|
|
762
|
+
|
|
763
|
+
return buildResolvedRetrieve(
|
|
764
|
+
request,
|
|
765
|
+
env,
|
|
766
|
+
cdbResolution,
|
|
767
|
+
attributeSelections,
|
|
768
|
+
macResolution,
|
|
769
|
+
);
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/**
|
|
773
|
+
* Resolve a MAC target to an IP for IP-based transports, honoring the resolve
|
|
774
|
+
* policy. Returns `undefined` for L2 transports (mac-telnet/romon) or when the
|
|
775
|
+
* target is not a MAC. Shared by single-target and group-fanout paths so
|
|
776
|
+
* `--resolve arp` works for CDB group members too.
|
|
777
|
+
*/
|
|
778
|
+
export async function resolveMacForRetrieve(
|
|
779
|
+
request: RetrieveRequest,
|
|
780
|
+
env: Record<string, string | undefined>,
|
|
781
|
+
cdb?: CdbResolution,
|
|
782
|
+
): Promise<{ mac: string; ip: string } | undefined> {
|
|
783
|
+
if (!isIpTransport(resolveProtocol(request, env, cdb).value)) {
|
|
784
|
+
return undefined;
|
|
785
|
+
}
|
|
786
|
+
return resolveMacTarget({
|
|
787
|
+
host: request.host,
|
|
788
|
+
targetInput: request.targetInput,
|
|
789
|
+
cdbTarget: cdb?.target,
|
|
790
|
+
env,
|
|
791
|
+
policy: parseResolvePolicy(request.resolve ?? env["CENTRS_RESOLVE"]),
|
|
792
|
+
operation: "retrieve",
|
|
793
|
+
});
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
function resolveProtocol(
|
|
797
|
+
request: RetrieveRequest,
|
|
798
|
+
env: Record<string, string | undefined>,
|
|
799
|
+
cdb?: CdbResolution,
|
|
800
|
+
): ResolvedSetting<RouterOsProtocol> {
|
|
801
|
+
const via = resolveStringSetting(
|
|
802
|
+
request.via,
|
|
803
|
+
env,
|
|
804
|
+
"CENTRS_VIA",
|
|
805
|
+
"rest-api",
|
|
806
|
+
"via",
|
|
807
|
+
undefined,
|
|
808
|
+
cdb?.overrides.via,
|
|
809
|
+
);
|
|
810
|
+
if (!via) {
|
|
811
|
+
throw new CentrsError({
|
|
812
|
+
code: "internal/unhandled",
|
|
813
|
+
summary: "Failed to resolve the default retrieve protocol.",
|
|
814
|
+
remediation:
|
|
815
|
+
"Report this bug; retrieve should default to `rest-api` when no protocol is pinned.",
|
|
816
|
+
});
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
if (
|
|
820
|
+
![
|
|
821
|
+
"rest-api",
|
|
822
|
+
"native-api",
|
|
823
|
+
"ssh",
|
|
824
|
+
"snmp",
|
|
825
|
+
"mndp",
|
|
826
|
+
"mac-telnet",
|
|
827
|
+
"romon",
|
|
828
|
+
"winbox-terminal",
|
|
829
|
+
].includes(via.value)
|
|
830
|
+
) {
|
|
831
|
+
throw new CentrsError({
|
|
832
|
+
code: "settings/invalid-via",
|
|
833
|
+
summary: `Unsupported protocol identifier: ${via.value}`,
|
|
834
|
+
remediation:
|
|
835
|
+
"Choose one of the known `via` values, such as `rest-api` for the current alpha retrieve loop.",
|
|
836
|
+
context: { via: via.value },
|
|
837
|
+
});
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
const plan = getProtocolPlan(via.value as RouterOsProtocol);
|
|
841
|
+
if (!plan.capabilities.includes("retrieve")) {
|
|
842
|
+
throw new CentrsError({
|
|
843
|
+
code: "routeros/unsupported-capability",
|
|
844
|
+
summary: `Protocol ${via.value} does not support the retrieve capability.`,
|
|
845
|
+
remediation:
|
|
846
|
+
"Choose a retrieve-capable protocol such as `rest-api`, `native-api`, or `snmp`.",
|
|
847
|
+
context: { via: via.value, capability: "retrieve" },
|
|
848
|
+
});
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
if (!plan.implemented) {
|
|
852
|
+
throw new CentrsError({
|
|
853
|
+
code: "routeros/protocol-not-implemented",
|
|
854
|
+
summary: `Protocol ${via.value} is planned but not implemented yet.`,
|
|
855
|
+
remediation:
|
|
856
|
+
"Use `--via rest-api` for the current alpha retrieve implementation.",
|
|
857
|
+
context: { via: via.value, capability: "retrieve" },
|
|
858
|
+
});
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
return via as ResolvedSetting<RouterOsProtocol>;
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
function resolveFormat(
|
|
865
|
+
request: RetrieveRequest,
|
|
866
|
+
env: Record<string, string | undefined>,
|
|
867
|
+
): ResolvedSetting<RetrieveOutputFormat> {
|
|
868
|
+
return resolveStringSetting(
|
|
869
|
+
request.format,
|
|
870
|
+
env,
|
|
871
|
+
"CENTRS_FORMAT",
|
|
872
|
+
"text",
|
|
873
|
+
"format",
|
|
874
|
+
parseOutputFormat,
|
|
875
|
+
) as ResolvedSetting<RetrieveOutputFormat>;
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
/**
|
|
879
|
+
* Path-existence probe. An invalid path raises a native trap that
|
|
880
|
+
* {@link mapRouterOsError} normalizes to `routeros/unknown-path` (REST instead
|
|
881
|
+
* returns an empty child list). Both the legacy `routeros/api-trap` catch-all
|
|
882
|
+
* and the grounded `routeros/unknown-path` classification are flattened to an
|
|
883
|
+
* empty list so the orchestrator surfaces a single `validation/unknown-path`.
|
|
884
|
+
* Traps during attribute/completion discovery do NOT use this probe — they
|
|
885
|
+
* surface as-is.
|
|
886
|
+
*/
|
|
887
|
+
async function inspectChildrenForProbe(
|
|
888
|
+
backend: ProtocolAdapter,
|
|
889
|
+
path: string,
|
|
890
|
+
): Promise<unknown[]> {
|
|
891
|
+
try {
|
|
892
|
+
return await backend.inspect("child", path);
|
|
893
|
+
} catch (error) {
|
|
894
|
+
if (
|
|
895
|
+
error instanceof CentrsError &&
|
|
896
|
+
(error.code === "routeros/api-trap" ||
|
|
897
|
+
error.code === "routeros/unknown-path")
|
|
898
|
+
) {
|
|
899
|
+
return [];
|
|
900
|
+
}
|
|
901
|
+
throw error;
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
async function inspectRetrievePath(
|
|
906
|
+
resolved: ResolvedRetrieveRequest,
|
|
907
|
+
backend: ProtocolAdapter,
|
|
908
|
+
): Promise<RetrieveInspection> {
|
|
909
|
+
const pathTokens = pathTokensForInspect(resolved.path);
|
|
910
|
+
const rootChildren = (await inspectChildrenForProbe(
|
|
911
|
+
backend,
|
|
912
|
+
pathToInspectString(pathTokens),
|
|
913
|
+
)) as InspectChildItem[];
|
|
914
|
+
|
|
915
|
+
const supportsPrint = rootChildren.some((child) =>
|
|
916
|
+
isCommandNode(child, "print"),
|
|
917
|
+
);
|
|
918
|
+
const supportsGet = rootChildren.some((child) => isCommandNode(child, "get"));
|
|
919
|
+
if (!supportsPrint && !supportsGet) {
|
|
920
|
+
throw new CentrsError({
|
|
921
|
+
code: "validation/unknown-path",
|
|
922
|
+
summary: `RouterOS path ${resolved.path} does not expose a retrieve command.`,
|
|
923
|
+
remediation:
|
|
924
|
+
"Check the slash-prefixed RouterOS path, or use a known readable path such as `/system/resource`, `/system/identity`, `/ip/address`, or `/interface`.",
|
|
925
|
+
context: {
|
|
926
|
+
path: resolved.path,
|
|
927
|
+
validationSource: "/console/inspect request=child",
|
|
928
|
+
availableChildren: rootChildren
|
|
929
|
+
.map((child) => child.name)
|
|
930
|
+
.filter((name): name is string => typeof name === "string"),
|
|
931
|
+
},
|
|
932
|
+
});
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
const singleton = isKnownSingletonPath(resolved.path);
|
|
936
|
+
return {
|
|
937
|
+
command: singleton && supportsGet ? "get" : "print",
|
|
938
|
+
};
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
async function inspectAttributes(
|
|
942
|
+
resolved: ResolvedRetrieveRequest,
|
|
943
|
+
inspection: RetrieveInspection,
|
|
944
|
+
backend: ProtocolAdapter,
|
|
945
|
+
): Promise<string[]> {
|
|
946
|
+
const pathTokens = pathTokensForInspect(resolved.path);
|
|
947
|
+
const argument = inspection.command === "get" ? "value-name" : "proplist";
|
|
948
|
+
const completionRows = (await backend.inspect(
|
|
949
|
+
"completion",
|
|
950
|
+
pathToInspectString([...pathTokens, inspection.command, argument]),
|
|
951
|
+
)) as InspectCompletionItem[];
|
|
952
|
+
const completions = extractCompletionNames(completionRows);
|
|
953
|
+
if (completions.length > 0) {
|
|
954
|
+
return completions;
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
const commandChildren = (await backend.inspect(
|
|
958
|
+
"child",
|
|
959
|
+
pathToInspectString([...pathTokens, inspection.command]),
|
|
960
|
+
)) as InspectChildItem[];
|
|
961
|
+
return commandChildren
|
|
962
|
+
.filter(isArgumentNode)
|
|
963
|
+
.map((child) => child.name)
|
|
964
|
+
.filter(
|
|
965
|
+
(name): name is string => typeof name === "string" && name.length > 0,
|
|
966
|
+
)
|
|
967
|
+
.sort();
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
async function executeRetrieve(
|
|
971
|
+
resolved: ResolvedRetrieveRequest,
|
|
972
|
+
backend: ProtocolAdapter,
|
|
973
|
+
): Promise<unknown> {
|
|
974
|
+
if (isKnownSingletonPath(resolved.path)) {
|
|
975
|
+
const data = await backend.getSingleton(resolved.path);
|
|
976
|
+
if (resolved.attributes.length > 0) {
|
|
977
|
+
return projectSingletonAttributes(data, resolved.attributes);
|
|
978
|
+
}
|
|
979
|
+
return data;
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
return backend.list(resolved.path, {
|
|
983
|
+
proplist: resolved.attributes.length > 0 ? resolved.attributes : undefined,
|
|
984
|
+
detail: resolved.allAttributes,
|
|
985
|
+
});
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
function pathTokensForInspect(path: string): string[] {
|
|
989
|
+
return path.split("/").filter(Boolean);
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
function isCommandNode(
|
|
993
|
+
child: InspectChildItem,
|
|
994
|
+
name: "get" | "print",
|
|
995
|
+
): boolean {
|
|
996
|
+
return (
|
|
997
|
+
child.name === name &&
|
|
998
|
+
(child.type === "cmd" || child["node-type"] === "cmd")
|
|
999
|
+
);
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
function isArgumentNode(child: InspectChildItem): boolean {
|
|
1003
|
+
return child.type === "arg" || child["node-type"] === "arg";
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
function extractCompletionNames(
|
|
1007
|
+
rows: readonly InspectCompletionItem[],
|
|
1008
|
+
): string[] {
|
|
1009
|
+
const names = rows
|
|
1010
|
+
.flatMap((row) => [row.completion, row.name, row.value, row.text])
|
|
1011
|
+
.filter((value): value is string => typeof value === "string")
|
|
1012
|
+
.map((value) => value.replace(/=.*$/, "").trim())
|
|
1013
|
+
.filter((value) => value.length > 0);
|
|
1014
|
+
return [...new Set(names)].sort();
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
function isKnownSingletonPath(path: string): boolean {
|
|
1018
|
+
return ["/system/resource", "/system/identity"].includes(
|
|
1019
|
+
path.replace(/\/$/, ""),
|
|
1020
|
+
);
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
function projectSingletonAttributes(
|
|
1024
|
+
data: unknown,
|
|
1025
|
+
attributes: readonly string[],
|
|
1026
|
+
): unknown {
|
|
1027
|
+
if (!isPlainObject(data)) {
|
|
1028
|
+
return data;
|
|
1029
|
+
}
|
|
1030
|
+
if (attributes.length === 1) {
|
|
1031
|
+
return data[attributes[0] ?? ""];
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
const projected: Record<string, unknown> = {};
|
|
1035
|
+
for (const attribute of attributes) {
|
|
1036
|
+
projected[attribute] = data[attribute];
|
|
1037
|
+
}
|
|
1038
|
+
return projected;
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
function resolveTimeoutSetting(
|
|
1042
|
+
timeout: RetrieveRequest["timeout"],
|
|
1043
|
+
env: Record<string, string | undefined>,
|
|
1044
|
+
via: RouterOsProtocol,
|
|
1045
|
+
commentKv?: ResolvedSetting<number>,
|
|
1046
|
+
): ResolvedSetting<number> {
|
|
1047
|
+
const resolved = resolveStringSetting(
|
|
1048
|
+
timeout === undefined ? undefined : String(timeout),
|
|
1049
|
+
env,
|
|
1050
|
+
"CENTRS_TIMEOUT",
|
|
1051
|
+
"10000",
|
|
1052
|
+
"timeout",
|
|
1053
|
+
(value) => {
|
|
1054
|
+
const parsed = parseDuration(value);
|
|
1055
|
+
if (parsed <= 0) {
|
|
1056
|
+
throw new CentrsError({
|
|
1057
|
+
code: "settings/invalid-timeout",
|
|
1058
|
+
summary: `Timeout must be greater than zero. Received: ${value}`,
|
|
1059
|
+
remediation:
|
|
1060
|
+
"Use a positive integer in milliseconds or a suffix like `5s` / `500ms`.",
|
|
1061
|
+
});
|
|
1062
|
+
}
|
|
1063
|
+
return parsed;
|
|
1064
|
+
},
|
|
1065
|
+
commentKv,
|
|
1066
|
+
);
|
|
1067
|
+
if (!resolved) {
|
|
1068
|
+
throw new Error("timeout resolution produced no value");
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
if (via === "rest-api" && resolved.value > 60_000) {
|
|
1072
|
+
throw new CentrsError({
|
|
1073
|
+
code: "usage/timeout-out-of-range",
|
|
1074
|
+
summary: `REST timeout ${resolved.value}ms exceeds the current RouterOS REST ceiling.`,
|
|
1075
|
+
remediation:
|
|
1076
|
+
"Use `--timeout 60s` or less for the current REST retrieve path.",
|
|
1077
|
+
context: {
|
|
1078
|
+
via,
|
|
1079
|
+
timeoutMs: resolved.value,
|
|
1080
|
+
ceilingMs: 60_000,
|
|
1081
|
+
},
|
|
1082
|
+
});
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
return resolved;
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
function normalizeAttributeSelection(request: RetrieveRequest): string[] {
|
|
1089
|
+
const selections = [
|
|
1090
|
+
...normalizeAttributes(request.attribute),
|
|
1091
|
+
...normalizeAttributes(request.attributes),
|
|
1092
|
+
];
|
|
1093
|
+
return [...new Set(selections)];
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
function normalizeAttributes(
|
|
1097
|
+
input: string | readonly string[] | undefined,
|
|
1098
|
+
): string[] {
|
|
1099
|
+
if (input === undefined) {
|
|
1100
|
+
return [];
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
if (Array.isArray(input)) {
|
|
1104
|
+
return input.flatMap((value) => normalizeAttributes(value));
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
return String(input)
|
|
1108
|
+
.split(",")
|
|
1109
|
+
.map((value: string) => value.trim())
|
|
1110
|
+
.filter((value: string) => value.length > 0);
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
function parseOutputFormat(value: string): RetrieveOutputFormat {
|
|
1114
|
+
if (retrieveOutputFormats.includes(value as RetrieveOutputFormat)) {
|
|
1115
|
+
return value as RetrieveOutputFormat;
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
throw new CentrsError({
|
|
1119
|
+
code: "settings/invalid-format",
|
|
1120
|
+
summary: `Unsupported output format: ${value}`,
|
|
1121
|
+
remediation: `Choose one of ${retrieveOutputFormats.join(", ")}.`,
|
|
1122
|
+
});
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
function pathToInspectString(path: readonly string[]): string {
|
|
1126
|
+
return path.join(",");
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
function countResultObjects(data: unknown): number {
|
|
1130
|
+
if (Array.isArray(data)) {
|
|
1131
|
+
return data.length;
|
|
1132
|
+
}
|
|
1133
|
+
if (data === null || data === undefined) {
|
|
1134
|
+
return 0;
|
|
1135
|
+
}
|
|
1136
|
+
return 1;
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
function byteLength(text: string): number {
|
|
1140
|
+
return new TextEncoder().encode(text).length;
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
function formatCoreSource(source: CoreSettingSource | undefined): string {
|
|
1144
|
+
if (!source) {
|
|
1145
|
+
return "unset";
|
|
1146
|
+
}
|
|
1147
|
+
return source.key ? `${source.kind}:${source.key}` : source.kind;
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
function exhaustiveOutputFormat(value: never): never {
|
|
1151
|
+
throw new Error(`Unhandled retrieve output format: ${String(value)}`);
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
1155
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
export function toYaml(value: unknown, indent = 0): string {
|
|
1159
|
+
const prefix = " ".repeat(indent);
|
|
1160
|
+
if (value === null || value === undefined) {
|
|
1161
|
+
return "null";
|
|
1162
|
+
}
|
|
1163
|
+
if (typeof value === "string") {
|
|
1164
|
+
return yamlScalar(value);
|
|
1165
|
+
}
|
|
1166
|
+
if (typeof value === "number" || typeof value === "boolean") {
|
|
1167
|
+
return String(value);
|
|
1168
|
+
}
|
|
1169
|
+
if (Array.isArray(value)) {
|
|
1170
|
+
if (value.length === 0) {
|
|
1171
|
+
return `${prefix}[]`;
|
|
1172
|
+
}
|
|
1173
|
+
return value
|
|
1174
|
+
.map((item) => {
|
|
1175
|
+
if (isScalar(item)) {
|
|
1176
|
+
return `${prefix}- ${toYaml(item, indent + 2)}`;
|
|
1177
|
+
}
|
|
1178
|
+
return `${prefix}-\n${toYaml(item, indent + 2)}`;
|
|
1179
|
+
})
|
|
1180
|
+
.join("\n");
|
|
1181
|
+
}
|
|
1182
|
+
if (isPlainObject(value)) {
|
|
1183
|
+
const entries = Object.entries(value).filter(
|
|
1184
|
+
([, entryValue]) => entryValue !== undefined,
|
|
1185
|
+
);
|
|
1186
|
+
if (entries.length === 0) {
|
|
1187
|
+
return `${prefix}{}`;
|
|
1188
|
+
}
|
|
1189
|
+
return entries
|
|
1190
|
+
.map(([key, entryValue]) => {
|
|
1191
|
+
if (isScalar(entryValue)) {
|
|
1192
|
+
return `${prefix}${key}: ${toYaml(entryValue, indent + 2)}`;
|
|
1193
|
+
}
|
|
1194
|
+
return `${prefix}${key}:\n${toYaml(entryValue, indent + 2)}`;
|
|
1195
|
+
})
|
|
1196
|
+
.join("\n");
|
|
1197
|
+
}
|
|
1198
|
+
return yamlScalar(JSON.stringify(value));
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
function isScalar(value: unknown): value is boolean | number | string | null {
|
|
1202
|
+
return (
|
|
1203
|
+
value === null ||
|
|
1204
|
+
typeof value === "string" ||
|
|
1205
|
+
typeof value === "number" ||
|
|
1206
|
+
typeof value === "boolean"
|
|
1207
|
+
);
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
function yamlScalar(value: string): string {
|
|
1211
|
+
if (value.length === 0) {
|
|
1212
|
+
return '""';
|
|
1213
|
+
}
|
|
1214
|
+
return JSON.stringify(value);
|
|
1215
|
+
}
|