mikromcp 1.8.0 → 1.9.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 +6 -5
- package/config/routers.example.yaml +19 -0
- package/dist/main.js +2022 -565
- package/package.json +1 -1
package/dist/main.js
CHANGED
|
@@ -126,7 +126,7 @@ function loadAppConfig() {
|
|
|
126
126
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
127
127
|
|
|
128
128
|
// src/mcp/tool-registry.ts
|
|
129
|
-
import { z as
|
|
129
|
+
import { z as z48 } from "zod";
|
|
130
130
|
|
|
131
131
|
// src/domain/tools/system-tools.ts
|
|
132
132
|
import { z as z2 } from "zod";
|
|
@@ -301,9 +301,24 @@ function enrichError(error, context) {
|
|
|
301
301
|
}
|
|
302
302
|
|
|
303
303
|
// src/domain/tools/tool-definition.ts
|
|
304
|
+
function snapshotPathsFor(tool, params) {
|
|
305
|
+
return typeof tool.snapshotPaths === "function" ? tool.snapshotPaths(params) : tool.snapshotPaths ?? [];
|
|
306
|
+
}
|
|
304
307
|
function toolError(err, context, tool) {
|
|
305
308
|
return enrichError(err, { routerId: context.routerId, tool });
|
|
306
309
|
}
|
|
310
|
+
function unavailableCapability(code, message, suggestedAction) {
|
|
311
|
+
return new Proxy({}, {
|
|
312
|
+
get() {
|
|
313
|
+
throw new MikroMCPError({
|
|
314
|
+
category: "INTERNAL" /* INTERNAL */,
|
|
315
|
+
code,
|
|
316
|
+
message,
|
|
317
|
+
recoverability: { retryable: false, suggestedAction }
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
});
|
|
321
|
+
}
|
|
307
322
|
|
|
308
323
|
// src/observability/logger.ts
|
|
309
324
|
import pino from "pino";
|
|
@@ -4950,11 +4965,11 @@ var listFilesTool = {
|
|
|
4950
4965
|
if (parsed.name !== void 0) {
|
|
4951
4966
|
const needle = parsed.name.toLowerCase();
|
|
4952
4967
|
files = files.filter(
|
|
4953
|
-
(
|
|
4968
|
+
(f2) => (f2.name ?? "").toLowerCase().includes(needle)
|
|
4954
4969
|
);
|
|
4955
4970
|
}
|
|
4956
4971
|
if (parsed.type !== void 0) {
|
|
4957
|
-
files = files.filter((
|
|
4972
|
+
files = files.filter((f2) => f2.type === parsed.type);
|
|
4958
4973
|
}
|
|
4959
4974
|
return {
|
|
4960
4975
|
content: listContent(
|
|
@@ -4963,7 +4978,7 @@ var listFilesTool = {
|
|
|
4963
4978
|
files,
|
|
4964
4979
|
files.length,
|
|
4965
4980
|
0,
|
|
4966
|
-
(
|
|
4981
|
+
(f2) => compactFields(f2, ["name", "type", "size", "creation-time"])
|
|
4967
4982
|
),
|
|
4968
4983
|
structuredContent: { routerId: context.routerId, files, total: files.length }
|
|
4969
4984
|
};
|
|
@@ -4995,7 +5010,7 @@ var getFileContentTool = {
|
|
|
4995
5010
|
limit: void 0,
|
|
4996
5011
|
offset: void 0
|
|
4997
5012
|
});
|
|
4998
|
-
const file = all.find((
|
|
5013
|
+
const file = all.find((f2) => f2.name === parsed.name);
|
|
4999
5014
|
if (!file) {
|
|
5000
5015
|
throw new MikroMCPError({
|
|
5001
5016
|
category: "NOT_FOUND" /* NOT_FOUND */,
|
|
@@ -5127,7 +5142,7 @@ var deleteFileTool = {
|
|
|
5127
5142
|
limit: void 0,
|
|
5128
5143
|
offset: void 0
|
|
5129
5144
|
});
|
|
5130
|
-
const file = all.find((
|
|
5145
|
+
const file = all.find((f2) => f2.name === parsed.name);
|
|
5131
5146
|
if (!file) {
|
|
5132
5147
|
return {
|
|
5133
5148
|
content: `File "${parsed.name}" not found on ${context.routerId}. Nothing to delete.`,
|
|
@@ -5398,221 +5413,1342 @@ var containerTools = [listContainersTool, manageContainerTool];
|
|
|
5398
5413
|
|
|
5399
5414
|
// src/domain/tools/change-management-tools.ts
|
|
5400
5415
|
import { z as z24 } from "zod";
|
|
5401
|
-
import { readFileSync } from "fs";
|
|
5416
|
+
import { readFileSync as readFileSync2 } from "fs";
|
|
5402
5417
|
|
|
5403
5418
|
// src/domain/snapshot/snapshot-engine.ts
|
|
5404
5419
|
import { mkdir, writeFile, readFile } from "fs/promises";
|
|
5405
5420
|
import { join as join2 } from "path";
|
|
5406
5421
|
import { nanoid } from "nanoid";
|
|
5407
5422
|
|
|
5408
|
-
// src/
|
|
5409
|
-
|
|
5410
|
-
|
|
5411
|
-
|
|
5412
|
-
|
|
5413
|
-
|
|
5414
|
-
"
|
|
5415
|
-
"
|
|
5416
|
-
"
|
|
5417
|
-
"
|
|
5418
|
-
"
|
|
5419
|
-
"
|
|
5420
|
-
"
|
|
5421
|
-
"
|
|
5422
|
-
|
|
5423
|
-
|
|
5424
|
-
|
|
5425
|
-
|
|
5426
|
-
|
|
5427
|
-
|
|
5428
|
-
|
|
5429
|
-
|
|
5430
|
-
|
|
5431
|
-
|
|
5432
|
-
|
|
5433
|
-
|
|
5434
|
-
|
|
5435
|
-
"
|
|
5436
|
-
|
|
5437
|
-
|
|
5438
|
-
|
|
5439
|
-
|
|
5440
|
-
|
|
5441
|
-
|
|
5442
|
-
const clean = {};
|
|
5443
|
-
for (const [k, v] of Object.entries(r)) {
|
|
5444
|
-
if (!RUNTIME_FIELDS.has(k)) clean[k] = v;
|
|
5423
|
+
// src/adapter/swos-client.ts
|
|
5424
|
+
import { createHash, randomBytes } from "crypto";
|
|
5425
|
+
import { request as httpRequest } from "http";
|
|
5426
|
+
|
|
5427
|
+
// src/adapter/swos-protocol.ts
|
|
5428
|
+
var Kind = {
|
|
5429
|
+
BOOL: "bool",
|
|
5430
|
+
STR: "str",
|
|
5431
|
+
OPTION: "option",
|
|
5432
|
+
MAC: "mac",
|
|
5433
|
+
IP: "ip",
|
|
5434
|
+
INT: "int",
|
|
5435
|
+
CHECKBOX: "checkbox",
|
|
5436
|
+
HEX: "hex"
|
|
5437
|
+
};
|
|
5438
|
+
function f(name, keys, kind, opts = {}) {
|
|
5439
|
+
return { name, keys, kind, ...opts };
|
|
5440
|
+
}
|
|
5441
|
+
var KEY_QUOTE = /([{,])([a-zA-Z][a-zA-Z0-9]*)/g;
|
|
5442
|
+
var HEX_QUOTE = /(0x[0-9a-fA-F]+)/g;
|
|
5443
|
+
var HEX_UNQUOTE = /"0x([0-9a-fA-F]+)"/g;
|
|
5444
|
+
var KEY_UNQUOTE = /"([a-zA-Z][a-zA-Z0-9]*)":/g;
|
|
5445
|
+
var HEX_TOKEN = /^0x[0-9a-fA-F]+$/;
|
|
5446
|
+
function isWireHex(value) {
|
|
5447
|
+
return typeof value === "string" && HEX_TOKEN.test(value);
|
|
5448
|
+
}
|
|
5449
|
+
function quoteBrokenJson(text) {
|
|
5450
|
+
return text.replace(KEY_QUOTE, '$1"$2"').replace(/'/g, '"').replace(HEX_QUOTE, '"$1"');
|
|
5451
|
+
}
|
|
5452
|
+
function normalize(value) {
|
|
5453
|
+
if (typeof value === "string") {
|
|
5454
|
+
if (isWireHex(value)) {
|
|
5455
|
+
const parsed = Number.parseInt(value, 16);
|
|
5456
|
+
return Number.isNaN(parsed) ? value : parsed;
|
|
5445
5457
|
}
|
|
5446
|
-
|
|
5458
|
+
return value;
|
|
5447
5459
|
}
|
|
5448
|
-
return
|
|
5449
|
-
|
|
5450
|
-
|
|
5451
|
-
|
|
5452
|
-
|
|
5453
|
-
|
|
5454
|
-
|
|
5455
|
-
"ip/firewall/address-list": ["list", "address"],
|
|
5456
|
-
"ip/address": ["address", "interface"],
|
|
5457
|
-
"ip/dns/static": ["name", "type"],
|
|
5458
|
-
"routing/rule": ["src-address", "routing-table"],
|
|
5459
|
-
"routing/table": ["name"],
|
|
5460
|
-
"interface/wireguard/peers": ["public-key"],
|
|
5461
|
-
"interface/bridge": ["name"],
|
|
5462
|
-
"interface/bridge/port": ["bridge", "interface"],
|
|
5463
|
-
"system/scheduler": ["name"],
|
|
5464
|
-
"system/script": ["name"],
|
|
5465
|
-
"system/package": ["name"],
|
|
5466
|
-
"container": ["name"],
|
|
5467
|
-
"certificate": ["name"],
|
|
5468
|
-
"file": ["name"],
|
|
5469
|
-
"interface/vrrp": ["name"],
|
|
5470
|
-
"ip/dhcp-server": ["name"],
|
|
5471
|
-
"ip/ipsec/peer": ["name"],
|
|
5472
|
-
"ip/pool": ["name"],
|
|
5473
|
-
"queue/simple": ["name"],
|
|
5474
|
-
"tool/netwatch": ["host"],
|
|
5475
|
-
"user": ["name"]
|
|
5476
|
-
};
|
|
5477
|
-
function normalizeValue(v) {
|
|
5478
|
-
if (v === true || v === "true" || v === "yes") return "true";
|
|
5479
|
-
if (v === false || v === "false" || v === "no") return "false";
|
|
5480
|
-
return String(v ?? "");
|
|
5481
|
-
}
|
|
5482
|
-
function semanticKey(record, keys) {
|
|
5483
|
-
return keys.map((k) => normalizeValue(record[k] ?? "")).join("|");
|
|
5460
|
+
if (Array.isArray(value)) return value.map(normalize);
|
|
5461
|
+
if (value && typeof value === "object") {
|
|
5462
|
+
return Object.fromEntries(
|
|
5463
|
+
Object.entries(value).map(([k, v]) => [k, normalize(v)])
|
|
5464
|
+
);
|
|
5465
|
+
}
|
|
5466
|
+
return value;
|
|
5484
5467
|
}
|
|
5485
|
-
function
|
|
5486
|
-
return
|
|
5468
|
+
function parseWireBlob(text) {
|
|
5469
|
+
return JSON.parse(quoteBrokenJson(text));
|
|
5487
5470
|
}
|
|
5488
|
-
function
|
|
5489
|
-
|
|
5490
|
-
const keysB = Object.keys(b).filter((k) => k !== ".id");
|
|
5491
|
-
if (keysA.length !== keysB.length) return false;
|
|
5492
|
-
return keysA.every((k) => normalizeValue(a[k]) === normalizeValue(b[k] ?? ""));
|
|
5471
|
+
function parseBlob(text) {
|
|
5472
|
+
return normalize(parseWireBlob(text));
|
|
5493
5473
|
}
|
|
5494
|
-
|
|
5495
|
-
|
|
5496
|
-
|
|
5497
|
-
|
|
5498
|
-
|
|
5499
|
-
|
|
5474
|
+
var MAX_WIRE_VALUE = 4294967295;
|
|
5475
|
+
function toWireHex(value) {
|
|
5476
|
+
if (!Number.isInteger(value) || value < 0 || value > MAX_WIRE_VALUE) {
|
|
5477
|
+
throw new MikroMCPError({
|
|
5478
|
+
category: "VALIDATION" /* VALIDATION */,
|
|
5479
|
+
code: "SWOS_VALUE_OUT_OF_RANGE",
|
|
5480
|
+
message: `Cannot encode ${value} as a SwOS wire value.`,
|
|
5481
|
+
details: { value, min: 0, max: MAX_WIRE_VALUE },
|
|
5482
|
+
recoverability: {
|
|
5483
|
+
retryable: false,
|
|
5484
|
+
suggestedAction: `Pass a whole number between 0 and ${MAX_WIRE_VALUE}.`
|
|
5485
|
+
}
|
|
5486
|
+
});
|
|
5500
5487
|
}
|
|
5501
|
-
|
|
5502
|
-
}
|
|
5503
|
-
|
|
5504
|
-
|
|
5505
|
-
|
|
5506
|
-
|
|
5488
|
+
const s = value.toString(16);
|
|
5489
|
+
return `0x${s.length % 2 ? `0${s}` : s}`;
|
|
5490
|
+
}
|
|
5491
|
+
function encodeLike(value, existing) {
|
|
5492
|
+
const token = toWireHex(value);
|
|
5493
|
+
if (!isWireHex(existing)) return token;
|
|
5494
|
+
const width = existing.length - 2;
|
|
5495
|
+
const digits = token.slice(2);
|
|
5496
|
+
return digits.length >= width ? token : `0x${digits.padStart(width, "0")}`;
|
|
5497
|
+
}
|
|
5498
|
+
function toHex(value) {
|
|
5499
|
+
if (typeof value === "number") return toWireHex(value);
|
|
5500
|
+
if (Array.isArray(value)) return value.map(toHex);
|
|
5501
|
+
if (value && typeof value === "object") {
|
|
5502
|
+
return Object.fromEntries(
|
|
5503
|
+
Object.entries(value).map(([k, v]) => [k, toHex(v)])
|
|
5507
5504
|
);
|
|
5508
|
-
plan.toCreate = [];
|
|
5509
5505
|
}
|
|
5510
|
-
return
|
|
5506
|
+
return value;
|
|
5511
5507
|
}
|
|
5512
|
-
function
|
|
5513
|
-
|
|
5514
|
-
|
|
5515
|
-
|
|
5516
|
-
|
|
5517
|
-
|
|
5518
|
-
|
|
5519
|
-
|
|
5520
|
-
|
|
5521
|
-
|
|
5522
|
-
|
|
5523
|
-
|
|
5524
|
-
|
|
5525
|
-
|
|
5526
|
-
|
|
5527
|
-
|
|
5528
|
-
|
|
5529
|
-
|
|
5530
|
-
|
|
5531
|
-
|
|
5532
|
-
|
|
5508
|
+
function dumpBlob(data) {
|
|
5509
|
+
let s = JSON.stringify(toHex(data));
|
|
5510
|
+
s = s.replace(HEX_UNQUOTE, "0x$1");
|
|
5511
|
+
s = s.replace(KEY_UNQUOTE, "$1:");
|
|
5512
|
+
return s.replace(/"/g, "'");
|
|
5513
|
+
}
|
|
5514
|
+
var SPEED = ["10M", "100M", "1G", "10G", "200M", "2.5G", "5G", "down"];
|
|
5515
|
+
var POE_OUT = ["off", "on", "auto"];
|
|
5516
|
+
var POE_VOLTAGE = ["auto", "low", "high"];
|
|
5517
|
+
var VLAN_MODE = ["disabled", "optional", "strict"];
|
|
5518
|
+
var VLAN_RECV = ["any", "only_tagged", "only_untagged"];
|
|
5519
|
+
var POE_STATE = [
|
|
5520
|
+
"none",
|
|
5521
|
+
"disabled",
|
|
5522
|
+
"waiting_for_load",
|
|
5523
|
+
"powered_on",
|
|
5524
|
+
"overload",
|
|
5525
|
+
"short_circuit",
|
|
5526
|
+
"voltage_too_low",
|
|
5527
|
+
"current_too_low",
|
|
5528
|
+
"power_cycle",
|
|
5529
|
+
"voltage_too_high",
|
|
5530
|
+
"controller_error"
|
|
5531
|
+
];
|
|
5532
|
+
var RSTP_ROLE = ["disabled", "alternate", "root", "designated", "backup"];
|
|
5533
|
+
var FLOW_CONTROL = ["off", "tx_only", "rx_only", "on"];
|
|
5534
|
+
var ACL_VLAN_PRESENT = ["any", "present", "not present"];
|
|
5535
|
+
var ACL_ACCOUNT_AS = ["none", "#1", "#2", "#3", "#4"];
|
|
5536
|
+
var ADDR_ACQ = ["DHCP with fallback", "static", "DHCP only"];
|
|
5537
|
+
var LINK = {
|
|
5538
|
+
path: "link.b",
|
|
5539
|
+
fields: [
|
|
5540
|
+
f("enabled", { swos: "en", swos_lite: "i01" }, Kind.BOOL),
|
|
5541
|
+
f("auto_neg", { swos: "an", swos_lite: "i02" }, Kind.BOOL),
|
|
5542
|
+
f("manual_duplex", { swos: "dpxc", swos_lite: "i03" }, Kind.BOOL),
|
|
5543
|
+
f("manual_speed", { swos: "spd", swos_lite: "i05" }, Kind.OPTION, {
|
|
5544
|
+
options: SPEED,
|
|
5545
|
+
perPort: true
|
|
5546
|
+
}),
|
|
5547
|
+
f("link", { swos: "lnk", swos_lite: "i06" }, Kind.BOOL),
|
|
5548
|
+
f("duplex", { swos: "dpx", swos_lite: "i07" }, Kind.BOOL),
|
|
5549
|
+
f("speed", { swos: "spdc", swos_lite: "i08" }, Kind.OPTION, { options: SPEED, perPort: true }),
|
|
5550
|
+
f("name", { swos: "nm", swos_lite: "i0a" }, Kind.STR, { perPort: true }),
|
|
5551
|
+
f("flow_rx", { swos: "fctr", swos_lite: "i12" }, Kind.BOOL),
|
|
5552
|
+
f("flow_tx", { swos: "fctc", swos_lite: "i16" }, Kind.BOOL),
|
|
5553
|
+
f("pause", { swos: "paus", swos_lite: "i15" }, Kind.BOOL),
|
|
5554
|
+
f("cable_pairs", { swos_lite: "i11" }, Kind.INT, {
|
|
5555
|
+
perPort: true,
|
|
5556
|
+
note: "per-port cable-pair status; 0xffff = all pairs OK"
|
|
5557
|
+
}),
|
|
5558
|
+
f("flow_control", { swos_lite: "i13" }, Kind.OPTION, {
|
|
5559
|
+
options: FLOW_CONTROL,
|
|
5560
|
+
note: "negotiated flow-control mode"
|
|
5561
|
+
}),
|
|
5562
|
+
f("flow_control_mask", { swos_lite: "i14" }, Kind.BOOL, {
|
|
5563
|
+
note: "per-port mask paired with flow_control (i13)"
|
|
5564
|
+
}),
|
|
5565
|
+
f("cable_length", { swos_lite: "i09" }, Kind.INT, {
|
|
5566
|
+
perPort: true,
|
|
5567
|
+
unknown: true,
|
|
5568
|
+
note: "0 when link down; tracks cable run length -> likely length, scale ~1e-3 m (heuristic)"
|
|
5569
|
+
}),
|
|
5570
|
+
f("unknown_i0b", { swos_lite: "i0b" }, Kind.HEX, { unknown: true }),
|
|
5571
|
+
f("unknown_i0c", { swos_lite: "i0c" }, Kind.HEX, { unknown: true }),
|
|
5572
|
+
f("diag_hops", { swos_lite: "i0d" }, Kind.HEX, { perPort: true, unknown: true }),
|
|
5573
|
+
f("diag_last_hop", { swos_lite: "i0e" }, Kind.HEX, { perPort: true, unknown: true }),
|
|
5574
|
+
f("diag_length", { swos_lite: "i0f" }, Kind.HEX, { perPort: true, unknown: true }),
|
|
5575
|
+
f("diag_fault_at", { swos_lite: "i10" }, Kind.HEX, { perPort: true, unknown: true })
|
|
5576
|
+
]
|
|
5577
|
+
};
|
|
5578
|
+
var SYS = {
|
|
5579
|
+
path: "sys.b",
|
|
5580
|
+
fields: [
|
|
5581
|
+
f("uptime", { swos: "upt", swos_lite: "i01" }, Kind.INT, { note: "seconds" }),
|
|
5582
|
+
f("ip", { swos: "cip", swos_lite: "i02" }, Kind.IP),
|
|
5583
|
+
f("mac", { swos: "mac", swos_lite: "i03" }, Kind.MAC),
|
|
5584
|
+
f("serial", { swos: "sid", swos_lite: "i04" }, Kind.STR),
|
|
5585
|
+
f("identity", { swos: "id", swos_lite: "i05" }, Kind.STR),
|
|
5586
|
+
f("version", { swos: "ver", swos_lite: "i06" }, Kind.STR),
|
|
5587
|
+
f("model", { swos: "brd", swos_lite: "i07" }, Kind.STR),
|
|
5588
|
+
f("discovery_protocol", { swos: "dsc", swos_lite: "i08" }, Kind.BOOL, {
|
|
5589
|
+
note: "per-port bitmask of MikroTik discovery enabled"
|
|
5590
|
+
}),
|
|
5591
|
+
f("static_ip", { swos: "sip", swos_lite: "i09" }, Kind.IP),
|
|
5592
|
+
f("addr_acq", { swos: "iptp", swos_lite: "i0a" }, Kind.OPTION, { options: ADDR_ACQ }),
|
|
5593
|
+
f("build_number", { swos_lite: "i0b" }, Kind.INT, { unknown: true }),
|
|
5594
|
+
f("allow_from_ports", { swos: "allp", swos_lite: "i12" }, Kind.BOOL),
|
|
5595
|
+
f("dhcp_trusted_ports", { swos: "dtrp", swos_lite: "i13" }, Kind.BOOL),
|
|
5596
|
+
f("dhcp_info_option", { swos: "ainf", swos_lite: "i14" }, Kind.CHECKBOX),
|
|
5597
|
+
f("psu1_voltage", { swos: "p1v", swos_lite: "i15" }, Kind.INT, { scale: 100, note: "volts" }),
|
|
5598
|
+
f("psu1_current", { swos: "p1c", swos_lite: "i16" }, Kind.INT, { note: "mA" }),
|
|
5599
|
+
f("igmp_snooping", { swos: "igmp", swos_lite: "i17" }, Kind.CHECKBOX),
|
|
5600
|
+
f("allow_from_ip", { swos: "alla", swos_lite: "i19" }, Kind.IP),
|
|
5601
|
+
f("allow_from_mask", { swos: "allm", swos_lite: "i1a" }, Kind.INT),
|
|
5602
|
+
f("allow_from_vlan", { swos: "avln", swos_lite: "i1b" }, Kind.INT),
|
|
5603
|
+
f("psu2_voltage", { swos: "p2v", swos_lite: "i1e" }, Kind.INT, { scale: 100 }),
|
|
5604
|
+
f("psu2_current", { swos: "p2c", swos_lite: "i1f" }, Kind.INT),
|
|
5605
|
+
f("bridge_priority", { swos: "prio", swos_lite: "i0e" }, Kind.INT),
|
|
5606
|
+
f("port_cost_mode", { swos: "cost", swos_lite: "i0f" }, Kind.OPTION, {
|
|
5607
|
+
options: ["short", "long"]
|
|
5608
|
+
}),
|
|
5609
|
+
f("root_bridge_priority", { swos_lite: "i10" }, Kind.INT),
|
|
5610
|
+
f("root_bridge_mac", { swos_lite: "i11" }, Kind.MAC),
|
|
5611
|
+
f("igmp_fast_leave", { swos: "igfl", swos_lite: "i27" }, Kind.BOOL),
|
|
5612
|
+
f("igmp_version", { swos: "igve", swos_lite: "i28" }, Kind.OPTION, { options: ["v2", "v3"] }),
|
|
5613
|
+
f("igmp_querier", { swos: "igmq", swos_lite: "i29" }, Kind.CHECKBOX),
|
|
5614
|
+
f("fwd_reserved_mc", { swos: "frmc", swos_lite: "i2a" }, Kind.CHECKBOX),
|
|
5615
|
+
f("cpu_temp", { swos_lite: "i22" }, Kind.INT, { signed: true, bits: 16, note: "deg C" }),
|
|
5616
|
+
f("power_consumption", { swos_lite: "i26" }, Kind.INT, {
|
|
5617
|
+
scale: 10,
|
|
5618
|
+
note: "watts (switch itself, excl. PoE)"
|
|
5619
|
+
})
|
|
5620
|
+
]
|
|
5621
|
+
};
|
|
5622
|
+
var POE = {
|
|
5623
|
+
path: "poe.b",
|
|
5624
|
+
fields: [
|
|
5625
|
+
f("out", { swos_lite: "i01" }, Kind.OPTION, { options: POE_OUT, perPort: true }),
|
|
5626
|
+
f("priority", { swos_lite: "i02" }, Kind.INT, {
|
|
5627
|
+
perPort: true,
|
|
5628
|
+
note: "wire = priority-1 (0..7 -> 1..8)"
|
|
5629
|
+
}),
|
|
5630
|
+
f("voltage_level", { swos_lite: "i03" }, Kind.OPTION, {
|
|
5631
|
+
options: POE_VOLTAGE,
|
|
5632
|
+
perPort: true
|
|
5633
|
+
}),
|
|
5634
|
+
f("state", { swos_lite: "i04" }, Kind.OPTION, { options: POE_STATE, perPort: true }),
|
|
5635
|
+
f("current", { swos_lite: "i05" }, Kind.INT, { perPort: true, note: "mA" }),
|
|
5636
|
+
f("voltage", { swos_lite: "i06" }, Kind.INT, { perPort: true, scale: 10, note: "volts" }),
|
|
5637
|
+
f("power", { swos_lite: "i07" }, Kind.INT, { perPort: true, scale: 10, note: "watts" }),
|
|
5638
|
+
f("total_raw", { swos_lite: "i08" }, Kind.INT, {
|
|
5639
|
+
unknown: true,
|
|
5640
|
+
note: "global PoE total (raw); semantics TBD"
|
|
5641
|
+
}),
|
|
5642
|
+
f("controller", { swos_lite: "i09" }, Kind.STR, {
|
|
5643
|
+
unknown: true,
|
|
5644
|
+
note: "PoE controller serial/fw string"
|
|
5645
|
+
}),
|
|
5646
|
+
f("lldp_enabled", { swos_lite: "i0a" }, Kind.CHECKBOX, {
|
|
5647
|
+
unknown: true,
|
|
5648
|
+
note: "global LLDP toggle (Lite); per-port in some firmwares"
|
|
5649
|
+
}),
|
|
5650
|
+
f("lldp_power", { swos_lite: "i0b" }, Kind.INT, {
|
|
5651
|
+
perPort: true,
|
|
5652
|
+
scale: 10,
|
|
5653
|
+
unknown: true,
|
|
5654
|
+
note: "per-port LLDP negotiated power"
|
|
5655
|
+
})
|
|
5656
|
+
]
|
|
5657
|
+
};
|
|
5658
|
+
var LACP = {
|
|
5659
|
+
path: "lacp.b",
|
|
5660
|
+
fields: [
|
|
5661
|
+
f("active", { swos_lite: "i01" }, Kind.INT, {
|
|
5662
|
+
perPort: true,
|
|
5663
|
+
note: "per-port LACP active/member flag (0/1)"
|
|
5664
|
+
}),
|
|
5665
|
+
f("trunk_id", { swos_lite: "i02" }, Kind.INT, {
|
|
5666
|
+
perPort: true,
|
|
5667
|
+
note: "static LAG / trunk id (observed 1, 2 and 3)"
|
|
5668
|
+
}),
|
|
5669
|
+
f("group", { swos_lite: "i03" }, Kind.INT, { perPort: true, note: "LAG group, engine max 15" }),
|
|
5670
|
+
f("partner_mac", { swos_lite: "i04" }, Kind.MAC, {
|
|
5671
|
+
perPort: true,
|
|
5672
|
+
note: "LACP partner/peer MAC (same value for both members of a LAG)"
|
|
5673
|
+
})
|
|
5674
|
+
]
|
|
5675
|
+
};
|
|
5676
|
+
var RSTP = {
|
|
5677
|
+
path: "rstp.b",
|
|
5678
|
+
fields: [
|
|
5679
|
+
f("enabled", { swos: "ena", swos_lite: "i01" }, Kind.BOOL, {
|
|
5680
|
+
note: "per-port RSTP-enabled mask (0x3ff = all)"
|
|
5681
|
+
}),
|
|
5682
|
+
f("role", { swos_lite: "i02" }, Kind.OPTION, {
|
|
5683
|
+
options: RSTP_ROLE,
|
|
5684
|
+
perPort: true,
|
|
5685
|
+
note: "per-port RSTP role"
|
|
5686
|
+
}),
|
|
5687
|
+
f("root_path_cost", { swos_lite: "i03" }, Kind.INT, {
|
|
5688
|
+
perPort: true,
|
|
5689
|
+
note: "per-port root path cost (0 = auto)"
|
|
5690
|
+
}),
|
|
5691
|
+
f("unknown_i04", { swos_lite: "i04" }, Kind.INT, {
|
|
5692
|
+
perPort: true,
|
|
5693
|
+
unknown: true,
|
|
5694
|
+
note: "observed 0/2/4/0x13; candidate = port priority / port number"
|
|
5695
|
+
}),
|
|
5696
|
+
f("mode_mask", { swos_lite: "i05" }, Kind.BOOL, {
|
|
5697
|
+
unknown: true,
|
|
5698
|
+
note: "0x3ff on this device; candidate = STP/RSTP mode mask"
|
|
5699
|
+
}),
|
|
5700
|
+
f("type_mask", { swos_lite: "i06" }, Kind.BOOL, {
|
|
5701
|
+
unknown: true,
|
|
5702
|
+
note: "0x3ff here; candidate = port type mask (paired with i07)"
|
|
5703
|
+
}),
|
|
5704
|
+
f("unknown_i07", { swos_lite: "i07" }, Kind.INT, {
|
|
5705
|
+
unknown: true,
|
|
5706
|
+
note: "0x324 here; paired mask for i06"
|
|
5707
|
+
}),
|
|
5708
|
+
f("forwarding", { swos_lite: "i08" }, Kind.BOOL, {
|
|
5709
|
+
note: "forwarding-state mask (port4 down -> bit clear)"
|
|
5710
|
+
}),
|
|
5711
|
+
f("learning", { swos_lite: "i09" }, Kind.BOOL, {
|
|
5712
|
+
note: "learning-state mask (port4 down -> bit clear)"
|
|
5713
|
+
})
|
|
5714
|
+
]
|
|
5715
|
+
};
|
|
5716
|
+
var SNMP = {
|
|
5717
|
+
path: "snmp.b",
|
|
5718
|
+
fields: [
|
|
5719
|
+
f("enabled", { swos: "en", swos_lite: "i01" }, Kind.CHECKBOX),
|
|
5720
|
+
f("community", { swos: "com", swos_lite: "i02" }, Kind.STR),
|
|
5721
|
+
f("contact", { swos: "ci", swos_lite: "i03" }, Kind.STR),
|
|
5722
|
+
f("location", { swos: "loc", swos_lite: "i04" }, Kind.STR)
|
|
5723
|
+
]
|
|
5724
|
+
};
|
|
5725
|
+
var FWD = {
|
|
5726
|
+
path: "fwd.b",
|
|
5727
|
+
fields: [
|
|
5728
|
+
...Array.from(
|
|
5729
|
+
{ length: 10 },
|
|
5730
|
+
(_, i) => f(
|
|
5731
|
+
`isolation_p${i + 1}`,
|
|
5732
|
+
{ swos_lite: `i${(i + 1).toString(16).padStart(2, "0")}` },
|
|
5733
|
+
Kind.BOOL,
|
|
5734
|
+
{
|
|
5735
|
+
note: "forwarding/isolation mask; bit n cleared"
|
|
5736
|
+
}
|
|
5737
|
+
)
|
|
5738
|
+
),
|
|
5739
|
+
f("port_lock", { swos_lite: "i10" }, Kind.BOOL, {
|
|
5740
|
+
perPort: true,
|
|
5741
|
+
note: "port lock (disable learning)"
|
|
5742
|
+
}),
|
|
5743
|
+
f("lock_on_first", { swos_lite: "i11" }, Kind.BOOL, {
|
|
5744
|
+
perPort: true,
|
|
5745
|
+
note: "lock on first learned MAC"
|
|
5746
|
+
}),
|
|
5747
|
+
f("mirror_ingress", { swos_lite: "i12" }, Kind.BOOL, { perPort: true }),
|
|
5748
|
+
f("mirror_egress", { swos_lite: "i13" }, Kind.BOOL, { perPort: true }),
|
|
5749
|
+
f("mirror_to", { swos_lite: "i14" }, Kind.BOOL, {
|
|
5750
|
+
perPort: true,
|
|
5751
|
+
note: "egress mirror target port (scalar mask on this device)"
|
|
5752
|
+
}),
|
|
5753
|
+
f("vlan_mode", { swos_lite: "i15" }, Kind.OPTION, { options: VLAN_MODE, perPort: true }),
|
|
5754
|
+
f("vlan_recv_mode", { swos_lite: "i17" }, Kind.OPTION, { options: VLAN_RECV, perPort: true }),
|
|
5755
|
+
f("default_vid", { swos_lite: "i18" }, Kind.INT, {
|
|
5756
|
+
perPort: true,
|
|
5757
|
+
note: "default VLAN id per port"
|
|
5758
|
+
}),
|
|
5759
|
+
f("force_vid", { swos_lite: "i19" }, Kind.BOOL, { perPort: true, note: "force VLAN id" }),
|
|
5760
|
+
f("storm_rate", { swos_lite: "i1a" }, Kind.INT, {
|
|
5761
|
+
perPort: true,
|
|
5762
|
+
scale: 1e5,
|
|
5763
|
+
note: "storm limit (fraction of link)"
|
|
5764
|
+
}),
|
|
5765
|
+
f("limit_unknown_ucast", { swos_lite: "i1b" }, Kind.BOOL, {
|
|
5766
|
+
perPort: true,
|
|
5767
|
+
note: "limit unknown unicast"
|
|
5768
|
+
}),
|
|
5769
|
+
f("flood_unknown_mcast", { swos_lite: "i1c" }, Kind.BOOL, {
|
|
5770
|
+
perPort: true,
|
|
5771
|
+
note: "flood unknown multicast"
|
|
5772
|
+
}),
|
|
5773
|
+
f("ingress_rate", { swos_lite: "i1d" }, Kind.INT, {
|
|
5774
|
+
perPort: true,
|
|
5775
|
+
scale: 1e5,
|
|
5776
|
+
note: "ingress rate limit (fraction of link)"
|
|
5777
|
+
}),
|
|
5778
|
+
f("egress_rate", { swos_lite: "i1e" }, Kind.INT, {
|
|
5779
|
+
perPort: true,
|
|
5780
|
+
scale: 1e5,
|
|
5781
|
+
note: "egress rate limit (fraction of link)"
|
|
5782
|
+
})
|
|
5783
|
+
]
|
|
5784
|
+
};
|
|
5785
|
+
var VLAN = {
|
|
5786
|
+
path: "vlan.b",
|
|
5787
|
+
isArray: true,
|
|
5788
|
+
note: "SwOS Lite vlan.b keys unverified (device had none)",
|
|
5789
|
+
fields: [
|
|
5790
|
+
f("vid", { swos: "vid" }, Kind.INT),
|
|
5791
|
+
f("name", { swos: "nm" }, Kind.STR),
|
|
5792
|
+
f("port_isolation", { swos: "piso" }, Kind.CHECKBOX),
|
|
5793
|
+
f("learning", { swos: "lrn" }, Kind.CHECKBOX),
|
|
5794
|
+
f("mirror", { swos: "mrr" }, Kind.CHECKBOX),
|
|
5795
|
+
f("igmp", { swos: "igmp" }, Kind.CHECKBOX),
|
|
5796
|
+
f("members", { swos: "mbr" }, Kind.BOOL)
|
|
5797
|
+
]
|
|
5798
|
+
};
|
|
5799
|
+
var HOST = {
|
|
5800
|
+
path: "host.b",
|
|
5801
|
+
isArray: true,
|
|
5802
|
+
note: "Static host table (device had none)",
|
|
5803
|
+
fields: [
|
|
5804
|
+
f("mac", { swos_lite: "i01" }, Kind.MAC),
|
|
5805
|
+
f("port", { swos_lite: "i02" }, Kind.INT, {
|
|
5806
|
+
note: "port id; 0x80 bit = SFP/combo, low nibble = 1-based port (interpretation unverified)"
|
|
5807
|
+
})
|
|
5808
|
+
]
|
|
5809
|
+
};
|
|
5810
|
+
var DHOST = {
|
|
5811
|
+
path: "!dhost.b",
|
|
5812
|
+
isArray: true,
|
|
5813
|
+
note: "Dynamically learned MAC table",
|
|
5814
|
+
fields: HOST.fields
|
|
5815
|
+
};
|
|
5816
|
+
var IGMP = {
|
|
5817
|
+
path: "!igmp.b",
|
|
5818
|
+
isArray: true,
|
|
5819
|
+
note: "IGMP snooping groups (device had none)",
|
|
5820
|
+
fields: [
|
|
5821
|
+
f("group_address", { swos_lite: "i01" }, Kind.INT, { note: "multicast group IP (raw int)" }),
|
|
5822
|
+
f("member_ports", { swos_lite: "i02" }, Kind.BOOL, { note: "per-port membership bitmask" }),
|
|
5823
|
+
f("vlan", { swos_lite: "i03" }, Kind.INT, { note: "VLAN id" })
|
|
5824
|
+
]
|
|
5825
|
+
};
|
|
5826
|
+
var ACL = {
|
|
5827
|
+
path: "acl.b",
|
|
5828
|
+
isArray: true,
|
|
5829
|
+
note: "ACL rules (device had none)",
|
|
5830
|
+
fields: [
|
|
5831
|
+
f("from", { swos_lite: "i01" }, Kind.BOOL, { note: "source port bitmask" }),
|
|
5832
|
+
f("mac_src", { swos_lite: "i02" }, Kind.MAC),
|
|
5833
|
+
f("mac_src_mask", { swos_lite: "i03" }, Kind.MAC),
|
|
5834
|
+
f("mac_dst", { swos_lite: "i04" }, Kind.MAC),
|
|
5835
|
+
f("mac_dst_mask", { swos_lite: "i05" }, Kind.MAC),
|
|
5836
|
+
f("ethertype", { swos_lite: "i06" }, Kind.INT),
|
|
5837
|
+
f("vlan_present", { swos_lite: "i07" }, Kind.OPTION, { options: ACL_VLAN_PRESENT }),
|
|
5838
|
+
f("vlan_id", { swos_lite: "i08" }, Kind.INT),
|
|
5839
|
+
f("priority", { swos_lite: "i09" }, Kind.INT),
|
|
5840
|
+
f("ip_src", { swos_lite: "i0a" }, Kind.INT, { note: "source IP (raw int)" }),
|
|
5841
|
+
f("ip_src_mask_hi", { swos_lite: "i0b" }, Kind.INT),
|
|
5842
|
+
f("ip_src_mask_lo", { swos_lite: "i0c" }, Kind.INT),
|
|
5843
|
+
f("ip_dst", { swos_lite: "i0d" }, Kind.INT, { note: "dest IP (raw int)" }),
|
|
5844
|
+
f("ip_dst_mask_hi", { swos_lite: "i0e" }, Kind.INT),
|
|
5845
|
+
f("ip_dst_mask_lo", { swos_lite: "i0f" }, Kind.INT),
|
|
5846
|
+
f("protocol", { swos_lite: "i10" }, Kind.INT),
|
|
5847
|
+
f("dscp", { swos_lite: "i11" }, Kind.INT),
|
|
5848
|
+
f("drop", { swos_lite: "i12" }, Kind.CHECKBOX, { note: "drop matching packets" }),
|
|
5849
|
+
f("mirror_to", { swos_lite: "i13" }, Kind.OPTION, { note: "egress port / off" }),
|
|
5850
|
+
f("redirect_to", { swos_lite: "i14" }, Kind.OPTION, { note: "egress port / off" }),
|
|
5851
|
+
f("set_vlan_id", { swos_lite: "i15" }, Kind.INT),
|
|
5852
|
+
f("set_priority", { swos_lite: "i16" }, Kind.INT),
|
|
5853
|
+
f("set_dscp", { swos_lite: "i17" }, Kind.INT),
|
|
5854
|
+
f("account_as", { swos_lite: "i18" }, Kind.OPTION, { options: ACL_ACCOUNT_AS })
|
|
5855
|
+
]
|
|
5856
|
+
};
|
|
5857
|
+
var ACLSTATS = {
|
|
5858
|
+
path: "!aclstats.b",
|
|
5859
|
+
fields: [
|
|
5860
|
+
f("counter_1", { swos_lite: "i01" }, Kind.INT, { perPort: true }),
|
|
5861
|
+
f("counter_2", { swos_lite: "i02" }, Kind.INT, { perPort: true }),
|
|
5862
|
+
f("counter_3", { swos_lite: "i03" }, Kind.INT, { perPort: true }),
|
|
5863
|
+
f("counter_4", { swos_lite: "i04" }, Kind.INT, { perPort: true })
|
|
5864
|
+
]
|
|
5865
|
+
};
|
|
5866
|
+
var SFP = {
|
|
5867
|
+
path: "sfp.b",
|
|
5868
|
+
note: "per-cage SFP DOM diagnostics (text fields are UTF-8 over hex)",
|
|
5869
|
+
fields: [
|
|
5870
|
+
f("vendor", { swos_lite: "i01" }, Kind.STR, { perPort: true }),
|
|
5871
|
+
f("part_number", { swos_lite: "i02" }, Kind.STR, { perPort: true }),
|
|
5872
|
+
f("revision", { swos_lite: "i03" }, Kind.STR, { perPort: true }),
|
|
5873
|
+
f("serial", { swos_lite: "i04" }, Kind.STR, { perPort: true }),
|
|
5874
|
+
f("date", { swos_lite: "i05" }, Kind.STR, { perPort: true }),
|
|
5875
|
+
f("type", { swos_lite: "i06" }, Kind.STR, { perPort: true, note: "xa:1 = hex-ascii" }),
|
|
5876
|
+
f("diag_extra", { swos_lite: "i07" }, Kind.INT, {
|
|
5877
|
+
perPort: true,
|
|
5878
|
+
unknown: true,
|
|
5879
|
+
note: "firmware-specific raw value (85000 on the populated cage); purpose unconfirmed"
|
|
5880
|
+
}),
|
|
5881
|
+
// Signed like sys.b cpu_temp. A cage with no DOM (passive copper DAC)
|
|
5882
|
+
// reports 0xff80 -> -128, which reads as "no reading" rather than 65408.
|
|
5883
|
+
f("temperature", { swos_lite: "i08" }, Kind.INT, {
|
|
5884
|
+
perPort: true,
|
|
5885
|
+
signed: true,
|
|
5886
|
+
bits: 16,
|
|
5887
|
+
note: "deg C; -128 = no DOM data (e.g. passive DAC)"
|
|
5888
|
+
}),
|
|
5889
|
+
f("voltage", { swos_lite: "i09" }, Kind.INT, { perPort: true, scale: 1e3, note: "volts" }),
|
|
5890
|
+
f("tx_bias", { swos_lite: "i0a" }, Kind.INT, { perPort: true, note: "mA" }),
|
|
5891
|
+
f("tx_power", { swos_lite: "i0b" }, Kind.INT, { perPort: true, scale: 1e4, note: "dBm" }),
|
|
5892
|
+
f("rx_power", { swos_lite: "i0c" }, Kind.INT, { perPort: true, scale: 1e4, note: "dBm" })
|
|
5893
|
+
]
|
|
5894
|
+
};
|
|
5895
|
+
var STATS = {
|
|
5896
|
+
path: "!stats.b",
|
|
5897
|
+
fields: [],
|
|
5898
|
+
genericPerPort: true,
|
|
5899
|
+
note: "Per-port cumulative counters (rx/tx bytes, packets, errors, drops...). iNN keys preserved as-is."
|
|
5900
|
+
};
|
|
5901
|
+
var STATS_METRICS = {
|
|
5902
|
+
i01: "rx_bytes",
|
|
5903
|
+
i0f: "tx_bytes",
|
|
5904
|
+
i05: "rx_unicasts",
|
|
5905
|
+
i11: "tx_unicasts",
|
|
5906
|
+
i07: "rx_broadcasts",
|
|
5907
|
+
i14: "tx_broadcasts",
|
|
5908
|
+
i08: "rx_multicasts",
|
|
5909
|
+
i13: "tx_multicasts",
|
|
5910
|
+
i23: "rx_total_packets",
|
|
5911
|
+
i24: "tx_total_packets",
|
|
5912
|
+
i21: "rx_rate",
|
|
5913
|
+
i22: "tx_rate",
|
|
5914
|
+
i25: "rx_packet_rate",
|
|
5915
|
+
i26: "tx_packet_rate",
|
|
5916
|
+
i17: "rx_pauses",
|
|
5917
|
+
i1d: "rx_errors",
|
|
5918
|
+
i1e: "rx_fcs_errors",
|
|
5919
|
+
i1c: "rx_jabber",
|
|
5920
|
+
i19: "rx_runt",
|
|
5921
|
+
i1a: "rx_fragments",
|
|
5922
|
+
i1b: "rx_too_long",
|
|
5923
|
+
i16: "tx_pauses",
|
|
5924
|
+
i04: "tx_fcs_errors",
|
|
5925
|
+
i1f: "tx_collisions",
|
|
5926
|
+
i15: "tx_single_collisions",
|
|
5927
|
+
i18: "tx_multiple_collisions",
|
|
5928
|
+
i12: "tx_excessive_collisions",
|
|
5929
|
+
i20: "tx_late_collisions",
|
|
5930
|
+
i06: "tx_deferred",
|
|
5931
|
+
i09: "pkt_64",
|
|
5932
|
+
i0a: "pkt_65_127",
|
|
5933
|
+
i0b: "pkt_128_255",
|
|
5934
|
+
i0c: "pkt_256_511",
|
|
5935
|
+
i0d: "pkt_512_1023",
|
|
5936
|
+
i0e: "pkt_1024_max"
|
|
5937
|
+
};
|
|
5938
|
+
var SWOS_SCHEMA = {
|
|
5939
|
+
"link.b": LINK,
|
|
5940
|
+
"sys.b": SYS,
|
|
5941
|
+
"poe.b": POE,
|
|
5942
|
+
"lacp.b": LACP,
|
|
5943
|
+
"rstp.b": RSTP,
|
|
5944
|
+
"snmp.b": SNMP,
|
|
5945
|
+
"fwd.b": FWD,
|
|
5946
|
+
"vlan.b": VLAN,
|
|
5947
|
+
// Only the "!" form exists on the device; a plain /stats.b 303s to /index.html.
|
|
5948
|
+
"!stats.b": STATS,
|
|
5949
|
+
"sfp.b": SFP,
|
|
5950
|
+
"host.b": HOST,
|
|
5951
|
+
"!dhost.b": DHOST,
|
|
5952
|
+
"!igmp.b": IGMP,
|
|
5953
|
+
"acl.b": ACL,
|
|
5954
|
+
"!aclstats.b": ACLSTATS
|
|
5955
|
+
};
|
|
5956
|
+
var SWOS_ENDPOINTS = Object.keys(SWOS_SCHEMA).sort();
|
|
5957
|
+
var VERIFIED_FIRMWARE = [
|
|
5958
|
+
{ model: "CSS610-8P-2S+", version: "2.21" }
|
|
5959
|
+
];
|
|
5960
|
+
var verifiedList = () => VERIFIED_FIRMWARE.map((v) => `${v.model} ${v.version}`).join(", ");
|
|
5961
|
+
function firmwareSupport(model, version) {
|
|
5962
|
+
const m = typeof model === "string" ? model : void 0;
|
|
5963
|
+
const v = typeof version === "string" ? version : void 0;
|
|
5964
|
+
if (m === void 0 || v === void 0) {
|
|
5965
|
+
return {
|
|
5966
|
+
model: m,
|
|
5967
|
+
version: v,
|
|
5968
|
+
verified: false,
|
|
5969
|
+
note: `Firmware could not be identified; the schema is verified against ${verifiedList()}.`
|
|
5970
|
+
};
|
|
5533
5971
|
}
|
|
5534
|
-
|
|
5535
|
-
|
|
5536
|
-
|
|
5537
|
-
|
|
5538
|
-
|
|
5539
|
-
|
|
5540
|
-
|
|
5541
|
-
toCreate.push(beforeRecord);
|
|
5542
|
-
} else if (!recordsAreEqual(beforeRecord, currentRecord)) {
|
|
5543
|
-
const { ".id": _, ...data } = beforeRecord;
|
|
5544
|
-
toUpdate.push({ currentId: currentRecord[".id"], data });
|
|
5545
|
-
}
|
|
5972
|
+
if (VERIFIED_FIRMWARE.some((entry) => entry.model === m && entry.version === v)) {
|
|
5973
|
+
return {
|
|
5974
|
+
model: m,
|
|
5975
|
+
version: v,
|
|
5976
|
+
verified: true,
|
|
5977
|
+
note: `${m} ${v} \u2014 schema verified on this firmware.`
|
|
5978
|
+
};
|
|
5546
5979
|
}
|
|
5547
|
-
const
|
|
5548
|
-
return
|
|
5980
|
+
const sameModel = VERIFIED_FIRMWARE.filter((entry) => entry.model === m).map((e) => e.version);
|
|
5981
|
+
return {
|
|
5982
|
+
model: m,
|
|
5983
|
+
version: v,
|
|
5984
|
+
verified: false,
|
|
5985
|
+
note: sameModel.length > 0 ? `${m} runs ${v}; the schema was verified on ${sameModel.join(", ")}. Field meanings may have changed \u2014 review the diff before applying.` : `${m} ${v} is not in the verified set (${verifiedList()}). Field meanings are inferred \u2014 review the diff before applying.`
|
|
5986
|
+
};
|
|
5549
5987
|
}
|
|
5550
|
-
|
|
5551
|
-
|
|
5552
|
-
|
|
5553
|
-
|
|
5554
|
-
|
|
5555
|
-
|
|
5556
|
-
|
|
5557
|
-
|
|
5558
|
-
|
|
5559
|
-
|
|
5988
|
+
function unmappedWireKeys(endpoint, wire) {
|
|
5989
|
+
const ep = SWOS_SCHEMA[endpoint];
|
|
5990
|
+
if (!ep || ep.isArray || ep.genericPerPort) return [];
|
|
5991
|
+
const mapped = new Set(ep.fields.map((field) => selectKey(field, wire)));
|
|
5992
|
+
return Object.keys(wire).filter((key2) => !mapped.has(key2));
|
|
5993
|
+
}
|
|
5994
|
+
function inferPortCount(data) {
|
|
5995
|
+
for (const v of Object.values(data)) {
|
|
5996
|
+
if (Array.isArray(v) && v.length > 0 && !Array.isArray(v[0]) && typeof v[0] !== "object") {
|
|
5997
|
+
return v.length;
|
|
5998
|
+
}
|
|
5999
|
+
}
|
|
6000
|
+
return 0;
|
|
6001
|
+
}
|
|
6002
|
+
function inferPortCountFromMasks(ep, data) {
|
|
6003
|
+
let widest = 0;
|
|
6004
|
+
for (const fieldDef of ep.fields) {
|
|
6005
|
+
if (fieldDef.kind !== Kind.BOOL) continue;
|
|
6006
|
+
const key2 = selectKey(fieldDef, data);
|
|
6007
|
+
const value = key2 === void 0 ? void 0 : data[key2];
|
|
6008
|
+
if (typeof value === "number" && value > 0) {
|
|
6009
|
+
widest = Math.max(widest, value.toString(2).length);
|
|
6010
|
+
}
|
|
6011
|
+
}
|
|
6012
|
+
return widest;
|
|
6013
|
+
}
|
|
6014
|
+
function selectKey(fieldDef, data) {
|
|
6015
|
+
return Object.values(fieldDef.keys).find((key2) => key2 in data);
|
|
6016
|
+
}
|
|
6017
|
+
function hexToBoolList(value, length) {
|
|
6018
|
+
const bits = value.toString(2).padStart(length, "0").split("").reverse().map((b) => b === "1");
|
|
6019
|
+
return bits.slice(0, length);
|
|
6020
|
+
}
|
|
6021
|
+
function decodeScalar(fieldDef, value, portCount) {
|
|
6022
|
+
switch (fieldDef.kind) {
|
|
6023
|
+
case Kind.BOOL:
|
|
6024
|
+
return hexToBoolList(value, portCount);
|
|
6025
|
+
case Kind.STR: {
|
|
6026
|
+
const hex = value;
|
|
6027
|
+
const buf = Buffer.from(hex.length % 2 ? `0${hex}` : hex, "hex");
|
|
6028
|
+
return buf.toString("utf8").replace(/[\0\s]+$/g, "");
|
|
6029
|
+
}
|
|
6030
|
+
case Kind.OPTION: {
|
|
6031
|
+
const idx = value;
|
|
6032
|
+
return fieldDef.options && idx < fieldDef.options.length ? fieldDef.options[idx] : idx;
|
|
6033
|
+
}
|
|
6034
|
+
case Kind.MAC:
|
|
6035
|
+
return value.toUpperCase().match(/../g)?.join(":") ?? "";
|
|
6036
|
+
case Kind.IP:
|
|
6037
|
+
if (!value) return "0.0.0.0";
|
|
6038
|
+
return Buffer.from(value.toString(16).padStart(8, "0"), "hex").reverse().join(".");
|
|
6039
|
+
case Kind.CHECKBOX:
|
|
6040
|
+
return value === 1;
|
|
6041
|
+
case Kind.INT: {
|
|
6042
|
+
let num = value;
|
|
6043
|
+
if (fieldDef.signed && fieldDef.bits) {
|
|
6044
|
+
const half = 1 << fieldDef.bits - 1;
|
|
6045
|
+
if (num >= half) num -= 1 << fieldDef.bits;
|
|
6046
|
+
}
|
|
6047
|
+
return fieldDef.scale ? num / fieldDef.scale : num;
|
|
6048
|
+
}
|
|
6049
|
+
default:
|
|
6050
|
+
return value;
|
|
6051
|
+
}
|
|
6052
|
+
}
|
|
6053
|
+
function decodeObject(ep, data) {
|
|
6054
|
+
const portCount = inferPortCount(data) || inferPortCountFromMasks(ep, data);
|
|
6055
|
+
const out = {};
|
|
6056
|
+
const knownKeys = /* @__PURE__ */ new Set();
|
|
6057
|
+
for (const fieldDef of ep.fields) {
|
|
6058
|
+
const key2 = selectKey(fieldDef, data);
|
|
6059
|
+
if (key2 === void 0) continue;
|
|
6060
|
+
knownKeys.add(key2);
|
|
6061
|
+
const v = data[key2];
|
|
6062
|
+
if (fieldDef.perPort || fieldDef.kind === Kind.BOOL) {
|
|
6063
|
+
out[fieldDef.name] = Array.isArray(v) ? v.map((x) => decodeScalar(fieldDef, x, portCount)) : decodeScalar(fieldDef, v, portCount);
|
|
6064
|
+
} else {
|
|
6065
|
+
out[fieldDef.name] = decodeScalar(fieldDef, v, portCount);
|
|
6066
|
+
}
|
|
5560
6067
|
}
|
|
6068
|
+
out["_raw"] = Object.fromEntries(Object.entries(data).filter(([k]) => !knownKeys.has(k)));
|
|
6069
|
+
return out;
|
|
5561
6070
|
}
|
|
5562
|
-
|
|
5563
|
-
|
|
5564
|
-
|
|
5565
|
-
|
|
5566
|
-
|
|
5567
|
-
|
|
5568
|
-
|
|
6071
|
+
function decodeRecords(ep, data) {
|
|
6072
|
+
return data.map((item) => {
|
|
6073
|
+
const row = {};
|
|
6074
|
+
const itemObj = item;
|
|
6075
|
+
const knownKeys = /* @__PURE__ */ new Set();
|
|
6076
|
+
for (const fieldDef of ep.fields) {
|
|
6077
|
+
const key2 = selectKey(fieldDef, itemObj);
|
|
6078
|
+
if (key2 === void 0) continue;
|
|
6079
|
+
knownKeys.add(key2);
|
|
6080
|
+
row[fieldDef.name] = decodeScalar(fieldDef, itemObj[key2], 0);
|
|
6081
|
+
}
|
|
6082
|
+
row["_raw"] = Object.fromEntries(Object.entries(itemObj).filter(([k]) => !knownKeys.has(k)));
|
|
6083
|
+
return row;
|
|
6084
|
+
});
|
|
5569
6085
|
}
|
|
5570
|
-
|
|
5571
|
-
const
|
|
5572
|
-
const
|
|
5573
|
-
|
|
5574
|
-
const
|
|
5575
|
-
|
|
5576
|
-
|
|
5577
|
-
|
|
5578
|
-
|
|
5579
|
-
|
|
6086
|
+
function decodeGenericPerPort(data) {
|
|
6087
|
+
const portCount = inferPortCount(data);
|
|
6088
|
+
const ports = {};
|
|
6089
|
+
for (let n = 1; n <= portCount; n++) ports[String(n)] = {};
|
|
6090
|
+
const scalars = {};
|
|
6091
|
+
for (const [k, v] of Object.entries(data)) {
|
|
6092
|
+
if (Array.isArray(v) && v.length === portCount && !Array.isArray(v[0]) && typeof v[0] !== "object") {
|
|
6093
|
+
for (let n = 0; n < portCount; n++) {
|
|
6094
|
+
const port = ports[String(n + 1)];
|
|
6095
|
+
port[k] = v[n];
|
|
6096
|
+
if (STATS_METRICS[k]) port[STATS_METRICS[k]] = v[n];
|
|
6097
|
+
}
|
|
6098
|
+
} else {
|
|
6099
|
+
scalars[k] = v;
|
|
6100
|
+
}
|
|
6101
|
+
}
|
|
6102
|
+
ports["_scalars"] = scalars;
|
|
6103
|
+
return ports;
|
|
5580
6104
|
}
|
|
5581
|
-
|
|
5582
|
-
|
|
6105
|
+
function decodeEndpoint(path, rawText) {
|
|
6106
|
+
const ep = SWOS_SCHEMA[path];
|
|
6107
|
+
if (!ep) throw new Error(`no SwOS schema for ${path}`);
|
|
6108
|
+
const data = parseBlob(rawText);
|
|
6109
|
+
if (ep.isArray) return decodeRecords(ep, data);
|
|
6110
|
+
if (ep.genericPerPort) return decodeGenericPerPort(data);
|
|
6111
|
+
return decodeObject(ep, data);
|
|
5583
6112
|
}
|
|
5584
6113
|
|
|
5585
|
-
// src/
|
|
5586
|
-
import {
|
|
5587
|
-
import { dirname } from "path";
|
|
5588
|
-
import { nanoid as nanoid2 } from "nanoid";
|
|
6114
|
+
// src/adapter/rest-client.ts
|
|
6115
|
+
import { request, Agent as Agent2 } from "undici";
|
|
5589
6116
|
|
|
5590
|
-
// src/
|
|
5591
|
-
import {
|
|
5592
|
-
|
|
5593
|
-
|
|
5594
|
-
"
|
|
5595
|
-
|
|
5596
|
-
|
|
5597
|
-
|
|
5598
|
-
|
|
5599
|
-
|
|
5600
|
-
|
|
5601
|
-
|
|
5602
|
-
|
|
5603
|
-
|
|
5604
|
-
|
|
5605
|
-
|
|
5606
|
-
|
|
5607
|
-
|
|
5608
|
-
|
|
5609
|
-
|
|
5610
|
-
|
|
5611
|
-
|
|
5612
|
-
|
|
5613
|
-
|
|
5614
|
-
|
|
5615
|
-
|
|
6117
|
+
// src/adapter/tls-manager.ts
|
|
6118
|
+
import { readFileSync } from "fs";
|
|
6119
|
+
import { buildConnector } from "undici";
|
|
6120
|
+
function makePinnedConnector(inner, expectedFingerprint) {
|
|
6121
|
+
const expected = expectedFingerprint.replace(/:/g, "").toLowerCase();
|
|
6122
|
+
return (opts, cb) => {
|
|
6123
|
+
inner(opts, (err, socket) => {
|
|
6124
|
+
if (err || !socket) {
|
|
6125
|
+
cb(err ?? new Error("TLS connection failed"), null);
|
|
6126
|
+
return;
|
|
6127
|
+
}
|
|
6128
|
+
const peer = socket;
|
|
6129
|
+
const cert = typeof peer.getPeerCertificate === "function" ? peer.getPeerCertificate() : void 0;
|
|
6130
|
+
const actual = (cert?.fingerprint256 ?? "").replace(/:/g, "").toLowerCase();
|
|
6131
|
+
if (actual !== expected) {
|
|
6132
|
+
socket.destroy();
|
|
6133
|
+
cb(
|
|
6134
|
+
new Error(
|
|
6135
|
+
`TLS certificate fingerprint mismatch. Expected: ${expected}, got: ${actual || "none"}`
|
|
6136
|
+
),
|
|
6137
|
+
null
|
|
6138
|
+
);
|
|
6139
|
+
return;
|
|
6140
|
+
}
|
|
6141
|
+
cb(null, socket);
|
|
6142
|
+
});
|
|
6143
|
+
};
|
|
6144
|
+
}
|
|
6145
|
+
function buildAgentOptions(tls) {
|
|
6146
|
+
if (!tls.enabled) {
|
|
6147
|
+
return {};
|
|
6148
|
+
}
|
|
6149
|
+
const connectOptions = {
|
|
6150
|
+
rejectUnauthorized: tls.rejectUnauthorized
|
|
6151
|
+
};
|
|
6152
|
+
if (tls.ca) {
|
|
6153
|
+
connectOptions.ca = readFileSync(tls.ca, "utf-8");
|
|
6154
|
+
}
|
|
6155
|
+
if (tls.fingerprint) {
|
|
6156
|
+
const inner = buildConnector(connectOptions);
|
|
6157
|
+
return { connect: makePinnedConnector(inner, tls.fingerprint) };
|
|
6158
|
+
}
|
|
6159
|
+
return {
|
|
6160
|
+
connect: connectOptions
|
|
6161
|
+
};
|
|
6162
|
+
}
|
|
6163
|
+
|
|
6164
|
+
// src/adapter/query-builder.ts
|
|
6165
|
+
function buildListQuery(options) {
|
|
6166
|
+
if (!options) {
|
|
6167
|
+
return { method: "GET" };
|
|
6168
|
+
}
|
|
6169
|
+
const hasFilter = options.filter && Object.keys(options.filter).length > 0;
|
|
6170
|
+
const hasProplist = options.proplist && options.proplist.length > 0;
|
|
6171
|
+
if (hasProplist) {
|
|
6172
|
+
const body = {};
|
|
6173
|
+
if (hasProplist) {
|
|
6174
|
+
body[".proplist"] = options.proplist.join(",");
|
|
6175
|
+
}
|
|
6176
|
+
if (hasFilter) {
|
|
6177
|
+
const queryParts = [];
|
|
6178
|
+
for (const [key2, value] of Object.entries(options.filter)) {
|
|
6179
|
+
queryParts.push(`${key2}=${value}`);
|
|
6180
|
+
}
|
|
6181
|
+
body[".query"] = queryParts;
|
|
6182
|
+
}
|
|
6183
|
+
return { method: "POST", body };
|
|
6184
|
+
}
|
|
6185
|
+
if (hasFilter) {
|
|
6186
|
+
const queryParams = {};
|
|
6187
|
+
for (const [key2, value] of Object.entries(options.filter)) {
|
|
6188
|
+
queryParams[key2] = value;
|
|
6189
|
+
}
|
|
6190
|
+
return { method: "GET", queryParams };
|
|
6191
|
+
}
|
|
6192
|
+
return { method: "GET" };
|
|
6193
|
+
}
|
|
6194
|
+
function applyPagination(items, limit2, offset2) {
|
|
6195
|
+
const total = items.length;
|
|
6196
|
+
const start = offset2 ?? 0;
|
|
6197
|
+
const sliced = limit2 !== void 0 ? items.slice(start, start + limit2) : items.slice(start);
|
|
6198
|
+
return {
|
|
6199
|
+
items: sliced,
|
|
6200
|
+
total,
|
|
6201
|
+
hasMore: start + sliced.length < total
|
|
6202
|
+
};
|
|
6203
|
+
}
|
|
6204
|
+
|
|
6205
|
+
// src/adapter/rest-client.ts
|
|
6206
|
+
var HttpError = class extends Error {
|
|
6207
|
+
statusCode;
|
|
6208
|
+
responseBody;
|
|
6209
|
+
constructor(statusCode, responseBody) {
|
|
6210
|
+
super(`HTTP ${statusCode}: ${responseBody}`);
|
|
6211
|
+
this.name = "HttpError";
|
|
6212
|
+
this.statusCode = statusCode;
|
|
6213
|
+
this.responseBody = responseBody;
|
|
6214
|
+
}
|
|
6215
|
+
};
|
|
6216
|
+
var REQUEST_TIMEOUT_MS = 3e4;
|
|
6217
|
+
var RouterOSRestClient = class {
|
|
6218
|
+
baseUrl;
|
|
6219
|
+
authHeader;
|
|
6220
|
+
agent;
|
|
6221
|
+
constructor(config, credentials) {
|
|
6222
|
+
const scheme = config.tls.enabled ? "https" : "http";
|
|
6223
|
+
this.baseUrl = `${scheme}://${config.host}:${config.port}/rest`;
|
|
6224
|
+
this.authHeader = "Basic " + Buffer.from(`${credentials.username}:${credentials.password}`).toString("base64");
|
|
6225
|
+
this.agent = new Agent2(buildAgentOptions(config.tls));
|
|
6226
|
+
}
|
|
6227
|
+
// ---------- public CRUD methods ----------
|
|
6228
|
+
/**
|
|
6229
|
+
* List resources at the given path.
|
|
6230
|
+
*
|
|
6231
|
+
* Applies filtering via GET query params or POST body depending on the
|
|
6232
|
+
* query complexity, then runs client-side pagination.
|
|
6233
|
+
*/
|
|
6234
|
+
async get(path, options) {
|
|
6235
|
+
const query = buildListQuery(options);
|
|
6236
|
+
let raw;
|
|
6237
|
+
if (query.method === "POST") {
|
|
6238
|
+
raw = await this.doRequest("POST", `${this.baseUrl}/${path}`, query.body);
|
|
6239
|
+
} else {
|
|
6240
|
+
const qs = query.queryParams ? "?" + new URLSearchParams(query.queryParams).toString() : "";
|
|
6241
|
+
raw = await this.doRequest("GET", `${this.baseUrl}/${path}${qs}`);
|
|
6242
|
+
}
|
|
6243
|
+
const rawArray = Array.isArray(raw) ? raw : [raw];
|
|
6244
|
+
const parsed = parseRecords(rawArray);
|
|
6245
|
+
if (options?.limit !== void 0 || options?.offset !== void 0) {
|
|
6246
|
+
const page = applyPagination(parsed, options?.limit, options?.offset);
|
|
6247
|
+
return page.items;
|
|
6248
|
+
}
|
|
6249
|
+
return parsed;
|
|
6250
|
+
}
|
|
6251
|
+
/** Fetch a single resource by its RouterOS `.id`. */
|
|
6252
|
+
async getOne(path, id) {
|
|
6253
|
+
const raw = await this.doRequest("GET", `${this.baseUrl}/${path}/${id}`);
|
|
6254
|
+
return parseRecord(raw);
|
|
6255
|
+
}
|
|
6256
|
+
/** Create a new resource (PUT). Returns the created record including `.id`. */
|
|
6257
|
+
async create(path, data) {
|
|
6258
|
+
const raw = await this.doRequest("PUT", `${this.baseUrl}/${path}`, data);
|
|
6259
|
+
return raw;
|
|
6260
|
+
}
|
|
6261
|
+
/** Update an existing resource by `.id` (PATCH). */
|
|
6262
|
+
async update(path, id, data) {
|
|
6263
|
+
await this.doRequest("PATCH", `${this.baseUrl}/${path}/${id}`, data);
|
|
6264
|
+
}
|
|
6265
|
+
/** Remove a resource by `.id` (DELETE). */
|
|
6266
|
+
async remove(path, id) {
|
|
6267
|
+
await this.doRequest("DELETE", `${this.baseUrl}/${path}/${id}`);
|
|
6268
|
+
}
|
|
6269
|
+
/** Execute a command (POST), e.g. `/ip/firewall/filter/print`. */
|
|
6270
|
+
async execute(path, data) {
|
|
6271
|
+
const result = await this.doRequest("POST", `${this.baseUrl}/${path}`, data);
|
|
6272
|
+
return result;
|
|
6273
|
+
}
|
|
6274
|
+
// ---------- lifecycle ----------
|
|
6275
|
+
/** Close the underlying HTTP agent and free resources. */
|
|
6276
|
+
close() {
|
|
6277
|
+
this.agent.close();
|
|
6278
|
+
}
|
|
6279
|
+
// ---------- internal ----------
|
|
6280
|
+
/**
|
|
6281
|
+
* Core HTTP request method.
|
|
6282
|
+
*
|
|
6283
|
+
* - Sends JSON body when provided.
|
|
6284
|
+
* - Attaches Basic Auth and content-type headers.
|
|
6285
|
+
* - Throws `HttpError` on non-2xx status codes.
|
|
6286
|
+
* - Returns parsed JSON (or `undefined` for empty bodies).
|
|
6287
|
+
*/
|
|
6288
|
+
async doRequest(method, url, body) {
|
|
6289
|
+
const headers = {
|
|
6290
|
+
Accept: "application/json",
|
|
6291
|
+
Authorization: this.authHeader
|
|
6292
|
+
};
|
|
6293
|
+
let requestBody;
|
|
6294
|
+
if (body !== void 0) {
|
|
6295
|
+
headers["Content-Type"] = "application/json";
|
|
6296
|
+
requestBody = JSON.stringify(body);
|
|
6297
|
+
}
|
|
6298
|
+
const response = await request(url, {
|
|
6299
|
+
method,
|
|
6300
|
+
headers,
|
|
6301
|
+
body: requestBody,
|
|
6302
|
+
dispatcher: this.agent,
|
|
6303
|
+
bodyTimeout: REQUEST_TIMEOUT_MS,
|
|
6304
|
+
headersTimeout: REQUEST_TIMEOUT_MS
|
|
6305
|
+
});
|
|
6306
|
+
const text = await response.body.text();
|
|
6307
|
+
if (response.statusCode < 200 || response.statusCode >= 300) {
|
|
6308
|
+
throw new HttpError(response.statusCode, text);
|
|
6309
|
+
}
|
|
6310
|
+
if (!text || text.length === 0) {
|
|
6311
|
+
return void 0;
|
|
6312
|
+
}
|
|
6313
|
+
return JSON.parse(text);
|
|
6314
|
+
}
|
|
6315
|
+
};
|
|
6316
|
+
|
|
6317
|
+
// src/adapter/swos-client.ts
|
|
6318
|
+
var REQUEST_TIMEOUT_MS2 = 15e3;
|
|
6319
|
+
function md5(input2) {
|
|
6320
|
+
return createHash("md5").update(input2).digest("hex");
|
|
6321
|
+
}
|
|
6322
|
+
function parseDigestChallenge(header) {
|
|
6323
|
+
if (!header.toLowerCase().startsWith("digest")) return void 0;
|
|
6324
|
+
const fields = {};
|
|
6325
|
+
const quoted = /([a-zA-Z0-9_-]+)\s*=\s*("([^"]*)"|([^\s,]+))/g;
|
|
6326
|
+
let m;
|
|
6327
|
+
while ((m = quoted.exec(header)) !== null) {
|
|
6328
|
+
fields[m[1]] = m[3] ?? m[4];
|
|
6329
|
+
}
|
|
6330
|
+
if (!fields.realm || !fields.nonce) return void 0;
|
|
6331
|
+
return {
|
|
6332
|
+
realm: fields.realm,
|
|
6333
|
+
nonce: fields.nonce,
|
|
6334
|
+
opaque: fields.opaque,
|
|
6335
|
+
qop: fields.qop,
|
|
6336
|
+
algorithm: fields.algorithm
|
|
6337
|
+
};
|
|
6338
|
+
}
|
|
6339
|
+
function buildDigestHeader(method, path, challenge, credentials, nonceCount) {
|
|
6340
|
+
const ha1 = md5(`${credentials.username}:${challenge.realm}:${credentials.password}`);
|
|
6341
|
+
const ha2 = md5(`${method}:${path}`);
|
|
6342
|
+
const nc = nonceCount.toString(16).padStart(8, "0");
|
|
6343
|
+
const cnonce = randomBytes(8).toString("hex");
|
|
6344
|
+
const qop = challenge.qop?.split(",").map((s) => s.trim()).find((s) => s === "auth");
|
|
6345
|
+
const response = qop ? md5(`${ha1}:${challenge.nonce}:${nc}:${cnonce}:${qop}:${ha2}`) : md5(`${ha1}:${challenge.nonce}:${ha2}`);
|
|
6346
|
+
const parts = [
|
|
6347
|
+
`username="${credentials.username}"`,
|
|
6348
|
+
`realm="${challenge.realm}"`,
|
|
6349
|
+
`nonce="${challenge.nonce}"`,
|
|
6350
|
+
`uri="${path}"`,
|
|
6351
|
+
`response="${response}"`
|
|
6352
|
+
];
|
|
6353
|
+
if (challenge.algorithm) parts.push(`algorithm=${challenge.algorithm}`);
|
|
6354
|
+
if (challenge.opaque) parts.push(`opaque="${challenge.opaque}"`);
|
|
6355
|
+
if (qop) parts.push(`qop=${qop}`, `nc=${nc}`, `cnonce="${cnonce}"`);
|
|
6356
|
+
return `Digest ${parts.join(", ")}`;
|
|
6357
|
+
}
|
|
6358
|
+
function assertKnownEndpoint(endpoint) {
|
|
6359
|
+
if (endpoint in SWOS_SCHEMA) return;
|
|
6360
|
+
throw new MikroMCPError({
|
|
6361
|
+
category: "VALIDATION" /* VALIDATION */,
|
|
6362
|
+
code: "SWOS_UNKNOWN_ENDPOINT",
|
|
6363
|
+
message: `No SwOS schema for "${endpoint}".`,
|
|
6364
|
+
details: { endpoint, known: Object.keys(SWOS_SCHEMA) },
|
|
6365
|
+
recoverability: {
|
|
6366
|
+
retryable: false,
|
|
6367
|
+
suggestedAction: "Call list_swos_endpoints to see the supported endpoints."
|
|
6368
|
+
}
|
|
6369
|
+
});
|
|
6370
|
+
}
|
|
6371
|
+
var SwosClient = class {
|
|
6372
|
+
host;
|
|
6373
|
+
port;
|
|
6374
|
+
credentials;
|
|
6375
|
+
requestTimeoutMs;
|
|
6376
|
+
challenge;
|
|
6377
|
+
nonceCount = 0;
|
|
6378
|
+
constructor(host, port, credentials, requestTimeoutMs = REQUEST_TIMEOUT_MS2) {
|
|
6379
|
+
this.host = host;
|
|
6380
|
+
this.port = port;
|
|
6381
|
+
this.credentials = credentials;
|
|
6382
|
+
this.requestTimeoutMs = requestTimeoutMs;
|
|
6383
|
+
}
|
|
6384
|
+
// ---------- raw transport ----------
|
|
6385
|
+
/**
|
|
6386
|
+
* Uses `node:http` with `insecureHTTPParser` rather than undici, because the
|
|
6387
|
+
* SwOS firmware terminates the status line of a POST response with a bare LF
|
|
6388
|
+
* instead of CRLF ("HTTP/1.0 200 OK\n"). A strict parser rejects that, which
|
|
6389
|
+
* would report a write that actually succeeded as a failure. The leniency is
|
|
6390
|
+
* scoped to this client — the RouterOS REST client keeps the strict parser.
|
|
6391
|
+
*/
|
|
6392
|
+
rawRequest(method, path, headers, body) {
|
|
6393
|
+
return new Promise((resolve, reject) => {
|
|
6394
|
+
const req = httpRequest(
|
|
6395
|
+
{
|
|
6396
|
+
host: this.host,
|
|
6397
|
+
port: this.port,
|
|
6398
|
+
path,
|
|
6399
|
+
method,
|
|
6400
|
+
headers,
|
|
6401
|
+
insecureHTTPParser: true
|
|
6402
|
+
},
|
|
6403
|
+
(res) => {
|
|
6404
|
+
res.setEncoding("utf8");
|
|
6405
|
+
let data = "";
|
|
6406
|
+
res.on("data", (chunk) => data += chunk);
|
|
6407
|
+
res.on(
|
|
6408
|
+
"end",
|
|
6409
|
+
() => resolve({ statusCode: res.statusCode ?? 0, headers: res.headers, body: data })
|
|
6410
|
+
);
|
|
6411
|
+
res.on("error", reject);
|
|
6412
|
+
}
|
|
6413
|
+
);
|
|
6414
|
+
req.on("error", reject);
|
|
6415
|
+
req.setTimeout(this.requestTimeoutMs, () => {
|
|
6416
|
+
const isWrite = method === "POST";
|
|
6417
|
+
req.destroy(
|
|
6418
|
+
new MikroMCPError({
|
|
6419
|
+
category: "ROUTER_TIMEOUT" /* ROUTER_TIMEOUT */,
|
|
6420
|
+
code: "SWOS_REQUEST_TIMEOUT",
|
|
6421
|
+
message: `SwOS ${method} request to ${path} timed out after ${this.requestTimeoutMs}ms${isWrite ? " \u2014 the write may still have been applied" : ""}`,
|
|
6422
|
+
recoverability: isWrite ? {
|
|
6423
|
+
retryable: false,
|
|
6424
|
+
suggestedAction: "SwOS firmware does not acknowledge writes, so a longer timeout will not help."
|
|
6425
|
+
} : {
|
|
6426
|
+
retryable: true,
|
|
6427
|
+
retryAfterMs: 3e3,
|
|
6428
|
+
suggestedAction: "The switch did not respond in time \u2014 retry the read."
|
|
6429
|
+
}
|
|
6430
|
+
})
|
|
6431
|
+
);
|
|
6432
|
+
});
|
|
6433
|
+
if (body !== void 0) req.write(body);
|
|
6434
|
+
req.end();
|
|
6435
|
+
});
|
|
6436
|
+
}
|
|
6437
|
+
async doRequest(method, path, body) {
|
|
6438
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
6439
|
+
const headers = {
|
|
6440
|
+
Accept: "text/plain",
|
|
6441
|
+
"Accept-Encoding": "identity"
|
|
6442
|
+
};
|
|
6443
|
+
if (body !== void 0) {
|
|
6444
|
+
headers["Content-Type"] = "text/plain";
|
|
6445
|
+
headers["Content-Length"] = Buffer.byteLength(body);
|
|
6446
|
+
}
|
|
6447
|
+
if (this.challenge) {
|
|
6448
|
+
this.nonceCount += 1;
|
|
6449
|
+
headers["Authorization"] = buildDigestHeader(
|
|
6450
|
+
method,
|
|
6451
|
+
path,
|
|
6452
|
+
this.challenge,
|
|
6453
|
+
this.credentials,
|
|
6454
|
+
this.nonceCount
|
|
6455
|
+
);
|
|
6456
|
+
}
|
|
6457
|
+
const response = await this.rawRequest(method, path, headers, body);
|
|
6458
|
+
const rawChallengeHeader = response.headers["www-authenticate"];
|
|
6459
|
+
const rawChallenge = Array.isArray(rawChallengeHeader) ? rawChallengeHeader[0] : rawChallengeHeader;
|
|
6460
|
+
if (response.statusCode === 401 && rawChallenge) {
|
|
6461
|
+
const parsed = parseDigestChallenge(rawChallenge);
|
|
6462
|
+
if (parsed) {
|
|
6463
|
+
this.challenge = parsed;
|
|
6464
|
+
this.nonceCount = 0;
|
|
6465
|
+
continue;
|
|
6466
|
+
}
|
|
6467
|
+
}
|
|
6468
|
+
if (response.statusCode < 200 || response.statusCode >= 300) {
|
|
6469
|
+
throw new HttpError(response.statusCode, response.body);
|
|
6470
|
+
}
|
|
6471
|
+
return response.body;
|
|
6472
|
+
}
|
|
6473
|
+
throw new HttpError(401, "digest authentication failed");
|
|
6474
|
+
}
|
|
6475
|
+
/** GET a .b endpoint, return the raw broken-JSON text. */
|
|
6476
|
+
async getRaw(endpoint) {
|
|
6477
|
+
return this.doRequest("GET", `/${endpoint}`);
|
|
6478
|
+
}
|
|
6479
|
+
/** POST a re-encoded .b blob back to the switch. */
|
|
6480
|
+
async postRaw(endpoint, body) {
|
|
6481
|
+
return this.doRequest("POST", `/${endpoint}`, body);
|
|
6482
|
+
}
|
|
6483
|
+
// ---------- decoded helpers ----------
|
|
6484
|
+
/** GET + decode into the schema. Unknown keys are preserved under `_raw`. */
|
|
6485
|
+
async get(endpoint) {
|
|
6486
|
+
assertKnownEndpoint(endpoint);
|
|
6487
|
+
return decodeEndpoint(endpoint, await this.getRaw(endpoint));
|
|
6488
|
+
}
|
|
6489
|
+
/**
|
|
6490
|
+
* GET and parse into a plain wire dict for local mutation. Hex tokens keep
|
|
6491
|
+
* their verbatim `"0x…"` form so untouched fields re-serialize byte-for-byte.
|
|
6492
|
+
*/
|
|
6493
|
+
async readBlob(endpoint) {
|
|
6494
|
+
assertKnownEndpoint(endpoint);
|
|
6495
|
+
return parseWireBlob(await this.getRaw(endpoint));
|
|
6496
|
+
}
|
|
6497
|
+
/**
|
|
6498
|
+
* GET for mutation, proving the codec is lossless for *this* device first.
|
|
6499
|
+
* The fixtures only prove it for the hardware they came from; a firmware that
|
|
6500
|
+
* serializes differently must fail here rather than during a whole-blob write.
|
|
6501
|
+
*/
|
|
6502
|
+
async readBlobForWrite(endpoint) {
|
|
6503
|
+
assertKnownEndpoint(endpoint);
|
|
6504
|
+
const raw = await this.getRaw(endpoint);
|
|
6505
|
+
const wire = parseWireBlob(raw);
|
|
6506
|
+
const reencoded = dumpBlob(wire);
|
|
6507
|
+
if (reencoded !== raw.trim()) {
|
|
6508
|
+
throw new MikroMCPError({
|
|
6509
|
+
category: "VALIDATION" /* VALIDATION */,
|
|
6510
|
+
code: "SWOS_ROUNDTRIP_MISMATCH",
|
|
6511
|
+
message: `MikroMCP cannot re-encode ${endpoint} on this device byte-for-byte; refusing to write.`,
|
|
6512
|
+
details: { endpoint, received: raw.trim(), reencoded },
|
|
6513
|
+
recoverability: {
|
|
6514
|
+
retryable: false,
|
|
6515
|
+
suggestedAction: "This firmware serializes a value in a form the codec does not preserve. Reads are unaffected; please open an issue with the blob."
|
|
6516
|
+
}
|
|
6517
|
+
});
|
|
6518
|
+
}
|
|
6519
|
+
return wire;
|
|
6520
|
+
}
|
|
6521
|
+
/** POST a wire dict (re-encoded via the codec) back to the switch. */
|
|
6522
|
+
async writeBlob(endpoint, wire) {
|
|
6523
|
+
assertKnownEndpoint(endpoint);
|
|
6524
|
+
return this.postRaw(endpoint, dumpBlob(wire));
|
|
6525
|
+
}
|
|
6526
|
+
/** No-op; SwOS uses plain HTTP with per-request connections. */
|
|
6527
|
+
close() {
|
|
6528
|
+
}
|
|
6529
|
+
};
|
|
6530
|
+
|
|
6531
|
+
// src/domain/snapshot/diff-engine.ts
|
|
6532
|
+
var RUNTIME_FIELDS = /* @__PURE__ */ new Set([
|
|
6533
|
+
"bytes",
|
|
6534
|
+
"packets",
|
|
6535
|
+
"dynamic",
|
|
6536
|
+
"invalid",
|
|
6537
|
+
"dead",
|
|
6538
|
+
"expired",
|
|
6539
|
+
"active",
|
|
6540
|
+
"running",
|
|
6541
|
+
"about",
|
|
6542
|
+
".about",
|
|
6543
|
+
".nextid",
|
|
6544
|
+
"creation-time",
|
|
6545
|
+
"last-logged-in",
|
|
6546
|
+
"last-link-up-time",
|
|
6547
|
+
"last-link-down-time",
|
|
6548
|
+
"link-downs",
|
|
6549
|
+
"rx-byte",
|
|
6550
|
+
"tx-byte",
|
|
6551
|
+
"rx-packet",
|
|
6552
|
+
"tx-packet",
|
|
6553
|
+
"uptime"
|
|
6554
|
+
]);
|
|
6555
|
+
var ORDER_SENSITIVE_PATHS = /* @__PURE__ */ new Set([
|
|
6556
|
+
"ip/firewall/filter",
|
|
6557
|
+
"ip/firewall/nat",
|
|
6558
|
+
"ip/firewall/mangle",
|
|
6559
|
+
"routing/rule"
|
|
6560
|
+
]);
|
|
6561
|
+
function normalizeForDiff(records) {
|
|
6562
|
+
const out = [];
|
|
6563
|
+
for (const r of records) {
|
|
6564
|
+
if (isTrue(r.dynamic)) continue;
|
|
6565
|
+
const clean = {};
|
|
6566
|
+
for (const [k, v] of Object.entries(r)) {
|
|
6567
|
+
if (!RUNTIME_FIELDS.has(k)) clean[k] = v;
|
|
6568
|
+
}
|
|
6569
|
+
out.push(clean);
|
|
6570
|
+
}
|
|
6571
|
+
return out;
|
|
6572
|
+
}
|
|
6573
|
+
var SEMANTIC_KEYS = {
|
|
6574
|
+
"ip/route": ["dst-address", "gateway", "routing-table"],
|
|
6575
|
+
"ip/firewall/filter": ["comment"],
|
|
6576
|
+
"ip/firewall/nat": ["comment"],
|
|
6577
|
+
"ip/firewall/mangle": ["comment"],
|
|
6578
|
+
"ip/firewall/address-list": ["list", "address"],
|
|
6579
|
+
"ip/address": ["address", "interface"],
|
|
6580
|
+
"ip/dns/static": ["name", "type"],
|
|
6581
|
+
"routing/rule": ["src-address", "routing-table"],
|
|
6582
|
+
"routing/table": ["name"],
|
|
6583
|
+
"interface/wireguard/peers": ["public-key"],
|
|
6584
|
+
"interface/bridge": ["name"],
|
|
6585
|
+
"interface/bridge/port": ["bridge", "interface"],
|
|
6586
|
+
"system/scheduler": ["name"],
|
|
6587
|
+
"system/script": ["name"],
|
|
6588
|
+
"system/package": ["name"],
|
|
6589
|
+
"container": ["name"],
|
|
6590
|
+
"certificate": ["name"],
|
|
6591
|
+
"file": ["name"],
|
|
6592
|
+
"interface/vrrp": ["name"],
|
|
6593
|
+
"ip/dhcp-server": ["name"],
|
|
6594
|
+
"ip/ipsec/peer": ["name"],
|
|
6595
|
+
"ip/pool": ["name"],
|
|
6596
|
+
"queue/simple": ["name"],
|
|
6597
|
+
"tool/netwatch": ["host"],
|
|
6598
|
+
"user": ["name"]
|
|
6599
|
+
};
|
|
6600
|
+
function normalizeValue(v) {
|
|
6601
|
+
if (v === true || v === "true" || v === "yes") return "true";
|
|
6602
|
+
if (v === false || v === "false" || v === "no") return "false";
|
|
6603
|
+
return String(v ?? "");
|
|
6604
|
+
}
|
|
6605
|
+
function semanticKey(record, keys) {
|
|
6606
|
+
return keys.map((k) => normalizeValue(record[k] ?? "")).join("|");
|
|
6607
|
+
}
|
|
6608
|
+
function recordSignature(record) {
|
|
6609
|
+
return Object.entries(record).filter(([k]) => k !== ".id").sort(([a], [b]) => a.localeCompare(b)).map(([k, v]) => `${k}=${normalizeValue(v)}`).join(";");
|
|
6610
|
+
}
|
|
6611
|
+
function recordsAreEqual(a, b) {
|
|
6612
|
+
const keysA = Object.keys(a).filter((k) => k !== ".id");
|
|
6613
|
+
const keysB = Object.keys(b).filter((k) => k !== ".id");
|
|
6614
|
+
if (keysA.length !== keysB.length) return false;
|
|
6615
|
+
return keysA.every((k) => normalizeValue(a[k]) === normalizeValue(b[k] ?? ""));
|
|
6616
|
+
}
|
|
6617
|
+
function hasDuplicateKeys(records, keys) {
|
|
6618
|
+
const seen = /* @__PURE__ */ new Set();
|
|
6619
|
+
for (const r of records) {
|
|
6620
|
+
const k = semanticKey(r, keys);
|
|
6621
|
+
if (seen.has(k)) return true;
|
|
6622
|
+
seen.add(k);
|
|
6623
|
+
}
|
|
6624
|
+
return false;
|
|
6625
|
+
}
|
|
6626
|
+
function applyUserRestriction(path, plan) {
|
|
6627
|
+
if (path === "user" && plan.toCreate.length > 0) {
|
|
6628
|
+
plan.warnings.push(
|
|
6629
|
+
`Refusing to recreate ${plan.toCreate.length} deleted user(s): passwords are not stored in snapshots and recreation would produce password-less logins. Recreate manually with manage_user.`
|
|
6630
|
+
);
|
|
6631
|
+
plan.toCreate = [];
|
|
6632
|
+
}
|
|
6633
|
+
return plan;
|
|
6634
|
+
}
|
|
6635
|
+
function computeRestorePlan(path, beforeRaw, currentRaw) {
|
|
6636
|
+
const before = normalizeForDiff(beforeRaw);
|
|
6637
|
+
const current = normalizeForDiff(currentRaw);
|
|
6638
|
+
const warnings = [];
|
|
6639
|
+
if (ORDER_SENSITIVE_PATHS.has(path)) {
|
|
6640
|
+
warnings.push(
|
|
6641
|
+
`Restored entries for ${path} are re-created at the end of the list \u2014 rule ORDER is not restored. Review ordering manually after rollback.`
|
|
6642
|
+
);
|
|
6643
|
+
}
|
|
6644
|
+
const keys = SEMANTIC_KEYS[path];
|
|
6645
|
+
const canUseKeys = keys !== void 0 && !hasDuplicateKeys(before, keys) && !hasDuplicateKeys(current, keys);
|
|
6646
|
+
if (!canUseKeys) {
|
|
6647
|
+
const beforeSigs = new Set(before.map(recordSignature));
|
|
6648
|
+
const currentSigs = new Set(current.map(recordSignature));
|
|
6649
|
+
return applyUserRestriction(path, {
|
|
6650
|
+
path,
|
|
6651
|
+
toCreate: before.filter((r) => !currentSigs.has(recordSignature(r))),
|
|
6652
|
+
toRemove: current.filter((r) => !beforeSigs.has(recordSignature(r))).map((r) => r[".id"]),
|
|
6653
|
+
toUpdate: [],
|
|
6654
|
+
warnings
|
|
6655
|
+
});
|
|
6656
|
+
}
|
|
6657
|
+
const currentByKey = new Map(current.map((r) => [semanticKey(r, keys), r]));
|
|
6658
|
+
const beforeByKey = new Map(before.map((r) => [semanticKey(r, keys), r]));
|
|
6659
|
+
const toCreate = [];
|
|
6660
|
+
const toUpdate = [];
|
|
6661
|
+
for (const [key2, beforeRecord] of beforeByKey) {
|
|
6662
|
+
const currentRecord = currentByKey.get(key2);
|
|
6663
|
+
if (!currentRecord) {
|
|
6664
|
+
toCreate.push(beforeRecord);
|
|
6665
|
+
} else if (!recordsAreEqual(beforeRecord, currentRecord)) {
|
|
6666
|
+
const { ".id": _, ...data } = beforeRecord;
|
|
6667
|
+
toUpdate.push({ currentId: currentRecord[".id"], data });
|
|
6668
|
+
}
|
|
6669
|
+
}
|
|
6670
|
+
const toRemove = current.filter((r) => !beforeByKey.has(semanticKey(r, keys))).map((r) => r[".id"]);
|
|
6671
|
+
return applyUserRestriction(path, { path, toCreate, toRemove, toUpdate, warnings });
|
|
6672
|
+
}
|
|
6673
|
+
async function applyRestorePlan(plan, client) {
|
|
6674
|
+
for (const id of plan.toRemove) {
|
|
6675
|
+
await client.remove(plan.path, id);
|
|
6676
|
+
}
|
|
6677
|
+
for (const record of plan.toCreate) {
|
|
6678
|
+
const { ".id": _, ...data } = record;
|
|
6679
|
+
await client.create(plan.path, data);
|
|
6680
|
+
}
|
|
6681
|
+
for (const { currentId, data } of plan.toUpdate) {
|
|
6682
|
+
await client.update(plan.path, currentId, data);
|
|
6683
|
+
}
|
|
6684
|
+
}
|
|
6685
|
+
|
|
6686
|
+
// src/domain/snapshot/snapshot-engine.ts
|
|
6687
|
+
function pathToSlug(rosPath) {
|
|
6688
|
+
return rosPath.replace(/\//g, "-");
|
|
6689
|
+
}
|
|
6690
|
+
function timestampPrefix() {
|
|
6691
|
+
return (/* @__PURE__ */ new Date()).toISOString().replace(/[-:T]/g, "").slice(0, 15);
|
|
6692
|
+
}
|
|
6693
|
+
async function persist(stored, snapshotDir) {
|
|
6694
|
+
const dir = join2(snapshotDir, stored.routerId);
|
|
6695
|
+
const filePath = join2(dir, `${stored.id}.json`);
|
|
6696
|
+
await mkdir(dir, { recursive: true });
|
|
6697
|
+
await writeFile(filePath, JSON.stringify(stored, null, 2));
|
|
6698
|
+
return {
|
|
6699
|
+
id: stored.id,
|
|
6700
|
+
routerId: stored.routerId,
|
|
6701
|
+
path: stored.path,
|
|
6702
|
+
ts: stored.ts,
|
|
6703
|
+
filePath,
|
|
6704
|
+
recordCount: stored.records.length
|
|
6705
|
+
};
|
|
6706
|
+
}
|
|
6707
|
+
async function takeSnapshot(client, routerId2, path, snapshotDir) {
|
|
6708
|
+
const id = `${timestampPrefix()}-${pathToSlug(path)}-${nanoid(6)}`;
|
|
6709
|
+
const ts = (/* @__PURE__ */ new Date()).toISOString();
|
|
6710
|
+
if (client instanceof SwosClient) {
|
|
6711
|
+
const blob = await client.getRaw(path);
|
|
6712
|
+
return persist({ id, routerId: routerId2, path, ts, records: [], blob }, snapshotDir);
|
|
6713
|
+
}
|
|
6714
|
+
const records = normalizeForDiff(await client.get(path, {}));
|
|
6715
|
+
return persist({ id, routerId: routerId2, path, ts, records }, snapshotDir);
|
|
6716
|
+
}
|
|
6717
|
+
async function loadSnapshot(filePath) {
|
|
6718
|
+
return JSON.parse(await readFile(filePath, "utf-8"));
|
|
6719
|
+
}
|
|
6720
|
+
|
|
6721
|
+
// src/domain/snapshot/write-journal.ts
|
|
6722
|
+
import { appendFile as appendFile2, mkdir as mkdir2 } from "fs/promises";
|
|
6723
|
+
import { dirname } from "path";
|
|
6724
|
+
import { nanoid as nanoid2 } from "nanoid";
|
|
6725
|
+
|
|
6726
|
+
// src/observability/audit-log.ts
|
|
6727
|
+
import { appendFile } from "fs/promises";
|
|
6728
|
+
var log22 = createLogger("audit");
|
|
6729
|
+
var SENSITIVE_KEYS = /* @__PURE__ */ new Set([
|
|
6730
|
+
"password",
|
|
6731
|
+
"pass",
|
|
6732
|
+
"token",
|
|
6733
|
+
"secret",
|
|
6734
|
+
"authorization",
|
|
6735
|
+
"credentials",
|
|
6736
|
+
"privatekey",
|
|
6737
|
+
"presharedkey",
|
|
6738
|
+
"psk",
|
|
6739
|
+
"passphrase",
|
|
6740
|
+
"community",
|
|
6741
|
+
"apikey",
|
|
6742
|
+
"confirmationsecret"
|
|
6743
|
+
]);
|
|
6744
|
+
function redactValue(value) {
|
|
6745
|
+
if (Array.isArray(value)) {
|
|
6746
|
+
return value.map(redactValue);
|
|
6747
|
+
}
|
|
6748
|
+
if (value !== null && typeof value === "object") {
|
|
6749
|
+
return redactParams(value);
|
|
6750
|
+
}
|
|
6751
|
+
return value;
|
|
5616
6752
|
}
|
|
5617
6753
|
function redactParams(params) {
|
|
5618
6754
|
const out = {};
|
|
@@ -5687,6 +6823,36 @@ var rollbackChangeInputSchema = z24.object({
|
|
|
5687
6823
|
journalId: z24.string().describe("Journal entry ID from write-journal.ndjson to roll back"),
|
|
5688
6824
|
dryRun: z24.boolean().default(false).describe("Preview the restore plan without applying changes")
|
|
5689
6825
|
}).strict();
|
|
6826
|
+
async function restoreSwosBlobs(snapshots, journalId, dryRun2, context) {
|
|
6827
|
+
const client = context.swosClient;
|
|
6828
|
+
if (!client) {
|
|
6829
|
+
throw new MikroMCPError({
|
|
6830
|
+
category: "VALIDATION" /* VALIDATION */,
|
|
6831
|
+
code: "PLATFORM_MISMATCH",
|
|
6832
|
+
message: `Journal entry "${journalId}" holds SwOS blob snapshots, but router "${context.routerId}" is not a SwOS switch.`,
|
|
6833
|
+
recoverability: {
|
|
6834
|
+
retryable: false,
|
|
6835
|
+
suggestedAction: "Roll back against the switch the write was made on."
|
|
6836
|
+
}
|
|
6837
|
+
});
|
|
6838
|
+
}
|
|
6839
|
+
const targets = snapshots.map((s) => ({ endpoint: s.path, blob: s.blob }));
|
|
6840
|
+
if (dryRun2) {
|
|
6841
|
+
return {
|
|
6842
|
+
content: `Rollback dry run for journal entry "${journalId}": would restore ${targets.length} SwOS endpoint(s).
|
|
6843
|
+
` + targets.map((t) => ` ${t.endpoint}:
|
|
6844
|
+
${t.blob}`).join("\n"),
|
|
6845
|
+
structuredContent: { action: "dry_run", journalId, targets }
|
|
6846
|
+
};
|
|
6847
|
+
}
|
|
6848
|
+
for (const target of targets) {
|
|
6849
|
+
await client.postRaw(target.endpoint, target.blob);
|
|
6850
|
+
}
|
|
6851
|
+
return {
|
|
6852
|
+
content: `Rolled back journal entry "${journalId}": restored ${targets.map((t) => t.endpoint).join(", ")}.`,
|
|
6853
|
+
structuredContent: { action: "rolled_back", journalId, targets }
|
|
6854
|
+
};
|
|
6855
|
+
}
|
|
5690
6856
|
function createChangeManagementTools(baseTools2) {
|
|
5691
6857
|
const toolMap = new Map(baseTools2.map((t) => [t.name, t]));
|
|
5692
6858
|
function requireTool(name) {
|
|
@@ -5722,7 +6888,7 @@ function createChangeManagementTools(baseTools2) {
|
|
|
5722
6888
|
for (const [i, step] of parsed.steps.entries()) {
|
|
5723
6889
|
const tool = requireTool(step.tool);
|
|
5724
6890
|
const currentState = {};
|
|
5725
|
-
for (const path of tool.
|
|
6891
|
+
for (const path of snapshotPathsFor(tool, { ...step.params, routerId: parsed.routerId })) {
|
|
5726
6892
|
try {
|
|
5727
6893
|
currentState[path] = await context.routerClient.get(path, {});
|
|
5728
6894
|
} catch {
|
|
@@ -5779,14 +6945,12 @@ function createChangeManagementTools(baseTools2) {
|
|
|
5779
6945
|
const tool = requireTool(step.tool);
|
|
5780
6946
|
const stepParams = { ...step.params, routerId: parsed.routerId };
|
|
5781
6947
|
const snapshotIds = [];
|
|
5782
|
-
|
|
5783
|
-
|
|
5784
|
-
|
|
5785
|
-
|
|
5786
|
-
|
|
5787
|
-
}
|
|
5788
|
-
log24.warn({ err, path, routerId: context.routerId, step: i }, "apply_plan step snapshot failed \u2014 proceeding without snapshot");
|
|
5789
|
-
}
|
|
6948
|
+
for (const path of snapshotPathsFor(tool, stepParams)) {
|
|
6949
|
+
try {
|
|
6950
|
+
const meta = await takeSnapshot(context.deviceClient, context.routerId, path, snapshotDir);
|
|
6951
|
+
snapshotIds.push(meta.id);
|
|
6952
|
+
} catch (err) {
|
|
6953
|
+
log24.warn({ err, path, routerId: context.routerId, step: i }, "apply_plan step snapshot failed \u2014 proceeding without snapshot");
|
|
5790
6954
|
}
|
|
5791
6955
|
}
|
|
5792
6956
|
const stepJournalId = recordAttempt({
|
|
@@ -5838,7 +7002,7 @@ function createChangeManagementTools(baseTools2) {
|
|
|
5838
7002
|
}
|
|
5839
7003
|
};
|
|
5840
7004
|
function findJournalEntry(journalPath, journalId) {
|
|
5841
|
-
const raw =
|
|
7005
|
+
const raw = readFileSync2(journalPath, "utf-8");
|
|
5842
7006
|
const lines = raw.split("\n").filter(Boolean);
|
|
5843
7007
|
const entry = lines.map((l) => JSON.parse(l)).find((e) => e.id === journalId && e.phase === "attempt");
|
|
5844
7008
|
if (!entry) {
|
|
@@ -5857,7 +7021,7 @@ function createChangeManagementTools(baseTools2) {
|
|
|
5857
7021
|
const rollbackChangeTool = {
|
|
5858
7022
|
name: "rollback_change",
|
|
5859
7023
|
title: "Rollback Change",
|
|
5860
|
-
description: "Restore
|
|
7024
|
+
description: "Restore device state to before a write, identified by its journal ID. RouterOS: reads the before-snapshot, diffs against live state, and applies the reverse. SwOS: re-POSTs the exact pre-write '.b' blob. Use dryRun=true to preview. Requires MIKROMCP_DATA_DIR (defaults to data/).",
|
|
5861
7025
|
inputSchema: rollbackChangeInputSchema,
|
|
5862
7026
|
annotations: {
|
|
5863
7027
|
readOnlyHint: false,
|
|
@@ -5865,6 +7029,7 @@ function createChangeManagementTools(baseTools2) {
|
|
|
5865
7029
|
idempotentHint: false,
|
|
5866
7030
|
openWorldHint: false
|
|
5867
7031
|
},
|
|
7032
|
+
platform: "any",
|
|
5868
7033
|
async handler(params, context) {
|
|
5869
7034
|
const parsed = rollbackChangeInputSchema.parse(params);
|
|
5870
7035
|
const journalPath = context.appConfig.journalPath;
|
|
@@ -5877,10 +7042,17 @@ function createChangeManagementTools(baseTools2) {
|
|
|
5877
7042
|
structuredContent: { action: "no_snapshot", journalId: parsed.journalId }
|
|
5878
7043
|
};
|
|
5879
7044
|
}
|
|
7045
|
+
const snapshots = await Promise.all(
|
|
7046
|
+
snapshotIds.map(
|
|
7047
|
+
(snapshotId) => loadSnapshot(`${snapshotDir}/${context.routerId}/${snapshotId}.json`)
|
|
7048
|
+
)
|
|
7049
|
+
);
|
|
7050
|
+
const swosSnapshots = snapshots.filter((s) => s.blob !== void 0);
|
|
7051
|
+
if (swosSnapshots.length > 0) {
|
|
7052
|
+
return restoreSwosBlobs(swosSnapshots, parsed.journalId, parsed.dryRun, context);
|
|
7053
|
+
}
|
|
5880
7054
|
const restorePlans = [];
|
|
5881
|
-
for (const
|
|
5882
|
-
const filePath = `${snapshotDir}/${context.routerId}/${snapshotId}.json`;
|
|
5883
|
-
const stored = await loadSnapshot(filePath);
|
|
7055
|
+
for (const stored of snapshots) {
|
|
5884
7056
|
const current = await context.routerClient.get(stored.path, {});
|
|
5885
7057
|
const plan = computeRestorePlan(stored.path, stored.records, current);
|
|
5886
7058
|
restorePlans.push(plan);
|
|
@@ -11019,27 +12191,437 @@ var manageInterfaceListMemberTool = {
|
|
|
11019
12191
|
await context.routerClient.remove("interface/list/member", existing[".id"]);
|
|
11020
12192
|
log45.info({ list: parsed.list, interface: parsed.interface }, "Interface list member removed");
|
|
11021
12193
|
return {
|
|
11022
|
-
content: `Removed "${parsed.interface}" from list "${parsed.list}".`,
|
|
12194
|
+
content: `Removed "${parsed.interface}" from list "${parsed.list}".`,
|
|
12195
|
+
structuredContent: {
|
|
12196
|
+
action: "removed",
|
|
12197
|
+
list: parsed.list,
|
|
12198
|
+
interface: parsed.interface,
|
|
12199
|
+
id: existing[".id"]
|
|
12200
|
+
}
|
|
12201
|
+
};
|
|
12202
|
+
} catch (err) {
|
|
12203
|
+
throw toolError(err, context, "manage_interface_list_member");
|
|
12204
|
+
}
|
|
12205
|
+
}
|
|
12206
|
+
};
|
|
12207
|
+
var interfaceListTools = [
|
|
12208
|
+
listInterfaceListsTool,
|
|
12209
|
+
manageInterfaceListTool,
|
|
12210
|
+
manageInterfaceListMemberTool
|
|
12211
|
+
];
|
|
12212
|
+
|
|
12213
|
+
// src/domain/tools/swos-tools.ts
|
|
12214
|
+
import { z as z46 } from "zod";
|
|
12215
|
+
var log46 = createLogger("swos-tools");
|
|
12216
|
+
var endpointEnum = z46.enum(SWOS_ENDPOINTS);
|
|
12217
|
+
var MAX_CONTENT_CHARS = 8e3;
|
|
12218
|
+
function requireSwosClient(context) {
|
|
12219
|
+
if (!context.swosClient) {
|
|
12220
|
+
throw new MikroMCPError({
|
|
12221
|
+
category: "VALIDATION" /* VALIDATION */,
|
|
12222
|
+
code: "PLATFORM_MISMATCH",
|
|
12223
|
+
message: `Router "${context.routerId}" is not configured as a SwOS switch (deviceType: "swos").`,
|
|
12224
|
+
recoverability: {
|
|
12225
|
+
retryable: false,
|
|
12226
|
+
suggestedAction: 'Set deviceType: "swos" for this device in routers.yaml, or target a switch.'
|
|
12227
|
+
}
|
|
12228
|
+
});
|
|
12229
|
+
}
|
|
12230
|
+
return context.swosClient;
|
|
12231
|
+
}
|
|
12232
|
+
async function readFirmwareSupport(client) {
|
|
12233
|
+
try {
|
|
12234
|
+
const sys = await client.get("sys.b");
|
|
12235
|
+
return firmwareSupport(sys.model, sys.version);
|
|
12236
|
+
} catch (err) {
|
|
12237
|
+
log46.warn({ err }, "Could not read sys.b for the firmware compatibility check");
|
|
12238
|
+
return {
|
|
12239
|
+
verified: false,
|
|
12240
|
+
note: `Firmware could not be read (${err instanceof Error ? err.message : String(err)}); compatibility unknown.`
|
|
12241
|
+
};
|
|
12242
|
+
}
|
|
12243
|
+
}
|
|
12244
|
+
var listEndpointsSchema = z46.object({
|
|
12245
|
+
routerId
|
|
12246
|
+
}).strict();
|
|
12247
|
+
var listSwosEndpointsTool = {
|
|
12248
|
+
name: "list_swos_endpoints",
|
|
12249
|
+
title: "List SwOS Endpoints",
|
|
12250
|
+
description: "List the SwOS/SwOS Lite '.b' API endpoints supported by this server, with the decoded field names per endpoint. Read-only schema introspection \u2014 no device call.",
|
|
12251
|
+
inputSchema: listEndpointsSchema,
|
|
12252
|
+
annotations: {
|
|
12253
|
+
readOnlyHint: true,
|
|
12254
|
+
destructiveHint: false,
|
|
12255
|
+
idempotentHint: true,
|
|
12256
|
+
openWorldHint: false
|
|
12257
|
+
},
|
|
12258
|
+
platform: "swos",
|
|
12259
|
+
async handler(params, context) {
|
|
12260
|
+
listEndpointsSchema.parse(params);
|
|
12261
|
+
const endpoints = SWOS_ENDPOINTS.map((ep) => ({
|
|
12262
|
+
endpoint: ep,
|
|
12263
|
+
fields: SWOS_SCHEMA[ep].fields.map((f2) => f2.name)
|
|
12264
|
+
}));
|
|
12265
|
+
const content = endpoints.map(
|
|
12266
|
+
(e) => `${e.endpoint}
|
|
12267
|
+
fields: ${e.fields.length > 0 ? e.fields.join(", ") : "(decoded generically per port)"}`
|
|
12268
|
+
).join("\n");
|
|
12269
|
+
return {
|
|
12270
|
+
content: `SwOS endpoints:
|
|
12271
|
+
${content}`,
|
|
12272
|
+
structuredContent: { routerId: context.routerId, endpoints }
|
|
12273
|
+
};
|
|
12274
|
+
}
|
|
12275
|
+
};
|
|
12276
|
+
var STATUS_ENDPOINTS = ["link.b", "sys.b", "poe.b", "sfp.b"];
|
|
12277
|
+
var getStatusSchema = z46.object({
|
|
12278
|
+
routerId,
|
|
12279
|
+
include: z46.array(z46.enum(STATUS_ENDPOINTS)).default([...STATUS_ENDPOINTS]).describe("Which status endpoints to fetch")
|
|
12280
|
+
}).strict();
|
|
12281
|
+
function sectionOf(sections, endpoint) {
|
|
12282
|
+
const section = sections[endpoint];
|
|
12283
|
+
return section && !("_error" in section) ? section : void 0;
|
|
12284
|
+
}
|
|
12285
|
+
function formatStatus(sections) {
|
|
12286
|
+
const lines = [];
|
|
12287
|
+
const sys = sectionOf(sections, "sys.b");
|
|
12288
|
+
if (sys) {
|
|
12289
|
+
const firmware = firmwareSupport(sys.model, sys.version);
|
|
12290
|
+
lines.push("Switch:");
|
|
12291
|
+
if (sys.identity) lines.push(` Identity: ${sys.identity}`);
|
|
12292
|
+
if (sys.model) lines.push(` Model: ${sys.model}`);
|
|
12293
|
+
if (sys.version) {
|
|
12294
|
+
lines.push(
|
|
12295
|
+
` Firmware: ${sys.version}${firmware.verified ? " (schema verified)" : " (schema not verified for this firmware \u2014 see the note on writes)"}`
|
|
12296
|
+
);
|
|
12297
|
+
}
|
|
12298
|
+
if (sys.serial) lines.push(` Serial: ${sys.serial}`);
|
|
12299
|
+
if (sys.uptime) lines.push(` Uptime: ${String(sys.uptime)}s`);
|
|
12300
|
+
if (sys.ip) lines.push(` IP: ${sys.ip}`);
|
|
12301
|
+
if (sys.mac) lines.push(` MAC: ${sys.mac}`);
|
|
12302
|
+
}
|
|
12303
|
+
const link = sectionOf(sections, "link.b");
|
|
12304
|
+
if (link) {
|
|
12305
|
+
const names = link.name;
|
|
12306
|
+
const speeds = link.speed;
|
|
12307
|
+
const links = link.link;
|
|
12308
|
+
const dups = link.duplex;
|
|
12309
|
+
const count = Math.max(names?.length ?? 0, speeds?.length ?? 0, links?.length ?? 0);
|
|
12310
|
+
lines.push("Ports:");
|
|
12311
|
+
for (let i = 0; i < count; i++) {
|
|
12312
|
+
const name = names?.[i] ?? `port${i + 1}`;
|
|
12313
|
+
const speed = speeds?.[i] ?? "?";
|
|
12314
|
+
const linkState = links?.[i] ? "up" : "down";
|
|
12315
|
+
const dup = dups?.[i] === void 0 ? "?" : dups[i] ? "full" : "half";
|
|
12316
|
+
lines.push(` ${String(name)}: ${linkState} (${String(speed)} ${dup})`);
|
|
12317
|
+
}
|
|
12318
|
+
}
|
|
12319
|
+
const poe = sectionOf(sections, "poe.b");
|
|
12320
|
+
if (poe) {
|
|
12321
|
+
const out = poe.out;
|
|
12322
|
+
const state = poe.state;
|
|
12323
|
+
const power = poe.power;
|
|
12324
|
+
const count = Math.max(out?.length ?? 0, state?.length ?? 0);
|
|
12325
|
+
lines.push("PoE:");
|
|
12326
|
+
for (let i = 0; i < count; i++) {
|
|
12327
|
+
const mode = out?.[i] ?? "?";
|
|
12328
|
+
const st = state?.[i] ?? "?";
|
|
12329
|
+
const pw = power?.[i] !== void 0 ? `${String(power[i])}W` : "";
|
|
12330
|
+
lines.push(` port${i + 1}: ${String(mode)} / ${String(st)} ${pw}`.trimEnd());
|
|
12331
|
+
}
|
|
12332
|
+
}
|
|
12333
|
+
const sfp = sectionOf(sections, "sfp.b");
|
|
12334
|
+
if (sfp) {
|
|
12335
|
+
const cages = Math.max(
|
|
12336
|
+
...["vendor", "part_number", "serial", "temperature"].map(
|
|
12337
|
+
(key2) => sfp[key2]?.length ?? 0
|
|
12338
|
+
)
|
|
12339
|
+
);
|
|
12340
|
+
if (cages > 0) {
|
|
12341
|
+
lines.push("SFP modules:");
|
|
12342
|
+
for (let i = 0; i < cages; i++) {
|
|
12343
|
+
const vendor = sfp.vendor?.[i];
|
|
12344
|
+
const part = sfp.part_number?.[i];
|
|
12345
|
+
if (!vendor && !part) continue;
|
|
12346
|
+
const serial = sfp.serial?.[i];
|
|
12347
|
+
const temp = sfp.temperature?.[i];
|
|
12348
|
+
lines.push(
|
|
12349
|
+
` sfp${i + 1}: ${String(vendor ?? "?")} ${String(part ?? "?")}${serial ? ` sn=${String(serial)}` : ""}${temp !== void 0 ? ` ${String(temp)}\xB0C` : ""}`
|
|
12350
|
+
);
|
|
12351
|
+
}
|
|
12352
|
+
}
|
|
12353
|
+
}
|
|
12354
|
+
const failed = Object.entries(sections).filter(([, v]) => v && typeof v === "object" && "_error" in v).map(([ep, v]) => ` ${ep}: ${v._error}`);
|
|
12355
|
+
if (failed.length > 0) lines.push("Sections that failed to load:", ...failed);
|
|
12356
|
+
return lines.length > 0 ? lines.join("\n") : "No status retrieved.";
|
|
12357
|
+
}
|
|
12358
|
+
var getSwosStatusTool = {
|
|
12359
|
+
name: "get_swos_status",
|
|
12360
|
+
title: "Get SwOS Status",
|
|
12361
|
+
description: "Retrieve status from a MikroTik SwOS switch (SwOS or SwOS Lite): identity, model, firmware, uptime, per-port link state/speed/duplex, PoE mode/state/power, and SFP modules.",
|
|
12362
|
+
inputSchema: getStatusSchema,
|
|
12363
|
+
annotations: {
|
|
12364
|
+
readOnlyHint: true,
|
|
12365
|
+
destructiveHint: false,
|
|
12366
|
+
idempotentHint: true,
|
|
12367
|
+
openWorldHint: false
|
|
12368
|
+
},
|
|
12369
|
+
platform: "swos",
|
|
12370
|
+
async handler(params, context) {
|
|
12371
|
+
const parsed = getStatusSchema.parse(params);
|
|
12372
|
+
const client = requireSwosClient(context);
|
|
12373
|
+
const sections = {};
|
|
12374
|
+
for (const ep of parsed.include) {
|
|
12375
|
+
try {
|
|
12376
|
+
sections[ep] = await client.get(ep);
|
|
12377
|
+
} catch (err) {
|
|
12378
|
+
log46.warn({ ep, err }, "SwOS status section failed, skipping");
|
|
12379
|
+
sections[ep] = { _error: err instanceof Error ? err.message : String(err) };
|
|
12380
|
+
}
|
|
12381
|
+
}
|
|
12382
|
+
const sys = sectionOf(sections, "sys.b");
|
|
12383
|
+
return {
|
|
12384
|
+
content: formatStatus(sections),
|
|
12385
|
+
structuredContent: {
|
|
12386
|
+
routerId: context.routerId,
|
|
12387
|
+
sections,
|
|
12388
|
+
...sys ? { compatibility: firmwareSupport(sys.model, sys.version) } : {}
|
|
12389
|
+
}
|
|
12390
|
+
};
|
|
12391
|
+
}
|
|
12392
|
+
};
|
|
12393
|
+
var getEndpointSchema = z46.object({
|
|
12394
|
+
routerId,
|
|
12395
|
+
endpoint: endpointEnum.describe("'.b' endpoint to fetch (see list_swos_endpoints)")
|
|
12396
|
+
}).strict();
|
|
12397
|
+
var getSwosEndpointTool = {
|
|
12398
|
+
name: "get_swos_endpoint",
|
|
12399
|
+
title: "Get SwOS Endpoint",
|
|
12400
|
+
description: "Fetch and decode a single SwOS '.b' endpoint (link.b, sys.b, poe.b, lacp.b, rstp.b, snmp.b, fwd.b, vlan.b, stats.b, sfp.b, host.b, acl.b, ...) as structured data. Unknown keys are preserved under '_raw'.",
|
|
12401
|
+
inputSchema: getEndpointSchema,
|
|
12402
|
+
annotations: {
|
|
12403
|
+
readOnlyHint: true,
|
|
12404
|
+
destructiveHint: false,
|
|
12405
|
+
idempotentHint: true,
|
|
12406
|
+
openWorldHint: false
|
|
12407
|
+
},
|
|
12408
|
+
platform: "swos",
|
|
12409
|
+
async handler(params, context) {
|
|
12410
|
+
const parsed = getEndpointSchema.parse(params);
|
|
12411
|
+
const client = requireSwosClient(context);
|
|
12412
|
+
try {
|
|
12413
|
+
const decoded = await client.get(parsed.endpoint);
|
|
12414
|
+
const rendered = JSON.stringify(decoded, null, 2);
|
|
12415
|
+
const content = rendered.length > MAX_CONTENT_CHARS ? `${rendered.slice(0, MAX_CONTENT_CHARS)}
|
|
12416
|
+
\u2026 truncated (${rendered.length} chars). Full decode is in structuredContent.` : rendered;
|
|
12417
|
+
return {
|
|
12418
|
+
content,
|
|
12419
|
+
structuredContent: { routerId: context.routerId, endpoint: parsed.endpoint, data: decoded }
|
|
12420
|
+
};
|
|
12421
|
+
} catch (err) {
|
|
12422
|
+
throw toolError(err, context, "get_swos_endpoint");
|
|
12423
|
+
}
|
|
12424
|
+
}
|
|
12425
|
+
};
|
|
12426
|
+
var WRITABLE_ENDPOINTS = SWOS_ENDPOINTS.filter(
|
|
12427
|
+
(ep) => !ep.startsWith("!") && !SWOS_SCHEMA[ep].isArray
|
|
12428
|
+
);
|
|
12429
|
+
var writeBlobSchema = z46.object({
|
|
12430
|
+
routerId,
|
|
12431
|
+
endpoint: z46.enum(WRITABLE_ENDPOINTS).describe("'.b' endpoint to write"),
|
|
12432
|
+
fields: z46.record(
|
|
12433
|
+
z46.string(),
|
|
12434
|
+
z46.union([
|
|
12435
|
+
z46.string(),
|
|
12436
|
+
z46.number(),
|
|
12437
|
+
z46.boolean(),
|
|
12438
|
+
z46.array(z46.union([z46.string(), z46.number(), z46.boolean()]))
|
|
12439
|
+
])
|
|
12440
|
+
).describe(
|
|
12441
|
+
"Values to set, keyed by decoded field name (from list_swos_endpoints) or by a raw wire key already present in the blob \u2014 anything else is rejected. Option fields take the enum name (e.g. 'auto'); text becomes a UTF-8 hex string; numbers and '0x...' strings are written as hex, padded to the width of the value they replace. A per-port field given a single value applies it to EVERY port."
|
|
12442
|
+
),
|
|
12443
|
+
dryRun: z46.boolean().default(true).describe("Preview the resulting blob without writing to the switch.")
|
|
12444
|
+
}).strict();
|
|
12445
|
+
function resolveField(epKey, key2) {
|
|
12446
|
+
const ep = SWOS_SCHEMA[epKey];
|
|
12447
|
+
if (!ep) return void 0;
|
|
12448
|
+
return ep.fields.find((field) => field.name === key2 || Object.values(field.keys).includes(key2));
|
|
12449
|
+
}
|
|
12450
|
+
function resolveWireKey(field, wire) {
|
|
12451
|
+
return Object.values(field.keys).find((key2) => key2 in wire);
|
|
12452
|
+
}
|
|
12453
|
+
function validationError(code, message, suggestedAction) {
|
|
12454
|
+
return new MikroMCPError({
|
|
12455
|
+
category: "VALIDATION" /* VALIDATION */,
|
|
12456
|
+
code,
|
|
12457
|
+
message,
|
|
12458
|
+
recoverability: { retryable: false, suggestedAction }
|
|
12459
|
+
});
|
|
12460
|
+
}
|
|
12461
|
+
function coerceValue(field, value, existing) {
|
|
12462
|
+
if (Array.isArray(value)) {
|
|
12463
|
+
const existingItems = Array.isArray(existing) ? existing : [];
|
|
12464
|
+
return value.map((v, i) => coerceScalar(field, v, existingItems[i]));
|
|
12465
|
+
}
|
|
12466
|
+
return coerceScalar(field, value, existing);
|
|
12467
|
+
}
|
|
12468
|
+
function coerceScalar(field, value, existing) {
|
|
12469
|
+
if (typeof value === "boolean") {
|
|
12470
|
+
if (field.kind === Kind.BOOL) {
|
|
12471
|
+
throw validationError(
|
|
12472
|
+
"SWOS_FIELD_SHAPE_MISMATCH",
|
|
12473
|
+
`Field "${field.name}" is a per-port bitmask, not a single flag.`,
|
|
12474
|
+
"Pass the mask as a number \u2014 e.g. 1023 for all 10 ports, 0 for none."
|
|
12475
|
+
);
|
|
12476
|
+
}
|
|
12477
|
+
return encodeLike(value ? 1 : 0, existing);
|
|
12478
|
+
}
|
|
12479
|
+
if (typeof value === "number") return encodeLike(value, existing);
|
|
12480
|
+
if (field.kind === Kind.OPTION) {
|
|
12481
|
+
const idx = (field.options ?? []).indexOf(String(value));
|
|
12482
|
+
if (idx < 0) {
|
|
12483
|
+
throw validationError(
|
|
12484
|
+
"SWOS_INVALID_OPTION",
|
|
12485
|
+
`Invalid option "${String(value)}" for field "${field.name}".`,
|
|
12486
|
+
`Use one of: ${(field.options ?? []).join(", ")}.`
|
|
12487
|
+
);
|
|
12488
|
+
}
|
|
12489
|
+
return encodeLike(idx, existing);
|
|
12490
|
+
}
|
|
12491
|
+
const text = String(value);
|
|
12492
|
+
return isWireHex(text) ? text : Buffer.from(text, "utf8").toString("hex");
|
|
12493
|
+
}
|
|
12494
|
+
function assertShapeMatches(perPort, key2, next, existing) {
|
|
12495
|
+
if (existing === void 0) return;
|
|
12496
|
+
if (Array.isArray(next) && Array.isArray(existing)) {
|
|
12497
|
+
if (next.length === existing.length) return;
|
|
12498
|
+
throw validationError(
|
|
12499
|
+
"SWOS_FIELD_SHAPE_MISMATCH",
|
|
12500
|
+
`Field "${key2}" holds ${existing.length} value(s) on the wire, but ${next.length} were given.`,
|
|
12501
|
+
`Pass exactly ${existing.length} value(s), or a single value to apply to all of them.`
|
|
12502
|
+
);
|
|
12503
|
+
}
|
|
12504
|
+
if (!Array.isArray(next) && !Array.isArray(existing)) return;
|
|
12505
|
+
if (!Array.isArray(next) && perPort) return;
|
|
12506
|
+
throw validationError(
|
|
12507
|
+
"SWOS_FIELD_SHAPE_MISMATCH",
|
|
12508
|
+
Array.isArray(next) ? `Field "${key2}" is a single per-port bitmask on the wire, but an array was given.` : `Field "${key2}" is a per-port array on the wire, but a single value was given.`,
|
|
12509
|
+
Array.isArray(next) ? "Pass the bitmask as one number (e.g. 1023 for all 10 ports)." : "Pass one value per port."
|
|
12510
|
+
);
|
|
12511
|
+
}
|
|
12512
|
+
var writeSwosBlobTool = {
|
|
12513
|
+
name: "write_swos_blob",
|
|
12514
|
+
title: "Write SwOS Blob",
|
|
12515
|
+
description: "Mutate a SwOS '.b' endpoint on a MikroTik switch. The full endpoint blob is read, the given fields are merged in, and the entire blob is written back (the firmware only accepts whole-blob writes); untouched fields are re-sent byte-for-byte, and a field this firmware does not expose is refused rather than injected. dryRun defaults to true \u2014 preview first. Every result reports whether the schema has been verified against the switch's firmware. The pre-write blob is snapshotted, so the change can be undone with rollback_change. Returns no_change when the values already match.",
|
|
12516
|
+
inputSchema: writeBlobSchema,
|
|
12517
|
+
annotations: {
|
|
12518
|
+
readOnlyHint: false,
|
|
12519
|
+
destructiveHint: true,
|
|
12520
|
+
idempotentHint: true,
|
|
12521
|
+
openWorldHint: false
|
|
12522
|
+
},
|
|
12523
|
+
platform: "swos",
|
|
12524
|
+
// The endpoint is an argument, so the snapshot path is too. The executor
|
|
12525
|
+
// captures the pre-write blob verbatim; rollback_change re-POSTs it.
|
|
12526
|
+
snapshotPaths: (params) => params.endpoint ? [String(params.endpoint)] : [],
|
|
12527
|
+
async handler(params, context) {
|
|
12528
|
+
const parsed = writeBlobSchema.parse(params);
|
|
12529
|
+
const client = requireSwosClient(context);
|
|
12530
|
+
try {
|
|
12531
|
+
const wire = await client.readBlobForWrite(parsed.endpoint);
|
|
12532
|
+
const changes = {};
|
|
12533
|
+
for (const [key2, value] of Object.entries(parsed.fields)) {
|
|
12534
|
+
const field = resolveField(parsed.endpoint, key2);
|
|
12535
|
+
const targetKey = field ? resolveWireKey(field, wire) : key2;
|
|
12536
|
+
if (targetKey === void 0 || !(targetKey in wire)) {
|
|
12537
|
+
throw validationError(
|
|
12538
|
+
"SWOS_UNKNOWN_FIELD",
|
|
12539
|
+
field ? `Field "${key2}" is not present in ${parsed.endpoint} on this device \u2014 its firmware does not expose it.` : `Unknown field "${key2}" for endpoint ${parsed.endpoint}.`,
|
|
12540
|
+
`Read the endpoint first; only keys the device actually sent can be written: ${Object.keys(wire).join(", ")}.`
|
|
12541
|
+
);
|
|
12542
|
+
}
|
|
12543
|
+
const existing = wire[targetKey];
|
|
12544
|
+
const next = field ? coerceValue(field, value, existing) : value;
|
|
12545
|
+
assertShapeMatches(field?.perPort ?? false, key2, next, existing);
|
|
12546
|
+
if (field?.perPort && !Array.isArray(next) && Array.isArray(existing)) {
|
|
12547
|
+
wire[targetKey] = existing.map(() => next);
|
|
12548
|
+
} else {
|
|
12549
|
+
wire[targetKey] = next;
|
|
12550
|
+
}
|
|
12551
|
+
changes[key2] = { from: existing, to: wire[targetKey] };
|
|
12552
|
+
}
|
|
12553
|
+
const modified = Object.entries(changes).filter(
|
|
12554
|
+
([, c]) => JSON.stringify(c.from) !== JSON.stringify(c.to)
|
|
12555
|
+
);
|
|
12556
|
+
if (modified.length === 0) {
|
|
12557
|
+
return {
|
|
12558
|
+
content: `No change: ${parsed.endpoint} on ${context.routerId} already has these values.`,
|
|
12559
|
+
structuredContent: {
|
|
12560
|
+
routerId: context.routerId,
|
|
12561
|
+
endpoint: parsed.endpoint,
|
|
12562
|
+
action: "no_change",
|
|
12563
|
+
changes
|
|
12564
|
+
}
|
|
12565
|
+
};
|
|
12566
|
+
}
|
|
12567
|
+
const diff = modified.map(([key2, c]) => ` ${key2}: ${JSON.stringify(c.from)} -> ${JSON.stringify(c.to)}`).join("\n");
|
|
12568
|
+
const preview = dumpBlob(wire);
|
|
12569
|
+
const firmware = await readFirmwareSupport(client);
|
|
12570
|
+
const unmapped = unmappedWireKeys(parsed.endpoint, wire);
|
|
12571
|
+
const banner = `${firmware.verified ? "Firmware" : "\u26A0\uFE0F Firmware"}: ${firmware.note}` + (unmapped.length > 0 ? `
|
|
12572
|
+
\u26A0\uFE0F ${unmapped.length} key(s) in ${parsed.endpoint} are not in the schema and are re-sent unchanged: ${unmapped.join(", ")}` : "");
|
|
12573
|
+
const compatibility = { ...firmware, unmappedKeys: unmapped };
|
|
12574
|
+
if (parsed.dryRun) {
|
|
12575
|
+
return {
|
|
12576
|
+
content: `DRY RUN \u2014 no write performed. ${modified.length} field(s) would change on ${parsed.endpoint}:
|
|
12577
|
+
${diff}
|
|
12578
|
+
|
|
12579
|
+
${banner}
|
|
12580
|
+
|
|
12581
|
+
Resulting blob:
|
|
12582
|
+
${preview}`,
|
|
12583
|
+
structuredContent: {
|
|
12584
|
+
routerId: context.routerId,
|
|
12585
|
+
endpoint: parsed.endpoint,
|
|
12586
|
+
action: "dry_run",
|
|
12587
|
+
dryRun: true,
|
|
12588
|
+
changes,
|
|
12589
|
+
compatibility,
|
|
12590
|
+
blob: preview
|
|
12591
|
+
}
|
|
12592
|
+
};
|
|
12593
|
+
}
|
|
12594
|
+
const response = await client.writeBlob(parsed.endpoint, wire);
|
|
12595
|
+
return {
|
|
12596
|
+
content: `Wrote ${parsed.endpoint} on ${context.routerId}:
|
|
12597
|
+
${diff}
|
|
12598
|
+
${banner}
|
|
12599
|
+
Response: ${response || "(empty)"}
|
|
12600
|
+
The pre-write blob was snapshotted \u2014 undo with rollback_change using this call's journal ID.`,
|
|
11023
12601
|
structuredContent: {
|
|
11024
|
-
|
|
11025
|
-
|
|
11026
|
-
|
|
11027
|
-
|
|
12602
|
+
routerId: context.routerId,
|
|
12603
|
+
endpoint: parsed.endpoint,
|
|
12604
|
+
action: "written",
|
|
12605
|
+
dryRun: false,
|
|
12606
|
+
changes,
|
|
12607
|
+
compatibility,
|
|
12608
|
+
response
|
|
11028
12609
|
}
|
|
11029
12610
|
};
|
|
11030
12611
|
} catch (err) {
|
|
11031
|
-
throw toolError(err, context, "
|
|
12612
|
+
throw toolError(err, context, "write_swos_blob");
|
|
11032
12613
|
}
|
|
11033
12614
|
}
|
|
11034
12615
|
};
|
|
11035
|
-
var
|
|
11036
|
-
|
|
11037
|
-
|
|
11038
|
-
|
|
12616
|
+
var swosTools = [
|
|
12617
|
+
listSwosEndpointsTool,
|
|
12618
|
+
getSwosStatusTool,
|
|
12619
|
+
getSwosEndpointTool,
|
|
12620
|
+
writeSwosBlobTool
|
|
11039
12621
|
];
|
|
11040
12622
|
|
|
11041
12623
|
// src/domain/tools/fleet-tools.ts
|
|
11042
|
-
import { z as
|
|
12624
|
+
import { z as z47 } from "zod";
|
|
11043
12625
|
|
|
11044
12626
|
// src/middleware/authz.ts
|
|
11045
12627
|
function matchesPattern(name, pattern) {
|
|
@@ -11121,7 +12703,7 @@ function getCredentials(router) {
|
|
|
11121
12703
|
}
|
|
11122
12704
|
|
|
11123
12705
|
// src/adapter/ssh-client.ts
|
|
11124
|
-
import { createHash } from "crypto";
|
|
12706
|
+
import { createHash as createHash2 } from "crypto";
|
|
11125
12707
|
import { Client } from "ssh2";
|
|
11126
12708
|
var SshClient = class {
|
|
11127
12709
|
constructor(config, credentials, options) {
|
|
@@ -11209,7 +12791,7 @@ var SshClient = class {
|
|
|
11209
12791
|
if (this.config.sshFingerprint) {
|
|
11210
12792
|
const expected = this.config.sshFingerprint.toLowerCase();
|
|
11211
12793
|
connectOptions.hostVerifier = (key2) => {
|
|
11212
|
-
const actual =
|
|
12794
|
+
const actual = createHash2("sha256").update(key2).digest("hex");
|
|
11213
12795
|
return actual === expected;
|
|
11214
12796
|
};
|
|
11215
12797
|
}
|
|
@@ -11221,7 +12803,7 @@ var SshClient = class {
|
|
|
11221
12803
|
// src/adapter/ftp-client.ts
|
|
11222
12804
|
import { Client as Client2 } from "basic-ftp";
|
|
11223
12805
|
import { Readable } from "stream";
|
|
11224
|
-
var
|
|
12806
|
+
var log47 = createLogger("ftp-client");
|
|
11225
12807
|
var FtpClient = class {
|
|
11226
12808
|
#host;
|
|
11227
12809
|
#port;
|
|
@@ -11234,7 +12816,7 @@ var FtpClient = class {
|
|
|
11234
12816
|
this.#password = password;
|
|
11235
12817
|
}
|
|
11236
12818
|
async upload(remoteName, content) {
|
|
11237
|
-
|
|
12819
|
+
log47.info({ host: this.#host, remoteName }, "Uploading file via FTP");
|
|
11238
12820
|
const client = new Client2();
|
|
11239
12821
|
try {
|
|
11240
12822
|
await client.access({
|
|
@@ -11244,7 +12826,7 @@ var FtpClient = class {
|
|
|
11244
12826
|
password: this.#password
|
|
11245
12827
|
});
|
|
11246
12828
|
await client.uploadFrom(Readable.from(Buffer.from(content, "utf-8")), remoteName);
|
|
11247
|
-
|
|
12829
|
+
log47.info({ host: this.#host, remoteName }, "FTP upload complete");
|
|
11248
12830
|
} finally {
|
|
11249
12831
|
client.close();
|
|
11250
12832
|
}
|
|
@@ -11266,7 +12848,7 @@ var FtpClient = class {
|
|
|
11266
12848
|
};
|
|
11267
12849
|
|
|
11268
12850
|
// src/adapter/sftp-client.ts
|
|
11269
|
-
import { createHash as
|
|
12851
|
+
import { createHash as createHash3 } from "crypto";
|
|
11270
12852
|
import { Client as Client3 } from "ssh2";
|
|
11271
12853
|
var SftpClient = class {
|
|
11272
12854
|
constructor(config, credentials) {
|
|
@@ -11308,7 +12890,7 @@ var SftpClient = class {
|
|
|
11308
12890
|
if (this.config.sshFingerprint) {
|
|
11309
12891
|
const expected = this.config.sshFingerprint.toLowerCase();
|
|
11310
12892
|
connectOptions.hostVerifier = (key2) => {
|
|
11311
|
-
const actual =
|
|
12893
|
+
const actual = createHash3("sha256").update(key2).digest("hex");
|
|
11312
12894
|
return actual === expected;
|
|
11313
12895
|
};
|
|
11314
12896
|
}
|
|
@@ -11335,8 +12917,21 @@ function createSftpClient(routerConfig) {
|
|
|
11335
12917
|
function buildRouterToolContext(args) {
|
|
11336
12918
|
const { routerConfig, correlationId, identity, pool, config, registry } = args;
|
|
11337
12919
|
const credentials = getCredentials(routerConfig);
|
|
12920
|
+
const deviceClient = pool.getClient(routerConfig, credentials);
|
|
12921
|
+
const swosClient = deviceClient instanceof SwosClient ? deviceClient : void 0;
|
|
12922
|
+
const restClient = swosClient ? void 0 : deviceClient;
|
|
11338
12923
|
return {
|
|
11339
|
-
|
|
12924
|
+
// Platform gating (tool-executor / bulk_execute) guarantees a RouterOS tool
|
|
12925
|
+
// never reaches a SwOS device, so `routerClient` is only ever dereferenced
|
|
12926
|
+
// when the pool actually returned a REST client. `deviceClient` carries the
|
|
12927
|
+
// honest type for platform-aware callers.
|
|
12928
|
+
routerClient: restClient ?? unavailableCapability(
|
|
12929
|
+
"ROUTEROS_CLIENT_UNAVAILABLE",
|
|
12930
|
+
`Router "${routerConfig.id}" is a SwOS switch and has no RouterOS REST API.`,
|
|
12931
|
+
"Use a swos_* tool, or target a RouterOS router."
|
|
12932
|
+
),
|
|
12933
|
+
deviceClient,
|
|
12934
|
+
swosClient,
|
|
11340
12935
|
routerId: routerConfig.id,
|
|
11341
12936
|
correlationId,
|
|
11342
12937
|
routerConfig,
|
|
@@ -11435,13 +13030,13 @@ function getStdioIdentity(stdioIdentityName, registry) {
|
|
|
11435
13030
|
}
|
|
11436
13031
|
|
|
11437
13032
|
// src/middleware/confirmation.ts
|
|
11438
|
-
import { createHmac, createHash as
|
|
13033
|
+
import { createHmac, createHash as createHash4, timingSafeEqual } from "crypto";
|
|
11439
13034
|
var CONFIRMATION_TTL_MS = 5 * 60 * 1e3;
|
|
11440
13035
|
var BYPASS_ROLES = ["admin", "superadmin"];
|
|
11441
13036
|
var usedTokens = /* @__PURE__ */ new Map();
|
|
11442
13037
|
function computeParamHash(params) {
|
|
11443
13038
|
const { confirmationToken: _, ...rest } = params;
|
|
11444
|
-
return
|
|
13039
|
+
return createHash4("sha256").update(JSON.stringify(rest)).digest("hex");
|
|
11445
13040
|
}
|
|
11446
13041
|
function sign(tool, routerId2, identityId, paramHash, expiresAtMs, secret) {
|
|
11447
13042
|
const payload = `${tool}|${routerId2}|${identityId}|${paramHash}|${expiresAtMs}`;
|
|
@@ -11756,7 +13351,7 @@ var CircuitBreaker = class {
|
|
|
11756
13351
|
};
|
|
11757
13352
|
|
|
11758
13353
|
// src/mcp/tool-executor.ts
|
|
11759
|
-
var
|
|
13354
|
+
var log48 = createLogger("tool-executor");
|
|
11760
13355
|
var AMBIGUOUS_WRITE_CATEGORIES = /* @__PURE__ */ new Set([
|
|
11761
13356
|
"ROUTER_TIMEOUT" /* ROUTER_TIMEOUT */,
|
|
11762
13357
|
"ROUTER_UNREACHABLE" /* ROUTER_UNREACHABLE */
|
|
@@ -11810,17 +13405,24 @@ function assertMaintenanceWindow(isDestructive, routerConfig, routerId2) {
|
|
|
11810
13405
|
}
|
|
11811
13406
|
}
|
|
11812
13407
|
function fleetUnavailable(what) {
|
|
11813
|
-
return
|
|
11814
|
-
|
|
11815
|
-
|
|
11816
|
-
|
|
11817
|
-
|
|
11818
|
-
|
|
11819
|
-
|
|
11820
|
-
|
|
11821
|
-
|
|
11822
|
-
|
|
11823
|
-
|
|
13408
|
+
return unavailableCapability(
|
|
13409
|
+
"FLEET_CONTEXT_UNAVAILABLE",
|
|
13410
|
+
`${what} is not available in a fleet-tool context (skipRouterContext). Target a specific router instead.`,
|
|
13411
|
+
"Use a router-scoped tool, or pass a routerId to operate on one router."
|
|
13412
|
+
);
|
|
13413
|
+
}
|
|
13414
|
+
function assertPlatformMatch(tool, routerConfig) {
|
|
13415
|
+
const toolPlatform = tool.platform ?? "routeros";
|
|
13416
|
+
const devicePlatform = routerConfig.deviceType ?? "routeros";
|
|
13417
|
+
if (toolPlatform === "any" || toolPlatform === devicePlatform) return;
|
|
13418
|
+
throw new MikroMCPError({
|
|
13419
|
+
category: "VALIDATION" /* VALIDATION */,
|
|
13420
|
+
code: "PLATFORM_MISMATCH",
|
|
13421
|
+
message: `Tool "${tool.name}" runs on ${toolPlatform} devices, but router "${routerConfig.id}" is a ${devicePlatform} device.`,
|
|
13422
|
+
details: { toolPlatform, devicePlatform },
|
|
13423
|
+
recoverability: {
|
|
13424
|
+
retryable: false,
|
|
13425
|
+
suggestedAction: toolPlatform === "swos" ? "Target a SwOS switch instead \u2014 check list_routers for the deviceType of each device." : "Target a RouterOS device instead \u2014 check list_routers for the deviceType of each device."
|
|
11824
13426
|
}
|
|
11825
13427
|
});
|
|
11826
13428
|
}
|
|
@@ -11835,6 +13437,7 @@ async function executeToolCall(tool, args, deps) {
|
|
|
11835
13437
|
if (tool.skipRouterContext) {
|
|
11836
13438
|
const fleetContext = {
|
|
11837
13439
|
routerClient: fleetUnavailable("routerClient"),
|
|
13440
|
+
deviceClient: fleetUnavailable("deviceClient"),
|
|
11838
13441
|
routerId: "",
|
|
11839
13442
|
correlationId,
|
|
11840
13443
|
routerConfig: fleetUnavailable("routerConfig"),
|
|
@@ -11855,6 +13458,7 @@ async function executeToolCall(tool, args, deps) {
|
|
|
11855
13458
|
args.routerId = routerId2;
|
|
11856
13459
|
checkAuthz(identity, tool.name, routerId2);
|
|
11857
13460
|
const routerConfig = registry.getRouter(routerId2);
|
|
13461
|
+
assertPlatformMatch(tool, routerConfig);
|
|
11858
13462
|
assertMaintenanceWindow(tool.annotations.destructiveHint, routerConfig, routerId2);
|
|
11859
13463
|
if (tool.annotations.destructiveHint && config.confirmationSecret) {
|
|
11860
13464
|
await checkConfirmation(tool.name, routerId2, args, identity, config.confirmationSecret);
|
|
@@ -11885,14 +13489,14 @@ async function executeToolCall(tool, args, deps) {
|
|
|
11885
13489
|
toolContext.circuitBreaker = cb;
|
|
11886
13490
|
const { confirmationToken: _ct, ...handlerArgs } = args;
|
|
11887
13491
|
const snapshotIds = [];
|
|
11888
|
-
if (
|
|
11889
|
-
for (const path of tool
|
|
13492
|
+
if (!tool.annotations.readOnlyHint) {
|
|
13493
|
+
for (const path of snapshotPathsFor(tool, handlerArgs)) {
|
|
11890
13494
|
try {
|
|
11891
|
-
const meta = await takeSnapshot(toolContext.
|
|
13495
|
+
const meta = await takeSnapshot(toolContext.deviceClient, routerId2, path, config.snapshotDir);
|
|
11892
13496
|
snapshotIds.push(meta.id);
|
|
11893
|
-
|
|
13497
|
+
log48.debug({ snapshotId: meta.id, path }, "Snapshot taken");
|
|
11894
13498
|
} catch (err) {
|
|
11895
|
-
|
|
13499
|
+
log48.warn({ err, path, routerId: routerId2 }, "Snapshot failed \u2014 proceeding without snapshot");
|
|
11896
13500
|
}
|
|
11897
13501
|
}
|
|
11898
13502
|
}
|
|
@@ -11933,7 +13537,7 @@ async function executeToolCall(tool, args, deps) {
|
|
|
11933
13537
|
durationMs: Date.now() - startMs
|
|
11934
13538
|
}, config.auditLogPath);
|
|
11935
13539
|
}
|
|
11936
|
-
|
|
13540
|
+
log48.info({ tool: tool.name, routerId: routerId2, correlationId }, "Tool executed successfully");
|
|
11937
13541
|
recordToolCall(tool.name, "success");
|
|
11938
13542
|
return formatToolResult(result);
|
|
11939
13543
|
} catch (err) {
|
|
@@ -11945,7 +13549,7 @@ async function executeToolCall(tool, args, deps) {
|
|
|
11945
13549
|
const failedRouterId = args.routerId;
|
|
11946
13550
|
if (failedRouterId) {
|
|
11947
13551
|
pool.removeClient(failedRouterId);
|
|
11948
|
-
|
|
13552
|
+
log48.info({ routerId: failedRouterId }, "Evicted pooled client after auth failure");
|
|
11949
13553
|
}
|
|
11950
13554
|
}
|
|
11951
13555
|
if (journalId) {
|
|
@@ -11975,7 +13579,7 @@ async function executeToolCall(tool, args, deps) {
|
|
|
11975
13579
|
durationMs: Date.now() - startMs
|
|
11976
13580
|
}, config.auditLogPath);
|
|
11977
13581
|
}
|
|
11978
|
-
|
|
13582
|
+
log48.error({ err: error, tool: tool.name, correlationId }, "Tool execution failed");
|
|
11979
13583
|
recordToolCall(tool.name, "error");
|
|
11980
13584
|
return formatError(error);
|
|
11981
13585
|
}
|
|
@@ -11983,7 +13587,7 @@ async function executeToolCall(tool, args, deps) {
|
|
|
11983
13587
|
}
|
|
11984
13588
|
|
|
11985
13589
|
// src/middleware/fleet-confirmation.ts
|
|
11986
|
-
import { createHmac as createHmac2, createHash as
|
|
13590
|
+
import { createHmac as createHmac2, createHash as createHash5, timingSafeEqual as timingSafeEqual2 } from "crypto";
|
|
11987
13591
|
var CONFIRMATION_TTL_MS2 = 5 * 60 * 1e3;
|
|
11988
13592
|
var usedTokens2 = /* @__PURE__ */ new Map();
|
|
11989
13593
|
function fingerprint(args) {
|
|
@@ -11994,7 +13598,7 @@ function fingerprint(args) {
|
|
|
11994
13598
|
params: args.params,
|
|
11995
13599
|
identityId: args.identityId
|
|
11996
13600
|
});
|
|
11997
|
-
return
|
|
13601
|
+
return createHash5("sha256").update(payload).digest("hex");
|
|
11998
13602
|
}
|
|
11999
13603
|
function sign2(fp, expiresAtMs, secret) {
|
|
12000
13604
|
const mac = createHmac2("sha256", secret).update(`${fp}|${expiresAtMs}`).digest("hex");
|
|
@@ -12053,22 +13657,29 @@ function checkFleetConfirmation(args, secret) {
|
|
|
12053
13657
|
}
|
|
12054
13658
|
|
|
12055
13659
|
// src/domain/tools/fleet-tools.ts
|
|
12056
|
-
var
|
|
12057
|
-
|
|
13660
|
+
var log49 = createLogger("fleet-tools");
|
|
13661
|
+
function swosResourceRecord(sys) {
|
|
13662
|
+
const decoded = sys ?? {};
|
|
13663
|
+
return {
|
|
13664
|
+
version: String(decoded.version ?? ""),
|
|
13665
|
+
uptime: `${String(decoded.uptime ?? 0)}s`
|
|
13666
|
+
};
|
|
13667
|
+
}
|
|
13668
|
+
var checkHealthInputSchema = z47.object({
|
|
12058
13669
|
routerId
|
|
12059
13670
|
}).strict();
|
|
12060
|
-
var bulkExecuteInputSchema =
|
|
12061
|
-
toolName:
|
|
12062
|
-
routerIds:
|
|
12063
|
-
tags:
|
|
12064
|
-
params:
|
|
12065
|
-
concurrency:
|
|
12066
|
-
confirmationToken:
|
|
13671
|
+
var bulkExecuteInputSchema = z47.object({
|
|
13672
|
+
toolName: z47.string().describe("Name of the tool to fan out (must be a single-router tool)"),
|
|
13673
|
+
routerIds: z47.array(z47.string()).optional().describe("Explicit list of router IDs to target"),
|
|
13674
|
+
tags: z47.array(z47.string()).optional().describe("Target all routers with ALL of these tags (mutually exclusive with routerIds)"),
|
|
13675
|
+
params: z47.record(z47.string(), z47.unknown()).describe("Params to pass to the tool (omit routerId \u2014 injected per router)"),
|
|
13676
|
+
concurrency: z47.number().int().min(1).max(20).default(5).describe("Max simultaneous router calls"),
|
|
13677
|
+
confirmationToken: z47.string().optional().describe(
|
|
12067
13678
|
"Fleet confirmation token from a prior APPROVAL_REQUIRED response. Required to fan out a destructive tool."
|
|
12068
13679
|
)
|
|
12069
13680
|
}).strict();
|
|
12070
|
-
var listRoutersInputSchema =
|
|
12071
|
-
tags:
|
|
13681
|
+
var listRoutersInputSchema = z47.object({
|
|
13682
|
+
tags: z47.array(z47.string()).optional().describe('Only return routers having any of these tags (e.g. ["edge", "prod"])')
|
|
12072
13683
|
}).strict();
|
|
12073
13684
|
var listRoutersTool = {
|
|
12074
13685
|
name: "list_routers",
|
|
@@ -12086,7 +13697,7 @@ var listRoutersTool = {
|
|
|
12086
13697
|
const parsed = listRoutersInputSchema.parse(params);
|
|
12087
13698
|
const { identity, appConfig } = context;
|
|
12088
13699
|
const defaultRouter = appConfig.defaultRouter;
|
|
12089
|
-
|
|
13700
|
+
log49.info({ tags: parsed.tags, identityId: identity.id }, "Listing routers");
|
|
12090
13701
|
let routers = context.routerRegistry.listRouters(parsed.tags);
|
|
12091
13702
|
if (identity.allowedRouters.length > 0) {
|
|
12092
13703
|
routers = routers.filter((r) => identity.allowedRouters.includes(r.id));
|
|
@@ -12096,6 +13707,7 @@ var listRoutersTool = {
|
|
|
12096
13707
|
id: r.id,
|
|
12097
13708
|
host: r.host,
|
|
12098
13709
|
port: r.port,
|
|
13710
|
+
deviceType: r.deviceType ?? "routeros",
|
|
12099
13711
|
tlsEnabled: r.tls.enabled,
|
|
12100
13712
|
tags: r.tags,
|
|
12101
13713
|
rosVersion: r.rosVersion,
|
|
@@ -12106,7 +13718,7 @@ var listRoutersTool = {
|
|
|
12106
13718
|
const lines = rows.map(
|
|
12107
13719
|
(r) => ` ${compactFields(
|
|
12108
13720
|
{ ...r, tags: r.tags.join(",") },
|
|
12109
|
-
["id", "host", "port", "tlsEnabled", "tags", "rosVersion", "isDefault"]
|
|
13721
|
+
["id", "host", "port", "deviceType", "tlsEnabled", "tags", "rosVersion", "isDefault"]
|
|
12110
13722
|
)}`
|
|
12111
13723
|
);
|
|
12112
13724
|
return {
|
|
@@ -12120,7 +13732,7 @@ function createFleetTools(baseTools2) {
|
|
|
12120
13732
|
const checkRouterHealthTool = {
|
|
12121
13733
|
name: "check_router_health",
|
|
12122
13734
|
title: "Check Router Health",
|
|
12123
|
-
description: "Probe a
|
|
13735
|
+
description: "Probe a device: RouterOS routers via system/resource, SwOS switches via sys.b. Returns health status, firmware version, uptime, and (RouterOS only) CPU load and memory info. Unlike other tools, this never throws \u2014 unreachable devices are reported as healthy=false.",
|
|
12124
13736
|
inputSchema: checkHealthInputSchema,
|
|
12125
13737
|
annotations: {
|
|
12126
13738
|
readOnlyHint: true,
|
|
@@ -12128,13 +13740,13 @@ function createFleetTools(baseTools2) {
|
|
|
12128
13740
|
idempotentHint: true,
|
|
12129
13741
|
openWorldHint: false
|
|
12130
13742
|
},
|
|
13743
|
+
platform: "any",
|
|
12131
13744
|
async handler(params, context) {
|
|
12132
13745
|
checkHealthInputSchema.parse(params);
|
|
12133
|
-
|
|
13746
|
+
log49.info({ routerId: context.routerId }, "Checking router health");
|
|
12134
13747
|
const startMs = Date.now();
|
|
12135
13748
|
try {
|
|
12136
|
-
const
|
|
12137
|
-
const resource = resources[0];
|
|
13749
|
+
const resource = context.swosClient ? swosResourceRecord(await context.swosClient.get("sys.b")) : (await context.routerClient.get("system/resource"))[0];
|
|
12138
13750
|
const latencyMs = Date.now() - startMs;
|
|
12139
13751
|
const result = {
|
|
12140
13752
|
routerId: context.routerId,
|
|
@@ -12192,7 +13804,7 @@ function createFleetTools(baseTools2) {
|
|
|
12192
13804
|
}
|
|
12193
13805
|
});
|
|
12194
13806
|
}
|
|
12195
|
-
|
|
13807
|
+
log49.info(
|
|
12196
13808
|
{ toolName: parsed.toolName, concurrency: parsed.concurrency },
|
|
12197
13809
|
"bulk_execute invoked"
|
|
12198
13810
|
);
|
|
@@ -12239,7 +13851,7 @@ function createFleetTools(baseTools2) {
|
|
|
12239
13851
|
try {
|
|
12240
13852
|
resolved.push(context.routerRegistry.getRouter(id));
|
|
12241
13853
|
} catch {
|
|
12242
|
-
|
|
13854
|
+
log49.warn({ routerId: id }, "bulk_execute: router not found");
|
|
12243
13855
|
preErrors.push({
|
|
12244
13856
|
routerId: id,
|
|
12245
13857
|
status: "error",
|
|
@@ -12309,6 +13921,7 @@ function createFleetTools(baseTools2) {
|
|
|
12309
13921
|
let journalId;
|
|
12310
13922
|
try {
|
|
12311
13923
|
checkAuthz(context.identity, parsed.toolName, router.id);
|
|
13924
|
+
assertPlatformMatch(targetTool, router);
|
|
12312
13925
|
assertMaintenanceWindow(targetTool.annotations.destructiveHint, router, router.id);
|
|
12313
13926
|
const routerContext = buildRouterToolContext({
|
|
12314
13927
|
routerConfig: router,
|
|
@@ -12320,18 +13933,18 @@ function createFleetTools(baseTools2) {
|
|
|
12320
13933
|
});
|
|
12321
13934
|
const toolParams = { ...parsed.params, routerId: router.id };
|
|
12322
13935
|
const snapshotIds = [];
|
|
12323
|
-
if (isWrite
|
|
12324
|
-
for (const path of targetTool
|
|
13936
|
+
if (isWrite) {
|
|
13937
|
+
for (const path of snapshotPathsFor(targetTool, toolParams)) {
|
|
12325
13938
|
try {
|
|
12326
13939
|
const meta = await takeSnapshot(
|
|
12327
|
-
routerContext.
|
|
13940
|
+
routerContext.deviceClient,
|
|
12328
13941
|
router.id,
|
|
12329
13942
|
path,
|
|
12330
13943
|
snapshotDir
|
|
12331
13944
|
);
|
|
12332
13945
|
snapshotIds.push(meta.id);
|
|
12333
13946
|
} catch (err) {
|
|
12334
|
-
|
|
13947
|
+
log49.warn(
|
|
12335
13948
|
{ err, path, routerId: router.id },
|
|
12336
13949
|
"bulk_execute snapshot failed \u2014 proceeding without snapshot"
|
|
12337
13950
|
);
|
|
@@ -12472,7 +14085,8 @@ var baseTools = [
|
|
|
12472
14085
|
...logTools,
|
|
12473
14086
|
...networkTestTools,
|
|
12474
14087
|
...containerConfigTools,
|
|
12475
|
-
...interfaceListTools
|
|
14088
|
+
...interfaceListTools,
|
|
14089
|
+
...swosTools
|
|
12476
14090
|
];
|
|
12477
14091
|
var allTools = [
|
|
12478
14092
|
...baseTools,
|
|
@@ -12481,12 +14095,12 @@ var allTools = [
|
|
|
12481
14095
|
];
|
|
12482
14096
|
|
|
12483
14097
|
// src/mcp/tool-registry.ts
|
|
12484
|
-
var
|
|
14098
|
+
var log50 = createLogger("tool-registry");
|
|
12485
14099
|
function registerAllTools(server, registry, pool, circuitBreakers, config, identityRegistry) {
|
|
12486
14100
|
const deps = { registry, pool, circuitBreakers, config, identityRegistry };
|
|
12487
14101
|
for (const tool of allTools) {
|
|
12488
14102
|
const registrationSchema = tool.annotations.destructiveHint ? tool.inputSchema.extend({
|
|
12489
|
-
confirmationToken:
|
|
14103
|
+
confirmationToken: z48.string().optional().describe(
|
|
12490
14104
|
"Token from a prior APPROVAL_REQUIRED response. Re-submit the identical call with this token to confirm the destructive action."
|
|
12491
14105
|
)
|
|
12492
14106
|
}) : tool.inputSchema;
|
|
@@ -12500,59 +14114,93 @@ function registerAllTools(server, registry, pool, circuitBreakers, config, ident
|
|
|
12500
14114
|
},
|
|
12501
14115
|
(args) => executeToolCall(tool, args, deps)
|
|
12502
14116
|
);
|
|
12503
|
-
|
|
14117
|
+
log50.debug({ tool: tool.name }, "Registered tool");
|
|
12504
14118
|
}
|
|
12505
|
-
|
|
14119
|
+
log50.info({ count: allTools.length }, "All tools registered");
|
|
12506
14120
|
}
|
|
12507
14121
|
|
|
12508
14122
|
// src/config/router-registry.ts
|
|
12509
|
-
import { readFileSync as
|
|
14123
|
+
import { readFileSync as readFileSync3, existsSync } from "fs";
|
|
12510
14124
|
import { parse } from "yaml";
|
|
12511
|
-
import { z as
|
|
12512
|
-
var
|
|
12513
|
-
var
|
|
12514
|
-
|
|
12515
|
-
|
|
12516
|
-
|
|
12517
|
-
|
|
12518
|
-
|
|
12519
|
-
|
|
12520
|
-
|
|
12521
|
-
|
|
12522
|
-
|
|
12523
|
-
|
|
12524
|
-
|
|
12525
|
-
|
|
14125
|
+
import { z as z49 } from "zod";
|
|
14126
|
+
var log51 = createLogger("router-registry");
|
|
14127
|
+
var DEVICE_TYPE_ALIASES = {
|
|
14128
|
+
routeros: "routeros",
|
|
14129
|
+
swos: "swos",
|
|
14130
|
+
"swos-lite": "swos"
|
|
14131
|
+
};
|
|
14132
|
+
var RouterConfigSchema = z49.object({
|
|
14133
|
+
host: z49.string().min(1, "host is required"),
|
|
14134
|
+
port: z49.number().int().min(1).max(65535),
|
|
14135
|
+
deviceType: z49.enum(Object.keys(DEVICE_TYPE_ALIASES)).optional(),
|
|
14136
|
+
tls: z49.object({
|
|
14137
|
+
enabled: z49.boolean(),
|
|
14138
|
+
rejectUnauthorized: z49.boolean(),
|
|
14139
|
+
ca: z49.string().optional(),
|
|
14140
|
+
fingerprint: z49.string().optional()
|
|
14141
|
+
}).strict().optional(),
|
|
14142
|
+
credentials: z49.object({
|
|
14143
|
+
source: z49.enum(["env", "vault"]),
|
|
14144
|
+
envPrefix: z49.string().optional(),
|
|
14145
|
+
vaultPath: z49.string().optional()
|
|
12526
14146
|
}).strict(),
|
|
12527
|
-
tags:
|
|
12528
|
-
rosVersion:
|
|
12529
|
-
sshPort:
|
|
12530
|
-
sshFingerprint:
|
|
12531
|
-
cmdAllow:
|
|
12532
|
-
cmdDeny:
|
|
12533
|
-
maintenanceWindows:
|
|
12534
|
-
|
|
12535
|
-
days:
|
|
12536
|
-
|
|
14147
|
+
tags: z49.array(z49.string()).default([]),
|
|
14148
|
+
rosVersion: z49.string().min(1).optional(),
|
|
14149
|
+
sshPort: z49.number().int().min(1).max(65535).optional(),
|
|
14150
|
+
sshFingerprint: z49.string().optional(),
|
|
14151
|
+
cmdAllow: z49.array(z49.string()).optional(),
|
|
14152
|
+
cmdDeny: z49.array(z49.string()).optional(),
|
|
14153
|
+
maintenanceWindows: z49.array(
|
|
14154
|
+
z49.object({
|
|
14155
|
+
days: z49.array(
|
|
14156
|
+
z49.enum(["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"])
|
|
12537
14157
|
),
|
|
12538
|
-
startTime:
|
|
12539
|
-
endTime:
|
|
12540
|
-
timezone:
|
|
14158
|
+
startTime: z49.string().regex(/^\d{2}:\d{2}$/, "startTime must be HH:MM"),
|
|
14159
|
+
endTime: z49.string().regex(/^\d{2}:\d{2}$/, "endTime must be HH:MM"),
|
|
14160
|
+
timezone: z49.string().min(1)
|
|
12541
14161
|
}).strict()
|
|
12542
14162
|
).optional()
|
|
12543
|
-
}).strict()
|
|
12544
|
-
|
|
12545
|
-
|
|
14163
|
+
}).strict().superRefine((config, ctx) => {
|
|
14164
|
+
if (deviceTypeOf(config.deviceType) === "routeros") {
|
|
14165
|
+
if (!config.tls) {
|
|
14166
|
+
ctx.addIssue({
|
|
14167
|
+
code: z49.ZodIssueCode.custom,
|
|
14168
|
+
path: ["tls"],
|
|
14169
|
+
message: "tls is required for RouterOS devices"
|
|
14170
|
+
});
|
|
14171
|
+
}
|
|
14172
|
+
if (!config.rosVersion) {
|
|
14173
|
+
ctx.addIssue({
|
|
14174
|
+
code: z49.ZodIssueCode.custom,
|
|
14175
|
+
path: ["rosVersion"],
|
|
14176
|
+
message: "rosVersion is required for RouterOS devices"
|
|
14177
|
+
});
|
|
14178
|
+
}
|
|
14179
|
+
return;
|
|
14180
|
+
}
|
|
14181
|
+
if (config.tls?.enabled) {
|
|
14182
|
+
ctx.addIssue({
|
|
14183
|
+
code: z49.ZodIssueCode.custom,
|
|
14184
|
+
path: ["tls", "enabled"],
|
|
14185
|
+
message: "SwOS devices speak plain HTTP only; tls.enabled must be false"
|
|
14186
|
+
});
|
|
14187
|
+
}
|
|
14188
|
+
});
|
|
14189
|
+
function deviceTypeOf(configured) {
|
|
14190
|
+
return configured ? DEVICE_TYPE_ALIASES[configured] : "routeros";
|
|
14191
|
+
}
|
|
14192
|
+
var ConfigFileSchema = z49.object({
|
|
14193
|
+
routers: z49.record(z49.string(), RouterConfigSchema)
|
|
12546
14194
|
}).strict();
|
|
12547
14195
|
var RouterRegistry = class {
|
|
12548
14196
|
routers;
|
|
12549
14197
|
constructor(configPath) {
|
|
12550
14198
|
this.routers = /* @__PURE__ */ new Map();
|
|
12551
14199
|
if (!existsSync(configPath)) {
|
|
12552
|
-
|
|
14200
|
+
log51.warn({ configPath }, "Router config file not found; starting with empty registry");
|
|
12553
14201
|
return;
|
|
12554
14202
|
}
|
|
12555
|
-
const raw =
|
|
14203
|
+
const raw = readFileSync3(configPath, "utf-8");
|
|
12556
14204
|
const parsed = parse(raw);
|
|
12557
14205
|
const result = ConfigFileSchema.safeParse(parsed);
|
|
12558
14206
|
if (!result.success) {
|
|
@@ -12561,16 +14209,23 @@ var RouterRegistry = class {
|
|
|
12561
14209
|
${issues}`);
|
|
12562
14210
|
}
|
|
12563
14211
|
for (const [id, config] of Object.entries(result.data.routers)) {
|
|
12564
|
-
const
|
|
14212
|
+
const deviceType = deviceTypeOf(config.deviceType);
|
|
14213
|
+
const routerConfig = {
|
|
14214
|
+
...config,
|
|
14215
|
+
id,
|
|
14216
|
+
deviceType,
|
|
14217
|
+
tls: config.tls ?? { enabled: false, rejectUnauthorized: true },
|
|
14218
|
+
rosVersion: config.rosVersion ?? "swos"
|
|
14219
|
+
};
|
|
12565
14220
|
if (routerConfig.tls.enabled && !routerConfig.tls.rejectUnauthorized) {
|
|
12566
|
-
|
|
14221
|
+
log51.warn(
|
|
12567
14222
|
{ routerId: id },
|
|
12568
14223
|
"TLS certificate validation is DISABLED (rejectUnauthorized=false). Set tls.fingerprint to pin the server certificate instead."
|
|
12569
14224
|
);
|
|
12570
14225
|
}
|
|
12571
14226
|
this.routers.set(id, routerConfig);
|
|
12572
14227
|
}
|
|
12573
|
-
|
|
14228
|
+
log51.info({ count: this.routers.size }, "Loaded routers from config");
|
|
12574
14229
|
}
|
|
12575
14230
|
getRouter(id) {
|
|
12576
14231
|
const router = this.routers.get(id);
|
|
@@ -12598,219 +14253,20 @@ ${issues}`);
|
|
|
12598
14253
|
};
|
|
12599
14254
|
|
|
12600
14255
|
// src/adapter/connection-pool.ts
|
|
12601
|
-
import { createHash as
|
|
12602
|
-
|
|
12603
|
-
|
|
12604
|
-
import { request, Agent as Agent2 } from "undici";
|
|
12605
|
-
|
|
12606
|
-
// src/adapter/tls-manager.ts
|
|
12607
|
-
import { readFileSync as readFileSync3 } from "fs";
|
|
12608
|
-
import { buildConnector } from "undici";
|
|
12609
|
-
function makePinnedConnector(inner, expectedFingerprint) {
|
|
12610
|
-
const expected = expectedFingerprint.replace(/:/g, "").toLowerCase();
|
|
12611
|
-
return (opts, cb) => {
|
|
12612
|
-
inner(opts, (err, socket) => {
|
|
12613
|
-
if (err || !socket) {
|
|
12614
|
-
cb(err ?? new Error("TLS connection failed"), null);
|
|
12615
|
-
return;
|
|
12616
|
-
}
|
|
12617
|
-
const peer = socket;
|
|
12618
|
-
const cert = typeof peer.getPeerCertificate === "function" ? peer.getPeerCertificate() : void 0;
|
|
12619
|
-
const actual = (cert?.fingerprint256 ?? "").replace(/:/g, "").toLowerCase();
|
|
12620
|
-
if (actual !== expected) {
|
|
12621
|
-
socket.destroy();
|
|
12622
|
-
cb(
|
|
12623
|
-
new Error(
|
|
12624
|
-
`TLS certificate fingerprint mismatch. Expected: ${expected}, got: ${actual || "none"}`
|
|
12625
|
-
),
|
|
12626
|
-
null
|
|
12627
|
-
);
|
|
12628
|
-
return;
|
|
12629
|
-
}
|
|
12630
|
-
cb(null, socket);
|
|
12631
|
-
});
|
|
12632
|
-
};
|
|
12633
|
-
}
|
|
12634
|
-
function buildAgentOptions(tls) {
|
|
12635
|
-
if (!tls.enabled) {
|
|
12636
|
-
return {};
|
|
12637
|
-
}
|
|
12638
|
-
const connectOptions = {
|
|
12639
|
-
rejectUnauthorized: tls.rejectUnauthorized
|
|
12640
|
-
};
|
|
12641
|
-
if (tls.ca) {
|
|
12642
|
-
connectOptions.ca = readFileSync3(tls.ca, "utf-8");
|
|
12643
|
-
}
|
|
12644
|
-
if (tls.fingerprint) {
|
|
12645
|
-
const inner = buildConnector(connectOptions);
|
|
12646
|
-
return { connect: makePinnedConnector(inner, tls.fingerprint) };
|
|
12647
|
-
}
|
|
12648
|
-
return {
|
|
12649
|
-
connect: connectOptions
|
|
12650
|
-
};
|
|
12651
|
-
}
|
|
12652
|
-
|
|
12653
|
-
// src/adapter/query-builder.ts
|
|
12654
|
-
function buildListQuery(options) {
|
|
12655
|
-
if (!options) {
|
|
12656
|
-
return { method: "GET" };
|
|
12657
|
-
}
|
|
12658
|
-
const hasFilter = options.filter && Object.keys(options.filter).length > 0;
|
|
12659
|
-
const hasProplist = options.proplist && options.proplist.length > 0;
|
|
12660
|
-
if (hasProplist) {
|
|
12661
|
-
const body = {};
|
|
12662
|
-
if (hasProplist) {
|
|
12663
|
-
body[".proplist"] = options.proplist.join(",");
|
|
12664
|
-
}
|
|
12665
|
-
if (hasFilter) {
|
|
12666
|
-
const queryParts = [];
|
|
12667
|
-
for (const [key2, value] of Object.entries(options.filter)) {
|
|
12668
|
-
queryParts.push(`${key2}=${value}`);
|
|
12669
|
-
}
|
|
12670
|
-
body[".query"] = queryParts;
|
|
12671
|
-
}
|
|
12672
|
-
return { method: "POST", body };
|
|
12673
|
-
}
|
|
12674
|
-
if (hasFilter) {
|
|
12675
|
-
const queryParams = {};
|
|
12676
|
-
for (const [key2, value] of Object.entries(options.filter)) {
|
|
12677
|
-
queryParams[key2] = value;
|
|
12678
|
-
}
|
|
12679
|
-
return { method: "GET", queryParams };
|
|
12680
|
-
}
|
|
12681
|
-
return { method: "GET" };
|
|
12682
|
-
}
|
|
12683
|
-
function applyPagination(items, limit2, offset2) {
|
|
12684
|
-
const total = items.length;
|
|
12685
|
-
const start = offset2 ?? 0;
|
|
12686
|
-
const sliced = limit2 !== void 0 ? items.slice(start, start + limit2) : items.slice(start);
|
|
12687
|
-
return {
|
|
12688
|
-
items: sliced,
|
|
12689
|
-
total,
|
|
12690
|
-
hasMore: start + sliced.length < total
|
|
12691
|
-
};
|
|
14256
|
+
import { createHash as createHash6 } from "crypto";
|
|
14257
|
+
function credentialHash(credentials) {
|
|
14258
|
+
return createHash6("sha256").update(`${credentials.username}\0${credentials.password}`).digest("hex");
|
|
12692
14259
|
}
|
|
12693
|
-
|
|
12694
|
-
|
|
12695
|
-
|
|
12696
|
-
statusCode;
|
|
12697
|
-
responseBody;
|
|
12698
|
-
constructor(statusCode, responseBody) {
|
|
12699
|
-
super(`HTTP ${statusCode}: ${responseBody}`);
|
|
12700
|
-
this.name = "HttpError";
|
|
12701
|
-
this.statusCode = statusCode;
|
|
12702
|
-
this.responseBody = responseBody;
|
|
12703
|
-
}
|
|
12704
|
-
};
|
|
12705
|
-
var REQUEST_TIMEOUT_MS = 3e4;
|
|
12706
|
-
var RouterOSRestClient = class {
|
|
12707
|
-
baseUrl;
|
|
12708
|
-
authHeader;
|
|
12709
|
-
agent;
|
|
12710
|
-
constructor(config, credentials) {
|
|
12711
|
-
const scheme = config.tls.enabled ? "https" : "http";
|
|
12712
|
-
this.baseUrl = `${scheme}://${config.host}:${config.port}/rest`;
|
|
12713
|
-
this.authHeader = "Basic " + Buffer.from(`${credentials.username}:${credentials.password}`).toString("base64");
|
|
12714
|
-
this.agent = new Agent2(buildAgentOptions(config.tls));
|
|
12715
|
-
}
|
|
12716
|
-
// ---------- public CRUD methods ----------
|
|
12717
|
-
/**
|
|
12718
|
-
* List resources at the given path.
|
|
12719
|
-
*
|
|
12720
|
-
* Applies filtering via GET query params or POST body depending on the
|
|
12721
|
-
* query complexity, then runs client-side pagination.
|
|
12722
|
-
*/
|
|
12723
|
-
async get(path, options) {
|
|
12724
|
-
const query = buildListQuery(options);
|
|
12725
|
-
let raw;
|
|
12726
|
-
if (query.method === "POST") {
|
|
12727
|
-
raw = await this.doRequest("POST", `${this.baseUrl}/${path}`, query.body);
|
|
12728
|
-
} else {
|
|
12729
|
-
const qs = query.queryParams ? "?" + new URLSearchParams(query.queryParams).toString() : "";
|
|
12730
|
-
raw = await this.doRequest("GET", `${this.baseUrl}/${path}${qs}`);
|
|
12731
|
-
}
|
|
12732
|
-
const rawArray = Array.isArray(raw) ? raw : [raw];
|
|
12733
|
-
const parsed = parseRecords(rawArray);
|
|
12734
|
-
if (options?.limit !== void 0 || options?.offset !== void 0) {
|
|
12735
|
-
const page = applyPagination(parsed, options?.limit, options?.offset);
|
|
12736
|
-
return page.items;
|
|
12737
|
-
}
|
|
12738
|
-
return parsed;
|
|
12739
|
-
}
|
|
12740
|
-
/** Fetch a single resource by its RouterOS `.id`. */
|
|
12741
|
-
async getOne(path, id) {
|
|
12742
|
-
const raw = await this.doRequest("GET", `${this.baseUrl}/${path}/${id}`);
|
|
12743
|
-
return parseRecord(raw);
|
|
12744
|
-
}
|
|
12745
|
-
/** Create a new resource (PUT). Returns the created record including `.id`. */
|
|
12746
|
-
async create(path, data) {
|
|
12747
|
-
const raw = await this.doRequest("PUT", `${this.baseUrl}/${path}`, data);
|
|
12748
|
-
return raw;
|
|
12749
|
-
}
|
|
12750
|
-
/** Update an existing resource by `.id` (PATCH). */
|
|
12751
|
-
async update(path, id, data) {
|
|
12752
|
-
await this.doRequest("PATCH", `${this.baseUrl}/${path}/${id}`, data);
|
|
12753
|
-
}
|
|
12754
|
-
/** Remove a resource by `.id` (DELETE). */
|
|
12755
|
-
async remove(path, id) {
|
|
12756
|
-
await this.doRequest("DELETE", `${this.baseUrl}/${path}/${id}`);
|
|
12757
|
-
}
|
|
12758
|
-
/** Execute a command (POST), e.g. `/ip/firewall/filter/print`. */
|
|
12759
|
-
async execute(path, data) {
|
|
12760
|
-
const result = await this.doRequest("POST", `${this.baseUrl}/${path}`, data);
|
|
12761
|
-
return result;
|
|
12762
|
-
}
|
|
12763
|
-
// ---------- lifecycle ----------
|
|
12764
|
-
/** Close the underlying HTTP agent and free resources. */
|
|
12765
|
-
close() {
|
|
12766
|
-
this.agent.close();
|
|
12767
|
-
}
|
|
12768
|
-
// ---------- internal ----------
|
|
12769
|
-
/**
|
|
12770
|
-
* Core HTTP request method.
|
|
12771
|
-
*
|
|
12772
|
-
* - Sends JSON body when provided.
|
|
12773
|
-
* - Attaches Basic Auth and content-type headers.
|
|
12774
|
-
* - Throws `HttpError` on non-2xx status codes.
|
|
12775
|
-
* - Returns parsed JSON (or `undefined` for empty bodies).
|
|
12776
|
-
*/
|
|
12777
|
-
async doRequest(method, url, body) {
|
|
12778
|
-
const headers = {
|
|
12779
|
-
Accept: "application/json",
|
|
12780
|
-
Authorization: this.authHeader
|
|
12781
|
-
};
|
|
12782
|
-
let requestBody;
|
|
12783
|
-
if (body !== void 0) {
|
|
12784
|
-
headers["Content-Type"] = "application/json";
|
|
12785
|
-
requestBody = JSON.stringify(body);
|
|
12786
|
-
}
|
|
12787
|
-
const response = await request(url, {
|
|
12788
|
-
method,
|
|
12789
|
-
headers,
|
|
12790
|
-
body: requestBody,
|
|
12791
|
-
dispatcher: this.agent,
|
|
12792
|
-
bodyTimeout: REQUEST_TIMEOUT_MS,
|
|
12793
|
-
headersTimeout: REQUEST_TIMEOUT_MS
|
|
12794
|
-
});
|
|
12795
|
-
const text = await response.body.text();
|
|
12796
|
-
if (response.statusCode < 200 || response.statusCode >= 300) {
|
|
12797
|
-
throw new HttpError(response.statusCode, text);
|
|
12798
|
-
}
|
|
12799
|
-
if (!text || text.length === 0) {
|
|
12800
|
-
return void 0;
|
|
12801
|
-
}
|
|
12802
|
-
return JSON.parse(text);
|
|
14260
|
+
function createDeviceClient(config, credentials) {
|
|
14261
|
+
if (config.deviceType === "swos") {
|
|
14262
|
+
return new SwosClient(config.host, config.port, credentials);
|
|
12803
14263
|
}
|
|
12804
|
-
|
|
12805
|
-
|
|
12806
|
-
// src/adapter/connection-pool.ts
|
|
12807
|
-
function credentialHash(credentials) {
|
|
12808
|
-
return createHash5("sha256").update(`${credentials.username}\0${credentials.password}`).digest("hex");
|
|
14264
|
+
return new RouterOSRestClient(config, credentials);
|
|
12809
14265
|
}
|
|
12810
14266
|
var ConnectionPool = class {
|
|
12811
14267
|
clients = /* @__PURE__ */ new Map();
|
|
12812
14268
|
/**
|
|
12813
|
-
* Get an existing client for the given
|
|
14269
|
+
* Get an existing client for the given device or create a new one. Clients are
|
|
12814
14270
|
* keyed by `config.id`, but a client whose credentials no longer match the
|
|
12815
14271
|
* request is closed and rebuilt so rotated credentials take effect.
|
|
12816
14272
|
*/
|
|
@@ -12823,7 +14279,7 @@ var ConnectionPool = class {
|
|
|
12823
14279
|
}
|
|
12824
14280
|
existing.client.close();
|
|
12825
14281
|
}
|
|
12826
|
-
const client =
|
|
14282
|
+
const client = createDeviceClient(config, credentials);
|
|
12827
14283
|
this.clients.set(config.id, { client, credHash });
|
|
12828
14284
|
return client;
|
|
12829
14285
|
}
|
|
@@ -12861,16 +14317,17 @@ var ConnectionPool = class {
|
|
|
12861
14317
|
};
|
|
12862
14318
|
|
|
12863
14319
|
// src/version.ts
|
|
12864
|
-
var VERSION = "1.
|
|
14320
|
+
var VERSION = "1.9.0";
|
|
12865
14321
|
|
|
12866
14322
|
// src/mcp/server.ts
|
|
12867
14323
|
var SERVER_INSTRUCTIONS = [
|
|
12868
|
-
"MikroMCP manages MikroTik RouterOS
|
|
14324
|
+
"MikroMCP manages MikroTik RouterOS routers and SwOS switches.",
|
|
12869
14325
|
"Reads are safe; treat writes as consequential \u2014 preview a write with dryRun:true and review the diff before applying.",
|
|
12870
14326
|
"Destructive operations may require a two-step confirmation token.",
|
|
12871
14327
|
"Writes are idempotent: already_exists and no_change are success, not errors.",
|
|
12872
14328
|
"Prefer dedicated tools over run_command.",
|
|
12873
14329
|
"routerId is optional when a default router is configured.",
|
|
14330
|
+
"Check list_routers first: swos devices take the swos_* tools, RouterOS devices take everything else.",
|
|
12874
14331
|
"For the full operating playbook, install the MikroMCP usage skill."
|
|
12875
14332
|
].join(" ");
|
|
12876
14333
|
function createServerFactory(config, identityRegistry) {
|
|
@@ -12903,7 +14360,7 @@ import { randomUUID } from "crypto";
|
|
|
12903
14360
|
import { createServer } from "http";
|
|
12904
14361
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
12905
14362
|
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
|
|
12906
|
-
var
|
|
14363
|
+
var log52 = createLogger("transport-http");
|
|
12907
14364
|
var SESSION_TTL_MS = 30 * 6e4;
|
|
12908
14365
|
var BodyTooLargeError = class extends Error {
|
|
12909
14366
|
constructor() {
|
|
@@ -12988,7 +14445,7 @@ async function connectHttp(makeServer, config, identityRegistry) {
|
|
|
12988
14445
|
if (now - session.lastSeenMs > SESSION_TTL_MS) {
|
|
12989
14446
|
streamableSessions.delete(id);
|
|
12990
14447
|
session.transport.close();
|
|
12991
|
-
|
|
14448
|
+
log52.info({ sessionId: id }, "Streamable HTTP session expired (idle TTL)");
|
|
12992
14449
|
}
|
|
12993
14450
|
}
|
|
12994
14451
|
}, 6e4);
|
|
@@ -13023,7 +14480,7 @@ async function connectHttp(makeServer, config, identityRegistry) {
|
|
|
13023
14480
|
return;
|
|
13024
14481
|
}
|
|
13025
14482
|
if (!checkRateLimit(ip)) {
|
|
13026
|
-
|
|
14483
|
+
log52.warn({ ip }, "Rate limit exceeded");
|
|
13027
14484
|
res.writeHead(429, { "Content-Type": "application/json" });
|
|
13028
14485
|
res.end(JSON.stringify({ error: "Too many requests. Retry after 60 seconds." }));
|
|
13029
14486
|
return;
|
|
@@ -13068,7 +14525,7 @@ async function connectHttp(makeServer, config, identityRegistry) {
|
|
|
13068
14525
|
}
|
|
13069
14526
|
return;
|
|
13070
14527
|
}
|
|
13071
|
-
|
|
14528
|
+
log52.error({ err }, "HTTP transport error");
|
|
13072
14529
|
if (!res.headersSent) {
|
|
13073
14530
|
res.writeHead(500, { "Content-Type": "application/json" });
|
|
13074
14531
|
res.end(JSON.stringify({ error: "Internal server error" }));
|
|
@@ -13081,7 +14538,7 @@ async function connectHttp(makeServer, config, identityRegistry) {
|
|
|
13081
14538
|
});
|
|
13082
14539
|
await new Promise((resolve, reject) => {
|
|
13083
14540
|
httpServer.listen(port, bindHost, () => {
|
|
13084
|
-
|
|
14541
|
+
log52.info(
|
|
13085
14542
|
{ port, bindHost },
|
|
13086
14543
|
"HTTP transport listening (POST /mcp for Streamable HTTP, GET /sse for legacy SSE)"
|
|
13087
14544
|
);
|
|
@@ -13114,13 +14571,13 @@ async function handleMcpRequest(pathname, req, res, url, port, maxBodyBytes, mak
|
|
|
13114
14571
|
sessionIdGenerator: () => randomUUID(),
|
|
13115
14572
|
onsessioninitialized: (id) => {
|
|
13116
14573
|
streamableSessions.set(id, { transport: transport2, identityId, lastSeenMs: Date.now() });
|
|
13117
|
-
|
|
14574
|
+
log52.info({ sessionId: id, identityId }, "Streamable HTTP session created");
|
|
13118
14575
|
}
|
|
13119
14576
|
});
|
|
13120
14577
|
transport2.onclose = () => {
|
|
13121
14578
|
if (transport2.sessionId) {
|
|
13122
14579
|
streamableSessions.delete(transport2.sessionId);
|
|
13123
|
-
|
|
14580
|
+
log52.info({ sessionId: transport2.sessionId }, "Streamable HTTP session closed");
|
|
13124
14581
|
}
|
|
13125
14582
|
};
|
|
13126
14583
|
await makeServer().connect(transport2);
|
|
@@ -13137,7 +14594,7 @@ async function handleMcpRequest(pathname, req, res, url, port, maxBodyBytes, mak
|
|
|
13137
14594
|
sseTransport.onclose = () => {
|
|
13138
14595
|
sseTransports.delete(sseTransport.sessionId);
|
|
13139
14596
|
};
|
|
13140
|
-
|
|
14597
|
+
log52.info({ sessionId: sseTransport.sessionId }, "SSE client connected");
|
|
13141
14598
|
await sseTransport.start();
|
|
13142
14599
|
return;
|
|
13143
14600
|
}
|
|
@@ -13157,19 +14614,19 @@ async function handleMcpRequest(pathname, req, res, url, port, maxBodyBytes, mak
|
|
|
13157
14614
|
|
|
13158
14615
|
// src/config/identity-registry.ts
|
|
13159
14616
|
import { readFileSync as readFileSync4, existsSync as existsSync2 } from "fs";
|
|
13160
|
-
import { createHash as
|
|
14617
|
+
import { createHash as createHash7 } from "crypto";
|
|
13161
14618
|
import { parse as parse2 } from "yaml";
|
|
13162
|
-
import { z as
|
|
14619
|
+
import { z as z50 } from "zod";
|
|
13163
14620
|
import bcrypt from "bcryptjs";
|
|
13164
|
-
var
|
|
13165
|
-
var IdentityConfigSchema =
|
|
13166
|
-
token:
|
|
13167
|
-
role:
|
|
13168
|
-
allowedRouters:
|
|
13169
|
-
allowedToolPatterns:
|
|
14621
|
+
var log53 = createLogger("identity-registry");
|
|
14622
|
+
var IdentityConfigSchema = z50.object({
|
|
14623
|
+
token: z50.string().min(1, "token hash is required"),
|
|
14624
|
+
role: z50.enum(["readonly", "operator", "admin", "superadmin"]),
|
|
14625
|
+
allowedRouters: z50.array(z50.string()).default([]),
|
|
14626
|
+
allowedToolPatterns: z50.array(z50.string()).default([])
|
|
13170
14627
|
}).strict();
|
|
13171
|
-
var ConfigFileSchema2 =
|
|
13172
|
-
identities:
|
|
14628
|
+
var ConfigFileSchema2 = z50.object({
|
|
14629
|
+
identities: z50.record(z50.string(), IdentityConfigSchema)
|
|
13173
14630
|
}).strict();
|
|
13174
14631
|
var IdentityRegistry = class {
|
|
13175
14632
|
identities = [];
|
|
@@ -13178,7 +14635,7 @@ var IdentityRegistry = class {
|
|
|
13178
14635
|
tokenCache = /* @__PURE__ */ new Map();
|
|
13179
14636
|
constructor(configPath) {
|
|
13180
14637
|
if (!existsSync2(configPath)) {
|
|
13181
|
-
|
|
14638
|
+
log53.warn(
|
|
13182
14639
|
{ configPath },
|
|
13183
14640
|
"Identity config file not found; starting with empty registry (stdio callers do not require it)"
|
|
13184
14641
|
);
|
|
@@ -13201,7 +14658,7 @@ ${issues}`);
|
|
|
13201
14658
|
allowedToolPatterns: config.allowedToolPatterns
|
|
13202
14659
|
});
|
|
13203
14660
|
}
|
|
13204
|
-
|
|
14661
|
+
log53.info({ count: this.identities.length }, "Loaded identities from config");
|
|
13205
14662
|
}
|
|
13206
14663
|
getIdentities() {
|
|
13207
14664
|
return this.identities.map(({ id, role, allowedRouters, allowedToolPatterns }) => ({
|
|
@@ -13212,7 +14669,7 @@ ${issues}`);
|
|
|
13212
14669
|
}));
|
|
13213
14670
|
}
|
|
13214
14671
|
async findIdentityByToken(token) {
|
|
13215
|
-
const cacheKey =
|
|
14672
|
+
const cacheKey = createHash7("sha256").update(token).digest("hex");
|
|
13216
14673
|
const toIdentity = (stored) => ({
|
|
13217
14674
|
id: stored.id,
|
|
13218
14675
|
role: stored.role,
|
|
@@ -13239,7 +14696,7 @@ ${issues}`);
|
|
|
13239
14696
|
// src/domain/snapshot/retention.ts
|
|
13240
14697
|
import { readdir, stat, unlink } from "fs/promises";
|
|
13241
14698
|
import { join as join3 } from "path";
|
|
13242
|
-
var
|
|
14699
|
+
var log54 = createLogger("snapshot-retention");
|
|
13243
14700
|
async function pruneSnapshots(snapshotDir, maxAgeDays) {
|
|
13244
14701
|
const cutoffMs = Date.now() - maxAgeDays * 864e5;
|
|
13245
14702
|
let removed = 0;
|
|
@@ -13266,21 +14723,21 @@ async function pruneSnapshots(snapshotDir, maxAgeDays) {
|
|
|
13266
14723
|
removed++;
|
|
13267
14724
|
}
|
|
13268
14725
|
} catch (err) {
|
|
13269
|
-
|
|
14726
|
+
log54.warn({ err, filePath }, "Failed to evaluate snapshot for retention");
|
|
13270
14727
|
}
|
|
13271
14728
|
}
|
|
13272
14729
|
}
|
|
13273
14730
|
if (removed > 0) {
|
|
13274
|
-
|
|
14731
|
+
log54.info({ removed, maxAgeDays }, "Pruned expired snapshots");
|
|
13275
14732
|
}
|
|
13276
14733
|
return removed;
|
|
13277
14734
|
}
|
|
13278
14735
|
|
|
13279
14736
|
// src/cli/serve.ts
|
|
13280
|
-
var
|
|
14737
|
+
var log55 = createLogger("main");
|
|
13281
14738
|
async function runServe() {
|
|
13282
14739
|
const config = loadAppConfig();
|
|
13283
|
-
|
|
14740
|
+
log55.info({ transport: config.transport, logLevel: config.logLevel }, "Starting MikroMCP server");
|
|
13284
14741
|
void pruneSnapshots(config.snapshotDir, config.retention.snapshotMaxAgeDays);
|
|
13285
14742
|
const identityRegistry = new IdentityRegistry(config.identitiesPath);
|
|
13286
14743
|
if (config.transport === "http") {
|
|
@@ -13288,7 +14745,7 @@ async function runServe() {
|
|
|
13288
14745
|
(i) => i.role === "readonly" || i.role === "operator"
|
|
13289
14746
|
);
|
|
13290
14747
|
if (hasLimitedRoles && !config.confirmationSecret) {
|
|
13291
|
-
|
|
14748
|
+
log55.error(
|
|
13292
14749
|
"MIKROMCP_CONFIRMATION_SECRET is required in HTTP mode when identities with role readonly or operator are configured"
|
|
13293
14750
|
);
|
|
13294
14751
|
process.exit(1);
|
|
@@ -13298,7 +14755,7 @@ async function runServe() {
|
|
|
13298
14755
|
if (config.transport === "stdio") {
|
|
13299
14756
|
const stdioIdentity = getStdioIdentity(config.stdioIdentity, identityRegistry);
|
|
13300
14757
|
await withIdentity(stdioIdentity, () => connectStdio(makeServer()));
|
|
13301
|
-
|
|
14758
|
+
log55.info("MikroMCP server running via stdio");
|
|
13302
14759
|
} else if (config.transport === "http") {
|
|
13303
14760
|
await connectHttp(
|
|
13304
14761
|
makeServer,
|
|
@@ -13310,13 +14767,13 @@ async function runServe() {
|
|
|
13310
14767
|
},
|
|
13311
14768
|
identityRegistry
|
|
13312
14769
|
);
|
|
13313
|
-
|
|
14770
|
+
log55.info(
|
|
13314
14771
|
{ port: config.port, bindHost: config.bindHost },
|
|
13315
14772
|
"MikroMCP server running via HTTP/SSE"
|
|
13316
14773
|
);
|
|
13317
14774
|
}
|
|
13318
14775
|
const shutdown = () => {
|
|
13319
|
-
|
|
14776
|
+
log55.info("Shutting down MikroMCP server");
|
|
13320
14777
|
pool.closeAll();
|
|
13321
14778
|
process.exit(0);
|
|
13322
14779
|
};
|
|
@@ -13329,7 +14786,7 @@ import { existsSync as existsSync3, readFileSync as readFileSync5 } from "fs";
|
|
|
13329
14786
|
import { homedir as homedir2 } from "os";
|
|
13330
14787
|
import * as net from "net";
|
|
13331
14788
|
import chalk from "chalk";
|
|
13332
|
-
var localVersion = "1.
|
|
14789
|
+
var localVersion = "1.9.0";
|
|
13333
14790
|
var results = [];
|
|
13334
14791
|
function ok(msg) {
|
|
13335
14792
|
console.log(chalk.green("\u2705 ") + msg);
|
|
@@ -13634,7 +15091,7 @@ async function runDoctor() {
|
|
|
13634
15091
|
|
|
13635
15092
|
// src/cli/init.ts
|
|
13636
15093
|
import { input, confirm, select } from "@inquirer/prompts";
|
|
13637
|
-
import { randomBytes } from "crypto";
|
|
15094
|
+
import { randomBytes as randomBytes2 } from "crypto";
|
|
13638
15095
|
import {
|
|
13639
15096
|
existsSync as existsSync4,
|
|
13640
15097
|
readFileSync as readFileSync6,
|
|
@@ -13789,7 +15246,7 @@ async function collectIdentityInfo() {
|
|
|
13789
15246
|
message: "Allowed tool patterns (comma-separated or * for all):",
|
|
13790
15247
|
default: "*"
|
|
13791
15248
|
});
|
|
13792
|
-
const rawToken =
|
|
15249
|
+
const rawToken = randomBytes2(32).toString("hex");
|
|
13793
15250
|
console.log(chalk2.green(`
|
|
13794
15251
|
Generated token: ${chalk2.bold(rawToken)}`));
|
|
13795
15252
|
console.log(chalk2.yellow(" \u26A0 Save this token \u2014 it will NOT be shown again.\n"));
|
|
@@ -13883,7 +15340,7 @@ function writeDotEnv(data) {
|
|
|
13883
15340
|
const userKey = `${data.envPrefix}_USER`;
|
|
13884
15341
|
const passKey = `${data.envPrefix}_PASS`;
|
|
13885
15342
|
const auditLogPath = defaultAuditLogPath();
|
|
13886
|
-
const confirmationSecret = data.transport === "http" ?
|
|
15343
|
+
const confirmationSecret = data.transport === "http" ? randomBytes2(32).toString("hex") : "";
|
|
13887
15344
|
const lines = [
|
|
13888
15345
|
"# \u2500\u2500 Router credentials \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500",
|
|
13889
15346
|
`${userKey}=${data.routerUser}`,
|
|
@@ -13952,11 +15409,11 @@ async function runInit() {
|
|
|
13952
15409
|
const configDir = mikromcpDir2();
|
|
13953
15410
|
const envPath = join4(configDir, ".env");
|
|
13954
15411
|
const existingFiles = [".env", "routers.yaml", "identities.yaml"].filter(
|
|
13955
|
-
(
|
|
15412
|
+
(f2) => existsSync4(join4(configDir, f2))
|
|
13956
15413
|
);
|
|
13957
15414
|
if (existingFiles.length > 0) {
|
|
13958
15415
|
console.log(chalk2.yellow(` \u26A0 Found existing files in ${configDir}:`));
|
|
13959
|
-
for (const
|
|
15416
|
+
for (const f2 of existingFiles) console.log(chalk2.yellow(` ${f2}`));
|
|
13960
15417
|
console.log();
|
|
13961
15418
|
const proceed = await confirm({
|
|
13962
15419
|
message: "Continue? (existing files will be overwritten or merged)",
|
|
@@ -14041,7 +15498,7 @@ async function runInit() {
|
|
|
14041
15498
|
}
|
|
14042
15499
|
|
|
14043
15500
|
// src/cli/index.ts
|
|
14044
|
-
var program = new Command().name("mikromcp").description("MikroTik RouterOS MCP server").version("1.
|
|
15501
|
+
var program = new Command().name("mikromcp").description("MikroTik RouterOS MCP server").version("1.9.0");
|
|
14045
15502
|
program.command("serve", { isDefault: true }).description("Start the MCP server (default)").action(runServe);
|
|
14046
15503
|
program.command("doctor").description("Check config, connectivity, and permissions").action(runDoctor);
|
|
14047
15504
|
program.command("init").description("Interactive setup wizard").action(runInit);
|