@usex/mikrotik-mcp 3.42.0 → 3.44.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,957 +4,979 @@ 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;
9
+ import { Client } from "ssh2";
10
+
11
+ // src/logger.ts
12
+ import { stderr } from "process";
13
+ var LEVELS = {
14
+ debug: 10,
15
+ info: 20,
16
+ warn: 30,
17
+ error: 40
18
+ };
19
+ var ENV_LEVEL = (process.env.MIKROTIK_LOG_LEVEL ?? "info").toLowerCase();
20
+ var THRESHOLD = LEVELS[ENV_LEVEL] ?? LEVELS.info;
21
+ var COLORS = {
22
+ debug: "\x1B[90m",
23
+ info: "\x1B[36m",
24
+ warn: "\x1B[33m",
25
+ error: "\x1B[31m"
26
+ };
27
+ var RESET = "\x1B[0m";
28
+ var useColor = stderr.isTTY ?? false;
29
+ function emit(level, message) {
30
+ if (LEVELS[level] < THRESHOLD)
31
+ return;
32
+ const tag = useColor ? `${COLORS[level]}${level.toUpperCase()}${RESET}` : level.toUpperCase();
33
+ stderr.write(`[mikrotik-mcp] ${tag} ${message}
34
+ `);
35
+ }
36
+ var logger = {
37
+ debug: (m) => emit("debug", m),
38
+ info: (m) => emit("info", m),
39
+ warn: (m) => emit("warn", m),
40
+ error: (m) => emit("error", m)
41
+ };
42
+
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 {}
97
53
  }
98
- return;
54
+ return new TextDecoder("utf-8").decode(data);
99
55
  }
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";
56
+
57
+ class MikroTikSSHClient {
58
+ client = null;
59
+ bastions = [];
60
+ opts;
61
+ lastError;
62
+ constructor(opts) {
63
+ this.opts = { port: 22, timeoutMs: 1e4, ...opts };
64
+ }
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);
117
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;
118
88
  }
119
89
  }
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)}`);
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
+ });
128
121
  }
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;
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
+ });
278
144
  }
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);
145
+ run(command, opts = {}) {
146
+ if (!this.client) {
147
+ return Promise.reject(new Error("Not connected to MikroTik device"));
289
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
+ });
290
217
  }
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
- }
331
-
332
- // src/logger.ts
333
- import { stderr } from "process";
334
- var LEVELS = {
335
- debug: 10,
336
- info: 20,
337
- warn: 30,
338
- error: 40
339
- };
340
- var ENV_LEVEL = (process.env.MIKROTIK_LOG_LEVEL ?? "info").toLowerCase();
341
- var THRESHOLD = LEVELS[ENV_LEVEL] ?? LEVELS.info;
342
- var COLORS = {
343
- debug: "\x1B[90m",
344
- info: "\x1B[36m",
345
- warn: "\x1B[33m",
346
- error: "\x1B[31m"
347
- };
348
- var RESET = "\x1B[0m";
349
- var useColor = stderr.isTTY ?? false;
350
- function emit(level, message) {
351
- if (LEVELS[level] < THRESHOLD)
352
- return;
353
- const tag = useColor ? `${COLORS[level]}${level.toUpperCase()}${RESET}` : level.toUpperCase();
354
- stderr.write(`[mikrotik-mcp] ${tag} ${message}
355
- `);
356
- }
357
- var logger = {
358
- debug: (m) => emit("debug", m),
359
- info: (m) => emit("info", m),
360
- warn: (m) => emit("warn", m),
361
- error: (m) => emit("error", m)
362
- };
363
-
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;
218
+ uploadFile(remotePath, data) {
219
+ if (!this.client) {
220
+ return Promise.reject(new Error("Not connected to MikroTik device"));
394
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
+ });
395
240
  }
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;
241
+ downloadFile(remotePath) {
242
+ if (!this.client) {
243
+ return Promise.reject(new Error("Not connected to MikroTik device"));
409
244
  }
245
+ const openSftp = this.client.sftp.bind(this.client);
246
+ return new Promise((resolve, reject) => {
247
+ openSftp((err, sftp) => {
248
+ if (err) {
249
+ reject(new Error(`SFTP subsystem unavailable: ${err.message}`));
250
+ return;
251
+ }
252
+ sftp.readFile(remotePath, (rerr, data) => {
253
+ try {
254
+ sftp.end();
255
+ } catch {}
256
+ if (rerr)
257
+ reject(new Error(`SFTP read failed: ${rerr.message}`));
258
+ else
259
+ resolve(data);
260
+ });
261
+ });
262
+ });
410
263
  }
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;
264
+ shell(opts = {}) {
265
+ if (!this.client) {
266
+ return Promise.reject(new Error("Not connected to MikroTik device"));
417
267
  }
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)
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));
460
275
  });
461
276
  }
462
- handlePacket(bytes) {
463
- 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."));
277
+ disconnect() {
278
+ if (this.client) {
279
+ try {
280
+ this.client.end();
281
+ } catch {}
282
+ this.client = null;
467
283
  }
468
- }
469
- async open() {
470
- this.tickTimer = setInterval(() => {
284
+ for (const b of this.bastions.reverse()) {
471
285
  try {
472
- this.session.tick(Date.now());
286
+ b.end();
473
287
  } 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
288
  }
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 = "";
289
+ this.bastions = [];
488
290
  }
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 };
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;
496
389
  }
497
- close() {
498
- if (this.tickTimer) {
499
- clearInterval(this.tickTimer);
500
- this.tickTimer = undefined;
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
+ }
501
410
  }
502
- if (!this.closed)
503
- this.session.end();
504
- }
505
- get isReady() {
506
- return this.ready && !this.closed;
507
- }
508
- onReady() {
509
- this.ready = true;
510
- for (const w of this.readyWaiters.splice(0))
511
- w.resolve();
512
- }
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();
520
411
  }
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);
538
- }
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)}`);
539
420
  }
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`));
546
- }
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";
547
469
  }
548
- endsWithPrompt(buffer) {
549
- const lines = emulateScreen(buffer).filter((line) => line.length > 0);
550
- return ROUTEROS_PROMPT_RE.test(lines[lines.length - 1] ?? "");
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;
551
491
  }
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."));
557
- }
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);
568
- }
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);
575
- });
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";
576
560
  }
577
- waitFor(predicate, timeoutMs, label) {
578
- if (this.closed) {
579
- return Promise.reject(this.closeError ?? new Error(`MAC-Telnet session closed while ${label}.`));
580
- }
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
- });
561
+ return parsed;
562
+ }
563
+
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;
589
580
  }
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;
598
- }
599
- return;
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);
600
591
  }
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
592
  }
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
- }
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;
615
602
  }
603
+ return active.defaultDevice in active.devices ? active.defaultDevice : Object.keys(active.devices)[0] ?? active.defaultDevice;
616
604
  }
617
-
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
- };
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(", ")}`);
642
626
  }
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;
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;
632
+ }
633
+
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;
692
664
  }
693
665
  }
694
- async run(command, _opts = {}) {
695
- if (!this.console || !this.console.isReady) {
696
- throw new Error("Not connected to MikroTik device (MAC-Telnet)");
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;
697
679
  }
698
- const { output } = await this.console.run(command);
699
- return output;
700
- }
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;
710
680
  }
711
- }
712
-
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 {}
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;
725
689
  }
726
- return new TextDecoder("utf-8").decode(data);
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) {
@@ -2266,6 +2413,28 @@ async function sampleDeviceTraffic(ctx, ip) {
2266
2413
  uploadLimit: limit(upLim)
2267
2414
  };
2268
2415
  }
2416
+ async function sampleAllTraffic(ctx) {
2417
+ const ts = Date.now();
2418
+ const out = await executeMikrotikCommand("/queue simple print stats detail", ctx);
2419
+ if (isEmpty(out) || looksLikeError(out))
2420
+ return { ts, queues: {} };
2421
+ const queues = {};
2422
+ const limit = (v) => v && v !== "0" ? v : "";
2423
+ for (const row of parseRecords(out).rows) {
2424
+ const ip = (row.target ?? "").split("/")[0]?.trim();
2425
+ if (!ip)
2426
+ continue;
2427
+ const [txB, rxB] = (row.bytes ?? "0/0").split("/");
2428
+ const [upLim, downLim] = (row["max-limit"] ?? "0/0").split("/");
2429
+ queues[ip] = {
2430
+ txBytes: parseLeadingNumber(txB) ?? 0,
2431
+ rxBytes: parseLeadingNumber(rxB) ?? 0,
2432
+ downloadLimit: limit(downLim),
2433
+ uploadLimit: limit(upLim)
2434
+ };
2435
+ }
2436
+ return { ts, queues };
2437
+ }
2269
2438
  async function setDeviceLimits(ctx, ip, opts) {
2270
2439
  const rate = (v) => {
2271
2440
  const t = (v ?? "").trim();
@@ -6062,7 +6231,7 @@ function expandQuery(tokens) {
6062
6231
  return out;
6063
6232
  }
6064
6233
  function buildToolIndex(tools) {
6065
- const entries = tools.map((t) => {
6234
+ const entries2 = tools.map((t) => {
6066
6235
  const nameTokens = tokenSet(t.name);
6067
6236
  const titleTokens = tokenSet(t.title);
6068
6237
  const moduleTokens = new Set([...tokenize(t.module), ...tokenize(t.group)]);
@@ -6071,11 +6240,11 @@ function buildToolIndex(tools) {
6071
6240
  return { ...t, nameTokens, titleTokens, moduleTokens, descTokens, allTokens };
6072
6241
  });
6073
6242
  const df = new Map;
6074
- for (const e of entries) {
6243
+ for (const e of entries2) {
6075
6244
  for (const tok of e.allTokens)
6076
6245
  df.set(tok, (df.get(tok) ?? 0) + 1);
6077
6246
  }
6078
- return { entries, df, n: entries.length };
6247
+ return { entries: entries2, df, n: entries2.length };
6079
6248
  }
6080
6249
  var FIELD_WEIGHT = { name: 6, title: 3.5, module: 3, desc: 1.5 };
6081
6250
  function idf(df, n, token) {
@@ -6141,7 +6310,7 @@ var cache = null;
6141
6310
  async function gateway() {
6142
6311
  if (cache)
6143
6312
  return cache;
6144
- const { moduleCatalog } = await import("./cli-rptenaf7.js");
6313
+ const { moduleCatalog } = await import("./cli-1e5sj65w.js");
6145
6314
  const forIndex = [];
6146
6315
  const byName = new Map;
6147
6316
  for (const mod of moduleCatalog) {
@@ -25738,4 +25907,4 @@ function selectToolModules(filter = {}, catalog = moduleCatalog) {
25738
25907
  }).map((m) => m.tools);
25739
25908
  }
25740
25909
 
25741
- 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 };
25910
+ 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, sampleAllTraffic, 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 };