@usex/mikrotik-mcp 3.32.2 → 3.34.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -14
- package/dist/cli.js +7 -5
- package/dist/index.d.ts +34 -3
- package/dist/index.js +5 -5
- package/dist/shared/{cli-ytn5v321.js → cli-g5kqcrt4.js} +1 -1
- package/dist/shared/{cli-t84nm0d4.js → cli-gt8xtjx9.js} +164 -79
- package/dist/shared/{library-bj9xrcg0.js → library-aa58hkb4.js} +164 -79
- package/dist/shared/{library-gsvkewg5.js → library-c629x3tn.js} +1 -1
- package/dist/ui/observability.html +4 -4
- package/package.json +4 -4
- package/schemas/config.schema.json +40 -0
package/README.md
CHANGED
|
@@ -63,6 +63,9 @@ in memory and auto-reverts if your session drops, so you can't lock yourself out
|
|
|
63
63
|
- 🖧 **Multiple devices** — define named routers and the AI targets one per call
|
|
64
64
|
(a validated `device` argument). Configure **both ends of a tunnel** from one
|
|
65
65
|
conversation. See **[docs/multi-device.md](docs/multi-device.md)**.
|
|
66
|
+
- 🪜 **SSH jump hosts** — reach a router with no exposed port by tunnelling
|
|
67
|
+
through another via `jumpVia` (ProxyJump/bastion) — commands, Safe Mode and
|
|
68
|
+
file upload all ride the hop. No new WAN port.
|
|
66
69
|
- 🤖 **Guided prompts** — 9 built-in workflows (harden, diagnose, guest Wi-Fi, VPNs,
|
|
67
70
|
cross-device tunnels, backup & document) that turn an intent into tool calls.
|
|
68
71
|
|
|
@@ -179,6 +182,17 @@ drives the whole both-ends flow. Full guide: **[docs/multi-device.md](docs/multi
|
|
|
179
182
|
// create_wireguard_interface { "device": "site-a", "name": "wg-to-b", "listen_port": 13231 }
|
|
180
183
|
```
|
|
181
184
|
|
|
185
|
+
**Behind a bastion?** Reach a router with no exposed SSH port by jumping through
|
|
186
|
+
another (OpenSSH-style ProxyJump) — `jumpVia` names a configured device to tunnel
|
|
187
|
+
through; commands, Safe Mode and SFTP upload all ride the hop:
|
|
188
|
+
|
|
189
|
+
```jsonc
|
|
190
|
+
"home-ax3": { "host": "10.10.30.100", "username": "admin", "jumpVia": "hex" }
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
The bastion router needs SSH TCP forwarding enabled (`/ip ssh set
|
|
194
|
+
forwarding-enabled=local`). See **[docs/multi-device.md](docs/multi-device.md#ssh-jump-hosts-bastion--proxyjump)**.
|
|
195
|
+
|
|
182
196
|
## Built-in prompts
|
|
183
197
|
|
|
184
198
|
MCP **prompts** are one-click guided workflows. This server ships 9 — authored as
|
|
@@ -220,20 +234,21 @@ See **[docs/safe-mode.md](docs/safe-mode.md)**.
|
|
|
220
234
|
Connection and transport settings come from `MIKROTIK_*` env vars or matching CLI
|
|
221
235
|
flags (highest precedence last: defaults → env → flags).
|
|
222
236
|
|
|
223
|
-
| Variable | Flag | Default | Purpose
|
|
224
|
-
| ----------------------------- | ------------------ | ----------- |
|
|
225
|
-
| `MIKROTIK_HOST` | `--host` | `127.0.0.1` | RouterOS host
|
|
226
|
-
| `MIKROTIK_USERNAME` | `--username` | `admin` | SSH user
|
|
227
|
-
| `MIKROTIK_PORT` | `--port` | `22` | SSH port
|
|
228
|
-
| `MIKROTIK_PASSWORD` | `--password` | — | SSH password _(or use a key →)_
|
|
229
|
-
| `MIKROTIK_KEY_FILENAME` | `--key-filename` | — | SSH private-key file path
|
|
230
|
-
| `MIKROTIK_PRIVATE_KEY` | `--private-key` | — | Inline private key (PEM)
|
|
231
|
-
| `MIKROTIK_KEY_PASSPHRASE` | `--key-passphrase` | — | Passphrase for an encrypted key
|
|
232
|
-
| `
|
|
233
|
-
| `
|
|
234
|
-
| `
|
|
235
|
-
| `
|
|
236
|
-
| `
|
|
237
|
+
| Variable | Flag | Default | Purpose |
|
|
238
|
+
| ----------------------------- | ------------------ | ----------- | ---------------------------------------------------------------------------------------------------- |
|
|
239
|
+
| `MIKROTIK_HOST` | `--host` | `127.0.0.1` | RouterOS host |
|
|
240
|
+
| `MIKROTIK_USERNAME` | `--username` | `admin` | SSH user |
|
|
241
|
+
| `MIKROTIK_PORT` | `--port` | `22` | SSH port |
|
|
242
|
+
| `MIKROTIK_PASSWORD` | `--password` | — | SSH password _(or use a key →)_ |
|
|
243
|
+
| `MIKROTIK_KEY_FILENAME` | `--key-filename` | — | SSH private-key file path |
|
|
244
|
+
| `MIKROTIK_PRIVATE_KEY` | `--private-key` | — | Inline private key (PEM) |
|
|
245
|
+
| `MIKROTIK_KEY_PASSPHRASE` | `--key-passphrase` | — | Passphrase for an encrypted key |
|
|
246
|
+
| `MIKROTIK_JUMP_HOST` | `--jump-host` | — | SSH bastion to tunnel through ([jump hosts](docs/multi-device.md#ssh-jump-hosts-bastion--proxyjump)) |
|
|
247
|
+
| `MIKROTIK_CONFIG_FILE` | `--config` | — | JSON file of named devices ([multi-device](docs/multi-device.md)) |
|
|
248
|
+
| `MIKROTIK_DEVICES` | `--devices` | — | Inline JSON of named devices |
|
|
249
|
+
| `MIKROTIK_MCP__TRANSPORT` | `--transport` | `stdio` | `stdio` / `streamable-http` / `sse` |
|
|
250
|
+
| `MIKROTIK_MCP__PORT` | `--mcp-port` | `8000` | HTTP bind port |
|
|
251
|
+
| `MIKROTIK_DASHBOARD__ENABLED` | `--dashboard` | `false` | Real-time observability dashboard ([docs](docs/observability.md)) |
|
|
237
252
|
|
|
238
253
|
Full table (incl. HTTP host, allow-lists, timeouts, `MIKROTIK_LOG_LEVEL`):
|
|
239
254
|
**[docs/configuration.md](docs/configuration.md)**.
|
package/dist/cli.js
CHANGED
|
@@ -84,7 +84,7 @@ import {
|
|
|
84
84
|
toggleAaaEntity,
|
|
85
85
|
updateAaaEntity,
|
|
86
86
|
writeBackup
|
|
87
|
-
} from "./shared/cli-
|
|
87
|
+
} from "./shared/cli-gt8xtjx9.js";
|
|
88
88
|
|
|
89
89
|
// src/cli.ts
|
|
90
90
|
import { existsSync as existsSync2 } from "fs";
|
|
@@ -1220,6 +1220,8 @@ function devicesPayload(store) {
|
|
|
1220
1220
|
authMode: dc.mac ? "mac-telnet" : dc.keyFilename || dc.privateKey ? "key" : dc.password ? "password" : "none",
|
|
1221
1221
|
isDefault: name === cfg.defaultDevice,
|
|
1222
1222
|
description: dc.description,
|
|
1223
|
+
jumpVia: dc.jumpVia,
|
|
1224
|
+
jumpHost: dc.jumpHost ? { host: dc.jumpHost.host, port: dc.jumpHost.port } : undefined,
|
|
1223
1225
|
status: getDeviceStatus(name),
|
|
1224
1226
|
history: getDeviceHistory(name),
|
|
1225
1227
|
activity: activity.get(name) ?? {
|
|
@@ -2231,7 +2233,7 @@ function registerPrompts(server) {
|
|
|
2231
2233
|
// package.json
|
|
2232
2234
|
var package_default = {
|
|
2233
2235
|
name: "@usex/mikrotik-mcp",
|
|
2234
|
-
version: "3.
|
|
2236
|
+
version: "3.34.0",
|
|
2235
2237
|
description: "MCP server for MikroTik RouterOS \u2014 660+ tools over SSH for firewall, NAT, routing, DHCP, DNS, WireGuard, wireless, QoS and more.",
|
|
2236
2238
|
keywords: [
|
|
2237
2239
|
"ai",
|
|
@@ -2313,7 +2315,7 @@ var package_default = {
|
|
|
2313
2315
|
},
|
|
2314
2316
|
devDependencies: {
|
|
2315
2317
|
"@resvg/resvg-js": "^2.6.2",
|
|
2316
|
-
"@tailwindcss/vite": "^4.3.
|
|
2318
|
+
"@tailwindcss/vite": "^4.3.2",
|
|
2317
2319
|
"@types/bun": "latest",
|
|
2318
2320
|
"@types/node": "^26.0.1",
|
|
2319
2321
|
"@types/react": "^19.2.17",
|
|
@@ -2324,13 +2326,13 @@ var package_default = {
|
|
|
2324
2326
|
"class-variance-authority": "^0.7.1",
|
|
2325
2327
|
clsx: "^2.1.1",
|
|
2326
2328
|
gsap: "^3.15.0",
|
|
2327
|
-
prettier: "^3.9.
|
|
2329
|
+
prettier: "^3.9.3",
|
|
2328
2330
|
react: "^19.2.7",
|
|
2329
2331
|
"react-dom": "^19.2.7",
|
|
2330
2332
|
recharts: "^3.9.0",
|
|
2331
2333
|
"release-it": "^20.2.1",
|
|
2332
2334
|
"tailwind-merge": "^3.6.0",
|
|
2333
|
-
tailwindcss: "^4.3.
|
|
2335
|
+
tailwindcss: "^4.3.2",
|
|
2334
2336
|
vite: "npm:@voidzero-dev/vite-plus-core@latest",
|
|
2335
2337
|
"vite-plus": "latest"
|
|
2336
2338
|
},
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
/** Connection details for a single MikroTik device. */
|
|
3
2
|
declare const DeviceConfigSchema: unknown;
|
|
4
3
|
type DeviceConfig = z.infer<typeof DeviceConfigSchema>;
|
|
5
4
|
declare const MikrotikConfigSchema: unknown;
|
|
@@ -184,15 +183,47 @@ interface SSHClientOptions {
|
|
|
184
183
|
/** Passphrase for an encrypted private key. */
|
|
185
184
|
keyPassphrase?: string;
|
|
186
185
|
timeoutMs?: number;
|
|
186
|
+
/**
|
|
187
|
+
* Optional SSH jump host (bastion / ProxyJump). When set, `connect()` reaches
|
|
188
|
+
* `host:port` THROUGH this hop: it opens an SSH session to the hop, asks it to
|
|
189
|
+
* `forwardOut` a TCP channel to the target, then runs the real SSH session
|
|
190
|
+
* over that channel. So the target never needs a port exposed to the network —
|
|
191
|
+
* only the bastion is reachable. The hop is itself an SSH endpoint and may
|
|
192
|
+
* carry its own `jump` for a multi-hop chain (A → B → target).
|
|
193
|
+
*/
|
|
194
|
+
jump?: SSHClientOptions;
|
|
187
195
|
}
|
|
188
196
|
declare class MikroTikSSHClient {
|
|
189
197
|
private client;
|
|
198
|
+
/**
|
|
199
|
+
* Open bastion connections, outermost-first, kept alive for the lifetime of
|
|
200
|
+
* the session (the target's transport rides a channel on the last one). Closed
|
|
201
|
+
* in reverse on `disconnect()`.
|
|
202
|
+
*/
|
|
203
|
+
private bastions;
|
|
190
204
|
private readonly opts;
|
|
191
205
|
/** Human-readable reason the last `connect()` failed, if it did. */
|
|
192
206
|
lastError?: string;
|
|
193
207
|
constructor(opts: SSHClientOptions);
|
|
194
|
-
/**
|
|
208
|
+
/**
|
|
209
|
+
* Establish the SSH connection — directly, or through one or more jump hosts
|
|
210
|
+
* when `opts.jump` is set. Resolves `true` on success, `false` on failure
|
|
211
|
+
* (with the reason on `lastError`).
|
|
212
|
+
*/
|
|
195
213
|
connect(): Promise<boolean>;
|
|
214
|
+
/**
|
|
215
|
+
* Open one ssh2 connection. When `sock` is given the connection rides that
|
|
216
|
+
* stream (a forwarded channel from a jump host) instead of dialling the
|
|
217
|
+
* network directly. Resolves the ready `Client`; rejects with a clear reason.
|
|
218
|
+
*/
|
|
219
|
+
private openClient;
|
|
220
|
+
/**
|
|
221
|
+
* Ask a connected jump host to open a TCP channel to `host:port` and resolve
|
|
222
|
+
* the resulting stream (used as the next hop's transport `sock`). A RouterOS
|
|
223
|
+
* bastion rejects this unless SSH TCP forwarding is enabled, so the failure
|
|
224
|
+
* names the exact fix.
|
|
225
|
+
*/
|
|
226
|
+
private forwardOut;
|
|
196
227
|
/** Run a single command on a fresh SSH channel and return its decoded output. */
|
|
197
228
|
run(command: string, opts?: {
|
|
198
229
|
maxMs?: number;
|
|
@@ -211,7 +242,7 @@ declare class MikroTikSSHClient {
|
|
|
211
242
|
cols?: number;
|
|
212
243
|
rows?: number;
|
|
213
244
|
}): Promise<ClientChannel>;
|
|
214
|
-
/** Close the SSH connection. Safe to call multiple times. */
|
|
245
|
+
/** Close the SSH connection (and any jump hosts). Safe to call multiple times. */
|
|
215
246
|
disconnect(): void;
|
|
216
247
|
}
|
|
217
248
|
/**
|
package/dist/index.js
CHANGED
|
@@ -21,7 +21,7 @@ import {
|
|
|
21
21
|
resolveDeviceName,
|
|
22
22
|
selectToolModules,
|
|
23
23
|
setConfig
|
|
24
|
-
} from "./shared/library-
|
|
24
|
+
} from "./shared/library-aa58hkb4.js";
|
|
25
25
|
// src/server.ts
|
|
26
26
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
27
27
|
import { ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
@@ -131,7 +131,7 @@ function registerPrompts(server) {
|
|
|
131
131
|
// package.json
|
|
132
132
|
var package_default = {
|
|
133
133
|
name: "@usex/mikrotik-mcp",
|
|
134
|
-
version: "3.
|
|
134
|
+
version: "3.34.0",
|
|
135
135
|
description: "MCP server for MikroTik RouterOS \u2014 660+ tools over SSH for firewall, NAT, routing, DHCP, DNS, WireGuard, wireless, QoS and more.",
|
|
136
136
|
keywords: [
|
|
137
137
|
"ai",
|
|
@@ -213,7 +213,7 @@ var package_default = {
|
|
|
213
213
|
},
|
|
214
214
|
devDependencies: {
|
|
215
215
|
"@resvg/resvg-js": "^2.6.2",
|
|
216
|
-
"@tailwindcss/vite": "^4.3.
|
|
216
|
+
"@tailwindcss/vite": "^4.3.2",
|
|
217
217
|
"@types/bun": "latest",
|
|
218
218
|
"@types/node": "^26.0.1",
|
|
219
219
|
"@types/react": "^19.2.17",
|
|
@@ -224,13 +224,13 @@ var package_default = {
|
|
|
224
224
|
"class-variance-authority": "^0.7.1",
|
|
225
225
|
clsx: "^2.1.1",
|
|
226
226
|
gsap: "^3.15.0",
|
|
227
|
-
prettier: "^3.9.
|
|
227
|
+
prettier: "^3.9.3",
|
|
228
228
|
react: "^19.2.7",
|
|
229
229
|
"react-dom": "^19.2.7",
|
|
230
230
|
recharts: "^3.9.0",
|
|
231
231
|
"release-it": "^20.2.1",
|
|
232
232
|
"tailwind-merge": "^3.6.0",
|
|
233
|
-
tailwindcss: "^4.3.
|
|
233
|
+
tailwindcss: "^4.3.2",
|
|
234
234
|
vite: "npm:@voidzero-dev/vite-plus-core@latest",
|
|
235
235
|
"vite-plus": "latest"
|
|
236
236
|
},
|
|
@@ -25,6 +25,16 @@ var McpServerSettingsSchema = z.object({
|
|
|
25
25
|
toolPageSize: z.coerce.number().int().min(0).default(0),
|
|
26
26
|
appViews: z.boolean().default(true)
|
|
27
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
|
+
});
|
|
28
38
|
var DeviceConfigSchema = z.object({
|
|
29
39
|
host: z.string().default("127.0.0.1"),
|
|
30
40
|
username: z.string().default("admin"),
|
|
@@ -34,6 +44,8 @@ var DeviceConfigSchema = z.object({
|
|
|
34
44
|
privateKey: z.string().optional(),
|
|
35
45
|
keyPassphrase: z.string().optional(),
|
|
36
46
|
timeoutMs: z.coerce.number().int().positive().default(1e4),
|
|
47
|
+
jumpVia: z.string().optional(),
|
|
48
|
+
jumpHost: JumpHostSchema.optional(),
|
|
37
49
|
mac: z.string().optional(),
|
|
38
50
|
sourceMac: z.string().optional(),
|
|
39
51
|
macHost: z.string().optional(),
|
|
@@ -131,6 +143,15 @@ function getConfigSource() {
|
|
|
131
143
|
function loadConfig(argv = process.argv.slice(2)) {
|
|
132
144
|
const flags = parseFlags(argv);
|
|
133
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;
|
|
134
155
|
const single = {
|
|
135
156
|
host: pick("host", "MIKROTIK_HOST"),
|
|
136
157
|
username: pick("username", "MIKROTIK_USERNAME"),
|
|
@@ -140,6 +161,7 @@ function loadConfig(argv = process.argv.slice(2)) {
|
|
|
140
161
|
privateKey: pick("private-key", "MIKROTIK_PRIVATE_KEY"),
|
|
141
162
|
keyPassphrase: pick("key-passphrase", "MIKROTIK_KEY_PASSPHRASE"),
|
|
142
163
|
timeoutMs: pick("timeout-ms", "MIKROTIK_TIMEOUT_MS"),
|
|
164
|
+
jumpHost,
|
|
143
165
|
mac: pick("mac", "MIKROTIK_MAC"),
|
|
144
166
|
sourceMac: pick("source-mac", "MIKROTIK_SOURCE_MAC"),
|
|
145
167
|
macHost: pick("mac-host", "MIKROTIK_MAC_HOST"),
|
|
@@ -706,45 +728,72 @@ function decodeOutput(data) {
|
|
|
706
728
|
|
|
707
729
|
class MikroTikSSHClient {
|
|
708
730
|
client = null;
|
|
731
|
+
bastions = [];
|
|
709
732
|
opts;
|
|
710
733
|
lastError;
|
|
711
734
|
constructor(opts) {
|
|
712
735
|
this.opts = { port: 22, timeoutMs: 1e4, ...opts };
|
|
713
736
|
}
|
|
714
|
-
connect() {
|
|
737
|
+
async connect() {
|
|
715
738
|
this.lastError = undefined;
|
|
716
|
-
|
|
739
|
+
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 client = await this.openClient(hops[i], sock);
|
|
747
|
+
this.bastions.push(client);
|
|
748
|
+
const next = sequence[i + 1];
|
|
749
|
+
sock = await this.forwardOut(client, next.host, next.port ?? 22);
|
|
750
|
+
}
|
|
751
|
+
this.client = await this.openClient(this.opts, sock);
|
|
752
|
+
return true;
|
|
753
|
+
} catch (e) {
|
|
754
|
+
this.lastError = e instanceof Error ? e.message : String(e);
|
|
755
|
+
logger.error(`Failed to connect to MikroTik: ${this.lastError}`);
|
|
756
|
+
this.disconnect();
|
|
757
|
+
return false;
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
openClient(o, sock) {
|
|
761
|
+
return new Promise((resolve2, reject) => {
|
|
717
762
|
const client = new Client;
|
|
718
763
|
const cfg = {
|
|
719
|
-
host:
|
|
720
|
-
port:
|
|
721
|
-
username:
|
|
722
|
-
readyTimeout:
|
|
764
|
+
host: o.host,
|
|
765
|
+
port: o.port ?? 22,
|
|
766
|
+
username: o.username,
|
|
767
|
+
readyTimeout: o.timeoutMs ?? 1e4
|
|
723
768
|
};
|
|
724
|
-
if (
|
|
725
|
-
cfg.
|
|
726
|
-
|
|
769
|
+
if (sock)
|
|
770
|
+
cfg.sock = sock;
|
|
771
|
+
if (o.privateKey) {
|
|
772
|
+
cfg.privateKey = o.privateKey;
|
|
773
|
+
} else if (o.keyFilename) {
|
|
727
774
|
try {
|
|
728
|
-
cfg.privateKey = readFileSync2(
|
|
775
|
+
cfg.privateKey = readFileSync2(o.keyFilename);
|
|
729
776
|
} catch (e) {
|
|
730
|
-
|
|
731
|
-
logger.error(`Failed to read SSH key file ${this.opts.keyFilename}: ${String(e)}`);
|
|
732
|
-
resolve2(false);
|
|
777
|
+
reject(new Error(`could not read key file ${o.keyFilename}: ${e instanceof Error ? e.message : String(e)}`));
|
|
733
778
|
return;
|
|
734
779
|
}
|
|
735
780
|
}
|
|
736
|
-
if (cfg.privateKey &&
|
|
737
|
-
cfg.passphrase =
|
|
738
|
-
if (
|
|
739
|
-
cfg.password =
|
|
740
|
-
client.on("ready", () =>
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
781
|
+
if (cfg.privateKey && o.keyPassphrase)
|
|
782
|
+
cfg.passphrase = o.keyPassphrase;
|
|
783
|
+
if (o.password)
|
|
784
|
+
cfg.password = o.password;
|
|
785
|
+
client.on("ready", () => resolve2(client)).on("error", (err) => reject(err)).connect(cfg);
|
|
786
|
+
});
|
|
787
|
+
}
|
|
788
|
+
forwardOut(via, host, port) {
|
|
789
|
+
return new Promise((resolve2, reject) => {
|
|
790
|
+
via.forwardOut("127.0.0.1", 0, host, port, (err, stream) => {
|
|
791
|
+
if (err) {
|
|
792
|
+
reject(new Error(`jump host could not open a tunnel to ${host}:${port}: ${err.message}. ` + "If the jump router runs RouterOS, enable SSH TCP forwarding on it: " + "/ip ssh set forwarding-enabled=local (or both)."));
|
|
793
|
+
} else {
|
|
794
|
+
resolve2(stream);
|
|
795
|
+
}
|
|
796
|
+
});
|
|
748
797
|
});
|
|
749
798
|
}
|
|
750
799
|
run(command, opts = {}) {
|
|
@@ -863,6 +912,12 @@ class MikroTikSSHClient {
|
|
|
863
912
|
} catch {}
|
|
864
913
|
this.client = null;
|
|
865
914
|
}
|
|
915
|
+
for (const b of this.bastions.reverse()) {
|
|
916
|
+
try {
|
|
917
|
+
b.end();
|
|
918
|
+
} catch {}
|
|
919
|
+
}
|
|
920
|
+
this.bastions = [];
|
|
866
921
|
}
|
|
867
922
|
}
|
|
868
923
|
|
|
@@ -870,6 +925,43 @@ class MikroTikSSHClient {
|
|
|
870
925
|
function isMacTelnetDevice(dc) {
|
|
871
926
|
return Boolean(dc.mac);
|
|
872
927
|
}
|
|
928
|
+
function sshOptionsOf(dc) {
|
|
929
|
+
return {
|
|
930
|
+
host: dc.host,
|
|
931
|
+
username: dc.username,
|
|
932
|
+
password: dc.password,
|
|
933
|
+
keyFilename: dc.keyFilename,
|
|
934
|
+
privateKey: dc.privateKey,
|
|
935
|
+
keyPassphrase: dc.keyPassphrase,
|
|
936
|
+
port: dc.port,
|
|
937
|
+
timeoutMs: dc.timeoutMs
|
|
938
|
+
};
|
|
939
|
+
}
|
|
940
|
+
function resolveJump(dc, seen = new Set) {
|
|
941
|
+
if (dc.jumpHost) {
|
|
942
|
+
return {
|
|
943
|
+
host: dc.jumpHost.host,
|
|
944
|
+
port: dc.jumpHost.port,
|
|
945
|
+
username: dc.jumpHost.username,
|
|
946
|
+
password: dc.jumpHost.password,
|
|
947
|
+
keyFilename: dc.jumpHost.keyFilename,
|
|
948
|
+
privateKey: dc.jumpHost.privateKey,
|
|
949
|
+
keyPassphrase: dc.jumpHost.keyPassphrase,
|
|
950
|
+
timeoutMs: dc.jumpHost.timeoutMs
|
|
951
|
+
};
|
|
952
|
+
}
|
|
953
|
+
if (!dc.jumpVia)
|
|
954
|
+
return;
|
|
955
|
+
if (seen.has(dc.jumpVia)) {
|
|
956
|
+
throw new Error(`SSH jump-host cycle detected at '${dc.jumpVia}' (a device can't jump through itself).`);
|
|
957
|
+
}
|
|
958
|
+
seen.add(dc.jumpVia);
|
|
959
|
+
const bastion = getDevice(dc.jumpVia);
|
|
960
|
+
if (isMacTelnetDevice(bastion)) {
|
|
961
|
+
throw new Error(`Jump host '${dc.jumpVia}' is a MAC-Telnet device; an SSH bastion must be reachable over SSH.`);
|
|
962
|
+
}
|
|
963
|
+
return { ...sshOptionsOf(bastion), jump: resolveJump(bastion, seen) };
|
|
964
|
+
}
|
|
873
965
|
function createDeviceClient(dc) {
|
|
874
966
|
if (isMacTelnetDevice(dc)) {
|
|
875
967
|
return new MikroTikMacTelnetClient({
|
|
@@ -882,16 +974,7 @@ function createDeviceClient(dc) {
|
|
|
882
974
|
timeoutMs: dc.timeoutMs
|
|
883
975
|
});
|
|
884
976
|
}
|
|
885
|
-
return new MikroTikSSHClient({
|
|
886
|
-
host: dc.host,
|
|
887
|
-
username: dc.username,
|
|
888
|
-
password: dc.password,
|
|
889
|
-
keyFilename: dc.keyFilename,
|
|
890
|
-
privateKey: dc.privateKey,
|
|
891
|
-
keyPassphrase: dc.keyPassphrase,
|
|
892
|
-
port: dc.port,
|
|
893
|
-
timeoutMs: dc.timeoutMs
|
|
894
|
-
});
|
|
977
|
+
return new MikroTikSSHClient({ ...sshOptionsOf(dc), jump: resolveJump(dc) });
|
|
895
978
|
}
|
|
896
979
|
function describeTransport(dc) {
|
|
897
980
|
return dc.mac ? `MAC ${dc.mac} (mac-telnet)` : `${dc.host}:${dc.port}`;
|
|
@@ -4487,40 +4570,39 @@ var changePlanTools = [
|
|
|
4487
4570
|
return "No commands provided to apply.";
|
|
4488
4571
|
const device = resolveDeviceName(ctx.device);
|
|
4489
4572
|
if (getDevice(ctx.device).mac) {
|
|
4490
|
-
|
|
4573
|
+
throw new Error("Safe Mode (and therefore apply_plan) requires SSH; it is not available on a MAC-Telnet device.");
|
|
4491
4574
|
}
|
|
4492
4575
|
const plan = buildChangePlan(commands);
|
|
4493
4576
|
const safe = getSafeModeManager(device);
|
|
4494
4577
|
const enabled2 = await safe.enable();
|
|
4495
4578
|
if (enabled2.startsWith("Error"))
|
|
4496
|
-
|
|
4579
|
+
throw new Error(enabled2);
|
|
4497
4580
|
const APPLY_BUDGET_MS = 90000;
|
|
4498
4581
|
const startedAt = Date.now();
|
|
4499
4582
|
const overBudget = () => Date.now() - startedAt > APPLY_BUDGET_MS;
|
|
4500
|
-
|
|
4501
|
-
|
|
4502
|
-
|
|
4503
|
-
|
|
4504
|
-
|
|
4505
|
-
|
|
4506
|
-
|
|
4507
|
-
|
|
4508
|
-
|
|
4509
|
-
|
|
4510
|
-
|
|
4511
|
-
|
|
4512
|
-
|
|
4513
|
-
|
|
4583
|
+
const applyAndDiff = async () => {
|
|
4584
|
+
try {
|
|
4585
|
+
const before = normalizeExport(await safe.execute("/export terse"));
|
|
4586
|
+
const log = [];
|
|
4587
|
+
for (const step of plan.steps) {
|
|
4588
|
+
if (overBudget()) {
|
|
4589
|
+
throw new Error(`aborted after ${Math.round((Date.now() - startedAt) / 1000)}s (time budget exceeded). ` + "Safe Mode is slow/unresponsive on this device; apply the change with the direct write tools instead");
|
|
4590
|
+
}
|
|
4591
|
+
const out = await safe.execute(step.command);
|
|
4592
|
+
if (looksLikeError(out)) {
|
|
4593
|
+
throw new Error(`step ${step.index} failed:
|
|
4594
|
+
${step.command}
|
|
4595
|
+
\u2192 ${out.trim()}`);
|
|
4596
|
+
}
|
|
4597
|
+
log.push(` \u2713 ${step.command}`);
|
|
4514
4598
|
}
|
|
4515
|
-
|
|
4516
|
-
|
|
4517
|
-
|
|
4518
|
-
|
|
4519
|
-
const reachable = !looksLikeError(await safe.execute("/system identity print"));
|
|
4520
|
-
const planBody = renderPlan(plan).split(`
|
|
4599
|
+
const after = normalizeExport(await safe.execute("/export terse"));
|
|
4600
|
+
const diff = diffLines(before, after, { fromLabel: "before", toLabel: "after" });
|
|
4601
|
+
const reachable2 = !looksLikeError(await safe.execute("/system identity print"));
|
|
4602
|
+
const planBody = renderPlan(plan).split(`
|
|
4521
4603
|
`).slice(2).join(`
|
|
4522
4604
|
`);
|
|
4523
|
-
|
|
4605
|
+
const header2 = `APPLY PLAN \u2014 ${device}
|
|
4524
4606
|
|
|
4525
4607
|
${planBody}
|
|
4526
4608
|
|
|
@@ -4530,32 +4612,35 @@ ${log.join(`
|
|
|
4530
4612
|
|
|
4531
4613
|
` + `EXACT DIFF (+${diff.summary.added}/-${diff.summary.removed}):
|
|
4532
4614
|
${diff.unified || "(no config change)"}`;
|
|
4533
|
-
|
|
4534
|
-
|
|
4535
|
-
|
|
4615
|
+
return { header: header2, reachable: reachable2 };
|
|
4616
|
+
} catch (e) {
|
|
4617
|
+
await safe.rollback().catch(() => {});
|
|
4618
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
4619
|
+
throw new Error(`apply_plan failed and was rolled back (nothing committed): ${msg}`);
|
|
4620
|
+
}
|
|
4621
|
+
};
|
|
4622
|
+
const { header, reachable } = await applyAndDiff();
|
|
4623
|
+
if (!a.confirm) {
|
|
4624
|
+
await safe.rollback();
|
|
4625
|
+
return `${header}
|
|
4536
4626
|
|
|
4537
4627
|
DRY-RUN: all changes rolled back. Re-run with confirm=true to commit.`;
|
|
4538
|
-
|
|
4539
|
-
|
|
4540
|
-
|
|
4541
|
-
|
|
4628
|
+
}
|
|
4629
|
+
if (!reachable) {
|
|
4630
|
+
await safe.rollback();
|
|
4631
|
+
throw new Error(`${header}
|
|
4542
4632
|
|
|
4543
|
-
ABORTED: the device stopped responding after applying \u2014 changes rolled back to avoid a lock-out
|
|
4544
|
-
|
|
4545
|
-
|
|
4546
|
-
|
|
4547
|
-
|
|
4633
|
+
ABORTED: the device stopped responding after applying \u2014 changes rolled back to avoid a lock-out.`);
|
|
4634
|
+
}
|
|
4635
|
+
const committed = await safe.commit();
|
|
4636
|
+
if (!committed.ok) {
|
|
4637
|
+
throw new Error(`${header}
|
|
4548
4638
|
|
|
4549
|
-
COMMIT FAILED \u2014 changes are NOT saved (still pending in Safe Mode).
|
|
4550
|
-
|
|
4551
|
-
|
|
4639
|
+
COMMIT FAILED \u2014 changes are NOT saved (still pending in Safe Mode). ${committed.message}`);
|
|
4640
|
+
}
|
|
4641
|
+
return `${header}
|
|
4552
4642
|
|
|
4553
4643
|
COMMITTED: changes are now permanent. ${committed.message}`;
|
|
4554
|
-
} catch (e) {
|
|
4555
|
-
await safe.rollback();
|
|
4556
|
-
const msg = e instanceof Error ? e.message : String(e);
|
|
4557
|
-
return `apply_plan failed and was rolled back: ${msg}`;
|
|
4558
|
-
}
|
|
4559
4644
|
}
|
|
4560
4645
|
})
|
|
4561
4646
|
];
|
|
@@ -5932,7 +6017,7 @@ var cache = null;
|
|
|
5932
6017
|
async function gateway() {
|
|
5933
6018
|
if (cache)
|
|
5934
6019
|
return cache;
|
|
5935
|
-
const { moduleCatalog } = await import("./cli-
|
|
6020
|
+
const { moduleCatalog } = await import("./cli-g5kqcrt4.js");
|
|
5936
6021
|
const forIndex = [];
|
|
5937
6022
|
const byName = new Map;
|
|
5938
6023
|
for (const mod of moduleCatalog) {
|