@underpostnet/cyberia 3.2.90 → 3.3.73

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 (490) hide show
  1. package/.dockerignore +1 -0
  2. package/.env.example +16 -0
  3. package/.github/workflows/coverall.cyberia.ci.yml +107 -0
  4. package/.github/workflows/cyberia-client.cd.yml +6 -18
  5. package/.github/workflows/cyberia-server.cd.yml +6 -18
  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 +6 -78
  13. package/.github/workflows/engine-cyberia.ci.yml +4 -5
  14. package/.github/workflows/ghpkg.ci.yml +42 -21
  15. package/.github/workflows/hardhat.ci.yml +12 -11
  16. package/.github/workflows/npmpkg.ci.yml +17 -15
  17. package/.github/workflows/pwa-microservices-template-page.cd.yml +1 -16
  18. package/.github/workflows/pwa-microservices-template-test.ci.yml +2 -2
  19. package/.github/workflows/release.cd.yml +2 -9
  20. package/.prettierignore +1 -0
  21. package/AGENTS.md +47 -0
  22. package/CHANGELOG.md +581 -2417
  23. package/CLI-HELP.md +416 -127
  24. package/Dockerfile +25 -9
  25. package/Dockerfile.dev +25 -9
  26. package/Dockerfile.test +4 -4
  27. package/README.md +21 -1
  28. package/bin/build.js +70 -43
  29. package/bin/build.template.js +8 -3
  30. package/bin/cyberia.js +372 -135
  31. package/bin/deploy.js +18 -104
  32. package/bin/index.js +372 -135
  33. package/compose.env +16 -0
  34. package/conf.js +93 -4
  35. package/deploy/cyberia-client/deploy.sh +47 -0
  36. package/deploy/cyberia-client/package.sh +18 -0
  37. package/deploy/cyberia-client/state.sh +14 -0
  38. package/deploy/cyberia-server/deploy.sh +47 -0
  39. package/deploy/cyberia-server/package.sh +18 -0
  40. package/deploy/cyberia-server/state.sh +14 -0
  41. package/deploy/dd-cyberia/deploy.sh +22 -0
  42. package/deploy/dd-cyberia/init.sh +55 -0
  43. package/deploy/dd-cyberia/package.sh +17 -0
  44. package/deploy/dd-cyberia/state.sh +14 -0
  45. package/deploy/dd-cyberia/sync-deploy.sh +116 -0
  46. package/deploy/lib/github-actions-logging.sh +594 -0
  47. package/deploy/lib/host.sh +185 -0
  48. package/deploy/lib/state.sh +62 -0
  49. package/deploy/pwa-microservices-template/deploy.sh +45 -0
  50. package/deploy/release/deploy.sh +32 -0
  51. package/deployment.yaml +7 -2
  52. package/docker-compose.yml +31 -37
  53. package/examples/static-page/README.md +19 -19
  54. package/examples/static-page/ssr-components/CustomPage.js +2 -2
  55. package/hardhat/hardhat.config.js +1 -6
  56. package/hardhat/package-lock.json +104 -43
  57. package/hardhat/package.json +3 -3
  58. package/manifests/alertmanager/deployment.yaml +81 -0
  59. package/manifests/blackbox-exporter/deployment.yaml +75 -0
  60. package/manifests/cronjobs/dd-cron/dd-cron-backup.yaml +36 -7
  61. package/manifests/cronjobs/dd-cron/dd-cron-vultr.yaml +77 -0
  62. package/manifests/deployment/dd-cyberia-development/deployment.yaml +7 -2
  63. package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
  64. package/manifests/grafana/deployment.yaml +26 -7
  65. package/manifests/grafana/kustomization.yaml +1 -0
  66. package/manifests/grafana/service.yaml +6 -1
  67. package/manifests/mariadb/pv.yaml +1 -0
  68. package/manifests/mongodb/statefulset.yaml +9 -2
  69. package/manifests/mysql/pv-pvc.yaml +14 -1
  70. package/manifests/postgresql/pv.yaml +12 -1
  71. package/manifests/postgresql/pvc.yaml +1 -0
  72. package/manifests/prometheus/deployment.yaml +18 -22
  73. package/package.json +83 -10
  74. package/scripts/audit-selinux.sh +64 -0
  75. package/scripts/event-monitor.sh +56 -0
  76. package/scripts/gpu-diag.sh +0 -0
  77. package/scripts/ip-info.sh +0 -0
  78. package/scripts/k3s-node-setup.sh +30 -14
  79. package/scripts/kubeadm-node-setup.sh +40 -31
  80. package/scripts/lxd-vm-setup.sh +0 -0
  81. package/scripts/maas-nat-firewalld.sh +0 -0
  82. package/scripts/maas-setup.sh +8 -8
  83. package/scripts/nat-iptables.sh +2 -0
  84. package/scripts/rhel-grpc-setup.sh +0 -0
  85. package/scripts/rocky-kickstart.sh +25 -9
  86. package/scripts/selinux-normalize.sh +907 -0
  87. package/scripts/shutdown-machine.sh +843 -0
  88. package/scripts/test-monitor.sh +3 -3
  89. package/src/api/atlas-sprite-sheet/atlas-sprite-sheet.controller.js +1 -1
  90. package/src/api/atlas-sprite-sheet/atlas-sprite-sheet.router.js +2 -2
  91. package/src/api/atlas-sprite-sheet/atlas-sprite-sheet.service.js +2 -2
  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 +1 -1
  99. package/src/api/cyberia-action/cyberia-action.service.js +2 -2
  100. package/src/api/cyberia-client-hints/cyberia-client-hints.controller.js +1 -1
  101. package/src/api/cyberia-client-hints/cyberia-client-hints.router.js +1 -1
  102. package/src/api/cyberia-client-hints/cyberia-client-hints.service.js +2 -2
  103. package/src/api/cyberia-dialogue/cyberia-dialogue.controller.js +1 -1
  104. package/src/api/cyberia-dialogue/cyberia-dialogue.router.js +1 -1
  105. package/src/api/cyberia-dialogue/cyberia-dialogue.service.js +2 -2
  106. package/src/api/cyberia-entity/cyberia-entity.controller.js +1 -1
  107. package/src/api/cyberia-entity/cyberia-entity.router.js +1 -1
  108. package/src/api/cyberia-entity/cyberia-entity.service.js +2 -2
  109. package/src/api/cyberia-entity-type-default/cyberia-entity-type-default.controller.js +1 -1
  110. package/src/api/cyberia-entity-type-default/cyberia-entity-type-default.router.js +1 -1
  111. package/src/api/cyberia-entity-type-default/cyberia-entity-type-default.service.js +2 -2
  112. package/src/api/cyberia-instance/cyberia-fallback-capture.js +466 -0
  113. package/src/api/cyberia-instance/cyberia-fallback-default-items.js +63 -0
  114. package/src/api/cyberia-instance/cyberia-fallback-world.js +16 -1
  115. package/src/api/cyberia-instance/cyberia-instance-map.service.js +1 -1
  116. package/src/api/cyberia-instance/cyberia-instance.controller.js +4 -1
  117. package/src/api/cyberia-instance/cyberia-instance.router.js +15 -2
  118. package/src/api/cyberia-instance/cyberia-instance.service.js +45 -2
  119. package/src/api/cyberia-instance-conf/cyberia-instance-conf.controller.js +1 -1
  120. package/src/api/cyberia-instance-conf/cyberia-instance-conf.model.js +1 -0
  121. package/src/api/cyberia-instance-conf/cyberia-instance-conf.router.js +1 -1
  122. package/src/api/cyberia-instance-conf/cyberia-instance-conf.service.js +2 -2
  123. package/src/api/cyberia-map/cyberia-map.controller.js +1 -1
  124. package/src/api/cyberia-map/cyberia-map.router.js +1 -1
  125. package/src/api/cyberia-map/cyberia-map.service.js +2 -2
  126. package/src/api/cyberia-quest/cyberia-quest.controller.js +1 -1
  127. package/src/api/cyberia-quest/cyberia-quest.router.js +1 -1
  128. package/src/api/cyberia-quest/cyberia-quest.service.js +2 -2
  129. package/src/api/cyberia-quest-progress/cyberia-quest-progress.controller.js +1 -1
  130. package/src/api/cyberia-quest-progress/cyberia-quest-progress.router.js +1 -1
  131. package/src/api/cyberia-quest-progress/cyberia-quest-progress.service.js +2 -2
  132. package/src/api/cyberia-saga/cyberia-saga.controller.js +1 -1
  133. package/src/api/cyberia-saga/cyberia-saga.router.js +1 -1
  134. package/src/api/cyberia-saga/cyberia-saga.service.js +2 -2
  135. package/src/api/cyberia-server-defaults/cyberia-server-defaults.js +16 -29
  136. package/src/api/cyberia-skill/cyberia-skill.controller.js +1 -1
  137. package/src/api/cyberia-skill/cyberia-skill.router.js +1 -1
  138. package/src/api/cyberia-skill/cyberia-skill.service.js +2 -2
  139. package/src/api/default/default.controller.js +1 -1
  140. package/src/api/default/default.router.js +1 -1
  141. package/src/api/default/default.service.js +2 -2
  142. package/src/api/document/document.controller.js +1 -1
  143. package/src/api/document/document.router.js +1 -1
  144. package/src/api/document/document.service.js +2 -2
  145. package/src/api/file/file.controller.js +1 -1
  146. package/src/api/file/file.router.js +1 -1
  147. package/src/api/file/file.service.js +2 -2
  148. package/src/api/instance/instance.controller.js +1 -1
  149. package/src/api/instance/instance.router.js +1 -1
  150. package/src/api/instance/instance.service.js +2 -2
  151. package/src/api/ipfs/ipfs.controller.js +1 -1
  152. package/src/api/ipfs/ipfs.router.js +2 -2
  153. package/src/api/ipfs/ipfs.service.js +2 -2
  154. package/src/api/object-layer/object-layer.controller.js +1 -1
  155. package/src/api/object-layer/object-layer.model.js +204 -3
  156. package/src/api/object-layer/object-layer.router.js +2 -2
  157. package/src/api/object-layer/object-layer.service.js +4 -17
  158. package/src/api/object-layer-render-frames/object-layer-render-frames.controller.js +1 -1
  159. package/src/api/object-layer-render-frames/object-layer-render-frames.router.js +1 -1
  160. package/src/api/object-layer-render-frames/object-layer-render-frames.service.js +2 -2
  161. package/src/api/test/test.controller.js +1 -1
  162. package/src/api/test/test.service.js +1 -1
  163. package/src/api/user/guest.service.js +3 -3
  164. package/src/api/user/user.controller.js +1 -1
  165. package/src/api/user/user.router.js +2 -2
  166. package/src/api/user/user.service.js +4 -4
  167. package/src/api.js +5 -9
  168. package/src/cli/app.js +391 -0
  169. package/src/cli/baremetal.js +30 -36
  170. package/src/cli/client.js +306 -0
  171. package/src/cli/cloud-init.js +3 -3
  172. package/src/cli/cluster.js +242 -135
  173. package/src/cli/db.js +126 -67
  174. package/src/cli/deploy.js +158 -60
  175. package/src/cli/docker-compose.js +5 -5
  176. package/src/cli/domains.js +184 -0
  177. package/src/cli/dotenv-store.js +143 -0
  178. package/src/cli/event.js +2281 -0
  179. package/src/cli/fs.js +3 -3
  180. package/src/cli/host.js +672 -0
  181. package/src/cli/image.js +4 -7
  182. package/src/cli/index.js +426 -134
  183. package/src/cli/ipfs.js +34 -30
  184. package/src/cli/kickstart.js +1 -1
  185. package/src/cli/kubectl.js +218 -41
  186. package/src/cli/lxd.js +3 -3
  187. package/src/cli/monitor.js +1359 -26
  188. package/src/cli/package.js +90 -0
  189. package/src/cli/release.js +73 -34
  190. package/src/cli/repository.js +564 -367
  191. package/src/cli/run.js +362 -654
  192. package/src/cli/secrets.js +2158 -964
  193. package/src/cli/ssh.js +651 -331
  194. package/src/cli/state.js +394 -0
  195. package/src/cli/static.js +2 -2
  196. package/src/cli/system.js +26 -13
  197. package/src/cli/test.js +199 -125
  198. package/src/cli/vultr.js +623 -0
  199. package/src/cli/wireguard.js +2930 -0
  200. package/src/client/components/core/CalendarCore.js +53 -96
  201. package/src/client/components/core/Docs.js +258 -211
  202. package/src/client/components/core/FileExplorer.js +0 -21
  203. package/src/client/components/core/PanelForm.js +4 -4
  204. package/src/client/components/core/Repository.js +56 -0
  205. package/src/client/components/core/Translate.js +7 -0
  206. package/src/client/components/core/{FullScreen.js → ViewModeController.js} +42 -39
  207. package/src/client/components/core/Wallet.js +0 -11
  208. package/src/client/components/core/Worker.js +7 -1
  209. package/src/client/components/cryptokoyn/SettingsCryptokoyn.js +2 -2
  210. package/src/client/components/cyberia/FallbackWorldEngineCyberia.js +368 -0
  211. package/src/client/components/cyberia-portal/AppShellCyberiaPortal.js +35 -1
  212. package/src/client/components/cyberia-portal/RouterCyberiaPortal.js +4 -0
  213. package/src/client/components/cyberia-portal/SettingsCyberiaPortal.js +2 -2
  214. package/src/client/components/cyberia-portal/TranslateCyberiaPortal.js +4 -0
  215. package/src/client/components/default/AppShellDefault.js +1 -1
  216. package/src/client/components/default/SettingsDefault.js +2 -2
  217. package/src/client/components/itemledger/SettingsItemledger.js +2 -2
  218. package/src/client/components/underpost/AppShellUnderpost.js +3 -2
  219. package/src/client/components/underpost/SettingsUnderpost.js +2 -2
  220. package/src/client/public/cyberia-docs/ARCHITECTURE.md +17 -3
  221. package/src/client/public/cyberia-docs/CYBERIA-CLI.md +32 -5
  222. package/src/client/public/cyberia-docs/CYBERIA-SERVER.md +1 -1
  223. package/src/client/public/cyberia-docs/CYBERIA.md +21 -1
  224. package/src/client/public/cyberia-docs/HARDHAT-MODULE.md +23 -6
  225. package/src/client/public/cyberia-docs/ROADMAP.md +1 -1
  226. package/src/client/public/cyberia-docs/WHITE-PAPER.md +1 -1
  227. package/src/client/public/default/android-chrome-144x144.png +0 -0
  228. package/src/client/public/default/android-chrome-192x192.png +0 -0
  229. package/src/client/public/default/android-chrome-256x256.png +0 -0
  230. package/src/client/public/default/android-chrome-36x36.png +0 -0
  231. package/src/client/public/default/android-chrome-384x384.png +0 -0
  232. package/src/client/public/default/android-chrome-48x48.png +0 -0
  233. package/src/client/public/default/android-chrome-512x512.png +0 -0
  234. package/src/client/public/default/android-chrome-72x72.png +0 -0
  235. package/src/client/public/default/android-chrome-96x96.png +0 -0
  236. package/src/client/public/default/apple-touch-icon-1024x1024.png +0 -0
  237. package/src/client/public/default/apple-touch-icon-114x114.png +0 -0
  238. package/src/client/public/default/apple-touch-icon-120x120.png +0 -0
  239. package/src/client/public/default/apple-touch-icon-144x144.png +0 -0
  240. package/src/client/public/default/apple-touch-icon-152x152.png +0 -0
  241. package/src/client/public/default/apple-touch-icon-167x167.png +0 -0
  242. package/src/client/public/default/apple-touch-icon-180x180.png +0 -0
  243. package/src/client/public/default/apple-touch-icon-57x57.png +0 -0
  244. package/src/client/public/default/apple-touch-icon-60x60.png +0 -0
  245. package/src/client/public/default/apple-touch-icon-72x72.png +0 -0
  246. package/src/client/public/default/apple-touch-icon-76x76.png +0 -0
  247. package/src/client/public/default/apple-touch-icon-precomposed.png +0 -0
  248. package/src/client/public/default/apple-touch-icon.png +0 -0
  249. package/src/client/public/default/apple-touch-startup-image-1125x2436.png +0 -0
  250. package/src/client/public/default/apple-touch-startup-image-1136x640.png +0 -0
  251. package/src/client/public/default/apple-touch-startup-image-1170x2532.png +0 -0
  252. package/src/client/public/default/apple-touch-startup-image-1179x2556.png +0 -0
  253. package/src/client/public/default/apple-touch-startup-image-1242x2208.png +0 -0
  254. package/src/client/public/default/apple-touch-startup-image-1242x2688.png +0 -0
  255. package/src/client/public/default/apple-touch-startup-image-1284x2778.png +0 -0
  256. package/src/client/public/default/apple-touch-startup-image-1290x2796.png +0 -0
  257. package/src/client/public/default/apple-touch-startup-image-1334x750.png +0 -0
  258. package/src/client/public/default/apple-touch-startup-image-1488x2266.png +0 -0
  259. package/src/client/public/default/apple-touch-startup-image-1536x2048.png +0 -0
  260. package/src/client/public/default/apple-touch-startup-image-1620x2160.png +0 -0
  261. package/src/client/public/default/apple-touch-startup-image-1640x2160.png +0 -0
  262. package/src/client/public/default/apple-touch-startup-image-1668x2224.png +0 -0
  263. package/src/client/public/default/apple-touch-startup-image-1668x2388.png +0 -0
  264. package/src/client/public/default/apple-touch-startup-image-1792x828.png +0 -0
  265. package/src/client/public/default/apple-touch-startup-image-2048x1536.png +0 -0
  266. package/src/client/public/default/apple-touch-startup-image-2048x2732.png +0 -0
  267. package/src/client/public/default/apple-touch-startup-image-2160x1620.png +0 -0
  268. package/src/client/public/default/apple-touch-startup-image-2160x1640.png +0 -0
  269. package/src/client/public/default/apple-touch-startup-image-2208x1242.png +0 -0
  270. package/src/client/public/default/apple-touch-startup-image-2224x1668.png +0 -0
  271. package/src/client/public/default/apple-touch-startup-image-2266x1488.png +0 -0
  272. package/src/client/public/default/apple-touch-startup-image-2388x1668.png +0 -0
  273. package/src/client/public/default/apple-touch-startup-image-2436x1125.png +0 -0
  274. package/src/client/public/default/apple-touch-startup-image-2532x1170.png +0 -0
  275. package/src/client/public/default/apple-touch-startup-image-2556x1179.png +0 -0
  276. package/src/client/public/default/apple-touch-startup-image-2688x1242.png +0 -0
  277. package/src/client/public/default/apple-touch-startup-image-2732x2048.png +0 -0
  278. package/src/client/public/default/apple-touch-startup-image-2778x1284.png +0 -0
  279. package/src/client/public/default/apple-touch-startup-image-2796x1290.png +0 -0
  280. package/src/client/public/default/apple-touch-startup-image-640x1136.png +0 -0
  281. package/src/client/public/default/apple-touch-startup-image-750x1334.png +0 -0
  282. package/src/client/public/default/apple-touch-startup-image-828x1792.png +0 -0
  283. package/src/client/public/default/assets/background/white.jpg +0 -0
  284. package/src/client/public/default/browserconfig.xml +1 -1
  285. package/src/client/public/default/favicon-16x16.png +0 -0
  286. package/src/client/public/default/favicon-32x32.png +0 -0
  287. package/src/client/public/default/favicon-48x48.png +0 -0
  288. package/src/client/public/default/favicon.ico +0 -0
  289. package/src/client/public/default/manifest.webmanifest +5 -5
  290. package/src/client/public/default/mstile-144x144.png +0 -0
  291. package/src/client/public/default/mstile-150x150.png +0 -0
  292. package/src/client/public/default/mstile-310x150.png +0 -0
  293. package/src/client/public/default/mstile-310x310.png +0 -0
  294. package/src/client/public/default/mstile-70x70.png +0 -0
  295. package/src/client/public/default/yandex-browser-50x50.png +0 -0
  296. package/src/client/public/default/yandex-browser-manifest.json +1 -1
  297. package/src/client/services/cyberia-instance/cyberia-instance.service.js +40 -0
  298. package/src/client/services/object-layer/object-layer.management.js +4 -4
  299. package/src/client/ssr/RootDocument.js +1 -1
  300. package/src/client/ssr/body/404.js +1 -1
  301. package/src/client/ssr/body/500.js +1 -1
  302. package/src/client/ssr/body/CacheControl.js +1 -1
  303. package/src/client/ssr/body/CyberiaDefaultSplashScreen.js +1 -1
  304. package/src/client/ssr/body/DefaultSplashScreen.js +1 -1
  305. package/src/client/ssr/body/SwaggerDarkMode.js +1 -1
  306. package/src/client/ssr/body/UnderpostDefaultSplashScreen.js +1 -1
  307. package/src/client/ssr/head/CryptokoynScripts.js +1 -1
  308. package/src/client/ssr/head/Css.js +1 -1
  309. package/src/client/ssr/head/CyberiaPortalScripts.js +1 -1
  310. package/src/client/ssr/head/DefaultScripts.js +1 -1
  311. package/src/client/ssr/head/ItemledgerScripts.js +1 -1
  312. package/src/client/ssr/head/Microdata.js +1 -1
  313. package/src/client/ssr/head/Production.js +1 -1
  314. package/src/client/ssr/head/Pwa.js +1 -1
  315. package/src/client/ssr/head/PwaItemledger.js +1 -1
  316. package/src/client/ssr/head/Seo.js +1 -1
  317. package/src/client/ssr/head/UnderpostScripts.js +1 -1
  318. package/src/client/ssr/mailer/DefaultRecoverEmail.js +1 -1
  319. package/src/client/ssr/mailer/DefaultVerifyEmail.js +1 -1
  320. package/src/client/ssr/views/Cyberia404.js +7 -5
  321. package/src/client/ssr/views/CyberiaServerMetrics.js +1 -1
  322. package/src/client/ssr/views/Maintenance.js +1 -1
  323. package/src/client/ssr/views/NoNetworkConnection.js +1 -1
  324. package/src/client/ssr/views/Test.js +1 -1
  325. package/src/client/sw/core.sw.js +20 -14
  326. package/src/client-builder/client-build-docs.js +61 -46
  327. package/src/client-builder/client-build-live.js +2 -2
  328. package/src/client-builder/client-build.js +58 -44
  329. package/src/client-builder/client-dev-server.js +3 -3
  330. package/src/client-builder/client-icons.js +2 -2
  331. package/src/client-builder/ssr.js +3 -3
  332. package/src/client.build.js +3 -6
  333. package/src/client.dev.js +3 -6
  334. package/src/db/DataBaseProvider.js +186 -3
  335. package/src/db/mariadb/MariaDB.js +3 -2
  336. package/src/db/mongo/MongoBootstrap.js +273 -101
  337. package/src/db/mongo/MongooseDB.js +11 -8
  338. package/src/{server/valkey.js → db/valkey/Valkey.js} +5 -4
  339. package/src/grpc/cyberia/grpc-server.js +1 -1
  340. package/src/index.js +103 -23
  341. package/src/mailer/EmailRender.js +2 -2
  342. package/src/mailer/MailerInterceptor.js +87 -0
  343. package/src/mailer/MailerProvider.js +50 -6
  344. package/src/projects/cyberia/atlas-sprite-sheet-generator.js +1 -1
  345. package/src/projects/cyberia/besu-genesis-generator.js +3 -3
  346. package/src/projects/cyberia/catalog-cyberia.js +54 -6
  347. package/src/projects/cyberia/gemini-client.js +1 -1
  348. package/src/projects/cyberia/generate-saga.js +15 -24
  349. package/src/projects/cyberia/hot-reload-trigger.js +1 -1
  350. package/src/projects/cyberia/instance-data.js +22 -3
  351. package/src/projects/cyberia/ipfs-client.js +4 -4
  352. package/src/projects/cyberia/map-preview-generator.js +1 -1
  353. package/src/projects/cyberia/object-layer.js +12 -22
  354. package/src/projects/cyberia/semantic-layer-generator.js +1 -1
  355. package/src/projects/underpost/catalog-underpost.js +13 -2
  356. package/src/proxy.js +4 -7
  357. package/src/runtime/cyberia-client/Dockerfile +9 -4
  358. package/src/runtime/cyberia-client/Dockerfile.dev +8 -2
  359. package/src/runtime/cyberia-server/Dockerfile +9 -4
  360. package/src/runtime/cyberia-server/Dockerfile.dev +8 -2
  361. package/src/runtime/engine-cyberia/Dockerfile +25 -9
  362. package/src/runtime/engine-cyberia/Dockerfile.dev +25 -9
  363. package/src/runtime/engine-cyberia/Dockerfile.test +4 -4
  364. package/src/runtime/engine-cyberia/compose.env +16 -0
  365. package/src/runtime/engine-cyberia/docker-compose.yml +31 -37
  366. package/src/runtime/express/Express.js +32 -27
  367. package/src/runtime/lampp/Lampp.js +22 -70
  368. package/src/runtime/nginx/Nginx.js +1 -1
  369. package/src/runtime/wp/Wp.js +16 -48
  370. package/src/server/{catalog.js → build/catalog.js} +15 -5
  371. package/src/server/build/coverage.js +117 -0
  372. package/src/server/build/execution.js +402 -0
  373. package/src/server/build/package.js +465 -0
  374. package/src/server/build/testing.js +528 -0
  375. package/src/server/{dns.js → network/dns.js} +241 -14
  376. package/src/server/network/forward-proxy.js +541 -0
  377. package/src/server/{middlewares.js → network/middlewares.js} +59 -4
  378. package/src/server/network/node-capability.js +98 -0
  379. package/src/server/{peer.js → network/peer.js} +3 -3
  380. package/src/server/{proxy.js → network/proxy.js} +7 -13
  381. package/src/server/network/router.js +356 -0
  382. package/src/server/{tls.js → network/tls.js} +2 -2
  383. package/src/server/network/underpost-compression.js +186 -0
  384. package/src/server/{underpost-gateway.js → network/underpost-gateway.js} +26 -16
  385. package/src/server/{underpost-ingress.js → network/underpost-ingress.js} +19 -3
  386. package/src/server/{cri.js → ops/cri.js} +2 -2
  387. package/src/server/ops/cron.js +949 -0
  388. package/src/server/ops/event-notification.js +284 -0
  389. package/src/server/{logger.js → ops/logger.js} +108 -23
  390. package/src/server/ops/monitoring.js +1724 -0
  391. package/src/server/ops/systemd.js +259 -0
  392. package/src/server/{conf.js → runtime/conf.js} +521 -520
  393. package/src/server/runtime/config-scope.js +215 -0
  394. package/src/server/runtime/environment.js +149 -0
  395. package/src/server/{process.js → runtime/process.js} +153 -13
  396. package/src/server/{runtime-status.js → runtime/runtime-status.js} +155 -16
  397. package/src/server/{runtime.js → runtime/runtime.js} +12 -8
  398. package/src/server/{start.js → runtime/start.js} +128 -39
  399. package/src/server/{auth.js → security/auth.js} +7 -8
  400. package/src/server/security/container-storage.js +382 -0
  401. package/src/server/{crypto.js → security/crypto.js} +1 -1
  402. package/src/server/security/selinux.js +185 -0
  403. package/src/server/{backup.js → storage/backup.js} +17 -8
  404. package/src/server/{data-query.js → storage/data-query.js} +1 -1
  405. package/src/server/{downloader.js → storage/downloader.js} +2 -2
  406. package/src/server/storage/repository.js +68 -0
  407. package/src/server.js +4 -7
  408. package/src/ws/IoInterface.js +1 -1
  409. package/src/ws/IoServer.js +1 -1
  410. package/src/ws/core/core.ws.connection.js +1 -1
  411. package/src/ws/core/core.ws.emit.js +1 -1
  412. package/src/ws/core/core.ws.server.js +1 -1
  413. package/src/ws/default/default.ws.connection.js +1 -1
  414. package/src/ws/default/default.ws.emit.js +1 -1
  415. package/src/ws/default/default.ws.server.js +1 -1
  416. package/test/e2e/event-e2e-public-ingress-down.js +45 -0
  417. package/test/e2e/event-e2e-wireguard-server-down.js +38 -0
  418. package/test/e2e/event-e2e-wireguard-spoke-down.js +40 -0
  419. package/test/{api.test.js → integration/app/api.test.js} +33 -14
  420. package/test/integration/app/cyberia/cyberia-cli-plain-reads.test.js +30 -0
  421. package/test/{cyberia-instance-conf-defaults.test.js → integration/app/cyberia/cyberia-instance-conf-defaults.test.js} +2 -1
  422. package/test/integration/app/cyberia/cyberia-load.test.js +467 -0
  423. package/test/integration/app/cyberia/fallback-world-capture.test.js +253 -0
  424. package/test/integration/app/cyberia/object-layer-item-id.test.js +327 -0
  425. package/test/{shape-generator.test.js → integration/app/cyberia/shape-generator.test.js} +1 -1
  426. package/test/integration/infra/1-security/config-scope.test.js +305 -0
  427. package/test/integration/infra/1-security/container-storage.test.js +356 -0
  428. package/test/integration/infra/1-security/in-pod-cli-surface.test.js +86 -0
  429. package/test/integration/infra/1-security/secret-onboarding.test.js +930 -0
  430. package/test/integration/infra/1-security/selinux.test.js +69 -0
  431. package/test/{sops-secret-store.test.js → integration/infra/1-security/sops-secret-store.test.js} +415 -61
  432. package/test/integration/infra/1-security/systemd-service.test.js +70 -0
  433. package/test/integration/infra/1-security/underpost-config-secret.test.js +367 -0
  434. package/test/integration/infra/2-network/dns-firewall.test.js +566 -0
  435. package/test/integration/infra/2-network/node-capability.test.js +182 -0
  436. package/test/integration/infra/2-network/wireguard-cli.test.js +1163 -0
  437. package/test/integration/infra/2-network/wireguard-edge.test.js +1731 -0
  438. package/test/{cluster-instances.test.js → integration/infra/3-cluster/cluster-instances.test.js} +11 -15
  439. package/test/{deploy-node-placement.test.js → integration/infra/3-cluster/deploy-node-placement.test.js} +3 -3
  440. package/test/integration/infra/3-cluster/docker-compose-stack.test.js +313 -0
  441. package/test/integration/infra/4-ingress/deploy-routes.test.js +379 -0
  442. package/test/integration/infra/4-ingress/gateway-static-assets.test.js +453 -0
  443. package/test/{instance-traffic-plan.test.js → integration/infra/4-ingress/instance-traffic-plan.test.js} +10 -6
  444. package/test/{underpost-gateway.test.js → integration/infra/4-ingress/underpost-gateway.test.js} +47 -6
  445. package/test/{underpost-ingress.test.js → integration/infra/4-ingress/underpost-ingress.test.js} +56 -4
  446. package/test/integration/infra/5-observability/cron-jobs.test.js +652 -0
  447. package/test/{deploy-monitor.test.js → integration/infra/5-observability/deploy-monitor.test.js} +29 -31
  448. package/test/integration/infra/5-observability/event-notification.test.js +197 -0
  449. package/test/integration/infra/5-observability/event-remediation.test.js +502 -0
  450. package/test/integration/infra/5-observability/event-targets.test.js +1174 -0
  451. package/test/integration/infra/5-observability/monitoring-stack.test.js +902 -0
  452. package/test/support/shell-harness.js +73 -0
  453. package/test/unit/build-template.test.js +97 -0
  454. package/test/unit/build-workflow-offline.test.js +132 -0
  455. package/test/unit/catalog.test.js +71 -0
  456. package/test/unit/client-build-docs.test.js +334 -0
  457. package/test/unit/conf-loading.test.js +303 -0
  458. package/test/unit/conf-resolution.test.js +979 -0
  459. package/test/unit/coverage-artifact.test.js +130 -0
  460. package/test/{crypto.test.js → unit/crypto.test.js} +1 -1
  461. package/test/unit/deploy-env-resolution.test.js +38 -0
  462. package/test/unit/deploy-log-table.test.js +447 -0
  463. package/test/unit/execution-profiles.test.js +219 -0
  464. package/test/unit/fleet-sync-source.test.js +149 -0
  465. package/test/unit/logger-redaction.test.js +112 -0
  466. package/test/unit/package.test.js +403 -0
  467. package/test/unit/prepare-host.test.js +299 -0
  468. package/test/unit/process-environment.test.js +18 -0
  469. package/test/unit/propagation-message.test.js +78 -0
  470. package/test/unit/redeploy-plan.test.js +24 -0
  471. package/test/unit/release-bump.test.js +474 -0
  472. package/test/unit/ssh-output-redaction.test.js +28 -0
  473. package/test/unit/start-options.test.js +46 -0
  474. package/test/unit/test-tiers.test.js +193 -0
  475. package/vitest.config.js +58 -0
  476. package/.nycrc +0 -9
  477. package/manifests/cronjobs/dd-cron/dd-cron-dns.yaml +0 -48
  478. package/manifests/mongodb/pv-pvc.yaml +0 -59
  479. package/scripts/link-local-underpost-cli.sh +0 -6
  480. package/src/api/user/user.build.js +0 -16
  481. package/src/cli/env.js +0 -177
  482. package/src/client/components/core/Blockchain.js +0 -41
  483. package/src/client/components/core/Blog.js +0 -9
  484. package/src/client/components/core/KeyboardAvoidance.js +0 -145
  485. package/src/client/public/default/assets/background/dark.jpg +0 -0
  486. package/src/client/public/default/assets/background/white0-min.jpg +0 -0
  487. package/src/client/public/default/assets/background/white0.jpg +0 -0
  488. package/src/client/public/doc/favicon.ico +0 -0
  489. package/src/client/public/doc/sitemap +0 -148
  490. package/src/server/cron.js +0 -476
package/src/cli/ssh.js CHANGED
@@ -5,14 +5,146 @@
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
+ import {
12
+ runSELinuxCommands,
13
+ selinuxPackagesCommandFactory,
14
+ selinuxRestoreconCommandFactory,
15
+ selinuxSshContextCommandsFactory,
16
+ selinuxSshPortCommandsFactory,
17
+ shellArgumentFactory,
18
+ } from '../server/security/selinux.js';
19
+ import ContainerStorageService from '../server/security/container-storage.js';
11
20
  import fs from 'fs-extra';
12
21
  import Underpost from '../index.js';
13
22
 
14
23
  const logger = loggerFactory(import.meta);
15
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
+
16
148
  /**
17
149
  * @class UnderpostSSH
18
150
  * @description Manages SSH key generation and connection setup.
@@ -21,33 +153,103 @@ const logger = loggerFactory(import.meta);
21
153
  class UnderpostSSH {
22
154
  static API = {
23
155
  /**
24
- * Loads configuration node from disk or returns default empty config.
156
+ * Path of the cluster users registry.
25
157
  * @method
26
- * @function loadConfigNode
158
+ * @function usersConfPath
27
159
  * @memberof UnderpostSSH
28
- * @param {string} deployId - Deployment ID for the config path
29
- * @returns {{confNode: Object, confNodePath: string}} Configuration node and its file path
30
- * @description Loads or creates a config node with users object structure
160
+ * @returns {string} Path to the users configuration file
31
161
  */
32
- loadConfigNode: (deployId) => {
33
- const confNodePath = `./engine-private/conf/${deployId}/conf.node.json`;
34
- const confNode = fs.existsSync(confNodePath) ? JSON.parse(fs.readFileSync(confNodePath, 'utf8')) : { users: {} };
35
- return { confNode, confNodePath };
36
- },
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),
37
185
 
38
186
  /**
39
- * Saves configuration node to disk.
187
+ * Saves the cluster users registry to disk.
40
188
  * @method
41
- * @function saveConfigNode
189
+ * @function saveUsers
42
190
  * @memberof UnderpostSSH
43
- * @param {string} confNodePath - Path to the configuration file
44
- * @param {Object} confNode - Configuration object to save
191
+ * @param {Array<Object>} users - User records to persist
45
192
  * @returns {void}
46
193
  */
47
- saveConfigNode: (confNodePath, confNode) => {
48
- 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;
49
241
  },
50
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
+
51
253
  /**
52
254
  * Checks if a system user exists.
53
255
  * @method
@@ -57,7 +259,7 @@ class UnderpostSSH {
57
259
  * @returns {boolean} True if user exists, false otherwise
58
260
  */
59
261
  checkUserExists: (username) => {
60
- const result = shellExec(`id -u ${username} 2>/dev/null || echo "not_found"`, {
262
+ const result = shellExec(`id -u ${shellArgumentFactory(username)} 2>/dev/null || echo "not_found"`, {
61
263
  silent: true,
62
264
  stdout: true,
63
265
  }).trim();
@@ -73,7 +275,7 @@ class UnderpostSSH {
73
275
  * @returns {string} User's home directory path
74
276
  */
75
277
  getUserHome: (username) => {
76
- return shellExec(`getent passwd ${username} | cut -d: -f6`, {
278
+ return shellExec(`getent passwd ${shellArgumentFactory(username)} | cut -d: -f6`, {
77
279
  silent: true,
78
280
  stdout: true,
79
281
  }).trim();
@@ -90,11 +292,13 @@ class UnderpostSSH {
90
292
  * @returns {void}
91
293
  */
92
294
  createSystemUser: (username, password, groups) => {
93
- shellExec(`useradd -m -s /bin/bash ${username}`);
94
- shellExec(`echo "${username}:${password}" | chpasswd`);
295
+ if (!/^[a-z_][a-z0-9_-]*[$]?$/i.test(username)) throw new TypeError('Invalid system username');
296
+ shellExec(`useradd -m -s /bin/bash ${shellArgumentFactory(username)}`);
297
+ shellExec(`printf '%s\n' ${shellArgumentFactory(`${username}:${password}`)} | chpasswd`, { disableLog: true });
95
298
  if (groups) {
96
299
  for (const group of groups.split(',').map((g) => g.trim())) {
97
- shellExec(`usermod -aG "${group}" "${username}"`);
300
+ if (!/^[a-z_][a-z0-9_-]*[$]?$/i.test(group)) throw new TypeError('Invalid system group');
301
+ shellExec(`usermod -aG ${shellArgumentFactory(group)} ${shellArgumentFactory(username)}`);
98
302
  }
99
303
  }
100
304
  },
@@ -109,8 +313,8 @@ class UnderpostSSH {
109
313
  */
110
314
  ensureSSHDirectory: (sshDir) => {
111
315
  if (!fs.existsSync(sshDir)) {
112
- shellExec(`mkdir -p ${sshDir}`);
113
- shellExec(`chmod 700 ${sshDir}`);
316
+ shellExec(`mkdir -p ${shellArgumentFactory(sshDir)}`);
317
+ shellExec(`chmod 700 ${shellArgumentFactory(sshDir)}`);
114
318
  }
115
319
  },
116
320
 
@@ -126,11 +330,12 @@ class UnderpostSSH {
126
330
  * @returns {void}
127
331
  */
128
332
  setSSHFilePermissions: (sshDir, username, keyPath, pubKeyPath) => {
129
- shellExec(`chmod 600 ${sshDir}/authorized_keys`);
130
- shellExec(`chmod 644 ${sshDir}/known_hosts`);
131
- if (keyPath) shellExec(`chmod 600 ${keyPath}`);
132
- if (pubKeyPath) shellExec(`chmod 644 ${pubKeyPath}`);
133
- shellExec(`chown -R ${username}:${username} ${sshDir}`);
333
+ shellExec(`chmod 600 ${shellArgumentFactory(`${sshDir}/authorized_keys`)}`);
334
+ shellExec(`chmod 644 ${shellArgumentFactory(`${sshDir}/known_hosts`)}`);
335
+ if (keyPath) shellExec(`chmod 600 ${shellArgumentFactory(keyPath)}`);
336
+ if (pubKeyPath) shellExec(`chmod 644 ${shellArgumentFactory(pubKeyPath)}`);
337
+ shellExec(`chown -R ${shellArgumentFactory(`${username}:${username}`)} ${shellArgumentFactory(sshDir)}`);
338
+ runSELinuxCommands(selinuxSshContextCommandsFactory({ sshDirectory: sshDir }), { execute: shellExec });
134
339
  },
135
340
 
136
341
  /**
@@ -140,17 +345,16 @@ class UnderpostSSH {
140
345
  * @memberof UnderpostSSH
141
346
  * @param {string} sshDir - SSH directory path
142
347
  * @param {string} pubKeyPath - Public key file path
143
- * @param {boolean} disablePassword - Whether to add no-forwarding restrictions
348
+ * @param {boolean} restrictForwarding - Whether to add no-forwarding restrictions
144
349
  * @returns {void}
145
350
  */
146
- configureAuthorizedKeys: (sshDir, pubKeyPath, disablePassword) => {
147
- if (disablePassword) {
148
- shellExec(`cat >> ${sshDir}/authorized_keys <<EOF
149
- no-port-forwarding,no-X11-forwarding,no-agent-forwarding ${fs.readFileSync(pubKeyPath, 'utf8')}
150
- EOF`);
151
- } else {
152
- shellExec(`cat ${pubKeyPath} >> ${sshDir}/authorized_keys`);
153
- }
351
+ configureAuthorizedKeys: (sshDir, pubKeyPath, restrictForwarding) => {
352
+ const key = fs.readFileSync(pubKeyPath, 'utf8').trim();
353
+ const entry = restrictForwarding ? `no-port-forwarding,no-X11-forwarding,no-agent-forwarding ${key}` : key;
354
+ const authorizedKeysPath = `${sshDir}/authorized_keys`;
355
+ shellExec(
356
+ `grep -qxF ${shellArgumentFactory(entry)} ${shellArgumentFactory(authorizedKeysPath)} || printf '%s\n' ${shellArgumentFactory(entry)} >> ${shellArgumentFactory(authorizedKeysPath)}`,
357
+ );
154
358
  },
155
359
 
156
360
  /**
@@ -164,27 +368,94 @@ EOF`);
164
368
  * @returns {void}
165
369
  */
166
370
  configureKnownHosts: (sshDir, port, host) => {
167
- shellExec(`ssh-keyscan -p ${port} -H localhost >> ${sshDir}/known_hosts`);
168
- shellExec(`ssh-keyscan -p ${port} -H 127.0.0.1 >> ${sshDir}/known_hosts`);
169
- if (host) shellExec(`ssh-keyscan -p ${port} -H ${host} >> ${sshDir}/known_hosts`);
371
+ const sshPort = Number(port);
372
+ if (!Number.isInteger(sshPort) || sshPort < 1 || sshPort > 65535)
373
+ throw new RangeError('SSH port must be 1-65535');
374
+ const knownHostsPath = shellArgumentFactory(`${sshDir}/known_hosts`);
375
+ shellExec(`ssh-keyscan -p ${sshPort} -H localhost >> ${knownHostsPath}`);
376
+ shellExec(`ssh-keyscan -p ${sshPort} -H 127.0.0.1 >> ${knownHostsPath}`);
377
+ if (host) shellExec(`ssh-keyscan -p ${sshPort} -H ${shellArgumentFactory(host)} >> ${knownHostsPath}`);
170
378
  },
171
379
 
172
380
  /**
173
- * Configures sudoers for passwordless sudo or sets user password.
381
+ * Grants passwordless sudo to a key-only account, or sets the user password.
174
382
  * @method
175
383
  * @function configureSudoAccess
176
384
  * @memberof UnderpostSSH
177
385
  * @param {string} username - Username to configure
178
- * @param {string} password - User password
179
- * @param {boolean} disablePassword - Whether to enable passwordless sudo
386
+ * @param {string} password - User password; empty means key-only
180
387
  * @returns {void}
181
388
  */
182
- configureSudoAccess: (username, password, disablePassword) => {
183
- if (disablePassword) {
184
- shellExec(`echo '${username} ALL=(ALL) NOPASSWD: ALL' | sudo tee /etc/sudoers.d/90_${username}`);
389
+ configureSudoAccess: (username, password) => {
390
+ if (!password) {
391
+ if (!/^[a-z_][a-z0-9_-]*[$]?$/i.test(username)) throw new TypeError('Invalid sudo username');
392
+ const sudoersPath = `/etc/sudoers.d/90_${username}`;
393
+ const temporaryPath = `/tmp/underpost-sudoers-${process.pid}-${username}`;
394
+ const rule = `${username} ALL=(ALL) NOPASSWD: ALL`;
395
+ try {
396
+ shellExec(`printf '%s\n' ${shellArgumentFactory(rule)} > ${shellArgumentFactory(temporaryPath)}`);
397
+ shellExec(`sudo visudo -cf ${shellArgumentFactory(temporaryPath)}`);
398
+ shellExec(
399
+ `sudo install -m 0440 -o root -g root ${shellArgumentFactory(temporaryPath)} ${shellArgumentFactory(sudoersPath)}`,
400
+ );
401
+ runSELinuxCommands([selinuxRestoreconCommandFactory(sudoersPath, { recursive: false })], {
402
+ execute: shellExec,
403
+ });
404
+ } finally {
405
+ fs.removeSync(temporaryPath);
406
+ }
185
407
  } else {
186
- shellExec(`echo "${username}:${password}" | sudo chpasswd`);
408
+ shellExec(`printf '%s\n' ${shellArgumentFactory(`${username}:${password}`)} | sudo chpasswd`, {
409
+ disableLog: true,
410
+ });
411
+ }
412
+ },
413
+
414
+ /**
415
+ * Initializes the default SSH files and credentials for a local SSH service.
416
+ * @method
417
+ * @function initializeDefaultSshConfig
418
+ * @memberof UnderpostSSH
419
+ * @param {object} params - SSH configuration values.
420
+ * @param {string} params.user - SSH user name.
421
+ * @param {string} params.password - SSH key passphrase and user password; empty means key-only.
422
+ * @param {string} params.host - Host used in the SSH key comment.
423
+ * @param {number} params.port - SSH port.
424
+ * @param {string} [params.controllerPubKeyPath='./engine-private/deploy/id_rsa.pub'] - Preferred controller public key to authorize.
425
+ * @returns {void}
426
+ */
427
+ initializeDefaultSshConfig: ({
428
+ user,
429
+ password,
430
+ host,
431
+ port,
432
+ controllerPubKeyPath = './engine-private/deploy/id_rsa.pub',
433
+ }) => {
434
+ const sshDir = `${Underpost.ssh.getUserHome(user)}/.ssh`;
435
+ const keyPath = `${sshDir}/id_rsa`;
436
+ const pubKeyPath = `${sshDir}/id_rsa.pub`;
437
+
438
+ Underpost.ssh.ensureSSHDirectory(sshDir);
439
+ fs.ensureFileSync(`${sshDir}/authorized_keys`);
440
+ fs.ensureFileSync(`${sshDir}/known_hosts`);
441
+
442
+ if (!fs.existsSync(keyPath)) {
443
+ shellExec(
444
+ `ssh-keygen -t ed25519 -f ${shellArgumentFactory(keyPath)} -N ${shellArgumentFactory(password)} -q -C ${shellArgumentFactory(`${user}@${host}`)}`,
445
+ { disableLog: true },
446
+ );
187
447
  }
448
+ if (!fs.existsSync(pubKeyPath))
449
+ shellExec(
450
+ `ssh-keygen -y -f ${shellArgumentFactory(keyPath)} -P ${shellArgumentFactory(password)} > ${shellArgumentFactory(pubKeyPath)}`,
451
+ { disableLog: true },
452
+ );
453
+
454
+ const authorizedKeyPath = fs.existsSync(controllerPubKeyPath) ? controllerPubKeyPath : pubKeyPath;
455
+ Underpost.ssh.configureAuthorizedKeys(sshDir, authorizedKeyPath, !password);
456
+ Underpost.ssh.configureKnownHosts(sshDir, port, host);
457
+ Underpost.ssh.configureSudoAccess(user, password);
458
+ Underpost.ssh.setSSHFilePermissions(sshDir, user, keyPath, pubKeyPath);
188
459
  },
189
460
 
190
461
  /**
@@ -193,11 +464,10 @@ EOF`);
193
464
  * @function callback
194
465
  * @memberof UnderpostSSH
195
466
  * @param {Object} options - Configuration options for SSH operations
196
- * @param {string} [options.deployId=''] - Deployment ID context for SSH operations
197
467
  * @param {boolean} [options.generate=false] - Generate new SSH credentials
198
468
  * @param {string} [options.user=''] - SSH user name (defaults to 'root')
199
- * @param {string} [options.password=''] - SSH user password (auto-generated if not provided, overridden by saved config if user exists)
200
- * @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)
201
471
  * @param {string} [options.filter=''] - Filter for user/group listings
202
472
  * @param {string} [options.groups=''] - Comma-separated list of groups for the user (defaults to 'wheel')
203
473
  * @param {number} [options.port=22] - SSH port number
@@ -208,24 +478,23 @@ EOF`);
208
478
  * @param {boolean} [options.reset=false] - Reset SSH configuration (clear authorized_keys and known_hosts)
209
479
  * @param {boolean} [options.keysList=false] - List authorized SSH keys
210
480
  * @param {boolean} [options.hostsList=false] - List known SSH hosts
211
- * @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
212
482
  * @param {boolean} [options.keyTest=false] - Test SSH key generation
213
483
  * @param {boolean} [options.stop=false] - Stop SSH service
214
484
  * @param {boolean} [options.status=false] - Check SSH service status
215
- * @param {boolean} [options.connectUri=false] - Output SSH connection URI
216
- * @param {boolean} [options.copy=false] - Copy SSH connection URI to clipboard
217
485
  * @returns {Promise<void>}
218
486
  * @description
219
- * 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>`):
220
490
  * - User creation with automatic key generation and backup
221
491
  * - User removal with key cleanup
222
- * - Key import/export between SSH directory and private backup location
492
+ * - Key import/export between SSH directory and the key store
223
493
  * - SSH service initialization and hardening
224
494
  * - User and group listing with optional filtering
225
495
  */
226
496
  callback: async (
227
497
  options = {
228
- deployId: '',
229
498
  generate: false,
230
499
  user: '',
231
500
  password: '',
@@ -244,62 +513,49 @@ EOF`);
244
513
  keyTest: false,
245
514
  stop: false,
246
515
  status: false,
247
- connectUri: false,
248
- copy: false,
249
516
  },
250
517
  ) => {
251
- let confNode, confNodePath;
252
-
253
- // Set defaults
254
518
  if (!options.user) options.user = 'root';
255
- if (!options.host) options.host = await Underpost.dns.getPublicIp();
256
- if (!options.password) options.password = options.disablePassword ? '' : generateRandomPasswordSelection(16);
257
- if (!options.groups) options.groups = 'wheel';
258
- if (!options.port) options.port = 22; // Handle connect uri
259
-
260
- const userHome = Underpost.ssh.getUserHome(options.user);
261
- options.userHome = userHome;
262
519
 
263
- // Load config and override password and host if user exists in config
264
- if (options.deployId) {
265
- const config = Underpost.ssh.loadConfigNode(options.deployId);
266
- confNode = config.confNode;
267
- confNodePath = config.confNodePath;
268
-
269
- if (confNode.users && confNode.users[options.user]) {
270
- if (confNode.users[options.user].host) {
271
- options.host = confNode.users[options.user].host;
272
- logger.info(`Using saved host for user ${options.user}: ${options.host}`);
273
- }
274
- if (confNode.users[options.user].password === '') {
275
- options.disablePassword = true;
276
- options.password = '';
277
- logger.info(`Using saved empty password for user ${options.user}`);
278
- } else if (confNode.users[options.user].password) {
279
- options.disablePassword = false;
280
- options.password = confNode.users[options.user].password;
281
- logger.info(`Using saved password for user ${options.user}`);
282
- }
283
- options.port = options.port || confNode.users[options.user].port || 22;
284
- }
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 || '';
285
546
  }
286
547
 
287
- 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`;
288
552
 
289
- // Handle connect uri
290
- if (options.connectUri) {
291
- const keyPath = `${userHome}/.ssh/id_rsa`;
292
- const uri = `ssh ${options.user}@${options.host} -i ${keyPath} -p ${options.port}`;
293
- if (options.copy) {
294
- pbcopy(uri);
295
- } else console.log(uri);
296
- return;
297
- }
553
+ logger.info('options', options);
298
554
 
299
- // Handle reset operation
300
555
  if (options.reset) {
301
- shellExec(`> ${userHome}/.ssh/authorized_keys`);
302
- shellExec(`> ${userHome}/.ssh/known_hosts`);
556
+ const sshDir = sshDirFactory();
557
+ shellExec(`> ${sshDir}/authorized_keys`);
558
+ shellExec(`> ${sshDir}/known_hosts`);
303
559
  }
304
560
 
305
561
  if (options.userLs) {
@@ -318,36 +574,30 @@ EOF`);
318
574
  console.log('Users'.bold.blue);
319
575
  console.log(`user : x : UID : GID : GECOS : home_dir : shell`.blue);
320
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
+ );
321
581
  }
322
582
 
323
- // Handle user removal (works with or without deployId)
324
583
  if (options.userRemove) {
325
584
  const groups = shellExec(`id -Gn ${options.user}`, { silent: true, stdout: true }).trim().replace(/ /g, ', ');
326
585
  shellExec(`userdel -r ${options.user}`);
327
586
 
328
- // Remove sudoers file if it exists
329
587
  const sudoersFile = `/etc/sudoers.d/90_${options.user}`;
330
588
  if (fs.existsSync(sudoersFile)) {
331
589
  shellExec(`sudo rm -f ${sudoersFile}`);
332
590
  logger.info(`Sudoers file removed: ${sudoersFile}`);
333
591
  }
334
592
 
335
- // Remove the private key copy folder and update config only if deployId is provided
336
- if (options.deployId) {
337
- if (!confNode) {
338
- const config = Underpost.ssh.loadConfigNode(options.deployId);
339
- confNode = config.confNode;
340
- confNodePath = config.confNodePath;
341
- }
342
-
343
- const privateCopyDir = `./engine-private/conf/${options.deployId}/users/${options.user}`;
344
- if (fs.existsSync(privateCopyDir)) {
345
- fs.removeSync(privateCopyDir);
346
- logger.info(`Private key copy removed from ${privateCopyDir}`);
347
- }
593
+ if (fs.existsSync(keyDir)) {
594
+ fs.removeSync(keyDir);
595
+ logger.info(`Key store removed from ${keyDir}`);
596
+ }
348
597
 
349
- delete confNode.users[options.user];
350
- Underpost.ssh.saveConfigNode(confNodePath, confNode);
598
+ if (confUser) {
599
+ users.splice(confUserIndex, 1);
600
+ Underpost.ssh.saveUsers(users);
351
601
  }
352
602
 
353
603
  logger.info(`User removed`);
@@ -355,145 +605,114 @@ EOF`);
355
605
  return;
356
606
  }
357
607
 
358
- // Handle user addition (works with or without deployId)
359
608
  if (options.userAdd) {
360
- let privateCopyDir, privateKeyPath, publicKeyPath, keysExistInBackup, userExistsInConfig;
361
-
362
- // If deployId is provided, check for existing config and backup keys
363
- if (options.deployId) {
364
- if (!confNode) {
365
- const config = Underpost.ssh.loadConfigNode(options.deployId);
366
- confNode = config.confNode;
367
- confNodePath = config.confNodePath;
368
- }
369
-
370
- userExistsInConfig = confNode.users && confNode.users[options.user];
371
- privateCopyDir = `./engine-private/conf/${options.deployId}/users/${options.user}`;
372
- privateKeyPath = `${privateCopyDir}/id_rsa`;
373
- publicKeyPath = `${privateCopyDir}/id_rsa.pub`;
374
- keysExistInBackup = fs.existsSync(privateKeyPath) && fs.existsSync(publicKeyPath);
375
-
376
- // If user exists in config AND keys exist in backup, import those keys
377
- if (userExistsInConfig && keysExistInBackup) {
378
- logger.info(`User ${options.user} already exists in config. Importing existing keys...`);
379
-
380
- // Create system user if it doesn't exist
381
- const userExists = Underpost.ssh.checkUserExists(options.user);
382
- if (!userExists) {
383
- Underpost.ssh.createSystemUser(options.user, options.password, options.groups);
384
- }
385
-
386
- const userHome = Underpost.ssh.getUserHome(options.user);
387
- const sshDir = `${userHome}/.ssh`;
388
- Underpost.ssh.ensureSSHDirectory(sshDir);
609
+ if (!Underpost.ssh.checkUserExists(options.user))
610
+ Underpost.ssh.createSystemUser(options.user, options.password, options.groups);
389
611
 
390
- const userKeyPath = `${sshDir}/id_rsa`;
391
- const userPubKeyPath = `${sshDir}/id_rsa.pub`;
392
-
393
- // Import keys from backup
394
- fs.copyFileSync(privateKeyPath, userKeyPath);
395
- fs.copyFileSync(publicKeyPath, userPubKeyPath);
396
-
397
- Underpost.ssh.configureAuthorizedKeys(sshDir, userPubKeyPath, options.disablePassword);
398
- Underpost.ssh.configureSudoAccess(options.user, options.password, options.disablePassword);
399
- Underpost.ssh.configureKnownHosts(sshDir, options.port, options.host);
400
- Underpost.ssh.setSSHFilePermissions(sshDir, options.user, userKeyPath, userPubKeyPath);
401
-
402
- logger.info(`Keys imported from ${privateCopyDir} to ${sshDir}`);
403
- logger.info(`User added with existing keys`);
404
- return;
405
- }
406
- }
407
-
408
- // New user or no existing keys - create new user and generate keys
409
- Underpost.ssh.createSystemUser(options.user, options.password, options.groups);
410
-
411
- const userHome = Underpost.ssh.getUserHome(options.user);
412
- const sshDir = `${userHome}/.ssh`;
612
+ const sshDir = sshDirFactory();
413
613
  Underpost.ssh.ensureSSHDirectory(sshDir);
414
-
415
- const keyPath = `${sshDir}/id_rsa`;
416
- const pubKeyPath = `${sshDir}/id_rsa.pub`;
417
-
418
- 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)) {
419
625
  shellExec(
420
- `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 },
421
628
  );
629
+ } else if (!fs.existsSync(hostPubKeyPath)) {
630
+ shellExec(`ssh-keygen -y -f ${hostKeyPath} -P "${options.password}" > ${hostPubKeyPath}`, {
631
+ disableLog: true,
632
+ });
422
633
  }
423
634
 
424
- Underpost.ssh.configureAuthorizedKeys(sshDir, pubKeyPath, options.disablePassword);
425
- 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);
426
637
  Underpost.ssh.configureKnownHosts(sshDir, options.port, options.host);
427
- Underpost.ssh.setSSHFilePermissions(sshDir, options.user, keyPath, pubKeyPath);
428
-
429
- // Save a copy of the keys to the private folder only if deployId is provided
430
- if (options.deployId) {
431
- if (!privateCopyDir) privateCopyDir = `./engine-private/conf/${options.deployId}/users/${options.user}`;
432
- fs.ensureDirSync(privateCopyDir);
433
-
434
- const privateKeyCopyPath = `${privateCopyDir}/id_rsa`;
435
- const publicKeyCopyPath = `${privateCopyDir}/id_rsa.pub`;
436
-
437
- fs.copyFileSync(keyPath, privateKeyCopyPath);
438
- fs.copyFileSync(pubKeyPath, publicKeyCopyPath);
439
-
440
- logger.info(`Keys backed up to ${privateCopyDir}`);
441
-
442
- confNode.users[options.user] = {
443
- ...confNode.users[options.user],
444
- ...options,
445
- keyPath,
446
- pubKeyPath,
447
- privateKeyCopyPath,
448
- publicKeyCopyPath,
449
- };
450
- Underpost.ssh.saveConfigNode(confNodePath, confNode);
451
- }
452
-
453
- logger.info(`User added`);
454
- return;
455
- }
638
+ Underpost.ssh.setSSHFilePermissions(sshDir, options.user, hostKeyPath, hostPubKeyPath);
456
639
 
457
- // Handle config user listing (only with deployId)
458
- if (options.deployId) {
459
- if (!confNode) {
460
- const config = Underpost.ssh.loadConfigNode(options.deployId);
461
- confNode = config.confNode;
462
- 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}`);
463
645
  }
464
646
 
465
- if (options.userLs && confNode && confNode.users) {
466
- logger.info(`Users in config:`);
467
- Object.keys(confNode.users).forEach((user) => {
468
- logger.info(`- ${user}`);
469
- });
470
- }
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;
471
666
  }
472
667
 
473
- // Handle generate root keys
474
668
  if (options.generate)
475
669
  Underpost.ssh.generateKeys({ user: options.user, password: options.password, host: options.host });
476
670
 
477
- // Handle list operations
478
- if (options.keysList) shellExec(`cat ${userHome}/.ssh/authorized_keys`);
479
- 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`);
480
673
 
481
- // Handle key test
482
- if (options.keyTest) {
483
- const keyPath = `${userHome}/.ssh/id_rsa`;
484
- shellExec(`ssh-keygen -y -f ${keyPath} -P "${options.password}"`);
485
- }
674
+ if (options.keyTest) shellExec(`ssh-keygen -y -f ${keyPath} -P "${options.password}"`);
486
675
 
487
- // Handle stop server
488
676
  if (options.stop) shellExec('service sshd stop');
489
677
 
490
- // Handle start server
491
678
  if (options.start) {
492
- Underpost.ssh.chmod({ user: options.user });
679
+ if (!confUser) {
680
+ Underpost.ssh.initializeDefaultSshConfig({
681
+ user: options.user,
682
+ password: options.password,
683
+ host: options.host,
684
+ port: options.port,
685
+ });
686
+ } else {
687
+ // Hardening sshd on a host where the account does not exist produces a
688
+ // daemon that starts cleanly and refuses the only user it was meant to
689
+ // admit. Nothing downstream reports that, so the run has to.
690
+ if (!Underpost.ssh.checkUserExists(options.user))
691
+ throw new Error(
692
+ `[ssh] user '${options.user}' does not exist on this host; ` +
693
+ `run: node bin ssh --user ${options.user} --user-add`,
694
+ );
695
+ // The registry records the key pair this cluster authenticates with, so
696
+ // a box whose authorized_keys was lost converges here rather than
697
+ // needing the key re-issued — which would invalidate every other host
698
+ // holding it.
699
+ if (fs.existsSync(pubKeyPath)) {
700
+ const sshDir = sshDirFactory();
701
+ Underpost.ssh.ensureSSHDirectory(sshDir);
702
+ fs.ensureFileSync(`${sshDir}/authorized_keys`);
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', {
707
+ user: options.user,
708
+ expected: pubKeyPath,
709
+ });
710
+ }
711
+ Underpost.ssh.chmod({ user: options.user });
712
+ }
493
713
  Underpost.ssh.initService({ port: options.port });
494
714
  }
495
715
 
496
- // Handle status server
497
716
  if (options.status) shellExec('service sshd status');
498
717
  },
499
718
 
@@ -515,9 +734,13 @@ EOF`);
515
734
  * @param {string} params.remoteDir - Destination directory on the node.
516
735
  * @param {number} [params.port=22] - SSH port.
517
736
  * @param {string} [params.user='root'] - SSH user (key-only).
518
- * @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.
519
738
  * @param {string} [params.owner='1000:1000'] - chown target on the node (empty to skip).
520
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.
521
744
  * @returns {void}
522
745
  */
523
746
  copyDirToNode: ({
@@ -526,16 +749,18 @@ EOF`);
526
749
  remoteDir,
527
750
  port = 22,
528
751
  user = 'root',
529
- keyPath = './engine-private/deploy/id_rsa',
752
+ keyPath = '',
530
753
  owner = '1000:1000',
531
754
  mode = '755',
755
+ containerStorage = false,
532
756
  }) => {
533
757
  if (!host) throw new Error('copyDirToNode requires a host');
534
758
  if (!localDir || !fs.existsSync(localDir)) throw new Error(`copyDirToNode: local dir not found: ${localDir}`);
535
759
  if (!remoteDir) throw new Error('copyDirToNode requires a remoteDir');
760
+ const key = sshKeyPathFactory(keyPath);
536
761
  try {
537
- shellExec(`chmod 600 ${keyPath}`, { silent: true, silentOnError: true, disableLog: true });
538
- 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}`;
539
764
  shellExec(`ssh ${sshOpts} ${user}@${host} 'mkdir -p ${remoteDir}'`, {
540
765
  silent: true,
541
766
  disableLog: true,
@@ -551,12 +776,32 @@ EOF`);
551
776
  silent: true,
552
777
  disableLog: true,
553
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
+ );
554
790
  } catch (err) {
555
791
  logger.error(`copyDirToNode failed`);
556
792
  process.exit(1);
557
793
  }
558
794
  },
559
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
+
560
805
  /**
561
806
  * Generic SSH remote command runner that SSH execution logic.
562
807
  * Executes arbitrary shell commands on a remote server via SSH with proper credential handling.
@@ -564,46 +809,68 @@ EOF`);
564
809
  * @function sshRemoteRunner
565
810
  * @param {string} remoteCommand - The command to execute on the remote server
566
811
  * @param {Object} options - Configuration options for SSH execution
567
- * @param {string} [options.deployId] - Deployment ID for credential lookup
568
- * @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
569
814
  * @param {boolean} [options.dev=false] - Development mode flag
570
815
  * @param {string} [options.cd='/home/dd/engine'] - Working directory on remote server
571
816
  * @param {boolean} [options.useSudo=true] - Whether to use sudo for command execution
572
- * @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
573
819
  * @returns {Promise<string>} Output from the shell execution
574
820
  * @memberof UnderpostSSH
575
821
  */
576
822
  sshRemoteRunner: async (remoteCommand, options = {}) => {
577
- const { deployId = '', user = '', dev = false, cd = '/home/dd/engine', useSudo = true, remote = true } = options;
578
-
579
- // 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.
580
842
  if (!remote) {
581
- return shellExec(remoteCommand);
843
+ const cwd = cd && fs.existsSync(cd) ? cd : '';
844
+ return shellExecRedacted(remoteCommand, { ...(cwd ? { cwd } : {}), silent, disableLog: true });
582
845
  }
583
846
 
584
- // Set up SSH credentials from config
585
- if (deployId && user) {
586
- await Underpost.ssh.setDefautlSshCredentials({ deployId, user });
587
- }
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)';
588
855
 
589
- // Build the complete SSH command
590
856
  const sshScript = `#!/usr/bin/env bash
591
857
  set -euo pipefail
592
858
 
593
- REMOTE_USER=$(node bin config get --plain DEFAULT_SSH_USER)
594
- REMOTE_HOST=$(node bin config get --plain DEFAULT_SSH_HOST)
595
- REMOTE_PORT=$(node bin config get --plain DEFAULT_SSH_PORT)
596
- 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}
597
863
 
598
- 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
599
866
 
600
- 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
601
868
  ${cd ? `cd ${cd}` : ''}
602
869
  ${useSudo ? `sudo -n -- /bin/bash -lc "${remoteCommand}"` : remoteCommand}
603
870
  EOF
604
871
  `;
605
872
 
606
- return shellExec(sshScript, { stdout: true });
873
+ return shellExecRedacted(sshScript, { silent, disableLog: true });
607
874
  },
608
875
 
609
876
  /**
@@ -651,7 +918,7 @@ EOF
651
918
  * @param {string} params.command - Remote command batch to execute.
652
919
  * @param {number} [params.port=22] - SSH port.
653
920
  * @param {string} [params.user='root'] - SSH user (key-only).
654
- * @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.
655
922
  * @param {number} [params.connectTimeoutSec=15] - Per-attempt SSH connect timeout.
656
923
  * @param {number} [params.retries=3] - Auth/exec retry attempts.
657
924
  * @param {number} [params.retryDelayMs=5000] - Base backoff between retries.
@@ -663,7 +930,7 @@ EOF
663
930
  command,
664
931
  port = 22,
665
932
  user = 'root',
666
- keyPath = './engine-private/deploy/id_rsa',
933
+ keyPath = '',
667
934
  connectTimeoutSec = 15,
668
935
  retries = 3,
669
936
  retryDelayMs = 5000,
@@ -677,10 +944,11 @@ EOF
677
944
  if (!reachable) return { ok: false, code: 255, stdout: '', stderr: 'ssh port unreachable', attempts: 0 };
678
945
  }
679
946
 
680
- 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 });
681
949
 
682
950
  const sshOpts = [
683
- `-i ${keyPath}`,
951
+ `-i ${key}`,
684
952
  `-o BatchMode=yes`,
685
953
  `-o PreferredAuthentications=publickey`,
686
954
  `-o PubkeyAuthentication=yes`,
@@ -756,7 +1024,7 @@ EOF
756
1024
  remotePath = '/tmp/underpost-remote-script.sh',
757
1025
  port = 22,
758
1026
  user = 'root',
759
- keyPath = './engine-private/deploy/id_rsa',
1027
+ keyPath = '',
760
1028
  retries = 3,
761
1029
  waitForPortMs = 0,
762
1030
  }) => {
@@ -779,25 +1047,73 @@ EOF
779
1047
  },
780
1048
 
781
1049
  /**
782
- * 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`.
783
1065
  * @async
784
1066
  * @function setDefautlSshCredentials
785
1067
  * @memberof UnderpostSSH
786
1068
  * @param {Object} options - Options for setting default SSH credentials
787
- * @param {string} options.deployId - Deployment ID for the config path
788
- * @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
789
1071
  * @returns {Promise<void>}
1072
+ * @throws {Error} When a named host has no connection registered for that account.
790
1073
  */
791
- setDefautlSshCredentials: async (options = { deployId: '', user: '' }) => {
792
- const confNodePath = `./engine-private/conf/${options.deployId}/conf.node.json`;
793
- if (fs.existsSync(confNodePath)) {
794
- const { users } = JSON.parse(fs.readFileSync(confNodePath, 'utf8'));
795
- const { user, host, keyPath, port } = users[options.user];
796
- Underpost.env.set('DEFAULT_SSH_USER', user);
797
- Underpost.env.set('DEFAULT_SSH_HOST', host);
798
- Underpost.env.set('DEFAULT_SSH_KEY_PATH', keyPath);
799
- Underpost.env.set('DEFAULT_SSH_PORT', port);
800
- } 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);
801
1117
  },
802
1118
 
803
1119
  /**
@@ -848,12 +1164,26 @@ EOF
848
1164
  * Sets ownership to specified user for ~/.ssh/ and contents.
849
1165
  */
850
1166
  chmod: ({ user }) => {
851
- shellExec(`sudo chmod 700 ~/.ssh/`);
852
- shellExec(`sudo chmod 600 ~/.ssh/authorized_keys`);
853
- shellExec(`sudo chmod 644 ~/.ssh/known_hosts`);
854
- shellExec(`sudo chmod 600 ~/.ssh/id_rsa`);
1167
+ const sshDirectory = `${Underpost.ssh.getUserHome(user)}/.ssh`;
1168
+ shellExec(`sudo chmod 700 ${shellArgumentFactory(sshDirectory)}`);
1169
+ shellExec(
1170
+ `if [ -f ${shellArgumentFactory(`${sshDirectory}/authorized_keys`)} ]; then sudo chmod 600 ${shellArgumentFactory(`${sshDirectory}/authorized_keys`)}; fi`,
1171
+ );
1172
+ shellExec(
1173
+ `if [ -f ${shellArgumentFactory(`${sshDirectory}/known_hosts`)} ]; then sudo chmod 644 ${shellArgumentFactory(`${sshDirectory}/known_hosts`)}; fi`,
1174
+ );
1175
+ shellExec(
1176
+ `if [ -f ${shellArgumentFactory(`${sshDirectory}/id_rsa`)} ]; then sudo chmod 600 ${shellArgumentFactory(`${sshDirectory}/id_rsa`)}; fi`,
1177
+ );
855
1178
  shellExec(`sudo chmod 600 /etc/ssh/ssh_host_ed25519_key`);
856
- shellExec(`chown -R ${user}:${user} ~/.ssh`);
1179
+ shellExec(`sudo chown -R ${shellArgumentFactory(`${user}:${user}`)} ${shellArgumentFactory(sshDirectory)}`);
1180
+ runSELinuxCommands(
1181
+ [
1182
+ ...selinuxSshContextCommandsFactory({ sshDirectory }),
1183
+ selinuxRestoreconCommandFactory('/etc/ssh/ssh_host_ed25519_key', { recursive: false }),
1184
+ ],
1185
+ { execute: shellExec },
1186
+ );
857
1187
  },
858
1188
 
859
1189
  /**
@@ -866,7 +1196,7 @@ EOF
866
1196
  * @description
867
1197
  * Configures SSH daemon with hardened security settings:
868
1198
  * - Disables password authentication (key-only)
869
- * - Disables root login
1199
+ * - Allows key-only root login
870
1200
  * - Enables ED25519 host key
871
1201
  * - Disables X11 forwarding and TCP forwarding
872
1202
  * - Sets client alive intervals to prevent ghost connections
@@ -878,69 +1208,57 @@ EOF
878
1208
  * - Displays service status with colored output
879
1209
  */
880
1210
  initService: ({ port }) => {
881
- shellExec(
882
- `sudo tee /etc/ssh/sshd_config <<EOF
883
- # ==============================================================
884
- # RHEL Hardened SSHD Configuration
885
- # ==============================================================
886
-
887
- # --- Network Settings ---
888
- Port ${port ? port : '22'}
889
- # Explicitly listen on all interfaces (IPv4 and IPv6)
890
-
891
- # --- Host Keys ---
892
- # ED25519 is the modern standard (Fast & Secure)
1211
+ const sshPort = Number(port || 22);
1212
+ const portCommands = selinuxSshPortCommandsFactory({ port: sshPort });
1213
+ const temporaryPath = `/tmp/underpost-sshd-config-${process.pid}`;
1214
+ const configuration = `Port ${sshPort}
893
1215
  HostKey /etc/ssh/ssh_host_ed25519_key
894
- # RSA is kept for compatibility with older clients (Optional)
895
- # HostKey /etc/ssh/ssh_host_rsa_key
896
-
897
- # --- Logging ---
898
1216
  SyslogFacility AUTHPRIV
899
- # VERBOSE logs the key fingerprint used for login (Audit trail)
900
1217
  LogLevel VERBOSE
901
-
902
- # --- Authentication & Security ---
903
- # STRICTLY KEY-BASED AUTHENTICATION
904
1218
  PubkeyAuthentication yes
905
1219
  PasswordAuthentication no
906
- ChallengeResponseAuthentication no
1220
+ KbdInteractiveAuthentication no
907
1221
  PermitEmptyPasswords no
908
-
909
- # PREVENT ROOT LOGIN
910
- # Administrators should log in as a standard user and use 'sudo'
911
- PermitRootLogin no
912
-
913
- # Security checks on ownership of ~/.ssh/ files
1222
+ PermitRootLogin prohibit-password
914
1223
  StrictModes yes
915
1224
  MaxAuthTries 3
916
1225
  LoginGraceTime 60
917
-
918
- # --- PAM (Pluggable Authentication Modules) ---
919
- # REQUIRED: Must be 'yes' on RHEL for proper session/SELinux handling.
920
- # Since PasswordAuthentication is 'no', PAM will not ask for passwords.
921
1226
  UsePAM yes
922
-
923
- # --- Session & Network Health ---
924
- # Disconnect idle sessions after 5 minutes (300s * 0) to prevent ghost connections
925
1227
  ClientAliveInterval 300
926
1228
  ClientAliveCountMax 0
927
-
928
- # --- Feature Restrictions ---
929
- # Disable GUI forwarding unless explicitly needed
930
1229
  X11Forwarding no
931
- # Disable DNS checks for faster logins (unless you use Host based auth)
932
1230
  UseDNS no
933
- # Disable tunneling unless needed
934
1231
  PermitTunnel no
935
1232
  AllowTcpForwarding no
936
-
937
- # --- Subsystem ---
1233
+ Include /etc/ssh/sshd_config.d/*.conf
938
1234
  Subsystem sftp /usr/libexec/openssh/sftp-server
939
- EOF`,
940
- { disableLog: true },
941
- );
942
- shellExec(`sudo systemctl enable sshd`);
943
- shellExec(`sudo systemctl restart sshd`);
1235
+ `;
1236
+
1237
+ fs.writeFileSync(temporaryPath, configuration, { mode: 0o600 });
1238
+ try {
1239
+ if (sshPort !== 22) {
1240
+ shellExec(
1241
+ `if command -v dnf >/dev/null 2>&1 && ! command -v semanage >/dev/null 2>&1; then ${selinuxPackagesCommandFactory()}; fi`,
1242
+ );
1243
+ }
1244
+ runSELinuxCommands(portCommands, { execute: shellExec });
1245
+ shellExec(`sudo ssh-keygen -A`);
1246
+ shellExec(`sudo /usr/sbin/sshd -t -f ${shellArgumentFactory(temporaryPath)}`);
1247
+ shellExec(`sudo install -m 0600 -o root -g root ${shellArgumentFactory(temporaryPath)} /etc/ssh/sshd_config`);
1248
+ runSELinuxCommands(
1249
+ [selinuxRestoreconCommandFactory(['/etc/ssh/sshd_config', '/etc/ssh/ssh_host_ed25519_key'])],
1250
+ { execute: shellExec },
1251
+ );
1252
+ if (sshPort !== 22) {
1253
+ shellExec(
1254
+ `if command -v firewall-cmd >/dev/null 2>&1 && sudo firewall-cmd --state >/dev/null 2>&1; then sudo firewall-cmd --permanent --add-port=${sshPort}/tcp && sudo firewall-cmd --reload; fi`,
1255
+ );
1256
+ }
1257
+ shellExec(`sudo systemctl enable sshd`);
1258
+ shellExec(`sudo systemctl restart sshd`);
1259
+ } finally {
1260
+ fs.removeSync(temporaryPath);
1261
+ }
944
1262
 
945
1263
  const status = shellExec(`sudo systemctl status sshd`, { silent: true, stdout: true });
946
1264
  if (status.match('running')) console.log(status.replaceAll(`running`, `running`.green));
@@ -952,4 +1270,6 @@ EOF`,
952
1270
  };
953
1271
  }
954
1272
 
1273
+ export { connectionFactory, hostNameFactory, upsertConnection, userRecordFactory };
1274
+
955
1275
  export default UnderpostSSH;