@usex/mikrotik-mcp 4.23.0 → 5.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +350 -284
- package/dist/cli.js +1870 -566
- package/dist/index.d.ts +57 -0
- package/dist/index.js +1 -1
- package/dist/shared/{library-2144zr9f.js → cli-2dj736ma.js} +33920 -22075
- package/dist/shared/{cli-p673815b.js → cli-vvy7yfyg.js} +1 -1
- package/dist/shared/{cli-tssmr7sb.js → library-13dhfdnq.js} +29455 -18578
- package/dist/shared/{library-8f4pbp0x.js → library-syt5fmtj.js} +1 -1
- package/dist/ui/aaa.html +1 -1
- package/dist/ui/connected-devices.html +1 -1
- package/dist/ui/dashboard.html +1 -1
- package/dist/ui/firewall-audit.html +1 -1
- package/dist/ui/firewall.html +1 -1
- package/dist/ui/interfaces.html +1 -1
- package/dist/ui/observability.html +70 -62
- package/dist/ui/records.html +1 -1
- package/package.json +12 -11
- package/policies/baseline.yaml +180 -0
- package/prompts/backup-and-document.md +28 -18
- package/prompts/build-tunnel-transactionally.md +74 -0
- package/prompts/fleet-rollout.md +68 -0
- package/prompts/setup-traffic-flow.md +68 -0
- package/schemas/README.md +1 -1
- package/schemas/config.schema.json +367 -4
- package/schemas/tool-catalog.json +3044 -1152
- package/schemas/tools/abort_rollout.json +17 -0
- package/schemas/tools/abort_transaction.json +17 -0
- package/schemas/tools/add_alert_rule.json +45 -0
- package/schemas/tools/add_ipv6_route.json +1 -1
- package/schemas/tools/add_route.json +3 -2
- package/schemas/tools/add_schedule.json +50 -0
- package/schemas/tools/add_script.json +1 -1
- package/schemas/tools/add_traffic_flow_target.json +36 -0
- package/schemas/tools/add_transaction_step.json +21 -0
- package/schemas/tools/analyze_flows.json +25 -0
- package/schemas/tools/begin_transaction.json +109 -0
- package/schemas/tools/block_attacker.json +22 -0
- package/schemas/tools/check_policy_snapshot.json +20 -0
- package/schemas/tools/commit_transaction.json +13 -0
- package/schemas/tools/configure_attack_response.json +12 -0
- package/schemas/tools/diff_explanations.json +24 -0
- package/schemas/tools/explain_device.json +22 -0
- package/schemas/tools/explain_policy_finding.json +17 -0
- package/schemas/tools/explain_rule_reachability.json +18 -0
- package/schemas/tools/explain_section.json +32 -0
- package/schemas/tools/export_policy_report.json +22 -0
- package/schemas/tools/flow_top_talkers.json +31 -0
- package/schemas/tools/get_alert_history.json +26 -0
- package/schemas/tools/get_attack_incident.json +13 -0
- package/schemas/tools/get_audit_timeline.json +24 -0
- package/schemas/tools/get_device_capabilities.json +7 -0
- package/schemas/tools/get_traffic_flow_settings.json +7 -0
- package/schemas/tools/list_alert_rules.json +7 -0
- package/schemas/tools/list_attack_incidents.json +27 -0
- package/schemas/tools/list_attack_responses.json +20 -0
- package/schemas/tools/list_policies.json +17 -0
- package/schemas/tools/list_schedules.json +12 -0
- package/schemas/tools/list_traffic_flow_targets.json +7 -0
- package/schemas/tools/mute_alert_rule.json +18 -0
- package/schemas/tools/plan_rollout.json +95 -0
- package/schemas/tools/refresh_device_capabilities.json +7 -0
- package/schemas/tools/remove_alert_rule.json +13 -0
- package/schemas/tools/remove_schedule.json +13 -0
- package/schemas/tools/remove_traffic_flow_target.json +13 -0
- package/schemas/tools/rollout_status.json +12 -0
- package/schemas/tools/run_policy_check.json +12 -0
- package/schemas/tools/run_schedule_now.json +13 -0
- package/schemas/tools/scan_for_attacks.json +21 -0
- package/schemas/tools/set_traffic_flow_settings.json +28 -0
- package/schemas/tools/simulate_change.json +72 -0
- package/schemas/tools/simulate_packet.json +53 -0
- package/schemas/tools/simulate_suite.json +71 -0
- package/schemas/tools/start_flow_collector.json +14 -0
- package/schemas/tools/start_rollout.json +98 -0
- package/schemas/tools/stop_flow_collector.json +7 -0
- package/schemas/tools/test_alert_channel.json +14 -0
- package/schemas/tools/unblock_attacker.json +20 -0
- package/schemas/tools/update_alert_rule.json +41 -0
- package/schemas/tools/update_route.json +3 -2
- package/schemas/tools/validate_policy_file.json +16 -0
- package/schemas/tools/verify_transaction.json +13 -0
package/dist/cli.js
CHANGED
|
@@ -3,13 +3,17 @@
|
|
|
3
3
|
import {
|
|
4
4
|
AAA_ENTITIES,
|
|
5
5
|
ALWAYS_ON_MODULES,
|
|
6
|
+
AlertEngine,
|
|
7
|
+
AlertRuleSchema,
|
|
6
8
|
Cmd,
|
|
7
9
|
DEFAULT_CONFIG_HISTORY_DIR,
|
|
8
10
|
DEFAULT_SNAPSHOT_DB,
|
|
9
11
|
DEFAULT_TZSP_PORT,
|
|
10
12
|
DeviceConfigSchema,
|
|
13
|
+
JobSchema,
|
|
11
14
|
LOGO_URL,
|
|
12
15
|
MikrotikConfigSchema,
|
|
16
|
+
NARRATIVE_SECTIONS,
|
|
13
17
|
PKG_META,
|
|
14
18
|
PROJECT_ROOT,
|
|
15
19
|
PROMPTS_DIR,
|
|
@@ -25,17 +29,24 @@ import {
|
|
|
25
29
|
addAaaEntity,
|
|
26
30
|
allToolModules,
|
|
27
31
|
allowDevice,
|
|
32
|
+
analyzeDevice,
|
|
28
33
|
analyzeDrift,
|
|
29
34
|
applyWritesSafely,
|
|
35
|
+
armJob,
|
|
36
|
+
attackStore,
|
|
30
37
|
attributeChanges,
|
|
38
|
+
auditAdapter,
|
|
31
39
|
backupDir,
|
|
32
40
|
blockDevice,
|
|
33
41
|
buildChangePlan,
|
|
34
42
|
buildChannelPlanCommands,
|
|
35
43
|
buildFtCommands,
|
|
44
|
+
buildGuards,
|
|
36
45
|
buildHaCommands,
|
|
37
46
|
buildLoadBalanceCommands,
|
|
47
|
+
buildModel,
|
|
38
48
|
buildSteerCommands,
|
|
49
|
+
buildTopology,
|
|
39
50
|
capsmanOverview,
|
|
40
51
|
capture,
|
|
41
52
|
captureSnapshot,
|
|
@@ -45,35 +56,73 @@ import {
|
|
|
45
56
|
closeMemoryStore,
|
|
46
57
|
commandUnsupported,
|
|
47
58
|
configureRecorder,
|
|
59
|
+
conversations,
|
|
48
60
|
createContext,
|
|
49
61
|
createDeviceClient,
|
|
62
|
+
createDeviceExecutor,
|
|
63
|
+
createDeviceExecutor1 as createDeviceExecutor2,
|
|
50
64
|
createLocalBackup,
|
|
65
|
+
currentPolicySet,
|
|
66
|
+
decide,
|
|
51
67
|
deleteBackup,
|
|
68
|
+
deliver,
|
|
69
|
+
describeCron,
|
|
70
|
+
describeDiff,
|
|
52
71
|
describeTransport,
|
|
53
72
|
deviceDirectory,
|
|
54
73
|
deviceLabels,
|
|
55
74
|
devicesView,
|
|
75
|
+
diffFindings,
|
|
56
76
|
diffLines,
|
|
77
|
+
diffNarratives,
|
|
78
|
+
diffTraces,
|
|
79
|
+
dropRollout,
|
|
80
|
+
dropTxn,
|
|
81
|
+
evaluatePolicies,
|
|
82
|
+
eventMet,
|
|
57
83
|
executeMikrotikCommand,
|
|
84
|
+
executePlan,
|
|
85
|
+
explainUnmet,
|
|
58
86
|
fetchAllReleases,
|
|
59
87
|
fetchCapsmanState,
|
|
60
88
|
fetchDevices,
|
|
61
89
|
fetchLatestRelease,
|
|
90
|
+
flowStore,
|
|
91
|
+
forgetJob,
|
|
92
|
+
getAlertEngine,
|
|
93
|
+
getCapabilities,
|
|
62
94
|
getConfig,
|
|
63
95
|
getConfigSource,
|
|
96
|
+
getDeviceHistory,
|
|
97
|
+
getDeviceNeighbors,
|
|
98
|
+
getDeviceStatus,
|
|
64
99
|
getEventStore,
|
|
100
|
+
getFlowCollector,
|
|
65
101
|
getMemoryStore,
|
|
66
102
|
getRadiusIncoming,
|
|
103
|
+
getRollout,
|
|
67
104
|
getS3Client,
|
|
105
|
+
getTxn,
|
|
68
106
|
getUmSettings,
|
|
69
107
|
haGuidance,
|
|
108
|
+
initialState,
|
|
109
|
+
invalidateCapabilities,
|
|
70
110
|
isEmpty,
|
|
111
|
+
isEventTrigger,
|
|
71
112
|
isMacTelnetDevice,
|
|
113
|
+
isMuted,
|
|
114
|
+
isNeverBlock,
|
|
115
|
+
isPlan,
|
|
72
116
|
isPoolEnabled,
|
|
73
117
|
isS3Configured,
|
|
74
118
|
listAaaEntity,
|
|
75
119
|
listBackups,
|
|
76
120
|
listDevices,
|
|
121
|
+
listRollouts,
|
|
122
|
+
listTxns,
|
|
123
|
+
liveExecutor,
|
|
124
|
+
liveRolloutUpdates,
|
|
125
|
+
liveTxnUpdates,
|
|
77
126
|
loadBalancePlan,
|
|
78
127
|
loadConfig,
|
|
79
128
|
loadFileCacheSync,
|
|
@@ -81,51 +130,107 @@ import {
|
|
|
81
130
|
looksLikeError,
|
|
82
131
|
makeDeviceStatic,
|
|
83
132
|
moduleCatalog,
|
|
133
|
+
neverBlockSet,
|
|
134
|
+
nextRun,
|
|
135
|
+
nextRunOf,
|
|
84
136
|
normalizeExport,
|
|
137
|
+
noteDriftResult,
|
|
85
138
|
openSnapshotStore,
|
|
86
|
-
|
|
87
|
-
|
|
139
|
+
parseDuration,
|
|
140
|
+
parseExport,
|
|
88
141
|
parseLeadingNumber,
|
|
89
142
|
parseRecords,
|
|
90
143
|
parseRouterosDate,
|
|
144
|
+
parseRules,
|
|
91
145
|
parseSize,
|
|
92
|
-
|
|
146
|
+
peekCapabilities,
|
|
147
|
+
persistRollout,
|
|
148
|
+
persistTxn,
|
|
149
|
+
policyFromConfig,
|
|
93
150
|
poolStatus,
|
|
94
151
|
presignExpiresIn,
|
|
152
|
+
primeRiskIndex,
|
|
153
|
+
probeDevice,
|
|
154
|
+
protocolMix,
|
|
155
|
+
publishRollout,
|
|
95
156
|
readBackup,
|
|
96
157
|
redact,
|
|
158
|
+
redactChannels,
|
|
97
159
|
registerTools,
|
|
98
160
|
registerUiResources,
|
|
161
|
+
rememberReport,
|
|
99
162
|
removeAaaEntity,
|
|
100
163
|
removeDeviceLease,
|
|
101
164
|
renameBackup,
|
|
165
|
+
renderDiff,
|
|
166
|
+
renderNarrative,
|
|
102
167
|
renderPlan,
|
|
168
|
+
renderReport,
|
|
103
169
|
reopenMemoryStore,
|
|
104
170
|
reportWeakClients,
|
|
171
|
+
requestAbort,
|
|
172
|
+
requestAbort1 as requestAbort2,
|
|
173
|
+
requestHold,
|
|
105
174
|
resetRadiusCounters,
|
|
106
175
|
resolveDeviceName,
|
|
107
176
|
restoreLocalBackup,
|
|
177
|
+
resume,
|
|
178
|
+
revokeBlock,
|
|
108
179
|
riskOf,
|
|
180
|
+
rolloutStore,
|
|
181
|
+
ruleWarnings,
|
|
109
182
|
runCapsmanAudit,
|
|
183
|
+
runJob,
|
|
184
|
+
runRollout,
|
|
185
|
+
runTransaction,
|
|
186
|
+
runnerOptions,
|
|
110
187
|
s3Target,
|
|
111
188
|
sampleAllTraffic,
|
|
112
189
|
sampleDeviceTraffic,
|
|
190
|
+
schedulableTools,
|
|
191
|
+
scheduleStore,
|
|
113
192
|
selectToolModules,
|
|
193
|
+
serializeCapabilities,
|
|
194
|
+
setAlertEngine,
|
|
114
195
|
setConfig,
|
|
115
196
|
setDeviceIp,
|
|
116
197
|
setDeviceLabel,
|
|
117
198
|
setDeviceLimits,
|
|
199
|
+
setMcpAlertSender,
|
|
118
200
|
setRadiusIncoming,
|
|
119
201
|
setUmSettings,
|
|
202
|
+
severityCounts,
|
|
120
203
|
splitCommands,
|
|
204
|
+
startAttackDetection,
|
|
205
|
+
startHealthChecks,
|
|
206
|
+
startScheduler,
|
|
121
207
|
steerAlreadyPresent,
|
|
208
|
+
step,
|
|
209
|
+
stopAttackDetection,
|
|
210
|
+
stopHealthChecks,
|
|
211
|
+
stopScheduler,
|
|
122
212
|
subscribe,
|
|
213
|
+
subscribeRollout,
|
|
214
|
+
subscribeTxn,
|
|
123
215
|
subscriberCount,
|
|
216
|
+
summarize,
|
|
217
|
+
summarize1 as summarize2,
|
|
218
|
+
sweep,
|
|
219
|
+
timeline,
|
|
220
|
+
toRecord,
|
|
221
|
+
toRecord1 as toRecord2,
|
|
124
222
|
toggleAaaEntity,
|
|
223
|
+
topTalkers,
|
|
224
|
+
topologyMermaid,
|
|
225
|
+
tracePacket,
|
|
226
|
+
txnStore,
|
|
227
|
+
unreachableRules,
|
|
125
228
|
updateAaaEntity,
|
|
126
229
|
updateSummaryLine,
|
|
230
|
+
validatePolicyText,
|
|
231
|
+
worstSeverity,
|
|
127
232
|
writeBackup
|
|
128
|
-
} from "./shared/cli-
|
|
233
|
+
} from "./shared/cli-2dj736ma.js";
|
|
129
234
|
|
|
130
235
|
// src/cli.ts
|
|
131
236
|
import { existsSync as existsSync2 } from "fs";
|
|
@@ -133,12 +238,24 @@ import { existsSync as existsSync2 } from "fs";
|
|
|
133
238
|
// src/observability/dashboard.ts
|
|
134
239
|
import { spawn } from "child_process";
|
|
135
240
|
import { readFileSync as readFileSync3 } from "fs";
|
|
136
|
-
import { homedir as
|
|
137
|
-
import { dirname as
|
|
241
|
+
import { homedir as homedir3, networkInterfaces } from "os";
|
|
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
|
}
|
|
@@ -1676,6 +1526,27 @@ function computeStats(events, opts) {
|
|
|
1676
1526
|
};
|
|
1677
1527
|
}
|
|
1678
1528
|
|
|
1529
|
+
// src/observability/http-error.ts
|
|
1530
|
+
import { homedir as homedir2 } from "os";
|
|
1531
|
+
var HOME = homedir2();
|
|
1532
|
+
var MAX_LEN = 300;
|
|
1533
|
+
function sanitizeMessage(raw, home, fallback) {
|
|
1534
|
+
const line = raw.split(`
|
|
1535
|
+
`, 1)[0].trim();
|
|
1536
|
+
const masked = home.length > 1 ? line.split(home).join("~") : line;
|
|
1537
|
+
if (!masked)
|
|
1538
|
+
return fallback;
|
|
1539
|
+
return masked.length > MAX_LEN ? `${masked.slice(0, MAX_LEN)}\u2026` : masked;
|
|
1540
|
+
}
|
|
1541
|
+
function clientError(e, fallback = "unknown error") {
|
|
1542
|
+
return sanitizeMessage(e instanceof Error ? e.message : String(e), HOME, fallback);
|
|
1543
|
+
}
|
|
1544
|
+
function logError(e) {
|
|
1545
|
+
if (e instanceof Error)
|
|
1546
|
+
return e.stack ?? e.message;
|
|
1547
|
+
return String(e);
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1679
1550
|
// src/observability/drift-routes.ts
|
|
1680
1551
|
var storePromise = null;
|
|
1681
1552
|
function getStore() {
|
|
@@ -1801,6 +1672,7 @@ async function driftRoutes(req, url) {
|
|
|
1801
1672
|
toLabel: `${device}@live`
|
|
1802
1673
|
});
|
|
1803
1674
|
const report = analyzeDrift(diff, device, baseline.snapshotId, baseline.setAt);
|
|
1675
|
+
noteDriftResult(report);
|
|
1804
1676
|
try {
|
|
1805
1677
|
const logCmd = `/log print where topics~"system" and time > ([:timestamp] - 7d)`;
|
|
1806
1678
|
const logText = await executeMikrotikCommand(logCmd, ctx);
|
|
@@ -1810,18 +1682,1267 @@ async function driftRoutes(req, url) {
|
|
|
1810
1682
|
} catch {}
|
|
1811
1683
|
return json(report);
|
|
1812
1684
|
} catch (e) {
|
|
1813
|
-
return json({ error:
|
|
1685
|
+
return json({ error: clientError(e) }, 502);
|
|
1814
1686
|
}
|
|
1815
1687
|
}
|
|
1816
1688
|
return null;
|
|
1817
1689
|
}
|
|
1818
1690
|
|
|
1819
|
-
// src/observability/
|
|
1691
|
+
// src/observability/txn-routes.ts
|
|
1820
1692
|
var JSON_HEADERS2 = { "content-type": "application/json; charset=utf-8" };
|
|
1821
1693
|
function json2(body, status = 200) {
|
|
1822
1694
|
return new Response(JSON.stringify(body), { status, headers: JSON_HEADERS2 });
|
|
1823
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
|
+
}
|
|
1824
2033
|
async function bodyJson2(req) {
|
|
2034
|
+
return await req.json().catch(() => ({}));
|
|
2035
|
+
}
|
|
2036
|
+
async function policyRoutes(req, url) {
|
|
2037
|
+
const p = url.pathname;
|
|
2038
|
+
if (!p.startsWith("/api/policies"))
|
|
2039
|
+
return null;
|
|
2040
|
+
if (p === "/api/policies" && req.method === "GET") {
|
|
2041
|
+
const set = currentPolicySet();
|
|
2042
|
+
return json5({
|
|
2043
|
+
files: set.files.map((f) => ({
|
|
2044
|
+
path: f.path,
|
|
2045
|
+
name: f.name,
|
|
2046
|
+
ok: f.ok,
|
|
2047
|
+
issues: f.issues,
|
|
2048
|
+
policies: f.policies
|
|
2049
|
+
})),
|
|
2050
|
+
ruleCount: set.policies.length,
|
|
2051
|
+
duplicateIds: set.duplicateIds,
|
|
2052
|
+
emptyPatterns: set.emptyPatterns,
|
|
2053
|
+
paths: getConfig().policy.paths
|
|
2054
|
+
});
|
|
2055
|
+
}
|
|
2056
|
+
if (p === "/api/policies/results" && req.method === "GET") {
|
|
2057
|
+
const device = url.searchParams.get("device") ?? undefined;
|
|
2058
|
+
const limit = Number(url.searchParams.get("limit") ?? 50);
|
|
2059
|
+
let results;
|
|
2060
|
+
try {
|
|
2061
|
+
results = await policyResults(device, limit);
|
|
2062
|
+
} catch (e) {
|
|
2063
|
+
return json5({ results: [], error: e instanceof Error ? e.message : String(e) });
|
|
2064
|
+
}
|
|
2065
|
+
return json5({ results });
|
|
2066
|
+
}
|
|
2067
|
+
if (p === "/api/policies/validate" && req.method === "POST") {
|
|
2068
|
+
const body = await bodyJson2(req);
|
|
2069
|
+
if (typeof body.content !== "string")
|
|
2070
|
+
return json5({ error: "content is required" }, 400);
|
|
2071
|
+
return json5(validatePolicyText(body.content));
|
|
2072
|
+
}
|
|
2073
|
+
if (p === "/api/policies/run" && req.method === "POST") {
|
|
2074
|
+
const body = await bodyJson2(req);
|
|
2075
|
+
const set = currentPolicySet();
|
|
2076
|
+
if (set.policies.length === 0) {
|
|
2077
|
+
return json5({ error: "no policy rules are loaded", reports: [] });
|
|
2078
|
+
}
|
|
2079
|
+
const cfg = getConfig();
|
|
2080
|
+
const devices = body.devices && body.devices.length > 0 ? body.devices : Object.entries(cfg.devices).filter(([, d]) => !d.disabled).map(([name]) => name);
|
|
2081
|
+
const reports = [];
|
|
2082
|
+
for (const device of devices) {
|
|
2083
|
+
const ctx = createContext(undefined, device);
|
|
2084
|
+
try {
|
|
2085
|
+
const text = await executeMikrotikCommand("/export terse", ctx);
|
|
2086
|
+
if (looksLikeError(text) || text.trim() === "") {
|
|
2087
|
+
reports.push({ device, error: text.trim() || "empty export" });
|
|
2088
|
+
continue;
|
|
2089
|
+
}
|
|
2090
|
+
const report = evaluatePolicies(set.policies, parseExport(text), {
|
|
2091
|
+
device,
|
|
2092
|
+
ts: Date.now()
|
|
2093
|
+
});
|
|
2094
|
+
rememberReport(report);
|
|
2095
|
+
await recordPolicyResult(report);
|
|
2096
|
+
reports.push({
|
|
2097
|
+
device,
|
|
2098
|
+
summary: report.summary,
|
|
2099
|
+
findings: report.findings,
|
|
2100
|
+
markdown: renderReport(report, "markdown")
|
|
2101
|
+
});
|
|
2102
|
+
} catch (e) {
|
|
2103
|
+
reports.push({ device, error: e instanceof Error ? e.message : String(e) });
|
|
2104
|
+
}
|
|
2105
|
+
}
|
|
2106
|
+
return json5({ reports });
|
|
2107
|
+
}
|
|
2108
|
+
return json5({ error: "not found" }, 404);
|
|
2109
|
+
}
|
|
2110
|
+
|
|
2111
|
+
// src/sim/suites.ts
|
|
2112
|
+
import { mkdirSync as mkdirSync7 } from "fs";
|
|
2113
|
+
import { dirname as dirname6 } from "path";
|
|
2114
|
+
var SCHEMA = [
|
|
2115
|
+
`CREATE TABLE IF NOT EXISTS sim_suites (
|
|
2116
|
+
id TEXT PRIMARY KEY,
|
|
2117
|
+
name TEXT NOT NULL,
|
|
2118
|
+
packets TEXT NOT NULL,
|
|
2119
|
+
updated INTEGER NOT NULL
|
|
2120
|
+
)`
|
|
2121
|
+
];
|
|
2122
|
+
|
|
2123
|
+
class SqliteSuiteStore {
|
|
2124
|
+
db;
|
|
2125
|
+
constructor(db) {
|
|
2126
|
+
this.db = db;
|
|
2127
|
+
db.run("PRAGMA journal_mode = WAL");
|
|
2128
|
+
for (const stmt of SCHEMA)
|
|
2129
|
+
db.run(stmt);
|
|
2130
|
+
}
|
|
2131
|
+
list() {
|
|
2132
|
+
const rows = this.db.query("SELECT * FROM sim_suites ORDER BY updated DESC").all();
|
|
2133
|
+
return rows.map((r) => ({
|
|
2134
|
+
id: r.id,
|
|
2135
|
+
name: r.name,
|
|
2136
|
+
packets: JSON.parse(r.packets),
|
|
2137
|
+
updated: r.updated
|
|
2138
|
+
}));
|
|
2139
|
+
}
|
|
2140
|
+
get(id) {
|
|
2141
|
+
const row = this.db.query("SELECT * FROM sim_suites WHERE id = $id").get({ $id: id });
|
|
2142
|
+
return row ? {
|
|
2143
|
+
id: row.id,
|
|
2144
|
+
name: row.name,
|
|
2145
|
+
packets: JSON.parse(row.packets),
|
|
2146
|
+
updated: row.updated
|
|
2147
|
+
} : null;
|
|
2148
|
+
}
|
|
2149
|
+
save(suite) {
|
|
2150
|
+
this.db.query(`INSERT OR REPLACE INTO sim_suites (id, name, packets, updated)
|
|
2151
|
+
VALUES ($id,$name,$packets,$updated)`).run({
|
|
2152
|
+
$id: suite.id,
|
|
2153
|
+
$name: suite.name,
|
|
2154
|
+
$packets: JSON.stringify(suite.packets),
|
|
2155
|
+
$updated: suite.updated
|
|
2156
|
+
});
|
|
2157
|
+
}
|
|
2158
|
+
}
|
|
2159
|
+
var storePromise3 = null;
|
|
2160
|
+
async function store2() {
|
|
2161
|
+
storePromise3 ??= (async () => {
|
|
2162
|
+
try {
|
|
2163
|
+
mkdirSync7(dirname6(DEFAULT_SNAPSHOT_DB), { recursive: true });
|
|
2164
|
+
} catch {}
|
|
2165
|
+
const { Database } = await import("bun:sqlite");
|
|
2166
|
+
return new SqliteSuiteStore(new Database(DEFAULT_SNAPSHOT_DB, { create: true }));
|
|
2167
|
+
})();
|
|
2168
|
+
return storePromise3;
|
|
2169
|
+
}
|
|
2170
|
+
async function listSuites() {
|
|
2171
|
+
try {
|
|
2172
|
+
return (await store2()).list();
|
|
2173
|
+
} catch (e) {
|
|
2174
|
+
logger.error(`sim suite read failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
2175
|
+
return [];
|
|
2176
|
+
}
|
|
2177
|
+
}
|
|
2178
|
+
async function getSuite(id) {
|
|
2179
|
+
try {
|
|
2180
|
+
return (await store2()).get(id);
|
|
2181
|
+
} catch {
|
|
2182
|
+
return null;
|
|
2183
|
+
}
|
|
2184
|
+
}
|
|
2185
|
+
async function saveSuite(input) {
|
|
2186
|
+
const suite = {
|
|
2187
|
+
id: input.id ?? `suite_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
|
2188
|
+
name: input.name,
|
|
2189
|
+
packets: input.packets,
|
|
2190
|
+
updated: Date.now()
|
|
2191
|
+
};
|
|
2192
|
+
try {
|
|
2193
|
+
(await store2()).save(suite);
|
|
2194
|
+
} catch (e) {
|
|
2195
|
+
logger.error(`sim suite write failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
2196
|
+
}
|
|
2197
|
+
return suite;
|
|
2198
|
+
}
|
|
2199
|
+
|
|
2200
|
+
// src/observability/sim-routes.ts
|
|
2201
|
+
var JSON_HEADERS6 = { "content-type": "application/json; charset=utf-8" };
|
|
2202
|
+
function json6(body, status = 200) {
|
|
2203
|
+
return new Response(JSON.stringify(body), { status, headers: JSON_HEADERS6 });
|
|
2204
|
+
}
|
|
2205
|
+
async function bodyJson3(req) {
|
|
2206
|
+
return await req.json().catch(() => ({}));
|
|
2207
|
+
}
|
|
2208
|
+
var storePromise4 = null;
|
|
2209
|
+
function snapshots() {
|
|
2210
|
+
storePromise4 ??= openSnapshotStore(DEFAULT_SNAPSHOT_DB);
|
|
2211
|
+
return storePromise4;
|
|
2212
|
+
}
|
|
2213
|
+
async function configFor(body) {
|
|
2214
|
+
if (body.configText)
|
|
2215
|
+
return { text: body.configText, source: "supplied text" };
|
|
2216
|
+
if (body.snapshotId) {
|
|
2217
|
+
const snapshot = (await snapshots()).get(body.snapshotId);
|
|
2218
|
+
if (!snapshot)
|
|
2219
|
+
return { error: `no snapshot '${body.snapshotId}'` };
|
|
2220
|
+
return { text: snapshot.body, source: `snapshot ${snapshot.id}` };
|
|
2221
|
+
}
|
|
2222
|
+
const device = body.device ?? getConfig().defaultDevice;
|
|
2223
|
+
const ctx = createContext(undefined, device);
|
|
2224
|
+
const text = await executeMikrotikCommand("/export terse", ctx);
|
|
2225
|
+
if (looksLikeError(text) || text.trim() === "") {
|
|
2226
|
+
return { error: `could not read the configuration of '${device}'` };
|
|
2227
|
+
}
|
|
2228
|
+
return { text, source: `live config of ${device}` };
|
|
2229
|
+
}
|
|
2230
|
+
async function simRoutes(req, url) {
|
|
2231
|
+
const p = url.pathname;
|
|
2232
|
+
if (!p.startsWith("/api/sim"))
|
|
2233
|
+
return null;
|
|
2234
|
+
if (p === "/api/sim/suites" && req.method === "GET") {
|
|
2235
|
+
return json6({ suites: await listSuites() });
|
|
2236
|
+
}
|
|
2237
|
+
if (p === "/api/sim/suites" && req.method === "POST") {
|
|
2238
|
+
const body = await bodyJson3(req);
|
|
2239
|
+
if (!body.name || !Array.isArray(body.packets) || body.packets.length === 0) {
|
|
2240
|
+
return json6({ error: "name and a non-empty packets array are required" }, 400);
|
|
2241
|
+
}
|
|
2242
|
+
const saved = await saveSuite({
|
|
2243
|
+
id: body.id,
|
|
2244
|
+
name: body.name,
|
|
2245
|
+
packets: body.packets
|
|
2246
|
+
});
|
|
2247
|
+
return json6({ suite: saved });
|
|
2248
|
+
}
|
|
2249
|
+
if (p === "/api/sim/packet" && req.method === "POST") {
|
|
2250
|
+
const body = await bodyJson3(req);
|
|
2251
|
+
const config = await configFor(body);
|
|
2252
|
+
if ("error" in config)
|
|
2253
|
+
return json6(config, 400);
|
|
2254
|
+
const model = buildModel(config.text);
|
|
2255
|
+
const result = tracePacket({ model, packet: body.packet });
|
|
2256
|
+
return json6({
|
|
2257
|
+
source: config.source,
|
|
2258
|
+
result,
|
|
2259
|
+
coverage: {
|
|
2260
|
+
unmodelled: model.unmodelled,
|
|
2261
|
+
unparsedLines: model.unparsedLines,
|
|
2262
|
+
dynamicRouteSources: model.dynamicRouteSources
|
|
2263
|
+
}
|
|
2264
|
+
});
|
|
2265
|
+
}
|
|
2266
|
+
if (p === "/api/sim/change" && req.method === "POST") {
|
|
2267
|
+
const body = await bodyJson3(req);
|
|
2268
|
+
const config = await configFor(body);
|
|
2269
|
+
if ("error" in config)
|
|
2270
|
+
return json6(config, 400);
|
|
2271
|
+
if (!body.changes || body.changes.trim() === "") {
|
|
2272
|
+
return json6({ error: "changes are required" }, 400);
|
|
2273
|
+
}
|
|
2274
|
+
const before = tracePacket({ model: buildModel(config.text), packet: body.packet });
|
|
2275
|
+
const afterModel = buildModel(`${config.text}
|
|
2276
|
+
${body.changes}
|
|
2277
|
+
`);
|
|
2278
|
+
const after = tracePacket({ model: afterModel, packet: body.packet });
|
|
2279
|
+
return json6({
|
|
2280
|
+
source: config.source,
|
|
2281
|
+
before,
|
|
2282
|
+
after,
|
|
2283
|
+
diff: diffTraces(before, after),
|
|
2284
|
+
coverage: { unmodelled: afterModel.unmodelled, unparsedLines: afterModel.unparsedLines }
|
|
2285
|
+
});
|
|
2286
|
+
}
|
|
2287
|
+
if (p === "/api/sim/reachability" && req.method === "GET") {
|
|
2288
|
+
const config = await configFor({
|
|
2289
|
+
device: url.searchParams.get("device") ?? undefined,
|
|
2290
|
+
snapshotId: url.searchParams.get("snapshot") ?? undefined,
|
|
2291
|
+
packet: {}
|
|
2292
|
+
});
|
|
2293
|
+
if ("error" in config)
|
|
2294
|
+
return json6(config, 400);
|
|
2295
|
+
const model = buildModel(config.text);
|
|
2296
|
+
const dead = unreachableRules(model.filter);
|
|
2297
|
+
return json6({
|
|
2298
|
+
source: config.source,
|
|
2299
|
+
rules: model.filter.map((r) => ({
|
|
2300
|
+
chain: r.chain,
|
|
2301
|
+
index: r.index,
|
|
2302
|
+
action: r.action,
|
|
2303
|
+
line: r.line,
|
|
2304
|
+
raw: r.raw,
|
|
2305
|
+
disabled: r.disabled,
|
|
2306
|
+
unreachable: dead.some((d) => d.rule === r),
|
|
2307
|
+
shadowedBy: dead.find((d) => d.rule === r)?.shadowedBy.index,
|
|
2308
|
+
why: dead.find((d) => d.rule === r)?.why
|
|
2309
|
+
})),
|
|
2310
|
+
unreachableCount: dead.length
|
|
2311
|
+
});
|
|
2312
|
+
}
|
|
2313
|
+
const runMatch = p.startsWith("/api/sim/suite/") && p.endsWith("/run");
|
|
2314
|
+
if (runMatch && req.method === "POST") {
|
|
2315
|
+
const id = decodeURIComponent(p.slice("/api/sim/suite/".length, -"/run".length));
|
|
2316
|
+
const suite = await getSuite(id);
|
|
2317
|
+
if (!suite)
|
|
2318
|
+
return json6({ error: `no suite '${id}'` }, 404);
|
|
2319
|
+
const body = await bodyJson3(req);
|
|
2320
|
+
const config = await configFor({ ...body, packet: {} });
|
|
2321
|
+
if ("error" in config)
|
|
2322
|
+
return json6(config, 400);
|
|
2323
|
+
const model = buildModel(config.text);
|
|
2324
|
+
const results = suite.packets.map((entry) => {
|
|
2325
|
+
const result = tracePacket({ model, packet: entry.packet });
|
|
2326
|
+
return {
|
|
2327
|
+
name: entry.name,
|
|
2328
|
+
expect: entry.expect,
|
|
2329
|
+
verdict: result.verdict,
|
|
2330
|
+
ok: result.verdict === entry.expect,
|
|
2331
|
+
summary: result.summary
|
|
2332
|
+
};
|
|
2333
|
+
});
|
|
2334
|
+
return json6({
|
|
2335
|
+
source: config.source,
|
|
2336
|
+
suite: { id: suite.id, name: suite.name },
|
|
2337
|
+
results,
|
|
2338
|
+
passed: results.filter((r) => r.ok).length,
|
|
2339
|
+
total: results.length
|
|
2340
|
+
});
|
|
2341
|
+
}
|
|
2342
|
+
return json6({ error: "not found" }, 404);
|
|
2343
|
+
}
|
|
2344
|
+
|
|
2345
|
+
// src/observability/schedule-routes.ts
|
|
2346
|
+
var JSON_HEADERS7 = { "content-type": "application/json; charset=utf-8" };
|
|
2347
|
+
function json7(body, status = 200) {
|
|
2348
|
+
return new Response(JSON.stringify(body), { status, headers: JSON_HEADERS7 });
|
|
2349
|
+
}
|
|
2350
|
+
function latestPerDevice(runs) {
|
|
2351
|
+
const latest = new Map;
|
|
2352
|
+
for (const run of runs) {
|
|
2353
|
+
if (run.outcome !== "ok")
|
|
2354
|
+
continue;
|
|
2355
|
+
const key = run.device ?? "(fleet)";
|
|
2356
|
+
const seen = latest.get(key);
|
|
2357
|
+
if (!seen || run.startedAt > seen.startedAt)
|
|
2358
|
+
latest.set(key, run);
|
|
2359
|
+
}
|
|
2360
|
+
return [...latest.values()];
|
|
2361
|
+
}
|
|
2362
|
+
function jobRow(job, runs, now) {
|
|
2363
|
+
const current = latestPerDevice(runs);
|
|
2364
|
+
const findings = current.flatMap((r) => r.findings);
|
|
2365
|
+
return {
|
|
2366
|
+
...job,
|
|
2367
|
+
cronText: describeCron(job.cron),
|
|
2368
|
+
nextRun: nextRunOf(job.id) ?? nextRun(job.cron, now),
|
|
2369
|
+
lastRun: runs[0] ? {
|
|
2370
|
+
startedAt: runs[0].startedAt,
|
|
2371
|
+
finishedAt: runs[0].finishedAt,
|
|
2372
|
+
outcome: runs[0].outcome,
|
|
2373
|
+
device: runs[0].device,
|
|
2374
|
+
error: runs[0].error
|
|
2375
|
+
} : null,
|
|
2376
|
+
posture: {
|
|
2377
|
+
total: findings.length,
|
|
2378
|
+
worst: worstSeverity(findings) ?? null,
|
|
2379
|
+
bySeverity: severityCounts(findings),
|
|
2380
|
+
devices: current.length
|
|
2381
|
+
},
|
|
2382
|
+
runCount: runs.length
|
|
2383
|
+
};
|
|
2384
|
+
}
|
|
2385
|
+
function regressionsFor(runs, jobId) {
|
|
2386
|
+
const byDevice = new Map;
|
|
2387
|
+
for (const run of runs) {
|
|
2388
|
+
if (run.outcome !== "ok")
|
|
2389
|
+
continue;
|
|
2390
|
+
const key = run.device ?? "(fleet)";
|
|
2391
|
+
const list = byDevice.get(key);
|
|
2392
|
+
if (list)
|
|
2393
|
+
list.push(run);
|
|
2394
|
+
else
|
|
2395
|
+
byDevice.set(key, [run]);
|
|
2396
|
+
}
|
|
2397
|
+
const out = [];
|
|
2398
|
+
for (const [device, list] of byDevice) {
|
|
2399
|
+
for (let i = 0;i < list.length - 1; i++) {
|
|
2400
|
+
const diff = diffFindings(list[i + 1].findings, list[i].findings);
|
|
2401
|
+
if (diff.added.length + diff.worsened.length + diff.resolved.length === 0)
|
|
2402
|
+
continue;
|
|
2403
|
+
out.push({
|
|
2404
|
+
jobId,
|
|
2405
|
+
device,
|
|
2406
|
+
at: list[i].startedAt,
|
|
2407
|
+
summary: describeDiff(diff),
|
|
2408
|
+
added: diff.added,
|
|
2409
|
+
worsened: diff.worsened,
|
|
2410
|
+
resolved: diff.resolved
|
|
2411
|
+
});
|
|
2412
|
+
}
|
|
2413
|
+
}
|
|
2414
|
+
return out.sort((a, b) => b.at - a.at);
|
|
2415
|
+
}
|
|
2416
|
+
async function scheduleRoutes(req, url) {
|
|
2417
|
+
const p = url.pathname;
|
|
2418
|
+
if (!p.startsWith("/api/schedules"))
|
|
2419
|
+
return null;
|
|
2420
|
+
let store3;
|
|
2421
|
+
try {
|
|
2422
|
+
store3 = await scheduleStore();
|
|
2423
|
+
} catch (e) {
|
|
2424
|
+
return json7({ error: e instanceof Error ? e.message : String(e), jobs: [] }, 503);
|
|
2425
|
+
}
|
|
2426
|
+
const now = Date.now();
|
|
2427
|
+
if (p === "/api/schedules" && req.method === "GET") {
|
|
2428
|
+
const jobs = store3.listJobs().map((job) => jobRow(job, store3.runs(job.id, 500), now));
|
|
2429
|
+
return json7({
|
|
2430
|
+
jobs,
|
|
2431
|
+
schedulable: schedulableTools().map((a) => ({ tool: a.tool, summary: a.summary }))
|
|
2432
|
+
});
|
|
2433
|
+
}
|
|
2434
|
+
if (p === "/api/schedules" && req.method === "POST") {
|
|
2435
|
+
const body = await req.json().catch(() => null);
|
|
2436
|
+
if (!body)
|
|
2437
|
+
return json7({ error: "invalid JSON body" }, 400);
|
|
2438
|
+
const parsed = JobSchema.safeParse(body);
|
|
2439
|
+
if (!parsed.success) {
|
|
2440
|
+
return json7({
|
|
2441
|
+
error: parsed.error.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; ")
|
|
2442
|
+
}, 400);
|
|
2443
|
+
}
|
|
2444
|
+
if (!auditAdapter(parsed.data.tool)) {
|
|
2445
|
+
return json7({ error: `'${parsed.data.tool}' is not a schedulable auditor` }, 400);
|
|
2446
|
+
}
|
|
2447
|
+
await primeRiskIndex();
|
|
2448
|
+
const risk = liveExecutor().riskOf(parsed.data.tool);
|
|
2449
|
+
if (risk !== "READ") {
|
|
2450
|
+
return json7({ error: `'${parsed.data.tool}' is annotated ${risk}; only READ may be scheduled` }, 400);
|
|
2451
|
+
}
|
|
2452
|
+
const existing = store3.getJob(parsed.data.id);
|
|
2453
|
+
const job = { ...parsed.data, createdAt: existing?.createdAt ?? now };
|
|
2454
|
+
store3.saveJob(job);
|
|
2455
|
+
if (job.enabled)
|
|
2456
|
+
armJob(job, now);
|
|
2457
|
+
else
|
|
2458
|
+
forgetJob(job.id);
|
|
2459
|
+
return json7({ job: jobRow(job, store3.runs(job.id, 500), now), replaced: existing !== null });
|
|
2460
|
+
}
|
|
2461
|
+
if (p === "/api/schedules/timeline" && req.method === "GET") {
|
|
2462
|
+
const jobId = url.searchParams.get("job") ?? undefined;
|
|
2463
|
+
const device = url.searchParams.get("device") ?? undefined;
|
|
2464
|
+
const days = Number(url.searchParams.get("days") ?? 30);
|
|
2465
|
+
const since = now - days * 86400000;
|
|
2466
|
+
const runs = store3.runs(jobId, 2000).filter((r) => r.startedAt >= since && (!device || r.device === device));
|
|
2467
|
+
return json7({
|
|
2468
|
+
points: runs.map((r) => ({
|
|
2469
|
+
at: r.startedAt,
|
|
2470
|
+
jobId: r.jobId,
|
|
2471
|
+
device: r.device,
|
|
2472
|
+
outcome: r.outcome,
|
|
2473
|
+
total: r.findings.length,
|
|
2474
|
+
bySeverity: severityCounts(r.findings),
|
|
2475
|
+
added: r.added,
|
|
2476
|
+
worsened: r.worsened,
|
|
2477
|
+
resolved: r.resolved,
|
|
2478
|
+
durationMs: r.finishedAt - r.startedAt
|
|
2479
|
+
})).sort((a, b) => a.at - b.at)
|
|
2480
|
+
});
|
|
2481
|
+
}
|
|
2482
|
+
if (p === "/api/schedules/regressions" && req.method === "GET") {
|
|
2483
|
+
const jobId = url.searchParams.get("job") ?? undefined;
|
|
2484
|
+
const limit = Number(url.searchParams.get("limit") ?? 50);
|
|
2485
|
+
const jobs = jobId ? [jobId] : store3.listJobs().map((j) => j.id);
|
|
2486
|
+
const items = jobs.flatMap((id2) => regressionsFor(store3.runs(id2, 500), id2)).sort((a, b) => b.at - a.at).slice(0, limit);
|
|
2487
|
+
return json7({ regressions: items });
|
|
2488
|
+
}
|
|
2489
|
+
const parts = p.slice("/api/schedules/".length).split("/").filter(Boolean);
|
|
2490
|
+
if (parts.length === 0 || parts.length > 2)
|
|
2491
|
+
return json7({ error: "not found" }, 404);
|
|
2492
|
+
const id = decodeURIComponent(parts[0]);
|
|
2493
|
+
const verb = parts[1];
|
|
2494
|
+
if (!verb && req.method === "DELETE") {
|
|
2495
|
+
const job = store3.getJob(id);
|
|
2496
|
+
if (!job)
|
|
2497
|
+
return json7({ error: `no schedule '${id}'` }, 404);
|
|
2498
|
+
const runCount = store3.runs(id, 1e4).length;
|
|
2499
|
+
store3.removeJob(id);
|
|
2500
|
+
forgetJob(id);
|
|
2501
|
+
return json7({ removed: id, runsDiscarded: runCount });
|
|
2502
|
+
}
|
|
2503
|
+
if (verb === "run" && req.method === "POST") {
|
|
2504
|
+
const job = store3.getJob(id);
|
|
2505
|
+
if (!job)
|
|
2506
|
+
return json7({ error: `no schedule '${id}'` }, 404);
|
|
2507
|
+
await primeRiskIndex();
|
|
2508
|
+
logger.info(`schedule '${id}' run requested from the dashboard`);
|
|
2509
|
+
const result = await runJob(job, liveExecutor(), runnerOptions());
|
|
2510
|
+
return json7({
|
|
2511
|
+
...result,
|
|
2512
|
+
job: jobRow(job, store3.runs(id, 500), Date.now())
|
|
2513
|
+
});
|
|
2514
|
+
}
|
|
2515
|
+
if (!verb && req.method === "GET") {
|
|
2516
|
+
const job = store3.getJob(id);
|
|
2517
|
+
if (!job)
|
|
2518
|
+
return json7({ error: `no schedule '${id}'` }, 404);
|
|
2519
|
+
const runs = store3.runs(id, 500);
|
|
2520
|
+
return json7({ job: jobRow(job, runs, now), runs, regressions: regressionsFor(runs, id) });
|
|
2521
|
+
}
|
|
2522
|
+
return json7({ error: "not found" }, 404);
|
|
2523
|
+
}
|
|
2524
|
+
|
|
2525
|
+
// src/observability/explain-routes.ts
|
|
2526
|
+
var JSON_HEADERS8 = { "content-type": "application/json; charset=utf-8" };
|
|
2527
|
+
function json8(body, status = 200) {
|
|
2528
|
+
return new Response(JSON.stringify(body), { status, headers: JSON_HEADERS8 });
|
|
2529
|
+
}
|
|
2530
|
+
var storePromise5 = null;
|
|
2531
|
+
function snapshots2() {
|
|
2532
|
+
storePromise5 ??= openSnapshotStore(DEFAULT_SNAPSHOT_DB);
|
|
2533
|
+
return storePromise5;
|
|
2534
|
+
}
|
|
2535
|
+
async function configFor2(device, snapshotId) {
|
|
2536
|
+
if (snapshotId) {
|
|
2537
|
+
const snapshot = (await snapshots2()).get(snapshotId);
|
|
2538
|
+
if (!snapshot)
|
|
2539
|
+
return { error: `no snapshot '${snapshotId}'`, status: 404 };
|
|
2540
|
+
return {
|
|
2541
|
+
text: snapshot.body,
|
|
2542
|
+
device: snapshot.device ?? device,
|
|
2543
|
+
source: `snapshot ${snapshotId}`
|
|
2544
|
+
};
|
|
2545
|
+
}
|
|
2546
|
+
const name = resolveDeviceName(device);
|
|
2547
|
+
const body = await executeMikrotikCommand("/export", createContext(undefined, name));
|
|
2548
|
+
if (looksLikeError(body) || body.trim() === "") {
|
|
2549
|
+
return { error: body.trim() || "the device returned an empty export", status: 502 };
|
|
2550
|
+
}
|
|
2551
|
+
return { text: body, device: name, source: "live device" };
|
|
2552
|
+
}
|
|
2553
|
+
function payload(narrative, source, markdown) {
|
|
2554
|
+
return {
|
|
2555
|
+
narrative,
|
|
2556
|
+
markdown,
|
|
2557
|
+
mermaid: topologyMermaid(narrative),
|
|
2558
|
+
source
|
|
2559
|
+
};
|
|
2560
|
+
}
|
|
2561
|
+
async function explainRoutes(req, url) {
|
|
2562
|
+
const p = url.pathname;
|
|
2563
|
+
if (!p.startsWith("/api/explain"))
|
|
2564
|
+
return null;
|
|
2565
|
+
if (p === "/api/explain/diff" && req.method === "POST") {
|
|
2566
|
+
const body = await req.json().catch(() => null);
|
|
2567
|
+
if (!body)
|
|
2568
|
+
return json8({ error: "invalid JSON body" }, 400);
|
|
2569
|
+
if (!body.before && !body.beforeText) {
|
|
2570
|
+
return json8({ error: "a diff needs a baseline: pass `before` (a snapshot id) or `beforeText`" }, 400);
|
|
2571
|
+
}
|
|
2572
|
+
const device2 = body.device ?? "";
|
|
2573
|
+
const before = body.beforeText ? { text: body.beforeText, device: device2, source: "supplied text" } : await configFor2(device2, body.before ?? null);
|
|
2574
|
+
if ("error" in before)
|
|
2575
|
+
return json8({ error: `before: ${before.error}` }, before.status);
|
|
2576
|
+
const after = body.afterText ? { text: body.afterText, device: device2, source: "supplied text" } : await configFor2(device2, body.after ?? null);
|
|
2577
|
+
if ("error" in after)
|
|
2578
|
+
return json8({ error: `after: ${after.error}` }, after.status);
|
|
2579
|
+
const diff = diffNarratives(analyzeDevice(before.text, before.device), analyzeDevice(after.text, after.device));
|
|
2580
|
+
return json8({ diff, markdown: renderDiff(diff), before: before.source, after: after.source });
|
|
2581
|
+
}
|
|
2582
|
+
const rest = p.slice("/api/explain/".length).split("/").filter(Boolean);
|
|
2583
|
+
if (rest.length === 0)
|
|
2584
|
+
return json8({ error: "not found" }, 404);
|
|
2585
|
+
if (req.method !== "GET")
|
|
2586
|
+
return json8({ error: "not found" }, 404);
|
|
2587
|
+
const device = decodeURIComponent(rest[0]);
|
|
2588
|
+
const snapshotId = url.searchParams.get("snapshot");
|
|
2589
|
+
let config;
|
|
2590
|
+
try {
|
|
2591
|
+
config = await configFor2(device, snapshotId);
|
|
2592
|
+
} catch (e) {
|
|
2593
|
+
return json8({ error: e instanceof Error ? e.message : String(e) }, 502);
|
|
2594
|
+
}
|
|
2595
|
+
if ("error" in config)
|
|
2596
|
+
return json8({ error: config.error }, config.status);
|
|
2597
|
+
const narrative = { ...analyzeDevice(config.text, config.device), generatedAt: Date.now() };
|
|
2598
|
+
if (rest.length === 1) {
|
|
2599
|
+
return json8(payload(narrative, config.source, renderNarrative(narrative)));
|
|
2600
|
+
}
|
|
2601
|
+
if (rest[1] === "section" && rest[2]) {
|
|
2602
|
+
const section = decodeURIComponent(rest[2]);
|
|
2603
|
+
if (!NARRATIVE_SECTIONS.includes(section)) {
|
|
2604
|
+
return json8({ error: `unknown section '${section}'. Known: ${NARRATIVE_SECTIONS.join(", ")}` }, 400);
|
|
2605
|
+
}
|
|
2606
|
+
return json8(payload(narrative, config.source, renderNarrative(narrative, { sections: [section] })));
|
|
2607
|
+
}
|
|
2608
|
+
return json8({ error: "not found" }, 404);
|
|
2609
|
+
}
|
|
2610
|
+
|
|
2611
|
+
// src/observability/attack-routes.ts
|
|
2612
|
+
var JSON_HEADERS9 = { "content-type": "application/json; charset=utf-8" };
|
|
2613
|
+
function json9(body, status = 200) {
|
|
2614
|
+
return new Response(JSON.stringify(body), { status, headers: JSON_HEADERS9 });
|
|
2615
|
+
}
|
|
2616
|
+
async function attackRoutes(req, url) {
|
|
2617
|
+
const p = url.pathname;
|
|
2618
|
+
if (!p.startsWith("/api/attacks"))
|
|
2619
|
+
return null;
|
|
2620
|
+
let store3;
|
|
2621
|
+
try {
|
|
2622
|
+
store3 = await attackStore();
|
|
2623
|
+
} catch (e) {
|
|
2624
|
+
return json9({ error: e instanceof Error ? e.message : String(e), incidents: [] }, 503);
|
|
2625
|
+
}
|
|
2626
|
+
const cfg = getConfig().attacks;
|
|
2627
|
+
const now = Date.now();
|
|
2628
|
+
if (p === "/api/attacks" && req.method === "GET") {
|
|
2629
|
+
const hours = Number(url.searchParams.get("hours") ?? 24);
|
|
2630
|
+
const incidents = store3.listIncidents({
|
|
2631
|
+
since: now - hours * 3600000,
|
|
2632
|
+
limit: Number(url.searchParams.get("limit") ?? 200)
|
|
2633
|
+
});
|
|
2634
|
+
const responses = store3.listResponses({ active: true, limit: 500 });
|
|
2635
|
+
const blocked = new Set(responses.map((r) => r.source));
|
|
2636
|
+
return json9({
|
|
2637
|
+
incidents: incidents.map((i) => ({ ...i, blocked: blocked.has(i.source) })),
|
|
2638
|
+
responses,
|
|
2639
|
+
posture: {
|
|
2640
|
+
enabled: cfg.enabled,
|
|
2641
|
+
mode: cfg.mode,
|
|
2642
|
+
autoRespondTo: cfg.autoRespondTo,
|
|
2643
|
+
minConfidence: cfg.minConfidence
|
|
2644
|
+
}
|
|
2645
|
+
});
|
|
2646
|
+
}
|
|
2647
|
+
if (p === "/api/attacks/sources" && req.method === "GET") {
|
|
2648
|
+
const hours = Number(url.searchParams.get("hours") ?? 168);
|
|
2649
|
+
const incidents = store3.listIncidents({ since: now - hours * 3600000, limit: 1000 });
|
|
2650
|
+
const bySource = new Map;
|
|
2651
|
+
for (const i of incidents) {
|
|
2652
|
+
if (i.source === "")
|
|
2653
|
+
continue;
|
|
2654
|
+
const seen = bySource.get(i.source);
|
|
2655
|
+
if (seen) {
|
|
2656
|
+
for (const d of i.devices)
|
|
2657
|
+
seen.devices.add(d);
|
|
2658
|
+
for (const d of i.detectors)
|
|
2659
|
+
seen.detectors.add(d);
|
|
2660
|
+
seen.firstTs = Math.min(seen.firstTs, i.firstTs);
|
|
2661
|
+
seen.lastTs = Math.max(seen.lastTs, i.lastTs);
|
|
2662
|
+
seen.incidents++;
|
|
2663
|
+
} else {
|
|
2664
|
+
bySource.set(i.source, {
|
|
2665
|
+
source: i.source,
|
|
2666
|
+
devices: new Set(i.devices),
|
|
2667
|
+
detectors: new Set(i.detectors),
|
|
2668
|
+
firstTs: i.firstTs,
|
|
2669
|
+
lastTs: i.lastTs,
|
|
2670
|
+
incidents: 1,
|
|
2671
|
+
worst: i.severity
|
|
2672
|
+
});
|
|
2673
|
+
}
|
|
2674
|
+
}
|
|
2675
|
+
const active = new Set(store3.listResponses({ active: true, limit: 500 }).map((r) => r.source));
|
|
2676
|
+
return json9({
|
|
2677
|
+
sources: [...bySource.values()].map((s) => ({
|
|
2678
|
+
source: s.source,
|
|
2679
|
+
devices: [...s.devices].sort(),
|
|
2680
|
+
detectors: [...s.detectors].sort(),
|
|
2681
|
+
firstTs: s.firstTs,
|
|
2682
|
+
lastTs: s.lastTs,
|
|
2683
|
+
incidents: s.incidents,
|
|
2684
|
+
worst: s.worst,
|
|
2685
|
+
blocked: active.has(s.source),
|
|
2686
|
+
geo: getDeviceGeo(s.source)
|
|
2687
|
+
})).sort((a, b) => b.incidents - a.incidents || b.lastTs - a.lastTs)
|
|
2688
|
+
});
|
|
2689
|
+
}
|
|
2690
|
+
if (p === "/api/attacks/config" && req.method === "GET") {
|
|
2691
|
+
const guards = buildGuards();
|
|
2692
|
+
const never = [...neverBlockSet(guards)].sort();
|
|
2693
|
+
const check = url.searchParams.get("check");
|
|
2694
|
+
return json9({
|
|
2695
|
+
config: cfg,
|
|
2696
|
+
neverBlock: never,
|
|
2697
|
+
check: check ? { address: check, protected: isNeverBlock(check, new Set(never)) } : undefined
|
|
2698
|
+
});
|
|
2699
|
+
}
|
|
2700
|
+
if (p === "/api/attacks/config" && req.method === "POST") {
|
|
2701
|
+
return json9({
|
|
2702
|
+
error: "edit the `attacks` block in Config \u2014 the policy has to survive a restart, so it is not held in memory here"
|
|
2703
|
+
}, 501);
|
|
2704
|
+
}
|
|
2705
|
+
if (p === "/api/attacks/scan" && req.method === "POST") {
|
|
2706
|
+
logger.info("attack sweep requested from the dashboard");
|
|
2707
|
+
const result = await sweep({ respond: false });
|
|
2708
|
+
return json9({
|
|
2709
|
+
incidents: result.incidents,
|
|
2710
|
+
unavailable: result.unavailable,
|
|
2711
|
+
devices: result.devices
|
|
2712
|
+
});
|
|
2713
|
+
}
|
|
2714
|
+
const rest = p.slice("/api/attacks/".length).split("/").filter(Boolean);
|
|
2715
|
+
if (rest[0] === "responses" && rest[1] && req.method === "DELETE") {
|
|
2716
|
+
const address = decodeURIComponent(rest[1]);
|
|
2717
|
+
const response = store3.responseFor(address);
|
|
2718
|
+
const devices = response?.devices ?? [];
|
|
2719
|
+
if (devices.length === 0)
|
|
2720
|
+
return json9({ error: `no recorded block for ${address}` }, 404);
|
|
2721
|
+
const results = await revokeBlock(address, devices, response?.list);
|
|
2722
|
+
store3.revokeResponse(address, now);
|
|
2723
|
+
return json9({ address, results });
|
|
2724
|
+
}
|
|
2725
|
+
if (rest.length === 1 && req.method === "GET") {
|
|
2726
|
+
const incident = store3.getIncident(decodeURIComponent(rest[0]));
|
|
2727
|
+
if (!incident)
|
|
2728
|
+
return json9({ error: "not found" }, 404);
|
|
2729
|
+
return json9({ incident, response: store3.responseFor(incident.source) });
|
|
2730
|
+
}
|
|
2731
|
+
if (rest.length === 2 && rest[1] === "respond" && req.method === "POST") {
|
|
2732
|
+
const incident = store3.getIncident(decodeURIComponent(rest[0]));
|
|
2733
|
+
if (!incident)
|
|
2734
|
+
return json9({ error: "not found" }, 404);
|
|
2735
|
+
const body = await req.json().catch(() => ({}));
|
|
2736
|
+
if (body.action === "dismiss") {
|
|
2737
|
+
return json9({ dismissed: incident.id });
|
|
2738
|
+
}
|
|
2739
|
+
const decision = decide({
|
|
2740
|
+
incident,
|
|
2741
|
+
policy: policyFromConfig(),
|
|
2742
|
+
guards: buildGuards(),
|
|
2743
|
+
recentBlockCount: store3.countRecentBlocks(incident.devices, now - 3600000),
|
|
2744
|
+
manual: true,
|
|
2745
|
+
confirm: body.confirm ?? false,
|
|
2746
|
+
timeout: body.timeout
|
|
2747
|
+
});
|
|
2748
|
+
if (!isPlan(decision)) {
|
|
2749
|
+
return json9({ refused: true, guard: decision.guard, reason: decision.reason }, 200);
|
|
2750
|
+
}
|
|
2751
|
+
if (decision.action === "escalate") {
|
|
2752
|
+
return json9({ escalated: true, reason: decision.reason });
|
|
2753
|
+
}
|
|
2754
|
+
if (!body.confirm) {
|
|
2755
|
+
return json9({ dryRun: true, plan: decision });
|
|
2756
|
+
}
|
|
2757
|
+
const applied = await executePlan(decision);
|
|
2758
|
+
const ok = applied.some((r) => r.ok);
|
|
2759
|
+
store3.recordResponse({
|
|
2760
|
+
incidentId: incident.id,
|
|
2761
|
+
action: decision.action,
|
|
2762
|
+
source: decision.source,
|
|
2763
|
+
devices: decision.devices,
|
|
2764
|
+
timeout: decision.timeout,
|
|
2765
|
+
list: decision.list,
|
|
2766
|
+
reason: decision.reason,
|
|
2767
|
+
ts: now,
|
|
2768
|
+
ok,
|
|
2769
|
+
error: ok ? undefined : applied.map((r) => r.detail).join("; ")
|
|
2770
|
+
});
|
|
2771
|
+
return json9({ applied, ok, plan: decision });
|
|
2772
|
+
}
|
|
2773
|
+
return json9({ error: "not found" }, 404);
|
|
2774
|
+
}
|
|
2775
|
+
|
|
2776
|
+
// src/observability/alert-routes.ts
|
|
2777
|
+
var JSON_HEADERS10 = { "content-type": "application/json; charset=utf-8" };
|
|
2778
|
+
function json10(body, status = 200) {
|
|
2779
|
+
return new Response(JSON.stringify(body), { status, headers: JSON_HEADERS10 });
|
|
2780
|
+
}
|
|
2781
|
+
function ruleRow(rule, subject, status, since) {
|
|
2782
|
+
return {
|
|
2783
|
+
id: rule.id,
|
|
2784
|
+
subject,
|
|
2785
|
+
description: rule.description,
|
|
2786
|
+
when: rule.when,
|
|
2787
|
+
severity: rule.severity,
|
|
2788
|
+
channels: rule.channels,
|
|
2789
|
+
for: rule.for,
|
|
2790
|
+
cooldown: rule.cooldown,
|
|
2791
|
+
enabled: rule.enabled,
|
|
2792
|
+
mutedUntil: rule.mutedUntil,
|
|
2793
|
+
status,
|
|
2794
|
+
since,
|
|
2795
|
+
warnings: ruleWarnings(rule)
|
|
2796
|
+
};
|
|
2797
|
+
}
|
|
2798
|
+
function previewRule(rule, events) {
|
|
2799
|
+
if (!isEventTrigger(rule.when))
|
|
2800
|
+
return { fires: 0, resolves: 0, matched: 0 };
|
|
2801
|
+
let state = initialState(events[0]?.ts ?? 0);
|
|
2802
|
+
let fires = 0;
|
|
2803
|
+
let resolves = 0;
|
|
2804
|
+
let matched = 0;
|
|
2805
|
+
for (const e of [...events].sort((a, b) => a.ts - b.ts)) {
|
|
2806
|
+
const ev = {
|
|
2807
|
+
kind: "tool_call",
|
|
2808
|
+
tool: e.tool,
|
|
2809
|
+
device: e.device,
|
|
2810
|
+
risk: e.risk,
|
|
2811
|
+
isError: e.isError
|
|
2812
|
+
};
|
|
2813
|
+
const met = eventMet(rule.when, ev);
|
|
2814
|
+
if (met)
|
|
2815
|
+
matched++;
|
|
2816
|
+
const res = step(rule, state, met, e.ts);
|
|
2817
|
+
state = res.state;
|
|
2818
|
+
if (res.action.type === "fire")
|
|
2819
|
+
fires++;
|
|
2820
|
+
if (res.action.type === "resolve")
|
|
2821
|
+
resolves++;
|
|
2822
|
+
}
|
|
2823
|
+
return { fires, resolves, matched };
|
|
2824
|
+
}
|
|
2825
|
+
async function alertRoutes(req, url, db) {
|
|
2826
|
+
const p = url.pathname;
|
|
2827
|
+
if (!p.startsWith("/api/alerts"))
|
|
2828
|
+
return null;
|
|
2829
|
+
const engine = getAlertEngine();
|
|
2830
|
+
const channels = getConfig().alerts?.channels;
|
|
2831
|
+
if (p === "/api/alerts" && req.method === "GET") {
|
|
2832
|
+
if (!engine) {
|
|
2833
|
+
return json10({ configured: false, rules: [], active: [], channels: {} });
|
|
2834
|
+
}
|
|
2835
|
+
const now = Date.now();
|
|
2836
|
+
const rows = engine.snapshot().map(({ rule, subject, state }) => ruleRow(rule, subject, isMuted(rule, now) ? "muted" : !rule.enabled ? "disabled" : state.status, state.since));
|
|
2837
|
+
return json10({
|
|
2838
|
+
configured: true,
|
|
2839
|
+
rules: rows,
|
|
2840
|
+
active: rows.filter((r) => r.status === "firing"),
|
|
2841
|
+
channels: redactChannels(channels)
|
|
2842
|
+
});
|
|
2843
|
+
}
|
|
2844
|
+
if (p === "/api/alerts/history" && req.method === "GET") {
|
|
2845
|
+
if (!engine)
|
|
2846
|
+
return json10({ history: [] });
|
|
2847
|
+
const hours = Number(url.searchParams.get("hours") ?? 24);
|
|
2848
|
+
const history = await engine.history({
|
|
2849
|
+
sinceMs: Date.now() - hours * 3600000,
|
|
2850
|
+
ruleId: url.searchParams.get("rule") ?? undefined,
|
|
2851
|
+
limit: Number(url.searchParams.get("limit") ?? 200)
|
|
2852
|
+
});
|
|
2853
|
+
return json10({ history });
|
|
2854
|
+
}
|
|
2855
|
+
if (p === "/api/alerts/rules" && req.method === "POST") {
|
|
2856
|
+
if (!engine)
|
|
2857
|
+
return json10({ error: "alerting is not configured" }, 400);
|
|
2858
|
+
const parsed = AlertRuleSchema.safeParse(await req.json());
|
|
2859
|
+
if (!parsed.success) {
|
|
2860
|
+
return json10({ error: parsed.error.issues.map((i) => i.message).join("; ") }, 400);
|
|
2861
|
+
}
|
|
2862
|
+
const rules = engine.configuredRules();
|
|
2863
|
+
if (rules.some((r) => r.id === parsed.data.id)) {
|
|
2864
|
+
return json10({ error: `rule '${parsed.data.id}' already exists` }, 409);
|
|
2865
|
+
}
|
|
2866
|
+
engine.setRules([...rules, parsed.data]);
|
|
2867
|
+
return json10({ ok: true, id: parsed.data.id });
|
|
2868
|
+
}
|
|
2869
|
+
const idMatch = p.match(/^\/api\/alerts\/rules\/([^/]+)$/);
|
|
2870
|
+
if (idMatch) {
|
|
2871
|
+
if (!engine)
|
|
2872
|
+
return json10({ error: "alerting is not configured" }, 400);
|
|
2873
|
+
const id = decodeURIComponent(idMatch[1]);
|
|
2874
|
+
const rules = engine.configuredRules();
|
|
2875
|
+
const existing = rules.find((r) => r.id === id);
|
|
2876
|
+
if (!existing)
|
|
2877
|
+
return json10({ error: `unknown rule: ${id}` }, 404);
|
|
2878
|
+
if (req.method === "DELETE") {
|
|
2879
|
+
engine.setRules(rules.filter((r) => r.id !== id));
|
|
2880
|
+
return json10({ ok: true });
|
|
2881
|
+
}
|
|
2882
|
+
if (req.method === "PATCH") {
|
|
2883
|
+
const body = await req.json();
|
|
2884
|
+
let mutedUntil = existing.mutedUntil;
|
|
2885
|
+
if (typeof body.mute === "string") {
|
|
2886
|
+
const ms = parseDuration(body.mute);
|
|
2887
|
+
if (ms === null)
|
|
2888
|
+
return json10({ error: `invalid duration: ${body.mute}` }, 400);
|
|
2889
|
+
mutedUntil = ms === 0 ? undefined : Date.now() + ms;
|
|
2890
|
+
delete body.mute;
|
|
2891
|
+
}
|
|
2892
|
+
const merged = AlertRuleSchema.safeParse({ ...existing, ...body, mutedUntil });
|
|
2893
|
+
if (!merged.success) {
|
|
2894
|
+
return json10({ error: merged.error.issues.map((i) => i.message).join("; ") }, 400);
|
|
2895
|
+
}
|
|
2896
|
+
engine.setRules(rules.map((r) => r.id === id ? merged.data : r));
|
|
2897
|
+
return json10({ ok: true, rule: merged.data });
|
|
2898
|
+
}
|
|
2899
|
+
}
|
|
2900
|
+
if (p === "/api/alerts/test" && req.method === "POST") {
|
|
2901
|
+
const body = await req.json();
|
|
2902
|
+
if (!channels || !body?.channel)
|
|
2903
|
+
return json10({ error: "channel is required" }, 400);
|
|
2904
|
+
try {
|
|
2905
|
+
const result = await deliver(body.channel, channels, {
|
|
2906
|
+
ruleId: "test",
|
|
2907
|
+
severity: "low",
|
|
2908
|
+
kind: "fire",
|
|
2909
|
+
title: "Test notification from mikrotik-mcp",
|
|
2910
|
+
body: "If you can read this, this channel is configured correctly.",
|
|
2911
|
+
at: Date.now()
|
|
2912
|
+
});
|
|
2913
|
+
return json10(result);
|
|
2914
|
+
} catch (e) {
|
|
2915
|
+
logger.error(`Alert channel test failed: ${logError(e)}`);
|
|
2916
|
+
return json10({ error: clientError(e) }, 502);
|
|
2917
|
+
}
|
|
2918
|
+
}
|
|
2919
|
+
if (p === "/api/alerts/preview" && req.method === "POST") {
|
|
2920
|
+
const parsed = AlertRuleSchema.safeParse(await req.json());
|
|
2921
|
+
if (!parsed.success) {
|
|
2922
|
+
return json10({ error: parsed.error.issues.map((i) => i.message).join("; ") }, 400);
|
|
2923
|
+
}
|
|
2924
|
+
const hours = Number(url.searchParams.get("hours") ?? 24);
|
|
2925
|
+
if (!db) {
|
|
2926
|
+
return json10({ hours, sampled: 0, fires: 0, resolves: 0, matched: 0, noHistory: true });
|
|
2927
|
+
}
|
|
2928
|
+
const events = db.query({ since: Date.now() - hours * 3600000, limit: 5000 }).map((e) => ({
|
|
2929
|
+
ts: e.ts,
|
|
2930
|
+
tool: e.tool,
|
|
2931
|
+
device: e.device,
|
|
2932
|
+
risk: e.risk,
|
|
2933
|
+
isError: e.isError
|
|
2934
|
+
}));
|
|
2935
|
+
return json10({ hours, sampled: events.length, ...previewRule(parsed.data, events) });
|
|
2936
|
+
}
|
|
2937
|
+
return null;
|
|
2938
|
+
}
|
|
2939
|
+
|
|
2940
|
+
// src/observability/memory-routes.ts
|
|
2941
|
+
var JSON_HEADERS11 = { "content-type": "application/json; charset=utf-8" };
|
|
2942
|
+
function json11(body, status = 200) {
|
|
2943
|
+
return new Response(JSON.stringify(body), { status, headers: JSON_HEADERS11 });
|
|
2944
|
+
}
|
|
2945
|
+
async function bodyJson4(req) {
|
|
1825
2946
|
return await req.json();
|
|
1826
2947
|
}
|
|
1827
2948
|
async function memoryRoutes(req, url) {
|
|
@@ -1830,29 +2951,29 @@ async function memoryRoutes(req, url) {
|
|
|
1830
2951
|
return null;
|
|
1831
2952
|
const cfg = getConfig();
|
|
1832
2953
|
if (!cfg.memory.enabled) {
|
|
1833
|
-
return
|
|
2954
|
+
return json11({ error: "Knowledge-graph memory is disabled" }, 503);
|
|
1834
2955
|
}
|
|
1835
2956
|
if (p === "/api/memory/config" && req.method === "GET") {
|
|
1836
2957
|
let stats = null;
|
|
1837
2958
|
try {
|
|
1838
|
-
const
|
|
1839
|
-
stats =
|
|
2959
|
+
const store4 = await getMemoryStore();
|
|
2960
|
+
stats = store4.stats();
|
|
1840
2961
|
} catch {}
|
|
1841
|
-
return
|
|
2962
|
+
return json11({
|
|
1842
2963
|
enabled: cfg.memory.enabled,
|
|
1843
2964
|
dbPath: cfg.memory.dbPath,
|
|
1844
2965
|
stats
|
|
1845
2966
|
});
|
|
1846
2967
|
}
|
|
1847
2968
|
if (p === "/api/memory/config" && req.method === "POST") {
|
|
1848
|
-
const body = await
|
|
2969
|
+
const body = await bodyJson4(req);
|
|
1849
2970
|
const updates = {};
|
|
1850
2971
|
if (body.dbPath !== undefined && typeof body.dbPath === "string" && body.dbPath !== cfg.memory.dbPath) {
|
|
1851
2972
|
updates.dbPath = body.dbPath;
|
|
1852
2973
|
try {
|
|
1853
2974
|
await reopenMemoryStore(body.dbPath);
|
|
1854
2975
|
} catch (e) {
|
|
1855
|
-
return
|
|
2976
|
+
return json11({
|
|
1856
2977
|
error: `Failed to open memory DB at ${body.dbPath}: ${e instanceof Error ? e.message : String(e)}`
|
|
1857
2978
|
}, 400);
|
|
1858
2979
|
}
|
|
@@ -1871,79 +2992,79 @@ async function memoryRoutes(req, url) {
|
|
|
1871
2992
|
}
|
|
1872
2993
|
let stats = null;
|
|
1873
2994
|
try {
|
|
1874
|
-
const
|
|
1875
|
-
stats =
|
|
2995
|
+
const store4 = await getMemoryStore();
|
|
2996
|
+
stats = store4.stats();
|
|
1876
2997
|
} catch {}
|
|
1877
|
-
return
|
|
2998
|
+
return json11({
|
|
1878
2999
|
ok: true,
|
|
1879
3000
|
enabled: getConfig().memory.enabled,
|
|
1880
3001
|
dbPath: getConfig().memory.dbPath,
|
|
1881
3002
|
stats
|
|
1882
3003
|
});
|
|
1883
3004
|
}
|
|
1884
|
-
const
|
|
3005
|
+
const store3 = await getMemoryStore();
|
|
1885
3006
|
if (p === "/api/memory/graph" && req.method === "GET") {
|
|
1886
|
-
return
|
|
3007
|
+
return json11(store3.readGraph());
|
|
1887
3008
|
}
|
|
1888
3009
|
if (p === "/api/memory/stats" && req.method === "GET") {
|
|
1889
|
-
return
|
|
3010
|
+
return json11(store3.stats());
|
|
1890
3011
|
}
|
|
1891
3012
|
if (p === "/api/memory/search" && req.method === "GET") {
|
|
1892
3013
|
const q = url.searchParams.get("q") || "";
|
|
1893
3014
|
const limit = Number(url.searchParams.get("limit") ?? 50);
|
|
1894
|
-
return
|
|
3015
|
+
return json11(store3.searchNodes(q, limit));
|
|
1895
3016
|
}
|
|
1896
3017
|
if (p === "/api/memory/activity" && req.method === "GET") {
|
|
1897
3018
|
const limit = Number(url.searchParams.get("limit") ?? 50);
|
|
1898
3019
|
const since = url.searchParams.get("since");
|
|
1899
|
-
return
|
|
3020
|
+
return json11(store3.activity(limit, since ? Number(since) : undefined));
|
|
1900
3021
|
}
|
|
1901
3022
|
const entityMatch = p.match(/^\/api\/memory\/entity\/(.+)$/);
|
|
1902
3023
|
if (entityMatch && req.method === "GET") {
|
|
1903
3024
|
const name = decodeURIComponent(entityMatch[1]);
|
|
1904
|
-
const graph =
|
|
3025
|
+
const graph = store3.openNodes([name]);
|
|
1905
3026
|
if (graph.entities.length === 0)
|
|
1906
|
-
return
|
|
1907
|
-
return
|
|
3027
|
+
return json11({ error: "Entity not found" }, 404);
|
|
3028
|
+
return json11({ entity: graph.entities[0], relations: graph.relations });
|
|
1908
3029
|
}
|
|
1909
3030
|
if (p === "/api/memory/entities" && req.method === "POST") {
|
|
1910
|
-
const body = await
|
|
1911
|
-
const created =
|
|
1912
|
-
return
|
|
3031
|
+
const body = await bodyJson4(req);
|
|
3032
|
+
const created = store3.createEntities(body.entities ?? []);
|
|
3033
|
+
return json11({ created, count: created.length });
|
|
1913
3034
|
}
|
|
1914
3035
|
if (p === "/api/memory/relations" && req.method === "POST") {
|
|
1915
|
-
const body = await
|
|
1916
|
-
const created =
|
|
1917
|
-
return
|
|
3036
|
+
const body = await bodyJson4(req);
|
|
3037
|
+
const created = store3.createRelations(body.relations ?? []);
|
|
3038
|
+
return json11({ created, count: created.length });
|
|
1918
3039
|
}
|
|
1919
3040
|
if (p === "/api/memory/observations" && req.method === "POST") {
|
|
1920
|
-
const body = await
|
|
1921
|
-
const results =
|
|
1922
|
-
return
|
|
3041
|
+
const body = await bodyJson4(req);
|
|
3042
|
+
const results = store3.addObservations(body.observations ?? []);
|
|
3043
|
+
return json11({ results });
|
|
1923
3044
|
}
|
|
1924
3045
|
if (p === "/api/memory/entities" && req.method === "DELETE") {
|
|
1925
|
-
const body = await
|
|
1926
|
-
const removed =
|
|
1927
|
-
return
|
|
3046
|
+
const body = await bodyJson4(req);
|
|
3047
|
+
const removed = store3.deleteEntities(body.names ?? []);
|
|
3048
|
+
return json11({ removed });
|
|
1928
3049
|
}
|
|
1929
3050
|
if (p === "/api/memory/relations" && req.method === "DELETE") {
|
|
1930
|
-
const body = await
|
|
1931
|
-
const removed =
|
|
1932
|
-
return
|
|
3051
|
+
const body = await bodyJson4(req);
|
|
3052
|
+
const removed = store3.deleteRelations(body.relations ?? []);
|
|
3053
|
+
return json11({ removed });
|
|
1933
3054
|
}
|
|
1934
3055
|
if (p === "/api/memory/observations" && req.method === "DELETE") {
|
|
1935
|
-
const body = await
|
|
1936
|
-
const removed =
|
|
1937
|
-
return
|
|
3056
|
+
const body = await bodyJson4(req);
|
|
3057
|
+
const removed = store3.deleteObservations(body.deletions ?? []);
|
|
3058
|
+
return json11({ removed });
|
|
1938
3059
|
}
|
|
1939
3060
|
return null;
|
|
1940
3061
|
}
|
|
1941
3062
|
|
|
1942
3063
|
// src/observability/dashboard.ts
|
|
1943
3064
|
var SERVER_TAG3 = "mikrotik-mcp";
|
|
1944
|
-
var
|
|
1945
|
-
function
|
|
1946
|
-
return new Response(JSON.stringify(body), { status, headers:
|
|
3065
|
+
var JSON_HEADERS12 = { "content-type": "application/json; charset=utf-8" };
|
|
3066
|
+
function json12(body, status = 200) {
|
|
3067
|
+
return new Response(JSON.stringify(body), { status, headers: JSON_HEADERS12 });
|
|
1947
3068
|
}
|
|
1948
3069
|
function restartProcess() {
|
|
1949
3070
|
let ok = false;
|
|
@@ -1958,7 +3079,7 @@ function restartProcess() {
|
|
|
1958
3079
|
ok = true;
|
|
1959
3080
|
logger.warn("Dashboard requested a restart \u2014 relaunched a fresh server process; this one is exiting.");
|
|
1960
3081
|
} catch (e) {
|
|
1961
|
-
logger.error(`Restart relaunch failed: ${
|
|
3082
|
+
logger.error(`Restart relaunch failed: ${logError(e)}. Exiting anyway.`);
|
|
1962
3083
|
}
|
|
1963
3084
|
setTimeout(() => process.exit(0), 500);
|
|
1964
3085
|
return ok;
|
|
@@ -1971,7 +3092,7 @@ function runUpgrade(spec) {
|
|
|
1971
3092
|
env: process.env,
|
|
1972
3093
|
stdio: ["ignore", "pipe", "pipe"]
|
|
1973
3094
|
});
|
|
1974
|
-
const
|
|
3095
|
+
const timer4 = setTimeout(() => {
|
|
1975
3096
|
child.kill();
|
|
1976
3097
|
resolve({ ok: false, log: `${out}
|
|
1977
3098
|
(timed out after 180s)` });
|
|
@@ -1979,12 +3100,12 @@ function runUpgrade(spec) {
|
|
|
1979
3100
|
child.stdout?.on("data", (d) => out += d.toString());
|
|
1980
3101
|
child.stderr?.on("data", (d) => out += d.toString());
|
|
1981
3102
|
child.on("error", (e) => {
|
|
1982
|
-
clearTimeout(
|
|
3103
|
+
clearTimeout(timer4);
|
|
1983
3104
|
resolve({ ok: false, log: `${out}
|
|
1984
3105
|
spawn error: ${String(e)}` });
|
|
1985
3106
|
});
|
|
1986
3107
|
child.on("exit", (code) => {
|
|
1987
|
-
clearTimeout(
|
|
3108
|
+
clearTimeout(timer4);
|
|
1988
3109
|
resolve({ ok: code === 0, log: out.trim() || `(exit ${code})` });
|
|
1989
3110
|
});
|
|
1990
3111
|
});
|
|
@@ -2018,8 +3139,8 @@ function filterFromQuery(url) {
|
|
|
2018
3139
|
until: num("until")
|
|
2019
3140
|
};
|
|
2020
3141
|
}
|
|
2021
|
-
function facets(
|
|
2022
|
-
const recent =
|
|
3142
|
+
function facets(store3) {
|
|
3143
|
+
const recent = store3.query({ limit: 5000 });
|
|
2023
3144
|
const tools = new Set;
|
|
2024
3145
|
const devices = new Set;
|
|
2025
3146
|
for (const e of recent) {
|
|
@@ -2032,8 +3153,8 @@ function facets(store) {
|
|
|
2032
3153
|
devices: [...devices].sort()
|
|
2033
3154
|
};
|
|
2034
3155
|
}
|
|
2035
|
-
function deviceActivity(
|
|
2036
|
-
const recent =
|
|
3156
|
+
function deviceActivity(store3) {
|
|
3157
|
+
const recent = store3.query({ limit: 5000 });
|
|
2037
3158
|
const map = new Map;
|
|
2038
3159
|
for (const e of recent) {
|
|
2039
3160
|
if (!e.device)
|
|
@@ -2062,9 +3183,9 @@ function deviceActivity(store) {
|
|
|
2062
3183
|
}
|
|
2063
3184
|
return out;
|
|
2064
3185
|
}
|
|
2065
|
-
function devicesPayload(
|
|
3186
|
+
function devicesPayload(store3) {
|
|
2066
3187
|
const cfg = getConfig();
|
|
2067
|
-
const activity = deviceActivity(
|
|
3188
|
+
const activity = deviceActivity(store3);
|
|
2068
3189
|
const poolEnabled = isPoolEnabled();
|
|
2069
3190
|
const poolMap = new Map(poolStatus().map((p) => [p.device, p]));
|
|
2070
3191
|
const devices = Object.entries(cfg.devices).map(([name, dc]) => {
|
|
@@ -2075,7 +3196,8 @@ function devicesPayload(store) {
|
|
|
2075
3196
|
host: dc.host,
|
|
2076
3197
|
port: dc.port,
|
|
2077
3198
|
mac: dc.mac,
|
|
2078
|
-
transport: dc.mac ? "mac-telnet" : "ssh",
|
|
3199
|
+
transport: dc.mac ? "mac-telnet" : dc.api ? "rest" : "ssh",
|
|
3200
|
+
restPort: dc.api ? dc.apiPort ?? 443 : undefined,
|
|
2079
3201
|
address: dc.mac ? dc.mac : `${dc.host}:${dc.port}`,
|
|
2080
3202
|
username: dc.username,
|
|
2081
3203
|
authMode: dc.mac ? "mac-telnet" : dc.keyFilename || dc.privateKey ? "key" : dc.password ? "password" : "none",
|
|
@@ -2237,11 +3359,11 @@ function issues(error) {
|
|
|
2237
3359
|
async function configRoutes(req, url, admin) {
|
|
2238
3360
|
const p = url.pathname;
|
|
2239
3361
|
if (p === "/api/config-schema" && req.method === "GET") {
|
|
2240
|
-
return
|
|
3362
|
+
return json12(configSchemaJson());
|
|
2241
3363
|
}
|
|
2242
3364
|
if (p === "/api/config/validate" && req.method === "POST") {
|
|
2243
3365
|
const merged = mergeSecrets(await readJson(req), getConfig());
|
|
2244
|
-
return
|
|
3366
|
+
return json12(validateConfig(merged));
|
|
2245
3367
|
}
|
|
2246
3368
|
if (p === "/api/config/test-device" && req.method === "POST") {
|
|
2247
3369
|
const body = await readJson(req);
|
|
@@ -2250,22 +3372,22 @@ async function configRoutes(req, url, admin) {
|
|
|
2250
3372
|
const merged = mergeSecrets(body?.config, currentDc);
|
|
2251
3373
|
const parsed = DeviceConfigSchema.safeParse(merged);
|
|
2252
3374
|
if (!parsed.success)
|
|
2253
|
-
return
|
|
3375
|
+
return json12({ ok: false, errors: issues(parsed.error) }, 400);
|
|
2254
3376
|
const status = await probeDevice(`config-test:${name}`, parsed.data);
|
|
2255
|
-
return
|
|
3377
|
+
return json12({ ok: true, status });
|
|
2256
3378
|
}
|
|
2257
3379
|
if (p === "/api/config/preview" && req.method === "POST") {
|
|
2258
3380
|
const before = JSON.stringify(redact(getConfig()), null, 2);
|
|
2259
3381
|
const after = JSON.stringify(await readJson(req) ?? {}, null, 2);
|
|
2260
3382
|
const d = diffLines(before, after, { fromLabel: "current", toLabel: "edited" });
|
|
2261
|
-
return
|
|
3383
|
+
return json12({ summary: d.summary, unified: d.unified });
|
|
2262
3384
|
}
|
|
2263
3385
|
if (p === "/api/config" && req.method === "POST") {
|
|
2264
3386
|
const body = await readJson(req);
|
|
2265
3387
|
const merged = mergeSecrets(body?.config, getConfig());
|
|
2266
3388
|
const v = validateConfig(merged);
|
|
2267
3389
|
if (!v.ok || !v.value)
|
|
2268
|
-
return
|
|
3390
|
+
return json12({ ok: false, errors: v.errors }, 400);
|
|
2269
3391
|
const prev = getConfig();
|
|
2270
3392
|
const before = JSON.stringify(redact(prev), null, 2);
|
|
2271
3393
|
const after = JSON.stringify(redact(v.value), null, 2);
|
|
@@ -2274,25 +3396,25 @@ async function configRoutes(req, url, admin) {
|
|
|
2274
3396
|
const nextDevs = Object.keys(v.value.devices);
|
|
2275
3397
|
const devicesChanged = prevDevs.length !== nextDevs.length || prevDevs.some((d) => !nextDevs.includes(d));
|
|
2276
3398
|
const res = admin.applyConfig(v.value, clampRollback(body?.rollbackMs));
|
|
2277
|
-
return
|
|
3399
|
+
return json12({ ok: true, ...res, devicesChanged, summary: diff.summary, unified: diff.unified });
|
|
2278
3400
|
}
|
|
2279
3401
|
if (p === "/api/config/keep" && req.method === "POST") {
|
|
2280
3402
|
const body = await readJson(req);
|
|
2281
3403
|
const kept = admin.keepConfig(String(body?.pendingId ?? ""));
|
|
2282
3404
|
if (kept)
|
|
2283
3405
|
recordVersion(getConfig(), "auto", Date.now());
|
|
2284
|
-
return
|
|
3406
|
+
return json12({ kept });
|
|
2285
3407
|
}
|
|
2286
3408
|
if (p === "/api/config/rollback" && req.method === "POST") {
|
|
2287
3409
|
const body = await readJson(req);
|
|
2288
3410
|
const rolledBack = admin.rollback(String(body?.pendingId ?? ""));
|
|
2289
|
-
return
|
|
3411
|
+
return json12({ rolledBack, config: redact(getConfig()) });
|
|
2290
3412
|
}
|
|
2291
3413
|
if (p === "/api/reload" && req.method === "POST") {
|
|
2292
3414
|
const body = await readJson(req);
|
|
2293
3415
|
if (body?.hard === true) {
|
|
2294
3416
|
const relaunched = restartProcess();
|
|
2295
|
-
return
|
|
3417
|
+
return json12({
|
|
2296
3418
|
ok: true,
|
|
2297
3419
|
mode: "restart",
|
|
2298
3420
|
note: relaunched ? "Restarting now \u2014 a fresh server process was launched and will bind the same port(s) in ~1.5s. Reconnect shortly." : "Could not self-relaunch; the process is exiting. It only comes back if a supervisor respawns it."
|
|
@@ -2303,11 +3425,10 @@ async function configRoutes(req, url, admin) {
|
|
|
2303
3425
|
setConfig(cfg);
|
|
2304
3426
|
const devices = Object.keys(cfg.devices);
|
|
2305
3427
|
logger.info(`Config reloaded from source \u2014 ${devices.length} device(s): ${devices.join(", ")}`);
|
|
2306
|
-
return
|
|
3428
|
+
return json12({ ok: true, mode: "config", count: devices.length, devices });
|
|
2307
3429
|
} catch (e) {
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
return json3({ ok: false, error }, 500);
|
|
3430
|
+
logger.error(`Config reload failed: ${logError(e)}`);
|
|
3431
|
+
return json12({ ok: false, error: clientError(e) }, 500);
|
|
2311
3432
|
}
|
|
2312
3433
|
}
|
|
2313
3434
|
if (p === "/api/config/history" && req.method === "GET") {
|
|
@@ -2323,55 +3444,55 @@ async function configRoutes(req, url, admin) {
|
|
|
2323
3444
|
} catch {}
|
|
2324
3445
|
return { ...v, drift: { added, removed } };
|
|
2325
3446
|
});
|
|
2326
|
-
return
|
|
3447
|
+
return json12({ versions, bytes: historyBytes(), retention: AUTO_RETENTION });
|
|
2327
3448
|
}
|
|
2328
3449
|
if (p === "/api/config/history/get" && req.method === "GET") {
|
|
2329
3450
|
const id = url.searchParams.get("id");
|
|
2330
3451
|
if (!id)
|
|
2331
|
-
return
|
|
3452
|
+
return json12({ error: "id required" }, 400);
|
|
2332
3453
|
try {
|
|
2333
3454
|
const v = readVersion(id);
|
|
2334
|
-
return
|
|
3455
|
+
return json12({
|
|
2335
3456
|
ts: v.ts,
|
|
2336
3457
|
kind: v.kind,
|
|
2337
3458
|
label: v.label,
|
|
2338
3459
|
config: JSON.stringify(redact(v.config), null, 2)
|
|
2339
3460
|
});
|
|
2340
3461
|
} catch {
|
|
2341
|
-
return
|
|
3462
|
+
return json12({ error: "not found" }, 404);
|
|
2342
3463
|
}
|
|
2343
3464
|
}
|
|
2344
3465
|
if (p === "/api/config/history/diff" && req.method === "GET") {
|
|
2345
3466
|
const id = url.searchParams.get("id");
|
|
2346
3467
|
if (!id)
|
|
2347
|
-
return
|
|
3468
|
+
return json12({ error: "id required" }, 400);
|
|
2348
3469
|
try {
|
|
2349
3470
|
const before = JSON.stringify(redact(readVersion(id).config), null, 2);
|
|
2350
3471
|
const after = JSON.stringify(redact(getConfig()), null, 2);
|
|
2351
3472
|
const d = diffLines(before, after, { fromLabel: "this version", toLabel: "current" });
|
|
2352
|
-
return
|
|
3473
|
+
return json12({ summary: d.summary, unified: d.unified });
|
|
2353
3474
|
} catch {
|
|
2354
|
-
return
|
|
3475
|
+
return json12({ error: "not found" }, 404);
|
|
2355
3476
|
}
|
|
2356
3477
|
}
|
|
2357
3478
|
if (p === "/api/config/history/checkpoint" && req.method === "POST") {
|
|
2358
3479
|
const b = await readJson(req);
|
|
2359
3480
|
const label = b?.label?.trim() || "checkpoint";
|
|
2360
|
-
return
|
|
3481
|
+
return json12({ ok: true, version: recordVersion(getConfig(), "checkpoint", Date.now(), label) });
|
|
2361
3482
|
}
|
|
2362
3483
|
if (p === "/api/config/history/restore" && req.method === "POST") {
|
|
2363
3484
|
const b = await readJson(req);
|
|
2364
3485
|
if (!b?.id)
|
|
2365
|
-
return
|
|
3486
|
+
return json12({ error: "id required" }, 400);
|
|
2366
3487
|
let target;
|
|
2367
3488
|
try {
|
|
2368
3489
|
target = readVersion(b.id);
|
|
2369
3490
|
} catch {
|
|
2370
|
-
return
|
|
3491
|
+
return json12({ error: "not found" }, 404);
|
|
2371
3492
|
}
|
|
2372
3493
|
const v = validateConfig(target.config);
|
|
2373
3494
|
if (!v.ok || !v.value)
|
|
2374
|
-
return
|
|
3495
|
+
return json12({ ok: false, errors: v.errors }, 400);
|
|
2375
3496
|
recordVersion(getConfig(), "auto", Date.now(), "before restore");
|
|
2376
3497
|
setConfig(v.value);
|
|
2377
3498
|
let persisted = true;
|
|
@@ -2382,13 +3503,13 @@ async function configRoutes(req, url, admin) {
|
|
|
2382
3503
|
}
|
|
2383
3504
|
const label = target.label ? `restored "${target.label}"` : `restored ${b.id}`;
|
|
2384
3505
|
recordVersion(getConfig(), "auto", Date.now(), label);
|
|
2385
|
-
return
|
|
3506
|
+
return json12({ ok: true, persisted, restored: b.id, config: redact(getConfig()) });
|
|
2386
3507
|
}
|
|
2387
3508
|
if (p === "/api/config/history/delete" && req.method === "POST") {
|
|
2388
3509
|
const b = await readJson(req);
|
|
2389
3510
|
if (!b?.id)
|
|
2390
|
-
return
|
|
2391
|
-
return deleteVersion(b.id) ?
|
|
3511
|
+
return json12({ error: "id required" }, 400);
|
|
3512
|
+
return deleteVersion(b.id) ? json12({ ok: true }) : json12({ error: "not found" }, 404);
|
|
2392
3513
|
}
|
|
2393
3514
|
return null;
|
|
2394
3515
|
}
|
|
@@ -2399,7 +3520,7 @@ async function modulesRoutes(req, url) {
|
|
|
2399
3520
|
if (p === "/api/modules" && req.method === "GET") {
|
|
2400
3521
|
const cfg = getConfig();
|
|
2401
3522
|
const src = getConfigSource();
|
|
2402
|
-
return
|
|
3523
|
+
return json12({
|
|
2403
3524
|
...moduleSurface(cfg.tools),
|
|
2404
3525
|
filter: cfg.tools,
|
|
2405
3526
|
source: src,
|
|
@@ -2409,11 +3530,11 @@ async function modulesRoutes(req, url) {
|
|
|
2409
3530
|
if (p === "/api/modules/toggle" && req.method === "POST") {
|
|
2410
3531
|
const b = await readJson(req);
|
|
2411
3532
|
if (typeof b?.slug !== "string" || typeof b?.enabled !== "boolean") {
|
|
2412
|
-
return
|
|
3533
|
+
return json12({ error: "slug (string) and enabled (boolean) are required" }, 400);
|
|
2413
3534
|
}
|
|
2414
3535
|
const mod = moduleCatalog.find((m) => m.slug.toLowerCase() === b.slug.toLowerCase());
|
|
2415
3536
|
if (!mod)
|
|
2416
|
-
return
|
|
3537
|
+
return json12({ error: `unknown module: ${b.slug}` }, 404);
|
|
2417
3538
|
const cfg = getConfig();
|
|
2418
3539
|
const nextTools = ToolFilterSchema.parse(applyModuleToggle(cfg.tools, mod.slug, b.enabled));
|
|
2419
3540
|
const next = { ...cfg, tools: nextTools };
|
|
@@ -2424,12 +3545,12 @@ async function modulesRoutes(req, url) {
|
|
|
2424
3545
|
atomicWrite(getConfigSource().path, serializeConfig(next));
|
|
2425
3546
|
} catch (e) {
|
|
2426
3547
|
persisted = false;
|
|
2427
|
-
warning = `applied live but not saved to disk: ${
|
|
3548
|
+
warning = `applied live but not saved to disk: ${clientError(e)}`;
|
|
2428
3549
|
}
|
|
2429
3550
|
if (persisted) {
|
|
2430
3551
|
recordVersion(getConfig(), "auto", Date.now(), `module ${b.enabled ? "enabled" : "disabled"}: ${mod.slug}`);
|
|
2431
3552
|
}
|
|
2432
|
-
return
|
|
3553
|
+
return json12({
|
|
2433
3554
|
ok: true,
|
|
2434
3555
|
persisted,
|
|
2435
3556
|
requiresReconnect: true,
|
|
@@ -2444,7 +3565,7 @@ async function modulesRoutes(req, url) {
|
|
|
2444
3565
|
if (p === "/api/modules/app-views" && req.method === "POST") {
|
|
2445
3566
|
const b = await readJson(req);
|
|
2446
3567
|
if (typeof b?.enabled !== "boolean") {
|
|
2447
|
-
return
|
|
3568
|
+
return json12({ error: "enabled (boolean) is required" }, 400);
|
|
2448
3569
|
}
|
|
2449
3570
|
const cfg = getConfig();
|
|
2450
3571
|
const next = { ...cfg, mcp: { ...cfg.mcp, appViews: b.enabled } };
|
|
@@ -2455,12 +3576,12 @@ async function modulesRoutes(req, url) {
|
|
|
2455
3576
|
atomicWrite(getConfigSource().path, serializeConfig(next));
|
|
2456
3577
|
} catch (e) {
|
|
2457
3578
|
persisted = false;
|
|
2458
|
-
warning = `applied live but not saved to disk: ${
|
|
3579
|
+
warning = `applied live but not saved to disk: ${clientError(e)}`;
|
|
2459
3580
|
}
|
|
2460
3581
|
if (persisted) {
|
|
2461
3582
|
recordVersion(getConfig(), "auto", Date.now(), `app views ${b.enabled ? "enabled" : "disabled"}`);
|
|
2462
3583
|
}
|
|
2463
|
-
return
|
|
3584
|
+
return json12({
|
|
2464
3585
|
ok: true,
|
|
2465
3586
|
persisted,
|
|
2466
3587
|
requiresReconnect: true,
|
|
@@ -2474,11 +3595,11 @@ async function modulesRoutes(req, url) {
|
|
|
2474
3595
|
async function captureRoutes(req, url) {
|
|
2475
3596
|
const p = url.pathname;
|
|
2476
3597
|
if (p === "/api/capture/status" && req.method === "GET") {
|
|
2477
|
-
return
|
|
3598
|
+
return json12(capture.stats());
|
|
2478
3599
|
}
|
|
2479
3600
|
if (p === "/api/capture/packets" && req.method === "GET") {
|
|
2480
3601
|
const limit = Number(url.searchParams.get("limit") ?? 200) || 200;
|
|
2481
|
-
return
|
|
3602
|
+
return json12({ packets: capture.recent(limit), stats: capture.stats() });
|
|
2482
3603
|
}
|
|
2483
3604
|
if (p === "/api/capture/pcap" && req.method === "GET") {
|
|
2484
3605
|
return new Response(capture.pcap(), {
|
|
@@ -2490,11 +3611,11 @@ async function captureRoutes(req, url) {
|
|
|
2490
3611
|
}
|
|
2491
3612
|
if (p === "/api/capture/start" && req.method === "POST") {
|
|
2492
3613
|
const body = await readJson(req);
|
|
2493
|
-
return
|
|
3614
|
+
return json12(await capture.start(typeof body?.port === "number" ? body.port : DEFAULT_TZSP_PORT));
|
|
2494
3615
|
}
|
|
2495
3616
|
if (p === "/api/capture/stop" && req.method === "POST") {
|
|
2496
3617
|
capture.stop();
|
|
2497
|
-
return
|
|
3618
|
+
return json12({ ok: true, ...capture.stats() });
|
|
2498
3619
|
}
|
|
2499
3620
|
return null;
|
|
2500
3621
|
}
|
|
@@ -2506,24 +3627,24 @@ async function capsmanRoutes(req, url) {
|
|
|
2506
3627
|
if (req.method === "GET") {
|
|
2507
3628
|
const ctx = createContext(undefined, url.searchParams.get("device") ?? undefined);
|
|
2508
3629
|
if (p === "/api/capsman/overview") {
|
|
2509
|
-
return
|
|
3630
|
+
return json12(capsmanOverview(await fetchCapsmanState(ctx)));
|
|
2510
3631
|
}
|
|
2511
3632
|
if (p === "/api/capsman/clients") {
|
|
2512
3633
|
const state = await fetchCapsmanState(ctx);
|
|
2513
3634
|
const weakDbm = Number.parseInt(url.searchParams.get("weak_dbm") ?? "", 10);
|
|
2514
3635
|
const weak = reportWeakClients(state, Number.isFinite(weakDbm) ? weakDbm : undefined);
|
|
2515
|
-
return
|
|
3636
|
+
return json12({ clients: state.clients, weak });
|
|
2516
3637
|
}
|
|
2517
3638
|
if (p === "/api/capsman/audit") {
|
|
2518
|
-
return
|
|
3639
|
+
return json12(runCapsmanAudit(await fetchCapsmanState(ctx)));
|
|
2519
3640
|
}
|
|
2520
3641
|
if (p === "/api/capsman/trends") {
|
|
2521
3642
|
if (!capsmanStore)
|
|
2522
|
-
return
|
|
3643
|
+
return json12({ error: "capsman store not active" }, 503);
|
|
2523
3644
|
const device = resolveDeviceName(url.searchParams.get("device") ?? undefined);
|
|
2524
3645
|
const days = daysParam(url, 7, 30);
|
|
2525
3646
|
const series = capsmanStore.radioSeries(device, Date.now() - days * 86400000);
|
|
2526
|
-
return
|
|
3647
|
+
return json12({ series, days });
|
|
2527
3648
|
}
|
|
2528
3649
|
return null;
|
|
2529
3650
|
}
|
|
@@ -2534,45 +3655,45 @@ async function capsmanRoutes(req, url) {
|
|
|
2534
3655
|
if (p === "/api/capsman/apply/steer") {
|
|
2535
3656
|
const client = state.clients.find((c) => c.mac.toLowerCase() === (body.mac ?? "").toLowerCase());
|
|
2536
3657
|
if (!client)
|
|
2537
|
-
return
|
|
3658
|
+
return json12({ ok: false, error: "client not associated" }, 400);
|
|
2538
3659
|
if (steerAlreadyPresent(state, body.mac ?? "")) {
|
|
2539
|
-
return
|
|
3660
|
+
return json12({ ok: true, message: "already steered (no-op)" });
|
|
2540
3661
|
}
|
|
2541
3662
|
const mode = body.mode === "soft" ? "soft" : "hard";
|
|
2542
3663
|
const commands = buildSteerCommands(state, body.mac ?? "", client.radioId, mode);
|
|
2543
3664
|
if (!body.confirm)
|
|
2544
|
-
return
|
|
2545
|
-
return
|
|
3665
|
+
return json12({ ok: true, preview: commands });
|
|
3666
|
+
return json12(await applyCapsmanWrites(ctx, commands, `pre-steer-${body.mac}`));
|
|
2546
3667
|
}
|
|
2547
3668
|
if (p === "/api/capsman/apply/load-balance") {
|
|
2548
3669
|
const plan = loadBalancePlan(state);
|
|
2549
3670
|
const commands = buildLoadBalanceCommands(state, plan);
|
|
2550
3671
|
if (!body.confirm)
|
|
2551
|
-
return
|
|
2552
|
-
return
|
|
3672
|
+
return json12({ ok: true, preview: commands, plan });
|
|
3673
|
+
return json12(await applyCapsmanWrites(ctx, commands, "pre-load-balance"));
|
|
2553
3674
|
}
|
|
2554
3675
|
if (p === "/api/capsman/apply/channel-plan") {
|
|
2555
3676
|
if (state.path === "/caps-man") {
|
|
2556
|
-
return
|
|
3677
|
+
return json12({ ok: false, error: "channel-plan apply is v7 /interface wifi only" }, 400);
|
|
2557
3678
|
}
|
|
2558
3679
|
const commands = buildChannelPlanCommands(state);
|
|
2559
3680
|
if (!body.confirm)
|
|
2560
|
-
return
|
|
2561
|
-
return
|
|
3681
|
+
return json12({ ok: true, preview: commands });
|
|
3682
|
+
return json12(await applyCapsmanWrites(ctx, commands, "pre-channel-plan"));
|
|
2562
3683
|
}
|
|
2563
3684
|
if (p === "/api/capsman/apply/ft") {
|
|
2564
3685
|
const commands = buildFtCommands(state);
|
|
2565
3686
|
if (!body.confirm)
|
|
2566
|
-
return
|
|
2567
|
-
return
|
|
3687
|
+
return json12({ ok: true, preview: commands });
|
|
3688
|
+
return json12(await applyCapsmanWrites(ctx, commands, "pre-ft"));
|
|
2568
3689
|
}
|
|
2569
3690
|
if (p === "/api/capsman/apply/ha") {
|
|
2570
3691
|
const commands = buildHaCommands(state);
|
|
2571
3692
|
const guidance = haGuidance(state);
|
|
2572
3693
|
if (!body.confirm)
|
|
2573
|
-
return
|
|
3694
|
+
return json12({ ok: true, preview: commands, guidance });
|
|
2574
3695
|
const res = await applyCapsmanWrites(ctx, commands, "pre-ha");
|
|
2575
|
-
return
|
|
3696
|
+
return json12({ ...res, guidance });
|
|
2576
3697
|
}
|
|
2577
3698
|
}
|
|
2578
3699
|
return null;
|
|
@@ -2598,32 +3719,32 @@ async function clientsRoutes(req, url) {
|
|
|
2598
3719
|
const deviceFromQuery = () => url.searchParams.get("device") ?? undefined;
|
|
2599
3720
|
if (p === "/api/clients" && req.method === "GET") {
|
|
2600
3721
|
const ctx = createContext(undefined, deviceFromQuery());
|
|
2601
|
-
return
|
|
3722
|
+
return json12(devicesView(await fetchDevices(ctx)));
|
|
2602
3723
|
}
|
|
2603
3724
|
if (p === "/api/clients/traffic" && req.method === "GET") {
|
|
2604
3725
|
const ip = url.searchParams.get("ip");
|
|
2605
3726
|
if (!ip)
|
|
2606
|
-
return
|
|
3727
|
+
return json12({ error: "ip required" }, 400);
|
|
2607
3728
|
const ctx = createContext(undefined, deviceFromQuery());
|
|
2608
|
-
return
|
|
3729
|
+
return json12(await sampleDeviceTraffic(ctx, ip));
|
|
2609
3730
|
}
|
|
2610
3731
|
if (p === "/api/clients/traffic-bulk" && req.method === "GET") {
|
|
2611
3732
|
const ctx = createContext(undefined, deviceFromQuery());
|
|
2612
|
-
return
|
|
3733
|
+
return json12(await sampleAllTraffic(ctx));
|
|
2613
3734
|
}
|
|
2614
3735
|
if (req.method === "POST") {
|
|
2615
3736
|
const b = await readJson(req);
|
|
2616
3737
|
const ctx = createContext(undefined, b?.device);
|
|
2617
3738
|
if (p === "/api/clients/limits") {
|
|
2618
3739
|
if (!b?.ip)
|
|
2619
|
-
return
|
|
2620
|
-
return
|
|
3740
|
+
return json12({ error: "ip required" }, 400);
|
|
3741
|
+
return json12(await setDeviceLimits(ctx, b.ip, { download: b.download, upload: b.upload }));
|
|
2621
3742
|
}
|
|
2622
3743
|
if (!b?.mac)
|
|
2623
|
-
return
|
|
3744
|
+
return json12({ error: "mac required" }, 400);
|
|
2624
3745
|
const run = async (op) => {
|
|
2625
3746
|
const r = await op;
|
|
2626
|
-
return
|
|
3747
|
+
return json12({ ...r, view: r.ok ? devicesView(await fetchDevices(ctx)) : undefined });
|
|
2627
3748
|
};
|
|
2628
3749
|
if (p === "/api/clients/block")
|
|
2629
3750
|
return run(blockDevice(ctx, b.mac, b.comment));
|
|
@@ -2635,12 +3756,12 @@ async function clientsRoutes(req, url) {
|
|
|
2635
3756
|
return run(removeDeviceLease(ctx, b.mac));
|
|
2636
3757
|
if (p === "/api/clients/set-ip") {
|
|
2637
3758
|
if (!b.ip)
|
|
2638
|
-
return
|
|
3759
|
+
return json12({ error: "ip required" }, 400);
|
|
2639
3760
|
return run(setDeviceIp(ctx, b.mac, b.ip));
|
|
2640
3761
|
}
|
|
2641
3762
|
if (p === "/api/clients/label") {
|
|
2642
3763
|
if (typeof b.label !== "string")
|
|
2643
|
-
return
|
|
3764
|
+
return json12({ error: "label required" }, 400);
|
|
2644
3765
|
return run(setDeviceLabel(ctx, b.mac, b.label));
|
|
2645
3766
|
}
|
|
2646
3767
|
}
|
|
@@ -2653,20 +3774,20 @@ async function aaaRoutes(req, url) {
|
|
|
2653
3774
|
const deviceFromQuery = () => url.searchParams.get("device") ?? undefined;
|
|
2654
3775
|
if (req.method === "GET") {
|
|
2655
3776
|
if (p === "/api/aaa/entities") {
|
|
2656
|
-
return
|
|
3777
|
+
return json12({ entities: AAA_ENTITIES });
|
|
2657
3778
|
}
|
|
2658
3779
|
if (p === "/api/aaa/radius-incoming") {
|
|
2659
|
-
return
|
|
3780
|
+
return json12(await getRadiusIncoming(createContext(undefined, deviceFromQuery())));
|
|
2660
3781
|
}
|
|
2661
3782
|
if (p === "/api/aaa/um-settings") {
|
|
2662
|
-
return
|
|
3783
|
+
return json12(await getUmSettings(createContext(undefined, deviceFromQuery())));
|
|
2663
3784
|
}
|
|
2664
3785
|
const listMatch = p.match(/^\/api\/aaa\/list\/([\w-]+)$/);
|
|
2665
3786
|
if (listMatch) {
|
|
2666
3787
|
const slug = listMatch[1];
|
|
2667
3788
|
if (!AAA_ENTITIES[slug])
|
|
2668
|
-
return
|
|
2669
|
-
return
|
|
3789
|
+
return json12({ error: "unknown entity" }, 404);
|
|
3790
|
+
return json12(await listAaaEntity(createContext(undefined, deviceFromQuery()), slug));
|
|
2670
3791
|
}
|
|
2671
3792
|
return null;
|
|
2672
3793
|
}
|
|
@@ -2674,30 +3795,30 @@ async function aaaRoutes(req, url) {
|
|
|
2674
3795
|
const body = await readJson(req);
|
|
2675
3796
|
const ctx = createContext(undefined, body?.device);
|
|
2676
3797
|
if (p === "/api/aaa/radius-incoming")
|
|
2677
|
-
return
|
|
3798
|
+
return json12(await setRadiusIncoming(ctx, body?.fields ?? {}));
|
|
2678
3799
|
if (p === "/api/aaa/um-settings")
|
|
2679
|
-
return
|
|
3800
|
+
return json12(await setUmSettings(ctx, body?.fields ?? {}));
|
|
2680
3801
|
if (p === "/api/aaa/radius-reset-counters")
|
|
2681
|
-
return
|
|
3802
|
+
return json12(await resetRadiusCounters(ctx));
|
|
2682
3803
|
const slug = body?.slug ?? "";
|
|
2683
3804
|
if (!AAA_ENTITIES[slug])
|
|
2684
|
-
return
|
|
3805
|
+
return json12({ error: "unknown entity" }, 404);
|
|
2685
3806
|
if (p === "/api/aaa/add")
|
|
2686
|
-
return
|
|
3807
|
+
return json12(await addAaaEntity(ctx, slug, body?.fields ?? {}));
|
|
2687
3808
|
if (p === "/api/aaa/update") {
|
|
2688
3809
|
if (!body?.id)
|
|
2689
|
-
return
|
|
2690
|
-
return
|
|
3810
|
+
return json12({ error: "id required" }, 400);
|
|
3811
|
+
return json12(await updateAaaEntity(ctx, slug, body.id, body?.fields ?? {}));
|
|
2691
3812
|
}
|
|
2692
3813
|
if (p === "/api/aaa/remove") {
|
|
2693
3814
|
if (!body?.id)
|
|
2694
|
-
return
|
|
2695
|
-
return
|
|
3815
|
+
return json12({ error: "id required" }, 400);
|
|
3816
|
+
return json12(await removeAaaEntity(ctx, slug, body.id));
|
|
2696
3817
|
}
|
|
2697
3818
|
if (p === "/api/aaa/toggle") {
|
|
2698
3819
|
if (!body?.id)
|
|
2699
|
-
return
|
|
2700
|
-
return
|
|
3820
|
+
return json12({ error: "id required" }, 400);
|
|
3821
|
+
return json12(await toggleAaaEntity(ctx, slug, body.id, body?.enable === true));
|
|
2701
3822
|
}
|
|
2702
3823
|
}
|
|
2703
3824
|
return null;
|
|
@@ -2713,43 +3834,43 @@ async function usageRoutes(req, url) {
|
|
|
2713
3834
|
return null;
|
|
2714
3835
|
if (p === "/api/usage/sampler") {
|
|
2715
3836
|
if (req.method === "GET")
|
|
2716
|
-
return
|
|
3837
|
+
return json12({ intervalMs: getUsageSamplerInterval() });
|
|
2717
3838
|
if (req.method === "POST") {
|
|
2718
3839
|
const b = await readJson(req);
|
|
2719
3840
|
const applied = setUsageSamplerInterval(Number(b?.intervalMs));
|
|
2720
|
-
return
|
|
3841
|
+
return json12({ intervalMs: applied });
|
|
2721
3842
|
}
|
|
2722
3843
|
return null;
|
|
2723
3844
|
}
|
|
2724
3845
|
if (req.method !== "GET")
|
|
2725
3846
|
return null;
|
|
2726
3847
|
if (!usageStore)
|
|
2727
|
-
return
|
|
3848
|
+
return json12({ error: "usage store not active" }, 503);
|
|
2728
3849
|
const device = resolveDeviceName(url.searchParams.get("device") ?? undefined);
|
|
2729
3850
|
const sinceTs = (days) => Date.now() - days * 86400000;
|
|
2730
3851
|
if (p === "/api/usage/um-users") {
|
|
2731
|
-
return
|
|
3852
|
+
return json12({ users: usageStore.umUsers(device) });
|
|
2732
3853
|
}
|
|
2733
3854
|
if (p === "/api/usage/client") {
|
|
2734
3855
|
const ip = url.searchParams.get("ip");
|
|
2735
3856
|
if (!ip)
|
|
2736
|
-
return
|
|
3857
|
+
return json12({ error: "ip required" }, 400);
|
|
2737
3858
|
const series = usageStore.clientDailyUsage(device, ip, sinceTs(daysParam(url, 90, 400)));
|
|
2738
|
-
return
|
|
3859
|
+
return json12(withTotals(series));
|
|
2739
3860
|
}
|
|
2740
3861
|
if (p === "/api/usage/um-user") {
|
|
2741
3862
|
const user = url.searchParams.get("user");
|
|
2742
3863
|
if (!user)
|
|
2743
|
-
return
|
|
3864
|
+
return json12({ error: "user required" }, 400);
|
|
2744
3865
|
const series = usageStore.umUserDailyUsage(device, user, sinceTs(daysParam(url, 90, 400)));
|
|
2745
|
-
return
|
|
3866
|
+
return json12(withTotals(series));
|
|
2746
3867
|
}
|
|
2747
3868
|
if (p === "/api/usage/heatmap") {
|
|
2748
3869
|
const user = url.searchParams.get("user");
|
|
2749
3870
|
const days = usageStore.heatmap(device, user || null, sinceTs(daysParam(url, 371, 400)));
|
|
2750
3871
|
const total = days.reduce((s, d) => s + d.count, 0);
|
|
2751
3872
|
const max = days.reduce((m, d) => Math.max(m, d.count), 0);
|
|
2752
|
-
return
|
|
3873
|
+
return json12({ days, total, max });
|
|
2753
3874
|
}
|
|
2754
3875
|
return null;
|
|
2755
3876
|
}
|
|
@@ -2769,31 +3890,31 @@ function snapStore() {
|
|
|
2769
3890
|
async function featureRoutes(req, url) {
|
|
2770
3891
|
const p = url.pathname;
|
|
2771
3892
|
if (p === "/api/snapshots" && req.method === "GET") {
|
|
2772
|
-
const
|
|
3893
|
+
const store3 = await snapStore();
|
|
2773
3894
|
const cfg = getConfig();
|
|
2774
|
-
const all = Object.keys(cfg.devices).flatMap((d) =>
|
|
3895
|
+
const all = Object.keys(cfg.devices).flatMap((d) => store3.list(d, 200, false));
|
|
2775
3896
|
all.sort((a, b) => b.ts - a.ts);
|
|
2776
|
-
return
|
|
3897
|
+
return json12({ snapshots: all });
|
|
2777
3898
|
}
|
|
2778
3899
|
const snapMatch = p.match(/^\/api\/snapshot\/(.+)$/);
|
|
2779
3900
|
if (snapMatch && req.method === "GET") {
|
|
2780
|
-
const
|
|
2781
|
-
const s =
|
|
2782
|
-
return s ?
|
|
3901
|
+
const store3 = await snapStore();
|
|
3902
|
+
const s = store3.get(decodeURIComponent(snapMatch[1]));
|
|
3903
|
+
return s ? json12(s) : json12({ error: "not found" }, 404);
|
|
2783
3904
|
}
|
|
2784
3905
|
if (p === "/api/snapshots/diff" && req.method === "POST") {
|
|
2785
|
-
const
|
|
3906
|
+
const store3 = await snapStore();
|
|
2786
3907
|
const b = await readJson(req);
|
|
2787
|
-
const from = b?.from ?
|
|
2788
|
-
const to = b?.to ?
|
|
3908
|
+
const from = b?.from ? store3.get(b.from) : null;
|
|
3909
|
+
const to = b?.to ? store3.get(b.to) : null;
|
|
2789
3910
|
if (!from || !to) {
|
|
2790
|
-
return
|
|
3911
|
+
return json12({ error: "both 'from' and 'to' snapshot ids are required" }, 400);
|
|
2791
3912
|
}
|
|
2792
3913
|
const diff = diffLines(normalizeExport(from.body), normalizeExport(to.body), {
|
|
2793
3914
|
fromLabel: from.label ?? from.id,
|
|
2794
3915
|
toLabel: to.label ?? to.id
|
|
2795
3916
|
});
|
|
2796
|
-
return
|
|
3917
|
+
return json12({
|
|
2797
3918
|
from: { id: from.id, label: from.label, ts: from.ts, device: from.device },
|
|
2798
3919
|
to: { id: to.id, label: to.label, ts: to.ts, device: to.device },
|
|
2799
3920
|
summary: diff.summary,
|
|
@@ -2807,16 +3928,16 @@ async function featureRoutes(req, url) {
|
|
|
2807
3928
|
...b?.script ? splitCommands(b.script) : []
|
|
2808
3929
|
];
|
|
2809
3930
|
if (commands.length === 0)
|
|
2810
|
-
return
|
|
3931
|
+
return json12({ error: "no commands provided" }, 400);
|
|
2811
3932
|
const plan = buildChangePlan(commands);
|
|
2812
|
-
return
|
|
3933
|
+
return json12({ plan, text: renderPlan(plan) });
|
|
2813
3934
|
}
|
|
2814
3935
|
if (p === "/api/s3" && req.method === "GET") {
|
|
2815
|
-
return
|
|
3936
|
+
return json12({ configured: isS3Configured(), target: isS3Configured() ? s3Target() : null });
|
|
2816
3937
|
}
|
|
2817
3938
|
if (p === "/api/s3/list" && req.method === "GET") {
|
|
2818
3939
|
if (!isS3Configured())
|
|
2819
|
-
return
|
|
3940
|
+
return json12({ configured: false, objects: [] });
|
|
2820
3941
|
const prefix = url.searchParams.get("prefix") ?? "";
|
|
2821
3942
|
try {
|
|
2822
3943
|
const res = await getS3Client().list({ prefix: prefix || undefined, maxKeys: 1000 });
|
|
@@ -2825,42 +3946,42 @@ async function featureRoutes(req, url) {
|
|
|
2825
3946
|
size: o.size ?? 0,
|
|
2826
3947
|
lastModified: o.lastModified ?? null
|
|
2827
3948
|
}));
|
|
2828
|
-
return
|
|
3949
|
+
return json12({ configured: true, target: s3Target(), objects, truncated: !!res.isTruncated });
|
|
2829
3950
|
} catch (e) {
|
|
2830
|
-
return
|
|
3951
|
+
return json12({ error: clientError(e) }, 502);
|
|
2831
3952
|
}
|
|
2832
3953
|
}
|
|
2833
3954
|
if (p === "/api/s3/presign" && req.method === "GET") {
|
|
2834
3955
|
if (!isS3Configured())
|
|
2835
|
-
return
|
|
3956
|
+
return json12({ error: "S3 not configured" }, 400);
|
|
2836
3957
|
const key = url.searchParams.get("key");
|
|
2837
3958
|
if (!key)
|
|
2838
|
-
return
|
|
3959
|
+
return json12({ error: "key required" }, 400);
|
|
2839
3960
|
try {
|
|
2840
3961
|
const link = getS3Client().presign(key, { expiresIn: presignExpiresIn(), method: "GET" });
|
|
2841
|
-
return
|
|
3962
|
+
return json12({ url: link });
|
|
2842
3963
|
} catch (e) {
|
|
2843
|
-
return
|
|
3964
|
+
return json12({ error: clientError(e) }, 502);
|
|
2844
3965
|
}
|
|
2845
3966
|
}
|
|
2846
3967
|
if (p === "/api/s3/delete" && req.method === "POST") {
|
|
2847
3968
|
if (!isS3Configured())
|
|
2848
|
-
return
|
|
3969
|
+
return json12({ error: "S3 not configured" }, 400);
|
|
2849
3970
|
const b = await readJson(req);
|
|
2850
3971
|
if (!b?.key)
|
|
2851
|
-
return
|
|
3972
|
+
return json12({ error: "key required" }, 400);
|
|
2852
3973
|
try {
|
|
2853
3974
|
const client = getS3Client();
|
|
2854
3975
|
if (!await client.exists(b.key))
|
|
2855
|
-
return
|
|
3976
|
+
return json12({ error: "not found" }, 404);
|
|
2856
3977
|
await client.delete(b.key);
|
|
2857
|
-
return
|
|
3978
|
+
return json12({ ok: true, key: b.key });
|
|
2858
3979
|
} catch (e) {
|
|
2859
|
-
return
|
|
3980
|
+
return json12({ error: clientError(e) }, 502);
|
|
2860
3981
|
}
|
|
2861
3982
|
}
|
|
2862
3983
|
if (p === "/api/backups" && req.method === "GET") {
|
|
2863
|
-
return
|
|
3984
|
+
return json12({
|
|
2864
3985
|
dir: backupDir(),
|
|
2865
3986
|
devices: Object.keys(getConfig().devices),
|
|
2866
3987
|
backups: listBackups()
|
|
@@ -2870,31 +3991,31 @@ async function featureRoutes(req, url) {
|
|
|
2870
3991
|
const b = await readJson(req);
|
|
2871
3992
|
const raw = b?.dir?.trim();
|
|
2872
3993
|
if (!raw)
|
|
2873
|
-
return
|
|
2874
|
-
const dir = raw === "~" || raw.startsWith("~/") ? join4(
|
|
3994
|
+
return json12({ error: "dir required" }, 400);
|
|
3995
|
+
const dir = raw === "~" || raw.startsWith("~/") ? join4(homedir3(), raw.slice(1)) : raw;
|
|
2875
3996
|
const next = { ...getConfig(), backupDir: dir };
|
|
2876
3997
|
setConfig(next);
|
|
2877
3998
|
try {
|
|
2878
3999
|
atomicWrite(getConfigSource().path, serializeConfig(next));
|
|
2879
4000
|
} catch (e) {
|
|
2880
|
-
return
|
|
4001
|
+
return json12({
|
|
2881
4002
|
ok: true,
|
|
2882
4003
|
dir: backupDir(),
|
|
2883
4004
|
persisted: false,
|
|
2884
|
-
warning: `applied live but not saved: ${
|
|
4005
|
+
warning: `applied live but not saved: ${clientError(e)}`
|
|
2885
4006
|
});
|
|
2886
4007
|
}
|
|
2887
4008
|
recordVersion(getConfig(), "auto", Date.now(), "backup path changed");
|
|
2888
|
-
return
|
|
4009
|
+
return json12({ ok: true, dir: backupDir(), persisted: true });
|
|
2889
4010
|
}
|
|
2890
4011
|
if (p === "/api/backups/get" && req.method === "GET") {
|
|
2891
4012
|
const name = url.searchParams.get("name");
|
|
2892
4013
|
if (!name)
|
|
2893
|
-
return
|
|
4014
|
+
return json12({ error: "name required" }, 400);
|
|
2894
4015
|
try {
|
|
2895
|
-
return
|
|
4016
|
+
return json12({ name, content: readBackup(name) });
|
|
2896
4017
|
} catch {
|
|
2897
|
-
return
|
|
4018
|
+
return json12({ error: "not found" }, 404);
|
|
2898
4019
|
}
|
|
2899
4020
|
}
|
|
2900
4021
|
if (p === "/api/backups/raw" && req.method === "GET") {
|
|
@@ -2915,41 +4036,41 @@ async function featureRoutes(req, url) {
|
|
|
2915
4036
|
if (p === "/api/backups/upload" && req.method === "POST") {
|
|
2916
4037
|
const b = await readJson(req);
|
|
2917
4038
|
if (!b?.name || typeof b.content !== "string") {
|
|
2918
|
-
return
|
|
4039
|
+
return json12({ error: "name and content are required" }, 400);
|
|
2919
4040
|
}
|
|
2920
4041
|
try {
|
|
2921
4042
|
const safe = b.name.endsWith(".rsc") ? b.name : `${b.name}.rsc`;
|
|
2922
|
-
return
|
|
4043
|
+
return json12({ ok: true, name: writeBackup(safe, b.content) });
|
|
2923
4044
|
} catch (e) {
|
|
2924
|
-
return
|
|
4045
|
+
return json12({ error: clientError(e) }, 400);
|
|
2925
4046
|
}
|
|
2926
4047
|
}
|
|
2927
4048
|
if (p === "/api/backups/rename" && req.method === "POST") {
|
|
2928
4049
|
const b = await readJson(req);
|
|
2929
4050
|
if (!b?.name || !b?.new_name)
|
|
2930
|
-
return
|
|
4051
|
+
return json12({ error: "name and new_name are required" }, 400);
|
|
2931
4052
|
try {
|
|
2932
|
-
return
|
|
4053
|
+
return json12({ ok: true, name: renameBackup(b.name, b.new_name) });
|
|
2933
4054
|
} catch (e) {
|
|
2934
|
-
return
|
|
4055
|
+
return json12({ error: clientError(e) }, 400);
|
|
2935
4056
|
}
|
|
2936
4057
|
}
|
|
2937
4058
|
if (p === "/api/backups/delete" && req.method === "POST") {
|
|
2938
4059
|
const b = await readJson(req);
|
|
2939
4060
|
if (!b?.name)
|
|
2940
|
-
return
|
|
4061
|
+
return json12({ error: "name required" }, 400);
|
|
2941
4062
|
try {
|
|
2942
|
-
return deleteBackup(b.name) ?
|
|
4063
|
+
return deleteBackup(b.name) ? json12({ ok: true }) : json12({ error: "not found" }, 404);
|
|
2943
4064
|
} catch (e) {
|
|
2944
|
-
return
|
|
4065
|
+
return json12({ error: clientError(e) }, 400);
|
|
2945
4066
|
}
|
|
2946
4067
|
}
|
|
2947
4068
|
if (p === "/api/backups/restore" && req.method === "POST") {
|
|
2948
4069
|
const b = await readJson(req);
|
|
2949
4070
|
if (!b?.name)
|
|
2950
|
-
return
|
|
4071
|
+
return json12({ error: "name required" }, 400);
|
|
2951
4072
|
const device = resolveDeviceName(b.device);
|
|
2952
|
-
return
|
|
4073
|
+
return json12(await restoreLocalBackup(device, b.name, b.confirm === true));
|
|
2953
4074
|
}
|
|
2954
4075
|
if (p === "/api/backups/create" && req.method === "POST") {
|
|
2955
4076
|
const b = await readJson(req);
|
|
@@ -2961,14 +4082,14 @@ async function featureRoutes(req, url) {
|
|
|
2961
4082
|
compact: b?.compact === true,
|
|
2962
4083
|
terse: b?.terse === true
|
|
2963
4084
|
});
|
|
2964
|
-
return r.ok ?
|
|
4085
|
+
return r.ok ? json12(r) : json12({ error: r.error ?? "export failed" }, 502);
|
|
2965
4086
|
}
|
|
2966
4087
|
return null;
|
|
2967
4088
|
}
|
|
2968
4089
|
async function runDashboard(cfg, transportLabel) {
|
|
2969
|
-
const
|
|
4090
|
+
const store3 = await openSqliteStore(cfg.dbPath);
|
|
2970
4091
|
configureRecorder({
|
|
2971
|
-
store,
|
|
4092
|
+
store: store3,
|
|
2972
4093
|
capture: {
|
|
2973
4094
|
captureBody: cfg.captureBody,
|
|
2974
4095
|
maxBodyBytes: cfg.maxBodyBytes,
|
|
@@ -2980,13 +4101,13 @@ async function runDashboard(cfg, transportLabel) {
|
|
|
2980
4101
|
startHealthChecks(30000);
|
|
2981
4102
|
startGeoLookups();
|
|
2982
4103
|
try {
|
|
2983
|
-
usageStore = await openUsageStore(join4(
|
|
4104
|
+
usageStore = await openUsageStore(join4(dirname7(cfg.dbPath), "usage.db"));
|
|
2984
4105
|
startUsageSampler(usageStore);
|
|
2985
4106
|
} catch (e) {
|
|
2986
4107
|
logger.warn(`[${SERVER_TAG3}] usage history disabled: ${String(e)}`);
|
|
2987
4108
|
}
|
|
2988
4109
|
try {
|
|
2989
|
-
capsmanStore = await openCapsmanStore(join4(
|
|
4110
|
+
capsmanStore = await openCapsmanStore(join4(dirname7(cfg.dbPath), "capsman.db"));
|
|
2990
4111
|
startCapsmanSampler(capsmanStore);
|
|
2991
4112
|
} catch (e) {
|
|
2992
4113
|
logger.warn(`[${SERVER_TAG3}] capsman trends disabled: ${String(e)}`);
|
|
@@ -3060,12 +4181,39 @@ async function runDashboard(cfg, transportLabel) {
|
|
|
3060
4181
|
const driftResp = await driftRoutes(req, url);
|
|
3061
4182
|
if (driftResp)
|
|
3062
4183
|
return driftResp;
|
|
4184
|
+
const txnResp = await txnRoutes(req, url);
|
|
4185
|
+
if (txnResp)
|
|
4186
|
+
return txnResp;
|
|
4187
|
+
const flowResp = await flowRoutes(req, url);
|
|
4188
|
+
if (flowResp)
|
|
4189
|
+
return flowResp;
|
|
4190
|
+
const rolloutResp = await rolloutRoutes(req, url);
|
|
4191
|
+
if (rolloutResp)
|
|
4192
|
+
return rolloutResp;
|
|
4193
|
+
const policyResp = await policyRoutes(req, url);
|
|
4194
|
+
if (policyResp)
|
|
4195
|
+
return policyResp;
|
|
4196
|
+
const simResp = await simRoutes(req, url);
|
|
4197
|
+
if (simResp)
|
|
4198
|
+
return simResp;
|
|
4199
|
+
const scheduleResp = await scheduleRoutes(req, url);
|
|
4200
|
+
if (scheduleResp)
|
|
4201
|
+
return scheduleResp;
|
|
4202
|
+
const explainResp = await explainRoutes(req, url);
|
|
4203
|
+
if (explainResp)
|
|
4204
|
+
return explainResp;
|
|
4205
|
+
const attackResp = await attackRoutes(req, url);
|
|
4206
|
+
if (attackResp)
|
|
4207
|
+
return attackResp;
|
|
4208
|
+
const alertResp = await alertRoutes(req, url, getEventStore());
|
|
4209
|
+
if (alertResp)
|
|
4210
|
+
return alertResp;
|
|
3063
4211
|
const memoryResp = await memoryRoutes(req, url);
|
|
3064
4212
|
if (memoryResp)
|
|
3065
4213
|
return memoryResp;
|
|
3066
4214
|
const db = getEventStore();
|
|
3067
4215
|
if (!db)
|
|
3068
|
-
return
|
|
4216
|
+
return json12({ error: "recorder not active" }, 503);
|
|
3069
4217
|
if (url.pathname === "/api/events" && req.method === "DELETE") {
|
|
3070
4218
|
let body = {};
|
|
3071
4219
|
try {
|
|
@@ -3073,7 +4221,7 @@ async function runDashboard(cfg, transportLabel) {
|
|
|
3073
4221
|
} catch {}
|
|
3074
4222
|
const ids = Array.isArray(body.ids) ? body.ids.filter((x) => typeof x === "string") : [];
|
|
3075
4223
|
const removed = body.all === true ? db.clear() : db.delete(ids);
|
|
3076
|
-
return
|
|
4224
|
+
return json12({ removed, total: db.total() });
|
|
3077
4225
|
}
|
|
3078
4226
|
if (url.pathname === "/api/ssh-pool") {
|
|
3079
4227
|
const cfg2 = getConfig();
|
|
@@ -3082,7 +4230,7 @@ async function runDashboard(cfg, transportLabel) {
|
|
|
3082
4230
|
const totalInflight = ps.reduce((s, p) => s + p.inflight, 0);
|
|
3083
4231
|
const totalIdle = ps.filter((p) => p.idle).length;
|
|
3084
4232
|
const totalBusy = ps.filter((p) => p.inflight > 0).length;
|
|
3085
|
-
return
|
|
4233
|
+
return json12({
|
|
3086
4234
|
enabled,
|
|
3087
4235
|
config: {
|
|
3088
4236
|
keepAlive: cfg2.ssh.keepAlive,
|
|
@@ -3111,16 +4259,39 @@ async function runDashboard(cfg, transportLabel) {
|
|
|
3111
4259
|
});
|
|
3112
4260
|
}
|
|
3113
4261
|
if (url.pathname === "/api/devices") {
|
|
3114
|
-
return
|
|
4262
|
+
return json12(devicesPayload(db));
|
|
4263
|
+
}
|
|
4264
|
+
if (url.pathname === "/api/capabilities") {
|
|
4265
|
+
const devices = Object.keys(getConfig().devices).map((name) => ({
|
|
4266
|
+
device: name,
|
|
4267
|
+
capabilities: serializeCapabilities(peekCapabilities(name))
|
|
4268
|
+
}));
|
|
4269
|
+
return json12({ devices });
|
|
4270
|
+
}
|
|
4271
|
+
if (url.pathname === "/api/capabilities/refresh" && req.method === "POST") {
|
|
4272
|
+
const b = await readJson(req);
|
|
4273
|
+
if (typeof b?.device !== "string")
|
|
4274
|
+
return json12({ error: "device (string) is required" }, 400);
|
|
4275
|
+
if (!(b.device in getConfig().devices)) {
|
|
4276
|
+
return json12({ error: `unknown device: ${b.device}` }, 404);
|
|
4277
|
+
}
|
|
4278
|
+
invalidateCapabilities(b.device);
|
|
4279
|
+
try {
|
|
4280
|
+
const caps = await getCapabilities(b.device);
|
|
4281
|
+
return json12({ device: b.device, capabilities: serializeCapabilities(caps) });
|
|
4282
|
+
} catch (e) {
|
|
4283
|
+
logger.error(`Capability probe failed for '${b.device}': ${logError(e)}`);
|
|
4284
|
+
return json12({ error: clientError(e) }, 502);
|
|
4285
|
+
}
|
|
3115
4286
|
}
|
|
3116
4287
|
if (url.pathname === "/api/devices/toggle" && req.method === "POST") {
|
|
3117
4288
|
const b = await readJson(req);
|
|
3118
4289
|
if (typeof b?.device !== "string" || typeof b?.disabled !== "boolean") {
|
|
3119
|
-
return
|
|
4290
|
+
return json12({ error: "device (string) and disabled (boolean) are required" }, 400);
|
|
3120
4291
|
}
|
|
3121
4292
|
const cfg2 = getConfig();
|
|
3122
4293
|
if (!(b.device in cfg2.devices))
|
|
3123
|
-
return
|
|
4294
|
+
return json12({ error: `unknown device: ${b.device}` }, 404);
|
|
3124
4295
|
const dc = cfg2.devices[b.device];
|
|
3125
4296
|
const next = {
|
|
3126
4297
|
...cfg2,
|
|
@@ -3136,12 +4307,12 @@ async function runDashboard(cfg, transportLabel) {
|
|
|
3136
4307
|
atomicWrite(getConfigSource().path, serializeConfig(next));
|
|
3137
4308
|
} catch (e) {
|
|
3138
4309
|
persisted = false;
|
|
3139
|
-
warning = `applied live but not saved to disk: ${
|
|
4310
|
+
warning = `applied live but not saved to disk: ${clientError(e)}`;
|
|
3140
4311
|
}
|
|
3141
4312
|
if (persisted) {
|
|
3142
4313
|
recordVersion(getConfig(), "auto", Date.now(), `device ${b.disabled ? "disabled" : "enabled"}: ${b.device}`);
|
|
3143
4314
|
}
|
|
3144
|
-
return
|
|
4315
|
+
return json12({
|
|
3145
4316
|
ok: true,
|
|
3146
4317
|
persisted,
|
|
3147
4318
|
requiresReconnect: true,
|
|
@@ -3154,17 +4325,17 @@ async function runDashboard(cfg, transportLabel) {
|
|
|
3154
4325
|
const name = typeof b?.device === "string" ? b.device : "";
|
|
3155
4326
|
const cfg2 = getConfig();
|
|
3156
4327
|
if (!(name in cfg2.devices))
|
|
3157
|
-
return
|
|
4328
|
+
return json12({ error: `unknown device: ${name}` }, 404);
|
|
3158
4329
|
if (url.pathname === "/api/devices/reconnect")
|
|
3159
4330
|
closeDevice(name);
|
|
3160
4331
|
const status = await probeDevice(name, cfg2.devices[name]);
|
|
3161
|
-
return
|
|
4332
|
+
return json12({ ok: true, status, ...devicesPayload(db) });
|
|
3162
4333
|
}
|
|
3163
4334
|
if (url.pathname === "/api/topology") {
|
|
3164
|
-
return
|
|
4335
|
+
return json12(topologyPayload());
|
|
3165
4336
|
}
|
|
3166
4337
|
if (url.pathname === "/api/config") {
|
|
3167
|
-
return
|
|
4338
|
+
return json12(configPayload());
|
|
3168
4339
|
}
|
|
3169
4340
|
if (url.pathname === "/" || url.pathname === "/index.html") {
|
|
3170
4341
|
return new Response(dashboardHtml(), {
|
|
@@ -3173,9 +4344,9 @@ async function runDashboard(cfg, transportLabel) {
|
|
|
3173
4344
|
}
|
|
3174
4345
|
if (url.pathname === "/api/releases/latest") {
|
|
3175
4346
|
try {
|
|
3176
|
-
return
|
|
4347
|
+
return json12(await fetchLatestRelease());
|
|
3177
4348
|
} catch (e) {
|
|
3178
|
-
return
|
|
4349
|
+
return json12({ error: clientError(e, "fetch failed") }, 502);
|
|
3179
4350
|
}
|
|
3180
4351
|
}
|
|
3181
4352
|
if (url.pathname === "/api/catalog" && req.method === "GET") {
|
|
@@ -3194,7 +4365,7 @@ async function runDashboard(cfg, transportLabel) {
|
|
|
3194
4365
|
}));
|
|
3195
4366
|
const prompts = listPrompts();
|
|
3196
4367
|
const groups = [...new Set(modules.map((m) => m.group))].sort();
|
|
3197
|
-
return
|
|
4368
|
+
return json12({
|
|
3198
4369
|
modules,
|
|
3199
4370
|
prompts,
|
|
3200
4371
|
groups,
|
|
@@ -3209,25 +4380,25 @@ async function runDashboard(cfg, transportLabel) {
|
|
|
3209
4380
|
}
|
|
3210
4381
|
if (url.pathname === "/api/releases" && req.method === "GET") {
|
|
3211
4382
|
try {
|
|
3212
|
-
return
|
|
4383
|
+
return json12(await fetchAllReleases());
|
|
3213
4384
|
} catch (e) {
|
|
3214
|
-
return
|
|
4385
|
+
return json12({ error: clientError(e, "fetch failed") }, 502);
|
|
3215
4386
|
}
|
|
3216
4387
|
}
|
|
3217
4388
|
if (url.pathname === "/api/upgrade" && req.method === "POST") {
|
|
3218
4389
|
const body = await readJson(req);
|
|
3219
4390
|
const version = String(body?.version ?? "latest");
|
|
3220
4391
|
if (version !== "latest" && !/^\d+\.\d+\.\d+$/.test(version)) {
|
|
3221
|
-
return
|
|
4392
|
+
return json12({ ok: false, error: `Invalid version "${version}" (use "latest" or x.y.z).` }, 400);
|
|
3222
4393
|
}
|
|
3223
4394
|
const spec = `@usex/mikrotik-mcp@${version}`;
|
|
3224
4395
|
logger.warn(`Dashboard requested upgrade: bun i -g ${spec}`);
|
|
3225
4396
|
const { ok, log } = await runUpgrade(spec);
|
|
3226
4397
|
if (!ok)
|
|
3227
|
-
return
|
|
4398
|
+
return json12({ ok: false, version, log }, 500);
|
|
3228
4399
|
const willRestart = body?.restart !== false;
|
|
3229
4400
|
const relaunched = willRestart ? restartProcess() : false;
|
|
3230
|
-
return
|
|
4401
|
+
return json12({
|
|
3231
4402
|
ok: true,
|
|
3232
4403
|
version,
|
|
3233
4404
|
log,
|
|
@@ -3237,7 +4408,7 @@ async function runDashboard(cfg, transportLabel) {
|
|
|
3237
4408
|
}
|
|
3238
4409
|
if (url.pathname === "/api/meta") {
|
|
3239
4410
|
const f = facets(db);
|
|
3240
|
-
return
|
|
4411
|
+
return json12({
|
|
3241
4412
|
...f,
|
|
3242
4413
|
version: VERSION,
|
|
3243
4414
|
risks: ["READ", "WRITE", "WRITE_IDEMPOTENT", "DESTRUCTIVE", "DANGEROUS"],
|
|
@@ -3248,19 +4419,19 @@ async function runDashboard(cfg, transportLabel) {
|
|
|
3248
4419
|
}
|
|
3249
4420
|
if (url.pathname === "/api/events") {
|
|
3250
4421
|
const filter = filterFromQuery(url);
|
|
3251
|
-
return
|
|
4422
|
+
return json12({ events: db.query(filter), total: db.total() });
|
|
3252
4423
|
}
|
|
3253
4424
|
const eventMatch = url.pathname.match(/^\/api\/event\/(.+)$/);
|
|
3254
4425
|
if (eventMatch) {
|
|
3255
4426
|
const e = db.get(decodeURIComponent(eventMatch[1]));
|
|
3256
|
-
return e ?
|
|
4427
|
+
return e ? json12(e) : json12({ error: "not found" }, 404);
|
|
3257
4428
|
}
|
|
3258
4429
|
if (url.pathname === "/api/stats") {
|
|
3259
4430
|
const now = Date.now();
|
|
3260
|
-
const
|
|
4431
|
+
const windowMs2 = Number(url.searchParams.get("window") ?? 3600000);
|
|
3261
4432
|
const buckets = Number(url.searchParams.get("buckets") ?? 60);
|
|
3262
|
-
const events = db.query({ since: now -
|
|
3263
|
-
return
|
|
4433
|
+
const events = db.query({ since: now - windowMs2, limit: 5000 });
|
|
4434
|
+
return json12(computeStats(events, { now, windowMs: windowMs2, buckets }));
|
|
3264
4435
|
}
|
|
3265
4436
|
return new Response("Not Found", { status: 404 });
|
|
3266
4437
|
}
|
|
@@ -3278,9 +4449,8 @@ async function runDashboard(cfg, transportLabel) {
|
|
|
3278
4449
|
try {
|
|
3279
4450
|
return await dashboardRoute(req, url, srv, configAdmin, transportLabel);
|
|
3280
4451
|
} catch (e) {
|
|
3281
|
-
|
|
3282
|
-
|
|
3283
|
-
return json3({ error: msg }, 502);
|
|
4452
|
+
logger.error(`Dashboard request failed (${url.pathname}): ${logError(e)}`);
|
|
4453
|
+
return json12({ error: clientError(e) }, 502);
|
|
3284
4454
|
}
|
|
3285
4455
|
},
|
|
3286
4456
|
websocket: {
|
|
@@ -3292,11 +4462,19 @@ async function runDashboard(cfg, transportLabel) {
|
|
|
3292
4462
|
} catch {}
|
|
3293
4463
|
});
|
|
3294
4464
|
} else {
|
|
3295
|
-
|
|
4465
|
+
const send = (payload2) => {
|
|
3296
4466
|
try {
|
|
3297
|
-
ws.send(JSON.stringify(
|
|
4467
|
+
ws.send(JSON.stringify(payload2));
|
|
3298
4468
|
} catch {}
|
|
3299
|
-
}
|
|
4469
|
+
};
|
|
4470
|
+
const unsubEvents = subscribe((e) => send({ type: "event", event: e }));
|
|
4471
|
+
const unsubTxn = subscribeTxn((update) => send({ type: "txn", update }));
|
|
4472
|
+
const unsubRollout = subscribeRollout((update) => send({ type: "rollout", update }));
|
|
4473
|
+
ws.data.unsub = () => {
|
|
4474
|
+
unsubEvents();
|
|
4475
|
+
unsubTxn();
|
|
4476
|
+
unsubRollout();
|
|
4477
|
+
};
|
|
3300
4478
|
}
|
|
3301
4479
|
ws.send(JSON.stringify({ type: "hello", transport: transportLabel }));
|
|
3302
4480
|
},
|
|
@@ -3317,14 +4495,14 @@ async function runDashboard(cfg, transportLabel) {
|
|
|
3317
4495
|
}
|
|
3318
4496
|
return {
|
|
3319
4497
|
server,
|
|
3320
|
-
store,
|
|
4498
|
+
store: store3,
|
|
3321
4499
|
stop() {
|
|
3322
4500
|
stopHealthChecks();
|
|
3323
4501
|
stopGeoLookups();
|
|
3324
4502
|
stopUsageSampler();
|
|
3325
4503
|
stopCapsmanSampler();
|
|
3326
4504
|
server.stop(true);
|
|
3327
|
-
|
|
4505
|
+
store3.close();
|
|
3328
4506
|
usageStore?.close();
|
|
3329
4507
|
usageStore = null;
|
|
3330
4508
|
capsmanStore?.close();
|
|
@@ -3641,6 +4819,12 @@ async function runStdio() {
|
|
|
3641
4819
|
const transport = new StdioServerTransport;
|
|
3642
4820
|
await server.connect(transport);
|
|
3643
4821
|
logger.info(`MCP server ready on stdio \u2014 ${toolCount} tools, ${promptCount} prompts, ${uiViewCount} app views${readOnly ? " [READ-ONLY]" : ""}`);
|
|
4822
|
+
setMcpAlertSender((n) => {
|
|
4823
|
+
server.server.sendLoggingMessage({
|
|
4824
|
+
level: n.severity === "critical" || n.severity === "high" ? "error" : "info",
|
|
4825
|
+
data: `[alert:${n.kind}] ${n.title}${n.device ? ` \xB7 ${n.device}` : ""} ` + `(severity ${n.severity}, rule ${n.ruleId})${n.body ? ` \u2014 ${n.body}` : ""}`
|
|
4826
|
+
}).catch(() => {});
|
|
4827
|
+
});
|
|
3644
4828
|
if (!getConfig().disableUpdateCheck) {
|
|
3645
4829
|
whisperUpdate(server).catch(() => {});
|
|
3646
4830
|
}
|
|
@@ -3711,6 +4895,100 @@ async function printBanner() {
|
|
|
3711
4895
|
}
|
|
3712
4896
|
}
|
|
3713
4897
|
|
|
4898
|
+
// src/alerts/sampler.ts
|
|
4899
|
+
var DEFAULT_ALERT_SAMPLE_MS = 30000;
|
|
4900
|
+
var timer4 = null;
|
|
4901
|
+
var snapshotStorePromise = null;
|
|
4902
|
+
function getSnapshotStore() {
|
|
4903
|
+
snapshotStorePromise ??= openSnapshotStore(DEFAULT_SNAPSHOT_DB);
|
|
4904
|
+
return snapshotStorePromise;
|
|
4905
|
+
}
|
|
4906
|
+
function computeMetricSample(windowMs2, now = Date.now()) {
|
|
4907
|
+
const store3 = getEventStore();
|
|
4908
|
+
if (!store3)
|
|
4909
|
+
return { calls: 0, errors: 0, avgDurationMs: 0, p95DurationMs: 0 };
|
|
4910
|
+
const events = store3.query({ since: now - windowMs2, limit: 1e4 });
|
|
4911
|
+
const calls = events.length;
|
|
4912
|
+
if (calls === 0)
|
|
4913
|
+
return { calls: 0, errors: 0, avgDurationMs: 0, p95DurationMs: 0 };
|
|
4914
|
+
const durations = events.map((e) => e.durationMs);
|
|
4915
|
+
return {
|
|
4916
|
+
calls,
|
|
4917
|
+
errors: events.reduce((n, e) => n + (e.isError ? 1 : 0), 0),
|
|
4918
|
+
avgDurationMs: durations.reduce((a, b) => a + b, 0) / calls,
|
|
4919
|
+
p95DurationMs: percentile(durations, 95)
|
|
4920
|
+
};
|
|
4921
|
+
}
|
|
4922
|
+
async function lastSeen(t) {
|
|
4923
|
+
switch (t.absence) {
|
|
4924
|
+
case "tool_call": {
|
|
4925
|
+
const store3 = getEventStore();
|
|
4926
|
+
if (!store3)
|
|
4927
|
+
return;
|
|
4928
|
+
const rows = store3.query({
|
|
4929
|
+
limit: 1,
|
|
4930
|
+
tool: t.tool?.length === 1 ? t.tool[0] : undefined,
|
|
4931
|
+
device: t.device?.length === 1 ? t.device[0] : undefined
|
|
4932
|
+
});
|
|
4933
|
+
return rows[0]?.ts;
|
|
4934
|
+
}
|
|
4935
|
+
case "snapshot": {
|
|
4936
|
+
const devices = t.device?.length ? t.device : Object.keys(getConfig().devices);
|
|
4937
|
+
let newest;
|
|
4938
|
+
try {
|
|
4939
|
+
const store3 = await getSnapshotStore();
|
|
4940
|
+
for (const d of devices) {
|
|
4941
|
+
const at = store3.latest(d)?.ts;
|
|
4942
|
+
if (at !== undefined && (newest === undefined || at > newest))
|
|
4943
|
+
newest = at;
|
|
4944
|
+
}
|
|
4945
|
+
} catch (e) {
|
|
4946
|
+
logger.debug(`[alerts] snapshot lookup failed: ${e instanceof Error ? e.message : e}`);
|
|
4947
|
+
return Date.now();
|
|
4948
|
+
}
|
|
4949
|
+
return newest;
|
|
4950
|
+
}
|
|
4951
|
+
case "device_seen": {
|
|
4952
|
+
const devices = t.device?.length ? t.device : Object.keys(getConfig().devices);
|
|
4953
|
+
let newest;
|
|
4954
|
+
for (const d of devices) {
|
|
4955
|
+
const st = getDeviceStatus(d);
|
|
4956
|
+
if (st.reachable !== true || st.checkedAt == null)
|
|
4957
|
+
continue;
|
|
4958
|
+
if (newest === undefined || st.checkedAt > newest)
|
|
4959
|
+
newest = st.checkedAt;
|
|
4960
|
+
}
|
|
4961
|
+
return newest;
|
|
4962
|
+
}
|
|
4963
|
+
}
|
|
4964
|
+
}
|
|
4965
|
+
async function sampleAlertsOnce(now = Date.now()) {
|
|
4966
|
+
const engine = getAlertEngine();
|
|
4967
|
+
if (!engine)
|
|
4968
|
+
return;
|
|
4969
|
+
if (engine.hasMetricRules()) {
|
|
4970
|
+
engine.sample((windowMs2) => computeMetricSample(windowMs2, now));
|
|
4971
|
+
}
|
|
4972
|
+
if (engine.hasAbsenceRules()) {
|
|
4973
|
+
await engine.sampleAbsence(lastSeen, now);
|
|
4974
|
+
}
|
|
4975
|
+
}
|
|
4976
|
+
function startAlertSampler(intervalMs = DEFAULT_ALERT_SAMPLE_MS) {
|
|
4977
|
+
stopAlertSampler();
|
|
4978
|
+
timer4 = setInterval(() => {
|
|
4979
|
+
sampleAlertsOnce().catch((e) => {
|
|
4980
|
+
logger.debug(`[alerts] sampler tick failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
4981
|
+
});
|
|
4982
|
+
}, intervalMs);
|
|
4983
|
+
timer4.unref?.();
|
|
4984
|
+
logger.debug(`[alerts] metric sampler started (${intervalMs}ms)`);
|
|
4985
|
+
}
|
|
4986
|
+
function stopAlertSampler() {
|
|
4987
|
+
if (timer4)
|
|
4988
|
+
clearInterval(timer4);
|
|
4989
|
+
timer4 = null;
|
|
4990
|
+
}
|
|
4991
|
+
|
|
3714
4992
|
// src/cli.ts
|
|
3715
4993
|
var HELP = `${SERVER_NAME} v${VERSION} \u2014 MikroTik RouterOS MCP server
|
|
3716
4994
|
|
|
@@ -3863,6 +5141,29 @@ async function main() {
|
|
|
3863
5141
|
warnIfPlaintextPasswordInContainer(Object.values(cfg.devices).some((d) => !!d.password));
|
|
3864
5142
|
const deviceNames = Object.keys(cfg.devices);
|
|
3865
5143
|
logger.info(`Starting ${SERVER_NAME} v${VERSION} (transport=${cfg.mcp.transport}, ` + `devices=${deviceNames.length === 1 ? deviceNames[0] : deviceNames.join("/")}, ` + `ssh-pool=${cfg.ssh.keepAlive ? "on" : "off"})`);
|
|
5144
|
+
if (cfg.alerts?.enabled !== false && cfg.alerts) {
|
|
5145
|
+
try {
|
|
5146
|
+
const rules = parseRules(cfg.alerts.rules);
|
|
5147
|
+
setAlertEngine(new AlertEngine({
|
|
5148
|
+
rules,
|
|
5149
|
+
channels: cfg.alerts.channels,
|
|
5150
|
+
historyDb: cfg.dashboard.dbPath,
|
|
5151
|
+
maxHistory: cfg.alerts.maxHistory
|
|
5152
|
+
}));
|
|
5153
|
+
startAlertSampler();
|
|
5154
|
+
logger.info(`Alerting enabled: ${rules.length} rule(s)`);
|
|
5155
|
+
} catch (e) {
|
|
5156
|
+
logger.error(`Alerting disabled \u2014 invalid rules: ${e instanceof Error ? e.message : String(e)}`);
|
|
5157
|
+
}
|
|
5158
|
+
}
|
|
5159
|
+
startScheduler().catch((e) => {
|
|
5160
|
+
logger.error(`Scheduled audits disabled: ${e instanceof Error ? e.message : String(e)}`);
|
|
5161
|
+
});
|
|
5162
|
+
try {
|
|
5163
|
+
startAttackDetection();
|
|
5164
|
+
} catch (e) {
|
|
5165
|
+
logger.error(`Attack detection disabled: ${e instanceof Error ? e.message : String(e)}`);
|
|
5166
|
+
}
|
|
3866
5167
|
let shuttingDown = false;
|
|
3867
5168
|
const shutdown = (signal) => {
|
|
3868
5169
|
if (shuttingDown)
|
|
@@ -3871,6 +5172,9 @@ async function main() {
|
|
|
3871
5172
|
logger.info(`Received ${signal}, shutting down\u2026`);
|
|
3872
5173
|
closeAll();
|
|
3873
5174
|
closeMemoryStore();
|
|
5175
|
+
stopAlertSampler();
|
|
5176
|
+
stopScheduler();
|
|
5177
|
+
stopAttackDetection();
|
|
3874
5178
|
process.exit(0);
|
|
3875
5179
|
};
|
|
3876
5180
|
process.on("SIGINT", () => shutdown("SIGINT"));
|