@usex/mikrotik-mcp 4.16.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 +314 -32
- 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)));
|
|
@@ -1216,9 +1393,9 @@ async function ingestSessions(store, device) {
|
|
|
1216
1393
|
store.upsertSessions(device, sessions);
|
|
1217
1394
|
}
|
|
1218
1395
|
async function sampleUsageOnce(store) {
|
|
1219
|
-
if (
|
|
1396
|
+
if (inFlight3)
|
|
1220
1397
|
return;
|
|
1221
|
-
|
|
1398
|
+
inFlight3 = true;
|
|
1222
1399
|
const ts = Date.now();
|
|
1223
1400
|
try {
|
|
1224
1401
|
const cfg = getConfig();
|
|
@@ -1229,37 +1406,37 @@ async function sampleUsageOnce(store) {
|
|
|
1229
1406
|
await sampleClients(store, name, ts);
|
|
1230
1407
|
await ingestSessions(store, name);
|
|
1231
1408
|
} catch (e) {
|
|
1232
|
-
logger.warn(`[${
|
|
1409
|
+
logger.warn(`[${SERVER_TAG2}] usage sample failed for '${name}': ${String(e)}`);
|
|
1233
1410
|
}
|
|
1234
1411
|
}));
|
|
1235
1412
|
store.pruneSamples(ts - USAGE_RETENTION_MS);
|
|
1236
1413
|
} finally {
|
|
1237
|
-
|
|
1414
|
+
inFlight3 = false;
|
|
1238
1415
|
}
|
|
1239
1416
|
}
|
|
1240
1417
|
function startUsageSampler(store, intervalMs = DEFAULT_USAGE_INTERVAL_MS) {
|
|
1241
1418
|
currentStore = store;
|
|
1242
|
-
currentIntervalMs =
|
|
1419
|
+
currentIntervalMs = clampInterval2(intervalMs);
|
|
1243
1420
|
sampleUsageOnce(store);
|
|
1244
|
-
|
|
1421
|
+
timer3 = setInterval(() => void sampleUsageOnce(store), currentIntervalMs);
|
|
1245
1422
|
}
|
|
1246
1423
|
function getUsageSamplerInterval() {
|
|
1247
1424
|
return currentIntervalMs;
|
|
1248
1425
|
}
|
|
1249
1426
|
function setUsageSamplerInterval(intervalMs) {
|
|
1250
|
-
currentIntervalMs =
|
|
1251
|
-
if (
|
|
1252
|
-
clearInterval(
|
|
1427
|
+
currentIntervalMs = clampInterval2(intervalMs);
|
|
1428
|
+
if (timer3)
|
|
1429
|
+
clearInterval(timer3);
|
|
1253
1430
|
if (currentStore) {
|
|
1254
1431
|
const store = currentStore;
|
|
1255
|
-
|
|
1432
|
+
timer3 = setInterval(() => void sampleUsageOnce(store), currentIntervalMs);
|
|
1256
1433
|
}
|
|
1257
1434
|
return currentIntervalMs;
|
|
1258
1435
|
}
|
|
1259
1436
|
function stopUsageSampler() {
|
|
1260
|
-
if (
|
|
1261
|
-
clearInterval(
|
|
1262
|
-
|
|
1437
|
+
if (timer3) {
|
|
1438
|
+
clearInterval(timer3);
|
|
1439
|
+
timer3 = null;
|
|
1263
1440
|
}
|
|
1264
1441
|
currentStore = null;
|
|
1265
1442
|
}
|
|
@@ -1621,7 +1798,7 @@ async function memoryRoutes(req, url) {
|
|
|
1621
1798
|
}
|
|
1622
1799
|
|
|
1623
1800
|
// src/observability/dashboard.ts
|
|
1624
|
-
var
|
|
1801
|
+
var SERVER_TAG3 = "mikrotik-mcp";
|
|
1625
1802
|
var JSON_HEADERS3 = { "content-type": "application/json; charset=utf-8" };
|
|
1626
1803
|
function json3(body, status = 200) {
|
|
1627
1804
|
return new Response(JSON.stringify(body), { status, headers: JSON_HEADERS3 });
|
|
@@ -1652,7 +1829,7 @@ function runUpgrade(spec) {
|
|
|
1652
1829
|
env: process.env,
|
|
1653
1830
|
stdio: ["ignore", "pipe", "pipe"]
|
|
1654
1831
|
});
|
|
1655
|
-
const
|
|
1832
|
+
const timer4 = setTimeout(() => {
|
|
1656
1833
|
child.kill();
|
|
1657
1834
|
resolve({ ok: false, log: `${out}
|
|
1658
1835
|
(timed out after 180s)` });
|
|
@@ -1660,12 +1837,12 @@ function runUpgrade(spec) {
|
|
|
1660
1837
|
child.stdout?.on("data", (d) => out += d.toString());
|
|
1661
1838
|
child.stderr?.on("data", (d) => out += d.toString());
|
|
1662
1839
|
child.on("error", (e) => {
|
|
1663
|
-
clearTimeout(
|
|
1840
|
+
clearTimeout(timer4);
|
|
1664
1841
|
resolve({ ok: false, log: `${out}
|
|
1665
1842
|
spawn error: ${String(e)}` });
|
|
1666
1843
|
});
|
|
1667
1844
|
child.on("exit", (code) => {
|
|
1668
|
-
clearTimeout(
|
|
1845
|
+
clearTimeout(timer4);
|
|
1669
1846
|
resolve({ ok: code === 0, log: out.trim() || `(exit ${code})` });
|
|
1670
1847
|
});
|
|
1671
1848
|
});
|
|
@@ -1782,7 +1959,7 @@ function devicesPayload(store) {
|
|
|
1782
1959
|
}
|
|
1783
1960
|
};
|
|
1784
1961
|
});
|
|
1785
|
-
return { server:
|
|
1962
|
+
return { server: SERVER_TAG3, defaultDevice: cfg.defaultDevice, devices };
|
|
1786
1963
|
}
|
|
1787
1964
|
function topologyPayload() {
|
|
1788
1965
|
const cfg = getConfig();
|
|
@@ -1795,7 +1972,7 @@ function topologyPayload() {
|
|
|
1795
1972
|
for (const { name } of devices)
|
|
1796
1973
|
neighborsByDevice[name] = getDeviceNeighbors(name);
|
|
1797
1974
|
return {
|
|
1798
|
-
server:
|
|
1975
|
+
server: SERVER_TAG3,
|
|
1799
1976
|
defaultDevice: cfg.defaultDevice,
|
|
1800
1977
|
generatedAt: Date.now(),
|
|
1801
1978
|
...buildTopology({ devices, neighborsByDevice })
|
|
@@ -2178,6 +2355,99 @@ async function captureRoutes(req, url) {
|
|
|
2178
2355
|
}
|
|
2179
2356
|
return null;
|
|
2180
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
|
+
}
|
|
2181
2451
|
async function clientsRoutes(req, url) {
|
|
2182
2452
|
const p = url.pathname;
|
|
2183
2453
|
if (!p.startsWith("/api/clients"))
|
|
@@ -2566,16 +2836,22 @@ async function runDashboard(cfg, transportLabel) {
|
|
|
2566
2836
|
});
|
|
2567
2837
|
startHealthChecks(30000);
|
|
2568
2838
|
try {
|
|
2569
|
-
usageStore = await openUsageStore(join3(
|
|
2839
|
+
usageStore = await openUsageStore(join3(dirname5(cfg.dbPath), "usage.db"));
|
|
2570
2840
|
startUsageSampler(usageStore);
|
|
2571
2841
|
} catch (e) {
|
|
2572
|
-
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)}`);
|
|
2573
2849
|
}
|
|
2574
2850
|
try {
|
|
2575
2851
|
if (isEmpty2())
|
|
2576
2852
|
recordVersion(getConfig(), "auto", Date.now(), "baseline");
|
|
2577
2853
|
} catch (e) {
|
|
2578
|
-
logger.warn(`[${
|
|
2854
|
+
logger.warn(`[${SERVER_TAG3}] could not seed config history baseline: ${String(e)}`);
|
|
2579
2855
|
}
|
|
2580
2856
|
const configAdmin = createConfigAdmin({
|
|
2581
2857
|
getConfig,
|
|
@@ -2622,6 +2898,9 @@ async function runDashboard(cfg, transportLabel) {
|
|
|
2622
2898
|
const captureResp = await captureRoutes(req, url);
|
|
2623
2899
|
if (captureResp)
|
|
2624
2900
|
return captureResp;
|
|
2901
|
+
const capsmanResp = await capsmanRoutes(req, url);
|
|
2902
|
+
if (capsmanResp)
|
|
2903
|
+
return capsmanResp;
|
|
2625
2904
|
const clientsResp = await clientsRoutes(req, url);
|
|
2626
2905
|
if (clientsResp)
|
|
2627
2906
|
return clientsResp;
|
|
@@ -2875,10 +3154,13 @@ async function runDashboard(cfg, transportLabel) {
|
|
|
2875
3154
|
stop() {
|
|
2876
3155
|
stopHealthChecks();
|
|
2877
3156
|
stopUsageSampler();
|
|
3157
|
+
stopCapsmanSampler();
|
|
2878
3158
|
server.stop(true);
|
|
2879
3159
|
store.close();
|
|
2880
3160
|
usageStore?.close();
|
|
2881
3161
|
usageStore = null;
|
|
3162
|
+
capsmanStore?.close();
|
|
3163
|
+
capsmanStore = null;
|
|
2882
3164
|
closeMemoryStore();
|
|
2883
3165
|
}
|
|
2884
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";
|