@usex/mikrotik-mcp 3.42.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-rptenaf7.js → cli-j7xj0at0.js} +1 -1
- package/dist/shared/{cli-sq4trv31.js → cli-vrm1c5fb.js} +1015 -868
- package/dist/shared/{library-5cjmn0n7.js → library-hjde13aa.js} +1001 -867
- package/dist/shared/{library-z22txcv0.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,327 +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 loadConfig(argv = process.argv.slice(2)) {
|
|
141
|
-
const flags = parseFlags(argv);
|
|
142
|
-
const pick = (flag, ...envNames) => flags[flag] ?? env(...envNames);
|
|
143
|
-
const jumpHostName = pick("jump-host", "MIKROTIK_JUMP_HOST");
|
|
144
|
-
const jumpHost = jumpHostName ? {
|
|
145
|
-
host: jumpHostName,
|
|
146
|
-
port: pick("jump-port", "MIKROTIK_JUMP_PORT"),
|
|
147
|
-
username: pick("jump-username", "MIKROTIK_JUMP_USERNAME"),
|
|
148
|
-
password: pick("jump-password", "MIKROTIK_JUMP_PASSWORD"),
|
|
149
|
-
keyFilename: pick("jump-key-filename", "MIKROTIK_JUMP_KEY_FILENAME"),
|
|
150
|
-
keyPassphrase: pick("jump-key-passphrase", "MIKROTIK_JUMP_KEY_PASSPHRASE")
|
|
151
|
-
} : undefined;
|
|
152
|
-
const single = {
|
|
153
|
-
host: pick("host", "MIKROTIK_HOST"),
|
|
154
|
-
username: pick("username", "MIKROTIK_USERNAME"),
|
|
155
|
-
password: pick("password", "MIKROTIK_PASSWORD"),
|
|
156
|
-
port: pick("port", "MIKROTIK_PORT"),
|
|
157
|
-
keyFilename: pick("key-filename", "MIKROTIK_KEY_FILENAME"),
|
|
158
|
-
privateKey: pick("private-key", "MIKROTIK_PRIVATE_KEY"),
|
|
159
|
-
keyPassphrase: pick("key-passphrase", "MIKROTIK_KEY_PASSPHRASE"),
|
|
160
|
-
timeoutMs: pick("timeout-ms", "MIKROTIK_TIMEOUT_MS"),
|
|
161
|
-
jumpHost,
|
|
162
|
-
mac: pick("mac", "MIKROTIK_MAC"),
|
|
163
|
-
sourceMac: pick("source-mac", "MIKROTIK_SOURCE_MAC"),
|
|
164
|
-
macHost: pick("mac-host", "MIKROTIK_MAC_HOST"),
|
|
165
|
-
macPort: pick("mac-port", "MIKROTIK_MAC_PORT")
|
|
166
|
-
};
|
|
167
|
-
const hasSingle = Object.values(single).some((v) => v !== undefined);
|
|
168
|
-
const devices = {};
|
|
169
|
-
let defaultDevice;
|
|
170
|
-
if (hasSingle) {
|
|
171
|
-
devices.default = single;
|
|
172
|
-
defaultDevice = "default";
|
|
173
|
-
}
|
|
174
|
-
const configFile = pick("config", "MIKROTIK_CONFIG_FILE");
|
|
175
|
-
configSource = configFile ? { path: resolve(configFile), fromFile: true } : { path: DEFAULT_CONFIG_FILE, fromFile: false };
|
|
176
|
-
const devicesInline = flags.devices ?? env("MIKROTIK_DEVICES");
|
|
177
|
-
let fileS3 = {};
|
|
178
|
-
let fileDashboard = {};
|
|
179
|
-
let fileTools;
|
|
180
|
-
let fileMcp;
|
|
181
|
-
if (configFile || devicesInline) {
|
|
182
|
-
const src = configFile ? parseDevicesSource(configFile, true) : parseDevicesSource(devicesInline, false);
|
|
183
|
-
for (const [name, dc] of Object.entries(src.devices))
|
|
184
|
-
devices[name] = dc;
|
|
185
|
-
if (src.defaultDevice)
|
|
186
|
-
defaultDevice = src.defaultDevice;
|
|
187
|
-
else if (!defaultDevice)
|
|
188
|
-
defaultDevice = Object.keys(src.devices)[0];
|
|
189
|
-
fileS3 = src.s3;
|
|
190
|
-
fileDashboard = src.dashboard;
|
|
191
|
-
fileTools = src.tools;
|
|
192
|
-
fileMcp = src.mcp;
|
|
193
|
-
}
|
|
194
|
-
const s3 = {
|
|
195
|
-
accessKeyId: pick("s3-access-key-id", "S3_ACCESS_KEY_ID", "AWS_ACCESS_KEY_ID"),
|
|
196
|
-
secretAccessKey: pick("s3-secret-access-key", "S3_SECRET_ACCESS_KEY", "AWS_SECRET_ACCESS_KEY"),
|
|
197
|
-
sessionToken: pick("s3-session-token", "S3_SESSION_TOKEN", "AWS_SESSION_TOKEN"),
|
|
198
|
-
region: pick("s3-region", "S3_REGION", "AWS_REGION"),
|
|
199
|
-
endpoint: pick("s3-endpoint", "S3_ENDPOINT", "AWS_ENDPOINT"),
|
|
200
|
-
bucket: pick("s3-bucket", "S3_BUCKET", "AWS_BUCKET"),
|
|
201
|
-
prefix: pick("s3-prefix", "MIKROTIK_S3_PREFIX"),
|
|
202
|
-
presignExpiresIn: pick("s3-presign-expires-in", "MIKROTIK_S3_PRESIGN_EXPIRES_IN"),
|
|
203
|
-
...fileS3
|
|
204
|
-
};
|
|
205
|
-
const appViewsRaw = pick("app-views", "MIKROTIK_MCP__APP_VIEWS");
|
|
206
|
-
const appViewsEnv = appViewsRaw === undefined ? undefined : !/^(0|false|no|off)$/i.test(appViewsRaw);
|
|
207
|
-
const mcp = {
|
|
208
|
-
transport: pick("transport", "MIKROTIK_MCP__TRANSPORT", "MCP_TRANSPORT"),
|
|
209
|
-
host: pick("mcp-host", "MIKROTIK_MCP__HOST"),
|
|
210
|
-
port: pick("mcp-port", "MIKROTIK_MCP__PORT"),
|
|
211
|
-
allowedHosts: pick("mcp-allowed-hosts", "MIKROTIK_MCP__ALLOWED_HOSTS"),
|
|
212
|
-
allowedOrigins: pick("mcp-allowed-origins", "MIKROTIK_MCP__ALLOWED_ORIGINS"),
|
|
213
|
-
corsOrigins: pick("mcp-cors-origins", "MIKROTIK_MCP__CORS_ORIGINS"),
|
|
214
|
-
toolPageSize: pick("tool-page-size", "MIKROTIK_MCP__TOOL_PAGE_SIZE"),
|
|
215
|
-
appViews: appViewsEnv,
|
|
216
|
-
...fileMcp
|
|
217
|
-
};
|
|
218
|
-
const isTruthy = (v) => /^(1|true|yes|on)$/i.test(v ?? "");
|
|
219
|
-
const readOnly = isTruthy(pick("read-only", "MIKROTIK_READ_ONLY"));
|
|
220
|
-
const csv = (v) => v === undefined ? undefined : v.split(",").map((s) => s.trim()).filter(Boolean);
|
|
221
|
-
const tools = {
|
|
222
|
-
enabledModules: csv(pick("tools-enabled-modules", "MIKROTIK_TOOLS__ENABLED_MODULES")),
|
|
223
|
-
disabledModules: csv(pick("tools-disabled-modules", "MIKROTIK_TOOLS__DISABLED_MODULES")),
|
|
224
|
-
enabledGroups: csv(pick("tools-enabled-groups", "MIKROTIK_TOOLS__ENABLED_GROUPS")),
|
|
225
|
-
disabledGroups: csv(pick("tools-disabled-groups", "MIKROTIK_TOOLS__DISABLED_GROUPS")),
|
|
226
|
-
...fileTools
|
|
227
|
-
};
|
|
228
|
-
const boolOpt = (v) => v === undefined ? undefined : isTruthy(v);
|
|
229
|
-
const dashboard = {
|
|
230
|
-
enabled: boolOpt(pick("dashboard", "MIKROTIK_DASHBOARD__ENABLED", "MIKROTIK_DASHBOARD")),
|
|
231
|
-
host: pick("dashboard-host", "MIKROTIK_DASHBOARD__HOST"),
|
|
232
|
-
port: pick("dashboard-port", "MIKROTIK_DASHBOARD__PORT"),
|
|
233
|
-
dbPath: pick("dashboard-db", "MIKROTIK_DASHBOARD__DB_PATH"),
|
|
234
|
-
maxEvents: pick("dashboard-max-events", "MIKROTIK_DASHBOARD__MAX_EVENTS"),
|
|
235
|
-
captureBody: boolOpt(pick("dashboard-capture-body", "MIKROTIK_DASHBOARD__CAPTURE_BODY")),
|
|
236
|
-
redactInput: boolOpt(pick("dashboard-redact-input", "MIKROTIK_DASHBOARD__REDACT_INPUT")),
|
|
237
|
-
maxBodyBytes: pick("dashboard-max-body-bytes", "MIKROTIK_DASHBOARD__MAX_BODY_BYTES"),
|
|
238
|
-
token: pick("dashboard-token", "MIKROTIK_DASHBOARD__TOKEN"),
|
|
239
|
-
...fileDashboard
|
|
240
|
-
};
|
|
241
|
-
const hasS3 = !!(s3.accessKeyId || s3.bucket || s3.endpoint);
|
|
242
|
-
const raw = {
|
|
243
|
-
devices: Object.keys(devices).length ? devices : { default: {} },
|
|
244
|
-
defaultDevice: defaultDevice ?? "default",
|
|
245
|
-
mcp,
|
|
246
|
-
dashboard,
|
|
247
|
-
readOnly,
|
|
248
|
-
tools,
|
|
249
|
-
...hasS3 ? { s3 } : {}
|
|
250
|
-
};
|
|
251
|
-
const pruned = JSON.parse(JSON.stringify(raw));
|
|
252
|
-
const parsed = MikrotikConfigSchema.parse(pruned);
|
|
253
|
-
if (!parsed.devices[parsed.defaultDevice]) {
|
|
254
|
-
parsed.defaultDevice = Object.keys(parsed.devices)[0] ?? "default";
|
|
255
|
-
}
|
|
256
|
-
return parsed;
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
// src/core/runtime.ts
|
|
260
|
-
var active = MikrotikConfigSchema.parse({});
|
|
261
|
-
function setConfig(cfg) {
|
|
262
|
-
active = cfg;
|
|
263
|
-
}
|
|
264
|
-
function getConfig() {
|
|
265
|
-
return active;
|
|
266
|
-
}
|
|
267
|
-
function listDevices() {
|
|
268
|
-
return { names: Object.keys(active.devices), default: active.defaultDevice };
|
|
269
|
-
}
|
|
270
|
-
function deviceKeyForLabel(name) {
|
|
271
|
-
const target = name.trim().toLowerCase();
|
|
272
|
-
for (const [key, dc] of Object.entries(active.devices)) {
|
|
273
|
-
if (dc.description && dc.description.trim().toLowerCase() === target)
|
|
274
|
-
return key;
|
|
275
|
-
}
|
|
276
|
-
return;
|
|
277
|
-
}
|
|
278
|
-
function deviceLabels() {
|
|
279
|
-
const seen = new Set;
|
|
280
|
-
const out = [];
|
|
281
|
-
for (const [key, dc] of Object.entries(active.devices)) {
|
|
282
|
-
const label = dc.description?.trim();
|
|
283
|
-
if (label && label !== key && !(label in active.devices) && !seen.has(label)) {
|
|
284
|
-
seen.add(label);
|
|
285
|
-
out.push(label);
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
return out;
|
|
289
|
-
}
|
|
290
|
-
function resolveDeviceName(name) {
|
|
291
|
-
if (name) {
|
|
292
|
-
if (name in active.devices)
|
|
293
|
-
return name;
|
|
294
|
-
const byLabel = deviceKeyForLabel(name);
|
|
295
|
-
if (byLabel)
|
|
296
|
-
return byLabel;
|
|
297
|
-
}
|
|
298
|
-
return active.defaultDevice in active.devices ? active.defaultDevice : Object.keys(active.devices)[0] ?? active.defaultDevice;
|
|
299
|
-
}
|
|
300
|
-
function deviceTarget(dc) {
|
|
301
|
-
if (!dc)
|
|
302
|
-
return "?";
|
|
303
|
-
return dc.mac ? `MAC ${dc.mac}` : `${dc.host}:${dc.port ?? 22}`;
|
|
304
|
-
}
|
|
305
|
-
function deviceDirectory() {
|
|
306
|
-
return Object.entries(active.devices).map(([key, dc]) => ({
|
|
307
|
-
key,
|
|
308
|
-
label: dc.description?.trim() || undefined,
|
|
309
|
-
target: deviceTarget(dc),
|
|
310
|
-
isDefault: key === active.defaultDevice
|
|
311
|
-
}));
|
|
312
|
-
}
|
|
313
|
-
function resolvedTarget(name) {
|
|
314
|
-
const key = resolveDeviceName(name);
|
|
315
|
-
const dc = active.devices[key];
|
|
316
|
-
return { key, label: dc?.description?.trim() || undefined, target: deviceTarget(dc) };
|
|
317
|
-
}
|
|
318
|
-
function getDevice(name) {
|
|
319
|
-
if (name && !(name in active.devices) && !deviceKeyForLabel(name)) {
|
|
320
|
-
throw new Error(`Unknown device '${name}'. Configured devices: ${Object.keys(active.devices).join(", ")}`);
|
|
321
|
-
}
|
|
322
|
-
const key = resolveDeviceName(name);
|
|
323
|
-
const dc = active.devices[key];
|
|
324
|
-
if (!dc)
|
|
325
|
-
throw new Error(`No device configuration available for '${key}'.`);
|
|
326
|
-
return dc;
|
|
327
|
-
}
|
|
9
|
+
import { Client } from "ssh2";
|
|
328
10
|
|
|
329
11
|
// src/logger.ts
|
|
330
12
|
import { stderr } from "process";
|
|
@@ -358,600 +40,940 @@ var logger = {
|
|
|
358
40
|
error: (m) => emit("error", m)
|
|
359
41
|
};
|
|
360
42
|
|
|
361
|
-
// src/
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
const clean = text.replace(ANSI_CSI, "").replace(ANSI_ESC2, "");
|
|
372
|
-
const lines = [[]];
|
|
373
|
-
let row = 0;
|
|
374
|
-
let col = 0;
|
|
375
|
-
for (const ch of clean) {
|
|
376
|
-
const code = ch.charCodeAt(0);
|
|
377
|
-
if (ch === `
|
|
378
|
-
`) {
|
|
379
|
-
row += 1;
|
|
380
|
-
if (!lines[row])
|
|
381
|
-
lines[row] = [];
|
|
382
|
-
col = 0;
|
|
383
|
-
} else if (ch === "\r") {
|
|
384
|
-
col = 0;
|
|
385
|
-
} else if (code === 8) {
|
|
386
|
-
col = Math.max(0, col - 1);
|
|
387
|
-
} else if (code >= 32 && code !== 127) {
|
|
388
|
-
const line = lines[row];
|
|
389
|
-
line[col] = ch;
|
|
390
|
-
col += 1;
|
|
391
|
-
}
|
|
392
|
-
}
|
|
393
|
-
return lines.map((line) => Array.from(line, (c) => c ?? " ").join("").replace(/\s+$/, ""));
|
|
394
|
-
}
|
|
395
|
-
function extractCommandOutput(raw, command) {
|
|
396
|
-
const lines = emulateScreen(raw);
|
|
397
|
-
let start = 1;
|
|
398
|
-
if (command !== undefined && lines.length > 0) {
|
|
399
|
-
const first = lines[0] ?? "";
|
|
400
|
-
const promptMatch = first.match(/^\[[^\]\r\n]*\][^\r\n]*?>\s?/);
|
|
401
|
-
const echoedOnFirst = promptMatch ? first.slice(promptMatch[0].length) : first;
|
|
402
|
-
let consumed = echoedOnFirst.length;
|
|
403
|
-
while (consumed < command.length && start < lines.length) {
|
|
404
|
-
consumed += (lines[start] ?? "").length;
|
|
405
|
-
start += 1;
|
|
406
|
-
}
|
|
407
|
-
}
|
|
408
|
-
const body = lines.slice(start);
|
|
409
|
-
while (body.length > 0) {
|
|
410
|
-
const last = body[body.length - 1];
|
|
411
|
-
if (last.length === 0 || ROUTEROS_PROMPT_RE.test(last)) {
|
|
412
|
-
body.pop();
|
|
413
|
-
continue;
|
|
414
|
-
}
|
|
415
|
-
break;
|
|
416
|
-
}
|
|
417
|
-
return body.join(`
|
|
418
|
-
`);
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
class MacTelnetConsole {
|
|
422
|
-
options;
|
|
423
|
-
session;
|
|
424
|
-
buffer = "";
|
|
425
|
-
ready = false;
|
|
426
|
-
closed = false;
|
|
427
|
-
closeError;
|
|
428
|
-
waiter;
|
|
429
|
-
tickTimer;
|
|
430
|
-
readyWaiters = [];
|
|
431
|
-
decoder = new TextDecoder;
|
|
432
|
-
probeTail = "";
|
|
433
|
-
constructor(options) {
|
|
434
|
-
this.options = {
|
|
435
|
-
rows: 9999,
|
|
436
|
-
cols: 512,
|
|
437
|
-
primeTimeoutMs: 30000,
|
|
438
|
-
commandTimeoutMs: 15000,
|
|
439
|
-
settleMs: 150,
|
|
440
|
-
acceptLicense: true,
|
|
441
|
-
...options
|
|
442
|
-
};
|
|
443
|
-
const createSession = options.createSession ?? ((init) => new MacTelnetSession(init));
|
|
444
|
-
this.session = createSession({
|
|
445
|
-
sink: options.sink,
|
|
446
|
-
sourceMac: options.sourceMac,
|
|
447
|
-
destinationMac: options.destinationMac,
|
|
448
|
-
username: options.username,
|
|
449
|
-
password: options.password,
|
|
450
|
-
sessionKey: options.sessionKey,
|
|
451
|
-
terminalType: "vt102",
|
|
452
|
-
terminalWidth: this.options.cols,
|
|
453
|
-
terminalHeight: this.options.rows,
|
|
454
|
-
onReady: () => this.onReady(),
|
|
455
|
-
onData: (bytes) => this.onData(bytes),
|
|
456
|
-
onClose: (error) => this.onClose(error)
|
|
457
|
-
});
|
|
458
|
-
}
|
|
459
|
-
handlePacket(bytes) {
|
|
460
|
-
try {
|
|
461
|
-
this.session.handlePacket(bytes);
|
|
462
|
-
} catch (error) {
|
|
463
|
-
this.onClose(error instanceof Error ? error : new Error("Failed to process a MAC-Telnet datagram."));
|
|
464
|
-
}
|
|
465
|
-
}
|
|
466
|
-
async open() {
|
|
467
|
-
this.tickTimer = setInterval(() => {
|
|
468
|
-
try {
|
|
469
|
-
this.session.tick(Date.now());
|
|
470
|
-
} catch {}
|
|
471
|
-
}, TICK_INTERVAL_MS);
|
|
472
|
-
this.tickTimer.unref?.();
|
|
473
|
-
this.session.start();
|
|
474
|
-
await this.waitReady(this.options.primeTimeoutMs);
|
|
475
|
-
await this.waitFor((buffer) => this.endsWithPrompt(buffer) || LICENSE_RE.test(buffer), this.options.primeTimeoutMs, "waiting for the RouterOS console prompt");
|
|
476
|
-
if (this.options.acceptLicense && LICENSE_RE.test(this.buffer)) {
|
|
477
|
-
this.buffer = "";
|
|
478
|
-
this.session.sendInput(enc.encode("n\r"));
|
|
479
|
-
await this.waitFor((buffer) => this.endsWithPrompt(buffer), this.options.primeTimeoutMs, "waiting for the prompt after the license screen");
|
|
480
|
-
}
|
|
481
|
-
this.buffer = "";
|
|
482
|
-
this.session.sendInput(enc.encode("\r"));
|
|
483
|
-
await this.waitFor((buffer) => this.endsWithPrompt(buffer), this.options.commandTimeoutMs, "waiting for a clean prompt");
|
|
484
|
-
this.buffer = "";
|
|
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) {
|
|
50
|
+
try {
|
|
51
|
+
return new TextDecoder(encoding, { fatal: true }).decode(data);
|
|
52
|
+
} catch {}
|
|
485
53
|
}
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
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 };
|
|
493
64
|
}
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
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;
|
|
498
88
|
}
|
|
499
|
-
if (!this.closed)
|
|
500
|
-
this.session.end();
|
|
501
|
-
}
|
|
502
|
-
get isReady() {
|
|
503
|
-
return this.ready && !this.closed;
|
|
504
89
|
}
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
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
|
+
});
|
|
509
121
|
}
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
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
|
+
});
|
|
517
144
|
}
|
|
518
|
-
|
|
519
|
-
this.
|
|
520
|
-
|
|
521
|
-
if (this.tickTimer) {
|
|
522
|
-
clearInterval(this.tickTimer);
|
|
523
|
-
this.tickTimer = undefined;
|
|
524
|
-
}
|
|
525
|
-
const failure = error ?? new Error("The MAC-Telnet console session closed.");
|
|
526
|
-
for (const w of this.readyWaiters.splice(0))
|
|
527
|
-
w.reject(failure);
|
|
528
|
-
if (this.waiter) {
|
|
529
|
-
const waiter = this.waiter;
|
|
530
|
-
this.waiter = undefined;
|
|
531
|
-
clearTimeout(waiter.timeout);
|
|
532
|
-
if (waiter.settle)
|
|
533
|
-
clearTimeout(waiter.settle);
|
|
534
|
-
waiter.reject(failure);
|
|
145
|
+
run(command, opts = {}) {
|
|
146
|
+
if (!this.client) {
|
|
147
|
+
return Promise.reject(new Error("Not connected to MikroTik device"));
|
|
535
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
|
+
});
|
|
536
217
|
}
|
|
537
|
-
|
|
538
|
-
if (
|
|
539
|
-
|
|
540
|
-
}
|
|
541
|
-
if (chunk.includes(`${ESC}Z`) || chunk.includes(`${ESC}[c`)) {
|
|
542
|
-
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"));
|
|
543
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
|
+
});
|
|
544
240
|
}
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
}
|
|
549
|
-
waitReady(timeoutMs) {
|
|
550
|
-
if (this.ready)
|
|
551
|
-
return Promise.resolve();
|
|
552
|
-
if (this.closed) {
|
|
553
|
-
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"));
|
|
554
244
|
}
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
}
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
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;
|
|
251
|
+
}
|
|
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
|
+
});
|
|
572
262
|
});
|
|
573
263
|
}
|
|
574
|
-
|
|
575
|
-
if (this.
|
|
576
|
-
return Promise.reject(
|
|
264
|
+
shell(opts = {}) {
|
|
265
|
+
if (!this.client) {
|
|
266
|
+
return Promise.reject(new Error("Not connected to MikroTik device"));
|
|
577
267
|
}
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
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));
|
|
585
275
|
});
|
|
586
276
|
}
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
clearTimeout(waiter.settle);
|
|
594
|
-
waiter.settle = undefined;
|
|
595
|
-
}
|
|
596
|
-
return;
|
|
277
|
+
disconnect() {
|
|
278
|
+
if (this.client) {
|
|
279
|
+
try {
|
|
280
|
+
this.client.end();
|
|
281
|
+
} catch {}
|
|
282
|
+
this.client = null;
|
|
597
283
|
}
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
clearTimeout(waiter.timeout);
|
|
605
|
-
waiter.resolve();
|
|
606
|
-
}, this.options.settleMs);
|
|
284
|
+
for (const b of this.bastions.reverse()) {
|
|
285
|
+
try {
|
|
286
|
+
b.end();
|
|
287
|
+
} catch {}
|
|
288
|
+
}
|
|
289
|
+
this.bastions = [];
|
|
607
290
|
}
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
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";
|
|
409
|
+
}
|
|
611
410
|
}
|
|
612
411
|
}
|
|
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 loadConfig(argv = process.argv.slice(2)) {
|
|
434
|
+
const flags = parseFlags(argv);
|
|
435
|
+
const pick = (flag, ...envNames) => flags[flag] ?? env(...envNames);
|
|
436
|
+
const jumpHostName = pick("jump-host", "MIKROTIK_JUMP_HOST");
|
|
437
|
+
const jumpHost = jumpHostName ? {
|
|
438
|
+
host: jumpHostName,
|
|
439
|
+
port: pick("jump-port", "MIKROTIK_JUMP_PORT"),
|
|
440
|
+
username: pick("jump-username", "MIKROTIK_JUMP_USERNAME"),
|
|
441
|
+
password: pick("jump-password", "MIKROTIK_JUMP_PASSWORD"),
|
|
442
|
+
keyFilename: pick("jump-key-filename", "MIKROTIK_JUMP_KEY_FILENAME"),
|
|
443
|
+
keyPassphrase: pick("jump-key-passphrase", "MIKROTIK_JUMP_KEY_PASSPHRASE")
|
|
444
|
+
} : undefined;
|
|
445
|
+
const single = {
|
|
446
|
+
host: pick("host", "MIKROTIK_HOST"),
|
|
447
|
+
username: pick("username", "MIKROTIK_USERNAME"),
|
|
448
|
+
password: pick("password", "MIKROTIK_PASSWORD"),
|
|
449
|
+
port: pick("port", "MIKROTIK_PORT"),
|
|
450
|
+
keyFilename: pick("key-filename", "MIKROTIK_KEY_FILENAME"),
|
|
451
|
+
privateKey: pick("private-key", "MIKROTIK_PRIVATE_KEY"),
|
|
452
|
+
keyPassphrase: pick("key-passphrase", "MIKROTIK_KEY_PASSPHRASE"),
|
|
453
|
+
timeoutMs: pick("timeout-ms", "MIKROTIK_TIMEOUT_MS"),
|
|
454
|
+
jumpHost,
|
|
455
|
+
mac: pick("mac", "MIKROTIK_MAC"),
|
|
456
|
+
sourceMac: pick("source-mac", "MIKROTIK_SOURCE_MAC"),
|
|
457
|
+
macHost: pick("mac-host", "MIKROTIK_MAC_HOST"),
|
|
458
|
+
macPort: pick("mac-port", "MIKROTIK_MAC_PORT")
|
|
459
|
+
};
|
|
460
|
+
const hasSingle = Object.values(single).some((v) => v !== undefined);
|
|
461
|
+
const devices = {};
|
|
462
|
+
let defaultDevice;
|
|
463
|
+
if (hasSingle) {
|
|
464
|
+
devices.default = single;
|
|
465
|
+
defaultDevice = "default";
|
|
466
|
+
}
|
|
467
|
+
const configFile = pick("config", "MIKROTIK_CONFIG_FILE");
|
|
468
|
+
configSource = configFile ? { path: resolve(configFile), fromFile: true } : { path: DEFAULT_CONFIG_FILE, fromFile: false };
|
|
469
|
+
const devicesInline = flags.devices ?? env("MIKROTIK_DEVICES");
|
|
470
|
+
let fileS3 = {};
|
|
471
|
+
let fileDashboard = {};
|
|
472
|
+
let fileTools;
|
|
473
|
+
let fileMcp;
|
|
474
|
+
let fileSsh;
|
|
475
|
+
if (configFile || devicesInline) {
|
|
476
|
+
const src = configFile ? parseDevicesSource(configFile, true) : parseDevicesSource(devicesInline, false);
|
|
477
|
+
for (const [name, dc] of Object.entries(src.devices))
|
|
478
|
+
devices[name] = dc;
|
|
479
|
+
if (src.defaultDevice)
|
|
480
|
+
defaultDevice = src.defaultDevice;
|
|
481
|
+
else if (!defaultDevice)
|
|
482
|
+
defaultDevice = Object.keys(src.devices)[0];
|
|
483
|
+
fileS3 = src.s3;
|
|
484
|
+
fileDashboard = src.dashboard;
|
|
485
|
+
fileTools = src.tools;
|
|
486
|
+
fileMcp = src.mcp;
|
|
487
|
+
fileSsh = src.ssh;
|
|
488
|
+
}
|
|
489
|
+
const s3 = {
|
|
490
|
+
accessKeyId: pick("s3-access-key-id", "S3_ACCESS_KEY_ID", "AWS_ACCESS_KEY_ID"),
|
|
491
|
+
secretAccessKey: pick("s3-secret-access-key", "S3_SECRET_ACCESS_KEY", "AWS_SECRET_ACCESS_KEY"),
|
|
492
|
+
sessionToken: pick("s3-session-token", "S3_SESSION_TOKEN", "AWS_SESSION_TOKEN"),
|
|
493
|
+
region: pick("s3-region", "S3_REGION", "AWS_REGION"),
|
|
494
|
+
endpoint: pick("s3-endpoint", "S3_ENDPOINT", "AWS_ENDPOINT"),
|
|
495
|
+
bucket: pick("s3-bucket", "S3_BUCKET", "AWS_BUCKET"),
|
|
496
|
+
prefix: pick("s3-prefix", "MIKROTIK_S3_PREFIX"),
|
|
497
|
+
presignExpiresIn: pick("s3-presign-expires-in", "MIKROTIK_S3_PRESIGN_EXPIRES_IN"),
|
|
498
|
+
...fileS3
|
|
499
|
+
};
|
|
500
|
+
const appViewsRaw = pick("app-views", "MIKROTIK_MCP__APP_VIEWS");
|
|
501
|
+
const appViewsEnv = appViewsRaw === undefined ? undefined : !/^(0|false|no|off)$/i.test(appViewsRaw);
|
|
502
|
+
const mcp = {
|
|
503
|
+
transport: pick("transport", "MIKROTIK_MCP__TRANSPORT", "MCP_TRANSPORT"),
|
|
504
|
+
host: pick("mcp-host", "MIKROTIK_MCP__HOST"),
|
|
505
|
+
port: pick("mcp-port", "MIKROTIK_MCP__PORT"),
|
|
506
|
+
allowedHosts: pick("mcp-allowed-hosts", "MIKROTIK_MCP__ALLOWED_HOSTS"),
|
|
507
|
+
allowedOrigins: pick("mcp-allowed-origins", "MIKROTIK_MCP__ALLOWED_ORIGINS"),
|
|
508
|
+
corsOrigins: pick("mcp-cors-origins", "MIKROTIK_MCP__CORS_ORIGINS"),
|
|
509
|
+
toolPageSize: pick("tool-page-size", "MIKROTIK_MCP__TOOL_PAGE_SIZE"),
|
|
510
|
+
appViews: appViewsEnv,
|
|
511
|
+
...fileMcp
|
|
512
|
+
};
|
|
513
|
+
const isTruthy = (v) => /^(1|true|yes|on)$/i.test(v ?? "");
|
|
514
|
+
const readOnly = isTruthy(pick("read-only", "MIKROTIK_READ_ONLY"));
|
|
515
|
+
const csv = (v) => v === undefined ? undefined : v.split(",").map((s) => s.trim()).filter(Boolean);
|
|
516
|
+
const tools = {
|
|
517
|
+
enabledModules: csv(pick("tools-enabled-modules", "MIKROTIK_TOOLS__ENABLED_MODULES")),
|
|
518
|
+
disabledModules: csv(pick("tools-disabled-modules", "MIKROTIK_TOOLS__DISABLED_MODULES")),
|
|
519
|
+
enabledGroups: csv(pick("tools-enabled-groups", "MIKROTIK_TOOLS__ENABLED_GROUPS")),
|
|
520
|
+
disabledGroups: csv(pick("tools-disabled-groups", "MIKROTIK_TOOLS__DISABLED_GROUPS")),
|
|
521
|
+
...fileTools
|
|
522
|
+
};
|
|
523
|
+
const boolOpt = (v) => v === undefined ? undefined : isTruthy(v);
|
|
524
|
+
const dashboard = {
|
|
525
|
+
enabled: boolOpt(pick("dashboard", "MIKROTIK_DASHBOARD__ENABLED", "MIKROTIK_DASHBOARD")),
|
|
526
|
+
host: pick("dashboard-host", "MIKROTIK_DASHBOARD__HOST"),
|
|
527
|
+
port: pick("dashboard-port", "MIKROTIK_DASHBOARD__PORT"),
|
|
528
|
+
dbPath: pick("dashboard-db", "MIKROTIK_DASHBOARD__DB_PATH"),
|
|
529
|
+
maxEvents: pick("dashboard-max-events", "MIKROTIK_DASHBOARD__MAX_EVENTS"),
|
|
530
|
+
captureBody: boolOpt(pick("dashboard-capture-body", "MIKROTIK_DASHBOARD__CAPTURE_BODY")),
|
|
531
|
+
redactInput: boolOpt(pick("dashboard-redact-input", "MIKROTIK_DASHBOARD__REDACT_INPUT")),
|
|
532
|
+
maxBodyBytes: pick("dashboard-max-body-bytes", "MIKROTIK_DASHBOARD__MAX_BODY_BYTES"),
|
|
533
|
+
token: pick("dashboard-token", "MIKROTIK_DASHBOARD__TOKEN"),
|
|
534
|
+
...fileDashboard
|
|
535
|
+
};
|
|
536
|
+
const ssh = {
|
|
537
|
+
keepAlive: boolOpt(pick("ssh-keep-alive", "MIKROTIK_SSH__KEEP_ALIVE")),
|
|
538
|
+
keepAliveInterval: pick("ssh-keepalive-interval", "MIKROTIK_SSH__KEEPALIVE_INTERVAL"),
|
|
539
|
+
idleTimeout: pick("ssh-idle-timeout", "MIKROTIK_SSH__IDLE_TIMEOUT"),
|
|
540
|
+
...fileSsh
|
|
541
|
+
};
|
|
542
|
+
const hasS3 = !!(s3.accessKeyId || s3.bucket || s3.endpoint);
|
|
543
|
+
const raw = {
|
|
544
|
+
devices: Object.keys(devices).length ? devices : { default: {} },
|
|
545
|
+
defaultDevice: defaultDevice ?? "default",
|
|
546
|
+
mcp,
|
|
547
|
+
dashboard,
|
|
548
|
+
readOnly,
|
|
549
|
+
tools,
|
|
550
|
+
ssh,
|
|
551
|
+
...hasS3 ? { s3 } : {}
|
|
552
|
+
};
|
|
553
|
+
const pruned = JSON.parse(JSON.stringify(raw));
|
|
554
|
+
const parsed = MikrotikConfigSchema.parse(pruned);
|
|
555
|
+
if (!parsed.devices[parsed.defaultDevice]) {
|
|
556
|
+
parsed.defaultDevice = Object.keys(parsed.devices)[0] ?? "default";
|
|
557
|
+
}
|
|
558
|
+
return parsed;
|
|
613
559
|
}
|
|
614
560
|
|
|
615
|
-
// src/
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
}
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
lastError;
|
|
632
|
-
routeHint;
|
|
633
|
-
constructor(opts) {
|
|
634
|
-
this.opts = {
|
|
635
|
-
port: MAC_TELNET_PORT,
|
|
636
|
-
timeoutMs: 1e4,
|
|
637
|
-
...opts
|
|
638
|
-
};
|
|
561
|
+
// src/core/runtime.ts
|
|
562
|
+
var active = MikrotikConfigSchema.parse({});
|
|
563
|
+
function setConfig(cfg) {
|
|
564
|
+
active = cfg;
|
|
565
|
+
}
|
|
566
|
+
function getConfig() {
|
|
567
|
+
return active;
|
|
568
|
+
}
|
|
569
|
+
function listDevices() {
|
|
570
|
+
return { names: Object.keys(active.devices), default: active.defaultDevice };
|
|
571
|
+
}
|
|
572
|
+
function deviceKeyForLabel(name) {
|
|
573
|
+
const target = name.trim().toLowerCase();
|
|
574
|
+
for (const [key, dc] of Object.entries(active.devices)) {
|
|
575
|
+
if (dc.description && dc.description.trim().toLowerCase() === target)
|
|
576
|
+
return key;
|
|
639
577
|
}
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
logger.info(`[mac-telnet] discovery candidates: ${candidatesText}`);
|
|
651
|
-
logger.info(`[mac-telnet] resolving route to ${this.opts.mac}\u2026`);
|
|
652
|
-
const route = await resolveMacTelnetRoute({
|
|
653
|
-
destinationMac,
|
|
654
|
-
host,
|
|
655
|
-
port: this.opts.port,
|
|
656
|
-
timeoutMs: this.opts.timeoutMs,
|
|
657
|
-
explicitSourceMac
|
|
658
|
-
});
|
|
659
|
-
const discoveryFailed = askedDiscovery && route.host === DEFAULT_MAC_TELNET_BROADCAST;
|
|
660
|
-
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;
|
|
661
|
-
logger.info(`[mac-telnet] route: source ${formatMac(route.sourceMac)} \u2192 ${route.host} ` + `(${explicitSourceMac ? "explicit" : discoveryFailed ? "DISCOVERY FAILED \u2014 limited broadcast" : "discovered"})`);
|
|
662
|
-
const transport = createUdpMacTelnetTransport({
|
|
663
|
-
host: route.host,
|
|
664
|
-
port: this.opts.port,
|
|
665
|
-
broadcast: isBroadcastHost(route.host)
|
|
666
|
-
});
|
|
667
|
-
this.transport = transport;
|
|
668
|
-
await transport.ready();
|
|
669
|
-
logger.info(`[mac-telnet] socket ready; starting login (this can take ~10\u201330s)\u2026`);
|
|
670
|
-
const console = new MacTelnetConsole({
|
|
671
|
-
sink: transport,
|
|
672
|
-
sourceMac: route.sourceMac,
|
|
673
|
-
destinationMac,
|
|
674
|
-
username: this.opts.username,
|
|
675
|
-
password: this.opts.password ?? "",
|
|
676
|
-
primeTimeoutMs: Math.max(this.opts.timeoutMs, 30000),
|
|
677
|
-
commandTimeoutMs: Math.max(this.opts.timeoutMs, 15000)
|
|
678
|
-
});
|
|
679
|
-
this.console = console;
|
|
680
|
-
transport.onMessage((bytes) => console.handlePacket(bytes));
|
|
681
|
-
await console.open();
|
|
682
|
-
return true;
|
|
683
|
-
} catch (e) {
|
|
684
|
-
const base = e instanceof Error ? e.message : String(e);
|
|
685
|
-
this.lastError = this.routeHint ? `${base} (${this.routeHint})` : base;
|
|
686
|
-
logger.error(`Failed to connect to MikroTik over MAC-Telnet: ${this.lastError}`);
|
|
687
|
-
this.disconnect();
|
|
688
|
-
return false;
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
function deviceLabels() {
|
|
581
|
+
const seen = new Set;
|
|
582
|
+
const out = [];
|
|
583
|
+
for (const [key, dc] of Object.entries(active.devices)) {
|
|
584
|
+
const label = dc.description?.trim();
|
|
585
|
+
if (label && label !== key && !(label in active.devices) && !seen.has(label)) {
|
|
586
|
+
seen.add(label);
|
|
587
|
+
out.push(label);
|
|
689
588
|
}
|
|
690
589
|
}
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
590
|
+
return out;
|
|
591
|
+
}
|
|
592
|
+
function resolveDeviceName(name) {
|
|
593
|
+
if (name) {
|
|
594
|
+
if (name in active.devices)
|
|
595
|
+
return name;
|
|
596
|
+
const byLabel = deviceKeyForLabel(name);
|
|
597
|
+
if (byLabel)
|
|
598
|
+
return byLabel;
|
|
697
599
|
}
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
600
|
+
return active.defaultDevice in active.devices ? active.defaultDevice : Object.keys(active.devices)[0] ?? active.defaultDevice;
|
|
601
|
+
}
|
|
602
|
+
function deviceTarget(dc) {
|
|
603
|
+
if (!dc)
|
|
604
|
+
return "?";
|
|
605
|
+
return dc.mac ? `MAC ${dc.mac}` : `${dc.host}:${dc.port ?? 22}`;
|
|
606
|
+
}
|
|
607
|
+
function deviceDirectory() {
|
|
608
|
+
return Object.entries(active.devices).map(([key, dc]) => ({
|
|
609
|
+
key,
|
|
610
|
+
label: dc.description?.trim() || undefined,
|
|
611
|
+
target: deviceTarget(dc),
|
|
612
|
+
isDefault: key === active.defaultDevice
|
|
613
|
+
}));
|
|
614
|
+
}
|
|
615
|
+
function resolvedTarget(name) {
|
|
616
|
+
const key = resolveDeviceName(name);
|
|
617
|
+
const dc = active.devices[key];
|
|
618
|
+
return { key, label: dc?.description?.trim() || undefined, target: deviceTarget(dc) };
|
|
619
|
+
}
|
|
620
|
+
function getDevice(name) {
|
|
621
|
+
if (name && !(name in active.devices) && !deviceKeyForLabel(name)) {
|
|
622
|
+
throw new Error(`Unknown device '${name}'. Configured devices: ${Object.keys(active.devices).join(", ")}`);
|
|
707
623
|
}
|
|
624
|
+
const key = resolveDeviceName(name);
|
|
625
|
+
const dc = active.devices[key];
|
|
626
|
+
if (!dc)
|
|
627
|
+
throw new Error(`No device configuration available for '${key}'.`);
|
|
628
|
+
return dc;
|
|
708
629
|
}
|
|
709
630
|
|
|
710
|
-
// src/
|
|
711
|
-
import {
|
|
712
|
-
|
|
713
|
-
var
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
631
|
+
// src/mac-telnet/console.ts
|
|
632
|
+
import { MacTelnetSession } from "@tikoci/centrs/protocols";
|
|
633
|
+
var ESC = "\x1B";
|
|
634
|
+
var enc = new TextEncoder;
|
|
635
|
+
var ANSI_CSI = new RegExp(`${ESC}\\[[0-9;?]*[ -/]*[@-~]`, "g");
|
|
636
|
+
var ANSI_ESC2 = new RegExp(`${ESC}[@-_]`, "g");
|
|
637
|
+
var ROUTEROS_PROMPT_RE = /\[[^\]@\r\n]+@[^\]\r\n]*\][^\r\n]*>\s*$/;
|
|
638
|
+
var TICK_INTERVAL_MS = 15;
|
|
639
|
+
var LICENSE_RE = /do you want to see the software license/i;
|
|
640
|
+
function emulateScreen(text) {
|
|
641
|
+
const clean = text.replace(ANSI_CSI, "").replace(ANSI_ESC2, "");
|
|
642
|
+
const lines = [[]];
|
|
643
|
+
let row = 0;
|
|
644
|
+
let col = 0;
|
|
645
|
+
for (const ch of clean) {
|
|
646
|
+
const code = ch.charCodeAt(0);
|
|
647
|
+
if (ch === `
|
|
648
|
+
`) {
|
|
649
|
+
row += 1;
|
|
650
|
+
if (!lines[row])
|
|
651
|
+
lines[row] = [];
|
|
652
|
+
col = 0;
|
|
653
|
+
} else if (ch === "\r") {
|
|
654
|
+
col = 0;
|
|
655
|
+
} else if (code === 8) {
|
|
656
|
+
col = Math.max(0, col - 1);
|
|
657
|
+
} else if (code >= 32 && code !== 127) {
|
|
658
|
+
const line = lines[row];
|
|
659
|
+
line[col] = ch;
|
|
660
|
+
col += 1;
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
return lines.map((line) => Array.from(line, (c) => c ?? " ").join("").replace(/\s+$/, ""));
|
|
664
|
+
}
|
|
665
|
+
function extractCommandOutput(raw, command) {
|
|
666
|
+
const lines = emulateScreen(raw);
|
|
667
|
+
let start = 1;
|
|
668
|
+
if (command !== undefined && lines.length > 0) {
|
|
669
|
+
const first = lines[0] ?? "";
|
|
670
|
+
const promptMatch = first.match(/^\[[^\]\r\n]*\][^\r\n]*?>\s?/);
|
|
671
|
+
const echoedOnFirst = promptMatch ? first.slice(promptMatch[0].length) : first;
|
|
672
|
+
let consumed = echoedOnFirst.length;
|
|
673
|
+
while (consumed < command.length && start < lines.length) {
|
|
674
|
+
consumed += (lines[start] ?? "").length;
|
|
675
|
+
start += 1;
|
|
676
|
+
}
|
|
722
677
|
}
|
|
723
|
-
|
|
678
|
+
const body = lines.slice(start);
|
|
679
|
+
while (body.length > 0) {
|
|
680
|
+
const last = body[body.length - 1];
|
|
681
|
+
if (last.length === 0 || ROUTEROS_PROMPT_RE.test(last)) {
|
|
682
|
+
body.pop();
|
|
683
|
+
continue;
|
|
684
|
+
}
|
|
685
|
+
break;
|
|
686
|
+
}
|
|
687
|
+
return body.join(`
|
|
688
|
+
`);
|
|
724
689
|
}
|
|
725
690
|
|
|
726
|
-
class
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
691
|
+
class MacTelnetConsole {
|
|
692
|
+
options;
|
|
693
|
+
session;
|
|
694
|
+
buffer = "";
|
|
695
|
+
ready = false;
|
|
696
|
+
closed = false;
|
|
697
|
+
closeError;
|
|
698
|
+
waiter;
|
|
699
|
+
tickTimer;
|
|
700
|
+
readyWaiters = [];
|
|
701
|
+
decoder = new TextDecoder;
|
|
702
|
+
probeTail = "";
|
|
703
|
+
constructor(options) {
|
|
704
|
+
this.options = {
|
|
705
|
+
rows: 9999,
|
|
706
|
+
cols: 512,
|
|
707
|
+
primeTimeoutMs: 30000,
|
|
708
|
+
commandTimeoutMs: 15000,
|
|
709
|
+
settleMs: 150,
|
|
710
|
+
acceptLicense: true,
|
|
711
|
+
...options
|
|
712
|
+
};
|
|
713
|
+
const createSession = options.createSession ?? ((init) => new MacTelnetSession(init));
|
|
714
|
+
this.session = createSession({
|
|
715
|
+
sink: options.sink,
|
|
716
|
+
sourceMac: options.sourceMac,
|
|
717
|
+
destinationMac: options.destinationMac,
|
|
718
|
+
username: options.username,
|
|
719
|
+
password: options.password,
|
|
720
|
+
sessionKey: options.sessionKey,
|
|
721
|
+
terminalType: "vt102",
|
|
722
|
+
terminalWidth: this.options.cols,
|
|
723
|
+
terminalHeight: this.options.rows,
|
|
724
|
+
onReady: () => this.onReady(),
|
|
725
|
+
onData: (bytes) => this.onData(bytes),
|
|
726
|
+
onClose: (error) => this.onClose(error)
|
|
727
|
+
});
|
|
733
728
|
}
|
|
734
|
-
|
|
735
|
-
this.lastError = undefined;
|
|
729
|
+
handlePacket(bytes) {
|
|
736
730
|
try {
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
const sequence = [...hops, this.opts];
|
|
741
|
-
let sock;
|
|
742
|
-
for (let i = 0;i < hops.length; i++) {
|
|
743
|
-
const hop = hops[i];
|
|
744
|
-
const client = await this.openClient(hop, sock);
|
|
745
|
-
this.bastions.push(client);
|
|
746
|
-
const next = sequence[i + 1];
|
|
747
|
-
const hopTimeout = hop.timeoutMs ?? this.opts.timeoutMs ?? 1e4;
|
|
748
|
-
sock = await this.forwardOut(client, next.host, next.port ?? 22, hopTimeout);
|
|
749
|
-
}
|
|
750
|
-
this.client = await this.openClient(this.opts, sock);
|
|
751
|
-
return true;
|
|
752
|
-
} catch (e) {
|
|
753
|
-
this.lastError = e instanceof Error ? e.message : String(e);
|
|
754
|
-
logger.error(`Failed to connect to MikroTik: ${this.lastError}`);
|
|
755
|
-
this.disconnect();
|
|
756
|
-
return false;
|
|
731
|
+
this.session.handlePacket(bytes);
|
|
732
|
+
} catch (error) {
|
|
733
|
+
this.onClose(error instanceof Error ? error : new Error("Failed to process a MAC-Telnet datagram."));
|
|
757
734
|
}
|
|
758
735
|
}
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
}
|
|
779
|
-
}
|
|
780
|
-
if (cfg.privateKey && o.keyPassphrase)
|
|
781
|
-
cfg.passphrase = o.keyPassphrase;
|
|
782
|
-
if (o.password)
|
|
783
|
-
cfg.password = o.password;
|
|
784
|
-
client.on("ready", () => resolve2(client)).on("error", (err) => reject(err)).connect(cfg);
|
|
785
|
-
});
|
|
736
|
+
async open() {
|
|
737
|
+
this.tickTimer = setInterval(() => {
|
|
738
|
+
try {
|
|
739
|
+
this.session.tick(Date.now());
|
|
740
|
+
} catch {}
|
|
741
|
+
}, TICK_INTERVAL_MS);
|
|
742
|
+
this.tickTimer.unref?.();
|
|
743
|
+
this.session.start();
|
|
744
|
+
await this.waitReady(this.options.primeTimeoutMs);
|
|
745
|
+
await this.waitFor((buffer) => this.endsWithPrompt(buffer) || LICENSE_RE.test(buffer), this.options.primeTimeoutMs, "waiting for the RouterOS console prompt");
|
|
746
|
+
if (this.options.acceptLicense && LICENSE_RE.test(this.buffer)) {
|
|
747
|
+
this.buffer = "";
|
|
748
|
+
this.session.sendInput(enc.encode("n\r"));
|
|
749
|
+
await this.waitFor((buffer) => this.endsWithPrompt(buffer), this.options.primeTimeoutMs, "waiting for the prompt after the license screen");
|
|
750
|
+
}
|
|
751
|
+
this.buffer = "";
|
|
752
|
+
this.session.sendInput(enc.encode("\r"));
|
|
753
|
+
await this.waitFor((buffer) => this.endsWithPrompt(buffer), this.options.commandTimeoutMs, "waiting for a clean prompt");
|
|
754
|
+
this.buffer = "";
|
|
786
755
|
}
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
settled = true;
|
|
795
|
-
reject(new Error(`jump host did not open a tunnel to ${host}:${port} within ${Math.round(timeoutMs / 1000)}s. ${enableHint}`));
|
|
796
|
-
}, timeoutMs);
|
|
797
|
-
via.forwardOut("127.0.0.1", 0, host, port, (err, stream) => {
|
|
798
|
-
if (settled)
|
|
799
|
-
return;
|
|
800
|
-
settled = true;
|
|
801
|
-
clearTimeout(timer);
|
|
802
|
-
if (err) {
|
|
803
|
-
reject(new Error(`jump host could not open a tunnel to ${host}:${port}: ${err.message}. ${enableHint}`));
|
|
804
|
-
} else {
|
|
805
|
-
resolve2(stream);
|
|
806
|
-
}
|
|
807
|
-
});
|
|
808
|
-
});
|
|
756
|
+
async run(cli) {
|
|
757
|
+
this.assertOpen();
|
|
758
|
+
this.buffer = "";
|
|
759
|
+
this.session.sendInput(enc.encode(`${cli}\r`));
|
|
760
|
+
await this.waitFor((buffer) => this.endsWithPrompt(buffer), this.options.commandTimeoutMs, `running over mac-telnet: ${cli}`);
|
|
761
|
+
const raw = this.buffer;
|
|
762
|
+
return { output: extractCommandOutput(raw, cli), raw };
|
|
809
763
|
}
|
|
810
|
-
|
|
811
|
-
if (
|
|
812
|
-
|
|
764
|
+
close() {
|
|
765
|
+
if (this.tickTimer) {
|
|
766
|
+
clearInterval(this.tickTimer);
|
|
767
|
+
this.tickTimer = undefined;
|
|
768
|
+
}
|
|
769
|
+
if (!this.closed)
|
|
770
|
+
this.session.end();
|
|
771
|
+
}
|
|
772
|
+
get isReady() {
|
|
773
|
+
return this.ready && !this.closed;
|
|
774
|
+
}
|
|
775
|
+
onReady() {
|
|
776
|
+
this.ready = true;
|
|
777
|
+
for (const w of this.readyWaiters.splice(0))
|
|
778
|
+
w.resolve();
|
|
779
|
+
}
|
|
780
|
+
onData(bytes) {
|
|
781
|
+
const chunk = this.decoder.decode(bytes, { stream: true });
|
|
782
|
+
const combined = `${this.probeTail}${chunk}`;
|
|
783
|
+
this.answerSizeProbe(combined);
|
|
784
|
+
this.probeTail = combined.slice(-3);
|
|
785
|
+
this.buffer += chunk;
|
|
786
|
+
this.checkWaiter();
|
|
787
|
+
}
|
|
788
|
+
onClose(error) {
|
|
789
|
+
this.closed = true;
|
|
790
|
+
this.closeError = error;
|
|
791
|
+
if (this.tickTimer) {
|
|
792
|
+
clearInterval(this.tickTimer);
|
|
793
|
+
this.tickTimer = undefined;
|
|
794
|
+
}
|
|
795
|
+
const failure = error ?? new Error("The MAC-Telnet console session closed.");
|
|
796
|
+
for (const w of this.readyWaiters.splice(0))
|
|
797
|
+
w.reject(failure);
|
|
798
|
+
if (this.waiter) {
|
|
799
|
+
const waiter = this.waiter;
|
|
800
|
+
this.waiter = undefined;
|
|
801
|
+
clearTimeout(waiter.timeout);
|
|
802
|
+
if (waiter.settle)
|
|
803
|
+
clearTimeout(waiter.settle);
|
|
804
|
+
waiter.reject(failure);
|
|
813
805
|
}
|
|
814
|
-
const openChannel = this.client.exec.bind(this.client);
|
|
815
|
-
return new Promise((resolve2, reject) => {
|
|
816
|
-
openChannel(command, (err, stream) => {
|
|
817
|
-
if (err) {
|
|
818
|
-
reject(err);
|
|
819
|
-
return;
|
|
820
|
-
}
|
|
821
|
-
const stdout = [];
|
|
822
|
-
const stderrBuf = [];
|
|
823
|
-
let settled = false;
|
|
824
|
-
let timer;
|
|
825
|
-
let idleTimer;
|
|
826
|
-
const clearTimers = () => {
|
|
827
|
-
if (timer)
|
|
828
|
-
clearTimeout(timer);
|
|
829
|
-
if (idleTimer)
|
|
830
|
-
clearTimeout(idleTimer);
|
|
831
|
-
};
|
|
832
|
-
const finish = () => {
|
|
833
|
-
if (settled)
|
|
834
|
-
return;
|
|
835
|
-
settled = true;
|
|
836
|
-
clearTimers();
|
|
837
|
-
const out = decodeOutput(Buffer.concat(stdout));
|
|
838
|
-
const error = decodeOutput(Buffer.concat(stderrBuf));
|
|
839
|
-
resolve2(error && !out ? error : out);
|
|
840
|
-
};
|
|
841
|
-
const fail = (e) => {
|
|
842
|
-
if (settled)
|
|
843
|
-
return;
|
|
844
|
-
settled = true;
|
|
845
|
-
clearTimers();
|
|
846
|
-
try {
|
|
847
|
-
stream.close();
|
|
848
|
-
} catch {}
|
|
849
|
-
reject(e);
|
|
850
|
-
};
|
|
851
|
-
const armIdle = () => {
|
|
852
|
-
if (idleTimer)
|
|
853
|
-
clearTimeout(idleTimer);
|
|
854
|
-
idleTimer = setTimeout(() => {
|
|
855
|
-
try {
|
|
856
|
-
stream.signal("INT");
|
|
857
|
-
} catch {}
|
|
858
|
-
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)}`));
|
|
859
|
-
}, RUN_IDLE_TIMEOUT_MS);
|
|
860
|
-
};
|
|
861
|
-
armIdle();
|
|
862
|
-
if (opts.maxMs && opts.maxMs > 0) {
|
|
863
|
-
timer = setTimeout(() => {
|
|
864
|
-
try {
|
|
865
|
-
stream.signal("INT");
|
|
866
|
-
} catch {}
|
|
867
|
-
try {
|
|
868
|
-
stream.close();
|
|
869
|
-
} catch {}
|
|
870
|
-
finish();
|
|
871
|
-
}, opts.maxMs);
|
|
872
|
-
}
|
|
873
|
-
stream.on("close", finish).on("data", (d) => {
|
|
874
|
-
stdout.push(d);
|
|
875
|
-
armIdle();
|
|
876
|
-
}).stderr.on("data", (d) => {
|
|
877
|
-
stderrBuf.push(d);
|
|
878
|
-
armIdle();
|
|
879
|
-
});
|
|
880
|
-
});
|
|
881
|
-
});
|
|
882
806
|
}
|
|
883
|
-
|
|
884
|
-
if (
|
|
885
|
-
|
|
807
|
+
answerSizeProbe(chunk) {
|
|
808
|
+
if (chunk.includes(`${ESC}[6n`)) {
|
|
809
|
+
this.session.sendInput(enc.encode(`${ESC}[${this.options.rows};${this.options.cols}R`));
|
|
810
|
+
}
|
|
811
|
+
if (chunk.includes(`${ESC}Z`) || chunk.includes(`${ESC}[c`)) {
|
|
812
|
+
this.session.sendInput(enc.encode(`${ESC}[?6c`));
|
|
886
813
|
}
|
|
887
|
-
const openSftp = this.client.sftp.bind(this.client);
|
|
888
|
-
return new Promise((resolve2, reject) => {
|
|
889
|
-
openSftp((err, sftp) => {
|
|
890
|
-
if (err) {
|
|
891
|
-
reject(new Error(`SFTP subsystem unavailable: ${err.message}`));
|
|
892
|
-
return;
|
|
893
|
-
}
|
|
894
|
-
sftp.writeFile(remotePath, data, (werr) => {
|
|
895
|
-
try {
|
|
896
|
-
sftp.end();
|
|
897
|
-
} catch {}
|
|
898
|
-
if (werr)
|
|
899
|
-
reject(new Error(`SFTP write failed: ${werr.message}`));
|
|
900
|
-
else
|
|
901
|
-
resolve2();
|
|
902
|
-
});
|
|
903
|
-
});
|
|
904
|
-
});
|
|
905
814
|
}
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
815
|
+
endsWithPrompt(buffer) {
|
|
816
|
+
const lines = emulateScreen(buffer).filter((line) => line.length > 0);
|
|
817
|
+
return ROUTEROS_PROMPT_RE.test(lines[lines.length - 1] ?? "");
|
|
818
|
+
}
|
|
819
|
+
waitReady(timeoutMs) {
|
|
820
|
+
if (this.ready)
|
|
821
|
+
return Promise.resolve();
|
|
822
|
+
if (this.closed) {
|
|
823
|
+
return Promise.reject(this.closeError ?? new Error("The MAC-Telnet session closed before login completed."));
|
|
909
824
|
}
|
|
910
|
-
const openSftp = this.client.sftp.bind(this.client);
|
|
911
825
|
return new Promise((resolve2, reject) => {
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
826
|
+
let timer;
|
|
827
|
+
const entry = {
|
|
828
|
+
resolve: () => {
|
|
829
|
+
clearTimeout(timer);
|
|
830
|
+
resolve2();
|
|
831
|
+
},
|
|
832
|
+
reject: (error) => {
|
|
833
|
+
clearTimeout(timer);
|
|
834
|
+
reject(error);
|
|
916
835
|
}
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
else
|
|
924
|
-
resolve2(data);
|
|
925
|
-
});
|
|
926
|
-
});
|
|
836
|
+
};
|
|
837
|
+
timer = setTimeout(() => {
|
|
838
|
+
this.readyWaiters = this.readyWaiters.filter((w) => w !== entry);
|
|
839
|
+
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."));
|
|
840
|
+
}, timeoutMs);
|
|
841
|
+
this.readyWaiters.push(entry);
|
|
927
842
|
});
|
|
928
843
|
}
|
|
929
|
-
|
|
930
|
-
if (
|
|
931
|
-
return Promise.reject(new Error(
|
|
844
|
+
waitFor(predicate, timeoutMs, label) {
|
|
845
|
+
if (this.closed) {
|
|
846
|
+
return Promise.reject(this.closeError ?? new Error(`MAC-Telnet session closed while ${label}.`));
|
|
932
847
|
}
|
|
933
|
-
const openShell = this.client.shell.bind(this.client);
|
|
934
848
|
return new Promise((resolve2, reject) => {
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
849
|
+
const timeout = setTimeout(() => {
|
|
850
|
+
this.waiter = undefined;
|
|
851
|
+
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."));
|
|
852
|
+
}, timeoutMs);
|
|
853
|
+
this.waiter = { predicate, resolve: resolve2, reject, timeout };
|
|
854
|
+
this.checkWaiter();
|
|
940
855
|
});
|
|
941
856
|
}
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
857
|
+
checkWaiter() {
|
|
858
|
+
const waiter = this.waiter;
|
|
859
|
+
if (!waiter)
|
|
860
|
+
return;
|
|
861
|
+
if (!waiter.predicate(this.buffer)) {
|
|
862
|
+
if (waiter.settle) {
|
|
863
|
+
clearTimeout(waiter.settle);
|
|
864
|
+
waiter.settle = undefined;
|
|
865
|
+
}
|
|
866
|
+
return;
|
|
948
867
|
}
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
868
|
+
if (waiter.settle)
|
|
869
|
+
clearTimeout(waiter.settle);
|
|
870
|
+
waiter.settle = setTimeout(() => {
|
|
871
|
+
if (this.waiter !== waiter)
|
|
872
|
+
return;
|
|
873
|
+
this.waiter = undefined;
|
|
874
|
+
clearTimeout(waiter.timeout);
|
|
875
|
+
waiter.resolve();
|
|
876
|
+
}, this.options.settleMs);
|
|
877
|
+
}
|
|
878
|
+
assertOpen() {
|
|
879
|
+
if (!this.ready || this.closed) {
|
|
880
|
+
throw new Error("The MAC-Telnet console is not open. Call open() and await it before running commands.");
|
|
953
881
|
}
|
|
954
|
-
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
// src/mac-telnet/client.ts
|
|
886
|
+
import {
|
|
887
|
+
DEFAULT_MAC_TELNET_BROADCAST,
|
|
888
|
+
isBroadcastHost,
|
|
889
|
+
MAC_TELNET_PORT,
|
|
890
|
+
createUdpMacTelnetTransport,
|
|
891
|
+
formatMac,
|
|
892
|
+
listBroadcastInterfaces,
|
|
893
|
+
parseMac,
|
|
894
|
+
resolveMacTelnetRoute
|
|
895
|
+
} from "@tikoci/centrs/protocols";
|
|
896
|
+
|
|
897
|
+
class MikroTikMacTelnetClient {
|
|
898
|
+
transport = null;
|
|
899
|
+
console = null;
|
|
900
|
+
opts;
|
|
901
|
+
lastError;
|
|
902
|
+
routeHint;
|
|
903
|
+
constructor(opts) {
|
|
904
|
+
this.opts = {
|
|
905
|
+
port: MAC_TELNET_PORT,
|
|
906
|
+
timeoutMs: 1e4,
|
|
907
|
+
...opts
|
|
908
|
+
};
|
|
909
|
+
}
|
|
910
|
+
async connect() {
|
|
911
|
+
this.lastError = undefined;
|
|
912
|
+
try {
|
|
913
|
+
const destinationMac = parseMac(this.opts.mac);
|
|
914
|
+
const explicitSourceMac = this.opts.sourceMac ? parseMac(this.opts.sourceMac) : undefined;
|
|
915
|
+
const host = this.opts.host ?? DEFAULT_MAC_TELNET_BROADCAST;
|
|
916
|
+
const askedDiscovery = host === DEFAULT_MAC_TELNET_BROADCAST && !explicitSourceMac;
|
|
917
|
+
const candidates = askedDiscovery ? listBroadcastInterfaces() : [];
|
|
918
|
+
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?)";
|
|
919
|
+
if (askedDiscovery)
|
|
920
|
+
logger.info(`[mac-telnet] discovery candidates: ${candidatesText}`);
|
|
921
|
+
logger.info(`[mac-telnet] resolving route to ${this.opts.mac}\u2026`);
|
|
922
|
+
const route = await resolveMacTelnetRoute({
|
|
923
|
+
destinationMac,
|
|
924
|
+
host,
|
|
925
|
+
port: this.opts.port,
|
|
926
|
+
timeoutMs: this.opts.timeoutMs,
|
|
927
|
+
explicitSourceMac
|
|
928
|
+
});
|
|
929
|
+
const discoveryFailed = askedDiscovery && route.host === DEFAULT_MAC_TELNET_BROADCAST;
|
|
930
|
+
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;
|
|
931
|
+
logger.info(`[mac-telnet] route: source ${formatMac(route.sourceMac)} \u2192 ${route.host} ` + `(${explicitSourceMac ? "explicit" : discoveryFailed ? "DISCOVERY FAILED \u2014 limited broadcast" : "discovered"})`);
|
|
932
|
+
const transport = createUdpMacTelnetTransport({
|
|
933
|
+
host: route.host,
|
|
934
|
+
port: this.opts.port,
|
|
935
|
+
broadcast: isBroadcastHost(route.host)
|
|
936
|
+
});
|
|
937
|
+
this.transport = transport;
|
|
938
|
+
await transport.ready();
|
|
939
|
+
logger.info(`[mac-telnet] socket ready; starting login (this can take ~10\u201330s)\u2026`);
|
|
940
|
+
const console = new MacTelnetConsole({
|
|
941
|
+
sink: transport,
|
|
942
|
+
sourceMac: route.sourceMac,
|
|
943
|
+
destinationMac,
|
|
944
|
+
username: this.opts.username,
|
|
945
|
+
password: this.opts.password ?? "",
|
|
946
|
+
primeTimeoutMs: Math.max(this.opts.timeoutMs, 30000),
|
|
947
|
+
commandTimeoutMs: Math.max(this.opts.timeoutMs, 15000)
|
|
948
|
+
});
|
|
949
|
+
this.console = console;
|
|
950
|
+
transport.onMessage((bytes) => console.handlePacket(bytes));
|
|
951
|
+
await console.open();
|
|
952
|
+
return true;
|
|
953
|
+
} catch (e) {
|
|
954
|
+
const base = e instanceof Error ? e.message : String(e);
|
|
955
|
+
this.lastError = this.routeHint ? `${base} (${this.routeHint})` : base;
|
|
956
|
+
logger.error(`Failed to connect to MikroTik over MAC-Telnet: ${this.lastError}`);
|
|
957
|
+
this.disconnect();
|
|
958
|
+
return false;
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
async run(command, _opts = {}) {
|
|
962
|
+
if (!this.console || !this.console.isReady) {
|
|
963
|
+
throw new Error("Not connected to MikroTik device (MAC-Telnet)");
|
|
964
|
+
}
|
|
965
|
+
const { output } = await this.console.run(command);
|
|
966
|
+
return output;
|
|
967
|
+
}
|
|
968
|
+
disconnect() {
|
|
969
|
+
try {
|
|
970
|
+
this.console?.close();
|
|
971
|
+
} catch {}
|
|
972
|
+
try {
|
|
973
|
+
this.transport?.close();
|
|
974
|
+
} catch {}
|
|
975
|
+
this.console = null;
|
|
976
|
+
this.transport = null;
|
|
955
977
|
}
|
|
956
978
|
}
|
|
957
979
|
|
|
@@ -1019,6 +1041,115 @@ function connectErrorMessage(name, dc, lastError) {
|
|
|
1019
1041
|
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.";
|
|
1020
1042
|
}
|
|
1021
1043
|
|
|
1044
|
+
// src/core/connection-pool.ts
|
|
1045
|
+
var entries = new Map;
|
|
1046
|
+
var connecting = new Map;
|
|
1047
|
+
function isPoolEnabled() {
|
|
1048
|
+
return getConfig().ssh.keepAlive;
|
|
1049
|
+
}
|
|
1050
|
+
function poolConfig() {
|
|
1051
|
+
const cfg = getConfig().ssh;
|
|
1052
|
+
return {
|
|
1053
|
+
keepAliveInterval: cfg.keepAliveInterval,
|
|
1054
|
+
keepAliveCountMax: 3,
|
|
1055
|
+
idleTimeout: cfg.idleTimeout
|
|
1056
|
+
};
|
|
1057
|
+
}
|
|
1058
|
+
function removeEntry(name) {
|
|
1059
|
+
const entry = entries.get(name);
|
|
1060
|
+
if (!entry)
|
|
1061
|
+
return;
|
|
1062
|
+
if (entry.idleTimer)
|
|
1063
|
+
clearTimeout(entry.idleTimer);
|
|
1064
|
+
entry.dead = true;
|
|
1065
|
+
entry.client.disconnect();
|
|
1066
|
+
entries.delete(name);
|
|
1067
|
+
logger.info(`SSH pool: closed connection to '${name}'`);
|
|
1068
|
+
}
|
|
1069
|
+
function armIdle(name, entry) {
|
|
1070
|
+
if (entry.idleTimer)
|
|
1071
|
+
clearTimeout(entry.idleTimer);
|
|
1072
|
+
if (entry.inflight > 0 || entry.dead)
|
|
1073
|
+
return;
|
|
1074
|
+
const { idleTimeout } = poolConfig();
|
|
1075
|
+
entry.idleTimer = setTimeout(() => {
|
|
1076
|
+
logger.info(`SSH pool: closing idle connection to '${name}' (${idleTimeout / 1000}s idle)`);
|
|
1077
|
+
removeEntry(name);
|
|
1078
|
+
}, idleTimeout);
|
|
1079
|
+
}
|
|
1080
|
+
function isConnectionError(msg) {
|
|
1081
|
+
return /not connected|ECONNRESET|EPIPE|socket.*(close|end|destroy)|channel.*(close|open)|timed out.*handshake/i.test(msg);
|
|
1082
|
+
}
|
|
1083
|
+
async function doConnect(name) {
|
|
1084
|
+
const dc = getDevice(name);
|
|
1085
|
+
const cfg = poolConfig();
|
|
1086
|
+
const client = new MikroTikSSHClient({
|
|
1087
|
+
...sshOptionsOf(dc),
|
|
1088
|
+
jump: resolveJump(dc),
|
|
1089
|
+
keepAliveInterval: cfg.keepAliveInterval,
|
|
1090
|
+
keepAliveCountMax: cfg.keepAliveCountMax
|
|
1091
|
+
});
|
|
1092
|
+
if (!await client.connect()) {
|
|
1093
|
+
throw new Error(connectErrorMessage(name, dc, client.lastError));
|
|
1094
|
+
}
|
|
1095
|
+
const entry = {
|
|
1096
|
+
client,
|
|
1097
|
+
inflight: 0,
|
|
1098
|
+
idleTimer: undefined,
|
|
1099
|
+
dead: false
|
|
1100
|
+
};
|
|
1101
|
+
entries.set(name, entry);
|
|
1102
|
+
logger.info(`SSH pool: opened persistent connection to '${name}'`);
|
|
1103
|
+
armIdle(name, entry);
|
|
1104
|
+
return entry;
|
|
1105
|
+
}
|
|
1106
|
+
async function acquire(name) {
|
|
1107
|
+
const existing = entries.get(name);
|
|
1108
|
+
if (existing && !existing.dead)
|
|
1109
|
+
return existing;
|
|
1110
|
+
if (existing)
|
|
1111
|
+
removeEntry(name);
|
|
1112
|
+
const pending = connecting.get(name);
|
|
1113
|
+
if (pending)
|
|
1114
|
+
return pending;
|
|
1115
|
+
const promise = doConnect(name);
|
|
1116
|
+
connecting.set(name, promise);
|
|
1117
|
+
try {
|
|
1118
|
+
return await promise;
|
|
1119
|
+
} catch (e) {
|
|
1120
|
+
removeEntry(name);
|
|
1121
|
+
throw e;
|
|
1122
|
+
} finally {
|
|
1123
|
+
connecting.delete(name);
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
async function runOnEntry(name, command, opts) {
|
|
1127
|
+
const entry = await acquire(name);
|
|
1128
|
+
if (entry.idleTimer) {
|
|
1129
|
+
clearTimeout(entry.idleTimer);
|
|
1130
|
+
entry.idleTimer = undefined;
|
|
1131
|
+
}
|
|
1132
|
+
entry.inflight++;
|
|
1133
|
+
try {
|
|
1134
|
+
return await entry.client.run(command, opts);
|
|
1135
|
+
} finally {
|
|
1136
|
+
entry.inflight--;
|
|
1137
|
+
armIdle(name, entry);
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
async function runPooled(command, deviceName, opts) {
|
|
1141
|
+
try {
|
|
1142
|
+
return await runOnEntry(deviceName, command, opts);
|
|
1143
|
+
} catch (e) {
|
|
1144
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
1145
|
+
if (!isConnectionError(msg))
|
|
1146
|
+
throw e;
|
|
1147
|
+
logger.info(`SSH pool: connection to '${deviceName}' lost (${msg}), reconnecting`);
|
|
1148
|
+
removeEntry(deviceName);
|
|
1149
|
+
return runOnEntry(deviceName, command, opts);
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1022
1153
|
// src/ssh/safe-mode.ts
|
|
1023
1154
|
var PROMPT_RE = /\[.+?@.+?\] (?:<SAFE> )?> ?$/m;
|
|
1024
1155
|
var ANSI_RE = /\x1B(?:\[[0-9;]*[mA-HJ-MSTfhilnprsu]|[()][0-9A-Za-z]|\[?\?\d+[hl])/g;
|
|
@@ -1239,6 +1370,9 @@ function getSafeModeManager(deviceName) {
|
|
|
1239
1370
|
async function runOnce(command, deviceName, opts) {
|
|
1240
1371
|
const name = resolveDeviceName(deviceName);
|
|
1241
1372
|
const dc = getDevice(deviceName);
|
|
1373
|
+
if (!isMacTelnetDevice(dc) && isPoolEnabled()) {
|
|
1374
|
+
return runPooled(command, name, opts);
|
|
1375
|
+
}
|
|
1242
1376
|
const client = createDeviceClient(dc);
|
|
1243
1377
|
try {
|
|
1244
1378
|
if (!await client.connect()) {
|
|
@@ -1317,7 +1451,7 @@ function quoteValue(value) {
|
|
|
1317
1451
|
return String(value);
|
|
1318
1452
|
if (value !== "" && BARE_SAFE.test(value))
|
|
1319
1453
|
return value;
|
|
1320
|
-
const escaped = value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/\r/g, "\\r").replace(/\n/g, "\\n").replace(/\t/g, "\\t");
|
|
1454
|
+
const escaped = value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/\$/g, "\\$").replace(/\r/g, "\\r").replace(/\n/g, "\\n").replace(/\t/g, "\\t");
|
|
1321
1455
|
return `"${escaped}"`;
|
|
1322
1456
|
}
|
|
1323
1457
|
function yesno(value) {
|
|
@@ -6038,7 +6172,7 @@ function expandQuery(tokens) {
|
|
|
6038
6172
|
return out;
|
|
6039
6173
|
}
|
|
6040
6174
|
function buildToolIndex(tools) {
|
|
6041
|
-
const
|
|
6175
|
+
const entries2 = tools.map((t) => {
|
|
6042
6176
|
const nameTokens = tokenSet(t.name);
|
|
6043
6177
|
const titleTokens = tokenSet(t.title);
|
|
6044
6178
|
const moduleTokens = new Set([...tokenize(t.module), ...tokenize(t.group)]);
|
|
@@ -6047,11 +6181,11 @@ function buildToolIndex(tools) {
|
|
|
6047
6181
|
return { ...t, nameTokens, titleTokens, moduleTokens, descTokens, allTokens };
|
|
6048
6182
|
});
|
|
6049
6183
|
const df = new Map;
|
|
6050
|
-
for (const e of
|
|
6184
|
+
for (const e of entries2) {
|
|
6051
6185
|
for (const tok of e.allTokens)
|
|
6052
6186
|
df.set(tok, (df.get(tok) ?? 0) + 1);
|
|
6053
6187
|
}
|
|
6054
|
-
return { entries, df, n:
|
|
6188
|
+
return { entries: entries2, df, n: entries2.length };
|
|
6055
6189
|
}
|
|
6056
6190
|
var FIELD_WEIGHT = { name: 6, title: 3.5, module: 3, desc: 1.5 };
|
|
6057
6191
|
function idf(df, n, token) {
|
|
@@ -6117,7 +6251,7 @@ var cache = null;
|
|
|
6117
6251
|
async function gateway() {
|
|
6118
6252
|
if (cache)
|
|
6119
6253
|
return cache;
|
|
6120
|
-
const { moduleCatalog } = await import("./library-
|
|
6254
|
+
const { moduleCatalog } = await import("./library-hjh5hy5d.js");
|
|
6121
6255
|
const forIndex = [];
|
|
6122
6256
|
const byName = new Map;
|
|
6123
6257
|
for (const mod of moduleCatalog) {
|
|
@@ -25714,4 +25848,4 @@ function selectToolModules(filter = {}, catalog = moduleCatalog) {
|
|
|
25714
25848
|
}).map((m) => m.tools);
|
|
25715
25849
|
}
|
|
25716
25850
|
|
|
25717
|
-
export { DeviceConfigSchema, MikrotikConfigSchema, loadConfig, setConfig, getConfig, listDevices, deviceLabels, resolveDeviceName, deviceDirectory, getDevice,
|
|
25851
|
+
export { DeviceConfigSchema, MikrotikConfigSchema, loadConfig, logger, MikroTikSSHClient, setConfig, getConfig, listDevices, deviceLabels, resolveDeviceName, deviceDirectory, getDevice, SafeModeManager, getSafeModeManager, executeMikrotikCommand, defineTool, registerTools, PROMPTS_DIR, registerUiResources, moduleCatalog, allToolModules, ALWAYS_ON_MODULES, selectToolModules };
|