@underpostnet/cyberia 3.3.0 → 3.3.76

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 (568) hide show
  1. package/.dockerignore +1 -0
  2. package/.env.example +17 -5
  3. package/.github/workflows/coverall.cyberia.ci.yml +39 -14
  4. package/.github/workflows/cyberia-client.cd.yml +5 -0
  5. package/.github/workflows/cyberia-server.cd.yml +5 -0
  6. package/.github/workflows/docker-image.cyberia-client.ci.yml +8 -5
  7. package/.github/workflows/docker-image.cyberia-client.dev.ci.yml +8 -3
  8. package/.github/workflows/docker-image.cyberia-server.ci.yml +8 -5
  9. package/.github/workflows/docker-image.cyberia-server.dev.ci.yml +8 -5
  10. package/.github/workflows/docker-image.engine-cyberia.ci.yml +15 -1
  11. package/.github/workflows/docker-image.engine-cyberia.dev.ci.yml +15 -1
  12. package/.github/workflows/engine-cyberia.cd.yml +3 -0
  13. package/.github/workflows/engine-cyberia.ci.yml +4 -5
  14. package/.github/workflows/ghpkg.ci.yml +35 -20
  15. package/.github/workflows/hardhat.ci.yml +12 -11
  16. package/.github/workflows/npmpkg.ci.yml +17 -15
  17. package/.github/workflows/publish.cyberia.ci.yml +44 -0
  18. package/.github/workflows/pwa-microservices-template-test.ci.yml +2 -2
  19. package/.github/workflows/release.cd.yml +1 -0
  20. package/.prettierignore +1 -0
  21. package/AGENTS.md +96 -0
  22. package/CHANGELOG.md +653 -2513
  23. package/CLI-HELP.md +323 -157
  24. package/Dockerfile +25 -9
  25. package/Dockerfile.dev +25 -9
  26. package/Dockerfile.test +5 -5
  27. package/README.md +48 -28
  28. package/bin/build.js +86 -41
  29. package/bin/build.template.js +8 -3
  30. package/bin/cyberia.js +1494 -1403
  31. package/bin/deploy.js +18 -104
  32. package/bin/index.js +1494 -1403
  33. package/compose.env +17 -5
  34. package/conf.js +98 -7
  35. package/deploy/cyberia-client/deploy.sh +46 -0
  36. package/deploy/cyberia-client/package.sh +17 -0
  37. package/deploy/cyberia-client/state.sh +15 -0
  38. package/deploy/cyberia-server/deploy.sh +46 -0
  39. package/deploy/cyberia-server/package.sh +17 -0
  40. package/deploy/cyberia-server/state.sh +15 -0
  41. package/deploy/dd-cyberia/deploy.sh +9 -29
  42. package/deploy/dd-cyberia/init.sh +38 -45
  43. package/deploy/dd-cyberia/package.sh +16 -0
  44. package/deploy/dd-cyberia/state.sh +14 -0
  45. package/deploy/dd-cyberia/sync-deploy.sh +133 -147
  46. package/deploy/lib/config.sh +29 -0
  47. package/deploy/lib/github-actions-logging.sh +610 -0
  48. package/deploy/lib/host.sh +194 -0
  49. package/deploy/lib/state.sh +65 -0
  50. package/deploy/pwa-microservices-template/deploy.sh +17 -42
  51. package/deploy/release/deploy.sh +9 -41
  52. package/deployment.yaml +7 -2
  53. package/docker-compose.yml +23 -17
  54. package/examples/static-page/README.md +19 -19
  55. package/examples/static-page/ssr-components/CustomPage.js +2 -2
  56. package/hardhat/hardhat.config.js +1 -6
  57. package/hardhat/package-lock.json +101 -40
  58. package/hardhat/package.json +2 -2
  59. package/manifests/alertmanager/deployment.yaml +81 -0
  60. package/manifests/blackbox-exporter/deployment.yaml +75 -0
  61. package/manifests/cronjobs/dd-cron/dd-cron-backup.yaml +32 -7
  62. package/manifests/cronjobs/dd-cron/dd-cron-vultr.yaml +32 -7
  63. package/manifests/deployment/dd-cyberia-development/deployment.yaml +7 -2
  64. package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
  65. package/manifests/deployment/mongo-express/deployment.yaml +46 -27
  66. package/manifests/deployment/mongo-express/kustomization.yaml +9 -0
  67. package/manifests/deployment/mongo-express/mongo-express-nodeport.yaml +18 -0
  68. package/manifests/deployment/mongo-express/service.yaml +16 -0
  69. package/manifests/deployment/mongo-express-no-auth/disable-mongodb-auth.yaml +16 -0
  70. package/manifests/deployment/mongo-express-no-auth/kustomization.yaml +16 -0
  71. package/manifests/grafana/deployment.yaml +26 -7
  72. package/manifests/grafana/kustomization.yaml +1 -0
  73. package/manifests/grafana/service.yaml +6 -1
  74. package/manifests/mariadb/pv.yaml +1 -0
  75. package/manifests/mongodb/statefulset.yaml +9 -2
  76. package/manifests/mysql/pv-pvc.yaml +14 -1
  77. package/manifests/postgresql/pv.yaml +12 -1
  78. package/manifests/postgresql/pvc.yaml +1 -0
  79. package/manifests/prometheus/deployment.yaml +18 -22
  80. package/package.json +87 -12
  81. package/scripts/event-monitor.sh +56 -0
  82. package/scripts/k3s-node-setup.sh +27 -14
  83. package/scripts/kubeadm-node-setup.sh +31 -11
  84. package/scripts/maas-setup.sh +8 -8
  85. package/scripts/selinux-normalize.sh +907 -0
  86. package/scripts/shutdown-machine.sh +843 -0
  87. package/scripts/test-monitor.sh +5 -5
  88. package/src/api/atlas-sprite-sheet/atlas-sprite-sheet.controller.js +15 -10
  89. package/src/api/atlas-sprite-sheet/atlas-sprite-sheet.model.js +36 -5
  90. package/src/api/atlas-sprite-sheet/atlas-sprite-sheet.router.js +7 -2
  91. package/src/api/atlas-sprite-sheet/atlas-sprite-sheet.service.js +90 -186
  92. package/src/api/core/core.controller.js +1 -1
  93. package/src/api/core/core.router.js +2 -2
  94. package/src/api/core/core.service.js +2 -2
  95. package/src/api/crypto/crypto.controller.js +1 -1
  96. package/src/api/crypto/crypto.service.js +1 -1
  97. package/src/api/cyberia-action/cyberia-action.controller.js +1 -1
  98. package/src/api/cyberia-action/cyberia-action.router.js +3 -3
  99. package/src/api/cyberia-action/cyberia-action.service.js +5 -6
  100. package/src/api/cyberia-audio/cyberia-audio.controller.js +6 -0
  101. package/src/api/cyberia-audio/cyberia-audio.model.js +61 -0
  102. package/src/api/cyberia-audio/cyberia-audio.router.js +21 -0
  103. package/src/api/cyberia-audio/cyberia-audio.service.js +220 -0
  104. package/src/api/cyberia-client-hints/cyberia-client-hints.controller.js +2 -4
  105. package/src/api/cyberia-client-hints/cyberia-client-hints.model.js +10 -19
  106. package/src/api/cyberia-client-hints/cyberia-client-hints.router.js +13 -35
  107. package/src/api/cyberia-client-hints/cyberia-client-hints.service.js +17 -34
  108. package/src/api/cyberia-dialogue/cyberia-dialogue.controller.js +1 -1
  109. package/src/api/cyberia-dialogue/cyberia-dialogue.model.js +1 -1
  110. package/src/api/cyberia-dialogue/cyberia-dialogue.router.js +2 -2
  111. package/src/api/cyberia-dialogue/cyberia-dialogue.service.js +2 -2
  112. package/src/api/cyberia-entity/cyberia-entity.controller.js +1 -1
  113. package/src/api/cyberia-entity/cyberia-entity.model.js +2 -0
  114. package/src/api/cyberia-entity/cyberia-entity.router.js +1 -1
  115. package/src/api/cyberia-entity/cyberia-entity.service.js +3 -3
  116. package/src/api/cyberia-entity-type-default/cyberia-entity-type-default.controller.js +8 -2
  117. package/src/api/cyberia-entity-type-default/cyberia-entity-type-default.model.js +31 -20
  118. package/src/api/cyberia-entity-type-default/cyberia-entity-type-default.router.js +17 -2
  119. package/src/api/cyberia-entity-type-default/cyberia-entity-type-default.service.js +468 -5
  120. package/src/api/cyberia-instance/cyberia-fallback-capture.js +502 -0
  121. package/src/api/cyberia-instance/cyberia-fallback-world.js +29 -40
  122. package/src/api/cyberia-instance/cyberia-instance-boot.service.js +5 -5
  123. package/src/api/cyberia-instance/cyberia-instance-items.js +98 -0
  124. package/src/api/cyberia-instance/cyberia-instance-map.service.js +35 -142
  125. package/src/api/cyberia-instance/cyberia-instance.controller.js +2 -5
  126. package/src/api/cyberia-instance/cyberia-instance.model.js +0 -14
  127. package/src/api/cyberia-instance/cyberia-instance.router.js +5 -18
  128. package/src/api/cyberia-instance/cyberia-instance.service.js +5 -45
  129. package/src/api/cyberia-instance/cyberia-world-generator.js +8 -13
  130. package/src/api/cyberia-instance-conf/cyberia-instance-conf.controller.js +1 -1
  131. package/src/api/cyberia-instance-conf/cyberia-instance-conf.model.js +49 -75
  132. package/src/api/cyberia-instance-conf/cyberia-instance-conf.router.js +1 -1
  133. package/src/api/cyberia-instance-conf/cyberia-instance-conf.service.js +76 -3
  134. package/src/api/cyberia-map/cyberia-map.controller.js +1 -1
  135. package/src/api/cyberia-map/cyberia-map.router.js +1 -1
  136. package/src/api/cyberia-map/cyberia-map.service.js +8 -4
  137. package/src/api/cyberia-map-audio-conf/cyberia-map-audio-conf.controller.js +31 -0
  138. package/src/api/cyberia-map-audio-conf/cyberia-map-audio-conf.model.js +56 -0
  139. package/src/api/cyberia-map-audio-conf/cyberia-map-audio-conf.router.js +33 -0
  140. package/src/api/cyberia-map-audio-conf/cyberia-map-audio-conf.service.js +189 -0
  141. package/src/api/cyberia-quest/cyberia-quest.controller.js +1 -1
  142. package/src/api/cyberia-quest/cyberia-quest.router.js +4 -4
  143. package/src/api/cyberia-quest/cyberia-quest.service.js +5 -5
  144. package/src/api/cyberia-quest-progress/cyberia-quest-progress.controller.js +1 -1
  145. package/src/api/cyberia-quest-progress/cyberia-quest-progress.model.js +1 -1
  146. package/src/api/cyberia-quest-progress/cyberia-quest-progress.router.js +1 -1
  147. package/src/api/cyberia-quest-progress/cyberia-quest-progress.service.js +2 -2
  148. package/src/api/cyberia-saga/cyberia-saga.controller.js +1 -1
  149. package/src/api/cyberia-saga/cyberia-saga.router.js +1 -1
  150. package/src/api/cyberia-saga/cyberia-saga.service.js +2 -2
  151. package/src/api/cyberia-server-defaults/cyberia-server-defaults.js +403 -214
  152. package/src/api/cyberia-skill/cyberia-skill.controller.js +1 -1
  153. package/src/api/cyberia-skill/cyberia-skill.model.js +6 -6
  154. package/src/api/cyberia-skill/cyberia-skill.router.js +1 -1
  155. package/src/api/cyberia-skill/cyberia-skill.service.js +2 -2
  156. package/src/api/default/default.controller.js +1 -1
  157. package/src/api/default/default.router.js +1 -1
  158. package/src/api/default/default.service.js +2 -2
  159. package/src/api/document/document.controller.js +1 -1
  160. package/src/api/document/document.router.js +1 -1
  161. package/src/api/document/document.service.js +2 -2
  162. package/src/api/file/file.controller.js +1 -1
  163. package/src/api/file/file.ref.js +153 -0
  164. package/src/api/file/file.ref.json +11 -2
  165. package/src/api/file/file.router.js +1 -1
  166. package/src/api/file/file.service.js +2 -2
  167. package/src/api/instance/instance.controller.js +1 -1
  168. package/src/api/instance/instance.router.js +1 -1
  169. package/src/api/instance/instance.service.js +2 -2
  170. package/src/api/ipfs/ipfs.controller.js +1 -1
  171. package/src/api/ipfs/ipfs.router.js +2 -2
  172. package/src/api/ipfs/ipfs.service.js +2 -2
  173. package/src/api/object-layer/object-layer.controller.js +1 -1
  174. package/src/api/object-layer/object-layer.model.js +47 -12
  175. package/src/api/object-layer/object-layer.router.js +12 -5
  176. package/src/api/object-layer/object-layer.service.js +116 -123
  177. package/src/api/object-layer-render-frames/object-layer-render-frames.controller.js +1 -1
  178. package/src/api/object-layer-render-frames/object-layer-render-frames.router.js +1 -1
  179. package/src/api/object-layer-render-frames/object-layer-render-frames.service.js +2 -2
  180. package/src/api/test/test.controller.js +1 -1
  181. package/src/api/test/test.service.js +1 -1
  182. package/src/api/user/guest.service.js +3 -3
  183. package/src/api/user/user.controller.js +1 -1
  184. package/src/api/user/user.router.js +2 -2
  185. package/src/api/user/user.service.js +4 -4
  186. package/src/api.js +5 -9
  187. package/src/cli/app.js +402 -0
  188. package/src/cli/baremetal.js +30 -35
  189. package/src/cli/client.js +306 -0
  190. package/src/cli/cloud-init.js +3 -3
  191. package/src/cli/cluster.js +191 -82
  192. package/src/cli/db.js +120 -68
  193. package/src/cli/deploy.js +157 -75
  194. package/src/cli/docker-compose.js +5 -5
  195. package/src/cli/domains.js +184 -0
  196. package/src/cli/dotenv-store.js +143 -0
  197. package/src/cli/event.js +2281 -0
  198. package/src/cli/fs-selection.js +115 -0
  199. package/src/cli/fs.js +159 -343
  200. package/src/cli/host.js +672 -0
  201. package/src/cli/image.js +4 -6
  202. package/src/cli/index.js +343 -151
  203. package/src/cli/ipfs.js +34 -30
  204. package/src/cli/kickstart.js +1 -1
  205. package/src/cli/kubectl.js +218 -41
  206. package/src/cli/lxd.js +3 -3
  207. package/src/cli/monitor.js +1359 -26
  208. package/src/cli/package.js +90 -0
  209. package/src/cli/release.js +16 -12
  210. package/src/cli/repository.js +586 -363
  211. package/src/cli/run.js +398 -843
  212. package/src/cli/secrets.js +2158 -964
  213. package/src/cli/ssh.js +478 -279
  214. package/src/cli/state.js +394 -0
  215. package/src/cli/static.js +2 -2
  216. package/src/cli/system.js +1 -1
  217. package/src/cli/test.js +203 -125
  218. package/src/cli/vultr.js +61 -21
  219. package/src/cli/wireguard.js +1112 -306
  220. package/src/client/components/core/CalendarCore.js +53 -96
  221. package/src/client/components/core/Docs.js +327 -303
  222. package/src/client/components/core/FileExplorer.js +0 -21
  223. package/src/client/components/core/Modal.js +56 -1
  224. package/src/client/components/core/PanelForm.js +4 -4
  225. package/src/client/components/core/Repository.js +56 -0
  226. package/src/client/components/core/Translate.js +7 -0
  227. package/src/client/components/core/{FullScreen.js → ViewModeController.js} +42 -39
  228. package/src/client/components/core/Wallet.js +0 -11
  229. package/src/client/components/core/Worker.js +7 -1
  230. package/src/client/components/cryptokoyn/SettingsCryptokoyn.js +2 -2
  231. package/src/client/components/cyberia/ActionEngineCyberia.js +12 -36
  232. package/src/client/components/cyberia/EntityEngineCyberia.js +492 -121
  233. package/src/client/components/cyberia/InstanceEngineCyberia.js +129 -160
  234. package/src/client/components/cyberia/InstanceSelectionView.js +7 -1
  235. package/src/client/components/cyberia/MapEngineCyberia.js +175 -69
  236. package/src/client/components/cyberia/ObjectLayerEngine.js +1011 -97
  237. package/src/client/components/cyberia/ObjectLayerEngineModal.js +81 -155
  238. package/src/client/components/cyberia/ObjectLayerEngineViewer.js +113 -98
  239. package/src/client/components/cyberia/SharedDefaultsCyberia.js +215 -56
  240. package/src/client/components/cyberia-portal/AppShellCyberiaPortal.js +2 -35
  241. package/src/client/components/cyberia-portal/CssCyberiaPortal.js +1 -1
  242. package/src/client/components/cyberia-portal/MainBodyCyberiaPortal.js +712 -76
  243. package/src/client/components/cyberia-portal/SettingsCyberiaPortal.js +2 -2
  244. package/src/client/components/cyberia-portal/TranslateCyberiaPortal.js +0 -4
  245. package/src/client/components/default/AppShellDefault.js +1 -1
  246. package/src/client/components/default/SettingsDefault.js +2 -2
  247. package/src/client/components/itemledger/SettingsItemledger.js +2 -2
  248. package/src/client/components/underpost/AppShellUnderpost.js +3 -2
  249. package/src/client/components/underpost/SettingsUnderpost.js +2 -2
  250. package/src/client/public/cyberia-docs/ARCHITECTURE.md +15 -15
  251. package/src/client/public/cyberia-docs/CYBERIA-CLI.md +313 -30
  252. package/src/client/public/cyberia-docs/CYBERIA-CLIENT.md +51 -29
  253. package/src/client/public/cyberia-docs/CYBERIA-SAGA.md +52 -53
  254. package/src/client/public/cyberia-docs/CYBERIA-SERVER.md +16 -11
  255. package/src/client/public/cyberia-docs/CYBERIA.md +48 -28
  256. package/src/client/public/cyberia-docs/ENTITY-PROFILE.md +11 -5
  257. package/src/client/public/cyberia-docs/HARDHAT-MODULE.md +23 -6
  258. package/src/client/public/cyberia-docs/ROADMAP.md +1 -1
  259. package/src/client/public/cyberia-docs/STATS-PROGRESSION.md +217 -0
  260. package/src/client/public/cyberia-docs/WHITE-PAPER.md +20 -12
  261. package/src/client/public/default/android-chrome-144x144.png +0 -0
  262. package/src/client/public/default/android-chrome-192x192.png +0 -0
  263. package/src/client/public/default/android-chrome-256x256.png +0 -0
  264. package/src/client/public/default/android-chrome-36x36.png +0 -0
  265. package/src/client/public/default/android-chrome-384x384.png +0 -0
  266. package/src/client/public/default/android-chrome-48x48.png +0 -0
  267. package/src/client/public/default/android-chrome-512x512.png +0 -0
  268. package/src/client/public/default/android-chrome-72x72.png +0 -0
  269. package/src/client/public/default/android-chrome-96x96.png +0 -0
  270. package/src/client/public/default/apple-touch-icon-1024x1024.png +0 -0
  271. package/src/client/public/default/apple-touch-icon-114x114.png +0 -0
  272. package/src/client/public/default/apple-touch-icon-120x120.png +0 -0
  273. package/src/client/public/default/apple-touch-icon-144x144.png +0 -0
  274. package/src/client/public/default/apple-touch-icon-152x152.png +0 -0
  275. package/src/client/public/default/apple-touch-icon-167x167.png +0 -0
  276. package/src/client/public/default/apple-touch-icon-180x180.png +0 -0
  277. package/src/client/public/default/apple-touch-icon-57x57.png +0 -0
  278. package/src/client/public/default/apple-touch-icon-60x60.png +0 -0
  279. package/src/client/public/default/apple-touch-icon-72x72.png +0 -0
  280. package/src/client/public/default/apple-touch-icon-76x76.png +0 -0
  281. package/src/client/public/default/apple-touch-icon-precomposed.png +0 -0
  282. package/src/client/public/default/apple-touch-icon.png +0 -0
  283. package/src/client/public/default/apple-touch-startup-image-1125x2436.png +0 -0
  284. package/src/client/public/default/apple-touch-startup-image-1136x640.png +0 -0
  285. package/src/client/public/default/apple-touch-startup-image-1170x2532.png +0 -0
  286. package/src/client/public/default/apple-touch-startup-image-1179x2556.png +0 -0
  287. package/src/client/public/default/apple-touch-startup-image-1242x2208.png +0 -0
  288. package/src/client/public/default/apple-touch-startup-image-1242x2688.png +0 -0
  289. package/src/client/public/default/apple-touch-startup-image-1284x2778.png +0 -0
  290. package/src/client/public/default/apple-touch-startup-image-1290x2796.png +0 -0
  291. package/src/client/public/default/apple-touch-startup-image-1334x750.png +0 -0
  292. package/src/client/public/default/apple-touch-startup-image-1488x2266.png +0 -0
  293. package/src/client/public/default/apple-touch-startup-image-1536x2048.png +0 -0
  294. package/src/client/public/default/apple-touch-startup-image-1620x2160.png +0 -0
  295. package/src/client/public/default/apple-touch-startup-image-1640x2160.png +0 -0
  296. package/src/client/public/default/apple-touch-startup-image-1668x2224.png +0 -0
  297. package/src/client/public/default/apple-touch-startup-image-1668x2388.png +0 -0
  298. package/src/client/public/default/apple-touch-startup-image-1792x828.png +0 -0
  299. package/src/client/public/default/apple-touch-startup-image-2048x1536.png +0 -0
  300. package/src/client/public/default/apple-touch-startup-image-2048x2732.png +0 -0
  301. package/src/client/public/default/apple-touch-startup-image-2160x1620.png +0 -0
  302. package/src/client/public/default/apple-touch-startup-image-2160x1640.png +0 -0
  303. package/src/client/public/default/apple-touch-startup-image-2208x1242.png +0 -0
  304. package/src/client/public/default/apple-touch-startup-image-2224x1668.png +0 -0
  305. package/src/client/public/default/apple-touch-startup-image-2266x1488.png +0 -0
  306. package/src/client/public/default/apple-touch-startup-image-2388x1668.png +0 -0
  307. package/src/client/public/default/apple-touch-startup-image-2436x1125.png +0 -0
  308. package/src/client/public/default/apple-touch-startup-image-2532x1170.png +0 -0
  309. package/src/client/public/default/apple-touch-startup-image-2556x1179.png +0 -0
  310. package/src/client/public/default/apple-touch-startup-image-2688x1242.png +0 -0
  311. package/src/client/public/default/apple-touch-startup-image-2732x2048.png +0 -0
  312. package/src/client/public/default/apple-touch-startup-image-2778x1284.png +0 -0
  313. package/src/client/public/default/apple-touch-startup-image-2796x1290.png +0 -0
  314. package/src/client/public/default/apple-touch-startup-image-640x1136.png +0 -0
  315. package/src/client/public/default/apple-touch-startup-image-750x1334.png +0 -0
  316. package/src/client/public/default/apple-touch-startup-image-828x1792.png +0 -0
  317. package/src/client/public/default/assets/background/white.jpg +0 -0
  318. package/src/client/public/default/browserconfig.xml +1 -1
  319. package/src/client/public/default/favicon-16x16.png +0 -0
  320. package/src/client/public/default/favicon-32x32.png +0 -0
  321. package/src/client/public/default/favicon-48x48.png +0 -0
  322. package/src/client/public/default/favicon.ico +0 -0
  323. package/src/client/public/default/manifest.webmanifest +5 -5
  324. package/src/client/public/default/mstile-144x144.png +0 -0
  325. package/src/client/public/default/mstile-150x150.png +0 -0
  326. package/src/client/public/default/mstile-310x150.png +0 -0
  327. package/src/client/public/default/mstile-310x310.png +0 -0
  328. package/src/client/public/default/mstile-70x70.png +0 -0
  329. package/src/client/public/default/yandex-browser-50x50.png +0 -0
  330. package/src/client/public/default/yandex-browser-manifest.json +1 -1
  331. package/src/client/services/cyberia-audio/cyberia-audio.service.js +99 -0
  332. package/src/client/services/cyberia-entity-type-default/cyberia-entity-type-default.service.js +38 -0
  333. package/src/client/services/cyberia-instance/cyberia-instance.management.js +5 -4
  334. package/src/client/services/cyberia-instance/cyberia-instance.service.js +0 -40
  335. package/src/client/services/cyberia-map/cyberia-map.management.js +5 -4
  336. package/src/client/services/cyberia-map-audio-conf/cyberia-map-audio-conf.service.js +138 -0
  337. package/src/client/services/object-layer/object-layer.management.js +6 -10
  338. package/src/client/services/object-layer/object-layer.service.js +3 -1
  339. package/src/client/ssr/RootDocument.js +1 -1
  340. package/src/client/ssr/body/404.js +1 -1
  341. package/src/client/ssr/body/500.js +1 -1
  342. package/src/client/ssr/body/CacheControl.js +1 -1
  343. package/src/client/ssr/body/CyberiaDefaultSplashScreen.js +1 -1
  344. package/src/client/ssr/body/DefaultSplashScreen.js +1 -1
  345. package/src/client/ssr/body/SwaggerDarkMode.js +1 -1
  346. package/src/client/ssr/body/UnderpostDefaultSplashScreen.js +1 -1
  347. package/src/client/ssr/head/CryptokoynScripts.js +1 -1
  348. package/src/client/ssr/head/Css.js +1 -1
  349. package/src/client/ssr/head/CyberiaPortalScripts.js +1 -1
  350. package/src/client/ssr/head/DefaultScripts.js +1 -1
  351. package/src/client/ssr/head/ItemledgerScripts.js +1 -1
  352. package/src/client/ssr/head/Microdata.js +1 -1
  353. package/src/client/ssr/head/Production.js +1 -1
  354. package/src/client/ssr/head/Pwa.js +1 -1
  355. package/src/client/ssr/head/PwaItemledger.js +1 -1
  356. package/src/client/ssr/head/Seo.js +1 -1
  357. package/src/client/ssr/head/UnderpostScripts.js +1 -1
  358. package/src/client/ssr/mailer/DefaultRecoverEmail.js +1 -1
  359. package/src/client/ssr/mailer/DefaultVerifyEmail.js +1 -1
  360. package/src/client/ssr/views/Cyberia404.js +7 -5
  361. package/src/client/ssr/views/CyberiaServerMetrics.js +1 -1
  362. package/src/client/ssr/views/Maintenance.js +1 -1
  363. package/src/client/ssr/views/NoNetworkConnection.js +1 -1
  364. package/src/client/ssr/views/Test.js +1 -1
  365. package/src/client/sw/core.sw.js +20 -14
  366. package/src/client-builder/client-build-docs.js +92 -77
  367. package/src/client-builder/client-build-live.js +2 -2
  368. package/src/client-builder/client-build.js +105 -74
  369. package/src/client-builder/client-bundle.js +347 -0
  370. package/src/client-builder/client-dev-server.js +3 -3
  371. package/src/client-builder/client-icons.js +2 -2
  372. package/src/client-builder/ssr.js +3 -3
  373. package/src/client.build.js +3 -6
  374. package/src/client.dev.js +3 -6
  375. package/src/db/DataBaseProvider.js +186 -3
  376. package/src/db/mariadb/MariaDB.js +3 -2
  377. package/src/db/mongo/MongoBootstrap.js +273 -101
  378. package/src/db/mongo/MongoExpress.js +187 -0
  379. package/src/db/mongo/MongooseDB.js +7 -8
  380. package/src/{server/valkey.js → db/valkey/Valkey.js} +5 -4
  381. package/src/grpc/cyberia/grpc-server.js +6 -8
  382. package/src/index.js +79 -23
  383. package/src/mailer/EmailRender.js +2 -2
  384. package/src/mailer/MailerInterceptor.js +87 -0
  385. package/src/mailer/MailerProvider.js +50 -6
  386. package/src/projects/cyberia/atlas-sprite-sheet-generator.js +144 -147
  387. package/src/projects/cyberia/atlas-sprite-sheet-store.js +391 -0
  388. package/src/projects/cyberia/besu-genesis-generator.js +8 -17
  389. package/src/projects/cyberia/boot-contract-fixtures.js +32 -0
  390. package/src/projects/cyberia/catalog-cyberia.js +54 -10
  391. package/src/projects/cyberia/gemini-client.js +4 -4
  392. package/src/projects/cyberia/generate-saga.js +5 -12
  393. package/src/projects/cyberia/hot-reload-trigger.js +18 -29
  394. package/src/projects/cyberia/instance-backup.js +210 -0
  395. package/src/projects/cyberia/instance-data.js +178 -258
  396. package/src/projects/cyberia/ipfs-client.js +4 -4
  397. package/src/projects/cyberia/map-preview-generator.js +33 -69
  398. package/src/projects/cyberia/object-layer.js +153 -320
  399. package/src/projects/cyberia/seed-audio.js +139 -0
  400. package/src/projects/cyberia/semantic-layer-generator-skin.js +2 -8
  401. package/src/projects/cyberia/semantic-layer-generator.js +3 -19
  402. package/src/projects/cyberia/stat-balance.js +81 -0
  403. package/src/projects/cyberia/stat-commands.js +23 -0
  404. package/src/projects/cyberia/stat-contract-generator.js +101 -0
  405. package/src/projects/underpost/catalog-underpost.js +9 -1
  406. package/src/proxy.js +4 -7
  407. package/src/runtime/cyberia-client/Dockerfile +17 -8
  408. package/src/runtime/cyberia-client/Dockerfile.dev +13 -4
  409. package/src/runtime/cyberia-server/Dockerfile +18 -6
  410. package/src/runtime/cyberia-server/Dockerfile.dev +16 -3
  411. package/src/runtime/engine-cyberia/Dockerfile +25 -9
  412. package/src/runtime/engine-cyberia/Dockerfile.dev +25 -9
  413. package/src/runtime/engine-cyberia/Dockerfile.test +5 -5
  414. package/src/runtime/engine-cyberia/compose.env +17 -5
  415. package/src/runtime/engine-cyberia/docker-compose.yml +23 -17
  416. package/src/runtime/express/Express.js +32 -27
  417. package/src/runtime/lampp/Lampp.js +22 -70
  418. package/src/runtime/nginx/Nginx.js +1 -1
  419. package/src/runtime/wp/Wp.js +16 -48
  420. package/src/server/{catalog.js → build/catalog.js} +15 -5
  421. package/src/server/build/coverage.js +188 -0
  422. package/src/server/build/execution.js +402 -0
  423. package/src/server/build/package.js +508 -0
  424. package/src/server/build/testing.js +578 -0
  425. package/src/server/{dns.js → network/dns.js} +144 -11
  426. package/src/server/{forward-proxy.js → network/forward-proxy.js} +10 -18
  427. package/src/server/{middlewares.js → network/middlewares.js} +11 -5
  428. package/src/server/network/node-capability.js +98 -0
  429. package/src/server/{peer.js → network/peer.js} +3 -3
  430. package/src/server/{proxy.js → network/proxy.js} +7 -13
  431. package/src/server/network/router.js +356 -0
  432. package/src/server/{tls.js → network/tls.js} +2 -2
  433. package/src/server/{underpost-compression.js → network/underpost-compression.js} +1 -1
  434. package/src/server/{underpost-gateway.js → network/underpost-gateway.js} +7 -7
  435. package/src/server/{underpost-ingress.js → network/underpost-ingress.js} +2 -2
  436. package/src/server/{cri.js → ops/cri.js} +2 -2
  437. package/src/server/{cron.js → ops/cron.js} +349 -74
  438. package/src/server/ops/event-notification.js +284 -0
  439. package/src/server/{logger.js → ops/logger.js} +143 -23
  440. package/src/server/ops/monitoring.js +1724 -0
  441. package/src/server/{systemd.js → ops/systemd.js} +55 -1
  442. package/src/server/{conf.js → runtime/conf.js} +516 -423
  443. package/src/server/runtime/config-scope.js +215 -0
  444. package/src/server/runtime/environment.js +149 -0
  445. package/src/server/{process.js → runtime/process.js} +153 -12
  446. package/src/server/{runtime-status.js → runtime/runtime-status.js} +155 -16
  447. package/src/server/{runtime.js → runtime/runtime.js} +12 -8
  448. package/src/server/{start.js → runtime/start.js} +128 -39
  449. package/src/server/{auth.js → security/auth.js} +7 -8
  450. package/src/server/security/container-storage.js +382 -0
  451. package/src/server/{crypto.js → security/crypto.js} +1 -1
  452. package/src/server/{selinux.js → security/selinux.js} +1 -1
  453. package/src/server/{backup.js → storage/backup.js} +17 -8
  454. package/src/server/{data-query.js → storage/data-query.js} +17 -1
  455. package/src/server/storage/downloader.js +121 -0
  456. package/src/server/storage/repository.js +68 -0
  457. package/src/server/storage/zip.js +153 -0
  458. package/src/server.js +4 -7
  459. package/src/ws/IoInterface.js +1 -1
  460. package/src/ws/IoServer.js +1 -1
  461. package/src/ws/core/core.ws.connection.js +1 -1
  462. package/src/ws/core/core.ws.emit.js +1 -1
  463. package/src/ws/core/core.ws.server.js +1 -1
  464. package/src/ws/default/default.ws.connection.js +1 -1
  465. package/src/ws/default/default.ws.emit.js +1 -1
  466. package/src/ws/default/default.ws.server.js +1 -1
  467. package/test/e2e/event-e2e-public-ingress-down.js +45 -0
  468. package/test/e2e/event-e2e-wireguard-server-down.js +38 -0
  469. package/test/e2e/event-e2e-wireguard-spoke-down.js +40 -0
  470. package/test/{api.test.js → integration/app/api.test.js} +33 -14
  471. package/test/integration/app/cyberia/atlas-sprite-sheet-store.test.js +402 -0
  472. package/test/integration/app/cyberia/atlas-sprite-sheet.test.js +86 -0
  473. package/test/integration/app/cyberia/cyberia-cli-plain-reads.test.js +64 -0
  474. package/test/integration/app/cyberia/cyberia-entity-type-default.test.js +842 -0
  475. package/test/integration/app/cyberia/cyberia-instance-conf-coerce.test.js +53 -0
  476. package/test/{cyberia-instance-conf-defaults.test.js → integration/app/cyberia/cyberia-instance-conf-defaults.test.js} +17 -23
  477. package/test/integration/app/cyberia/cyberia-instance-items.test.js +90 -0
  478. package/test/integration/app/cyberia/cyberia-load.test.js +466 -0
  479. package/test/integration/app/cyberia/cyberia-map-audio-conf.test.js +256 -0
  480. package/test/integration/app/cyberia/cyberia-stats.test.js +150 -0
  481. package/test/integration/app/cyberia/fallback-world-capture.test.js +338 -0
  482. package/test/integration/app/cyberia/object-layer-item-id.test.js +327 -0
  483. package/test/integration/app/cyberia/object-layer-item-selection.test.js +56 -0
  484. package/test/integration/app/cyberia/object-layer-natural-key.test.js +32 -0
  485. package/test/integration/app/cyberia/seed-audio.test.js +275 -0
  486. package/test/{shape-generator.test.js → integration/app/cyberia/shape-generator.test.js} +1 -1
  487. package/test/integration/infra/1-security/config-scope.test.js +305 -0
  488. package/test/integration/infra/1-security/container-storage.test.js +356 -0
  489. package/test/integration/infra/1-security/in-pod-cli-surface.test.js +86 -0
  490. package/test/integration/infra/1-security/secret-onboarding.test.js +930 -0
  491. package/test/{selinux.test.js → integration/infra/1-security/selinux.test.js} +2 -4
  492. package/test/{sops-secret-store.test.js → integration/infra/1-security/sops-secret-store.test.js} +415 -61
  493. package/test/integration/infra/1-security/systemd-service.test.js +70 -0
  494. package/test/integration/infra/1-security/underpost-config-secret.test.js +367 -0
  495. package/test/integration/infra/2-network/dns-firewall.test.js +566 -0
  496. package/test/integration/infra/2-network/node-capability.test.js +182 -0
  497. package/test/integration/infra/2-network/wireguard-cli.test.js +1187 -0
  498. package/test/{wireguard-edge.test.js → integration/infra/2-network/wireguard-edge.test.js} +649 -55
  499. package/test/{cluster-instances.test.js → integration/infra/3-cluster/cluster-instances.test.js} +11 -15
  500. package/test/{deploy-node-placement.test.js → integration/infra/3-cluster/deploy-node-placement.test.js} +3 -3
  501. package/test/integration/infra/3-cluster/docker-compose-stack.test.js +313 -0
  502. package/test/integration/infra/3-cluster/mongo-express-deploy.test.js +156 -0
  503. package/test/integration/infra/4-ingress/api-cross-origin.test.js +79 -0
  504. package/test/integration/infra/4-ingress/deploy-routes.test.js +379 -0
  505. package/test/integration/infra/4-ingress/gateway-static-assets.test.js +453 -0
  506. package/test/{instance-traffic-plan.test.js → integration/infra/4-ingress/instance-traffic-plan.test.js} +10 -6
  507. package/test/{underpost-gateway.test.js → integration/infra/4-ingress/underpost-gateway.test.js} +6 -6
  508. package/test/{underpost-ingress.test.js → integration/infra/4-ingress/underpost-ingress.test.js} +4 -4
  509. package/test/integration/infra/5-observability/cron-jobs.test.js +652 -0
  510. package/test/{deploy-monitor.test.js → integration/infra/5-observability/deploy-monitor.test.js} +29 -31
  511. package/test/integration/infra/5-observability/event-notification.test.js +197 -0
  512. package/test/integration/infra/5-observability/event-remediation.test.js +502 -0
  513. package/test/integration/infra/5-observability/event-targets.test.js +1174 -0
  514. package/test/integration/infra/5-observability/monitoring-stack.test.js +902 -0
  515. package/test/support/shell-harness.js +73 -0
  516. package/test/unit/build-template.test.js +97 -0
  517. package/test/unit/build-workflow-offline.test.js +132 -0
  518. package/test/unit/catalog.test.js +71 -0
  519. package/test/unit/client-build-docs.test.js +361 -0
  520. package/test/unit/client-bundle.test.js +313 -0
  521. package/test/unit/conf-loading.test.js +303 -0
  522. package/test/unit/conf-resolution.test.js +996 -0
  523. package/test/unit/coverage-artifact.test.js +221 -0
  524. package/test/{crypto.test.js → unit/crypto.test.js} +1 -1
  525. package/test/unit/cyberia/instance-backup.test.js +183 -0
  526. package/test/unit/cyberia/instance-object-layer-items.test.js +46 -0
  527. package/test/unit/cyberia/publish-workflow.test.js +22 -0
  528. package/test/unit/cyberia/stat-balance.test.js +69 -0
  529. package/test/unit/deploy-env-resolution.test.js +38 -0
  530. package/test/unit/deploy-log-table.test.js +518 -0
  531. package/test/unit/downloader-integrity.test.js +98 -0
  532. package/test/unit/execution-profiles.test.js +219 -0
  533. package/test/unit/file-reference-registry.test.js +141 -0
  534. package/test/unit/fleet-sync-source.test.js +149 -0
  535. package/test/unit/fs-storage-paths.test.js +596 -0
  536. package/test/unit/logger-redaction.test.js +141 -0
  537. package/test/unit/package.test.js +457 -0
  538. package/test/unit/prepare-host.test.js +382 -0
  539. package/test/unit/process-environment.test.js +18 -0
  540. package/test/unit/propagation-message.test.js +78 -0
  541. package/test/unit/redeploy-plan.test.js +24 -0
  542. package/test/unit/release-bump.test.js +474 -0
  543. package/test/unit/ssh-output-redaction.test.js +28 -0
  544. package/test/unit/start-options.test.js +46 -0
  545. package/test/unit/test-tiers.test.js +220 -0
  546. package/test/unit/zip-archive.test.js +142 -0
  547. package/vitest.config.js +66 -0
  548. package/.nycrc +0 -9
  549. package/deploy/lib/logging.sh +0 -96
  550. package/manifests/cronjobs/dd-cron/dd-cron-dns.yaml +0 -48
  551. package/manifests/mongodb/pv-pvc.yaml +0 -59
  552. package/scripts/coverall-test-cyberia.sh +0 -24
  553. package/scripts/coverall-test.sh +0 -24
  554. package/scripts/link-local-underpost-cli.sh +0 -6
  555. package/src/api/cyberia-instance/cyberia-fallback-default-items.js +0 -63
  556. package/src/api/user/user.build.js +0 -16
  557. package/src/cli/env.js +0 -177
  558. package/src/client/components/core/Blockchain.js +0 -41
  559. package/src/client/components/core/Blog.js +0 -9
  560. package/src/client/components/core/KeyboardAvoidance.js +0 -145
  561. package/src/client/components/cyberia/FallbackWorldEngineCyberia.js +0 -368
  562. package/src/client/public/default/assets/background/dark.jpg +0 -0
  563. package/src/client/public/default/assets/background/white0-min.jpg +0 -0
  564. package/src/client/public/default/assets/background/white0.jpg +0 -0
  565. package/src/client/public/doc/favicon.ico +0 -0
  566. package/src/client/public/doc/sitemap +0 -148
  567. package/src/server/downloader.js +0 -73
  568. package/src/server/environment.js +0 -98
package/src/cli/ssh.js CHANGED
@@ -5,9 +5,9 @@
5
5
  */
6
6
 
7
7
  import { generateRandomPasswordSelection } from '../client/components/core/CommonJs.js';
8
- import { pbcopy, shellExec } from '../server/process.js';
9
- import { loggerFactory } from '../server/logger.js';
10
- import { waitForPort } from '../server/conf.js';
8
+ import { shellExec } from '../server/runtime/process.js';
9
+ import { loggerFactory, redactSensitiveText } from '../server/ops/logger.js';
10
+ import { waitForPort } from '../server/runtime/conf.js';
11
11
  import {
12
12
  runSELinuxCommands,
13
13
  selinuxPackagesCommandFactory,
@@ -15,12 +15,136 @@ import {
15
15
  selinuxSshContextCommandsFactory,
16
16
  selinuxSshPortCommandsFactory,
17
17
  shellArgumentFactory,
18
- } from '../server/selinux.js';
18
+ } from '../server/security/selinux.js';
19
+ import ContainerStorageService from '../server/security/container-storage.js';
19
20
  import fs from 'fs-extra';
20
21
  import Underpost from '../index.js';
21
22
 
22
23
  const logger = loggerFactory(import.meta);
23
24
 
25
+ const shellExecRedacted = (command, options = {}) => {
26
+ const silent = options.silent === true;
27
+ try {
28
+ const result = shellExec(command, { ...options, silent: true });
29
+ if (!silent) {
30
+ if (result.stdout) process.stdout.write(redactSensitiveText(result.stdout));
31
+ if (result.stderr) process.stderr.write(redactSensitiveText(result.stderr));
32
+ }
33
+ return result.stdout;
34
+ } catch (error) {
35
+ error.stdout = redactSensitiveText(error.stdout || '');
36
+ error.stderr = redactSensitiveText(error.stderr || '');
37
+ error.message = redactSensitiveText(error.message || error);
38
+ if (!silent) {
39
+ if (error.stdout) process.stdout.write(error.stdout);
40
+ if (error.stderr) process.stderr.write(error.stderr);
41
+ }
42
+ throw error;
43
+ }
44
+ };
45
+
46
+ /**
47
+ * SSH users are cluster scoped: one registry and one key store serve every
48
+ * deploy id running on the cluster, so an account is provisioned once instead
49
+ * of once per app.
50
+ *
51
+ * A record is `{ user, password, groups, keyPath, pubKeyPath, hosts }`, where an
52
+ * empty password means key-only and `hosts` carries one connection per host:
53
+ *
54
+ * hosts: [{ host: '10.0.0.2', port: 22 }, { host: '10.0.0.3', port: 22 }]
55
+ *
56
+ * One account reaches many hosts — the same operator account on every WireGuard
57
+ * spoke is the normal case — so the host cannot be a field of the record. It was,
58
+ * and registering that account for a second host silently replaced the first,
59
+ * leaving remediation unable to resolve a spoke it had just been given.
60
+ *
61
+ * The port belongs to the connection for the same reason: two hosts of one
62
+ * account can listen on different ports, and a single record-level port could
63
+ * only describe one of them.
64
+ */
65
+ const USERS_CONF_PATH = './engine-private/deploy/conf.users.json';
66
+ const USERS_KEYS_PATH = './engine-private/deploy/users';
67
+ const DEFAULT_SSH_PORT = 22;
68
+ /** The checkout's own key, and where a workload receives it instead: a projected Secret volume. */
69
+ const HOST_KEY_PATH = './engine-private/deploy/id_rsa';
70
+ const SECRET_KEY_PATH = '/etc/underpost/secrets/ssh/id_rsa';
71
+
72
+ /**
73
+ * The private key this process authenticates with.
74
+ *
75
+ * One resolver for every SSH caller, because the answer differs by where the process runs and
76
+ * nothing else should have to know that. A pod has the key as a projected Secret and no checkout
77
+ * to read it from; the host CLI has the checkout and no mount. The mount is preferred over the
78
+ * checkout default but never over a caller's own path, because on a host the key is per target —
79
+ * the hub answers to a different one than the LAN nodes — and only the caller knows which.
80
+ * @param {string} [keyPath] - Explicit path, which always wins.
81
+ * @returns {string} Path to the private key.
82
+ * @memberof UnderpostSSH
83
+ */
84
+ const sshKeyPathFactory = (keyPath = '') =>
85
+ `${keyPath || ''}`.trim() || (fs.existsSync(SECRET_KEY_PATH) ? SECRET_KEY_PATH : '') || HOST_KEY_PATH;
86
+
87
+ /**
88
+ * @method hostNameFactory
89
+ * @description A host as the registry compares it: the bare address, so a value
90
+ * written with a prefix (`10.0.0.2/32`) matches one without.
91
+ * @param {string} host - Host or IP, with or without a CIDR suffix.
92
+ * @returns {string} Normalized host.
93
+ * @memberof UnderpostSSH
94
+ */
95
+ const hostNameFactory = (host) => `${host || ''}`.trim().split('/')[0];
96
+
97
+ /**
98
+ * @method connectionFactory
99
+ * @description Normalizes one connection entry.
100
+ * @param {object} [connection] - Raw connection entry.
101
+ * @returns {?{host: string, port: number}} Normalized connection, or null without a host.
102
+ * @memberof UnderpostSSH
103
+ */
104
+ const connectionFactory = (connection = {}) => {
105
+ const host = hostNameFactory(connection.host);
106
+ return host ? { host, port: Number(connection.port) || DEFAULT_SSH_PORT } : null;
107
+ };
108
+
109
+ /**
110
+ * @method upsertConnection
111
+ * @description Adds a connection to a host list, replacing the entry for that
112
+ * host when one is already present.
113
+ *
114
+ * The one write path for `hosts`, so re-registering an account for a host it
115
+ * already reaches updates that connection instead of appending a duplicate the
116
+ * reverse lookup would then have to choose between.
117
+ * @param {Array<{host: string, port: number}>} [hosts] - Existing connections.
118
+ * @param {object} connection - Connection to add.
119
+ * @returns {Array<{host: string, port: number}>} Updated connections.
120
+ * @memberof UnderpostSSH
121
+ */
122
+ const upsertConnection = (hosts = [], connection) => {
123
+ const entry = connectionFactory(connection);
124
+ if (!entry) return hosts;
125
+ return [...hosts.filter((existing) => existing.host !== entry.host), entry];
126
+ };
127
+
128
+ /**
129
+ * @method userRecordFactory
130
+ * @description Normalizes one registry entry, so every consumer reads one shape
131
+ * whether the file was written by hand, partially, or not at all.
132
+ * @param {object} [entry] - Raw registry entry.
133
+ * @returns {object} Normalized record.
134
+ * @memberof UnderpostSSH
135
+ */
136
+ const userRecordFactory = (entry = {}) => ({
137
+ user: `${entry.user || ''}`.trim(),
138
+ password: `${entry.password ?? ''}`,
139
+ groups: `${entry.groups || ''}`,
140
+ keyPath: `${entry.keyPath || ''}`,
141
+ pubKeyPath: `${entry.pubKeyPath || ''}`,
142
+ hosts: (Array.isArray(entry.hosts) ? entry.hosts : []).reduce(
143
+ (hosts, connection) => upsertConnection(hosts, connection),
144
+ [],
145
+ ),
146
+ });
147
+
24
148
  /**
25
149
  * @class UnderpostSSH
26
150
  * @description Manages SSH key generation and connection setup.
@@ -29,33 +153,103 @@ const logger = loggerFactory(import.meta);
29
153
  class UnderpostSSH {
30
154
  static API = {
31
155
  /**
32
- * Loads configuration node from disk or returns default empty config.
156
+ * Path of the cluster users registry.
33
157
  * @method
34
- * @function loadConfigNode
158
+ * @function usersConfPath
35
159
  * @memberof UnderpostSSH
36
- * @param {string} deployId - Deployment ID for the config path
37
- * @returns {{confNode: Object, confNodePath: string}} Configuration node and its file path
38
- * @description Loads or creates a config node with users object structure
160
+ * @returns {string} Path to the users configuration file
39
161
  */
40
- loadConfigNode: (deployId) => {
41
- const confNodePath = `./engine-private/conf/${deployId}/conf.node.json`;
42
- const confNode = fs.existsSync(confNodePath) ? JSON.parse(fs.readFileSync(confNodePath, 'utf8')) : { users: {} };
43
- return { confNode, confNodePath };
44
- },
162
+ usersConfPath: () => USERS_CONF_PATH,
163
+
164
+ /**
165
+ * Key store directory for a user.
166
+ * @method
167
+ * @function userKeyDir
168
+ * @memberof UnderpostSSH
169
+ * @param {string} user - SSH user name
170
+ * @returns {string} Directory holding `<dir>/id_rsa` and `<dir>/id_rsa.pub`
171
+ */
172
+ userKeyDir: (user) => `${USERS_KEYS_PATH}/${user}`,
173
+
174
+ /**
175
+ * Loads the cluster users registry from disk.
176
+ * @method
177
+ * @function loadUsers
178
+ * @memberof UnderpostSSH
179
+ * @returns {Array<Object>} Registered user records, empty when unregistered
180
+ */
181
+ loadUsers: () =>
182
+ (fs.existsSync(USERS_CONF_PATH) ? JSON.parse(fs.readFileSync(USERS_CONF_PATH, 'utf8')) : [])
183
+ .map(userRecordFactory)
184
+ .filter((entry) => entry.user),
45
185
 
46
186
  /**
47
- * Saves configuration node to disk.
187
+ * Saves the cluster users registry to disk.
48
188
  * @method
49
- * @function saveConfigNode
189
+ * @function saveUsers
50
190
  * @memberof UnderpostSSH
51
- * @param {string} confNodePath - Path to the configuration file
52
- * @param {Object} confNode - Configuration object to save
191
+ * @param {Array<Object>} users - User records to persist
53
192
  * @returns {void}
54
193
  */
55
- saveConfigNode: (confNodePath, confNode) => {
56
- fs.outputFileSync(confNodePath, JSON.stringify(confNode, null, 4), 'utf8');
194
+ saveUsers: (users) => {
195
+ fs.outputFileSync(USERS_CONF_PATH, JSON.stringify(users.map(userRecordFactory), null, 2), 'utf8');
196
+ },
197
+
198
+ /**
199
+ * Looks up a registered user record.
200
+ * @method
201
+ * @function findUser
202
+ * @memberof UnderpostSSH
203
+ * @param {string} user - SSH user name
204
+ * @returns {Object|undefined} The record, or undefined when unregistered
205
+ */
206
+ findUser: (user) => Underpost.ssh.loadUsers().find((entry) => entry.user === user),
207
+
208
+ /**
209
+ * Resolves the connection an account uses to reach one host.
210
+ * @method
211
+ * @function resolveConnection
212
+ * @memberof UnderpostSSH
213
+ * @param {object} params
214
+ * @param {string} params.host - Host or IP the account was registered against.
215
+ * @param {string} [params.user] - Restrict the lookup to this account.
216
+ * @returns {Object|undefined} Flat connection `{ user, host, port, password, keyPath, pubKeyPath }`, or undefined
217
+ * @description
218
+ * The reverse of {@link UnderpostSSH.findUser}, for callers that know where
219
+ * they must act but not who acts there — remediation resolving a WireGuard
220
+ * spoke's tunnel address to the account that can repair it. Returns the flat
221
+ * view every SSH caller needs, so no consumer has to know the registry is
222
+ * keyed by host.
223
+ */
224
+ resolveConnection: ({ host, user = '' } = {}) => {
225
+ const target = hostNameFactory(host);
226
+ if (!target) return undefined;
227
+ for (const record of Underpost.ssh.loadUsers()) {
228
+ if (user && record.user !== user) continue;
229
+ const connection = record.hosts.find((entry) => entry.host === target);
230
+ if (!connection) continue;
231
+ return {
232
+ user: record.user,
233
+ host: connection.host,
234
+ port: connection.port,
235
+ password: record.password,
236
+ keyPath: record.keyPath,
237
+ pubKeyPath: record.pubKeyPath,
238
+ };
239
+ }
240
+ return undefined;
57
241
  },
58
242
 
243
+ /**
244
+ * Lists every host an account is registered for.
245
+ * @method
246
+ * @function userHosts
247
+ * @memberof UnderpostSSH
248
+ * @param {string} user - SSH user name
249
+ * @returns {Array<{host: string, port: number}>} Connection metadata per host
250
+ */
251
+ userHosts: (user) => Underpost.ssh.findUser(user)?.hosts || [],
252
+
59
253
  /**
60
254
  * Checks if a system user exists.
61
255
  * @method
@@ -151,12 +345,12 @@ class UnderpostSSH {
151
345
  * @memberof UnderpostSSH
152
346
  * @param {string} sshDir - SSH directory path
153
347
  * @param {string} pubKeyPath - Public key file path
154
- * @param {boolean} disablePassword - Whether to add no-forwarding restrictions
348
+ * @param {boolean} restrictForwarding - Whether to add no-forwarding restrictions
155
349
  * @returns {void}
156
350
  */
157
- configureAuthorizedKeys: (sshDir, pubKeyPath, disablePassword) => {
351
+ configureAuthorizedKeys: (sshDir, pubKeyPath, restrictForwarding) => {
158
352
  const key = fs.readFileSync(pubKeyPath, 'utf8').trim();
159
- const entry = disablePassword ? `no-port-forwarding,no-X11-forwarding,no-agent-forwarding ${key}` : key;
353
+ const entry = restrictForwarding ? `no-port-forwarding,no-X11-forwarding,no-agent-forwarding ${key}` : key;
160
354
  const authorizedKeysPath = `${sshDir}/authorized_keys`;
161
355
  shellExec(
162
356
  `grep -qxF ${shellArgumentFactory(entry)} ${shellArgumentFactory(authorizedKeysPath)} || printf '%s\n' ${shellArgumentFactory(entry)} >> ${shellArgumentFactory(authorizedKeysPath)}`,
@@ -184,17 +378,16 @@ class UnderpostSSH {
184
378
  },
185
379
 
186
380
  /**
187
- * Configures sudoers for passwordless sudo or sets user password.
381
+ * Grants passwordless sudo to a key-only account, or sets the user password.
188
382
  * @method
189
383
  * @function configureSudoAccess
190
384
  * @memberof UnderpostSSH
191
385
  * @param {string} username - Username to configure
192
- * @param {string} password - User password
193
- * @param {boolean} disablePassword - Whether to enable passwordless sudo
386
+ * @param {string} password - User password; empty means key-only
194
387
  * @returns {void}
195
388
  */
196
- configureSudoAccess: (username, password, disablePassword) => {
197
- if (disablePassword) {
389
+ configureSudoAccess: (username, password) => {
390
+ if (!password) {
198
391
  if (!/^[a-z_][a-z0-9_-]*[$]?$/i.test(username)) throw new TypeError('Invalid sudo username');
199
392
  const sudoersPath = `/etc/sudoers.d/90_${username}`;
200
393
  const temporaryPath = `/tmp/underpost-sudoers-${process.pid}-${username}`;
@@ -224,25 +417,21 @@ class UnderpostSSH {
224
417
  * @function initializeDefaultSshConfig
225
418
  * @memberof UnderpostSSH
226
419
  * @param {object} params - SSH configuration values.
227
- * @param {string} params.userHome - Home directory of the SSH user.
228
420
  * @param {string} params.user - SSH user name.
229
- * @param {string} params.password - SSH key passphrase and user password.
421
+ * @param {string} params.password - SSH key passphrase and user password; empty means key-only.
230
422
  * @param {string} params.host - Host used in the SSH key comment.
231
423
  * @param {number} params.port - SSH port.
232
- * @param {boolean} params.disablePassword - Whether to configure passwordless sudo.
233
424
  * @param {string} [params.controllerPubKeyPath='./engine-private/deploy/id_rsa.pub'] - Preferred controller public key to authorize.
234
425
  * @returns {void}
235
426
  */
236
427
  initializeDefaultSshConfig: ({
237
- userHome,
238
428
  user,
239
429
  password,
240
430
  host,
241
431
  port,
242
- disablePassword,
243
432
  controllerPubKeyPath = './engine-private/deploy/id_rsa.pub',
244
433
  }) => {
245
- const sshDir = `${userHome}/.ssh`;
434
+ const sshDir = `${Underpost.ssh.getUserHome(user)}/.ssh`;
246
435
  const keyPath = `${sshDir}/id_rsa`;
247
436
  const pubKeyPath = `${sshDir}/id_rsa.pub`;
248
437
 
@@ -263,9 +452,9 @@ class UnderpostSSH {
263
452
  );
264
453
 
265
454
  const authorizedKeyPath = fs.existsSync(controllerPubKeyPath) ? controllerPubKeyPath : pubKeyPath;
266
- Underpost.ssh.configureAuthorizedKeys(sshDir, authorizedKeyPath, disablePassword);
455
+ Underpost.ssh.configureAuthorizedKeys(sshDir, authorizedKeyPath, !password);
267
456
  Underpost.ssh.configureKnownHosts(sshDir, port, host);
268
- Underpost.ssh.configureSudoAccess(user, password, disablePassword);
457
+ Underpost.ssh.configureSudoAccess(user, password);
269
458
  Underpost.ssh.setSSHFilePermissions(sshDir, user, keyPath, pubKeyPath);
270
459
  },
271
460
 
@@ -275,11 +464,10 @@ class UnderpostSSH {
275
464
  * @function callback
276
465
  * @memberof UnderpostSSH
277
466
  * @param {Object} options - Configuration options for SSH operations
278
- * @param {string} [options.deployId=''] - Deployment ID context for SSH operations
279
467
  * @param {boolean} [options.generate=false] - Generate new SSH credentials
280
468
  * @param {string} [options.user=''] - SSH user name (defaults to 'root')
281
- * @param {string} [options.password=''] - SSH user password (auto-generated if not provided, overridden by saved config if user exists)
282
- * @param {string} [options.host=''] - SSH host address (defaults to public IP, overridden by saved config if user exists)
469
+ * @param {string} [options.password=''] - SSH user password (taken from the registry, else generated unless --disable-password)
470
+ * @param {string} [options.host=''] - SSH host address (taken from the registry, else the public IP)
283
471
  * @param {string} [options.filter=''] - Filter for user/group listings
284
472
  * @param {string} [options.groups=''] - Comma-separated list of groups for the user (defaults to 'wheel')
285
473
  * @param {number} [options.port=22] - SSH port number
@@ -290,24 +478,23 @@ class UnderpostSSH {
290
478
  * @param {boolean} [options.reset=false] - Reset SSH configuration (clear authorized_keys and known_hosts)
291
479
  * @param {boolean} [options.keysList=false] - List authorized SSH keys
292
480
  * @param {boolean} [options.hostsList=false] - List known SSH hosts
293
- * @param {boolean} [options.disablePassword=false] - If true, enable passwordless sudo and add SSH restrictions
481
+ * @param {boolean} [options.disablePassword=false] - Create the account key-only instead of generating a password
294
482
  * @param {boolean} [options.keyTest=false] - Test SSH key generation
295
483
  * @param {boolean} [options.stop=false] - Stop SSH service
296
484
  * @param {boolean} [options.status=false] - Check SSH service status
297
- * @param {boolean} [options.connectUri=false] - Output SSH connection URI
298
- * @param {boolean} [options.copy=false] - Copy SSH connection URI to clipboard
299
485
  * @returns {Promise<void>}
300
486
  * @description
301
- * Handles various SSH operations:
487
+ * Handles SSH operations against the cluster users registry
488
+ * (`engine-private/deploy/conf.users.json`) and key store
489
+ * (`engine-private/deploy/users/<user>`):
302
490
  * - User creation with automatic key generation and backup
303
491
  * - User removal with key cleanup
304
- * - Key import/export between SSH directory and private backup location
492
+ * - Key import/export between SSH directory and the key store
305
493
  * - SSH service initialization and hardening
306
494
  * - User and group listing with optional filtering
307
495
  */
308
496
  callback: async (
309
497
  options = {
310
- deployId: '',
311
498
  generate: false,
312
499
  user: '',
313
500
  password: '',
@@ -326,62 +513,49 @@ class UnderpostSSH {
326
513
  keyTest: false,
327
514
  stop: false,
328
515
  status: false,
329
- connectUri: false,
330
- copy: false,
331
516
  },
332
517
  ) => {
333
- let confNode, confNodePath;
334
-
335
- // Set defaults
336
518
  if (!options.user) options.user = 'root';
337
- if (!options.host) options.host = await Underpost.dns.getPublicIp();
338
- if (!options.password) options.password = options.disablePassword ? '' : generateRandomPasswordSelection(16);
339
- if (!options.groups) options.groups = 'wheel';
340
- if (!options.port) options.port = 22; // Handle connect uri
341
-
342
- const userHome = Underpost.ssh.getUserHome(options.user);
343
- options.userHome = userHome;
344
519
 
345
- // Load config and override password and host if user exists in config
346
- if (options.deployId) {
347
- const config = Underpost.ssh.loadConfigNode(options.deployId);
348
- confNode = config.confNode;
349
- confNodePath = config.confNodePath;
350
-
351
- if (confNode.users && confNode.users[options.user]) {
352
- if (confNode.users[options.user].host) {
353
- options.host = confNode.users[options.user].host;
354
- logger.info(`Using saved host for user ${options.user}: ${options.host}`);
355
- }
356
- if (confNode.users[options.user].password === '') {
357
- options.disablePassword = true;
358
- options.password = '';
359
- logger.info(`Using saved empty password for user ${options.user}`);
360
- } else if (confNode.users[options.user].password) {
361
- options.disablePassword = false;
362
- options.password = confNode.users[options.user].password;
363
- logger.info(`Using saved password for user ${options.user}`);
364
- }
365
- options.port = options.port || confNode.users[options.user].port || 22;
366
- }
520
+ const users = Underpost.ssh.loadUsers();
521
+ const confUserIndex = users.findIndex((entry) => entry.user === options.user);
522
+ const confUser = users[confUserIndex];
523
+ const registeredHosts = confUser?.hosts || [];
524
+
525
+ // Explicit flags win; the registry fills in what was omitted, but only for
526
+ // the operations that act on one host. A single registered host is
527
+ // unambiguous and several are not — an account on many spokes must be told
528
+ // which one is meant rather than acting on whichever the registry lists
529
+ // first. Listing and removal are account-scoped and need no host at all.
530
+ const hostScoped = options.userAdd || options.start || options.keyTest || options.hostsList;
531
+ if (!options.host && hostScoped) {
532
+ if (registeredHosts.length > 1)
533
+ throw new Error(
534
+ `[ssh] user '${options.user}' is registered for several hosts ` +
535
+ `(${registeredHosts.map((entry) => entry.host).join(', ')}); pass --host to select one`,
536
+ );
537
+ options.host = registeredHosts[0]?.host || (await Underpost.dns.getPublicIp());
538
+ }
539
+ if (!options.port)
540
+ options.port =
541
+ registeredHosts.find((entry) => entry.host === hostNameFactory(options.host))?.port || DEFAULT_SSH_PORT;
542
+ if (!options.groups) options.groups = 'wheel';
543
+ if (!options.password) {
544
+ const generatePassword = !confUser && !options.disablePassword;
545
+ options.password = generatePassword ? generateRandomPasswordSelection(16) : confUser?.password || '';
367
546
  }
368
547
 
369
- logger.info('options', options);
548
+ const keyDir = Underpost.ssh.userKeyDir(options.user);
549
+ const keyPath = confUser?.keyPath || `${keyDir}/id_rsa`;
550
+ const pubKeyPath = confUser?.pubKeyPath || `${keyDir}/id_rsa.pub`;
551
+ const sshDirFactory = () => `${Underpost.ssh.getUserHome(options.user)}/.ssh`;
370
552
 
371
- // Handle connect uri
372
- if (options.connectUri) {
373
- const keyPath = `${userHome}/.ssh/id_rsa`;
374
- const uri = `ssh ${options.user}@${options.host} -i ${keyPath} -p ${options.port}`;
375
- if (options.copy) {
376
- pbcopy(uri);
377
- } else console.log(uri);
378
- return;
379
- }
553
+ logger.info('options', options);
380
554
 
381
- // Handle reset operation
382
555
  if (options.reset) {
383
- shellExec(`> ${userHome}/.ssh/authorized_keys`);
384
- shellExec(`> ${userHome}/.ssh/known_hosts`);
556
+ const sshDir = sshDirFactory();
557
+ shellExec(`> ${sshDir}/authorized_keys`);
558
+ shellExec(`> ${sshDir}/known_hosts`);
385
559
  }
386
560
 
387
561
  if (options.userLs) {
@@ -400,36 +574,30 @@ class UnderpostSSH {
400
574
  console.log('Users'.bold.blue);
401
575
  console.log(`user : x : UID : GID : GECOS : home_dir : shell`.blue);
402
576
  console.log(filter ? usersOut.replaceAll(filter, filter.red) : usersOut);
577
+ console.log('Registered cluster users'.bold.blue);
578
+ users.forEach((entry) =>
579
+ entry.hosts.forEach((connection) => console.log(`${entry.user}@${connection.host}:${connection.port}`)),
580
+ );
403
581
  }
404
582
 
405
- // Handle user removal (works with or without deployId)
406
583
  if (options.userRemove) {
407
584
  const groups = shellExec(`id -Gn ${options.user}`, { silent: true, stdout: true }).trim().replace(/ /g, ', ');
408
585
  shellExec(`userdel -r ${options.user}`);
409
586
 
410
- // Remove sudoers file if it exists
411
587
  const sudoersFile = `/etc/sudoers.d/90_${options.user}`;
412
588
  if (fs.existsSync(sudoersFile)) {
413
589
  shellExec(`sudo rm -f ${sudoersFile}`);
414
590
  logger.info(`Sudoers file removed: ${sudoersFile}`);
415
591
  }
416
592
 
417
- // Remove the private key copy folder and update config only if deployId is provided
418
- if (options.deployId) {
419
- if (!confNode) {
420
- const config = Underpost.ssh.loadConfigNode(options.deployId);
421
- confNode = config.confNode;
422
- confNodePath = config.confNodePath;
423
- }
424
-
425
- const privateCopyDir = `./engine-private/conf/${options.deployId}/users/${options.user}`;
426
- if (fs.existsSync(privateCopyDir)) {
427
- fs.removeSync(privateCopyDir);
428
- logger.info(`Private key copy removed from ${privateCopyDir}`);
429
- }
593
+ if (fs.existsSync(keyDir)) {
594
+ fs.removeSync(keyDir);
595
+ logger.info(`Key store removed from ${keyDir}`);
596
+ }
430
597
 
431
- delete confNode.users[options.user];
432
- Underpost.ssh.saveConfigNode(confNodePath, confNode);
598
+ if (confUser) {
599
+ users.splice(confUserIndex, 1);
600
+ Underpost.ssh.saveUsers(users);
433
601
  }
434
602
 
435
603
  logger.info(`User removed`);
@@ -437,148 +605,83 @@ class UnderpostSSH {
437
605
  return;
438
606
  }
439
607
 
440
- // Handle user addition (works with or without deployId)
441
608
  if (options.userAdd) {
442
- let privateCopyDir, privateKeyPath, publicKeyPath, keysExistInBackup, userExistsInConfig;
443
-
444
- // If deployId is provided, check for existing config and backup keys
445
- if (options.deployId) {
446
- if (!confNode) {
447
- const config = Underpost.ssh.loadConfigNode(options.deployId);
448
- confNode = config.confNode;
449
- confNodePath = config.confNodePath;
450
- }
451
-
452
- userExistsInConfig = confNode.users && confNode.users[options.user];
453
- privateCopyDir = `./engine-private/conf/${options.deployId}/users/${options.user}`;
454
- privateKeyPath = `${privateCopyDir}/id_rsa`;
455
- publicKeyPath = `${privateCopyDir}/id_rsa.pub`;
456
- keysExistInBackup = fs.existsSync(privateKeyPath) && fs.existsSync(publicKeyPath);
457
-
458
- // If user exists in config AND keys exist in backup, import those keys
459
- if (userExistsInConfig && keysExistInBackup) {
460
- logger.info(`User ${options.user} already exists in config. Importing existing keys...`);
461
-
462
- // Create system user if it doesn't exist
463
- const userExists = Underpost.ssh.checkUserExists(options.user);
464
- if (!userExists) {
465
- Underpost.ssh.createSystemUser(options.user, options.password, options.groups);
466
- }
467
-
468
- const userHome = Underpost.ssh.getUserHome(options.user);
469
- const sshDir = `${userHome}/.ssh`;
470
- Underpost.ssh.ensureSSHDirectory(sshDir);
471
-
472
- const userKeyPath = `${sshDir}/id_rsa`;
473
- const userPubKeyPath = `${sshDir}/id_rsa.pub`;
474
-
475
- // Import keys from backup
476
- fs.copyFileSync(privateKeyPath, userKeyPath);
477
- fs.copyFileSync(publicKeyPath, userPubKeyPath);
478
-
479
- Underpost.ssh.configureAuthorizedKeys(sshDir, userPubKeyPath, options.disablePassword);
480
- Underpost.ssh.configureSudoAccess(options.user, options.password, options.disablePassword);
481
- Underpost.ssh.configureKnownHosts(sshDir, options.port, options.host);
482
- Underpost.ssh.setSSHFilePermissions(sshDir, options.user, userKeyPath, userPubKeyPath);
483
-
484
- logger.info(`Keys imported from ${privateCopyDir} to ${sshDir}`);
485
- logger.info(`User added with existing keys`);
486
- return;
487
- }
488
- }
489
-
490
- // New user or no existing keys - create new user and generate keys
491
- Underpost.ssh.createSystemUser(options.user, options.password, options.groups);
609
+ if (!Underpost.ssh.checkUserExists(options.user))
610
+ Underpost.ssh.createSystemUser(options.user, options.password, options.groups);
492
611
 
493
- const userHome = Underpost.ssh.getUserHome(options.user);
494
- const sshDir = `${userHome}/.ssh`;
612
+ const sshDir = sshDirFactory();
495
613
  Underpost.ssh.ensureSSHDirectory(sshDir);
496
-
497
- const keyPath = `${sshDir}/id_rsa`;
498
- const pubKeyPath = `${sshDir}/id_rsa.pub`;
499
-
500
- if (!fs.existsSync(keyPath)) {
614
+ const hostKeyPath = `${sshDir}/id_rsa`;
615
+ const hostPubKeyPath = `${sshDir}/id_rsa.pub`;
616
+
617
+ // A key already trusted cluster wide is reinstalled, never re-issued:
618
+ // regenerating it would invalidate every host that already holds it.
619
+ const storedKeys = fs.existsSync(keyPath) && fs.existsSync(pubKeyPath);
620
+ if (storedKeys) {
621
+ fs.copyFileSync(keyPath, hostKeyPath);
622
+ fs.copyFileSync(pubKeyPath, hostPubKeyPath);
623
+ logger.info(`Keys imported from ${keyDir} to ${sshDir}`);
624
+ } else if (!fs.existsSync(hostKeyPath)) {
501
625
  shellExec(
502
- `ssh-keygen -t ed25519 -f ${keyPath} -N "${options.password}" -q -C "${options.user}@${options.host}"`,
626
+ `ssh-keygen -t ed25519 -f ${hostKeyPath} -N "${options.password}" -q -C "${options.user}@${options.host}"`,
627
+ { disableLog: true },
503
628
  );
629
+ } else if (!fs.existsSync(hostPubKeyPath)) {
630
+ shellExec(`ssh-keygen -y -f ${hostKeyPath} -P "${options.password}" > ${hostPubKeyPath}`, {
631
+ disableLog: true,
632
+ });
504
633
  }
505
634
 
506
- Underpost.ssh.configureAuthorizedKeys(sshDir, pubKeyPath, options.disablePassword);
507
- Underpost.ssh.configureSudoAccess(options.user, options.password, options.disablePassword);
635
+ Underpost.ssh.configureAuthorizedKeys(sshDir, hostPubKeyPath, !options.password);
636
+ Underpost.ssh.configureSudoAccess(options.user, options.password);
508
637
  Underpost.ssh.configureKnownHosts(sshDir, options.port, options.host);
509
- Underpost.ssh.setSSHFilePermissions(sshDir, options.user, keyPath, pubKeyPath);
510
-
511
- // Save a copy of the keys to the private folder only if deployId is provided
512
- if (options.deployId) {
513
- if (!privateCopyDir) privateCopyDir = `./engine-private/conf/${options.deployId}/users/${options.user}`;
514
- fs.ensureDirSync(privateCopyDir);
515
-
516
- const privateKeyCopyPath = `${privateCopyDir}/id_rsa`;
517
- const publicKeyCopyPath = `${privateCopyDir}/id_rsa.pub`;
518
-
519
- fs.copyFileSync(keyPath, privateKeyCopyPath);
520
- fs.copyFileSync(pubKeyPath, publicKeyCopyPath);
521
-
522
- logger.info(`Keys backed up to ${privateCopyDir}`);
523
-
524
- confNode.users[options.user] = {
525
- ...confNode.users[options.user],
526
- ...options,
527
- keyPath,
528
- pubKeyPath,
529
- privateKeyCopyPath,
530
- publicKeyCopyPath,
531
- };
532
- Underpost.ssh.saveConfigNode(confNodePath, confNode);
533
- }
534
-
535
- logger.info(`User added`);
536
- return;
537
- }
638
+ Underpost.ssh.setSSHFilePermissions(sshDir, options.user, hostKeyPath, hostPubKeyPath);
538
639
 
539
- // Handle config user listing (only with deployId)
540
- if (options.deployId) {
541
- if (!confNode) {
542
- const config = Underpost.ssh.loadConfigNode(options.deployId);
543
- confNode = config.confNode;
544
- confNodePath = config.confNodePath;
640
+ if (!storedKeys) {
641
+ fs.ensureDirSync(keyDir);
642
+ fs.copyFileSync(hostKeyPath, keyPath);
643
+ fs.copyFileSync(hostPubKeyPath, pubKeyPath);
644
+ logger.info(`Keys backed up to ${keyDir}`);
545
645
  }
546
646
 
547
- if (options.userLs && confNode && confNode.users) {
548
- logger.info(`Users in config:`);
549
- Object.keys(confNode.users).forEach((user) => {
550
- logger.info(`- ${user}`);
551
- });
552
- }
647
+ // Additive: registering the account for another host adds that host,
648
+ // it does not replace the record. One operator account across several
649
+ // spokes is the normal case, and replacing here is what previously left
650
+ // remediation unable to resolve a spoke that had just been registered.
651
+ const record = {
652
+ ...(confUser || {}),
653
+ user: options.user,
654
+ password: options.password,
655
+ groups: options.groups,
656
+ keyPath,
657
+ pubKeyPath,
658
+ hosts: upsertConnection(registeredHosts, { host: options.host, port: options.port }),
659
+ };
660
+ if (confUser) users[confUserIndex] = record;
661
+ else users.push(record);
662
+ Underpost.ssh.saveUsers(users);
663
+
664
+ logger.info(`User added`, { user: options.user, hosts: record.hosts });
665
+ return;
553
666
  }
554
667
 
555
- // Handle generate root keys
556
668
  if (options.generate)
557
669
  Underpost.ssh.generateKeys({ user: options.user, password: options.password, host: options.host });
558
670
 
559
- // Handle list operations
560
- if (options.keysList) shellExec(`cat ${userHome}/.ssh/authorized_keys`);
561
- if (options.hostsList) shellExec(`cat ${userHome}/.ssh/known_hosts`);
671
+ if (options.keysList) shellExec(`cat ${sshDirFactory()}/authorized_keys`);
672
+ if (options.hostsList) shellExec(`cat ${sshDirFactory()}/known_hosts`);
562
673
 
563
- // Handle key test
564
- if (options.keyTest) {
565
- const keyPath = `${userHome}/.ssh/id_rsa`;
566
- shellExec(`ssh-keygen -y -f ${keyPath} -P "${options.password}"`);
567
- }
674
+ if (options.keyTest) shellExec(`ssh-keygen -y -f ${keyPath} -P "${options.password}"`);
568
675
 
569
- // Handle stop server
570
676
  if (options.stop) shellExec('service sshd stop');
571
677
 
572
- // Handle start server
573
678
  if (options.start) {
574
- if (!options.deployId) {
679
+ if (!confUser) {
575
680
  Underpost.ssh.initializeDefaultSshConfig({
576
- userHome,
577
681
  user: options.user,
578
682
  password: options.password,
579
683
  host: options.host,
580
684
  port: options.port,
581
- disablePassword: options.disablePassword,
582
685
  });
583
686
  } else {
584
687
  // Hardening sshd on a host where the account does not exist produces a
@@ -587,32 +690,29 @@ class UnderpostSSH {
587
690
  if (!Underpost.ssh.checkUserExists(options.user))
588
691
  throw new Error(
589
692
  `[ssh] user '${options.user}' does not exist on this host; ` +
590
- `run: node bin ssh --deploy-id ${options.deployId} --user ${options.user} --user-add`,
693
+ `run: node bin ssh --user ${options.user} --user-add`,
591
694
  );
592
- Underpost.ssh.chmod({ user: options.user });
593
- // The registry records the key pair this deploy authenticates with, so
695
+ // The registry records the key pair this cluster authenticates with, so
594
696
  // a box whose authorized_keys was lost converges here rather than
595
697
  // needing the key re-issued — which would invalidate every other host
596
698
  // holding it.
597
- const recordedPubKey = confNode?.users?.[options.user]?.publicKeyCopyPath;
598
- if (recordedPubKey && fs.existsSync(recordedPubKey)) {
599
- const sshDir = `${userHome}/.ssh`;
699
+ if (fs.existsSync(pubKeyPath)) {
700
+ const sshDir = sshDirFactory();
600
701
  Underpost.ssh.ensureSSHDirectory(sshDir);
601
702
  fs.ensureFileSync(`${sshDir}/authorized_keys`);
602
- Underpost.ssh.configureAuthorizedKeys(sshDir, recordedPubKey, options.disablePassword);
603
- Underpost.ssh.chmod({ user: options.user });
604
- logger.info(`Authorized key present for ${options.user}`, { source: recordedPubKey });
605
- } else if (recordedPubKey) {
606
- logger.warn('Recorded public key is missing; authorized_keys left untouched', {
703
+ Underpost.ssh.configureAuthorizedKeys(sshDir, pubKeyPath, !options.password);
704
+ logger.info(`Authorized key present for ${options.user}`, { source: pubKeyPath });
705
+ } else {
706
+ logger.warn('Registered public key is missing; authorized_keys left untouched', {
607
707
  user: options.user,
608
- expected: recordedPubKey,
708
+ expected: pubKeyPath,
609
709
  });
610
710
  }
711
+ Underpost.ssh.chmod({ user: options.user });
611
712
  }
612
713
  Underpost.ssh.initService({ port: options.port });
613
714
  }
614
715
 
615
- // Handle status server
616
716
  if (options.status) shellExec('service sshd status');
617
717
  },
618
718
 
@@ -634,9 +734,13 @@ class UnderpostSSH {
634
734
  * @param {string} params.remoteDir - Destination directory on the node.
635
735
  * @param {number} [params.port=22] - SSH port.
636
736
  * @param {string} [params.user='root'] - SSH user (key-only).
637
- * @param {string} [params.keyPath='./engine-private/deploy/id_rsa'] - Private key path.
737
+ * @param {string} [params.keyPath] - Private key path; resolved by {@link sshKeyPathFactory} when empty.
638
738
  * @param {string} [params.owner='1000:1000'] - chown target on the node (empty to skip).
639
739
  * @param {string} [params.mode='755'] - chmod mode on the node (empty to skip).
740
+ * @param {boolean} [params.containerStorage=false] - Give the destination tree the persistent
741
+ * `container_file_t` mapping on the node. Required for a hostPath volume: the label has to
742
+ * exist on the node that actually mounts it, and a directory created here by `mkdir -p`
743
+ * otherwise inherits whatever the parent carries — which no unprivileged pod can read.
640
744
  * @returns {void}
641
745
  */
642
746
  copyDirToNode: ({
@@ -645,16 +749,18 @@ class UnderpostSSH {
645
749
  remoteDir,
646
750
  port = 22,
647
751
  user = 'root',
648
- keyPath = './engine-private/deploy/id_rsa',
752
+ keyPath = '',
649
753
  owner = '1000:1000',
650
754
  mode = '755',
755
+ containerStorage = false,
651
756
  }) => {
652
757
  if (!host) throw new Error('copyDirToNode requires a host');
653
758
  if (!localDir || !fs.existsSync(localDir)) throw new Error(`copyDirToNode: local dir not found: ${localDir}`);
654
759
  if (!remoteDir) throw new Error('copyDirToNode requires a remoteDir');
760
+ const key = sshKeyPathFactory(keyPath);
655
761
  try {
656
- shellExec(`chmod 600 ${keyPath}`, { silent: true, silentOnError: true, disableLog: true });
657
- const sshOpts = `-i ${keyPath} -o BatchMode=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -p ${port}`;
762
+ shellExec(`chmod 600 ${key}`, { silent: true, silentOnError: true, disableLog: true });
763
+ const sshOpts = `-i ${key} -o BatchMode=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -p ${port}`;
658
764
  shellExec(`ssh ${sshOpts} ${user}@${host} 'mkdir -p ${remoteDir}'`, {
659
765
  silent: true,
660
766
  disableLog: true,
@@ -670,12 +776,32 @@ class UnderpostSSH {
670
776
  silent: true,
671
777
  disableLog: true,
672
778
  });
779
+ // After the fixups, so the restorecon pass covers the files tar just extracted. A no-op
780
+ // on a node without SELinux, and it never runs unless the caller asked for it.
781
+ if (containerStorage)
782
+ shellExec(
783
+ `ssh ${sshOpts} ${user}@${host} ${shellArgumentFactory(
784
+ ContainerStorageService.containerStorageSubtreeCommandsFactory(remoteDir, {
785
+ sudo: user !== 'root',
786
+ }).join('; '),
787
+ )}`,
788
+ { silent: true, disableLog: true },
789
+ );
673
790
  } catch (err) {
674
791
  logger.error(`copyDirToNode failed`);
675
792
  process.exit(1);
676
793
  }
677
794
  },
678
795
 
796
+ /**
797
+ * The private key this process authenticates with; see {@link sshKeyPathFactory}.
798
+ * @function keyPathFactory
799
+ * @param {string} [keyPath] - Explicit path, which always wins.
800
+ * @returns {string} Path to the private key.
801
+ * @memberof UnderpostSSH
802
+ */
803
+ keyPathFactory: sshKeyPathFactory,
804
+
679
805
  /**
680
806
  * Generic SSH remote command runner that SSH execution logic.
681
807
  * Executes arbitrary shell commands on a remote server via SSH with proper credential handling.
@@ -683,46 +809,68 @@ class UnderpostSSH {
683
809
  * @function sshRemoteRunner
684
810
  * @param {string} remoteCommand - The command to execute on the remote server
685
811
  * @param {Object} options - Configuration options for SSH execution
686
- * @param {string} [options.deployId] - Deployment ID for credential lookup
687
- * @param {string} [options.user] - SSH user for credential lookup
812
+ * @param {string} [options.user] - SSH user for cluster credential lookup
813
+ * @param {string} [options.host] - Host to reach; selects the connection when the user has several
688
814
  * @param {boolean} [options.dev=false] - Development mode flag
689
815
  * @param {string} [options.cd='/home/dd/engine'] - Working directory on remote server
690
816
  * @param {boolean} [options.useSudo=true] - Whether to use sudo for command execution
691
- * @param {boolean} [options.remote=true] - Whether to execute as remote command (if false, runs locally)
817
+ * @param {boolean} [options.remote=true] - Whether to execute as remote command (if false, runs locally in `cd`)
818
+ * @param {boolean} [options.silent=false] - Suppress the command's output; it is still returned to the caller
692
819
  * @returns {Promise<string>} Output from the shell execution
693
820
  * @memberof UnderpostSSH
694
821
  */
695
822
  sshRemoteRunner: async (remoteCommand, options = {}) => {
696
- const { deployId = '', user = '', dev = false, cd = '/home/dd/engine', useSudo = true, remote = true } = options;
697
-
698
- // If not executing remotely, just run locally
823
+ const {
824
+ user = '',
825
+ host = '',
826
+ dev = false,
827
+ cd = '/home/dd/engine',
828
+ useSudo = true,
829
+ remote = true,
830
+ silent = false,
831
+ } = options;
832
+
833
+ // Local execution still honours `cd`: the underpost CLI resolves its deploy
834
+ // configuration relative to the working directory, so running it from
835
+ // wherever the caller happened to start would act on a different cluster's
836
+ // config, or none. It honours it only when that directory exists — a checkout
837
+ // that lives elsewhere (a CI container, a developer tree) has no deploy path to
838
+ // enter, and chdir failing there turns a runnable local command into an error
839
+ // reported as a failed remediation.
840
+ // The generated wrapper is transport, not information: echoing it buries
841
+ // whatever the command actually said under a page of boilerplate.
699
842
  if (!remote) {
700
- return shellExec(remoteCommand);
843
+ const cwd = cd && fs.existsSync(cd) ? cd : '';
844
+ return shellExecRedacted(remoteCommand, { ...(cwd ? { cwd } : {}), silent, disableLog: true });
701
845
  }
702
846
 
703
- // Set up SSH credentials from config
704
- if (deployId && user) {
705
- await Underpost.ssh.setDefautlSshCredentials({ deployId, user });
706
- }
847
+ // Set up SSH credentials from the cluster config
848
+ if (user) await Underpost.ssh.setDefautlSshCredentials({ user, host });
849
+
850
+ // The store holds the key `setDefautlSshCredentials` just resolved for this target, which is
851
+ // the only per-target answer; a pod has no such key on disk and takes its mount instead.
852
+ const sshKey = fs.existsSync(SECRET_KEY_PATH)
853
+ ? shellArgumentFactory(SECRET_KEY_PATH)
854
+ : '$(node bin host get --plain DEFAULT_SSH_KEY_PATH)';
707
855
 
708
- // Build the complete SSH command
709
856
  const sshScript = `#!/usr/bin/env bash
710
857
  set -euo pipefail
711
858
 
712
- REMOTE_USER=$(node bin config get --plain DEFAULT_SSH_USER)
713
- REMOTE_HOST=$(node bin config get --plain DEFAULT_SSH_HOST)
714
- REMOTE_PORT=$(node bin config get --plain DEFAULT_SSH_PORT)
715
- SSH_KEY=$(node bin config get --plain DEFAULT_SSH_KEY_PATH)
859
+ REMOTE_USER=$(node bin host get --plain DEFAULT_SSH_USER)
860
+ REMOTE_HOST=$(node bin host get --plain DEFAULT_SSH_HOST)
861
+ REMOTE_PORT=$(node bin host get --plain DEFAULT_SSH_PORT)
862
+ SSH_KEY=${sshKey}
716
863
 
717
- chmod 600 "$SSH_KEY"
864
+ # A projected Secret is read-only, and its mode is already restrictive enough for ssh.
865
+ chmod 600 "$SSH_KEY" 2>/dev/null || true
718
866
 
719
- ssh -i "$SSH_KEY" -o BatchMode=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$REMOTE_USER@$REMOTE_HOST" -p $REMOTE_PORT sh <<EOF
867
+ ssh -i "$SSH_KEY" -o BatchMode=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR "$REMOTE_USER@$REMOTE_HOST" -p $REMOTE_PORT sh <<EOF
720
868
  ${cd ? `cd ${cd}` : ''}
721
869
  ${useSudo ? `sudo -n -- /bin/bash -lc "${remoteCommand}"` : remoteCommand}
722
870
  EOF
723
871
  `;
724
872
 
725
- return shellExec(sshScript, { stdout: true });
873
+ return shellExecRedacted(sshScript, { silent, disableLog: true });
726
874
  },
727
875
 
728
876
  /**
@@ -770,7 +918,7 @@ EOF
770
918
  * @param {string} params.command - Remote command batch to execute.
771
919
  * @param {number} [params.port=22] - SSH port.
772
920
  * @param {string} [params.user='root'] - SSH user (key-only).
773
- * @param {string} [params.keyPath] - Private key path (defaults to engine deploy key).
921
+ * @param {string} [params.keyPath] - Private key path; resolved by {@link sshKeyPathFactory} when empty.
774
922
  * @param {number} [params.connectTimeoutSec=15] - Per-attempt SSH connect timeout.
775
923
  * @param {number} [params.retries=3] - Auth/exec retry attempts.
776
924
  * @param {number} [params.retryDelayMs=5000] - Base backoff between retries.
@@ -782,7 +930,7 @@ EOF
782
930
  command,
783
931
  port = 22,
784
932
  user = 'root',
785
- keyPath = './engine-private/deploy/id_rsa',
933
+ keyPath = '',
786
934
  connectTimeoutSec = 15,
787
935
  retries = 3,
788
936
  retryDelayMs = 5000,
@@ -796,10 +944,11 @@ EOF
796
944
  if (!reachable) return { ok: false, code: 255, stdout: '', stderr: 'ssh port unreachable', attempts: 0 };
797
945
  }
798
946
 
799
- shellExec(`chmod 600 ${keyPath}`, { silent: true, silentOnError: true, disableLog: true });
947
+ const key = sshKeyPathFactory(keyPath);
948
+ shellExec(`chmod 600 ${key}`, { silent: true, silentOnError: true, disableLog: true });
800
949
 
801
950
  const sshOpts = [
802
- `-i ${keyPath}`,
951
+ `-i ${key}`,
803
952
  `-o BatchMode=yes`,
804
953
  `-o PreferredAuthentications=publickey`,
805
954
  `-o PubkeyAuthentication=yes`,
@@ -875,7 +1024,7 @@ EOF
875
1024
  remotePath = '/tmp/underpost-remote-script.sh',
876
1025
  port = 22,
877
1026
  user = 'root',
878
- keyPath = './engine-private/deploy/id_rsa',
1027
+ keyPath = '',
879
1028
  retries = 3,
880
1029
  waitForPortMs = 0,
881
1030
  }) => {
@@ -898,25 +1047,73 @@ EOF
898
1047
  },
899
1048
 
900
1049
  /**
901
- * Loads saved SSH credentials from config and sets them in the UnderpostRootEnv API.
1050
+ * Loads a user's SSH credentials for one host and sets them in the host
1051
+ * configuration store.
1052
+ *
1053
+ * `host` is what selects the connection: an account registered for several
1054
+ * hosts has several, and picking one without being told would send a repair
1055
+ * to the wrong machine. Omitting it is only unambiguous for an account with
1056
+ * exactly one registered host.
1057
+ *
1058
+ * A named host is resolved against the registry and nowhere else. The root
1059
+ * env store is ambient — whatever the previous command left there — so
1060
+ * substituting it for a host the registry does not know would silently point
1061
+ * an operation at a different machine, which for remediation means repairing
1062
+ * one host because another was unreachable. Its `DEFAULT_SSH_*` values are
1063
+ * promoted only when no host was named, for a controller that dispatches
1064
+ * work but was never itself provisioned by `--user-add`.
902
1065
  * @async
903
1066
  * @function setDefautlSshCredentials
904
1067
  * @memberof UnderpostSSH
905
1068
  * @param {Object} options - Options for setting default SSH credentials
906
- * @param {string} options.deployId - Deployment ID for the config path
907
- * @param {string} options.user - SSH user name
1069
+ * @param {string} options.user - SSH user name registered at cluster scope
1070
+ * @param {string} [options.host] - Host to connect to; required when the account has several
908
1071
  * @returns {Promise<void>}
1072
+ * @throws {Error} When a named host has no connection registered for that account.
909
1073
  */
910
- setDefautlSshCredentials: async (options = { deployId: '', user: '' }) => {
911
- const confNodePath = `./engine-private/conf/${options.deployId}/conf.node.json`;
912
- if (fs.existsSync(confNodePath)) {
913
- const { users } = JSON.parse(fs.readFileSync(confNodePath, 'utf8'));
914
- const { user, host, keyPath, port } = users[options.user];
915
- Underpost.env.set('DEFAULT_SSH_USER', user);
916
- Underpost.env.set('DEFAULT_SSH_HOST', host);
917
- Underpost.env.set('DEFAULT_SSH_KEY_PATH', keyPath);
918
- Underpost.env.set('DEFAULT_SSH_PORT', port);
919
- } else logger.warn(`No SSH config found at ${confNodePath}`);
1074
+ setDefautlSshCredentials: async (options = { user: '', host: '' }) => {
1075
+ const hosts = Underpost.ssh.userHosts(options.user);
1076
+ if (!options.host && hosts.length > 1)
1077
+ throw new Error(
1078
+ `[ssh] user '${options.user}' is registered for several hosts ` +
1079
+ `(${hosts.map((entry) => entry.host).join(', ')}); the caller must name one`,
1080
+ );
1081
+
1082
+ const registered = Underpost.ssh.resolveConnection({
1083
+ user: options.user,
1084
+ host: options.host || hosts[0]?.host,
1085
+ });
1086
+
1087
+ if (!registered && options.host)
1088
+ throw new Error(
1089
+ `[ssh] no connection is registered for '${options.user}@${options.host}' in ${USERS_CONF_PATH}; ` +
1090
+ `run: node bin ssh --user ${options.user} --host ${options.host} --user-add`,
1091
+ );
1092
+
1093
+ const connection =
1094
+ registered ||
1095
+ (process.env.DEFAULT_SSH_USER && process.env.DEFAULT_SSH_HOST && process.env.DEFAULT_SSH_KEY_PATH
1096
+ ? {
1097
+ user: process.env.DEFAULT_SSH_USER,
1098
+ host: process.env.DEFAULT_SSH_HOST,
1099
+ keyPath: process.env.DEFAULT_SSH_KEY_PATH,
1100
+ port: process.env.DEFAULT_SSH_PORT || DEFAULT_SSH_PORT,
1101
+ }
1102
+ : undefined);
1103
+
1104
+ if (!connection) {
1105
+ logger.warn(`No SSH credentials for '${options.user}'`, {
1106
+ host: options.host || '(unspecified)',
1107
+ registry: USERS_CONF_PATH,
1108
+ });
1109
+ return;
1110
+ }
1111
+ if (!registered) logger.warn(`Using the deploy environment DEFAULT_SSH_* values`, { requested: options.user });
1112
+
1113
+ Underpost.host.store.set('DEFAULT_SSH_USER', connection.user);
1114
+ Underpost.host.store.set('DEFAULT_SSH_HOST', connection.host);
1115
+ Underpost.host.store.set('DEFAULT_SSH_KEY_PATH', connection.keyPath);
1116
+ Underpost.host.store.set('DEFAULT_SSH_PORT', connection.port);
920
1117
  },
921
1118
 
922
1119
  /**
@@ -1073,4 +1270,6 @@ Subsystem sftp /usr/libexec/openssh/sftp-server
1073
1270
  };
1074
1271
  }
1075
1272
 
1273
+ export { connectionFactory, hostNameFactory, upsertConnection, userRecordFactory };
1274
+
1076
1275
  export default UnderpostSSH;