@usex/mikrotik-mcp 3.25.0 → 3.26.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/dist/cli.js +279 -6
- package/dist/index.js +1 -1
- package/dist/ui/observability.html +41 -41
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -24328,7 +24328,7 @@ function selectToolModules(filter = {}, catalog = moduleCatalog) {
|
|
|
24328
24328
|
// src/observability/dashboard.ts
|
|
24329
24329
|
import { readFileSync as readFileSync6 } from "fs";
|
|
24330
24330
|
import { homedir as homedir2, networkInterfaces } from "os";
|
|
24331
|
-
import { join as join6 } from "path";
|
|
24331
|
+
import { dirname as dirname6, join as join6 } from "path";
|
|
24332
24332
|
var {serve } = globalThis.Bun;
|
|
24333
24333
|
import { z as z114 } from "zod";
|
|
24334
24334
|
|
|
@@ -24975,6 +24975,219 @@ async function openSqliteStore(path) {
|
|
|
24975
24975
|
return new SqliteEventStore(db);
|
|
24976
24976
|
}
|
|
24977
24977
|
|
|
24978
|
+
// src/observability/usage-store.ts
|
|
24979
|
+
import { mkdirSync as mkdirSync6 } from "fs";
|
|
24980
|
+
import { dirname as dirname5 } from "path";
|
|
24981
|
+
function dayOf(ts) {
|
|
24982
|
+
return new Date(ts).toISOString().slice(0, 10);
|
|
24983
|
+
}
|
|
24984
|
+
function dailyUsageFromSamples(samples) {
|
|
24985
|
+
const byDay = new Map;
|
|
24986
|
+
for (let i = 1;i < samples.length; i++) {
|
|
24987
|
+
const prev = samples[i - 1];
|
|
24988
|
+
const cur = samples[i];
|
|
24989
|
+
const dRx = cur.rx >= prev.rx ? cur.rx - prev.rx : cur.rx;
|
|
24990
|
+
const dTx = cur.tx >= prev.tx ? cur.tx - prev.tx : cur.tx;
|
|
24991
|
+
const day = dayOf(cur.ts);
|
|
24992
|
+
const acc = byDay.get(day) ?? { rx: 0, tx: 0 };
|
|
24993
|
+
acc.rx += dRx;
|
|
24994
|
+
acc.tx += dTx;
|
|
24995
|
+
byDay.set(day, acc);
|
|
24996
|
+
}
|
|
24997
|
+
return [...byDay.entries()].map(([day, v]) => ({ day, rx: v.rx, tx: v.tx })).sort((a, b) => a.day.localeCompare(b.day));
|
|
24998
|
+
}
|
|
24999
|
+
var SCHEMA_STATEMENTS3 = [
|
|
25000
|
+
`CREATE TABLE IF NOT EXISTS usage_samples (
|
|
25001
|
+
device TEXT NOT NULL,
|
|
25002
|
+
subject TEXT NOT NULL,
|
|
25003
|
+
ts INTEGER NOT NULL,
|
|
25004
|
+
rx INTEGER NOT NULL,
|
|
25005
|
+
tx INTEGER NOT NULL
|
|
25006
|
+
)`,
|
|
25007
|
+
"CREATE INDEX IF NOT EXISTS idx_usage_sub ON usage_samples(device, subject, ts)",
|
|
25008
|
+
"CREATE INDEX IF NOT EXISTS idx_usage_ts ON usage_samples(ts)",
|
|
25009
|
+
`CREATE TABLE IF NOT EXISTS vpn_sessions (
|
|
25010
|
+
device TEXT NOT NULL,
|
|
25011
|
+
session_id TEXT NOT NULL,
|
|
25012
|
+
user TEXT NOT NULL,
|
|
25013
|
+
service TEXT,
|
|
25014
|
+
nas TEXT,
|
|
25015
|
+
started INTEGER NOT NULL,
|
|
25016
|
+
day TEXT NOT NULL,
|
|
25017
|
+
rx INTEGER NOT NULL,
|
|
25018
|
+
tx INTEGER NOT NULL,
|
|
25019
|
+
PRIMARY KEY (device, session_id)
|
|
25020
|
+
)`,
|
|
25021
|
+
"CREATE INDEX IF NOT EXISTS idx_sess_user ON vpn_sessions(device, user, day)",
|
|
25022
|
+
"CREATE INDEX IF NOT EXISTS idx_sess_day ON vpn_sessions(device, day)"
|
|
25023
|
+
];
|
|
25024
|
+
|
|
25025
|
+
class SqliteUsageStore {
|
|
25026
|
+
db;
|
|
25027
|
+
constructor(db) {
|
|
25028
|
+
this.db = db;
|
|
25029
|
+
db.run("PRAGMA journal_mode = WAL");
|
|
25030
|
+
db.run("PRAGMA synchronous = NORMAL");
|
|
25031
|
+
for (const stmt of SCHEMA_STATEMENTS3)
|
|
25032
|
+
db.run(stmt);
|
|
25033
|
+
}
|
|
25034
|
+
recordClientSamples(device, ts, samples) {
|
|
25035
|
+
if (samples.length === 0)
|
|
25036
|
+
return;
|
|
25037
|
+
const insert = this.db.query("INSERT INTO usage_samples (device, subject, ts, rx, tx) VALUES ($d,$s,$ts,$rx,$tx)");
|
|
25038
|
+
const tx = this.db.transaction((rows) => {
|
|
25039
|
+
for (const r of rows) {
|
|
25040
|
+
insert.run({ $d: device, $s: r.ip, $ts: ts, $rx: r.rx, $tx: r.tx });
|
|
25041
|
+
}
|
|
25042
|
+
});
|
|
25043
|
+
tx(samples);
|
|
25044
|
+
}
|
|
25045
|
+
upsertSessions(device, sessions) {
|
|
25046
|
+
if (sessions.length === 0)
|
|
25047
|
+
return 0;
|
|
25048
|
+
const stmt = this.db.query(`INSERT INTO vpn_sessions (device, session_id, user, service, nas, started, day, rx, tx)
|
|
25049
|
+
VALUES ($d,$id,$u,$svc,$nas,$st,$day,$rx,$tx)
|
|
25050
|
+
ON CONFLICT(device, session_id) DO UPDATE SET rx=excluded.rx, tx=excluded.tx`);
|
|
25051
|
+
let n = 0;
|
|
25052
|
+
const tx = this.db.transaction((rows) => {
|
|
25053
|
+
for (const s of rows) {
|
|
25054
|
+
stmt.run({
|
|
25055
|
+
$d: device,
|
|
25056
|
+
$id: s.sessionId,
|
|
25057
|
+
$u: s.user,
|
|
25058
|
+
$svc: s.service ?? null,
|
|
25059
|
+
$nas: s.nas ?? null,
|
|
25060
|
+
$st: s.started,
|
|
25061
|
+
$day: dayOf(s.started),
|
|
25062
|
+
$rx: s.rx,
|
|
25063
|
+
$tx: s.tx
|
|
25064
|
+
});
|
|
25065
|
+
n++;
|
|
25066
|
+
}
|
|
25067
|
+
});
|
|
25068
|
+
tx(sessions);
|
|
25069
|
+
return n;
|
|
25070
|
+
}
|
|
25071
|
+
clientDailyUsage(device, ip, sinceTs) {
|
|
25072
|
+
const rows = this.db.query("SELECT ts, rx, tx FROM usage_samples WHERE device=$d AND subject=$s AND ts>=$since ORDER BY ts ASC").all({ $d: device, $s: ip, $since: sinceTs });
|
|
25073
|
+
return dailyUsageFromSamples(rows);
|
|
25074
|
+
}
|
|
25075
|
+
umUserDailyUsage(device, user, sinceTs) {
|
|
25076
|
+
const rows = this.db.query(`SELECT day, SUM(rx) AS rx, SUM(tx) AS tx FROM vpn_sessions
|
|
25077
|
+
WHERE device=$d AND user=$u AND started>=$since GROUP BY day ORDER BY day ASC`).all({ $d: device, $u: user, $since: sinceTs });
|
|
25078
|
+
return rows.map((r) => ({ day: r.day, rx: Number(r.rx), tx: Number(r.tx) }));
|
|
25079
|
+
}
|
|
25080
|
+
umUsers(device) {
|
|
25081
|
+
const rows = this.db.query("SELECT DISTINCT user FROM vpn_sessions WHERE device=$d ORDER BY user ASC").all({ $d: device });
|
|
25082
|
+
return rows.map((r) => r.user);
|
|
25083
|
+
}
|
|
25084
|
+
heatmap(device, user, sinceTs) {
|
|
25085
|
+
const sinceDay = dayOf(sinceTs);
|
|
25086
|
+
const rows = user ? this.db.query("SELECT day, COUNT(*) AS count FROM vpn_sessions WHERE device=$d AND user=$u AND day>=$since GROUP BY day").all({ $d: device, $u: user, $since: sinceDay }) : this.db.query("SELECT day, COUNT(*) AS count FROM vpn_sessions WHERE device=$d AND day>=$since GROUP BY day").all({ $d: device, $since: sinceDay });
|
|
25087
|
+
return rows.map((r) => ({ day: r.day, count: Number(r.count) }));
|
|
25088
|
+
}
|
|
25089
|
+
pruneSamples(olderThanTs) {
|
|
25090
|
+
const res = this.db.query("DELETE FROM usage_samples WHERE ts < $t").run({ $t: olderThanTs });
|
|
25091
|
+
return Number(res.changes ?? 0);
|
|
25092
|
+
}
|
|
25093
|
+
close() {
|
|
25094
|
+
this.db.close();
|
|
25095
|
+
}
|
|
25096
|
+
}
|
|
25097
|
+
async function openUsageStore(path) {
|
|
25098
|
+
if (path !== ":memory:") {
|
|
25099
|
+
try {
|
|
25100
|
+
mkdirSync6(dirname5(path), { recursive: true });
|
|
25101
|
+
} catch {}
|
|
25102
|
+
}
|
|
25103
|
+
const { Database } = await import("bun:sqlite");
|
|
25104
|
+
const db = new Database(path, { create: true });
|
|
25105
|
+
return new SqliteUsageStore(db);
|
|
25106
|
+
}
|
|
25107
|
+
|
|
25108
|
+
// src/observability/usage-sampler.ts
|
|
25109
|
+
var SERVER_TAG = "mikrotik-mcp";
|
|
25110
|
+
var USAGE_RETENTION_MS = 93 * 24 * 60 * 60 * 1000;
|
|
25111
|
+
var timer2 = null;
|
|
25112
|
+
var inFlight2 = false;
|
|
25113
|
+
function bytesOf(v) {
|
|
25114
|
+
return parseSize(v) ?? parseLeadingNumber(v) ?? 0;
|
|
25115
|
+
}
|
|
25116
|
+
function ipOf(target) {
|
|
25117
|
+
return (target ?? "").split("/")[0]?.trim() ?? "";
|
|
25118
|
+
}
|
|
25119
|
+
async function sampleClients(store2, device, ts) {
|
|
25120
|
+
const ctx = createContext(undefined, device);
|
|
25121
|
+
const out = await executeMikrotikCommand("/queue simple print stats detail", ctx);
|
|
25122
|
+
if (isEmpty(out) || looksLikeError(out) || commandUnsupported(out))
|
|
25123
|
+
return;
|
|
25124
|
+
const samples = [];
|
|
25125
|
+
for (const row of parseRecords(out).rows) {
|
|
25126
|
+
const ip = ipOf(row.target ?? "");
|
|
25127
|
+
if (!ip)
|
|
25128
|
+
continue;
|
|
25129
|
+
const [tx, rx] = (row.bytes ?? "0/0").split("/");
|
|
25130
|
+
samples.push({ ip, rx: bytesOf(rx), tx: bytesOf(tx) });
|
|
25131
|
+
}
|
|
25132
|
+
store2.recordClientSamples(device, ts, samples);
|
|
25133
|
+
}
|
|
25134
|
+
async function ingestSessions(store2, device) {
|
|
25135
|
+
const ctx = createContext(undefined, device);
|
|
25136
|
+
const out = await executeMikrotikCommand("/user-manager session print detail", ctx);
|
|
25137
|
+
if (isEmpty(out) || looksLikeError(out) || commandUnsupported(out))
|
|
25138
|
+
return;
|
|
25139
|
+
const sessions = [];
|
|
25140
|
+
for (const row of parseRecords(out).rows) {
|
|
25141
|
+
const user = row.user ?? "";
|
|
25142
|
+
const started = parseRouterosDate(row.started ?? row["start-time"]);
|
|
25143
|
+
if (!user || started == null)
|
|
25144
|
+
continue;
|
|
25145
|
+
const sessionId = row["acct-session-id"] || `${user}|${started}|${row["calling-station-id"] ?? row["nas-port-id"] ?? ""}`;
|
|
25146
|
+
sessions.push({
|
|
25147
|
+
sessionId,
|
|
25148
|
+
user,
|
|
25149
|
+
service: row.service,
|
|
25150
|
+
nas: row["nas-ip-address"] ?? row["nas-port-id"],
|
|
25151
|
+
started,
|
|
25152
|
+
rx: bytesOf(row.download),
|
|
25153
|
+
tx: bytesOf(row.upload)
|
|
25154
|
+
});
|
|
25155
|
+
}
|
|
25156
|
+
store2.upsertSessions(device, sessions);
|
|
25157
|
+
}
|
|
25158
|
+
async function sampleUsageOnce(store2) {
|
|
25159
|
+
if (inFlight2)
|
|
25160
|
+
return;
|
|
25161
|
+
inFlight2 = true;
|
|
25162
|
+
const ts = Date.now();
|
|
25163
|
+
try {
|
|
25164
|
+
const cfg = getConfig();
|
|
25165
|
+
await Promise.all(Object.entries(cfg.devices).map(async ([name, dc]) => {
|
|
25166
|
+
if (dc.mac)
|
|
25167
|
+
return;
|
|
25168
|
+
try {
|
|
25169
|
+
await sampleClients(store2, name, ts);
|
|
25170
|
+
await ingestSessions(store2, name);
|
|
25171
|
+
} catch (e) {
|
|
25172
|
+
logger.warn(`[${SERVER_TAG}] usage sample failed for '${name}': ${String(e)}`);
|
|
25173
|
+
}
|
|
25174
|
+
}));
|
|
25175
|
+
store2.pruneSamples(ts - USAGE_RETENTION_MS);
|
|
25176
|
+
} finally {
|
|
25177
|
+
inFlight2 = false;
|
|
25178
|
+
}
|
|
25179
|
+
}
|
|
25180
|
+
function startUsageSampler(store2, intervalMs = 10 * 60000) {
|
|
25181
|
+
sampleUsageOnce(store2);
|
|
25182
|
+
timer2 = setInterval(() => void sampleUsageOnce(store2), intervalMs);
|
|
25183
|
+
}
|
|
25184
|
+
function stopUsageSampler() {
|
|
25185
|
+
if (timer2) {
|
|
25186
|
+
clearInterval(timer2);
|
|
25187
|
+
timer2 = null;
|
|
25188
|
+
}
|
|
25189
|
+
}
|
|
25190
|
+
|
|
24978
25191
|
// src/observability/stats.ts
|
|
24979
25192
|
function percentile(values, p) {
|
|
24980
25193
|
if (values.length === 0)
|
|
@@ -25069,7 +25282,7 @@ function computeStats(events, opts) {
|
|
|
25069
25282
|
}
|
|
25070
25283
|
|
|
25071
25284
|
// src/observability/dashboard.ts
|
|
25072
|
-
var
|
|
25285
|
+
var SERVER_TAG2 = "mikrotik-mcp";
|
|
25073
25286
|
var JSON_HEADERS = { "content-type": "application/json; charset=utf-8" };
|
|
25074
25287
|
function json(body, status = 200) {
|
|
25075
25288
|
return new Response(JSON.stringify(body), { status, headers: JSON_HEADERS });
|
|
@@ -25170,7 +25383,7 @@ function devicesPayload(store2) {
|
|
|
25170
25383
|
avgMs: 0
|
|
25171
25384
|
}
|
|
25172
25385
|
}));
|
|
25173
|
-
return { server:
|
|
25386
|
+
return { server: SERVER_TAG2, defaultDevice: cfg.defaultDevice, devices };
|
|
25174
25387
|
}
|
|
25175
25388
|
function topologyPayload() {
|
|
25176
25389
|
const cfg = getConfig();
|
|
@@ -25183,7 +25396,7 @@ function topologyPayload() {
|
|
|
25183
25396
|
for (const { name } of devices)
|
|
25184
25397
|
neighborsByDevice[name] = getDeviceNeighbors(name);
|
|
25185
25398
|
return {
|
|
25186
|
-
server:
|
|
25399
|
+
server: SERVER_TAG2,
|
|
25187
25400
|
defaultDevice: cfg.defaultDevice,
|
|
25188
25401
|
generatedAt: Date.now(),
|
|
25189
25402
|
...buildTopology({ devices, neighborsByDevice })
|
|
@@ -25528,6 +25741,54 @@ async function aaaRoutes(req, url) {
|
|
|
25528
25741
|
}
|
|
25529
25742
|
return null;
|
|
25530
25743
|
}
|
|
25744
|
+
var usageStore = null;
|
|
25745
|
+
function daysParam(url, fallback, max) {
|
|
25746
|
+
const n = Number(url.searchParams.get("days"));
|
|
25747
|
+
return Number.isFinite(n) && n > 0 ? Math.min(n, max) : fallback;
|
|
25748
|
+
}
|
|
25749
|
+
function usageRoutes(req, url) {
|
|
25750
|
+
const p = url.pathname;
|
|
25751
|
+
if (!p.startsWith("/api/usage"))
|
|
25752
|
+
return null;
|
|
25753
|
+
if (req.method !== "GET")
|
|
25754
|
+
return null;
|
|
25755
|
+
if (!usageStore)
|
|
25756
|
+
return json({ error: "usage store not active" }, 503);
|
|
25757
|
+
const device = resolveDeviceName(url.searchParams.get("device") ?? undefined);
|
|
25758
|
+
const sinceTs = (days) => Date.now() - days * 86400000;
|
|
25759
|
+
if (p === "/api/usage/um-users") {
|
|
25760
|
+
return json({ users: usageStore.umUsers(device) });
|
|
25761
|
+
}
|
|
25762
|
+
if (p === "/api/usage/client") {
|
|
25763
|
+
const ip = url.searchParams.get("ip");
|
|
25764
|
+
if (!ip)
|
|
25765
|
+
return json({ error: "ip required" }, 400);
|
|
25766
|
+
const series = usageStore.clientDailyUsage(device, ip, sinceTs(daysParam(url, 90, 400)));
|
|
25767
|
+
return json(withTotals(series));
|
|
25768
|
+
}
|
|
25769
|
+
if (p === "/api/usage/um-user") {
|
|
25770
|
+
const user = url.searchParams.get("user");
|
|
25771
|
+
if (!user)
|
|
25772
|
+
return json({ error: "user required" }, 400);
|
|
25773
|
+
const series = usageStore.umUserDailyUsage(device, user, sinceTs(daysParam(url, 90, 400)));
|
|
25774
|
+
return json(withTotals(series));
|
|
25775
|
+
}
|
|
25776
|
+
if (p === "/api/usage/heatmap") {
|
|
25777
|
+
const user = url.searchParams.get("user");
|
|
25778
|
+
const days = usageStore.heatmap(device, user || null, sinceTs(daysParam(url, 371, 400)));
|
|
25779
|
+
const total = days.reduce((s, d) => s + d.count, 0);
|
|
25780
|
+
const max = days.reduce((m, d) => Math.max(m, d.count), 0);
|
|
25781
|
+
return json({ days, total, max });
|
|
25782
|
+
}
|
|
25783
|
+
return null;
|
|
25784
|
+
}
|
|
25785
|
+
function withTotals(series) {
|
|
25786
|
+
return {
|
|
25787
|
+
series,
|
|
25788
|
+
totalRx: series.reduce((s, d) => s + d.rx, 0),
|
|
25789
|
+
totalTx: series.reduce((s, d) => s + d.tx, 0)
|
|
25790
|
+
};
|
|
25791
|
+
}
|
|
25531
25792
|
var snapStorePromise = null;
|
|
25532
25793
|
function snapStore() {
|
|
25533
25794
|
if (!snapStorePromise)
|
|
@@ -25746,11 +26007,17 @@ async function runDashboard(cfg, transportLabel) {
|
|
|
25746
26007
|
transport: transportLabel
|
|
25747
26008
|
});
|
|
25748
26009
|
startHealthChecks(30000);
|
|
26010
|
+
try {
|
|
26011
|
+
usageStore = await openUsageStore(join6(dirname6(cfg.dbPath), "usage.db"));
|
|
26012
|
+
startUsageSampler(usageStore);
|
|
26013
|
+
} catch (e) {
|
|
26014
|
+
logger.warn(`[${SERVER_TAG2}] usage history disabled: ${String(e)}`);
|
|
26015
|
+
}
|
|
25749
26016
|
try {
|
|
25750
26017
|
if (isEmpty2())
|
|
25751
26018
|
recordVersion(getConfig(), "auto", Date.now(), "baseline");
|
|
25752
26019
|
} catch (e) {
|
|
25753
|
-
logger.warn(`[${
|
|
26020
|
+
logger.warn(`[${SERVER_TAG2}] could not seed config history baseline: ${String(e)}`);
|
|
25754
26021
|
}
|
|
25755
26022
|
const configAdmin = createConfigAdmin({
|
|
25756
26023
|
getConfig,
|
|
@@ -25808,6 +26075,9 @@ async function runDashboard(cfg, transportLabel) {
|
|
|
25808
26075
|
const aaaResp = await aaaRoutes(req, url);
|
|
25809
26076
|
if (aaaResp)
|
|
25810
26077
|
return aaaResp;
|
|
26078
|
+
const usageResp = usageRoutes(req, url);
|
|
26079
|
+
if (usageResp)
|
|
26080
|
+
return usageResp;
|
|
25811
26081
|
const featureResp = await featureRoutes(req, url);
|
|
25812
26082
|
if (featureResp)
|
|
25813
26083
|
return featureResp;
|
|
@@ -25894,8 +26164,11 @@ async function runDashboard(cfg, transportLabel) {
|
|
|
25894
26164
|
store: store2,
|
|
25895
26165
|
stop() {
|
|
25896
26166
|
stopHealthChecks();
|
|
26167
|
+
stopUsageSampler();
|
|
25897
26168
|
server.stop(true);
|
|
25898
26169
|
store2.close();
|
|
26170
|
+
usageStore?.close();
|
|
26171
|
+
usageStore = null;
|
|
25899
26172
|
}
|
|
25900
26173
|
};
|
|
25901
26174
|
}
|
|
@@ -26052,7 +26325,7 @@ function registerPrompts(server) {
|
|
|
26052
26325
|
// package.json
|
|
26053
26326
|
var package_default = {
|
|
26054
26327
|
name: "@usex/mikrotik-mcp",
|
|
26055
|
-
version: "3.
|
|
26328
|
+
version: "3.26.0",
|
|
26056
26329
|
description: "MCP server for MikroTik RouterOS \u2014 660+ tools over SSH for firewall, NAT, routing, DHCP, DNS, WireGuard, wireless, QoS and more.",
|
|
26057
26330
|
keywords: [
|
|
26058
26331
|
"ai",
|
package/dist/index.js
CHANGED
|
@@ -24339,7 +24339,7 @@ function selectToolModules(filter = {}, catalog = moduleCatalog) {
|
|
|
24339
24339
|
// package.json
|
|
24340
24340
|
var package_default = {
|
|
24341
24341
|
name: "@usex/mikrotik-mcp",
|
|
24342
|
-
version: "3.
|
|
24342
|
+
version: "3.26.0",
|
|
24343
24343
|
description: "MCP server for MikroTik RouterOS \u2014 660+ tools over SSH for firewall, NAT, routing, DHCP, DNS, WireGuard, wireless, QoS and more.",
|
|
24344
24344
|
keywords: [
|
|
24345
24345
|
"ai",
|