@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
|
@@ -0,0 +1,866 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared, reusable transport seam: the `ProtocolAdapter`.
|
|
3
|
+
*
|
|
4
|
+
* The adapter is the single boundary the command orchestrators (retrieve today,
|
|
5
|
+
* execute in WP-1c) drive REST and native-api through. It exposes the read-shaped
|
|
6
|
+
* `inspect`/`getSingleton`/`list` operations plus an `execute` seam for the
|
|
7
|
+
* future write surface, so both transports share one validate → run pipeline and
|
|
8
|
+
* one RouterOS error vocabulary.
|
|
9
|
+
*
|
|
10
|
+
* Dependency direction is `protocols ← retrieve`, never the reverse: this layer
|
|
11
|
+
* must not import retrieve-specific types. Callers pass a small
|
|
12
|
+
* {@link ProtocolAdapterConfig} describing the connection, not a resolved
|
|
13
|
+
* request. Envelope assembly stays in the command layer; the adapter returns raw
|
|
14
|
+
* protocol data.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { mapRouterOsError } from "../core/routeros-errors.ts";
|
|
18
|
+
import { CentrsError } from "../errors.ts";
|
|
19
|
+
import type { RouterOsProtocol } from "./index.ts";
|
|
20
|
+
import {
|
|
21
|
+
createUdpMacTelnetTransport,
|
|
22
|
+
isBroadcastHost,
|
|
23
|
+
type MacAddress,
|
|
24
|
+
type MacTelnetTransport,
|
|
25
|
+
parseMac,
|
|
26
|
+
resolveMacTelnetRoute,
|
|
27
|
+
} from "./mac-telnet.ts";
|
|
28
|
+
import { MacTelnetConsole } from "./mac-telnet-console.ts";
|
|
29
|
+
import {
|
|
30
|
+
type ApiReply,
|
|
31
|
+
connectNativeApi,
|
|
32
|
+
type NativeApiCommand,
|
|
33
|
+
type NativeApiSession,
|
|
34
|
+
} from "./native-api.ts";
|
|
35
|
+
import { SshExecClient } from "./ssh.ts";
|
|
36
|
+
|
|
37
|
+
/** Connection inputs an adapter needs, free of retrieve-specific resolver types. */
|
|
38
|
+
export interface ProtocolAdapterConfig {
|
|
39
|
+
/** Transport to build. `retrieve` uses `rest-api` or `native-api`. */
|
|
40
|
+
protocol: RouterOsProtocol;
|
|
41
|
+
/** Resolved host (literal address or DNS name). */
|
|
42
|
+
host: string;
|
|
43
|
+
/** Resolved port. */
|
|
44
|
+
port: number;
|
|
45
|
+
/** TLS transport (native-api over api-ssl). REST uses the base URL scheme. */
|
|
46
|
+
tls: boolean;
|
|
47
|
+
/** REST base URL, e.g. `http://host:port`. Also used as the native endpoint label. */
|
|
48
|
+
baseUrl: string;
|
|
49
|
+
/** RouterOS username, when provided. */
|
|
50
|
+
username?: string;
|
|
51
|
+
/** RouterOS password (empty string when none). */
|
|
52
|
+
password: string;
|
|
53
|
+
/** Per-command timeout in milliseconds. */
|
|
54
|
+
timeoutMs: number;
|
|
55
|
+
/** Target device MAC, for L2 transports (mac-telnet). */
|
|
56
|
+
mac?: string;
|
|
57
|
+
/** Client (in-packet source) MAC for mac-telnet; a synthetic one is used when omitted. */
|
|
58
|
+
sourceMac?: string;
|
|
59
|
+
/** SSH private-key path for the ssh/sftp transport (path only). */
|
|
60
|
+
sshKey?: string;
|
|
61
|
+
/**
|
|
62
|
+
* Opt out of peer verification: accept self-signed TLS certs (REST/native
|
|
63
|
+
* api-ssl) and disable strict SSH host-key checking. Default `false`
|
|
64
|
+
* (verify). The single `--insecure` knob across every transport.
|
|
65
|
+
*/
|
|
66
|
+
insecure?: boolean;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Projection/detail options for a menu `list`. */
|
|
70
|
+
export interface RetrieveListOptions {
|
|
71
|
+
proplist?: readonly string[];
|
|
72
|
+
detail?: boolean;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** What an adapter can do. Lets callers pick a transport per capability. */
|
|
76
|
+
export interface ProtocolAdapterCapabilities {
|
|
77
|
+
retrieve: boolean;
|
|
78
|
+
execute: boolean;
|
|
79
|
+
inspect: boolean;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* A structured CLI-shaped command for the `execute` seam. WP-1c owns
|
|
84
|
+
* canonicalization and validation; this is the minimal typed shape the
|
|
85
|
+
* transports run. Supply `command` for structured path-POST / native `talk`, or
|
|
86
|
+
* `script` for the `/rest/execute` script fallback.
|
|
87
|
+
*/
|
|
88
|
+
export interface ProtocolExecuteRequest {
|
|
89
|
+
/** Slash-prefixed RouterOS path the command targets, e.g. `/ip/address`. */
|
|
90
|
+
path: string;
|
|
91
|
+
/** Verb appended to the path, e.g. `add`, `set`, `remove`, `print`. */
|
|
92
|
+
command: string;
|
|
93
|
+
/** `name=value` attributes for the command. */
|
|
94
|
+
attributes?: Record<string, string>;
|
|
95
|
+
/** `?query` words (already including the leading `?`), native-api only. */
|
|
96
|
+
queries?: readonly string[];
|
|
97
|
+
/** Raw CLI line for the `/rest/execute` script fallback. */
|
|
98
|
+
script?: string;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Result of an `execute` round-trip. */
|
|
102
|
+
export interface ProtocolExecuteResult {
|
|
103
|
+
/** `!re`-style records the command returned (may be empty). */
|
|
104
|
+
records: Record<string, string>[];
|
|
105
|
+
/** Scalar `ret` value from `/rest/execute`, when present. */
|
|
106
|
+
ret?: string;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Runtime transport seam. The orchestrator drives validation and data fetch
|
|
111
|
+
* through these operations so REST and native-api share the same pipeline and
|
|
112
|
+
* envelope shape.
|
|
113
|
+
*/
|
|
114
|
+
export interface ProtocolAdapter {
|
|
115
|
+
/** Transport this adapter speaks. */
|
|
116
|
+
readonly protocol: RouterOsProtocol;
|
|
117
|
+
/** Capability flags describing what this adapter supports. */
|
|
118
|
+
readonly capabilities: ProtocolAdapterCapabilities;
|
|
119
|
+
/** `/console/inspect` probe (`request=child` or `request=completion`). */
|
|
120
|
+
inspect(request: "child" | "completion", path: string): Promise<unknown[]>;
|
|
121
|
+
/** Read a single record (singleton menu) as an object. */
|
|
122
|
+
getSingleton(path: string): Promise<unknown>;
|
|
123
|
+
/** Read a menu as an array of records, optionally projected/detailed. */
|
|
124
|
+
list(path: string, options: RetrieveListOptions): Promise<unknown[]>;
|
|
125
|
+
/** Run a CLI-shaped command (WP-1c write surface). */
|
|
126
|
+
execute(request: ProtocolExecuteRequest): Promise<ProtocolExecuteResult>;
|
|
127
|
+
/** Release any underlying connection. Safe to call when never connected. */
|
|
128
|
+
close(): Promise<void>;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Build the adapter for the configured protocol. `retrieve` only ever asks for
|
|
133
|
+
* `rest-api` or `native-api`; anything else is treated as REST today.
|
|
134
|
+
*/
|
|
135
|
+
export function createProtocolAdapter(
|
|
136
|
+
config: ProtocolAdapterConfig,
|
|
137
|
+
): ProtocolAdapter {
|
|
138
|
+
if (config.protocol === "native-api") {
|
|
139
|
+
return new NativeApiAdapter(config);
|
|
140
|
+
}
|
|
141
|
+
if (config.protocol === "mac-telnet") {
|
|
142
|
+
return new MacTelnetAdapter(config);
|
|
143
|
+
}
|
|
144
|
+
if (config.protocol === "ssh") {
|
|
145
|
+
return new SshExecAdapter(config);
|
|
146
|
+
}
|
|
147
|
+
return new RestAdapter(config);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
interface RestResponse {
|
|
151
|
+
status: number;
|
|
152
|
+
text: string;
|
|
153
|
+
data: unknown;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
class RestAdapter implements ProtocolAdapter {
|
|
157
|
+
readonly protocol: RouterOsProtocol;
|
|
158
|
+
readonly capabilities: ProtocolAdapterCapabilities = {
|
|
159
|
+
retrieve: true,
|
|
160
|
+
execute: true,
|
|
161
|
+
inspect: true,
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
constructor(private readonly config: ProtocolAdapterConfig) {
|
|
165
|
+
this.protocol = config.protocol;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async inspect(
|
|
169
|
+
request: "child" | "completion",
|
|
170
|
+
path: string,
|
|
171
|
+
): Promise<unknown[]> {
|
|
172
|
+
return this.restPost<unknown[]>("/console/inspect", { request, path });
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async getSingleton(path: string): Promise<unknown> {
|
|
176
|
+
return this.restGet(path);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async list(path: string, options: RetrieveListOptions): Promise<unknown[]> {
|
|
180
|
+
const hasProjection =
|
|
181
|
+
(options.proplist?.length ?? 0) > 0 || options.detail === true;
|
|
182
|
+
if (!hasProjection) {
|
|
183
|
+
return (await this.restGet(path)) as unknown[];
|
|
184
|
+
}
|
|
185
|
+
const body: { ".proplist"?: readonly string[]; detail?: string } = {};
|
|
186
|
+
if (options.proplist && options.proplist.length > 0) {
|
|
187
|
+
body[".proplist"] = options.proplist;
|
|
188
|
+
}
|
|
189
|
+
if (options.detail) {
|
|
190
|
+
body.detail = "true";
|
|
191
|
+
}
|
|
192
|
+
return this.restPost<unknown[]>(`${path.replace(/\/$/, "")}/print`, body);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async execute(
|
|
196
|
+
request: ProtocolExecuteRequest,
|
|
197
|
+
): Promise<ProtocolExecuteResult> {
|
|
198
|
+
if (request.script !== undefined) {
|
|
199
|
+
const data = await this.restPost<unknown>("/execute", {
|
|
200
|
+
script: request.script,
|
|
201
|
+
"as-string": "",
|
|
202
|
+
});
|
|
203
|
+
return normalizeRestExecute(data);
|
|
204
|
+
}
|
|
205
|
+
const target = `${request.path.replace(/\/$/, "")}/${request.command}`;
|
|
206
|
+
const data = await this.restPost<unknown>(target, request.attributes ?? {});
|
|
207
|
+
return normalizeRestExecute(data);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async close(): Promise<void> {
|
|
211
|
+
// REST is stateless; nothing to release.
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
private async restGet(path: string): Promise<unknown> {
|
|
215
|
+
const response = await this.fetchRest(path, { method: "GET" });
|
|
216
|
+
return response.data;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
private async restPost<T = unknown>(
|
|
220
|
+
path: string,
|
|
221
|
+
body: Record<string, unknown>,
|
|
222
|
+
): Promise<T> {
|
|
223
|
+
const response = await this.fetchRest(path, {
|
|
224
|
+
method: "POST",
|
|
225
|
+
body: JSON.stringify(body),
|
|
226
|
+
headers: { "Content-Type": "application/json" },
|
|
227
|
+
});
|
|
228
|
+
return response.data as T;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
private async fetchRest(
|
|
232
|
+
path: string,
|
|
233
|
+
init: RequestInit,
|
|
234
|
+
): Promise<RestResponse> {
|
|
235
|
+
const url = joinRestUrl(this.config.baseUrl, path);
|
|
236
|
+
const headers = new Headers(init.headers);
|
|
237
|
+
if (this.config.username) {
|
|
238
|
+
headers.set(
|
|
239
|
+
"Authorization",
|
|
240
|
+
`Basic ${Buffer.from(`${this.config.username}:${this.config.password}`, "utf8").toString("base64")}`,
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const controller = new AbortController();
|
|
245
|
+
const timeoutHandle = setTimeout(
|
|
246
|
+
() => controller.abort("timeout"),
|
|
247
|
+
this.config.timeoutMs,
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
// Unified TLS trust: verify by default, accept self-signed only when the
|
|
251
|
+
// caller opted in with `--insecure` (RouterOS ships self-signed www-ssl
|
|
252
|
+
// certs). Bun honors a `tls` field on the fetch init at runtime.
|
|
253
|
+
const fetchInit: RequestInit & {
|
|
254
|
+
tls?: { rejectUnauthorized?: boolean };
|
|
255
|
+
} = {
|
|
256
|
+
...init,
|
|
257
|
+
headers,
|
|
258
|
+
signal: controller.signal,
|
|
259
|
+
tls: { rejectUnauthorized: this.config.insecure !== true },
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
try {
|
|
263
|
+
const response = await fetch(url, fetchInit);
|
|
264
|
+
const text = await response.text();
|
|
265
|
+
const data = parseResponseBody(text);
|
|
266
|
+
if (!response.ok) {
|
|
267
|
+
throw this.mapHttpFailure(response.status, text, data, path);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return { status: response.status, text, data };
|
|
271
|
+
} catch (error) {
|
|
272
|
+
if (error instanceof CentrsError) {
|
|
273
|
+
throw error;
|
|
274
|
+
}
|
|
275
|
+
throw this.mapTransportFailure(error, path);
|
|
276
|
+
} finally {
|
|
277
|
+
clearTimeout(timeoutHandle);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
private mapHttpFailure(
|
|
282
|
+
status: number,
|
|
283
|
+
text: string,
|
|
284
|
+
data: unknown,
|
|
285
|
+
path: string,
|
|
286
|
+
): CentrsError {
|
|
287
|
+
const { protocol, host, port } = this.config;
|
|
288
|
+
if (status === 401 || status === 403) {
|
|
289
|
+
return new CentrsError({
|
|
290
|
+
code: "transport/auth-failed",
|
|
291
|
+
summary: `RouterOS rejected the REST credentials for ${host}:${port}.`,
|
|
292
|
+
remediation:
|
|
293
|
+
"Check `--username` / `--password` or the matching `CENTRS_*` environment variables, and confirm the user has RouterOS REST access.",
|
|
294
|
+
context: { via: protocol, host, port, path, status },
|
|
295
|
+
causeData: data ?? text,
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (status >= 500 && status <= 599) {
|
|
300
|
+
// A 5xx is a transient server/connection drop (RouterOS REST under
|
|
301
|
+
// fanout load drops a fraction of parallel POSTs). Classify it as a
|
|
302
|
+
// transport drop, not a `routeros/*` request failure, so the fanout
|
|
303
|
+
// retry allowlist can retry it without blanket-retrying real
|
|
304
|
+
// router-side rejections.
|
|
305
|
+
return new CentrsError({
|
|
306
|
+
code: "transport/connection-closed",
|
|
307
|
+
summary: `RouterOS REST returned HTTP ${status} for ${path}; the connection was dropped before a result.`,
|
|
308
|
+
remediation:
|
|
309
|
+
"Retry the request; if it persists, reduce fanout concurrency or check RouterOS REST service health.",
|
|
310
|
+
context: { via: protocol, host, port, path, status },
|
|
311
|
+
causeData: data ?? text,
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const detail = isPlainObject(data)
|
|
316
|
+
? readRecordString(data, "detail")
|
|
317
|
+
: undefined;
|
|
318
|
+
const rawRouterOsError = detail ?? text;
|
|
319
|
+
return mapRouterOsError(rawRouterOsError, {
|
|
320
|
+
transport: "rest-api",
|
|
321
|
+
httpStatus: status,
|
|
322
|
+
context: { via: protocol, host, port, path, status },
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
private mapTransportFailure(error: unknown, path: string): CentrsError {
|
|
327
|
+
const { protocol, host, port, timeoutMs } = this.config;
|
|
328
|
+
const signals = collectTransportSignals(error);
|
|
329
|
+
const codes = signals.codes.map((code) => code.toLowerCase());
|
|
330
|
+
const messages = signals.messages.map((message) => message.toLowerCase());
|
|
331
|
+
if (
|
|
332
|
+
codes.includes("abort_err") ||
|
|
333
|
+
messages.some((candidate) => candidate.includes("timeout"))
|
|
334
|
+
) {
|
|
335
|
+
return new CentrsError({
|
|
336
|
+
code: "transport/timeout",
|
|
337
|
+
summary: `Timed out waiting for ${protocol} to respond from ${host}:${port}.`,
|
|
338
|
+
remediation:
|
|
339
|
+
"Increase `--timeout` within the REST ceiling, or confirm the host and port are reachable.",
|
|
340
|
+
context: { via: protocol, host, port, path, timeoutMs },
|
|
341
|
+
cause: error,
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
if (
|
|
346
|
+
codes.some(
|
|
347
|
+
(code) => code === "econnrefused" || code === "connectionrefused",
|
|
348
|
+
) ||
|
|
349
|
+
messages.some(
|
|
350
|
+
(candidate) =>
|
|
351
|
+
candidate.includes("econnrefused") ||
|
|
352
|
+
candidate.includes("connection refused") ||
|
|
353
|
+
candidate.includes("unable to connect"),
|
|
354
|
+
)
|
|
355
|
+
) {
|
|
356
|
+
return new CentrsError({
|
|
357
|
+
code: "transport/connection-refused",
|
|
358
|
+
summary: `Connection refused by ${host}:${port} over ${protocol}.`,
|
|
359
|
+
remediation:
|
|
360
|
+
"Check that the RouterOS REST service is enabled on that port and that any local forwarding or firewall rules are correct.",
|
|
361
|
+
context: { via: protocol, host, port, path },
|
|
362
|
+
cause: error,
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
if (
|
|
367
|
+
codes.some((code) => ["enotfound", "eai_again", "dns"].includes(code)) ||
|
|
368
|
+
messages.some(
|
|
369
|
+
(candidate) =>
|
|
370
|
+
candidate.includes("enotfound") ||
|
|
371
|
+
candidate.includes("eai_again") ||
|
|
372
|
+
candidate.includes("dns") ||
|
|
373
|
+
candidate.includes("could not resolve") ||
|
|
374
|
+
candidate.includes("name lookup"),
|
|
375
|
+
)
|
|
376
|
+
) {
|
|
377
|
+
return new CentrsError({
|
|
378
|
+
code: "transport/dns",
|
|
379
|
+
summary: `Could not resolve ${host} for ${protocol}.`,
|
|
380
|
+
remediation:
|
|
381
|
+
"Check the host spelling, DNS configuration, or pass a literal address with `--host`.",
|
|
382
|
+
context: { via: protocol, host, port, path },
|
|
383
|
+
cause: error,
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
if (messages.some((candidate) => candidate.includes("certificate"))) {
|
|
388
|
+
return new CentrsError({
|
|
389
|
+
code: "transport/tls-certificate",
|
|
390
|
+
summary: `TLS certificate validation failed for ${host}:${port}.`,
|
|
391
|
+
remediation:
|
|
392
|
+
"RouterOS ships a self-signed certificate by default. Install a trusted chain, or pass `--insecure` (`CENTRS_INSECURE=1`) to accept it; an HTTP URL also avoids TLS.",
|
|
393
|
+
context: { via: protocol, host, port, path },
|
|
394
|
+
cause: error,
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
return new CentrsError({
|
|
399
|
+
code: "transport/network",
|
|
400
|
+
summary: `Network request to ${host}:${port} failed over ${protocol}.`,
|
|
401
|
+
remediation:
|
|
402
|
+
"Check the host, port, and service availability, then re-run with `--format json` if you need the structured cause data.",
|
|
403
|
+
context: { via: protocol, host, port, path },
|
|
404
|
+
cause: error,
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
class NativeApiAdapter implements ProtocolAdapter {
|
|
410
|
+
readonly protocol: RouterOsProtocol;
|
|
411
|
+
readonly capabilities: ProtocolAdapterCapabilities = {
|
|
412
|
+
retrieve: true,
|
|
413
|
+
execute: true,
|
|
414
|
+
inspect: true,
|
|
415
|
+
};
|
|
416
|
+
private session?: NativeApiSession;
|
|
417
|
+
|
|
418
|
+
constructor(private readonly config: ProtocolAdapterConfig) {
|
|
419
|
+
this.protocol = config.protocol;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
async inspect(
|
|
423
|
+
request: "child" | "completion",
|
|
424
|
+
path: string,
|
|
425
|
+
): Promise<unknown[]> {
|
|
426
|
+
const replies = await this.talk({
|
|
427
|
+
command: "/console/inspect",
|
|
428
|
+
attributes: { request, path },
|
|
429
|
+
});
|
|
430
|
+
return repliesToRecords(replies);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
async getSingleton(path: string): Promise<unknown> {
|
|
434
|
+
const replies = await this.talk({
|
|
435
|
+
command: `${path.replace(/\/$/, "")}/print`,
|
|
436
|
+
});
|
|
437
|
+
const records = repliesToRecords(replies);
|
|
438
|
+
return records[0] ?? {};
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
async list(path: string, options: RetrieveListOptions): Promise<unknown[]> {
|
|
442
|
+
const command: NativeApiCommand = {
|
|
443
|
+
command: `${path.replace(/\/$/, "")}/print`,
|
|
444
|
+
};
|
|
445
|
+
if (options.proplist && options.proplist.length > 0) {
|
|
446
|
+
command.proplist = options.proplist;
|
|
447
|
+
}
|
|
448
|
+
if (options.detail) {
|
|
449
|
+
command.attributes = { detail: "" };
|
|
450
|
+
}
|
|
451
|
+
const replies = await this.talk(command);
|
|
452
|
+
return repliesToRecords(replies);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
async execute(
|
|
456
|
+
request: ProtocolExecuteRequest,
|
|
457
|
+
): Promise<ProtocolExecuteResult> {
|
|
458
|
+
const command: NativeApiCommand = request.script
|
|
459
|
+
? { command: "/execute", attributes: { script: request.script } }
|
|
460
|
+
: { command: `${request.path.replace(/\/$/, "")}/${request.command}` };
|
|
461
|
+
if (
|
|
462
|
+
!request.script &&
|
|
463
|
+
request.attributes &&
|
|
464
|
+
Object.keys(request.attributes).length > 0
|
|
465
|
+
) {
|
|
466
|
+
command.attributes = request.attributes;
|
|
467
|
+
}
|
|
468
|
+
if (!request.script && request.queries && request.queries.length > 0) {
|
|
469
|
+
command.queries = request.queries;
|
|
470
|
+
}
|
|
471
|
+
const replies = await this.talk(command);
|
|
472
|
+
return { records: repliesToRecords(replies) };
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
async close(): Promise<void> {
|
|
476
|
+
this.session?.close();
|
|
477
|
+
this.session = undefined;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
private async connect(): Promise<NativeApiSession> {
|
|
481
|
+
if (this.session) {
|
|
482
|
+
return this.session;
|
|
483
|
+
}
|
|
484
|
+
const { session } = await connectNativeApi({
|
|
485
|
+
host: this.config.host,
|
|
486
|
+
port: this.config.port,
|
|
487
|
+
username: this.config.username ?? "",
|
|
488
|
+
password: this.config.password,
|
|
489
|
+
tls: this.config.tls,
|
|
490
|
+
// Unified TLS trust: verify api-ssl peers by default; `--insecure` accepts
|
|
491
|
+
// RouterOS's self-signed cert. (Previously native-api always accepted.)
|
|
492
|
+
rejectUnauthorized: this.config.insecure !== true,
|
|
493
|
+
timeoutMs: this.config.timeoutMs,
|
|
494
|
+
});
|
|
495
|
+
this.session = session;
|
|
496
|
+
return session;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
private async talk(command: NativeApiCommand): Promise<ApiReply[]> {
|
|
500
|
+
const session = await this.connect();
|
|
501
|
+
return this.withTimeout(session.talk(command));
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
private async withTimeout<T>(promise: Promise<T>): Promise<T> {
|
|
505
|
+
const timeoutMs = this.config.timeoutMs;
|
|
506
|
+
let handle: ReturnType<typeof setTimeout> | undefined;
|
|
507
|
+
const endpoint = this.config.baseUrl;
|
|
508
|
+
// Swallow the eventual rejection from the raced command so that closing
|
|
509
|
+
// the session below (which rejects the in-flight talk) cannot surface as
|
|
510
|
+
// an unhandled rejection once the timeout has already won the race.
|
|
511
|
+
promise.catch(() => undefined);
|
|
512
|
+
const timeout = new Promise<never>((_resolve, reject) => {
|
|
513
|
+
handle = setTimeout(() => {
|
|
514
|
+
// Reject with the timeout error first so it wins the race, then
|
|
515
|
+
// tear down the connection (which rejects the pending talk with
|
|
516
|
+
// transport/connection-closed — now harmlessly ignored).
|
|
517
|
+
reject(
|
|
518
|
+
new CentrsError({
|
|
519
|
+
code: "transport/timeout",
|
|
520
|
+
summary: `The RouterOS API command to ${endpoint} timed out after ${timeoutMs}ms.`,
|
|
521
|
+
remediation:
|
|
522
|
+
"Raise `--timeout`, or confirm the api service is responsive.",
|
|
523
|
+
context: { via: "native-api", endpoint, timeoutMs },
|
|
524
|
+
}),
|
|
525
|
+
);
|
|
526
|
+
this.session?.close();
|
|
527
|
+
this.session = undefined;
|
|
528
|
+
}, timeoutMs);
|
|
529
|
+
});
|
|
530
|
+
try {
|
|
531
|
+
return await Promise.race([promise, timeout]);
|
|
532
|
+
} finally {
|
|
533
|
+
if (handle !== undefined) {
|
|
534
|
+
clearTimeout(handle);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* MAC-Telnet execute adapter: drives a {@link MacTelnetConsole} over a UDP
|
|
542
|
+
* datagram transport. Unlike REST/native this is a console transport, so it is
|
|
543
|
+
* **execute-only** — `retrieve`/`inspect` are not mac-telnet capabilities
|
|
544
|
+
* (matrix: retrieve/mac-telnet is out of scope), and `execute` runs the raw CLI
|
|
545
|
+
* line over the console and returns the captured output as `ret`. Validation
|
|
546
|
+
* over mac-telnet is the console `:parse` gate (see `execute.ts`).
|
|
547
|
+
*/
|
|
548
|
+
class MacTelnetAdapter implements ProtocolAdapter {
|
|
549
|
+
readonly protocol: RouterOsProtocol = "mac-telnet";
|
|
550
|
+
readonly capabilities: ProtocolAdapterCapabilities = {
|
|
551
|
+
retrieve: false,
|
|
552
|
+
execute: true,
|
|
553
|
+
inspect: false,
|
|
554
|
+
};
|
|
555
|
+
private readonly destinationMac: MacAddress;
|
|
556
|
+
/** Explicit client MAC from config; when unset, connect() resolves the real egress MAC. */
|
|
557
|
+
private readonly explicitSourceMac?: MacAddress;
|
|
558
|
+
private transport?: MacTelnetTransport;
|
|
559
|
+
private console?: MacTelnetConsole;
|
|
560
|
+
private opening?: Promise<MacTelnetConsole>;
|
|
561
|
+
|
|
562
|
+
constructor(private readonly config: ProtocolAdapterConfig) {
|
|
563
|
+
if (!config.mac) {
|
|
564
|
+
throw new CentrsError({
|
|
565
|
+
code: "internal/unhandled",
|
|
566
|
+
summary: "The mac-telnet adapter was built without a target MAC.",
|
|
567
|
+
remediation:
|
|
568
|
+
"Report this bug; resolveTarget must set target.mac for mac-telnet.",
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
this.destinationMac = parseMac(config.mac);
|
|
572
|
+
this.explicitSourceMac = config.sourceMac
|
|
573
|
+
? parseMac(config.sourceMac)
|
|
574
|
+
: undefined;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
private unsupported(operation: string): CentrsError {
|
|
578
|
+
return new CentrsError({
|
|
579
|
+
code: "transport/capability-unsupported",
|
|
580
|
+
summary: `mac-telnet does not support ${operation}.`,
|
|
581
|
+
remediation:
|
|
582
|
+
"mac-telnet is an execute/terminal (console) transport; use `--via rest-api`/`native-api` to read structured data.",
|
|
583
|
+
context: { via: "mac-telnet", operation },
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
inspect(): Promise<unknown[]> {
|
|
588
|
+
return Promise.reject(this.unsupported("/console/inspect"));
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
getSingleton(): Promise<unknown> {
|
|
592
|
+
return Promise.reject(this.unsupported("singleton reads"));
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
list(): Promise<unknown[]> {
|
|
596
|
+
return Promise.reject(this.unsupported("menu reads"));
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
async execute(
|
|
600
|
+
request: ProtocolExecuteRequest,
|
|
601
|
+
): Promise<ProtocolExecuteResult> {
|
|
602
|
+
const cli = request.script;
|
|
603
|
+
if (cli === undefined) {
|
|
604
|
+
throw new CentrsError({
|
|
605
|
+
code: "internal/unhandled",
|
|
606
|
+
summary:
|
|
607
|
+
"mac-telnet execute requires a raw CLI line (script), not a structured path command.",
|
|
608
|
+
remediation:
|
|
609
|
+
"Report this bug; runCommand must pass the raw command as `script` for mac-telnet.",
|
|
610
|
+
});
|
|
611
|
+
}
|
|
612
|
+
const console = await this.open();
|
|
613
|
+
const { output } = await console.run(cli);
|
|
614
|
+
return { records: [], ret: output };
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
async close(): Promise<void> {
|
|
618
|
+
this.console?.close();
|
|
619
|
+
this.transport?.close();
|
|
620
|
+
this.console = undefined;
|
|
621
|
+
this.transport = undefined;
|
|
622
|
+
this.opening = undefined;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
private open(): Promise<MacTelnetConsole> {
|
|
626
|
+
if (this.console?.isReady) {
|
|
627
|
+
return Promise.resolve(this.console);
|
|
628
|
+
}
|
|
629
|
+
if (!this.opening) {
|
|
630
|
+
// On failure, tear down any partial socket/console and clear `opening` so
|
|
631
|
+
// a later call can retry cleanly rather than re-await a rejected promise.
|
|
632
|
+
this.opening = this.connect().catch((error) => {
|
|
633
|
+
this.console?.close();
|
|
634
|
+
this.transport?.close();
|
|
635
|
+
this.console = undefined;
|
|
636
|
+
this.transport = undefined;
|
|
637
|
+
this.opening = undefined;
|
|
638
|
+
throw error;
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
return this.opening;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
private async connect(): Promise<MacTelnetConsole> {
|
|
645
|
+
const { sourceMac, host } = await this.resolveRoute();
|
|
646
|
+
const transport = createUdpMacTelnetTransport({
|
|
647
|
+
host,
|
|
648
|
+
port: this.config.port,
|
|
649
|
+
broadcast: isBroadcastHost(host),
|
|
650
|
+
});
|
|
651
|
+
await transport.ready();
|
|
652
|
+
const console = new MacTelnetConsole({
|
|
653
|
+
sink: transport,
|
|
654
|
+
sourceMac,
|
|
655
|
+
destinationMac: this.destinationMac,
|
|
656
|
+
username: this.config.username ?? "",
|
|
657
|
+
password: this.config.password,
|
|
658
|
+
primeTimeoutMs: Math.max(this.config.timeoutMs, 30_000),
|
|
659
|
+
commandTimeoutMs: this.config.timeoutMs,
|
|
660
|
+
});
|
|
661
|
+
transport.onMessage((bytes) => console.handlePacket(bytes));
|
|
662
|
+
this.transport = transport;
|
|
663
|
+
this.console = console;
|
|
664
|
+
await console.open();
|
|
665
|
+
return console;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/**
|
|
669
|
+
* Decide the in-packet source MAC and UDP delivery host. RouterOS only replies
|
|
670
|
+
* to a SESSIONSTART whose in-packet source MAC is the sending interface's real
|
|
671
|
+
* MAC, so a synthetic MAC is only a last resort.
|
|
672
|
+
*
|
|
673
|
+
* - An explicit configured source MAC always wins (keeps the configured host).
|
|
674
|
+
* - The default delivery host (the `255.255.255.255` sentinel) means "find the
|
|
675
|
+
* device": spray every interface's directed broadcast and use the one that
|
|
676
|
+
* answers — the only path that reaches a device on a non-default-route NIC
|
|
677
|
+
* (e.g. ZeroTier), where WinBox's MAC connection also works.
|
|
678
|
+
* - An explicit host is honored, using that interface's real egress MAC.
|
|
679
|
+
*/
|
|
680
|
+
private resolveRoute(): Promise<{ sourceMac: MacAddress; host: string }> {
|
|
681
|
+
return resolveMacTelnetRoute({
|
|
682
|
+
destinationMac: this.destinationMac,
|
|
683
|
+
host: this.config.host,
|
|
684
|
+
port: this.config.port,
|
|
685
|
+
timeoutMs: this.config.timeoutMs,
|
|
686
|
+
explicitSourceMac: this.explicitSourceMac,
|
|
687
|
+
});
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/**
|
|
692
|
+
* Execute over SSH. RouterOS grants no pseudo-tty, but `ssh host "<command>"`
|
|
693
|
+
* runs one single-line console command and returns clean output, so this is a
|
|
694
|
+
* per-command batch adapter (like the SFTP client) — no persistent session,
|
|
695
|
+
* `close()` is a no-op. It is a console transport: only the raw-CLI (`script`)
|
|
696
|
+
* execute path applies; structured reads/inspect are unsupported (use
|
|
697
|
+
* rest-api/native-api). Validation rides the shared `:put [:parse …]` gate the
|
|
698
|
+
* orchestrator runs through `execute({ script })`.
|
|
699
|
+
*/
|
|
700
|
+
class SshExecAdapter implements ProtocolAdapter {
|
|
701
|
+
readonly protocol: RouterOsProtocol = "ssh";
|
|
702
|
+
readonly capabilities: ProtocolAdapterCapabilities = {
|
|
703
|
+
retrieve: false,
|
|
704
|
+
execute: true,
|
|
705
|
+
inspect: false,
|
|
706
|
+
};
|
|
707
|
+
private readonly client: SshExecClient;
|
|
708
|
+
|
|
709
|
+
constructor(config: ProtocolAdapterConfig) {
|
|
710
|
+
this.client = new SshExecClient({
|
|
711
|
+
host: config.host,
|
|
712
|
+
port: config.port,
|
|
713
|
+
username: config.username,
|
|
714
|
+
sshKey: config.sshKey,
|
|
715
|
+
insecure: config.insecure,
|
|
716
|
+
timeoutMs: config.timeoutMs,
|
|
717
|
+
});
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
private unsupported(operation: string): CentrsError {
|
|
721
|
+
return new CentrsError({
|
|
722
|
+
code: "transport/capability-unsupported",
|
|
723
|
+
summary: `ssh does not support ${operation}.`,
|
|
724
|
+
remediation:
|
|
725
|
+
"ssh is an execute/terminal (console) transport; use `--via rest-api`/`native-api` to read structured data.",
|
|
726
|
+
context: { via: "ssh", operation },
|
|
727
|
+
});
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
inspect(): Promise<unknown[]> {
|
|
731
|
+
return Promise.reject(this.unsupported("/console/inspect"));
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
getSingleton(): Promise<unknown> {
|
|
735
|
+
return Promise.reject(this.unsupported("singleton reads"));
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
list(): Promise<unknown[]> {
|
|
739
|
+
return Promise.reject(this.unsupported("menu reads"));
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
async execute(
|
|
743
|
+
request: ProtocolExecuteRequest,
|
|
744
|
+
): Promise<ProtocolExecuteResult> {
|
|
745
|
+
const cli = request.script;
|
|
746
|
+
if (cli === undefined) {
|
|
747
|
+
throw new CentrsError({
|
|
748
|
+
code: "internal/unhandled",
|
|
749
|
+
summary:
|
|
750
|
+
"ssh execute requires a raw CLI line (script), not a structured path command.",
|
|
751
|
+
remediation:
|
|
752
|
+
"Report this bug; runCommand must pass the raw command as `script` for ssh.",
|
|
753
|
+
});
|
|
754
|
+
}
|
|
755
|
+
const output = await this.client.exec(cli);
|
|
756
|
+
return { records: [], ret: output };
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
close(): Promise<void> {
|
|
760
|
+
return Promise.resolve();
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
function normalizeRestExecute(data: unknown): ProtocolExecuteResult {
|
|
765
|
+
if (Array.isArray(data)) {
|
|
766
|
+
return { records: data.filter(isPlainObject) as Record<string, string>[] };
|
|
767
|
+
}
|
|
768
|
+
if (isPlainObject(data)) {
|
|
769
|
+
const { ret } = data as { ret?: unknown };
|
|
770
|
+
if (typeof ret === "string") {
|
|
771
|
+
return { records: [], ret };
|
|
772
|
+
}
|
|
773
|
+
return { records: [data as Record<string, string>] };
|
|
774
|
+
}
|
|
775
|
+
if (typeof data === "string") {
|
|
776
|
+
return { records: [], ret: data };
|
|
777
|
+
}
|
|
778
|
+
return { records: [] };
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
function repliesToRecords(
|
|
782
|
+
replies: readonly ApiReply[],
|
|
783
|
+
): Record<string, string>[] {
|
|
784
|
+
return replies
|
|
785
|
+
.filter(
|
|
786
|
+
(reply) =>
|
|
787
|
+
reply.type === "!re" ||
|
|
788
|
+
(reply.type === "!done" && Object.keys(reply.attributes).length > 0),
|
|
789
|
+
)
|
|
790
|
+
.map((reply) => ({ ...reply.attributes }));
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
function joinRestUrl(baseUrl: string, path: string): string {
|
|
794
|
+
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
|
|
795
|
+
return `${baseUrl}${normalizedPath}`;
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
function parseResponseBody(text: string): unknown {
|
|
799
|
+
if (text.length === 0) {
|
|
800
|
+
return null;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
try {
|
|
804
|
+
return JSON.parse(text) as unknown;
|
|
805
|
+
} catch {
|
|
806
|
+
return text;
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
interface TransportSignals {
|
|
811
|
+
codes: string[];
|
|
812
|
+
messages: string[];
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
function collectTransportSignals(error: unknown): TransportSignals {
|
|
816
|
+
const signals: TransportSignals = { codes: [], messages: [] };
|
|
817
|
+
collectTransportSignalsInto(error, signals, new Set<unknown>());
|
|
818
|
+
return signals;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
function collectTransportSignalsInto(
|
|
822
|
+
error: unknown,
|
|
823
|
+
signals: TransportSignals,
|
|
824
|
+
seen: Set<unknown>,
|
|
825
|
+
): void {
|
|
826
|
+
if (error === null || error === undefined || seen.has(error)) {
|
|
827
|
+
return;
|
|
828
|
+
}
|
|
829
|
+
if (typeof error !== "object") {
|
|
830
|
+
signals.messages.push(String(error));
|
|
831
|
+
return;
|
|
832
|
+
}
|
|
833
|
+
seen.add(error);
|
|
834
|
+
|
|
835
|
+
if (error instanceof Error && error.message.length > 0) {
|
|
836
|
+
signals.messages.push(error.message);
|
|
837
|
+
} else if ("message" in error && typeof error.message === "string") {
|
|
838
|
+
signals.messages.push(error.message);
|
|
839
|
+
}
|
|
840
|
+
if ("code" in error && typeof error.code === "string") {
|
|
841
|
+
signals.codes.push(error.code);
|
|
842
|
+
}
|
|
843
|
+
if ("errno" in error && typeof error.errno === "string") {
|
|
844
|
+
signals.codes.push(error.errno);
|
|
845
|
+
}
|
|
846
|
+
if ("cause" in error) {
|
|
847
|
+
collectTransportSignalsInto(error.cause, signals, seen);
|
|
848
|
+
}
|
|
849
|
+
if ("errors" in error && Array.isArray(error.errors)) {
|
|
850
|
+
for (const nested of error.errors) {
|
|
851
|
+
collectTransportSignalsInto(nested, signals, seen);
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
857
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
function readRecordString(
|
|
861
|
+
value: Record<string, unknown>,
|
|
862
|
+
key: string,
|
|
863
|
+
): string | undefined {
|
|
864
|
+
const candidate = value[key];
|
|
865
|
+
return typeof candidate === "string" ? candidate : undefined;
|
|
866
|
+
}
|