mikromcp 1.13.0 → 1.13.1
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 +1 -1
- package/config/routers.example.yaml +2 -2
- package/dist/main.js +87 -31
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
[](https://github.com/AliKarami/MikroMCP/actions/workflows/ci.yml)
|
|
14
14
|
[](https://github.com/AliKarami/MikroMCP/actions/workflows/release.yml)
|
|
15
|
-
[](package.json)
|
|
16
16
|
[](LICENSE)
|
|
17
17
|
[](package.json)
|
|
18
18
|
[](https://help.mikrotik.com/docs/display/ROS/REST+API)
|
|
@@ -62,7 +62,7 @@ routers:
|
|
|
62
62
|
# sshPort: 22 # SSH port (default 22)
|
|
63
63
|
# sshUsername: "automation" # Optional SSH/SFTP username; defaults to REST username
|
|
64
64
|
# sshPrivateKeyPath: "/home/user/.ssh/id_ed25519" # Absolute path; suppresses SSH/SFTP password auth
|
|
65
|
-
# sshFingerprint: "
|
|
65
|
+
# sshFingerprint: "SHA256:..." # Pin the SSH host key: SHA256:<base64> as ssh-keygen -l prints it, or hex
|
|
66
66
|
# cmdAllow: # Strict allow-list for run_command (see above)
|
|
67
67
|
# - "/ip/route/print*"
|
|
68
68
|
# cmdDeny: # Additional deny patterns beyond builtins
|
|
@@ -85,4 +85,4 @@ routers:
|
|
|
85
85
|
# envPrefix: "ROUTER_EDGE02"
|
|
86
86
|
# tags: ["branch-office", "edge"]
|
|
87
87
|
# rosVersion: "7.12"
|
|
88
|
-
# sshFingerprint: "
|
|
88
|
+
# sshFingerprint: "SHA256:..."
|
package/dist/main.js
CHANGED
|
@@ -154,6 +154,7 @@ var UNREACHABLE_CODES = /* @__PURE__ */ new Set([
|
|
|
154
154
|
var TIMEOUT_CODES = /* @__PURE__ */ new Set(["UND_ERR_HEADERS_TIMEOUT", "UND_ERR_BODY_TIMEOUT"]);
|
|
155
155
|
var SSH_AUTH_LEVEL = "client-authentication";
|
|
156
156
|
var SSH_TIMEOUT_LEVEL = "client-timeout";
|
|
157
|
+
var SSH_HOST_KEY_MISMATCH = "SSH_HOST_KEY_MISMATCH";
|
|
157
158
|
function defaultRecoverability(category) {
|
|
158
159
|
switch (category) {
|
|
159
160
|
case "VALIDATION" /* VALIDATION */:
|
|
@@ -294,6 +295,22 @@ function enrichError(error, context) {
|
|
|
294
295
|
cause: error
|
|
295
296
|
});
|
|
296
297
|
}
|
|
298
|
+
if (typeof raw === "object" && raw !== null && raw.code === SSH_HOST_KEY_MISMATCH) {
|
|
299
|
+
return new MikroMCPError({
|
|
300
|
+
category: "ROUTER_AUTH_FAILED" /* ROUTER_AUTH_FAILED */,
|
|
301
|
+
code: SSH_HOST_KEY_MISMATCH,
|
|
302
|
+
message: rawMessage,
|
|
303
|
+
details: buildDetails(
|
|
304
|
+
{ transport: "ssh", expected: raw.expected, actual: raw.actual },
|
|
305
|
+
context
|
|
306
|
+
),
|
|
307
|
+
recoverability: {
|
|
308
|
+
retryable: false,
|
|
309
|
+
suggestedAction: "A reset, Netinstall or /ip ssh regenerate-host-key changes the router's host key; so does another device on that address, or an intercepted connection. Do not copy the presented fingerprint into routers.yaml unverified: have the operator confirm the router's key over a channel they trust, then update sshFingerprint and restart the server."
|
|
310
|
+
},
|
|
311
|
+
cause: error
|
|
312
|
+
});
|
|
313
|
+
}
|
|
297
314
|
if (typeof raw === "object" && raw !== null && typeof raw.statusCode === "number") {
|
|
298
315
|
const statusCode = raw.statusCode;
|
|
299
316
|
let category2 = categoryFromStatus(statusCode);
|
|
@@ -535,6 +552,9 @@ import { z as z3 } from "zod";
|
|
|
535
552
|
var NUMERIC_RE = /^-?\d+(\.\d+)?$/;
|
|
536
553
|
var DURATION_RE = /^\d+[wdhms](\d+[wdhms])*$/;
|
|
537
554
|
var FREE_TEXT_KEYS = /* @__PURE__ */ new Set(["comment", "text"]);
|
|
555
|
+
function isVersionKey(key2) {
|
|
556
|
+
return key2 === "version" || key2.endsWith("-version") || key2.endsWith("-firmware");
|
|
557
|
+
}
|
|
538
558
|
function parseRouterOSValue(key2, value) {
|
|
539
559
|
if (key2 === ".id" || FREE_TEXT_KEYS.has(key2)) {
|
|
540
560
|
return value;
|
|
@@ -545,8 +565,9 @@ function parseRouterOSValue(key2, value) {
|
|
|
545
565
|
return value;
|
|
546
566
|
}
|
|
547
567
|
if (NUMERIC_RE.test(value)) {
|
|
548
|
-
const num = Number(value);
|
|
549
568
|
const isInteger = !value.includes(".");
|
|
569
|
+
if (!isInteger && isVersionKey(key2)) return value;
|
|
570
|
+
const num = Number(value);
|
|
550
571
|
if (isInteger ? Number.isSafeInteger(num) : Number.isFinite(num)) {
|
|
551
572
|
return num;
|
|
552
573
|
}
|
|
@@ -14032,9 +14053,37 @@ function getCredentials(router) {
|
|
|
14032
14053
|
}
|
|
14033
14054
|
|
|
14034
14055
|
// src/adapter/ssh-client.ts
|
|
14035
|
-
import { createHash as createHash2 } from "crypto";
|
|
14036
14056
|
import { readFileSync as readFileSync3 } from "fs";
|
|
14037
14057
|
import { Client } from "ssh2";
|
|
14058
|
+
|
|
14059
|
+
// src/adapter/ssh-host-key.ts
|
|
14060
|
+
import { createHash as createHash2 } from "crypto";
|
|
14061
|
+
function pinHostKey(fingerprint2) {
|
|
14062
|
+
const expected = fingerprint2.toLowerCase();
|
|
14063
|
+
let refused;
|
|
14064
|
+
return {
|
|
14065
|
+
hostVerifier: (key2) => {
|
|
14066
|
+
const digest = createHash2("sha256").update(key2).digest();
|
|
14067
|
+
if (digest.toString("hex") === expected) return true;
|
|
14068
|
+
refused = digest;
|
|
14069
|
+
return false;
|
|
14070
|
+
},
|
|
14071
|
+
connectionError: (err) => {
|
|
14072
|
+
if (refused === void 0) return err;
|
|
14073
|
+
const actual = refused.toString("hex");
|
|
14074
|
+
const openssh = `SHA256:${refused.toString("base64").replace(/=+$/, "")}`;
|
|
14075
|
+
return Object.assign(
|
|
14076
|
+
new Error(
|
|
14077
|
+
`SSH host key does not match sshFingerprint. Expected: ${expected}, got: ${actual} (${openssh})`,
|
|
14078
|
+
{ cause: err }
|
|
14079
|
+
),
|
|
14080
|
+
{ code: "SSH_HOST_KEY_MISMATCH", expected, actual }
|
|
14081
|
+
);
|
|
14082
|
+
}
|
|
14083
|
+
};
|
|
14084
|
+
}
|
|
14085
|
+
|
|
14086
|
+
// src/adapter/ssh-client.ts
|
|
14038
14087
|
var SshClient = class {
|
|
14039
14088
|
constructor(config, credentials, options) {
|
|
14040
14089
|
this.config = config;
|
|
@@ -14052,6 +14101,7 @@ var SshClient = class {
|
|
|
14052
14101
|
const privateKey = this.config.sshPrivateKeyPath ? readFileSync3(this.config.sshPrivateKeyPath) : void 0;
|
|
14053
14102
|
return new Promise((resolve, reject) => {
|
|
14054
14103
|
const conn = new Client();
|
|
14104
|
+
const pin = this.config.sshFingerprint ? pinHostKey(this.config.sshFingerprint) : void 0;
|
|
14055
14105
|
const chunks = [];
|
|
14056
14106
|
let outputSize = 0;
|
|
14057
14107
|
let truncated = false;
|
|
@@ -14111,7 +14161,7 @@ var SshClient = class {
|
|
|
14111
14161
|
});
|
|
14112
14162
|
});
|
|
14113
14163
|
});
|
|
14114
|
-
conn.on("error", (err) => cleanup(err));
|
|
14164
|
+
conn.on("error", (err) => cleanup(pin ? pin.connectionError(err) : err));
|
|
14115
14165
|
const connectOptions = {
|
|
14116
14166
|
host: this.config.host,
|
|
14117
14167
|
port: this.config.sshPort ?? 22,
|
|
@@ -14120,13 +14170,7 @@ var SshClient = class {
|
|
|
14120
14170
|
};
|
|
14121
14171
|
if (privateKey) connectOptions.privateKey = privateKey;
|
|
14122
14172
|
else connectOptions.password = this.credentials.password;
|
|
14123
|
-
if (
|
|
14124
|
-
const expected = this.config.sshFingerprint.toLowerCase();
|
|
14125
|
-
connectOptions.hostVerifier = (key2) => {
|
|
14126
|
-
const actual = createHash2("sha256").update(key2).digest("hex");
|
|
14127
|
-
return actual === expected;
|
|
14128
|
-
};
|
|
14129
|
-
}
|
|
14173
|
+
if (pin) connectOptions.hostVerifier = pin.hostVerifier;
|
|
14130
14174
|
conn.connect(connectOptions);
|
|
14131
14175
|
});
|
|
14132
14176
|
}
|
|
@@ -14180,7 +14224,6 @@ var FtpClient = class {
|
|
|
14180
14224
|
};
|
|
14181
14225
|
|
|
14182
14226
|
// src/adapter/sftp-client.ts
|
|
14183
|
-
import { createHash as createHash3 } from "crypto";
|
|
14184
14227
|
import { readFileSync as readFileSync4 } from "fs";
|
|
14185
14228
|
import { Client as Client3 } from "ssh2";
|
|
14186
14229
|
var SftpClient = class {
|
|
@@ -14194,6 +14237,7 @@ var SftpClient = class {
|
|
|
14194
14237
|
const privateKey = this.config.sshPrivateKeyPath ? readFileSync4(this.config.sshPrivateKeyPath) : void 0;
|
|
14195
14238
|
return new Promise((resolve, reject) => {
|
|
14196
14239
|
const conn = new Client3();
|
|
14240
|
+
const pin = this.config.sshFingerprint ? pinHostKey(this.config.sshFingerprint) : void 0;
|
|
14197
14241
|
let settled = false;
|
|
14198
14242
|
const cleanup = (err) => {
|
|
14199
14243
|
if (settled) return;
|
|
@@ -14213,7 +14257,7 @@ var SftpClient = class {
|
|
|
14213
14257
|
});
|
|
14214
14258
|
});
|
|
14215
14259
|
});
|
|
14216
|
-
conn.on("error", (err) => cleanup(err));
|
|
14260
|
+
conn.on("error", (err) => cleanup(pin ? pin.connectionError(err) : err));
|
|
14217
14261
|
const connectOptions = {
|
|
14218
14262
|
host: this.config.host,
|
|
14219
14263
|
port: this.config.sshPort ?? 22,
|
|
@@ -14222,13 +14266,7 @@ var SftpClient = class {
|
|
|
14222
14266
|
};
|
|
14223
14267
|
if (privateKey) connectOptions.privateKey = privateKey;
|
|
14224
14268
|
else connectOptions.password = this.credentials.password;
|
|
14225
|
-
if (
|
|
14226
|
-
const expected = this.config.sshFingerprint.toLowerCase();
|
|
14227
|
-
connectOptions.hostVerifier = (key2) => {
|
|
14228
|
-
const actual = createHash3("sha256").update(key2).digest("hex");
|
|
14229
|
-
return actual === expected;
|
|
14230
|
-
};
|
|
14231
|
-
}
|
|
14269
|
+
if (pin) connectOptions.hostVerifier = pin.hostVerifier;
|
|
14232
14270
|
conn.connect(connectOptions);
|
|
14233
14271
|
});
|
|
14234
14272
|
}
|
|
@@ -14365,13 +14403,13 @@ function getStdioIdentity(stdioIdentityName, registry) {
|
|
|
14365
14403
|
}
|
|
14366
14404
|
|
|
14367
14405
|
// src/middleware/confirmation.ts
|
|
14368
|
-
import { createHmac, createHash as
|
|
14406
|
+
import { createHmac, createHash as createHash3, timingSafeEqual } from "crypto";
|
|
14369
14407
|
var CONFIRMATION_TTL_MS = 5 * 60 * 1e3;
|
|
14370
14408
|
var BYPASS_ROLES = ["admin", "superadmin"];
|
|
14371
14409
|
var usedTokens = /* @__PURE__ */ new Map();
|
|
14372
14410
|
function computeParamHash(params) {
|
|
14373
14411
|
const { confirmationToken: _, ...rest } = params;
|
|
14374
|
-
return
|
|
14412
|
+
return createHash3("sha256").update(JSON.stringify(rest)).digest("hex");
|
|
14375
14413
|
}
|
|
14376
14414
|
function sign(tool, routerId2, identityId, paramHash, expiresAtMs, secret) {
|
|
14377
14415
|
const payload = `${tool}|${routerId2}|${identityId}|${paramHash}|${expiresAtMs}`;
|
|
@@ -14947,7 +14985,7 @@ async function executeToolCall(tool, args, deps) {
|
|
|
14947
14985
|
}
|
|
14948
14986
|
|
|
14949
14987
|
// src/middleware/fleet-confirmation.ts
|
|
14950
|
-
import { createHmac as createHmac2, createHash as
|
|
14988
|
+
import { createHmac as createHmac2, createHash as createHash4, randomBytes as randomBytes2, timingSafeEqual as timingSafeEqual2 } from "crypto";
|
|
14951
14989
|
var CONFIRMATION_TTL_MS2 = 5 * 60 * 1e3;
|
|
14952
14990
|
var processSecret = randomBytes2(32).toString("hex");
|
|
14953
14991
|
var usedTokens2 = /* @__PURE__ */ new Map();
|
|
@@ -14959,7 +14997,7 @@ function fingerprint(args) {
|
|
|
14959
14997
|
params: args.params,
|
|
14960
14998
|
identityId: args.identityId
|
|
14961
14999
|
});
|
|
14962
|
-
return
|
|
15000
|
+
return createHash4("sha256").update(payload).digest("hex");
|
|
14963
15001
|
}
|
|
14964
15002
|
function sign2(fp, expiresAtMs, secret) {
|
|
14965
15003
|
const mac = createHmac2("sha256", secret).update(`${fp}|${expiresAtMs}`).digest("hex");
|
|
@@ -15577,6 +15615,14 @@ function privateKeyIsShared(path) {
|
|
|
15577
15615
|
if (process.platform === "win32") return false;
|
|
15578
15616
|
return (statSync(path).mode & 63) !== 0;
|
|
15579
15617
|
}
|
|
15618
|
+
var SHA256_HEX_RE = /^[0-9a-f]{64}$/;
|
|
15619
|
+
var OPENSSH_SHA256_RE = /^SHA256:([A-Za-z0-9+/]{43})=?$/;
|
|
15620
|
+
function sshFingerprintHex(value) {
|
|
15621
|
+
const openssh = OPENSSH_SHA256_RE.exec(value);
|
|
15622
|
+
if (openssh) return Buffer.from(openssh[1], "base64").toString("hex");
|
|
15623
|
+
const hex = value.replace(/:/g, "").toLowerCase();
|
|
15624
|
+
return SHA256_HEX_RE.test(hex) ? hex : null;
|
|
15625
|
+
}
|
|
15580
15626
|
|
|
15581
15627
|
// src/config/router-registry.ts
|
|
15582
15628
|
var log51 = createLogger("router-registry");
|
|
@@ -15605,7 +15651,17 @@ var RouterConfigSchema = z49.object({
|
|
|
15605
15651
|
sshPort: z49.number().int().min(1).max(65535).optional(),
|
|
15606
15652
|
sshUsername: z49.string().min(1).optional(),
|
|
15607
15653
|
sshPrivateKeyPath: z49.string().min(1).refine(isAbsolute, "sshPrivateKeyPath must be absolute").optional(),
|
|
15608
|
-
|
|
15654
|
+
// Stored as lowercase hex, the form SshClient compares. A malformed value can
|
|
15655
|
+
// never match a host key, so it fails here instead of on every SSH call.
|
|
15656
|
+
sshFingerprint: z49.string().transform((value, ctx) => {
|
|
15657
|
+
const hex = sshFingerprintHex(value);
|
|
15658
|
+
if (hex !== null) return hex;
|
|
15659
|
+
ctx.addIssue({
|
|
15660
|
+
code: z49.ZodIssueCode.custom,
|
|
15661
|
+
message: "sshFingerprint must be a SHA-256 host-key fingerprint: 64 hex characters (colons allowed) or SHA256:<base64> as ssh-keygen -l prints it"
|
|
15662
|
+
});
|
|
15663
|
+
return z49.NEVER;
|
|
15664
|
+
}).optional(),
|
|
15609
15665
|
cmdAllow: z49.array(z49.string()).optional(),
|
|
15610
15666
|
cmdDeny: z49.array(z49.string()).optional(),
|
|
15611
15667
|
maintenanceWindows: z49.array(
|
|
@@ -15740,9 +15796,9 @@ ${keyIssues.join("\n")}`);
|
|
|
15740
15796
|
};
|
|
15741
15797
|
|
|
15742
15798
|
// src/adapter/connection-pool.ts
|
|
15743
|
-
import { createHash as
|
|
15799
|
+
import { createHash as createHash5 } from "crypto";
|
|
15744
15800
|
function credentialHash(credentials) {
|
|
15745
|
-
return
|
|
15801
|
+
return createHash5("sha256").update(`${credentials.username}\0${credentials.password}`).digest("hex");
|
|
15746
15802
|
}
|
|
15747
15803
|
function createDeviceClient(config, credentials) {
|
|
15748
15804
|
if (config.deviceType === "swos") {
|
|
@@ -15804,7 +15860,7 @@ var ConnectionPool = class {
|
|
|
15804
15860
|
};
|
|
15805
15861
|
|
|
15806
15862
|
// src/version.ts
|
|
15807
|
-
var VERSION = "1.13.
|
|
15863
|
+
var VERSION = "1.13.1";
|
|
15808
15864
|
|
|
15809
15865
|
// src/mcp/server.ts
|
|
15810
15866
|
var SERVER_INSTRUCTIONS = [
|
|
@@ -16112,7 +16168,7 @@ async function handleMcpRequest(pathname, req, res, url, port, maxBodyBytes, mak
|
|
|
16112
16168
|
|
|
16113
16169
|
// src/config/identity-registry.ts
|
|
16114
16170
|
import { readFileSync as readFileSync6, existsSync as existsSync2 } from "fs";
|
|
16115
|
-
import { createHash as
|
|
16171
|
+
import { createHash as createHash6 } from "crypto";
|
|
16116
16172
|
import { parse as parse2 } from "yaml";
|
|
16117
16173
|
import { z as z50 } from "zod";
|
|
16118
16174
|
import bcrypt from "bcryptjs";
|
|
@@ -16167,7 +16223,7 @@ ${issues}`);
|
|
|
16167
16223
|
}));
|
|
16168
16224
|
}
|
|
16169
16225
|
async findIdentityByToken(token) {
|
|
16170
|
-
const cacheKey =
|
|
16226
|
+
const cacheKey = createHash6("sha256").update(token).digest("hex");
|
|
16171
16227
|
const toIdentity = (stored) => ({
|
|
16172
16228
|
id: stored.id,
|
|
16173
16229
|
role: stored.role,
|
|
@@ -16282,7 +16338,7 @@ import { existsSync as existsSync3, readFileSync as readFileSync7 } from "fs";
|
|
|
16282
16338
|
import { homedir as homedir2 } from "os";
|
|
16283
16339
|
import * as net from "net";
|
|
16284
16340
|
import chalk from "chalk";
|
|
16285
|
-
var localVersion = "1.13.
|
|
16341
|
+
var localVersion = "1.13.1";
|
|
16286
16342
|
var results = [];
|
|
16287
16343
|
function ok(msg) {
|
|
16288
16344
|
console.log(chalk.green("\u2705 ") + msg);
|
|
@@ -17027,7 +17083,7 @@ async function runInit() {
|
|
|
17027
17083
|
}
|
|
17028
17084
|
|
|
17029
17085
|
// src/cli/index.ts
|
|
17030
|
-
var program = new Command().name("mikromcp").description("MikroTik RouterOS MCP server").version("1.13.
|
|
17086
|
+
var program = new Command().name("mikromcp").description("MikroTik RouterOS MCP server").version("1.13.1");
|
|
17031
17087
|
program.command("serve", { isDefault: true }).description("Start the MCP server (default)").action(runServe);
|
|
17032
17088
|
program.command("doctor").description("Check config, connectivity, and permissions").action(runDoctor);
|
|
17033
17089
|
program.command("init").description("Interactive setup wizard").action(runInit);
|