@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.
@@ -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/config.ts
7
+ // src/ssh/client.ts
8
8
  import { readFileSync } from "fs";
9
- import { homedir } from "os";
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/mac-telnet/console.ts
362
- import { MacTelnetSession } from "@tikoci/centrs/protocols";
363
- var ESC = "\x1B";
364
- var enc = new TextEncoder;
365
- var ANSI_CSI = new RegExp(`${ESC}\\[[0-9;?]*[ -/]*[@-~]`, "g");
366
- var ANSI_ESC2 = new RegExp(`${ESC}[@-_]`, "g");
367
- var ROUTEROS_PROMPT_RE = /\[[^\]@\r\n]+@[^\]\r\n]*\][^\r\n]*>\s*$/;
368
- var TICK_INTERVAL_MS = 15;
369
- var LICENSE_RE = /do you want to see the software license/i;
370
- function emulateScreen(text) {
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
- async run(cli) {
487
- this.assertOpen();
488
- this.buffer = "";
489
- this.session.sendInput(enc.encode(`${cli}\r`));
490
- await this.waitFor((buffer) => this.endsWithPrompt(buffer), this.options.commandTimeoutMs, `running over mac-telnet: ${cli}`);
491
- const raw = this.buffer;
492
- return { output: extractCommandOutput(raw, cli), raw };
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
- close() {
495
- if (this.tickTimer) {
496
- clearInterval(this.tickTimer);
497
- this.tickTimer = undefined;
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
- onReady() {
506
- this.ready = true;
507
- for (const w of this.readyWaiters.splice(0))
508
- w.resolve();
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
- onData(bytes) {
511
- const chunk = this.decoder.decode(bytes, { stream: true });
512
- const combined = `${this.probeTail}${chunk}`;
513
- this.answerSizeProbe(combined);
514
- this.probeTail = combined.slice(-3);
515
- this.buffer += chunk;
516
- this.checkWaiter();
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
- onClose(error) {
519
- this.closed = true;
520
- this.closeError = error;
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
- answerSizeProbe(chunk) {
538
- if (chunk.includes(`${ESC}[6n`)) {
539
- this.session.sendInput(enc.encode(`${ESC}[${this.options.rows};${this.options.cols}R`));
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
- endsWithPrompt(buffer) {
546
- const lines = emulateScreen(buffer).filter((line) => line.length > 0);
547
- return ROUTEROS_PROMPT_RE.test(lines[lines.length - 1] ?? "");
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
- return new Promise((resolve2, reject) => {
556
- let timer;
557
- const entry = {
558
- resolve: () => {
559
- clearTimeout(timer);
560
- resolve2();
561
- },
562
- reject: (error) => {
563
- clearTimeout(timer);
564
- 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;
565
251
  }
566
- };
567
- timer = setTimeout(() => {
568
- this.readyWaiters = this.readyWaiters.filter((w) => w !== entry);
569
- 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."));
570
- }, timeoutMs);
571
- this.readyWaiters.push(entry);
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
- waitFor(predicate, timeoutMs, label) {
575
- if (this.closed) {
576
- return Promise.reject(this.closeError ?? new Error(`MAC-Telnet session closed while ${label}.`));
264
+ shell(opts = {}) {
265
+ if (!this.client) {
266
+ return Promise.reject(new Error("Not connected to MikroTik device"));
577
267
  }
578
- return new Promise((resolve2, reject) => {
579
- const timeout = setTimeout(() => {
580
- this.waiter = undefined;
581
- 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."));
582
- }, timeoutMs);
583
- this.waiter = { predicate, resolve: resolve2, reject, timeout };
584
- this.checkWaiter();
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
- checkWaiter() {
588
- const waiter = this.waiter;
589
- if (!waiter)
590
- return;
591
- if (!waiter.predicate(this.buffer)) {
592
- if (waiter.settle) {
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
- if (waiter.settle)
599
- clearTimeout(waiter.settle);
600
- waiter.settle = setTimeout(() => {
601
- if (this.waiter !== waiter)
602
- return;
603
- this.waiter = undefined;
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
- assertOpen() {
609
- if (!this.ready || this.closed) {
610
- throw new Error("The MAC-Telnet console is not open. Call open() and await it before running commands.");
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/mac-telnet/client.ts
616
- import {
617
- DEFAULT_MAC_TELNET_BROADCAST,
618
- isBroadcastHost,
619
- MAC_TELNET_PORT,
620
- createUdpMacTelnetTransport,
621
- formatMac,
622
- listBroadcastInterfaces,
623
- parseMac,
624
- resolveMacTelnetRoute
625
- } from "@tikoci/centrs/protocols";
626
-
627
- class MikroTikMacTelnetClient {
628
- transport = null;
629
- console = null;
630
- opts;
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
- async connect() {
641
- this.lastError = undefined;
642
- try {
643
- const destinationMac = parseMac(this.opts.mac);
644
- const explicitSourceMac = this.opts.sourceMac ? parseMac(this.opts.sourceMac) : undefined;
645
- const host = this.opts.host ?? DEFAULT_MAC_TELNET_BROADCAST;
646
- const askedDiscovery = host === DEFAULT_MAC_TELNET_BROADCAST && !explicitSourceMac;
647
- const candidates = askedDiscovery ? listBroadcastInterfaces() : [];
648
- 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?)";
649
- if (askedDiscovery)
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
- async run(command, _opts = {}) {
692
- if (!this.console || !this.console.isReady) {
693
- throw new Error("Not connected to MikroTik device (MAC-Telnet)");
694
- }
695
- const { output } = await this.console.run(command);
696
- return output;
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
- disconnect() {
699
- try {
700
- this.console?.close();
701
- } catch {}
702
- try {
703
- this.transport?.close();
704
- } catch {}
705
- this.console = null;
706
- this.transport = null;
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/ssh/client.ts
711
- import { readFileSync as readFileSync2 } from "fs";
712
- import { Client } from "ssh2";
713
- var RUN_IDLE_TIMEOUT_MS = 60000;
714
- function decodeOutput(data) {
715
- if (!data || data.length === 0)
716
- return "";
717
- const encodings = ["utf-8", "windows-1252", "latin1"];
718
- for (const encoding of encodings) {
719
- try {
720
- return new TextDecoder(encoding, { fatal: true }).decode(data);
721
- } catch {}
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
- return new TextDecoder("utf-8").decode(data);
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 MikroTikSSHClient {
727
- client = null;
728
- bastions = [];
729
- opts;
730
- lastError;
731
- constructor(opts) {
732
- this.opts = { port: 22, timeoutMs: 1e4, ...opts };
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
- async connect() {
735
- this.lastError = undefined;
729
+ handlePacket(bytes) {
736
730
  try {
737
- const hops = [];
738
- for (let j = this.opts.jump;j; j = j.jump)
739
- hops.unshift(j);
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
- openClient(o, sock) {
760
- return new Promise((resolve2, reject) => {
761
- const client = new Client;
762
- const cfg = {
763
- host: o.host,
764
- port: o.port ?? 22,
765
- username: o.username,
766
- readyTimeout: o.timeoutMs ?? 1e4
767
- };
768
- if (sock)
769
- cfg.sock = sock;
770
- if (o.privateKey) {
771
- cfg.privateKey = o.privateKey;
772
- } else if (o.keyFilename) {
773
- try {
774
- cfg.privateKey = readFileSync2(o.keyFilename);
775
- } catch (e) {
776
- reject(new Error(`could not read key file ${o.keyFilename}: ${e instanceof Error ? e.message : String(e)}`));
777
- return;
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
- forwardOut(via, host, port, timeoutMs) {
788
- return new Promise((resolve2, reject) => {
789
- let settled = false;
790
- 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.";
791
- const timer = setTimeout(() => {
792
- if (settled)
793
- return;
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
- run(command, opts = {}) {
811
- if (!this.client) {
812
- return Promise.reject(new Error("Not connected to MikroTik device"));
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
- uploadFile(remotePath, data) {
884
- if (!this.client) {
885
- return Promise.reject(new Error("Not connected to MikroTik device"));
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
- downloadFile(remotePath) {
907
- if (!this.client) {
908
- return Promise.reject(new Error("Not connected to MikroTik device"));
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
- openSftp((err, sftp) => {
913
- if (err) {
914
- reject(new Error(`SFTP subsystem unavailable: ${err.message}`));
915
- return;
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
- sftp.readFile(remotePath, (rerr, data) => {
918
- try {
919
- sftp.end();
920
- } catch {}
921
- if (rerr)
922
- reject(new Error(`SFTP read failed: ${rerr.message}`));
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
- shell(opts = {}) {
930
- if (!this.client) {
931
- return Promise.reject(new Error("Not connected to MikroTik device"));
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
- openShell({
936
- term: opts.term ?? "dumb",
937
- cols: opts.cols ?? 220,
938
- rows: opts.rows ?? 50
939
- }, (err, stream) => err ? reject(err) : resolve2(stream));
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
- disconnect() {
943
- if (this.client) {
944
- try {
945
- this.client.end();
946
- } catch {}
947
- this.client = null;
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
- for (const b of this.bastions.reverse()) {
950
- try {
951
- b.end();
952
- } catch {}
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
- this.bastions = [];
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 entries = tools.map((t) => {
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 entries) {
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: entries.length };
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-2s6sp2r5.js");
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) {
@@ -18123,21 +18257,34 @@ ${result}`;
18123
18257
  name: "check_route_path",
18124
18258
  title: "Check IPv4 Route Path",
18125
18259
  annotations: READ,
18126
- description: "Resolves which nexthop RouterOS would use for a given IPv4 destination (`/ip route check`) " + '\u2014 answers "which gateway will this packet take?" without sending any traffic. ' + "Optionally scoped by `source` address and `routing_mark` 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.",
18260
+ 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.",
18127
18261
  inputSchema: {
18128
18262
  destination: z83.string(),
18129
- source: z83.string().optional(),
18130
- routing_mark: z83.string().optional()
18263
+ routing_table: z83.string().optional().describe('Policy-routing table name (v7) or routing-mark (v6), e.g. "VPN"')
18131
18264
  },
18132
18265
  async handler(a, ctx) {
18133
18266
  ctx.info(`Checking route path to: ${a.destination}`);
18134
- const cmd = new Cmd(`/ip route check ${a.destination}`).opt("src-address", a.source).opt("routing-mark", a.routing_mark).build();
18135
- const result = await executeMikrotikCommand(cmd, ctx);
18136
- if (!result)
18137
- return `Unable to check route to ${a.destination}`;
18267
+ const table = a.routing_table;
18268
+ const v6Cmd = new Cmd(`/ip route check ${a.destination}`).opt("routing-mark", table).build();
18269
+ const v6Result = await executeMikrotikCommand(v6Cmd, ctx);
18270
+ if (!commandUnsupported(v6Result) && !looksLikeError(v6Result) && !isEmpty(v6Result)) {
18271
+ return `ROUTE PATH TO ${a.destination}:
18272
+
18273
+ ${v6Result}`;
18274
+ }
18275
+ const where = [`dst-address in ${a.destination}`, "active=yes"];
18276
+ if (table)
18277
+ where.push(`routing-table=${quoteValue(table)}`);
18278
+ const v7Cmd = `/ip route print detail where ${where.join(" ")}`;
18279
+ const v7Result = await executeMikrotikCommand(v7Cmd, ctx);
18280
+ if (looksLikeError(v7Result))
18281
+ return `Failed to check route to ${a.destination}: ${v7Result}`;
18282
+ if (isEmpty(v7Result)) {
18283
+ return `No active route to ${a.destination}${table ? ` in table '${table}'` : ""}.`;
18284
+ }
18138
18285
  return `ROUTE PATH TO ${a.destination}:
18139
18286
 
18140
- ${result}`;
18287
+ ${v7Result}`;
18141
18288
  }
18142
18289
  }),
18143
18290
  defineTool({
@@ -25701,4 +25848,4 @@ function selectToolModules(filter = {}, catalog = moduleCatalog) {
25701
25848
  }).map((m) => m.tools);
25702
25849
  }
25703
25850
 
25704
- export { DeviceConfigSchema, MikrotikConfigSchema, loadConfig, setConfig, getConfig, listDevices, deviceLabels, resolveDeviceName, deviceDirectory, getDevice, logger, MikroTikSSHClient, SafeModeManager, getSafeModeManager, executeMikrotikCommand, defineTool, registerTools, PROMPTS_DIR, registerUiResources, moduleCatalog, allToolModules, ALWAYS_ON_MODULES, selectToolModules };
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 };