@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,796 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RouterOS native API protocol (the binary "api" service).
|
|
3
|
+
*
|
|
4
|
+
* Transport-side base for the `native-api` protocol id. This module is split
|
|
5
|
+
* into three layers so each is independently testable:
|
|
6
|
+
*
|
|
7
|
+
* 1. Pure **codec** — variable-length word framing, sentence framing, and
|
|
8
|
+
* reply parsing. No IO; fully unit-testable with byte vectors.
|
|
9
|
+
* 2. A **session** (`NativeApiSession`) driven over an injectable byte stream
|
|
10
|
+
* (`NativeApiByteSink`), so scripted server bytes can drive it in tests and
|
|
11
|
+
* a real socket drives it in integration.
|
|
12
|
+
* 3. A thin **TCP/TLS adapter** (`connectNativeApi`) built on `Bun.connect`.
|
|
13
|
+
*
|
|
14
|
+
* Wire format grounded against the MikroTik wiki API page and the canonical
|
|
15
|
+
* `go-routeros` and `librouteros` implementations. Default ports: 8728 (plain),
|
|
16
|
+
* 8729 (api-ssl / TLS).
|
|
17
|
+
*
|
|
18
|
+
* Selection rules live in `docs/CONSTITUTION.md`; per-cell status in
|
|
19
|
+
* `docs/MATRIX.md`.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { createHash } from "node:crypto";
|
|
23
|
+
import { mapRouterOsError } from "../core/routeros-errors.ts";
|
|
24
|
+
import { CentrsError } from "../errors.ts";
|
|
25
|
+
|
|
26
|
+
const utf8Encoder = new TextEncoder();
|
|
27
|
+
const utf8Decoder = new TextDecoder();
|
|
28
|
+
|
|
29
|
+
/** Default plaintext native-api TCP port. */
|
|
30
|
+
export const NATIVE_API_PORT = 8728;
|
|
31
|
+
/** Default api-ssl (TLS) native-api TCP port. */
|
|
32
|
+
export const NATIVE_API_TLS_PORT = 8729;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Encode a word length using RouterOS's variable-length big-endian scheme.
|
|
36
|
+
*
|
|
37
|
+
* | length range | bytes | leading mask |
|
|
38
|
+
* | ------------------- | ----- | ------------ |
|
|
39
|
+
* | `< 0x80` | 1 | `0xxxxxxx` |
|
|
40
|
+
* | `< 0x4000` | 2 | `10xxxxxx` |
|
|
41
|
+
* | `< 0x200000` | 3 | `110xxxxx` |
|
|
42
|
+
* | `< 0x10000000` | 4 | `1110xxxx` |
|
|
43
|
+
* | `>= 0x10000000` | 5 | `0xF0` + raw |
|
|
44
|
+
*/
|
|
45
|
+
export function encodeLength(length: number): Uint8Array {
|
|
46
|
+
if (!Number.isInteger(length) || length < 0) {
|
|
47
|
+
throw new CentrsError({
|
|
48
|
+
code: "internal/native-api-length",
|
|
49
|
+
summary: `Cannot encode a negative or non-integer word length (${length}).`,
|
|
50
|
+
remediation:
|
|
51
|
+
"This is a centrs bug; report it with the command that triggered it.",
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
if (length > 0xffffffff) {
|
|
55
|
+
throw new CentrsError({
|
|
56
|
+
code: "internal/native-api-length",
|
|
57
|
+
summary: `Word length ${length} exceeds the 32-bit RouterOS API limit.`,
|
|
58
|
+
remediation:
|
|
59
|
+
"This is a centrs bug; report it with the command that triggered it.",
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
if (length < 0x80) {
|
|
63
|
+
return Uint8Array.of(length);
|
|
64
|
+
}
|
|
65
|
+
if (length < 0x4000) {
|
|
66
|
+
return Uint8Array.of((length >> 8) | 0x80, length & 0xff);
|
|
67
|
+
}
|
|
68
|
+
if (length < 0x200000) {
|
|
69
|
+
return Uint8Array.of(
|
|
70
|
+
(length >> 16) | 0xc0,
|
|
71
|
+
(length >> 8) & 0xff,
|
|
72
|
+
length & 0xff,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
if (length < 0x10000000) {
|
|
76
|
+
return Uint8Array.of(
|
|
77
|
+
(length >> 24) | 0xe0,
|
|
78
|
+
(length >> 16) & 0xff,
|
|
79
|
+
(length >> 8) & 0xff,
|
|
80
|
+
length & 0xff,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
return Uint8Array.of(
|
|
84
|
+
0xf0,
|
|
85
|
+
(length >>> 24) & 0xff,
|
|
86
|
+
(length >> 16) & 0xff,
|
|
87
|
+
(length >> 8) & 0xff,
|
|
88
|
+
length & 0xff,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface DecodedLength {
|
|
93
|
+
/** Decoded length value. */
|
|
94
|
+
value: number;
|
|
95
|
+
/** Number of prefix bytes consumed. */
|
|
96
|
+
size: number;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Decode a variable-length prefix starting at `offset`.
|
|
101
|
+
*
|
|
102
|
+
* Returns `null` when the buffer does not yet hold the full prefix (the caller
|
|
103
|
+
* should wait for more bytes). Throws on an invalid leading byte.
|
|
104
|
+
*/
|
|
105
|
+
export function decodeLength(
|
|
106
|
+
buffer: Uint8Array,
|
|
107
|
+
offset = 0,
|
|
108
|
+
): DecodedLength | null {
|
|
109
|
+
if (offset >= buffer.length) {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
const b0 = buffer[offset] as number;
|
|
113
|
+
let size: number;
|
|
114
|
+
let value: number;
|
|
115
|
+
if ((b0 & 0x80) === 0x00) {
|
|
116
|
+
return { value: b0, size: 1 };
|
|
117
|
+
}
|
|
118
|
+
if ((b0 & 0xc0) === 0x80) {
|
|
119
|
+
size = 2;
|
|
120
|
+
value = b0 & 0x3f;
|
|
121
|
+
} else if ((b0 & 0xe0) === 0xc0) {
|
|
122
|
+
size = 3;
|
|
123
|
+
value = b0 & 0x1f;
|
|
124
|
+
} else if ((b0 & 0xf0) === 0xe0) {
|
|
125
|
+
size = 4;
|
|
126
|
+
value = b0 & 0x0f;
|
|
127
|
+
} else if ((b0 & 0xf8) === 0xf0) {
|
|
128
|
+
size = 5;
|
|
129
|
+
value = 0;
|
|
130
|
+
} else {
|
|
131
|
+
throw new CentrsError({
|
|
132
|
+
code: "routeros/api-protocol",
|
|
133
|
+
summary: `Invalid RouterOS API length descriptor 0x${b0.toString(16)}.`,
|
|
134
|
+
remediation:
|
|
135
|
+
"The byte stream is corrupt or not a RouterOS API service; confirm the target port speaks the native API.",
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
if (offset + size > buffer.length) {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
for (let index = 1; index < size; index += 1) {
|
|
142
|
+
value = (value << 8) | (buffer[offset + index] as number);
|
|
143
|
+
}
|
|
144
|
+
return { value: value >>> 0, size };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Encode a single word: length prefix followed by its UTF-8 bytes. */
|
|
148
|
+
export function encodeWord(word: string): Uint8Array {
|
|
149
|
+
const payload = utf8Encoder.encode(word);
|
|
150
|
+
const prefix = encodeLength(payload.length);
|
|
151
|
+
const out = new Uint8Array(prefix.length + payload.length);
|
|
152
|
+
out.set(prefix, 0);
|
|
153
|
+
out.set(payload, prefix.length);
|
|
154
|
+
return out;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Encode a sentence: each word framed, terminated by a zero-length word. */
|
|
158
|
+
export function encodeSentence(words: readonly string[]): Uint8Array {
|
|
159
|
+
const parts = words.map(encodeWord);
|
|
160
|
+
const total = parts.reduce((sum, part) => sum + part.length, 0) + 1;
|
|
161
|
+
const out = new Uint8Array(total);
|
|
162
|
+
let cursor = 0;
|
|
163
|
+
for (const part of parts) {
|
|
164
|
+
out.set(part, cursor);
|
|
165
|
+
cursor += part.length;
|
|
166
|
+
}
|
|
167
|
+
out[cursor] = 0x00; // zero-length terminator word
|
|
168
|
+
return out;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Incremental sentence reader. Accumulates TCP chunks and yields whole
|
|
173
|
+
* sentences (arrays of decoded words) as they complete. Partial words and
|
|
174
|
+
* partial length prefixes are buffered until the rest of the bytes arrive.
|
|
175
|
+
*/
|
|
176
|
+
export class SentenceReader {
|
|
177
|
+
private buffer: Uint8Array = new Uint8Array(0);
|
|
178
|
+
private current: string[] = [];
|
|
179
|
+
|
|
180
|
+
/** Push a chunk and return any sentences completed by it. */
|
|
181
|
+
push(chunk: Uint8Array): string[][] {
|
|
182
|
+
if (chunk.length > 0) {
|
|
183
|
+
const next = new Uint8Array(this.buffer.length + chunk.length);
|
|
184
|
+
next.set(this.buffer, 0);
|
|
185
|
+
next.set(chunk, this.buffer.length);
|
|
186
|
+
this.buffer = next;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const sentences: string[][] = [];
|
|
190
|
+
let offset = 0;
|
|
191
|
+
for (;;) {
|
|
192
|
+
const header = decodeLength(this.buffer, offset);
|
|
193
|
+
if (header === null) {
|
|
194
|
+
break;
|
|
195
|
+
}
|
|
196
|
+
const wordStart = offset + header.size;
|
|
197
|
+
const wordEnd = wordStart + header.value;
|
|
198
|
+
if (header.value > 0 && wordEnd > this.buffer.length) {
|
|
199
|
+
break; // word body not fully buffered yet
|
|
200
|
+
}
|
|
201
|
+
offset = wordEnd;
|
|
202
|
+
if (header.value === 0) {
|
|
203
|
+
sentences.push(this.current);
|
|
204
|
+
this.current = [];
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
this.current.push(
|
|
208
|
+
utf8Decoder.decode(this.buffer.subarray(wordStart, wordEnd)),
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (offset > 0) {
|
|
213
|
+
this.buffer = this.buffer.subarray(offset);
|
|
214
|
+
}
|
|
215
|
+
return sentences;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Bytes buffered but not yet consumed into a completed sentence. */
|
|
219
|
+
get pending(): number {
|
|
220
|
+
return this.buffer.length;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export type ApiReplyType = "!re" | "!done" | "!trap" | "!fatal" | "!empty";
|
|
225
|
+
|
|
226
|
+
export interface ApiReply {
|
|
227
|
+
/** Reply tag word (`!re`, `!done`, `!trap`, `!fatal`, `!empty`). */
|
|
228
|
+
type: ApiReplyType;
|
|
229
|
+
/** `=name=value` attribute words, keyed by name. */
|
|
230
|
+
attributes: Record<string, string>;
|
|
231
|
+
/** `.tag=` multiplexing tag, when present. */
|
|
232
|
+
tag?: string;
|
|
233
|
+
/** Raw words exactly as received, including the leading reply word. */
|
|
234
|
+
words: readonly string[];
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Parse a raw sentence (array of words) into a structured reply. */
|
|
238
|
+
export function parseReply(words: readonly string[]): ApiReply {
|
|
239
|
+
const head = words[0] ?? "";
|
|
240
|
+
const type: ApiReplyType =
|
|
241
|
+
head === "!re" ||
|
|
242
|
+
head === "!done" ||
|
|
243
|
+
head === "!trap" ||
|
|
244
|
+
head === "!fatal" ||
|
|
245
|
+
head === "!empty"
|
|
246
|
+
? head
|
|
247
|
+
: "!fatal";
|
|
248
|
+
const attributes: Record<string, string> = {};
|
|
249
|
+
let tag: string | undefined;
|
|
250
|
+
for (let index = 1; index < words.length; index += 1) {
|
|
251
|
+
const word = words[index] as string;
|
|
252
|
+
if (word.startsWith("=")) {
|
|
253
|
+
const separator = word.indexOf("=", 1);
|
|
254
|
+
if (separator === -1) {
|
|
255
|
+
attributes[word.slice(1)] = "";
|
|
256
|
+
} else {
|
|
257
|
+
attributes[word.slice(1, separator)] = word.slice(separator + 1);
|
|
258
|
+
}
|
|
259
|
+
} else if (word.startsWith(".tag=")) {
|
|
260
|
+
tag = word.slice(".tag=".length);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return { type, attributes, tag, words };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Read an attribute by name (variable key avoids index-signature pitfalls). */
|
|
267
|
+
export function readAttribute(
|
|
268
|
+
reply: ApiReply,
|
|
269
|
+
name: string,
|
|
270
|
+
): string | undefined {
|
|
271
|
+
return reply.attributes[name];
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Classify a native-API `!trap` as an authentication failure.
|
|
276
|
+
*
|
|
277
|
+
* Auth failures surface on `/login` (bad credentials) or on any command when
|
|
278
|
+
* the session is not authorized, so the trap message is matched rather than
|
|
279
|
+
* relying on the command word alone. The strings are grounded on live RouterOS
|
|
280
|
+
* CHR output: "invalid user name or password", "could not authenticate -
|
|
281
|
+
* radius timeout", "not logged in", "access denied". Misclassifying these as a
|
|
282
|
+
* syntax/validation error was the reported `execute` bug.
|
|
283
|
+
*/
|
|
284
|
+
export function isNativeAuthFailure(command: string, message: string): boolean {
|
|
285
|
+
return (
|
|
286
|
+
/not logged in|access denied|invalid user|could not authenticate|authentication failed|radius (?:timeout|error)|bad password|password|\blogin\b|\buser\b/i.test(
|
|
287
|
+
message,
|
|
288
|
+
) || command === "/login"
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Compute the legacy (pre-6.43) login response from a hex challenge.
|
|
294
|
+
*
|
|
295
|
+
* `response = "00" + md5( 0x00 || password || rawChallenge )` where
|
|
296
|
+
* `rawChallenge` is the challenge hex string decoded to bytes.
|
|
297
|
+
*/
|
|
298
|
+
export function challengeResponse(
|
|
299
|
+
challengeHex: string,
|
|
300
|
+
password: string,
|
|
301
|
+
): string {
|
|
302
|
+
const challenge = Buffer.from(challengeHex, "hex");
|
|
303
|
+
const hash = createHash("md5");
|
|
304
|
+
hash.update(Buffer.from([0]));
|
|
305
|
+
hash.update(Buffer.from(password, "utf8"));
|
|
306
|
+
hash.update(challenge);
|
|
307
|
+
return `00${hash.digest("hex")}`;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Build an API command word `=name=value` attribute word.
|
|
312
|
+
*
|
|
313
|
+
* The value needs **no escaping**: words are length-prefix framed on the wire
|
|
314
|
+
* ({@link encodeWord} UTF-8-encodes by byte length), so any UTF-8 content — `=`,
|
|
315
|
+
* spaces, CR/LF, NUL, multibyte characters — passes through verbatim, and
|
|
316
|
+
* {@link parseReply} splits on the *second* `=` so a value that contains `=` is
|
|
317
|
+
* read back whole. Round-trip pinned in `test/unit/native-api.test.ts`
|
|
318
|
+
* ("attribute value round-trip (JG-15)").
|
|
319
|
+
*/
|
|
320
|
+
export function attributeWord(name: string, value: string): string {
|
|
321
|
+
return `=${name}=${value}`;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** Sink the session writes encoded sentences to. */
|
|
325
|
+
export interface NativeApiByteSink {
|
|
326
|
+
/** Write raw bytes to the transport. */
|
|
327
|
+
write(bytes: Uint8Array): void;
|
|
328
|
+
/** Close the transport. */
|
|
329
|
+
close(): void;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export interface NativeApiCommand {
|
|
333
|
+
/** RouterOS path/command word, e.g. `/system/resource/print`. */
|
|
334
|
+
command: string;
|
|
335
|
+
/** `=name=value` attributes. */
|
|
336
|
+
attributes?: Record<string, string>;
|
|
337
|
+
/** `?query` words (already including the leading `?`). */
|
|
338
|
+
queries?: readonly string[];
|
|
339
|
+
/** `.proplist` restriction (comma-joined when an array). */
|
|
340
|
+
proplist?: readonly string[] | string;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
interface PendingCommand {
|
|
344
|
+
command: string;
|
|
345
|
+
records: ApiReply[];
|
|
346
|
+
trap?: ApiReply;
|
|
347
|
+
resolve: (replies: ApiReply[]) => void;
|
|
348
|
+
reject: (error: CentrsError) => void;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
export interface NativeApiSessionOptions {
|
|
352
|
+
sink: NativeApiByteSink;
|
|
353
|
+
/** Host/port label used in error envelopes. */
|
|
354
|
+
endpoint?: string;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Stateful native API session over an injectable byte sink.
|
|
359
|
+
*
|
|
360
|
+
* Multiplexes commands with `.tag=` so concurrent `talk()` calls route their
|
|
361
|
+
* `!re`/`!done`/`!trap` replies back to the right caller. Feed inbound bytes
|
|
362
|
+
* via {@link handleData} and transport closure via {@link handleClose}.
|
|
363
|
+
*/
|
|
364
|
+
export class NativeApiSession {
|
|
365
|
+
private readonly sink: NativeApiByteSink;
|
|
366
|
+
private readonly endpoint: string;
|
|
367
|
+
private readonly reader = new SentenceReader();
|
|
368
|
+
private readonly pending = new Map<string, PendingCommand>();
|
|
369
|
+
private tagCounter = 0;
|
|
370
|
+
private closed = false;
|
|
371
|
+
private closeError: CentrsError | undefined;
|
|
372
|
+
|
|
373
|
+
constructor(options: NativeApiSessionOptions) {
|
|
374
|
+
this.sink = options.sink;
|
|
375
|
+
this.endpoint = options.endpoint ?? "the RouterOS API service";
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** Feed inbound transport bytes into the parser. */
|
|
379
|
+
handleData(chunk: Uint8Array): void {
|
|
380
|
+
let sentences: string[][];
|
|
381
|
+
try {
|
|
382
|
+
sentences = this.reader.push(chunk);
|
|
383
|
+
} catch (error) {
|
|
384
|
+
this.failAll(
|
|
385
|
+
error instanceof CentrsError
|
|
386
|
+
? error
|
|
387
|
+
: new CentrsError({
|
|
388
|
+
code: "routeros/api-protocol",
|
|
389
|
+
summary: "Failed to parse a RouterOS API reply.",
|
|
390
|
+
remediation:
|
|
391
|
+
"Confirm the target port speaks the native API and is not a different service.",
|
|
392
|
+
cause: error,
|
|
393
|
+
}),
|
|
394
|
+
);
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
for (const words of sentences) {
|
|
398
|
+
if (words.length === 0) {
|
|
399
|
+
continue;
|
|
400
|
+
}
|
|
401
|
+
this.dispatch(parseReply(words));
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** Notify the session the transport closed (optionally with an error). */
|
|
406
|
+
handleClose(error?: Error): void {
|
|
407
|
+
if (this.closed) {
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
this.failAll(
|
|
411
|
+
this.closeError ??
|
|
412
|
+
new CentrsError({
|
|
413
|
+
code: "transport/connection-closed",
|
|
414
|
+
summary: `The RouterOS API connection to ${this.endpoint} closed unexpectedly.`,
|
|
415
|
+
remediation:
|
|
416
|
+
"Retry; if it persists, confirm the api service is enabled and reachable.",
|
|
417
|
+
cause: error,
|
|
418
|
+
}),
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
private dispatch(reply: ApiReply): void {
|
|
423
|
+
if (reply.type === "!fatal") {
|
|
424
|
+
this.closeError = new CentrsError({
|
|
425
|
+
code: "routeros/api-fatal",
|
|
426
|
+
summary:
|
|
427
|
+
readAttribute(reply, "message") ??
|
|
428
|
+
`The RouterOS API service at ${this.endpoint} reported a fatal error.`,
|
|
429
|
+
remediation:
|
|
430
|
+
"The router closed the API connection; check the System > Logging output on the device.",
|
|
431
|
+
context: { attributes: reply.attributes },
|
|
432
|
+
});
|
|
433
|
+
this.failAll(this.closeError);
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const tag = reply.tag;
|
|
438
|
+
if (tag === undefined) {
|
|
439
|
+
return; // untagged async/system notice — no waiter to route to
|
|
440
|
+
}
|
|
441
|
+
const waiter = this.pending.get(tag);
|
|
442
|
+
if (waiter === undefined) {
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
switch (reply.type) {
|
|
447
|
+
case "!re":
|
|
448
|
+
waiter.records.push(reply);
|
|
449
|
+
return;
|
|
450
|
+
case "!trap":
|
|
451
|
+
waiter.trap = reply;
|
|
452
|
+
return;
|
|
453
|
+
case "!empty":
|
|
454
|
+
return;
|
|
455
|
+
case "!done": {
|
|
456
|
+
this.pending.delete(tag);
|
|
457
|
+
if (waiter.trap) {
|
|
458
|
+
waiter.reject(this.trapToError(waiter.command, waiter.trap));
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
if (Object.keys(reply.attributes).length > 0) {
|
|
462
|
+
waiter.records.push(reply);
|
|
463
|
+
}
|
|
464
|
+
waiter.resolve(waiter.records);
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
default:
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
private trapToError(command: string, trap: ApiReply): CentrsError {
|
|
473
|
+
const message =
|
|
474
|
+
readAttribute(trap, "message") ?? "RouterOS API command failed.";
|
|
475
|
+
if (isNativeAuthFailure(command, message)) {
|
|
476
|
+
return new CentrsError({
|
|
477
|
+
code: "transport/auth-failed",
|
|
478
|
+
summary: `RouterOS rejected the API credentials for ${this.endpoint}: ${message}`,
|
|
479
|
+
remediation:
|
|
480
|
+
"Check the username/password (and any RADIUS backend) and that the user has API access (a group with the `api` policy).",
|
|
481
|
+
context: {
|
|
482
|
+
command,
|
|
483
|
+
category: readAttribute(trap, "category"),
|
|
484
|
+
message,
|
|
485
|
+
},
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
return mapRouterOsError(message, {
|
|
490
|
+
transport: "native-api",
|
|
491
|
+
context: {
|
|
492
|
+
command,
|
|
493
|
+
category: readAttribute(trap, "category"),
|
|
494
|
+
message,
|
|
495
|
+
},
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
private failAll(error: CentrsError): void {
|
|
500
|
+
this.closed = true;
|
|
501
|
+
const waiters = [...this.pending.values()];
|
|
502
|
+
this.pending.clear();
|
|
503
|
+
for (const waiter of waiters) {
|
|
504
|
+
waiter.reject(error);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
private nextTag(): string {
|
|
509
|
+
this.tagCounter += 1;
|
|
510
|
+
return `t${this.tagCounter}`;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
private buildWords(command: NativeApiCommand, tag: string): string[] {
|
|
514
|
+
const words: string[] = [command.command];
|
|
515
|
+
for (const [name, value] of Object.entries(command.attributes ?? {})) {
|
|
516
|
+
words.push(attributeWord(name, value));
|
|
517
|
+
}
|
|
518
|
+
for (const query of command.queries ?? []) {
|
|
519
|
+
words.push(query);
|
|
520
|
+
}
|
|
521
|
+
if (command.proplist !== undefined) {
|
|
522
|
+
const list = Array.isArray(command.proplist)
|
|
523
|
+
? command.proplist.join(",")
|
|
524
|
+
: (command.proplist as string);
|
|
525
|
+
// `.proplist` is a regular attribute word (`=.proplist=`), unlike the
|
|
526
|
+
// bare-dot `.tag` API attribute word.
|
|
527
|
+
words.push(`=.proplist=${list}`);
|
|
528
|
+
}
|
|
529
|
+
words.push(`.tag=${tag}`);
|
|
530
|
+
return words;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Send a command and resolve with its reply records (`!re` sentences, plus
|
|
535
|
+
* the `!done` sentence when it carries attributes). Rejects on `!trap`,
|
|
536
|
+
* `!fatal`, or transport closure.
|
|
537
|
+
*/
|
|
538
|
+
talk(command: NativeApiCommand): Promise<ApiReply[]> {
|
|
539
|
+
if (this.closed) {
|
|
540
|
+
return Promise.reject(
|
|
541
|
+
this.closeError ??
|
|
542
|
+
new CentrsError({
|
|
543
|
+
code: "transport/connection-closed",
|
|
544
|
+
summary: `The RouterOS API session to ${this.endpoint} is closed.`,
|
|
545
|
+
remediation: "Open a new connection before sending commands.",
|
|
546
|
+
}),
|
|
547
|
+
);
|
|
548
|
+
}
|
|
549
|
+
const tag = this.nextTag();
|
|
550
|
+
const words = this.buildWords(command, tag);
|
|
551
|
+
return new Promise<ApiReply[]>((resolve, reject) => {
|
|
552
|
+
this.pending.set(tag, {
|
|
553
|
+
command: command.command,
|
|
554
|
+
records: [],
|
|
555
|
+
resolve,
|
|
556
|
+
reject,
|
|
557
|
+
});
|
|
558
|
+
try {
|
|
559
|
+
this.sink.write(encodeSentence(words));
|
|
560
|
+
} catch (error) {
|
|
561
|
+
this.pending.delete(tag);
|
|
562
|
+
reject(
|
|
563
|
+
error instanceof CentrsError
|
|
564
|
+
? error
|
|
565
|
+
: new CentrsError({
|
|
566
|
+
code: "transport/network",
|
|
567
|
+
summary: `Failed to write to the RouterOS API connection to ${this.endpoint}.`,
|
|
568
|
+
remediation: "Retry; confirm the connection is still open.",
|
|
569
|
+
cause: error,
|
|
570
|
+
}),
|
|
571
|
+
);
|
|
572
|
+
}
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* Authenticate. Uses the combined approach: send name+password; if the
|
|
578
|
+
* router answers with a legacy `=ret=` challenge, compute the MD5 response
|
|
579
|
+
* and send the second `/login`. Resolves on success, rejects on bad auth.
|
|
580
|
+
*/
|
|
581
|
+
async login(username: string, password: string): Promise<void> {
|
|
582
|
+
const first = await this.talk({
|
|
583
|
+
command: "/login",
|
|
584
|
+
attributes: { name: username, password },
|
|
585
|
+
});
|
|
586
|
+
const challenge = first
|
|
587
|
+
.map((reply) => readAttribute(reply, "ret"))
|
|
588
|
+
.find((ret): ret is string => typeof ret === "string");
|
|
589
|
+
if (challenge === undefined) {
|
|
590
|
+
return; // modern plaintext login succeeded
|
|
591
|
+
}
|
|
592
|
+
await this.talk({
|
|
593
|
+
command: "/login",
|
|
594
|
+
attributes: {
|
|
595
|
+
name: username,
|
|
596
|
+
response: challengeResponse(challenge, password),
|
|
597
|
+
},
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/** Close the transport and reject any in-flight commands. */
|
|
602
|
+
close(): void {
|
|
603
|
+
if (this.closed) {
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
this.closed = true;
|
|
607
|
+
try {
|
|
608
|
+
this.sink.close();
|
|
609
|
+
} finally {
|
|
610
|
+
const waiters = [...this.pending.values()];
|
|
611
|
+
this.pending.clear();
|
|
612
|
+
for (const waiter of waiters) {
|
|
613
|
+
waiter.reject(
|
|
614
|
+
new CentrsError({
|
|
615
|
+
code: "transport/connection-closed",
|
|
616
|
+
summary: `The RouterOS API session to ${this.endpoint} was closed by the caller.`,
|
|
617
|
+
remediation: "Open a new connection before sending more commands.",
|
|
618
|
+
}),
|
|
619
|
+
);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
export interface ConnectNativeApiOptions {
|
|
626
|
+
host: string;
|
|
627
|
+
port?: number;
|
|
628
|
+
username: string;
|
|
629
|
+
password: string;
|
|
630
|
+
/** Use TLS (api-ssl). Defaults to false. */
|
|
631
|
+
tls?: boolean;
|
|
632
|
+
/** Reject self-signed/anonymous certs. api-ssl is often unverified. */
|
|
633
|
+
rejectUnauthorized?: boolean;
|
|
634
|
+
/** Connection timeout in milliseconds. */
|
|
635
|
+
timeoutMs?: number;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
interface ConnectedNativeApi {
|
|
639
|
+
session: NativeApiSession;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
* Open a TCP (or TLS) native API connection, authenticate, and return a
|
|
644
|
+
* ready-to-use {@link NativeApiSession}. IO path; covered by integration tests.
|
|
645
|
+
*/
|
|
646
|
+
export async function connectNativeApi(
|
|
647
|
+
options: ConnectNativeApiOptions,
|
|
648
|
+
): Promise<ConnectedNativeApi> {
|
|
649
|
+
const port =
|
|
650
|
+
options.port ?? (options.tls ? NATIVE_API_TLS_PORT : NATIVE_API_PORT);
|
|
651
|
+
const endpoint = `${options.host}:${port}`;
|
|
652
|
+
const timeoutMs = options.timeoutMs ?? 15_000;
|
|
653
|
+
|
|
654
|
+
let session: NativeApiSession | undefined;
|
|
655
|
+
const connectPromise = Bun.connect({
|
|
656
|
+
hostname: options.host,
|
|
657
|
+
port,
|
|
658
|
+
...(options.tls
|
|
659
|
+
? {
|
|
660
|
+
tls: {
|
|
661
|
+
rejectUnauthorized: options.rejectUnauthorized ?? false,
|
|
662
|
+
},
|
|
663
|
+
}
|
|
664
|
+
: {}),
|
|
665
|
+
socket: {
|
|
666
|
+
data(_socket, data) {
|
|
667
|
+
session?.handleData(new Uint8Array(data));
|
|
668
|
+
},
|
|
669
|
+
close() {
|
|
670
|
+
session?.handleClose();
|
|
671
|
+
},
|
|
672
|
+
error(_socket, error) {
|
|
673
|
+
session?.handleClose(error);
|
|
674
|
+
},
|
|
675
|
+
},
|
|
676
|
+
}).catch((error: unknown) => {
|
|
677
|
+
throw mapConnectError(error, endpoint);
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
const socket = await withTimeout(connectPromise, timeoutMs, endpoint);
|
|
681
|
+
session = new NativeApiSession({
|
|
682
|
+
endpoint,
|
|
683
|
+
sink: {
|
|
684
|
+
write(bytes) {
|
|
685
|
+
socket.write(bytes);
|
|
686
|
+
},
|
|
687
|
+
close() {
|
|
688
|
+
socket.end();
|
|
689
|
+
},
|
|
690
|
+
},
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
try {
|
|
694
|
+
await withTimeout(
|
|
695
|
+
session.login(options.username, options.password),
|
|
696
|
+
timeoutMs,
|
|
697
|
+
endpoint,
|
|
698
|
+
);
|
|
699
|
+
} catch (error) {
|
|
700
|
+
// Never leave a half-open socket when login times out or is rejected.
|
|
701
|
+
session.close();
|
|
702
|
+
throw error;
|
|
703
|
+
}
|
|
704
|
+
return { session };
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
async function withTimeout<T>(
|
|
708
|
+
promise: Promise<T>,
|
|
709
|
+
timeoutMs: number,
|
|
710
|
+
endpoint: string,
|
|
711
|
+
): Promise<T> {
|
|
712
|
+
let handle: ReturnType<typeof setTimeout> | undefined;
|
|
713
|
+
const timeout = new Promise<never>((_resolve, reject) => {
|
|
714
|
+
handle = setTimeout(() => {
|
|
715
|
+
reject(
|
|
716
|
+
new CentrsError({
|
|
717
|
+
code: "transport/timeout",
|
|
718
|
+
summary: `Connecting to the RouterOS API service at ${endpoint} timed out after ${timeoutMs}ms.`,
|
|
719
|
+
remediation:
|
|
720
|
+
"Confirm the api service is enabled and reachable, or raise the timeout.",
|
|
721
|
+
}),
|
|
722
|
+
);
|
|
723
|
+
}, timeoutMs);
|
|
724
|
+
});
|
|
725
|
+
try {
|
|
726
|
+
return await Promise.race([promise, timeout]);
|
|
727
|
+
} finally {
|
|
728
|
+
if (handle !== undefined) {
|
|
729
|
+
clearTimeout(handle);
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
function mapConnectError(error: unknown, endpoint: string): CentrsError {
|
|
735
|
+
if (error instanceof CentrsError) {
|
|
736
|
+
return error;
|
|
737
|
+
}
|
|
738
|
+
const code = extractSystemCode(error);
|
|
739
|
+
if (code === "ECONNREFUSED") {
|
|
740
|
+
return new CentrsError({
|
|
741
|
+
code: "transport/connection-refused",
|
|
742
|
+
summary: `The RouterOS API service at ${endpoint} refused the connection.`,
|
|
743
|
+
remediation:
|
|
744
|
+
"Enable the api service (`/ip service enable api`) and confirm the port/firewall.",
|
|
745
|
+
cause: error,
|
|
746
|
+
});
|
|
747
|
+
}
|
|
748
|
+
if (code === "ENOTFOUND" || code === "EAI_AGAIN") {
|
|
749
|
+
return new CentrsError({
|
|
750
|
+
code: "transport/dns",
|
|
751
|
+
summary: `Could not resolve the RouterOS API host for ${endpoint}.`,
|
|
752
|
+
remediation: "Check the hostname or use an IP address.",
|
|
753
|
+
cause: error,
|
|
754
|
+
});
|
|
755
|
+
}
|
|
756
|
+
if (isTlsCertificateError(code, error)) {
|
|
757
|
+
return new CentrsError({
|
|
758
|
+
code: "transport/tls-certificate",
|
|
759
|
+
summary: `TLS certificate validation failed for the api-ssl service at ${endpoint}.`,
|
|
760
|
+
remediation:
|
|
761
|
+
"RouterOS ships a self-signed certificate by default. Install a trusted chain, or pass `--insecure` (`CENTRS_INSECURE=1`) to accept it.",
|
|
762
|
+
cause: error,
|
|
763
|
+
});
|
|
764
|
+
}
|
|
765
|
+
return new CentrsError({
|
|
766
|
+
code: "transport/network",
|
|
767
|
+
summary: `Failed to connect to the RouterOS API service at ${endpoint}.`,
|
|
768
|
+
remediation: "Confirm the host, port, and network path to the device.",
|
|
769
|
+
cause: error,
|
|
770
|
+
});
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
/** Detect a TLS peer-verification failure (api-ssl self-signed cert, etc.). */
|
|
774
|
+
function isTlsCertificateError(
|
|
775
|
+
code: string | undefined,
|
|
776
|
+
error: unknown,
|
|
777
|
+
): boolean {
|
|
778
|
+
if (code && /CERT|SELF_SIGNED|VERIFY|SSL|TLS/i.test(code)) {
|
|
779
|
+
return true;
|
|
780
|
+
}
|
|
781
|
+
const message =
|
|
782
|
+
error instanceof Error
|
|
783
|
+
? error.message
|
|
784
|
+
: typeof error === "object" && error && "message" in error
|
|
785
|
+
? String((error as { message: unknown }).message)
|
|
786
|
+
: "";
|
|
787
|
+
return /certificate|self.signed|self_signed/i.test(message);
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
function extractSystemCode(error: unknown): string | undefined {
|
|
791
|
+
if (error && typeof error === "object" && "code" in error) {
|
|
792
|
+
const code = (error as { code: unknown }).code;
|
|
793
|
+
return typeof code === "string" ? code : undefined;
|
|
794
|
+
}
|
|
795
|
+
return undefined;
|
|
796
|
+
}
|