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