@usex/mikrotik-mcp 4.15.0 → 4.17.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 +1 -0
- package/dist/cli.js +322 -33
- package/dist/index.js +1 -1
- package/dist/shared/{cli-9s02dq5d.js → cli-1ftjz1jx.js} +1 -1
- package/dist/shared/{cli-w6ecrhxa.js → cli-v75jtv6p.js} +1016 -24
- package/dist/shared/{library-212xbkwr.js → library-qma8metz.js} +976 -23
- package/dist/shared/{library-c0fxxq85.js → library-ydfn3v4j.js} +1 -1
- package/dist/ui/observability.html +58 -58
- package/package.json +1 -1
- package/schemas/README.md +1 -1
- package/schemas/tool-catalog.json +321 -2
- package/schemas/tools/apply_capsman_channel_plan.json +20 -0
- package/schemas/tools/apply_capsman_fixes.json +24 -0
- package/schemas/tools/apply_capsman_load_balance.json +13 -0
- package/schemas/tools/audit_capsman_coverage.json +7 -0
- package/schemas/tools/audit_capsman_ft.json +7 -0
- package/schemas/tools/audit_capsman_ha.json +7 -0
- package/schemas/tools/audit_capsman_load.json +7 -0
- package/schemas/tools/enable_capsman_ft.json +24 -0
- package/schemas/tools/report_weak_signal_clients.json +14 -0
- package/schemas/tools/run_capsman_audit.json +28 -0
- package/schemas/tools/setup_capsman_ha.json +17 -0
- package/schemas/tools/steer_client.json +37 -0
package/README.md
CHANGED
|
@@ -385,6 +385,7 @@ source** (`bun run gen:schemas`) so they can never drift:
|
|
|
385
385
|
| **[Firewall Audit](docs/firewall-audit.md)** | Shadowed/broad/dead rules, risk-scored |
|
|
386
386
|
| **[Security Hardening](docs/security-hardening.md)** | Per-category audit+remediate, fix by finding_id, snapshot + Safe-Mode |
|
|
387
387
|
| **[Port-Scan Detection](docs/port-scan-detection.md)** | Detect+tag six scan signatures behind a trust-excluding jump-gate |
|
|
388
|
+
| **[CAPsMAN Orchestrator](docs/capsman.md)** | Wi-Fi fabric audit: coverage/co-channel, weak signal, load, FT & HA |
|
|
388
389
|
| **[Packet Capture Studio](docs/packet-capture.md)** | Live TZSP capture + pcap export |
|
|
389
390
|
| **[Discovery](docs/discovery.md)** | `bun run discover`, MNDP neighbours, topology map |
|
|
390
391
|
| **[Config Studio](docs/config-studio.md)** | Edit config in the dashboard with autocomplete |
|
package/dist/cli.js
CHANGED
|
@@ -25,11 +25,19 @@ import {
|
|
|
25
25
|
allToolModules,
|
|
26
26
|
allowDevice,
|
|
27
27
|
analyzeDrift,
|
|
28
|
+
applyWritesSafely,
|
|
28
29
|
attributeChanges,
|
|
29
30
|
backupDir,
|
|
30
31
|
blockDevice,
|
|
31
32
|
buildChangePlan,
|
|
33
|
+
buildChannelPlanCommands,
|
|
34
|
+
buildFtCommands,
|
|
35
|
+
buildHaCommands,
|
|
36
|
+
buildLoadBalanceCommands,
|
|
37
|
+
buildSteerCommands,
|
|
38
|
+
capsmanOverview,
|
|
32
39
|
capture,
|
|
40
|
+
captureSnapshot,
|
|
33
41
|
checkForUpdate,
|
|
34
42
|
closeAll,
|
|
35
43
|
closeMemoryStore,
|
|
@@ -46,6 +54,7 @@ import {
|
|
|
46
54
|
diffLines,
|
|
47
55
|
executeMikrotikCommand,
|
|
48
56
|
fetchAllReleases,
|
|
57
|
+
fetchCapsmanState,
|
|
49
58
|
fetchDevices,
|
|
50
59
|
fetchLatestRelease,
|
|
51
60
|
getConfig,
|
|
@@ -55,6 +64,7 @@ import {
|
|
|
55
64
|
getRadiusIncoming,
|
|
56
65
|
getS3Client,
|
|
57
66
|
getUmSettings,
|
|
67
|
+
haGuidance,
|
|
58
68
|
isEmpty,
|
|
59
69
|
isMacTelnetDevice,
|
|
60
70
|
isPoolEnabled,
|
|
@@ -62,6 +72,7 @@ import {
|
|
|
62
72
|
listAaaEntity,
|
|
63
73
|
listBackups,
|
|
64
74
|
listDevices,
|
|
75
|
+
loadBalancePlan,
|
|
65
76
|
loadConfig,
|
|
66
77
|
loadFileCacheSync,
|
|
67
78
|
logger,
|
|
@@ -87,10 +98,12 @@ import {
|
|
|
87
98
|
renameBackup,
|
|
88
99
|
renderPlan,
|
|
89
100
|
reopenMemoryStore,
|
|
101
|
+
reportWeakClients,
|
|
90
102
|
resetRadiusCounters,
|
|
91
103
|
resolveDeviceName,
|
|
92
104
|
restoreLocalBackup,
|
|
93
105
|
riskOf,
|
|
106
|
+
runCapsmanAudit,
|
|
94
107
|
s3Target,
|
|
95
108
|
sampleAllTraffic,
|
|
96
109
|
sampleDeviceTraffic,
|
|
@@ -102,13 +115,14 @@ import {
|
|
|
102
115
|
setRadiusIncoming,
|
|
103
116
|
setUmSettings,
|
|
104
117
|
splitCommands,
|
|
118
|
+
steerAlreadyPresent,
|
|
105
119
|
subscribe,
|
|
106
120
|
subscriberCount,
|
|
107
121
|
toggleAaaEntity,
|
|
108
122
|
updateAaaEntity,
|
|
109
123
|
updateSummaryLine,
|
|
110
124
|
writeBackup
|
|
111
|
-
} from "./shared/cli-
|
|
125
|
+
} from "./shared/cli-v75jtv6p.js";
|
|
112
126
|
|
|
113
127
|
// src/cli.ts
|
|
114
128
|
import { existsSync as existsSync2 } from "fs";
|
|
@@ -117,7 +131,7 @@ import { existsSync as existsSync2 } from "fs";
|
|
|
117
131
|
import { spawn } from "child_process";
|
|
118
132
|
import { readFileSync as readFileSync3 } from "fs";
|
|
119
133
|
import { homedir, networkInterfaces } from "os";
|
|
120
|
-
import { dirname as
|
|
134
|
+
import { dirname as dirname5, join as join3 } from "path";
|
|
121
135
|
var {serve } = globalThis.Bun;
|
|
122
136
|
import { z as z2 } from "zod";
|
|
123
137
|
|
|
@@ -1018,9 +1032,172 @@ async function openSqliteStore(path) {
|
|
|
1018
1032
|
return new SqliteEventStore(db);
|
|
1019
1033
|
}
|
|
1020
1034
|
|
|
1021
|
-
// src/observability/
|
|
1035
|
+
// src/observability/capsman-store.ts
|
|
1022
1036
|
import { mkdirSync as mkdirSync4 } from "fs";
|
|
1023
1037
|
import { dirname as dirname3 } from "path";
|
|
1038
|
+
function summariseRadioSamples(rows) {
|
|
1039
|
+
const byRadio = new Map;
|
|
1040
|
+
for (const r of rows) {
|
|
1041
|
+
const list = byRadio.get(r.radioId);
|
|
1042
|
+
if (list)
|
|
1043
|
+
list.push(r);
|
|
1044
|
+
else
|
|
1045
|
+
byRadio.set(r.radioId, [r]);
|
|
1046
|
+
}
|
|
1047
|
+
const series = [];
|
|
1048
|
+
for (const [radioId, list] of byRadio) {
|
|
1049
|
+
list.sort((a, b) => a.ts - b.ts);
|
|
1050
|
+
const last = list[list.length - 1];
|
|
1051
|
+
const points = list.map((r) => ({ ts: r.ts, clients: r.clients, channel: r.channel }));
|
|
1052
|
+
const peak = points.reduce((m, p) => Math.max(m, p.clients), 0);
|
|
1053
|
+
const total = points.reduce((s, p) => s + p.clients, 0);
|
|
1054
|
+
series.push({
|
|
1055
|
+
radioId,
|
|
1056
|
+
cap: last.cap,
|
|
1057
|
+
band: last.band,
|
|
1058
|
+
points,
|
|
1059
|
+
peak,
|
|
1060
|
+
avg: points.length ? Math.round(total / points.length) : 0
|
|
1061
|
+
});
|
|
1062
|
+
}
|
|
1063
|
+
return series.sort((a, b) => a.radioId.localeCompare(b.radioId));
|
|
1064
|
+
}
|
|
1065
|
+
var SCHEMA_STATEMENTS2 = [
|
|
1066
|
+
`CREATE TABLE IF NOT EXISTS capsman_samples (
|
|
1067
|
+
device TEXT NOT NULL,
|
|
1068
|
+
radio_id TEXT NOT NULL,
|
|
1069
|
+
cap TEXT NOT NULL,
|
|
1070
|
+
band TEXT NOT NULL,
|
|
1071
|
+
ts INTEGER NOT NULL,
|
|
1072
|
+
clients INTEGER NOT NULL,
|
|
1073
|
+
channel INTEGER
|
|
1074
|
+
)`,
|
|
1075
|
+
"CREATE INDEX IF NOT EXISTS idx_capsman_radio ON capsman_samples(device, radio_id, ts)",
|
|
1076
|
+
"CREATE INDEX IF NOT EXISTS idx_capsman_ts ON capsman_samples(ts)"
|
|
1077
|
+
];
|
|
1078
|
+
|
|
1079
|
+
class SqliteCapsmanStore {
|
|
1080
|
+
db;
|
|
1081
|
+
constructor(db) {
|
|
1082
|
+
this.db = db;
|
|
1083
|
+
db.run("PRAGMA journal_mode = WAL");
|
|
1084
|
+
db.run("PRAGMA synchronous = NORMAL");
|
|
1085
|
+
for (const stmt of SCHEMA_STATEMENTS2)
|
|
1086
|
+
db.run(stmt);
|
|
1087
|
+
}
|
|
1088
|
+
recordRadioSamples(device, ts, samples) {
|
|
1089
|
+
if (samples.length === 0)
|
|
1090
|
+
return;
|
|
1091
|
+
const insert = this.db.query(`INSERT INTO capsman_samples (device, radio_id, cap, band, ts, clients, channel)
|
|
1092
|
+
VALUES ($d,$r,$cap,$band,$ts,$c,$ch)`);
|
|
1093
|
+
const tx = this.db.transaction((rows) => {
|
|
1094
|
+
for (const r of rows) {
|
|
1095
|
+
insert.run({
|
|
1096
|
+
$d: device,
|
|
1097
|
+
$r: r.radioId,
|
|
1098
|
+
$cap: r.cap,
|
|
1099
|
+
$band: r.band,
|
|
1100
|
+
$ts: ts,
|
|
1101
|
+
$c: r.clients,
|
|
1102
|
+
$ch: r.channel ?? null
|
|
1103
|
+
});
|
|
1104
|
+
}
|
|
1105
|
+
});
|
|
1106
|
+
tx(samples);
|
|
1107
|
+
}
|
|
1108
|
+
radioSeries(device, sinceTs) {
|
|
1109
|
+
const rows = this.db.query(`SELECT radio_id AS radioId, cap, band, ts, clients, channel
|
|
1110
|
+
FROM capsman_samples WHERE device=$d AND ts>=$since ORDER BY ts ASC`).all({ $d: device, $since: sinceTs });
|
|
1111
|
+
return summariseRadioSamples(rows);
|
|
1112
|
+
}
|
|
1113
|
+
pruneSamples(olderThanTs) {
|
|
1114
|
+
const res = this.db.query("DELETE FROM capsman_samples WHERE ts < $t").run({ $t: olderThanTs });
|
|
1115
|
+
return Number(res.changes ?? 0);
|
|
1116
|
+
}
|
|
1117
|
+
close() {
|
|
1118
|
+
this.db.close();
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
async function openCapsmanStore(path) {
|
|
1122
|
+
if (path !== ":memory:") {
|
|
1123
|
+
try {
|
|
1124
|
+
mkdirSync4(dirname3(path), { recursive: true });
|
|
1125
|
+
} catch {}
|
|
1126
|
+
}
|
|
1127
|
+
const { Database } = await import("bun:sqlite");
|
|
1128
|
+
const db = new Database(path, { create: true });
|
|
1129
|
+
return new SqliteCapsmanStore(db);
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
// src/observability/capsman-sampler.ts
|
|
1133
|
+
var SERVER_TAG = "mikrotik-mcp";
|
|
1134
|
+
var CAPSMAN_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
|
|
1135
|
+
var DEFAULT_CAPSMAN_INTERVAL_MS = 5 * 60000;
|
|
1136
|
+
var MIN_CAPSMAN_INTERVAL_MS = 60000;
|
|
1137
|
+
var MAX_CAPSMAN_INTERVAL_MS = 6 * 60 * 60000;
|
|
1138
|
+
var timer2 = null;
|
|
1139
|
+
var inFlight2 = false;
|
|
1140
|
+
var noCapsman = new Set;
|
|
1141
|
+
function clampInterval(ms) {
|
|
1142
|
+
if (!Number.isFinite(ms))
|
|
1143
|
+
return DEFAULT_CAPSMAN_INTERVAL_MS;
|
|
1144
|
+
return Math.max(MIN_CAPSMAN_INTERVAL_MS, Math.min(MAX_CAPSMAN_INTERVAL_MS, Math.round(ms)));
|
|
1145
|
+
}
|
|
1146
|
+
async function sampleDevice(store, device, ts) {
|
|
1147
|
+
if (noCapsman.has(device))
|
|
1148
|
+
return;
|
|
1149
|
+
const ctx = createContext(undefined, device);
|
|
1150
|
+
const state = await fetchCapsmanState(ctx);
|
|
1151
|
+
if (state.path === null) {
|
|
1152
|
+
noCapsman.add(device);
|
|
1153
|
+
return;
|
|
1154
|
+
}
|
|
1155
|
+
const overview = capsmanOverview(state);
|
|
1156
|
+
const samples = overview.radios.map((r) => ({
|
|
1157
|
+
radioId: r.radioId,
|
|
1158
|
+
cap: r.cap,
|
|
1159
|
+
band: r.band,
|
|
1160
|
+
channel: r.channel,
|
|
1161
|
+
clients: r.clientCount
|
|
1162
|
+
}));
|
|
1163
|
+
store.recordRadioSamples(device, ts, samples);
|
|
1164
|
+
}
|
|
1165
|
+
async function sampleCapsmanOnce(store) {
|
|
1166
|
+
if (inFlight2)
|
|
1167
|
+
return;
|
|
1168
|
+
inFlight2 = true;
|
|
1169
|
+
const ts = Date.now();
|
|
1170
|
+
try {
|
|
1171
|
+
const cfg = getConfig();
|
|
1172
|
+
await Promise.all(Object.entries(cfg.devices).map(async ([name, dc]) => {
|
|
1173
|
+
if (dc.mac)
|
|
1174
|
+
return;
|
|
1175
|
+
try {
|
|
1176
|
+
await sampleDevice(store, name, ts);
|
|
1177
|
+
} catch (e) {
|
|
1178
|
+
logger.warn(`[${SERVER_TAG}] capsman sample failed for '${name}': ${String(e)}`);
|
|
1179
|
+
}
|
|
1180
|
+
}));
|
|
1181
|
+
store.pruneSamples(ts - CAPSMAN_RETENTION_MS);
|
|
1182
|
+
} finally {
|
|
1183
|
+
inFlight2 = false;
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
function startCapsmanSampler(store, intervalMs = DEFAULT_CAPSMAN_INTERVAL_MS) {
|
|
1187
|
+
const ms = clampInterval(intervalMs);
|
|
1188
|
+
sampleCapsmanOnce(store);
|
|
1189
|
+
timer2 = setInterval(() => void sampleCapsmanOnce(store), ms);
|
|
1190
|
+
}
|
|
1191
|
+
function stopCapsmanSampler() {
|
|
1192
|
+
if (timer2) {
|
|
1193
|
+
clearInterval(timer2);
|
|
1194
|
+
timer2 = null;
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
// src/observability/usage-store.ts
|
|
1199
|
+
import { mkdirSync as mkdirSync5 } from "fs";
|
|
1200
|
+
import { dirname as dirname4 } from "path";
|
|
1024
1201
|
function dayOf(ts) {
|
|
1025
1202
|
return new Date(ts).toISOString().slice(0, 10);
|
|
1026
1203
|
}
|
|
@@ -1039,7 +1216,7 @@ function dailyUsageFromSamples(samples) {
|
|
|
1039
1216
|
}
|
|
1040
1217
|
return [...byDay.entries()].map(([day, v]) => ({ day, rx: v.rx, tx: v.tx })).sort((a, b) => a.day.localeCompare(b.day));
|
|
1041
1218
|
}
|
|
1042
|
-
var
|
|
1219
|
+
var SCHEMA_STATEMENTS3 = [
|
|
1043
1220
|
`CREATE TABLE IF NOT EXISTS usage_samples (
|
|
1044
1221
|
device TEXT NOT NULL,
|
|
1045
1222
|
subject TEXT NOT NULL,
|
|
@@ -1071,7 +1248,7 @@ class SqliteUsageStore {
|
|
|
1071
1248
|
this.db = db;
|
|
1072
1249
|
db.run("PRAGMA journal_mode = WAL");
|
|
1073
1250
|
db.run("PRAGMA synchronous = NORMAL");
|
|
1074
|
-
for (const stmt of
|
|
1251
|
+
for (const stmt of SCHEMA_STATEMENTS3)
|
|
1075
1252
|
db.run(stmt);
|
|
1076
1253
|
}
|
|
1077
1254
|
recordClientSamples(device, ts, samples) {
|
|
@@ -1140,7 +1317,7 @@ class SqliteUsageStore {
|
|
|
1140
1317
|
async function openUsageStore(path) {
|
|
1141
1318
|
if (path !== ":memory:") {
|
|
1142
1319
|
try {
|
|
1143
|
-
|
|
1320
|
+
mkdirSync5(dirname4(path), { recursive: true });
|
|
1144
1321
|
} catch {}
|
|
1145
1322
|
}
|
|
1146
1323
|
const { Database } = await import("bun:sqlite");
|
|
@@ -1149,16 +1326,16 @@ async function openUsageStore(path) {
|
|
|
1149
1326
|
}
|
|
1150
1327
|
|
|
1151
1328
|
// src/observability/usage-sampler.ts
|
|
1152
|
-
var
|
|
1329
|
+
var SERVER_TAG2 = "mikrotik-mcp";
|
|
1153
1330
|
var USAGE_RETENTION_MS = 93 * 24 * 60 * 60 * 1000;
|
|
1154
1331
|
var DEFAULT_USAGE_INTERVAL_MS = 60000;
|
|
1155
1332
|
var MIN_USAGE_INTERVAL_MS = 30000;
|
|
1156
1333
|
var MAX_USAGE_INTERVAL_MS = 6 * 60 * 60000;
|
|
1157
|
-
var
|
|
1158
|
-
var
|
|
1334
|
+
var timer3 = null;
|
|
1335
|
+
var inFlight3 = false;
|
|
1159
1336
|
var currentStore = null;
|
|
1160
1337
|
var currentIntervalMs = DEFAULT_USAGE_INTERVAL_MS;
|
|
1161
|
-
function
|
|
1338
|
+
function clampInterval2(ms) {
|
|
1162
1339
|
if (!Number.isFinite(ms))
|
|
1163
1340
|
return DEFAULT_USAGE_INTERVAL_MS;
|
|
1164
1341
|
return Math.max(MIN_USAGE_INTERVAL_MS, Math.min(MAX_USAGE_INTERVAL_MS, Math.round(ms)));
|
|
@@ -1184,10 +1361,17 @@ async function sampleClients(store, device, ts) {
|
|
|
1184
1361
|
}
|
|
1185
1362
|
store.recordClientSamples(device, ts, samples);
|
|
1186
1363
|
}
|
|
1364
|
+
var noUserManager = new Set;
|
|
1187
1365
|
async function ingestSessions(store, device) {
|
|
1366
|
+
if (noUserManager.has(device))
|
|
1367
|
+
return;
|
|
1188
1368
|
const ctx = createContext(undefined, device);
|
|
1189
1369
|
const out = await executeMikrotikCommand("/user-manager session print detail", ctx);
|
|
1190
|
-
if (
|
|
1370
|
+
if (commandUnsupported(out)) {
|
|
1371
|
+
noUserManager.add(device);
|
|
1372
|
+
return;
|
|
1373
|
+
}
|
|
1374
|
+
if (isEmpty(out) || looksLikeError(out))
|
|
1191
1375
|
return;
|
|
1192
1376
|
const sessions = [];
|
|
1193
1377
|
for (const row of parseRecords(out).rows) {
|
|
@@ -1209,9 +1393,9 @@ async function ingestSessions(store, device) {
|
|
|
1209
1393
|
store.upsertSessions(device, sessions);
|
|
1210
1394
|
}
|
|
1211
1395
|
async function sampleUsageOnce(store) {
|
|
1212
|
-
if (
|
|
1396
|
+
if (inFlight3)
|
|
1213
1397
|
return;
|
|
1214
|
-
|
|
1398
|
+
inFlight3 = true;
|
|
1215
1399
|
const ts = Date.now();
|
|
1216
1400
|
try {
|
|
1217
1401
|
const cfg = getConfig();
|
|
@@ -1222,37 +1406,37 @@ async function sampleUsageOnce(store) {
|
|
|
1222
1406
|
await sampleClients(store, name, ts);
|
|
1223
1407
|
await ingestSessions(store, name);
|
|
1224
1408
|
} catch (e) {
|
|
1225
|
-
logger.warn(`[${
|
|
1409
|
+
logger.warn(`[${SERVER_TAG2}] usage sample failed for '${name}': ${String(e)}`);
|
|
1226
1410
|
}
|
|
1227
1411
|
}));
|
|
1228
1412
|
store.pruneSamples(ts - USAGE_RETENTION_MS);
|
|
1229
1413
|
} finally {
|
|
1230
|
-
|
|
1414
|
+
inFlight3 = false;
|
|
1231
1415
|
}
|
|
1232
1416
|
}
|
|
1233
1417
|
function startUsageSampler(store, intervalMs = DEFAULT_USAGE_INTERVAL_MS) {
|
|
1234
1418
|
currentStore = store;
|
|
1235
|
-
currentIntervalMs =
|
|
1419
|
+
currentIntervalMs = clampInterval2(intervalMs);
|
|
1236
1420
|
sampleUsageOnce(store);
|
|
1237
|
-
|
|
1421
|
+
timer3 = setInterval(() => void sampleUsageOnce(store), currentIntervalMs);
|
|
1238
1422
|
}
|
|
1239
1423
|
function getUsageSamplerInterval() {
|
|
1240
1424
|
return currentIntervalMs;
|
|
1241
1425
|
}
|
|
1242
1426
|
function setUsageSamplerInterval(intervalMs) {
|
|
1243
|
-
currentIntervalMs =
|
|
1244
|
-
if (
|
|
1245
|
-
clearInterval(
|
|
1427
|
+
currentIntervalMs = clampInterval2(intervalMs);
|
|
1428
|
+
if (timer3)
|
|
1429
|
+
clearInterval(timer3);
|
|
1246
1430
|
if (currentStore) {
|
|
1247
1431
|
const store = currentStore;
|
|
1248
|
-
|
|
1432
|
+
timer3 = setInterval(() => void sampleUsageOnce(store), currentIntervalMs);
|
|
1249
1433
|
}
|
|
1250
1434
|
return currentIntervalMs;
|
|
1251
1435
|
}
|
|
1252
1436
|
function stopUsageSampler() {
|
|
1253
|
-
if (
|
|
1254
|
-
clearInterval(
|
|
1255
|
-
|
|
1437
|
+
if (timer3) {
|
|
1438
|
+
clearInterval(timer3);
|
|
1439
|
+
timer3 = null;
|
|
1256
1440
|
}
|
|
1257
1441
|
currentStore = null;
|
|
1258
1442
|
}
|
|
@@ -1614,7 +1798,7 @@ async function memoryRoutes(req, url) {
|
|
|
1614
1798
|
}
|
|
1615
1799
|
|
|
1616
1800
|
// src/observability/dashboard.ts
|
|
1617
|
-
var
|
|
1801
|
+
var SERVER_TAG3 = "mikrotik-mcp";
|
|
1618
1802
|
var JSON_HEADERS3 = { "content-type": "application/json; charset=utf-8" };
|
|
1619
1803
|
function json3(body, status = 200) {
|
|
1620
1804
|
return new Response(JSON.stringify(body), { status, headers: JSON_HEADERS3 });
|
|
@@ -1645,7 +1829,7 @@ function runUpgrade(spec) {
|
|
|
1645
1829
|
env: process.env,
|
|
1646
1830
|
stdio: ["ignore", "pipe", "pipe"]
|
|
1647
1831
|
});
|
|
1648
|
-
const
|
|
1832
|
+
const timer4 = setTimeout(() => {
|
|
1649
1833
|
child.kill();
|
|
1650
1834
|
resolve({ ok: false, log: `${out}
|
|
1651
1835
|
(timed out after 180s)` });
|
|
@@ -1653,12 +1837,12 @@ function runUpgrade(spec) {
|
|
|
1653
1837
|
child.stdout?.on("data", (d) => out += d.toString());
|
|
1654
1838
|
child.stderr?.on("data", (d) => out += d.toString());
|
|
1655
1839
|
child.on("error", (e) => {
|
|
1656
|
-
clearTimeout(
|
|
1840
|
+
clearTimeout(timer4);
|
|
1657
1841
|
resolve({ ok: false, log: `${out}
|
|
1658
1842
|
spawn error: ${String(e)}` });
|
|
1659
1843
|
});
|
|
1660
1844
|
child.on("exit", (code) => {
|
|
1661
|
-
clearTimeout(
|
|
1845
|
+
clearTimeout(timer4);
|
|
1662
1846
|
resolve({ ok: code === 0, log: out.trim() || `(exit ${code})` });
|
|
1663
1847
|
});
|
|
1664
1848
|
});
|
|
@@ -1775,7 +1959,7 @@ function devicesPayload(store) {
|
|
|
1775
1959
|
}
|
|
1776
1960
|
};
|
|
1777
1961
|
});
|
|
1778
|
-
return { server:
|
|
1962
|
+
return { server: SERVER_TAG3, defaultDevice: cfg.defaultDevice, devices };
|
|
1779
1963
|
}
|
|
1780
1964
|
function topologyPayload() {
|
|
1781
1965
|
const cfg = getConfig();
|
|
@@ -1788,7 +1972,7 @@ function topologyPayload() {
|
|
|
1788
1972
|
for (const { name } of devices)
|
|
1789
1973
|
neighborsByDevice[name] = getDeviceNeighbors(name);
|
|
1790
1974
|
return {
|
|
1791
|
-
server:
|
|
1975
|
+
server: SERVER_TAG3,
|
|
1792
1976
|
defaultDevice: cfg.defaultDevice,
|
|
1793
1977
|
generatedAt: Date.now(),
|
|
1794
1978
|
...buildTopology({ devices, neighborsByDevice })
|
|
@@ -2171,6 +2355,99 @@ async function captureRoutes(req, url) {
|
|
|
2171
2355
|
}
|
|
2172
2356
|
return null;
|
|
2173
2357
|
}
|
|
2358
|
+
var capsmanStore = null;
|
|
2359
|
+
async function capsmanRoutes(req, url) {
|
|
2360
|
+
const p = url.pathname;
|
|
2361
|
+
if (!p.startsWith("/api/capsman"))
|
|
2362
|
+
return null;
|
|
2363
|
+
if (req.method === "GET") {
|
|
2364
|
+
const ctx = createContext(undefined, url.searchParams.get("device") ?? undefined);
|
|
2365
|
+
if (p === "/api/capsman/overview") {
|
|
2366
|
+
return json3(capsmanOverview(await fetchCapsmanState(ctx)));
|
|
2367
|
+
}
|
|
2368
|
+
if (p === "/api/capsman/clients") {
|
|
2369
|
+
const state = await fetchCapsmanState(ctx);
|
|
2370
|
+
const weakDbm = Number.parseInt(url.searchParams.get("weak_dbm") ?? "", 10);
|
|
2371
|
+
const weak = reportWeakClients(state, Number.isFinite(weakDbm) ? weakDbm : undefined);
|
|
2372
|
+
return json3({ clients: state.clients, weak });
|
|
2373
|
+
}
|
|
2374
|
+
if (p === "/api/capsman/audit") {
|
|
2375
|
+
return json3(runCapsmanAudit(await fetchCapsmanState(ctx)));
|
|
2376
|
+
}
|
|
2377
|
+
if (p === "/api/capsman/trends") {
|
|
2378
|
+
if (!capsmanStore)
|
|
2379
|
+
return json3({ error: "capsman store not active" }, 503);
|
|
2380
|
+
const device = resolveDeviceName(url.searchParams.get("device") ?? undefined);
|
|
2381
|
+
const days = daysParam(url, 7, 30);
|
|
2382
|
+
const series = capsmanStore.radioSeries(device, Date.now() - days * 86400000);
|
|
2383
|
+
return json3({ series, days });
|
|
2384
|
+
}
|
|
2385
|
+
return null;
|
|
2386
|
+
}
|
|
2387
|
+
if (req.method === "POST") {
|
|
2388
|
+
const body = await readJson(req);
|
|
2389
|
+
const ctx = createContext(undefined, body?.device);
|
|
2390
|
+
const state = await fetchCapsmanState(ctx);
|
|
2391
|
+
if (p === "/api/capsman/apply/steer") {
|
|
2392
|
+
const client = state.clients.find((c) => c.mac.toLowerCase() === (body.mac ?? "").toLowerCase());
|
|
2393
|
+
if (!client)
|
|
2394
|
+
return json3({ ok: false, error: "client not associated" }, 400);
|
|
2395
|
+
if (steerAlreadyPresent(state, body.mac ?? "")) {
|
|
2396
|
+
return json3({ ok: true, message: "already steered (no-op)" });
|
|
2397
|
+
}
|
|
2398
|
+
const mode = body.mode === "soft" ? "soft" : "hard";
|
|
2399
|
+
const commands = buildSteerCommands(state, body.mac ?? "", client.radioId, mode);
|
|
2400
|
+
if (!body.confirm)
|
|
2401
|
+
return json3({ ok: true, preview: commands });
|
|
2402
|
+
return json3(await applyCapsmanWrites(ctx, commands, `pre-steer-${body.mac}`));
|
|
2403
|
+
}
|
|
2404
|
+
if (p === "/api/capsman/apply/load-balance") {
|
|
2405
|
+
const plan = loadBalancePlan(state);
|
|
2406
|
+
const commands = buildLoadBalanceCommands(state, plan);
|
|
2407
|
+
if (!body.confirm)
|
|
2408
|
+
return json3({ ok: true, preview: commands, plan });
|
|
2409
|
+
return json3(await applyCapsmanWrites(ctx, commands, "pre-load-balance"));
|
|
2410
|
+
}
|
|
2411
|
+
if (p === "/api/capsman/apply/channel-plan") {
|
|
2412
|
+
if (state.path === "/caps-man") {
|
|
2413
|
+
return json3({ ok: false, error: "channel-plan apply is v7 /interface wifi only" }, 400);
|
|
2414
|
+
}
|
|
2415
|
+
const commands = buildChannelPlanCommands(state);
|
|
2416
|
+
if (!body.confirm)
|
|
2417
|
+
return json3({ ok: true, preview: commands });
|
|
2418
|
+
return json3(await applyCapsmanWrites(ctx, commands, "pre-channel-plan"));
|
|
2419
|
+
}
|
|
2420
|
+
if (p === "/api/capsman/apply/ft") {
|
|
2421
|
+
const commands = buildFtCommands(state);
|
|
2422
|
+
if (!body.confirm)
|
|
2423
|
+
return json3({ ok: true, preview: commands });
|
|
2424
|
+
return json3(await applyCapsmanWrites(ctx, commands, "pre-ft"));
|
|
2425
|
+
}
|
|
2426
|
+
if (p === "/api/capsman/apply/ha") {
|
|
2427
|
+
const commands = buildHaCommands(state);
|
|
2428
|
+
const guidance = haGuidance(state);
|
|
2429
|
+
if (!body.confirm)
|
|
2430
|
+
return json3({ ok: true, preview: commands, guidance });
|
|
2431
|
+
const res = await applyCapsmanWrites(ctx, commands, "pre-ha");
|
|
2432
|
+
return json3({ ...res, guidance });
|
|
2433
|
+
}
|
|
2434
|
+
}
|
|
2435
|
+
return null;
|
|
2436
|
+
}
|
|
2437
|
+
async function applyCapsmanWrites(ctx, commands, label) {
|
|
2438
|
+
if (commands.length === 0)
|
|
2439
|
+
return { ok: true, applied: 0 };
|
|
2440
|
+
const snapshotId = await captureSnapshot(ctx, label);
|
|
2441
|
+
const device = resolveDeviceName(ctx.device);
|
|
2442
|
+
const outcome = await applyWritesSafely(ctx, device, commands, { allowDirectFallback: false });
|
|
2443
|
+
return {
|
|
2444
|
+
ok: outcome.committed && !outcome.error,
|
|
2445
|
+
snapshotId,
|
|
2446
|
+
safeMode: outcome.safeMode,
|
|
2447
|
+
applied: outcome.applied,
|
|
2448
|
+
error: outcome.error
|
|
2449
|
+
};
|
|
2450
|
+
}
|
|
2174
2451
|
async function clientsRoutes(req, url) {
|
|
2175
2452
|
const p = url.pathname;
|
|
2176
2453
|
if (!p.startsWith("/api/clients"))
|
|
@@ -2559,16 +2836,22 @@ async function runDashboard(cfg, transportLabel) {
|
|
|
2559
2836
|
});
|
|
2560
2837
|
startHealthChecks(30000);
|
|
2561
2838
|
try {
|
|
2562
|
-
usageStore = await openUsageStore(join3(
|
|
2839
|
+
usageStore = await openUsageStore(join3(dirname5(cfg.dbPath), "usage.db"));
|
|
2563
2840
|
startUsageSampler(usageStore);
|
|
2564
2841
|
} catch (e) {
|
|
2565
|
-
logger.warn(`[${
|
|
2842
|
+
logger.warn(`[${SERVER_TAG3}] usage history disabled: ${String(e)}`);
|
|
2843
|
+
}
|
|
2844
|
+
try {
|
|
2845
|
+
capsmanStore = await openCapsmanStore(join3(dirname5(cfg.dbPath), "capsman.db"));
|
|
2846
|
+
startCapsmanSampler(capsmanStore);
|
|
2847
|
+
} catch (e) {
|
|
2848
|
+
logger.warn(`[${SERVER_TAG3}] capsman trends disabled: ${String(e)}`);
|
|
2566
2849
|
}
|
|
2567
2850
|
try {
|
|
2568
2851
|
if (isEmpty2())
|
|
2569
2852
|
recordVersion(getConfig(), "auto", Date.now(), "baseline");
|
|
2570
2853
|
} catch (e) {
|
|
2571
|
-
logger.warn(`[${
|
|
2854
|
+
logger.warn(`[${SERVER_TAG3}] could not seed config history baseline: ${String(e)}`);
|
|
2572
2855
|
}
|
|
2573
2856
|
const configAdmin = createConfigAdmin({
|
|
2574
2857
|
getConfig,
|
|
@@ -2615,6 +2898,9 @@ async function runDashboard(cfg, transportLabel) {
|
|
|
2615
2898
|
const captureResp = await captureRoutes(req, url);
|
|
2616
2899
|
if (captureResp)
|
|
2617
2900
|
return captureResp;
|
|
2901
|
+
const capsmanResp = await capsmanRoutes(req, url);
|
|
2902
|
+
if (capsmanResp)
|
|
2903
|
+
return capsmanResp;
|
|
2618
2904
|
const clientsResp = await clientsRoutes(req, url);
|
|
2619
2905
|
if (clientsResp)
|
|
2620
2906
|
return clientsResp;
|
|
@@ -2868,10 +3154,13 @@ async function runDashboard(cfg, transportLabel) {
|
|
|
2868
3154
|
stop() {
|
|
2869
3155
|
stopHealthChecks();
|
|
2870
3156
|
stopUsageSampler();
|
|
3157
|
+
stopCapsmanSampler();
|
|
2871
3158
|
server.stop(true);
|
|
2872
3159
|
store.close();
|
|
2873
3160
|
usageStore?.close();
|
|
2874
3161
|
usageStore = null;
|
|
3162
|
+
capsmanStore?.close();
|
|
3163
|
+
capsmanStore = null;
|
|
2875
3164
|
closeMemoryStore();
|
|
2876
3165
|
}
|
|
2877
3166
|
};
|
package/dist/index.js
CHANGED
|
@@ -29,7 +29,7 @@ import {
|
|
|
29
29
|
selectToolModules,
|
|
30
30
|
setConfig,
|
|
31
31
|
updateSummaryLine
|
|
32
|
-
} from "./shared/library-
|
|
32
|
+
} from "./shared/library-qma8metz.js";
|
|
33
33
|
// src/server.ts
|
|
34
34
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
35
35
|
import { ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|