@usex/mikrotik-mcp 3.41.0 → 3.43.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/README.md +51 -15
- package/dist/cli.js +76 -25
- package/dist/index.d.ts +4 -0
- package/dist/index.js +2 -2
- package/dist/shared/{cli-fwswsq2a.js → cli-j7xj0at0.js} +1 -1
- package/dist/shared/{cli-ef0ns1dq.js → cli-vrm1c5fb.js} +1038 -878
- package/dist/shared/{library-vhp4xd29.js → library-hjde13aa.js} +1021 -874
- package/dist/shared/{library-2s6sp2r5.js → library-hjh5hy5d.js} +1 -1
- package/dist/ui/observability.html +12 -12
- package/package.json +1 -1
- package/schemas/config.schema.json +29 -1
- package/schemas/tool-catalog.json +9 -15
- package/schemas/tools/check_route_path.json +2 -4
- package/schemas/tools/download_file.json +2 -6
|
@@ -4,330 +4,9 @@ var __require = import.meta.require;
|
|
|
4
4
|
// src/tools/address-list.ts
|
|
5
5
|
import { z as z3 } from "zod";
|
|
6
6
|
|
|
7
|
-
// src/
|
|
7
|
+
// src/ssh/client.ts
|
|
8
8
|
import { readFileSync } from "fs";
|
|
9
|
-
import {
|
|
10
|
-
import { join, resolve } from "path";
|
|
11
|
-
import { z } from "zod";
|
|
12
|
-
var DEFAULT_DASHBOARD_DB = join(homedir(), ".mikrotik-mcp", "events.db");
|
|
13
|
-
var DEFAULT_SNAPSHOT_DB = join(homedir(), ".mikrotik-mcp", "snapshots.db");
|
|
14
|
-
var DEFAULT_BACKUP_DIR = join(homedir(), ".mikrotik-mcp", "backups");
|
|
15
|
-
var DEFAULT_CONFIG_HISTORY_DIR = join(homedir(), ".mikrotik-mcp", "config-history");
|
|
16
|
-
var DEFAULT_CONFIG_FILE = join(homedir(), ".mikrotik-mcp", "config.json");
|
|
17
|
-
var TransportSchema = z.enum(["stdio", "sse", "streamable-http"]);
|
|
18
|
-
var McpServerSettingsSchema = z.object({
|
|
19
|
-
transport: TransportSchema.default("stdio"),
|
|
20
|
-
host: z.string().default("0.0.0.0"),
|
|
21
|
-
port: z.coerce.number().int().positive().default(8000),
|
|
22
|
-
allowedHosts: z.string().default(""),
|
|
23
|
-
allowedOrigins: z.string().default(""),
|
|
24
|
-
corsOrigins: z.string().default(""),
|
|
25
|
-
toolPageSize: z.coerce.number().int().min(0).default(0),
|
|
26
|
-
appViews: z.boolean().default(true)
|
|
27
|
-
});
|
|
28
|
-
var JumpHostSchema = z.object({
|
|
29
|
-
host: z.string(),
|
|
30
|
-
port: z.coerce.number().int().positive().default(22),
|
|
31
|
-
username: z.string().default("admin"),
|
|
32
|
-
password: z.string().optional(),
|
|
33
|
-
keyFilename: z.string().optional(),
|
|
34
|
-
privateKey: z.string().optional(),
|
|
35
|
-
keyPassphrase: z.string().optional(),
|
|
36
|
-
timeoutMs: z.coerce.number().int().positive().optional()
|
|
37
|
-
});
|
|
38
|
-
var DeviceConfigSchema = z.object({
|
|
39
|
-
host: z.string().default("127.0.0.1"),
|
|
40
|
-
username: z.string().default("admin"),
|
|
41
|
-
password: z.string().default(""),
|
|
42
|
-
port: z.coerce.number().int().positive().default(22),
|
|
43
|
-
keyFilename: z.string().optional(),
|
|
44
|
-
privateKey: z.string().optional(),
|
|
45
|
-
keyPassphrase: z.string().optional(),
|
|
46
|
-
timeoutMs: z.coerce.number().int().positive().default(1e4),
|
|
47
|
-
jumpVia: z.string().optional(),
|
|
48
|
-
jumpHost: JumpHostSchema.optional(),
|
|
49
|
-
mac: z.string().optional(),
|
|
50
|
-
sourceMac: z.string().optional(),
|
|
51
|
-
macHost: z.string().optional(),
|
|
52
|
-
macPort: z.coerce.number().int().positive().optional(),
|
|
53
|
-
description: z.string().optional()
|
|
54
|
-
});
|
|
55
|
-
var S3ConfigSchema = z.object({
|
|
56
|
-
accessKeyId: z.string().optional(),
|
|
57
|
-
secretAccessKey: z.string().optional(),
|
|
58
|
-
sessionToken: z.string().optional(),
|
|
59
|
-
region: z.string().optional(),
|
|
60
|
-
endpoint: z.string().optional(),
|
|
61
|
-
bucket: z.string().optional(),
|
|
62
|
-
prefix: z.string().default(""),
|
|
63
|
-
presignExpiresIn: z.coerce.number().int().positive().default(3600)
|
|
64
|
-
});
|
|
65
|
-
var DashboardConfigSchema = z.object({
|
|
66
|
-
enabled: z.boolean().default(false),
|
|
67
|
-
host: z.string().default("0.0.0.0"),
|
|
68
|
-
port: z.coerce.number().int().positive().default(9090),
|
|
69
|
-
dbPath: z.string().default(DEFAULT_DASHBOARD_DB),
|
|
70
|
-
maxEvents: z.coerce.number().int().positive().default(1e5),
|
|
71
|
-
captureBody: z.boolean().default(true),
|
|
72
|
-
redactInput: z.boolean().default(false),
|
|
73
|
-
maxBodyBytes: z.coerce.number().int().nonnegative().default(16384),
|
|
74
|
-
token: z.string().optional()
|
|
75
|
-
});
|
|
76
|
-
var ToolFilterSchema = z.object({
|
|
77
|
-
enabledModules: z.array(z.string()).default([]),
|
|
78
|
-
disabledModules: z.array(z.string()).default([]),
|
|
79
|
-
enabledGroups: z.array(z.string()).default([]),
|
|
80
|
-
disabledGroups: z.array(z.string()).default([])
|
|
81
|
-
});
|
|
82
|
-
var MikrotikConfigSchema = z.object({
|
|
83
|
-
devices: z.record(z.string(), DeviceConfigSchema).default(() => ({ default: DeviceConfigSchema.parse({}) })),
|
|
84
|
-
defaultDevice: z.string().default("default"),
|
|
85
|
-
mcp: McpServerSettingsSchema.default(() => McpServerSettingsSchema.parse({})),
|
|
86
|
-
s3: S3ConfigSchema.optional(),
|
|
87
|
-
dashboard: DashboardConfigSchema.default(() => DashboardConfigSchema.parse({})),
|
|
88
|
-
readOnly: z.boolean().default(false),
|
|
89
|
-
tools: ToolFilterSchema.default(() => ToolFilterSchema.parse({})),
|
|
90
|
-
backupDir: z.string().optional()
|
|
91
|
-
});
|
|
92
|
-
function env(...names) {
|
|
93
|
-
for (const n of names) {
|
|
94
|
-
const v = process.env[n];
|
|
95
|
-
if (v !== undefined && v !== "")
|
|
96
|
-
return v;
|
|
97
|
-
}
|
|
98
|
-
return;
|
|
99
|
-
}
|
|
100
|
-
function parseFlags(argv) {
|
|
101
|
-
const out = {};
|
|
102
|
-
for (let i = 0;i < argv.length; i++) {
|
|
103
|
-
const arg = argv[i];
|
|
104
|
-
if (!arg.startsWith("--"))
|
|
105
|
-
continue;
|
|
106
|
-
const eq = arg.indexOf("=");
|
|
107
|
-
if (eq !== -1) {
|
|
108
|
-
out[arg.slice(2, eq)] = arg.slice(eq + 1);
|
|
109
|
-
} else {
|
|
110
|
-
const key = arg.slice(2);
|
|
111
|
-
const next = argv[i + 1];
|
|
112
|
-
if (next !== undefined && !next.startsWith("--")) {
|
|
113
|
-
out[key] = next;
|
|
114
|
-
i++;
|
|
115
|
-
} else {
|
|
116
|
-
out[key] = "true";
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
return out;
|
|
121
|
-
}
|
|
122
|
-
function parseDevicesSource(raw, fromFile) {
|
|
123
|
-
let json;
|
|
124
|
-
try {
|
|
125
|
-
json = JSON.parse(fromFile ? readFileSync(raw, "utf8") : raw);
|
|
126
|
-
} catch (e) {
|
|
127
|
-
throw new Error(`Failed to load MikroTik devices from ${fromFile ? `file ${raw}` : "MIKROTIK_DEVICES"}: ${e instanceof Error ? e.message : String(e)}`);
|
|
128
|
-
}
|
|
129
|
-
const obj = json;
|
|
130
|
-
const structured = obj.devices !== undefined;
|
|
131
|
-
const devices = obj.devices ?? obj;
|
|
132
|
-
const defaultDevice = typeof obj.defaultDevice === "string" ? obj.defaultDevice : undefined;
|
|
133
|
-
const s3 = structured && obj.s3 && typeof obj.s3 === "object" ? obj.s3 : undefined;
|
|
134
|
-
const dashboard = structured && obj.dashboard && typeof obj.dashboard === "object" ? obj.dashboard : undefined;
|
|
135
|
-
const tools = structured && obj.tools && typeof obj.tools === "object" ? obj.tools : undefined;
|
|
136
|
-
const mcp = structured && obj.mcp && typeof obj.mcp === "object" ? obj.mcp : undefined;
|
|
137
|
-
return { devices, defaultDevice, s3, dashboard, tools, mcp };
|
|
138
|
-
}
|
|
139
|
-
var configSource = { path: DEFAULT_CONFIG_FILE, fromFile: false };
|
|
140
|
-
function getConfigSource() {
|
|
141
|
-
return configSource;
|
|
142
|
-
}
|
|
143
|
-
function loadConfig(argv = process.argv.slice(2)) {
|
|
144
|
-
const flags = parseFlags(argv);
|
|
145
|
-
const pick = (flag, ...envNames) => flags[flag] ?? env(...envNames);
|
|
146
|
-
const jumpHostName = pick("jump-host", "MIKROTIK_JUMP_HOST");
|
|
147
|
-
const jumpHost = jumpHostName ? {
|
|
148
|
-
host: jumpHostName,
|
|
149
|
-
port: pick("jump-port", "MIKROTIK_JUMP_PORT"),
|
|
150
|
-
username: pick("jump-username", "MIKROTIK_JUMP_USERNAME"),
|
|
151
|
-
password: pick("jump-password", "MIKROTIK_JUMP_PASSWORD"),
|
|
152
|
-
keyFilename: pick("jump-key-filename", "MIKROTIK_JUMP_KEY_FILENAME"),
|
|
153
|
-
keyPassphrase: pick("jump-key-passphrase", "MIKROTIK_JUMP_KEY_PASSPHRASE")
|
|
154
|
-
} : undefined;
|
|
155
|
-
const single = {
|
|
156
|
-
host: pick("host", "MIKROTIK_HOST"),
|
|
157
|
-
username: pick("username", "MIKROTIK_USERNAME"),
|
|
158
|
-
password: pick("password", "MIKROTIK_PASSWORD"),
|
|
159
|
-
port: pick("port", "MIKROTIK_PORT"),
|
|
160
|
-
keyFilename: pick("key-filename", "MIKROTIK_KEY_FILENAME"),
|
|
161
|
-
privateKey: pick("private-key", "MIKROTIK_PRIVATE_KEY"),
|
|
162
|
-
keyPassphrase: pick("key-passphrase", "MIKROTIK_KEY_PASSPHRASE"),
|
|
163
|
-
timeoutMs: pick("timeout-ms", "MIKROTIK_TIMEOUT_MS"),
|
|
164
|
-
jumpHost,
|
|
165
|
-
mac: pick("mac", "MIKROTIK_MAC"),
|
|
166
|
-
sourceMac: pick("source-mac", "MIKROTIK_SOURCE_MAC"),
|
|
167
|
-
macHost: pick("mac-host", "MIKROTIK_MAC_HOST"),
|
|
168
|
-
macPort: pick("mac-port", "MIKROTIK_MAC_PORT")
|
|
169
|
-
};
|
|
170
|
-
const hasSingle = Object.values(single).some((v) => v !== undefined);
|
|
171
|
-
const devices = {};
|
|
172
|
-
let defaultDevice;
|
|
173
|
-
if (hasSingle) {
|
|
174
|
-
devices.default = single;
|
|
175
|
-
defaultDevice = "default";
|
|
176
|
-
}
|
|
177
|
-
const configFile = pick("config", "MIKROTIK_CONFIG_FILE");
|
|
178
|
-
configSource = configFile ? { path: resolve(configFile), fromFile: true } : { path: DEFAULT_CONFIG_FILE, fromFile: false };
|
|
179
|
-
const devicesInline = flags.devices ?? env("MIKROTIK_DEVICES");
|
|
180
|
-
let fileS3 = {};
|
|
181
|
-
let fileDashboard = {};
|
|
182
|
-
let fileTools;
|
|
183
|
-
let fileMcp;
|
|
184
|
-
if (configFile || devicesInline) {
|
|
185
|
-
const src = configFile ? parseDevicesSource(configFile, true) : parseDevicesSource(devicesInline, false);
|
|
186
|
-
for (const [name, dc] of Object.entries(src.devices))
|
|
187
|
-
devices[name] = dc;
|
|
188
|
-
if (src.defaultDevice)
|
|
189
|
-
defaultDevice = src.defaultDevice;
|
|
190
|
-
else if (!defaultDevice)
|
|
191
|
-
defaultDevice = Object.keys(src.devices)[0];
|
|
192
|
-
fileS3 = src.s3;
|
|
193
|
-
fileDashboard = src.dashboard;
|
|
194
|
-
fileTools = src.tools;
|
|
195
|
-
fileMcp = src.mcp;
|
|
196
|
-
}
|
|
197
|
-
const s3 = {
|
|
198
|
-
accessKeyId: pick("s3-access-key-id", "S3_ACCESS_KEY_ID", "AWS_ACCESS_KEY_ID"),
|
|
199
|
-
secretAccessKey: pick("s3-secret-access-key", "S3_SECRET_ACCESS_KEY", "AWS_SECRET_ACCESS_KEY"),
|
|
200
|
-
sessionToken: pick("s3-session-token", "S3_SESSION_TOKEN", "AWS_SESSION_TOKEN"),
|
|
201
|
-
region: pick("s3-region", "S3_REGION", "AWS_REGION"),
|
|
202
|
-
endpoint: pick("s3-endpoint", "S3_ENDPOINT", "AWS_ENDPOINT"),
|
|
203
|
-
bucket: pick("s3-bucket", "S3_BUCKET", "AWS_BUCKET"),
|
|
204
|
-
prefix: pick("s3-prefix", "MIKROTIK_S3_PREFIX"),
|
|
205
|
-
presignExpiresIn: pick("s3-presign-expires-in", "MIKROTIK_S3_PRESIGN_EXPIRES_IN"),
|
|
206
|
-
...fileS3
|
|
207
|
-
};
|
|
208
|
-
const appViewsRaw = pick("app-views", "MIKROTIK_MCP__APP_VIEWS");
|
|
209
|
-
const appViewsEnv = appViewsRaw === undefined ? undefined : !/^(0|false|no|off)$/i.test(appViewsRaw);
|
|
210
|
-
const mcp = {
|
|
211
|
-
transport: pick("transport", "MIKROTIK_MCP__TRANSPORT", "MCP_TRANSPORT"),
|
|
212
|
-
host: pick("mcp-host", "MIKROTIK_MCP__HOST"),
|
|
213
|
-
port: pick("mcp-port", "MIKROTIK_MCP__PORT"),
|
|
214
|
-
allowedHosts: pick("mcp-allowed-hosts", "MIKROTIK_MCP__ALLOWED_HOSTS"),
|
|
215
|
-
allowedOrigins: pick("mcp-allowed-origins", "MIKROTIK_MCP__ALLOWED_ORIGINS"),
|
|
216
|
-
corsOrigins: pick("mcp-cors-origins", "MIKROTIK_MCP__CORS_ORIGINS"),
|
|
217
|
-
toolPageSize: pick("tool-page-size", "MIKROTIK_MCP__TOOL_PAGE_SIZE"),
|
|
218
|
-
appViews: appViewsEnv,
|
|
219
|
-
...fileMcp
|
|
220
|
-
};
|
|
221
|
-
const isTruthy = (v) => /^(1|true|yes|on)$/i.test(v ?? "");
|
|
222
|
-
const readOnly = isTruthy(pick("read-only", "MIKROTIK_READ_ONLY"));
|
|
223
|
-
const csv = (v) => v === undefined ? undefined : v.split(",").map((s) => s.trim()).filter(Boolean);
|
|
224
|
-
const tools = {
|
|
225
|
-
enabledModules: csv(pick("tools-enabled-modules", "MIKROTIK_TOOLS__ENABLED_MODULES")),
|
|
226
|
-
disabledModules: csv(pick("tools-disabled-modules", "MIKROTIK_TOOLS__DISABLED_MODULES")),
|
|
227
|
-
enabledGroups: csv(pick("tools-enabled-groups", "MIKROTIK_TOOLS__ENABLED_GROUPS")),
|
|
228
|
-
disabledGroups: csv(pick("tools-disabled-groups", "MIKROTIK_TOOLS__DISABLED_GROUPS")),
|
|
229
|
-
...fileTools
|
|
230
|
-
};
|
|
231
|
-
const boolOpt = (v) => v === undefined ? undefined : isTruthy(v);
|
|
232
|
-
const dashboard = {
|
|
233
|
-
enabled: boolOpt(pick("dashboard", "MIKROTIK_DASHBOARD__ENABLED", "MIKROTIK_DASHBOARD")),
|
|
234
|
-
host: pick("dashboard-host", "MIKROTIK_DASHBOARD__HOST"),
|
|
235
|
-
port: pick("dashboard-port", "MIKROTIK_DASHBOARD__PORT"),
|
|
236
|
-
dbPath: pick("dashboard-db", "MIKROTIK_DASHBOARD__DB_PATH"),
|
|
237
|
-
maxEvents: pick("dashboard-max-events", "MIKROTIK_DASHBOARD__MAX_EVENTS"),
|
|
238
|
-
captureBody: boolOpt(pick("dashboard-capture-body", "MIKROTIK_DASHBOARD__CAPTURE_BODY")),
|
|
239
|
-
redactInput: boolOpt(pick("dashboard-redact-input", "MIKROTIK_DASHBOARD__REDACT_INPUT")),
|
|
240
|
-
maxBodyBytes: pick("dashboard-max-body-bytes", "MIKROTIK_DASHBOARD__MAX_BODY_BYTES"),
|
|
241
|
-
token: pick("dashboard-token", "MIKROTIK_DASHBOARD__TOKEN"),
|
|
242
|
-
...fileDashboard
|
|
243
|
-
};
|
|
244
|
-
const hasS3 = !!(s3.accessKeyId || s3.bucket || s3.endpoint);
|
|
245
|
-
const raw = {
|
|
246
|
-
devices: Object.keys(devices).length ? devices : { default: {} },
|
|
247
|
-
defaultDevice: defaultDevice ?? "default",
|
|
248
|
-
mcp,
|
|
249
|
-
dashboard,
|
|
250
|
-
readOnly,
|
|
251
|
-
tools,
|
|
252
|
-
...hasS3 ? { s3 } : {}
|
|
253
|
-
};
|
|
254
|
-
const pruned = JSON.parse(JSON.stringify(raw));
|
|
255
|
-
const parsed = MikrotikConfigSchema.parse(pruned);
|
|
256
|
-
if (!parsed.devices[parsed.defaultDevice]) {
|
|
257
|
-
parsed.defaultDevice = Object.keys(parsed.devices)[0] ?? "default";
|
|
258
|
-
}
|
|
259
|
-
return parsed;
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
// src/core/runtime.ts
|
|
263
|
-
var active = MikrotikConfigSchema.parse({});
|
|
264
|
-
function setConfig(cfg) {
|
|
265
|
-
active = cfg;
|
|
266
|
-
}
|
|
267
|
-
function getConfig() {
|
|
268
|
-
return active;
|
|
269
|
-
}
|
|
270
|
-
function listDevices() {
|
|
271
|
-
return { names: Object.keys(active.devices), default: active.defaultDevice };
|
|
272
|
-
}
|
|
273
|
-
function deviceKeyForLabel(name) {
|
|
274
|
-
const target = name.trim().toLowerCase();
|
|
275
|
-
for (const [key, dc] of Object.entries(active.devices)) {
|
|
276
|
-
if (dc.description && dc.description.trim().toLowerCase() === target)
|
|
277
|
-
return key;
|
|
278
|
-
}
|
|
279
|
-
return;
|
|
280
|
-
}
|
|
281
|
-
function deviceLabels() {
|
|
282
|
-
const seen = new Set;
|
|
283
|
-
const out = [];
|
|
284
|
-
for (const [key, dc] of Object.entries(active.devices)) {
|
|
285
|
-
const label = dc.description?.trim();
|
|
286
|
-
if (label && label !== key && !(label in active.devices) && !seen.has(label)) {
|
|
287
|
-
seen.add(label);
|
|
288
|
-
out.push(label);
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
return out;
|
|
292
|
-
}
|
|
293
|
-
function resolveDeviceName(name) {
|
|
294
|
-
if (name) {
|
|
295
|
-
if (name in active.devices)
|
|
296
|
-
return name;
|
|
297
|
-
const byLabel = deviceKeyForLabel(name);
|
|
298
|
-
if (byLabel)
|
|
299
|
-
return byLabel;
|
|
300
|
-
}
|
|
301
|
-
return active.defaultDevice in active.devices ? active.defaultDevice : Object.keys(active.devices)[0] ?? active.defaultDevice;
|
|
302
|
-
}
|
|
303
|
-
function deviceTarget(dc) {
|
|
304
|
-
if (!dc)
|
|
305
|
-
return "?";
|
|
306
|
-
return dc.mac ? `MAC ${dc.mac}` : `${dc.host}:${dc.port ?? 22}`;
|
|
307
|
-
}
|
|
308
|
-
function deviceDirectory() {
|
|
309
|
-
return Object.entries(active.devices).map(([key, dc]) => ({
|
|
310
|
-
key,
|
|
311
|
-
label: dc.description?.trim() || undefined,
|
|
312
|
-
target: deviceTarget(dc),
|
|
313
|
-
isDefault: key === active.defaultDevice
|
|
314
|
-
}));
|
|
315
|
-
}
|
|
316
|
-
function resolvedTarget(name) {
|
|
317
|
-
const key = resolveDeviceName(name);
|
|
318
|
-
const dc = active.devices[key];
|
|
319
|
-
return { key, label: dc?.description?.trim() || undefined, target: deviceTarget(dc) };
|
|
320
|
-
}
|
|
321
|
-
function getDevice(name) {
|
|
322
|
-
if (name && !(name in active.devices) && !deviceKeyForLabel(name)) {
|
|
323
|
-
throw new Error(`Unknown device '${name}'. Configured devices: ${Object.keys(active.devices).join(", ")}`);
|
|
324
|
-
}
|
|
325
|
-
const key = resolveDeviceName(name);
|
|
326
|
-
const dc = active.devices[key];
|
|
327
|
-
if (!dc)
|
|
328
|
-
throw new Error(`No device configuration available for '${key}'.`);
|
|
329
|
-
return dc;
|
|
330
|
-
}
|
|
9
|
+
import { Client } from "ssh2";
|
|
331
10
|
|
|
332
11
|
// src/logger.ts
|
|
333
12
|
import { stderr } from "process";
|
|
@@ -361,600 +40,943 @@ var logger = {
|
|
|
361
40
|
error: (m) => emit("error", m)
|
|
362
41
|
};
|
|
363
42
|
|
|
364
|
-
// src/
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
var TICK_INTERVAL_MS = 15;
|
|
372
|
-
var LICENSE_RE = /do you want to see the software license/i;
|
|
373
|
-
function emulateScreen(text) {
|
|
374
|
-
const clean = text.replace(ANSI_CSI, "").replace(ANSI_ESC2, "");
|
|
375
|
-
const lines = [[]];
|
|
376
|
-
let row = 0;
|
|
377
|
-
let col = 0;
|
|
378
|
-
for (const ch of clean) {
|
|
379
|
-
const code = ch.charCodeAt(0);
|
|
380
|
-
if (ch === `
|
|
381
|
-
`) {
|
|
382
|
-
row += 1;
|
|
383
|
-
if (!lines[row])
|
|
384
|
-
lines[row] = [];
|
|
385
|
-
col = 0;
|
|
386
|
-
} else if (ch === "\r") {
|
|
387
|
-
col = 0;
|
|
388
|
-
} else if (code === 8) {
|
|
389
|
-
col = Math.max(0, col - 1);
|
|
390
|
-
} else if (code >= 32 && code !== 127) {
|
|
391
|
-
const line = lines[row];
|
|
392
|
-
line[col] = ch;
|
|
393
|
-
col += 1;
|
|
394
|
-
}
|
|
395
|
-
}
|
|
396
|
-
return lines.map((line) => Array.from(line, (c) => c ?? " ").join("").replace(/\s+$/, ""));
|
|
397
|
-
}
|
|
398
|
-
function extractCommandOutput(raw, command) {
|
|
399
|
-
const lines = emulateScreen(raw);
|
|
400
|
-
let start = 1;
|
|
401
|
-
if (command !== undefined && lines.length > 0) {
|
|
402
|
-
const first = lines[0] ?? "";
|
|
403
|
-
const promptMatch = first.match(/^\[[^\]\r\n]*\][^\r\n]*?>\s?/);
|
|
404
|
-
const echoedOnFirst = promptMatch ? first.slice(promptMatch[0].length) : first;
|
|
405
|
-
let consumed = echoedOnFirst.length;
|
|
406
|
-
while (consumed < command.length && start < lines.length) {
|
|
407
|
-
consumed += (lines[start] ?? "").length;
|
|
408
|
-
start += 1;
|
|
409
|
-
}
|
|
410
|
-
}
|
|
411
|
-
const body = lines.slice(start);
|
|
412
|
-
while (body.length > 0) {
|
|
413
|
-
const last = body[body.length - 1];
|
|
414
|
-
if (last.length === 0 || ROUTEROS_PROMPT_RE.test(last)) {
|
|
415
|
-
body.pop();
|
|
416
|
-
continue;
|
|
417
|
-
}
|
|
418
|
-
break;
|
|
419
|
-
}
|
|
420
|
-
return body.join(`
|
|
421
|
-
`);
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
class MacTelnetConsole {
|
|
425
|
-
options;
|
|
426
|
-
session;
|
|
427
|
-
buffer = "";
|
|
428
|
-
ready = false;
|
|
429
|
-
closed = false;
|
|
430
|
-
closeError;
|
|
431
|
-
waiter;
|
|
432
|
-
tickTimer;
|
|
433
|
-
readyWaiters = [];
|
|
434
|
-
decoder = new TextDecoder;
|
|
435
|
-
probeTail = "";
|
|
436
|
-
constructor(options) {
|
|
437
|
-
this.options = {
|
|
438
|
-
rows: 9999,
|
|
439
|
-
cols: 512,
|
|
440
|
-
primeTimeoutMs: 30000,
|
|
441
|
-
commandTimeoutMs: 15000,
|
|
442
|
-
settleMs: 150,
|
|
443
|
-
acceptLicense: true,
|
|
444
|
-
...options
|
|
445
|
-
};
|
|
446
|
-
const createSession = options.createSession ?? ((init) => new MacTelnetSession(init));
|
|
447
|
-
this.session = createSession({
|
|
448
|
-
sink: options.sink,
|
|
449
|
-
sourceMac: options.sourceMac,
|
|
450
|
-
destinationMac: options.destinationMac,
|
|
451
|
-
username: options.username,
|
|
452
|
-
password: options.password,
|
|
453
|
-
sessionKey: options.sessionKey,
|
|
454
|
-
terminalType: "vt102",
|
|
455
|
-
terminalWidth: this.options.cols,
|
|
456
|
-
terminalHeight: this.options.rows,
|
|
457
|
-
onReady: () => this.onReady(),
|
|
458
|
-
onData: (bytes) => this.onData(bytes),
|
|
459
|
-
onClose: (error) => this.onClose(error)
|
|
460
|
-
});
|
|
461
|
-
}
|
|
462
|
-
handlePacket(bytes) {
|
|
43
|
+
// src/ssh/client.ts
|
|
44
|
+
var RUN_IDLE_TIMEOUT_MS = 60000;
|
|
45
|
+
function decodeOutput(data) {
|
|
46
|
+
if (!data || data.length === 0)
|
|
47
|
+
return "";
|
|
48
|
+
const encodings = ["utf-8", "windows-1252", "latin1"];
|
|
49
|
+
for (const encoding of encodings) {
|
|
463
50
|
try {
|
|
464
|
-
|
|
465
|
-
} catch
|
|
466
|
-
this.onClose(error instanceof Error ? error : new Error("Failed to process a MAC-Telnet datagram."));
|
|
467
|
-
}
|
|
468
|
-
}
|
|
469
|
-
async open() {
|
|
470
|
-
this.tickTimer = setInterval(() => {
|
|
471
|
-
try {
|
|
472
|
-
this.session.tick(Date.now());
|
|
473
|
-
} catch {}
|
|
474
|
-
}, TICK_INTERVAL_MS);
|
|
475
|
-
this.tickTimer.unref?.();
|
|
476
|
-
this.session.start();
|
|
477
|
-
await this.waitReady(this.options.primeTimeoutMs);
|
|
478
|
-
await this.waitFor((buffer) => this.endsWithPrompt(buffer) || LICENSE_RE.test(buffer), this.options.primeTimeoutMs, "waiting for the RouterOS console prompt");
|
|
479
|
-
if (this.options.acceptLicense && LICENSE_RE.test(this.buffer)) {
|
|
480
|
-
this.buffer = "";
|
|
481
|
-
this.session.sendInput(enc.encode("n\r"));
|
|
482
|
-
await this.waitFor((buffer) => this.endsWithPrompt(buffer), this.options.primeTimeoutMs, "waiting for the prompt after the license screen");
|
|
483
|
-
}
|
|
484
|
-
this.buffer = "";
|
|
485
|
-
this.session.sendInput(enc.encode("\r"));
|
|
486
|
-
await this.waitFor((buffer) => this.endsWithPrompt(buffer), this.options.commandTimeoutMs, "waiting for a clean prompt");
|
|
487
|
-
this.buffer = "";
|
|
488
|
-
}
|
|
489
|
-
async run(cli) {
|
|
490
|
-
this.assertOpen();
|
|
491
|
-
this.buffer = "";
|
|
492
|
-
this.session.sendInput(enc.encode(`${cli}\r`));
|
|
493
|
-
await this.waitFor((buffer) => this.endsWithPrompt(buffer), this.options.commandTimeoutMs, `running over mac-telnet: ${cli}`);
|
|
494
|
-
const raw = this.buffer;
|
|
495
|
-
return { output: extractCommandOutput(raw, cli), raw };
|
|
51
|
+
return new TextDecoder(encoding, { fatal: true }).decode(data);
|
|
52
|
+
} catch {}
|
|
496
53
|
}
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
54
|
+
return new TextDecoder("utf-8").decode(data);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
class MikroTikSSHClient {
|
|
58
|
+
client = null;
|
|
59
|
+
bastions = [];
|
|
60
|
+
opts;
|
|
61
|
+
lastError;
|
|
62
|
+
constructor(opts) {
|
|
63
|
+
this.opts = { port: 22, timeoutMs: 1e4, ...opts };
|
|
504
64
|
}
|
|
505
|
-
|
|
506
|
-
|
|
65
|
+
async connect() {
|
|
66
|
+
this.lastError = undefined;
|
|
67
|
+
try {
|
|
68
|
+
const hops = [];
|
|
69
|
+
for (let j = this.opts.jump;j; j = j.jump)
|
|
70
|
+
hops.unshift(j);
|
|
71
|
+
const sequence = [...hops, this.opts];
|
|
72
|
+
let sock;
|
|
73
|
+
for (let i = 0;i < hops.length; i++) {
|
|
74
|
+
const hop = hops[i];
|
|
75
|
+
const client = await this.openClient(hop, sock);
|
|
76
|
+
this.bastions.push(client);
|
|
77
|
+
const next = sequence[i + 1];
|
|
78
|
+
const hopTimeout = hop.timeoutMs ?? this.opts.timeoutMs ?? 1e4;
|
|
79
|
+
sock = await this.forwardOut(client, next.host, next.port ?? 22, hopTimeout);
|
|
80
|
+
}
|
|
81
|
+
this.client = await this.openClient(this.opts, sock);
|
|
82
|
+
return true;
|
|
83
|
+
} catch (e) {
|
|
84
|
+
this.lastError = e instanceof Error ? e.message : String(e);
|
|
85
|
+
logger.error(`Failed to connect to MikroTik: ${this.lastError}`);
|
|
86
|
+
this.disconnect();
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
507
89
|
}
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
90
|
+
openClient(o, sock) {
|
|
91
|
+
return new Promise((resolve, reject) => {
|
|
92
|
+
const client = new Client;
|
|
93
|
+
const cfg = {
|
|
94
|
+
host: o.host,
|
|
95
|
+
port: o.port ?? 22,
|
|
96
|
+
username: o.username,
|
|
97
|
+
readyTimeout: o.timeoutMs ?? 1e4
|
|
98
|
+
};
|
|
99
|
+
if (sock)
|
|
100
|
+
cfg.sock = sock;
|
|
101
|
+
if (o.keepAliveInterval)
|
|
102
|
+
cfg.keepaliveInterval = o.keepAliveInterval;
|
|
103
|
+
if (o.keepAliveCountMax)
|
|
104
|
+
cfg.keepaliveCountMax = o.keepAliveCountMax;
|
|
105
|
+
if (o.privateKey) {
|
|
106
|
+
cfg.privateKey = o.privateKey;
|
|
107
|
+
} else if (o.keyFilename) {
|
|
108
|
+
try {
|
|
109
|
+
cfg.privateKey = readFileSync(o.keyFilename);
|
|
110
|
+
} catch (e) {
|
|
111
|
+
reject(new Error(`could not read key file ${o.keyFilename}: ${e instanceof Error ? e.message : String(e)}`));
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (cfg.privateKey && o.keyPassphrase)
|
|
116
|
+
cfg.passphrase = o.keyPassphrase;
|
|
117
|
+
if (o.password)
|
|
118
|
+
cfg.password = o.password;
|
|
119
|
+
client.on("ready", () => resolve(client)).on("error", (err) => reject(err)).connect(cfg);
|
|
120
|
+
});
|
|
512
121
|
}
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
122
|
+
forwardOut(via, host, port, timeoutMs) {
|
|
123
|
+
return new Promise((resolve, reject) => {
|
|
124
|
+
let settled = false;
|
|
125
|
+
const enableHint = "If the jump router runs RouterOS, enable SSH TCP forwarding on it " + "(`/ip ssh set forwarding-enabled=local`, or `both`) and confirm the bastion can reach the target.";
|
|
126
|
+
const timer = setTimeout(() => {
|
|
127
|
+
if (settled)
|
|
128
|
+
return;
|
|
129
|
+
settled = true;
|
|
130
|
+
reject(new Error(`jump host did not open a tunnel to ${host}:${port} within ${Math.round(timeoutMs / 1000)}s. ${enableHint}`));
|
|
131
|
+
}, timeoutMs);
|
|
132
|
+
via.forwardOut("127.0.0.1", 0, host, port, (err, stream) => {
|
|
133
|
+
if (settled)
|
|
134
|
+
return;
|
|
135
|
+
settled = true;
|
|
136
|
+
clearTimeout(timer);
|
|
137
|
+
if (err) {
|
|
138
|
+
reject(new Error(`jump host could not open a tunnel to ${host}:${port}: ${err.message}. ${enableHint}`));
|
|
139
|
+
} else {
|
|
140
|
+
resolve(stream);
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
});
|
|
520
144
|
}
|
|
521
|
-
|
|
522
|
-
this.
|
|
523
|
-
|
|
524
|
-
if (this.tickTimer) {
|
|
525
|
-
clearInterval(this.tickTimer);
|
|
526
|
-
this.tickTimer = undefined;
|
|
527
|
-
}
|
|
528
|
-
const failure = error ?? new Error("The MAC-Telnet console session closed.");
|
|
529
|
-
for (const w of this.readyWaiters.splice(0))
|
|
530
|
-
w.reject(failure);
|
|
531
|
-
if (this.waiter) {
|
|
532
|
-
const waiter = this.waiter;
|
|
533
|
-
this.waiter = undefined;
|
|
534
|
-
clearTimeout(waiter.timeout);
|
|
535
|
-
if (waiter.settle)
|
|
536
|
-
clearTimeout(waiter.settle);
|
|
537
|
-
waiter.reject(failure);
|
|
145
|
+
run(command, opts = {}) {
|
|
146
|
+
if (!this.client) {
|
|
147
|
+
return Promise.reject(new Error("Not connected to MikroTik device"));
|
|
538
148
|
}
|
|
149
|
+
const openChannel = this.client.exec.bind(this.client);
|
|
150
|
+
return new Promise((resolve, reject) => {
|
|
151
|
+
openChannel(command, (err, stream) => {
|
|
152
|
+
if (err) {
|
|
153
|
+
reject(err);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
const stdout = [];
|
|
157
|
+
const stderrBuf = [];
|
|
158
|
+
let settled = false;
|
|
159
|
+
let timer;
|
|
160
|
+
let idleTimer;
|
|
161
|
+
const clearTimers = () => {
|
|
162
|
+
if (timer)
|
|
163
|
+
clearTimeout(timer);
|
|
164
|
+
if (idleTimer)
|
|
165
|
+
clearTimeout(idleTimer);
|
|
166
|
+
};
|
|
167
|
+
const finish = () => {
|
|
168
|
+
if (settled)
|
|
169
|
+
return;
|
|
170
|
+
settled = true;
|
|
171
|
+
clearTimers();
|
|
172
|
+
const out = decodeOutput(Buffer.concat(stdout));
|
|
173
|
+
const error = decodeOutput(Buffer.concat(stderrBuf));
|
|
174
|
+
resolve(error && !out ? error : out);
|
|
175
|
+
};
|
|
176
|
+
const fail = (e) => {
|
|
177
|
+
if (settled)
|
|
178
|
+
return;
|
|
179
|
+
settled = true;
|
|
180
|
+
clearTimers();
|
|
181
|
+
try {
|
|
182
|
+
stream.close();
|
|
183
|
+
} catch {}
|
|
184
|
+
reject(e);
|
|
185
|
+
};
|
|
186
|
+
const armIdle = () => {
|
|
187
|
+
if (idleTimer)
|
|
188
|
+
clearTimeout(idleTimer);
|
|
189
|
+
idleTimer = setTimeout(() => {
|
|
190
|
+
try {
|
|
191
|
+
stream.signal("INT");
|
|
192
|
+
} catch {}
|
|
193
|
+
fail(new Error(`MikroTik command produced no output for ${RUN_IDLE_TIMEOUT_MS / 1000}s and the SSH ` + "channel never closed \u2014 it appears wedged (often a malformed or unbalanced command). " + `Aborted to avoid hanging the connection. Command: ${command.slice(0, 120)}`));
|
|
194
|
+
}, RUN_IDLE_TIMEOUT_MS);
|
|
195
|
+
};
|
|
196
|
+
armIdle();
|
|
197
|
+
if (opts.maxMs && opts.maxMs > 0) {
|
|
198
|
+
timer = setTimeout(() => {
|
|
199
|
+
try {
|
|
200
|
+
stream.signal("INT");
|
|
201
|
+
} catch {}
|
|
202
|
+
try {
|
|
203
|
+
stream.close();
|
|
204
|
+
} catch {}
|
|
205
|
+
finish();
|
|
206
|
+
}, opts.maxMs);
|
|
207
|
+
}
|
|
208
|
+
stream.on("close", finish).on("data", (d) => {
|
|
209
|
+
stdout.push(d);
|
|
210
|
+
armIdle();
|
|
211
|
+
}).stderr.on("data", (d) => {
|
|
212
|
+
stderrBuf.push(d);
|
|
213
|
+
armIdle();
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
});
|
|
539
217
|
}
|
|
540
|
-
|
|
541
|
-
if (
|
|
542
|
-
|
|
543
|
-
}
|
|
544
|
-
if (chunk.includes(`${ESC}Z`) || chunk.includes(`${ESC}[c`)) {
|
|
545
|
-
this.session.sendInput(enc.encode(`${ESC}[?6c`));
|
|
218
|
+
uploadFile(remotePath, data) {
|
|
219
|
+
if (!this.client) {
|
|
220
|
+
return Promise.reject(new Error("Not connected to MikroTik device"));
|
|
546
221
|
}
|
|
222
|
+
const openSftp = this.client.sftp.bind(this.client);
|
|
223
|
+
return new Promise((resolve, reject) => {
|
|
224
|
+
openSftp((err, sftp) => {
|
|
225
|
+
if (err) {
|
|
226
|
+
reject(new Error(`SFTP subsystem unavailable: ${err.message}`));
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
sftp.writeFile(remotePath, data, (werr) => {
|
|
230
|
+
try {
|
|
231
|
+
sftp.end();
|
|
232
|
+
} catch {}
|
|
233
|
+
if (werr)
|
|
234
|
+
reject(new Error(`SFTP write failed: ${werr.message}`));
|
|
235
|
+
else
|
|
236
|
+
resolve();
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
});
|
|
547
240
|
}
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
}
|
|
552
|
-
waitReady(timeoutMs) {
|
|
553
|
-
if (this.ready)
|
|
554
|
-
return Promise.resolve();
|
|
555
|
-
if (this.closed) {
|
|
556
|
-
return Promise.reject(this.closeError ?? new Error("The MAC-Telnet session closed before login completed."));
|
|
241
|
+
downloadFile(remotePath) {
|
|
242
|
+
if (!this.client) {
|
|
243
|
+
return Promise.reject(new Error("Not connected to MikroTik device"));
|
|
557
244
|
}
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
},
|
|
565
|
-
reject: (error) => {
|
|
566
|
-
clearTimeout(timer);
|
|
567
|
-
reject(error);
|
|
245
|
+
const openSftp = this.client.sftp.bind(this.client);
|
|
246
|
+
return new Promise((resolve, reject) => {
|
|
247
|
+
openSftp((err, sftp) => {
|
|
248
|
+
if (err) {
|
|
249
|
+
reject(new Error(`SFTP subsystem unavailable: ${err.message}`));
|
|
250
|
+
return;
|
|
568
251
|
}
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
252
|
+
sftp.readFile(remotePath, (rerr, data) => {
|
|
253
|
+
try {
|
|
254
|
+
sftp.end();
|
|
255
|
+
} catch {}
|
|
256
|
+
if (rerr)
|
|
257
|
+
reject(new Error(`SFTP read failed: ${rerr.message}`));
|
|
258
|
+
else
|
|
259
|
+
resolve(data);
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
shell(opts = {}) {
|
|
265
|
+
if (!this.client) {
|
|
266
|
+
return Promise.reject(new Error("Not connected to MikroTik device"));
|
|
267
|
+
}
|
|
268
|
+
const openShell = this.client.shell.bind(this.client);
|
|
269
|
+
return new Promise((resolve, reject) => {
|
|
270
|
+
openShell({
|
|
271
|
+
term: opts.term ?? "dumb",
|
|
272
|
+
cols: opts.cols ?? 220,
|
|
273
|
+
rows: opts.rows ?? 50
|
|
274
|
+
}, (err, stream) => err ? reject(err) : resolve(stream));
|
|
575
275
|
});
|
|
576
276
|
}
|
|
577
|
-
|
|
578
|
-
if (this.
|
|
579
|
-
|
|
277
|
+
disconnect() {
|
|
278
|
+
if (this.client) {
|
|
279
|
+
try {
|
|
280
|
+
this.client.end();
|
|
281
|
+
} catch {}
|
|
282
|
+
this.client = null;
|
|
580
283
|
}
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
this.checkWaiter();
|
|
588
|
-
});
|
|
284
|
+
for (const b of this.bastions.reverse()) {
|
|
285
|
+
try {
|
|
286
|
+
b.end();
|
|
287
|
+
} catch {}
|
|
288
|
+
}
|
|
289
|
+
this.bastions = [];
|
|
589
290
|
}
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// src/config.ts
|
|
294
|
+
import { readFileSync as readFileSync2 } from "fs";
|
|
295
|
+
import { homedir } from "os";
|
|
296
|
+
import { join, resolve } from "path";
|
|
297
|
+
import { z } from "zod";
|
|
298
|
+
var DEFAULT_DASHBOARD_DB = join(homedir(), ".mikrotik-mcp", "events.db");
|
|
299
|
+
var DEFAULT_SNAPSHOT_DB = join(homedir(), ".mikrotik-mcp", "snapshots.db");
|
|
300
|
+
var DEFAULT_BACKUP_DIR = join(homedir(), ".mikrotik-mcp", "backups");
|
|
301
|
+
var DEFAULT_CONFIG_HISTORY_DIR = join(homedir(), ".mikrotik-mcp", "config-history");
|
|
302
|
+
var DEFAULT_CONFIG_FILE = join(homedir(), ".mikrotik-mcp", "config.json");
|
|
303
|
+
var TransportSchema = z.enum(["stdio", "sse", "streamable-http"]);
|
|
304
|
+
var McpServerSettingsSchema = z.object({
|
|
305
|
+
transport: TransportSchema.default("stdio"),
|
|
306
|
+
host: z.string().default("0.0.0.0"),
|
|
307
|
+
port: z.coerce.number().int().positive().default(8000),
|
|
308
|
+
allowedHosts: z.string().default(""),
|
|
309
|
+
allowedOrigins: z.string().default(""),
|
|
310
|
+
corsOrigins: z.string().default(""),
|
|
311
|
+
toolPageSize: z.coerce.number().int().min(0).default(0),
|
|
312
|
+
appViews: z.boolean().default(true)
|
|
313
|
+
});
|
|
314
|
+
var JumpHostSchema = z.object({
|
|
315
|
+
host: z.string(),
|
|
316
|
+
port: z.coerce.number().int().positive().default(22),
|
|
317
|
+
username: z.string().default("admin"),
|
|
318
|
+
password: z.string().optional(),
|
|
319
|
+
keyFilename: z.string().optional(),
|
|
320
|
+
privateKey: z.string().optional(),
|
|
321
|
+
keyPassphrase: z.string().optional(),
|
|
322
|
+
timeoutMs: z.coerce.number().int().positive().optional()
|
|
323
|
+
});
|
|
324
|
+
var DeviceConfigSchema = z.object({
|
|
325
|
+
host: z.string().default("127.0.0.1"),
|
|
326
|
+
username: z.string().default("admin"),
|
|
327
|
+
password: z.string().default(""),
|
|
328
|
+
port: z.coerce.number().int().positive().default(22),
|
|
329
|
+
keyFilename: z.string().optional(),
|
|
330
|
+
privateKey: z.string().optional(),
|
|
331
|
+
keyPassphrase: z.string().optional(),
|
|
332
|
+
timeoutMs: z.coerce.number().int().positive().default(1e4),
|
|
333
|
+
jumpVia: z.string().optional(),
|
|
334
|
+
jumpHost: JumpHostSchema.optional(),
|
|
335
|
+
mac: z.string().optional(),
|
|
336
|
+
sourceMac: z.string().optional(),
|
|
337
|
+
macHost: z.string().optional(),
|
|
338
|
+
macPort: z.coerce.number().int().positive().optional(),
|
|
339
|
+
description: z.string().optional()
|
|
340
|
+
});
|
|
341
|
+
var S3ConfigSchema = z.object({
|
|
342
|
+
accessKeyId: z.string().optional(),
|
|
343
|
+
secretAccessKey: z.string().optional(),
|
|
344
|
+
sessionToken: z.string().optional(),
|
|
345
|
+
region: z.string().optional(),
|
|
346
|
+
endpoint: z.string().optional(),
|
|
347
|
+
bucket: z.string().optional(),
|
|
348
|
+
prefix: z.string().default(""),
|
|
349
|
+
presignExpiresIn: z.coerce.number().int().positive().default(3600)
|
|
350
|
+
});
|
|
351
|
+
var DashboardConfigSchema = z.object({
|
|
352
|
+
enabled: z.boolean().default(false),
|
|
353
|
+
host: z.string().default("0.0.0.0"),
|
|
354
|
+
port: z.coerce.number().int().positive().default(9090),
|
|
355
|
+
dbPath: z.string().default(DEFAULT_DASHBOARD_DB),
|
|
356
|
+
maxEvents: z.coerce.number().int().positive().default(1e5),
|
|
357
|
+
captureBody: z.boolean().default(true),
|
|
358
|
+
redactInput: z.boolean().default(false),
|
|
359
|
+
maxBodyBytes: z.coerce.number().int().nonnegative().default(16384),
|
|
360
|
+
token: z.string().optional()
|
|
361
|
+
});
|
|
362
|
+
var SSHConfigSchema = z.object({
|
|
363
|
+
keepAlive: z.boolean().default(true),
|
|
364
|
+
keepAliveInterval: z.coerce.number().int().nonnegative().default(1e4),
|
|
365
|
+
idleTimeout: z.coerce.number().int().positive().default(30000)
|
|
366
|
+
});
|
|
367
|
+
var ToolFilterSchema = z.object({
|
|
368
|
+
enabledModules: z.array(z.string()).default([]),
|
|
369
|
+
disabledModules: z.array(z.string()).default([]),
|
|
370
|
+
enabledGroups: z.array(z.string()).default([]),
|
|
371
|
+
disabledGroups: z.array(z.string()).default([])
|
|
372
|
+
});
|
|
373
|
+
var MikrotikConfigSchema = z.object({
|
|
374
|
+
devices: z.record(z.string(), DeviceConfigSchema).default(() => ({ default: DeviceConfigSchema.parse({}) })),
|
|
375
|
+
defaultDevice: z.string().default("default"),
|
|
376
|
+
mcp: McpServerSettingsSchema.default(() => McpServerSettingsSchema.parse({})),
|
|
377
|
+
s3: S3ConfigSchema.optional(),
|
|
378
|
+
dashboard: DashboardConfigSchema.default(() => DashboardConfigSchema.parse({})),
|
|
379
|
+
ssh: SSHConfigSchema.default(() => SSHConfigSchema.parse({})),
|
|
380
|
+
readOnly: z.boolean().default(false),
|
|
381
|
+
tools: ToolFilterSchema.default(() => ToolFilterSchema.parse({})),
|
|
382
|
+
backupDir: z.string().optional()
|
|
383
|
+
});
|
|
384
|
+
function env(...names) {
|
|
385
|
+
for (const n of names) {
|
|
386
|
+
const v = process.env[n];
|
|
387
|
+
if (v !== undefined && v !== "")
|
|
388
|
+
return v;
|
|
389
|
+
}
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
function parseFlags(argv) {
|
|
393
|
+
const out = {};
|
|
394
|
+
for (let i = 0;i < argv.length; i++) {
|
|
395
|
+
const arg = argv[i];
|
|
396
|
+
if (!arg.startsWith("--"))
|
|
397
|
+
continue;
|
|
398
|
+
const eq = arg.indexOf("=");
|
|
399
|
+
if (eq !== -1) {
|
|
400
|
+
out[arg.slice(2, eq)] = arg.slice(eq + 1);
|
|
401
|
+
} else {
|
|
402
|
+
const key = arg.slice(2);
|
|
403
|
+
const next = argv[i + 1];
|
|
404
|
+
if (next !== undefined && !next.startsWith("--")) {
|
|
405
|
+
out[key] = next;
|
|
406
|
+
i++;
|
|
407
|
+
} else {
|
|
408
|
+
out[key] = "true";
|
|
598
409
|
}
|
|
599
|
-
return;
|
|
600
410
|
}
|
|
601
|
-
if (waiter.settle)
|
|
602
|
-
clearTimeout(waiter.settle);
|
|
603
|
-
waiter.settle = setTimeout(() => {
|
|
604
|
-
if (this.waiter !== waiter)
|
|
605
|
-
return;
|
|
606
|
-
this.waiter = undefined;
|
|
607
|
-
clearTimeout(waiter.timeout);
|
|
608
|
-
waiter.resolve();
|
|
609
|
-
}, this.options.settleMs);
|
|
610
411
|
}
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
412
|
+
return out;
|
|
413
|
+
}
|
|
414
|
+
function parseDevicesSource(raw, fromFile) {
|
|
415
|
+
let json;
|
|
416
|
+
try {
|
|
417
|
+
json = JSON.parse(fromFile ? readFileSync2(raw, "utf8") : raw);
|
|
418
|
+
} catch (e) {
|
|
419
|
+
throw new Error(`Failed to load MikroTik devices from ${fromFile ? `file ${raw}` : "MIKROTIK_DEVICES"}: ${e instanceof Error ? e.message : String(e)}`);
|
|
420
|
+
}
|
|
421
|
+
const obj = json;
|
|
422
|
+
const structured = obj.devices !== undefined;
|
|
423
|
+
const devices = obj.devices ?? obj;
|
|
424
|
+
const defaultDevice = typeof obj.defaultDevice === "string" ? obj.defaultDevice : undefined;
|
|
425
|
+
const s3 = structured && obj.s3 && typeof obj.s3 === "object" ? obj.s3 : undefined;
|
|
426
|
+
const dashboard = structured && obj.dashboard && typeof obj.dashboard === "object" ? obj.dashboard : undefined;
|
|
427
|
+
const tools = structured && obj.tools && typeof obj.tools === "object" ? obj.tools : undefined;
|
|
428
|
+
const mcp = structured && obj.mcp && typeof obj.mcp === "object" ? obj.mcp : undefined;
|
|
429
|
+
const ssh = structured && obj.ssh && typeof obj.ssh === "object" ? obj.ssh : undefined;
|
|
430
|
+
return { devices, defaultDevice, s3, dashboard, tools, mcp, ssh };
|
|
431
|
+
}
|
|
432
|
+
var configSource = { path: DEFAULT_CONFIG_FILE, fromFile: false };
|
|
433
|
+
function getConfigSource() {
|
|
434
|
+
return configSource;
|
|
435
|
+
}
|
|
436
|
+
function loadConfig(argv = process.argv.slice(2)) {
|
|
437
|
+
const flags = parseFlags(argv);
|
|
438
|
+
const pick = (flag, ...envNames) => flags[flag] ?? env(...envNames);
|
|
439
|
+
const jumpHostName = pick("jump-host", "MIKROTIK_JUMP_HOST");
|
|
440
|
+
const jumpHost = jumpHostName ? {
|
|
441
|
+
host: jumpHostName,
|
|
442
|
+
port: pick("jump-port", "MIKROTIK_JUMP_PORT"),
|
|
443
|
+
username: pick("jump-username", "MIKROTIK_JUMP_USERNAME"),
|
|
444
|
+
password: pick("jump-password", "MIKROTIK_JUMP_PASSWORD"),
|
|
445
|
+
keyFilename: pick("jump-key-filename", "MIKROTIK_JUMP_KEY_FILENAME"),
|
|
446
|
+
keyPassphrase: pick("jump-key-passphrase", "MIKROTIK_JUMP_KEY_PASSPHRASE")
|
|
447
|
+
} : undefined;
|
|
448
|
+
const single = {
|
|
449
|
+
host: pick("host", "MIKROTIK_HOST"),
|
|
450
|
+
username: pick("username", "MIKROTIK_USERNAME"),
|
|
451
|
+
password: pick("password", "MIKROTIK_PASSWORD"),
|
|
452
|
+
port: pick("port", "MIKROTIK_PORT"),
|
|
453
|
+
keyFilename: pick("key-filename", "MIKROTIK_KEY_FILENAME"),
|
|
454
|
+
privateKey: pick("private-key", "MIKROTIK_PRIVATE_KEY"),
|
|
455
|
+
keyPassphrase: pick("key-passphrase", "MIKROTIK_KEY_PASSPHRASE"),
|
|
456
|
+
timeoutMs: pick("timeout-ms", "MIKROTIK_TIMEOUT_MS"),
|
|
457
|
+
jumpHost,
|
|
458
|
+
mac: pick("mac", "MIKROTIK_MAC"),
|
|
459
|
+
sourceMac: pick("source-mac", "MIKROTIK_SOURCE_MAC"),
|
|
460
|
+
macHost: pick("mac-host", "MIKROTIK_MAC_HOST"),
|
|
461
|
+
macPort: pick("mac-port", "MIKROTIK_MAC_PORT")
|
|
462
|
+
};
|
|
463
|
+
const hasSingle = Object.values(single).some((v) => v !== undefined);
|
|
464
|
+
const devices = {};
|
|
465
|
+
let defaultDevice;
|
|
466
|
+
if (hasSingle) {
|
|
467
|
+
devices.default = single;
|
|
468
|
+
defaultDevice = "default";
|
|
469
|
+
}
|
|
470
|
+
const configFile = pick("config", "MIKROTIK_CONFIG_FILE");
|
|
471
|
+
configSource = configFile ? { path: resolve(configFile), fromFile: true } : { path: DEFAULT_CONFIG_FILE, fromFile: false };
|
|
472
|
+
const devicesInline = flags.devices ?? env("MIKROTIK_DEVICES");
|
|
473
|
+
let fileS3 = {};
|
|
474
|
+
let fileDashboard = {};
|
|
475
|
+
let fileTools;
|
|
476
|
+
let fileMcp;
|
|
477
|
+
let fileSsh;
|
|
478
|
+
if (configFile || devicesInline) {
|
|
479
|
+
const src = configFile ? parseDevicesSource(configFile, true) : parseDevicesSource(devicesInline, false);
|
|
480
|
+
for (const [name, dc] of Object.entries(src.devices))
|
|
481
|
+
devices[name] = dc;
|
|
482
|
+
if (src.defaultDevice)
|
|
483
|
+
defaultDevice = src.defaultDevice;
|
|
484
|
+
else if (!defaultDevice)
|
|
485
|
+
defaultDevice = Object.keys(src.devices)[0];
|
|
486
|
+
fileS3 = src.s3;
|
|
487
|
+
fileDashboard = src.dashboard;
|
|
488
|
+
fileTools = src.tools;
|
|
489
|
+
fileMcp = src.mcp;
|
|
490
|
+
fileSsh = src.ssh;
|
|
491
|
+
}
|
|
492
|
+
const s3 = {
|
|
493
|
+
accessKeyId: pick("s3-access-key-id", "S3_ACCESS_KEY_ID", "AWS_ACCESS_KEY_ID"),
|
|
494
|
+
secretAccessKey: pick("s3-secret-access-key", "S3_SECRET_ACCESS_KEY", "AWS_SECRET_ACCESS_KEY"),
|
|
495
|
+
sessionToken: pick("s3-session-token", "S3_SESSION_TOKEN", "AWS_SESSION_TOKEN"),
|
|
496
|
+
region: pick("s3-region", "S3_REGION", "AWS_REGION"),
|
|
497
|
+
endpoint: pick("s3-endpoint", "S3_ENDPOINT", "AWS_ENDPOINT"),
|
|
498
|
+
bucket: pick("s3-bucket", "S3_BUCKET", "AWS_BUCKET"),
|
|
499
|
+
prefix: pick("s3-prefix", "MIKROTIK_S3_PREFIX"),
|
|
500
|
+
presignExpiresIn: pick("s3-presign-expires-in", "MIKROTIK_S3_PRESIGN_EXPIRES_IN"),
|
|
501
|
+
...fileS3
|
|
502
|
+
};
|
|
503
|
+
const appViewsRaw = pick("app-views", "MIKROTIK_MCP__APP_VIEWS");
|
|
504
|
+
const appViewsEnv = appViewsRaw === undefined ? undefined : !/^(0|false|no|off)$/i.test(appViewsRaw);
|
|
505
|
+
const mcp = {
|
|
506
|
+
transport: pick("transport", "MIKROTIK_MCP__TRANSPORT", "MCP_TRANSPORT"),
|
|
507
|
+
host: pick("mcp-host", "MIKROTIK_MCP__HOST"),
|
|
508
|
+
port: pick("mcp-port", "MIKROTIK_MCP__PORT"),
|
|
509
|
+
allowedHosts: pick("mcp-allowed-hosts", "MIKROTIK_MCP__ALLOWED_HOSTS"),
|
|
510
|
+
allowedOrigins: pick("mcp-allowed-origins", "MIKROTIK_MCP__ALLOWED_ORIGINS"),
|
|
511
|
+
corsOrigins: pick("mcp-cors-origins", "MIKROTIK_MCP__CORS_ORIGINS"),
|
|
512
|
+
toolPageSize: pick("tool-page-size", "MIKROTIK_MCP__TOOL_PAGE_SIZE"),
|
|
513
|
+
appViews: appViewsEnv,
|
|
514
|
+
...fileMcp
|
|
515
|
+
};
|
|
516
|
+
const isTruthy = (v) => /^(1|true|yes|on)$/i.test(v ?? "");
|
|
517
|
+
const readOnly = isTruthy(pick("read-only", "MIKROTIK_READ_ONLY"));
|
|
518
|
+
const csv = (v) => v === undefined ? undefined : v.split(",").map((s) => s.trim()).filter(Boolean);
|
|
519
|
+
const tools = {
|
|
520
|
+
enabledModules: csv(pick("tools-enabled-modules", "MIKROTIK_TOOLS__ENABLED_MODULES")),
|
|
521
|
+
disabledModules: csv(pick("tools-disabled-modules", "MIKROTIK_TOOLS__DISABLED_MODULES")),
|
|
522
|
+
enabledGroups: csv(pick("tools-enabled-groups", "MIKROTIK_TOOLS__ENABLED_GROUPS")),
|
|
523
|
+
disabledGroups: csv(pick("tools-disabled-groups", "MIKROTIK_TOOLS__DISABLED_GROUPS")),
|
|
524
|
+
...fileTools
|
|
525
|
+
};
|
|
526
|
+
const boolOpt = (v) => v === undefined ? undefined : isTruthy(v);
|
|
527
|
+
const dashboard = {
|
|
528
|
+
enabled: boolOpt(pick("dashboard", "MIKROTIK_DASHBOARD__ENABLED", "MIKROTIK_DASHBOARD")),
|
|
529
|
+
host: pick("dashboard-host", "MIKROTIK_DASHBOARD__HOST"),
|
|
530
|
+
port: pick("dashboard-port", "MIKROTIK_DASHBOARD__PORT"),
|
|
531
|
+
dbPath: pick("dashboard-db", "MIKROTIK_DASHBOARD__DB_PATH"),
|
|
532
|
+
maxEvents: pick("dashboard-max-events", "MIKROTIK_DASHBOARD__MAX_EVENTS"),
|
|
533
|
+
captureBody: boolOpt(pick("dashboard-capture-body", "MIKROTIK_DASHBOARD__CAPTURE_BODY")),
|
|
534
|
+
redactInput: boolOpt(pick("dashboard-redact-input", "MIKROTIK_DASHBOARD__REDACT_INPUT")),
|
|
535
|
+
maxBodyBytes: pick("dashboard-max-body-bytes", "MIKROTIK_DASHBOARD__MAX_BODY_BYTES"),
|
|
536
|
+
token: pick("dashboard-token", "MIKROTIK_DASHBOARD__TOKEN"),
|
|
537
|
+
...fileDashboard
|
|
538
|
+
};
|
|
539
|
+
const ssh = {
|
|
540
|
+
keepAlive: boolOpt(pick("ssh-keep-alive", "MIKROTIK_SSH__KEEP_ALIVE")),
|
|
541
|
+
keepAliveInterval: pick("ssh-keepalive-interval", "MIKROTIK_SSH__KEEPALIVE_INTERVAL"),
|
|
542
|
+
idleTimeout: pick("ssh-idle-timeout", "MIKROTIK_SSH__IDLE_TIMEOUT"),
|
|
543
|
+
...fileSsh
|
|
544
|
+
};
|
|
545
|
+
const hasS3 = !!(s3.accessKeyId || s3.bucket || s3.endpoint);
|
|
546
|
+
const raw = {
|
|
547
|
+
devices: Object.keys(devices).length ? devices : { default: {} },
|
|
548
|
+
defaultDevice: defaultDevice ?? "default",
|
|
549
|
+
mcp,
|
|
550
|
+
dashboard,
|
|
551
|
+
readOnly,
|
|
552
|
+
tools,
|
|
553
|
+
ssh,
|
|
554
|
+
...hasS3 ? { s3 } : {}
|
|
555
|
+
};
|
|
556
|
+
const pruned = JSON.parse(JSON.stringify(raw));
|
|
557
|
+
const parsed = MikrotikConfigSchema.parse(pruned);
|
|
558
|
+
if (!parsed.devices[parsed.defaultDevice]) {
|
|
559
|
+
parsed.defaultDevice = Object.keys(parsed.devices)[0] ?? "default";
|
|
615
560
|
}
|
|
561
|
+
return parsed;
|
|
616
562
|
}
|
|
617
563
|
|
|
618
|
-
// src/
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
}
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
lastError;
|
|
635
|
-
routeHint;
|
|
636
|
-
constructor(opts) {
|
|
637
|
-
this.opts = {
|
|
638
|
-
port: MAC_TELNET_PORT,
|
|
639
|
-
timeoutMs: 1e4,
|
|
640
|
-
...opts
|
|
641
|
-
};
|
|
564
|
+
// src/core/runtime.ts
|
|
565
|
+
var active = MikrotikConfigSchema.parse({});
|
|
566
|
+
function setConfig(cfg) {
|
|
567
|
+
active = cfg;
|
|
568
|
+
}
|
|
569
|
+
function getConfig() {
|
|
570
|
+
return active;
|
|
571
|
+
}
|
|
572
|
+
function listDevices() {
|
|
573
|
+
return { names: Object.keys(active.devices), default: active.defaultDevice };
|
|
574
|
+
}
|
|
575
|
+
function deviceKeyForLabel(name) {
|
|
576
|
+
const target = name.trim().toLowerCase();
|
|
577
|
+
for (const [key, dc] of Object.entries(active.devices)) {
|
|
578
|
+
if (dc.description && dc.description.trim().toLowerCase() === target)
|
|
579
|
+
return key;
|
|
642
580
|
}
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
logger.info(`[mac-telnet] discovery candidates: ${candidatesText}`);
|
|
654
|
-
logger.info(`[mac-telnet] resolving route to ${this.opts.mac}\u2026`);
|
|
655
|
-
const route = await resolveMacTelnetRoute({
|
|
656
|
-
destinationMac,
|
|
657
|
-
host,
|
|
658
|
-
port: this.opts.port,
|
|
659
|
-
timeoutMs: this.opts.timeoutMs,
|
|
660
|
-
explicitSourceMac
|
|
661
|
-
});
|
|
662
|
-
const discoveryFailed = askedDiscovery && route.host === DEFAULT_MAC_TELNET_BROADCAST;
|
|
663
|
-
this.routeHint = discoveryFailed ? "no local interface got a reply from the device during discovery \u2014 MAC-Telnet is Layer-2 " + "only, so the machine running this MCP server MUST be on the SAME Ethernet/Wi-Fi segment as " + "the device (it cannot cross any router or the internet). Likely: this host is not on that " + "segment, mac-server is disabled on the device's facing interface (`/tool mac-server`), or a " + "host firewall is dropping the UDP 20561 reply. " + `Interfaces tried: ${candidatesText}. ` + "To pin it, set the device's `macHost` to the LAN's directed broadcast (e.g. 192.168.88.255) " + "and `sourceMac` to the real MAC of the interface on that LAN." : undefined;
|
|
664
|
-
logger.info(`[mac-telnet] route: source ${formatMac(route.sourceMac)} \u2192 ${route.host} ` + `(${explicitSourceMac ? "explicit" : discoveryFailed ? "DISCOVERY FAILED \u2014 limited broadcast" : "discovered"})`);
|
|
665
|
-
const transport = createUdpMacTelnetTransport({
|
|
666
|
-
host: route.host,
|
|
667
|
-
port: this.opts.port,
|
|
668
|
-
broadcast: isBroadcastHost(route.host)
|
|
669
|
-
});
|
|
670
|
-
this.transport = transport;
|
|
671
|
-
await transport.ready();
|
|
672
|
-
logger.info(`[mac-telnet] socket ready; starting login (this can take ~10\u201330s)\u2026`);
|
|
673
|
-
const console = new MacTelnetConsole({
|
|
674
|
-
sink: transport,
|
|
675
|
-
sourceMac: route.sourceMac,
|
|
676
|
-
destinationMac,
|
|
677
|
-
username: this.opts.username,
|
|
678
|
-
password: this.opts.password ?? "",
|
|
679
|
-
primeTimeoutMs: Math.max(this.opts.timeoutMs, 30000),
|
|
680
|
-
commandTimeoutMs: Math.max(this.opts.timeoutMs, 15000)
|
|
681
|
-
});
|
|
682
|
-
this.console = console;
|
|
683
|
-
transport.onMessage((bytes) => console.handlePacket(bytes));
|
|
684
|
-
await console.open();
|
|
685
|
-
return true;
|
|
686
|
-
} catch (e) {
|
|
687
|
-
const base = e instanceof Error ? e.message : String(e);
|
|
688
|
-
this.lastError = this.routeHint ? `${base} (${this.routeHint})` : base;
|
|
689
|
-
logger.error(`Failed to connect to MikroTik over MAC-Telnet: ${this.lastError}`);
|
|
690
|
-
this.disconnect();
|
|
691
|
-
return false;
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
function deviceLabels() {
|
|
584
|
+
const seen = new Set;
|
|
585
|
+
const out = [];
|
|
586
|
+
for (const [key, dc] of Object.entries(active.devices)) {
|
|
587
|
+
const label = dc.description?.trim();
|
|
588
|
+
if (label && label !== key && !(label in active.devices) && !seen.has(label)) {
|
|
589
|
+
seen.add(label);
|
|
590
|
+
out.push(label);
|
|
692
591
|
}
|
|
693
592
|
}
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
593
|
+
return out;
|
|
594
|
+
}
|
|
595
|
+
function resolveDeviceName(name) {
|
|
596
|
+
if (name) {
|
|
597
|
+
if (name in active.devices)
|
|
598
|
+
return name;
|
|
599
|
+
const byLabel = deviceKeyForLabel(name);
|
|
600
|
+
if (byLabel)
|
|
601
|
+
return byLabel;
|
|
700
602
|
}
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
603
|
+
return active.defaultDevice in active.devices ? active.defaultDevice : Object.keys(active.devices)[0] ?? active.defaultDevice;
|
|
604
|
+
}
|
|
605
|
+
function deviceTarget(dc) {
|
|
606
|
+
if (!dc)
|
|
607
|
+
return "?";
|
|
608
|
+
return dc.mac ? `MAC ${dc.mac}` : `${dc.host}:${dc.port ?? 22}`;
|
|
609
|
+
}
|
|
610
|
+
function deviceDirectory() {
|
|
611
|
+
return Object.entries(active.devices).map(([key, dc]) => ({
|
|
612
|
+
key,
|
|
613
|
+
label: dc.description?.trim() || undefined,
|
|
614
|
+
target: deviceTarget(dc),
|
|
615
|
+
isDefault: key === active.defaultDevice
|
|
616
|
+
}));
|
|
617
|
+
}
|
|
618
|
+
function resolvedTarget(name) {
|
|
619
|
+
const key = resolveDeviceName(name);
|
|
620
|
+
const dc = active.devices[key];
|
|
621
|
+
return { key, label: dc?.description?.trim() || undefined, target: deviceTarget(dc) };
|
|
622
|
+
}
|
|
623
|
+
function getDevice(name) {
|
|
624
|
+
if (name && !(name in active.devices) && !deviceKeyForLabel(name)) {
|
|
625
|
+
throw new Error(`Unknown device '${name}'. Configured devices: ${Object.keys(active.devices).join(", ")}`);
|
|
710
626
|
}
|
|
627
|
+
const key = resolveDeviceName(name);
|
|
628
|
+
const dc = active.devices[key];
|
|
629
|
+
if (!dc)
|
|
630
|
+
throw new Error(`No device configuration available for '${key}'.`);
|
|
631
|
+
return dc;
|
|
711
632
|
}
|
|
712
633
|
|
|
713
|
-
// src/
|
|
714
|
-
import {
|
|
715
|
-
|
|
716
|
-
var
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
634
|
+
// src/mac-telnet/console.ts
|
|
635
|
+
import { MacTelnetSession } from "@tikoci/centrs/protocols";
|
|
636
|
+
var ESC = "\x1B";
|
|
637
|
+
var enc = new TextEncoder;
|
|
638
|
+
var ANSI_CSI = new RegExp(`${ESC}\\[[0-9;?]*[ -/]*[@-~]`, "g");
|
|
639
|
+
var ANSI_ESC2 = new RegExp(`${ESC}[@-_]`, "g");
|
|
640
|
+
var ROUTEROS_PROMPT_RE = /\[[^\]@\r\n]+@[^\]\r\n]*\][^\r\n]*>\s*$/;
|
|
641
|
+
var TICK_INTERVAL_MS = 15;
|
|
642
|
+
var LICENSE_RE = /do you want to see the software license/i;
|
|
643
|
+
function emulateScreen(text) {
|
|
644
|
+
const clean = text.replace(ANSI_CSI, "").replace(ANSI_ESC2, "");
|
|
645
|
+
const lines = [[]];
|
|
646
|
+
let row = 0;
|
|
647
|
+
let col = 0;
|
|
648
|
+
for (const ch of clean) {
|
|
649
|
+
const code = ch.charCodeAt(0);
|
|
650
|
+
if (ch === `
|
|
651
|
+
`) {
|
|
652
|
+
row += 1;
|
|
653
|
+
if (!lines[row])
|
|
654
|
+
lines[row] = [];
|
|
655
|
+
col = 0;
|
|
656
|
+
} else if (ch === "\r") {
|
|
657
|
+
col = 0;
|
|
658
|
+
} else if (code === 8) {
|
|
659
|
+
col = Math.max(0, col - 1);
|
|
660
|
+
} else if (code >= 32 && code !== 127) {
|
|
661
|
+
const line = lines[row];
|
|
662
|
+
line[col] = ch;
|
|
663
|
+
col += 1;
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
return lines.map((line) => Array.from(line, (c) => c ?? " ").join("").replace(/\s+$/, ""));
|
|
667
|
+
}
|
|
668
|
+
function extractCommandOutput(raw, command) {
|
|
669
|
+
const lines = emulateScreen(raw);
|
|
670
|
+
let start = 1;
|
|
671
|
+
if (command !== undefined && lines.length > 0) {
|
|
672
|
+
const first = lines[0] ?? "";
|
|
673
|
+
const promptMatch = first.match(/^\[[^\]\r\n]*\][^\r\n]*?>\s?/);
|
|
674
|
+
const echoedOnFirst = promptMatch ? first.slice(promptMatch[0].length) : first;
|
|
675
|
+
let consumed = echoedOnFirst.length;
|
|
676
|
+
while (consumed < command.length && start < lines.length) {
|
|
677
|
+
consumed += (lines[start] ?? "").length;
|
|
678
|
+
start += 1;
|
|
679
|
+
}
|
|
725
680
|
}
|
|
726
|
-
|
|
681
|
+
const body = lines.slice(start);
|
|
682
|
+
while (body.length > 0) {
|
|
683
|
+
const last = body[body.length - 1];
|
|
684
|
+
if (last.length === 0 || ROUTEROS_PROMPT_RE.test(last)) {
|
|
685
|
+
body.pop();
|
|
686
|
+
continue;
|
|
687
|
+
}
|
|
688
|
+
break;
|
|
689
|
+
}
|
|
690
|
+
return body.join(`
|
|
691
|
+
`);
|
|
727
692
|
}
|
|
728
693
|
|
|
729
|
-
class
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
694
|
+
class MacTelnetConsole {
|
|
695
|
+
options;
|
|
696
|
+
session;
|
|
697
|
+
buffer = "";
|
|
698
|
+
ready = false;
|
|
699
|
+
closed = false;
|
|
700
|
+
closeError;
|
|
701
|
+
waiter;
|
|
702
|
+
tickTimer;
|
|
703
|
+
readyWaiters = [];
|
|
704
|
+
decoder = new TextDecoder;
|
|
705
|
+
probeTail = "";
|
|
706
|
+
constructor(options) {
|
|
707
|
+
this.options = {
|
|
708
|
+
rows: 9999,
|
|
709
|
+
cols: 512,
|
|
710
|
+
primeTimeoutMs: 30000,
|
|
711
|
+
commandTimeoutMs: 15000,
|
|
712
|
+
settleMs: 150,
|
|
713
|
+
acceptLicense: true,
|
|
714
|
+
...options
|
|
715
|
+
};
|
|
716
|
+
const createSession = options.createSession ?? ((init) => new MacTelnetSession(init));
|
|
717
|
+
this.session = createSession({
|
|
718
|
+
sink: options.sink,
|
|
719
|
+
sourceMac: options.sourceMac,
|
|
720
|
+
destinationMac: options.destinationMac,
|
|
721
|
+
username: options.username,
|
|
722
|
+
password: options.password,
|
|
723
|
+
sessionKey: options.sessionKey,
|
|
724
|
+
terminalType: "vt102",
|
|
725
|
+
terminalWidth: this.options.cols,
|
|
726
|
+
terminalHeight: this.options.rows,
|
|
727
|
+
onReady: () => this.onReady(),
|
|
728
|
+
onData: (bytes) => this.onData(bytes),
|
|
729
|
+
onClose: (error) => this.onClose(error)
|
|
730
|
+
});
|
|
736
731
|
}
|
|
737
|
-
|
|
738
|
-
this.lastError = undefined;
|
|
732
|
+
handlePacket(bytes) {
|
|
739
733
|
try {
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
const sequence = [...hops, this.opts];
|
|
744
|
-
let sock;
|
|
745
|
-
for (let i = 0;i < hops.length; i++) {
|
|
746
|
-
const hop = hops[i];
|
|
747
|
-
const client = await this.openClient(hop, sock);
|
|
748
|
-
this.bastions.push(client);
|
|
749
|
-
const next = sequence[i + 1];
|
|
750
|
-
const hopTimeout = hop.timeoutMs ?? this.opts.timeoutMs ?? 1e4;
|
|
751
|
-
sock = await this.forwardOut(client, next.host, next.port ?? 22, hopTimeout);
|
|
752
|
-
}
|
|
753
|
-
this.client = await this.openClient(this.opts, sock);
|
|
754
|
-
return true;
|
|
755
|
-
} catch (e) {
|
|
756
|
-
this.lastError = e instanceof Error ? e.message : String(e);
|
|
757
|
-
logger.error(`Failed to connect to MikroTik: ${this.lastError}`);
|
|
758
|
-
this.disconnect();
|
|
759
|
-
return false;
|
|
734
|
+
this.session.handlePacket(bytes);
|
|
735
|
+
} catch (error) {
|
|
736
|
+
this.onClose(error instanceof Error ? error : new Error("Failed to process a MAC-Telnet datagram."));
|
|
760
737
|
}
|
|
761
738
|
}
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
}
|
|
782
|
-
}
|
|
783
|
-
if (cfg.privateKey && o.keyPassphrase)
|
|
784
|
-
cfg.passphrase = o.keyPassphrase;
|
|
785
|
-
if (o.password)
|
|
786
|
-
cfg.password = o.password;
|
|
787
|
-
client.on("ready", () => resolve2(client)).on("error", (err) => reject(err)).connect(cfg);
|
|
788
|
-
});
|
|
739
|
+
async open() {
|
|
740
|
+
this.tickTimer = setInterval(() => {
|
|
741
|
+
try {
|
|
742
|
+
this.session.tick(Date.now());
|
|
743
|
+
} catch {}
|
|
744
|
+
}, TICK_INTERVAL_MS);
|
|
745
|
+
this.tickTimer.unref?.();
|
|
746
|
+
this.session.start();
|
|
747
|
+
await this.waitReady(this.options.primeTimeoutMs);
|
|
748
|
+
await this.waitFor((buffer) => this.endsWithPrompt(buffer) || LICENSE_RE.test(buffer), this.options.primeTimeoutMs, "waiting for the RouterOS console prompt");
|
|
749
|
+
if (this.options.acceptLicense && LICENSE_RE.test(this.buffer)) {
|
|
750
|
+
this.buffer = "";
|
|
751
|
+
this.session.sendInput(enc.encode("n\r"));
|
|
752
|
+
await this.waitFor((buffer) => this.endsWithPrompt(buffer), this.options.primeTimeoutMs, "waiting for the prompt after the license screen");
|
|
753
|
+
}
|
|
754
|
+
this.buffer = "";
|
|
755
|
+
this.session.sendInput(enc.encode("\r"));
|
|
756
|
+
await this.waitFor((buffer) => this.endsWithPrompt(buffer), this.options.commandTimeoutMs, "waiting for a clean prompt");
|
|
757
|
+
this.buffer = "";
|
|
789
758
|
}
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
settled = true;
|
|
798
|
-
reject(new Error(`jump host did not open a tunnel to ${host}:${port} within ${Math.round(timeoutMs / 1000)}s. ${enableHint}`));
|
|
799
|
-
}, timeoutMs);
|
|
800
|
-
via.forwardOut("127.0.0.1", 0, host, port, (err, stream) => {
|
|
801
|
-
if (settled)
|
|
802
|
-
return;
|
|
803
|
-
settled = true;
|
|
804
|
-
clearTimeout(timer);
|
|
805
|
-
if (err) {
|
|
806
|
-
reject(new Error(`jump host could not open a tunnel to ${host}:${port}: ${err.message}. ${enableHint}`));
|
|
807
|
-
} else {
|
|
808
|
-
resolve2(stream);
|
|
809
|
-
}
|
|
810
|
-
});
|
|
811
|
-
});
|
|
759
|
+
async run(cli) {
|
|
760
|
+
this.assertOpen();
|
|
761
|
+
this.buffer = "";
|
|
762
|
+
this.session.sendInput(enc.encode(`${cli}\r`));
|
|
763
|
+
await this.waitFor((buffer) => this.endsWithPrompt(buffer), this.options.commandTimeoutMs, `running over mac-telnet: ${cli}`);
|
|
764
|
+
const raw = this.buffer;
|
|
765
|
+
return { output: extractCommandOutput(raw, cli), raw };
|
|
812
766
|
}
|
|
813
|
-
|
|
814
|
-
if (
|
|
815
|
-
|
|
767
|
+
close() {
|
|
768
|
+
if (this.tickTimer) {
|
|
769
|
+
clearInterval(this.tickTimer);
|
|
770
|
+
this.tickTimer = undefined;
|
|
771
|
+
}
|
|
772
|
+
if (!this.closed)
|
|
773
|
+
this.session.end();
|
|
774
|
+
}
|
|
775
|
+
get isReady() {
|
|
776
|
+
return this.ready && !this.closed;
|
|
777
|
+
}
|
|
778
|
+
onReady() {
|
|
779
|
+
this.ready = true;
|
|
780
|
+
for (const w of this.readyWaiters.splice(0))
|
|
781
|
+
w.resolve();
|
|
782
|
+
}
|
|
783
|
+
onData(bytes) {
|
|
784
|
+
const chunk = this.decoder.decode(bytes, { stream: true });
|
|
785
|
+
const combined = `${this.probeTail}${chunk}`;
|
|
786
|
+
this.answerSizeProbe(combined);
|
|
787
|
+
this.probeTail = combined.slice(-3);
|
|
788
|
+
this.buffer += chunk;
|
|
789
|
+
this.checkWaiter();
|
|
790
|
+
}
|
|
791
|
+
onClose(error) {
|
|
792
|
+
this.closed = true;
|
|
793
|
+
this.closeError = error;
|
|
794
|
+
if (this.tickTimer) {
|
|
795
|
+
clearInterval(this.tickTimer);
|
|
796
|
+
this.tickTimer = undefined;
|
|
797
|
+
}
|
|
798
|
+
const failure = error ?? new Error("The MAC-Telnet console session closed.");
|
|
799
|
+
for (const w of this.readyWaiters.splice(0))
|
|
800
|
+
w.reject(failure);
|
|
801
|
+
if (this.waiter) {
|
|
802
|
+
const waiter = this.waiter;
|
|
803
|
+
this.waiter = undefined;
|
|
804
|
+
clearTimeout(waiter.timeout);
|
|
805
|
+
if (waiter.settle)
|
|
806
|
+
clearTimeout(waiter.settle);
|
|
807
|
+
waiter.reject(failure);
|
|
816
808
|
}
|
|
817
|
-
const openChannel = this.client.exec.bind(this.client);
|
|
818
|
-
return new Promise((resolve2, reject) => {
|
|
819
|
-
openChannel(command, (err, stream) => {
|
|
820
|
-
if (err) {
|
|
821
|
-
reject(err);
|
|
822
|
-
return;
|
|
823
|
-
}
|
|
824
|
-
const stdout = [];
|
|
825
|
-
const stderrBuf = [];
|
|
826
|
-
let settled = false;
|
|
827
|
-
let timer;
|
|
828
|
-
let idleTimer;
|
|
829
|
-
const clearTimers = () => {
|
|
830
|
-
if (timer)
|
|
831
|
-
clearTimeout(timer);
|
|
832
|
-
if (idleTimer)
|
|
833
|
-
clearTimeout(idleTimer);
|
|
834
|
-
};
|
|
835
|
-
const finish = () => {
|
|
836
|
-
if (settled)
|
|
837
|
-
return;
|
|
838
|
-
settled = true;
|
|
839
|
-
clearTimers();
|
|
840
|
-
const out = decodeOutput(Buffer.concat(stdout));
|
|
841
|
-
const error = decodeOutput(Buffer.concat(stderrBuf));
|
|
842
|
-
resolve2(error && !out ? error : out);
|
|
843
|
-
};
|
|
844
|
-
const fail = (e) => {
|
|
845
|
-
if (settled)
|
|
846
|
-
return;
|
|
847
|
-
settled = true;
|
|
848
|
-
clearTimers();
|
|
849
|
-
try {
|
|
850
|
-
stream.close();
|
|
851
|
-
} catch {}
|
|
852
|
-
reject(e);
|
|
853
|
-
};
|
|
854
|
-
const armIdle = () => {
|
|
855
|
-
if (idleTimer)
|
|
856
|
-
clearTimeout(idleTimer);
|
|
857
|
-
idleTimer = setTimeout(() => {
|
|
858
|
-
try {
|
|
859
|
-
stream.signal("INT");
|
|
860
|
-
} catch {}
|
|
861
|
-
fail(new Error(`MikroTik command produced no output for ${RUN_IDLE_TIMEOUT_MS / 1000}s and the SSH ` + "channel never closed \u2014 it appears wedged (often a malformed or unbalanced command). " + `Aborted to avoid hanging the connection. Command: ${command.slice(0, 120)}`));
|
|
862
|
-
}, RUN_IDLE_TIMEOUT_MS);
|
|
863
|
-
};
|
|
864
|
-
armIdle();
|
|
865
|
-
if (opts.maxMs && opts.maxMs > 0) {
|
|
866
|
-
timer = setTimeout(() => {
|
|
867
|
-
try {
|
|
868
|
-
stream.signal("INT");
|
|
869
|
-
} catch {}
|
|
870
|
-
try {
|
|
871
|
-
stream.close();
|
|
872
|
-
} catch {}
|
|
873
|
-
finish();
|
|
874
|
-
}, opts.maxMs);
|
|
875
|
-
}
|
|
876
|
-
stream.on("close", finish).on("data", (d) => {
|
|
877
|
-
stdout.push(d);
|
|
878
|
-
armIdle();
|
|
879
|
-
}).stderr.on("data", (d) => {
|
|
880
|
-
stderrBuf.push(d);
|
|
881
|
-
armIdle();
|
|
882
|
-
});
|
|
883
|
-
});
|
|
884
|
-
});
|
|
885
809
|
}
|
|
886
|
-
|
|
887
|
-
if (
|
|
888
|
-
|
|
810
|
+
answerSizeProbe(chunk) {
|
|
811
|
+
if (chunk.includes(`${ESC}[6n`)) {
|
|
812
|
+
this.session.sendInput(enc.encode(`${ESC}[${this.options.rows};${this.options.cols}R`));
|
|
813
|
+
}
|
|
814
|
+
if (chunk.includes(`${ESC}Z`) || chunk.includes(`${ESC}[c`)) {
|
|
815
|
+
this.session.sendInput(enc.encode(`${ESC}[?6c`));
|
|
889
816
|
}
|
|
890
|
-
const openSftp = this.client.sftp.bind(this.client);
|
|
891
|
-
return new Promise((resolve2, reject) => {
|
|
892
|
-
openSftp((err, sftp) => {
|
|
893
|
-
if (err) {
|
|
894
|
-
reject(new Error(`SFTP subsystem unavailable: ${err.message}`));
|
|
895
|
-
return;
|
|
896
|
-
}
|
|
897
|
-
sftp.writeFile(remotePath, data, (werr) => {
|
|
898
|
-
try {
|
|
899
|
-
sftp.end();
|
|
900
|
-
} catch {}
|
|
901
|
-
if (werr)
|
|
902
|
-
reject(new Error(`SFTP write failed: ${werr.message}`));
|
|
903
|
-
else
|
|
904
|
-
resolve2();
|
|
905
|
-
});
|
|
906
|
-
});
|
|
907
|
-
});
|
|
908
817
|
}
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
818
|
+
endsWithPrompt(buffer) {
|
|
819
|
+
const lines = emulateScreen(buffer).filter((line) => line.length > 0);
|
|
820
|
+
return ROUTEROS_PROMPT_RE.test(lines[lines.length - 1] ?? "");
|
|
821
|
+
}
|
|
822
|
+
waitReady(timeoutMs) {
|
|
823
|
+
if (this.ready)
|
|
824
|
+
return Promise.resolve();
|
|
825
|
+
if (this.closed) {
|
|
826
|
+
return Promise.reject(this.closeError ?? new Error("The MAC-Telnet session closed before login completed."));
|
|
912
827
|
}
|
|
913
|
-
const openSftp = this.client.sftp.bind(this.client);
|
|
914
828
|
return new Promise((resolve2, reject) => {
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
829
|
+
let timer;
|
|
830
|
+
const entry = {
|
|
831
|
+
resolve: () => {
|
|
832
|
+
clearTimeout(timer);
|
|
833
|
+
resolve2();
|
|
834
|
+
},
|
|
835
|
+
reject: (error) => {
|
|
836
|
+
clearTimeout(timer);
|
|
837
|
+
reject(error);
|
|
919
838
|
}
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
else
|
|
927
|
-
resolve2(data);
|
|
928
|
-
});
|
|
929
|
-
});
|
|
839
|
+
};
|
|
840
|
+
timer = setTimeout(() => {
|
|
841
|
+
this.readyWaiters = this.readyWaiters.filter((w) => w !== entry);
|
|
842
|
+
reject(new Error("MAC-Telnet login did not complete \u2014 no console response from the device. " + "Confirm the device is reachable over mac-telnet (mac-server interface list) and the credentials are correct."));
|
|
843
|
+
}, timeoutMs);
|
|
844
|
+
this.readyWaiters.push(entry);
|
|
930
845
|
});
|
|
931
846
|
}
|
|
932
|
-
|
|
933
|
-
if (
|
|
934
|
-
return Promise.reject(new Error(
|
|
847
|
+
waitFor(predicate, timeoutMs, label) {
|
|
848
|
+
if (this.closed) {
|
|
849
|
+
return Promise.reject(this.closeError ?? new Error(`MAC-Telnet session closed while ${label}.`));
|
|
935
850
|
}
|
|
936
|
-
const openShell = this.client.shell.bind(this.client);
|
|
937
851
|
return new Promise((resolve2, reject) => {
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
852
|
+
const timeout = setTimeout(() => {
|
|
853
|
+
this.waiter = undefined;
|
|
854
|
+
reject(new Error(`Timed out ${label}. The RouterOS console did not return a prompt in time; ` + "raise the timeout or confirm the device is responsive over mac-telnet."));
|
|
855
|
+
}, timeoutMs);
|
|
856
|
+
this.waiter = { predicate, resolve: resolve2, reject, timeout };
|
|
857
|
+
this.checkWaiter();
|
|
943
858
|
});
|
|
944
859
|
}
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
860
|
+
checkWaiter() {
|
|
861
|
+
const waiter = this.waiter;
|
|
862
|
+
if (!waiter)
|
|
863
|
+
return;
|
|
864
|
+
if (!waiter.predicate(this.buffer)) {
|
|
865
|
+
if (waiter.settle) {
|
|
866
|
+
clearTimeout(waiter.settle);
|
|
867
|
+
waiter.settle = undefined;
|
|
868
|
+
}
|
|
869
|
+
return;
|
|
951
870
|
}
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
871
|
+
if (waiter.settle)
|
|
872
|
+
clearTimeout(waiter.settle);
|
|
873
|
+
waiter.settle = setTimeout(() => {
|
|
874
|
+
if (this.waiter !== waiter)
|
|
875
|
+
return;
|
|
876
|
+
this.waiter = undefined;
|
|
877
|
+
clearTimeout(waiter.timeout);
|
|
878
|
+
waiter.resolve();
|
|
879
|
+
}, this.options.settleMs);
|
|
880
|
+
}
|
|
881
|
+
assertOpen() {
|
|
882
|
+
if (!this.ready || this.closed) {
|
|
883
|
+
throw new Error("The MAC-Telnet console is not open. Call open() and await it before running commands.");
|
|
956
884
|
}
|
|
957
|
-
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
// src/mac-telnet/client.ts
|
|
889
|
+
import {
|
|
890
|
+
DEFAULT_MAC_TELNET_BROADCAST,
|
|
891
|
+
isBroadcastHost,
|
|
892
|
+
MAC_TELNET_PORT,
|
|
893
|
+
createUdpMacTelnetTransport,
|
|
894
|
+
formatMac,
|
|
895
|
+
listBroadcastInterfaces,
|
|
896
|
+
parseMac,
|
|
897
|
+
resolveMacTelnetRoute
|
|
898
|
+
} from "@tikoci/centrs/protocols";
|
|
899
|
+
|
|
900
|
+
class MikroTikMacTelnetClient {
|
|
901
|
+
transport = null;
|
|
902
|
+
console = null;
|
|
903
|
+
opts;
|
|
904
|
+
lastError;
|
|
905
|
+
routeHint;
|
|
906
|
+
constructor(opts) {
|
|
907
|
+
this.opts = {
|
|
908
|
+
port: MAC_TELNET_PORT,
|
|
909
|
+
timeoutMs: 1e4,
|
|
910
|
+
...opts
|
|
911
|
+
};
|
|
912
|
+
}
|
|
913
|
+
async connect() {
|
|
914
|
+
this.lastError = undefined;
|
|
915
|
+
try {
|
|
916
|
+
const destinationMac = parseMac(this.opts.mac);
|
|
917
|
+
const explicitSourceMac = this.opts.sourceMac ? parseMac(this.opts.sourceMac) : undefined;
|
|
918
|
+
const host = this.opts.host ?? DEFAULT_MAC_TELNET_BROADCAST;
|
|
919
|
+
const askedDiscovery = host === DEFAULT_MAC_TELNET_BROADCAST && !explicitSourceMac;
|
|
920
|
+
const candidates = askedDiscovery ? listBroadcastInterfaces() : [];
|
|
921
|
+
const candidatesText = candidates.length ? candidates.map((i) => `${i.name}(${formatMac(i.mac)}\u2192${i.broadcast})`).join(", ") : "NONE \u2014 no usable non-internal IPv4 interface found (is this host on the device's LAN?)";
|
|
922
|
+
if (askedDiscovery)
|
|
923
|
+
logger.info(`[mac-telnet] discovery candidates: ${candidatesText}`);
|
|
924
|
+
logger.info(`[mac-telnet] resolving route to ${this.opts.mac}\u2026`);
|
|
925
|
+
const route = await resolveMacTelnetRoute({
|
|
926
|
+
destinationMac,
|
|
927
|
+
host,
|
|
928
|
+
port: this.opts.port,
|
|
929
|
+
timeoutMs: this.opts.timeoutMs,
|
|
930
|
+
explicitSourceMac
|
|
931
|
+
});
|
|
932
|
+
const discoveryFailed = askedDiscovery && route.host === DEFAULT_MAC_TELNET_BROADCAST;
|
|
933
|
+
this.routeHint = discoveryFailed ? "no local interface got a reply from the device during discovery \u2014 MAC-Telnet is Layer-2 " + "only, so the machine running this MCP server MUST be on the SAME Ethernet/Wi-Fi segment as " + "the device (it cannot cross any router or the internet). Likely: this host is not on that " + "segment, mac-server is disabled on the device's facing interface (`/tool mac-server`), or a " + "host firewall is dropping the UDP 20561 reply. " + `Interfaces tried: ${candidatesText}. ` + "To pin it, set the device's `macHost` to the LAN's directed broadcast (e.g. 192.168.88.255) " + "and `sourceMac` to the real MAC of the interface on that LAN." : undefined;
|
|
934
|
+
logger.info(`[mac-telnet] route: source ${formatMac(route.sourceMac)} \u2192 ${route.host} ` + `(${explicitSourceMac ? "explicit" : discoveryFailed ? "DISCOVERY FAILED \u2014 limited broadcast" : "discovered"})`);
|
|
935
|
+
const transport = createUdpMacTelnetTransport({
|
|
936
|
+
host: route.host,
|
|
937
|
+
port: this.opts.port,
|
|
938
|
+
broadcast: isBroadcastHost(route.host)
|
|
939
|
+
});
|
|
940
|
+
this.transport = transport;
|
|
941
|
+
await transport.ready();
|
|
942
|
+
logger.info(`[mac-telnet] socket ready; starting login (this can take ~10\u201330s)\u2026`);
|
|
943
|
+
const console = new MacTelnetConsole({
|
|
944
|
+
sink: transport,
|
|
945
|
+
sourceMac: route.sourceMac,
|
|
946
|
+
destinationMac,
|
|
947
|
+
username: this.opts.username,
|
|
948
|
+
password: this.opts.password ?? "",
|
|
949
|
+
primeTimeoutMs: Math.max(this.opts.timeoutMs, 30000),
|
|
950
|
+
commandTimeoutMs: Math.max(this.opts.timeoutMs, 15000)
|
|
951
|
+
});
|
|
952
|
+
this.console = console;
|
|
953
|
+
transport.onMessage((bytes) => console.handlePacket(bytes));
|
|
954
|
+
await console.open();
|
|
955
|
+
return true;
|
|
956
|
+
} catch (e) {
|
|
957
|
+
const base = e instanceof Error ? e.message : String(e);
|
|
958
|
+
this.lastError = this.routeHint ? `${base} (${this.routeHint})` : base;
|
|
959
|
+
logger.error(`Failed to connect to MikroTik over MAC-Telnet: ${this.lastError}`);
|
|
960
|
+
this.disconnect();
|
|
961
|
+
return false;
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
async run(command, _opts = {}) {
|
|
965
|
+
if (!this.console || !this.console.isReady) {
|
|
966
|
+
throw new Error("Not connected to MikroTik device (MAC-Telnet)");
|
|
967
|
+
}
|
|
968
|
+
const { output } = await this.console.run(command);
|
|
969
|
+
return output;
|
|
970
|
+
}
|
|
971
|
+
disconnect() {
|
|
972
|
+
try {
|
|
973
|
+
this.console?.close();
|
|
974
|
+
} catch {}
|
|
975
|
+
try {
|
|
976
|
+
this.transport?.close();
|
|
977
|
+
} catch {}
|
|
978
|
+
this.console = null;
|
|
979
|
+
this.transport = null;
|
|
958
980
|
}
|
|
959
981
|
}
|
|
960
982
|
|
|
@@ -1025,6 +1047,128 @@ function connectErrorMessage(name, dc, lastError) {
|
|
|
1025
1047
|
return `Failed to connect to MikroTik device '${name}' at ${dc.host}:${dc.port} (auth: ${authMode})${reason}. ` + "Check the host/port are reachable, the SSH service is enabled (/ip service), and the credentials are correct.";
|
|
1026
1048
|
}
|
|
1027
1049
|
|
|
1050
|
+
// src/core/connection-pool.ts
|
|
1051
|
+
var entries = new Map;
|
|
1052
|
+
var connecting = new Map;
|
|
1053
|
+
function isPoolEnabled() {
|
|
1054
|
+
return getConfig().ssh.keepAlive;
|
|
1055
|
+
}
|
|
1056
|
+
function poolConfig() {
|
|
1057
|
+
const cfg = getConfig().ssh;
|
|
1058
|
+
return {
|
|
1059
|
+
keepAliveInterval: cfg.keepAliveInterval,
|
|
1060
|
+
keepAliveCountMax: 3,
|
|
1061
|
+
idleTimeout: cfg.idleTimeout
|
|
1062
|
+
};
|
|
1063
|
+
}
|
|
1064
|
+
function removeEntry(name) {
|
|
1065
|
+
const entry = entries.get(name);
|
|
1066
|
+
if (!entry)
|
|
1067
|
+
return;
|
|
1068
|
+
if (entry.idleTimer)
|
|
1069
|
+
clearTimeout(entry.idleTimer);
|
|
1070
|
+
entry.dead = true;
|
|
1071
|
+
entry.client.disconnect();
|
|
1072
|
+
entries.delete(name);
|
|
1073
|
+
logger.info(`SSH pool: closed connection to '${name}'`);
|
|
1074
|
+
}
|
|
1075
|
+
function armIdle(name, entry) {
|
|
1076
|
+
if (entry.idleTimer)
|
|
1077
|
+
clearTimeout(entry.idleTimer);
|
|
1078
|
+
if (entry.inflight > 0 || entry.dead)
|
|
1079
|
+
return;
|
|
1080
|
+
const { idleTimeout } = poolConfig();
|
|
1081
|
+
entry.idleTimer = setTimeout(() => {
|
|
1082
|
+
logger.info(`SSH pool: closing idle connection to '${name}' (${idleTimeout / 1000}s idle)`);
|
|
1083
|
+
removeEntry(name);
|
|
1084
|
+
}, idleTimeout);
|
|
1085
|
+
}
|
|
1086
|
+
function isConnectionError(msg) {
|
|
1087
|
+
return /not connected|ECONNRESET|EPIPE|socket.*(close|end|destroy)|channel.*(close|open)|timed out.*handshake/i.test(msg);
|
|
1088
|
+
}
|
|
1089
|
+
async function doConnect(name) {
|
|
1090
|
+
const dc = getDevice(name);
|
|
1091
|
+
const cfg = poolConfig();
|
|
1092
|
+
const client = new MikroTikSSHClient({
|
|
1093
|
+
...sshOptionsOf(dc),
|
|
1094
|
+
jump: resolveJump(dc),
|
|
1095
|
+
keepAliveInterval: cfg.keepAliveInterval,
|
|
1096
|
+
keepAliveCountMax: cfg.keepAliveCountMax
|
|
1097
|
+
});
|
|
1098
|
+
if (!await client.connect()) {
|
|
1099
|
+
throw new Error(connectErrorMessage(name, dc, client.lastError));
|
|
1100
|
+
}
|
|
1101
|
+
const entry = {
|
|
1102
|
+
client,
|
|
1103
|
+
inflight: 0,
|
|
1104
|
+
idleTimer: undefined,
|
|
1105
|
+
dead: false
|
|
1106
|
+
};
|
|
1107
|
+
entries.set(name, entry);
|
|
1108
|
+
logger.info(`SSH pool: opened persistent connection to '${name}'`);
|
|
1109
|
+
armIdle(name, entry);
|
|
1110
|
+
return entry;
|
|
1111
|
+
}
|
|
1112
|
+
async function acquire(name) {
|
|
1113
|
+
const existing = entries.get(name);
|
|
1114
|
+
if (existing && !existing.dead)
|
|
1115
|
+
return existing;
|
|
1116
|
+
if (existing)
|
|
1117
|
+
removeEntry(name);
|
|
1118
|
+
const pending = connecting.get(name);
|
|
1119
|
+
if (pending)
|
|
1120
|
+
return pending;
|
|
1121
|
+
const promise = doConnect(name);
|
|
1122
|
+
connecting.set(name, promise);
|
|
1123
|
+
try {
|
|
1124
|
+
return await promise;
|
|
1125
|
+
} catch (e) {
|
|
1126
|
+
removeEntry(name);
|
|
1127
|
+
throw e;
|
|
1128
|
+
} finally {
|
|
1129
|
+
connecting.delete(name);
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
async function runOnEntry(name, command, opts) {
|
|
1133
|
+
const entry = await acquire(name);
|
|
1134
|
+
if (entry.idleTimer) {
|
|
1135
|
+
clearTimeout(entry.idleTimer);
|
|
1136
|
+
entry.idleTimer = undefined;
|
|
1137
|
+
}
|
|
1138
|
+
entry.inflight++;
|
|
1139
|
+
try {
|
|
1140
|
+
return await entry.client.run(command, opts);
|
|
1141
|
+
} finally {
|
|
1142
|
+
entry.inflight--;
|
|
1143
|
+
armIdle(name, entry);
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
async function runPooled(command, deviceName, opts) {
|
|
1147
|
+
try {
|
|
1148
|
+
return await runOnEntry(deviceName, command, opts);
|
|
1149
|
+
} catch (e) {
|
|
1150
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
1151
|
+
if (!isConnectionError(msg))
|
|
1152
|
+
throw e;
|
|
1153
|
+
logger.info(`SSH pool: connection to '${deviceName}' lost (${msg}), reconnecting`);
|
|
1154
|
+
removeEntry(deviceName);
|
|
1155
|
+
return runOnEntry(deviceName, command, opts);
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
function closeAll() {
|
|
1159
|
+
for (const name of Array.from(entries.keys())) {
|
|
1160
|
+
removeEntry(name);
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
function poolStatus() {
|
|
1164
|
+
return Array.from(entries, ([name, e]) => ({
|
|
1165
|
+
device: name,
|
|
1166
|
+
inflight: e.inflight,
|
|
1167
|
+
idle: e.inflight === 0 && !e.dead,
|
|
1168
|
+
dead: e.dead
|
|
1169
|
+
}));
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1028
1172
|
// src/ssh/safe-mode.ts
|
|
1029
1173
|
var PROMPT_RE = /\[.+?@.+?\] (?:<SAFE> )?> ?$/m;
|
|
1030
1174
|
var ANSI_RE = /\x1B(?:\[[0-9;]*[mA-HJ-MSTfhilnprsu]|[()][0-9A-Za-z]|\[?\?\d+[hl])/g;
|
|
@@ -1245,6 +1389,9 @@ function getSafeModeManager(deviceName) {
|
|
|
1245
1389
|
async function runOnce(command, deviceName, opts) {
|
|
1246
1390
|
const name = resolveDeviceName(deviceName);
|
|
1247
1391
|
const dc = getDevice(deviceName);
|
|
1392
|
+
if (!isMacTelnetDevice(dc) && isPoolEnabled()) {
|
|
1393
|
+
return runPooled(command, name, opts);
|
|
1394
|
+
}
|
|
1248
1395
|
const client = createDeviceClient(dc);
|
|
1249
1396
|
try {
|
|
1250
1397
|
if (!await client.connect()) {
|
|
@@ -1323,7 +1470,7 @@ function quoteValue(value) {
|
|
|
1323
1470
|
return String(value);
|
|
1324
1471
|
if (value !== "" && BARE_SAFE.test(value))
|
|
1325
1472
|
return value;
|
|
1326
|
-
const escaped = value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/\r/g, "\\r").replace(/\n/g, "\\n").replace(/\t/g, "\\t");
|
|
1473
|
+
const escaped = value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/\$/g, "\\$").replace(/\r/g, "\\r").replace(/\n/g, "\\n").replace(/\t/g, "\\t");
|
|
1327
1474
|
return `"${escaped}"`;
|
|
1328
1475
|
}
|
|
1329
1476
|
function yesno(value) {
|
|
@@ -6062,7 +6209,7 @@ function expandQuery(tokens) {
|
|
|
6062
6209
|
return out;
|
|
6063
6210
|
}
|
|
6064
6211
|
function buildToolIndex(tools) {
|
|
6065
|
-
const
|
|
6212
|
+
const entries2 = tools.map((t) => {
|
|
6066
6213
|
const nameTokens = tokenSet(t.name);
|
|
6067
6214
|
const titleTokens = tokenSet(t.title);
|
|
6068
6215
|
const moduleTokens = new Set([...tokenize(t.module), ...tokenize(t.group)]);
|
|
@@ -6071,11 +6218,11 @@ function buildToolIndex(tools) {
|
|
|
6071
6218
|
return { ...t, nameTokens, titleTokens, moduleTokens, descTokens, allTokens };
|
|
6072
6219
|
});
|
|
6073
6220
|
const df = new Map;
|
|
6074
|
-
for (const e of
|
|
6221
|
+
for (const e of entries2) {
|
|
6075
6222
|
for (const tok of e.allTokens)
|
|
6076
6223
|
df.set(tok, (df.get(tok) ?? 0) + 1);
|
|
6077
6224
|
}
|
|
6078
|
-
return { entries, df, n:
|
|
6225
|
+
return { entries: entries2, df, n: entries2.length };
|
|
6079
6226
|
}
|
|
6080
6227
|
var FIELD_WEIGHT = { name: 6, title: 3.5, module: 3, desc: 1.5 };
|
|
6081
6228
|
function idf(df, n, token) {
|
|
@@ -6141,7 +6288,7 @@ var cache = null;
|
|
|
6141
6288
|
async function gateway() {
|
|
6142
6289
|
if (cache)
|
|
6143
6290
|
return cache;
|
|
6144
|
-
const { moduleCatalog } = await import("./cli-
|
|
6291
|
+
const { moduleCatalog } = await import("./cli-j7xj0at0.js");
|
|
6145
6292
|
const forIndex = [];
|
|
6146
6293
|
const byName = new Map;
|
|
6147
6294
|
for (const mod of moduleCatalog) {
|
|
@@ -18147,21 +18294,34 @@ ${result}`;
|
|
|
18147
18294
|
name: "check_route_path",
|
|
18148
18295
|
title: "Check IPv4 Route Path",
|
|
18149
18296
|
annotations: READ,
|
|
18150
|
-
description: "Resolves which nexthop RouterOS would use for a given IPv4 destination
|
|
18297
|
+
description: "Resolves which nexthop RouterOS would use for a given IPv4 destination " + '\u2014 answers "which gateway will this packet take?" without sending any traffic. ' + "Version-aware: uses `/ip route check` on v6, falls back to " + "`/ip route print where dst-address in <dest> active=yes` on v7+ where the check " + "command was removed. Optionally scoped by `routing_table` (v7) / `routing_mark` (v6) " + "for policy-routing table lookups. " + "For listing all known routes use list_routes; for a named-table view use get_routing_table. " + "Returns the resolved nexthop and interface detail.",
|
|
18151
18298
|
inputSchema: {
|
|
18152
18299
|
destination: z83.string(),
|
|
18153
|
-
|
|
18154
|
-
routing_mark: z83.string().optional()
|
|
18300
|
+
routing_table: z83.string().optional().describe('Policy-routing table name (v7) or routing-mark (v6), e.g. "VPN"')
|
|
18155
18301
|
},
|
|
18156
18302
|
async handler(a, ctx) {
|
|
18157
18303
|
ctx.info(`Checking route path to: ${a.destination}`);
|
|
18158
|
-
const
|
|
18159
|
-
const
|
|
18160
|
-
|
|
18161
|
-
|
|
18304
|
+
const table = a.routing_table;
|
|
18305
|
+
const v6Cmd = new Cmd(`/ip route check ${a.destination}`).opt("routing-mark", table).build();
|
|
18306
|
+
const v6Result = await executeMikrotikCommand(v6Cmd, ctx);
|
|
18307
|
+
if (!commandUnsupported(v6Result) && !looksLikeError(v6Result) && !isEmpty(v6Result)) {
|
|
18308
|
+
return `ROUTE PATH TO ${a.destination}:
|
|
18309
|
+
|
|
18310
|
+
${v6Result}`;
|
|
18311
|
+
}
|
|
18312
|
+
const where = [`dst-address in ${a.destination}`, "active=yes"];
|
|
18313
|
+
if (table)
|
|
18314
|
+
where.push(`routing-table=${quoteValue(table)}`);
|
|
18315
|
+
const v7Cmd = `/ip route print detail where ${where.join(" ")}`;
|
|
18316
|
+
const v7Result = await executeMikrotikCommand(v7Cmd, ctx);
|
|
18317
|
+
if (looksLikeError(v7Result))
|
|
18318
|
+
return `Failed to check route to ${a.destination}: ${v7Result}`;
|
|
18319
|
+
if (isEmpty(v7Result)) {
|
|
18320
|
+
return `No active route to ${a.destination}${table ? ` in table '${table}'` : ""}.`;
|
|
18321
|
+
}
|
|
18162
18322
|
return `ROUTE PATH TO ${a.destination}:
|
|
18163
18323
|
|
|
18164
|
-
${
|
|
18324
|
+
${v7Result}`;
|
|
18165
18325
|
}
|
|
18166
18326
|
}),
|
|
18167
18327
|
defineTool({
|
|
@@ -25725,4 +25885,4 @@ function selectToolModules(filter = {}, catalog = moduleCatalog) {
|
|
|
25725
25885
|
}).map((m) => m.tools);
|
|
25726
25886
|
}
|
|
25727
25887
|
|
|
25728
|
-
export { __require, DEFAULT_SNAPSHOT_DB, DEFAULT_CONFIG_HISTORY_DIR, DeviceConfigSchema, ToolFilterSchema, MikrotikConfigSchema, getConfigSource, loadConfig, setConfig, getConfig, listDevices, deviceLabels, resolveDeviceName, deviceDirectory,
|
|
25888
|
+
export { __require, DEFAULT_SNAPSHOT_DB, DEFAULT_CONFIG_HISTORY_DIR, DeviceConfigSchema, ToolFilterSchema, MikrotikConfigSchema, getConfigSource, loadConfig, logger, setConfig, getConfig, listDevices, deviceLabels, resolveDeviceName, deviceDirectory, isMacTelnetDevice, createDeviceClient, describeTransport, isPoolEnabled, closeAll, poolStatus, executeMikrotikCommand, createContext, isEmpty, looksLikeError, commandUnsupported, parseKeyValues, parseRouterosDate, parseSize, parseSystemResource, parseRecords, parseLeadingNumber, REDACTED, redact, configureRecorder, getEventStore, subscribe, subscriberCount, registerTools, fetchDevices, sampleDeviceTraffic, setDeviceLimits, blockDevice, allowDevice, makeDeviceStatic, setDeviceIp, setDeviceLabel, removeDeviceLease, devicesView, PROMPTS_DIR, UI_DIST_DIR, registerUiResources, backupDir, listBackups, readBackup, writeBackup, deleteBackup, renameBackup, createLocalBackup, restoreLocalBackup, isS3Configured, getS3Client, presignExpiresIn, s3Target, splitCommands, buildChangePlan, renderPlan, diffLines, normalizeExport, openSnapshotStore, DEFAULT_TZSP_PORT, capture2 as capture, AAA_ENTITIES, listAaaEntity, addAaaEntity, updateAaaEntity, removeAaaEntity, toggleAaaEntity, getRadiusIncoming, setRadiusIncoming, resetRadiusCounters, getUmSettings, setUmSettings, moduleCatalog, allToolModules, ALWAYS_ON_MODULES, selectToolModules };
|