@usex/mikrotik-mcp 4.24.0 → 5.0.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 +108 -51
- package/dist/cli.js +1839 -554
- package/dist/index.d.ts +57 -0
- package/dist/index.js +1 -1
- package/dist/shared/{library-2f8ty72a.js → cli-2dj736ma.js} +34321 -22476
- package/dist/shared/{cli-ps0h2mxx.js → cli-vvy7yfyg.js} +1 -1
- package/dist/shared/{cli-10wtq3xb.js → library-13dhfdnq.js} +29451 -18574
- package/dist/shared/{library-phwm9kqe.js → library-syt5fmtj.js} +1 -1
- package/dist/ui/observability.html +70 -62
- package/package.json +3 -2
- package/policies/baseline.yaml +180 -0
- package/prompts/backup-and-document.md +28 -18
- package/prompts/build-tunnel-transactionally.md +74 -0
- package/prompts/fleet-rollout.md +68 -0
- package/prompts/setup-traffic-flow.md +68 -0
- package/schemas/README.md +1 -1
- package/schemas/config.schema.json +367 -4
- package/schemas/tool-catalog.json +3044 -1152
- package/schemas/tools/abort_rollout.json +17 -0
- package/schemas/tools/abort_transaction.json +17 -0
- package/schemas/tools/add_alert_rule.json +45 -0
- package/schemas/tools/add_ipv6_route.json +1 -1
- package/schemas/tools/add_route.json +3 -2
- package/schemas/tools/add_schedule.json +50 -0
- package/schemas/tools/add_script.json +1 -1
- package/schemas/tools/add_traffic_flow_target.json +36 -0
- package/schemas/tools/add_transaction_step.json +21 -0
- package/schemas/tools/analyze_flows.json +25 -0
- package/schemas/tools/begin_transaction.json +109 -0
- package/schemas/tools/block_attacker.json +22 -0
- package/schemas/tools/check_policy_snapshot.json +20 -0
- package/schemas/tools/commit_transaction.json +13 -0
- package/schemas/tools/configure_attack_response.json +12 -0
- package/schemas/tools/diff_explanations.json +24 -0
- package/schemas/tools/explain_device.json +22 -0
- package/schemas/tools/explain_policy_finding.json +17 -0
- package/schemas/tools/explain_rule_reachability.json +18 -0
- package/schemas/tools/explain_section.json +32 -0
- package/schemas/tools/export_policy_report.json +22 -0
- package/schemas/tools/flow_top_talkers.json +31 -0
- package/schemas/tools/get_alert_history.json +26 -0
- package/schemas/tools/get_attack_incident.json +13 -0
- package/schemas/tools/get_audit_timeline.json +24 -0
- package/schemas/tools/get_device_capabilities.json +7 -0
- package/schemas/tools/get_traffic_flow_settings.json +7 -0
- package/schemas/tools/list_alert_rules.json +7 -0
- package/schemas/tools/list_attack_incidents.json +27 -0
- package/schemas/tools/list_attack_responses.json +20 -0
- package/schemas/tools/list_policies.json +17 -0
- package/schemas/tools/list_schedules.json +12 -0
- package/schemas/tools/list_traffic_flow_targets.json +7 -0
- package/schemas/tools/mute_alert_rule.json +18 -0
- package/schemas/tools/plan_rollout.json +95 -0
- package/schemas/tools/refresh_device_capabilities.json +7 -0
- package/schemas/tools/remove_alert_rule.json +13 -0
- package/schemas/tools/remove_schedule.json +13 -0
- package/schemas/tools/remove_traffic_flow_target.json +13 -0
- package/schemas/tools/rollout_status.json +12 -0
- package/schemas/tools/run_policy_check.json +12 -0
- package/schemas/tools/run_schedule_now.json +13 -0
- package/schemas/tools/scan_for_attacks.json +21 -0
- package/schemas/tools/set_traffic_flow_settings.json +28 -0
- package/schemas/tools/simulate_change.json +72 -0
- package/schemas/tools/simulate_packet.json +53 -0
- package/schemas/tools/simulate_suite.json +71 -0
- package/schemas/tools/start_flow_collector.json +14 -0
- package/schemas/tools/start_rollout.json +98 -0
- package/schemas/tools/stop_flow_collector.json +7 -0
- package/schemas/tools/test_alert_channel.json +14 -0
- package/schemas/tools/unblock_attacker.json +20 -0
- package/schemas/tools/update_alert_rule.json +41 -0
- package/schemas/tools/update_route.json +3 -2
- package/schemas/tools/validate_policy_file.json +16 -0
- package/schemas/tools/verify_transaction.json +13 -0
package/dist/cli.js
CHANGED
|
@@ -3,13 +3,17 @@
|
|
|
3
3
|
import {
|
|
4
4
|
AAA_ENTITIES,
|
|
5
5
|
ALWAYS_ON_MODULES,
|
|
6
|
+
AlertEngine,
|
|
7
|
+
AlertRuleSchema,
|
|
6
8
|
Cmd,
|
|
7
9
|
DEFAULT_CONFIG_HISTORY_DIR,
|
|
8
10
|
DEFAULT_SNAPSHOT_DB,
|
|
9
11
|
DEFAULT_TZSP_PORT,
|
|
10
12
|
DeviceConfigSchema,
|
|
13
|
+
JobSchema,
|
|
11
14
|
LOGO_URL,
|
|
12
15
|
MikrotikConfigSchema,
|
|
16
|
+
NARRATIVE_SECTIONS,
|
|
13
17
|
PKG_META,
|
|
14
18
|
PROJECT_ROOT,
|
|
15
19
|
PROMPTS_DIR,
|
|
@@ -25,17 +29,24 @@ import {
|
|
|
25
29
|
addAaaEntity,
|
|
26
30
|
allToolModules,
|
|
27
31
|
allowDevice,
|
|
32
|
+
analyzeDevice,
|
|
28
33
|
analyzeDrift,
|
|
29
34
|
applyWritesSafely,
|
|
35
|
+
armJob,
|
|
36
|
+
attackStore,
|
|
30
37
|
attributeChanges,
|
|
38
|
+
auditAdapter,
|
|
31
39
|
backupDir,
|
|
32
40
|
blockDevice,
|
|
33
41
|
buildChangePlan,
|
|
34
42
|
buildChannelPlanCommands,
|
|
35
43
|
buildFtCommands,
|
|
44
|
+
buildGuards,
|
|
36
45
|
buildHaCommands,
|
|
37
46
|
buildLoadBalanceCommands,
|
|
47
|
+
buildModel,
|
|
38
48
|
buildSteerCommands,
|
|
49
|
+
buildTopology,
|
|
39
50
|
capsmanOverview,
|
|
40
51
|
capture,
|
|
41
52
|
captureSnapshot,
|
|
@@ -45,35 +56,73 @@ import {
|
|
|
45
56
|
closeMemoryStore,
|
|
46
57
|
commandUnsupported,
|
|
47
58
|
configureRecorder,
|
|
59
|
+
conversations,
|
|
48
60
|
createContext,
|
|
49
61
|
createDeviceClient,
|
|
62
|
+
createDeviceExecutor,
|
|
63
|
+
createDeviceExecutor1 as createDeviceExecutor2,
|
|
50
64
|
createLocalBackup,
|
|
65
|
+
currentPolicySet,
|
|
66
|
+
decide,
|
|
51
67
|
deleteBackup,
|
|
68
|
+
deliver,
|
|
69
|
+
describeCron,
|
|
70
|
+
describeDiff,
|
|
52
71
|
describeTransport,
|
|
53
72
|
deviceDirectory,
|
|
54
73
|
deviceLabels,
|
|
55
74
|
devicesView,
|
|
75
|
+
diffFindings,
|
|
56
76
|
diffLines,
|
|
77
|
+
diffNarratives,
|
|
78
|
+
diffTraces,
|
|
79
|
+
dropRollout,
|
|
80
|
+
dropTxn,
|
|
81
|
+
evaluatePolicies,
|
|
82
|
+
eventMet,
|
|
57
83
|
executeMikrotikCommand,
|
|
84
|
+
executePlan,
|
|
85
|
+
explainUnmet,
|
|
58
86
|
fetchAllReleases,
|
|
59
87
|
fetchCapsmanState,
|
|
60
88
|
fetchDevices,
|
|
61
89
|
fetchLatestRelease,
|
|
90
|
+
flowStore,
|
|
91
|
+
forgetJob,
|
|
92
|
+
getAlertEngine,
|
|
93
|
+
getCapabilities,
|
|
62
94
|
getConfig,
|
|
63
95
|
getConfigSource,
|
|
96
|
+
getDeviceHistory,
|
|
97
|
+
getDeviceNeighbors,
|
|
98
|
+
getDeviceStatus,
|
|
64
99
|
getEventStore,
|
|
100
|
+
getFlowCollector,
|
|
65
101
|
getMemoryStore,
|
|
66
102
|
getRadiusIncoming,
|
|
103
|
+
getRollout,
|
|
67
104
|
getS3Client,
|
|
105
|
+
getTxn,
|
|
68
106
|
getUmSettings,
|
|
69
107
|
haGuidance,
|
|
108
|
+
initialState,
|
|
109
|
+
invalidateCapabilities,
|
|
70
110
|
isEmpty,
|
|
111
|
+
isEventTrigger,
|
|
71
112
|
isMacTelnetDevice,
|
|
113
|
+
isMuted,
|
|
114
|
+
isNeverBlock,
|
|
115
|
+
isPlan,
|
|
72
116
|
isPoolEnabled,
|
|
73
117
|
isS3Configured,
|
|
74
118
|
listAaaEntity,
|
|
75
119
|
listBackups,
|
|
76
120
|
listDevices,
|
|
121
|
+
listRollouts,
|
|
122
|
+
listTxns,
|
|
123
|
+
liveExecutor,
|
|
124
|
+
liveRolloutUpdates,
|
|
125
|
+
liveTxnUpdates,
|
|
77
126
|
loadBalancePlan,
|
|
78
127
|
loadConfig,
|
|
79
128
|
loadFileCacheSync,
|
|
@@ -81,51 +130,107 @@ import {
|
|
|
81
130
|
looksLikeError,
|
|
82
131
|
makeDeviceStatic,
|
|
83
132
|
moduleCatalog,
|
|
133
|
+
neverBlockSet,
|
|
134
|
+
nextRun,
|
|
135
|
+
nextRunOf,
|
|
84
136
|
normalizeExport,
|
|
137
|
+
noteDriftResult,
|
|
85
138
|
openSnapshotStore,
|
|
86
|
-
|
|
87
|
-
|
|
139
|
+
parseDuration,
|
|
140
|
+
parseExport,
|
|
88
141
|
parseLeadingNumber,
|
|
89
142
|
parseRecords,
|
|
90
143
|
parseRouterosDate,
|
|
144
|
+
parseRules,
|
|
91
145
|
parseSize,
|
|
92
|
-
|
|
146
|
+
peekCapabilities,
|
|
147
|
+
persistRollout,
|
|
148
|
+
persistTxn,
|
|
149
|
+
policyFromConfig,
|
|
93
150
|
poolStatus,
|
|
94
151
|
presignExpiresIn,
|
|
152
|
+
primeRiskIndex,
|
|
153
|
+
probeDevice,
|
|
154
|
+
protocolMix,
|
|
155
|
+
publishRollout,
|
|
95
156
|
readBackup,
|
|
96
157
|
redact,
|
|
158
|
+
redactChannels,
|
|
97
159
|
registerTools,
|
|
98
160
|
registerUiResources,
|
|
161
|
+
rememberReport,
|
|
99
162
|
removeAaaEntity,
|
|
100
163
|
removeDeviceLease,
|
|
101
164
|
renameBackup,
|
|
165
|
+
renderDiff,
|
|
166
|
+
renderNarrative,
|
|
102
167
|
renderPlan,
|
|
168
|
+
renderReport,
|
|
103
169
|
reopenMemoryStore,
|
|
104
170
|
reportWeakClients,
|
|
171
|
+
requestAbort,
|
|
172
|
+
requestAbort1 as requestAbort2,
|
|
173
|
+
requestHold,
|
|
105
174
|
resetRadiusCounters,
|
|
106
175
|
resolveDeviceName,
|
|
107
176
|
restoreLocalBackup,
|
|
177
|
+
resume,
|
|
178
|
+
revokeBlock,
|
|
108
179
|
riskOf,
|
|
180
|
+
rolloutStore,
|
|
181
|
+
ruleWarnings,
|
|
109
182
|
runCapsmanAudit,
|
|
183
|
+
runJob,
|
|
184
|
+
runRollout,
|
|
185
|
+
runTransaction,
|
|
186
|
+
runnerOptions,
|
|
110
187
|
s3Target,
|
|
111
188
|
sampleAllTraffic,
|
|
112
189
|
sampleDeviceTraffic,
|
|
190
|
+
schedulableTools,
|
|
191
|
+
scheduleStore,
|
|
113
192
|
selectToolModules,
|
|
193
|
+
serializeCapabilities,
|
|
194
|
+
setAlertEngine,
|
|
114
195
|
setConfig,
|
|
115
196
|
setDeviceIp,
|
|
116
197
|
setDeviceLabel,
|
|
117
198
|
setDeviceLimits,
|
|
199
|
+
setMcpAlertSender,
|
|
118
200
|
setRadiusIncoming,
|
|
119
201
|
setUmSettings,
|
|
202
|
+
severityCounts,
|
|
120
203
|
splitCommands,
|
|
204
|
+
startAttackDetection,
|
|
205
|
+
startHealthChecks,
|
|
206
|
+
startScheduler,
|
|
121
207
|
steerAlreadyPresent,
|
|
208
|
+
step,
|
|
209
|
+
stopAttackDetection,
|
|
210
|
+
stopHealthChecks,
|
|
211
|
+
stopScheduler,
|
|
122
212
|
subscribe,
|
|
213
|
+
subscribeRollout,
|
|
214
|
+
subscribeTxn,
|
|
123
215
|
subscriberCount,
|
|
216
|
+
summarize,
|
|
217
|
+
summarize1 as summarize2,
|
|
218
|
+
sweep,
|
|
219
|
+
timeline,
|
|
220
|
+
toRecord,
|
|
221
|
+
toRecord1 as toRecord2,
|
|
124
222
|
toggleAaaEntity,
|
|
223
|
+
topTalkers,
|
|
224
|
+
topologyMermaid,
|
|
225
|
+
tracePacket,
|
|
226
|
+
txnStore,
|
|
227
|
+
unreachableRules,
|
|
125
228
|
updateAaaEntity,
|
|
126
229
|
updateSummaryLine,
|
|
230
|
+
validatePolicyText,
|
|
231
|
+
worstSeverity,
|
|
127
232
|
writeBackup
|
|
128
|
-
} from "./shared/cli-
|
|
233
|
+
} from "./shared/cli-2dj736ma.js";
|
|
129
234
|
|
|
130
235
|
// src/cli.ts
|
|
131
236
|
import { existsSync as existsSync2 } from "fs";
|
|
@@ -134,11 +239,23 @@ import { existsSync as existsSync2 } from "fs";
|
|
|
134
239
|
import { spawn } from "child_process";
|
|
135
240
|
import { readFileSync as readFileSync3 } from "fs";
|
|
136
241
|
import { homedir as homedir3, networkInterfaces } from "os";
|
|
137
|
-
import { dirname as
|
|
242
|
+
import { dirname as dirname7, join as join4 } from "path";
|
|
138
243
|
var {serve } = globalThis.Bun;
|
|
139
244
|
import { z as z2 } from "zod";
|
|
140
245
|
|
|
141
246
|
// src/observability/modules.ts
|
|
247
|
+
function unsupportedDevices(requires) {
|
|
248
|
+
const out = [];
|
|
249
|
+
for (const name of Object.keys(getConfig().devices)) {
|
|
250
|
+
const caps = peekCapabilities(name);
|
|
251
|
+
if (!caps)
|
|
252
|
+
continue;
|
|
253
|
+
const reason = explainUnmet(caps, requires);
|
|
254
|
+
if (reason)
|
|
255
|
+
out.push({ device: name, reason });
|
|
256
|
+
}
|
|
257
|
+
return out;
|
|
258
|
+
}
|
|
142
259
|
var lcSet = (xs) => new Set((xs ?? []).map((s) => s.toLowerCase()));
|
|
143
260
|
function isModuleEnabled(filter, slug, group) {
|
|
144
261
|
const enabledModules = lcSet(filter.enabledModules);
|
|
@@ -157,14 +274,19 @@ function isModuleEnabled(filter, slug, group) {
|
|
|
157
274
|
return true;
|
|
158
275
|
}
|
|
159
276
|
function moduleSurface(filter, catalog = moduleCatalog) {
|
|
160
|
-
const modules = catalog.map((m) =>
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
277
|
+
const modules = catalog.map((m) => {
|
|
278
|
+
const requires = m.tools.find((t) => t.requires)?.requires;
|
|
279
|
+
const unsupported = requires ? unsupportedDevices(requires) : [];
|
|
280
|
+
return {
|
|
281
|
+
slug: m.slug,
|
|
282
|
+
label: m.label,
|
|
283
|
+
group: m.group,
|
|
284
|
+
description: m.description,
|
|
285
|
+
toolCount: m.tools.length,
|
|
286
|
+
enabled: isModuleEnabled(filter, m.slug, m.group),
|
|
287
|
+
...unsupported.length > 0 ? { unsupported } : {}
|
|
288
|
+
};
|
|
289
|
+
});
|
|
168
290
|
const hasAllowList = (filter.enabledModules?.length ?? 0) > 0 || (filter.enabledGroups?.length ?? 0) > 0;
|
|
169
291
|
return {
|
|
170
292
|
modules,
|
|
@@ -532,297 +654,13 @@ function registerPrompts(server, opts = {}) {
|
|
|
532
654
|
return count;
|
|
533
655
|
}
|
|
534
656
|
|
|
535
|
-
// src/observability/topology.ts
|
|
536
|
-
function normMac(mac) {
|
|
537
|
-
if (!mac)
|
|
538
|
-
return;
|
|
539
|
-
const hex = mac.replace(/[^0-9a-fA-F]/g, "").toUpperCase();
|
|
540
|
-
return hex.length ? hex : undefined;
|
|
541
|
-
}
|
|
542
|
-
function parseNeighbors(text) {
|
|
543
|
-
const { rows } = parseRecords(text);
|
|
544
|
-
const out = [];
|
|
545
|
-
for (const r of rows) {
|
|
546
|
-
const n = {
|
|
547
|
-
interface: r.interface || r["interface-name"] || undefined,
|
|
548
|
-
ip: r.address || r.address4 || undefined,
|
|
549
|
-
ipv6: r.address6 || undefined,
|
|
550
|
-
mac: r["mac-address"] || undefined,
|
|
551
|
-
identity: r.identity || undefined,
|
|
552
|
-
platform: r.platform || undefined,
|
|
553
|
-
board: r.board || undefined,
|
|
554
|
-
version: r.version || undefined
|
|
555
|
-
};
|
|
556
|
-
if (n.mac || n.ip || n.identity)
|
|
557
|
-
out.push(n);
|
|
558
|
-
}
|
|
559
|
-
return out;
|
|
560
|
-
}
|
|
561
|
-
function suggestName(n, taken) {
|
|
562
|
-
const base = n.identity && n.identity.trim().replace(/[^A-Za-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "") || (normMac(n.mac) ? `device-${normMac(n.mac).slice(-6).toLowerCase()}` : "") || (n.ip ? `device-${n.ip.replace(/\./g, "-")}` : "device");
|
|
563
|
-
let name = base || "device";
|
|
564
|
-
let i = 2;
|
|
565
|
-
while (taken.has(name))
|
|
566
|
-
name = `${base}-${i++}`;
|
|
567
|
-
taken.add(name);
|
|
568
|
-
return name;
|
|
569
|
-
}
|
|
570
|
-
function buildTopology(input) {
|
|
571
|
-
const macToDevice = new Map;
|
|
572
|
-
const ipToDevice = new Map;
|
|
573
|
-
const identityToDevice = new Map;
|
|
574
|
-
const nodes = new Map;
|
|
575
|
-
for (const { name, config, status } of input.devices) {
|
|
576
|
-
nodes.set(name, {
|
|
577
|
-
id: name,
|
|
578
|
-
kind: "device",
|
|
579
|
-
label: status.identity || name,
|
|
580
|
-
configured: true,
|
|
581
|
-
onboardable: false,
|
|
582
|
-
identity: status.identity,
|
|
583
|
-
ip: config.host,
|
|
584
|
-
mac: config.mac,
|
|
585
|
-
platform: "MikroTik",
|
|
586
|
-
board: status.boardName,
|
|
587
|
-
version: status.version,
|
|
588
|
-
reachable: status.reachable,
|
|
589
|
-
cpuLoad: status.cpuLoad,
|
|
590
|
-
memUsedPct: status.memUsedPct,
|
|
591
|
-
uptime: status.uptime
|
|
592
|
-
});
|
|
593
|
-
const m = normMac(config.mac);
|
|
594
|
-
if (m)
|
|
595
|
-
macToDevice.set(m, name);
|
|
596
|
-
if (config.host)
|
|
597
|
-
ipToDevice.set(config.host, name);
|
|
598
|
-
if (status.identity)
|
|
599
|
-
identityToDevice.set(status.identity.toLowerCase(), name);
|
|
600
|
-
identityToDevice.set(name.toLowerCase(), name);
|
|
601
|
-
}
|
|
602
|
-
const matchConfigured = (n) => {
|
|
603
|
-
const m = normMac(n.mac);
|
|
604
|
-
if (m && macToDevice.has(m))
|
|
605
|
-
return macToDevice.get(m);
|
|
606
|
-
if (n.ip && ipToDevice.has(n.ip))
|
|
607
|
-
return ipToDevice.get(n.ip);
|
|
608
|
-
if (n.identity && identityToDevice.has(n.identity.toLowerCase())) {
|
|
609
|
-
return identityToDevice.get(n.identity.toLowerCase());
|
|
610
|
-
}
|
|
611
|
-
return;
|
|
612
|
-
};
|
|
613
|
-
const edges = [];
|
|
614
|
-
const edgeKeys = new Set;
|
|
615
|
-
const takenNames = new Set(input.devices.map((d) => d.name));
|
|
616
|
-
const addEdge = (from, to, iface) => {
|
|
617
|
-
if (from === to)
|
|
618
|
-
return;
|
|
619
|
-
const key = `${from}\x00${to}\x00${iface ?? ""}`;
|
|
620
|
-
if (edgeKeys.has(key))
|
|
621
|
-
return;
|
|
622
|
-
edgeKeys.add(key);
|
|
623
|
-
edges.push({ from, to, interface: iface });
|
|
624
|
-
};
|
|
625
|
-
const neighborId = (n) => {
|
|
626
|
-
const m = normMac(n.mac);
|
|
627
|
-
if (m)
|
|
628
|
-
return `nb:mac:${m}`;
|
|
629
|
-
if (n.identity)
|
|
630
|
-
return `nb:id:${n.identity.toLowerCase()}`;
|
|
631
|
-
return `nb:ip:${n.ip ?? "unknown"}`;
|
|
632
|
-
};
|
|
633
|
-
for (const { name } of input.devices) {
|
|
634
|
-
const seen = input.neighborsByDevice[name] ?? [];
|
|
635
|
-
for (const n of seen) {
|
|
636
|
-
const configuredId = matchConfigured(n);
|
|
637
|
-
if (configuredId) {
|
|
638
|
-
addEdge(name, configuredId, n.interface);
|
|
639
|
-
continue;
|
|
640
|
-
}
|
|
641
|
-
const id = neighborId(n);
|
|
642
|
-
const existing = nodes.get(id);
|
|
643
|
-
if (existing) {
|
|
644
|
-
existing.ip ??= n.ip;
|
|
645
|
-
existing.mac ??= n.mac;
|
|
646
|
-
existing.identity ??= n.identity;
|
|
647
|
-
existing.board ??= n.board;
|
|
648
|
-
existing.version ??= n.version;
|
|
649
|
-
if (!existing.label && n.identity)
|
|
650
|
-
existing.label = n.identity;
|
|
651
|
-
} else {
|
|
652
|
-
nodes.set(id, {
|
|
653
|
-
id,
|
|
654
|
-
kind: "neighbor",
|
|
655
|
-
label: n.identity || n.mac || n.ip || "unknown",
|
|
656
|
-
configured: false,
|
|
657
|
-
onboardable: true,
|
|
658
|
-
identity: n.identity,
|
|
659
|
-
ip: n.ip,
|
|
660
|
-
mac: n.mac,
|
|
661
|
-
platform: n.platform,
|
|
662
|
-
board: n.board,
|
|
663
|
-
version: n.version,
|
|
664
|
-
suggestedConfig: {
|
|
665
|
-
name: suggestName(n, takenNames),
|
|
666
|
-
host: n.ip,
|
|
667
|
-
mac: n.ip ? undefined : n.mac,
|
|
668
|
-
port: 22,
|
|
669
|
-
username: "admin"
|
|
670
|
-
}
|
|
671
|
-
});
|
|
672
|
-
}
|
|
673
|
-
addEdge(name, id, n.interface);
|
|
674
|
-
}
|
|
675
|
-
}
|
|
676
|
-
const nodeList = [...nodes.values()];
|
|
677
|
-
return {
|
|
678
|
-
nodes: nodeList,
|
|
679
|
-
edges,
|
|
680
|
-
stats: {
|
|
681
|
-
devices: input.devices.length,
|
|
682
|
-
neighbors: nodeList.filter((n) => n.kind === "neighbor").length,
|
|
683
|
-
onboardable: nodeList.filter((n) => n.onboardable).length
|
|
684
|
-
}
|
|
685
|
-
};
|
|
686
|
-
}
|
|
687
|
-
|
|
688
|
-
// src/observability/health.ts
|
|
689
|
-
var LOG_TAG = "mikrotik-mcp";
|
|
690
|
-
var HISTORY_CAP = 60;
|
|
691
|
-
var statuses = new Map;
|
|
692
|
-
var histories = new Map;
|
|
693
|
-
var neighbors = new Map;
|
|
694
|
-
var timer = null;
|
|
695
|
-
var inFlight = false;
|
|
696
|
-
var UNKNOWN = { reachable: null, checkedAt: null, latencyMs: null };
|
|
697
|
-
function getDeviceStatus(name) {
|
|
698
|
-
return statuses.get(name) ?? UNKNOWN;
|
|
699
|
-
}
|
|
700
|
-
function getDeviceNeighbors(name) {
|
|
701
|
-
return neighbors.get(name) ?? [];
|
|
702
|
-
}
|
|
703
|
-
function getDeviceHistory(name) {
|
|
704
|
-
return histories.get(name) ?? [];
|
|
705
|
-
}
|
|
706
|
-
function pushHistory(name, sample) {
|
|
707
|
-
const arr = histories.get(name) ?? [];
|
|
708
|
-
arr.push(sample);
|
|
709
|
-
if (arr.length > HISTORY_CAP)
|
|
710
|
-
arr.splice(0, arr.length - HISTORY_CAP);
|
|
711
|
-
histories.set(name, arr);
|
|
712
|
-
}
|
|
713
|
-
async function probeDevice(name, dc) {
|
|
714
|
-
const client = createDeviceClient({
|
|
715
|
-
...dc,
|
|
716
|
-
timeoutMs: Math.min(dc.timeoutMs ?? 1e4, 8000)
|
|
717
|
-
});
|
|
718
|
-
const t0 = Date.now();
|
|
719
|
-
let status;
|
|
720
|
-
try {
|
|
721
|
-
const ok = await client.connect();
|
|
722
|
-
if (!ok) {
|
|
723
|
-
status = {
|
|
724
|
-
reachable: false,
|
|
725
|
-
checkedAt: Date.now(),
|
|
726
|
-
latencyMs: null,
|
|
727
|
-
error: client.lastError ?? "connection failed"
|
|
728
|
-
};
|
|
729
|
-
} else {
|
|
730
|
-
const rawResource = await client.run("/system resource print");
|
|
731
|
-
const checkedAt = Date.now();
|
|
732
|
-
const latencyMs = checkedAt - t0;
|
|
733
|
-
const sys = parseSystemResource(rawResource);
|
|
734
|
-
if (!sys) {
|
|
735
|
-
logger.warn(`[${LOG_TAG}] '${name}' is reachable but '/system resource print' returned no ` + `parseable metrics. Raw output: ${JSON.stringify(rawResource.slice(0, 200))}`);
|
|
736
|
-
}
|
|
737
|
-
let identity;
|
|
738
|
-
try {
|
|
739
|
-
identity = parseKeyValues(await client.run("/system identity print")).name || undefined;
|
|
740
|
-
} catch {}
|
|
741
|
-
status = {
|
|
742
|
-
reachable: true,
|
|
743
|
-
checkedAt,
|
|
744
|
-
latencyMs,
|
|
745
|
-
identity,
|
|
746
|
-
version: sys?.version,
|
|
747
|
-
boardName: sys?.boardName,
|
|
748
|
-
architecture: sys?.architecture,
|
|
749
|
-
cpuCount: sys?.cpuCount,
|
|
750
|
-
cpuLoad: sys?.cpuLoad,
|
|
751
|
-
freeMemory: sys?.freeMemory,
|
|
752
|
-
totalMemory: sys?.totalMemory,
|
|
753
|
-
memUsedPct: sys?.memUsedPct,
|
|
754
|
-
freeHdd: sys?.freeHdd,
|
|
755
|
-
totalHdd: sys?.totalHdd,
|
|
756
|
-
hddUsedPct: sys?.hddUsedPct,
|
|
757
|
-
uptime: sys?.uptime,
|
|
758
|
-
error: sys ? undefined : "reachable, but no system metrics returned"
|
|
759
|
-
};
|
|
760
|
-
pushHistory(name, {
|
|
761
|
-
ts: checkedAt,
|
|
762
|
-
cpuLoad: sys?.cpuLoad ?? null,
|
|
763
|
-
memUsedPct: sys?.memUsedPct ?? null,
|
|
764
|
-
hddUsedPct: sys?.hddUsedPct ?? null,
|
|
765
|
-
latencyMs
|
|
766
|
-
});
|
|
767
|
-
try {
|
|
768
|
-
neighbors.set(name, parseNeighbors(await client.run("/ip neighbor print detail")));
|
|
769
|
-
} catch {}
|
|
770
|
-
try {
|
|
771
|
-
status.disks = parseDisks(await client.run("/disk print detail"));
|
|
772
|
-
} catch {}
|
|
773
|
-
}
|
|
774
|
-
} catch (e) {
|
|
775
|
-
status = {
|
|
776
|
-
reachable: false,
|
|
777
|
-
checkedAt: Date.now(),
|
|
778
|
-
latencyMs: null,
|
|
779
|
-
error: e instanceof Error ? e.message : String(e)
|
|
780
|
-
};
|
|
781
|
-
} finally {
|
|
782
|
-
client.disconnect();
|
|
783
|
-
}
|
|
784
|
-
statuses.set(name, status);
|
|
785
|
-
return status;
|
|
786
|
-
}
|
|
787
|
-
var MAC_PROBE_INTERVAL_MS = 5 * 60000;
|
|
788
|
-
var lastMacProbe = new Map;
|
|
789
|
-
async function probeAll() {
|
|
790
|
-
if (inFlight)
|
|
791
|
-
return;
|
|
792
|
-
inFlight = true;
|
|
793
|
-
try {
|
|
794
|
-
const cfg = getConfig();
|
|
795
|
-
const now = Date.now();
|
|
796
|
-
await Promise.all(Object.entries(cfg.devices).map(([name, dc]) => {
|
|
797
|
-
if (dc.mac) {
|
|
798
|
-
if (now - (lastMacProbe.get(name) ?? 0) < MAC_PROBE_INTERVAL_MS)
|
|
799
|
-
return Promise.resolve();
|
|
800
|
-
lastMacProbe.set(name, now);
|
|
801
|
-
}
|
|
802
|
-
return probeDevice(name, dc).catch(() => {});
|
|
803
|
-
}));
|
|
804
|
-
} finally {
|
|
805
|
-
inFlight = false;
|
|
806
|
-
}
|
|
807
|
-
}
|
|
808
|
-
function startHealthChecks(intervalMs = 30000) {
|
|
809
|
-
probeAll();
|
|
810
|
-
timer = setInterval(() => void probeAll(), intervalMs);
|
|
811
|
-
}
|
|
812
|
-
function stopHealthChecks() {
|
|
813
|
-
if (timer) {
|
|
814
|
-
clearInterval(timer);
|
|
815
|
-
timer = null;
|
|
816
|
-
}
|
|
817
|
-
}
|
|
818
|
-
|
|
819
657
|
// src/observability/geo.ts
|
|
820
658
|
import { lookup } from "dns/promises";
|
|
821
|
-
var
|
|
659
|
+
var LOG_TAG = "mikrotik-mcp";
|
|
822
660
|
var cache = new Map;
|
|
823
661
|
var REFRESH_MS = 24 * 60 * 60000;
|
|
824
|
-
var
|
|
825
|
-
var
|
|
662
|
+
var timer = null;
|
|
663
|
+
var inFlight = false;
|
|
826
664
|
function getDeviceGeo(name) {
|
|
827
665
|
return cache.get(name)?.geo ?? null;
|
|
828
666
|
}
|
|
@@ -870,12 +708,12 @@ async function fetchGeo(ip) {
|
|
|
870
708
|
try {
|
|
871
709
|
return await fetchIpkit(ip);
|
|
872
710
|
} catch (e) {
|
|
873
|
-
logger.warn(`[${
|
|
711
|
+
logger.warn(`[${LOG_TAG}] ipkit geo failed for ${ip}, trying ipquery: ${e instanceof Error ? e.message : String(e)}`);
|
|
874
712
|
}
|
|
875
713
|
try {
|
|
876
714
|
return await fetchIpquery(ip);
|
|
877
715
|
} catch (e) {
|
|
878
|
-
logger.warn(`[${
|
|
716
|
+
logger.warn(`[${LOG_TAG}] geo lookup failed for ${ip}: ${e instanceof Error ? e.message : String(e)}`);
|
|
879
717
|
return null;
|
|
880
718
|
}
|
|
881
719
|
}
|
|
@@ -885,9 +723,9 @@ async function resolveDevice(name, host) {
|
|
|
885
723
|
cache.set(name, { geo, at: Date.now() });
|
|
886
724
|
}
|
|
887
725
|
async function refreshGeo() {
|
|
888
|
-
if (
|
|
726
|
+
if (inFlight)
|
|
889
727
|
return;
|
|
890
|
-
|
|
728
|
+
inFlight = true;
|
|
891
729
|
try {
|
|
892
730
|
const now = Date.now();
|
|
893
731
|
await Promise.all(Object.entries(getConfig().devices).map(([name, dc]) => {
|
|
@@ -897,17 +735,17 @@ async function refreshGeo() {
|
|
|
897
735
|
return resolveDevice(name, dc.mac ? undefined : dc.host);
|
|
898
736
|
}));
|
|
899
737
|
} finally {
|
|
900
|
-
|
|
738
|
+
inFlight = false;
|
|
901
739
|
}
|
|
902
740
|
}
|
|
903
741
|
function startGeoLookups() {
|
|
904
742
|
refreshGeo();
|
|
905
|
-
|
|
743
|
+
timer = setInterval(() => void refreshGeo(), REFRESH_MS);
|
|
906
744
|
}
|
|
907
745
|
function stopGeoLookups() {
|
|
908
|
-
if (
|
|
909
|
-
clearInterval(
|
|
910
|
-
|
|
746
|
+
if (timer) {
|
|
747
|
+
clearInterval(timer);
|
|
748
|
+
timer = null;
|
|
911
749
|
}
|
|
912
750
|
}
|
|
913
751
|
|
|
@@ -915,7 +753,7 @@ function stopGeoLookups() {
|
|
|
915
753
|
import { join as join3 } from "path";
|
|
916
754
|
import { mkdir, readFile, writeFile } from "fs/promises";
|
|
917
755
|
import { homedir } from "os";
|
|
918
|
-
var
|
|
756
|
+
var LOG_TAG2 = "mikrotik-mcp";
|
|
919
757
|
var CODE_RE = /^[a-z]{2}$/;
|
|
920
758
|
var sourceUrl = (code) => `https://hatscripts.github.io/circle-flags/flags/${code}.svg`;
|
|
921
759
|
var VENDOR_DIR = join3(PROJECT_ROOT, "assets", "flags");
|
|
@@ -947,7 +785,7 @@ async function flagSvg(code) {
|
|
|
947
785
|
mkdir(CACHE_DIR, { recursive: true }).then(() => writeFile(join3(CACHE_DIR, `${c}.svg`), svg)).catch(() => {});
|
|
948
786
|
return svg;
|
|
949
787
|
} catch (e) {
|
|
950
|
-
logger.warn(`[${
|
|
788
|
+
logger.warn(`[${LOG_TAG2}] flag fetch failed for '${c}': ${e instanceof Error ? e.message : String(e)}`);
|
|
951
789
|
return null;
|
|
952
790
|
}
|
|
953
791
|
}
|
|
@@ -1010,6 +848,8 @@ function rowToEvent(r) {
|
|
|
1010
848
|
risk: r.risk,
|
|
1011
849
|
device: r.device ?? undefined,
|
|
1012
850
|
transport: r.transport ?? undefined,
|
|
851
|
+
deviceTransport: r.device_transport ?? undefined,
|
|
852
|
+
restFallback: r.rest_fallback ?? undefined,
|
|
1013
853
|
durationMs: r.duration_ms,
|
|
1014
854
|
isError: r.is_error === 1,
|
|
1015
855
|
error: r.error ?? undefined,
|
|
@@ -1038,13 +878,19 @@ var SCHEMA_STATEMENTS = [
|
|
|
1038
878
|
output_bytes INTEGER NOT NULL,
|
|
1039
879
|
has_structured INTEGER NOT NULL,
|
|
1040
880
|
truncated INTEGER NOT NULL,
|
|
1041
|
-
reason TEXT
|
|
881
|
+
reason TEXT,
|
|
882
|
+
device_transport TEXT,
|
|
883
|
+
rest_fallback TEXT
|
|
1042
884
|
)`,
|
|
1043
885
|
"CREATE INDEX IF NOT EXISTS idx_events_ts ON events(ts)",
|
|
1044
886
|
"CREATE INDEX IF NOT EXISTS idx_events_tool ON events(tool)",
|
|
1045
887
|
"CREATE INDEX IF NOT EXISTS idx_events_error ON events(is_error)"
|
|
1046
888
|
];
|
|
1047
|
-
var MIGRATIONS = [
|
|
889
|
+
var MIGRATIONS = [
|
|
890
|
+
"ALTER TABLE events ADD COLUMN reason TEXT",
|
|
891
|
+
"ALTER TABLE events ADD COLUMN device_transport TEXT",
|
|
892
|
+
"ALTER TABLE events ADD COLUMN rest_fallback TEXT"
|
|
893
|
+
];
|
|
1048
894
|
|
|
1049
895
|
class SqliteEventStore {
|
|
1050
896
|
db;
|
|
@@ -1062,9 +908,11 @@ class SqliteEventStore {
|
|
|
1062
908
|
}
|
|
1063
909
|
insert(e) {
|
|
1064
910
|
this.db.query(`INSERT OR REPLACE INTO events
|
|
1065
|
-
(id, ts, tool, title, risk, device, transport,
|
|
911
|
+
(id, ts, tool, title, risk, device, transport, device_transport, rest_fallback,
|
|
912
|
+
duration_ms, is_error, error,
|
|
1066
913
|
input, output, output_bytes, has_structured, truncated, reason)
|
|
1067
|
-
VALUES ($id,$ts,$tool,$title,$risk,$device,$transport,$
|
|
914
|
+
VALUES ($id,$ts,$tool,$title,$risk,$device,$transport,$devtransport,$restfallback,
|
|
915
|
+
$dur,$err,$errmsg,
|
|
1068
916
|
$input,$output,$obytes,$structured,$trunc,$reason)`).run({
|
|
1069
917
|
$id: e.id,
|
|
1070
918
|
$ts: e.ts,
|
|
@@ -1073,6 +921,8 @@ class SqliteEventStore {
|
|
|
1073
921
|
$risk: e.risk,
|
|
1074
922
|
$device: e.device ?? null,
|
|
1075
923
|
$transport: e.transport ?? null,
|
|
924
|
+
$devtransport: e.deviceTransport ?? null,
|
|
925
|
+
$restfallback: e.restFallback ?? null,
|
|
1076
926
|
$dur: e.durationMs,
|
|
1077
927
|
$err: e.isError ? 1 : 0,
|
|
1078
928
|
$errmsg: e.error ?? null,
|
|
@@ -1277,8 +1127,8 @@ var CAPSMAN_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
|
|
|
1277
1127
|
var DEFAULT_CAPSMAN_INTERVAL_MS = 5 * 60000;
|
|
1278
1128
|
var MIN_CAPSMAN_INTERVAL_MS = 60000;
|
|
1279
1129
|
var MAX_CAPSMAN_INTERVAL_MS = 6 * 60 * 60000;
|
|
1280
|
-
var
|
|
1281
|
-
var
|
|
1130
|
+
var timer2 = null;
|
|
1131
|
+
var inFlight2 = false;
|
|
1282
1132
|
var noCapsman = new Set;
|
|
1283
1133
|
function clampInterval(ms) {
|
|
1284
1134
|
if (!Number.isFinite(ms))
|
|
@@ -1305,9 +1155,9 @@ async function sampleDevice(store, device, ts) {
|
|
|
1305
1155
|
store.recordRadioSamples(device, ts, samples);
|
|
1306
1156
|
}
|
|
1307
1157
|
async function sampleCapsmanOnce(store) {
|
|
1308
|
-
if (
|
|
1158
|
+
if (inFlight2)
|
|
1309
1159
|
return;
|
|
1310
|
-
|
|
1160
|
+
inFlight2 = true;
|
|
1311
1161
|
const ts = Date.now();
|
|
1312
1162
|
try {
|
|
1313
1163
|
const cfg = getConfig();
|
|
@@ -1322,18 +1172,18 @@ async function sampleCapsmanOnce(store) {
|
|
|
1322
1172
|
}));
|
|
1323
1173
|
store.pruneSamples(ts - CAPSMAN_RETENTION_MS);
|
|
1324
1174
|
} finally {
|
|
1325
|
-
|
|
1175
|
+
inFlight2 = false;
|
|
1326
1176
|
}
|
|
1327
1177
|
}
|
|
1328
1178
|
function startCapsmanSampler(store, intervalMs = DEFAULT_CAPSMAN_INTERVAL_MS) {
|
|
1329
1179
|
const ms = clampInterval(intervalMs);
|
|
1330
1180
|
sampleCapsmanOnce(store);
|
|
1331
|
-
|
|
1181
|
+
timer2 = setInterval(() => void sampleCapsmanOnce(store), ms);
|
|
1332
1182
|
}
|
|
1333
1183
|
function stopCapsmanSampler() {
|
|
1334
|
-
if (
|
|
1335
|
-
clearInterval(
|
|
1336
|
-
|
|
1184
|
+
if (timer2) {
|
|
1185
|
+
clearInterval(timer2);
|
|
1186
|
+
timer2 = null;
|
|
1337
1187
|
}
|
|
1338
1188
|
}
|
|
1339
1189
|
|
|
@@ -1473,8 +1323,8 @@ var USAGE_RETENTION_MS = 93 * 24 * 60 * 60 * 1000;
|
|
|
1473
1323
|
var DEFAULT_USAGE_INTERVAL_MS = 60000;
|
|
1474
1324
|
var MIN_USAGE_INTERVAL_MS = 30000;
|
|
1475
1325
|
var MAX_USAGE_INTERVAL_MS = 6 * 60 * 60000;
|
|
1476
|
-
var
|
|
1477
|
-
var
|
|
1326
|
+
var timer3 = null;
|
|
1327
|
+
var inFlight3 = false;
|
|
1478
1328
|
var currentStore = null;
|
|
1479
1329
|
var currentIntervalMs = DEFAULT_USAGE_INTERVAL_MS;
|
|
1480
1330
|
function clampInterval2(ms) {
|
|
@@ -1535,9 +1385,9 @@ async function ingestSessions(store, device) {
|
|
|
1535
1385
|
store.upsertSessions(device, sessions);
|
|
1536
1386
|
}
|
|
1537
1387
|
async function sampleUsageOnce(store) {
|
|
1538
|
-
if (
|
|
1388
|
+
if (inFlight3)
|
|
1539
1389
|
return;
|
|
1540
|
-
|
|
1390
|
+
inFlight3 = true;
|
|
1541
1391
|
const ts = Date.now();
|
|
1542
1392
|
try {
|
|
1543
1393
|
const cfg = getConfig();
|
|
@@ -1553,32 +1403,32 @@ async function sampleUsageOnce(store) {
|
|
|
1553
1403
|
}));
|
|
1554
1404
|
store.pruneSamples(ts - USAGE_RETENTION_MS);
|
|
1555
1405
|
} finally {
|
|
1556
|
-
|
|
1406
|
+
inFlight3 = false;
|
|
1557
1407
|
}
|
|
1558
1408
|
}
|
|
1559
1409
|
function startUsageSampler(store, intervalMs = DEFAULT_USAGE_INTERVAL_MS) {
|
|
1560
1410
|
currentStore = store;
|
|
1561
1411
|
currentIntervalMs = clampInterval2(intervalMs);
|
|
1562
1412
|
sampleUsageOnce(store);
|
|
1563
|
-
|
|
1413
|
+
timer3 = setInterval(() => void sampleUsageOnce(store), currentIntervalMs);
|
|
1564
1414
|
}
|
|
1565
1415
|
function getUsageSamplerInterval() {
|
|
1566
1416
|
return currentIntervalMs;
|
|
1567
1417
|
}
|
|
1568
1418
|
function setUsageSamplerInterval(intervalMs) {
|
|
1569
1419
|
currentIntervalMs = clampInterval2(intervalMs);
|
|
1570
|
-
if (
|
|
1571
|
-
clearInterval(
|
|
1420
|
+
if (timer3)
|
|
1421
|
+
clearInterval(timer3);
|
|
1572
1422
|
if (currentStore) {
|
|
1573
1423
|
const store = currentStore;
|
|
1574
|
-
|
|
1424
|
+
timer3 = setInterval(() => void sampleUsageOnce(store), currentIntervalMs);
|
|
1575
1425
|
}
|
|
1576
1426
|
return currentIntervalMs;
|
|
1577
1427
|
}
|
|
1578
1428
|
function stopUsageSampler() {
|
|
1579
|
-
if (
|
|
1580
|
-
clearInterval(
|
|
1581
|
-
|
|
1429
|
+
if (timer3) {
|
|
1430
|
+
clearInterval(timer3);
|
|
1431
|
+
timer3 = null;
|
|
1582
1432
|
}
|
|
1583
1433
|
currentStore = null;
|
|
1584
1434
|
}
|
|
@@ -1822,6 +1672,7 @@ async function driftRoutes(req, url) {
|
|
|
1822
1672
|
toLabel: `${device}@live`
|
|
1823
1673
|
});
|
|
1824
1674
|
const report = analyzeDrift(diff, device, baseline.snapshotId, baseline.setAt);
|
|
1675
|
+
noteDriftResult(report);
|
|
1825
1676
|
try {
|
|
1826
1677
|
const logCmd = `/log print where topics~"system" and time > ([:timestamp] - 7d)`;
|
|
1827
1678
|
const logText = await executeMikrotikCommand(logCmd, ctx);
|
|
@@ -1837,12 +1688,1261 @@ async function driftRoutes(req, url) {
|
|
|
1837
1688
|
return null;
|
|
1838
1689
|
}
|
|
1839
1690
|
|
|
1840
|
-
// src/observability/
|
|
1691
|
+
// src/observability/txn-routes.ts
|
|
1841
1692
|
var JSON_HEADERS2 = { "content-type": "application/json; charset=utf-8" };
|
|
1842
1693
|
function json2(body, status = 200) {
|
|
1843
1694
|
return new Response(JSON.stringify(body), { status, headers: JSON_HEADERS2 });
|
|
1844
1695
|
}
|
|
1696
|
+
function liveRecord(id) {
|
|
1697
|
+
const entry = getTxn(id);
|
|
1698
|
+
return entry ? { ...toRecord(entry.txn, entry.ts, entry.label), updated: Date.now() } : null;
|
|
1699
|
+
}
|
|
1700
|
+
async function txnRoutes(req, url) {
|
|
1701
|
+
const p = url.pathname;
|
|
1702
|
+
if (!p.startsWith("/api/txn"))
|
|
1703
|
+
return null;
|
|
1704
|
+
let storeError;
|
|
1705
|
+
const store = await txnStore().catch((e) => {
|
|
1706
|
+
storeError = e instanceof Error ? e.message : String(e);
|
|
1707
|
+
return null;
|
|
1708
|
+
});
|
|
1709
|
+
if (p === "/api/txn" && req.method === "GET") {
|
|
1710
|
+
const limit = Number(url.searchParams.get("limit") ?? 50);
|
|
1711
|
+
const stored = store?.list(limit) ?? [];
|
|
1712
|
+
const live = listTxns().map((e) => ({
|
|
1713
|
+
...toRecord(e.txn, e.ts, e.label),
|
|
1714
|
+
updated: Date.now()
|
|
1715
|
+
}));
|
|
1716
|
+
const byId = new Map(stored.map((r) => [r.id, r]));
|
|
1717
|
+
for (const r of live)
|
|
1718
|
+
byId.set(r.id, r);
|
|
1719
|
+
const transactions = [...byId.values()].sort((a, b) => b.ts - a.ts).slice(0, limit);
|
|
1720
|
+
return json2({ transactions, live: liveTxnUpdates(), error: storeError });
|
|
1721
|
+
}
|
|
1722
|
+
const parts = p.slice("/api/txn/".length).split("/").filter(Boolean);
|
|
1723
|
+
if (parts.length === 0 || parts.length > 2)
|
|
1724
|
+
return json2({ error: "not found" }, 404);
|
|
1725
|
+
const id = decodeURIComponent(parts[0]);
|
|
1726
|
+
if (parts[1] === "abort" && req.method === "POST") {
|
|
1727
|
+
const entry = getTxn(id);
|
|
1728
|
+
if (!entry)
|
|
1729
|
+
return json2({ error: `no open transaction '${id}'` }, 404);
|
|
1730
|
+
entry.txn = requestAbort(entry.txn, "aborted from the dashboard");
|
|
1731
|
+
const run = await runTransaction({
|
|
1732
|
+
txn: entry.txn,
|
|
1733
|
+
steps: entry.steps,
|
|
1734
|
+
executor: createDeviceExecutor(createContext(), entry.txn.id)
|
|
1735
|
+
});
|
|
1736
|
+
entry.txn = run.txn;
|
|
1737
|
+
await persistTxn(entry);
|
|
1738
|
+
if (run.state !== undefined)
|
|
1739
|
+
dropTxn(id);
|
|
1740
|
+
return json2({
|
|
1741
|
+
state: run.state,
|
|
1742
|
+
summary: run.summary,
|
|
1743
|
+
transaction: toRecord(run.txn, entry.ts)
|
|
1744
|
+
});
|
|
1745
|
+
}
|
|
1746
|
+
if (parts.length === 1 && req.method === "GET") {
|
|
1747
|
+
const record = liveRecord(id) ?? store?.get(id) ?? null;
|
|
1748
|
+
if (!record)
|
|
1749
|
+
return json2({ error: `unknown transaction '${id}'` }, 404);
|
|
1750
|
+
return json2({ transaction: record, events: store?.events(id) ?? [] });
|
|
1751
|
+
}
|
|
1752
|
+
return json2({ error: "method not allowed" }, 405);
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
// src/observability/flow-routes.ts
|
|
1756
|
+
var JSON_HEADERS3 = { "content-type": "application/json; charset=utf-8" };
|
|
1757
|
+
function json3(body, status = 200) {
|
|
1758
|
+
return new Response(JSON.stringify(body), { status, headers: JSON_HEADERS3 });
|
|
1759
|
+
}
|
|
1760
|
+
var WINDOWS = {
|
|
1761
|
+
"5m": 5 * 60000,
|
|
1762
|
+
"15m": 15 * 60000,
|
|
1763
|
+
"1h": 3600000,
|
|
1764
|
+
"6h": 6 * 3600000,
|
|
1765
|
+
"24h": 24 * 3600000
|
|
1766
|
+
};
|
|
1767
|
+
function bucketFor(windowMs) {
|
|
1768
|
+
return Math.max(60000, Math.round(windowMs / 60 / 60000) * 60000);
|
|
1769
|
+
}
|
|
1770
|
+
function windowMs(url) {
|
|
1771
|
+
return WINDOWS[url.searchParams.get("window") ?? "1h"] ?? WINDOWS["1h"];
|
|
1772
|
+
}
|
|
1773
|
+
async function flowRoutes(req, url) {
|
|
1774
|
+
const p = url.pathname;
|
|
1775
|
+
if (!p.startsWith("/api/flows"))
|
|
1776
|
+
return null;
|
|
1777
|
+
if (req.method !== "GET")
|
|
1778
|
+
return json3({ error: "method not allowed" }, 405);
|
|
1779
|
+
const collector = getFlowCollector();
|
|
1780
|
+
if (p === "/api/flows/health") {
|
|
1781
|
+
let store = null;
|
|
1782
|
+
try {
|
|
1783
|
+
store = (await flowStore()).stats();
|
|
1784
|
+
} catch {}
|
|
1785
|
+
return json3({ collector: collector.stats(), store });
|
|
1786
|
+
}
|
|
1787
|
+
collector.drain();
|
|
1788
|
+
let records;
|
|
1789
|
+
const span = windowMs(url);
|
|
1790
|
+
const to = Date.now();
|
|
1791
|
+
const from = to - span;
|
|
1792
|
+
const address = url.searchParams.get("address") ?? undefined;
|
|
1793
|
+
try {
|
|
1794
|
+
records = (await flowStore()).query({ from, to, address, limit: 200000 });
|
|
1795
|
+
} catch (e) {
|
|
1796
|
+
return json3({ error: e instanceof Error ? e.message : String(e), records: [] }, 200);
|
|
1797
|
+
}
|
|
1798
|
+
if (p === "/api/flows/top") {
|
|
1799
|
+
const dimension = url.searchParams.get("dimension") ?? "source";
|
|
1800
|
+
const limit = Number(url.searchParams.get("limit") ?? 10);
|
|
1801
|
+
return json3({
|
|
1802
|
+
window: { from, to },
|
|
1803
|
+
totals: summarize(records),
|
|
1804
|
+
top: topTalkers(records, dimension, limit, true),
|
|
1805
|
+
protocols: protocolMix(records).slice(0, 8),
|
|
1806
|
+
applications: topTalkers(records, "application", 8, true)
|
|
1807
|
+
});
|
|
1808
|
+
}
|
|
1809
|
+
if (p === "/api/flows/conversations") {
|
|
1810
|
+
const limit = Number(url.searchParams.get("limit") ?? 20);
|
|
1811
|
+
return json3({
|
|
1812
|
+
window: { from, to },
|
|
1813
|
+
conversations: conversations(records, limit)
|
|
1814
|
+
});
|
|
1815
|
+
}
|
|
1816
|
+
if (p === "/api/flows/timeline") {
|
|
1817
|
+
const dimension = url.searchParams.get("dimension") ?? "source";
|
|
1818
|
+
const topN = Number(url.searchParams.get("topN") ?? 5);
|
|
1819
|
+
const bucket = bucketFor(span);
|
|
1820
|
+
return json3({
|
|
1821
|
+
window: { from, to },
|
|
1822
|
+
bucketMs: bucket,
|
|
1823
|
+
keys: topTalkers(records, dimension, topN).map((t) => t.key),
|
|
1824
|
+
buckets: timeline(records, from, to, bucket, dimension, topN)
|
|
1825
|
+
});
|
|
1826
|
+
}
|
|
1827
|
+
return json3({ error: "not found" }, 404);
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1830
|
+
// src/observability/rollout-routes.ts
|
|
1831
|
+
var JSON_HEADERS4 = { "content-type": "application/json; charset=utf-8" };
|
|
1832
|
+
function json4(body, status = 200) {
|
|
1833
|
+
return new Response(JSON.stringify(body), { status, headers: JSON_HEADERS4 });
|
|
1834
|
+
}
|
|
1835
|
+
function liveRecord2(id) {
|
|
1836
|
+
const entry = getRollout(id);
|
|
1837
|
+
return entry ? {
|
|
1838
|
+
...toRecord2(entry.state, entry.ts, { label: entry.label, commands: entry.commands }),
|
|
1839
|
+
updated: Date.now()
|
|
1840
|
+
} : null;
|
|
1841
|
+
}
|
|
1842
|
+
async function rolloutRoutes(req, url) {
|
|
1843
|
+
const p = url.pathname;
|
|
1844
|
+
if (!p.startsWith("/api/rollout"))
|
|
1845
|
+
return null;
|
|
1846
|
+
let storeError;
|
|
1847
|
+
const store = await rolloutStore().catch((e) => {
|
|
1848
|
+
storeError = e instanceof Error ? e.message : String(e);
|
|
1849
|
+
return null;
|
|
1850
|
+
});
|
|
1851
|
+
if (p === "/api/rollout" && req.method === "GET") {
|
|
1852
|
+
const limit = Number(url.searchParams.get("limit") ?? 50);
|
|
1853
|
+
const stored = store?.list(limit) ?? [];
|
|
1854
|
+
const live = listRollouts().map((e) => ({
|
|
1855
|
+
...toRecord2(e.state, e.ts, { label: e.label, commands: e.commands }),
|
|
1856
|
+
updated: Date.now()
|
|
1857
|
+
}));
|
|
1858
|
+
const byId = new Map(stored.map((r) => [r.id, r]));
|
|
1859
|
+
for (const r of live)
|
|
1860
|
+
byId.set(r.id, r);
|
|
1861
|
+
const rollouts = [...byId.values()].sort((a, b) => b.ts - a.ts).slice(0, limit);
|
|
1862
|
+
return json4({ rollouts, live: liveRolloutUpdates(), error: storeError });
|
|
1863
|
+
}
|
|
1864
|
+
const parts = p.slice("/api/rollout/".length).split("/").filter(Boolean);
|
|
1865
|
+
if (parts.length === 0 || parts.length > 2)
|
|
1866
|
+
return json4({ error: "not found" }, 404);
|
|
1867
|
+
const id = decodeURIComponent(parts[0]);
|
|
1868
|
+
const verb = parts[1];
|
|
1869
|
+
if (verb && req.method === "POST") {
|
|
1870
|
+
const entry = getRollout(id);
|
|
1871
|
+
if (!entry)
|
|
1872
|
+
return json4({ error: `no active rollout '${id}'` }, 404);
|
|
1873
|
+
switch (verb) {
|
|
1874
|
+
case "hold": {
|
|
1875
|
+
entry.state = requestHold(entry.state);
|
|
1876
|
+
await persistRollout(entry);
|
|
1877
|
+
publishRollout({
|
|
1878
|
+
rolloutId: id,
|
|
1879
|
+
ts: Date.now(),
|
|
1880
|
+
phase: entry.state.phase,
|
|
1881
|
+
currentWave: entry.state.currentWave,
|
|
1882
|
+
action: "hold",
|
|
1883
|
+
ok: true,
|
|
1884
|
+
devices: entry.state.devices,
|
|
1885
|
+
gates: entry.state.gates
|
|
1886
|
+
});
|
|
1887
|
+
return json4({ held: true, summary: summarize2(entry.state) });
|
|
1888
|
+
}
|
|
1889
|
+
case "resume": {
|
|
1890
|
+
entry.state = resume(entry.state);
|
|
1891
|
+
const run = await runRollout({
|
|
1892
|
+
state: entry.state,
|
|
1893
|
+
commands: entry.commands,
|
|
1894
|
+
executor: createDeviceExecutor2(createContext(), id),
|
|
1895
|
+
onEvent: ({ action, state }) => {
|
|
1896
|
+
entry.state = state;
|
|
1897
|
+
publishRollout({
|
|
1898
|
+
rolloutId: id,
|
|
1899
|
+
ts: Date.now(),
|
|
1900
|
+
phase: state.phase,
|
|
1901
|
+
currentWave: state.currentWave,
|
|
1902
|
+
action: action.kind,
|
|
1903
|
+
device: "device" in action ? action.device : undefined,
|
|
1904
|
+
ok: true,
|
|
1905
|
+
outcome: state.outcome,
|
|
1906
|
+
devices: state.devices,
|
|
1907
|
+
gates: state.gates
|
|
1908
|
+
});
|
|
1909
|
+
}
|
|
1910
|
+
});
|
|
1911
|
+
entry.state = run.state;
|
|
1912
|
+
await persistRollout(entry);
|
|
1913
|
+
if (run.outcome)
|
|
1914
|
+
dropRollout(id);
|
|
1915
|
+
return json4({ outcome: run.outcome, summary: run.summary });
|
|
1916
|
+
}
|
|
1917
|
+
case "abort": {
|
|
1918
|
+
entry.state = requestAbort2(entry.state, "aborted from the dashboard");
|
|
1919
|
+
const run = await runRollout({
|
|
1920
|
+
state: entry.state,
|
|
1921
|
+
commands: entry.commands,
|
|
1922
|
+
executor: createDeviceExecutor2(createContext(), id)
|
|
1923
|
+
});
|
|
1924
|
+
entry.state = run.state;
|
|
1925
|
+
await persistRollout(entry);
|
|
1926
|
+
if (run.outcome)
|
|
1927
|
+
dropRollout(id);
|
|
1928
|
+
return json4({ outcome: run.outcome, summary: run.summary });
|
|
1929
|
+
}
|
|
1930
|
+
default:
|
|
1931
|
+
return json4({ error: `unknown action '${verb}'` }, 404);
|
|
1932
|
+
}
|
|
1933
|
+
}
|
|
1934
|
+
if (parts.length === 1 && req.method === "GET") {
|
|
1935
|
+
const record = liveRecord2(id) ?? store?.get(id) ?? null;
|
|
1936
|
+
if (!record)
|
|
1937
|
+
return json4({ error: `unknown rollout '${id}'` }, 404);
|
|
1938
|
+
return json4({ rollout: record, events: store?.events(id) ?? [] });
|
|
1939
|
+
}
|
|
1940
|
+
return json4({ error: "method not allowed" }, 405);
|
|
1941
|
+
}
|
|
1942
|
+
|
|
1943
|
+
// src/policy/results.ts
|
|
1944
|
+
import { mkdirSync as mkdirSync6 } from "fs";
|
|
1945
|
+
import { dirname as dirname5 } from "path";
|
|
1946
|
+
var SCHEMA_STATEMENTS4 = [
|
|
1947
|
+
`CREATE TABLE IF NOT EXISTS policy_results (
|
|
1948
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
1949
|
+
device TEXT NOT NULL,
|
|
1950
|
+
ts INTEGER NOT NULL,
|
|
1951
|
+
score INTEGER NOT NULL,
|
|
1952
|
+
passed INTEGER NOT NULL,
|
|
1953
|
+
failed INTEGER NOT NULL,
|
|
1954
|
+
not_applicable INTEGER NOT NULL,
|
|
1955
|
+
by_severity TEXT NOT NULL,
|
|
1956
|
+
findings TEXT NOT NULL
|
|
1957
|
+
)`,
|
|
1958
|
+
"CREATE INDEX IF NOT EXISTS idx_policy_results_device_ts ON policy_results(device, ts)"
|
|
1959
|
+
];
|
|
1960
|
+
|
|
1961
|
+
class SqlitePolicyResultStore {
|
|
1962
|
+
db;
|
|
1963
|
+
constructor(db) {
|
|
1964
|
+
this.db = db;
|
|
1965
|
+
db.run("PRAGMA journal_mode = WAL");
|
|
1966
|
+
db.run("PRAGMA synchronous = NORMAL");
|
|
1967
|
+
for (const stmt of SCHEMA_STATEMENTS4)
|
|
1968
|
+
db.run(stmt);
|
|
1969
|
+
}
|
|
1970
|
+
insert(report) {
|
|
1971
|
+
this.db.query(`INSERT INTO policy_results
|
|
1972
|
+
(device, ts, score, passed, failed, not_applicable, by_severity, findings)
|
|
1973
|
+
VALUES ($device,$ts,$score,$passed,$failed,$na,$bySeverity,$findings)`).run({
|
|
1974
|
+
$device: report.device ?? "(snapshot)",
|
|
1975
|
+
$ts: report.ts ?? Date.now(),
|
|
1976
|
+
$score: report.summary.score,
|
|
1977
|
+
$passed: report.summary.passed,
|
|
1978
|
+
$failed: report.summary.failed,
|
|
1979
|
+
$na: report.summary.notApplicable,
|
|
1980
|
+
$bySeverity: JSON.stringify(report.summary.bySeverity),
|
|
1981
|
+
$findings: JSON.stringify(report.findings.filter((f) => f.status === "fail"))
|
|
1982
|
+
});
|
|
1983
|
+
}
|
|
1984
|
+
list(device, limit = 50) {
|
|
1985
|
+
const rows = device ? this.db.query("SELECT * FROM policy_results WHERE device = $device ORDER BY ts DESC LIMIT $limit").all({ $device: device, $limit: limit }) : this.db.query("SELECT * FROM policy_results ORDER BY ts DESC LIMIT $limit").all({ $limit: limit });
|
|
1986
|
+
return rows.map((r) => ({
|
|
1987
|
+
id: r.id,
|
|
1988
|
+
device: r.device,
|
|
1989
|
+
ts: r.ts,
|
|
1990
|
+
score: r.score,
|
|
1991
|
+
passed: r.passed,
|
|
1992
|
+
failed: r.failed,
|
|
1993
|
+
notApplicable: r.not_applicable,
|
|
1994
|
+
bySeverity: JSON.parse(r.by_severity),
|
|
1995
|
+
findings: JSON.parse(r.findings)
|
|
1996
|
+
}));
|
|
1997
|
+
}
|
|
1998
|
+
close() {
|
|
1999
|
+
this.db.close();
|
|
2000
|
+
}
|
|
2001
|
+
}
|
|
2002
|
+
async function openPolicyResultStore(path) {
|
|
2003
|
+
if (path !== ":memory:") {
|
|
2004
|
+
try {
|
|
2005
|
+
mkdirSync6(dirname5(path), { recursive: true });
|
|
2006
|
+
} catch {}
|
|
2007
|
+
}
|
|
2008
|
+
const { Database } = await import("bun:sqlite");
|
|
2009
|
+
const db = new Database(path, { create: true });
|
|
2010
|
+
return new SqlitePolicyResultStore(db);
|
|
2011
|
+
}
|
|
2012
|
+
var storePromise2 = null;
|
|
2013
|
+
function store() {
|
|
2014
|
+
storePromise2 ??= openPolicyResultStore(DEFAULT_SNAPSHOT_DB);
|
|
2015
|
+
return storePromise2;
|
|
2016
|
+
}
|
|
2017
|
+
async function recordPolicyResult(report) {
|
|
2018
|
+
try {
|
|
2019
|
+
(await store()).insert(report);
|
|
2020
|
+
} catch (e) {
|
|
2021
|
+
logger.error(`policy result write failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
2022
|
+
}
|
|
2023
|
+
}
|
|
2024
|
+
async function policyResults(device, limit = 50) {
|
|
2025
|
+
return (await store()).list(device, limit);
|
|
2026
|
+
}
|
|
2027
|
+
|
|
2028
|
+
// src/observability/policy-routes.ts
|
|
2029
|
+
var JSON_HEADERS5 = { "content-type": "application/json; charset=utf-8" };
|
|
2030
|
+
function json5(body, status = 200) {
|
|
2031
|
+
return new Response(JSON.stringify(body), { status, headers: JSON_HEADERS5 });
|
|
2032
|
+
}
|
|
1845
2033
|
async function bodyJson2(req) {
|
|
2034
|
+
return await req.json().catch(() => ({}));
|
|
2035
|
+
}
|
|
2036
|
+
async function policyRoutes(req, url) {
|
|
2037
|
+
const p = url.pathname;
|
|
2038
|
+
if (!p.startsWith("/api/policies"))
|
|
2039
|
+
return null;
|
|
2040
|
+
if (p === "/api/policies" && req.method === "GET") {
|
|
2041
|
+
const set = currentPolicySet();
|
|
2042
|
+
return json5({
|
|
2043
|
+
files: set.files.map((f) => ({
|
|
2044
|
+
path: f.path,
|
|
2045
|
+
name: f.name,
|
|
2046
|
+
ok: f.ok,
|
|
2047
|
+
issues: f.issues,
|
|
2048
|
+
policies: f.policies
|
|
2049
|
+
})),
|
|
2050
|
+
ruleCount: set.policies.length,
|
|
2051
|
+
duplicateIds: set.duplicateIds,
|
|
2052
|
+
emptyPatterns: set.emptyPatterns,
|
|
2053
|
+
paths: getConfig().policy.paths
|
|
2054
|
+
});
|
|
2055
|
+
}
|
|
2056
|
+
if (p === "/api/policies/results" && req.method === "GET") {
|
|
2057
|
+
const device = url.searchParams.get("device") ?? undefined;
|
|
2058
|
+
const limit = Number(url.searchParams.get("limit") ?? 50);
|
|
2059
|
+
let results;
|
|
2060
|
+
try {
|
|
2061
|
+
results = await policyResults(device, limit);
|
|
2062
|
+
} catch (e) {
|
|
2063
|
+
return json5({ results: [], error: e instanceof Error ? e.message : String(e) });
|
|
2064
|
+
}
|
|
2065
|
+
return json5({ results });
|
|
2066
|
+
}
|
|
2067
|
+
if (p === "/api/policies/validate" && req.method === "POST") {
|
|
2068
|
+
const body = await bodyJson2(req);
|
|
2069
|
+
if (typeof body.content !== "string")
|
|
2070
|
+
return json5({ error: "content is required" }, 400);
|
|
2071
|
+
return json5(validatePolicyText(body.content));
|
|
2072
|
+
}
|
|
2073
|
+
if (p === "/api/policies/run" && req.method === "POST") {
|
|
2074
|
+
const body = await bodyJson2(req);
|
|
2075
|
+
const set = currentPolicySet();
|
|
2076
|
+
if (set.policies.length === 0) {
|
|
2077
|
+
return json5({ error: "no policy rules are loaded", reports: [] });
|
|
2078
|
+
}
|
|
2079
|
+
const cfg = getConfig();
|
|
2080
|
+
const devices = body.devices && body.devices.length > 0 ? body.devices : Object.entries(cfg.devices).filter(([, d]) => !d.disabled).map(([name]) => name);
|
|
2081
|
+
const reports = [];
|
|
2082
|
+
for (const device of devices) {
|
|
2083
|
+
const ctx = createContext(undefined, device);
|
|
2084
|
+
try {
|
|
2085
|
+
const text = await executeMikrotikCommand("/export terse", ctx);
|
|
2086
|
+
if (looksLikeError(text) || text.trim() === "") {
|
|
2087
|
+
reports.push({ device, error: text.trim() || "empty export" });
|
|
2088
|
+
continue;
|
|
2089
|
+
}
|
|
2090
|
+
const report = evaluatePolicies(set.policies, parseExport(text), {
|
|
2091
|
+
device,
|
|
2092
|
+
ts: Date.now()
|
|
2093
|
+
});
|
|
2094
|
+
rememberReport(report);
|
|
2095
|
+
await recordPolicyResult(report);
|
|
2096
|
+
reports.push({
|
|
2097
|
+
device,
|
|
2098
|
+
summary: report.summary,
|
|
2099
|
+
findings: report.findings,
|
|
2100
|
+
markdown: renderReport(report, "markdown")
|
|
2101
|
+
});
|
|
2102
|
+
} catch (e) {
|
|
2103
|
+
reports.push({ device, error: e instanceof Error ? e.message : String(e) });
|
|
2104
|
+
}
|
|
2105
|
+
}
|
|
2106
|
+
return json5({ reports });
|
|
2107
|
+
}
|
|
2108
|
+
return json5({ error: "not found" }, 404);
|
|
2109
|
+
}
|
|
2110
|
+
|
|
2111
|
+
// src/sim/suites.ts
|
|
2112
|
+
import { mkdirSync as mkdirSync7 } from "fs";
|
|
2113
|
+
import { dirname as dirname6 } from "path";
|
|
2114
|
+
var SCHEMA = [
|
|
2115
|
+
`CREATE TABLE IF NOT EXISTS sim_suites (
|
|
2116
|
+
id TEXT PRIMARY KEY,
|
|
2117
|
+
name TEXT NOT NULL,
|
|
2118
|
+
packets TEXT NOT NULL,
|
|
2119
|
+
updated INTEGER NOT NULL
|
|
2120
|
+
)`
|
|
2121
|
+
];
|
|
2122
|
+
|
|
2123
|
+
class SqliteSuiteStore {
|
|
2124
|
+
db;
|
|
2125
|
+
constructor(db) {
|
|
2126
|
+
this.db = db;
|
|
2127
|
+
db.run("PRAGMA journal_mode = WAL");
|
|
2128
|
+
for (const stmt of SCHEMA)
|
|
2129
|
+
db.run(stmt);
|
|
2130
|
+
}
|
|
2131
|
+
list() {
|
|
2132
|
+
const rows = this.db.query("SELECT * FROM sim_suites ORDER BY updated DESC").all();
|
|
2133
|
+
return rows.map((r) => ({
|
|
2134
|
+
id: r.id,
|
|
2135
|
+
name: r.name,
|
|
2136
|
+
packets: JSON.parse(r.packets),
|
|
2137
|
+
updated: r.updated
|
|
2138
|
+
}));
|
|
2139
|
+
}
|
|
2140
|
+
get(id) {
|
|
2141
|
+
const row = this.db.query("SELECT * FROM sim_suites WHERE id = $id").get({ $id: id });
|
|
2142
|
+
return row ? {
|
|
2143
|
+
id: row.id,
|
|
2144
|
+
name: row.name,
|
|
2145
|
+
packets: JSON.parse(row.packets),
|
|
2146
|
+
updated: row.updated
|
|
2147
|
+
} : null;
|
|
2148
|
+
}
|
|
2149
|
+
save(suite) {
|
|
2150
|
+
this.db.query(`INSERT OR REPLACE INTO sim_suites (id, name, packets, updated)
|
|
2151
|
+
VALUES ($id,$name,$packets,$updated)`).run({
|
|
2152
|
+
$id: suite.id,
|
|
2153
|
+
$name: suite.name,
|
|
2154
|
+
$packets: JSON.stringify(suite.packets),
|
|
2155
|
+
$updated: suite.updated
|
|
2156
|
+
});
|
|
2157
|
+
}
|
|
2158
|
+
}
|
|
2159
|
+
var storePromise3 = null;
|
|
2160
|
+
async function store2() {
|
|
2161
|
+
storePromise3 ??= (async () => {
|
|
2162
|
+
try {
|
|
2163
|
+
mkdirSync7(dirname6(DEFAULT_SNAPSHOT_DB), { recursive: true });
|
|
2164
|
+
} catch {}
|
|
2165
|
+
const { Database } = await import("bun:sqlite");
|
|
2166
|
+
return new SqliteSuiteStore(new Database(DEFAULT_SNAPSHOT_DB, { create: true }));
|
|
2167
|
+
})();
|
|
2168
|
+
return storePromise3;
|
|
2169
|
+
}
|
|
2170
|
+
async function listSuites() {
|
|
2171
|
+
try {
|
|
2172
|
+
return (await store2()).list();
|
|
2173
|
+
} catch (e) {
|
|
2174
|
+
logger.error(`sim suite read failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
2175
|
+
return [];
|
|
2176
|
+
}
|
|
2177
|
+
}
|
|
2178
|
+
async function getSuite(id) {
|
|
2179
|
+
try {
|
|
2180
|
+
return (await store2()).get(id);
|
|
2181
|
+
} catch {
|
|
2182
|
+
return null;
|
|
2183
|
+
}
|
|
2184
|
+
}
|
|
2185
|
+
async function saveSuite(input) {
|
|
2186
|
+
const suite = {
|
|
2187
|
+
id: input.id ?? `suite_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
|
2188
|
+
name: input.name,
|
|
2189
|
+
packets: input.packets,
|
|
2190
|
+
updated: Date.now()
|
|
2191
|
+
};
|
|
2192
|
+
try {
|
|
2193
|
+
(await store2()).save(suite);
|
|
2194
|
+
} catch (e) {
|
|
2195
|
+
logger.error(`sim suite write failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
2196
|
+
}
|
|
2197
|
+
return suite;
|
|
2198
|
+
}
|
|
2199
|
+
|
|
2200
|
+
// src/observability/sim-routes.ts
|
|
2201
|
+
var JSON_HEADERS6 = { "content-type": "application/json; charset=utf-8" };
|
|
2202
|
+
function json6(body, status = 200) {
|
|
2203
|
+
return new Response(JSON.stringify(body), { status, headers: JSON_HEADERS6 });
|
|
2204
|
+
}
|
|
2205
|
+
async function bodyJson3(req) {
|
|
2206
|
+
return await req.json().catch(() => ({}));
|
|
2207
|
+
}
|
|
2208
|
+
var storePromise4 = null;
|
|
2209
|
+
function snapshots() {
|
|
2210
|
+
storePromise4 ??= openSnapshotStore(DEFAULT_SNAPSHOT_DB);
|
|
2211
|
+
return storePromise4;
|
|
2212
|
+
}
|
|
2213
|
+
async function configFor(body) {
|
|
2214
|
+
if (body.configText)
|
|
2215
|
+
return { text: body.configText, source: "supplied text" };
|
|
2216
|
+
if (body.snapshotId) {
|
|
2217
|
+
const snapshot = (await snapshots()).get(body.snapshotId);
|
|
2218
|
+
if (!snapshot)
|
|
2219
|
+
return { error: `no snapshot '${body.snapshotId}'` };
|
|
2220
|
+
return { text: snapshot.body, source: `snapshot ${snapshot.id}` };
|
|
2221
|
+
}
|
|
2222
|
+
const device = body.device ?? getConfig().defaultDevice;
|
|
2223
|
+
const ctx = createContext(undefined, device);
|
|
2224
|
+
const text = await executeMikrotikCommand("/export terse", ctx);
|
|
2225
|
+
if (looksLikeError(text) || text.trim() === "") {
|
|
2226
|
+
return { error: `could not read the configuration of '${device}'` };
|
|
2227
|
+
}
|
|
2228
|
+
return { text, source: `live config of ${device}` };
|
|
2229
|
+
}
|
|
2230
|
+
async function simRoutes(req, url) {
|
|
2231
|
+
const p = url.pathname;
|
|
2232
|
+
if (!p.startsWith("/api/sim"))
|
|
2233
|
+
return null;
|
|
2234
|
+
if (p === "/api/sim/suites" && req.method === "GET") {
|
|
2235
|
+
return json6({ suites: await listSuites() });
|
|
2236
|
+
}
|
|
2237
|
+
if (p === "/api/sim/suites" && req.method === "POST") {
|
|
2238
|
+
const body = await bodyJson3(req);
|
|
2239
|
+
if (!body.name || !Array.isArray(body.packets) || body.packets.length === 0) {
|
|
2240
|
+
return json6({ error: "name and a non-empty packets array are required" }, 400);
|
|
2241
|
+
}
|
|
2242
|
+
const saved = await saveSuite({
|
|
2243
|
+
id: body.id,
|
|
2244
|
+
name: body.name,
|
|
2245
|
+
packets: body.packets
|
|
2246
|
+
});
|
|
2247
|
+
return json6({ suite: saved });
|
|
2248
|
+
}
|
|
2249
|
+
if (p === "/api/sim/packet" && req.method === "POST") {
|
|
2250
|
+
const body = await bodyJson3(req);
|
|
2251
|
+
const config = await configFor(body);
|
|
2252
|
+
if ("error" in config)
|
|
2253
|
+
return json6(config, 400);
|
|
2254
|
+
const model = buildModel(config.text);
|
|
2255
|
+
const result = tracePacket({ model, packet: body.packet });
|
|
2256
|
+
return json6({
|
|
2257
|
+
source: config.source,
|
|
2258
|
+
result,
|
|
2259
|
+
coverage: {
|
|
2260
|
+
unmodelled: model.unmodelled,
|
|
2261
|
+
unparsedLines: model.unparsedLines,
|
|
2262
|
+
dynamicRouteSources: model.dynamicRouteSources
|
|
2263
|
+
}
|
|
2264
|
+
});
|
|
2265
|
+
}
|
|
2266
|
+
if (p === "/api/sim/change" && req.method === "POST") {
|
|
2267
|
+
const body = await bodyJson3(req);
|
|
2268
|
+
const config = await configFor(body);
|
|
2269
|
+
if ("error" in config)
|
|
2270
|
+
return json6(config, 400);
|
|
2271
|
+
if (!body.changes || body.changes.trim() === "") {
|
|
2272
|
+
return json6({ error: "changes are required" }, 400);
|
|
2273
|
+
}
|
|
2274
|
+
const before = tracePacket({ model: buildModel(config.text), packet: body.packet });
|
|
2275
|
+
const afterModel = buildModel(`${config.text}
|
|
2276
|
+
${body.changes}
|
|
2277
|
+
`);
|
|
2278
|
+
const after = tracePacket({ model: afterModel, packet: body.packet });
|
|
2279
|
+
return json6({
|
|
2280
|
+
source: config.source,
|
|
2281
|
+
before,
|
|
2282
|
+
after,
|
|
2283
|
+
diff: diffTraces(before, after),
|
|
2284
|
+
coverage: { unmodelled: afterModel.unmodelled, unparsedLines: afterModel.unparsedLines }
|
|
2285
|
+
});
|
|
2286
|
+
}
|
|
2287
|
+
if (p === "/api/sim/reachability" && req.method === "GET") {
|
|
2288
|
+
const config = await configFor({
|
|
2289
|
+
device: url.searchParams.get("device") ?? undefined,
|
|
2290
|
+
snapshotId: url.searchParams.get("snapshot") ?? undefined,
|
|
2291
|
+
packet: {}
|
|
2292
|
+
});
|
|
2293
|
+
if ("error" in config)
|
|
2294
|
+
return json6(config, 400);
|
|
2295
|
+
const model = buildModel(config.text);
|
|
2296
|
+
const dead = unreachableRules(model.filter);
|
|
2297
|
+
return json6({
|
|
2298
|
+
source: config.source,
|
|
2299
|
+
rules: model.filter.map((r) => ({
|
|
2300
|
+
chain: r.chain,
|
|
2301
|
+
index: r.index,
|
|
2302
|
+
action: r.action,
|
|
2303
|
+
line: r.line,
|
|
2304
|
+
raw: r.raw,
|
|
2305
|
+
disabled: r.disabled,
|
|
2306
|
+
unreachable: dead.some((d) => d.rule === r),
|
|
2307
|
+
shadowedBy: dead.find((d) => d.rule === r)?.shadowedBy.index,
|
|
2308
|
+
why: dead.find((d) => d.rule === r)?.why
|
|
2309
|
+
})),
|
|
2310
|
+
unreachableCount: dead.length
|
|
2311
|
+
});
|
|
2312
|
+
}
|
|
2313
|
+
const runMatch = p.startsWith("/api/sim/suite/") && p.endsWith("/run");
|
|
2314
|
+
if (runMatch && req.method === "POST") {
|
|
2315
|
+
const id = decodeURIComponent(p.slice("/api/sim/suite/".length, -"/run".length));
|
|
2316
|
+
const suite = await getSuite(id);
|
|
2317
|
+
if (!suite)
|
|
2318
|
+
return json6({ error: `no suite '${id}'` }, 404);
|
|
2319
|
+
const body = await bodyJson3(req);
|
|
2320
|
+
const config = await configFor({ ...body, packet: {} });
|
|
2321
|
+
if ("error" in config)
|
|
2322
|
+
return json6(config, 400);
|
|
2323
|
+
const model = buildModel(config.text);
|
|
2324
|
+
const results = suite.packets.map((entry) => {
|
|
2325
|
+
const result = tracePacket({ model, packet: entry.packet });
|
|
2326
|
+
return {
|
|
2327
|
+
name: entry.name,
|
|
2328
|
+
expect: entry.expect,
|
|
2329
|
+
verdict: result.verdict,
|
|
2330
|
+
ok: result.verdict === entry.expect,
|
|
2331
|
+
summary: result.summary
|
|
2332
|
+
};
|
|
2333
|
+
});
|
|
2334
|
+
return json6({
|
|
2335
|
+
source: config.source,
|
|
2336
|
+
suite: { id: suite.id, name: suite.name },
|
|
2337
|
+
results,
|
|
2338
|
+
passed: results.filter((r) => r.ok).length,
|
|
2339
|
+
total: results.length
|
|
2340
|
+
});
|
|
2341
|
+
}
|
|
2342
|
+
return json6({ error: "not found" }, 404);
|
|
2343
|
+
}
|
|
2344
|
+
|
|
2345
|
+
// src/observability/schedule-routes.ts
|
|
2346
|
+
var JSON_HEADERS7 = { "content-type": "application/json; charset=utf-8" };
|
|
2347
|
+
function json7(body, status = 200) {
|
|
2348
|
+
return new Response(JSON.stringify(body), { status, headers: JSON_HEADERS7 });
|
|
2349
|
+
}
|
|
2350
|
+
function latestPerDevice(runs) {
|
|
2351
|
+
const latest = new Map;
|
|
2352
|
+
for (const run of runs) {
|
|
2353
|
+
if (run.outcome !== "ok")
|
|
2354
|
+
continue;
|
|
2355
|
+
const key = run.device ?? "(fleet)";
|
|
2356
|
+
const seen = latest.get(key);
|
|
2357
|
+
if (!seen || run.startedAt > seen.startedAt)
|
|
2358
|
+
latest.set(key, run);
|
|
2359
|
+
}
|
|
2360
|
+
return [...latest.values()];
|
|
2361
|
+
}
|
|
2362
|
+
function jobRow(job, runs, now) {
|
|
2363
|
+
const current = latestPerDevice(runs);
|
|
2364
|
+
const findings = current.flatMap((r) => r.findings);
|
|
2365
|
+
return {
|
|
2366
|
+
...job,
|
|
2367
|
+
cronText: describeCron(job.cron),
|
|
2368
|
+
nextRun: nextRunOf(job.id) ?? nextRun(job.cron, now),
|
|
2369
|
+
lastRun: runs[0] ? {
|
|
2370
|
+
startedAt: runs[0].startedAt,
|
|
2371
|
+
finishedAt: runs[0].finishedAt,
|
|
2372
|
+
outcome: runs[0].outcome,
|
|
2373
|
+
device: runs[0].device,
|
|
2374
|
+
error: runs[0].error
|
|
2375
|
+
} : null,
|
|
2376
|
+
posture: {
|
|
2377
|
+
total: findings.length,
|
|
2378
|
+
worst: worstSeverity(findings) ?? null,
|
|
2379
|
+
bySeverity: severityCounts(findings),
|
|
2380
|
+
devices: current.length
|
|
2381
|
+
},
|
|
2382
|
+
runCount: runs.length
|
|
2383
|
+
};
|
|
2384
|
+
}
|
|
2385
|
+
function regressionsFor(runs, jobId) {
|
|
2386
|
+
const byDevice = new Map;
|
|
2387
|
+
for (const run of runs) {
|
|
2388
|
+
if (run.outcome !== "ok")
|
|
2389
|
+
continue;
|
|
2390
|
+
const key = run.device ?? "(fleet)";
|
|
2391
|
+
const list = byDevice.get(key);
|
|
2392
|
+
if (list)
|
|
2393
|
+
list.push(run);
|
|
2394
|
+
else
|
|
2395
|
+
byDevice.set(key, [run]);
|
|
2396
|
+
}
|
|
2397
|
+
const out = [];
|
|
2398
|
+
for (const [device, list] of byDevice) {
|
|
2399
|
+
for (let i = 0;i < list.length - 1; i++) {
|
|
2400
|
+
const diff = diffFindings(list[i + 1].findings, list[i].findings);
|
|
2401
|
+
if (diff.added.length + diff.worsened.length + diff.resolved.length === 0)
|
|
2402
|
+
continue;
|
|
2403
|
+
out.push({
|
|
2404
|
+
jobId,
|
|
2405
|
+
device,
|
|
2406
|
+
at: list[i].startedAt,
|
|
2407
|
+
summary: describeDiff(diff),
|
|
2408
|
+
added: diff.added,
|
|
2409
|
+
worsened: diff.worsened,
|
|
2410
|
+
resolved: diff.resolved
|
|
2411
|
+
});
|
|
2412
|
+
}
|
|
2413
|
+
}
|
|
2414
|
+
return out.sort((a, b) => b.at - a.at);
|
|
2415
|
+
}
|
|
2416
|
+
async function scheduleRoutes(req, url) {
|
|
2417
|
+
const p = url.pathname;
|
|
2418
|
+
if (!p.startsWith("/api/schedules"))
|
|
2419
|
+
return null;
|
|
2420
|
+
let store3;
|
|
2421
|
+
try {
|
|
2422
|
+
store3 = await scheduleStore();
|
|
2423
|
+
} catch (e) {
|
|
2424
|
+
return json7({ error: e instanceof Error ? e.message : String(e), jobs: [] }, 503);
|
|
2425
|
+
}
|
|
2426
|
+
const now = Date.now();
|
|
2427
|
+
if (p === "/api/schedules" && req.method === "GET") {
|
|
2428
|
+
const jobs = store3.listJobs().map((job) => jobRow(job, store3.runs(job.id, 500), now));
|
|
2429
|
+
return json7({
|
|
2430
|
+
jobs,
|
|
2431
|
+
schedulable: schedulableTools().map((a) => ({ tool: a.tool, summary: a.summary }))
|
|
2432
|
+
});
|
|
2433
|
+
}
|
|
2434
|
+
if (p === "/api/schedules" && req.method === "POST") {
|
|
2435
|
+
const body = await req.json().catch(() => null);
|
|
2436
|
+
if (!body)
|
|
2437
|
+
return json7({ error: "invalid JSON body" }, 400);
|
|
2438
|
+
const parsed = JobSchema.safeParse(body);
|
|
2439
|
+
if (!parsed.success) {
|
|
2440
|
+
return json7({
|
|
2441
|
+
error: parsed.error.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; ")
|
|
2442
|
+
}, 400);
|
|
2443
|
+
}
|
|
2444
|
+
if (!auditAdapter(parsed.data.tool)) {
|
|
2445
|
+
return json7({ error: `'${parsed.data.tool}' is not a schedulable auditor` }, 400);
|
|
2446
|
+
}
|
|
2447
|
+
await primeRiskIndex();
|
|
2448
|
+
const risk = liveExecutor().riskOf(parsed.data.tool);
|
|
2449
|
+
if (risk !== "READ") {
|
|
2450
|
+
return json7({ error: `'${parsed.data.tool}' is annotated ${risk}; only READ may be scheduled` }, 400);
|
|
2451
|
+
}
|
|
2452
|
+
const existing = store3.getJob(parsed.data.id);
|
|
2453
|
+
const job = { ...parsed.data, createdAt: existing?.createdAt ?? now };
|
|
2454
|
+
store3.saveJob(job);
|
|
2455
|
+
if (job.enabled)
|
|
2456
|
+
armJob(job, now);
|
|
2457
|
+
else
|
|
2458
|
+
forgetJob(job.id);
|
|
2459
|
+
return json7({ job: jobRow(job, store3.runs(job.id, 500), now), replaced: existing !== null });
|
|
2460
|
+
}
|
|
2461
|
+
if (p === "/api/schedules/timeline" && req.method === "GET") {
|
|
2462
|
+
const jobId = url.searchParams.get("job") ?? undefined;
|
|
2463
|
+
const device = url.searchParams.get("device") ?? undefined;
|
|
2464
|
+
const days = Number(url.searchParams.get("days") ?? 30);
|
|
2465
|
+
const since = now - days * 86400000;
|
|
2466
|
+
const runs = store3.runs(jobId, 2000).filter((r) => r.startedAt >= since && (!device || r.device === device));
|
|
2467
|
+
return json7({
|
|
2468
|
+
points: runs.map((r) => ({
|
|
2469
|
+
at: r.startedAt,
|
|
2470
|
+
jobId: r.jobId,
|
|
2471
|
+
device: r.device,
|
|
2472
|
+
outcome: r.outcome,
|
|
2473
|
+
total: r.findings.length,
|
|
2474
|
+
bySeverity: severityCounts(r.findings),
|
|
2475
|
+
added: r.added,
|
|
2476
|
+
worsened: r.worsened,
|
|
2477
|
+
resolved: r.resolved,
|
|
2478
|
+
durationMs: r.finishedAt - r.startedAt
|
|
2479
|
+
})).sort((a, b) => a.at - b.at)
|
|
2480
|
+
});
|
|
2481
|
+
}
|
|
2482
|
+
if (p === "/api/schedules/regressions" && req.method === "GET") {
|
|
2483
|
+
const jobId = url.searchParams.get("job") ?? undefined;
|
|
2484
|
+
const limit = Number(url.searchParams.get("limit") ?? 50);
|
|
2485
|
+
const jobs = jobId ? [jobId] : store3.listJobs().map((j) => j.id);
|
|
2486
|
+
const items = jobs.flatMap((id2) => regressionsFor(store3.runs(id2, 500), id2)).sort((a, b) => b.at - a.at).slice(0, limit);
|
|
2487
|
+
return json7({ regressions: items });
|
|
2488
|
+
}
|
|
2489
|
+
const parts = p.slice("/api/schedules/".length).split("/").filter(Boolean);
|
|
2490
|
+
if (parts.length === 0 || parts.length > 2)
|
|
2491
|
+
return json7({ error: "not found" }, 404);
|
|
2492
|
+
const id = decodeURIComponent(parts[0]);
|
|
2493
|
+
const verb = parts[1];
|
|
2494
|
+
if (!verb && req.method === "DELETE") {
|
|
2495
|
+
const job = store3.getJob(id);
|
|
2496
|
+
if (!job)
|
|
2497
|
+
return json7({ error: `no schedule '${id}'` }, 404);
|
|
2498
|
+
const runCount = store3.runs(id, 1e4).length;
|
|
2499
|
+
store3.removeJob(id);
|
|
2500
|
+
forgetJob(id);
|
|
2501
|
+
return json7({ removed: id, runsDiscarded: runCount });
|
|
2502
|
+
}
|
|
2503
|
+
if (verb === "run" && req.method === "POST") {
|
|
2504
|
+
const job = store3.getJob(id);
|
|
2505
|
+
if (!job)
|
|
2506
|
+
return json7({ error: `no schedule '${id}'` }, 404);
|
|
2507
|
+
await primeRiskIndex();
|
|
2508
|
+
logger.info(`schedule '${id}' run requested from the dashboard`);
|
|
2509
|
+
const result = await runJob(job, liveExecutor(), runnerOptions());
|
|
2510
|
+
return json7({
|
|
2511
|
+
...result,
|
|
2512
|
+
job: jobRow(job, store3.runs(id, 500), Date.now())
|
|
2513
|
+
});
|
|
2514
|
+
}
|
|
2515
|
+
if (!verb && req.method === "GET") {
|
|
2516
|
+
const job = store3.getJob(id);
|
|
2517
|
+
if (!job)
|
|
2518
|
+
return json7({ error: `no schedule '${id}'` }, 404);
|
|
2519
|
+
const runs = store3.runs(id, 500);
|
|
2520
|
+
return json7({ job: jobRow(job, runs, now), runs, regressions: regressionsFor(runs, id) });
|
|
2521
|
+
}
|
|
2522
|
+
return json7({ error: "not found" }, 404);
|
|
2523
|
+
}
|
|
2524
|
+
|
|
2525
|
+
// src/observability/explain-routes.ts
|
|
2526
|
+
var JSON_HEADERS8 = { "content-type": "application/json; charset=utf-8" };
|
|
2527
|
+
function json8(body, status = 200) {
|
|
2528
|
+
return new Response(JSON.stringify(body), { status, headers: JSON_HEADERS8 });
|
|
2529
|
+
}
|
|
2530
|
+
var storePromise5 = null;
|
|
2531
|
+
function snapshots2() {
|
|
2532
|
+
storePromise5 ??= openSnapshotStore(DEFAULT_SNAPSHOT_DB);
|
|
2533
|
+
return storePromise5;
|
|
2534
|
+
}
|
|
2535
|
+
async function configFor2(device, snapshotId) {
|
|
2536
|
+
if (snapshotId) {
|
|
2537
|
+
const snapshot = (await snapshots2()).get(snapshotId);
|
|
2538
|
+
if (!snapshot)
|
|
2539
|
+
return { error: `no snapshot '${snapshotId}'`, status: 404 };
|
|
2540
|
+
return {
|
|
2541
|
+
text: snapshot.body,
|
|
2542
|
+
device: snapshot.device ?? device,
|
|
2543
|
+
source: `snapshot ${snapshotId}`
|
|
2544
|
+
};
|
|
2545
|
+
}
|
|
2546
|
+
const name = resolveDeviceName(device);
|
|
2547
|
+
const body = await executeMikrotikCommand("/export", createContext(undefined, name));
|
|
2548
|
+
if (looksLikeError(body) || body.trim() === "") {
|
|
2549
|
+
return { error: body.trim() || "the device returned an empty export", status: 502 };
|
|
2550
|
+
}
|
|
2551
|
+
return { text: body, device: name, source: "live device" };
|
|
2552
|
+
}
|
|
2553
|
+
function payload(narrative, source, markdown) {
|
|
2554
|
+
return {
|
|
2555
|
+
narrative,
|
|
2556
|
+
markdown,
|
|
2557
|
+
mermaid: topologyMermaid(narrative),
|
|
2558
|
+
source
|
|
2559
|
+
};
|
|
2560
|
+
}
|
|
2561
|
+
async function explainRoutes(req, url) {
|
|
2562
|
+
const p = url.pathname;
|
|
2563
|
+
if (!p.startsWith("/api/explain"))
|
|
2564
|
+
return null;
|
|
2565
|
+
if (p === "/api/explain/diff" && req.method === "POST") {
|
|
2566
|
+
const body = await req.json().catch(() => null);
|
|
2567
|
+
if (!body)
|
|
2568
|
+
return json8({ error: "invalid JSON body" }, 400);
|
|
2569
|
+
if (!body.before && !body.beforeText) {
|
|
2570
|
+
return json8({ error: "a diff needs a baseline: pass `before` (a snapshot id) or `beforeText`" }, 400);
|
|
2571
|
+
}
|
|
2572
|
+
const device2 = body.device ?? "";
|
|
2573
|
+
const before = body.beforeText ? { text: body.beforeText, device: device2, source: "supplied text" } : await configFor2(device2, body.before ?? null);
|
|
2574
|
+
if ("error" in before)
|
|
2575
|
+
return json8({ error: `before: ${before.error}` }, before.status);
|
|
2576
|
+
const after = body.afterText ? { text: body.afterText, device: device2, source: "supplied text" } : await configFor2(device2, body.after ?? null);
|
|
2577
|
+
if ("error" in after)
|
|
2578
|
+
return json8({ error: `after: ${after.error}` }, after.status);
|
|
2579
|
+
const diff = diffNarratives(analyzeDevice(before.text, before.device), analyzeDevice(after.text, after.device));
|
|
2580
|
+
return json8({ diff, markdown: renderDiff(diff), before: before.source, after: after.source });
|
|
2581
|
+
}
|
|
2582
|
+
const rest = p.slice("/api/explain/".length).split("/").filter(Boolean);
|
|
2583
|
+
if (rest.length === 0)
|
|
2584
|
+
return json8({ error: "not found" }, 404);
|
|
2585
|
+
if (req.method !== "GET")
|
|
2586
|
+
return json8({ error: "not found" }, 404);
|
|
2587
|
+
const device = decodeURIComponent(rest[0]);
|
|
2588
|
+
const snapshotId = url.searchParams.get("snapshot");
|
|
2589
|
+
let config;
|
|
2590
|
+
try {
|
|
2591
|
+
config = await configFor2(device, snapshotId);
|
|
2592
|
+
} catch (e) {
|
|
2593
|
+
return json8({ error: e instanceof Error ? e.message : String(e) }, 502);
|
|
2594
|
+
}
|
|
2595
|
+
if ("error" in config)
|
|
2596
|
+
return json8({ error: config.error }, config.status);
|
|
2597
|
+
const narrative = { ...analyzeDevice(config.text, config.device), generatedAt: Date.now() };
|
|
2598
|
+
if (rest.length === 1) {
|
|
2599
|
+
return json8(payload(narrative, config.source, renderNarrative(narrative)));
|
|
2600
|
+
}
|
|
2601
|
+
if (rest[1] === "section" && rest[2]) {
|
|
2602
|
+
const section = decodeURIComponent(rest[2]);
|
|
2603
|
+
if (!NARRATIVE_SECTIONS.includes(section)) {
|
|
2604
|
+
return json8({ error: `unknown section '${section}'. Known: ${NARRATIVE_SECTIONS.join(", ")}` }, 400);
|
|
2605
|
+
}
|
|
2606
|
+
return json8(payload(narrative, config.source, renderNarrative(narrative, { sections: [section] })));
|
|
2607
|
+
}
|
|
2608
|
+
return json8({ error: "not found" }, 404);
|
|
2609
|
+
}
|
|
2610
|
+
|
|
2611
|
+
// src/observability/attack-routes.ts
|
|
2612
|
+
var JSON_HEADERS9 = { "content-type": "application/json; charset=utf-8" };
|
|
2613
|
+
function json9(body, status = 200) {
|
|
2614
|
+
return new Response(JSON.stringify(body), { status, headers: JSON_HEADERS9 });
|
|
2615
|
+
}
|
|
2616
|
+
async function attackRoutes(req, url) {
|
|
2617
|
+
const p = url.pathname;
|
|
2618
|
+
if (!p.startsWith("/api/attacks"))
|
|
2619
|
+
return null;
|
|
2620
|
+
let store3;
|
|
2621
|
+
try {
|
|
2622
|
+
store3 = await attackStore();
|
|
2623
|
+
} catch (e) {
|
|
2624
|
+
return json9({ error: e instanceof Error ? e.message : String(e), incidents: [] }, 503);
|
|
2625
|
+
}
|
|
2626
|
+
const cfg = getConfig().attacks;
|
|
2627
|
+
const now = Date.now();
|
|
2628
|
+
if (p === "/api/attacks" && req.method === "GET") {
|
|
2629
|
+
const hours = Number(url.searchParams.get("hours") ?? 24);
|
|
2630
|
+
const incidents = store3.listIncidents({
|
|
2631
|
+
since: now - hours * 3600000,
|
|
2632
|
+
limit: Number(url.searchParams.get("limit") ?? 200)
|
|
2633
|
+
});
|
|
2634
|
+
const responses = store3.listResponses({ active: true, limit: 500 });
|
|
2635
|
+
const blocked = new Set(responses.map((r) => r.source));
|
|
2636
|
+
return json9({
|
|
2637
|
+
incidents: incidents.map((i) => ({ ...i, blocked: blocked.has(i.source) })),
|
|
2638
|
+
responses,
|
|
2639
|
+
posture: {
|
|
2640
|
+
enabled: cfg.enabled,
|
|
2641
|
+
mode: cfg.mode,
|
|
2642
|
+
autoRespondTo: cfg.autoRespondTo,
|
|
2643
|
+
minConfidence: cfg.minConfidence
|
|
2644
|
+
}
|
|
2645
|
+
});
|
|
2646
|
+
}
|
|
2647
|
+
if (p === "/api/attacks/sources" && req.method === "GET") {
|
|
2648
|
+
const hours = Number(url.searchParams.get("hours") ?? 168);
|
|
2649
|
+
const incidents = store3.listIncidents({ since: now - hours * 3600000, limit: 1000 });
|
|
2650
|
+
const bySource = new Map;
|
|
2651
|
+
for (const i of incidents) {
|
|
2652
|
+
if (i.source === "")
|
|
2653
|
+
continue;
|
|
2654
|
+
const seen = bySource.get(i.source);
|
|
2655
|
+
if (seen) {
|
|
2656
|
+
for (const d of i.devices)
|
|
2657
|
+
seen.devices.add(d);
|
|
2658
|
+
for (const d of i.detectors)
|
|
2659
|
+
seen.detectors.add(d);
|
|
2660
|
+
seen.firstTs = Math.min(seen.firstTs, i.firstTs);
|
|
2661
|
+
seen.lastTs = Math.max(seen.lastTs, i.lastTs);
|
|
2662
|
+
seen.incidents++;
|
|
2663
|
+
} else {
|
|
2664
|
+
bySource.set(i.source, {
|
|
2665
|
+
source: i.source,
|
|
2666
|
+
devices: new Set(i.devices),
|
|
2667
|
+
detectors: new Set(i.detectors),
|
|
2668
|
+
firstTs: i.firstTs,
|
|
2669
|
+
lastTs: i.lastTs,
|
|
2670
|
+
incidents: 1,
|
|
2671
|
+
worst: i.severity
|
|
2672
|
+
});
|
|
2673
|
+
}
|
|
2674
|
+
}
|
|
2675
|
+
const active = new Set(store3.listResponses({ active: true, limit: 500 }).map((r) => r.source));
|
|
2676
|
+
return json9({
|
|
2677
|
+
sources: [...bySource.values()].map((s) => ({
|
|
2678
|
+
source: s.source,
|
|
2679
|
+
devices: [...s.devices].sort(),
|
|
2680
|
+
detectors: [...s.detectors].sort(),
|
|
2681
|
+
firstTs: s.firstTs,
|
|
2682
|
+
lastTs: s.lastTs,
|
|
2683
|
+
incidents: s.incidents,
|
|
2684
|
+
worst: s.worst,
|
|
2685
|
+
blocked: active.has(s.source),
|
|
2686
|
+
geo: getDeviceGeo(s.source)
|
|
2687
|
+
})).sort((a, b) => b.incidents - a.incidents || b.lastTs - a.lastTs)
|
|
2688
|
+
});
|
|
2689
|
+
}
|
|
2690
|
+
if (p === "/api/attacks/config" && req.method === "GET") {
|
|
2691
|
+
const guards = buildGuards();
|
|
2692
|
+
const never = [...neverBlockSet(guards)].sort();
|
|
2693
|
+
const check = url.searchParams.get("check");
|
|
2694
|
+
return json9({
|
|
2695
|
+
config: cfg,
|
|
2696
|
+
neverBlock: never,
|
|
2697
|
+
check: check ? { address: check, protected: isNeverBlock(check, new Set(never)) } : undefined
|
|
2698
|
+
});
|
|
2699
|
+
}
|
|
2700
|
+
if (p === "/api/attacks/config" && req.method === "POST") {
|
|
2701
|
+
return json9({
|
|
2702
|
+
error: "edit the `attacks` block in Config \u2014 the policy has to survive a restart, so it is not held in memory here"
|
|
2703
|
+
}, 501);
|
|
2704
|
+
}
|
|
2705
|
+
if (p === "/api/attacks/scan" && req.method === "POST") {
|
|
2706
|
+
logger.info("attack sweep requested from the dashboard");
|
|
2707
|
+
const result = await sweep({ respond: false });
|
|
2708
|
+
return json9({
|
|
2709
|
+
incidents: result.incidents,
|
|
2710
|
+
unavailable: result.unavailable,
|
|
2711
|
+
devices: result.devices
|
|
2712
|
+
});
|
|
2713
|
+
}
|
|
2714
|
+
const rest = p.slice("/api/attacks/".length).split("/").filter(Boolean);
|
|
2715
|
+
if (rest[0] === "responses" && rest[1] && req.method === "DELETE") {
|
|
2716
|
+
const address = decodeURIComponent(rest[1]);
|
|
2717
|
+
const response = store3.responseFor(address);
|
|
2718
|
+
const devices = response?.devices ?? [];
|
|
2719
|
+
if (devices.length === 0)
|
|
2720
|
+
return json9({ error: `no recorded block for ${address}` }, 404);
|
|
2721
|
+
const results = await revokeBlock(address, devices, response?.list);
|
|
2722
|
+
store3.revokeResponse(address, now);
|
|
2723
|
+
return json9({ address, results });
|
|
2724
|
+
}
|
|
2725
|
+
if (rest.length === 1 && req.method === "GET") {
|
|
2726
|
+
const incident = store3.getIncident(decodeURIComponent(rest[0]));
|
|
2727
|
+
if (!incident)
|
|
2728
|
+
return json9({ error: "not found" }, 404);
|
|
2729
|
+
return json9({ incident, response: store3.responseFor(incident.source) });
|
|
2730
|
+
}
|
|
2731
|
+
if (rest.length === 2 && rest[1] === "respond" && req.method === "POST") {
|
|
2732
|
+
const incident = store3.getIncident(decodeURIComponent(rest[0]));
|
|
2733
|
+
if (!incident)
|
|
2734
|
+
return json9({ error: "not found" }, 404);
|
|
2735
|
+
const body = await req.json().catch(() => ({}));
|
|
2736
|
+
if (body.action === "dismiss") {
|
|
2737
|
+
return json9({ dismissed: incident.id });
|
|
2738
|
+
}
|
|
2739
|
+
const decision = decide({
|
|
2740
|
+
incident,
|
|
2741
|
+
policy: policyFromConfig(),
|
|
2742
|
+
guards: buildGuards(),
|
|
2743
|
+
recentBlockCount: store3.countRecentBlocks(incident.devices, now - 3600000),
|
|
2744
|
+
manual: true,
|
|
2745
|
+
confirm: body.confirm ?? false,
|
|
2746
|
+
timeout: body.timeout
|
|
2747
|
+
});
|
|
2748
|
+
if (!isPlan(decision)) {
|
|
2749
|
+
return json9({ refused: true, guard: decision.guard, reason: decision.reason }, 200);
|
|
2750
|
+
}
|
|
2751
|
+
if (decision.action === "escalate") {
|
|
2752
|
+
return json9({ escalated: true, reason: decision.reason });
|
|
2753
|
+
}
|
|
2754
|
+
if (!body.confirm) {
|
|
2755
|
+
return json9({ dryRun: true, plan: decision });
|
|
2756
|
+
}
|
|
2757
|
+
const applied = await executePlan(decision);
|
|
2758
|
+
const ok = applied.some((r) => r.ok);
|
|
2759
|
+
store3.recordResponse({
|
|
2760
|
+
incidentId: incident.id,
|
|
2761
|
+
action: decision.action,
|
|
2762
|
+
source: decision.source,
|
|
2763
|
+
devices: decision.devices,
|
|
2764
|
+
timeout: decision.timeout,
|
|
2765
|
+
list: decision.list,
|
|
2766
|
+
reason: decision.reason,
|
|
2767
|
+
ts: now,
|
|
2768
|
+
ok,
|
|
2769
|
+
error: ok ? undefined : applied.map((r) => r.detail).join("; ")
|
|
2770
|
+
});
|
|
2771
|
+
return json9({ applied, ok, plan: decision });
|
|
2772
|
+
}
|
|
2773
|
+
return json9({ error: "not found" }, 404);
|
|
2774
|
+
}
|
|
2775
|
+
|
|
2776
|
+
// src/observability/alert-routes.ts
|
|
2777
|
+
var JSON_HEADERS10 = { "content-type": "application/json; charset=utf-8" };
|
|
2778
|
+
function json10(body, status = 200) {
|
|
2779
|
+
return new Response(JSON.stringify(body), { status, headers: JSON_HEADERS10 });
|
|
2780
|
+
}
|
|
2781
|
+
function ruleRow(rule, subject, status, since) {
|
|
2782
|
+
return {
|
|
2783
|
+
id: rule.id,
|
|
2784
|
+
subject,
|
|
2785
|
+
description: rule.description,
|
|
2786
|
+
when: rule.when,
|
|
2787
|
+
severity: rule.severity,
|
|
2788
|
+
channels: rule.channels,
|
|
2789
|
+
for: rule.for,
|
|
2790
|
+
cooldown: rule.cooldown,
|
|
2791
|
+
enabled: rule.enabled,
|
|
2792
|
+
mutedUntil: rule.mutedUntil,
|
|
2793
|
+
status,
|
|
2794
|
+
since,
|
|
2795
|
+
warnings: ruleWarnings(rule)
|
|
2796
|
+
};
|
|
2797
|
+
}
|
|
2798
|
+
function previewRule(rule, events) {
|
|
2799
|
+
if (!isEventTrigger(rule.when))
|
|
2800
|
+
return { fires: 0, resolves: 0, matched: 0 };
|
|
2801
|
+
let state = initialState(events[0]?.ts ?? 0);
|
|
2802
|
+
let fires = 0;
|
|
2803
|
+
let resolves = 0;
|
|
2804
|
+
let matched = 0;
|
|
2805
|
+
for (const e of [...events].sort((a, b) => a.ts - b.ts)) {
|
|
2806
|
+
const ev = {
|
|
2807
|
+
kind: "tool_call",
|
|
2808
|
+
tool: e.tool,
|
|
2809
|
+
device: e.device,
|
|
2810
|
+
risk: e.risk,
|
|
2811
|
+
isError: e.isError
|
|
2812
|
+
};
|
|
2813
|
+
const met = eventMet(rule.when, ev);
|
|
2814
|
+
if (met)
|
|
2815
|
+
matched++;
|
|
2816
|
+
const res = step(rule, state, met, e.ts);
|
|
2817
|
+
state = res.state;
|
|
2818
|
+
if (res.action.type === "fire")
|
|
2819
|
+
fires++;
|
|
2820
|
+
if (res.action.type === "resolve")
|
|
2821
|
+
resolves++;
|
|
2822
|
+
}
|
|
2823
|
+
return { fires, resolves, matched };
|
|
2824
|
+
}
|
|
2825
|
+
async function alertRoutes(req, url, db) {
|
|
2826
|
+
const p = url.pathname;
|
|
2827
|
+
if (!p.startsWith("/api/alerts"))
|
|
2828
|
+
return null;
|
|
2829
|
+
const engine = getAlertEngine();
|
|
2830
|
+
const channels = getConfig().alerts?.channels;
|
|
2831
|
+
if (p === "/api/alerts" && req.method === "GET") {
|
|
2832
|
+
if (!engine) {
|
|
2833
|
+
return json10({ configured: false, rules: [], active: [], channels: {} });
|
|
2834
|
+
}
|
|
2835
|
+
const now = Date.now();
|
|
2836
|
+
const rows = engine.snapshot().map(({ rule, subject, state }) => ruleRow(rule, subject, isMuted(rule, now) ? "muted" : !rule.enabled ? "disabled" : state.status, state.since));
|
|
2837
|
+
return json10({
|
|
2838
|
+
configured: true,
|
|
2839
|
+
rules: rows,
|
|
2840
|
+
active: rows.filter((r) => r.status === "firing"),
|
|
2841
|
+
channels: redactChannels(channels)
|
|
2842
|
+
});
|
|
2843
|
+
}
|
|
2844
|
+
if (p === "/api/alerts/history" && req.method === "GET") {
|
|
2845
|
+
if (!engine)
|
|
2846
|
+
return json10({ history: [] });
|
|
2847
|
+
const hours = Number(url.searchParams.get("hours") ?? 24);
|
|
2848
|
+
const history = await engine.history({
|
|
2849
|
+
sinceMs: Date.now() - hours * 3600000,
|
|
2850
|
+
ruleId: url.searchParams.get("rule") ?? undefined,
|
|
2851
|
+
limit: Number(url.searchParams.get("limit") ?? 200)
|
|
2852
|
+
});
|
|
2853
|
+
return json10({ history });
|
|
2854
|
+
}
|
|
2855
|
+
if (p === "/api/alerts/rules" && req.method === "POST") {
|
|
2856
|
+
if (!engine)
|
|
2857
|
+
return json10({ error: "alerting is not configured" }, 400);
|
|
2858
|
+
const parsed = AlertRuleSchema.safeParse(await req.json());
|
|
2859
|
+
if (!parsed.success) {
|
|
2860
|
+
return json10({ error: parsed.error.issues.map((i) => i.message).join("; ") }, 400);
|
|
2861
|
+
}
|
|
2862
|
+
const rules = engine.configuredRules();
|
|
2863
|
+
if (rules.some((r) => r.id === parsed.data.id)) {
|
|
2864
|
+
return json10({ error: `rule '${parsed.data.id}' already exists` }, 409);
|
|
2865
|
+
}
|
|
2866
|
+
engine.setRules([...rules, parsed.data]);
|
|
2867
|
+
return json10({ ok: true, id: parsed.data.id });
|
|
2868
|
+
}
|
|
2869
|
+
const idMatch = p.match(/^\/api\/alerts\/rules\/([^/]+)$/);
|
|
2870
|
+
if (idMatch) {
|
|
2871
|
+
if (!engine)
|
|
2872
|
+
return json10({ error: "alerting is not configured" }, 400);
|
|
2873
|
+
const id = decodeURIComponent(idMatch[1]);
|
|
2874
|
+
const rules = engine.configuredRules();
|
|
2875
|
+
const existing = rules.find((r) => r.id === id);
|
|
2876
|
+
if (!existing)
|
|
2877
|
+
return json10({ error: `unknown rule: ${id}` }, 404);
|
|
2878
|
+
if (req.method === "DELETE") {
|
|
2879
|
+
engine.setRules(rules.filter((r) => r.id !== id));
|
|
2880
|
+
return json10({ ok: true });
|
|
2881
|
+
}
|
|
2882
|
+
if (req.method === "PATCH") {
|
|
2883
|
+
const body = await req.json();
|
|
2884
|
+
let mutedUntil = existing.mutedUntil;
|
|
2885
|
+
if (typeof body.mute === "string") {
|
|
2886
|
+
const ms = parseDuration(body.mute);
|
|
2887
|
+
if (ms === null)
|
|
2888
|
+
return json10({ error: `invalid duration: ${body.mute}` }, 400);
|
|
2889
|
+
mutedUntil = ms === 0 ? undefined : Date.now() + ms;
|
|
2890
|
+
delete body.mute;
|
|
2891
|
+
}
|
|
2892
|
+
const merged = AlertRuleSchema.safeParse({ ...existing, ...body, mutedUntil });
|
|
2893
|
+
if (!merged.success) {
|
|
2894
|
+
return json10({ error: merged.error.issues.map((i) => i.message).join("; ") }, 400);
|
|
2895
|
+
}
|
|
2896
|
+
engine.setRules(rules.map((r) => r.id === id ? merged.data : r));
|
|
2897
|
+
return json10({ ok: true, rule: merged.data });
|
|
2898
|
+
}
|
|
2899
|
+
}
|
|
2900
|
+
if (p === "/api/alerts/test" && req.method === "POST") {
|
|
2901
|
+
const body = await req.json();
|
|
2902
|
+
if (!channels || !body?.channel)
|
|
2903
|
+
return json10({ error: "channel is required" }, 400);
|
|
2904
|
+
try {
|
|
2905
|
+
const result = await deliver(body.channel, channels, {
|
|
2906
|
+
ruleId: "test",
|
|
2907
|
+
severity: "low",
|
|
2908
|
+
kind: "fire",
|
|
2909
|
+
title: "Test notification from mikrotik-mcp",
|
|
2910
|
+
body: "If you can read this, this channel is configured correctly.",
|
|
2911
|
+
at: Date.now()
|
|
2912
|
+
});
|
|
2913
|
+
return json10(result);
|
|
2914
|
+
} catch (e) {
|
|
2915
|
+
logger.error(`Alert channel test failed: ${logError(e)}`);
|
|
2916
|
+
return json10({ error: clientError(e) }, 502);
|
|
2917
|
+
}
|
|
2918
|
+
}
|
|
2919
|
+
if (p === "/api/alerts/preview" && req.method === "POST") {
|
|
2920
|
+
const parsed = AlertRuleSchema.safeParse(await req.json());
|
|
2921
|
+
if (!parsed.success) {
|
|
2922
|
+
return json10({ error: parsed.error.issues.map((i) => i.message).join("; ") }, 400);
|
|
2923
|
+
}
|
|
2924
|
+
const hours = Number(url.searchParams.get("hours") ?? 24);
|
|
2925
|
+
if (!db) {
|
|
2926
|
+
return json10({ hours, sampled: 0, fires: 0, resolves: 0, matched: 0, noHistory: true });
|
|
2927
|
+
}
|
|
2928
|
+
const events = db.query({ since: Date.now() - hours * 3600000, limit: 5000 }).map((e) => ({
|
|
2929
|
+
ts: e.ts,
|
|
2930
|
+
tool: e.tool,
|
|
2931
|
+
device: e.device,
|
|
2932
|
+
risk: e.risk,
|
|
2933
|
+
isError: e.isError
|
|
2934
|
+
}));
|
|
2935
|
+
return json10({ hours, sampled: events.length, ...previewRule(parsed.data, events) });
|
|
2936
|
+
}
|
|
2937
|
+
return null;
|
|
2938
|
+
}
|
|
2939
|
+
|
|
2940
|
+
// src/observability/memory-routes.ts
|
|
2941
|
+
var JSON_HEADERS11 = { "content-type": "application/json; charset=utf-8" };
|
|
2942
|
+
function json11(body, status = 200) {
|
|
2943
|
+
return new Response(JSON.stringify(body), { status, headers: JSON_HEADERS11 });
|
|
2944
|
+
}
|
|
2945
|
+
async function bodyJson4(req) {
|
|
1846
2946
|
return await req.json();
|
|
1847
2947
|
}
|
|
1848
2948
|
async function memoryRoutes(req, url) {
|
|
@@ -1851,29 +2951,29 @@ async function memoryRoutes(req, url) {
|
|
|
1851
2951
|
return null;
|
|
1852
2952
|
const cfg = getConfig();
|
|
1853
2953
|
if (!cfg.memory.enabled) {
|
|
1854
|
-
return
|
|
2954
|
+
return json11({ error: "Knowledge-graph memory is disabled" }, 503);
|
|
1855
2955
|
}
|
|
1856
2956
|
if (p === "/api/memory/config" && req.method === "GET") {
|
|
1857
2957
|
let stats = null;
|
|
1858
2958
|
try {
|
|
1859
|
-
const
|
|
1860
|
-
stats =
|
|
2959
|
+
const store4 = await getMemoryStore();
|
|
2960
|
+
stats = store4.stats();
|
|
1861
2961
|
} catch {}
|
|
1862
|
-
return
|
|
2962
|
+
return json11({
|
|
1863
2963
|
enabled: cfg.memory.enabled,
|
|
1864
2964
|
dbPath: cfg.memory.dbPath,
|
|
1865
2965
|
stats
|
|
1866
2966
|
});
|
|
1867
2967
|
}
|
|
1868
2968
|
if (p === "/api/memory/config" && req.method === "POST") {
|
|
1869
|
-
const body = await
|
|
2969
|
+
const body = await bodyJson4(req);
|
|
1870
2970
|
const updates = {};
|
|
1871
2971
|
if (body.dbPath !== undefined && typeof body.dbPath === "string" && body.dbPath !== cfg.memory.dbPath) {
|
|
1872
2972
|
updates.dbPath = body.dbPath;
|
|
1873
2973
|
try {
|
|
1874
2974
|
await reopenMemoryStore(body.dbPath);
|
|
1875
2975
|
} catch (e) {
|
|
1876
|
-
return
|
|
2976
|
+
return json11({
|
|
1877
2977
|
error: `Failed to open memory DB at ${body.dbPath}: ${e instanceof Error ? e.message : String(e)}`
|
|
1878
2978
|
}, 400);
|
|
1879
2979
|
}
|
|
@@ -1892,79 +2992,79 @@ async function memoryRoutes(req, url) {
|
|
|
1892
2992
|
}
|
|
1893
2993
|
let stats = null;
|
|
1894
2994
|
try {
|
|
1895
|
-
const
|
|
1896
|
-
stats =
|
|
2995
|
+
const store4 = await getMemoryStore();
|
|
2996
|
+
stats = store4.stats();
|
|
1897
2997
|
} catch {}
|
|
1898
|
-
return
|
|
2998
|
+
return json11({
|
|
1899
2999
|
ok: true,
|
|
1900
3000
|
enabled: getConfig().memory.enabled,
|
|
1901
3001
|
dbPath: getConfig().memory.dbPath,
|
|
1902
3002
|
stats
|
|
1903
3003
|
});
|
|
1904
3004
|
}
|
|
1905
|
-
const
|
|
3005
|
+
const store3 = await getMemoryStore();
|
|
1906
3006
|
if (p === "/api/memory/graph" && req.method === "GET") {
|
|
1907
|
-
return
|
|
3007
|
+
return json11(store3.readGraph());
|
|
1908
3008
|
}
|
|
1909
3009
|
if (p === "/api/memory/stats" && req.method === "GET") {
|
|
1910
|
-
return
|
|
3010
|
+
return json11(store3.stats());
|
|
1911
3011
|
}
|
|
1912
3012
|
if (p === "/api/memory/search" && req.method === "GET") {
|
|
1913
3013
|
const q = url.searchParams.get("q") || "";
|
|
1914
3014
|
const limit = Number(url.searchParams.get("limit") ?? 50);
|
|
1915
|
-
return
|
|
3015
|
+
return json11(store3.searchNodes(q, limit));
|
|
1916
3016
|
}
|
|
1917
3017
|
if (p === "/api/memory/activity" && req.method === "GET") {
|
|
1918
3018
|
const limit = Number(url.searchParams.get("limit") ?? 50);
|
|
1919
3019
|
const since = url.searchParams.get("since");
|
|
1920
|
-
return
|
|
3020
|
+
return json11(store3.activity(limit, since ? Number(since) : undefined));
|
|
1921
3021
|
}
|
|
1922
3022
|
const entityMatch = p.match(/^\/api\/memory\/entity\/(.+)$/);
|
|
1923
3023
|
if (entityMatch && req.method === "GET") {
|
|
1924
3024
|
const name = decodeURIComponent(entityMatch[1]);
|
|
1925
|
-
const graph =
|
|
3025
|
+
const graph = store3.openNodes([name]);
|
|
1926
3026
|
if (graph.entities.length === 0)
|
|
1927
|
-
return
|
|
1928
|
-
return
|
|
3027
|
+
return json11({ error: "Entity not found" }, 404);
|
|
3028
|
+
return json11({ entity: graph.entities[0], relations: graph.relations });
|
|
1929
3029
|
}
|
|
1930
3030
|
if (p === "/api/memory/entities" && req.method === "POST") {
|
|
1931
|
-
const body = await
|
|
1932
|
-
const created =
|
|
1933
|
-
return
|
|
3031
|
+
const body = await bodyJson4(req);
|
|
3032
|
+
const created = store3.createEntities(body.entities ?? []);
|
|
3033
|
+
return json11({ created, count: created.length });
|
|
1934
3034
|
}
|
|
1935
3035
|
if (p === "/api/memory/relations" && req.method === "POST") {
|
|
1936
|
-
const body = await
|
|
1937
|
-
const created =
|
|
1938
|
-
return
|
|
3036
|
+
const body = await bodyJson4(req);
|
|
3037
|
+
const created = store3.createRelations(body.relations ?? []);
|
|
3038
|
+
return json11({ created, count: created.length });
|
|
1939
3039
|
}
|
|
1940
3040
|
if (p === "/api/memory/observations" && req.method === "POST") {
|
|
1941
|
-
const body = await
|
|
1942
|
-
const results =
|
|
1943
|
-
return
|
|
3041
|
+
const body = await bodyJson4(req);
|
|
3042
|
+
const results = store3.addObservations(body.observations ?? []);
|
|
3043
|
+
return json11({ results });
|
|
1944
3044
|
}
|
|
1945
3045
|
if (p === "/api/memory/entities" && req.method === "DELETE") {
|
|
1946
|
-
const body = await
|
|
1947
|
-
const removed =
|
|
1948
|
-
return
|
|
3046
|
+
const body = await bodyJson4(req);
|
|
3047
|
+
const removed = store3.deleteEntities(body.names ?? []);
|
|
3048
|
+
return json11({ removed });
|
|
1949
3049
|
}
|
|
1950
3050
|
if (p === "/api/memory/relations" && req.method === "DELETE") {
|
|
1951
|
-
const body = await
|
|
1952
|
-
const removed =
|
|
1953
|
-
return
|
|
3051
|
+
const body = await bodyJson4(req);
|
|
3052
|
+
const removed = store3.deleteRelations(body.relations ?? []);
|
|
3053
|
+
return json11({ removed });
|
|
1954
3054
|
}
|
|
1955
3055
|
if (p === "/api/memory/observations" && req.method === "DELETE") {
|
|
1956
|
-
const body = await
|
|
1957
|
-
const removed =
|
|
1958
|
-
return
|
|
3056
|
+
const body = await bodyJson4(req);
|
|
3057
|
+
const removed = store3.deleteObservations(body.deletions ?? []);
|
|
3058
|
+
return json11({ removed });
|
|
1959
3059
|
}
|
|
1960
3060
|
return null;
|
|
1961
3061
|
}
|
|
1962
3062
|
|
|
1963
3063
|
// src/observability/dashboard.ts
|
|
1964
3064
|
var SERVER_TAG3 = "mikrotik-mcp";
|
|
1965
|
-
var
|
|
1966
|
-
function
|
|
1967
|
-
return new Response(JSON.stringify(body), { status, headers:
|
|
3065
|
+
var JSON_HEADERS12 = { "content-type": "application/json; charset=utf-8" };
|
|
3066
|
+
function json12(body, status = 200) {
|
|
3067
|
+
return new Response(JSON.stringify(body), { status, headers: JSON_HEADERS12 });
|
|
1968
3068
|
}
|
|
1969
3069
|
function restartProcess() {
|
|
1970
3070
|
let ok = false;
|
|
@@ -1992,7 +3092,7 @@ function runUpgrade(spec) {
|
|
|
1992
3092
|
env: process.env,
|
|
1993
3093
|
stdio: ["ignore", "pipe", "pipe"]
|
|
1994
3094
|
});
|
|
1995
|
-
const
|
|
3095
|
+
const timer4 = setTimeout(() => {
|
|
1996
3096
|
child.kill();
|
|
1997
3097
|
resolve({ ok: false, log: `${out}
|
|
1998
3098
|
(timed out after 180s)` });
|
|
@@ -2000,12 +3100,12 @@ function runUpgrade(spec) {
|
|
|
2000
3100
|
child.stdout?.on("data", (d) => out += d.toString());
|
|
2001
3101
|
child.stderr?.on("data", (d) => out += d.toString());
|
|
2002
3102
|
child.on("error", (e) => {
|
|
2003
|
-
clearTimeout(
|
|
3103
|
+
clearTimeout(timer4);
|
|
2004
3104
|
resolve({ ok: false, log: `${out}
|
|
2005
3105
|
spawn error: ${String(e)}` });
|
|
2006
3106
|
});
|
|
2007
3107
|
child.on("exit", (code) => {
|
|
2008
|
-
clearTimeout(
|
|
3108
|
+
clearTimeout(timer4);
|
|
2009
3109
|
resolve({ ok: code === 0, log: out.trim() || `(exit ${code})` });
|
|
2010
3110
|
});
|
|
2011
3111
|
});
|
|
@@ -2039,8 +3139,8 @@ function filterFromQuery(url) {
|
|
|
2039
3139
|
until: num("until")
|
|
2040
3140
|
};
|
|
2041
3141
|
}
|
|
2042
|
-
function facets(
|
|
2043
|
-
const recent =
|
|
3142
|
+
function facets(store3) {
|
|
3143
|
+
const recent = store3.query({ limit: 5000 });
|
|
2044
3144
|
const tools = new Set;
|
|
2045
3145
|
const devices = new Set;
|
|
2046
3146
|
for (const e of recent) {
|
|
@@ -2053,8 +3153,8 @@ function facets(store) {
|
|
|
2053
3153
|
devices: [...devices].sort()
|
|
2054
3154
|
};
|
|
2055
3155
|
}
|
|
2056
|
-
function deviceActivity(
|
|
2057
|
-
const recent =
|
|
3156
|
+
function deviceActivity(store3) {
|
|
3157
|
+
const recent = store3.query({ limit: 5000 });
|
|
2058
3158
|
const map = new Map;
|
|
2059
3159
|
for (const e of recent) {
|
|
2060
3160
|
if (!e.device)
|
|
@@ -2083,9 +3183,9 @@ function deviceActivity(store) {
|
|
|
2083
3183
|
}
|
|
2084
3184
|
return out;
|
|
2085
3185
|
}
|
|
2086
|
-
function devicesPayload(
|
|
3186
|
+
function devicesPayload(store3) {
|
|
2087
3187
|
const cfg = getConfig();
|
|
2088
|
-
const activity = deviceActivity(
|
|
3188
|
+
const activity = deviceActivity(store3);
|
|
2089
3189
|
const poolEnabled = isPoolEnabled();
|
|
2090
3190
|
const poolMap = new Map(poolStatus().map((p) => [p.device, p]));
|
|
2091
3191
|
const devices = Object.entries(cfg.devices).map(([name, dc]) => {
|
|
@@ -2096,7 +3196,8 @@ function devicesPayload(store) {
|
|
|
2096
3196
|
host: dc.host,
|
|
2097
3197
|
port: dc.port,
|
|
2098
3198
|
mac: dc.mac,
|
|
2099
|
-
transport: dc.mac ? "mac-telnet" : "ssh",
|
|
3199
|
+
transport: dc.mac ? "mac-telnet" : dc.api ? "rest" : "ssh",
|
|
3200
|
+
restPort: dc.api ? dc.apiPort ?? 443 : undefined,
|
|
2100
3201
|
address: dc.mac ? dc.mac : `${dc.host}:${dc.port}`,
|
|
2101
3202
|
username: dc.username,
|
|
2102
3203
|
authMode: dc.mac ? "mac-telnet" : dc.keyFilename || dc.privateKey ? "key" : dc.password ? "password" : "none",
|
|
@@ -2258,11 +3359,11 @@ function issues(error) {
|
|
|
2258
3359
|
async function configRoutes(req, url, admin) {
|
|
2259
3360
|
const p = url.pathname;
|
|
2260
3361
|
if (p === "/api/config-schema" && req.method === "GET") {
|
|
2261
|
-
return
|
|
3362
|
+
return json12(configSchemaJson());
|
|
2262
3363
|
}
|
|
2263
3364
|
if (p === "/api/config/validate" && req.method === "POST") {
|
|
2264
3365
|
const merged = mergeSecrets(await readJson(req), getConfig());
|
|
2265
|
-
return
|
|
3366
|
+
return json12(validateConfig(merged));
|
|
2266
3367
|
}
|
|
2267
3368
|
if (p === "/api/config/test-device" && req.method === "POST") {
|
|
2268
3369
|
const body = await readJson(req);
|
|
@@ -2271,22 +3372,22 @@ async function configRoutes(req, url, admin) {
|
|
|
2271
3372
|
const merged = mergeSecrets(body?.config, currentDc);
|
|
2272
3373
|
const parsed = DeviceConfigSchema.safeParse(merged);
|
|
2273
3374
|
if (!parsed.success)
|
|
2274
|
-
return
|
|
3375
|
+
return json12({ ok: false, errors: issues(parsed.error) }, 400);
|
|
2275
3376
|
const status = await probeDevice(`config-test:${name}`, parsed.data);
|
|
2276
|
-
return
|
|
3377
|
+
return json12({ ok: true, status });
|
|
2277
3378
|
}
|
|
2278
3379
|
if (p === "/api/config/preview" && req.method === "POST") {
|
|
2279
3380
|
const before = JSON.stringify(redact(getConfig()), null, 2);
|
|
2280
3381
|
const after = JSON.stringify(await readJson(req) ?? {}, null, 2);
|
|
2281
3382
|
const d = diffLines(before, after, { fromLabel: "current", toLabel: "edited" });
|
|
2282
|
-
return
|
|
3383
|
+
return json12({ summary: d.summary, unified: d.unified });
|
|
2283
3384
|
}
|
|
2284
3385
|
if (p === "/api/config" && req.method === "POST") {
|
|
2285
3386
|
const body = await readJson(req);
|
|
2286
3387
|
const merged = mergeSecrets(body?.config, getConfig());
|
|
2287
3388
|
const v = validateConfig(merged);
|
|
2288
3389
|
if (!v.ok || !v.value)
|
|
2289
|
-
return
|
|
3390
|
+
return json12({ ok: false, errors: v.errors }, 400);
|
|
2290
3391
|
const prev = getConfig();
|
|
2291
3392
|
const before = JSON.stringify(redact(prev), null, 2);
|
|
2292
3393
|
const after = JSON.stringify(redact(v.value), null, 2);
|
|
@@ -2295,25 +3396,25 @@ async function configRoutes(req, url, admin) {
|
|
|
2295
3396
|
const nextDevs = Object.keys(v.value.devices);
|
|
2296
3397
|
const devicesChanged = prevDevs.length !== nextDevs.length || prevDevs.some((d) => !nextDevs.includes(d));
|
|
2297
3398
|
const res = admin.applyConfig(v.value, clampRollback(body?.rollbackMs));
|
|
2298
|
-
return
|
|
3399
|
+
return json12({ ok: true, ...res, devicesChanged, summary: diff.summary, unified: diff.unified });
|
|
2299
3400
|
}
|
|
2300
3401
|
if (p === "/api/config/keep" && req.method === "POST") {
|
|
2301
3402
|
const body = await readJson(req);
|
|
2302
3403
|
const kept = admin.keepConfig(String(body?.pendingId ?? ""));
|
|
2303
3404
|
if (kept)
|
|
2304
3405
|
recordVersion(getConfig(), "auto", Date.now());
|
|
2305
|
-
return
|
|
3406
|
+
return json12({ kept });
|
|
2306
3407
|
}
|
|
2307
3408
|
if (p === "/api/config/rollback" && req.method === "POST") {
|
|
2308
3409
|
const body = await readJson(req);
|
|
2309
3410
|
const rolledBack = admin.rollback(String(body?.pendingId ?? ""));
|
|
2310
|
-
return
|
|
3411
|
+
return json12({ rolledBack, config: redact(getConfig()) });
|
|
2311
3412
|
}
|
|
2312
3413
|
if (p === "/api/reload" && req.method === "POST") {
|
|
2313
3414
|
const body = await readJson(req);
|
|
2314
3415
|
if (body?.hard === true) {
|
|
2315
3416
|
const relaunched = restartProcess();
|
|
2316
|
-
return
|
|
3417
|
+
return json12({
|
|
2317
3418
|
ok: true,
|
|
2318
3419
|
mode: "restart",
|
|
2319
3420
|
note: relaunched ? "Restarting now \u2014 a fresh server process was launched and will bind the same port(s) in ~1.5s. Reconnect shortly." : "Could not self-relaunch; the process is exiting. It only comes back if a supervisor respawns it."
|
|
@@ -2324,10 +3425,10 @@ async function configRoutes(req, url, admin) {
|
|
|
2324
3425
|
setConfig(cfg);
|
|
2325
3426
|
const devices = Object.keys(cfg.devices);
|
|
2326
3427
|
logger.info(`Config reloaded from source \u2014 ${devices.length} device(s): ${devices.join(", ")}`);
|
|
2327
|
-
return
|
|
3428
|
+
return json12({ ok: true, mode: "config", count: devices.length, devices });
|
|
2328
3429
|
} catch (e) {
|
|
2329
3430
|
logger.error(`Config reload failed: ${logError(e)}`);
|
|
2330
|
-
return
|
|
3431
|
+
return json12({ ok: false, error: clientError(e) }, 500);
|
|
2331
3432
|
}
|
|
2332
3433
|
}
|
|
2333
3434
|
if (p === "/api/config/history" && req.method === "GET") {
|
|
@@ -2343,55 +3444,55 @@ async function configRoutes(req, url, admin) {
|
|
|
2343
3444
|
} catch {}
|
|
2344
3445
|
return { ...v, drift: { added, removed } };
|
|
2345
3446
|
});
|
|
2346
|
-
return
|
|
3447
|
+
return json12({ versions, bytes: historyBytes(), retention: AUTO_RETENTION });
|
|
2347
3448
|
}
|
|
2348
3449
|
if (p === "/api/config/history/get" && req.method === "GET") {
|
|
2349
3450
|
const id = url.searchParams.get("id");
|
|
2350
3451
|
if (!id)
|
|
2351
|
-
return
|
|
3452
|
+
return json12({ error: "id required" }, 400);
|
|
2352
3453
|
try {
|
|
2353
3454
|
const v = readVersion(id);
|
|
2354
|
-
return
|
|
3455
|
+
return json12({
|
|
2355
3456
|
ts: v.ts,
|
|
2356
3457
|
kind: v.kind,
|
|
2357
3458
|
label: v.label,
|
|
2358
3459
|
config: JSON.stringify(redact(v.config), null, 2)
|
|
2359
3460
|
});
|
|
2360
3461
|
} catch {
|
|
2361
|
-
return
|
|
3462
|
+
return json12({ error: "not found" }, 404);
|
|
2362
3463
|
}
|
|
2363
3464
|
}
|
|
2364
3465
|
if (p === "/api/config/history/diff" && req.method === "GET") {
|
|
2365
3466
|
const id = url.searchParams.get("id");
|
|
2366
3467
|
if (!id)
|
|
2367
|
-
return
|
|
3468
|
+
return json12({ error: "id required" }, 400);
|
|
2368
3469
|
try {
|
|
2369
3470
|
const before = JSON.stringify(redact(readVersion(id).config), null, 2);
|
|
2370
3471
|
const after = JSON.stringify(redact(getConfig()), null, 2);
|
|
2371
3472
|
const d = diffLines(before, after, { fromLabel: "this version", toLabel: "current" });
|
|
2372
|
-
return
|
|
3473
|
+
return json12({ summary: d.summary, unified: d.unified });
|
|
2373
3474
|
} catch {
|
|
2374
|
-
return
|
|
3475
|
+
return json12({ error: "not found" }, 404);
|
|
2375
3476
|
}
|
|
2376
3477
|
}
|
|
2377
3478
|
if (p === "/api/config/history/checkpoint" && req.method === "POST") {
|
|
2378
3479
|
const b = await readJson(req);
|
|
2379
3480
|
const label = b?.label?.trim() || "checkpoint";
|
|
2380
|
-
return
|
|
3481
|
+
return json12({ ok: true, version: recordVersion(getConfig(), "checkpoint", Date.now(), label) });
|
|
2381
3482
|
}
|
|
2382
3483
|
if (p === "/api/config/history/restore" && req.method === "POST") {
|
|
2383
3484
|
const b = await readJson(req);
|
|
2384
3485
|
if (!b?.id)
|
|
2385
|
-
return
|
|
3486
|
+
return json12({ error: "id required" }, 400);
|
|
2386
3487
|
let target;
|
|
2387
3488
|
try {
|
|
2388
3489
|
target = readVersion(b.id);
|
|
2389
3490
|
} catch {
|
|
2390
|
-
return
|
|
3491
|
+
return json12({ error: "not found" }, 404);
|
|
2391
3492
|
}
|
|
2392
3493
|
const v = validateConfig(target.config);
|
|
2393
3494
|
if (!v.ok || !v.value)
|
|
2394
|
-
return
|
|
3495
|
+
return json12({ ok: false, errors: v.errors }, 400);
|
|
2395
3496
|
recordVersion(getConfig(), "auto", Date.now(), "before restore");
|
|
2396
3497
|
setConfig(v.value);
|
|
2397
3498
|
let persisted = true;
|
|
@@ -2402,13 +3503,13 @@ async function configRoutes(req, url, admin) {
|
|
|
2402
3503
|
}
|
|
2403
3504
|
const label = target.label ? `restored "${target.label}"` : `restored ${b.id}`;
|
|
2404
3505
|
recordVersion(getConfig(), "auto", Date.now(), label);
|
|
2405
|
-
return
|
|
3506
|
+
return json12({ ok: true, persisted, restored: b.id, config: redact(getConfig()) });
|
|
2406
3507
|
}
|
|
2407
3508
|
if (p === "/api/config/history/delete" && req.method === "POST") {
|
|
2408
3509
|
const b = await readJson(req);
|
|
2409
3510
|
if (!b?.id)
|
|
2410
|
-
return
|
|
2411
|
-
return deleteVersion(b.id) ?
|
|
3511
|
+
return json12({ error: "id required" }, 400);
|
|
3512
|
+
return deleteVersion(b.id) ? json12({ ok: true }) : json12({ error: "not found" }, 404);
|
|
2412
3513
|
}
|
|
2413
3514
|
return null;
|
|
2414
3515
|
}
|
|
@@ -2419,7 +3520,7 @@ async function modulesRoutes(req, url) {
|
|
|
2419
3520
|
if (p === "/api/modules" && req.method === "GET") {
|
|
2420
3521
|
const cfg = getConfig();
|
|
2421
3522
|
const src = getConfigSource();
|
|
2422
|
-
return
|
|
3523
|
+
return json12({
|
|
2423
3524
|
...moduleSurface(cfg.tools),
|
|
2424
3525
|
filter: cfg.tools,
|
|
2425
3526
|
source: src,
|
|
@@ -2429,11 +3530,11 @@ async function modulesRoutes(req, url) {
|
|
|
2429
3530
|
if (p === "/api/modules/toggle" && req.method === "POST") {
|
|
2430
3531
|
const b = await readJson(req);
|
|
2431
3532
|
if (typeof b?.slug !== "string" || typeof b?.enabled !== "boolean") {
|
|
2432
|
-
return
|
|
3533
|
+
return json12({ error: "slug (string) and enabled (boolean) are required" }, 400);
|
|
2433
3534
|
}
|
|
2434
3535
|
const mod = moduleCatalog.find((m) => m.slug.toLowerCase() === b.slug.toLowerCase());
|
|
2435
3536
|
if (!mod)
|
|
2436
|
-
return
|
|
3537
|
+
return json12({ error: `unknown module: ${b.slug}` }, 404);
|
|
2437
3538
|
const cfg = getConfig();
|
|
2438
3539
|
const nextTools = ToolFilterSchema.parse(applyModuleToggle(cfg.tools, mod.slug, b.enabled));
|
|
2439
3540
|
const next = { ...cfg, tools: nextTools };
|
|
@@ -2449,7 +3550,7 @@ async function modulesRoutes(req, url) {
|
|
|
2449
3550
|
if (persisted) {
|
|
2450
3551
|
recordVersion(getConfig(), "auto", Date.now(), `module ${b.enabled ? "enabled" : "disabled"}: ${mod.slug}`);
|
|
2451
3552
|
}
|
|
2452
|
-
return
|
|
3553
|
+
return json12({
|
|
2453
3554
|
ok: true,
|
|
2454
3555
|
persisted,
|
|
2455
3556
|
requiresReconnect: true,
|
|
@@ -2464,7 +3565,7 @@ async function modulesRoutes(req, url) {
|
|
|
2464
3565
|
if (p === "/api/modules/app-views" && req.method === "POST") {
|
|
2465
3566
|
const b = await readJson(req);
|
|
2466
3567
|
if (typeof b?.enabled !== "boolean") {
|
|
2467
|
-
return
|
|
3568
|
+
return json12({ error: "enabled (boolean) is required" }, 400);
|
|
2468
3569
|
}
|
|
2469
3570
|
const cfg = getConfig();
|
|
2470
3571
|
const next = { ...cfg, mcp: { ...cfg.mcp, appViews: b.enabled } };
|
|
@@ -2480,7 +3581,7 @@ async function modulesRoutes(req, url) {
|
|
|
2480
3581
|
if (persisted) {
|
|
2481
3582
|
recordVersion(getConfig(), "auto", Date.now(), `app views ${b.enabled ? "enabled" : "disabled"}`);
|
|
2482
3583
|
}
|
|
2483
|
-
return
|
|
3584
|
+
return json12({
|
|
2484
3585
|
ok: true,
|
|
2485
3586
|
persisted,
|
|
2486
3587
|
requiresReconnect: true,
|
|
@@ -2494,11 +3595,11 @@ async function modulesRoutes(req, url) {
|
|
|
2494
3595
|
async function captureRoutes(req, url) {
|
|
2495
3596
|
const p = url.pathname;
|
|
2496
3597
|
if (p === "/api/capture/status" && req.method === "GET") {
|
|
2497
|
-
return
|
|
3598
|
+
return json12(capture.stats());
|
|
2498
3599
|
}
|
|
2499
3600
|
if (p === "/api/capture/packets" && req.method === "GET") {
|
|
2500
3601
|
const limit = Number(url.searchParams.get("limit") ?? 200) || 200;
|
|
2501
|
-
return
|
|
3602
|
+
return json12({ packets: capture.recent(limit), stats: capture.stats() });
|
|
2502
3603
|
}
|
|
2503
3604
|
if (p === "/api/capture/pcap" && req.method === "GET") {
|
|
2504
3605
|
return new Response(capture.pcap(), {
|
|
@@ -2510,11 +3611,11 @@ async function captureRoutes(req, url) {
|
|
|
2510
3611
|
}
|
|
2511
3612
|
if (p === "/api/capture/start" && req.method === "POST") {
|
|
2512
3613
|
const body = await readJson(req);
|
|
2513
|
-
return
|
|
3614
|
+
return json12(await capture.start(typeof body?.port === "number" ? body.port : DEFAULT_TZSP_PORT));
|
|
2514
3615
|
}
|
|
2515
3616
|
if (p === "/api/capture/stop" && req.method === "POST") {
|
|
2516
3617
|
capture.stop();
|
|
2517
|
-
return
|
|
3618
|
+
return json12({ ok: true, ...capture.stats() });
|
|
2518
3619
|
}
|
|
2519
3620
|
return null;
|
|
2520
3621
|
}
|
|
@@ -2526,24 +3627,24 @@ async function capsmanRoutes(req, url) {
|
|
|
2526
3627
|
if (req.method === "GET") {
|
|
2527
3628
|
const ctx = createContext(undefined, url.searchParams.get("device") ?? undefined);
|
|
2528
3629
|
if (p === "/api/capsman/overview") {
|
|
2529
|
-
return
|
|
3630
|
+
return json12(capsmanOverview(await fetchCapsmanState(ctx)));
|
|
2530
3631
|
}
|
|
2531
3632
|
if (p === "/api/capsman/clients") {
|
|
2532
3633
|
const state = await fetchCapsmanState(ctx);
|
|
2533
3634
|
const weakDbm = Number.parseInt(url.searchParams.get("weak_dbm") ?? "", 10);
|
|
2534
3635
|
const weak = reportWeakClients(state, Number.isFinite(weakDbm) ? weakDbm : undefined);
|
|
2535
|
-
return
|
|
3636
|
+
return json12({ clients: state.clients, weak });
|
|
2536
3637
|
}
|
|
2537
3638
|
if (p === "/api/capsman/audit") {
|
|
2538
|
-
return
|
|
3639
|
+
return json12(runCapsmanAudit(await fetchCapsmanState(ctx)));
|
|
2539
3640
|
}
|
|
2540
3641
|
if (p === "/api/capsman/trends") {
|
|
2541
3642
|
if (!capsmanStore)
|
|
2542
|
-
return
|
|
3643
|
+
return json12({ error: "capsman store not active" }, 503);
|
|
2543
3644
|
const device = resolveDeviceName(url.searchParams.get("device") ?? undefined);
|
|
2544
3645
|
const days = daysParam(url, 7, 30);
|
|
2545
3646
|
const series = capsmanStore.radioSeries(device, Date.now() - days * 86400000);
|
|
2546
|
-
return
|
|
3647
|
+
return json12({ series, days });
|
|
2547
3648
|
}
|
|
2548
3649
|
return null;
|
|
2549
3650
|
}
|
|
@@ -2554,45 +3655,45 @@ async function capsmanRoutes(req, url) {
|
|
|
2554
3655
|
if (p === "/api/capsman/apply/steer") {
|
|
2555
3656
|
const client = state.clients.find((c) => c.mac.toLowerCase() === (body.mac ?? "").toLowerCase());
|
|
2556
3657
|
if (!client)
|
|
2557
|
-
return
|
|
3658
|
+
return json12({ ok: false, error: "client not associated" }, 400);
|
|
2558
3659
|
if (steerAlreadyPresent(state, body.mac ?? "")) {
|
|
2559
|
-
return
|
|
3660
|
+
return json12({ ok: true, message: "already steered (no-op)" });
|
|
2560
3661
|
}
|
|
2561
3662
|
const mode = body.mode === "soft" ? "soft" : "hard";
|
|
2562
3663
|
const commands = buildSteerCommands(state, body.mac ?? "", client.radioId, mode);
|
|
2563
3664
|
if (!body.confirm)
|
|
2564
|
-
return
|
|
2565
|
-
return
|
|
3665
|
+
return json12({ ok: true, preview: commands });
|
|
3666
|
+
return json12(await applyCapsmanWrites(ctx, commands, `pre-steer-${body.mac}`));
|
|
2566
3667
|
}
|
|
2567
3668
|
if (p === "/api/capsman/apply/load-balance") {
|
|
2568
3669
|
const plan = loadBalancePlan(state);
|
|
2569
3670
|
const commands = buildLoadBalanceCommands(state, plan);
|
|
2570
3671
|
if (!body.confirm)
|
|
2571
|
-
return
|
|
2572
|
-
return
|
|
3672
|
+
return json12({ ok: true, preview: commands, plan });
|
|
3673
|
+
return json12(await applyCapsmanWrites(ctx, commands, "pre-load-balance"));
|
|
2573
3674
|
}
|
|
2574
3675
|
if (p === "/api/capsman/apply/channel-plan") {
|
|
2575
3676
|
if (state.path === "/caps-man") {
|
|
2576
|
-
return
|
|
3677
|
+
return json12({ ok: false, error: "channel-plan apply is v7 /interface wifi only" }, 400);
|
|
2577
3678
|
}
|
|
2578
3679
|
const commands = buildChannelPlanCommands(state);
|
|
2579
3680
|
if (!body.confirm)
|
|
2580
|
-
return
|
|
2581
|
-
return
|
|
3681
|
+
return json12({ ok: true, preview: commands });
|
|
3682
|
+
return json12(await applyCapsmanWrites(ctx, commands, "pre-channel-plan"));
|
|
2582
3683
|
}
|
|
2583
3684
|
if (p === "/api/capsman/apply/ft") {
|
|
2584
3685
|
const commands = buildFtCommands(state);
|
|
2585
3686
|
if (!body.confirm)
|
|
2586
|
-
return
|
|
2587
|
-
return
|
|
3687
|
+
return json12({ ok: true, preview: commands });
|
|
3688
|
+
return json12(await applyCapsmanWrites(ctx, commands, "pre-ft"));
|
|
2588
3689
|
}
|
|
2589
3690
|
if (p === "/api/capsman/apply/ha") {
|
|
2590
3691
|
const commands = buildHaCommands(state);
|
|
2591
3692
|
const guidance = haGuidance(state);
|
|
2592
3693
|
if (!body.confirm)
|
|
2593
|
-
return
|
|
3694
|
+
return json12({ ok: true, preview: commands, guidance });
|
|
2594
3695
|
const res = await applyCapsmanWrites(ctx, commands, "pre-ha");
|
|
2595
|
-
return
|
|
3696
|
+
return json12({ ...res, guidance });
|
|
2596
3697
|
}
|
|
2597
3698
|
}
|
|
2598
3699
|
return null;
|
|
@@ -2618,32 +3719,32 @@ async function clientsRoutes(req, url) {
|
|
|
2618
3719
|
const deviceFromQuery = () => url.searchParams.get("device") ?? undefined;
|
|
2619
3720
|
if (p === "/api/clients" && req.method === "GET") {
|
|
2620
3721
|
const ctx = createContext(undefined, deviceFromQuery());
|
|
2621
|
-
return
|
|
3722
|
+
return json12(devicesView(await fetchDevices(ctx)));
|
|
2622
3723
|
}
|
|
2623
3724
|
if (p === "/api/clients/traffic" && req.method === "GET") {
|
|
2624
3725
|
const ip = url.searchParams.get("ip");
|
|
2625
3726
|
if (!ip)
|
|
2626
|
-
return
|
|
3727
|
+
return json12({ error: "ip required" }, 400);
|
|
2627
3728
|
const ctx = createContext(undefined, deviceFromQuery());
|
|
2628
|
-
return
|
|
3729
|
+
return json12(await sampleDeviceTraffic(ctx, ip));
|
|
2629
3730
|
}
|
|
2630
3731
|
if (p === "/api/clients/traffic-bulk" && req.method === "GET") {
|
|
2631
3732
|
const ctx = createContext(undefined, deviceFromQuery());
|
|
2632
|
-
return
|
|
3733
|
+
return json12(await sampleAllTraffic(ctx));
|
|
2633
3734
|
}
|
|
2634
3735
|
if (req.method === "POST") {
|
|
2635
3736
|
const b = await readJson(req);
|
|
2636
3737
|
const ctx = createContext(undefined, b?.device);
|
|
2637
3738
|
if (p === "/api/clients/limits") {
|
|
2638
3739
|
if (!b?.ip)
|
|
2639
|
-
return
|
|
2640
|
-
return
|
|
3740
|
+
return json12({ error: "ip required" }, 400);
|
|
3741
|
+
return json12(await setDeviceLimits(ctx, b.ip, { download: b.download, upload: b.upload }));
|
|
2641
3742
|
}
|
|
2642
3743
|
if (!b?.mac)
|
|
2643
|
-
return
|
|
3744
|
+
return json12({ error: "mac required" }, 400);
|
|
2644
3745
|
const run = async (op) => {
|
|
2645
3746
|
const r = await op;
|
|
2646
|
-
return
|
|
3747
|
+
return json12({ ...r, view: r.ok ? devicesView(await fetchDevices(ctx)) : undefined });
|
|
2647
3748
|
};
|
|
2648
3749
|
if (p === "/api/clients/block")
|
|
2649
3750
|
return run(blockDevice(ctx, b.mac, b.comment));
|
|
@@ -2655,12 +3756,12 @@ async function clientsRoutes(req, url) {
|
|
|
2655
3756
|
return run(removeDeviceLease(ctx, b.mac));
|
|
2656
3757
|
if (p === "/api/clients/set-ip") {
|
|
2657
3758
|
if (!b.ip)
|
|
2658
|
-
return
|
|
3759
|
+
return json12({ error: "ip required" }, 400);
|
|
2659
3760
|
return run(setDeviceIp(ctx, b.mac, b.ip));
|
|
2660
3761
|
}
|
|
2661
3762
|
if (p === "/api/clients/label") {
|
|
2662
3763
|
if (typeof b.label !== "string")
|
|
2663
|
-
return
|
|
3764
|
+
return json12({ error: "label required" }, 400);
|
|
2664
3765
|
return run(setDeviceLabel(ctx, b.mac, b.label));
|
|
2665
3766
|
}
|
|
2666
3767
|
}
|
|
@@ -2673,20 +3774,20 @@ async function aaaRoutes(req, url) {
|
|
|
2673
3774
|
const deviceFromQuery = () => url.searchParams.get("device") ?? undefined;
|
|
2674
3775
|
if (req.method === "GET") {
|
|
2675
3776
|
if (p === "/api/aaa/entities") {
|
|
2676
|
-
return
|
|
3777
|
+
return json12({ entities: AAA_ENTITIES });
|
|
2677
3778
|
}
|
|
2678
3779
|
if (p === "/api/aaa/radius-incoming") {
|
|
2679
|
-
return
|
|
3780
|
+
return json12(await getRadiusIncoming(createContext(undefined, deviceFromQuery())));
|
|
2680
3781
|
}
|
|
2681
3782
|
if (p === "/api/aaa/um-settings") {
|
|
2682
|
-
return
|
|
3783
|
+
return json12(await getUmSettings(createContext(undefined, deviceFromQuery())));
|
|
2683
3784
|
}
|
|
2684
3785
|
const listMatch = p.match(/^\/api\/aaa\/list\/([\w-]+)$/);
|
|
2685
3786
|
if (listMatch) {
|
|
2686
3787
|
const slug = listMatch[1];
|
|
2687
3788
|
if (!AAA_ENTITIES[slug])
|
|
2688
|
-
return
|
|
2689
|
-
return
|
|
3789
|
+
return json12({ error: "unknown entity" }, 404);
|
|
3790
|
+
return json12(await listAaaEntity(createContext(undefined, deviceFromQuery()), slug));
|
|
2690
3791
|
}
|
|
2691
3792
|
return null;
|
|
2692
3793
|
}
|
|
@@ -2694,30 +3795,30 @@ async function aaaRoutes(req, url) {
|
|
|
2694
3795
|
const body = await readJson(req);
|
|
2695
3796
|
const ctx = createContext(undefined, body?.device);
|
|
2696
3797
|
if (p === "/api/aaa/radius-incoming")
|
|
2697
|
-
return
|
|
3798
|
+
return json12(await setRadiusIncoming(ctx, body?.fields ?? {}));
|
|
2698
3799
|
if (p === "/api/aaa/um-settings")
|
|
2699
|
-
return
|
|
3800
|
+
return json12(await setUmSettings(ctx, body?.fields ?? {}));
|
|
2700
3801
|
if (p === "/api/aaa/radius-reset-counters")
|
|
2701
|
-
return
|
|
3802
|
+
return json12(await resetRadiusCounters(ctx));
|
|
2702
3803
|
const slug = body?.slug ?? "";
|
|
2703
3804
|
if (!AAA_ENTITIES[slug])
|
|
2704
|
-
return
|
|
3805
|
+
return json12({ error: "unknown entity" }, 404);
|
|
2705
3806
|
if (p === "/api/aaa/add")
|
|
2706
|
-
return
|
|
3807
|
+
return json12(await addAaaEntity(ctx, slug, body?.fields ?? {}));
|
|
2707
3808
|
if (p === "/api/aaa/update") {
|
|
2708
3809
|
if (!body?.id)
|
|
2709
|
-
return
|
|
2710
|
-
return
|
|
3810
|
+
return json12({ error: "id required" }, 400);
|
|
3811
|
+
return json12(await updateAaaEntity(ctx, slug, body.id, body?.fields ?? {}));
|
|
2711
3812
|
}
|
|
2712
3813
|
if (p === "/api/aaa/remove") {
|
|
2713
3814
|
if (!body?.id)
|
|
2714
|
-
return
|
|
2715
|
-
return
|
|
3815
|
+
return json12({ error: "id required" }, 400);
|
|
3816
|
+
return json12(await removeAaaEntity(ctx, slug, body.id));
|
|
2716
3817
|
}
|
|
2717
3818
|
if (p === "/api/aaa/toggle") {
|
|
2718
3819
|
if (!body?.id)
|
|
2719
|
-
return
|
|
2720
|
-
return
|
|
3820
|
+
return json12({ error: "id required" }, 400);
|
|
3821
|
+
return json12(await toggleAaaEntity(ctx, slug, body.id, body?.enable === true));
|
|
2721
3822
|
}
|
|
2722
3823
|
}
|
|
2723
3824
|
return null;
|
|
@@ -2733,43 +3834,43 @@ async function usageRoutes(req, url) {
|
|
|
2733
3834
|
return null;
|
|
2734
3835
|
if (p === "/api/usage/sampler") {
|
|
2735
3836
|
if (req.method === "GET")
|
|
2736
|
-
return
|
|
3837
|
+
return json12({ intervalMs: getUsageSamplerInterval() });
|
|
2737
3838
|
if (req.method === "POST") {
|
|
2738
3839
|
const b = await readJson(req);
|
|
2739
3840
|
const applied = setUsageSamplerInterval(Number(b?.intervalMs));
|
|
2740
|
-
return
|
|
3841
|
+
return json12({ intervalMs: applied });
|
|
2741
3842
|
}
|
|
2742
3843
|
return null;
|
|
2743
3844
|
}
|
|
2744
3845
|
if (req.method !== "GET")
|
|
2745
3846
|
return null;
|
|
2746
3847
|
if (!usageStore)
|
|
2747
|
-
return
|
|
3848
|
+
return json12({ error: "usage store not active" }, 503);
|
|
2748
3849
|
const device = resolveDeviceName(url.searchParams.get("device") ?? undefined);
|
|
2749
3850
|
const sinceTs = (days) => Date.now() - days * 86400000;
|
|
2750
3851
|
if (p === "/api/usage/um-users") {
|
|
2751
|
-
return
|
|
3852
|
+
return json12({ users: usageStore.umUsers(device) });
|
|
2752
3853
|
}
|
|
2753
3854
|
if (p === "/api/usage/client") {
|
|
2754
3855
|
const ip = url.searchParams.get("ip");
|
|
2755
3856
|
if (!ip)
|
|
2756
|
-
return
|
|
3857
|
+
return json12({ error: "ip required" }, 400);
|
|
2757
3858
|
const series = usageStore.clientDailyUsage(device, ip, sinceTs(daysParam(url, 90, 400)));
|
|
2758
|
-
return
|
|
3859
|
+
return json12(withTotals(series));
|
|
2759
3860
|
}
|
|
2760
3861
|
if (p === "/api/usage/um-user") {
|
|
2761
3862
|
const user = url.searchParams.get("user");
|
|
2762
3863
|
if (!user)
|
|
2763
|
-
return
|
|
3864
|
+
return json12({ error: "user required" }, 400);
|
|
2764
3865
|
const series = usageStore.umUserDailyUsage(device, user, sinceTs(daysParam(url, 90, 400)));
|
|
2765
|
-
return
|
|
3866
|
+
return json12(withTotals(series));
|
|
2766
3867
|
}
|
|
2767
3868
|
if (p === "/api/usage/heatmap") {
|
|
2768
3869
|
const user = url.searchParams.get("user");
|
|
2769
3870
|
const days = usageStore.heatmap(device, user || null, sinceTs(daysParam(url, 371, 400)));
|
|
2770
3871
|
const total = days.reduce((s, d) => s + d.count, 0);
|
|
2771
3872
|
const max = days.reduce((m, d) => Math.max(m, d.count), 0);
|
|
2772
|
-
return
|
|
3873
|
+
return json12({ days, total, max });
|
|
2773
3874
|
}
|
|
2774
3875
|
return null;
|
|
2775
3876
|
}
|
|
@@ -2789,31 +3890,31 @@ function snapStore() {
|
|
|
2789
3890
|
async function featureRoutes(req, url) {
|
|
2790
3891
|
const p = url.pathname;
|
|
2791
3892
|
if (p === "/api/snapshots" && req.method === "GET") {
|
|
2792
|
-
const
|
|
3893
|
+
const store3 = await snapStore();
|
|
2793
3894
|
const cfg = getConfig();
|
|
2794
|
-
const all = Object.keys(cfg.devices).flatMap((d) =>
|
|
3895
|
+
const all = Object.keys(cfg.devices).flatMap((d) => store3.list(d, 200, false));
|
|
2795
3896
|
all.sort((a, b) => b.ts - a.ts);
|
|
2796
|
-
return
|
|
3897
|
+
return json12({ snapshots: all });
|
|
2797
3898
|
}
|
|
2798
3899
|
const snapMatch = p.match(/^\/api\/snapshot\/(.+)$/);
|
|
2799
3900
|
if (snapMatch && req.method === "GET") {
|
|
2800
|
-
const
|
|
2801
|
-
const s =
|
|
2802
|
-
return s ?
|
|
3901
|
+
const store3 = await snapStore();
|
|
3902
|
+
const s = store3.get(decodeURIComponent(snapMatch[1]));
|
|
3903
|
+
return s ? json12(s) : json12({ error: "not found" }, 404);
|
|
2803
3904
|
}
|
|
2804
3905
|
if (p === "/api/snapshots/diff" && req.method === "POST") {
|
|
2805
|
-
const
|
|
3906
|
+
const store3 = await snapStore();
|
|
2806
3907
|
const b = await readJson(req);
|
|
2807
|
-
const from = b?.from ?
|
|
2808
|
-
const to = b?.to ?
|
|
3908
|
+
const from = b?.from ? store3.get(b.from) : null;
|
|
3909
|
+
const to = b?.to ? store3.get(b.to) : null;
|
|
2809
3910
|
if (!from || !to) {
|
|
2810
|
-
return
|
|
3911
|
+
return json12({ error: "both 'from' and 'to' snapshot ids are required" }, 400);
|
|
2811
3912
|
}
|
|
2812
3913
|
const diff = diffLines(normalizeExport(from.body), normalizeExport(to.body), {
|
|
2813
3914
|
fromLabel: from.label ?? from.id,
|
|
2814
3915
|
toLabel: to.label ?? to.id
|
|
2815
3916
|
});
|
|
2816
|
-
return
|
|
3917
|
+
return json12({
|
|
2817
3918
|
from: { id: from.id, label: from.label, ts: from.ts, device: from.device },
|
|
2818
3919
|
to: { id: to.id, label: to.label, ts: to.ts, device: to.device },
|
|
2819
3920
|
summary: diff.summary,
|
|
@@ -2827,16 +3928,16 @@ async function featureRoutes(req, url) {
|
|
|
2827
3928
|
...b?.script ? splitCommands(b.script) : []
|
|
2828
3929
|
];
|
|
2829
3930
|
if (commands.length === 0)
|
|
2830
|
-
return
|
|
3931
|
+
return json12({ error: "no commands provided" }, 400);
|
|
2831
3932
|
const plan = buildChangePlan(commands);
|
|
2832
|
-
return
|
|
3933
|
+
return json12({ plan, text: renderPlan(plan) });
|
|
2833
3934
|
}
|
|
2834
3935
|
if (p === "/api/s3" && req.method === "GET") {
|
|
2835
|
-
return
|
|
3936
|
+
return json12({ configured: isS3Configured(), target: isS3Configured() ? s3Target() : null });
|
|
2836
3937
|
}
|
|
2837
3938
|
if (p === "/api/s3/list" && req.method === "GET") {
|
|
2838
3939
|
if (!isS3Configured())
|
|
2839
|
-
return
|
|
3940
|
+
return json12({ configured: false, objects: [] });
|
|
2840
3941
|
const prefix = url.searchParams.get("prefix") ?? "";
|
|
2841
3942
|
try {
|
|
2842
3943
|
const res = await getS3Client().list({ prefix: prefix || undefined, maxKeys: 1000 });
|
|
@@ -2845,42 +3946,42 @@ async function featureRoutes(req, url) {
|
|
|
2845
3946
|
size: o.size ?? 0,
|
|
2846
3947
|
lastModified: o.lastModified ?? null
|
|
2847
3948
|
}));
|
|
2848
|
-
return
|
|
3949
|
+
return json12({ configured: true, target: s3Target(), objects, truncated: !!res.isTruncated });
|
|
2849
3950
|
} catch (e) {
|
|
2850
|
-
return
|
|
3951
|
+
return json12({ error: clientError(e) }, 502);
|
|
2851
3952
|
}
|
|
2852
3953
|
}
|
|
2853
3954
|
if (p === "/api/s3/presign" && req.method === "GET") {
|
|
2854
3955
|
if (!isS3Configured())
|
|
2855
|
-
return
|
|
3956
|
+
return json12({ error: "S3 not configured" }, 400);
|
|
2856
3957
|
const key = url.searchParams.get("key");
|
|
2857
3958
|
if (!key)
|
|
2858
|
-
return
|
|
3959
|
+
return json12({ error: "key required" }, 400);
|
|
2859
3960
|
try {
|
|
2860
3961
|
const link = getS3Client().presign(key, { expiresIn: presignExpiresIn(), method: "GET" });
|
|
2861
|
-
return
|
|
3962
|
+
return json12({ url: link });
|
|
2862
3963
|
} catch (e) {
|
|
2863
|
-
return
|
|
3964
|
+
return json12({ error: clientError(e) }, 502);
|
|
2864
3965
|
}
|
|
2865
3966
|
}
|
|
2866
3967
|
if (p === "/api/s3/delete" && req.method === "POST") {
|
|
2867
3968
|
if (!isS3Configured())
|
|
2868
|
-
return
|
|
3969
|
+
return json12({ error: "S3 not configured" }, 400);
|
|
2869
3970
|
const b = await readJson(req);
|
|
2870
3971
|
if (!b?.key)
|
|
2871
|
-
return
|
|
3972
|
+
return json12({ error: "key required" }, 400);
|
|
2872
3973
|
try {
|
|
2873
3974
|
const client = getS3Client();
|
|
2874
3975
|
if (!await client.exists(b.key))
|
|
2875
|
-
return
|
|
3976
|
+
return json12({ error: "not found" }, 404);
|
|
2876
3977
|
await client.delete(b.key);
|
|
2877
|
-
return
|
|
3978
|
+
return json12({ ok: true, key: b.key });
|
|
2878
3979
|
} catch (e) {
|
|
2879
|
-
return
|
|
3980
|
+
return json12({ error: clientError(e) }, 502);
|
|
2880
3981
|
}
|
|
2881
3982
|
}
|
|
2882
3983
|
if (p === "/api/backups" && req.method === "GET") {
|
|
2883
|
-
return
|
|
3984
|
+
return json12({
|
|
2884
3985
|
dir: backupDir(),
|
|
2885
3986
|
devices: Object.keys(getConfig().devices),
|
|
2886
3987
|
backups: listBackups()
|
|
@@ -2890,14 +3991,14 @@ async function featureRoutes(req, url) {
|
|
|
2890
3991
|
const b = await readJson(req);
|
|
2891
3992
|
const raw = b?.dir?.trim();
|
|
2892
3993
|
if (!raw)
|
|
2893
|
-
return
|
|
3994
|
+
return json12({ error: "dir required" }, 400);
|
|
2894
3995
|
const dir = raw === "~" || raw.startsWith("~/") ? join4(homedir3(), raw.slice(1)) : raw;
|
|
2895
3996
|
const next = { ...getConfig(), backupDir: dir };
|
|
2896
3997
|
setConfig(next);
|
|
2897
3998
|
try {
|
|
2898
3999
|
atomicWrite(getConfigSource().path, serializeConfig(next));
|
|
2899
4000
|
} catch (e) {
|
|
2900
|
-
return
|
|
4001
|
+
return json12({
|
|
2901
4002
|
ok: true,
|
|
2902
4003
|
dir: backupDir(),
|
|
2903
4004
|
persisted: false,
|
|
@@ -2905,16 +4006,16 @@ async function featureRoutes(req, url) {
|
|
|
2905
4006
|
});
|
|
2906
4007
|
}
|
|
2907
4008
|
recordVersion(getConfig(), "auto", Date.now(), "backup path changed");
|
|
2908
|
-
return
|
|
4009
|
+
return json12({ ok: true, dir: backupDir(), persisted: true });
|
|
2909
4010
|
}
|
|
2910
4011
|
if (p === "/api/backups/get" && req.method === "GET") {
|
|
2911
4012
|
const name = url.searchParams.get("name");
|
|
2912
4013
|
if (!name)
|
|
2913
|
-
return
|
|
4014
|
+
return json12({ error: "name required" }, 400);
|
|
2914
4015
|
try {
|
|
2915
|
-
return
|
|
4016
|
+
return json12({ name, content: readBackup(name) });
|
|
2916
4017
|
} catch {
|
|
2917
|
-
return
|
|
4018
|
+
return json12({ error: "not found" }, 404);
|
|
2918
4019
|
}
|
|
2919
4020
|
}
|
|
2920
4021
|
if (p === "/api/backups/raw" && req.method === "GET") {
|
|
@@ -2935,41 +4036,41 @@ async function featureRoutes(req, url) {
|
|
|
2935
4036
|
if (p === "/api/backups/upload" && req.method === "POST") {
|
|
2936
4037
|
const b = await readJson(req);
|
|
2937
4038
|
if (!b?.name || typeof b.content !== "string") {
|
|
2938
|
-
return
|
|
4039
|
+
return json12({ error: "name and content are required" }, 400);
|
|
2939
4040
|
}
|
|
2940
4041
|
try {
|
|
2941
4042
|
const safe = b.name.endsWith(".rsc") ? b.name : `${b.name}.rsc`;
|
|
2942
|
-
return
|
|
4043
|
+
return json12({ ok: true, name: writeBackup(safe, b.content) });
|
|
2943
4044
|
} catch (e) {
|
|
2944
|
-
return
|
|
4045
|
+
return json12({ error: clientError(e) }, 400);
|
|
2945
4046
|
}
|
|
2946
4047
|
}
|
|
2947
4048
|
if (p === "/api/backups/rename" && req.method === "POST") {
|
|
2948
4049
|
const b = await readJson(req);
|
|
2949
4050
|
if (!b?.name || !b?.new_name)
|
|
2950
|
-
return
|
|
4051
|
+
return json12({ error: "name and new_name are required" }, 400);
|
|
2951
4052
|
try {
|
|
2952
|
-
return
|
|
4053
|
+
return json12({ ok: true, name: renameBackup(b.name, b.new_name) });
|
|
2953
4054
|
} catch (e) {
|
|
2954
|
-
return
|
|
4055
|
+
return json12({ error: clientError(e) }, 400);
|
|
2955
4056
|
}
|
|
2956
4057
|
}
|
|
2957
4058
|
if (p === "/api/backups/delete" && req.method === "POST") {
|
|
2958
4059
|
const b = await readJson(req);
|
|
2959
4060
|
if (!b?.name)
|
|
2960
|
-
return
|
|
4061
|
+
return json12({ error: "name required" }, 400);
|
|
2961
4062
|
try {
|
|
2962
|
-
return deleteBackup(b.name) ?
|
|
4063
|
+
return deleteBackup(b.name) ? json12({ ok: true }) : json12({ error: "not found" }, 404);
|
|
2963
4064
|
} catch (e) {
|
|
2964
|
-
return
|
|
4065
|
+
return json12({ error: clientError(e) }, 400);
|
|
2965
4066
|
}
|
|
2966
4067
|
}
|
|
2967
4068
|
if (p === "/api/backups/restore" && req.method === "POST") {
|
|
2968
4069
|
const b = await readJson(req);
|
|
2969
4070
|
if (!b?.name)
|
|
2970
|
-
return
|
|
4071
|
+
return json12({ error: "name required" }, 400);
|
|
2971
4072
|
const device = resolveDeviceName(b.device);
|
|
2972
|
-
return
|
|
4073
|
+
return json12(await restoreLocalBackup(device, b.name, b.confirm === true));
|
|
2973
4074
|
}
|
|
2974
4075
|
if (p === "/api/backups/create" && req.method === "POST") {
|
|
2975
4076
|
const b = await readJson(req);
|
|
@@ -2981,14 +4082,14 @@ async function featureRoutes(req, url) {
|
|
|
2981
4082
|
compact: b?.compact === true,
|
|
2982
4083
|
terse: b?.terse === true
|
|
2983
4084
|
});
|
|
2984
|
-
return r.ok ?
|
|
4085
|
+
return r.ok ? json12(r) : json12({ error: r.error ?? "export failed" }, 502);
|
|
2985
4086
|
}
|
|
2986
4087
|
return null;
|
|
2987
4088
|
}
|
|
2988
4089
|
async function runDashboard(cfg, transportLabel) {
|
|
2989
|
-
const
|
|
4090
|
+
const store3 = await openSqliteStore(cfg.dbPath);
|
|
2990
4091
|
configureRecorder({
|
|
2991
|
-
store,
|
|
4092
|
+
store: store3,
|
|
2992
4093
|
capture: {
|
|
2993
4094
|
captureBody: cfg.captureBody,
|
|
2994
4095
|
maxBodyBytes: cfg.maxBodyBytes,
|
|
@@ -3000,13 +4101,13 @@ async function runDashboard(cfg, transportLabel) {
|
|
|
3000
4101
|
startHealthChecks(30000);
|
|
3001
4102
|
startGeoLookups();
|
|
3002
4103
|
try {
|
|
3003
|
-
usageStore = await openUsageStore(join4(
|
|
4104
|
+
usageStore = await openUsageStore(join4(dirname7(cfg.dbPath), "usage.db"));
|
|
3004
4105
|
startUsageSampler(usageStore);
|
|
3005
4106
|
} catch (e) {
|
|
3006
4107
|
logger.warn(`[${SERVER_TAG3}] usage history disabled: ${String(e)}`);
|
|
3007
4108
|
}
|
|
3008
4109
|
try {
|
|
3009
|
-
capsmanStore = await openCapsmanStore(join4(
|
|
4110
|
+
capsmanStore = await openCapsmanStore(join4(dirname7(cfg.dbPath), "capsman.db"));
|
|
3010
4111
|
startCapsmanSampler(capsmanStore);
|
|
3011
4112
|
} catch (e) {
|
|
3012
4113
|
logger.warn(`[${SERVER_TAG3}] capsman trends disabled: ${String(e)}`);
|
|
@@ -3080,12 +4181,39 @@ async function runDashboard(cfg, transportLabel) {
|
|
|
3080
4181
|
const driftResp = await driftRoutes(req, url);
|
|
3081
4182
|
if (driftResp)
|
|
3082
4183
|
return driftResp;
|
|
4184
|
+
const txnResp = await txnRoutes(req, url);
|
|
4185
|
+
if (txnResp)
|
|
4186
|
+
return txnResp;
|
|
4187
|
+
const flowResp = await flowRoutes(req, url);
|
|
4188
|
+
if (flowResp)
|
|
4189
|
+
return flowResp;
|
|
4190
|
+
const rolloutResp = await rolloutRoutes(req, url);
|
|
4191
|
+
if (rolloutResp)
|
|
4192
|
+
return rolloutResp;
|
|
4193
|
+
const policyResp = await policyRoutes(req, url);
|
|
4194
|
+
if (policyResp)
|
|
4195
|
+
return policyResp;
|
|
4196
|
+
const simResp = await simRoutes(req, url);
|
|
4197
|
+
if (simResp)
|
|
4198
|
+
return simResp;
|
|
4199
|
+
const scheduleResp = await scheduleRoutes(req, url);
|
|
4200
|
+
if (scheduleResp)
|
|
4201
|
+
return scheduleResp;
|
|
4202
|
+
const explainResp = await explainRoutes(req, url);
|
|
4203
|
+
if (explainResp)
|
|
4204
|
+
return explainResp;
|
|
4205
|
+
const attackResp = await attackRoutes(req, url);
|
|
4206
|
+
if (attackResp)
|
|
4207
|
+
return attackResp;
|
|
4208
|
+
const alertResp = await alertRoutes(req, url, getEventStore());
|
|
4209
|
+
if (alertResp)
|
|
4210
|
+
return alertResp;
|
|
3083
4211
|
const memoryResp = await memoryRoutes(req, url);
|
|
3084
4212
|
if (memoryResp)
|
|
3085
4213
|
return memoryResp;
|
|
3086
4214
|
const db = getEventStore();
|
|
3087
4215
|
if (!db)
|
|
3088
|
-
return
|
|
4216
|
+
return json12({ error: "recorder not active" }, 503);
|
|
3089
4217
|
if (url.pathname === "/api/events" && req.method === "DELETE") {
|
|
3090
4218
|
let body = {};
|
|
3091
4219
|
try {
|
|
@@ -3093,7 +4221,7 @@ async function runDashboard(cfg, transportLabel) {
|
|
|
3093
4221
|
} catch {}
|
|
3094
4222
|
const ids = Array.isArray(body.ids) ? body.ids.filter((x) => typeof x === "string") : [];
|
|
3095
4223
|
const removed = body.all === true ? db.clear() : db.delete(ids);
|
|
3096
|
-
return
|
|
4224
|
+
return json12({ removed, total: db.total() });
|
|
3097
4225
|
}
|
|
3098
4226
|
if (url.pathname === "/api/ssh-pool") {
|
|
3099
4227
|
const cfg2 = getConfig();
|
|
@@ -3102,7 +4230,7 @@ async function runDashboard(cfg, transportLabel) {
|
|
|
3102
4230
|
const totalInflight = ps.reduce((s, p) => s + p.inflight, 0);
|
|
3103
4231
|
const totalIdle = ps.filter((p) => p.idle).length;
|
|
3104
4232
|
const totalBusy = ps.filter((p) => p.inflight > 0).length;
|
|
3105
|
-
return
|
|
4233
|
+
return json12({
|
|
3106
4234
|
enabled,
|
|
3107
4235
|
config: {
|
|
3108
4236
|
keepAlive: cfg2.ssh.keepAlive,
|
|
@@ -3131,16 +4259,39 @@ async function runDashboard(cfg, transportLabel) {
|
|
|
3131
4259
|
});
|
|
3132
4260
|
}
|
|
3133
4261
|
if (url.pathname === "/api/devices") {
|
|
3134
|
-
return
|
|
4262
|
+
return json12(devicesPayload(db));
|
|
4263
|
+
}
|
|
4264
|
+
if (url.pathname === "/api/capabilities") {
|
|
4265
|
+
const devices = Object.keys(getConfig().devices).map((name) => ({
|
|
4266
|
+
device: name,
|
|
4267
|
+
capabilities: serializeCapabilities(peekCapabilities(name))
|
|
4268
|
+
}));
|
|
4269
|
+
return json12({ devices });
|
|
4270
|
+
}
|
|
4271
|
+
if (url.pathname === "/api/capabilities/refresh" && req.method === "POST") {
|
|
4272
|
+
const b = await readJson(req);
|
|
4273
|
+
if (typeof b?.device !== "string")
|
|
4274
|
+
return json12({ error: "device (string) is required" }, 400);
|
|
4275
|
+
if (!(b.device in getConfig().devices)) {
|
|
4276
|
+
return json12({ error: `unknown device: ${b.device}` }, 404);
|
|
4277
|
+
}
|
|
4278
|
+
invalidateCapabilities(b.device);
|
|
4279
|
+
try {
|
|
4280
|
+
const caps = await getCapabilities(b.device);
|
|
4281
|
+
return json12({ device: b.device, capabilities: serializeCapabilities(caps) });
|
|
4282
|
+
} catch (e) {
|
|
4283
|
+
logger.error(`Capability probe failed for '${b.device}': ${logError(e)}`);
|
|
4284
|
+
return json12({ error: clientError(e) }, 502);
|
|
4285
|
+
}
|
|
3135
4286
|
}
|
|
3136
4287
|
if (url.pathname === "/api/devices/toggle" && req.method === "POST") {
|
|
3137
4288
|
const b = await readJson(req);
|
|
3138
4289
|
if (typeof b?.device !== "string" || typeof b?.disabled !== "boolean") {
|
|
3139
|
-
return
|
|
4290
|
+
return json12({ error: "device (string) and disabled (boolean) are required" }, 400);
|
|
3140
4291
|
}
|
|
3141
4292
|
const cfg2 = getConfig();
|
|
3142
4293
|
if (!(b.device in cfg2.devices))
|
|
3143
|
-
return
|
|
4294
|
+
return json12({ error: `unknown device: ${b.device}` }, 404);
|
|
3144
4295
|
const dc = cfg2.devices[b.device];
|
|
3145
4296
|
const next = {
|
|
3146
4297
|
...cfg2,
|
|
@@ -3161,7 +4312,7 @@ async function runDashboard(cfg, transportLabel) {
|
|
|
3161
4312
|
if (persisted) {
|
|
3162
4313
|
recordVersion(getConfig(), "auto", Date.now(), `device ${b.disabled ? "disabled" : "enabled"}: ${b.device}`);
|
|
3163
4314
|
}
|
|
3164
|
-
return
|
|
4315
|
+
return json12({
|
|
3165
4316
|
ok: true,
|
|
3166
4317
|
persisted,
|
|
3167
4318
|
requiresReconnect: true,
|
|
@@ -3174,17 +4325,17 @@ async function runDashboard(cfg, transportLabel) {
|
|
|
3174
4325
|
const name = typeof b?.device === "string" ? b.device : "";
|
|
3175
4326
|
const cfg2 = getConfig();
|
|
3176
4327
|
if (!(name in cfg2.devices))
|
|
3177
|
-
return
|
|
4328
|
+
return json12({ error: `unknown device: ${name}` }, 404);
|
|
3178
4329
|
if (url.pathname === "/api/devices/reconnect")
|
|
3179
4330
|
closeDevice(name);
|
|
3180
4331
|
const status = await probeDevice(name, cfg2.devices[name]);
|
|
3181
|
-
return
|
|
4332
|
+
return json12({ ok: true, status, ...devicesPayload(db) });
|
|
3182
4333
|
}
|
|
3183
4334
|
if (url.pathname === "/api/topology") {
|
|
3184
|
-
return
|
|
4335
|
+
return json12(topologyPayload());
|
|
3185
4336
|
}
|
|
3186
4337
|
if (url.pathname === "/api/config") {
|
|
3187
|
-
return
|
|
4338
|
+
return json12(configPayload());
|
|
3188
4339
|
}
|
|
3189
4340
|
if (url.pathname === "/" || url.pathname === "/index.html") {
|
|
3190
4341
|
return new Response(dashboardHtml(), {
|
|
@@ -3193,9 +4344,9 @@ async function runDashboard(cfg, transportLabel) {
|
|
|
3193
4344
|
}
|
|
3194
4345
|
if (url.pathname === "/api/releases/latest") {
|
|
3195
4346
|
try {
|
|
3196
|
-
return
|
|
4347
|
+
return json12(await fetchLatestRelease());
|
|
3197
4348
|
} catch (e) {
|
|
3198
|
-
return
|
|
4349
|
+
return json12({ error: clientError(e, "fetch failed") }, 502);
|
|
3199
4350
|
}
|
|
3200
4351
|
}
|
|
3201
4352
|
if (url.pathname === "/api/catalog" && req.method === "GET") {
|
|
@@ -3214,7 +4365,7 @@ async function runDashboard(cfg, transportLabel) {
|
|
|
3214
4365
|
}));
|
|
3215
4366
|
const prompts = listPrompts();
|
|
3216
4367
|
const groups = [...new Set(modules.map((m) => m.group))].sort();
|
|
3217
|
-
return
|
|
4368
|
+
return json12({
|
|
3218
4369
|
modules,
|
|
3219
4370
|
prompts,
|
|
3220
4371
|
groups,
|
|
@@ -3229,25 +4380,25 @@ async function runDashboard(cfg, transportLabel) {
|
|
|
3229
4380
|
}
|
|
3230
4381
|
if (url.pathname === "/api/releases" && req.method === "GET") {
|
|
3231
4382
|
try {
|
|
3232
|
-
return
|
|
4383
|
+
return json12(await fetchAllReleases());
|
|
3233
4384
|
} catch (e) {
|
|
3234
|
-
return
|
|
4385
|
+
return json12({ error: clientError(e, "fetch failed") }, 502);
|
|
3235
4386
|
}
|
|
3236
4387
|
}
|
|
3237
4388
|
if (url.pathname === "/api/upgrade" && req.method === "POST") {
|
|
3238
4389
|
const body = await readJson(req);
|
|
3239
4390
|
const version = String(body?.version ?? "latest");
|
|
3240
4391
|
if (version !== "latest" && !/^\d+\.\d+\.\d+$/.test(version)) {
|
|
3241
|
-
return
|
|
4392
|
+
return json12({ ok: false, error: `Invalid version "${version}" (use "latest" or x.y.z).` }, 400);
|
|
3242
4393
|
}
|
|
3243
4394
|
const spec = `@usex/mikrotik-mcp@${version}`;
|
|
3244
4395
|
logger.warn(`Dashboard requested upgrade: bun i -g ${spec}`);
|
|
3245
4396
|
const { ok, log } = await runUpgrade(spec);
|
|
3246
4397
|
if (!ok)
|
|
3247
|
-
return
|
|
4398
|
+
return json12({ ok: false, version, log }, 500);
|
|
3248
4399
|
const willRestart = body?.restart !== false;
|
|
3249
4400
|
const relaunched = willRestart ? restartProcess() : false;
|
|
3250
|
-
return
|
|
4401
|
+
return json12({
|
|
3251
4402
|
ok: true,
|
|
3252
4403
|
version,
|
|
3253
4404
|
log,
|
|
@@ -3257,7 +4408,7 @@ async function runDashboard(cfg, transportLabel) {
|
|
|
3257
4408
|
}
|
|
3258
4409
|
if (url.pathname === "/api/meta") {
|
|
3259
4410
|
const f = facets(db);
|
|
3260
|
-
return
|
|
4411
|
+
return json12({
|
|
3261
4412
|
...f,
|
|
3262
4413
|
version: VERSION,
|
|
3263
4414
|
risks: ["READ", "WRITE", "WRITE_IDEMPOTENT", "DESTRUCTIVE", "DANGEROUS"],
|
|
@@ -3268,19 +4419,19 @@ async function runDashboard(cfg, transportLabel) {
|
|
|
3268
4419
|
}
|
|
3269
4420
|
if (url.pathname === "/api/events") {
|
|
3270
4421
|
const filter = filterFromQuery(url);
|
|
3271
|
-
return
|
|
4422
|
+
return json12({ events: db.query(filter), total: db.total() });
|
|
3272
4423
|
}
|
|
3273
4424
|
const eventMatch = url.pathname.match(/^\/api\/event\/(.+)$/);
|
|
3274
4425
|
if (eventMatch) {
|
|
3275
4426
|
const e = db.get(decodeURIComponent(eventMatch[1]));
|
|
3276
|
-
return e ?
|
|
4427
|
+
return e ? json12(e) : json12({ error: "not found" }, 404);
|
|
3277
4428
|
}
|
|
3278
4429
|
if (url.pathname === "/api/stats") {
|
|
3279
4430
|
const now = Date.now();
|
|
3280
|
-
const
|
|
4431
|
+
const windowMs2 = Number(url.searchParams.get("window") ?? 3600000);
|
|
3281
4432
|
const buckets = Number(url.searchParams.get("buckets") ?? 60);
|
|
3282
|
-
const events = db.query({ since: now -
|
|
3283
|
-
return
|
|
4433
|
+
const events = db.query({ since: now - windowMs2, limit: 5000 });
|
|
4434
|
+
return json12(computeStats(events, { now, windowMs: windowMs2, buckets }));
|
|
3284
4435
|
}
|
|
3285
4436
|
return new Response("Not Found", { status: 404 });
|
|
3286
4437
|
}
|
|
@@ -3299,7 +4450,7 @@ async function runDashboard(cfg, transportLabel) {
|
|
|
3299
4450
|
return await dashboardRoute(req, url, srv, configAdmin, transportLabel);
|
|
3300
4451
|
} catch (e) {
|
|
3301
4452
|
logger.error(`Dashboard request failed (${url.pathname}): ${logError(e)}`);
|
|
3302
|
-
return
|
|
4453
|
+
return json12({ error: clientError(e) }, 502);
|
|
3303
4454
|
}
|
|
3304
4455
|
},
|
|
3305
4456
|
websocket: {
|
|
@@ -3311,11 +4462,19 @@ async function runDashboard(cfg, transportLabel) {
|
|
|
3311
4462
|
} catch {}
|
|
3312
4463
|
});
|
|
3313
4464
|
} else {
|
|
3314
|
-
|
|
4465
|
+
const send = (payload2) => {
|
|
3315
4466
|
try {
|
|
3316
|
-
ws.send(JSON.stringify(
|
|
4467
|
+
ws.send(JSON.stringify(payload2));
|
|
3317
4468
|
} catch {}
|
|
3318
|
-
}
|
|
4469
|
+
};
|
|
4470
|
+
const unsubEvents = subscribe((e) => send({ type: "event", event: e }));
|
|
4471
|
+
const unsubTxn = subscribeTxn((update) => send({ type: "txn", update }));
|
|
4472
|
+
const unsubRollout = subscribeRollout((update) => send({ type: "rollout", update }));
|
|
4473
|
+
ws.data.unsub = () => {
|
|
4474
|
+
unsubEvents();
|
|
4475
|
+
unsubTxn();
|
|
4476
|
+
unsubRollout();
|
|
4477
|
+
};
|
|
3319
4478
|
}
|
|
3320
4479
|
ws.send(JSON.stringify({ type: "hello", transport: transportLabel }));
|
|
3321
4480
|
},
|
|
@@ -3336,14 +4495,14 @@ async function runDashboard(cfg, transportLabel) {
|
|
|
3336
4495
|
}
|
|
3337
4496
|
return {
|
|
3338
4497
|
server,
|
|
3339
|
-
store,
|
|
4498
|
+
store: store3,
|
|
3340
4499
|
stop() {
|
|
3341
4500
|
stopHealthChecks();
|
|
3342
4501
|
stopGeoLookups();
|
|
3343
4502
|
stopUsageSampler();
|
|
3344
4503
|
stopCapsmanSampler();
|
|
3345
4504
|
server.stop(true);
|
|
3346
|
-
|
|
4505
|
+
store3.close();
|
|
3347
4506
|
usageStore?.close();
|
|
3348
4507
|
usageStore = null;
|
|
3349
4508
|
capsmanStore?.close();
|
|
@@ -3660,6 +4819,12 @@ async function runStdio() {
|
|
|
3660
4819
|
const transport = new StdioServerTransport;
|
|
3661
4820
|
await server.connect(transport);
|
|
3662
4821
|
logger.info(`MCP server ready on stdio \u2014 ${toolCount} tools, ${promptCount} prompts, ${uiViewCount} app views${readOnly ? " [READ-ONLY]" : ""}`);
|
|
4822
|
+
setMcpAlertSender((n) => {
|
|
4823
|
+
server.server.sendLoggingMessage({
|
|
4824
|
+
level: n.severity === "critical" || n.severity === "high" ? "error" : "info",
|
|
4825
|
+
data: `[alert:${n.kind}] ${n.title}${n.device ? ` \xB7 ${n.device}` : ""} ` + `(severity ${n.severity}, rule ${n.ruleId})${n.body ? ` \u2014 ${n.body}` : ""}`
|
|
4826
|
+
}).catch(() => {});
|
|
4827
|
+
});
|
|
3663
4828
|
if (!getConfig().disableUpdateCheck) {
|
|
3664
4829
|
whisperUpdate(server).catch(() => {});
|
|
3665
4830
|
}
|
|
@@ -3730,6 +4895,100 @@ async function printBanner() {
|
|
|
3730
4895
|
}
|
|
3731
4896
|
}
|
|
3732
4897
|
|
|
4898
|
+
// src/alerts/sampler.ts
|
|
4899
|
+
var DEFAULT_ALERT_SAMPLE_MS = 30000;
|
|
4900
|
+
var timer4 = null;
|
|
4901
|
+
var snapshotStorePromise = null;
|
|
4902
|
+
function getSnapshotStore() {
|
|
4903
|
+
snapshotStorePromise ??= openSnapshotStore(DEFAULT_SNAPSHOT_DB);
|
|
4904
|
+
return snapshotStorePromise;
|
|
4905
|
+
}
|
|
4906
|
+
function computeMetricSample(windowMs2, now = Date.now()) {
|
|
4907
|
+
const store3 = getEventStore();
|
|
4908
|
+
if (!store3)
|
|
4909
|
+
return { calls: 0, errors: 0, avgDurationMs: 0, p95DurationMs: 0 };
|
|
4910
|
+
const events = store3.query({ since: now - windowMs2, limit: 1e4 });
|
|
4911
|
+
const calls = events.length;
|
|
4912
|
+
if (calls === 0)
|
|
4913
|
+
return { calls: 0, errors: 0, avgDurationMs: 0, p95DurationMs: 0 };
|
|
4914
|
+
const durations = events.map((e) => e.durationMs);
|
|
4915
|
+
return {
|
|
4916
|
+
calls,
|
|
4917
|
+
errors: events.reduce((n, e) => n + (e.isError ? 1 : 0), 0),
|
|
4918
|
+
avgDurationMs: durations.reduce((a, b) => a + b, 0) / calls,
|
|
4919
|
+
p95DurationMs: percentile(durations, 95)
|
|
4920
|
+
};
|
|
4921
|
+
}
|
|
4922
|
+
async function lastSeen(t) {
|
|
4923
|
+
switch (t.absence) {
|
|
4924
|
+
case "tool_call": {
|
|
4925
|
+
const store3 = getEventStore();
|
|
4926
|
+
if (!store3)
|
|
4927
|
+
return;
|
|
4928
|
+
const rows = store3.query({
|
|
4929
|
+
limit: 1,
|
|
4930
|
+
tool: t.tool?.length === 1 ? t.tool[0] : undefined,
|
|
4931
|
+
device: t.device?.length === 1 ? t.device[0] : undefined
|
|
4932
|
+
});
|
|
4933
|
+
return rows[0]?.ts;
|
|
4934
|
+
}
|
|
4935
|
+
case "snapshot": {
|
|
4936
|
+
const devices = t.device?.length ? t.device : Object.keys(getConfig().devices);
|
|
4937
|
+
let newest;
|
|
4938
|
+
try {
|
|
4939
|
+
const store3 = await getSnapshotStore();
|
|
4940
|
+
for (const d of devices) {
|
|
4941
|
+
const at = store3.latest(d)?.ts;
|
|
4942
|
+
if (at !== undefined && (newest === undefined || at > newest))
|
|
4943
|
+
newest = at;
|
|
4944
|
+
}
|
|
4945
|
+
} catch (e) {
|
|
4946
|
+
logger.debug(`[alerts] snapshot lookup failed: ${e instanceof Error ? e.message : e}`);
|
|
4947
|
+
return Date.now();
|
|
4948
|
+
}
|
|
4949
|
+
return newest;
|
|
4950
|
+
}
|
|
4951
|
+
case "device_seen": {
|
|
4952
|
+
const devices = t.device?.length ? t.device : Object.keys(getConfig().devices);
|
|
4953
|
+
let newest;
|
|
4954
|
+
for (const d of devices) {
|
|
4955
|
+
const st = getDeviceStatus(d);
|
|
4956
|
+
if (st.reachable !== true || st.checkedAt == null)
|
|
4957
|
+
continue;
|
|
4958
|
+
if (newest === undefined || st.checkedAt > newest)
|
|
4959
|
+
newest = st.checkedAt;
|
|
4960
|
+
}
|
|
4961
|
+
return newest;
|
|
4962
|
+
}
|
|
4963
|
+
}
|
|
4964
|
+
}
|
|
4965
|
+
async function sampleAlertsOnce(now = Date.now()) {
|
|
4966
|
+
const engine = getAlertEngine();
|
|
4967
|
+
if (!engine)
|
|
4968
|
+
return;
|
|
4969
|
+
if (engine.hasMetricRules()) {
|
|
4970
|
+
engine.sample((windowMs2) => computeMetricSample(windowMs2, now));
|
|
4971
|
+
}
|
|
4972
|
+
if (engine.hasAbsenceRules()) {
|
|
4973
|
+
await engine.sampleAbsence(lastSeen, now);
|
|
4974
|
+
}
|
|
4975
|
+
}
|
|
4976
|
+
function startAlertSampler(intervalMs = DEFAULT_ALERT_SAMPLE_MS) {
|
|
4977
|
+
stopAlertSampler();
|
|
4978
|
+
timer4 = setInterval(() => {
|
|
4979
|
+
sampleAlertsOnce().catch((e) => {
|
|
4980
|
+
logger.debug(`[alerts] sampler tick failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
4981
|
+
});
|
|
4982
|
+
}, intervalMs);
|
|
4983
|
+
timer4.unref?.();
|
|
4984
|
+
logger.debug(`[alerts] metric sampler started (${intervalMs}ms)`);
|
|
4985
|
+
}
|
|
4986
|
+
function stopAlertSampler() {
|
|
4987
|
+
if (timer4)
|
|
4988
|
+
clearInterval(timer4);
|
|
4989
|
+
timer4 = null;
|
|
4990
|
+
}
|
|
4991
|
+
|
|
3733
4992
|
// src/cli.ts
|
|
3734
4993
|
var HELP = `${SERVER_NAME} v${VERSION} \u2014 MikroTik RouterOS MCP server
|
|
3735
4994
|
|
|
@@ -3882,6 +5141,29 @@ async function main() {
|
|
|
3882
5141
|
warnIfPlaintextPasswordInContainer(Object.values(cfg.devices).some((d) => !!d.password));
|
|
3883
5142
|
const deviceNames = Object.keys(cfg.devices);
|
|
3884
5143
|
logger.info(`Starting ${SERVER_NAME} v${VERSION} (transport=${cfg.mcp.transport}, ` + `devices=${deviceNames.length === 1 ? deviceNames[0] : deviceNames.join("/")}, ` + `ssh-pool=${cfg.ssh.keepAlive ? "on" : "off"})`);
|
|
5144
|
+
if (cfg.alerts?.enabled !== false && cfg.alerts) {
|
|
5145
|
+
try {
|
|
5146
|
+
const rules = parseRules(cfg.alerts.rules);
|
|
5147
|
+
setAlertEngine(new AlertEngine({
|
|
5148
|
+
rules,
|
|
5149
|
+
channels: cfg.alerts.channels,
|
|
5150
|
+
historyDb: cfg.dashboard.dbPath,
|
|
5151
|
+
maxHistory: cfg.alerts.maxHistory
|
|
5152
|
+
}));
|
|
5153
|
+
startAlertSampler();
|
|
5154
|
+
logger.info(`Alerting enabled: ${rules.length} rule(s)`);
|
|
5155
|
+
} catch (e) {
|
|
5156
|
+
logger.error(`Alerting disabled \u2014 invalid rules: ${e instanceof Error ? e.message : String(e)}`);
|
|
5157
|
+
}
|
|
5158
|
+
}
|
|
5159
|
+
startScheduler().catch((e) => {
|
|
5160
|
+
logger.error(`Scheduled audits disabled: ${e instanceof Error ? e.message : String(e)}`);
|
|
5161
|
+
});
|
|
5162
|
+
try {
|
|
5163
|
+
startAttackDetection();
|
|
5164
|
+
} catch (e) {
|
|
5165
|
+
logger.error(`Attack detection disabled: ${e instanceof Error ? e.message : String(e)}`);
|
|
5166
|
+
}
|
|
3885
5167
|
let shuttingDown = false;
|
|
3886
5168
|
const shutdown = (signal) => {
|
|
3887
5169
|
if (shuttingDown)
|
|
@@ -3890,6 +5172,9 @@ async function main() {
|
|
|
3890
5172
|
logger.info(`Received ${signal}, shutting down\u2026`);
|
|
3891
5173
|
closeAll();
|
|
3892
5174
|
closeMemoryStore();
|
|
5175
|
+
stopAlertSampler();
|
|
5176
|
+
stopScheduler();
|
|
5177
|
+
stopAttackDetection();
|
|
3893
5178
|
process.exit(0);
|
|
3894
5179
|
};
|
|
3895
5180
|
process.on("SIGINT", () => shutdown("SIGINT"));
|