@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
@@ -0,0 +1,2930 @@
1
+ /**
2
+ * WireGuard overlay and HAProxy edge routing lifecycle
3
+ * @module src/cli/wireguard.js
4
+ * @namespace UnderpostWireguard
5
+ */
6
+
7
+ import fs from 'fs-extra';
8
+ import { isIP } from 'node:net';
9
+ import os from 'node:os';
10
+ import nodePath from 'node:path';
11
+ import { getConfFilePath, loadConfInstances, loadConfServerJson, syncPrivateConf } from '../server/runtime/conf.js';
12
+ import { loadDeployCatalog } from '../server/build/catalog.js';
13
+ import { loadCronDeployEnv, parseList } from '../server/ops/cron.js';
14
+ import { resolveDeployList } from '../server/network/router.js';
15
+ import {
16
+ FORWARD_PROXY,
17
+ forwardProxyCommandFactory,
18
+ forwardProxyConfigFactory,
19
+ forwardProxyNodeCandidatesFactory,
20
+ forwardProxyNodeProbeCommandFactory,
21
+ forwardProxyServerFactory,
22
+ forwardProxyServiceCommandsFactory,
23
+ forwardProxyStartProbeCommandFactory,
24
+ forwardProxyUnitFactory,
25
+ } from '../server/network/forward-proxy.js';
26
+ import { assertRoleCapability } from '../server/network/node-capability.js';
27
+ import { loggerFactory, redactSensitiveText } from '../server/ops/logger.js';
28
+ import { nodeExporterServiceScriptFactory } from '../server/ops/monitoring.js';
29
+ import { installRootFile, pbcopy, shellExec, sleepSync } from '../server/runtime/process.js';
30
+ import {
31
+ homeDirectoryPathFactory,
32
+ journalctlCommandFactory,
33
+ runSystemdCommands,
34
+ systemctlCommandFactory,
35
+ systemdAvailableCommandFactory,
36
+ systemdReloadIfActiveCommandFactory,
37
+ systemdStatusCommandsFactory,
38
+ } from '../server/ops/systemd.js';
39
+ import Underpost from '../index.js';
40
+ import { syncDeployPackages } from '../server/build/package.js';
41
+
42
+ const logger = loggerFactory(import.meta);
43
+
44
+ /**
45
+ * @constant UNDERPOST_EDGE
46
+ * @description Fixed identity of the edge subsystem: paths, ports and resource
47
+ * names that both ends of a tunnel have to agree on.
48
+ * @memberof UnderpostWireguard
49
+ */
50
+ const UNDERPOST_EDGE = {
51
+ interfaceName: 'wg0',
52
+ listenPort: 51820,
53
+ // The hub's own address, and the subnet a spoke routes back through it. Both
54
+ // sides of every tunnel have to agree on the second one, so it is declared
55
+ // once here rather than defaulted separately at each end.
56
+ cidr: '10.0.0.1/24',
57
+ tunnelCidr: '10.0.0.0/24',
58
+ // Held below the 30s NAT mapping most consumer routers use, so a spoke's
59
+ // outbound session is refreshed before the ISP forgets it exists.
60
+ keepalive: 25,
61
+ wireguardDir: '/etc/wireguard',
62
+ haproxyDir: '/etc/haproxy',
63
+ haproxyConfName: 'haproxy.cfg',
64
+ sniMapName: 'domain2backend.map',
65
+ httpMapName: 'domain2backend-http.map',
66
+ statsSocket: '/var/lib/haproxy/stats',
67
+ natChainPrefix: 'UNDERPOST_WG',
68
+ sysctlPath: '/etc/sysctl.d/99-underpost-wireguard.conf',
69
+ packages: ['wireguard-tools', 'haproxy', 'iptables'],
70
+ httpPort: 80,
71
+ httpsPort: 443,
72
+ // Spoke-side SSH. The public port is per-edge and lives in topology,
73
+ // because exposing SSH is a decision, not a default.
74
+ sshPort: 22,
75
+ defaultSshForwardPort: 2222,
76
+ // A peer is considered live within this window: WireGuard re-handshakes at
77
+ // least every 120s while traffic flows, so a longer gap means the link is
78
+ // down rather than merely idle.
79
+ handshakeStaleSeconds: 180,
80
+ };
81
+
82
+ /** The SSH command that reaches a resolved node target; empty when the node is this machine. */
83
+ const sshUriFactory = ({ user = '', host = '', port = 0, keyPath = '' } = {}) =>
84
+ host ? `ssh ${user}@${host}${keyPath ? ` -i ${keyPath}` : ''} -p ${port || UNDERPOST_EDGE.sshPort}` : '';
85
+
86
+ /** Prints one line per node of a fleet-wide run and fails the process if any did. */
87
+ const reportFleetOutcome = ({ ok, nodes }) => {
88
+ for (const node of nodes)
89
+ console.log(` ${node.ok ? 'ok '.green : 'FAIL'.red} ${node.nodeName.padEnd(28)} ${node.via}`);
90
+ if (!ok) process.exitCode = 1;
91
+ };
92
+
93
+ /** Where the dispatcher's unit lives once `underpost event --service` has installed it. */
94
+ const EVENT_SERVICE_UNIT_PATH = '/etc/systemd/system/underpost-event.service';
95
+
96
+ /**
97
+ * @constant ENGINE_SYNC_STEPS
98
+ * @description What bringing one node's checkout up to date consists of.
99
+ *
100
+ * The deploy's package script runs twice, and that is the shape of the whole sequence: every
101
+ * step between them is the node's own CLI, which cannot load from a checkout whose installed
102
+ * packages no longer match its manifest — so the first run repairs the tree it finds, and the
103
+ * second installs the manifest the switch just landed. The first is advisory because a node
104
+ * whose tree predates that script must still reach the switch that gives it one.
105
+ *
106
+ * The switch moves the node between package scopes, and everything the node runs off that scope
107
+ * has to follow it, or the sync leaves the node running the previous one:
108
+ * - the repository pair is written into the node's host configuration, because that store is the
109
+ * only place `deploy/lib/host.sh` reads the source a later `prepare_host` pulls from. Without
110
+ * it that preparation resolves the default pairing and undoes the switch.
111
+ * - the cluster's CronJob manifests are regenerated and republished, because their pod bodies run
112
+ * the checkout that just changed.
113
+ * - the supervised services are reconciled through their own generators, on the unit file's
114
+ * existence rather than on the unit being active: one the previous scope's tree left dead is
115
+ * exactly the one that has to come back, and `is-active` skipped precisely that case.
116
+ *
117
+ * All of those are scoped to the role of the node reached — see {@link NODE_ROLE_STEPS}.
118
+ *
119
+ * A step whose placeholder resolves to nothing is dropped rather than run empty: a monorepo sync
120
+ * belongs to no deploy, a deploy that ships no package script has nothing to run, and a run
121
+ * without a token must not overwrite the node's with a blank.
122
+ * @memberof UnderpostWireguard
123
+ */
124
+ const ENGINE_SYNC_STEPS = [
125
+ { command: 'bash <deploy-package-script>', halt: false },
126
+ { command: 'node bin host set GITHUB_TOKEN <github-token>' },
127
+ { command: 'underpost run clean', halt: true },
128
+ { command: 'underpost cmt --switch-repo <engine> --target-branch <engine-branch>', halt: true },
129
+ {
130
+ command: 'underpost cmt ./engine-private --switch-repo <engine-private> --target-branch <engine-private-branch>',
131
+ halt: true,
132
+ },
133
+ { command: 'bash <deploy-package-script>', halt: true },
134
+ { command: 'node bin host set ENGINE_SRC_REPO <engine>', halt: true },
135
+ { command: 'node bin host set ENGINE_SRC_PRIVATE_REPO <engine-private>', halt: true },
136
+ { command: '<cron-reconcile-command>', halt: false },
137
+ { command: `test -f ${EVENT_SERVICE_UNIT_PATH} && <event-service-command>`, halt: false },
138
+ { command: `test -f ${FORWARD_PROXY.unitPath} && <forward-proxy-command>`, halt: false },
139
+ ];
140
+
141
+ /**
142
+ * @method deployIdFactory
143
+ * @description Normalizes a deploy id to the `dd-<conf-id>` convention.
144
+ * @param {string} deployId - Deploy id, with or without the prefix.
145
+ * @returns {string} Prefixed deploy id.
146
+ * @memberof UnderpostWireguard
147
+ */
148
+ const deployIdFactory = (deployId) => {
149
+ const value = `${deployId || ''}`.trim();
150
+ // `dd` is the meta id every runner reads as "all of dd.routes"; prefixing it
151
+ // would turn it into a deploy that does not exist.
152
+ if (!value || value === 'dd') return value;
153
+ return value.startsWith('dd-') ? value : `dd-${value}`;
154
+ };
155
+
156
+ /**
157
+ * @method deployPackageScriptFactory
158
+ * @description The package script of the deploy an engine source belongs to, when the source
159
+ * being synced actually ships one.
160
+ *
161
+ * Both halves have to hold. A monorepo sync belongs to no deploy, and a deploy that ships no
162
+ * script of its own would leave the node running `bash` against a path that does not exist —
163
+ * which the second, halting occurrence turns into a failed sync rather than a missing install.
164
+ * The check reads this checkout because it is the tree the node lands on once the switch
165
+ * completes, which is the only tree whose contents the step can be resolved against.
166
+ * @param {string} engine - Engine source repository the node is switched onto.
167
+ * @returns {string} Path to that deploy's package script, or `''` when there is none to run.
168
+ * @memberof UnderpostWireguard
169
+ */
170
+ const deployPackageScriptFactory = (engine = '') => {
171
+ const deployId = deployIdFactory(Underpost.repo.confIdFactory(engine));
172
+ const path = deployId ? `./deploy/${deployId}/package.sh` : '';
173
+ return path && fs.existsSync(path) ? path : '';
174
+ };
175
+
176
+ /**
177
+ * @method deployListFactory
178
+ * @description The deploys whose hostnames a run publishes.
179
+ *
180
+ * `dd` expands through the same `dd.routes` read every other runner uses, so
181
+ * the edge routes exactly the set the cluster deploys — a hostname cannot be
182
+ * live in the cluster and absent from the edge because two lists drifted.
183
+ * @param {string} deployId - Deploy id, comma-separated list, or `dd`.
184
+ * @returns {Array<string>} Normalized deploy ids.
185
+ * @memberof UnderpostWireguard
186
+ */
187
+ const deployListFactory = (deployId) => {
188
+ const value = `${deployId || ''}`.trim();
189
+ if (!value) return [];
190
+ if (value === 'dd') return resolveDeployList('dd').map(deployIdFactory).filter(Boolean);
191
+ return value.split(',').map(deployIdFactory).filter(Boolean);
192
+ };
193
+
194
+ /**
195
+ * @constant EDGE_TOPOLOGY_PATH
196
+ * @description Location of the deployment topology.
197
+ *
198
+ * Cluster-wide rather than per deploy, and stored beside `dd.routes` for that
199
+ * reason: the hub has one interface, one address and one peer table, and those
200
+ * are properties of the machine. A copy per deploy would be several records of
201
+ * one fact, free to disagree. `--deploy-id` selects which hostnames are routed
202
+ * across it, not which tunnel exists.
203
+ *
204
+ * Holds public keys only — the private half never leaves `/etc/wireguard` on
205
+ * the host that generated it.
206
+ * @memberof UnderpostWireguard
207
+ */
208
+ const EDGE_TOPOLOGY_PATH = './engine-private/deploy/conf.wireguard.json';
209
+ const EDGE_NODES_PATH = './engine-private/deploy/nodes';
210
+ const EDGE_NODE_ROLES = Object.freeze(['control', 'worker', 'hub']);
211
+
212
+ /**
213
+ * @constant NODE_ROLE_STEPS
214
+ * @description The supervised work each node role owns, as the command that reconciles it.
215
+ *
216
+ * A role owns a subsystem or it does not, and every one of these runs the checkout a sync
217
+ * replaces. The control node is the cluster's control plane, so it alone publishes the CronJobs —
218
+ * elsewhere that step rewrote the cron deploy's manifests and relabeled host directories before
219
+ * failing on a cluster it cannot reach. It is also the only node the dispatcher may run on, and
220
+ * the hub the only one the forward proxy may run on; both refuse to configure themselves
221
+ * anywhere else. A role that does not own the dispatcher withdraws it, rather than leaving a unit
222
+ * that holds a port and answers with code no one reconciles.
223
+ *
224
+ * Each is reconciled through its own generator rather than `systemctl restart`, because the units
225
+ * are generated: their `ExecStart` names a Node binary probed under systemd's own constraints,
226
+ * and a checkout switch can leave that path unexecutable. Restarting reruns a broken unit until
227
+ * systemd rate-limits it; only the generator re-probes, rewrites and clears the failed state — or
228
+ * says why no Node on the node can run the service.
229
+ * @memberof UnderpostWireguard
230
+ */
231
+ const NODE_ROLE_STEPS = Object.freeze({
232
+ control: {
233
+ '<cron-reconcile-command>': 'node bin cron --setup-start --git --apply',
234
+ '<event-service-command>': 'node bin event --service',
235
+ },
236
+ hub: {
237
+ '<event-service-command>': 'node bin event --service-stop',
238
+ '<forward-proxy-command>': 'node bin wireguard --forward-proxy-server',
239
+ },
240
+ worker: { '<event-service-command>': 'node bin event --service-stop' },
241
+ });
242
+
243
+ /**
244
+ * @method nodeRoleStepsFactory
245
+ * @description {@link NODE_ROLE_STEPS} for one role, with every step a role does not own resolved
246
+ * to nothing so the sequence drops it rather than running it empty.
247
+ * @param {string} nodeRole - `control`, `worker` or `hub`.
248
+ * @returns {Object<string, string>} One value per role-scoped step.
249
+ * @memberof UnderpostWireguard
250
+ */
251
+ const nodeRoleStepsFactory = (nodeRole = '') => {
252
+ const owned = NODE_ROLE_STEPS[nodeRole] || {};
253
+ const steps = [...new Set(Object.values(NODE_ROLE_STEPS).flatMap((role) => Object.keys(role)))];
254
+ return Object.fromEntries(steps.map((step) => [step, owned[step] || '']));
255
+ };
256
+
257
+ /** The node role a sync target carries: a hub is its own role, a spoke's is in its node document. */
258
+ const targetRoleFactory = (target = {}) => `${target.nodeRole || target.role || ''}`.trim();
259
+
260
+ const nodeNameFactory = (value = '') => {
261
+ const nodeName = `${value || ''}`.trim();
262
+ if (nodeName && !/^[a-zA-Z0-9._-]+$/.test(nodeName))
263
+ throw new Error('[wireguard] node names may contain only letters, numbers, dot, underscore, and hyphen');
264
+ return nodeName;
265
+ };
266
+
267
+ /**
268
+ * @method peerFactory
269
+ * @description Normalizes one spoke entry, filling the fields a partially
270
+ * written topology may omit.
271
+ *
272
+ * `allowedIPs` defaults to the peer's own tunnel address alone: a spoke routes
273
+ * its LAN only when topology says so, so a mistyped entry cannot silently
274
+ * claim a subnet another spoke already answers for.
275
+ * @param {object} peer - Raw topology entry.
276
+ * @returns {object} Normalized entry.
277
+ * @memberof UnderpostWireguard
278
+ */
279
+ const peerFactory = (peer = {}) => {
280
+ const address = `${peer.address || ''}`.trim();
281
+ const allowedIPs = (Array.isArray(peer.allowedIPs) ? peer.allowedIPs : [])
282
+ .map((entry) => `${entry || ''}`.trim())
283
+ .filter(Boolean);
284
+ return {
285
+ id: `${peer.id || ''}`.trim(),
286
+ address,
287
+ managementHost: `${peer.managementHost || ''}`.trim(),
288
+ publicKey: `${peer.publicKey || ''}`.trim(),
289
+ allowedIPs: allowedIPs.length > 0 ? allowedIPs : address ? [`${address}/32`] : [],
290
+ hosts: (Array.isArray(peer.hosts) ? peer.hosts : []).map((host) => `${host}`.trim().toLowerCase()).filter(Boolean),
291
+ instances: (Array.isArray(peer.instances) ? peer.instances : []).map((id) => `${id}`.trim()).filter(Boolean),
292
+ default: peer.default === true,
293
+ };
294
+ };
295
+
296
+ /**
297
+ * @method defaultPeerFactory
298
+ * @description The spoke an unmatched hostname falls back to, and the one QUIC
299
+ * is forwarded to.
300
+ *
301
+ * A lone peer is its own fallback: a single-spoke hub has nowhere else to send
302
+ * a hostname, and requiring `"default": true` there would only be ceremony.
303
+ * With several peers and none nominated there is no fallback at all, so nothing
304
+ * is dispatched somewhere arbitrary.
305
+ * @param {Array<object>} [peers] - Normalized topology entries.
306
+ * @returns {?object} The fallback peer, or null.
307
+ * @memberof UnderpostWireguard
308
+ */
309
+ const defaultPeerFactory = (peers = []) =>
310
+ peers.find((peer) => peer.default === true) || (peers.length === 1 ? peers[0] : null);
311
+
312
+ /** Returns the host portion of a WireGuard endpoint. */
313
+ const endpointHostFactory = (endpoint = '') => {
314
+ const value = `${endpoint || ''}`.trim();
315
+ if (!value) return '';
316
+ try {
317
+ return new URL(`udp://${value}`).hostname.replace(/^\[|\]$/g, '');
318
+ } catch {
319
+ return '';
320
+ }
321
+ };
322
+
323
+ const hubFactory = (hub = {}) => ({
324
+ interfaceName: `${hub.interfaceName || UNDERPOST_EDGE.interfaceName}`.trim(),
325
+ listenPort: Number(hub.listenPort) > 0 ? Number(hub.listenPort) : UNDERPOST_EDGE.listenPort,
326
+ address: `${hub.address || UNDERPOST_EDGE.cidr}`.trim(),
327
+ publicKey: `${hub.publicKey || ''}`.trim(),
328
+ sshForwardPort: Number(hub.sshForwardPort) > 0 ? Number(hub.sshForwardPort) : 0,
329
+ peers: (Array.isArray(hub.peers) ? hub.peers : []).map(peerFactory).filter((peer) => peer.id),
330
+ });
331
+
332
+ const topologyFactory = (topology = {}) =>
333
+ Object.fromEntries(
334
+ Object.entries(topology)
335
+ .map(([host, hub]) => [endpointHostFactory(host), hubFactory(hub)])
336
+ .filter(([host]) => isIP(host) === 4)
337
+ .sort(([a], [b]) => a.localeCompare(b)),
338
+ );
339
+
340
+ const nodeIdentityFactory = (identity = {}) => {
341
+ const role = EDGE_NODE_ROLES.includes(`${identity.role || ''}`.trim()) ? `${identity.role}`.trim() : '';
342
+ return {
343
+ nodeName: nodeNameFactory(identity.nodeName),
344
+ role,
345
+ hubHost: endpointHostFactory(identity.hubHost || identity.hub),
346
+ peerId: role === 'hub' ? '' : `${identity.peerId || ''}`.trim(),
347
+ };
348
+ };
349
+
350
+ const readTopology = () => {
351
+ if (!fs.existsSync(EDGE_TOPOLOGY_PATH)) return {};
352
+ try {
353
+ const source = JSON.parse(fs.readFileSync(EDGE_TOPOLOGY_PATH, 'utf8'));
354
+ const topology = topologyFactory(source);
355
+ const invalidHosts = Object.keys(source).filter((host) => isIP(endpointHostFactory(host)) !== 4);
356
+ if (invalidHosts.length > 0)
357
+ throw new Error(`top-level keys must be static IPv4 addresses: ${invalidHosts.join(', ')}`);
358
+ return topology;
359
+ } catch (error) {
360
+ throw new Error(`[wireguard] invalid topology ${EDGE_TOPOLOGY_PATH}: ${error.message}`);
361
+ }
362
+ };
363
+
364
+ const writeTopology = (topology) => {
365
+ const target = EDGE_TOPOLOGY_PATH;
366
+ const next = `${JSON.stringify(topologyFactory(topology), null, 2)}\n`;
367
+ const current = fs.existsSync(target) ? fs.readFileSync(target, 'utf8') : '';
368
+ if (current === next) return false;
369
+ fs.mkdirpSync(nodePath.dirname(target));
370
+ fs.writeFileSync(target, next, 'utf8');
371
+ return true;
372
+ };
373
+
374
+ const nodeConfigPathFactory = (nodeName) => `${EDGE_NODES_PATH}/${nodeName}.json`;
375
+
376
+ const readNodeConfig = (nodeName) => {
377
+ const selected = nodeNameFactory(nodeName);
378
+ const target = nodeConfigPathFactory(selected);
379
+ if (!selected || !fs.existsSync(target)) return nodeIdentityFactory({ nodeName: selected });
380
+ try {
381
+ return nodeIdentityFactory({ nodeName: selected, ...JSON.parse(fs.readFileSync(target, 'utf8')) });
382
+ } catch (error) {
383
+ throw new Error(`[wireguard] invalid node identity ${target}: ${error.message}`);
384
+ }
385
+ };
386
+
387
+ /**
388
+ * @method nodeNameCandidatesFactory
389
+ * @description The node document names a hostname may be recorded under, in
390
+ * precedence order.
391
+ *
392
+ * The short name follows the full one because an FQDN is a property of the
393
+ * resolver domain, not of the node: `vultr` and `vultr.guest` are one host, and
394
+ * its record must not stop matching when a search domain is added.
395
+ * @param {string} [hostname] - Hostname to resolve; defaults to this machine's.
396
+ * @returns {Array<string>} Candidate node names, most specific first.
397
+ * @memberof UnderpostWireguard
398
+ */
399
+ /**
400
+ * @method hostAddressesFactory
401
+ * @description This machine's own routable IPv4 addresses, read from its interfaces.
402
+ * @returns {Set<string>} Addresses, excluding loopback.
403
+ * @memberof UnderpostWireguard
404
+ */
405
+ const hostAddressesFactory = () =>
406
+ new Set(
407
+ Object.values(os.networkInterfaces())
408
+ .flat()
409
+ .filter((entry) => entry?.family === 'IPv4' && !entry.internal)
410
+ .map((entry) => entry.address),
411
+ );
412
+
413
+ /**
414
+ * @method localPeerFactory
415
+ * @description Whether a peer is this machine.
416
+ *
417
+ * Decided from the peer's registered management address, not from the node
418
+ * document that named it: a document is named after a hostname, and a generic
419
+ * one — `localhost.localdomain` — names every machine that kept the default.
420
+ * Concluding locality from that would run a repair, or a checkout switch, on
421
+ * whichever host happened to load the config instead of on the peer. An
422
+ * unverifiable address is remote, so the check can only ever fail towards SSH.
423
+ * @param {string} managementHost - Peer management address from topology.
424
+ * @param {Set<string>} [addresses] - This machine's addresses.
425
+ * @returns {boolean} True when the peer is this machine.
426
+ * @memberof UnderpostWireguard
427
+ */
428
+ const localPeerFactory = (managementHost = '', addresses = hostAddressesFactory()) =>
429
+ Boolean(managementHost) && addresses.has(`${managementHost}`.trim());
430
+
431
+ const nodeNameCandidatesFactory = (hostname = os.hostname()) => {
432
+ const name = nodeNameFactory(hostname);
433
+ return [...new Set([name, name.split('.')[0]])].filter(Boolean);
434
+ };
435
+
436
+ const hostNodeNameFactory = () => {
437
+ const candidates = nodeNameCandidatesFactory();
438
+ return candidates.find((name) => fs.existsSync(nodeConfigPathFactory(name))) || candidates[0] || '';
439
+ };
440
+
441
+ /**
442
+ * @method readNodeIdentity
443
+ * @description This machine's identity: the tracked node document named after it.
444
+ *
445
+ * The document's filename is the node name, so the hostname resolves it. A
446
+ * host-local record of which node this is would be a second copy of a fact the
447
+ * machine already knows, free to disagree with it after a rename.
448
+ * @returns {object} Normalized identity; empty when this host has no document.
449
+ * @memberof UnderpostWireguard
450
+ */
451
+ const readNodeIdentity = () => readNodeConfig(hostNodeNameFactory());
452
+
453
+ const readNodeConfigs = () => {
454
+ if (!fs.existsSync(EDGE_NODES_PATH)) return [];
455
+ return fs
456
+ .readdirSync(EDGE_NODES_PATH)
457
+ .filter((name) => name.endsWith('.json'))
458
+ .map((name) => readNodeConfig(name.slice(0, -5)));
459
+ };
460
+
461
+ const writeNodeIdentity = (identity, { dryRun = false } = {}) => {
462
+ const normalized = nodeIdentityFactory(identity);
463
+ if (dryRun) return normalized;
464
+ fs.mkdirpSync(EDGE_NODES_PATH);
465
+ const target = nodeConfigPathFactory(normalized.nodeName);
466
+ const content = `${JSON.stringify(
467
+ {
468
+ role: normalized.role,
469
+ hubHost: normalized.hubHost,
470
+ ...(normalized.role === 'hub' ? {} : { peerId: normalized.peerId }),
471
+ },
472
+ null,
473
+ 2,
474
+ )}\n`;
475
+ if (!fs.existsSync(target) || fs.readFileSync(target, 'utf8') !== content) fs.writeFileSync(target, content, 'utf8');
476
+ return normalized;
477
+ };
478
+
479
+ const edgeContextFactory = ({ topology = {}, identity = {} } = {}) => {
480
+ const node = nodeIdentityFactory(identity);
481
+ const hub = topologyFactory(topology)[node.hubHost];
482
+ if (!node.nodeName || !node.role || !node.hubHost)
483
+ throw new Error(
484
+ `[wireguard] host '${os.hostname()}' has no identity in ${EDGE_NODES_PATH}; ` +
485
+ 'run --node-config --node-role <control|worker|hub> --hub-host <public-ip>',
486
+ );
487
+ if (!hub) throw new Error(`[wireguard] hub '${node.hubHost}' is not registered in ${EDGE_TOPOLOGY_PATH}`);
488
+ const peer = node.role === 'hub' ? null : hub.peers.find((entry) => entry.id === node.peerId);
489
+ if (node.role !== 'hub' && !peer)
490
+ throw new Error(`[wireguard] node '${node.nodeName}' references unknown peer '${node.peerId}' on ${node.hubHost}`);
491
+ return {
492
+ ...node,
493
+ interfaceName: hub.interfaceName,
494
+ listenPort: hub.listenPort,
495
+ address: node.role === 'hub' ? hub.address : peer.address,
496
+ publicKey: node.role === 'hub' ? hub.publicKey : peer.publicKey,
497
+ hubPublicKey: hub.publicKey,
498
+ endpoint: `${node.hubHost}:${hub.listenPort}`,
499
+ sshForwardPort: hub.sshForwardPort,
500
+ peers: hub.peers,
501
+ };
502
+ };
503
+
504
+ const readEdgeContext = () => edgeContextFactory({ topology: readTopology(), identity: readNodeIdentity() });
505
+
506
+ const hubHostResolve = ({ topology = readTopology(), identity = readNodeIdentity(), hubHost = '' } = {}) => {
507
+ const selected = endpointHostFactory(hubHost || identity.hubHost);
508
+ if (selected) return selected;
509
+ const hosts = Object.keys(topology);
510
+ if (hosts.length === 1) return hosts[0];
511
+ throw new Error('[wireguard] --hub-host is required when no current identity selects one hub');
512
+ };
513
+
514
+ /**
515
+ * @method redirectHostFactory
516
+ * @description The hostname a `redirect` points at.
517
+ * @param {string} [redirect] - Absolute URL or bare hostname.
518
+ * @returns {string} Hostname, or an empty string when there is none.
519
+ * @memberof UnderpostWireguard
520
+ */
521
+ const redirectHostFactory = (redirect) => {
522
+ const value = `${redirect || ''}`.trim();
523
+ if (!value) return '';
524
+ try {
525
+ return new URL(value.includes('://') ? value : `https://${value}`).hostname.toLowerCase();
526
+ } catch {
527
+ return '';
528
+ }
529
+ };
530
+
531
+ /**
532
+ * @method hostProxyEntriesFactory
533
+ * @description The hostnames a `conf.server.json` publishes through the edge,
534
+ * with the ports each one claims.
535
+ *
536
+ * A sub-path's `proxy` array is the declaration that the hostname is reachable
537
+ * from outside; a host whose sub-paths declare none is internal and gets no
538
+ * route. Ports are unioned across sub-paths because the edge routes a hostname,
539
+ * not a sub-path — a single stream carries every path the host serves.
540
+ * @param {object} confServer - Parsed `conf.server.json`.
541
+ * @returns {Array<{host: string, ports: Array<number>, redirects: Array<string>}>} One entry per published hostname.
542
+ * @memberof UnderpostWireguard
543
+ */
544
+ const hostProxyEntriesFactory = ({ confServer = {} } = {}) => {
545
+ const entries = [];
546
+ for (const host of Object.keys(confServer)) {
547
+ const paths = confServer[host] || {};
548
+ const ports = new Set();
549
+ const redirects = new Set();
550
+ for (const path of Object.keys(paths)) {
551
+ const node = paths[path] || {};
552
+ for (const port of Array.isArray(node.proxy) ? node.proxy : [])
553
+ if (Number.isInteger(Number(port))) ports.add(Number(port));
554
+ const redirect = redirectHostFactory(node.redirect);
555
+ if (redirect) redirects.add(redirect);
556
+ }
557
+ if (ports.size === 0) continue;
558
+ entries.push({
559
+ host: `${host}`.toLowerCase(),
560
+ ports: [...ports].sort((a, b) => a - b),
561
+ redirects: [...redirects],
562
+ });
563
+ }
564
+ return entries.sort((a, b) => a.host.localeCompare(b.host));
565
+ };
566
+
567
+ /**
568
+ * @method instanceProxyEntriesFactory
569
+ * @description The hostnames a deploy's instances publish.
570
+ *
571
+ * Instances declare no `proxy` array — their hostname reaches the same cluster
572
+ * ingress the deploy's own hosts do, so it terminates the same two ports. Every
573
+ * variant of a family resolves through both its own id and its template id, so
574
+ * topology can bind a whole family with one entry.
575
+ * @param {Array<object>} [instances] - Expanded entries from `loadConfInstances`.
576
+ * @returns {Array<{host: string, ports: Array<number>, instances: Array<string>}>} One entry per instance hostname.
577
+ * @memberof UnderpostWireguard
578
+ */
579
+ const instanceProxyEntriesFactory = ({ instances = [] } = {}) => {
580
+ const byHost = new Map();
581
+ for (const instance of instances) {
582
+ const host = `${instance?.host || ''}`.trim().toLowerCase();
583
+ if (!host) continue;
584
+ if (!byHost.has(host))
585
+ byHost.set(host, { host, ports: [UNDERPOST_EDGE.httpPort, UNDERPOST_EDGE.httpsPort], instances: [] });
586
+ const ids = byHost.get(host).instances;
587
+ for (const id of [instance.id, instance.templateId].filter(Boolean)) if (!ids.includes(`${id}`)) ids.push(`${id}`);
588
+ }
589
+ return [...byHost.values()].sort((a, b) => a.host.localeCompare(b.host));
590
+ };
591
+
592
+ /**
593
+ * @method edgeRouteTableFactory
594
+ * @description Resolves every published hostname to the spoke that serves it.
595
+ *
596
+ * Resolution is ordered from most specific to least: an explicit hostname
597
+ * binding, then an instance id, then the hostname a redirect points at, then the
598
+ * default spoke. The redirect hop exists because a redirect host publishes
599
+ * nothing of its own — `dogmadual.com` only says "go to `www.dogmadual.com`",
600
+ * and the spoke that answers the target is the one that has to answer the
601
+ * redirect too.
602
+ *
603
+ * A hostname that resolves to nothing is returned in `unresolved` rather than
604
+ * dropped: a silently missing route is a hostname that answers nothing at all,
605
+ * which is invisible until someone reports the outage.
606
+ * @param {object} [confServer] - Parsed `conf.server.json`.
607
+ * @param {Array<object>} [instances] - Expanded instance entries.
608
+ * @param {Array<object>} [peers] - Registry entries.
609
+ * @returns {{routes: Array<object>, unresolved: Array<string>, peers: Array<object>}} Route table, unbound hostnames, and the peers actually referenced.
610
+ * @memberof UnderpostWireguard
611
+ */
612
+ const edgeRouteTableFactory = ({ confServer = {}, instances = [], peers = [] } = {}) => {
613
+ const entries = new Map();
614
+ for (const entry of hostProxyEntriesFactory({ confServer })) entries.set(entry.host, { ...entry, instances: [] });
615
+ for (const entry of instanceProxyEntriesFactory({ instances })) {
616
+ const current = entries.get(entry.host);
617
+ if (!current) entries.set(entry.host, { ...entry, redirects: [] });
618
+ else {
619
+ current.ports = [...new Set([...current.ports, ...entry.ports])].sort((a, b) => a - b);
620
+ current.instances = [...new Set([...current.instances, ...entry.instances])];
621
+ }
622
+ }
623
+
624
+ const list = peers.map(peerFactory).filter((peer) => peer.id && peer.address);
625
+ const byHost = new Map();
626
+ const byInstance = new Map();
627
+ // First declaration wins, so a duplicated binding is deterministic rather
628
+ // than dependent on topology order changing under an edit.
629
+ for (const peer of list) {
630
+ for (const host of peer.hosts) if (!byHost.has(host)) byHost.set(host, peer);
631
+ for (const id of peer.instances) if (!byInstance.has(id)) byInstance.set(id, peer);
632
+ }
633
+ const fallback = defaultPeerFactory(list);
634
+
635
+ const resolve = (entry, seen) => {
636
+ if (byHost.has(entry.host)) return { peer: byHost.get(entry.host), via: 'host' };
637
+ for (const id of entry.instances) if (byInstance.has(id)) return { peer: byInstance.get(id), via: 'instance' };
638
+ for (const target of entry.redirects) {
639
+ if (seen.has(target)) continue;
640
+ const targetEntry = entries.get(target);
641
+ if (!targetEntry) continue;
642
+ const resolved = resolve(targetEntry, new Set([...seen, entry.host]));
643
+ if (resolved.peer && resolved.via !== 'default') return { peer: resolved.peer, via: 'redirect' };
644
+ }
645
+ return fallback ? { peer: fallback, via: 'default' } : { peer: null, via: '' };
646
+ };
647
+
648
+ const routes = [];
649
+ const unresolved = [];
650
+ for (const entry of [...entries.values()].sort((a, b) => a.host.localeCompare(b.host))) {
651
+ const { peer, via } = resolve(entry, new Set([entry.host]));
652
+ if (!peer) {
653
+ unresolved.push(entry.host);
654
+ continue;
655
+ }
656
+ routes.push({ host: entry.host, ports: entry.ports, peerId: peer.id, address: peer.address, via });
657
+ }
658
+ const referenced = new Set(routes.map((route) => route.peerId));
659
+ return { routes, unresolved, peers: list.filter((peer) => referenced.has(peer.id) || peer === fallback) };
660
+ };
661
+
662
+ /**
663
+ * @method allowedIpsConflictsFactory
664
+ * @description CIDRs that more than one peer claims.
665
+ *
666
+ * WireGuard resolves an outbound packet to a peer by longest-prefix match over
667
+ * every peer's `AllowedIPs`, so a CIDR claimed twice is not shared — one peer
668
+ * wins and the other silently never receives that traffic. Homelabs routinely
669
+ * sit on the same `192.168.1.0/24`, which makes this the likeliest way a
670
+ * multi-spoke topology breaks.
671
+ *
672
+ * Duplicate tunnel addresses are caught by the same pass, because an address
673
+ * with no explicit `allowedIPs` contributes its own `/32`.
674
+ * @param {Array<object>} [peers] - Registry entries.
675
+ * @returns {Array<{cidr: string, peers: Array<string>}>} Contested CIDRs.
676
+ * @memberof UnderpostWireguard
677
+ */
678
+ const allowedIpsConflictsFactory = ({ peers = [] } = {}) => {
679
+ const byCidr = new Map();
680
+ for (const peer of peers.map(peerFactory))
681
+ for (const cidr of peer.allowedIPs) byCidr.set(cidr, [...new Set([...(byCidr.get(cidr) || []), peer.id])]);
682
+ return [...byCidr.entries()]
683
+ .filter(([, ids]) => ids.length > 1)
684
+ .map(([cidr, ids]) => ({ cidr, peers: ids.sort() }))
685
+ .sort((a, b) => a.cidr.localeCompare(b.cidr));
686
+ };
687
+
688
+ /**
689
+ * @method mergeRouteTablesFactory
690
+ * @description Folds one route table per deploy into the single table the edge
691
+ * publishes.
692
+ *
693
+ * The edge is one machine holding one pair of map files, so every deploy in
694
+ * `dd.routes` has to be compiled together — publishing one deploy's table alone
695
+ * would overwrite the maps and take every other deploy's hostname off the
696
+ * internet. Each route records the deploy that contributed it, which is the
697
+ * only way to attribute a hostname once the tables are merged.
698
+ *
699
+ * A hostname two deploys both claim is a configuration error, not a merge to
700
+ * resolve: one of them is not being served. First wins so the output is
701
+ * deterministic, and the collision is reported.
702
+ * @param {Array<{deployId: string, routes: Array<object>, unresolved: Array<string>, peers: Array<object>}>} [tables] - Per-deploy tables.
703
+ * @returns {{routes: Array<object>, unresolved: Array<object>, conflicts: Array<object>, peers: Array<object>}} Merged table.
704
+ * @memberof UnderpostWireguard
705
+ */
706
+ const mergeRouteTablesFactory = ({ tables = [] } = {}) => {
707
+ const routes = new Map();
708
+ const conflicts = [];
709
+ const unresolved = [];
710
+ const peers = new Map();
711
+ for (const table of tables) {
712
+ for (const route of table.routes || []) {
713
+ const current = routes.get(route.host);
714
+ if (!current) {
715
+ routes.set(route.host, { ...route, deployId: table.deployId });
716
+ continue;
717
+ }
718
+ if (current.peerId !== route.peerId)
719
+ conflicts.push({ host: route.host, claimed: [current.deployId, table.deployId], serving: current.peerId });
720
+ }
721
+ for (const host of table.unresolved || []) unresolved.push({ host, deployId: table.deployId });
722
+ for (const peer of table.peers || []) if (!peers.has(peer.id)) peers.set(peer.id, peer);
723
+ }
724
+ return {
725
+ routes: [...routes.values()].sort((a, b) => a.host.localeCompare(b.host)),
726
+ unresolved: unresolved.sort((a, b) => a.host.localeCompare(b.host)),
727
+ conflicts,
728
+ peers: [...peers.values()].sort((a, b) => a.id.localeCompare(b.id)),
729
+ };
730
+ };
731
+
732
+ /**
733
+ * @method backendNameFactory
734
+ * @description A peer id as an HAProxy proxy name. Only word characters are
735
+ * safe in one, and the prefix keeps the two transports of a single spoke
736
+ * distinguishable in `haproxy -c` output and the stats socket.
737
+ * @param {string} kind - `http` or `tls`.
738
+ * @param {string} peerId - Registry peer id.
739
+ * @returns {string} Backend name.
740
+ * @memberof UnderpostWireguard
741
+ */
742
+ const backendNameFactory = (kind, peerId) => `be_${kind}_${`${peerId || 'default'}`.replace(/[^a-zA-Z0-9]/g, '_')}`;
743
+
744
+ /**
745
+ * @method haproxyMapsFactory
746
+ * @description The two lookup tables the frontends select a backend with.
747
+ *
748
+ * Split by transport because the two are matched on different things — a
749
+ * plaintext `Host` header and a TLS SNI extension — and a hostname may publish
750
+ * one port without the other.
751
+ * @param {Array<object>} [routes] - Route table from {@link UnderpostWireguard.edgeRouteTableFactory}.
752
+ * @returns {{sni: string, http: string}} Rendered map files.
753
+ * @memberof UnderpostWireguard
754
+ */
755
+ const haproxyMapsFactory = ({ routes = [] } = {}) => {
756
+ const render = (port, kind) =>
757
+ routes
758
+ .filter((route) => route.peerId && route.ports.includes(port))
759
+ .map((route) => `${route.host} ${backendNameFactory(kind, route.peerId)}`)
760
+ .join('\n');
761
+ const sni = render(UNDERPOST_EDGE.httpsPort, 'tls');
762
+ const http = render(UNDERPOST_EDGE.httpPort, 'http');
763
+ return { sni: sni ? `${sni}\n` : '', http: http ? `${http}\n` : '' };
764
+ };
765
+
766
+ /**
767
+ * @method haproxyConfFactory
768
+ * @description Renders the public edge gateway.
769
+ *
770
+ * There is no `bind ... ssl` line anywhere in the output, and that absence is
771
+ * the design: the `:443` frontend runs in TCP mode and selects a backend from
772
+ * `req.ssl_sni`, which reads the ClientHello without decrypting it. The edge
773
+ * therefore holds no certificate and no private key for any hostname it routes.
774
+ *
775
+ * An unmatched hostname reaches the default spoke, or — with no default
776
+ * declared — is refused rather than dispatched somewhere arbitrary. `421` is
777
+ * the accurate answer for cleartext: the request reached a server that does not
778
+ * serve that authority.
779
+ * `sshForwardPort` adds the one frontend that is not hostname-routed. SSH
780
+ * carries no SNI and no `Host`, so there is nothing to route on: the port goes
781
+ * whole to the fallback spoke, exactly as UDP/443 does. It exists because a
782
+ * spoke behind CGNAT is otherwise unreachable for CI — the deploy runs on the
783
+ * cluster, and the only public address in the topology is the hub's.
784
+ * @param {Array<object>} [peers] - Peers to emit backends for.
785
+ * @param {string} [defaultPeerId] - Peer an unmatched hostname falls back to.
786
+ * @param {string} [mapDir] - Directory holding the map files.
787
+ * @param {number} [sshForwardPort] - Public port forwarded to the spoke's SSH; 0 emits no frontend.
788
+ * @returns {string} haproxy.cfg contents.
789
+ * @memberof UnderpostWireguard
790
+ */
791
+ const haproxyConfFactory = ({
792
+ peers = [],
793
+ defaultPeerId = '',
794
+ mapDir = UNDERPOST_EDGE.haproxyDir,
795
+ sshForwardPort = 0,
796
+ } = {}) => {
797
+ const sniMap = `${mapDir}/${UNDERPOST_EDGE.sniMapName}`;
798
+ const httpMap = `${mapDir}/${UNDERPOST_EDGE.httpMapName}`;
799
+ const fallback = peers.find((peer) => peer.id === defaultPeerId) || defaultPeerFactory(peers);
800
+ const backends = peers
801
+ .map(
802
+ (peer) => `backend ${backendNameFactory('http', peer.id)}
803
+ mode http
804
+ server ${peer.id} ${peer.address}:${UNDERPOST_EDGE.httpPort} check
805
+
806
+ backend ${backendNameFactory('tls', peer.id)}
807
+ mode tcp
808
+ server ${peer.id} ${peer.address}:${UNDERPOST_EDGE.httpsPort} check`,
809
+ )
810
+ .join('\n\n');
811
+ const defaults = fallback
812
+ ? `backend ${backendNameFactory('http', 'default')}
813
+ mode http
814
+ server ${fallback.id} ${fallback.address}:${UNDERPOST_EDGE.httpPort} check
815
+
816
+ backend ${backendNameFactory('tls', 'default')}
817
+ mode tcp
818
+ server ${fallback.id} ${fallback.address}:${UNDERPOST_EDGE.httpsPort} check`
819
+ : `backend ${backendNameFactory('http', 'default')}
820
+ mode http
821
+ http-request deny deny_status 421
822
+
823
+ backend ${backendNameFactory('tls', 'default')}
824
+ mode tcp
825
+ tcp-request content reject`;
826
+
827
+ // Both conditions matter: no port means SSH was never opened, and no fallback
828
+ // means there is no spoke to send it to. Emitting a frontend without a
829
+ // backend would bind the port and refuse every connection on it.
830
+ const sshEnabled = Number(sshForwardPort) > 0 && Boolean(fallback);
831
+ const sshFrontend = sshEnabled
832
+ ? `
833
+ frontend fe_ssh
834
+ bind :${sshForwardPort}
835
+ mode tcp
836
+ option tcplog
837
+ # No inspect-delay: the server speaks first in the SSH protocol, so waiting
838
+ # for client bytes here would stall every connection until the timeout.
839
+ default_backend ${backendNameFactory('ssh', 'default')}
840
+ `
841
+ : '';
842
+ const sshBackend = sshEnabled
843
+ ? `
844
+
845
+ backend ${backendNameFactory('ssh', 'default')}
846
+ mode tcp
847
+ server ${fallback.id} ${fallback.address}:${UNDERPOST_EDGE.sshPort} check`
848
+ : '';
849
+
850
+ return `# Generated by \`underpost wireguard --haproxy-sync\`. Do not edit by hand:
851
+ # every rule here is derived from conf.server.json, conf.instances.json and
852
+ # conf.wireguard.json, and the next sync overwrites the file.
853
+ global
854
+ log /dev/log local0
855
+ maxconn 20000
856
+ user haproxy
857
+ group haproxy
858
+ daemon
859
+ # \`expose-fd listeners\` is what makes a reload seamless: the incoming
860
+ # process inherits the listening sockets from the outgoing one, so no
861
+ # connection is refused while routes change.
862
+ stats socket ${UNDERPOST_EDGE.statsSocket} mode 660 level admin expose-fd listeners
863
+ stats timeout 30s
864
+
865
+ defaults
866
+ log global
867
+ option dontlognull
868
+ timeout connect 5s
869
+ timeout client 60s
870
+ timeout server 60s
871
+ # Websockets and other long-lived streams are held by the tunnel timeout,
872
+ # not the idle ones, or the edge would cut a healthy connection at 60s.
873
+ timeout tunnel 1h
874
+
875
+ frontend fe_http
876
+ bind :${UNDERPOST_EDGE.httpPort}
877
+ mode http
878
+ option httplog
879
+ option forwardfor
880
+ # Plaintext, so the Host header is readable without terminating anything.
881
+ # The port is stripped before lookup because a client may send \`host:80\`.
882
+ use_backend %[req.hdr(host),lower,word(1,:),map(${httpMap},${backendNameFactory('http', 'default')})]
883
+ default_backend ${backendNameFactory('http', 'default')}
884
+
885
+ frontend fe_https
886
+ bind :${UNDERPOST_EDGE.httpsPort}
887
+ mode tcp
888
+ option tcplog
889
+ # Wait for the ClientHello, read the SNI, forward the bytes untouched. A
890
+ # connection that never sends one falls to the default backend.
891
+ tcp-request inspect-delay 5s
892
+ tcp-request content accept if { req_ssl_hello_type 1 }
893
+ use_backend %[req.ssl_sni,lower,map(${sniMap},${backendNameFactory('tls', 'default')})]
894
+ default_backend ${backendNameFactory('tls', 'default')}
895
+ ${sshFrontend}
896
+ ${backends}${backends ? '\n\n' : ''}${defaults}${sshBackend}
897
+ `;
898
+ };
899
+
900
+ /**
901
+ * @method wireguardPrivateKeyDirective
902
+ * @description Loads the interface key from disk after the interface is up.
903
+ *
904
+ * The key is deliberately not inlined into the rendered config: keeping it in
905
+ * its own 0600 file means no rendered configuration, dry-run print, diff or log
906
+ * line can ever carry it, and the config itself becomes a pure function of the
907
+ * topology. `wg-quick` runs `PostUp` after `wg setconf`, so the interface is
908
+ * keyed before it forwards anything.
909
+ * @param {string} keyPath - Path of the private key file.
910
+ * @returns {string} `PostUp` directive.
911
+ * @memberof UnderpostWireguard
912
+ */
913
+ const wireguardPrivateKeyDirective = (keyPath) => `PostUp = wg set %i private-key ${keyPath}`;
914
+
915
+ /**
916
+ * @method wireguardClientForwardingDirectivesFactory
917
+ * @description Lets workloads behind a spoke reach tunnel services using the
918
+ * spoke's WireGuard address. The destination-scoped masquerade avoids exposing
919
+ * Kubernetes pod CIDRs to the hub or changing ordinary pod egress.
920
+ * @param {string} cidr - Tunnel CIDR reachable through the hub.
921
+ * @returns {string} Paired `wg-quick` lifecycle directives.
922
+ * @memberof UnderpostWireguard
923
+ */
924
+ const wireguardClientForwardingDirectivesFactory = (cidr) => {
925
+ const outbound = `FORWARD -o %i -d ${cidr} -j ACCEPT`;
926
+ const inbound = `FORWARD -i %i -s ${cidr} -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT`;
927
+ const masquerade = `POSTROUTING -o %i -d ${cidr} -j MASQUERADE`;
928
+ return [
929
+ 'PostUp = sysctl -q -w net.ipv4.ip_forward=1',
930
+ `PostUp = iptables -C ${outbound} 2>/dev/null || iptables -I ${outbound}`,
931
+ `PostUp = iptables -C ${inbound} 2>/dev/null || iptables -I ${inbound}`,
932
+ `PostUp = iptables -t nat -C ${masquerade} 2>/dev/null || iptables -t nat -I ${masquerade}`,
933
+ `PostDown = iptables -t nat -D ${masquerade} 2>/dev/null || true`,
934
+ `PostDown = iptables -D ${inbound} 2>/dev/null || true`,
935
+ `PostDown = iptables -D ${outbound} 2>/dev/null || true`,
936
+ ].join('\n');
937
+ };
938
+
939
+ /**
940
+ * @method wireguardServerConfFactory
941
+ * @description Renders the hub interface and its spoke table.
942
+ *
943
+ * Forwarding rules are attached to the interface rather than applied globally,
944
+ * so tearing the tunnel down removes them again and a stopped hub leaves no
945
+ * rule behind that outlives it.
946
+ * @param {string} [interfaceName] - Interface name.
947
+ * @param {string} address - Hub address with CIDR (`10.0.0.1/24`).
948
+ * @param {number} [listenPort] - UDP listen port.
949
+ * @param {string} keyPath - Path of the private key file.
950
+ * @param {Array<object>} [peers] - Registry entries.
951
+ * @returns {string} `wg-quick` configuration.
952
+ * @memberof UnderpostWireguard
953
+ */
954
+ const wireguardServerConfFactory = ({
955
+ interfaceName = UNDERPOST_EDGE.interfaceName,
956
+ address,
957
+ listenPort = UNDERPOST_EDGE.listenPort,
958
+ keyPath,
959
+ peers = [],
960
+ } = {}) => {
961
+ const peerBlocks = peers
962
+ .map(peerFactory)
963
+ .filter((peer) => peer.id && peer.publicKey && peer.allowedIPs.length > 0)
964
+ .map(
965
+ (peer) => `
966
+ [Peer]
967
+ # ${peer.id}
968
+ PublicKey = ${peer.publicKey}
969
+ AllowedIPs = ${peer.allowedIPs.join(', ')}`,
970
+ )
971
+ .join('\n');
972
+ return `# Generated by \`underpost wireguard --wireguard-setup\` on the registered hub. Do not edit by hand.
973
+ [Interface]
974
+ Address = ${address}
975
+ ListenPort = ${listenPort}
976
+ ${wireguardPrivateKeyDirective(keyPath)}
977
+ PostUp = sysctl -q -w net.ipv4.ip_forward=1
978
+ PostUp = iptables -I FORWARD -i ${interfaceName} -j ACCEPT
979
+ PostUp = iptables -I FORWARD -o ${interfaceName} -j ACCEPT
980
+ PostDown = iptables -D FORWARD -i ${interfaceName} -j ACCEPT
981
+ PostDown = iptables -D FORWARD -o ${interfaceName} -j ACCEPT
982
+ ${peerBlocks}
983
+ `;
984
+ };
985
+
986
+ /**
987
+ * @method wireguardClientConfFactory
988
+ * @description Renders a spoke interface.
989
+ *
990
+ * `AllowedIPs` is the tunnel CIDR alone, never `0.0.0.0/0`: the spoke is
991
+ * publishing services through the hub, not routing its own egress through it,
992
+ * and a default route here would send a whole cluster's outbound traffic across
993
+ * the VPS.
994
+ * @param {string} address - Spoke address (`10.0.0.2`, with or without CIDR).
995
+ * @param {string} keyPath - Path of the private key file.
996
+ * @param {string} publicKey - Hub public key.
997
+ * @param {string} endpoint - Hub `host:port`.
998
+ * @param {string} [cidr] - Tunnel CIDR reachable through the hub.
999
+ * @param {number} [keepalive] - Keepalive interval in seconds.
1000
+ * @returns {string} `wg-quick` configuration.
1001
+ * @memberof UnderpostWireguard
1002
+ */
1003
+ const wireguardClientConfFactory = ({
1004
+ address,
1005
+ keyPath,
1006
+ publicKey,
1007
+ endpoint,
1008
+ cidr = UNDERPOST_EDGE.tunnelCidr,
1009
+ keepalive = UNDERPOST_EDGE.keepalive,
1010
+ } = {}) => `# Generated by \`underpost wireguard --wireguard-setup\` on a registered node. Do not edit by hand.
1011
+ [Interface]
1012
+ Address = ${`${address}`.includes('/') ? address : `${address}/32`}
1013
+ ${wireguardPrivateKeyDirective(keyPath)}
1014
+ ${wireguardClientForwardingDirectivesFactory(cidr)}
1015
+
1016
+ [Peer]
1017
+ PublicKey = ${publicKey}
1018
+ Endpoint = ${endpoint}
1019
+ AllowedIPs = ${cidr}
1020
+ # Holds the outbound NAT mapping open, which is the only reason a hub behind no
1021
+ # NAT can reach a spoke behind CGNAT at all.
1022
+ PersistentKeepalive = ${keepalive}
1023
+ `;
1024
+
1025
+ /**
1026
+ * @method quicForwardCommandsFactory
1027
+ * @description The packet rules that carry QUIC to the default spoke, and the
1028
+ * ones that remove them again.
1029
+ *
1030
+ * QUIC cannot be routed by hostname — a QUIC Initial carries its SNI inside an
1031
+ * encrypted frame — so the whole UDP port goes to one spoke, exactly as
1032
+ * `underpost-ingress` sends every datagram to one data plane. A client that
1033
+ * tries QUIC against another spoke's hostname gets no answer and falls back to
1034
+ * TCP, which is routed correctly.
1035
+ *
1036
+ * The rules live in dedicated chains that are flushed and refilled on every
1037
+ * apply, which is what makes a re-run idempotent: there is no accumulating list
1038
+ * of near-duplicate rules and no need to guess what a previous run installed.
1039
+ * @param {string} [chainPrefix] - Prefix for the two managed chains.
1040
+ * @param {string} [interfaceName] - Tunnel interface.
1041
+ * @param {string} [target] - Spoke address; empty renders the chains without rules.
1042
+ * @param {number} [port] - UDP port to forward.
1043
+ * @returns {{ensure: Array<string>, remove: Array<string>}} Shell commands.
1044
+ * @memberof UnderpostWireguard
1045
+ */
1046
+ const quicForwardCommandsFactory = ({
1047
+ chainPrefix = UNDERPOST_EDGE.natChainPrefix,
1048
+ interfaceName = UNDERPOST_EDGE.interfaceName,
1049
+ target = '',
1050
+ port = UNDERPOST_EDGE.httpsPort,
1051
+ } = {}) => {
1052
+ const pre = `${chainPrefix}_PRE`;
1053
+ const post = `${chainPrefix}_POST`;
1054
+ return {
1055
+ ensure: [
1056
+ `sudo iptables -t nat -N ${pre} 2>/dev/null || true`,
1057
+ `sudo iptables -t nat -N ${post} 2>/dev/null || true`,
1058
+ `sudo iptables -t nat -C PREROUTING -j ${pre} 2>/dev/null || sudo iptables -t nat -A PREROUTING -j ${pre}`,
1059
+ `sudo iptables -t nat -C POSTROUTING -j ${post} 2>/dev/null || sudo iptables -t nat -A POSTROUTING -j ${post}`,
1060
+ `sudo iptables -t nat -F ${pre}`,
1061
+ `sudo iptables -t nat -F ${post}`,
1062
+ ...(target
1063
+ ? [
1064
+ // `! -i <iface>` keeps datagrams that already arrived through the
1065
+ // tunnel from being re-DNAT'd back into it.
1066
+ `sudo iptables -t nat -A ${pre} ! -i ${interfaceName} -p udp --dport ${port} -j DNAT --to-destination ${target}:${port}`,
1067
+ `sudo iptables -t nat -A ${post} -o ${interfaceName} -d ${target} -p udp --dport ${port} -j MASQUERADE`,
1068
+ ]
1069
+ : []),
1070
+ ],
1071
+ remove: [
1072
+ `sudo iptables -t nat -D PREROUTING -j ${pre} 2>/dev/null || true`,
1073
+ `sudo iptables -t nat -D POSTROUTING -j ${post} 2>/dev/null || true`,
1074
+ `sudo iptables -t nat -F ${pre} 2>/dev/null || true`,
1075
+ `sudo iptables -t nat -F ${post} 2>/dev/null || true`,
1076
+ `sudo iptables -t nat -X ${pre} 2>/dev/null || true`,
1077
+ `sudo iptables -t nat -X ${post} 2>/dev/null || true`,
1078
+ ],
1079
+ };
1080
+ };
1081
+
1082
+ /**
1083
+ * @method firewallCommandsFactory
1084
+ * @description Opens the ports each role needs, when firewalld is running.
1085
+ *
1086
+ * A spoke opens nothing publicly — it dials out — but its tunnel interface has
1087
+ * to land in a zone that permits forwarded traffic, or the cluster ingress is
1088
+ * unreachable over a tunnel that is otherwise perfectly healthy.
1089
+ * The same list drives the teardown, flipped to `--remove-*`, so a reset cannot
1090
+ * leave behind a permanent rule the setup added — the two directions are one
1091
+ * declaration rather than two that drift.
1092
+ * @param {string} role - `hub`, `control`, or `worker`.
1093
+ * @param {string} [interfaceName] - Tunnel interface.
1094
+ * @param {number} [listenPort] - UDP listen port.
1095
+ * @param {string} [tunnelCidr] - Tunnel source admitted to the forward proxy.
1096
+ * @param {number} [sshForwardPort] - Public SSH port to open; 0 opens none.
1097
+ * @param {boolean} [remove] - Withdraw the rules instead of adding them.
1098
+ * @returns {Array<string>} Shell commands, each a no-op when firewalld is absent.
1099
+ * @memberof UnderpostWireguard
1100
+ */
1101
+ const firewallCommandsFactory = ({
1102
+ role,
1103
+ interfaceName = UNDERPOST_EDGE.interfaceName,
1104
+ listenPort = UNDERPOST_EDGE.listenPort,
1105
+ tunnelCidr = UNDERPOST_EDGE.tunnelCidr,
1106
+ sshForwardPort = 0,
1107
+ remove = false,
1108
+ } = {}) => {
1109
+ const verb = remove ? 'remove' : 'add';
1110
+ const rules = [
1111
+ // Every role, the hub most of all: firewalld's forward chain ends in
1112
+ // `reject with icmpx admin-prohibited`, so an unzoned tunnel interface has
1113
+ // its forwarded traffic dropped while the hub itself still answers on it —
1114
+ // spoke-to-spoke stops working over a tunnel that looks perfectly healthy.
1115
+ `--zone=trusted --${verb}-interface=${interfaceName}`,
1116
+ ...(role === 'hub'
1117
+ ? [
1118
+ `--${verb}-port=${UNDERPOST_EDGE.httpPort}/tcp`,
1119
+ `--${verb}-port=${UNDERPOST_EDGE.httpsPort}/tcp`,
1120
+ `--${verb}-port=${UNDERPOST_EDGE.httpsPort}/udp`,
1121
+ `--${verb}-port=${listenPort}/udp`,
1122
+ // Public, unlike the forward proxy: CI dials it from GitHub's runners,
1123
+ // which have no fixed address to narrow the rule to.
1124
+ ...(Number(sshForwardPort) > 0 ? [`--${verb}-port=${sshForwardPort}/tcp`] : []),
1125
+ // The forward proxy is admitted from the tunnel only. The listener
1126
+ // already binds the tunnel address alone, so this rule narrows a port
1127
+ // that is unreachable from anywhere else rather than opening one.
1128
+ `--${verb}-rich-rule="rule family=ipv4 source address=${tunnelCidr} port port=${FORWARD_PROXY.port} protocol=tcp accept"`,
1129
+ `--${verb}-masquerade`,
1130
+ ]
1131
+ : []),
1132
+ ];
1133
+ const guard =
1134
+ 'command -v firewall-cmd >/dev/null 2>&1 && ' +
1135
+ systemctlCommandFactory({ action: 'is-active --quiet', name: 'firewalld', sudo: false });
1136
+ return [
1137
+ ...rules.map((rule) => `sudo sh -c '${guard} && firewall-cmd --permanent ${rule} >/dev/null || true'`),
1138
+ `sudo sh -c '${guard} && firewall-cmd --reload >/dev/null || true'`,
1139
+ ];
1140
+ };
1141
+
1142
+ /**
1143
+ * @method peerSummaryFactory
1144
+ * @description One spoke as topology declares it: its transport address and
1145
+ * the three bindings that route hostnames to it.
1146
+ * @param {object} peer - Normalized topology entry.
1147
+ * @returns {object} Topology view of the peer.
1148
+ * @memberof UnderpostWireguard
1149
+ */
1150
+ const peerSummaryFactory = (peer) => ({
1151
+ id: peer.id,
1152
+ address: peer.address,
1153
+ managementHost: peer.managementHost,
1154
+ // The identity the far end actually presents. Registry-only fields say what a
1155
+ // spoke is *called*; this is what `wg` matches on, so it is the one field that
1156
+ // settles "is the hub expecting the key this machine holds". Public by
1157
+ // definition — the private half never leaves the host that generated it.
1158
+ publicKey: peer.publicKey,
1159
+ allowedIPs: peer.allowedIPs,
1160
+ hosts: peer.hosts,
1161
+ instances: peer.instances,
1162
+ default: peer.default,
1163
+ });
1164
+
1165
+ /**
1166
+ * @method unregisteredPeersFactory
1167
+ * @description Public keys the live interface carries that topology does not.
1168
+ *
1169
+ * `wg set` adds a peer and never removes the one it supersedes, so re-keying a
1170
+ * spoke or re-running `--peer-add` with a corrected key leaves the previous
1171
+ * identity on the interface, still holding its old handshake. Every other view
1172
+ * here is keyed by topology, which makes those leftovers invisible — and an
1173
+ * invisible peer that still claims an address is exactly what makes a tunnel
1174
+ * "configured correctly" and dead at the same time.
1175
+ * @param {Array<object>} [peers] - Registry entries.
1176
+ * @param {string} [latestHandshakes] - `wg show <iface> latest-handshakes` output.
1177
+ * @param {number} [now] - Current time in epoch seconds.
1178
+ * @returns {Array<{publicKey: string, handshakeAgeSeconds: ?number}>} Peers on the wire but not in topology.
1179
+ * @memberof UnderpostWireguard
1180
+ */
1181
+ const unregisteredPeersFactory = ({ peers = [], latestHandshakes = '', now = Math.floor(Date.now() / 1000) } = {}) => {
1182
+ const known = new Set(
1183
+ peers
1184
+ .map(peerFactory)
1185
+ .map((peer) => peer.publicKey)
1186
+ .filter(Boolean),
1187
+ );
1188
+ return `${latestHandshakes || ''}`
1189
+ .split('\n')
1190
+ .map((line) => line.trim())
1191
+ .filter(Boolean)
1192
+ .map((line) => line.split(/\s+/))
1193
+ .filter(([publicKey]) => publicKey && !known.has(publicKey))
1194
+ .map(([publicKey, seconds]) => ({
1195
+ publicKey,
1196
+ handshakeAgeSeconds: Number(seconds) > 0 ? now - Number(seconds) : null,
1197
+ }));
1198
+ };
1199
+
1200
+ /**
1201
+ * @method wireguardStatusFactory
1202
+ * @description Folds `wg show` output onto topology, one row per spoke.
1203
+ *
1204
+ * Built from the per-peer sub-commands rather than `wg show <iface> dump`,
1205
+ * because the dump's first line contains the interface private key — this
1206
+ * command prints its output, so it must never read that line at all.
1207
+ * @param {Array<object>} [peers] - Registry entries.
1208
+ * @param {string} [latestHandshakes] - `wg show <iface> latest-handshakes` output.
1209
+ * @param {string} [transfer] - `wg show <iface> transfer` output.
1210
+ * @param {string} [endpoints] - `wg show <iface> endpoints` output.
1211
+ * @param {number} [now] - Current time in epoch seconds.
1212
+ * @returns {Array<object>} One row per peer.
1213
+ * @memberof UnderpostWireguard
1214
+ */
1215
+ const wireguardStatusFactory = ({
1216
+ peers = [],
1217
+ latestHandshakes = '',
1218
+ transfer = '',
1219
+ endpoints = '',
1220
+ now = Math.floor(Date.now() / 1000),
1221
+ } = {}) => {
1222
+ const parse = (raw) =>
1223
+ new Map(
1224
+ `${raw || ''}`
1225
+ .split('\n')
1226
+ .map((line) => line.trim())
1227
+ .filter(Boolean)
1228
+ .map((line) => {
1229
+ const [key, ...rest] = line.split(/\s+/);
1230
+ return [key, rest];
1231
+ }),
1232
+ );
1233
+ const handshakes = parse(latestHandshakes);
1234
+ const transfers = parse(transfer);
1235
+ const peerEndpoints = parse(endpoints);
1236
+ return peers.map(peerFactory).map((peer) => {
1237
+ const handshake = Number(handshakes.get(peer.publicKey)?.[0] || 0);
1238
+ const [rx = '0', tx = '0'] = transfers.get(peer.publicKey) || [];
1239
+ const endpoint = peerEndpoints.get(peer.publicKey)?.[0] || '';
1240
+ return {
1241
+ ...peerSummaryFactory(peer),
1242
+ endpoint: endpoint && endpoint !== '(none)' ? endpoint : '',
1243
+ handshakeAgeSeconds: handshake > 0 ? now - handshake : null,
1244
+ rxBytes: Number(rx) || 0,
1245
+ txBytes: Number(tx) || 0,
1246
+ online: handshake > 0 && now - handshake <= UNDERPOST_EDGE.handshakeStaleSeconds,
1247
+ };
1248
+ });
1249
+ };
1250
+
1251
+ /**
1252
+ * Resolves the local interface health without reading private key material.
1253
+ * A hub must have a fresh handshake with its default routing peer. Control and
1254
+ * worker nodes must have a fresh handshake with the configured hub identity.
1255
+ */
1256
+ const wireguardHealthFactory = ({
1257
+ context = {},
1258
+ active = '',
1259
+ latestHandshakes = '',
1260
+ now = Math.floor(Date.now() / 1000),
1261
+ } = {}) => {
1262
+ const edge = context;
1263
+ const serviceActive = active === 'active';
1264
+ const expectedPeer =
1265
+ edge.role === 'hub'
1266
+ ? defaultPeerFactory(edge.peers)
1267
+ : { id: 'hub', address: hubTunnelAddressFactory(edge), publicKey: edge.hubPublicKey };
1268
+ const [peer = {}] = wireguardStatusFactory({
1269
+ peers: expectedPeer ? [expectedPeer] : [],
1270
+ latestHandshakes,
1271
+ now,
1272
+ });
1273
+ return {
1274
+ ok: serviceActive && Boolean(expectedPeer?.publicKey) && peer.online === true,
1275
+ role: edge.role || '(unset)',
1276
+ serviceActive,
1277
+ peerId: expectedPeer?.id || '(unset)',
1278
+ handshakeAgeSeconds: peer.handshakeAgeSeconds ?? null,
1279
+ };
1280
+ };
1281
+
1282
+ /** Refuses a remediation command when SSH reached a different edge host. */
1283
+ const assertEdgeIdentity = (edge = {}, { expectedRole = '', expectedId = '' } = {}) => {
1284
+ if (expectedRole && edge.role !== expectedRole)
1285
+ throw new Error(`[wireguard] expected role '${expectedRole}', reached '${edge.role || '(unset)'}'`);
1286
+ if (expectedId && edge.peerId !== expectedId)
1287
+ throw new Error(`[wireguard] expected peer '${expectedId}', reached '${edge.peerId || '(unset)'}'`);
1288
+ return edge;
1289
+ };
1290
+
1291
+ /**
1292
+ * @method writeServerInterfaceConf
1293
+ * @description Rewrites the hub interface config from the current peer table.
1294
+ *
1295
+ * A spoke's config holds no peer table of its own, so this is a no-op there —
1296
+ * which is what lets `--peer-add` and `--peer-remove` run unconditionally.
1297
+ * @param {object} state - Registry the interface belongs to.
1298
+ * @param {Array<object>} peers - Peer table to render.
1299
+ * @param {boolean} [dryRun] - Print instead of writing.
1300
+ * @returns {boolean} True when the file changed.
1301
+ * @memberof UnderpostWireguard
1302
+ */
1303
+ const writeServerInterfaceConf = ({ state, peers, dryRun = false }) => {
1304
+ if (state.role !== 'hub') return false;
1305
+ return installRootFile({
1306
+ target: `${UNDERPOST_EDGE.wireguardDir}/${state.interfaceName}.conf`,
1307
+ content: wireguardServerConfFactory({
1308
+ interfaceName: state.interfaceName,
1309
+ address: state.address,
1310
+ listenPort: state.listenPort,
1311
+ keyPath: `${UNDERPOST_EDGE.wireguardDir}/${state.interfaceName}.key`,
1312
+ peers,
1313
+ }),
1314
+ mode: '0600',
1315
+ dryRun,
1316
+ });
1317
+ };
1318
+
1319
+ /**
1320
+ * @method runHostCommands
1321
+ * @description Executes a host-mutating command list, or prints it.
1322
+ * @param {Array<string>} commands - Shell commands.
1323
+ * @param {boolean} [dryRun] - Print instead of executing.
1324
+ * @returns {void}
1325
+ * @memberof UnderpostWireguard
1326
+ */
1327
+ const runHostCommands = (commands, dryRun = false) => {
1328
+ for (const command of commands) {
1329
+ if (dryRun) logger.info(`[dry-run] ${command}`);
1330
+ else shellExec(command, { silent: true });
1331
+ }
1332
+ };
1333
+
1334
+ const runServiceCommands = (commands, dryRun = false) =>
1335
+ runSystemdCommands(commands, {
1336
+ dryRun,
1337
+ execute: (command) => shellExec(command, { silent: true }),
1338
+ onDryRun: (command) => logger.info(`[dry-run] ${command}`),
1339
+ });
1340
+
1341
+ /**
1342
+ * @method csvFactory
1343
+ * @description Splits a comma-separated CLI value, or reports that the flag was
1344
+ * not passed at all — which is what lets a partial update leave the stored
1345
+ * fields it does not name untouched.
1346
+ * @param {string} [value] - Raw option value.
1347
+ * @returns {?Array<string>} Trimmed entries, or `undefined` when absent.
1348
+ * @memberof UnderpostWireguard
1349
+ */
1350
+ const csvFactory = (value) =>
1351
+ value === undefined || value === null || `${value}`.trim() === ''
1352
+ ? undefined
1353
+ : `${value}`
1354
+ .split(',')
1355
+ .map((entry) => entry.trim())
1356
+ .filter(Boolean);
1357
+
1358
+ /**
1359
+ * @method liveWireguardCommand
1360
+ * @description Applies a `wg set` change to a running interface, and does
1361
+ * nothing when there is none.
1362
+ *
1363
+ * `wg set` is what admits or drops a peer without restarting the interface, so
1364
+ * established tunnels keep their sessions. It is also the only step that
1365
+ * requires the interface to be up — persistence is the regenerated config file,
1366
+ * so a host whose tunnel is currently down still records the change.
1367
+ * @param {string} interfaceName - Interface name.
1368
+ * @param {string} args - Arguments after `wg set <interface>`.
1369
+ * @returns {string} Shell command.
1370
+ * @memberof UnderpostWireguard
1371
+ */
1372
+ const liveWireguardCommand = (interfaceName, args) =>
1373
+ `sudo sh -c 'wg show ${interfaceName} >/dev/null 2>&1 && wg set ${interfaceName} ${args} || true'`;
1374
+
1375
+ /**
1376
+ * @method warnTopologyHazards
1377
+ * @description Reports the two topology states that break routing silently.
1378
+ *
1379
+ * A peer with no `hosts` and no `instances` claims nothing. It still serves
1380
+ * every hostname while it is the *only* peer, because a lone peer is its own
1381
+ * fallback — so a single-spoke edge works with no bindings at all. Adding a
1382
+ * second peer removes that implicit fallback, and every hostname the bindings do
1383
+ * not name goes unresolved at once, along with the QUIC forward.
1384
+ *
1385
+ * That cliff arrives when the peer is registered, not when routes are published,
1386
+ * so it is reported at both points rather than left for the next sync to
1387
+ * discover.
1388
+ * @param {Array<object>} [peers] - The topology peers.
1389
+ * @returns {void}
1390
+ * @memberof UnderpostWireguard
1391
+ */
1392
+ const warnTopologyHazards = (peers = []) => {
1393
+ if (peers.length === 0) {
1394
+ logger.warn('Registry has no peers: no hostname can resolve until at least one is registered', {
1395
+ next: '--peer-add <id> --peer-ip <10.0.0.x> --public-key <key>',
1396
+ });
1397
+ return;
1398
+ }
1399
+ if (peers.length > 1 && !peers.some((peer) => peer.default))
1400
+ logger.warn('No peer is marked --default: unmatched hostnames are unresolved and UDP :443 is not forwarded', {
1401
+ peers: peers.map((peer) => peer.id),
1402
+ fix: '--peer-add <id> --peer-ip <ip> --public-key <key> --default',
1403
+ });
1404
+ const conflicts = allowedIpsConflictsFactory({ peers });
1405
+ if (conflicts.length > 0)
1406
+ logger.warn('Overlapping AllowedIPs: only one peer can receive traffic for each contested CIDR', {
1407
+ conflicts,
1408
+ fix: 'give each spoke a distinct tunnel address, and re-number or omit colliding LAN subnets',
1409
+ });
1410
+ };
1411
+
1412
+ /** Return an interface address without its CIDR prefix. */
1413
+ const tunnelAddressFactory = (address) => `${address || ''}`.trim().split('/')[0];
1414
+
1415
+ const tunnelNetworkCidrFactory = (address, fallback = UNDERPOST_EDGE.tunnelCidr) => {
1416
+ const [host, prefixValue] = `${address || ''}`.trim().split('/');
1417
+ const octets = host.split('.').map(Number);
1418
+ const prefix = Number(prefixValue);
1419
+ if (
1420
+ octets.length !== 4 ||
1421
+ octets.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255) ||
1422
+ !Number.isInteger(prefix) ||
1423
+ prefix < 0 ||
1424
+ prefix > 32
1425
+ )
1426
+ return fallback;
1427
+ const value = octets.reduce((result, octet) => ((result << 8) | octet) >>> 0, 0);
1428
+ const mask = prefix === 0 ? 0 : (0xffffffff << (32 - prefix)) >>> 0;
1429
+ const network = (value & mask) >>> 0;
1430
+ return `${[network >>> 24, (network >>> 16) & 255, (network >>> 8) & 255, network & 255].join('.')}/${prefix}`;
1431
+ };
1432
+
1433
+ /**
1434
+ * @method hubTunnelAddressFactory
1435
+ * @description The hub's address inside the tunnel, as seen from either end.
1436
+ *
1437
+ * A spoke's topology entry never records it: WireGuard identifies the hub by
1438
+ * public key and public endpoint, and the spoke stores only the network it
1439
+ * routes back through it. So a spoke derives the address the same way
1440
+ * the hub topology assigns it — the tunnel network's first host,
1441
+ * which is what `UNDERPOST_EDGE.cidr` declares. Probing the hub from inside the
1442
+ * tunnel is the one check that distinguishes a dead tunnel from a hub that is
1443
+ * merely busy, so the address has to be resolvable without one.
1444
+ * @param {object} [state] - Derived runtime context.
1445
+ * @returns {string} Hub tunnel address, or an empty string when underivable.
1446
+ * @memberof UnderpostWireguard
1447
+ */
1448
+ const hubTunnelAddressFactory = (state = {}) => {
1449
+ if (state.role === 'hub') return tunnelAddressFactory(state.address);
1450
+ const [network, prefix] = tunnelNetworkCidrFactory(state.address).split('/');
1451
+ const octets = network.split('.').map(Number);
1452
+ if (octets.length !== 4 || octets.some((octet) => !Number.isInteger(octet)) || Number(prefix) > 30) return '';
1453
+ const value = (octets.reduce((result, octet) => ((result << 8) | octet) >>> 0, 0) + 1) >>> 0;
1454
+ return [value >>> 24, (value >>> 16) & 255, (value >>> 8) & 255, value & 255].join('.');
1455
+ };
1456
+
1457
+ /**
1458
+ * @class UnderpostWireguard
1459
+ * @description Lifecycle for the edge: the WireGuard transport that reaches the
1460
+ * spokes, and the HAProxy gateway that selects one per connection.
1461
+ * @memberof UnderpostWireguard
1462
+ */
1463
+ class UnderpostWireguard {
1464
+ static API = {
1465
+ /**
1466
+ * @method install
1467
+ * @description Installs the host packages the subsystem needs.
1468
+ *
1469
+ * Each package is queried before anything is installed, so a host that is
1470
+ * already provisioned performs no work and a partially provisioned one
1471
+ * installs only what it is missing.
1472
+ * @param {object} [options] - CLI options.
1473
+ * @returns {Array<string>} Packages that were missing.
1474
+ * @memberof UnderpostWireguard
1475
+ */
1476
+ install(options = {}) {
1477
+ if (shellExec('command -v dnf', { stdout: true, silent: true, silentOnError: true }).trim() === '')
1478
+ throw new Error('[wireguard] No dnf on this host; the edge subsystem targets RHEL 9 / Rocky Linux 9');
1479
+ const missing = UNDERPOST_EDGE.packages.filter(
1480
+ (name) => shellExec(`rpm -q ${name}`, { silent: true, silentOnError: true }).code !== 0,
1481
+ );
1482
+ if (missing.length === 0) {
1483
+ logger.info('Edge packages already installed', { packages: UNDERPOST_EDGE.packages });
1484
+ return [];
1485
+ }
1486
+ runHostCommands(
1487
+ [
1488
+ `sudo dnf -y install epel-release || true`,
1489
+ `sudo dnf -y install ${missing.join(' ')}`,
1490
+ // HAProxy dials backends on the tunnel; without this boolean SELinux
1491
+ // refuses the connection and every route answers 503.
1492
+ `sudo sh -c 'command -v setsebool >/dev/null 2>&1 && setsebool -P haproxy_connect_any 1 || true'`,
1493
+ ],
1494
+ options.dryRun,
1495
+ );
1496
+ logger.info('Edge packages installed', { missing });
1497
+ return missing;
1498
+ },
1499
+
1500
+ /**
1501
+ * @method ensureKeyPair
1502
+ * @description Resolves the interface key pair, generating it once.
1503
+ *
1504
+ * The private half is created under `umask 077` inside the same shell that
1505
+ * writes it, so it is never readable by anyone but root — not even for the
1506
+ * moment between creation and a later `chmod`. It is never read back into
1507
+ * this process; only the public half is returned.
1508
+ * @param {string} interfaceName - Interface name.
1509
+ * @param {boolean} [dryRun] - Skip generation.
1510
+ * @returns {{privateKeyPath: string, publicKeyPath: string, publicKey: string, generated: boolean}} Key pair locations and the public key.
1511
+ * @memberof UnderpostWireguard
1512
+ */
1513
+ ensureKeyPair(interfaceName, dryRun = false) {
1514
+ const privateKeyPath = `${UNDERPOST_EDGE.wireguardDir}/${interfaceName}.key`;
1515
+ const publicKeyPath = `${UNDERPOST_EDGE.wireguardDir}/${interfaceName}.pub`;
1516
+ const exists =
1517
+ shellExec(`sudo test -s ${privateKeyPath} && sudo test -s ${publicKeyPath}`, {
1518
+ silent: true,
1519
+ silentOnError: true,
1520
+ }).code === 0;
1521
+ if (!exists) {
1522
+ if (dryRun) {
1523
+ logger.info(`[dry-run] generate wireguard key pair for ${interfaceName}`);
1524
+ return { privateKeyPath, publicKeyPath, publicKey: '', generated: true };
1525
+ }
1526
+ shellExec(
1527
+ `sudo sh -c 'umask 077 && mkdir -p ${UNDERPOST_EDGE.wireguardDir} && wg genkey > ${privateKeyPath} && wg pubkey < ${privateKeyPath} > ${publicKeyPath}'`,
1528
+ { silent: true },
1529
+ );
1530
+ shellExec(`sudo chmod 0600 ${privateKeyPath} && sudo chmod 0644 ${publicKeyPath}`, { silent: true });
1531
+ }
1532
+ const publicKey = shellExec(`sudo cat ${publicKeyPath}`, {
1533
+ stdout: true,
1534
+ silent: true,
1535
+ silentOnError: true,
1536
+ }).trim();
1537
+ return { privateKeyPath, publicKeyPath, publicKey, generated: !exists };
1538
+ },
1539
+
1540
+ nodeConfig(options = {}) {
1541
+ const nodeName = nodeNameFactory(options.nodeName || os.hostname());
1542
+ const current = readNodeConfig(nodeName);
1543
+ const identity = nodeIdentityFactory({
1544
+ nodeName,
1545
+ role: options.nodeRole || current.role,
1546
+ hubHost: options.hubHost || current.hubHost,
1547
+ peerId: options.peerId || current.peerId,
1548
+ });
1549
+ if (!identity.role) throw new Error(`[wireguard] --node-role must be one of: ${EDGE_NODE_ROLES.join(', ')}`);
1550
+ if (isIP(identity.hubHost) !== 4) throw new Error('[wireguard] --hub-host must be the hub static IPv4 address');
1551
+ const topology = readTopology();
1552
+ const hub = topology[identity.hubHost];
1553
+ if (!hub) throw new Error(`[wireguard] hub '${identity.hubHost}' is not registered in ${EDGE_TOPOLOGY_PATH}`);
1554
+ if (identity.role !== 'hub' && !identity.peerId)
1555
+ throw new Error('[wireguard] --peer-id is required for control and worker nodes');
1556
+ if (identity.role !== 'hub' && !hub.peers.some((peer) => peer.id === identity.peerId))
1557
+ throw new Error(
1558
+ `[wireguard] peer '${identity.peerId}' is not registered on ${identity.hubHost}; add it with --peer-add first`,
1559
+ );
1560
+ const duplicate = readNodeConfigs().find(
1561
+ (node) =>
1562
+ node.nodeName !== identity.nodeName &&
1563
+ node.hubHost === identity.hubHost &&
1564
+ ((identity.role === 'hub' && node.role === 'hub') ||
1565
+ (identity.role !== 'hub' && node.peerId === identity.peerId)),
1566
+ );
1567
+ if (duplicate)
1568
+ throw new Error(
1569
+ `[wireguard] ${identity.role === 'hub' ? `hub '${identity.hubHost}'` : `peer '${identity.peerId}'`} ` +
1570
+ `is already assigned to node '${duplicate.nodeName}'`,
1571
+ );
1572
+ const saved = writeNodeIdentity(identity, options);
1573
+ logger.info(options.dryRun ? '[dry-run] WireGuard node identity' : 'WireGuard node identity configured', saved);
1574
+ return saved;
1575
+ },
1576
+
1577
+ /**
1578
+ * @method setup
1579
+ * @description Builds this selected node's interface and updates only its
1580
+ * public key in deployment topology.
1581
+ * @param {object} options - CLI options.
1582
+ * @returns {object} The derived runtime context.
1583
+ * @memberof UnderpostWireguard
1584
+ */
1585
+ setup(options = {}) {
1586
+ const buildConf = options.buildConf === true;
1587
+ const topology = readTopology();
1588
+ const identity = readNodeIdentity();
1589
+ const state = edgeContextFactory({ topology, identity });
1590
+ const interfaceName = `${options.interface || state.interfaceName}`.trim();
1591
+ if (state.role !== 'hub' && (options.port !== undefined || options.sshForwardPort !== undefined))
1592
+ throw new Error('[wireguard] --port and --ssh-forward-port configure the hub only');
1593
+ const listenPort = state.role === 'hub' && Number(options.port) > 0 ? Number(options.port) : state.listenPort;
1594
+ const sshForwardPort =
1595
+ options.sshForwardPort === undefined || options.sshForwardPort === null || `${options.sshForwardPort}` === ''
1596
+ ? state.sshForwardPort
1597
+ : Math.max(0, Number(options.sshForwardPort) || 0);
1598
+ const { privateKeyPath, publicKey } = buildConf
1599
+ ? {
1600
+ privateKeyPath: `${UNDERPOST_EDGE.wireguardDir}/${interfaceName}.key`,
1601
+ publicKey: `${options.publicKey || state.publicKey}`.trim(),
1602
+ }
1603
+ : UnderpostWireguard.API.ensureKeyPair(interfaceName, options.dryRun);
1604
+ if (!publicKey && !options.dryRun) throw new Error('[wireguard] the selected node has no WireGuard public key');
1605
+
1606
+ let conf;
1607
+ let nextTopology;
1608
+ if (state.role === 'hub') {
1609
+ const address = `${options.cidr || state.address}`.trim();
1610
+ if (!address.includes('/')) throw new Error('[wireguard] --cidr must carry a prefix length (e.g. 10.0.0.1/24)');
1611
+ const hub = {
1612
+ ...topology[state.hubHost],
1613
+ interfaceName,
1614
+ listenPort,
1615
+ address,
1616
+ publicKey,
1617
+ sshForwardPort,
1618
+ };
1619
+ nextTopology = { ...topology, [state.hubHost]: hub };
1620
+ conf = wireguardServerConfFactory({
1621
+ interfaceName,
1622
+ address,
1623
+ listenPort,
1624
+ keyPath: privateKeyPath,
1625
+ peers: hub.peers,
1626
+ });
1627
+ } else {
1628
+ const address = `${options.peerIp || state.address}`.trim();
1629
+ if (!state.hubPublicKey) throw new Error(`[wireguard] hub '${state.hubHost}' has no publicKey`);
1630
+ const hub = topology[state.hubHost];
1631
+ const peers = hub.peers.map((peer) =>
1632
+ peer.id === state.peerId ? peerFactory({ ...peer, address, publicKey }) : peer,
1633
+ );
1634
+ nextTopology = { ...topology, [state.hubHost]: { ...hub, peers } };
1635
+ conf = wireguardClientConfFactory({
1636
+ address,
1637
+ keyPath: privateKeyPath,
1638
+ publicKey: state.hubPublicKey,
1639
+ endpoint: `${state.hubHost}:${listenPort}`,
1640
+ cidr: `${options.cidr || tunnelNetworkCidrFactory(hub.address)}`.trim(),
1641
+ keepalive: UNDERPOST_EDGE.keepalive,
1642
+ });
1643
+ }
1644
+ const next = edgeContextFactory({ topology: nextTopology, identity });
1645
+
1646
+ let confChanged = false;
1647
+ if (!buildConf) {
1648
+ confChanged = installRootFile({
1649
+ target: `${UNDERPOST_EDGE.wireguardDir}/${interfaceName}.conf`,
1650
+ content: conf,
1651
+ mode: '0600',
1652
+ dryRun: options.dryRun,
1653
+ });
1654
+ runHostCommands(
1655
+ [
1656
+ `sudo sh -c 'echo net.ipv4.ip_forward=1 > ${UNDERPOST_EDGE.sysctlPath}'`,
1657
+ `sudo sysctl -q --system`,
1658
+ ...firewallCommandsFactory({
1659
+ role: next.role,
1660
+ interfaceName,
1661
+ listenPort,
1662
+ tunnelCidr: tunnelNetworkCidrFactory(next.address),
1663
+ sshForwardPort: next.sshForwardPort,
1664
+ }),
1665
+ ],
1666
+ options.dryRun,
1667
+ );
1668
+ }
1669
+ if (!options.dryRun) writeTopology(nextTopology);
1670
+ // Writing the config is idempotent; the *running* interface is not. wg-quick
1671
+ // reads the file only at start, so a changed config under a live unit is a
1672
+ // divergence that stays silent until the next reboot re-reads it.
1673
+ const restartRequired =
1674
+ confChanged &&
1675
+ !options.dryRun &&
1676
+ shellExec(
1677
+ systemctlCommandFactory({ action: 'is-active --quiet', name: `wg-quick@${interfaceName}`, sudo: false }),
1678
+ { silent: true, silentOnError: true },
1679
+ ).code === 0;
1680
+ logger.info(buildConf ? 'Topology updated; host untouched' : 'WireGuard interface configured', {
1681
+ interfaceName,
1682
+ role: next.role,
1683
+ nodeName: next.nodeName,
1684
+ hubHost: next.hubHost,
1685
+ address: next.address,
1686
+ publicKey: next.publicKey,
1687
+ peers: next.peers.length,
1688
+ restartRequired,
1689
+ });
1690
+ if (restartRequired)
1691
+ logger.warn('Interface config changed while the tunnel is up; the live interface still runs the old one', {
1692
+ apply: `--wireguard-restart --check --interface ${interfaceName}`,
1693
+ });
1694
+ return next;
1695
+ },
1696
+
1697
+ /**
1698
+ * @method peerAdd
1699
+ * @description Registers a spoke and applies it to the running hub.
1700
+ *
1701
+ * `wg set` installs the peer on the live interface, so an existing tunnel is
1702
+ * never interrupted to admit a new one — topology and the config file
1703
+ * are updated in the same pass so the peer also survives a restart.
1704
+ *
1705
+ * A spoke that re-keys is registered under the same id with a new public key.
1706
+ * WireGuard identifies a peer *by its key*, not by any name, so admitting the
1707
+ * new one does not replace the old: the superseded key would stay on the live
1708
+ * interface still claiming the same `AllowedIPs`, and longest-prefix match
1709
+ * could keep handing that traffic to an identity the spoke no longer holds.
1710
+ * It is dropped first, which is what makes a reconnect leave no trace.
1711
+ * @param {object} options - CLI options.
1712
+ * @returns {object} The updated hub topology.
1713
+ * @memberof UnderpostWireguard
1714
+ */
1715
+ peerAdd(options = {}) {
1716
+ const topology = readTopology();
1717
+ const hubHost = hubHostResolve({ topology, hubHost: options.hubHost });
1718
+ const hub = topology[hubHost];
1719
+ if (!hub) throw new Error(`[wireguard] hub '${hubHost}' is not registered in ${EDGE_TOPOLOGY_PATH}`);
1720
+ const state = options.buildConf === true ? null : readEdgeContext();
1721
+ if (state && (state.role !== 'hub' || state.hubHost !== hubHost))
1722
+ throw new Error('[wireguard] live peer changes can run only on the selected hub; use --build-conf off-host');
1723
+ const id = `${options.peerAdd || ''}`.trim();
1724
+ const address = `${options.peerIp || ''}`.trim();
1725
+ const publicKey = `${options.publicKey || ''}`.trim();
1726
+ if (!id) throw new Error('[wireguard] --peer-add requires a peer id');
1727
+ if (!address) throw new Error('[wireguard] --peer-add requires --peer-ip');
1728
+ if (!publicKey) throw new Error('[wireguard] --peer-add requires --public-key');
1729
+ // Only flags that were actually passed override the stored entry, so
1730
+ // re-registering a spoke to correct its key does not silently drop the
1731
+ // subnets and bindings a previous run gave it.
1732
+ const overrides = Object.fromEntries(
1733
+ [
1734
+ ['allowedIPs', csvFactory(options.allowedIps)],
1735
+ ['hosts', csvFactory(options.hosts)],
1736
+ ['instances', csvFactory(options.instances)],
1737
+ ['managementHost', options.managementHost ? `${options.managementHost}`.trim() : undefined],
1738
+ ['default', options.default === true ? true : undefined],
1739
+ ].filter(([, value]) => value !== undefined),
1740
+ );
1741
+ const current = hub.peers.find((entry) => entry.id === id) || {};
1742
+ const peer = peerFactory({ ...current, id, address, publicKey, ...overrides });
1743
+ const supersededKey = current.publicKey && current.publicKey !== publicKey ? current.publicKey : '';
1744
+ const peers = [...hub.peers.filter((entry) => entry.id !== id), peer].sort((a, b) => a.id.localeCompare(b.id));
1745
+ const nextHub = { ...hub, peers };
1746
+ if (options.buildConf !== true) {
1747
+ runHostCommands(
1748
+ [
1749
+ ...(supersededKey ? [liveWireguardCommand(state.interfaceName, `peer ${supersededKey} remove`)] : []),
1750
+ liveWireguardCommand(state.interfaceName, `peer ${publicKey} allowed-ips ${peer.allowedIPs.join(',')}`),
1751
+ ],
1752
+ options.dryRun,
1753
+ );
1754
+ writeServerInterfaceConf({ state, peers, dryRun: options.dryRun });
1755
+ }
1756
+ if (!options.dryRun) writeTopology({ ...topology, [hubHost]: nextHub });
1757
+ logger.info(options.buildConf === true ? 'Peer recorded; host untouched' : 'Peer registered', {
1758
+ id,
1759
+ hubHost,
1760
+ address,
1761
+ managementHost: peer.managementHost || '(unset)',
1762
+ allowedIPs: peer.allowedIPs,
1763
+ rekeyed: supersededKey !== '',
1764
+ });
1765
+ warnTopologyHazards(peers);
1766
+ return nextHub;
1767
+ },
1768
+
1769
+ /**
1770
+ * @method peerRemove
1771
+ * @description Removes a spoke from topology and from the running hub.
1772
+ * @param {object} options - CLI options.
1773
+ * @returns {object} The updated hub topology.
1774
+ * @memberof UnderpostWireguard
1775
+ */
1776
+ peerRemove(options = {}) {
1777
+ const topology = readTopology();
1778
+ const hubHost = hubHostResolve({ topology, hubHost: options.hubHost });
1779
+ const hub = topology[hubHost];
1780
+ if (!hub) throw new Error(`[wireguard] hub '${hubHost}' is not registered in ${EDGE_TOPOLOGY_PATH}`);
1781
+ const state = options.buildConf === true ? null : readEdgeContext();
1782
+ if (state && (state.role !== 'hub' || state.hubHost !== hubHost))
1783
+ throw new Error('[wireguard] live peer changes can run only on the selected hub; use --build-conf off-host');
1784
+ const id = `${options.peerRemove || ''}`.trim();
1785
+ const peer = hub.peers.find((entry) => entry.id === id);
1786
+ if (!peer) {
1787
+ logger.warn('No such peer in topology', { hubHost, id });
1788
+ return hub;
1789
+ }
1790
+ const peers = hub.peers.filter((entry) => entry.id !== id);
1791
+ const nextHub = { ...hub, peers };
1792
+ if (options.buildConf !== true) {
1793
+ runHostCommands([liveWireguardCommand(state.interfaceName, `peer ${peer.publicKey} remove`)], options.dryRun);
1794
+ writeServerInterfaceConf({ state, peers, dryRun: options.dryRun });
1795
+ }
1796
+ if (!options.dryRun) writeTopology({ ...topology, [hubHost]: nextHub });
1797
+ logger.info(options.buildConf === true ? 'Peer removed from topology; host untouched' : 'Peer removed', {
1798
+ hubHost,
1799
+ id,
1800
+ });
1801
+ warnTopologyHazards(peers);
1802
+ return nextHub;
1803
+ },
1804
+
1805
+ /**
1806
+ * @method buildConf
1807
+ * @description Rewrites topology in place, normalized, touching no host.
1808
+ *
1809
+ * Topology is authored, not derived — its peer bindings and public keys
1810
+ * exist nowhere else, so nothing can regenerate it from other configuration.
1811
+ * This is the repair path: it fills in defaults a hand-edited file omitted,
1812
+ * drops entries with no id, and reports what it read, so malformed topology
1813
+ * is corrected before a sync acts on it.
1814
+ * @param {object} [options] - CLI options.
1815
+ * @returns {object} The normalized topology.
1816
+ * @memberof UnderpostWireguard
1817
+ */
1818
+ buildConf(options = {}) {
1819
+ const topology = readTopology();
1820
+ const changed = options.dryRun ? false : writeTopology(topology);
1821
+ logger.info('WireGuard topology normalized', {
1822
+ target: EDGE_TOPOLOGY_PATH,
1823
+ hubs: Object.entries(topology).map(([host, hub]) => ({ host, peers: hub.peers.map((peer) => peer.id) })),
1824
+ changed,
1825
+ });
1826
+ for (const hub of Object.values(topology)) warnTopologyHazards(hub.peers);
1827
+ return topology;
1828
+ },
1829
+
1830
+ /**
1831
+ * @method routeTable
1832
+ * @description The resolved hostname-to-spoke table for one deploy, a list,
1833
+ * or every deploy in `dd.routes`.
1834
+ *
1835
+ * Reads each deploy's configuration through the same helpers the cluster
1836
+ * runners use, so a hostname the edge routes is exactly a hostname the
1837
+ * cluster publishes.
1838
+ *
1839
+ * A deploy listed in `dd.routes` whose configuration is not checked out
1840
+ * locally is skipped with a warning rather than failing the run: the private
1841
+ * conf of an unrelated deploy is not a precondition for publishing the ones
1842
+ * that are present.
1843
+ *
1844
+ * Omitting the id means `dd` — the whole of `dd.routes`. The edge holds one
1845
+ * pair of map files for the cluster, so a complete table is the only default
1846
+ * that publishes a working edge; narrowing it is the deliberate act.
1847
+ * @param {string} [deployId] - Deploy id, comma-separated list, or `dd` (the default).
1848
+ * @returns {{routes: Array<object>, unresolved: Array<object>, conflicts: Array<object>, peers: Array<object>, deployList: Array<string>, missing: Array<string>}} Merged table.
1849
+ * @throws {Error} When no requested deploy has a readable configuration.
1850
+ * @memberof UnderpostWireguard
1851
+ */
1852
+ routeTable(deployId) {
1853
+ const deployList = deployListFactory(deployId || 'dd');
1854
+ if (deployList.length === 0) throw new Error('[wireguard] --deploy-id resolved to no deploys');
1855
+ const state = readEdgeContext();
1856
+ const tables = [];
1857
+ const missing = [];
1858
+ for (const id of deployList) {
1859
+ const confServerPath = getConfFilePath(id, 'server');
1860
+ if (!fs.existsSync(confServerPath)) {
1861
+ missing.push(id);
1862
+ continue;
1863
+ }
1864
+ const instancesPath = `./engine-private/conf/${id}/conf.instances.json`;
1865
+ tables.push({
1866
+ deployId: id,
1867
+ ...edgeRouteTableFactory({
1868
+ confServer: loadConfServerJson(confServerPath),
1869
+ instances: fs.existsSync(instancesPath) ? loadConfInstances(id) : [],
1870
+ peers: state.peers,
1871
+ }),
1872
+ });
1873
+ }
1874
+ if (missing.length > 0) logger.warn('Deploys with no local configuration were skipped', { missing });
1875
+ if (tables.length === 0)
1876
+ throw new Error(`[wireguard] No readable deploy configuration among: ${deployList.join(', ')}`);
1877
+ return { ...mergeRouteTablesFactory({ tables }), deployList, missing };
1878
+ },
1879
+
1880
+ /**
1881
+ * @method status
1882
+ * @description The whole edge context in one report: this machine's role and
1883
+ * transport, both daemons, every spoke with its bindings and link health, and
1884
+ * the routing the gateway would publish.
1885
+ *
1886
+ * This is the only read-only entry point. Host probes are skipped under
1887
+ * `--build-conf`, which has to run on a workstation with no interface to
1888
+ * query, so an off-box run reports topology and routing alone.
1889
+ * @param {object} options - CLI options.
1890
+ * @returns {object} Runtime, topology and routing summary.
1891
+ * @memberof UnderpostWireguard
1892
+ */
1893
+ status(options = {}) {
1894
+ const state = readEdgeContext();
1895
+ const interfaceName = `${options.interface || state.interfaceName}`.trim();
1896
+ const probeHost = options.buildConf !== true;
1897
+ const read = (command) => shellExec(command, { stdout: true, silent: true, silentOnError: true }).trim();
1898
+ const show = (subCommand) => (probeHost ? read(`sudo wg show ${interfaceName} ${subCommand}`) : '');
1899
+ // Sampled once: the topology view and the leftover-peer view must describe
1900
+ // the same instant, or a peer can appear in neither.
1901
+ const handshakes = show('latest-handshakes');
1902
+ const livePeers =
1903
+ state.role === 'hub'
1904
+ ? wireguardStatusFactory({
1905
+ peers: state.peers,
1906
+ latestHandshakes: handshakes,
1907
+ transfer: show('transfer'),
1908
+ endpoints: show('endpoints'),
1909
+ })
1910
+ : [];
1911
+ const [liveHub] =
1912
+ state.role === 'hub'
1913
+ ? []
1914
+ : wireguardStatusFactory({
1915
+ peers: [{ id: 'hub', address: hubTunnelAddressFactory(state), publicKey: state.hubPublicKey }],
1916
+ latestHandshakes: handshakes,
1917
+ transfer: show('transfer'),
1918
+ endpoints: show('endpoints'),
1919
+ });
1920
+
1921
+ let table = null;
1922
+ try {
1923
+ table = UnderpostWireguard.API.routeTable(options.deployId);
1924
+ } catch (error) {
1925
+ logger.warn('Route table unavailable', { message: error.message });
1926
+ }
1927
+
1928
+ const via = {};
1929
+ for (const route of table?.routes || []) via[route.via] = (via[route.via] || 0) + 1;
1930
+ const summary = {
1931
+ nodeName: state.nodeName,
1932
+ role: state.role,
1933
+ hubHost: state.hubHost,
1934
+ peerId: state.peerId || '(hub)',
1935
+ interface: interfaceName,
1936
+ address: state.address,
1937
+ endpoint: state.endpoint,
1938
+ publicKey: state.publicKey,
1939
+ // What this machine dials, next to what it presents. A spoke that cannot
1940
+ // handshake is usually holding one of these two wrong, and reading them
1941
+ // off the same report is what makes that a five-second check.
1942
+ ...(state.role !== 'hub' ? { hubPublicKey: state.hubPublicKey || '(unset)' } : {}),
1943
+ ...(probeHost
1944
+ ? {
1945
+ wireguard: read(systemdStatusCommandsFactory(`wg-quick@${interfaceName}`).active),
1946
+ haproxy: read(systemdStatusCommandsFactory('haproxy').active),
1947
+ forwardProxy: read(systemdStatusCommandsFactory(FORWARD_PROXY.serviceName).active),
1948
+ quicTarget: defaultPeerFactory(state.peers)?.address || '',
1949
+ sshForward: state.sshForwardPort
1950
+ ? `:${state.sshForwardPort} -> ${defaultPeerFactory(state.peers)?.address || '(no spoke)'}:${UNDERPOST_EDGE.sshPort}`
1951
+ : '(closed)',
1952
+ }
1953
+ : {}),
1954
+ ...(state.role === 'hub'
1955
+ ? { peers: probeHost ? livePeers : state.peers.map(peerSummaryFactory) }
1956
+ : { hub: liveHub }),
1957
+ topologyPeers: state.peers.map(peerSummaryFactory),
1958
+ ...(probeHost
1959
+ ? {
1960
+ unregisteredPeers: unregisteredPeersFactory({
1961
+ peers: state.role === 'hub' ? state.peers : [{ publicKey: state.hubPublicKey }],
1962
+ latestHandshakes: handshakes,
1963
+ }),
1964
+ }
1965
+ : {}),
1966
+ routing: table
1967
+ ? {
1968
+ deployList: table.deployList,
1969
+ missing: table.missing,
1970
+ via,
1971
+ routes: table.routes.map((route) => `${route.host} -> ${route.peerId} (${route.via}, ${route.deployId})`),
1972
+ }
1973
+ : null,
1974
+ };
1975
+ logger.info('Edge status', summary);
1976
+
1977
+ if (table && table.routes.length === 0)
1978
+ logger.warn('No hostname resolved to a spoke: the edge would refuse every request', {
1979
+ unresolved: table.unresolved.length,
1980
+ next: 'register a peer with --peer-add, then bind it with --hosts / --instances, or mark it --default',
1981
+ });
1982
+ else if (table?.unresolved.length > 0)
1983
+ logger.warn('Hostnames with no spoke binding', { unresolved: table.unresolved });
1984
+ if (table?.conflicts.length > 0)
1985
+ logger.warn('Hostnames claimed by more than one deploy; only the first is served', {
1986
+ conflicts: table.conflicts,
1987
+ });
1988
+ warnTopologyHazards(state.peers);
1989
+ return summary;
1990
+ },
1991
+
1992
+ /**
1993
+ * @method haproxySync
1994
+ * @description Recompiles the routing tables from the deploy configuration
1995
+ * and hot-reloads HAProxy.
1996
+ *
1997
+ * The candidate config is validated before the running process is signalled,
1998
+ * and the previous files are put back if it fails — a config that does not
1999
+ * parse would otherwise take the whole edge down on reload. The reload itself
2000
+ * hands the listening sockets to the incoming process, so established
2001
+ * connections and the tunnels underneath them are untouched.
2002
+ * @param {object} options - CLI options.
2003
+ * @returns {{routes: Array<object>, unresolved: Array<object>, conflicts: Array<object>, changed: boolean}} What was published.
2004
+ * @throws {Error} When HAProxy rejects the candidate config.
2005
+ * @memberof UnderpostWireguard
2006
+ */
2007
+ haproxySync(options = {}) {
2008
+ const { routes, unresolved, conflicts, peers, deployList } = UnderpostWireguard.API.routeTable(options.deployId);
2009
+ // Fail before the per-hostname report: with nothing resolved, listing
2010
+ // every unbound hostname buries the one line that explains the run.
2011
+ if (routes.length === 0)
2012
+ throw new Error(
2013
+ '[wireguard] No hostname resolved to a spoke; publishing would refuse every request. ' +
2014
+ 'Register a peer with --peer-add and bind it with --hosts / --instances, or mark it --default.',
2015
+ );
2016
+ if (unresolved.length > 0)
2017
+ logger.warn('Hostnames with no spoke binding; they will fall through to the default backend or be refused', {
2018
+ unresolved,
2019
+ });
2020
+ if (conflicts.length > 0)
2021
+ logger.warn('Hostnames claimed by more than one deploy; only the first is served', { conflicts });
2022
+ const state = readEdgeContext();
2023
+ assertRoleCapability({ role: state.role, capability: 'haproxy', operation: 'wireguard --haproxy-sync' });
2024
+ const defaultPeer = defaultPeerFactory(state.peers);
2025
+ const maps = haproxyMapsFactory({ routes });
2026
+ const conf = haproxyConfFactory({
2027
+ peers,
2028
+ defaultPeerId: defaultPeer?.id || '',
2029
+ sshForwardPort: state.sshForwardPort,
2030
+ });
2031
+
2032
+ const targets = [
2033
+ { target: `${UNDERPOST_EDGE.haproxyDir}/${UNDERPOST_EDGE.sniMapName}`, content: maps.sni },
2034
+ { target: `${UNDERPOST_EDGE.haproxyDir}/${UNDERPOST_EDGE.httpMapName}`, content: maps.http },
2035
+ { target: `${UNDERPOST_EDGE.haproxyDir}/${UNDERPOST_EDGE.haproxyConfName}`, content: conf },
2036
+ ];
2037
+ const previous = targets.map(({ target }) => ({
2038
+ target,
2039
+ content: fs.existsSync(target) ? fs.readFileSync(target, 'utf8') : null,
2040
+ }));
2041
+ const restore = () => {
2042
+ for (const entry of previous) {
2043
+ if (entry.content === null) shellExec(`sudo rm -f ${entry.target}`, { silent: true });
2044
+ else installRootFile({ target: entry.target, content: entry.content, mode: '0644' });
2045
+ }
2046
+ };
2047
+
2048
+ let changed = false;
2049
+ for (const entry of targets) changed = installRootFile({ ...entry, dryRun: options.dryRun }) || changed;
2050
+ if (options.dryRun) {
2051
+ logger.info('[dry-run] edge routes', { deployList, routes, unresolved, conflicts });
2052
+ return { routes, unresolved, conflicts, changed };
2053
+ }
2054
+
2055
+ const check = shellExec(
2056
+ `sudo haproxy -c -f ${UNDERPOST_EDGE.haproxyDir}/${UNDERPOST_EDGE.haproxyConfName} 2>&1`,
2057
+ { stdout: true, silent: true, silentOnError: true },
2058
+ );
2059
+ if (!`${check}`.includes('Configuration file is valid')) {
2060
+ restore();
2061
+ const detail = `${check}`.trim().split('\n').slice(-3).join(' ');
2062
+ logger.error('HAProxy rejected the candidate config; the previous config was restored', { detail });
2063
+ throw new Error(`[wireguard] HAProxy rejected the generated config: ${detail}`);
2064
+ }
2065
+ // Reload only a daemon that is already up: on a first bring-up the config
2066
+ // is written before `--haproxy-setup` enables the service, and reloading a
2067
+ // stopped unit is a failure rather than a no-op.
2068
+ if (changed) shellExec(systemdReloadIfActiveCommandFactory('haproxy'), { silent: true });
2069
+
2070
+ const quicTarget = defaultPeer?.address || '';
2071
+ runHostCommands(
2072
+ quicForwardCommandsFactory({ interfaceName: state.interfaceName, target: quicTarget }).ensure,
2073
+ options.dryRun,
2074
+ );
2075
+ logger.info('Edge routes published', {
2076
+ deployList,
2077
+ routes: routes.length,
2078
+ unresolved: unresolved.length,
2079
+ conflicts: conflicts.length,
2080
+ reloaded: changed,
2081
+ quicTarget,
2082
+ });
2083
+ return { routes, unresolved, conflicts, changed };
2084
+ },
2085
+
2086
+ /**
2087
+ * @method haproxySetup
2088
+ * @description Installs HAProxy, publishes the current routes, and enables
2089
+ * the daemon so the edge survives a reboot.
2090
+ * @param {object} options - CLI options.
2091
+ * @returns {void}
2092
+ * @memberof UnderpostWireguard
2093
+ */
2094
+ haproxySetup(options = {}) {
2095
+ UnderpostWireguard.API.install(options);
2096
+ UnderpostWireguard.API.haproxySync(options);
2097
+ runServiceCommands([systemctlCommandFactory({ action: 'enable --now', name: 'haproxy' })], options.dryRun);
2098
+ logger.info('HAProxy edge gateway enabled');
2099
+ },
2100
+
2101
+ /**
2102
+ * @method forwardProxyConfig
2103
+ * @description Resolves and validates the hub proxy endpoint.
2104
+ * @param {object} [options] - CLI options.
2105
+ * @returns {{host: string, port: number, apiKey: string, address: string, interfaceName: string, listenPort: number, tunnelCidr: string}} Resolved endpoint; `apiKey` must never be logged.
2106
+ * @throws {Error} When run on a spoke, or with no key configured.
2107
+ * @memberof UnderpostWireguard
2108
+ */
2109
+ forwardProxyConfig(options = {}) {
2110
+ const state = readEdgeContext();
2111
+ assertRoleCapability({
2112
+ role: state.role,
2113
+ capability: 'forward-proxy',
2114
+ operation: 'wireguard --forward-proxy-server',
2115
+ });
2116
+ const config = forwardProxyConfigFactory({
2117
+ host: `${options.forwardProxyServerHost || ''}`.trim() || tunnelAddressFactory(state.address),
2118
+ port: options.forwardProxyServerPort,
2119
+ });
2120
+ if (!config.apiKey)
2121
+ throw new Error(
2122
+ `[wireguard] ${FORWARD_PROXY.env.apiKey} is not set; every proxied request is authenticated with it. ` +
2123
+ `Export it, put it in the deploy env selected by \`underpost app load --env <environment>\` (./.env), ` +
2124
+ `or set it with \`underpost host set ${FORWARD_PROXY.env.apiKey} <key>\``,
2125
+ );
2126
+ if (['0.0.0.0', '::', '*'].includes(config.host))
2127
+ logger.warn('Forward proxy is bound to a wildcard address, not the tunnel', {
2128
+ host: config.host,
2129
+ consequence: 'the proxy is reachable from every interface; only the API key refuses a request',
2130
+ instead: `--forward-proxy-server-host ${tunnelAddressFactory(state.address) || tunnelAddressFactory(UNDERPOST_EDGE.cidr)}`,
2131
+ });
2132
+ return {
2133
+ ...config,
2134
+ address: `${config.host}:${config.port}`,
2135
+ interfaceName: state.interfaceName,
2136
+ listenPort: state.listenPort,
2137
+ tunnelCidr: tunnelNetworkCidrFactory(state.address),
2138
+ };
2139
+ },
2140
+
2141
+ /**
2142
+ * @method forwardProxyNodePath
2143
+ * @description Selects a compatible Node binary that systemd can execute.
2144
+ * @returns {{path: string, probed: boolean, rejected: Array<object>}} The chosen binary, and why each earlier candidate was passed over.
2145
+ * @memberof UnderpostWireguard
2146
+ */
2147
+ forwardProxyNodePath() {
2148
+ const read = (command) => shellExec(command, { stdout: true, silent: true, silentOnError: true }).trim();
2149
+ const ok = (command) => shellExec(command, { silent: true, silentOnError: true }).code === 0;
2150
+ const requiredMajor = Number(`${process.versions.node}`.split('.')[0]) || 0;
2151
+ const probed = read(systemdAvailableCommandFactory()) !== '';
2152
+ const user = os.userInfo().username;
2153
+ const rejected = [];
2154
+ for (const candidate of forwardProxyNodeCandidatesFactory()) {
2155
+ if (!ok(`sudo test -x ${candidate}`)) {
2156
+ rejected.push({ candidate, reason: 'not present' });
2157
+ continue;
2158
+ }
2159
+ const major = Number(read(`sudo ${candidate} --version`).replace(/^v/, '').split('.')[0]) || 0;
2160
+ if (major < requiredMajor) {
2161
+ rejected.push({ candidate, reason: `runs Node v${major || '?'}, the engine needs v${requiredMajor}` });
2162
+ continue;
2163
+ }
2164
+ if (probed && !ok(forwardProxyNodeProbeCommandFactory(candidate, user))) {
2165
+ rejected.push({
2166
+ candidate,
2167
+ reason: homeDirectoryPathFactory(candidate)
2168
+ ? 'systemd cannot execute it: it is under a home directory, whose SELinux label a unit cannot enter'
2169
+ : 'systemd cannot execute it',
2170
+ });
2171
+ continue;
2172
+ }
2173
+ return { path: candidate, probed, rejected };
2174
+ }
2175
+ return { path: '', probed, rejected };
2176
+ },
2177
+
2178
+ /**
2179
+ * @method forwardProxyServer
2180
+ * @description Reconciles the supervised proxy service.
2181
+ * @param {object} [options] - CLI options.
2182
+ * @returns {?object} What was reconciled, or null under `--dry-run`.
2183
+ * @throws {Error} When run on a spoke, or with no key configured.
2184
+ * @memberof UnderpostWireguard
2185
+ */
2186
+ forwardProxyServer(options = {}) {
2187
+ if (`${process.env[FORWARD_PROXY.supervisedEnv] || ''}`.trim())
2188
+ return UnderpostWireguard.API.forwardProxyListen(options);
2189
+
2190
+ const read = (command) => shellExec(command, { stdout: true, silent: true, silentOnError: true }).trim();
2191
+ const ok = (command) => shellExec(command, { silent: true, silentOnError: true }).code === 0;
2192
+ const withdrawBrokenUnit = () => {
2193
+ if (fs.existsSync(FORWARD_PROXY.unitPath))
2194
+ runServiceCommands(forwardProxyServiceCommandsFactory().remove, false);
2195
+ };
2196
+ const config = UnderpostWireguard.API.forwardProxyConfig(options);
2197
+ const node = options.dryRun
2198
+ ? { path: process.execPath, probed: false, rejected: [] }
2199
+ : UnderpostWireguard.API.forwardProxyNodePath();
2200
+ if (!node.path) {
2201
+ logger.error('No Node binary the forward proxy service can execute', {
2202
+ requires: `Node v${`${process.versions.node}`.split('.')[0]}`,
2203
+ rejected: node.rejected,
2204
+ fix: 'install Node system-wide, then re-run: curl -fsSL https://rpm.nodesource.com/setup_24.x | sudo bash - && sudo dnf install -y nodejs',
2205
+ });
2206
+ withdrawBrokenUnit();
2207
+ throw new Error(
2208
+ '[wireguard] No Node binary the forward proxy service can execute; install Node system-wide (a binary under /root or /home cannot be run by a unit) and re-run',
2209
+ );
2210
+ }
2211
+ if (node.probed === false && homeDirectoryPathFactory(node.path))
2212
+ logger.warn('Forward proxy unit points at a Node binary under a home directory', {
2213
+ node: node.path,
2214
+ risk: 'systemd refuses those on an SELinux host and the service restarts on 203/EXEC',
2215
+ fix: 'install Node system-wide, or check `journalctl -u underpost-forward-proxy` after this run',
2216
+ });
2217
+ if (node.probed && !ok(forwardProxyStartProbeCommandFactory({ nodePath: node.path }))) {
2218
+ logger.error('The forward proxy service cannot start this checkout', {
2219
+ node: node.path,
2220
+ cli: process.argv[1],
2221
+ workingDirectory: process.cwd(),
2222
+ likely: homeDirectoryPathFactory(process.cwd())
2223
+ ? 'the checkout is under a home directory, which a unit cannot read on an SELinux host'
2224
+ : 'the service cannot read the checkout; see the journal for the failing command',
2225
+ check: `sudo ${forwardProxyStartProbeCommandFactory({ nodePath: node.path }).replace(/^sudo /, '')}`,
2226
+ });
2227
+ withdrawBrokenUnit();
2228
+ throw new Error(
2229
+ `[wireguard] A systemd unit cannot start ${process.argv[1]} from ${process.cwd()}; move the checkout out of a home directory (e.g. /opt/underpost/engine) and re-run`,
2230
+ );
2231
+ }
2232
+ runHostCommands(
2233
+ firewallCommandsFactory({
2234
+ role: 'hub',
2235
+ interfaceName: config.interfaceName,
2236
+ listenPort: config.listenPort,
2237
+ tunnelCidr: config.tunnelCidr,
2238
+ }),
2239
+ options.dryRun,
2240
+ );
2241
+ const unit = forwardProxyUnitFactory({
2242
+ host: config.host,
2243
+ port: config.port,
2244
+ apiKey: config.apiKey,
2245
+ interfaceName: config.interfaceName,
2246
+ command: forwardProxyCommandFactory({ host: config.host, port: config.port, execPath: node.path }),
2247
+ });
2248
+ const changed = installRootFile({
2249
+ target: FORWARD_PROXY.unitPath,
2250
+ content: unit,
2251
+ mode: '0600',
2252
+ dryRun: options.dryRun,
2253
+ });
2254
+ runServiceCommands(forwardProxyServiceCommandsFactory({ changed }).ensure, options.dryRun);
2255
+ if (options.dryRun) {
2256
+ logger.info('[dry-run] would reconcile the forward proxy service', {
2257
+ service: FORWARD_PROXY.serviceName,
2258
+ address: config.address,
2259
+ });
2260
+ return null;
2261
+ }
2262
+ const statusCommands = systemdStatusCommandsFactory(FORWARD_PROXY.serviceName);
2263
+ const state = read(statusCommands.active);
2264
+ const enabled = read(statusCommands.enabled);
2265
+ logger.info('Forward proxy service reconciled', {
2266
+ service: FORWARD_PROXY.serviceName,
2267
+ address: config.address,
2268
+ tunnel: config.tunnelCidr,
2269
+ node: node.path,
2270
+ unitChanged: changed,
2271
+ state,
2272
+ enabled,
2273
+ logs: journalctlCommandFactory({ name: FORWARD_PROXY.serviceName, follow: true }),
2274
+ });
2275
+ const running = state === 'active' || state === 'activating';
2276
+ if (!running) {
2277
+ logger.error('Forward proxy service did not come up', {
2278
+ state: state || '(unknown)',
2279
+ likely: `${config.host} does not exist yet, so the listener cannot bind — bring the tunnel up with --wireguard-start`,
2280
+ check: journalctlCommandFactory({ name: FORWARD_PROXY.serviceName, lines: 20 }),
2281
+ });
2282
+ process.exitCode = 1;
2283
+ }
2284
+ return { service: FORWARD_PROXY.serviceName, address: config.address, changed, state, enabled };
2285
+ },
2286
+
2287
+ /**
2288
+ * @method forwardProxyListen
2289
+ * @description Starts the authenticated listener on the resolved hub address.
2290
+ * @param {object} [options] - CLI options.
2291
+ * @returns {object} The listening server.
2292
+ * @throws {Error} When run on a spoke, or with no key configured.
2293
+ * @memberof UnderpostWireguard
2294
+ */
2295
+ forwardProxyListen(options = {}) {
2296
+ const config = UnderpostWireguard.API.forwardProxyConfig(options);
2297
+ let server;
2298
+ server = forwardProxyServerFactory({
2299
+ config,
2300
+ onError(error) {
2301
+ logger.error('Forward proxy listener failed', { address: config.address, message: error.message });
2302
+ process.exitCode = 1;
2303
+ server.close(() => {});
2304
+ },
2305
+ onListen() {
2306
+ logger.info('Forward proxy listening', { address: config.address, tunnel: UNDERPOST_EDGE.tunnelCidr });
2307
+ },
2308
+ });
2309
+ return server;
2310
+ },
2311
+
2312
+ /**
2313
+ * @method start
2314
+ * @description Enables and starts the tunnel, and the QUIC forward with it.
2315
+ * @param {object} options - CLI options.
2316
+ * @returns {void}
2317
+ * @memberof UnderpostWireguard
2318
+ */
2319
+ start(options = {}) {
2320
+ const state = readEdgeContext();
2321
+ const interfaceName = `${options.interface || state.interfaceName}`.trim();
2322
+ runServiceCommands(
2323
+ [systemctlCommandFactory({ action: 'enable --now', name: `wg-quick@${interfaceName}` })],
2324
+ options.dryRun,
2325
+ );
2326
+ if (state.role === 'hub')
2327
+ runHostCommands(
2328
+ quicForwardCommandsFactory({ interfaceName, target: defaultPeerFactory(state.peers)?.address || '' }).ensure,
2329
+ options.dryRun,
2330
+ );
2331
+ logger.info('WireGuard interface started', { interfaceName });
2332
+ },
2333
+
2334
+ /** Restarts the interface so an active but stale tunnel is actually renegotiated. */
2335
+ restart(options = {}) {
2336
+ const state = assertEdgeIdentity(readEdgeContext(), options);
2337
+ const interfaceName = `${options.interface || state.interfaceName}`.trim();
2338
+ runServiceCommands(
2339
+ [
2340
+ systemctlCommandFactory({ action: 'enable', name: `wg-quick@${interfaceName}` }),
2341
+ systemctlCommandFactory({ action: 'restart', name: `wg-quick@${interfaceName}` }),
2342
+ ],
2343
+ options.dryRun,
2344
+ );
2345
+ if (state.role === 'hub')
2346
+ runHostCommands(
2347
+ quicForwardCommandsFactory({ interfaceName, target: defaultPeerFactory(state.peers)?.address || '' }).ensure,
2348
+ options.dryRun,
2349
+ );
2350
+ logger.info('WireGuard interface restarted', { interfaceName });
2351
+ },
2352
+
2353
+ /** Waits for the local interface to become healthy and sets a failing exit code otherwise. */
2354
+ check(options = {}) {
2355
+ const state = assertEdgeIdentity(readEdgeContext(), options);
2356
+ const interfaceName = `${options.interface || state.interfaceName}`.trim();
2357
+ const timeoutSeconds = options.checkTimeout === undefined ? 30 : Math.max(0, Number(options.checkTimeout) || 0);
2358
+ const deadline = Date.now() + timeoutSeconds * 1000;
2359
+ let health;
2360
+ do {
2361
+ const read = (command) =>
2362
+ `${
2363
+ shellExec(command, {
2364
+ stdout: true,
2365
+ silent: true,
2366
+ silentOnError: true,
2367
+ disableLog: true,
2368
+ }) || ''
2369
+ }`.trim();
2370
+ health = wireguardHealthFactory({
2371
+ context: state,
2372
+ active: read(systemdStatusCommandsFactory(`wg-quick@${interfaceName}`).active),
2373
+ latestHandshakes: read(`sudo wg show ${interfaceName} latest-handshakes`),
2374
+ });
2375
+ if (health.ok || Date.now() >= deadline) break;
2376
+ sleepSync(1000);
2377
+ } while (true);
2378
+
2379
+ const report = { interface: interfaceName, ...health };
2380
+ if (health.ok) logger.info('WireGuard health check passed', report);
2381
+ else {
2382
+ logger.error('WireGuard health check failed', report);
2383
+ process.exitCode = 1;
2384
+ }
2385
+ return report;
2386
+ },
2387
+
2388
+ /**
2389
+ * @method stop
2390
+ * @description Tears the tunnel down and removes the packet rules that only
2391
+ * make sense while it is up.
2392
+ * @param {object} options - CLI options.
2393
+ * @returns {void}
2394
+ * @memberof UnderpostWireguard
2395
+ */
2396
+ stop(options = {}) {
2397
+ const state = readEdgeContext();
2398
+ const interfaceName = `${options.interface || state.interfaceName}`.trim();
2399
+ runHostCommands(
2400
+ [
2401
+ systemctlCommandFactory({
2402
+ action: 'disable --now',
2403
+ name: `wg-quick@${interfaceName}`,
2404
+ stderr: true,
2405
+ allowFailure: true,
2406
+ }),
2407
+ `sudo sh -c 'wg-quick down ${interfaceName} 2>/dev/null || true'`,
2408
+ ...quicForwardCommandsFactory({ interfaceName }).remove,
2409
+ ],
2410
+ options.dryRun,
2411
+ );
2412
+ logger.info('WireGuard interface stopped', { interfaceName });
2413
+ },
2414
+
2415
+ /**
2416
+ * @method reset
2417
+ * @description Returns the host to zero: stops the daemons and withdraws
2418
+ * every artifact the setup installed.
2419
+ *
2420
+ * Everything this subsystem writes outside the repo is removed — interface
2421
+ * config, sysctl drop-in, both HAProxy map files *and* the generated
2422
+ * `haproxy.cfg`, the forward proxy unit, the NAT chains, and the firewalld
2423
+ * rules opened for the recorded role. Leaving `haproxy.cfg` behind while
2424
+ * deleting the maps it reads is worse than leaving both: the daemon then
2425
+ * fails to start on a config that references files that no longer exist.
2426
+ *
2427
+ * The key pair and topology are deliberately kept: destroying the key
2428
+ * invalidates every spoke's peer entry, and topology is authored source
2429
+ * that nothing can regenerate. A reset is for reconfiguring an edge rather
2430
+ * than for re-establishing trust with all of them; re-keying is what
2431
+ * {@link UnderpostWireguard.reinstall} is for.
2432
+ * @param {object} options - CLI options.
2433
+ * @returns {void}
2434
+ * @memberof UnderpostWireguard
2435
+ */
2436
+ reset(options = {}) {
2437
+ const state = readEdgeContext();
2438
+ const interfaceName = `${options.interface || state.interfaceName}`.trim();
2439
+ UnderpostWireguard.API.stop({ ...options, interface: interfaceName });
2440
+ runHostCommands(
2441
+ [
2442
+ `sudo rm -f ${UNDERPOST_EDGE.wireguardDir}/${interfaceName}.conf`,
2443
+ `sudo rm -f ${UNDERPOST_EDGE.sysctlPath}`,
2444
+ systemctlCommandFactory({
2445
+ action: 'disable --now',
2446
+ name: 'haproxy',
2447
+ stderr: true,
2448
+ allowFailure: true,
2449
+ }),
2450
+ `sudo rm -f ${UNDERPOST_EDGE.haproxyDir}/${UNDERPOST_EDGE.sniMapName}`,
2451
+ `sudo rm -f ${UNDERPOST_EDGE.haproxyDir}/${UNDERPOST_EDGE.httpMapName}`,
2452
+ `sudo rm -f ${UNDERPOST_EDGE.haproxyDir}/${UNDERPOST_EDGE.haproxyConfName}`,
2453
+ ...forwardProxyServiceCommandsFactory().remove,
2454
+ ...firewallCommandsFactory({
2455
+ role: state.role,
2456
+ interfaceName,
2457
+ listenPort: state.listenPort,
2458
+ tunnelCidr: tunnelNetworkCidrFactory(state.address),
2459
+ sshForwardPort: state.sshForwardPort,
2460
+ remove: true,
2461
+ }),
2462
+ ],
2463
+ options.dryRun,
2464
+ );
2465
+ logger.info('Edge host state removed; key pair, topology and node identity retained', {
2466
+ interfaceName,
2467
+ role: state.role,
2468
+ firewallWithdrawn: true,
2469
+ });
2470
+ },
2471
+
2472
+ /**
2473
+ * @method reinstall
2474
+ * @description Full purge and rebuild: reset, drop the key pair, reinstall
2475
+ * the packages, then re-key and republish.
2476
+ *
2477
+ * Every spoke has to be re-registered afterwards, because the hub's identity
2478
+ * changed — that is the point of the command, and the reason it is separate
2479
+ * from `--wireguard-reset`.
2480
+ * @param {object} options - CLI options.
2481
+ * @returns {void}
2482
+ * @memberof UnderpostWireguard
2483
+ */
2484
+ reinstall(options = {}) {
2485
+ const state = readEdgeContext();
2486
+ const interfaceName = `${options.interface || state.interfaceName}`.trim();
2487
+ UnderpostWireguard.API.reset({ ...options, interface: interfaceName });
2488
+ runHostCommands(
2489
+ [
2490
+ `sudo rm -f ${UNDERPOST_EDGE.wireguardDir}/${interfaceName}.key`,
2491
+ `sudo rm -f ${UNDERPOST_EDGE.wireguardDir}/${interfaceName}.pub`,
2492
+ `sudo dnf -y reinstall ${UNDERPOST_EDGE.packages.join(' ')} || sudo dnf -y install ${UNDERPOST_EDGE.packages.join(' ')}`,
2493
+ ],
2494
+ options.dryRun,
2495
+ );
2496
+ const next = UnderpostWireguard.API.setup({ ...options, interface: interfaceName });
2497
+ logger.warn('Re-keyed: this machine now presents a new identity, and the far end still expects the old one', {
2498
+ interfaceName,
2499
+ publicKey: next.publicKey,
2500
+ topology:
2501
+ next.role === 'hub'
2502
+ ? `hub ${next.hubHost} now has publicKey '${next.publicKey}'`
2503
+ : `peer ${next.peerId} on hub ${next.hubHost} now has publicKey '${next.publicKey}'`,
2504
+ });
2505
+ },
2506
+
2507
+ /**
2508
+ * @method syncTargets
2509
+ * @description Every node the engine is deployed on, with the identity that
2510
+ * reaches it.
2511
+ *
2512
+ * Both registries are joined through the same resolvers remediation uses,
2513
+ * so a node the engine can be synced on is exactly a node it can be repaired
2514
+ * on — a second host list here would be free to disagree with that.
2515
+ * @param {object} [options] - CLI options (`nodes`).
2516
+ * @returns {Array<object>} Resolved targets, hubs first.
2517
+ * @throws {Error} When a selector matches no registered node.
2518
+ * @memberof UnderpostWireguard
2519
+ */
2520
+ syncTargets(options = {}) {
2521
+ const selectors = parseList(options.nodes);
2522
+ const matched = new Set();
2523
+ const selected = (nodeName, id) => {
2524
+ const hit = selectors.find((selector) => selector === nodeName || selector === id);
2525
+ if (hit) matched.add(hit);
2526
+ return selectors.length === 0 || Boolean(hit);
2527
+ };
2528
+
2529
+ const targets = [
2530
+ ...Underpost.event
2531
+ .hubs()
2532
+ .filter((hub) => selected(hub.nodeName, hub.hubHost))
2533
+ .map((hub) => ({ ...Underpost.event.hubTarget(hub.hubHost), nodeName: hub.nodeName || hub.hubHost })),
2534
+ ...Underpost.event
2535
+ .spokes()
2536
+ .filter((spoke) => selected(spoke.nodeName, spoke.id))
2537
+ .map((spoke) => ({ ...Underpost.event.spokeTarget(spoke.id), nodeName: spoke.nodeName || spoke.id })),
2538
+ ];
2539
+
2540
+ const unknown = selectors.filter((selector) => !matched.has(selector));
2541
+ if (unknown.length > 0) throw new Error(`[wireguard] no registered node matches: ${unknown.join(', ')}`);
2542
+ return targets;
2543
+ },
2544
+
2545
+ /**
2546
+ * @method connectUri
2547
+ * @description The SSH command that reaches each selected node, built from
2548
+ * the same node-to-identity join every fleet action uses.
2549
+ *
2550
+ * A node is named by its document (`deploy/nodes/<node-name>.json`), and the
2551
+ * address that document resolves to — a hub's public host, a spoke's
2552
+ * management host — is the key `conf.users.json` is registered under, so the
2553
+ * URI is the connection the cluster already holds rather than one composed
2554
+ * by hand. A node that is this machine resolves to no URI: there is no hop
2555
+ * to make.
2556
+ * @param {object} [options] - CLI options (`nodes`).
2557
+ * @returns {Array<{nodeName: string, via: string, uri: string}>} One entry per selected node, hubs first.
2558
+ * @throws {Error} When no node is registered, or a selector matches none.
2559
+ * @memberof UnderpostWireguard
2560
+ */
2561
+ connectUri(options = {}) {
2562
+ const targets = Underpost.wireguard.syncTargets(options);
2563
+ if (targets.length === 0) throw new Error(`[wireguard] no node is registered in ${EDGE_TOPOLOGY_PATH}`);
2564
+ return targets.map((target) => ({
2565
+ nodeName: target.nodeName,
2566
+ via: target.via,
2567
+ uri: sshUriFactory(target),
2568
+ }));
2569
+ },
2570
+
2571
+ /**
2572
+ * @method nodeExporter
2573
+ * @description Provisions the host metrics collector on the nodes the
2574
+ * cluster cannot schedule it onto.
2575
+ *
2576
+ * Only hubs: every cluster node already runs the collector as a DaemonSet
2577
+ * pod, so a second copy there would bind a port the pod holds. The
2578
+ * collector listens on the node's tunnel address, which is the address
2579
+ * Prometheus already scrapes it at.
2580
+ * @param {object} [options] - CLI options (`nodes`, `dryRun`).
2581
+ * @returns {Promise<{ok: boolean, nodes: Array<object>}>} Per-node outcome.
2582
+ * @memberof UnderpostWireguard
2583
+ */
2584
+ async nodeExporter(options = {}) {
2585
+ const targets = Underpost.wireguard.syncTargets(options).filter((target) => target.role === 'hub');
2586
+ if (targets.length === 0)
2587
+ throw new Error(
2588
+ `[wireguard] no hub node ${
2589
+ parseList(options.nodes).length > 0 ? 'matches --nodes' : `is registered in ${EDGE_TOPOLOGY_PATH}`
2590
+ }; cluster nodes run the collector as a DaemonSet, so only hubs are provisioned here`,
2591
+ );
2592
+ const nodes = [];
2593
+
2594
+ for (const target of targets) {
2595
+ logger.info('Provisioning host metrics collector', { node: target.nodeName, address: target.address });
2596
+ const result = await Underpost.event.runCommand(
2597
+ nodeExporterServiceScriptFactory({ host: target.address, interfaceName: options.interface || 'wg0' }),
2598
+ { ...options, user: target.user, host: target.host },
2599
+ );
2600
+ if (!result.ok)
2601
+ logger.error('Collector provisioning failed', {
2602
+ node: target.nodeName,
2603
+ output: `${result.error || result.output || ''}`.trim().slice(-1500),
2604
+ });
2605
+ nodes.push({ nodeName: target.nodeName, via: target.via, ok: result.ok });
2606
+ }
2607
+
2608
+ return { ok: nodes.every((node) => node.ok), nodes };
2609
+ },
2610
+
2611
+ /**
2612
+ * @method localRole
2613
+ * @description This machine's node role, or `''` when it is not a fleet node.
2614
+ *
2615
+ * The empty answer is the meaningful one: a workstation or CI runner has no node document, and
2616
+ * is not a node whose capabilities can be checked. Callers gate on a role when there is one and
2617
+ * leave an off-fleet machine alone, so authorization follows where an operation executes rather
2618
+ * than which module it lives in.
2619
+ * @returns {string} `hub`, `control`, `worker`, or `''`.
2620
+ * @memberof UnderpostWireguard
2621
+ */
2622
+ localRole() {
2623
+ try {
2624
+ return readEdgeContext().role || '';
2625
+ } catch {
2626
+ return '';
2627
+ }
2628
+ },
2629
+
2630
+ /**
2631
+ * @method syncCommands
2632
+ * @description The sync sequence, bound to the repositories it pulls from.
2633
+ *
2634
+ * `--repo-engine` accepts `owner/repo` or a clone URL and defaults to the
2635
+ * configured GitHub account's `engine`. The private repository is derived
2636
+ * from the conf id the two share rather than named separately, so syncing a
2637
+ * node onto `engine-test-lampp` brings `engine-lampp-private` with it —
2638
+ * the pair {@link UnderpostRun.pull} resolves, resolved the same way.
2639
+ * `--repo-engine-private` overrides that derivation with its own
2640
+ * `owner/repo` or clone URL.
2641
+ *
2642
+ * `--cmd` replaces the whole sequence with the given comma-separated
2643
+ * commands, each running as a fail-fast step, so `--sync --cmd 'uptime'` is
2644
+ * a fleet-wide shell over the same SSH identity rather than a sync.
2645
+ *
2646
+ * Both default branches are resolved here, once, and named explicitly in the
2647
+ * commands. The node is about to replace the checkout it is running from, so
2648
+ * asking it to work out which branch to fetch would depend on the very
2649
+ * tooling the step exists to renew: a node carrying an older `underpost`
2650
+ * resolves it differently, or not at all, and fetches a ref that does not
2651
+ * exist — after origin has already been repointed.
2652
+ * @param {object} [options] - CLI options (`cmd`, `repoEngine`, `repoEnginePrivate`), plus the
2653
+ * `nodeRole` of the node the steps are for.
2654
+ * @returns {Array<{command: string, halt: boolean}>} Steps in execution order.
2655
+ * @memberof UnderpostWireguard
2656
+ */
2657
+ syncCommands(options = {}) {
2658
+ const custom = parseList(options.cmd);
2659
+ if (custom.length > 0) return custom.map((command) => ({ command, halt: true }));
2660
+ const { engine, enginePrivate } = Underpost.repo.enginePairFactory({
2661
+ engine: options.repoEngine,
2662
+ enginePrivate: options.repoEnginePrivate,
2663
+ });
2664
+ const values = {
2665
+ '<engine-private-branch>': Underpost.repo.getDefaultBranch(enginePrivate),
2666
+ '<engine-private>': enginePrivate,
2667
+ '<engine-branch>': Underpost.repo.getDefaultBranch(engine),
2668
+ '<engine>': engine,
2669
+ '<deploy-package-script>': deployPackageScriptFactory(engine),
2670
+ ...nodeRoleStepsFactory(options.nodeRole),
2671
+ '<github-token>': process.env.GITHUB_TOKEN || '',
2672
+ };
2673
+ const entries = Object.entries(values);
2674
+ return ENGINE_SYNC_STEPS.filter(({ command }) =>
2675
+ entries.every(([placeholder, value]) => value || !command.includes(placeholder)),
2676
+ ).map((step) => ({
2677
+ ...step,
2678
+ command: entries.reduce(
2679
+ (command, [placeholder, value]) => command.replaceAll(placeholder, value),
2680
+ step.command,
2681
+ ),
2682
+ }));
2683
+ },
2684
+
2685
+ /**
2686
+ * @method syncScript
2687
+ * @description The whole sequence as one command.
2688
+ *
2689
+ * A node is reached once, not once per step: each SSH session re-reads the
2690
+ * credential store, re-authenticates and re-enters the checkout, so running
2691
+ * six of them per host is six times the handshake for one unit of work — and
2692
+ * a step could land on a different session than the one before it. `&&`
2693
+ * carries the halt order; the advisory step is neutralized in place so a
2694
+ * remaining audit finding cannot stop the install behind it.
2695
+ * When `--cmd` is set the label becomes `[cmd]` so a fleet run reads as what
2696
+ * it actually executed rather than a sync.
2697
+ * @param {object} [options] - CLI options (`cmd`, `repoEngine`, `repoEnginePrivate`), plus the
2698
+ * `nodeRole` of the node the script is for.
2699
+ * @returns {string} Composed remote command.
2700
+ * @memberof UnderpostWireguard
2701
+ */
2702
+ syncScript(options = {}) {
2703
+ const commands = Underpost.wireguard.syncCommands(options);
2704
+ const label = parseList(options.cmd).length > 0 ? 'cmd' : 'sync';
2705
+ return commands
2706
+ .map(
2707
+ ({ command, halt }) =>
2708
+ `echo '[${label}] ${redactSensitiveText(command)}' && ${halt ? command : `{ ${command} || true; }`}`,
2709
+ )
2710
+ .join(' && ');
2711
+ },
2712
+
2713
+ /**
2714
+ * @method sync
2715
+ * @description Brings every node's engine checkout to the current sources,
2716
+ * over the SSH identity registered for it.
2717
+ *
2718
+ * One node failing does not stop the others: they are independent hosts, and
2719
+ * a partially synced fleet is reported rather than hidden. Each node is one
2720
+ * session, and the `[sync]` line it last echoed names the step it stopped at. The dispatcher
2721
+ * step each node receives is the one its own role calls for.
2722
+ * @param {object} [options] - CLI options (`cmd`, `nodes`, `repoEngine`, `repoEnginePrivate`, `dryRun`).
2723
+ * @returns {Promise<{ok: boolean, nodes: Array<object>}>} Per-node outcome.
2724
+ * @memberof UnderpostWireguard
2725
+ */
2726
+ async sync(options = {}) {
2727
+ // The engine repositories are private, and their credentials live in the
2728
+ // cron deploy environment rather than the host's.
2729
+ loadCronDeployEnv();
2730
+ const allTargets = Underpost.wireguard.syncTargets(options);
2731
+ if (allTargets.length === 0) throw new Error(`[wireguard] no node is registered in ${EDGE_TOPOLOGY_PATH}`);
2732
+
2733
+ // This sequence repoints origin and hard-resets the checkout it runs in, so the machine
2734
+ // driving it is the source and never a destination. Which machine that is, is decided
2735
+ // under the hub by the address a peer is registered at — `managementHost` is unique,
2736
+ // where a hostname is not: the control plane and a workstation are both
2737
+ // `localhost.localdomain`, and matching on that name would aim the switch at whichever
2738
+ // of them happened to run the command. Dispatch is SSH-only for the same reason.
2739
+ const addresses = hostAddressesFactory();
2740
+ const runsHere = (target) => !target.user || addresses.has(`${target.host || ''}`.trim());
2741
+ const targets = allTargets.filter((target) => !runsHere(target));
2742
+ for (const target of allTargets.filter(runsHere))
2743
+ logger.warn('Skipping the node this command runs from; it is the sync source, not a destination', {
2744
+ node: target.nodeName,
2745
+ via: target.via,
2746
+ hint: 'update this checkout with git, or run the sync from another node',
2747
+ });
2748
+ if (targets.length === 0)
2749
+ throw new Error(
2750
+ `[wireguard] every selected node resolves to this machine, which is the sync source; ` +
2751
+ `nothing would be synced.`,
2752
+ );
2753
+
2754
+ const custom = parseList(options.cmd).length > 0;
2755
+ if (!custom && options.dryRun !== true) {
2756
+ const { engine } = Underpost.repo.enginePairFactory({
2757
+ engine: options.repoEngine,
2758
+ enginePrivate: options.repoEnginePrivate,
2759
+ });
2760
+ const deployId = deployIdFactory(Underpost.repo.confIdFactory(engine));
2761
+ if (deployId) {
2762
+ await syncDeployPackages({ deployIds: [deployId] });
2763
+ const { privateConfPaths } = await loadDeployCatalog(deployId);
2764
+ syncPrivateConf(deployId, privateConfPaths);
2765
+ }
2766
+ }
2767
+
2768
+ // One script per role, not per node: the branches it names are resolved against the remotes,
2769
+ // and every node of a role is sent the same sequence.
2770
+ const scripts = new Map();
2771
+ const scriptFor = (nodeRole) => {
2772
+ if (!scripts.has(nodeRole)) scripts.set(nodeRole, Underpost.wireguard.syncScript({ ...options, nodeRole }));
2773
+ return scripts.get(nodeRole);
2774
+ };
2775
+ const nodes = [];
2776
+
2777
+ for (const target of targets) {
2778
+ logger.info(custom ? 'Running custom commands' : 'Syncing engine checkout', {
2779
+ node: target.nodeName,
2780
+ via: target.via,
2781
+ });
2782
+ const result = await Underpost.event.runCommand(scriptFor(targetRoleFactory(target)), {
2783
+ ...options,
2784
+ user: target.user,
2785
+ host: target.host,
2786
+ requireRemote: true,
2787
+ });
2788
+ if (!result.ok)
2789
+ logger.error('Sync failed', {
2790
+ node: target.nodeName,
2791
+ output: `${result.error || result.output || ''}`.trim().slice(-1500),
2792
+ });
2793
+ nodes.push({ nodeName: target.nodeName, via: target.via, ok: result.ok });
2794
+ }
2795
+
2796
+ return { ok: nodes.every((node) => node.ok), nodes };
2797
+ },
2798
+
2799
+ /**
2800
+ * @method callback
2801
+ * @description CLI entry point for both `underpost wireguard` and
2802
+ * `underpost haproxy`.
2803
+ *
2804
+ * Flags are evaluated in lifecycle order — install, setup, peer changes,
2805
+ * route publication, then daemon control — so a single invocation can carry
2806
+ * a whole bring-up (`--node-config --wireguard-install --wireguard-setup
2807
+ * --haproxy-setup --wireguard-start`) and still execute the steps in the only
2808
+ * order that works. `--status` runs last, so it reports what the run left
2809
+ * behind.
2810
+ * @param {object} [options] - CLI options.
2811
+ * @returns {Promise<void>}
2812
+ * @memberof UnderpostWireguard
2813
+ */
2814
+ async callback(options = {}) {
2815
+ // Fleet-wide and identity-independent: they reach other machines rather
2816
+ // than reconciling this one, so they never fall through to a host action.
2817
+ // `--cmd` alone is a fleet run too; given with `--sync` it replaces the
2818
+ // sync steps inside the same `sync()` runner, so one dispatch covers both.
2819
+ if (options.sync === true || options.cmd) return reportFleetOutcome(await UnderpostWireguard.API.sync(options));
2820
+ if (options.nodeExporter === true) return reportFleetOutcome(await UnderpostWireguard.API.nodeExporter(options));
2821
+ if (options.connectUri === true) {
2822
+ const entries = UnderpostWireguard.API.connectUri(options);
2823
+ // One node prints a pasteable command and nothing else; a fleet listing
2824
+ // names the node each command belongs to.
2825
+ const output = entries
2826
+ .map(({ nodeName, uri, via }) => {
2827
+ const value = uri || `# ${nodeName} is this machine (${via})`;
2828
+ return entries.length === 1 ? value : `${nodeName.padEnd(28)} ${value}`;
2829
+ })
2830
+ .join('\n');
2831
+ if (options.copy === true) pbcopy(output);
2832
+ else console.log(output);
2833
+ return;
2834
+ }
2835
+
2836
+ if (options.nodeConfig === true) UnderpostWireguard.API.nodeConfig(options);
2837
+ // `--build-conf` is a hard promise, not a modifier: it short-circuits
2838
+ // every host action so the run cannot touch /etc, iptables, systemd or a
2839
+ // live interface even when other lifecycle flags are also present.
2840
+ if (options.buildConf === true) {
2841
+ if (options.wireguardSetup === true) UnderpostWireguard.API.setup(options);
2842
+ if (options.peerAdd) UnderpostWireguard.API.peerAdd(options);
2843
+ if (options.peerRemove) UnderpostWireguard.API.peerRemove(options);
2844
+ if (!options.wireguardSetup && !options.peerAdd && !options.peerRemove)
2845
+ UnderpostWireguard.API.buildConf(options);
2846
+ if (options.status === true) UnderpostWireguard.API.status(options);
2847
+ return;
2848
+ }
2849
+
2850
+ const hostActions = [
2851
+ options.wireguardInstall,
2852
+ options.wireguardSetup,
2853
+ options.peerAdd,
2854
+ options.peerRemove,
2855
+ options.haproxySetup,
2856
+ options.haproxySync,
2857
+ options.wireguardStop,
2858
+ options.wireguardStart,
2859
+ options.wireguardRestart,
2860
+ options.forwardProxyServer,
2861
+ options.status,
2862
+ options.check,
2863
+ options.wireguardReset,
2864
+ options.wireguardReinstall,
2865
+ ];
2866
+ if (options.nodeConfig === true && !hostActions.some(Boolean)) return;
2867
+
2868
+ if (options.wireguardReinstall === true) return void UnderpostWireguard.API.reinstall(options);
2869
+ if (options.wireguardReset === true) return void UnderpostWireguard.API.reset(options);
2870
+ if (options.wireguardInstall === true) UnderpostWireguard.API.install(options);
2871
+ if (options.wireguardSetup === true) UnderpostWireguard.API.setup(options);
2872
+ if (options.peerAdd) UnderpostWireguard.API.peerAdd(options);
2873
+ if (options.peerRemove) UnderpostWireguard.API.peerRemove(options);
2874
+ if (options.haproxySetup === true) UnderpostWireguard.API.haproxySetup(options);
2875
+ else if (options.haproxySync === true) UnderpostWireguard.API.haproxySync(options);
2876
+ if (options.wireguardStop === true) UnderpostWireguard.API.stop(options);
2877
+ if (options.wireguardStart === true) UnderpostWireguard.API.start(options);
2878
+ if (options.wireguardRestart === true) UnderpostWireguard.API.restart(options);
2879
+ // After the tunnel, because the service requires it and its address only
2880
+ // exists once the interface is up; before `--status`, so a run that
2881
+ // reconciles the service also reports it.
2882
+ if (options.forwardProxyServer === true) UnderpostWireguard.API.forwardProxyServer(options);
2883
+ if (options.check === true) UnderpostWireguard.API.check(options);
2884
+ if (options.status === true) UnderpostWireguard.API.status(options);
2885
+ },
2886
+ };
2887
+ }
2888
+
2889
+ export {
2890
+ EDGE_TOPOLOGY_PATH,
2891
+ ENGINE_SYNC_STEPS,
2892
+ UNDERPOST_EDGE,
2893
+ allowedIpsConflictsFactory,
2894
+ assertEdgeIdentity,
2895
+ backendNameFactory,
2896
+ defaultPeerFactory,
2897
+ deployListFactory,
2898
+ edgeRouteTableFactory,
2899
+ edgeContextFactory,
2900
+ endpointHostFactory,
2901
+ firewallCommandsFactory,
2902
+ haproxyConfFactory,
2903
+ haproxyMapsFactory,
2904
+ hostProxyEntriesFactory,
2905
+ hostAddressesFactory,
2906
+ hubTunnelAddressFactory,
2907
+ instanceProxyEntriesFactory,
2908
+ localPeerFactory,
2909
+ mergeRouteTablesFactory,
2910
+ peerFactory,
2911
+ quicForwardCommandsFactory,
2912
+ readEdgeContext,
2913
+ readNodeConfigs,
2914
+ readTopology,
2915
+ redirectHostFactory,
2916
+ sshUriFactory,
2917
+ unregisteredPeersFactory,
2918
+ tunnelAddressFactory,
2919
+ tunnelNetworkCidrFactory,
2920
+ wireguardClientConfFactory,
2921
+ wireguardHealthFactory,
2922
+ wireguardServerConfFactory,
2923
+ wireguardStatusFactory,
2924
+ topologyFactory,
2925
+ hubFactory,
2926
+ nodeIdentityFactory,
2927
+ nodeNameCandidatesFactory,
2928
+ };
2929
+
2930
+ export default UnderpostWireguard;