@usex/mikrotik-mcp 3.32.1 → 3.33.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 +8 -4
- package/dist/index.d.ts +34 -3
- package/dist/index.js +2 -2
- 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/package.json +1 -1
- 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";
|
|
@@ -2231,7 +2231,7 @@ function registerPrompts(server) {
|
|
|
2231
2231
|
// package.json
|
|
2232
2232
|
var package_default = {
|
|
2233
2233
|
name: "@usex/mikrotik-mcp",
|
|
2234
|
-
version: "3.
|
|
2234
|
+
version: "3.33.0",
|
|
2235
2235
|
description: "MCP server for MikroTik RouterOS \u2014 660+ tools over SSH for firewall, NAT, routing, DHCP, DNS, WireGuard, wireless, QoS and more.",
|
|
2236
2236
|
keywords: [
|
|
2237
2237
|
"ai",
|
|
@@ -2707,16 +2707,20 @@ async function main() {
|
|
|
2707
2707
|
warnIfPlaintextPasswordInContainer(Object.values(cfg.devices).some((d) => !!d.password));
|
|
2708
2708
|
const deviceNames = Object.keys(cfg.devices);
|
|
2709
2709
|
logger.info(`Starting ${SERVER_NAME} v${VERSION} (transport=${cfg.mcp.transport}, ` + `devices=${deviceNames.length === 1 ? deviceNames[0] : deviceNames.join("/")})`);
|
|
2710
|
-
|
|
2710
|
+
const startDashboard = async () => {
|
|
2711
|
+
if (!cfg.dashboard.enabled)
|
|
2712
|
+
return;
|
|
2711
2713
|
try {
|
|
2712
2714
|
await runDashboard(cfg.dashboard, cfg.mcp.transport);
|
|
2713
2715
|
} catch (e) {
|
|
2714
2716
|
logger.error(`Failed to start observability dashboard: ${e instanceof Error ? e.message : String(e)}. ` + "Continuing without it.");
|
|
2715
2717
|
}
|
|
2716
|
-
}
|
|
2718
|
+
};
|
|
2717
2719
|
if (cfg.mcp.transport === "stdio") {
|
|
2718
2720
|
await runStdio();
|
|
2721
|
+
await startDashboard();
|
|
2719
2722
|
} else {
|
|
2723
|
+
await startDashboard();
|
|
2720
2724
|
await runHttp(cfg.mcp);
|
|
2721
2725
|
}
|
|
2722
2726
|
}
|
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.33.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",
|
|
@@ -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) {
|
|
@@ -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(),
|
|
@@ -128,6 +140,15 @@ var configSource = { path: DEFAULT_CONFIG_FILE, fromFile: false };
|
|
|
128
140
|
function loadConfig(argv = process.argv.slice(2)) {
|
|
129
141
|
const flags = parseFlags(argv);
|
|
130
142
|
const pick = (flag, ...envNames) => flags[flag] ?? env(...envNames);
|
|
143
|
+
const jumpHostName = pick("jump-host", "MIKROTIK_JUMP_HOST");
|
|
144
|
+
const jumpHost = jumpHostName ? {
|
|
145
|
+
host: jumpHostName,
|
|
146
|
+
port: pick("jump-port", "MIKROTIK_JUMP_PORT"),
|
|
147
|
+
username: pick("jump-username", "MIKROTIK_JUMP_USERNAME"),
|
|
148
|
+
password: pick("jump-password", "MIKROTIK_JUMP_PASSWORD"),
|
|
149
|
+
keyFilename: pick("jump-key-filename", "MIKROTIK_JUMP_KEY_FILENAME"),
|
|
150
|
+
keyPassphrase: pick("jump-key-passphrase", "MIKROTIK_JUMP_KEY_PASSPHRASE")
|
|
151
|
+
} : undefined;
|
|
131
152
|
const single = {
|
|
132
153
|
host: pick("host", "MIKROTIK_HOST"),
|
|
133
154
|
username: pick("username", "MIKROTIK_USERNAME"),
|
|
@@ -137,6 +158,7 @@ function loadConfig(argv = process.argv.slice(2)) {
|
|
|
137
158
|
privateKey: pick("private-key", "MIKROTIK_PRIVATE_KEY"),
|
|
138
159
|
keyPassphrase: pick("key-passphrase", "MIKROTIK_KEY_PASSPHRASE"),
|
|
139
160
|
timeoutMs: pick("timeout-ms", "MIKROTIK_TIMEOUT_MS"),
|
|
161
|
+
jumpHost,
|
|
140
162
|
mac: pick("mac", "MIKROTIK_MAC"),
|
|
141
163
|
sourceMac: pick("source-mac", "MIKROTIK_SOURCE_MAC"),
|
|
142
164
|
macHost: pick("mac-host", "MIKROTIK_MAC_HOST"),
|
|
@@ -703,45 +725,72 @@ function decodeOutput(data) {
|
|
|
703
725
|
|
|
704
726
|
class MikroTikSSHClient {
|
|
705
727
|
client = null;
|
|
728
|
+
bastions = [];
|
|
706
729
|
opts;
|
|
707
730
|
lastError;
|
|
708
731
|
constructor(opts) {
|
|
709
732
|
this.opts = { port: 22, timeoutMs: 1e4, ...opts };
|
|
710
733
|
}
|
|
711
|
-
connect() {
|
|
734
|
+
async connect() {
|
|
712
735
|
this.lastError = undefined;
|
|
713
|
-
|
|
736
|
+
try {
|
|
737
|
+
const hops = [];
|
|
738
|
+
for (let j = this.opts.jump;j; j = j.jump)
|
|
739
|
+
hops.unshift(j);
|
|
740
|
+
const sequence = [...hops, this.opts];
|
|
741
|
+
let sock;
|
|
742
|
+
for (let i = 0;i < hops.length; i++) {
|
|
743
|
+
const client = await this.openClient(hops[i], sock);
|
|
744
|
+
this.bastions.push(client);
|
|
745
|
+
const next = sequence[i + 1];
|
|
746
|
+
sock = await this.forwardOut(client, next.host, next.port ?? 22);
|
|
747
|
+
}
|
|
748
|
+
this.client = await this.openClient(this.opts, sock);
|
|
749
|
+
return true;
|
|
750
|
+
} catch (e) {
|
|
751
|
+
this.lastError = e instanceof Error ? e.message : String(e);
|
|
752
|
+
logger.error(`Failed to connect to MikroTik: ${this.lastError}`);
|
|
753
|
+
this.disconnect();
|
|
754
|
+
return false;
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
openClient(o, sock) {
|
|
758
|
+
return new Promise((resolve2, reject) => {
|
|
714
759
|
const client = new Client;
|
|
715
760
|
const cfg = {
|
|
716
|
-
host:
|
|
717
|
-
port:
|
|
718
|
-
username:
|
|
719
|
-
readyTimeout:
|
|
761
|
+
host: o.host,
|
|
762
|
+
port: o.port ?? 22,
|
|
763
|
+
username: o.username,
|
|
764
|
+
readyTimeout: o.timeoutMs ?? 1e4
|
|
720
765
|
};
|
|
721
|
-
if (
|
|
722
|
-
cfg.
|
|
723
|
-
|
|
766
|
+
if (sock)
|
|
767
|
+
cfg.sock = sock;
|
|
768
|
+
if (o.privateKey) {
|
|
769
|
+
cfg.privateKey = o.privateKey;
|
|
770
|
+
} else if (o.keyFilename) {
|
|
724
771
|
try {
|
|
725
|
-
cfg.privateKey = readFileSync2(
|
|
772
|
+
cfg.privateKey = readFileSync2(o.keyFilename);
|
|
726
773
|
} catch (e) {
|
|
727
|
-
|
|
728
|
-
logger.error(`Failed to read SSH key file ${this.opts.keyFilename}: ${String(e)}`);
|
|
729
|
-
resolve2(false);
|
|
774
|
+
reject(new Error(`could not read key file ${o.keyFilename}: ${e instanceof Error ? e.message : String(e)}`));
|
|
730
775
|
return;
|
|
731
776
|
}
|
|
732
777
|
}
|
|
733
|
-
if (cfg.privateKey &&
|
|
734
|
-
cfg.passphrase =
|
|
735
|
-
if (
|
|
736
|
-
cfg.password =
|
|
737
|
-
client.on("ready", () =>
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
778
|
+
if (cfg.privateKey && o.keyPassphrase)
|
|
779
|
+
cfg.passphrase = o.keyPassphrase;
|
|
780
|
+
if (o.password)
|
|
781
|
+
cfg.password = o.password;
|
|
782
|
+
client.on("ready", () => resolve2(client)).on("error", (err) => reject(err)).connect(cfg);
|
|
783
|
+
});
|
|
784
|
+
}
|
|
785
|
+
forwardOut(via, host, port) {
|
|
786
|
+
return new Promise((resolve2, reject) => {
|
|
787
|
+
via.forwardOut("127.0.0.1", 0, host, port, (err, stream) => {
|
|
788
|
+
if (err) {
|
|
789
|
+
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)."));
|
|
790
|
+
} else {
|
|
791
|
+
resolve2(stream);
|
|
792
|
+
}
|
|
793
|
+
});
|
|
745
794
|
});
|
|
746
795
|
}
|
|
747
796
|
run(command, opts = {}) {
|
|
@@ -860,6 +909,12 @@ class MikroTikSSHClient {
|
|
|
860
909
|
} catch {}
|
|
861
910
|
this.client = null;
|
|
862
911
|
}
|
|
912
|
+
for (const b of this.bastions.reverse()) {
|
|
913
|
+
try {
|
|
914
|
+
b.end();
|
|
915
|
+
} catch {}
|
|
916
|
+
}
|
|
917
|
+
this.bastions = [];
|
|
863
918
|
}
|
|
864
919
|
}
|
|
865
920
|
|
|
@@ -867,6 +922,43 @@ class MikroTikSSHClient {
|
|
|
867
922
|
function isMacTelnetDevice(dc) {
|
|
868
923
|
return Boolean(dc.mac);
|
|
869
924
|
}
|
|
925
|
+
function sshOptionsOf(dc) {
|
|
926
|
+
return {
|
|
927
|
+
host: dc.host,
|
|
928
|
+
username: dc.username,
|
|
929
|
+
password: dc.password,
|
|
930
|
+
keyFilename: dc.keyFilename,
|
|
931
|
+
privateKey: dc.privateKey,
|
|
932
|
+
keyPassphrase: dc.keyPassphrase,
|
|
933
|
+
port: dc.port,
|
|
934
|
+
timeoutMs: dc.timeoutMs
|
|
935
|
+
};
|
|
936
|
+
}
|
|
937
|
+
function resolveJump(dc, seen = new Set) {
|
|
938
|
+
if (dc.jumpHost) {
|
|
939
|
+
return {
|
|
940
|
+
host: dc.jumpHost.host,
|
|
941
|
+
port: dc.jumpHost.port,
|
|
942
|
+
username: dc.jumpHost.username,
|
|
943
|
+
password: dc.jumpHost.password,
|
|
944
|
+
keyFilename: dc.jumpHost.keyFilename,
|
|
945
|
+
privateKey: dc.jumpHost.privateKey,
|
|
946
|
+
keyPassphrase: dc.jumpHost.keyPassphrase,
|
|
947
|
+
timeoutMs: dc.jumpHost.timeoutMs
|
|
948
|
+
};
|
|
949
|
+
}
|
|
950
|
+
if (!dc.jumpVia)
|
|
951
|
+
return;
|
|
952
|
+
if (seen.has(dc.jumpVia)) {
|
|
953
|
+
throw new Error(`SSH jump-host cycle detected at '${dc.jumpVia}' (a device can't jump through itself).`);
|
|
954
|
+
}
|
|
955
|
+
seen.add(dc.jumpVia);
|
|
956
|
+
const bastion = getDevice(dc.jumpVia);
|
|
957
|
+
if (isMacTelnetDevice(bastion)) {
|
|
958
|
+
throw new Error(`Jump host '${dc.jumpVia}' is a MAC-Telnet device; an SSH bastion must be reachable over SSH.`);
|
|
959
|
+
}
|
|
960
|
+
return { ...sshOptionsOf(bastion), jump: resolveJump(bastion, seen) };
|
|
961
|
+
}
|
|
870
962
|
function createDeviceClient(dc) {
|
|
871
963
|
if (isMacTelnetDevice(dc)) {
|
|
872
964
|
return new MikroTikMacTelnetClient({
|
|
@@ -879,16 +971,7 @@ function createDeviceClient(dc) {
|
|
|
879
971
|
timeoutMs: dc.timeoutMs
|
|
880
972
|
});
|
|
881
973
|
}
|
|
882
|
-
return new MikroTikSSHClient({
|
|
883
|
-
host: dc.host,
|
|
884
|
-
username: dc.username,
|
|
885
|
-
password: dc.password,
|
|
886
|
-
keyFilename: dc.keyFilename,
|
|
887
|
-
privateKey: dc.privateKey,
|
|
888
|
-
keyPassphrase: dc.keyPassphrase,
|
|
889
|
-
port: dc.port,
|
|
890
|
-
timeoutMs: dc.timeoutMs
|
|
891
|
-
});
|
|
974
|
+
return new MikroTikSSHClient({ ...sshOptionsOf(dc), jump: resolveJump(dc) });
|
|
892
975
|
}
|
|
893
976
|
function connectErrorMessage(name, dc, lastError) {
|
|
894
977
|
const reason = lastError ? ` \u2014 ${lastError}` : "";
|
|
@@ -4402,40 +4485,39 @@ var changePlanTools = [
|
|
|
4402
4485
|
return "No commands provided to apply.";
|
|
4403
4486
|
const device = resolveDeviceName(ctx.device);
|
|
4404
4487
|
if (getDevice(ctx.device).mac) {
|
|
4405
|
-
|
|
4488
|
+
throw new Error("Safe Mode (and therefore apply_plan) requires SSH; it is not available on a MAC-Telnet device.");
|
|
4406
4489
|
}
|
|
4407
4490
|
const plan = buildChangePlan(commands);
|
|
4408
4491
|
const safe = getSafeModeManager(device);
|
|
4409
4492
|
const enabled2 = await safe.enable();
|
|
4410
4493
|
if (enabled2.startsWith("Error"))
|
|
4411
|
-
|
|
4494
|
+
throw new Error(enabled2);
|
|
4412
4495
|
const APPLY_BUDGET_MS = 90000;
|
|
4413
4496
|
const startedAt = Date.now();
|
|
4414
4497
|
const overBudget = () => Date.now() - startedAt > APPLY_BUDGET_MS;
|
|
4415
|
-
|
|
4416
|
-
|
|
4417
|
-
|
|
4418
|
-
|
|
4419
|
-
|
|
4420
|
-
|
|
4421
|
-
|
|
4422
|
-
|
|
4423
|
-
|
|
4424
|
-
|
|
4425
|
-
|
|
4426
|
-
|
|
4427
|
-
|
|
4428
|
-
|
|
4498
|
+
const applyAndDiff = async () => {
|
|
4499
|
+
try {
|
|
4500
|
+
const before = normalizeExport(await safe.execute("/export terse"));
|
|
4501
|
+
const log = [];
|
|
4502
|
+
for (const step of plan.steps) {
|
|
4503
|
+
if (overBudget()) {
|
|
4504
|
+
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");
|
|
4505
|
+
}
|
|
4506
|
+
const out = await safe.execute(step.command);
|
|
4507
|
+
if (looksLikeError(out)) {
|
|
4508
|
+
throw new Error(`step ${step.index} failed:
|
|
4509
|
+
${step.command}
|
|
4510
|
+
\u2192 ${out.trim()}`);
|
|
4511
|
+
}
|
|
4512
|
+
log.push(` \u2713 ${step.command}`);
|
|
4429
4513
|
}
|
|
4430
|
-
|
|
4431
|
-
|
|
4432
|
-
|
|
4433
|
-
|
|
4434
|
-
const reachable = !looksLikeError(await safe.execute("/system identity print"));
|
|
4435
|
-
const planBody = renderPlan(plan).split(`
|
|
4514
|
+
const after = normalizeExport(await safe.execute("/export terse"));
|
|
4515
|
+
const diff = diffLines(before, after, { fromLabel: "before", toLabel: "after" });
|
|
4516
|
+
const reachable2 = !looksLikeError(await safe.execute("/system identity print"));
|
|
4517
|
+
const planBody = renderPlan(plan).split(`
|
|
4436
4518
|
`).slice(2).join(`
|
|
4437
4519
|
`);
|
|
4438
|
-
|
|
4520
|
+
const header2 = `APPLY PLAN \u2014 ${device}
|
|
4439
4521
|
|
|
4440
4522
|
${planBody}
|
|
4441
4523
|
|
|
@@ -4445,32 +4527,35 @@ ${log.join(`
|
|
|
4445
4527
|
|
|
4446
4528
|
` + `EXACT DIFF (+${diff.summary.added}/-${diff.summary.removed}):
|
|
4447
4529
|
${diff.unified || "(no config change)"}`;
|
|
4448
|
-
|
|
4449
|
-
|
|
4450
|
-
|
|
4530
|
+
return { header: header2, reachable: reachable2 };
|
|
4531
|
+
} catch (e) {
|
|
4532
|
+
await safe.rollback().catch(() => {});
|
|
4533
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
4534
|
+
throw new Error(`apply_plan failed and was rolled back (nothing committed): ${msg}`);
|
|
4535
|
+
}
|
|
4536
|
+
};
|
|
4537
|
+
const { header, reachable } = await applyAndDiff();
|
|
4538
|
+
if (!a.confirm) {
|
|
4539
|
+
await safe.rollback();
|
|
4540
|
+
return `${header}
|
|
4451
4541
|
|
|
4452
4542
|
DRY-RUN: all changes rolled back. Re-run with confirm=true to commit.`;
|
|
4453
|
-
|
|
4454
|
-
|
|
4455
|
-
|
|
4456
|
-
|
|
4543
|
+
}
|
|
4544
|
+
if (!reachable) {
|
|
4545
|
+
await safe.rollback();
|
|
4546
|
+
throw new Error(`${header}
|
|
4457
4547
|
|
|
4458
|
-
ABORTED: the device stopped responding after applying \u2014 changes rolled back to avoid a lock-out
|
|
4459
|
-
|
|
4460
|
-
|
|
4461
|
-
|
|
4462
|
-
|
|
4548
|
+
ABORTED: the device stopped responding after applying \u2014 changes rolled back to avoid a lock-out.`);
|
|
4549
|
+
}
|
|
4550
|
+
const committed = await safe.commit();
|
|
4551
|
+
if (!committed.ok) {
|
|
4552
|
+
throw new Error(`${header}
|
|
4463
4553
|
|
|
4464
|
-
COMMIT FAILED \u2014 changes are NOT saved (still pending in Safe Mode).
|
|
4465
|
-
|
|
4466
|
-
|
|
4554
|
+
COMMIT FAILED \u2014 changes are NOT saved (still pending in Safe Mode). ${committed.message}`);
|
|
4555
|
+
}
|
|
4556
|
+
return `${header}
|
|
4467
4557
|
|
|
4468
4558
|
COMMITTED: changes are now permanent. ${committed.message}`;
|
|
4469
|
-
} catch (e) {
|
|
4470
|
-
await safe.rollback();
|
|
4471
|
-
const msg = e instanceof Error ? e.message : String(e);
|
|
4472
|
-
return `apply_plan failed and was rolled back: ${msg}`;
|
|
4473
|
-
}
|
|
4474
4559
|
}
|
|
4475
4560
|
})
|
|
4476
4561
|
];
|
|
@@ -5847,7 +5932,7 @@ var cache = null;
|
|
|
5847
5932
|
async function gateway() {
|
|
5848
5933
|
if (cache)
|
|
5849
5934
|
return cache;
|
|
5850
|
-
const { moduleCatalog } = await import("./library-
|
|
5935
|
+
const { moduleCatalog } = await import("./library-c629x3tn.js");
|
|
5851
5936
|
const forIndex = [];
|
|
5852
5937
|
const byName = new Map;
|
|
5853
5938
|
for (const mod of moduleCatalog) {
|
package/package.json
CHANGED
|
@@ -53,6 +53,46 @@
|
|
|
53
53
|
"exclusiveMinimum": 0,
|
|
54
54
|
"maximum": 9007199254740991
|
|
55
55
|
},
|
|
56
|
+
"jumpVia": {
|
|
57
|
+
"type": "string"
|
|
58
|
+
},
|
|
59
|
+
"jumpHost": {
|
|
60
|
+
"type": "object",
|
|
61
|
+
"properties": {
|
|
62
|
+
"host": {
|
|
63
|
+
"type": "string"
|
|
64
|
+
},
|
|
65
|
+
"port": {
|
|
66
|
+
"default": 22,
|
|
67
|
+
"type": "integer",
|
|
68
|
+
"exclusiveMinimum": 0,
|
|
69
|
+
"maximum": 9007199254740991
|
|
70
|
+
},
|
|
71
|
+
"username": {
|
|
72
|
+
"default": "admin",
|
|
73
|
+
"type": "string"
|
|
74
|
+
},
|
|
75
|
+
"password": {
|
|
76
|
+
"type": "string"
|
|
77
|
+
},
|
|
78
|
+
"keyFilename": {
|
|
79
|
+
"type": "string"
|
|
80
|
+
},
|
|
81
|
+
"privateKey": {
|
|
82
|
+
"type": "string"
|
|
83
|
+
},
|
|
84
|
+
"keyPassphrase": {
|
|
85
|
+
"type": "string"
|
|
86
|
+
},
|
|
87
|
+
"timeoutMs": {
|
|
88
|
+
"type": "integer",
|
|
89
|
+
"exclusiveMinimum": 0,
|
|
90
|
+
"maximum": 9007199254740991
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
"required": ["host", "port", "username"],
|
|
94
|
+
"additionalProperties": false
|
|
95
|
+
},
|
|
56
96
|
"mac": {
|
|
57
97
|
"type": "string"
|
|
58
98
|
},
|