@hivemagnus/magnus-server 0.0.0-stage → 1.0.2

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.
Files changed (356) hide show
  1. package/.env.example +171 -0
  2. package/LICENSE +202 -0
  3. package/README.md +169 -2
  4. package/agent-version.json +1 -0
  5. package/dist/ai/HEARTBEAT.md +56 -0
  6. package/dist/ai/IDENTITY.md +54 -0
  7. package/dist/ai/MEMORY.md +40 -0
  8. package/dist/ai/TOOLS.md +538 -0
  9. package/dist/ai/USER.md +32 -0
  10. package/dist/cli/args.js +62 -0
  11. package/dist/cli/env-catalog.js +131 -0
  12. package/dist/cli/preflight.js +89 -0
  13. package/dist/cli.js +175 -0
  14. package/dist/config/cors.js +16 -0
  15. package/dist/config/env.js +392 -0
  16. package/dist/config/features.js +16 -0
  17. package/dist/config/index.js +3 -0
  18. package/dist/config/listener-security.js +38 -0
  19. package/dist/config/version.js +31 -0
  20. package/dist/db/client.js +44 -0
  21. package/dist/db/generated/prisma/browser.js +18 -0
  22. package/dist/db/generated/prisma/client.js +35 -0
  23. package/dist/db/generated/prisma/commonInputTypes.js +11 -0
  24. package/dist/db/generated/prisma/enums.js +42 -0
  25. package/dist/db/generated/prisma/internal/class.js +50 -0
  26. package/dist/db/generated/prisma/internal/prismaNamespace.js +1598 -0
  27. package/dist/db/generated/prisma/internal/prismaNamespaceBrowser.js +1569 -0
  28. package/dist/db/generated/prisma/models/A2ATask.js +2 -0
  29. package/dist/db/generated/prisma/models/AdmissionFence.js +2 -0
  30. package/dist/db/generated/prisma/models/Agent.js +2 -0
  31. package/dist/db/generated/prisma/models/AiConversation.js +2 -0
  32. package/dist/db/generated/prisma/models/AiDashboard.js +2 -0
  33. package/dist/db/generated/prisma/models/AiMemory.js +2 -0
  34. package/dist/db/generated/prisma/models/AiMessage.js +2 -0
  35. package/dist/db/generated/prisma/models/AiScheduledOp.js +2 -0
  36. package/dist/db/generated/prisma/models/AlertConditionState.js +2 -0
  37. package/dist/db/generated/prisma/models/AlertRule.js +2 -0
  38. package/dist/db/generated/prisma/models/AlertViolation.js +2 -0
  39. package/dist/db/generated/prisma/models/AnomalyEvent.js +2 -0
  40. package/dist/db/generated/prisma/models/ApplicationObservation.js +2 -0
  41. package/dist/db/generated/prisma/models/ArdentInstallation.js +2 -0
  42. package/dist/db/generated/prisma/models/ArdentProfile.js +2 -0
  43. package/dist/db/generated/prisma/models/ArdentScopeEnrollment.js +2 -0
  44. package/dist/db/generated/prisma/models/CommandResourceFence.js +2 -0
  45. package/dist/db/generated/prisma/models/CommandResourceLease.js +2 -0
  46. package/dist/db/generated/prisma/models/CommandResult.js +2 -0
  47. package/dist/db/generated/prisma/models/CronJob.js +2 -0
  48. package/dist/db/generated/prisma/models/CronJobExecution.js +2 -0
  49. package/dist/db/generated/prisma/models/DeployJob.js +2 -0
  50. package/dist/db/generated/prisma/models/DriftScan.js +2 -0
  51. package/dist/db/generated/prisma/models/EngineeringMetricSample.js +2 -0
  52. package/dist/db/generated/prisma/models/EngineeringRecoveryExercise.js +2 -0
  53. package/dist/db/generated/prisma/models/ExecutionRun.js +2 -0
  54. package/dist/db/generated/prisma/models/FilePatchTask.js +2 -0
  55. package/dist/db/generated/prisma/models/GpuSnapshot.js +2 -0
  56. package/dist/db/generated/prisma/models/HealthSnapshot.js +2 -0
  57. package/dist/db/generated/prisma/models/HermesCoreRollout.js +2 -0
  58. package/dist/db/generated/prisma/models/HermesCoreRolloutHost.js +2 -0
  59. package/dist/db/generated/prisma/models/HermesCredentialPool.js +2 -0
  60. package/dist/db/generated/prisma/models/HermesCredentialPoolAttachment.js +2 -0
  61. package/dist/db/generated/prisma/models/HermesCronJob.js +2 -0
  62. package/dist/db/generated/prisma/models/HermesHook.js +2 -0
  63. package/dist/db/generated/prisma/models/HermesInstallation.js +2 -0
  64. package/dist/db/generated/prisma/models/HermesMcpServer.js +2 -0
  65. package/dist/db/generated/prisma/models/HermesMcpServerAttachment.js +2 -0
  66. package/dist/db/generated/prisma/models/HermesMessagingBinding.js +2 -0
  67. package/dist/db/generated/prisma/models/HermesOperationJob.js +2 -0
  68. package/dist/db/generated/prisma/models/HermesPluginInstall.js +2 -0
  69. package/dist/db/generated/prisma/models/HermesProfile.js +2 -0
  70. package/dist/db/generated/prisma/models/HermesProviderConfig.js +2 -0
  71. package/dist/db/generated/prisma/models/HermesRegistryClient.js +2 -0
  72. package/dist/db/generated/prisma/models/HermesRegistryInventory.js +2 -0
  73. package/dist/db/generated/prisma/models/HermesRegistryReceipt.js +2 -0
  74. package/dist/db/generated/prisma/models/HermesRegistryRevision.js +2 -0
  75. package/dist/db/generated/prisma/models/HermesRegistryTarget.js +2 -0
  76. package/dist/db/generated/prisma/models/HermesRole.js +2 -0
  77. package/dist/db/generated/prisma/models/HermesRoleApplication.js +2 -0
  78. package/dist/db/generated/prisma/models/HermesSkillAssignment.js +2 -0
  79. package/dist/db/generated/prisma/models/HermesToolsetPolicy.js +2 -0
  80. package/dist/db/generated/prisma/models/IncidentLesson.js +2 -0
  81. package/dist/db/generated/prisma/models/LibraryCategory.js +2 -0
  82. package/dist/db/generated/prisma/models/LibraryFile.js +2 -0
  83. package/dist/db/generated/prisma/models/LogEntry.js +2 -0
  84. package/dist/db/generated/prisma/models/MagnusReleaseCanary.js +2 -0
  85. package/dist/db/generated/prisma/models/MaintenanceWindow.js +2 -0
  86. package/dist/db/generated/prisma/models/ManagedCronDefinition.js +2 -0
  87. package/dist/db/generated/prisma/models/ManagedCronOccurrence.js +2 -0
  88. package/dist/db/generated/prisma/models/Node.js +2 -0
  89. package/dist/db/generated/prisma/models/Notification.js +2 -0
  90. package/dist/db/generated/prisma/models/NotificationPreference.js +2 -0
  91. package/dist/db/generated/prisma/models/RemediationExecution.js +2 -0
  92. package/dist/db/generated/prisma/models/RemediationRule.js +2 -0
  93. package/dist/db/generated/prisma/models/RemoteCommand.js +2 -0
  94. package/dist/db/generated/prisma/models/ResourceDrainParticipant.js +2 -0
  95. package/dist/db/generated/prisma/models/ResourceEnrollment.js +2 -0
  96. package/dist/db/generated/prisma/models/ResourceParticipantWork.js +2 -0
  97. package/dist/db/generated/prisma/models/Role.js +2 -0
  98. package/dist/db/generated/prisma/models/RoleApplication.js +2 -0
  99. package/dist/db/generated/prisma/models/RolloutTarget.js +2 -0
  100. package/dist/db/generated/prisma/models/SecurityExportOutbox.js +2 -0
  101. package/dist/db/generated/prisma/models/SecurityFusionAction.js +2 -0
  102. package/dist/db/generated/prisma/models/SecurityFusionActionApproval.js +2 -0
  103. package/dist/db/generated/prisma/models/SecurityFusionCase.js +2 -0
  104. package/dist/db/generated/prisma/models/SecurityFusionCaseActivity.js +2 -0
  105. package/dist/db/generated/prisma/models/SecurityFusionCaseEntity.js +2 -0
  106. package/dist/db/generated/prisma/models/SecurityFusionCaseEvent.js +2 -0
  107. package/dist/db/generated/prisma/models/SecurityFusionCaseRelationship.js +2 -0
  108. package/dist/db/generated/prisma/models/SecurityFusionCollector.js +2 -0
  109. package/dist/db/generated/prisma/models/SecurityFusionCommandOutbox.js +2 -0
  110. package/dist/db/generated/prisma/models/SecurityFusionDeletionReceipt.js +2 -0
  111. package/dist/db/generated/prisma/models/SecurityFusionDelivery.js +2 -0
  112. package/dist/db/generated/prisma/models/SecurityFusionEntity.js +2 -0
  113. package/dist/db/generated/prisma/models/SecurityFusionEntityAlias.js +2 -0
  114. package/dist/db/generated/prisma/models/SecurityFusionEvent.js +2 -0
  115. package/dist/db/generated/prisma/models/SecurityFusionEventEntity.js +2 -0
  116. package/dist/db/generated/prisma/models/SecurityFusionEvidence.js +2 -0
  117. package/dist/db/generated/prisma/models/SecurityFusionInvestigation.js +2 -0
  118. package/dist/db/generated/prisma/models/SecurityFusionNonce.js +2 -0
  119. package/dist/db/generated/prisma/models/SecurityFusionReceiptChainHead.js +2 -0
  120. package/dist/db/generated/prisma/models/SecurityFusionRuleVersion.js +2 -0
  121. package/dist/db/generated/prisma/models/SecurityFusionSourceState.js +2 -0
  122. package/dist/db/generated/prisma/models/SecurityServiceState.js +2 -0
  123. package/dist/db/generated/prisma/models/SharedResourceFence.js +2 -0
  124. package/dist/db/generated/prisma/models/SharedResourceLease.js +2 -0
  125. package/dist/db/generated/prisma/models/SharedResourceLeaseResource.js +2 -0
  126. package/dist/db/generated/prisma/models/SkillInstallation.js +2 -0
  127. package/dist/db/generated/prisma/models/SkillRegistryEntry.js +2 -0
  128. package/dist/db/generated/prisma/models/SkillTag.js +2 -0
  129. package/dist/db/generated/prisma/models/SkillVersion.js +2 -0
  130. package/dist/db/generated/prisma/models/TeamApprovalDecision.js +2 -0
  131. package/dist/db/generated/prisma/models/User.js +2 -0
  132. package/dist/db/generated/prisma/models/WebhookDelivery.js +2 -0
  133. package/dist/db/generated/prisma/models/WebhookEndpoint.js +2 -0
  134. package/dist/db/generated/prisma/models.js +2 -0
  135. package/dist/db/index.js +3 -0
  136. package/dist/db/read-retry.js +35 -0
  137. package/dist/db/seed.js +348 -0
  138. package/dist/index.js +162 -0
  139. package/dist/middleware/auth.js +361 -0
  140. package/dist/middleware/body-limit.js +14 -0
  141. package/dist/middleware/correlation-id.js +13 -0
  142. package/dist/middleware/error-handler.js +28 -0
  143. package/dist/middleware/iam-token.js +21 -0
  144. package/dist/middleware/rate-limit.js +78 -0
  145. package/dist/middleware/request-logger.js +85 -0
  146. package/dist/middleware/security-headers.js +23 -0
  147. package/dist/middleware/tracing.js +120 -0
  148. package/dist/middleware/user-status.js +33 -0
  149. package/dist/openapi/spec.js +2656 -0
  150. package/dist/queue/redis.js +20 -0
  151. package/dist/queue/requeue.js +27 -0
  152. package/dist/queue/workers.js +342 -0
  153. package/dist/routes/ai-schedule.js +73 -0
  154. package/dist/routes/alerts.js +79 -0
  155. package/dist/routes/anomalies.js +101 -0
  156. package/dist/routes/ardent.js +82 -0
  157. package/dist/routes/assistant.js +417 -0
  158. package/dist/routes/audit.js +138 -0
  159. package/dist/routes/codex.js +65 -0
  160. package/dist/routes/commands.js +226 -0
  161. package/dist/routes/credentials.js +58 -0
  162. package/dist/routes/cron.js +292 -0
  163. package/dist/routes/dependencies.js +49 -0
  164. package/dist/routes/deploy.js +18 -0
  165. package/dist/routes/discovery.js +168 -0
  166. package/dist/routes/docs.js +24 -0
  167. package/dist/routes/drift.js +126 -0
  168. package/dist/routes/events-stream.js +87 -0
  169. package/dist/routes/files.js +214 -0
  170. package/dist/routes/fleet-checks.js +21 -0
  171. package/dist/routes/health.js +133 -0
  172. package/dist/routes/heartbeat.js +545 -0
  173. package/dist/routes/hermes-registry.js +278 -0
  174. package/dist/routes/hermes.js +61 -0
  175. package/dist/routes/library.js +402 -0
  176. package/dist/routes/logs.js +172 -0
  177. package/dist/routes/managed-cron.js +23 -0
  178. package/dist/routes/mcp.js +39 -0
  179. package/dist/routes/metrics.js +187 -0
  180. package/dist/routes/nodes.js +951 -0
  181. package/dist/routes/notifications.js +130 -0
  182. package/dist/routes/ops.js +258 -0
  183. package/dist/routes/orchestra.js +430 -0
  184. package/dist/routes/predictions.js +23 -0
  185. package/dist/routes/profiling.js +68 -0
  186. package/dist/routes/quick-command.js +72 -0
  187. package/dist/routes/remediation.js +111 -0
  188. package/dist/routes/resource-leases.js +297 -0
  189. package/dist/routes/roles.js +370 -0
  190. package/dist/routes/rollout.js +124 -0
  191. package/dist/routes/scan.js +116 -0
  192. package/dist/routes/security-backup.js +58 -0
  193. package/dist/routes/security-fusion.js +605 -0
  194. package/dist/routes/security-integration.js +71 -0
  195. package/dist/routes/self-update.js +129 -0
  196. package/dist/routes/shared-resource-leases.js +78 -0
  197. package/dist/routes/skills.js +485 -0
  198. package/dist/routes/stats.js +96 -0
  199. package/dist/routes/team-approvals.js +23 -0
  200. package/dist/routes/terminal-ticket.js +15 -0
  201. package/dist/routes/terminal.js +17 -0
  202. package/dist/routes/topology.js +37 -0
  203. package/dist/routes/users.js +219 -0
  204. package/dist/routes/webhooks.js +137 -0
  205. package/dist/schemas/command-reconciliation.js +14 -0
  206. package/dist/schemas/commands.js +51 -0
  207. package/dist/schemas/cron.js +33 -0
  208. package/dist/schemas/deploy.js +20 -0
  209. package/dist/schemas/files.js +14 -0
  210. package/dist/schemas/hermes.js +69 -0
  211. package/dist/schemas/index.js +11 -0
  212. package/dist/schemas/library.js +48 -0
  213. package/dist/schemas/logs.js +35 -0
  214. package/dist/schemas/managed-cron.js +127 -0
  215. package/dist/schemas/node-responses.js +87 -0
  216. package/dist/schemas/node-session-receipt.js +84 -0
  217. package/dist/schemas/nodes.js +36 -0
  218. package/dist/schemas/orchestra.js +118 -0
  219. package/dist/schemas/resource-participant-reconciliation.js +99 -0
  220. package/dist/schemas/roles.js +54 -0
  221. package/dist/schemas/shared-resource-lease.js +132 -0
  222. package/dist/schemas/skills.js +44 -0
  223. package/dist/schemas/users.js +28 -0
  224. package/dist/scripts/run-disposable-recovery-exercise.js +39 -0
  225. package/dist/sdk/index.js +2 -0
  226. package/dist/sdk/node-api-client.js +474 -0
  227. package/dist/security/approval-assertion.js +66 -0
  228. package/dist/security/command-completion-registry.js +35 -0
  229. package/dist/security/command-request.js +12 -0
  230. package/dist/security/command-result-signature.js +34 -0
  231. package/dist/security/managed-http.js +45 -0
  232. package/dist/security/native-completion-registry.js +44 -0
  233. package/dist/server.js +178 -0
  234. package/dist/services/admission-fence.js +71 -0
  235. package/dist/services/ai-assistant-service.js +2523 -0
  236. package/dist/services/ai-correlation-service.js +225 -0
  237. package/dist/services/ai-export-service.js +141 -0
  238. package/dist/services/ai-insights-service.js +219 -0
  239. package/dist/services/ai-schedule-service.js +145 -0
  240. package/dist/services/ai-scratchpad-service.js +222 -0
  241. package/dist/services/ai-tools.js +1343 -0
  242. package/dist/services/alert-service.js +313 -0
  243. package/dist/services/anomaly-detector.js +419 -0
  244. package/dist/services/api-local-work.js +79 -0
  245. package/dist/services/application-probes.js +309 -0
  246. package/dist/services/ardent-scope-enrollment.js +123 -0
  247. package/dist/services/ardent-service.js +86 -0
  248. package/dist/services/audit-service.js +308 -0
  249. package/dist/services/codex-service.js +209 -0
  250. package/dist/services/command-lifecycle.js +60 -0
  251. package/dist/services/command-resource-leases.js +305 -0
  252. package/dist/services/control-host-node.js +242 -0
  253. package/dist/services/credential-rotation-service.js +294 -0
  254. package/dist/services/dependency-service.js +145 -0
  255. package/dist/services/disposable-recovery-runner.js +595 -0
  256. package/dist/services/docker-group-health.js +121 -0
  257. package/dist/services/engineering-assurance.js +492 -0
  258. package/dist/services/engineering-evidence-ledger.js +94 -0
  259. package/dist/services/engineering-metric-evidence.js +26 -0
  260. package/dist/services/event-bus.js +227 -0
  261. package/dist/services/fleet-check-log.js +102 -0
  262. package/dist/services/forecast-service.js +154 -0
  263. package/dist/services/health-retention.js +175 -0
  264. package/dist/services/heartbeat-stream.js +63 -0
  265. package/dist/services/hermes-catalog-import.js +62 -0
  266. package/dist/services/hermes-cli-parser.js +671 -0
  267. package/dist/services/hermes-core-rollout.js +90 -0
  268. package/dist/services/hermes-dashboard-endpoint.js +13 -0
  269. package/dist/services/hermes-plugin-source.js +149 -0
  270. package/dist/services/hermes-registry-service.js +258 -0
  271. package/dist/services/hermes-service.js +566 -0
  272. package/dist/services/hivemem-client.js +85 -0
  273. package/dist/services/host-local-writer.js +82 -0
  274. package/dist/services/incident-evidence.js +110 -0
  275. package/dist/services/incident-lessons.js +186 -0
  276. package/dist/services/llm-providers/anthropic.js +143 -0
  277. package/dist/services/llm-providers/index.js +7 -0
  278. package/dist/services/llm-providers/llama-cpp.js +237 -0
  279. package/dist/services/llm-providers/ollama.js +292 -0
  280. package/dist/services/llm-providers/openai-compatible.js +200 -0
  281. package/dist/services/llm-providers/provider-error.js +36 -0
  282. package/dist/services/llm-providers/registry.js +45 -0
  283. package/dist/services/llm-providers/types.js +2 -0
  284. package/dist/services/log-emitter.js +19 -0
  285. package/dist/services/log-retention.js +66 -0
  286. package/dist/services/maintenance-service.js +29 -0
  287. package/dist/services/managed-cron.js +493 -0
  288. package/dist/services/managed-ssh-admission.js +68 -0
  289. package/dist/services/monitor-pulse.js +10 -0
  290. package/dist/services/node-issue-monitor.js +71 -0
  291. package/dist/services/node-service.js +35 -0
  292. package/dist/services/notification-retention.js +51 -0
  293. package/dist/services/notification-service.js +94 -0
  294. package/dist/services/operational-observations.js +112 -0
  295. package/dist/services/operations-config.js +67 -0
  296. package/dist/services/ops-service.js +491 -0
  297. package/dist/services/orchestra-extras-service.js +370 -0
  298. package/dist/services/orchestra-resources-service.js +530 -0
  299. package/dist/services/os-scheduler-writer.js +73 -0
  300. package/dist/services/password-service.js +22 -0
  301. package/dist/services/prediction-service.js +284 -0
  302. package/dist/services/recovery-exercises.js +682 -0
  303. package/dist/services/remediation-service.js +325 -0
  304. package/dist/services/resource-enrollment.js +433 -0
  305. package/dist/services/resource-node-lock.js +18 -0
  306. package/dist/services/resource-participant-work.js +302 -0
  307. package/dist/services/role-service.js +388 -0
  308. package/dist/services/rollout-service.js +440 -0
  309. package/dist/services/security-export.js +185 -0
  310. package/dist/services/security-fusion-actions.js +22 -0
  311. package/dist/services/security-fusion-approvals.js +83 -0
  312. package/dist/services/security-fusion-evidence-integrity.js +144 -0
  313. package/dist/services/security-fusion-ingest.js +101 -0
  314. package/dist/services/security-fusion-outbox.js +185 -0
  315. package/dist/services/security-fusion-receipt-chain.js +134 -0
  316. package/dist/services/security-fusion-service.js +751 -0
  317. package/dist/services/security-fusion-verification.js +50 -0
  318. package/dist/services/self-update-service.js +200 -0
  319. package/dist/services/shared-resource-leases.js +413 -0
  320. package/dist/services/skill-publication-bundle.js +90 -0
  321. package/dist/services/skill-publication-settlement.js +36 -0
  322. package/dist/services/skill-publication-state.js +40 -0
  323. package/dist/services/skill-registry-service.js +327 -0
  324. package/dist/services/skill-service.js +148 -0
  325. package/dist/services/ssh-exec-service.js +139 -0
  326. package/dist/services/ssh-host-verification.js +27 -0
  327. package/dist/services/tailscale-node-key-service.js +75 -0
  328. package/dist/services/team-approvals.js +118 -0
  329. package/dist/services/topology-service.js +138 -0
  330. package/dist/services/transfer-authority-snapshot.js +151 -0
  331. package/dist/services/webhook-credential-migration.js +97 -0
  332. package/dist/services/webhook-security.js +242 -0
  333. package/dist/services/webhook-service.js +494 -0
  334. package/dist/types/auth.js +3 -0
  335. package/dist/types/index.js +3 -0
  336. package/dist/types/library.js +3 -0
  337. package/dist/types/notifications.js +3 -0
  338. package/dist/types/roles.js +3 -0
  339. package/dist/types/shared-contracts.js +3 -0
  340. package/dist/utils/env.js +141 -0
  341. package/dist/utils/heartbeat-peer.js +19 -0
  342. package/dist/utils/idempotency.js +45 -0
  343. package/dist/utils/ip.js +25 -0
  344. package/dist/utils/json-fields.js +94 -0
  345. package/dist/utils/local-tls.js +39 -0
  346. package/dist/utils/magnus-home.js +15 -0
  347. package/dist/utils/node-url.js +59 -0
  348. package/dist/utils/redact.js +26 -0
  349. package/dist/utils/safe-equal.js +13 -0
  350. package/dist/utils/sentinel-nodes.js +16 -0
  351. package/dist/utils/validate.js +41 -0
  352. package/package.json +119 -4
  353. package/prisma/migrations/20261031000000_baseline/migration.sql +2935 -0
  354. package/prisma/migrations/migration_lock.toml +3 -0
  355. package/prisma/schema.prisma +2185 -0
  356. package/prisma.config.ts +14 -0
@@ -0,0 +1,951 @@
1
+ import { Hono } from 'hono';
2
+ import { nanoid } from 'nanoid';
3
+ import { AGENT_VERSION } from '../config/index.js';
4
+ import { getDb } from '../db/index.js';
5
+ import { promises as dns } from 'node:dns';
6
+ import { authMiddleware, getUser, requireRole } from '../middleware/auth.js';
7
+ import { logAuditEvent } from '../services/audit-service.js';
8
+ import { HttpError } from '../middleware/error-handler.js';
9
+ import { parseBody } from '../utils/validate.js';
10
+ import { bulkNodeTagsSchema, registerNodeSchema, updateNodeSchema } from '../schemas/nodes.js';
11
+ import { normalizeIpAddress, isUsableNodeIp } from '../utils/ip.js';
12
+ import { deleteNodeCascade } from '../services/node-service.js';
13
+ import { serializeStringArray, serializeRecord } from '../utils/json-fields.js';
14
+ import { SENTINEL_NODE_IDS } from '../utils/sentinel-nodes.js';
15
+ import { NodeApiClient } from '../sdk/index.js';
16
+ import { getNodeBaseUrl } from '../utils/node-url.js';
17
+ import { getNodeManagerSecret } from '../utils/env.js';
18
+ import { ensureControlHostNode } from '../services/control-host-node.js';
19
+ import { withReadRetry } from '../db/read-retry.js';
20
+ import { parseTailscaleNodeKeyPosture, summarizeTailscaleNodeKeys, TAILSCALE_RENEWAL_WARNING_DAYS, } from '../services/tailscale-node-key-service.js';
21
+ function parseSystemUpdatePosture(value) {
22
+ try {
23
+ const parsed = JSON.parse(value);
24
+ if (typeof parsed.supported !== 'boolean' ||
25
+ typeof parsed.platform !== 'string' ||
26
+ !['enabled', 'disabled', 'partial', 'unsupported', 'unknown'].includes(parsed.automaticUpdates ?? '') ||
27
+ typeof parsed.unattendedUpgradesInstalled !== 'boolean' ||
28
+ typeof parsed.periodicUpdateListsEnabled !== 'boolean' ||
29
+ typeof parsed.periodicUnattendedUpgradeEnabled !== 'boolean' ||
30
+ !Array.isArray(parsed.timers) ||
31
+ typeof parsed.rebootRequired !== 'boolean' ||
32
+ !Array.isArray(parsed.rebootRequiredPackages))
33
+ return null;
34
+ return {
35
+ ...parsed,
36
+ // Older agents did not report this field. Treat them as not covered so
37
+ // fleet posture cannot mistake the old security-only policy for full upgrades.
38
+ fullUpgradePolicyEnabled: parsed.fullUpgradePolicyEnabled === true,
39
+ };
40
+ }
41
+ catch {
42
+ return null;
43
+ }
44
+ }
45
+ function parseWazuhPosture(value) {
46
+ try {
47
+ const parsed = JSON.parse(value);
48
+ if (typeof parsed.installed !== 'boolean' ||
49
+ !['active', 'inactive', 'failed', 'not_installed', 'unknown'].includes(parsed.serviceStatus ?? '') ||
50
+ !['enabled', 'disabled', 'static', 'unknown'].includes(parsed.enabled ?? '') ||
51
+ !(parsed.version === null || typeof parsed.version === 'string') ||
52
+ !Array.isArray(parsed.managerAddresses) ||
53
+ !parsed.managerAddresses.every((address) => typeof address === 'string')) {
54
+ return null;
55
+ }
56
+ return parsed;
57
+ }
58
+ catch {
59
+ return null;
60
+ }
61
+ }
62
+ async function tryResolveHostnameToIp(hostname) {
63
+ const normalizedHost = hostname.trim();
64
+ if (!normalizedHost)
65
+ return null;
66
+ // Allow IP literals directly (e.g., hostname accidentally set to IP)
67
+ if (isUsableNodeIp(normalizedHost)) {
68
+ return normalizeIpAddress(normalizedHost);
69
+ }
70
+ try {
71
+ const records = await dns.lookup(normalizedHost, { all: true, verbatim: true });
72
+ const candidate = records
73
+ .map((record) => normalizeIpAddress(record.address))
74
+ .find((address) => isUsableNodeIp(address));
75
+ return candidate ?? null;
76
+ }
77
+ catch {
78
+ return null;
79
+ }
80
+ }
81
+ export const nodesRouter = new Hono();
82
+ // All routes require auth
83
+ nodesRouter.use('*', authMiddleware);
84
+ // ── List nodes ──
85
+ nodesRouter.get('/', async (c) => {
86
+ await ensureControlHostNode();
87
+ const db = getDb();
88
+ const page = Number(c.req.query('page') ?? '1');
89
+ const pageSize = Math.min(Number(c.req.query('pageSize') ?? '50'), 100);
90
+ const status = c.req.query('status');
91
+ const tag = c.req.query('tag');
92
+ const query = (c.req.query('query') ?? '').trim();
93
+ const baseWhere = { id: { notIn: SENTINEL_NODE_IDS } };
94
+ if (status)
95
+ baseWhere['status'] = status.toUpperCase();
96
+ // Tags remain a wire-compatible JSON string, so search for a quoted member.
97
+ if (tag)
98
+ baseWhere['tags'] = { contains: `"${tag}"` };
99
+ if (query) {
100
+ baseWhere['OR'] = [
101
+ { name: { contains: query } },
102
+ { hostname: { contains: query } },
103
+ { ipAddress: { contains: query } },
104
+ ];
105
+ }
106
+ const where = baseWhere;
107
+ const [nodes, total] = await withReadRetry('nodes list', () => Promise.all([
108
+ db.node.findMany({
109
+ where,
110
+ skip: (page - 1) * pageSize,
111
+ take: pageSize,
112
+ orderBy: { name: 'asc' },
113
+ include: { agent: true, role: { select: { id: true, name: true } } },
114
+ }),
115
+ db.node.count({ where }),
116
+ ]));
117
+ // Strip token from API response — never expose node secrets to the frontend
118
+ const safeNodes = nodes.map(({ token: _token, ...rest }) => rest);
119
+ return c.json({
120
+ success: true,
121
+ data: safeNodes,
122
+ meta: { total, page, pageSize, hasMore: page * pageSize < total },
123
+ });
124
+ });
125
+ // ── Get all unique tags with counts ──
126
+ nodesRouter.get('/tags', async (c) => {
127
+ const db = getDb();
128
+ const nodes = await db.node.findMany({
129
+ where: { id: { notIn: SENTINEL_NODE_IDS } },
130
+ select: { tags: true },
131
+ });
132
+ const tagCounts = new Map();
133
+ for (const node of nodes) {
134
+ const tags = JSON.parse(node.tags || '[]');
135
+ for (const tag of tags) {
136
+ tagCounts.set(tag, (tagCounts.get(tag) ?? 0) + 1);
137
+ }
138
+ }
139
+ const tags = [...tagCounts.entries()]
140
+ .map(([name, count]) => ({ name, count }))
141
+ .sort((a, b) => a.name.localeCompare(b.name));
142
+ return c.json({ success: true, data: tags });
143
+ });
144
+ // ── Fleet Wazuh inventory from each node's latest heartbeat ──
145
+ nodesRouter.get('/wazuh/summary', async (c) => {
146
+ const db = getDb();
147
+ const nodes = await db.node.findMany({
148
+ where: { id: { notIn: SENTINEL_NODE_IDS } },
149
+ orderBy: { name: 'asc' },
150
+ select: {
151
+ id: true,
152
+ name: true,
153
+ hostname: true,
154
+ status: true,
155
+ lastHeartbeatAt: true,
156
+ healthSnapshots: {
157
+ orderBy: { timestamp: 'desc' },
158
+ take: 1,
159
+ select: { timestamp: true, wazuh: true },
160
+ },
161
+ },
162
+ });
163
+ const inventory = nodes.map((node) => {
164
+ const snapshot = node.healthSnapshots[0] ?? null;
165
+ return {
166
+ id: node.id,
167
+ name: node.name,
168
+ hostname: node.hostname,
169
+ nodeStatus: node.status,
170
+ lastHeartbeatAt: node.lastHeartbeatAt,
171
+ observedAt: snapshot?.timestamp ?? null,
172
+ wazuh: snapshot ? parseWazuhPosture(snapshot.wazuh) : null,
173
+ };
174
+ });
175
+ return c.json({
176
+ success: true,
177
+ data: {
178
+ total: inventory.length,
179
+ reporting: inventory.filter((node) => node.wazuh !== null).length,
180
+ installed: inventory.filter((node) => node.wazuh?.installed).length,
181
+ active: inventory.filter((node) => node.wazuh?.serviceStatus === 'active').length,
182
+ failed: inventory.filter((node) => node.wazuh?.serviceStatus === 'failed').length,
183
+ nodes: inventory,
184
+ },
185
+ });
186
+ });
187
+ // ── Fleet Tailscale node-key renewal posture ──
188
+ nodesRouter.get('/tailscale/summary', async (c) => {
189
+ const db = getDb();
190
+ const nodes = await db.node.findMany({
191
+ where: { id: { notIn: SENTINEL_NODE_IDS } },
192
+ orderBy: { name: 'asc' },
193
+ select: {
194
+ id: true,
195
+ name: true,
196
+ hostname: true,
197
+ status: true,
198
+ lastHeartbeatAt: true,
199
+ healthSnapshots: {
200
+ orderBy: { timestamp: 'desc' },
201
+ take: 1,
202
+ select: { timestamp: true, services: true },
203
+ },
204
+ },
205
+ });
206
+ const nowMs = Date.now();
207
+ const inventory = nodes.map((node) => {
208
+ const snapshot = node.healthSnapshots[0] ?? null;
209
+ return {
210
+ id: node.id,
211
+ name: node.name,
212
+ hostname: node.hostname,
213
+ nodeStatus: node.status,
214
+ lastHeartbeatAt: node.lastHeartbeatAt,
215
+ observedAt: snapshot?.timestamp ?? null,
216
+ tailscale: snapshot
217
+ ? parseTailscaleNodeKeyPosture(snapshot.services, nowMs, TAILSCALE_RENEWAL_WARNING_DAYS)
218
+ : null,
219
+ };
220
+ });
221
+ return c.json({
222
+ success: true,
223
+ data: summarizeTailscaleNodeKeys(inventory, TAILSCALE_RENEWAL_WARNING_DAYS),
224
+ });
225
+ });
226
+ // ── Fleet automatic-update and reboot posture ──
227
+ nodesRouter.get('/system-updates/summary', async (c) => {
228
+ const db = getDb();
229
+ const nodes = await db.node.findMany({
230
+ where: { id: { notIn: SENTINEL_NODE_IDS } },
231
+ orderBy: { name: 'asc' },
232
+ select: {
233
+ id: true,
234
+ name: true,
235
+ hostname: true,
236
+ status: true,
237
+ lastHeartbeatAt: true,
238
+ healthSnapshots: {
239
+ orderBy: { timestamp: 'desc' },
240
+ take: 1,
241
+ select: { timestamp: true, systemUpdates: true },
242
+ },
243
+ },
244
+ });
245
+ const inventory = nodes.map((node) => {
246
+ const snapshot = node.healthSnapshots[0] ?? null;
247
+ return {
248
+ id: node.id,
249
+ name: node.name,
250
+ hostname: node.hostname,
251
+ nodeStatus: node.status,
252
+ lastHeartbeatAt: node.lastHeartbeatAt,
253
+ observedAt: snapshot?.timestamp ?? null,
254
+ systemUpdates: snapshot ? parseSystemUpdatePosture(snapshot.systemUpdates) : null,
255
+ };
256
+ });
257
+ return c.json({
258
+ success: true,
259
+ data: {
260
+ total: inventory.length,
261
+ reporting: inventory.filter((node) => node.systemUpdates !== null).length,
262
+ enabled: inventory.filter((node) => node.systemUpdates?.automaticUpdates === 'enabled').length,
263
+ actionNeeded: inventory.filter((node) => ['disabled', 'partial'].includes(node.systemUpdates?.automaticUpdates ?? '')).length,
264
+ rebootRequired: inventory.filter((node) => node.systemUpdates?.rebootRequired).length,
265
+ nodes: inventory,
266
+ },
267
+ });
268
+ });
269
+ nodesRouter.post('/:nodeId/system-updates/enable', requireRole('ADMIN', 'OPERATOR'), async (c) => {
270
+ const db = getDb();
271
+ const user = getUser(c);
272
+ const nodeId = c.req.param('nodeId');
273
+ const node = await db.node.findUnique({ where: { id: nodeId } });
274
+ if (!node)
275
+ throw new HttpError(404, 'NOT_FOUND', 'Node not found');
276
+ if (node.status !== 'ONLINE') {
277
+ throw new HttpError(409, 'INVALID_STATE', `Node is ${node.status}, must be ONLINE`);
278
+ }
279
+ try {
280
+ const client = new NodeApiClient({
281
+ baseUrl: getNodeBaseUrl(node.ipAddress, node.port),
282
+ resourceNodeId: node.id,
283
+ nodeToken: getNodeManagerSecret(),
284
+ timeout: 20 * 60_000,
285
+ });
286
+ const response = await client.enableSystemUpdates();
287
+ if (!response.success || !response.data)
288
+ throw new Error('Node agent rejected the update policy change');
289
+ await logAuditEvent({
290
+ action: 'node.system_updates.enable',
291
+ actorId: user.userId,
292
+ targetType: 'node',
293
+ targetId: nodeId,
294
+ details: { hostname: node.hostname, automaticUpdates: response.data.automaticUpdates },
295
+ });
296
+ return c.json({ success: true, data: response.data });
297
+ }
298
+ catch (err) {
299
+ const message = err instanceof Error ? err.message : String(err);
300
+ await logAuditEvent({
301
+ action: 'node.system_updates.enable_failed',
302
+ actorId: user.userId,
303
+ targetType: 'node',
304
+ targetId: nodeId,
305
+ details: { hostname: node.hostname, error: message },
306
+ });
307
+ throw new HttpError(502, 'NODE_UPDATE_POLICY_FAILED', message);
308
+ }
309
+ });
310
+ // ── Bulk tag operations (add/remove across multiple nodes) ──
311
+ nodesRouter.post('/tags/bulk', async (c) => {
312
+ const db = getDb();
313
+ const user = getUser(c);
314
+ if (user.role !== 'ADMIN' && user.role !== 'OPERATOR') {
315
+ throw new HttpError(403, 'FORBIDDEN', 'Operator or admin access required');
316
+ }
317
+ const body = await parseBody(c, bulkNodeTagsSchema);
318
+ const tagsToApply = [...new Set(body.tags.map((t) => t.trim().toLowerCase()).filter((t) => t.length > 0))];
319
+ const nodes = await db.node.findMany({
320
+ where: { id: { in: body.nodeIds } },
321
+ select: { id: true, tags: true },
322
+ });
323
+ const updates = nodes.map((node) => {
324
+ const currentTags = JSON.parse(node.tags || '[]');
325
+ let newTags;
326
+ if (body.action === 'add') {
327
+ newTags = [...new Set([...currentTags, ...tagsToApply])];
328
+ }
329
+ else {
330
+ newTags = currentTags.filter((t) => !tagsToApply.includes(t));
331
+ }
332
+ return db.node.update({
333
+ where: { id: node.id },
334
+ data: { tags: JSON.stringify(newTags) },
335
+ });
336
+ });
337
+ await Promise.all(updates);
338
+ await logAuditEvent({
339
+ action: 'node.tags.bulk',
340
+ actorId: user.userId,
341
+ targetType: 'node',
342
+ targetId: nodes.length === 1 ? nodes[0].id : 'bulk',
343
+ details: { action: body.action, tags: tagsToApply, nodeIds: nodes.map((node) => node.id), updated: nodes.length },
344
+ });
345
+ return c.json({
346
+ success: true,
347
+ data: { updated: nodes.length, action: body.action, tags: tagsToApply },
348
+ });
349
+ });
350
+ // ── Compare nodes side-by-side ──
351
+ nodesRouter.get('/compare', async (c) => {
352
+ const idsParam = c.req.query('ids');
353
+ if (!idsParam) {
354
+ throw new HttpError(400, 'VALIDATION_ERROR', 'ids query parameter is required (comma-separated node IDs)');
355
+ }
356
+ const ids = idsParam.split(',').map((id) => id.trim()).filter(Boolean);
357
+ if (ids.length < 2 || ids.length > 5) {
358
+ throw new HttpError(400, 'VALIDATION_ERROR', 'Provide 2-5 node IDs to compare');
359
+ }
360
+ const db = getDb();
361
+ const nodes = await db.node.findMany({
362
+ where: { id: { in: ids } },
363
+ include: {
364
+ agent: { select: { version: true } },
365
+ healthSnapshots: {
366
+ orderBy: { timestamp: 'desc' },
367
+ take: 1,
368
+ },
369
+ skillInstalls: true,
370
+ roleApplications: {
371
+ include: { role: { select: { name: true } } },
372
+ },
373
+ },
374
+ });
375
+ if (nodes.length < 2) {
376
+ throw new HttpError(404, 'NOT_FOUND', 'One or more nodes not found');
377
+ }
378
+ // Build comparison structure
379
+ const comparison = nodes.map((node) => {
380
+ const health = node.healthSnapshots[0] ?? null;
381
+ const tags = (() => { try {
382
+ return JSON.parse(node.tags ?? '[]');
383
+ }
384
+ catch {
385
+ return [];
386
+ } })();
387
+ return {
388
+ id: node.id,
389
+ name: node.name,
390
+ hostname: node.hostname,
391
+ ipAddress: node.ipAddress,
392
+ status: node.status,
393
+ agentVersion: node.agent?.version ?? 'unknown',
394
+ os: node.os,
395
+ arch: node.arch,
396
+ tags,
397
+ health: health
398
+ ? {
399
+ cpuUsagePercent: health.cpuUsagePercent,
400
+ memoryPercent: health.memoryPercent,
401
+ disks: health.disks,
402
+ timestamp: health.timestamp,
403
+ }
404
+ : null,
405
+ skills: node.skillInstalls.map((s) => ({
406
+ name: s.skillName,
407
+ version: s.version,
408
+ status: s.status,
409
+ })),
410
+ roles: node.roleApplications.map((ra) => ({
411
+ roleName: ra.role.name,
412
+ action: ra.action,
413
+ status: ra.status,
414
+ })),
415
+ };
416
+ });
417
+ // Identify differences
418
+ const diffFields = [];
419
+ const reference = comparison[0];
420
+ if (!reference) {
421
+ throw new HttpError(500, 'INTERNAL_ERROR', 'Comparison failed');
422
+ }
423
+ const fieldChecks = ['status', 'agentVersion', 'os', 'arch'];
424
+ for (const field of fieldChecks) {
425
+ const values = new Set(comparison.map((n) => n[field]));
426
+ if (values.size > 1)
427
+ diffFields.push(field);
428
+ }
429
+ // Check health metric differences (>10% threshold)
430
+ if (comparison.every((n) => n.health)) {
431
+ const healthFields = ['cpuUsagePercent', 'memoryPercent'];
432
+ for (const field of healthFields) {
433
+ const values = comparison.map((n) => n.health?.[field] ?? 0);
434
+ const range = Math.max(...values) - Math.min(...values);
435
+ if (range > 10)
436
+ diffFields.push(`health.${field}`);
437
+ }
438
+ }
439
+ // Check skill differences
440
+ const skillSets = comparison.map((n) => new Set(n.skills.map((s) => `${s.name}@${s.version}`)));
441
+ const allSkills = new Set(skillSets.flatMap((s) => [...s]));
442
+ for (const skill of allSkills) {
443
+ if (!skillSets.every((s) => s.has(skill))) {
444
+ diffFields.push('skills');
445
+ break;
446
+ }
447
+ }
448
+ return c.json({
449
+ success: true,
450
+ data: {
451
+ nodes: comparison,
452
+ diffFields,
453
+ },
454
+ });
455
+ });
456
+ // ── Scan all online nodes for linux users ──
457
+ nodesRouter.get('/scan-users', requireRole('ADMIN', 'OPERATOR'), async (c) => {
458
+ const db = getDb();
459
+ const nodes = await db.node.findMany({
460
+ where: { status: 'ONLINE', id: { notIn: SENTINEL_NODE_IDS } },
461
+ select: { id: true, name: true, ipAddress: true, port: true },
462
+ });
463
+ const results = {};
464
+ await Promise.allSettled(nodes.map(async (node) => {
465
+ try {
466
+ const client = new NodeApiClient({
467
+ baseUrl: getNodeBaseUrl(node.ipAddress, node.port),
468
+ resourceNodeId: node.id,
469
+ nodeToken: getNodeManagerSecret(),
470
+ timeout: 10_000,
471
+ });
472
+ const res = await client.listUsers();
473
+ if (res.success && res.data) {
474
+ for (const username of res.data) {
475
+ if (!results[username])
476
+ results[username] = [];
477
+ results[username].push(node.id);
478
+ }
479
+ }
480
+ }
481
+ catch {
482
+ // Skip unreachable nodes
483
+ }
484
+ }));
485
+ return c.json({ success: true, data: results });
486
+ });
487
+ // ── Get single node ──
488
+ nodesRouter.get('/:nodeId', async (c) => {
489
+ const db = getDb();
490
+ const node = await db.node.findUnique({
491
+ where: { id: c.req.param('nodeId') },
492
+ include: { agent: true, role: { select: { id: true, name: true } } },
493
+ });
494
+ if (!node)
495
+ throw new HttpError(404, 'NOT_FOUND', 'Node not found');
496
+ const { token: _token, ...safeNode } = node;
497
+ return c.json({ success: true, data: safeNode });
498
+ });
499
+ // ── Register a new node ──
500
+ nodesRouter.post('/register', requireRole('ADMIN', 'OPERATOR'), async (c) => {
501
+ const db = getDb();
502
+ const user = getUser(c);
503
+ const body = await parseBody(c, registerNodeSchema);
504
+ const token = `node-${nanoid()}`;
505
+ const node = await db.node.create({
506
+ data: {
507
+ name: body.name,
508
+ hostname: body.hostname,
509
+ ipAddress: body.ipAddress,
510
+ port: body.port ?? 4100,
511
+ os: body.os ?? 'unknown',
512
+ arch: body.arch ?? 'unknown',
513
+ tags: serializeStringArray(body.tags),
514
+ metadata: serializeRecord(body.metadata),
515
+ token,
516
+ status: 'PROVISIONING',
517
+ },
518
+ });
519
+ // Create agent record
520
+ await db.agent.create({
521
+ data: {
522
+ nodeId: node.id,
523
+ version: body.agentVersion ?? AGENT_VERSION,
524
+ status: 'RUNNING',
525
+ capabilities: serializeStringArray([]),
526
+ },
527
+ });
528
+ await logAuditEvent({
529
+ action: 'node.register',
530
+ actorId: user.userId,
531
+ targetType: 'node',
532
+ targetId: node.id,
533
+ details: { name: body.name, hostname: body.hostname },
534
+ });
535
+ return c.json({
536
+ success: true,
537
+ data: {
538
+ nodeId: node.id,
539
+ token,
540
+ controlApiUrl: process.env['CONTROL_API_URL'] ?? 'https://localhost:4000',
541
+ },
542
+ }, 201);
543
+ });
544
+ // ── Update node ──
545
+ nodesRouter.patch('/:nodeId', requireRole('ADMIN', 'OPERATOR'), async (c) => {
546
+ const db = getDb();
547
+ const user = getUser(c);
548
+ const nodeId = c.req.param('nodeId');
549
+ const body = await parseBody(c, updateNodeSchema);
550
+ // Build update payload explicitly to satisfy Prisma's union input types
551
+ const data = {};
552
+ if (body.name)
553
+ data['name'] = body.name;
554
+ if (body.tags)
555
+ data['tags'] = serializeStringArray(body.tags);
556
+ if (body.metadata)
557
+ data['metadata'] = serializeRecord(body.metadata);
558
+ if (body.status)
559
+ data['status'] = body.status.toUpperCase();
560
+ if (body.roleId !== undefined)
561
+ data['roleId'] = body.roleId ?? null;
562
+ const node = await db.node.update({
563
+ where: { id: nodeId },
564
+ data,
565
+ }).catch((err) => {
566
+ if (err.code === 'P2025')
567
+ throw new HttpError(404, 'NOT_FOUND', 'Node not found');
568
+ throw err;
569
+ });
570
+ const { token: _token, ...safeNode } = node;
571
+ await logAuditEvent({
572
+ action: 'node.update',
573
+ actorId: user.userId,
574
+ targetType: 'node',
575
+ targetId: nodeId,
576
+ details: { name: body.name, tags: body.tags, status: body.status },
577
+ });
578
+ return c.json({ success: true, data: safeNode });
579
+ });
580
+ // ── Repair node address (non-destructive) ──
581
+ nodesRouter.post('/:nodeId/repair-address', requireRole('ADMIN', 'OPERATOR'), async (c) => {
582
+ const db = getDb();
583
+ const user = getUser(c);
584
+ const nodeId = c.req.param('nodeId');
585
+ const body = await c.req
586
+ .json()
587
+ .catch(() => ({}));
588
+ const node = await db.node.findUnique({ where: { id: nodeId } });
589
+ if (!node)
590
+ throw new HttpError(404, 'NOT_FOUND', 'Node not found');
591
+ const targetHostname = (body.hostname ?? node.hostname).trim();
592
+ if (!targetHostname) {
593
+ throw new HttpError(400, 'VALIDATION_ERROR', 'hostname cannot be empty');
594
+ }
595
+ let source = 'existing';
596
+ let resolvedIp = null;
597
+ if (body.ipAddress !== undefined) {
598
+ const manualIp = normalizeIpAddress(body.ipAddress);
599
+ if (!isUsableNodeIp(manualIp)) {
600
+ throw new HttpError(400, 'VALIDATION_ERROR', 'ipAddress must be a valid non-loopback IP');
601
+ }
602
+ resolvedIp = manualIp;
603
+ source = 'manual';
604
+ }
605
+ if (!resolvedIp && (body.resolveDns ?? true)) {
606
+ resolvedIp = await tryResolveHostnameToIp(targetHostname);
607
+ if (resolvedIp) {
608
+ source = 'dns';
609
+ }
610
+ }
611
+ if (!resolvedIp && isUsableNodeIp(node.ipAddress)) {
612
+ resolvedIp = normalizeIpAddress(node.ipAddress);
613
+ source = 'existing';
614
+ }
615
+ if (!resolvedIp) {
616
+ throw new HttpError(400, 'VALIDATION_ERROR', 'Unable to determine a routable IP. Provide ipAddress or ensure hostname resolves via DNS.');
617
+ }
618
+ const ipChanged = resolvedIp !== node.ipAddress;
619
+ const hostnameChanged = targetHostname !== node.hostname;
620
+ if (!ipChanged && !hostnameChanged) {
621
+ const { token: _token, ...safeNode } = node;
622
+ return c.json({
623
+ success: true,
624
+ data: {
625
+ changed: false,
626
+ source,
627
+ previousIpAddress: node.ipAddress,
628
+ node: safeNode,
629
+ },
630
+ });
631
+ }
632
+ const updated = await db.node.update({
633
+ where: { id: nodeId },
634
+ data: {
635
+ hostname: targetHostname,
636
+ ipAddress: resolvedIp,
637
+ },
638
+ });
639
+ await logAuditEvent({
640
+ action: 'node.repair_address',
641
+ actorId: user.userId,
642
+ targetType: 'node',
643
+ targetId: nodeId,
644
+ details: {
645
+ hostname: targetHostname,
646
+ previousIpAddress: node.ipAddress,
647
+ newIpAddress: resolvedIp,
648
+ source,
649
+ },
650
+ });
651
+ const { token: _token, ...safeNode } = updated;
652
+ return c.json({
653
+ success: true,
654
+ data: {
655
+ changed: true,
656
+ source,
657
+ previousIpAddress: node.ipAddress,
658
+ node: safeNode,
659
+ },
660
+ });
661
+ });
662
+ // ── Delete (hard-delete) node ──
663
+ nodesRouter.delete('/:nodeId', requireRole('ADMIN'), async (c) => {
664
+ const db = getDb();
665
+ const user = getUser(c);
666
+ const nodeId = c.req.param('nodeId');
667
+ const existing = await db.node.findUnique({ where: { id: nodeId } });
668
+ if (!existing)
669
+ throw new HttpError(404, 'NOT_FOUND', 'Node not found');
670
+ // Delete related records in dependency order
671
+ await deleteNodeCascade(nodeId);
672
+ await logAuditEvent({
673
+ action: 'node.decommission',
674
+ actorId: user.userId,
675
+ targetType: 'node',
676
+ targetId: nodeId,
677
+ details: { name: existing.name, hostname: existing.hostname, hardDelete: true },
678
+ });
679
+ return c.json({ success: true, data: { deleted: true } });
680
+ });
681
+ /** Convert BigInt fields in a health snapshot to numbers for JSON serialization. */
682
+ function serializeSnapshot(snapshot) {
683
+ const result = {};
684
+ for (const [key, value] of Object.entries(snapshot)) {
685
+ result[key] = typeof value === 'bigint' ? Number(value) : value;
686
+ }
687
+ return result;
688
+ }
689
+ function parseGpuDevices(value) {
690
+ try {
691
+ const parsed = JSON.parse(value);
692
+ return Array.isArray(parsed) ? parsed : [];
693
+ }
694
+ catch {
695
+ return [];
696
+ }
697
+ }
698
+ const GPU_PERIOD_MS = {
699
+ '6h': 6 * 60 * 60 * 1000,
700
+ '24h': 24 * 60 * 60 * 1000,
701
+ '7d': 7 * 24 * 60 * 60 * 1000,
702
+ };
703
+ // ── GPU history (30-second storage; seven-day responses use five-minute buckets) ──
704
+ nodesRouter.get('/:nodeId/gpu', async (c) => {
705
+ const db = getDb();
706
+ const nodeId = c.req.param('nodeId');
707
+ const period = c.req.query('period') ?? '24h';
708
+ const periodMs = GPU_PERIOD_MS[period];
709
+ if (!periodMs) {
710
+ throw new HttpError(400, 'VALIDATION_ERROR', 'period must be 6h, 24h, or 7d');
711
+ }
712
+ const snapshots = await db.gpuSnapshot.findMany({
713
+ where: { nodeId, sampledAt: { gte: new Date(Date.now() - periodMs) } },
714
+ orderBy: { sampledAt: 'asc' },
715
+ take: 21_000,
716
+ });
717
+ const fullHistory = snapshots.map((snapshot) => ({
718
+ sampledAt: snapshot.sampledAt,
719
+ sampleIntervalSeconds: snapshot.sampleIntervalSeconds,
720
+ status: snapshot.status,
721
+ devices: parseGpuDevices(snapshot.devices),
722
+ maxUtilizationPercent: snapshot.maxUtilizationPercent,
723
+ maxFanPercent: snapshot.maxFanPercent,
724
+ }));
725
+ const history = period === '7d'
726
+ ? [...fullHistory.reduce((buckets, point) => {
727
+ const bucket = Math.floor(new Date(point.sampledAt).getTime() / (5 * 60 * 1000));
728
+ buckets.set(bucket, point);
729
+ return buckets;
730
+ }, new Map()).values()]
731
+ : fullHistory;
732
+ return c.json({
733
+ success: true,
734
+ data: {
735
+ period,
736
+ latest: fullHistory.at(-1) ?? null,
737
+ history,
738
+ },
739
+ });
740
+ });
741
+ // ── Get node health (latest snapshot) ──
742
+ nodesRouter.get('/:nodeId/health', async (c) => {
743
+ const db = getDb();
744
+ const nodeId = c.req.param('nodeId');
745
+ const snapshot = await db.healthSnapshot.findFirst({
746
+ where: { nodeId },
747
+ orderBy: { timestamp: 'desc' },
748
+ });
749
+ if (!snapshot)
750
+ throw new HttpError(404, 'NOT_FOUND', 'No health data available');
751
+ return c.json({ success: true, data: serializeSnapshot(snapshot) });
752
+ });
753
+ // ── Get latest Wazuh posture for one node ──
754
+ nodesRouter.get('/:nodeId/wazuh', async (c) => {
755
+ const db = getDb();
756
+ const nodeId = c.req.param('nodeId');
757
+ const snapshot = await db.healthSnapshot.findFirst({
758
+ where: { nodeId },
759
+ orderBy: { timestamp: 'desc' },
760
+ select: { nodeId: true, timestamp: true, wazuh: true },
761
+ });
762
+ if (!snapshot)
763
+ throw new HttpError(404, 'NOT_FOUND', 'No health data available');
764
+ return c.json({
765
+ success: true,
766
+ data: {
767
+ nodeId: snapshot.nodeId,
768
+ observedAt: snapshot.timestamp,
769
+ wazuh: parseWazuhPosture(snapshot.wazuh),
770
+ },
771
+ });
772
+ });
773
+ // ── Health history ──
774
+ nodesRouter.get('/:nodeId/health/history', async (c) => {
775
+ const db = getDb();
776
+ const nodeId = c.req.param('nodeId');
777
+ const limit = Math.min(Number(c.req.query('limit') ?? '100'), 500);
778
+ const snapshots = await db.healthSnapshot.findMany({
779
+ where: { nodeId },
780
+ orderBy: { timestamp: 'desc' },
781
+ take: limit,
782
+ });
783
+ return c.json({ success: true, data: snapshots.map((s) => serializeSnapshot(s)) });
784
+ });
785
+ // ── Health time-series for charting ──
786
+ const PERIOD_MS = {
787
+ '1h': 60 * 60 * 1000,
788
+ '6h': 6 * 60 * 60 * 1000,
789
+ '24h': 24 * 60 * 60 * 1000,
790
+ '7d': 7 * 24 * 60 * 60 * 1000,
791
+ '30d': 30 * 24 * 60 * 60 * 1000,
792
+ };
793
+ function extractMetric(snapshot, metric) {
794
+ switch (metric) {
795
+ case 'cpu': return Number(snapshot.cpuUsagePercent ?? 0);
796
+ case 'memory': return Number(snapshot.memoryPercent ?? 0);
797
+ case 'swap': {
798
+ const total = Number(snapshot.swapTotalBytes ?? 0);
799
+ const used = Number(snapshot.swapUsedBytes ?? 0);
800
+ return total > 0 ? (used / total) * 100 : 0;
801
+ }
802
+ case 'disk': {
803
+ try {
804
+ const disks = typeof snapshot.disks === 'string' ? JSON.parse(snapshot.disks) : snapshot.disks;
805
+ if (Array.isArray(disks) && disks.length > 0) {
806
+ return disks[0].usePercent ?? 0;
807
+ }
808
+ }
809
+ catch { /* ignore */ }
810
+ return 0;
811
+ }
812
+ }
813
+ }
814
+ nodesRouter.get('/:nodeId/health/timeseries', async (c) => {
815
+ const db = getDb();
816
+ const nodeId = c.req.param('nodeId');
817
+ const metric = (c.req.query('metric') ?? 'cpu');
818
+ const period = c.req.query('period') ?? '1h';
819
+ if (!['cpu', 'memory', 'swap', 'disk'].includes(metric)) {
820
+ throw new HttpError(400, 'VALIDATION_ERROR', 'metric must be cpu, memory, swap, or disk');
821
+ }
822
+ const periodMs = PERIOD_MS[period];
823
+ if (!periodMs) {
824
+ throw new HttpError(400, 'VALIDATION_ERROR', 'period must be 1h, 6h, 24h, 7d, or 30d');
825
+ }
826
+ const since = new Date(Date.now() - periodMs);
827
+ // Load only the timestamp and the columns the requested metric reads.
828
+ const snapshots = await db.healthSnapshot.findMany({
829
+ where: { nodeId, timestamp: { gte: since } },
830
+ orderBy: { timestamp: 'asc' },
831
+ select: {
832
+ timestamp: true,
833
+ cpuUsagePercent: metric === 'cpu',
834
+ memoryPercent: metric === 'memory',
835
+ swapTotalBytes: metric === 'swap',
836
+ swapUsedBytes: metric === 'swap',
837
+ disks: metric === 'disk',
838
+ },
839
+ });
840
+ if (snapshots.length === 0) {
841
+ return c.json({
842
+ success: true,
843
+ data: { timestamps: [], values: [], min: 0, max: 0, avg: 0 },
844
+ });
845
+ }
846
+ // For periods > 24h, downsample to 5-minute averages
847
+ const shouldDownsample = periodMs > 24 * 60 * 60 * 1000;
848
+ const windowMs = shouldDownsample ? 5 * 60 * 1000 : 0;
849
+ if (shouldDownsample) {
850
+ const buckets = new Map();
851
+ for (const snap of snapshots) {
852
+ const ts = snap.timestamp.getTime();
853
+ const bucketKey = Math.floor(ts / windowMs) * windowMs;
854
+ const value = extractMetric(snap, metric);
855
+ const existing = buckets.get(bucketKey);
856
+ if (existing) {
857
+ existing.sum += value;
858
+ existing.count++;
859
+ }
860
+ else {
861
+ buckets.set(bucketKey, { sum: value, count: 1 });
862
+ }
863
+ }
864
+ const sortedKeys = [...buckets.keys()].sort((a, b) => a - b);
865
+ const timestamps = sortedKeys.map((k) => new Date(k).toISOString());
866
+ const values = sortedKeys.map((k) => {
867
+ const b = buckets.get(k);
868
+ return Math.round((b.sum / b.count) * 100) / 100;
869
+ });
870
+ const min = Math.min(...values);
871
+ const max = Math.max(...values);
872
+ const avg = Math.round((values.reduce((a, b) => a + b, 0) / values.length) * 100) / 100;
873
+ return c.json({ success: true, data: { timestamps, values, min, max, avg } });
874
+ }
875
+ // Raw data for short periods
876
+ const timestamps = snapshots.map((s) => s.timestamp.toISOString());
877
+ const values = snapshots.map((s) => Math.round(extractMetric(s, metric) * 100) / 100);
878
+ const min = Math.min(...values);
879
+ const max = Math.max(...values);
880
+ const avg = Math.round((values.reduce((a, b) => a + b, 0) / values.length) * 100) / 100;
881
+ return c.json({ success: true, data: { timestamps, values, min, max, avg } });
882
+ });
883
+ // The legacy migration path embedded credentials in an unverified remote shell,
884
+ // restarted only part of the agent environment, and deleted inventory before a
885
+ // verified heartbeat/command proof. Reviewed key-only deployment scripts are the
886
+ // sole migration path until the agent API provides a transactional equivalent.
887
+ nodesRouter.post('/:nodeId/migrate', requireRole('ADMIN'), (c) => c.json({
888
+ success: false,
889
+ error: {
890
+ code: 'NODE_MIGRATION_RETIRED',
891
+ message: 'In-band node migration is retired; use the reviewed key-only agent deployment runbook.',
892
+ },
893
+ }, 410));
894
+ // ── Provision user on a specific node ──
895
+ nodesRouter.post('/:nodeId/provision-user', requireRole('ADMIN', 'OPERATOR'), async (c) => {
896
+ const db = getDb();
897
+ const user = getUser(c);
898
+ const nodeId = c.req.param('nodeId');
899
+ const body = await c.req.json();
900
+ const node = await db.node.findUnique({ where: { id: nodeId } });
901
+ if (!node)
902
+ throw new HttpError(404, 'NOT_FOUND', 'Node not found');
903
+ const client = new NodeApiClient({
904
+ baseUrl: getNodeBaseUrl(node.ipAddress, node.port),
905
+ resourceNodeId: node.id,
906
+ nodeToken: getNodeManagerSecret(),
907
+ });
908
+ const result = await client.provisionUser({
909
+ username: body.username,
910
+ password: body.password,
911
+ group: body.group,
912
+ });
913
+ await logAuditEvent({
914
+ action: 'node.provision_user',
915
+ actorId: user.userId,
916
+ ipAddress: c.req.header('x-forwarded-for') ?? 'unknown',
917
+ targetType: 'node',
918
+ targetId: nodeId,
919
+ details: { username: body.username, nodeId },
920
+ });
921
+ return c.json(result);
922
+ });
923
+ // ── Change user password on a specific node ──
924
+ nodesRouter.post('/:nodeId/change-password', requireRole('ADMIN', 'OPERATOR'), async (c) => {
925
+ const db = getDb();
926
+ const user = getUser(c);
927
+ const nodeId = c.req.param('nodeId');
928
+ const body = await c.req.json();
929
+ const node = await db.node.findUnique({ where: { id: nodeId } });
930
+ if (!node)
931
+ throw new HttpError(404, 'NOT_FOUND', 'Node not found');
932
+ const client = new NodeApiClient({
933
+ baseUrl: getNodeBaseUrl(node.ipAddress, node.port),
934
+ resourceNodeId: node.id,
935
+ nodeToken: getNodeManagerSecret(),
936
+ });
937
+ const result = await client.changeUserPassword({
938
+ username: body.username,
939
+ password: body.password,
940
+ });
941
+ await logAuditEvent({
942
+ action: 'node.change_password',
943
+ actorId: user.userId,
944
+ ipAddress: c.req.header('x-forwarded-for') ?? 'unknown',
945
+ targetType: 'node',
946
+ targetId: nodeId,
947
+ details: { username: body.username, nodeId },
948
+ });
949
+ return c.json(result);
950
+ });
951
+ //# sourceMappingURL=nodes.js.map