@usex/mikrotik-mcp 4.24.0 → 5.1.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 +1854 -554
- package/dist/index.d.ts +57 -0
- package/dist/index.js +1 -1
- package/dist/shared/{library-2f8ty72a.js → cli-mqb3rqfy.js} +34841 -22974
- package/dist/shared/{cli-ps0h2mxx.js → cli-s9fc4c7j.js} +1 -1
- package/dist/shared/{cli-10wtq3xb.js → library-tq8phch8.js} +27682 -16779
- package/dist/shared/{library-phwm9kqe.js → library-xgy70fsf.js} +1 -1
- package/dist/ui/observability.html +71 -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 +3050 -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 +27 -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-mqb3rqfy.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,1276 @@ 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
|
+
const body = await req.json().catch(() => ({}));
|
|
2707
|
+
const scope = body.devices?.length ? body.devices.join(", ") : body.onlineOnly ? "reachable devices" : "every device";
|
|
2708
|
+
logger.info(`attack sweep requested from the dashboard (${scope})`);
|
|
2709
|
+
const result = await sweep({
|
|
2710
|
+
devices: body.devices?.length ? body.devices : undefined,
|
|
2711
|
+
onlineOnly: body.onlineOnly,
|
|
2712
|
+
respond: false
|
|
2713
|
+
});
|
|
2714
|
+
return json9({
|
|
2715
|
+
incidents: result.incidents,
|
|
2716
|
+
unavailable: result.unavailable,
|
|
2717
|
+
devices: result.devices,
|
|
2718
|
+
skipped: result.skipped
|
|
2719
|
+
});
|
|
2720
|
+
}
|
|
2721
|
+
if (p === "/api/attacks/devices" && req.method === "GET") {
|
|
2722
|
+
return json9({
|
|
2723
|
+
devices: listDevices().names.map((name) => ({
|
|
2724
|
+
name,
|
|
2725
|
+
reachable: getDeviceStatus(name).reachable
|
|
2726
|
+
}))
|
|
2727
|
+
});
|
|
2728
|
+
}
|
|
2729
|
+
const rest = p.slice("/api/attacks/".length).split("/").filter(Boolean);
|
|
2730
|
+
if (rest[0] === "responses" && rest[1] && req.method === "DELETE") {
|
|
2731
|
+
const address = decodeURIComponent(rest[1]);
|
|
2732
|
+
const response = store3.responseFor(address);
|
|
2733
|
+
const devices = response?.devices ?? [];
|
|
2734
|
+
if (devices.length === 0)
|
|
2735
|
+
return json9({ error: `no recorded block for ${address}` }, 404);
|
|
2736
|
+
const results = await revokeBlock(address, devices, response?.list);
|
|
2737
|
+
store3.revokeResponse(address, now);
|
|
2738
|
+
return json9({ address, results });
|
|
2739
|
+
}
|
|
2740
|
+
if (rest.length === 1 && req.method === "GET") {
|
|
2741
|
+
const incident = store3.getIncident(decodeURIComponent(rest[0]));
|
|
2742
|
+
if (!incident)
|
|
2743
|
+
return json9({ error: "not found" }, 404);
|
|
2744
|
+
return json9({ incident, response: store3.responseFor(incident.source) });
|
|
2745
|
+
}
|
|
2746
|
+
if (rest.length === 2 && rest[1] === "respond" && req.method === "POST") {
|
|
2747
|
+
const incident = store3.getIncident(decodeURIComponent(rest[0]));
|
|
2748
|
+
if (!incident)
|
|
2749
|
+
return json9({ error: "not found" }, 404);
|
|
2750
|
+
const body = await req.json().catch(() => ({}));
|
|
2751
|
+
if (body.action === "dismiss") {
|
|
2752
|
+
return json9({ dismissed: incident.id });
|
|
2753
|
+
}
|
|
2754
|
+
const decision = decide({
|
|
2755
|
+
incident,
|
|
2756
|
+
policy: policyFromConfig(),
|
|
2757
|
+
guards: buildGuards(),
|
|
2758
|
+
recentBlockCount: store3.countRecentBlocks(incident.devices, now - 3600000),
|
|
2759
|
+
manual: true,
|
|
2760
|
+
confirm: body.confirm ?? false,
|
|
2761
|
+
timeout: body.timeout
|
|
2762
|
+
});
|
|
2763
|
+
if (!isPlan(decision)) {
|
|
2764
|
+
return json9({ refused: true, guard: decision.guard, reason: decision.reason }, 200);
|
|
2765
|
+
}
|
|
2766
|
+
if (decision.action === "escalate") {
|
|
2767
|
+
return json9({ escalated: true, reason: decision.reason });
|
|
2768
|
+
}
|
|
2769
|
+
if (!body.confirm) {
|
|
2770
|
+
return json9({ dryRun: true, plan: decision });
|
|
2771
|
+
}
|
|
2772
|
+
const applied = await executePlan(decision);
|
|
2773
|
+
const ok = applied.some((r) => r.ok);
|
|
2774
|
+
store3.recordResponse({
|
|
2775
|
+
incidentId: incident.id,
|
|
2776
|
+
action: decision.action,
|
|
2777
|
+
source: decision.source,
|
|
2778
|
+
devices: decision.devices,
|
|
2779
|
+
timeout: decision.timeout,
|
|
2780
|
+
list: decision.list,
|
|
2781
|
+
reason: decision.reason,
|
|
2782
|
+
ts: now,
|
|
2783
|
+
ok,
|
|
2784
|
+
error: ok ? undefined : applied.map((r) => r.detail).join("; ")
|
|
2785
|
+
});
|
|
2786
|
+
return json9({ applied, ok, plan: decision });
|
|
2787
|
+
}
|
|
2788
|
+
return json9({ error: "not found" }, 404);
|
|
2789
|
+
}
|
|
2790
|
+
|
|
2791
|
+
// src/observability/alert-routes.ts
|
|
2792
|
+
var JSON_HEADERS10 = { "content-type": "application/json; charset=utf-8" };
|
|
2793
|
+
function json10(body, status = 200) {
|
|
2794
|
+
return new Response(JSON.stringify(body), { status, headers: JSON_HEADERS10 });
|
|
2795
|
+
}
|
|
2796
|
+
function ruleRow(rule, subject, status, since) {
|
|
2797
|
+
return {
|
|
2798
|
+
id: rule.id,
|
|
2799
|
+
subject,
|
|
2800
|
+
description: rule.description,
|
|
2801
|
+
when: rule.when,
|
|
2802
|
+
severity: rule.severity,
|
|
2803
|
+
channels: rule.channels,
|
|
2804
|
+
for: rule.for,
|
|
2805
|
+
cooldown: rule.cooldown,
|
|
2806
|
+
enabled: rule.enabled,
|
|
2807
|
+
mutedUntil: rule.mutedUntil,
|
|
2808
|
+
status,
|
|
2809
|
+
since,
|
|
2810
|
+
warnings: ruleWarnings(rule)
|
|
2811
|
+
};
|
|
2812
|
+
}
|
|
2813
|
+
function previewRule(rule, events) {
|
|
2814
|
+
if (!isEventTrigger(rule.when))
|
|
2815
|
+
return { fires: 0, resolves: 0, matched: 0 };
|
|
2816
|
+
let state = initialState(events[0]?.ts ?? 0);
|
|
2817
|
+
let fires = 0;
|
|
2818
|
+
let resolves = 0;
|
|
2819
|
+
let matched = 0;
|
|
2820
|
+
for (const e of [...events].sort((a, b) => a.ts - b.ts)) {
|
|
2821
|
+
const ev = {
|
|
2822
|
+
kind: "tool_call",
|
|
2823
|
+
tool: e.tool,
|
|
2824
|
+
device: e.device,
|
|
2825
|
+
risk: e.risk,
|
|
2826
|
+
isError: e.isError
|
|
2827
|
+
};
|
|
2828
|
+
const met = eventMet(rule.when, ev);
|
|
2829
|
+
if (met)
|
|
2830
|
+
matched++;
|
|
2831
|
+
const res = step(rule, state, met, e.ts);
|
|
2832
|
+
state = res.state;
|
|
2833
|
+
if (res.action.type === "fire")
|
|
2834
|
+
fires++;
|
|
2835
|
+
if (res.action.type === "resolve")
|
|
2836
|
+
resolves++;
|
|
2837
|
+
}
|
|
2838
|
+
return { fires, resolves, matched };
|
|
2839
|
+
}
|
|
2840
|
+
async function alertRoutes(req, url, db) {
|
|
2841
|
+
const p = url.pathname;
|
|
2842
|
+
if (!p.startsWith("/api/alerts"))
|
|
2843
|
+
return null;
|
|
2844
|
+
const engine = getAlertEngine();
|
|
2845
|
+
const channels = getConfig().alerts?.channels;
|
|
2846
|
+
if (p === "/api/alerts" && req.method === "GET") {
|
|
2847
|
+
if (!engine) {
|
|
2848
|
+
return json10({ configured: false, rules: [], active: [], channels: {} });
|
|
2849
|
+
}
|
|
2850
|
+
const now = Date.now();
|
|
2851
|
+
const rows = engine.snapshot().map(({ rule, subject, state }) => ruleRow(rule, subject, isMuted(rule, now) ? "muted" : !rule.enabled ? "disabled" : state.status, state.since));
|
|
2852
|
+
return json10({
|
|
2853
|
+
configured: true,
|
|
2854
|
+
rules: rows,
|
|
2855
|
+
active: rows.filter((r) => r.status === "firing"),
|
|
2856
|
+
channels: redactChannels(channels)
|
|
2857
|
+
});
|
|
2858
|
+
}
|
|
2859
|
+
if (p === "/api/alerts/history" && req.method === "GET") {
|
|
2860
|
+
if (!engine)
|
|
2861
|
+
return json10({ history: [] });
|
|
2862
|
+
const hours = Number(url.searchParams.get("hours") ?? 24);
|
|
2863
|
+
const history = await engine.history({
|
|
2864
|
+
sinceMs: Date.now() - hours * 3600000,
|
|
2865
|
+
ruleId: url.searchParams.get("rule") ?? undefined,
|
|
2866
|
+
limit: Number(url.searchParams.get("limit") ?? 200)
|
|
2867
|
+
});
|
|
2868
|
+
return json10({ history });
|
|
2869
|
+
}
|
|
2870
|
+
if (p === "/api/alerts/rules" && req.method === "POST") {
|
|
2871
|
+
if (!engine)
|
|
2872
|
+
return json10({ error: "alerting is not configured" }, 400);
|
|
2873
|
+
const parsed = AlertRuleSchema.safeParse(await req.json());
|
|
2874
|
+
if (!parsed.success) {
|
|
2875
|
+
return json10({ error: parsed.error.issues.map((i) => i.message).join("; ") }, 400);
|
|
2876
|
+
}
|
|
2877
|
+
const rules = engine.configuredRules();
|
|
2878
|
+
if (rules.some((r) => r.id === parsed.data.id)) {
|
|
2879
|
+
return json10({ error: `rule '${parsed.data.id}' already exists` }, 409);
|
|
2880
|
+
}
|
|
2881
|
+
engine.setRules([...rules, parsed.data]);
|
|
2882
|
+
return json10({ ok: true, id: parsed.data.id });
|
|
2883
|
+
}
|
|
2884
|
+
const idMatch = p.match(/^\/api\/alerts\/rules\/([^/]+)$/);
|
|
2885
|
+
if (idMatch) {
|
|
2886
|
+
if (!engine)
|
|
2887
|
+
return json10({ error: "alerting is not configured" }, 400);
|
|
2888
|
+
const id = decodeURIComponent(idMatch[1]);
|
|
2889
|
+
const rules = engine.configuredRules();
|
|
2890
|
+
const existing = rules.find((r) => r.id === id);
|
|
2891
|
+
if (!existing)
|
|
2892
|
+
return json10({ error: `unknown rule: ${id}` }, 404);
|
|
2893
|
+
if (req.method === "DELETE") {
|
|
2894
|
+
engine.setRules(rules.filter((r) => r.id !== id));
|
|
2895
|
+
return json10({ ok: true });
|
|
2896
|
+
}
|
|
2897
|
+
if (req.method === "PATCH") {
|
|
2898
|
+
const body = await req.json();
|
|
2899
|
+
let mutedUntil = existing.mutedUntil;
|
|
2900
|
+
if (typeof body.mute === "string") {
|
|
2901
|
+
const ms = parseDuration(body.mute);
|
|
2902
|
+
if (ms === null)
|
|
2903
|
+
return json10({ error: `invalid duration: ${body.mute}` }, 400);
|
|
2904
|
+
mutedUntil = ms === 0 ? undefined : Date.now() + ms;
|
|
2905
|
+
delete body.mute;
|
|
2906
|
+
}
|
|
2907
|
+
const merged = AlertRuleSchema.safeParse({ ...existing, ...body, mutedUntil });
|
|
2908
|
+
if (!merged.success) {
|
|
2909
|
+
return json10({ error: merged.error.issues.map((i) => i.message).join("; ") }, 400);
|
|
2910
|
+
}
|
|
2911
|
+
engine.setRules(rules.map((r) => r.id === id ? merged.data : r));
|
|
2912
|
+
return json10({ ok: true, rule: merged.data });
|
|
2913
|
+
}
|
|
2914
|
+
}
|
|
2915
|
+
if (p === "/api/alerts/test" && req.method === "POST") {
|
|
2916
|
+
const body = await req.json();
|
|
2917
|
+
if (!channels || !body?.channel)
|
|
2918
|
+
return json10({ error: "channel is required" }, 400);
|
|
2919
|
+
try {
|
|
2920
|
+
const result = await deliver(body.channel, channels, {
|
|
2921
|
+
ruleId: "test",
|
|
2922
|
+
severity: "low",
|
|
2923
|
+
kind: "fire",
|
|
2924
|
+
title: "Test notification from mikrotik-mcp",
|
|
2925
|
+
body: "If you can read this, this channel is configured correctly.",
|
|
2926
|
+
at: Date.now()
|
|
2927
|
+
});
|
|
2928
|
+
return json10(result);
|
|
2929
|
+
} catch (e) {
|
|
2930
|
+
logger.error(`Alert channel test failed: ${logError(e)}`);
|
|
2931
|
+
return json10({ error: clientError(e) }, 502);
|
|
2932
|
+
}
|
|
2933
|
+
}
|
|
2934
|
+
if (p === "/api/alerts/preview" && req.method === "POST") {
|
|
2935
|
+
const parsed = AlertRuleSchema.safeParse(await req.json());
|
|
2936
|
+
if (!parsed.success) {
|
|
2937
|
+
return json10({ error: parsed.error.issues.map((i) => i.message).join("; ") }, 400);
|
|
2938
|
+
}
|
|
2939
|
+
const hours = Number(url.searchParams.get("hours") ?? 24);
|
|
2940
|
+
if (!db) {
|
|
2941
|
+
return json10({ hours, sampled: 0, fires: 0, resolves: 0, matched: 0, noHistory: true });
|
|
2942
|
+
}
|
|
2943
|
+
const events = db.query({ since: Date.now() - hours * 3600000, limit: 5000 }).map((e) => ({
|
|
2944
|
+
ts: e.ts,
|
|
2945
|
+
tool: e.tool,
|
|
2946
|
+
device: e.device,
|
|
2947
|
+
risk: e.risk,
|
|
2948
|
+
isError: e.isError
|
|
2949
|
+
}));
|
|
2950
|
+
return json10({ hours, sampled: events.length, ...previewRule(parsed.data, events) });
|
|
2951
|
+
}
|
|
2952
|
+
return null;
|
|
2953
|
+
}
|
|
2954
|
+
|
|
2955
|
+
// src/observability/memory-routes.ts
|
|
2956
|
+
var JSON_HEADERS11 = { "content-type": "application/json; charset=utf-8" };
|
|
2957
|
+
function json11(body, status = 200) {
|
|
2958
|
+
return new Response(JSON.stringify(body), { status, headers: JSON_HEADERS11 });
|
|
2959
|
+
}
|
|
2960
|
+
async function bodyJson4(req) {
|
|
1846
2961
|
return await req.json();
|
|
1847
2962
|
}
|
|
1848
2963
|
async function memoryRoutes(req, url) {
|
|
@@ -1851,29 +2966,29 @@ async function memoryRoutes(req, url) {
|
|
|
1851
2966
|
return null;
|
|
1852
2967
|
const cfg = getConfig();
|
|
1853
2968
|
if (!cfg.memory.enabled) {
|
|
1854
|
-
return
|
|
2969
|
+
return json11({ error: "Knowledge-graph memory is disabled" }, 503);
|
|
1855
2970
|
}
|
|
1856
2971
|
if (p === "/api/memory/config" && req.method === "GET") {
|
|
1857
2972
|
let stats = null;
|
|
1858
2973
|
try {
|
|
1859
|
-
const
|
|
1860
|
-
stats =
|
|
2974
|
+
const store4 = await getMemoryStore();
|
|
2975
|
+
stats = store4.stats();
|
|
1861
2976
|
} catch {}
|
|
1862
|
-
return
|
|
2977
|
+
return json11({
|
|
1863
2978
|
enabled: cfg.memory.enabled,
|
|
1864
2979
|
dbPath: cfg.memory.dbPath,
|
|
1865
2980
|
stats
|
|
1866
2981
|
});
|
|
1867
2982
|
}
|
|
1868
2983
|
if (p === "/api/memory/config" && req.method === "POST") {
|
|
1869
|
-
const body = await
|
|
2984
|
+
const body = await bodyJson4(req);
|
|
1870
2985
|
const updates = {};
|
|
1871
2986
|
if (body.dbPath !== undefined && typeof body.dbPath === "string" && body.dbPath !== cfg.memory.dbPath) {
|
|
1872
2987
|
updates.dbPath = body.dbPath;
|
|
1873
2988
|
try {
|
|
1874
2989
|
await reopenMemoryStore(body.dbPath);
|
|
1875
2990
|
} catch (e) {
|
|
1876
|
-
return
|
|
2991
|
+
return json11({
|
|
1877
2992
|
error: `Failed to open memory DB at ${body.dbPath}: ${e instanceof Error ? e.message : String(e)}`
|
|
1878
2993
|
}, 400);
|
|
1879
2994
|
}
|
|
@@ -1892,79 +3007,79 @@ async function memoryRoutes(req, url) {
|
|
|
1892
3007
|
}
|
|
1893
3008
|
let stats = null;
|
|
1894
3009
|
try {
|
|
1895
|
-
const
|
|
1896
|
-
stats =
|
|
3010
|
+
const store4 = await getMemoryStore();
|
|
3011
|
+
stats = store4.stats();
|
|
1897
3012
|
} catch {}
|
|
1898
|
-
return
|
|
3013
|
+
return json11({
|
|
1899
3014
|
ok: true,
|
|
1900
3015
|
enabled: getConfig().memory.enabled,
|
|
1901
3016
|
dbPath: getConfig().memory.dbPath,
|
|
1902
3017
|
stats
|
|
1903
3018
|
});
|
|
1904
3019
|
}
|
|
1905
|
-
const
|
|
3020
|
+
const store3 = await getMemoryStore();
|
|
1906
3021
|
if (p === "/api/memory/graph" && req.method === "GET") {
|
|
1907
|
-
return
|
|
3022
|
+
return json11(store3.readGraph());
|
|
1908
3023
|
}
|
|
1909
3024
|
if (p === "/api/memory/stats" && req.method === "GET") {
|
|
1910
|
-
return
|
|
3025
|
+
return json11(store3.stats());
|
|
1911
3026
|
}
|
|
1912
3027
|
if (p === "/api/memory/search" && req.method === "GET") {
|
|
1913
3028
|
const q = url.searchParams.get("q") || "";
|
|
1914
3029
|
const limit = Number(url.searchParams.get("limit") ?? 50);
|
|
1915
|
-
return
|
|
3030
|
+
return json11(store3.searchNodes(q, limit));
|
|
1916
3031
|
}
|
|
1917
3032
|
if (p === "/api/memory/activity" && req.method === "GET") {
|
|
1918
3033
|
const limit = Number(url.searchParams.get("limit") ?? 50);
|
|
1919
3034
|
const since = url.searchParams.get("since");
|
|
1920
|
-
return
|
|
3035
|
+
return json11(store3.activity(limit, since ? Number(since) : undefined));
|
|
1921
3036
|
}
|
|
1922
3037
|
const entityMatch = p.match(/^\/api\/memory\/entity\/(.+)$/);
|
|
1923
3038
|
if (entityMatch && req.method === "GET") {
|
|
1924
3039
|
const name = decodeURIComponent(entityMatch[1]);
|
|
1925
|
-
const graph =
|
|
3040
|
+
const graph = store3.openNodes([name]);
|
|
1926
3041
|
if (graph.entities.length === 0)
|
|
1927
|
-
return
|
|
1928
|
-
return
|
|
3042
|
+
return json11({ error: "Entity not found" }, 404);
|
|
3043
|
+
return json11({ entity: graph.entities[0], relations: graph.relations });
|
|
1929
3044
|
}
|
|
1930
3045
|
if (p === "/api/memory/entities" && req.method === "POST") {
|
|
1931
|
-
const body = await
|
|
1932
|
-
const created =
|
|
1933
|
-
return
|
|
3046
|
+
const body = await bodyJson4(req);
|
|
3047
|
+
const created = store3.createEntities(body.entities ?? []);
|
|
3048
|
+
return json11({ created, count: created.length });
|
|
1934
3049
|
}
|
|
1935
3050
|
if (p === "/api/memory/relations" && req.method === "POST") {
|
|
1936
|
-
const body = await
|
|
1937
|
-
const created =
|
|
1938
|
-
return
|
|
3051
|
+
const body = await bodyJson4(req);
|
|
3052
|
+
const created = store3.createRelations(body.relations ?? []);
|
|
3053
|
+
return json11({ created, count: created.length });
|
|
1939
3054
|
}
|
|
1940
3055
|
if (p === "/api/memory/observations" && req.method === "POST") {
|
|
1941
|
-
const body = await
|
|
1942
|
-
const results =
|
|
1943
|
-
return
|
|
3056
|
+
const body = await bodyJson4(req);
|
|
3057
|
+
const results = store3.addObservations(body.observations ?? []);
|
|
3058
|
+
return json11({ results });
|
|
1944
3059
|
}
|
|
1945
3060
|
if (p === "/api/memory/entities" && req.method === "DELETE") {
|
|
1946
|
-
const body = await
|
|
1947
|
-
const removed =
|
|
1948
|
-
return
|
|
3061
|
+
const body = await bodyJson4(req);
|
|
3062
|
+
const removed = store3.deleteEntities(body.names ?? []);
|
|
3063
|
+
return json11({ removed });
|
|
1949
3064
|
}
|
|
1950
3065
|
if (p === "/api/memory/relations" && req.method === "DELETE") {
|
|
1951
|
-
const body = await
|
|
1952
|
-
const removed =
|
|
1953
|
-
return
|
|
3066
|
+
const body = await bodyJson4(req);
|
|
3067
|
+
const removed = store3.deleteRelations(body.relations ?? []);
|
|
3068
|
+
return json11({ removed });
|
|
1954
3069
|
}
|
|
1955
3070
|
if (p === "/api/memory/observations" && req.method === "DELETE") {
|
|
1956
|
-
const body = await
|
|
1957
|
-
const removed =
|
|
1958
|
-
return
|
|
3071
|
+
const body = await bodyJson4(req);
|
|
3072
|
+
const removed = store3.deleteObservations(body.deletions ?? []);
|
|
3073
|
+
return json11({ removed });
|
|
1959
3074
|
}
|
|
1960
3075
|
return null;
|
|
1961
3076
|
}
|
|
1962
3077
|
|
|
1963
3078
|
// src/observability/dashboard.ts
|
|
1964
3079
|
var SERVER_TAG3 = "mikrotik-mcp";
|
|
1965
|
-
var
|
|
1966
|
-
function
|
|
1967
|
-
return new Response(JSON.stringify(body), { status, headers:
|
|
3080
|
+
var JSON_HEADERS12 = { "content-type": "application/json; charset=utf-8" };
|
|
3081
|
+
function json12(body, status = 200) {
|
|
3082
|
+
return new Response(JSON.stringify(body), { status, headers: JSON_HEADERS12 });
|
|
1968
3083
|
}
|
|
1969
3084
|
function restartProcess() {
|
|
1970
3085
|
let ok = false;
|
|
@@ -1992,7 +3107,7 @@ function runUpgrade(spec) {
|
|
|
1992
3107
|
env: process.env,
|
|
1993
3108
|
stdio: ["ignore", "pipe", "pipe"]
|
|
1994
3109
|
});
|
|
1995
|
-
const
|
|
3110
|
+
const timer4 = setTimeout(() => {
|
|
1996
3111
|
child.kill();
|
|
1997
3112
|
resolve({ ok: false, log: `${out}
|
|
1998
3113
|
(timed out after 180s)` });
|
|
@@ -2000,12 +3115,12 @@ function runUpgrade(spec) {
|
|
|
2000
3115
|
child.stdout?.on("data", (d) => out += d.toString());
|
|
2001
3116
|
child.stderr?.on("data", (d) => out += d.toString());
|
|
2002
3117
|
child.on("error", (e) => {
|
|
2003
|
-
clearTimeout(
|
|
3118
|
+
clearTimeout(timer4);
|
|
2004
3119
|
resolve({ ok: false, log: `${out}
|
|
2005
3120
|
spawn error: ${String(e)}` });
|
|
2006
3121
|
});
|
|
2007
3122
|
child.on("exit", (code) => {
|
|
2008
|
-
clearTimeout(
|
|
3123
|
+
clearTimeout(timer4);
|
|
2009
3124
|
resolve({ ok: code === 0, log: out.trim() || `(exit ${code})` });
|
|
2010
3125
|
});
|
|
2011
3126
|
});
|
|
@@ -2039,8 +3154,8 @@ function filterFromQuery(url) {
|
|
|
2039
3154
|
until: num("until")
|
|
2040
3155
|
};
|
|
2041
3156
|
}
|
|
2042
|
-
function facets(
|
|
2043
|
-
const recent =
|
|
3157
|
+
function facets(store3) {
|
|
3158
|
+
const recent = store3.query({ limit: 5000 });
|
|
2044
3159
|
const tools = new Set;
|
|
2045
3160
|
const devices = new Set;
|
|
2046
3161
|
for (const e of recent) {
|
|
@@ -2053,8 +3168,8 @@ function facets(store) {
|
|
|
2053
3168
|
devices: [...devices].sort()
|
|
2054
3169
|
};
|
|
2055
3170
|
}
|
|
2056
|
-
function deviceActivity(
|
|
2057
|
-
const recent =
|
|
3171
|
+
function deviceActivity(store3) {
|
|
3172
|
+
const recent = store3.query({ limit: 5000 });
|
|
2058
3173
|
const map = new Map;
|
|
2059
3174
|
for (const e of recent) {
|
|
2060
3175
|
if (!e.device)
|
|
@@ -2083,9 +3198,9 @@ function deviceActivity(store) {
|
|
|
2083
3198
|
}
|
|
2084
3199
|
return out;
|
|
2085
3200
|
}
|
|
2086
|
-
function devicesPayload(
|
|
3201
|
+
function devicesPayload(store3) {
|
|
2087
3202
|
const cfg = getConfig();
|
|
2088
|
-
const activity = deviceActivity(
|
|
3203
|
+
const activity = deviceActivity(store3);
|
|
2089
3204
|
const poolEnabled = isPoolEnabled();
|
|
2090
3205
|
const poolMap = new Map(poolStatus().map((p) => [p.device, p]));
|
|
2091
3206
|
const devices = Object.entries(cfg.devices).map(([name, dc]) => {
|
|
@@ -2096,7 +3211,8 @@ function devicesPayload(store) {
|
|
|
2096
3211
|
host: dc.host,
|
|
2097
3212
|
port: dc.port,
|
|
2098
3213
|
mac: dc.mac,
|
|
2099
|
-
transport: dc.mac ? "mac-telnet" : "ssh",
|
|
3214
|
+
transport: dc.mac ? "mac-telnet" : dc.api ? "rest" : "ssh",
|
|
3215
|
+
restPort: dc.api ? dc.apiPort ?? 443 : undefined,
|
|
2100
3216
|
address: dc.mac ? dc.mac : `${dc.host}:${dc.port}`,
|
|
2101
3217
|
username: dc.username,
|
|
2102
3218
|
authMode: dc.mac ? "mac-telnet" : dc.keyFilename || dc.privateKey ? "key" : dc.password ? "password" : "none",
|
|
@@ -2258,11 +3374,11 @@ function issues(error) {
|
|
|
2258
3374
|
async function configRoutes(req, url, admin) {
|
|
2259
3375
|
const p = url.pathname;
|
|
2260
3376
|
if (p === "/api/config-schema" && req.method === "GET") {
|
|
2261
|
-
return
|
|
3377
|
+
return json12(configSchemaJson());
|
|
2262
3378
|
}
|
|
2263
3379
|
if (p === "/api/config/validate" && req.method === "POST") {
|
|
2264
3380
|
const merged = mergeSecrets(await readJson(req), getConfig());
|
|
2265
|
-
return
|
|
3381
|
+
return json12(validateConfig(merged));
|
|
2266
3382
|
}
|
|
2267
3383
|
if (p === "/api/config/test-device" && req.method === "POST") {
|
|
2268
3384
|
const body = await readJson(req);
|
|
@@ -2271,22 +3387,22 @@ async function configRoutes(req, url, admin) {
|
|
|
2271
3387
|
const merged = mergeSecrets(body?.config, currentDc);
|
|
2272
3388
|
const parsed = DeviceConfigSchema.safeParse(merged);
|
|
2273
3389
|
if (!parsed.success)
|
|
2274
|
-
return
|
|
3390
|
+
return json12({ ok: false, errors: issues(parsed.error) }, 400);
|
|
2275
3391
|
const status = await probeDevice(`config-test:${name}`, parsed.data);
|
|
2276
|
-
return
|
|
3392
|
+
return json12({ ok: true, status });
|
|
2277
3393
|
}
|
|
2278
3394
|
if (p === "/api/config/preview" && req.method === "POST") {
|
|
2279
3395
|
const before = JSON.stringify(redact(getConfig()), null, 2);
|
|
2280
3396
|
const after = JSON.stringify(await readJson(req) ?? {}, null, 2);
|
|
2281
3397
|
const d = diffLines(before, after, { fromLabel: "current", toLabel: "edited" });
|
|
2282
|
-
return
|
|
3398
|
+
return json12({ summary: d.summary, unified: d.unified });
|
|
2283
3399
|
}
|
|
2284
3400
|
if (p === "/api/config" && req.method === "POST") {
|
|
2285
3401
|
const body = await readJson(req);
|
|
2286
3402
|
const merged = mergeSecrets(body?.config, getConfig());
|
|
2287
3403
|
const v = validateConfig(merged);
|
|
2288
3404
|
if (!v.ok || !v.value)
|
|
2289
|
-
return
|
|
3405
|
+
return json12({ ok: false, errors: v.errors }, 400);
|
|
2290
3406
|
const prev = getConfig();
|
|
2291
3407
|
const before = JSON.stringify(redact(prev), null, 2);
|
|
2292
3408
|
const after = JSON.stringify(redact(v.value), null, 2);
|
|
@@ -2295,25 +3411,25 @@ async function configRoutes(req, url, admin) {
|
|
|
2295
3411
|
const nextDevs = Object.keys(v.value.devices);
|
|
2296
3412
|
const devicesChanged = prevDevs.length !== nextDevs.length || prevDevs.some((d) => !nextDevs.includes(d));
|
|
2297
3413
|
const res = admin.applyConfig(v.value, clampRollback(body?.rollbackMs));
|
|
2298
|
-
return
|
|
3414
|
+
return json12({ ok: true, ...res, devicesChanged, summary: diff.summary, unified: diff.unified });
|
|
2299
3415
|
}
|
|
2300
3416
|
if (p === "/api/config/keep" && req.method === "POST") {
|
|
2301
3417
|
const body = await readJson(req);
|
|
2302
3418
|
const kept = admin.keepConfig(String(body?.pendingId ?? ""));
|
|
2303
3419
|
if (kept)
|
|
2304
3420
|
recordVersion(getConfig(), "auto", Date.now());
|
|
2305
|
-
return
|
|
3421
|
+
return json12({ kept });
|
|
2306
3422
|
}
|
|
2307
3423
|
if (p === "/api/config/rollback" && req.method === "POST") {
|
|
2308
3424
|
const body = await readJson(req);
|
|
2309
3425
|
const rolledBack = admin.rollback(String(body?.pendingId ?? ""));
|
|
2310
|
-
return
|
|
3426
|
+
return json12({ rolledBack, config: redact(getConfig()) });
|
|
2311
3427
|
}
|
|
2312
3428
|
if (p === "/api/reload" && req.method === "POST") {
|
|
2313
3429
|
const body = await readJson(req);
|
|
2314
3430
|
if (body?.hard === true) {
|
|
2315
3431
|
const relaunched = restartProcess();
|
|
2316
|
-
return
|
|
3432
|
+
return json12({
|
|
2317
3433
|
ok: true,
|
|
2318
3434
|
mode: "restart",
|
|
2319
3435
|
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 +3440,10 @@ async function configRoutes(req, url, admin) {
|
|
|
2324
3440
|
setConfig(cfg);
|
|
2325
3441
|
const devices = Object.keys(cfg.devices);
|
|
2326
3442
|
logger.info(`Config reloaded from source \u2014 ${devices.length} device(s): ${devices.join(", ")}`);
|
|
2327
|
-
return
|
|
3443
|
+
return json12({ ok: true, mode: "config", count: devices.length, devices });
|
|
2328
3444
|
} catch (e) {
|
|
2329
3445
|
logger.error(`Config reload failed: ${logError(e)}`);
|
|
2330
|
-
return
|
|
3446
|
+
return json12({ ok: false, error: clientError(e) }, 500);
|
|
2331
3447
|
}
|
|
2332
3448
|
}
|
|
2333
3449
|
if (p === "/api/config/history" && req.method === "GET") {
|
|
@@ -2343,55 +3459,55 @@ async function configRoutes(req, url, admin) {
|
|
|
2343
3459
|
} catch {}
|
|
2344
3460
|
return { ...v, drift: { added, removed } };
|
|
2345
3461
|
});
|
|
2346
|
-
return
|
|
3462
|
+
return json12({ versions, bytes: historyBytes(), retention: AUTO_RETENTION });
|
|
2347
3463
|
}
|
|
2348
3464
|
if (p === "/api/config/history/get" && req.method === "GET") {
|
|
2349
3465
|
const id = url.searchParams.get("id");
|
|
2350
3466
|
if (!id)
|
|
2351
|
-
return
|
|
3467
|
+
return json12({ error: "id required" }, 400);
|
|
2352
3468
|
try {
|
|
2353
3469
|
const v = readVersion(id);
|
|
2354
|
-
return
|
|
3470
|
+
return json12({
|
|
2355
3471
|
ts: v.ts,
|
|
2356
3472
|
kind: v.kind,
|
|
2357
3473
|
label: v.label,
|
|
2358
3474
|
config: JSON.stringify(redact(v.config), null, 2)
|
|
2359
3475
|
});
|
|
2360
3476
|
} catch {
|
|
2361
|
-
return
|
|
3477
|
+
return json12({ error: "not found" }, 404);
|
|
2362
3478
|
}
|
|
2363
3479
|
}
|
|
2364
3480
|
if (p === "/api/config/history/diff" && req.method === "GET") {
|
|
2365
3481
|
const id = url.searchParams.get("id");
|
|
2366
3482
|
if (!id)
|
|
2367
|
-
return
|
|
3483
|
+
return json12({ error: "id required" }, 400);
|
|
2368
3484
|
try {
|
|
2369
3485
|
const before = JSON.stringify(redact(readVersion(id).config), null, 2);
|
|
2370
3486
|
const after = JSON.stringify(redact(getConfig()), null, 2);
|
|
2371
3487
|
const d = diffLines(before, after, { fromLabel: "this version", toLabel: "current" });
|
|
2372
|
-
return
|
|
3488
|
+
return json12({ summary: d.summary, unified: d.unified });
|
|
2373
3489
|
} catch {
|
|
2374
|
-
return
|
|
3490
|
+
return json12({ error: "not found" }, 404);
|
|
2375
3491
|
}
|
|
2376
3492
|
}
|
|
2377
3493
|
if (p === "/api/config/history/checkpoint" && req.method === "POST") {
|
|
2378
3494
|
const b = await readJson(req);
|
|
2379
3495
|
const label = b?.label?.trim() || "checkpoint";
|
|
2380
|
-
return
|
|
3496
|
+
return json12({ ok: true, version: recordVersion(getConfig(), "checkpoint", Date.now(), label) });
|
|
2381
3497
|
}
|
|
2382
3498
|
if (p === "/api/config/history/restore" && req.method === "POST") {
|
|
2383
3499
|
const b = await readJson(req);
|
|
2384
3500
|
if (!b?.id)
|
|
2385
|
-
return
|
|
3501
|
+
return json12({ error: "id required" }, 400);
|
|
2386
3502
|
let target;
|
|
2387
3503
|
try {
|
|
2388
3504
|
target = readVersion(b.id);
|
|
2389
3505
|
} catch {
|
|
2390
|
-
return
|
|
3506
|
+
return json12({ error: "not found" }, 404);
|
|
2391
3507
|
}
|
|
2392
3508
|
const v = validateConfig(target.config);
|
|
2393
3509
|
if (!v.ok || !v.value)
|
|
2394
|
-
return
|
|
3510
|
+
return json12({ ok: false, errors: v.errors }, 400);
|
|
2395
3511
|
recordVersion(getConfig(), "auto", Date.now(), "before restore");
|
|
2396
3512
|
setConfig(v.value);
|
|
2397
3513
|
let persisted = true;
|
|
@@ -2402,13 +3518,13 @@ async function configRoutes(req, url, admin) {
|
|
|
2402
3518
|
}
|
|
2403
3519
|
const label = target.label ? `restored "${target.label}"` : `restored ${b.id}`;
|
|
2404
3520
|
recordVersion(getConfig(), "auto", Date.now(), label);
|
|
2405
|
-
return
|
|
3521
|
+
return json12({ ok: true, persisted, restored: b.id, config: redact(getConfig()) });
|
|
2406
3522
|
}
|
|
2407
3523
|
if (p === "/api/config/history/delete" && req.method === "POST") {
|
|
2408
3524
|
const b = await readJson(req);
|
|
2409
3525
|
if (!b?.id)
|
|
2410
|
-
return
|
|
2411
|
-
return deleteVersion(b.id) ?
|
|
3526
|
+
return json12({ error: "id required" }, 400);
|
|
3527
|
+
return deleteVersion(b.id) ? json12({ ok: true }) : json12({ error: "not found" }, 404);
|
|
2412
3528
|
}
|
|
2413
3529
|
return null;
|
|
2414
3530
|
}
|
|
@@ -2419,7 +3535,7 @@ async function modulesRoutes(req, url) {
|
|
|
2419
3535
|
if (p === "/api/modules" && req.method === "GET") {
|
|
2420
3536
|
const cfg = getConfig();
|
|
2421
3537
|
const src = getConfigSource();
|
|
2422
|
-
return
|
|
3538
|
+
return json12({
|
|
2423
3539
|
...moduleSurface(cfg.tools),
|
|
2424
3540
|
filter: cfg.tools,
|
|
2425
3541
|
source: src,
|
|
@@ -2429,11 +3545,11 @@ async function modulesRoutes(req, url) {
|
|
|
2429
3545
|
if (p === "/api/modules/toggle" && req.method === "POST") {
|
|
2430
3546
|
const b = await readJson(req);
|
|
2431
3547
|
if (typeof b?.slug !== "string" || typeof b?.enabled !== "boolean") {
|
|
2432
|
-
return
|
|
3548
|
+
return json12({ error: "slug (string) and enabled (boolean) are required" }, 400);
|
|
2433
3549
|
}
|
|
2434
3550
|
const mod = moduleCatalog.find((m) => m.slug.toLowerCase() === b.slug.toLowerCase());
|
|
2435
3551
|
if (!mod)
|
|
2436
|
-
return
|
|
3552
|
+
return json12({ error: `unknown module: ${b.slug}` }, 404);
|
|
2437
3553
|
const cfg = getConfig();
|
|
2438
3554
|
const nextTools = ToolFilterSchema.parse(applyModuleToggle(cfg.tools, mod.slug, b.enabled));
|
|
2439
3555
|
const next = { ...cfg, tools: nextTools };
|
|
@@ -2449,7 +3565,7 @@ async function modulesRoutes(req, url) {
|
|
|
2449
3565
|
if (persisted) {
|
|
2450
3566
|
recordVersion(getConfig(), "auto", Date.now(), `module ${b.enabled ? "enabled" : "disabled"}: ${mod.slug}`);
|
|
2451
3567
|
}
|
|
2452
|
-
return
|
|
3568
|
+
return json12({
|
|
2453
3569
|
ok: true,
|
|
2454
3570
|
persisted,
|
|
2455
3571
|
requiresReconnect: true,
|
|
@@ -2464,7 +3580,7 @@ async function modulesRoutes(req, url) {
|
|
|
2464
3580
|
if (p === "/api/modules/app-views" && req.method === "POST") {
|
|
2465
3581
|
const b = await readJson(req);
|
|
2466
3582
|
if (typeof b?.enabled !== "boolean") {
|
|
2467
|
-
return
|
|
3583
|
+
return json12({ error: "enabled (boolean) is required" }, 400);
|
|
2468
3584
|
}
|
|
2469
3585
|
const cfg = getConfig();
|
|
2470
3586
|
const next = { ...cfg, mcp: { ...cfg.mcp, appViews: b.enabled } };
|
|
@@ -2480,7 +3596,7 @@ async function modulesRoutes(req, url) {
|
|
|
2480
3596
|
if (persisted) {
|
|
2481
3597
|
recordVersion(getConfig(), "auto", Date.now(), `app views ${b.enabled ? "enabled" : "disabled"}`);
|
|
2482
3598
|
}
|
|
2483
|
-
return
|
|
3599
|
+
return json12({
|
|
2484
3600
|
ok: true,
|
|
2485
3601
|
persisted,
|
|
2486
3602
|
requiresReconnect: true,
|
|
@@ -2494,11 +3610,11 @@ async function modulesRoutes(req, url) {
|
|
|
2494
3610
|
async function captureRoutes(req, url) {
|
|
2495
3611
|
const p = url.pathname;
|
|
2496
3612
|
if (p === "/api/capture/status" && req.method === "GET") {
|
|
2497
|
-
return
|
|
3613
|
+
return json12(capture.stats());
|
|
2498
3614
|
}
|
|
2499
3615
|
if (p === "/api/capture/packets" && req.method === "GET") {
|
|
2500
3616
|
const limit = Number(url.searchParams.get("limit") ?? 200) || 200;
|
|
2501
|
-
return
|
|
3617
|
+
return json12({ packets: capture.recent(limit), stats: capture.stats() });
|
|
2502
3618
|
}
|
|
2503
3619
|
if (p === "/api/capture/pcap" && req.method === "GET") {
|
|
2504
3620
|
return new Response(capture.pcap(), {
|
|
@@ -2510,11 +3626,11 @@ async function captureRoutes(req, url) {
|
|
|
2510
3626
|
}
|
|
2511
3627
|
if (p === "/api/capture/start" && req.method === "POST") {
|
|
2512
3628
|
const body = await readJson(req);
|
|
2513
|
-
return
|
|
3629
|
+
return json12(await capture.start(typeof body?.port === "number" ? body.port : DEFAULT_TZSP_PORT));
|
|
2514
3630
|
}
|
|
2515
3631
|
if (p === "/api/capture/stop" && req.method === "POST") {
|
|
2516
3632
|
capture.stop();
|
|
2517
|
-
return
|
|
3633
|
+
return json12({ ok: true, ...capture.stats() });
|
|
2518
3634
|
}
|
|
2519
3635
|
return null;
|
|
2520
3636
|
}
|
|
@@ -2526,24 +3642,24 @@ async function capsmanRoutes(req, url) {
|
|
|
2526
3642
|
if (req.method === "GET") {
|
|
2527
3643
|
const ctx = createContext(undefined, url.searchParams.get("device") ?? undefined);
|
|
2528
3644
|
if (p === "/api/capsman/overview") {
|
|
2529
|
-
return
|
|
3645
|
+
return json12(capsmanOverview(await fetchCapsmanState(ctx)));
|
|
2530
3646
|
}
|
|
2531
3647
|
if (p === "/api/capsman/clients") {
|
|
2532
3648
|
const state = await fetchCapsmanState(ctx);
|
|
2533
3649
|
const weakDbm = Number.parseInt(url.searchParams.get("weak_dbm") ?? "", 10);
|
|
2534
3650
|
const weak = reportWeakClients(state, Number.isFinite(weakDbm) ? weakDbm : undefined);
|
|
2535
|
-
return
|
|
3651
|
+
return json12({ clients: state.clients, weak });
|
|
2536
3652
|
}
|
|
2537
3653
|
if (p === "/api/capsman/audit") {
|
|
2538
|
-
return
|
|
3654
|
+
return json12(runCapsmanAudit(await fetchCapsmanState(ctx)));
|
|
2539
3655
|
}
|
|
2540
3656
|
if (p === "/api/capsman/trends") {
|
|
2541
3657
|
if (!capsmanStore)
|
|
2542
|
-
return
|
|
3658
|
+
return json12({ error: "capsman store not active" }, 503);
|
|
2543
3659
|
const device = resolveDeviceName(url.searchParams.get("device") ?? undefined);
|
|
2544
3660
|
const days = daysParam(url, 7, 30);
|
|
2545
3661
|
const series = capsmanStore.radioSeries(device, Date.now() - days * 86400000);
|
|
2546
|
-
return
|
|
3662
|
+
return json12({ series, days });
|
|
2547
3663
|
}
|
|
2548
3664
|
return null;
|
|
2549
3665
|
}
|
|
@@ -2554,45 +3670,45 @@ async function capsmanRoutes(req, url) {
|
|
|
2554
3670
|
if (p === "/api/capsman/apply/steer") {
|
|
2555
3671
|
const client = state.clients.find((c) => c.mac.toLowerCase() === (body.mac ?? "").toLowerCase());
|
|
2556
3672
|
if (!client)
|
|
2557
|
-
return
|
|
3673
|
+
return json12({ ok: false, error: "client not associated" }, 400);
|
|
2558
3674
|
if (steerAlreadyPresent(state, body.mac ?? "")) {
|
|
2559
|
-
return
|
|
3675
|
+
return json12({ ok: true, message: "already steered (no-op)" });
|
|
2560
3676
|
}
|
|
2561
3677
|
const mode = body.mode === "soft" ? "soft" : "hard";
|
|
2562
3678
|
const commands = buildSteerCommands(state, body.mac ?? "", client.radioId, mode);
|
|
2563
3679
|
if (!body.confirm)
|
|
2564
|
-
return
|
|
2565
|
-
return
|
|
3680
|
+
return json12({ ok: true, preview: commands });
|
|
3681
|
+
return json12(await applyCapsmanWrites(ctx, commands, `pre-steer-${body.mac}`));
|
|
2566
3682
|
}
|
|
2567
3683
|
if (p === "/api/capsman/apply/load-balance") {
|
|
2568
3684
|
const plan = loadBalancePlan(state);
|
|
2569
3685
|
const commands = buildLoadBalanceCommands(state, plan);
|
|
2570
3686
|
if (!body.confirm)
|
|
2571
|
-
return
|
|
2572
|
-
return
|
|
3687
|
+
return json12({ ok: true, preview: commands, plan });
|
|
3688
|
+
return json12(await applyCapsmanWrites(ctx, commands, "pre-load-balance"));
|
|
2573
3689
|
}
|
|
2574
3690
|
if (p === "/api/capsman/apply/channel-plan") {
|
|
2575
3691
|
if (state.path === "/caps-man") {
|
|
2576
|
-
return
|
|
3692
|
+
return json12({ ok: false, error: "channel-plan apply is v7 /interface wifi only" }, 400);
|
|
2577
3693
|
}
|
|
2578
3694
|
const commands = buildChannelPlanCommands(state);
|
|
2579
3695
|
if (!body.confirm)
|
|
2580
|
-
return
|
|
2581
|
-
return
|
|
3696
|
+
return json12({ ok: true, preview: commands });
|
|
3697
|
+
return json12(await applyCapsmanWrites(ctx, commands, "pre-channel-plan"));
|
|
2582
3698
|
}
|
|
2583
3699
|
if (p === "/api/capsman/apply/ft") {
|
|
2584
3700
|
const commands = buildFtCommands(state);
|
|
2585
3701
|
if (!body.confirm)
|
|
2586
|
-
return
|
|
2587
|
-
return
|
|
3702
|
+
return json12({ ok: true, preview: commands });
|
|
3703
|
+
return json12(await applyCapsmanWrites(ctx, commands, "pre-ft"));
|
|
2588
3704
|
}
|
|
2589
3705
|
if (p === "/api/capsman/apply/ha") {
|
|
2590
3706
|
const commands = buildHaCommands(state);
|
|
2591
3707
|
const guidance = haGuidance(state);
|
|
2592
3708
|
if (!body.confirm)
|
|
2593
|
-
return
|
|
3709
|
+
return json12({ ok: true, preview: commands, guidance });
|
|
2594
3710
|
const res = await applyCapsmanWrites(ctx, commands, "pre-ha");
|
|
2595
|
-
return
|
|
3711
|
+
return json12({ ...res, guidance });
|
|
2596
3712
|
}
|
|
2597
3713
|
}
|
|
2598
3714
|
return null;
|
|
@@ -2618,32 +3734,32 @@ async function clientsRoutes(req, url) {
|
|
|
2618
3734
|
const deviceFromQuery = () => url.searchParams.get("device") ?? undefined;
|
|
2619
3735
|
if (p === "/api/clients" && req.method === "GET") {
|
|
2620
3736
|
const ctx = createContext(undefined, deviceFromQuery());
|
|
2621
|
-
return
|
|
3737
|
+
return json12(devicesView(await fetchDevices(ctx)));
|
|
2622
3738
|
}
|
|
2623
3739
|
if (p === "/api/clients/traffic" && req.method === "GET") {
|
|
2624
3740
|
const ip = url.searchParams.get("ip");
|
|
2625
3741
|
if (!ip)
|
|
2626
|
-
return
|
|
3742
|
+
return json12({ error: "ip required" }, 400);
|
|
2627
3743
|
const ctx = createContext(undefined, deviceFromQuery());
|
|
2628
|
-
return
|
|
3744
|
+
return json12(await sampleDeviceTraffic(ctx, ip));
|
|
2629
3745
|
}
|
|
2630
3746
|
if (p === "/api/clients/traffic-bulk" && req.method === "GET") {
|
|
2631
3747
|
const ctx = createContext(undefined, deviceFromQuery());
|
|
2632
|
-
return
|
|
3748
|
+
return json12(await sampleAllTraffic(ctx));
|
|
2633
3749
|
}
|
|
2634
3750
|
if (req.method === "POST") {
|
|
2635
3751
|
const b = await readJson(req);
|
|
2636
3752
|
const ctx = createContext(undefined, b?.device);
|
|
2637
3753
|
if (p === "/api/clients/limits") {
|
|
2638
3754
|
if (!b?.ip)
|
|
2639
|
-
return
|
|
2640
|
-
return
|
|
3755
|
+
return json12({ error: "ip required" }, 400);
|
|
3756
|
+
return json12(await setDeviceLimits(ctx, b.ip, { download: b.download, upload: b.upload }));
|
|
2641
3757
|
}
|
|
2642
3758
|
if (!b?.mac)
|
|
2643
|
-
return
|
|
3759
|
+
return json12({ error: "mac required" }, 400);
|
|
2644
3760
|
const run = async (op) => {
|
|
2645
3761
|
const r = await op;
|
|
2646
|
-
return
|
|
3762
|
+
return json12({ ...r, view: r.ok ? devicesView(await fetchDevices(ctx)) : undefined });
|
|
2647
3763
|
};
|
|
2648
3764
|
if (p === "/api/clients/block")
|
|
2649
3765
|
return run(blockDevice(ctx, b.mac, b.comment));
|
|
@@ -2655,12 +3771,12 @@ async function clientsRoutes(req, url) {
|
|
|
2655
3771
|
return run(removeDeviceLease(ctx, b.mac));
|
|
2656
3772
|
if (p === "/api/clients/set-ip") {
|
|
2657
3773
|
if (!b.ip)
|
|
2658
|
-
return
|
|
3774
|
+
return json12({ error: "ip required" }, 400);
|
|
2659
3775
|
return run(setDeviceIp(ctx, b.mac, b.ip));
|
|
2660
3776
|
}
|
|
2661
3777
|
if (p === "/api/clients/label") {
|
|
2662
3778
|
if (typeof b.label !== "string")
|
|
2663
|
-
return
|
|
3779
|
+
return json12({ error: "label required" }, 400);
|
|
2664
3780
|
return run(setDeviceLabel(ctx, b.mac, b.label));
|
|
2665
3781
|
}
|
|
2666
3782
|
}
|
|
@@ -2673,20 +3789,20 @@ async function aaaRoutes(req, url) {
|
|
|
2673
3789
|
const deviceFromQuery = () => url.searchParams.get("device") ?? undefined;
|
|
2674
3790
|
if (req.method === "GET") {
|
|
2675
3791
|
if (p === "/api/aaa/entities") {
|
|
2676
|
-
return
|
|
3792
|
+
return json12({ entities: AAA_ENTITIES });
|
|
2677
3793
|
}
|
|
2678
3794
|
if (p === "/api/aaa/radius-incoming") {
|
|
2679
|
-
return
|
|
3795
|
+
return json12(await getRadiusIncoming(createContext(undefined, deviceFromQuery())));
|
|
2680
3796
|
}
|
|
2681
3797
|
if (p === "/api/aaa/um-settings") {
|
|
2682
|
-
return
|
|
3798
|
+
return json12(await getUmSettings(createContext(undefined, deviceFromQuery())));
|
|
2683
3799
|
}
|
|
2684
3800
|
const listMatch = p.match(/^\/api\/aaa\/list\/([\w-]+)$/);
|
|
2685
3801
|
if (listMatch) {
|
|
2686
3802
|
const slug = listMatch[1];
|
|
2687
3803
|
if (!AAA_ENTITIES[slug])
|
|
2688
|
-
return
|
|
2689
|
-
return
|
|
3804
|
+
return json12({ error: "unknown entity" }, 404);
|
|
3805
|
+
return json12(await listAaaEntity(createContext(undefined, deviceFromQuery()), slug));
|
|
2690
3806
|
}
|
|
2691
3807
|
return null;
|
|
2692
3808
|
}
|
|
@@ -2694,30 +3810,30 @@ async function aaaRoutes(req, url) {
|
|
|
2694
3810
|
const body = await readJson(req);
|
|
2695
3811
|
const ctx = createContext(undefined, body?.device);
|
|
2696
3812
|
if (p === "/api/aaa/radius-incoming")
|
|
2697
|
-
return
|
|
3813
|
+
return json12(await setRadiusIncoming(ctx, body?.fields ?? {}));
|
|
2698
3814
|
if (p === "/api/aaa/um-settings")
|
|
2699
|
-
return
|
|
3815
|
+
return json12(await setUmSettings(ctx, body?.fields ?? {}));
|
|
2700
3816
|
if (p === "/api/aaa/radius-reset-counters")
|
|
2701
|
-
return
|
|
3817
|
+
return json12(await resetRadiusCounters(ctx));
|
|
2702
3818
|
const slug = body?.slug ?? "";
|
|
2703
3819
|
if (!AAA_ENTITIES[slug])
|
|
2704
|
-
return
|
|
3820
|
+
return json12({ error: "unknown entity" }, 404);
|
|
2705
3821
|
if (p === "/api/aaa/add")
|
|
2706
|
-
return
|
|
3822
|
+
return json12(await addAaaEntity(ctx, slug, body?.fields ?? {}));
|
|
2707
3823
|
if (p === "/api/aaa/update") {
|
|
2708
3824
|
if (!body?.id)
|
|
2709
|
-
return
|
|
2710
|
-
return
|
|
3825
|
+
return json12({ error: "id required" }, 400);
|
|
3826
|
+
return json12(await updateAaaEntity(ctx, slug, body.id, body?.fields ?? {}));
|
|
2711
3827
|
}
|
|
2712
3828
|
if (p === "/api/aaa/remove") {
|
|
2713
3829
|
if (!body?.id)
|
|
2714
|
-
return
|
|
2715
|
-
return
|
|
3830
|
+
return json12({ error: "id required" }, 400);
|
|
3831
|
+
return json12(await removeAaaEntity(ctx, slug, body.id));
|
|
2716
3832
|
}
|
|
2717
3833
|
if (p === "/api/aaa/toggle") {
|
|
2718
3834
|
if (!body?.id)
|
|
2719
|
-
return
|
|
2720
|
-
return
|
|
3835
|
+
return json12({ error: "id required" }, 400);
|
|
3836
|
+
return json12(await toggleAaaEntity(ctx, slug, body.id, body?.enable === true));
|
|
2721
3837
|
}
|
|
2722
3838
|
}
|
|
2723
3839
|
return null;
|
|
@@ -2733,43 +3849,43 @@ async function usageRoutes(req, url) {
|
|
|
2733
3849
|
return null;
|
|
2734
3850
|
if (p === "/api/usage/sampler") {
|
|
2735
3851
|
if (req.method === "GET")
|
|
2736
|
-
return
|
|
3852
|
+
return json12({ intervalMs: getUsageSamplerInterval() });
|
|
2737
3853
|
if (req.method === "POST") {
|
|
2738
3854
|
const b = await readJson(req);
|
|
2739
3855
|
const applied = setUsageSamplerInterval(Number(b?.intervalMs));
|
|
2740
|
-
return
|
|
3856
|
+
return json12({ intervalMs: applied });
|
|
2741
3857
|
}
|
|
2742
3858
|
return null;
|
|
2743
3859
|
}
|
|
2744
3860
|
if (req.method !== "GET")
|
|
2745
3861
|
return null;
|
|
2746
3862
|
if (!usageStore)
|
|
2747
|
-
return
|
|
3863
|
+
return json12({ error: "usage store not active" }, 503);
|
|
2748
3864
|
const device = resolveDeviceName(url.searchParams.get("device") ?? undefined);
|
|
2749
3865
|
const sinceTs = (days) => Date.now() - days * 86400000;
|
|
2750
3866
|
if (p === "/api/usage/um-users") {
|
|
2751
|
-
return
|
|
3867
|
+
return json12({ users: usageStore.umUsers(device) });
|
|
2752
3868
|
}
|
|
2753
3869
|
if (p === "/api/usage/client") {
|
|
2754
3870
|
const ip = url.searchParams.get("ip");
|
|
2755
3871
|
if (!ip)
|
|
2756
|
-
return
|
|
3872
|
+
return json12({ error: "ip required" }, 400);
|
|
2757
3873
|
const series = usageStore.clientDailyUsage(device, ip, sinceTs(daysParam(url, 90, 400)));
|
|
2758
|
-
return
|
|
3874
|
+
return json12(withTotals(series));
|
|
2759
3875
|
}
|
|
2760
3876
|
if (p === "/api/usage/um-user") {
|
|
2761
3877
|
const user = url.searchParams.get("user");
|
|
2762
3878
|
if (!user)
|
|
2763
|
-
return
|
|
3879
|
+
return json12({ error: "user required" }, 400);
|
|
2764
3880
|
const series = usageStore.umUserDailyUsage(device, user, sinceTs(daysParam(url, 90, 400)));
|
|
2765
|
-
return
|
|
3881
|
+
return json12(withTotals(series));
|
|
2766
3882
|
}
|
|
2767
3883
|
if (p === "/api/usage/heatmap") {
|
|
2768
3884
|
const user = url.searchParams.get("user");
|
|
2769
3885
|
const days = usageStore.heatmap(device, user || null, sinceTs(daysParam(url, 371, 400)));
|
|
2770
3886
|
const total = days.reduce((s, d) => s + d.count, 0);
|
|
2771
3887
|
const max = days.reduce((m, d) => Math.max(m, d.count), 0);
|
|
2772
|
-
return
|
|
3888
|
+
return json12({ days, total, max });
|
|
2773
3889
|
}
|
|
2774
3890
|
return null;
|
|
2775
3891
|
}
|
|
@@ -2789,31 +3905,31 @@ function snapStore() {
|
|
|
2789
3905
|
async function featureRoutes(req, url) {
|
|
2790
3906
|
const p = url.pathname;
|
|
2791
3907
|
if (p === "/api/snapshots" && req.method === "GET") {
|
|
2792
|
-
const
|
|
3908
|
+
const store3 = await snapStore();
|
|
2793
3909
|
const cfg = getConfig();
|
|
2794
|
-
const all = Object.keys(cfg.devices).flatMap((d) =>
|
|
3910
|
+
const all = Object.keys(cfg.devices).flatMap((d) => store3.list(d, 200, false));
|
|
2795
3911
|
all.sort((a, b) => b.ts - a.ts);
|
|
2796
|
-
return
|
|
3912
|
+
return json12({ snapshots: all });
|
|
2797
3913
|
}
|
|
2798
3914
|
const snapMatch = p.match(/^\/api\/snapshot\/(.+)$/);
|
|
2799
3915
|
if (snapMatch && req.method === "GET") {
|
|
2800
|
-
const
|
|
2801
|
-
const s =
|
|
2802
|
-
return s ?
|
|
3916
|
+
const store3 = await snapStore();
|
|
3917
|
+
const s = store3.get(decodeURIComponent(snapMatch[1]));
|
|
3918
|
+
return s ? json12(s) : json12({ error: "not found" }, 404);
|
|
2803
3919
|
}
|
|
2804
3920
|
if (p === "/api/snapshots/diff" && req.method === "POST") {
|
|
2805
|
-
const
|
|
3921
|
+
const store3 = await snapStore();
|
|
2806
3922
|
const b = await readJson(req);
|
|
2807
|
-
const from = b?.from ?
|
|
2808
|
-
const to = b?.to ?
|
|
3923
|
+
const from = b?.from ? store3.get(b.from) : null;
|
|
3924
|
+
const to = b?.to ? store3.get(b.to) : null;
|
|
2809
3925
|
if (!from || !to) {
|
|
2810
|
-
return
|
|
3926
|
+
return json12({ error: "both 'from' and 'to' snapshot ids are required" }, 400);
|
|
2811
3927
|
}
|
|
2812
3928
|
const diff = diffLines(normalizeExport(from.body), normalizeExport(to.body), {
|
|
2813
3929
|
fromLabel: from.label ?? from.id,
|
|
2814
3930
|
toLabel: to.label ?? to.id
|
|
2815
3931
|
});
|
|
2816
|
-
return
|
|
3932
|
+
return json12({
|
|
2817
3933
|
from: { id: from.id, label: from.label, ts: from.ts, device: from.device },
|
|
2818
3934
|
to: { id: to.id, label: to.label, ts: to.ts, device: to.device },
|
|
2819
3935
|
summary: diff.summary,
|
|
@@ -2827,16 +3943,16 @@ async function featureRoutes(req, url) {
|
|
|
2827
3943
|
...b?.script ? splitCommands(b.script) : []
|
|
2828
3944
|
];
|
|
2829
3945
|
if (commands.length === 0)
|
|
2830
|
-
return
|
|
3946
|
+
return json12({ error: "no commands provided" }, 400);
|
|
2831
3947
|
const plan = buildChangePlan(commands);
|
|
2832
|
-
return
|
|
3948
|
+
return json12({ plan, text: renderPlan(plan) });
|
|
2833
3949
|
}
|
|
2834
3950
|
if (p === "/api/s3" && req.method === "GET") {
|
|
2835
|
-
return
|
|
3951
|
+
return json12({ configured: isS3Configured(), target: isS3Configured() ? s3Target() : null });
|
|
2836
3952
|
}
|
|
2837
3953
|
if (p === "/api/s3/list" && req.method === "GET") {
|
|
2838
3954
|
if (!isS3Configured())
|
|
2839
|
-
return
|
|
3955
|
+
return json12({ configured: false, objects: [] });
|
|
2840
3956
|
const prefix = url.searchParams.get("prefix") ?? "";
|
|
2841
3957
|
try {
|
|
2842
3958
|
const res = await getS3Client().list({ prefix: prefix || undefined, maxKeys: 1000 });
|
|
@@ -2845,42 +3961,42 @@ async function featureRoutes(req, url) {
|
|
|
2845
3961
|
size: o.size ?? 0,
|
|
2846
3962
|
lastModified: o.lastModified ?? null
|
|
2847
3963
|
}));
|
|
2848
|
-
return
|
|
3964
|
+
return json12({ configured: true, target: s3Target(), objects, truncated: !!res.isTruncated });
|
|
2849
3965
|
} catch (e) {
|
|
2850
|
-
return
|
|
3966
|
+
return json12({ error: clientError(e) }, 502);
|
|
2851
3967
|
}
|
|
2852
3968
|
}
|
|
2853
3969
|
if (p === "/api/s3/presign" && req.method === "GET") {
|
|
2854
3970
|
if (!isS3Configured())
|
|
2855
|
-
return
|
|
3971
|
+
return json12({ error: "S3 not configured" }, 400);
|
|
2856
3972
|
const key = url.searchParams.get("key");
|
|
2857
3973
|
if (!key)
|
|
2858
|
-
return
|
|
3974
|
+
return json12({ error: "key required" }, 400);
|
|
2859
3975
|
try {
|
|
2860
3976
|
const link = getS3Client().presign(key, { expiresIn: presignExpiresIn(), method: "GET" });
|
|
2861
|
-
return
|
|
3977
|
+
return json12({ url: link });
|
|
2862
3978
|
} catch (e) {
|
|
2863
|
-
return
|
|
3979
|
+
return json12({ error: clientError(e) }, 502);
|
|
2864
3980
|
}
|
|
2865
3981
|
}
|
|
2866
3982
|
if (p === "/api/s3/delete" && req.method === "POST") {
|
|
2867
3983
|
if (!isS3Configured())
|
|
2868
|
-
return
|
|
3984
|
+
return json12({ error: "S3 not configured" }, 400);
|
|
2869
3985
|
const b = await readJson(req);
|
|
2870
3986
|
if (!b?.key)
|
|
2871
|
-
return
|
|
3987
|
+
return json12({ error: "key required" }, 400);
|
|
2872
3988
|
try {
|
|
2873
3989
|
const client = getS3Client();
|
|
2874
3990
|
if (!await client.exists(b.key))
|
|
2875
|
-
return
|
|
3991
|
+
return json12({ error: "not found" }, 404);
|
|
2876
3992
|
await client.delete(b.key);
|
|
2877
|
-
return
|
|
3993
|
+
return json12({ ok: true, key: b.key });
|
|
2878
3994
|
} catch (e) {
|
|
2879
|
-
return
|
|
3995
|
+
return json12({ error: clientError(e) }, 502);
|
|
2880
3996
|
}
|
|
2881
3997
|
}
|
|
2882
3998
|
if (p === "/api/backups" && req.method === "GET") {
|
|
2883
|
-
return
|
|
3999
|
+
return json12({
|
|
2884
4000
|
dir: backupDir(),
|
|
2885
4001
|
devices: Object.keys(getConfig().devices),
|
|
2886
4002
|
backups: listBackups()
|
|
@@ -2890,14 +4006,14 @@ async function featureRoutes(req, url) {
|
|
|
2890
4006
|
const b = await readJson(req);
|
|
2891
4007
|
const raw = b?.dir?.trim();
|
|
2892
4008
|
if (!raw)
|
|
2893
|
-
return
|
|
4009
|
+
return json12({ error: "dir required" }, 400);
|
|
2894
4010
|
const dir = raw === "~" || raw.startsWith("~/") ? join4(homedir3(), raw.slice(1)) : raw;
|
|
2895
4011
|
const next = { ...getConfig(), backupDir: dir };
|
|
2896
4012
|
setConfig(next);
|
|
2897
4013
|
try {
|
|
2898
4014
|
atomicWrite(getConfigSource().path, serializeConfig(next));
|
|
2899
4015
|
} catch (e) {
|
|
2900
|
-
return
|
|
4016
|
+
return json12({
|
|
2901
4017
|
ok: true,
|
|
2902
4018
|
dir: backupDir(),
|
|
2903
4019
|
persisted: false,
|
|
@@ -2905,16 +4021,16 @@ async function featureRoutes(req, url) {
|
|
|
2905
4021
|
});
|
|
2906
4022
|
}
|
|
2907
4023
|
recordVersion(getConfig(), "auto", Date.now(), "backup path changed");
|
|
2908
|
-
return
|
|
4024
|
+
return json12({ ok: true, dir: backupDir(), persisted: true });
|
|
2909
4025
|
}
|
|
2910
4026
|
if (p === "/api/backups/get" && req.method === "GET") {
|
|
2911
4027
|
const name = url.searchParams.get("name");
|
|
2912
4028
|
if (!name)
|
|
2913
|
-
return
|
|
4029
|
+
return json12({ error: "name required" }, 400);
|
|
2914
4030
|
try {
|
|
2915
|
-
return
|
|
4031
|
+
return json12({ name, content: readBackup(name) });
|
|
2916
4032
|
} catch {
|
|
2917
|
-
return
|
|
4033
|
+
return json12({ error: "not found" }, 404);
|
|
2918
4034
|
}
|
|
2919
4035
|
}
|
|
2920
4036
|
if (p === "/api/backups/raw" && req.method === "GET") {
|
|
@@ -2935,41 +4051,41 @@ async function featureRoutes(req, url) {
|
|
|
2935
4051
|
if (p === "/api/backups/upload" && req.method === "POST") {
|
|
2936
4052
|
const b = await readJson(req);
|
|
2937
4053
|
if (!b?.name || typeof b.content !== "string") {
|
|
2938
|
-
return
|
|
4054
|
+
return json12({ error: "name and content are required" }, 400);
|
|
2939
4055
|
}
|
|
2940
4056
|
try {
|
|
2941
4057
|
const safe = b.name.endsWith(".rsc") ? b.name : `${b.name}.rsc`;
|
|
2942
|
-
return
|
|
4058
|
+
return json12({ ok: true, name: writeBackup(safe, b.content) });
|
|
2943
4059
|
} catch (e) {
|
|
2944
|
-
return
|
|
4060
|
+
return json12({ error: clientError(e) }, 400);
|
|
2945
4061
|
}
|
|
2946
4062
|
}
|
|
2947
4063
|
if (p === "/api/backups/rename" && req.method === "POST") {
|
|
2948
4064
|
const b = await readJson(req);
|
|
2949
4065
|
if (!b?.name || !b?.new_name)
|
|
2950
|
-
return
|
|
4066
|
+
return json12({ error: "name and new_name are required" }, 400);
|
|
2951
4067
|
try {
|
|
2952
|
-
return
|
|
4068
|
+
return json12({ ok: true, name: renameBackup(b.name, b.new_name) });
|
|
2953
4069
|
} catch (e) {
|
|
2954
|
-
return
|
|
4070
|
+
return json12({ error: clientError(e) }, 400);
|
|
2955
4071
|
}
|
|
2956
4072
|
}
|
|
2957
4073
|
if (p === "/api/backups/delete" && req.method === "POST") {
|
|
2958
4074
|
const b = await readJson(req);
|
|
2959
4075
|
if (!b?.name)
|
|
2960
|
-
return
|
|
4076
|
+
return json12({ error: "name required" }, 400);
|
|
2961
4077
|
try {
|
|
2962
|
-
return deleteBackup(b.name) ?
|
|
4078
|
+
return deleteBackup(b.name) ? json12({ ok: true }) : json12({ error: "not found" }, 404);
|
|
2963
4079
|
} catch (e) {
|
|
2964
|
-
return
|
|
4080
|
+
return json12({ error: clientError(e) }, 400);
|
|
2965
4081
|
}
|
|
2966
4082
|
}
|
|
2967
4083
|
if (p === "/api/backups/restore" && req.method === "POST") {
|
|
2968
4084
|
const b = await readJson(req);
|
|
2969
4085
|
if (!b?.name)
|
|
2970
|
-
return
|
|
4086
|
+
return json12({ error: "name required" }, 400);
|
|
2971
4087
|
const device = resolveDeviceName(b.device);
|
|
2972
|
-
return
|
|
4088
|
+
return json12(await restoreLocalBackup(device, b.name, b.confirm === true));
|
|
2973
4089
|
}
|
|
2974
4090
|
if (p === "/api/backups/create" && req.method === "POST") {
|
|
2975
4091
|
const b = await readJson(req);
|
|
@@ -2981,14 +4097,14 @@ async function featureRoutes(req, url) {
|
|
|
2981
4097
|
compact: b?.compact === true,
|
|
2982
4098
|
terse: b?.terse === true
|
|
2983
4099
|
});
|
|
2984
|
-
return r.ok ?
|
|
4100
|
+
return r.ok ? json12(r) : json12({ error: r.error ?? "export failed" }, 502);
|
|
2985
4101
|
}
|
|
2986
4102
|
return null;
|
|
2987
4103
|
}
|
|
2988
4104
|
async function runDashboard(cfg, transportLabel) {
|
|
2989
|
-
const
|
|
4105
|
+
const store3 = await openSqliteStore(cfg.dbPath);
|
|
2990
4106
|
configureRecorder({
|
|
2991
|
-
store,
|
|
4107
|
+
store: store3,
|
|
2992
4108
|
capture: {
|
|
2993
4109
|
captureBody: cfg.captureBody,
|
|
2994
4110
|
maxBodyBytes: cfg.maxBodyBytes,
|
|
@@ -3000,13 +4116,13 @@ async function runDashboard(cfg, transportLabel) {
|
|
|
3000
4116
|
startHealthChecks(30000);
|
|
3001
4117
|
startGeoLookups();
|
|
3002
4118
|
try {
|
|
3003
|
-
usageStore = await openUsageStore(join4(
|
|
4119
|
+
usageStore = await openUsageStore(join4(dirname7(cfg.dbPath), "usage.db"));
|
|
3004
4120
|
startUsageSampler(usageStore);
|
|
3005
4121
|
} catch (e) {
|
|
3006
4122
|
logger.warn(`[${SERVER_TAG3}] usage history disabled: ${String(e)}`);
|
|
3007
4123
|
}
|
|
3008
4124
|
try {
|
|
3009
|
-
capsmanStore = await openCapsmanStore(join4(
|
|
4125
|
+
capsmanStore = await openCapsmanStore(join4(dirname7(cfg.dbPath), "capsman.db"));
|
|
3010
4126
|
startCapsmanSampler(capsmanStore);
|
|
3011
4127
|
} catch (e) {
|
|
3012
4128
|
logger.warn(`[${SERVER_TAG3}] capsman trends disabled: ${String(e)}`);
|
|
@@ -3080,12 +4196,39 @@ async function runDashboard(cfg, transportLabel) {
|
|
|
3080
4196
|
const driftResp = await driftRoutes(req, url);
|
|
3081
4197
|
if (driftResp)
|
|
3082
4198
|
return driftResp;
|
|
4199
|
+
const txnResp = await txnRoutes(req, url);
|
|
4200
|
+
if (txnResp)
|
|
4201
|
+
return txnResp;
|
|
4202
|
+
const flowResp = await flowRoutes(req, url);
|
|
4203
|
+
if (flowResp)
|
|
4204
|
+
return flowResp;
|
|
4205
|
+
const rolloutResp = await rolloutRoutes(req, url);
|
|
4206
|
+
if (rolloutResp)
|
|
4207
|
+
return rolloutResp;
|
|
4208
|
+
const policyResp = await policyRoutes(req, url);
|
|
4209
|
+
if (policyResp)
|
|
4210
|
+
return policyResp;
|
|
4211
|
+
const simResp = await simRoutes(req, url);
|
|
4212
|
+
if (simResp)
|
|
4213
|
+
return simResp;
|
|
4214
|
+
const scheduleResp = await scheduleRoutes(req, url);
|
|
4215
|
+
if (scheduleResp)
|
|
4216
|
+
return scheduleResp;
|
|
4217
|
+
const explainResp = await explainRoutes(req, url);
|
|
4218
|
+
if (explainResp)
|
|
4219
|
+
return explainResp;
|
|
4220
|
+
const attackResp = await attackRoutes(req, url);
|
|
4221
|
+
if (attackResp)
|
|
4222
|
+
return attackResp;
|
|
4223
|
+
const alertResp = await alertRoutes(req, url, getEventStore());
|
|
4224
|
+
if (alertResp)
|
|
4225
|
+
return alertResp;
|
|
3083
4226
|
const memoryResp = await memoryRoutes(req, url);
|
|
3084
4227
|
if (memoryResp)
|
|
3085
4228
|
return memoryResp;
|
|
3086
4229
|
const db = getEventStore();
|
|
3087
4230
|
if (!db)
|
|
3088
|
-
return
|
|
4231
|
+
return json12({ error: "recorder not active" }, 503);
|
|
3089
4232
|
if (url.pathname === "/api/events" && req.method === "DELETE") {
|
|
3090
4233
|
let body = {};
|
|
3091
4234
|
try {
|
|
@@ -3093,7 +4236,7 @@ async function runDashboard(cfg, transportLabel) {
|
|
|
3093
4236
|
} catch {}
|
|
3094
4237
|
const ids = Array.isArray(body.ids) ? body.ids.filter((x) => typeof x === "string") : [];
|
|
3095
4238
|
const removed = body.all === true ? db.clear() : db.delete(ids);
|
|
3096
|
-
return
|
|
4239
|
+
return json12({ removed, total: db.total() });
|
|
3097
4240
|
}
|
|
3098
4241
|
if (url.pathname === "/api/ssh-pool") {
|
|
3099
4242
|
const cfg2 = getConfig();
|
|
@@ -3102,7 +4245,7 @@ async function runDashboard(cfg, transportLabel) {
|
|
|
3102
4245
|
const totalInflight = ps.reduce((s, p) => s + p.inflight, 0);
|
|
3103
4246
|
const totalIdle = ps.filter((p) => p.idle).length;
|
|
3104
4247
|
const totalBusy = ps.filter((p) => p.inflight > 0).length;
|
|
3105
|
-
return
|
|
4248
|
+
return json12({
|
|
3106
4249
|
enabled,
|
|
3107
4250
|
config: {
|
|
3108
4251
|
keepAlive: cfg2.ssh.keepAlive,
|
|
@@ -3131,16 +4274,39 @@ async function runDashboard(cfg, transportLabel) {
|
|
|
3131
4274
|
});
|
|
3132
4275
|
}
|
|
3133
4276
|
if (url.pathname === "/api/devices") {
|
|
3134
|
-
return
|
|
4277
|
+
return json12(devicesPayload(db));
|
|
4278
|
+
}
|
|
4279
|
+
if (url.pathname === "/api/capabilities") {
|
|
4280
|
+
const devices = Object.keys(getConfig().devices).map((name) => ({
|
|
4281
|
+
device: name,
|
|
4282
|
+
capabilities: serializeCapabilities(peekCapabilities(name))
|
|
4283
|
+
}));
|
|
4284
|
+
return json12({ devices });
|
|
4285
|
+
}
|
|
4286
|
+
if (url.pathname === "/api/capabilities/refresh" && req.method === "POST") {
|
|
4287
|
+
const b = await readJson(req);
|
|
4288
|
+
if (typeof b?.device !== "string")
|
|
4289
|
+
return json12({ error: "device (string) is required" }, 400);
|
|
4290
|
+
if (!(b.device in getConfig().devices)) {
|
|
4291
|
+
return json12({ error: `unknown device: ${b.device}` }, 404);
|
|
4292
|
+
}
|
|
4293
|
+
invalidateCapabilities(b.device);
|
|
4294
|
+
try {
|
|
4295
|
+
const caps = await getCapabilities(b.device);
|
|
4296
|
+
return json12({ device: b.device, capabilities: serializeCapabilities(caps) });
|
|
4297
|
+
} catch (e) {
|
|
4298
|
+
logger.error(`Capability probe failed for '${b.device}': ${logError(e)}`);
|
|
4299
|
+
return json12({ error: clientError(e) }, 502);
|
|
4300
|
+
}
|
|
3135
4301
|
}
|
|
3136
4302
|
if (url.pathname === "/api/devices/toggle" && req.method === "POST") {
|
|
3137
4303
|
const b = await readJson(req);
|
|
3138
4304
|
if (typeof b?.device !== "string" || typeof b?.disabled !== "boolean") {
|
|
3139
|
-
return
|
|
4305
|
+
return json12({ error: "device (string) and disabled (boolean) are required" }, 400);
|
|
3140
4306
|
}
|
|
3141
4307
|
const cfg2 = getConfig();
|
|
3142
4308
|
if (!(b.device in cfg2.devices))
|
|
3143
|
-
return
|
|
4309
|
+
return json12({ error: `unknown device: ${b.device}` }, 404);
|
|
3144
4310
|
const dc = cfg2.devices[b.device];
|
|
3145
4311
|
const next = {
|
|
3146
4312
|
...cfg2,
|
|
@@ -3161,7 +4327,7 @@ async function runDashboard(cfg, transportLabel) {
|
|
|
3161
4327
|
if (persisted) {
|
|
3162
4328
|
recordVersion(getConfig(), "auto", Date.now(), `device ${b.disabled ? "disabled" : "enabled"}: ${b.device}`);
|
|
3163
4329
|
}
|
|
3164
|
-
return
|
|
4330
|
+
return json12({
|
|
3165
4331
|
ok: true,
|
|
3166
4332
|
persisted,
|
|
3167
4333
|
requiresReconnect: true,
|
|
@@ -3174,17 +4340,17 @@ async function runDashboard(cfg, transportLabel) {
|
|
|
3174
4340
|
const name = typeof b?.device === "string" ? b.device : "";
|
|
3175
4341
|
const cfg2 = getConfig();
|
|
3176
4342
|
if (!(name in cfg2.devices))
|
|
3177
|
-
return
|
|
4343
|
+
return json12({ error: `unknown device: ${name}` }, 404);
|
|
3178
4344
|
if (url.pathname === "/api/devices/reconnect")
|
|
3179
4345
|
closeDevice(name);
|
|
3180
4346
|
const status = await probeDevice(name, cfg2.devices[name]);
|
|
3181
|
-
return
|
|
4347
|
+
return json12({ ok: true, status, ...devicesPayload(db) });
|
|
3182
4348
|
}
|
|
3183
4349
|
if (url.pathname === "/api/topology") {
|
|
3184
|
-
return
|
|
4350
|
+
return json12(topologyPayload());
|
|
3185
4351
|
}
|
|
3186
4352
|
if (url.pathname === "/api/config") {
|
|
3187
|
-
return
|
|
4353
|
+
return json12(configPayload());
|
|
3188
4354
|
}
|
|
3189
4355
|
if (url.pathname === "/" || url.pathname === "/index.html") {
|
|
3190
4356
|
return new Response(dashboardHtml(), {
|
|
@@ -3193,9 +4359,9 @@ async function runDashboard(cfg, transportLabel) {
|
|
|
3193
4359
|
}
|
|
3194
4360
|
if (url.pathname === "/api/releases/latest") {
|
|
3195
4361
|
try {
|
|
3196
|
-
return
|
|
4362
|
+
return json12(await fetchLatestRelease());
|
|
3197
4363
|
} catch (e) {
|
|
3198
|
-
return
|
|
4364
|
+
return json12({ error: clientError(e, "fetch failed") }, 502);
|
|
3199
4365
|
}
|
|
3200
4366
|
}
|
|
3201
4367
|
if (url.pathname === "/api/catalog" && req.method === "GET") {
|
|
@@ -3214,7 +4380,7 @@ async function runDashboard(cfg, transportLabel) {
|
|
|
3214
4380
|
}));
|
|
3215
4381
|
const prompts = listPrompts();
|
|
3216
4382
|
const groups = [...new Set(modules.map((m) => m.group))].sort();
|
|
3217
|
-
return
|
|
4383
|
+
return json12({
|
|
3218
4384
|
modules,
|
|
3219
4385
|
prompts,
|
|
3220
4386
|
groups,
|
|
@@ -3229,25 +4395,25 @@ async function runDashboard(cfg, transportLabel) {
|
|
|
3229
4395
|
}
|
|
3230
4396
|
if (url.pathname === "/api/releases" && req.method === "GET") {
|
|
3231
4397
|
try {
|
|
3232
|
-
return
|
|
4398
|
+
return json12(await fetchAllReleases());
|
|
3233
4399
|
} catch (e) {
|
|
3234
|
-
return
|
|
4400
|
+
return json12({ error: clientError(e, "fetch failed") }, 502);
|
|
3235
4401
|
}
|
|
3236
4402
|
}
|
|
3237
4403
|
if (url.pathname === "/api/upgrade" && req.method === "POST") {
|
|
3238
4404
|
const body = await readJson(req);
|
|
3239
4405
|
const version = String(body?.version ?? "latest");
|
|
3240
4406
|
if (version !== "latest" && !/^\d+\.\d+\.\d+$/.test(version)) {
|
|
3241
|
-
return
|
|
4407
|
+
return json12({ ok: false, error: `Invalid version "${version}" (use "latest" or x.y.z).` }, 400);
|
|
3242
4408
|
}
|
|
3243
4409
|
const spec = `@usex/mikrotik-mcp@${version}`;
|
|
3244
4410
|
logger.warn(`Dashboard requested upgrade: bun i -g ${spec}`);
|
|
3245
4411
|
const { ok, log } = await runUpgrade(spec);
|
|
3246
4412
|
if (!ok)
|
|
3247
|
-
return
|
|
4413
|
+
return json12({ ok: false, version, log }, 500);
|
|
3248
4414
|
const willRestart = body?.restart !== false;
|
|
3249
4415
|
const relaunched = willRestart ? restartProcess() : false;
|
|
3250
|
-
return
|
|
4416
|
+
return json12({
|
|
3251
4417
|
ok: true,
|
|
3252
4418
|
version,
|
|
3253
4419
|
log,
|
|
@@ -3257,7 +4423,7 @@ async function runDashboard(cfg, transportLabel) {
|
|
|
3257
4423
|
}
|
|
3258
4424
|
if (url.pathname === "/api/meta") {
|
|
3259
4425
|
const f = facets(db);
|
|
3260
|
-
return
|
|
4426
|
+
return json12({
|
|
3261
4427
|
...f,
|
|
3262
4428
|
version: VERSION,
|
|
3263
4429
|
risks: ["READ", "WRITE", "WRITE_IDEMPOTENT", "DESTRUCTIVE", "DANGEROUS"],
|
|
@@ -3268,19 +4434,19 @@ async function runDashboard(cfg, transportLabel) {
|
|
|
3268
4434
|
}
|
|
3269
4435
|
if (url.pathname === "/api/events") {
|
|
3270
4436
|
const filter = filterFromQuery(url);
|
|
3271
|
-
return
|
|
4437
|
+
return json12({ events: db.query(filter), total: db.total() });
|
|
3272
4438
|
}
|
|
3273
4439
|
const eventMatch = url.pathname.match(/^\/api\/event\/(.+)$/);
|
|
3274
4440
|
if (eventMatch) {
|
|
3275
4441
|
const e = db.get(decodeURIComponent(eventMatch[1]));
|
|
3276
|
-
return e ?
|
|
4442
|
+
return e ? json12(e) : json12({ error: "not found" }, 404);
|
|
3277
4443
|
}
|
|
3278
4444
|
if (url.pathname === "/api/stats") {
|
|
3279
4445
|
const now = Date.now();
|
|
3280
|
-
const
|
|
4446
|
+
const windowMs2 = Number(url.searchParams.get("window") ?? 3600000);
|
|
3281
4447
|
const buckets = Number(url.searchParams.get("buckets") ?? 60);
|
|
3282
|
-
const events = db.query({ since: now -
|
|
3283
|
-
return
|
|
4448
|
+
const events = db.query({ since: now - windowMs2, limit: 5000 });
|
|
4449
|
+
return json12(computeStats(events, { now, windowMs: windowMs2, buckets }));
|
|
3284
4450
|
}
|
|
3285
4451
|
return new Response("Not Found", { status: 404 });
|
|
3286
4452
|
}
|
|
@@ -3299,7 +4465,7 @@ async function runDashboard(cfg, transportLabel) {
|
|
|
3299
4465
|
return await dashboardRoute(req, url, srv, configAdmin, transportLabel);
|
|
3300
4466
|
} catch (e) {
|
|
3301
4467
|
logger.error(`Dashboard request failed (${url.pathname}): ${logError(e)}`);
|
|
3302
|
-
return
|
|
4468
|
+
return json12({ error: clientError(e) }, 502);
|
|
3303
4469
|
}
|
|
3304
4470
|
},
|
|
3305
4471
|
websocket: {
|
|
@@ -3311,11 +4477,19 @@ async function runDashboard(cfg, transportLabel) {
|
|
|
3311
4477
|
} catch {}
|
|
3312
4478
|
});
|
|
3313
4479
|
} else {
|
|
3314
|
-
|
|
4480
|
+
const send = (payload2) => {
|
|
3315
4481
|
try {
|
|
3316
|
-
ws.send(JSON.stringify(
|
|
4482
|
+
ws.send(JSON.stringify(payload2));
|
|
3317
4483
|
} catch {}
|
|
3318
|
-
}
|
|
4484
|
+
};
|
|
4485
|
+
const unsubEvents = subscribe((e) => send({ type: "event", event: e }));
|
|
4486
|
+
const unsubTxn = subscribeTxn((update) => send({ type: "txn", update }));
|
|
4487
|
+
const unsubRollout = subscribeRollout((update) => send({ type: "rollout", update }));
|
|
4488
|
+
ws.data.unsub = () => {
|
|
4489
|
+
unsubEvents();
|
|
4490
|
+
unsubTxn();
|
|
4491
|
+
unsubRollout();
|
|
4492
|
+
};
|
|
3319
4493
|
}
|
|
3320
4494
|
ws.send(JSON.stringify({ type: "hello", transport: transportLabel }));
|
|
3321
4495
|
},
|
|
@@ -3336,14 +4510,14 @@ async function runDashboard(cfg, transportLabel) {
|
|
|
3336
4510
|
}
|
|
3337
4511
|
return {
|
|
3338
4512
|
server,
|
|
3339
|
-
store,
|
|
4513
|
+
store: store3,
|
|
3340
4514
|
stop() {
|
|
3341
4515
|
stopHealthChecks();
|
|
3342
4516
|
stopGeoLookups();
|
|
3343
4517
|
stopUsageSampler();
|
|
3344
4518
|
stopCapsmanSampler();
|
|
3345
4519
|
server.stop(true);
|
|
3346
|
-
|
|
4520
|
+
store3.close();
|
|
3347
4521
|
usageStore?.close();
|
|
3348
4522
|
usageStore = null;
|
|
3349
4523
|
capsmanStore?.close();
|
|
@@ -3660,6 +4834,12 @@ async function runStdio() {
|
|
|
3660
4834
|
const transport = new StdioServerTransport;
|
|
3661
4835
|
await server.connect(transport);
|
|
3662
4836
|
logger.info(`MCP server ready on stdio \u2014 ${toolCount} tools, ${promptCount} prompts, ${uiViewCount} app views${readOnly ? " [READ-ONLY]" : ""}`);
|
|
4837
|
+
setMcpAlertSender((n) => {
|
|
4838
|
+
server.server.sendLoggingMessage({
|
|
4839
|
+
level: n.severity === "critical" || n.severity === "high" ? "error" : "info",
|
|
4840
|
+
data: `[alert:${n.kind}] ${n.title}${n.device ? ` \xB7 ${n.device}` : ""} ` + `(severity ${n.severity}, rule ${n.ruleId})${n.body ? ` \u2014 ${n.body}` : ""}`
|
|
4841
|
+
}).catch(() => {});
|
|
4842
|
+
});
|
|
3663
4843
|
if (!getConfig().disableUpdateCheck) {
|
|
3664
4844
|
whisperUpdate(server).catch(() => {});
|
|
3665
4845
|
}
|
|
@@ -3730,6 +4910,100 @@ async function printBanner() {
|
|
|
3730
4910
|
}
|
|
3731
4911
|
}
|
|
3732
4912
|
|
|
4913
|
+
// src/alerts/sampler.ts
|
|
4914
|
+
var DEFAULT_ALERT_SAMPLE_MS = 30000;
|
|
4915
|
+
var timer4 = null;
|
|
4916
|
+
var snapshotStorePromise = null;
|
|
4917
|
+
function getSnapshotStore() {
|
|
4918
|
+
snapshotStorePromise ??= openSnapshotStore(DEFAULT_SNAPSHOT_DB);
|
|
4919
|
+
return snapshotStorePromise;
|
|
4920
|
+
}
|
|
4921
|
+
function computeMetricSample(windowMs2, now = Date.now()) {
|
|
4922
|
+
const store3 = getEventStore();
|
|
4923
|
+
if (!store3)
|
|
4924
|
+
return { calls: 0, errors: 0, avgDurationMs: 0, p95DurationMs: 0 };
|
|
4925
|
+
const events = store3.query({ since: now - windowMs2, limit: 1e4 });
|
|
4926
|
+
const calls = events.length;
|
|
4927
|
+
if (calls === 0)
|
|
4928
|
+
return { calls: 0, errors: 0, avgDurationMs: 0, p95DurationMs: 0 };
|
|
4929
|
+
const durations = events.map((e) => e.durationMs);
|
|
4930
|
+
return {
|
|
4931
|
+
calls,
|
|
4932
|
+
errors: events.reduce((n, e) => n + (e.isError ? 1 : 0), 0),
|
|
4933
|
+
avgDurationMs: durations.reduce((a, b) => a + b, 0) / calls,
|
|
4934
|
+
p95DurationMs: percentile(durations, 95)
|
|
4935
|
+
};
|
|
4936
|
+
}
|
|
4937
|
+
async function lastSeen(t) {
|
|
4938
|
+
switch (t.absence) {
|
|
4939
|
+
case "tool_call": {
|
|
4940
|
+
const store3 = getEventStore();
|
|
4941
|
+
if (!store3)
|
|
4942
|
+
return;
|
|
4943
|
+
const rows = store3.query({
|
|
4944
|
+
limit: 1,
|
|
4945
|
+
tool: t.tool?.length === 1 ? t.tool[0] : undefined,
|
|
4946
|
+
device: t.device?.length === 1 ? t.device[0] : undefined
|
|
4947
|
+
});
|
|
4948
|
+
return rows[0]?.ts;
|
|
4949
|
+
}
|
|
4950
|
+
case "snapshot": {
|
|
4951
|
+
const devices = t.device?.length ? t.device : Object.keys(getConfig().devices);
|
|
4952
|
+
let newest;
|
|
4953
|
+
try {
|
|
4954
|
+
const store3 = await getSnapshotStore();
|
|
4955
|
+
for (const d of devices) {
|
|
4956
|
+
const at = store3.latest(d)?.ts;
|
|
4957
|
+
if (at !== undefined && (newest === undefined || at > newest))
|
|
4958
|
+
newest = at;
|
|
4959
|
+
}
|
|
4960
|
+
} catch (e) {
|
|
4961
|
+
logger.debug(`[alerts] snapshot lookup failed: ${e instanceof Error ? e.message : e}`);
|
|
4962
|
+
return Date.now();
|
|
4963
|
+
}
|
|
4964
|
+
return newest;
|
|
4965
|
+
}
|
|
4966
|
+
case "device_seen": {
|
|
4967
|
+
const devices = t.device?.length ? t.device : Object.keys(getConfig().devices);
|
|
4968
|
+
let newest;
|
|
4969
|
+
for (const d of devices) {
|
|
4970
|
+
const st = getDeviceStatus(d);
|
|
4971
|
+
if (st.reachable !== true || st.checkedAt == null)
|
|
4972
|
+
continue;
|
|
4973
|
+
if (newest === undefined || st.checkedAt > newest)
|
|
4974
|
+
newest = st.checkedAt;
|
|
4975
|
+
}
|
|
4976
|
+
return newest;
|
|
4977
|
+
}
|
|
4978
|
+
}
|
|
4979
|
+
}
|
|
4980
|
+
async function sampleAlertsOnce(now = Date.now()) {
|
|
4981
|
+
const engine = getAlertEngine();
|
|
4982
|
+
if (!engine)
|
|
4983
|
+
return;
|
|
4984
|
+
if (engine.hasMetricRules()) {
|
|
4985
|
+
engine.sample((windowMs2) => computeMetricSample(windowMs2, now));
|
|
4986
|
+
}
|
|
4987
|
+
if (engine.hasAbsenceRules()) {
|
|
4988
|
+
await engine.sampleAbsence(lastSeen, now);
|
|
4989
|
+
}
|
|
4990
|
+
}
|
|
4991
|
+
function startAlertSampler(intervalMs = DEFAULT_ALERT_SAMPLE_MS) {
|
|
4992
|
+
stopAlertSampler();
|
|
4993
|
+
timer4 = setInterval(() => {
|
|
4994
|
+
sampleAlertsOnce().catch((e) => {
|
|
4995
|
+
logger.debug(`[alerts] sampler tick failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
4996
|
+
});
|
|
4997
|
+
}, intervalMs);
|
|
4998
|
+
timer4.unref?.();
|
|
4999
|
+
logger.debug(`[alerts] metric sampler started (${intervalMs}ms)`);
|
|
5000
|
+
}
|
|
5001
|
+
function stopAlertSampler() {
|
|
5002
|
+
if (timer4)
|
|
5003
|
+
clearInterval(timer4);
|
|
5004
|
+
timer4 = null;
|
|
5005
|
+
}
|
|
5006
|
+
|
|
3733
5007
|
// src/cli.ts
|
|
3734
5008
|
var HELP = `${SERVER_NAME} v${VERSION} \u2014 MikroTik RouterOS MCP server
|
|
3735
5009
|
|
|
@@ -3882,6 +5156,29 @@ async function main() {
|
|
|
3882
5156
|
warnIfPlaintextPasswordInContainer(Object.values(cfg.devices).some((d) => !!d.password));
|
|
3883
5157
|
const deviceNames = Object.keys(cfg.devices);
|
|
3884
5158
|
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"})`);
|
|
5159
|
+
if (cfg.alerts?.enabled !== false && cfg.alerts) {
|
|
5160
|
+
try {
|
|
5161
|
+
const rules = parseRules(cfg.alerts.rules);
|
|
5162
|
+
setAlertEngine(new AlertEngine({
|
|
5163
|
+
rules,
|
|
5164
|
+
channels: cfg.alerts.channels,
|
|
5165
|
+
historyDb: cfg.dashboard.dbPath,
|
|
5166
|
+
maxHistory: cfg.alerts.maxHistory
|
|
5167
|
+
}));
|
|
5168
|
+
startAlertSampler();
|
|
5169
|
+
logger.info(`Alerting enabled: ${rules.length} rule(s)`);
|
|
5170
|
+
} catch (e) {
|
|
5171
|
+
logger.error(`Alerting disabled \u2014 invalid rules: ${e instanceof Error ? e.message : String(e)}`);
|
|
5172
|
+
}
|
|
5173
|
+
}
|
|
5174
|
+
startScheduler().catch((e) => {
|
|
5175
|
+
logger.error(`Scheduled audits disabled: ${e instanceof Error ? e.message : String(e)}`);
|
|
5176
|
+
});
|
|
5177
|
+
try {
|
|
5178
|
+
startAttackDetection();
|
|
5179
|
+
} catch (e) {
|
|
5180
|
+
logger.error(`Attack detection disabled: ${e instanceof Error ? e.message : String(e)}`);
|
|
5181
|
+
}
|
|
3885
5182
|
let shuttingDown = false;
|
|
3886
5183
|
const shutdown = (signal) => {
|
|
3887
5184
|
if (shuttingDown)
|
|
@@ -3890,6 +5187,9 @@ async function main() {
|
|
|
3890
5187
|
logger.info(`Received ${signal}, shutting down\u2026`);
|
|
3891
5188
|
closeAll();
|
|
3892
5189
|
closeMemoryStore();
|
|
5190
|
+
stopAlertSampler();
|
|
5191
|
+
stopScheduler();
|
|
5192
|
+
stopAttackDetection();
|
|
3893
5193
|
process.exit(0);
|
|
3894
5194
|
};
|
|
3895
5195
|
process.on("SIGINT", () => shutdown("SIGINT"));
|