@underpostnet/cyberia 3.3.0 → 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 (471) hide show
  1. package/.dockerignore +1 -0
  2. package/.github/workflows/coverall.cyberia.ci.yml +34 -14
  3. package/.github/workflows/cyberia-client.cd.yml +5 -0
  4. package/.github/workflows/cyberia-server.cd.yml +5 -0
  5. package/.github/workflows/docker-image.cyberia-client.ci.yml +8 -5
  6. package/.github/workflows/docker-image.cyberia-client.dev.ci.yml +8 -3
  7. package/.github/workflows/docker-image.cyberia-server.ci.yml +8 -5
  8. package/.github/workflows/docker-image.cyberia-server.dev.ci.yml +8 -5
  9. package/.github/workflows/docker-image.engine-cyberia.ci.yml +15 -1
  10. package/.github/workflows/docker-image.engine-cyberia.dev.ci.yml +15 -1
  11. package/.github/workflows/engine-cyberia.cd.yml +3 -0
  12. package/.github/workflows/engine-cyberia.ci.yml +4 -5
  13. package/.github/workflows/ghpkg.ci.yml +35 -20
  14. package/.github/workflows/hardhat.ci.yml +12 -11
  15. package/.github/workflows/npmpkg.ci.yml +17 -15
  16. package/.github/workflows/pwa-microservices-template-test.ci.yml +2 -2
  17. package/.github/workflows/release.cd.yml +1 -0
  18. package/.prettierignore +1 -0
  19. package/AGENTS.md +47 -0
  20. package/CHANGELOG.md +565 -2510
  21. package/CLI-HELP.md +306 -147
  22. package/Dockerfile +25 -9
  23. package/Dockerfile.dev +25 -9
  24. package/Dockerfile.test +4 -4
  25. package/README.md +21 -1
  26. package/bin/build.js +66 -41
  27. package/bin/build.template.js +8 -3
  28. package/bin/cyberia.js +308 -91
  29. package/bin/deploy.js +18 -104
  30. package/bin/index.js +308 -91
  31. package/conf.js +91 -4
  32. package/deploy/cyberia-client/deploy.sh +47 -0
  33. package/deploy/cyberia-client/package.sh +18 -0
  34. package/deploy/cyberia-client/state.sh +14 -0
  35. package/deploy/cyberia-server/deploy.sh +47 -0
  36. package/deploy/cyberia-server/package.sh +18 -0
  37. package/deploy/cyberia-server/state.sh +14 -0
  38. package/deploy/dd-cyberia/deploy.sh +6 -26
  39. package/deploy/dd-cyberia/init.sh +35 -43
  40. package/deploy/dd-cyberia/package.sh +17 -0
  41. package/deploy/dd-cyberia/state.sh +14 -0
  42. package/deploy/dd-cyberia/sync-deploy.sh +92 -170
  43. package/deploy/lib/github-actions-logging.sh +594 -0
  44. package/deploy/lib/host.sh +185 -0
  45. package/deploy/lib/state.sh +62 -0
  46. package/deploy/pwa-microservices-template/deploy.sh +12 -39
  47. package/deploy/release/deploy.sh +9 -39
  48. package/deployment.yaml +7 -2
  49. package/docker-compose.yml +7 -7
  50. package/examples/static-page/README.md +19 -19
  51. package/examples/static-page/ssr-components/CustomPage.js +2 -2
  52. package/hardhat/hardhat.config.js +1 -6
  53. package/hardhat/package-lock.json +101 -40
  54. package/hardhat/package.json +2 -2
  55. package/manifests/alertmanager/deployment.yaml +81 -0
  56. package/manifests/blackbox-exporter/deployment.yaml +75 -0
  57. package/manifests/cronjobs/dd-cron/dd-cron-backup.yaml +32 -7
  58. package/manifests/cronjobs/dd-cron/dd-cron-vultr.yaml +32 -7
  59. package/manifests/deployment/dd-cyberia-development/deployment.yaml +7 -2
  60. package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
  61. package/manifests/grafana/deployment.yaml +26 -7
  62. package/manifests/grafana/kustomization.yaml +1 -0
  63. package/manifests/grafana/service.yaml +6 -1
  64. package/manifests/mariadb/pv.yaml +1 -0
  65. package/manifests/mongodb/statefulset.yaml +9 -2
  66. package/manifests/mysql/pv-pvc.yaml +14 -1
  67. package/manifests/postgresql/pv.yaml +12 -1
  68. package/manifests/postgresql/pvc.yaml +1 -0
  69. package/manifests/prometheus/deployment.yaml +18 -22
  70. package/package.json +83 -10
  71. package/scripts/event-monitor.sh +56 -0
  72. package/scripts/k3s-node-setup.sh +27 -14
  73. package/scripts/kubeadm-node-setup.sh +31 -11
  74. package/scripts/maas-setup.sh +8 -8
  75. package/scripts/selinux-normalize.sh +907 -0
  76. package/scripts/shutdown-machine.sh +843 -0
  77. package/scripts/test-monitor.sh +3 -3
  78. package/src/api/atlas-sprite-sheet/atlas-sprite-sheet.controller.js +1 -1
  79. package/src/api/atlas-sprite-sheet/atlas-sprite-sheet.router.js +2 -2
  80. package/src/api/atlas-sprite-sheet/atlas-sprite-sheet.service.js +2 -2
  81. package/src/api/core/core.controller.js +1 -1
  82. package/src/api/core/core.router.js +2 -2
  83. package/src/api/core/core.service.js +2 -2
  84. package/src/api/crypto/crypto.controller.js +1 -1
  85. package/src/api/crypto/crypto.service.js +1 -1
  86. package/src/api/cyberia-action/cyberia-action.controller.js +1 -1
  87. package/src/api/cyberia-action/cyberia-action.router.js +1 -1
  88. package/src/api/cyberia-action/cyberia-action.service.js +2 -2
  89. package/src/api/cyberia-client-hints/cyberia-client-hints.controller.js +1 -1
  90. package/src/api/cyberia-client-hints/cyberia-client-hints.router.js +1 -1
  91. package/src/api/cyberia-client-hints/cyberia-client-hints.service.js +2 -2
  92. package/src/api/cyberia-dialogue/cyberia-dialogue.controller.js +1 -1
  93. package/src/api/cyberia-dialogue/cyberia-dialogue.router.js +1 -1
  94. package/src/api/cyberia-dialogue/cyberia-dialogue.service.js +2 -2
  95. package/src/api/cyberia-entity/cyberia-entity.controller.js +1 -1
  96. package/src/api/cyberia-entity/cyberia-entity.router.js +1 -1
  97. package/src/api/cyberia-entity/cyberia-entity.service.js +2 -2
  98. package/src/api/cyberia-entity-type-default/cyberia-entity-type-default.controller.js +1 -1
  99. package/src/api/cyberia-entity-type-default/cyberia-entity-type-default.router.js +1 -1
  100. package/src/api/cyberia-entity-type-default/cyberia-entity-type-default.service.js +2 -2
  101. package/src/api/cyberia-instance/cyberia-fallback-capture.js +466 -0
  102. package/src/api/cyberia-instance/cyberia-fallback-world.js +8 -0
  103. package/src/api/cyberia-instance/cyberia-instance-map.service.js +1 -1
  104. package/src/api/cyberia-instance/cyberia-instance.controller.js +1 -1
  105. package/src/api/cyberia-instance/cyberia-instance.router.js +2 -2
  106. package/src/api/cyberia-instance/cyberia-instance.service.js +2 -2
  107. package/src/api/cyberia-instance-conf/cyberia-instance-conf.controller.js +1 -1
  108. package/src/api/cyberia-instance-conf/cyberia-instance-conf.router.js +1 -1
  109. package/src/api/cyberia-instance-conf/cyberia-instance-conf.service.js +2 -2
  110. package/src/api/cyberia-map/cyberia-map.controller.js +1 -1
  111. package/src/api/cyberia-map/cyberia-map.router.js +1 -1
  112. package/src/api/cyberia-map/cyberia-map.service.js +2 -2
  113. package/src/api/cyberia-quest/cyberia-quest.controller.js +1 -1
  114. package/src/api/cyberia-quest/cyberia-quest.router.js +1 -1
  115. package/src/api/cyberia-quest/cyberia-quest.service.js +2 -2
  116. package/src/api/cyberia-quest-progress/cyberia-quest-progress.controller.js +1 -1
  117. package/src/api/cyberia-quest-progress/cyberia-quest-progress.router.js +1 -1
  118. package/src/api/cyberia-quest-progress/cyberia-quest-progress.service.js +2 -2
  119. package/src/api/cyberia-saga/cyberia-saga.controller.js +1 -1
  120. package/src/api/cyberia-saga/cyberia-saga.router.js +1 -1
  121. package/src/api/cyberia-saga/cyberia-saga.service.js +2 -2
  122. package/src/api/cyberia-server-defaults/cyberia-server-defaults.js +0 -26
  123. package/src/api/cyberia-skill/cyberia-skill.controller.js +1 -1
  124. package/src/api/cyberia-skill/cyberia-skill.router.js +1 -1
  125. package/src/api/cyberia-skill/cyberia-skill.service.js +2 -2
  126. package/src/api/default/default.controller.js +1 -1
  127. package/src/api/default/default.router.js +1 -1
  128. package/src/api/default/default.service.js +2 -2
  129. package/src/api/document/document.controller.js +1 -1
  130. package/src/api/document/document.router.js +1 -1
  131. package/src/api/document/document.service.js +2 -2
  132. package/src/api/file/file.controller.js +1 -1
  133. package/src/api/file/file.router.js +1 -1
  134. package/src/api/file/file.service.js +2 -2
  135. package/src/api/instance/instance.controller.js +1 -1
  136. package/src/api/instance/instance.router.js +1 -1
  137. package/src/api/instance/instance.service.js +2 -2
  138. package/src/api/ipfs/ipfs.controller.js +1 -1
  139. package/src/api/ipfs/ipfs.router.js +2 -2
  140. package/src/api/ipfs/ipfs.service.js +2 -2
  141. package/src/api/object-layer/object-layer.controller.js +1 -1
  142. package/src/api/object-layer/object-layer.router.js +2 -2
  143. package/src/api/object-layer/object-layer.service.js +3 -3
  144. package/src/api/object-layer-render-frames/object-layer-render-frames.controller.js +1 -1
  145. package/src/api/object-layer-render-frames/object-layer-render-frames.router.js +1 -1
  146. package/src/api/object-layer-render-frames/object-layer-render-frames.service.js +2 -2
  147. package/src/api/test/test.controller.js +1 -1
  148. package/src/api/test/test.service.js +1 -1
  149. package/src/api/user/guest.service.js +3 -3
  150. package/src/api/user/user.controller.js +1 -1
  151. package/src/api/user/user.router.js +2 -2
  152. package/src/api/user/user.service.js +4 -4
  153. package/src/api.js +5 -9
  154. package/src/cli/app.js +391 -0
  155. package/src/cli/baremetal.js +30 -35
  156. package/src/cli/client.js +306 -0
  157. package/src/cli/cloud-init.js +3 -3
  158. package/src/cli/cluster.js +184 -82
  159. package/src/cli/db.js +120 -68
  160. package/src/cli/deploy.js +153 -71
  161. package/src/cli/docker-compose.js +5 -5
  162. package/src/cli/domains.js +184 -0
  163. package/src/cli/dotenv-store.js +143 -0
  164. package/src/cli/event.js +2281 -0
  165. package/src/cli/fs.js +3 -3
  166. package/src/cli/host.js +672 -0
  167. package/src/cli/image.js +4 -6
  168. package/src/cli/index.js +322 -142
  169. package/src/cli/ipfs.js +34 -30
  170. package/src/cli/kickstart.js +1 -1
  171. package/src/cli/kubectl.js +218 -41
  172. package/src/cli/lxd.js +3 -3
  173. package/src/cli/monitor.js +1359 -26
  174. package/src/cli/package.js +90 -0
  175. package/src/cli/release.js +16 -12
  176. package/src/cli/repository.js +558 -363
  177. package/src/cli/run.js +331 -650
  178. package/src/cli/secrets.js +2158 -964
  179. package/src/cli/ssh.js +478 -279
  180. package/src/cli/state.js +394 -0
  181. package/src/cli/static.js +2 -2
  182. package/src/cli/system.js +1 -1
  183. package/src/cli/test.js +199 -125
  184. package/src/cli/vultr.js +61 -21
  185. package/src/cli/wireguard.js +1111 -306
  186. package/src/client/components/core/CalendarCore.js +53 -96
  187. package/src/client/components/core/Docs.js +258 -211
  188. package/src/client/components/core/FileExplorer.js +0 -21
  189. package/src/client/components/core/PanelForm.js +4 -4
  190. package/src/client/components/core/Repository.js +56 -0
  191. package/src/client/components/core/Translate.js +7 -0
  192. package/src/client/components/core/{FullScreen.js → ViewModeController.js} +42 -39
  193. package/src/client/components/core/Wallet.js +0 -11
  194. package/src/client/components/core/Worker.js +7 -1
  195. package/src/client/components/cryptokoyn/SettingsCryptokoyn.js +2 -2
  196. package/src/client/components/cyberia-portal/AppShellCyberiaPortal.js +2 -1
  197. package/src/client/components/cyberia-portal/SettingsCyberiaPortal.js +2 -2
  198. package/src/client/components/default/AppShellDefault.js +1 -1
  199. package/src/client/components/default/SettingsDefault.js +2 -2
  200. package/src/client/components/itemledger/SettingsItemledger.js +2 -2
  201. package/src/client/components/underpost/AppShellUnderpost.js +3 -2
  202. package/src/client/components/underpost/SettingsUnderpost.js +2 -2
  203. package/src/client/public/cyberia-docs/CYBERIA-CLI.md +32 -5
  204. package/src/client/public/cyberia-docs/CYBERIA.md +21 -1
  205. package/src/client/public/cyberia-docs/HARDHAT-MODULE.md +23 -6
  206. package/src/client/public/cyberia-docs/ROADMAP.md +1 -1
  207. package/src/client/public/cyberia-docs/WHITE-PAPER.md +1 -1
  208. package/src/client/public/default/android-chrome-144x144.png +0 -0
  209. package/src/client/public/default/android-chrome-192x192.png +0 -0
  210. package/src/client/public/default/android-chrome-256x256.png +0 -0
  211. package/src/client/public/default/android-chrome-36x36.png +0 -0
  212. package/src/client/public/default/android-chrome-384x384.png +0 -0
  213. package/src/client/public/default/android-chrome-48x48.png +0 -0
  214. package/src/client/public/default/android-chrome-512x512.png +0 -0
  215. package/src/client/public/default/android-chrome-72x72.png +0 -0
  216. package/src/client/public/default/android-chrome-96x96.png +0 -0
  217. package/src/client/public/default/apple-touch-icon-1024x1024.png +0 -0
  218. package/src/client/public/default/apple-touch-icon-114x114.png +0 -0
  219. package/src/client/public/default/apple-touch-icon-120x120.png +0 -0
  220. package/src/client/public/default/apple-touch-icon-144x144.png +0 -0
  221. package/src/client/public/default/apple-touch-icon-152x152.png +0 -0
  222. package/src/client/public/default/apple-touch-icon-167x167.png +0 -0
  223. package/src/client/public/default/apple-touch-icon-180x180.png +0 -0
  224. package/src/client/public/default/apple-touch-icon-57x57.png +0 -0
  225. package/src/client/public/default/apple-touch-icon-60x60.png +0 -0
  226. package/src/client/public/default/apple-touch-icon-72x72.png +0 -0
  227. package/src/client/public/default/apple-touch-icon-76x76.png +0 -0
  228. package/src/client/public/default/apple-touch-icon-precomposed.png +0 -0
  229. package/src/client/public/default/apple-touch-icon.png +0 -0
  230. package/src/client/public/default/apple-touch-startup-image-1125x2436.png +0 -0
  231. package/src/client/public/default/apple-touch-startup-image-1136x640.png +0 -0
  232. package/src/client/public/default/apple-touch-startup-image-1170x2532.png +0 -0
  233. package/src/client/public/default/apple-touch-startup-image-1179x2556.png +0 -0
  234. package/src/client/public/default/apple-touch-startup-image-1242x2208.png +0 -0
  235. package/src/client/public/default/apple-touch-startup-image-1242x2688.png +0 -0
  236. package/src/client/public/default/apple-touch-startup-image-1284x2778.png +0 -0
  237. package/src/client/public/default/apple-touch-startup-image-1290x2796.png +0 -0
  238. package/src/client/public/default/apple-touch-startup-image-1334x750.png +0 -0
  239. package/src/client/public/default/apple-touch-startup-image-1488x2266.png +0 -0
  240. package/src/client/public/default/apple-touch-startup-image-1536x2048.png +0 -0
  241. package/src/client/public/default/apple-touch-startup-image-1620x2160.png +0 -0
  242. package/src/client/public/default/apple-touch-startup-image-1640x2160.png +0 -0
  243. package/src/client/public/default/apple-touch-startup-image-1668x2224.png +0 -0
  244. package/src/client/public/default/apple-touch-startup-image-1668x2388.png +0 -0
  245. package/src/client/public/default/apple-touch-startup-image-1792x828.png +0 -0
  246. package/src/client/public/default/apple-touch-startup-image-2048x1536.png +0 -0
  247. package/src/client/public/default/apple-touch-startup-image-2048x2732.png +0 -0
  248. package/src/client/public/default/apple-touch-startup-image-2160x1620.png +0 -0
  249. package/src/client/public/default/apple-touch-startup-image-2160x1640.png +0 -0
  250. package/src/client/public/default/apple-touch-startup-image-2208x1242.png +0 -0
  251. package/src/client/public/default/apple-touch-startup-image-2224x1668.png +0 -0
  252. package/src/client/public/default/apple-touch-startup-image-2266x1488.png +0 -0
  253. package/src/client/public/default/apple-touch-startup-image-2388x1668.png +0 -0
  254. package/src/client/public/default/apple-touch-startup-image-2436x1125.png +0 -0
  255. package/src/client/public/default/apple-touch-startup-image-2532x1170.png +0 -0
  256. package/src/client/public/default/apple-touch-startup-image-2556x1179.png +0 -0
  257. package/src/client/public/default/apple-touch-startup-image-2688x1242.png +0 -0
  258. package/src/client/public/default/apple-touch-startup-image-2732x2048.png +0 -0
  259. package/src/client/public/default/apple-touch-startup-image-2778x1284.png +0 -0
  260. package/src/client/public/default/apple-touch-startup-image-2796x1290.png +0 -0
  261. package/src/client/public/default/apple-touch-startup-image-640x1136.png +0 -0
  262. package/src/client/public/default/apple-touch-startup-image-750x1334.png +0 -0
  263. package/src/client/public/default/apple-touch-startup-image-828x1792.png +0 -0
  264. package/src/client/public/default/assets/background/white.jpg +0 -0
  265. package/src/client/public/default/browserconfig.xml +1 -1
  266. package/src/client/public/default/favicon-16x16.png +0 -0
  267. package/src/client/public/default/favicon-32x32.png +0 -0
  268. package/src/client/public/default/favicon-48x48.png +0 -0
  269. package/src/client/public/default/favicon.ico +0 -0
  270. package/src/client/public/default/manifest.webmanifest +5 -5
  271. package/src/client/public/default/mstile-144x144.png +0 -0
  272. package/src/client/public/default/mstile-150x150.png +0 -0
  273. package/src/client/public/default/mstile-310x150.png +0 -0
  274. package/src/client/public/default/mstile-310x310.png +0 -0
  275. package/src/client/public/default/mstile-70x70.png +0 -0
  276. package/src/client/public/default/yandex-browser-50x50.png +0 -0
  277. package/src/client/public/default/yandex-browser-manifest.json +1 -1
  278. package/src/client/ssr/RootDocument.js +1 -1
  279. package/src/client/ssr/body/404.js +1 -1
  280. package/src/client/ssr/body/500.js +1 -1
  281. package/src/client/ssr/body/CacheControl.js +1 -1
  282. package/src/client/ssr/body/CyberiaDefaultSplashScreen.js +1 -1
  283. package/src/client/ssr/body/DefaultSplashScreen.js +1 -1
  284. package/src/client/ssr/body/SwaggerDarkMode.js +1 -1
  285. package/src/client/ssr/body/UnderpostDefaultSplashScreen.js +1 -1
  286. package/src/client/ssr/head/CryptokoynScripts.js +1 -1
  287. package/src/client/ssr/head/Css.js +1 -1
  288. package/src/client/ssr/head/CyberiaPortalScripts.js +1 -1
  289. package/src/client/ssr/head/DefaultScripts.js +1 -1
  290. package/src/client/ssr/head/ItemledgerScripts.js +1 -1
  291. package/src/client/ssr/head/Microdata.js +1 -1
  292. package/src/client/ssr/head/Production.js +1 -1
  293. package/src/client/ssr/head/Pwa.js +1 -1
  294. package/src/client/ssr/head/PwaItemledger.js +1 -1
  295. package/src/client/ssr/head/Seo.js +1 -1
  296. package/src/client/ssr/head/UnderpostScripts.js +1 -1
  297. package/src/client/ssr/mailer/DefaultRecoverEmail.js +1 -1
  298. package/src/client/ssr/mailer/DefaultVerifyEmail.js +1 -1
  299. package/src/client/ssr/views/Cyberia404.js +7 -5
  300. package/src/client/ssr/views/CyberiaServerMetrics.js +1 -1
  301. package/src/client/ssr/views/Maintenance.js +1 -1
  302. package/src/client/ssr/views/NoNetworkConnection.js +1 -1
  303. package/src/client/ssr/views/Test.js +1 -1
  304. package/src/client/sw/core.sw.js +20 -14
  305. package/src/client-builder/client-build-docs.js +61 -46
  306. package/src/client-builder/client-build-live.js +2 -2
  307. package/src/client-builder/client-build.js +38 -30
  308. package/src/client-builder/client-dev-server.js +3 -3
  309. package/src/client-builder/client-icons.js +2 -2
  310. package/src/client-builder/ssr.js +3 -3
  311. package/src/client.build.js +3 -6
  312. package/src/client.dev.js +3 -6
  313. package/src/db/DataBaseProvider.js +186 -3
  314. package/src/db/mariadb/MariaDB.js +3 -2
  315. package/src/db/mongo/MongoBootstrap.js +273 -101
  316. package/src/db/mongo/MongooseDB.js +7 -8
  317. package/src/{server/valkey.js → db/valkey/Valkey.js} +5 -4
  318. package/src/grpc/cyberia/grpc-server.js +1 -1
  319. package/src/index.js +79 -23
  320. package/src/mailer/EmailRender.js +2 -2
  321. package/src/mailer/MailerInterceptor.js +87 -0
  322. package/src/mailer/MailerProvider.js +50 -6
  323. package/src/projects/cyberia/atlas-sprite-sheet-generator.js +1 -1
  324. package/src/projects/cyberia/besu-genesis-generator.js +3 -3
  325. package/src/projects/cyberia/catalog-cyberia.js +53 -10
  326. package/src/projects/cyberia/gemini-client.js +1 -1
  327. package/src/projects/cyberia/generate-saga.js +1 -1
  328. package/src/projects/cyberia/hot-reload-trigger.js +1 -1
  329. package/src/projects/cyberia/instance-data.js +1 -1
  330. package/src/projects/cyberia/ipfs-client.js +4 -4
  331. package/src/projects/cyberia/map-preview-generator.js +1 -1
  332. package/src/projects/cyberia/object-layer.js +1 -1
  333. package/src/projects/cyberia/semantic-layer-generator.js +1 -1
  334. package/src/projects/underpost/catalog-underpost.js +9 -1
  335. package/src/proxy.js +4 -7
  336. package/src/runtime/cyberia-client/Dockerfile +9 -4
  337. package/src/runtime/cyberia-client/Dockerfile.dev +8 -2
  338. package/src/runtime/cyberia-server/Dockerfile +9 -4
  339. package/src/runtime/cyberia-server/Dockerfile.dev +8 -2
  340. package/src/runtime/engine-cyberia/Dockerfile +25 -9
  341. package/src/runtime/engine-cyberia/Dockerfile.dev +25 -9
  342. package/src/runtime/engine-cyberia/Dockerfile.test +4 -4
  343. package/src/runtime/engine-cyberia/docker-compose.yml +7 -7
  344. package/src/runtime/express/Express.js +32 -27
  345. package/src/runtime/lampp/Lampp.js +22 -70
  346. package/src/runtime/nginx/Nginx.js +1 -1
  347. package/src/runtime/wp/Wp.js +16 -48
  348. package/src/server/{catalog.js → build/catalog.js} +15 -5
  349. package/src/server/build/coverage.js +117 -0
  350. package/src/server/build/execution.js +402 -0
  351. package/src/server/build/package.js +465 -0
  352. package/src/server/build/testing.js +528 -0
  353. package/src/server/{dns.js → network/dns.js} +144 -11
  354. package/src/server/{forward-proxy.js → network/forward-proxy.js} +10 -18
  355. package/src/server/{middlewares.js → network/middlewares.js} +3 -3
  356. package/src/server/network/node-capability.js +98 -0
  357. package/src/server/{peer.js → network/peer.js} +3 -3
  358. package/src/server/{proxy.js → network/proxy.js} +7 -13
  359. package/src/server/network/router.js +356 -0
  360. package/src/server/{tls.js → network/tls.js} +2 -2
  361. package/src/server/{underpost-compression.js → network/underpost-compression.js} +1 -1
  362. package/src/server/{underpost-gateway.js → network/underpost-gateway.js} +7 -7
  363. package/src/server/{underpost-ingress.js → network/underpost-ingress.js} +2 -2
  364. package/src/server/{cri.js → ops/cri.js} +2 -2
  365. package/src/server/{cron.js → ops/cron.js} +349 -74
  366. package/src/server/ops/event-notification.js +284 -0
  367. package/src/server/{logger.js → ops/logger.js} +108 -23
  368. package/src/server/ops/monitoring.js +1724 -0
  369. package/src/server/{systemd.js → ops/systemd.js} +55 -1
  370. package/src/server/{conf.js → runtime/conf.js} +513 -422
  371. package/src/server/runtime/config-scope.js +215 -0
  372. package/src/server/runtime/environment.js +149 -0
  373. package/src/server/{process.js → runtime/process.js} +153 -12
  374. package/src/server/{runtime-status.js → runtime/runtime-status.js} +155 -16
  375. package/src/server/{runtime.js → runtime/runtime.js} +12 -8
  376. package/src/server/{start.js → runtime/start.js} +128 -39
  377. package/src/server/{auth.js → security/auth.js} +7 -8
  378. package/src/server/security/container-storage.js +382 -0
  379. package/src/server/{crypto.js → security/crypto.js} +1 -1
  380. package/src/server/{selinux.js → security/selinux.js} +1 -1
  381. package/src/server/{backup.js → storage/backup.js} +17 -8
  382. package/src/server/{data-query.js → storage/data-query.js} +1 -1
  383. package/src/server/{downloader.js → storage/downloader.js} +2 -2
  384. package/src/server/storage/repository.js +68 -0
  385. package/src/server.js +4 -7
  386. package/src/ws/IoInterface.js +1 -1
  387. package/src/ws/IoServer.js +1 -1
  388. package/src/ws/core/core.ws.connection.js +1 -1
  389. package/src/ws/core/core.ws.emit.js +1 -1
  390. package/src/ws/core/core.ws.server.js +1 -1
  391. package/src/ws/default/default.ws.connection.js +1 -1
  392. package/src/ws/default/default.ws.emit.js +1 -1
  393. package/src/ws/default/default.ws.server.js +1 -1
  394. package/test/e2e/event-e2e-public-ingress-down.js +45 -0
  395. package/test/e2e/event-e2e-wireguard-server-down.js +38 -0
  396. package/test/e2e/event-e2e-wireguard-spoke-down.js +40 -0
  397. package/test/{api.test.js → integration/app/api.test.js} +33 -14
  398. package/test/integration/app/cyberia/cyberia-cli-plain-reads.test.js +30 -0
  399. package/test/{cyberia-instance-conf-defaults.test.js → integration/app/cyberia/cyberia-instance-conf-defaults.test.js} +1 -1
  400. package/test/integration/app/cyberia/cyberia-load.test.js +467 -0
  401. package/test/integration/app/cyberia/fallback-world-capture.test.js +253 -0
  402. package/test/integration/app/cyberia/object-layer-item-id.test.js +327 -0
  403. package/test/{shape-generator.test.js → integration/app/cyberia/shape-generator.test.js} +1 -1
  404. package/test/integration/infra/1-security/config-scope.test.js +305 -0
  405. package/test/integration/infra/1-security/container-storage.test.js +356 -0
  406. package/test/integration/infra/1-security/in-pod-cli-surface.test.js +86 -0
  407. package/test/integration/infra/1-security/secret-onboarding.test.js +930 -0
  408. package/test/{selinux.test.js → integration/infra/1-security/selinux.test.js} +2 -4
  409. package/test/{sops-secret-store.test.js → integration/infra/1-security/sops-secret-store.test.js} +415 -61
  410. package/test/integration/infra/1-security/systemd-service.test.js +70 -0
  411. package/test/integration/infra/1-security/underpost-config-secret.test.js +367 -0
  412. package/test/integration/infra/2-network/dns-firewall.test.js +566 -0
  413. package/test/integration/infra/2-network/node-capability.test.js +182 -0
  414. package/test/integration/infra/2-network/wireguard-cli.test.js +1163 -0
  415. package/test/{wireguard-edge.test.js → integration/infra/2-network/wireguard-edge.test.js} +609 -55
  416. package/test/{cluster-instances.test.js → integration/infra/3-cluster/cluster-instances.test.js} +11 -15
  417. package/test/{deploy-node-placement.test.js → integration/infra/3-cluster/deploy-node-placement.test.js} +3 -3
  418. package/test/integration/infra/3-cluster/docker-compose-stack.test.js +313 -0
  419. package/test/integration/infra/4-ingress/deploy-routes.test.js +379 -0
  420. package/test/integration/infra/4-ingress/gateway-static-assets.test.js +453 -0
  421. package/test/{instance-traffic-plan.test.js → integration/infra/4-ingress/instance-traffic-plan.test.js} +10 -6
  422. package/test/{underpost-gateway.test.js → integration/infra/4-ingress/underpost-gateway.test.js} +6 -6
  423. package/test/{underpost-ingress.test.js → integration/infra/4-ingress/underpost-ingress.test.js} +4 -4
  424. package/test/integration/infra/5-observability/cron-jobs.test.js +652 -0
  425. package/test/{deploy-monitor.test.js → integration/infra/5-observability/deploy-monitor.test.js} +29 -31
  426. package/test/integration/infra/5-observability/event-notification.test.js +197 -0
  427. package/test/integration/infra/5-observability/event-remediation.test.js +502 -0
  428. package/test/integration/infra/5-observability/event-targets.test.js +1174 -0
  429. package/test/integration/infra/5-observability/monitoring-stack.test.js +902 -0
  430. package/test/support/shell-harness.js +73 -0
  431. package/test/unit/build-template.test.js +97 -0
  432. package/test/unit/build-workflow-offline.test.js +132 -0
  433. package/test/unit/catalog.test.js +71 -0
  434. package/test/unit/client-build-docs.test.js +334 -0
  435. package/test/unit/conf-loading.test.js +303 -0
  436. package/test/unit/conf-resolution.test.js +979 -0
  437. package/test/unit/coverage-artifact.test.js +130 -0
  438. package/test/{crypto.test.js → unit/crypto.test.js} +1 -1
  439. package/test/unit/deploy-env-resolution.test.js +38 -0
  440. package/test/unit/deploy-log-table.test.js +447 -0
  441. package/test/unit/execution-profiles.test.js +219 -0
  442. package/test/unit/fleet-sync-source.test.js +149 -0
  443. package/test/unit/logger-redaction.test.js +112 -0
  444. package/test/unit/package.test.js +403 -0
  445. package/test/unit/prepare-host.test.js +299 -0
  446. package/test/unit/process-environment.test.js +18 -0
  447. package/test/unit/propagation-message.test.js +78 -0
  448. package/test/unit/redeploy-plan.test.js +24 -0
  449. package/test/unit/release-bump.test.js +474 -0
  450. package/test/unit/ssh-output-redaction.test.js +28 -0
  451. package/test/unit/start-options.test.js +46 -0
  452. package/test/unit/test-tiers.test.js +193 -0
  453. package/vitest.config.js +58 -0
  454. package/.nycrc +0 -9
  455. package/deploy/lib/logging.sh +0 -96
  456. package/manifests/cronjobs/dd-cron/dd-cron-dns.yaml +0 -48
  457. package/manifests/mongodb/pv-pvc.yaml +0 -59
  458. package/scripts/coverall-test-cyberia.sh +0 -24
  459. package/scripts/coverall-test.sh +0 -24
  460. package/scripts/link-local-underpost-cli.sh +0 -6
  461. package/src/api/user/user.build.js +0 -16
  462. package/src/cli/env.js +0 -177
  463. package/src/client/components/core/Blockchain.js +0 -41
  464. package/src/client/components/core/Blog.js +0 -9
  465. package/src/client/components/core/KeyboardAvoidance.js +0 -145
  466. package/src/client/public/default/assets/background/dark.jpg +0 -0
  467. package/src/client/public/default/assets/background/white0-min.jpg +0 -0
  468. package/src/client/public/default/assets/background/white0.jpg +0 -0
  469. package/src/client/public/doc/favicon.ico +0 -0
  470. package/src/client/public/doc/sitemap +0 -148
  471. package/src/server/environment.js +0 -98
@@ -1,9 +1,17 @@
1
- /** WireGuard overlay and HAProxy edge routing lifecycle. */
1
+ /**
2
+ * WireGuard overlay and HAProxy edge routing lifecycle
3
+ * @module src/cli/wireguard.js
4
+ * @namespace UnderpostWireguard
5
+ */
2
6
 
3
7
  import fs from 'fs-extra';
8
+ import { isIP } from 'node:net';
4
9
  import os from 'node:os';
5
10
  import nodePath from 'node:path';
6
- import { getConfFilePath, loadConfInstances, loadConfServerJson, resolveDeployList } from '../server/conf.js';
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';
7
15
  import {
8
16
  FORWARD_PROXY,
9
17
  forwardProxyCommandFactory,
@@ -14,9 +22,11 @@ import {
14
22
  forwardProxyServiceCommandsFactory,
15
23
  forwardProxyStartProbeCommandFactory,
16
24
  forwardProxyUnitFactory,
17
- } from '../server/forward-proxy.js';
18
- import { loggerFactory } from '../server/logger.js';
19
- import { shellExec } from '../server/process.js';
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';
20
30
  import {
21
31
  homeDirectoryPathFactory,
22
32
  journalctlCommandFactory,
@@ -25,7 +35,9 @@ import {
25
35
  systemdAvailableCommandFactory,
26
36
  systemdReloadIfActiveCommandFactory,
27
37
  systemdStatusCommandsFactory,
28
- } from '../server/systemd.js';
38
+ } from '../server/ops/systemd.js';
39
+ import Underpost from '../index.js';
40
+ import { syncDeployPackages } from '../server/build/package.js';
29
41
 
30
42
  const logger = loggerFactory(import.meta);
31
43
 
@@ -57,7 +69,7 @@ const UNDERPOST_EDGE = {
57
69
  packages: ['wireguard-tools', 'haproxy', 'iptables'],
58
70
  httpPort: 80,
59
71
  httpsPort: 443,
60
- // Spoke-side SSH. The public port is per-edge and lives in the registry,
72
+ // Spoke-side SSH. The public port is per-edge and lives in topology,
61
73
  // because exposing SSH is a decision, not a default.
62
74
  sshPort: 22,
63
75
  defaultSshForwardPort: 2222,
@@ -67,6 +79,65 @@ const UNDERPOST_EDGE = {
67
79
  handshakeStaleSeconds: 180,
68
80
  };
69
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
+
70
141
  /**
71
142
  * @method deployIdFactory
72
143
  * @description Normalizes a deploy id to the `dd-<conf-id>` convention.
@@ -76,17 +147,37 @@ const UNDERPOST_EDGE = {
76
147
  */
77
148
  const deployIdFactory = (deployId) => {
78
149
  const value = `${deployId || ''}`.trim();
79
- // `dd` is the meta id every runner reads as "all of dd.router"; prefixing it
150
+ // `dd` is the meta id every runner reads as "all of dd.routes"; prefixing it
80
151
  // would turn it into a deploy that does not exist.
81
152
  if (!value || value === 'dd') return value;
82
153
  return value.startsWith('dd-') ? value : `dd-${value}`;
83
154
  };
84
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
+
85
176
  /**
86
177
  * @method deployListFactory
87
178
  * @description The deploys whose hostnames a run publishes.
88
179
  *
89
- * `dd` expands through the same `dd.router` read every other runner uses, so
180
+ * `dd` expands through the same `dd.routes` read every other runner uses, so
90
181
  * the edge routes exactly the set the cluster deploys — a hostname cannot be
91
182
  * live in the cluster and absent from the edge because two lists drifted.
92
183
  * @param {string} deployId - Deploy id, comma-separated list, or `dd`.
@@ -101,10 +192,10 @@ const deployListFactory = (deployId) => {
101
192
  };
102
193
 
103
194
  /**
104
- * @constant EDGE_STATE_PATH
105
- * @description Location of the peer registry.
195
+ * @constant EDGE_TOPOLOGY_PATH
196
+ * @description Location of the deployment topology.
106
197
  *
107
- * Cluster-wide rather than per deploy, and stored beside `dd.router` for that
198
+ * Cluster-wide rather than per deploy, and stored beside `dd.routes` for that
108
199
  * reason: the hub has one interface, one address and one peer table, and those
109
200
  * are properties of the machine. A copy per deploy would be several records of
110
201
  * one fact, free to disagree. `--deploy-id` selects which hostnames are routed
@@ -114,17 +205,74 @@ const deployListFactory = (deployId) => {
114
205
  * the host that generated it.
115
206
  * @memberof UnderpostWireguard
116
207
  */
117
- const EDGE_STATE_PATH = './engine-private/deploy/conf.wireguard.json';
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
+ };
118
266
 
119
267
  /**
120
268
  * @method peerFactory
121
269
  * @description Normalizes one spoke entry, filling the fields a partially
122
- * written registry may omit.
270
+ * written topology may omit.
123
271
  *
124
272
  * `allowedIPs` defaults to the peer's own tunnel address alone: a spoke routes
125
- * its LAN only when the registry says so, so a mistyped entry cannot silently
273
+ * its LAN only when topology says so, so a mistyped entry cannot silently
126
274
  * claim a subnet another spoke already answers for.
127
- * @param {object} peer - Raw registry entry.
275
+ * @param {object} peer - Raw topology entry.
128
276
  * @returns {object} Normalized entry.
129
277
  * @memberof UnderpostWireguard
130
278
  */
@@ -136,6 +284,7 @@ const peerFactory = (peer = {}) => {
136
284
  return {
137
285
  id: `${peer.id || ''}`.trim(),
138
286
  address,
287
+ managementHost: `${peer.managementHost || ''}`.trim(),
139
288
  publicKey: `${peer.publicKey || ''}`.trim(),
140
289
  allowedIPs: allowedIPs.length > 0 ? allowedIPs : address ? [`${address}/32`] : [],
141
290
  hosts: (Array.isArray(peer.hosts) ? peer.hosts : []).map((host) => `${host}`.trim().toLowerCase()).filter(Boolean),
@@ -153,74 +302,213 @@ const peerFactory = (peer = {}) => {
153
302
  * a hostname, and requiring `"default": true` there would only be ceremony.
154
303
  * With several peers and none nominated there is no fallback at all, so nothing
155
304
  * is dispatched somewhere arbitrary.
156
- * @param {Array<object>} [peers] - Normalized registry entries.
305
+ * @param {Array<object>} [peers] - Normalized topology entries.
157
306
  * @returns {?object} The fallback peer, or null.
158
307
  * @memberof UnderpostWireguard
159
308
  */
160
309
  const defaultPeerFactory = (peers = []) =>
161
310
  peers.find((peer) => peer.default === true) || (peers.length === 1 ? peers[0] : null);
162
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
+
163
387
  /**
164
- * @method edgeStateFactory
165
- * @description Normalizes the registry as a whole, so every consumer reads one
166
- * shape whether the file exists, is partial, or is absent.
388
+ * @method nodeNameCandidatesFactory
389
+ * @description The node document names a hostname may be recorded under, in
390
+ * precedence order.
167
391
  *
168
- * `endpoint` and `hubPublicKey` are the pair a spoke needs to rebuild its own
169
- * interface: the hub it dials, and the identity it expects there. Both are
170
- * recorded so re-running the setup does not require re-supplying them, which is
171
- * what makes a spoke's bring-up repeatable and `--wireguard-reinstall` possible
172
- * on a spoke at all. Neither is secret — a public key is public.
173
- * @param {object} [state] - Raw registry contents.
174
- * @returns {object} Normalized registry.
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.
175
397
  * @memberof UnderpostWireguard
176
398
  */
177
- const edgeStateFactory = (state = {}) => ({
178
- interfaceName: `${state.interfaceName || UNDERPOST_EDGE.interfaceName}`.trim(),
179
- role: `${state.role || ''}`.trim(),
180
- listenPort: Number(state.listenPort) > 0 ? Number(state.listenPort) : UNDERPOST_EDGE.listenPort,
181
- address: `${state.address || ''}`.trim(),
182
- endpoint: `${state.endpoint || ''}`.trim(),
183
- hubPublicKey: `${state.hubPublicKey || ''}`.trim(),
184
- publicKey: `${state.publicKey || ''}`.trim(),
185
- // 0 means the edge publishes no SSH port at all, which is the default: an
186
- // edge that forwards SSH is one someone deliberately opened.
187
- sshForwardPort: Number(state.sshForwardPort) > 0 ? Number(state.sshForwardPort) : 0,
188
- peers: (Array.isArray(state.peers) ? state.peers : []).map(peerFactory).filter((peer) => peer.id),
189
- });
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
+ );
190
412
 
191
413
  /**
192
- * @method readEdgeState
193
- * @description The peer registry. A missing or malformed file is treated as an
194
- * empty registry rather than an error: the first `--wireguard-setup` on a fresh
195
- * host runs before any registry exists.
196
- * @returns {object} Normalized registry.
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.
197
426
  * @memberof UnderpostWireguard
198
427
  */
199
- const readEdgeState = () => {
200
- if (!fs.existsSync(EDGE_STATE_PATH)) return edgeStateFactory();
201
- try {
202
- return edgeStateFactory(JSON.parse(fs.readFileSync(EDGE_STATE_PATH, 'utf8')));
203
- } catch (error) {
204
- logger.warn('Ignoring unreadable wireguard registry', { target: EDGE_STATE_PATH, message: error.message });
205
- return edgeStateFactory();
206
- }
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] || '';
207
439
  };
208
440
 
209
441
  /**
210
- * @method writeEdgeState
211
- * @description Persists the peer registry.
212
- * @param {object} state - Registry to write.
213
- * @returns {boolean} True when the file changed.
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.
214
449
  * @memberof UnderpostWireguard
215
450
  */
216
- const writeEdgeState = (state) => {
217
- const target = EDGE_STATE_PATH;
218
- const next = `${JSON.stringify(edgeStateFactory(state), null, 2)}\n`;
219
- const current = fs.existsSync(target) ? fs.readFileSync(target, 'utf8') : '';
220
- if (current === next) return false;
221
- fs.mkdirpSync(nodePath.dirname(target));
222
- fs.writeFileSync(target, next, 'utf8');
223
- return true;
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');
224
512
  };
225
513
 
226
514
  /**
@@ -283,7 +571,7 @@ const hostProxyEntriesFactory = ({ confServer = {} } = {}) => {
283
571
  * Instances declare no `proxy` array — their hostname reaches the same cluster
284
572
  * ingress the deploy's own hosts do, so it terminates the same two ports. Every
285
573
  * variant of a family resolves through both its own id and its template id, so
286
- * a registry can bind a whole family with one entry.
574
+ * topology can bind a whole family with one entry.
287
575
  * @param {Array<object>} [instances] - Expanded entries from `loadConfInstances`.
288
576
  * @returns {Array<{host: string, ports: Array<number>, instances: Array<string>}>} One entry per instance hostname.
289
577
  * @memberof UnderpostWireguard
@@ -337,7 +625,7 @@ const edgeRouteTableFactory = ({ confServer = {}, instances = [], peers = [] } =
337
625
  const byHost = new Map();
338
626
  const byInstance = new Map();
339
627
  // First declaration wins, so a duplicated binding is deterministic rather
340
- // than dependent on registry order changing under an edit.
628
+ // than dependent on topology order changing under an edit.
341
629
  for (const peer of list) {
342
630
  for (const host of peer.hosts) if (!byHost.has(host)) byHost.set(host, peer);
343
631
  for (const id of peer.instances) if (!byInstance.has(id)) byInstance.set(id, peer);
@@ -379,7 +667,7 @@ const edgeRouteTableFactory = ({ confServer = {}, instances = [], peers = [] } =
379
667
  * every peer's `AllowedIPs`, so a CIDR claimed twice is not shared — one peer
380
668
  * wins and the other silently never receives that traffic. Homelabs routinely
381
669
  * sit on the same `192.168.1.0/24`, which makes this the likeliest way a
382
- * multi-spoke registry breaks.
670
+ * multi-spoke topology breaks.
383
671
  *
384
672
  * Duplicate tunnel addresses are caught by the same pass, because an address
385
673
  * with no explicit `allowedIPs` contributes its own `/32`.
@@ -403,7 +691,7 @@ const allowedIpsConflictsFactory = ({ peers = [] } = {}) => {
403
691
  * publishes.
404
692
  *
405
693
  * The edge is one machine holding one pair of map files, so every deploy in
406
- * `dd.router` has to be compiled together — publishing one deploy's table alone
694
+ * `dd.routes` has to be compiled together — publishing one deploy's table alone
407
695
  * would overwrite the maps and take every other deploy's hostname off the
408
696
  * internet. Each route records the deploy that contributed it, which is the
409
697
  * only way to attribute a hostname once the tables are merged.
@@ -616,7 +904,7 @@ ${backends}${backends ? '\n\n' : ''}${defaults}${sshBackend}
616
904
  * The key is deliberately not inlined into the rendered config: keeping it in
617
905
  * its own 0600 file means no rendered configuration, dry-run print, diff or log
618
906
  * line can ever carry it, and the config itself becomes a pure function of the
619
- * registry. `wg-quick` runs `PostUp` after `wg setconf`, so the interface is
907
+ * topology. `wg-quick` runs `PostUp` after `wg setconf`, so the interface is
620
908
  * keyed before it forwards anything.
621
909
  * @param {string} keyPath - Path of the private key file.
622
910
  * @returns {string} `PostUp` directive.
@@ -648,28 +936,6 @@ const wireguardClientForwardingDirectivesFactory = (cidr) => {
648
936
  ].join('\n');
649
937
  };
650
938
 
651
- /**
652
- * @method wireguardClientSettingsFactory
653
- * @description Reads the non-secret settings needed to reconcile an installed
654
- * spoke when its shared registry has no machine-local client fields.
655
- * @param {string} [conf] - Selected WireGuard configuration directives.
656
- * @returns {{address: string, hubPublicKey: string, endpoint: string, cidr: string}}
657
- * @memberof UnderpostWireguard
658
- */
659
- const wireguardClientSettingsFactory = (conf = '') => {
660
- const values = new Map();
661
- for (const line of `${conf}`.split('\n')) {
662
- const match = line.match(/^\s*(Address|PublicKey|Endpoint|AllowedIPs)\s*=\s*(.*?)\s*$/i);
663
- if (match && !values.has(match[1].toLowerCase())) values.set(match[1].toLowerCase(), match[2]);
664
- }
665
- return {
666
- address: `${values.get('address') || ''}`.split(',')[0].trim(),
667
- hubPublicKey: `${values.get('publickey') || ''}`.trim(),
668
- endpoint: `${values.get('endpoint') || ''}`.trim(),
669
- cidr: `${values.get('allowedips') || ''}`.split(',')[0].trim(),
670
- };
671
- };
672
-
673
939
  /**
674
940
  * @method wireguardServerConfFactory
675
941
  * @description Renders the hub interface and its spoke table.
@@ -703,7 +969,7 @@ PublicKey = ${peer.publicKey}
703
969
  AllowedIPs = ${peer.allowedIPs.join(', ')}`,
704
970
  )
705
971
  .join('\n');
706
- return `# Generated by \`underpost wireguard --wireguard-setup --server\`. Do not edit by hand.
972
+ return `# Generated by \`underpost wireguard --wireguard-setup\` on the registered hub. Do not edit by hand.
707
973
  [Interface]
708
974
  Address = ${address}
709
975
  ListenPort = ${listenPort}
@@ -741,7 +1007,7 @@ const wireguardClientConfFactory = ({
741
1007
  endpoint,
742
1008
  cidr = UNDERPOST_EDGE.tunnelCidr,
743
1009
  keepalive = UNDERPOST_EDGE.keepalive,
744
- } = {}) => `# Generated by \`underpost wireguard --wireguard-setup --client\`. Do not edit by hand.
1010
+ } = {}) => `# Generated by \`underpost wireguard --wireguard-setup\` on a registered node. Do not edit by hand.
745
1011
  [Interface]
746
1012
  Address = ${`${address}`.includes('/') ? address : `${address}/32`}
747
1013
  ${wireguardPrivateKeyDirective(keyPath)}
@@ -823,7 +1089,7 @@ const quicForwardCommandsFactory = ({
823
1089
  * The same list drives the teardown, flipped to `--remove-*`, so a reset cannot
824
1090
  * leave behind a permanent rule the setup added — the two directions are one
825
1091
  * declaration rather than two that drift.
826
- * @param {string} role - `server` or `client`.
1092
+ * @param {string} role - `hub`, `control`, or `worker`.
827
1093
  * @param {string} [interfaceName] - Tunnel interface.
828
1094
  * @param {number} [listenPort] - UDP listen port.
829
1095
  * @param {string} [tunnelCidr] - Tunnel source admitted to the forward proxy.
@@ -841,8 +1107,13 @@ const firewallCommandsFactory = ({
841
1107
  remove = false,
842
1108
  } = {}) => {
843
1109
  const verb = remove ? 'remove' : 'add';
844
- const rules =
845
- role === 'server'
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'
846
1117
  ? [
847
1118
  `--${verb}-port=${UNDERPOST_EDGE.httpPort}/tcp`,
848
1119
  `--${verb}-port=${UNDERPOST_EDGE.httpsPort}/tcp`,
@@ -857,7 +1128,8 @@ const firewallCommandsFactory = ({
857
1128
  `--${verb}-rich-rule="rule family=ipv4 source address=${tunnelCidr} port port=${FORWARD_PROXY.port} protocol=tcp accept"`,
858
1129
  `--${verb}-masquerade`,
859
1130
  ]
860
- : [`--zone=trusted --${verb}-interface=${interfaceName}`];
1131
+ : []),
1132
+ ];
861
1133
  const guard =
862
1134
  'command -v firewall-cmd >/dev/null 2>&1 && ' +
863
1135
  systemctlCommandFactory({ action: 'is-active --quiet', name: 'firewalld', sudo: false });
@@ -869,24 +1141,65 @@ const firewallCommandsFactory = ({
869
1141
 
870
1142
  /**
871
1143
  * @method peerSummaryFactory
872
- * @description One spoke as the registry declares it: its transport address and
1144
+ * @description One spoke as topology declares it: its transport address and
873
1145
  * the three bindings that route hostnames to it.
874
- * @param {object} peer - Normalized registry entry.
875
- * @returns {object} Registry view of the peer.
1146
+ * @param {object} peer - Normalized topology entry.
1147
+ * @returns {object} Topology view of the peer.
876
1148
  * @memberof UnderpostWireguard
877
1149
  */
878
1150
  const peerSummaryFactory = (peer) => ({
879
1151
  id: peer.id,
880
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,
881
1159
  allowedIPs: peer.allowedIPs,
882
1160
  hosts: peer.hosts,
883
1161
  instances: peer.instances,
884
1162
  default: peer.default,
885
1163
  });
886
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
+
887
1200
  /**
888
1201
  * @method wireguardStatusFactory
889
- * @description Folds `wg show` output onto the registry, one row per spoke.
1202
+ * @description Folds `wg show` output onto topology, one row per spoke.
890
1203
  *
891
1204
  * Built from the per-peer sub-commands rather than `wg show <iface> dump`,
892
1205
  * because the dump's first line contains the interface private key — this
@@ -936,33 +1249,43 @@ const wireguardStatusFactory = ({
936
1249
  };
937
1250
 
938
1251
  /**
939
- * @method writeRootFile
940
- * @description Places a file the host's services read, at an explicit mode.
941
- *
942
- * Staged and installed rather than written directly: the deploy may run
943
- * unprivileged while `/etc/wireguard` and `/etc/haproxy` are root-owned, and
944
- * `install -m` sets the mode as it copies, so a 0600 file is never briefly
945
- * readable at the default umask.
946
- * @param {string} target - Destination path.
947
- * @param {string} content - File contents.
948
- * @param {string} [mode] - Octal mode.
949
- * @param {boolean} [dryRun] - Print the destination instead of writing.
950
- * @returns {boolean} True when the file changed.
951
- * @memberof UnderpostWireguard
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.
952
1255
  */
953
- const writeRootFile = ({ target, content, mode = '0644', dryRun = false }) => {
954
- const current = fs.existsSync(target) ? fs.readFileSync(target, 'utf8') : null;
955
- if (current === content) return false;
956
- if (dryRun) {
957
- logger.info(`[dry-run] write ${target} (mode ${mode})`, { bytes: content.length });
958
- return true;
959
- }
960
- const staged = nodePath.join('/tmp', `underpost-edge-${nodePath.basename(target)}-${process.pid}`);
961
- fs.writeFileSync(staged, content, { mode: 0o600 });
962
- shellExec(`sudo mkdir -p ${nodePath.dirname(target)}`, { silent: true });
963
- shellExec(`sudo install -m ${mode} -o root -g root ${staged} ${target}`, { silent: true });
964
- fs.removeSync(staged);
965
- return true;
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;
966
1289
  };
967
1290
 
968
1291
  /**
@@ -978,8 +1301,8 @@ const writeRootFile = ({ target, content, mode = '0644', dryRun = false }) => {
978
1301
  * @memberof UnderpostWireguard
979
1302
  */
980
1303
  const writeServerInterfaceConf = ({ state, peers, dryRun = false }) => {
981
- if (state.role !== 'server') return false;
982
- return writeRootFile({
1304
+ if (state.role !== 'hub') return false;
1305
+ return installRootFile({
983
1306
  target: `${UNDERPOST_EDGE.wireguardDir}/${state.interfaceName}.conf`,
984
1307
  content: wireguardServerConfFactory({
985
1308
  interfaceName: state.interfaceName,
@@ -1050,8 +1373,8 @@ const liveWireguardCommand = (interfaceName, args) =>
1050
1373
  `sudo sh -c 'wg show ${interfaceName} >/dev/null 2>&1 && wg set ${interfaceName} ${args} || true'`;
1051
1374
 
1052
1375
  /**
1053
- * @method warnRegistryHazards
1054
- * @description Reports the two registry states that break routing silently.
1376
+ * @method warnTopologyHazards
1377
+ * @description Reports the two topology states that break routing silently.
1055
1378
  *
1056
1379
  * A peer with no `hosts` and no `instances` claims nothing. It still serves
1057
1380
  * every hostname while it is the *only* peer, because a lone peer is its own
@@ -1062,11 +1385,11 @@ const liveWireguardCommand = (interfaceName, args) =>
1062
1385
  * That cliff arrives when the peer is registered, not when routes are published,
1063
1386
  * so it is reported at both points rather than left for the next sync to
1064
1387
  * discover.
1065
- * @param {Array<object>} [peers] - The registry's peers.
1388
+ * @param {Array<object>} [peers] - The topology peers.
1066
1389
  * @returns {void}
1067
1390
  * @memberof UnderpostWireguard
1068
1391
  */
1069
- const warnRegistryHazards = (peers = []) => {
1392
+ const warnTopologyHazards = (peers = []) => {
1070
1393
  if (peers.length === 0) {
1071
1394
  logger.warn('Registry has no peers: no hostname can resolve until at least one is registered', {
1072
1395
  next: '--peer-add <id> --peer-ip <10.0.0.x> --public-key <key>',
@@ -1107,6 +1430,30 @@ const tunnelNetworkCidrFactory = (address, fallback = UNDERPOST_EDGE.tunnelCidr)
1107
1430
  return `${[network >>> 24, (network >>> 16) & 255, (network >>> 8) & 255, network & 255].join('.')}/${prefix}`;
1108
1431
  };
1109
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
+
1110
1457
  /**
1111
1458
  * @class UnderpostWireguard
1112
1459
  * @description Lifecycle for the edge: the WireGuard transport that reaches the
@@ -1190,94 +1537,115 @@ class UnderpostWireguard {
1190
1537
  return { privateKeyPath, publicKeyPath, publicKey, generated: !exists };
1191
1538
  },
1192
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
+
1193
1577
  /**
1194
1578
  * @method setup
1195
- * @description Builds the tunnel interface for either role and persists what
1196
- * the other side needs to know.
1197
- *
1198
- * The hub writes its own address and public key into the registry so a spoke
1199
- * can be configured from the same file the routes are derived from; a spoke
1200
- * records the hub it dials. Re-running with the same inputs rewrites the same
1201
- * bytes and reloads nothing.
1579
+ * @description Builds this selected node's interface and updates only its
1580
+ * public key in deployment topology.
1202
1581
  * @param {object} options - CLI options.
1203
- * @returns {object} The registry as persisted.
1582
+ * @returns {object} The derived runtime context.
1204
1583
  * @memberof UnderpostWireguard
1205
1584
  */
1206
1585
  setup(options = {}) {
1207
- const state = readEdgeState();
1208
1586
  const buildConf = options.buildConf === true;
1587
+ const topology = readTopology();
1588
+ const identity = readNodeIdentity();
1589
+ const state = edgeContextFactory({ topology, identity });
1209
1590
  const interfaceName = `${options.interface || state.interfaceName}`.trim();
1210
- if (options.server === true && options.client === true)
1211
- throw new Error('[wireguard] --server and --client are mutually exclusive');
1212
- const installedClient =
1213
- buildConf || options.server === true
1214
- ? wireguardClientSettingsFactory()
1215
- : wireguardClientSettingsFactory(
1216
- shellExec(
1217
- `sudo sed -n -E '/^[[:space:]]*(Address|PublicKey|Endpoint|AllowedIPs)[[:space:]]*=/p' ${UNDERPOST_EDGE.wireguardDir}/${interfaceName}.conf`,
1218
- { stdout: true, silent: true, silentOnError: true },
1219
- ).trim(),
1220
- );
1221
- // A re-run on a configured host inherits its recorded role, so repeating
1222
- // the setup does not require repeating every flag that established it.
1223
- const role =
1224
- options.server === true
1225
- ? 'server'
1226
- : options.client === true
1227
- ? 'client'
1228
- : installedClient.endpoint
1229
- ? 'client'
1230
- : state.role;
1231
- if (!role) throw new Error('[wireguard] --wireguard-setup requires --server or --client');
1232
- const listenPort = Number(options.port) > 0 ? Number(options.port) : state.listenPort;
1233
- // `--ssh-forward-port 0` is the off switch, so an explicit 0 has to win
1234
- // over the stored value rather than read as "not supplied".
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;
1235
1594
  const sshForwardPort =
1236
1595
  options.sshForwardPort === undefined || options.sshForwardPort === null || `${options.sshForwardPort}` === ''
1237
1596
  ? state.sshForwardPort
1238
1597
  : Math.max(0, Number(options.sshForwardPort) || 0);
1239
- // Authoring the registry must work on a machine that is not the edge — a
1240
- // workstation has no `/etc/wireguard` to key, and generating one there
1241
- // would mint an identity no host will ever present.
1242
1598
  const { privateKeyPath, publicKey } = buildConf
1243
- ? { privateKeyPath: `${UNDERPOST_EDGE.wireguardDir}/${interfaceName}.key`, publicKey: state.publicKey }
1599
+ ? {
1600
+ privateKeyPath: `${UNDERPOST_EDGE.wireguardDir}/${interfaceName}.key`,
1601
+ publicKey: `${options.publicKey || state.publicKey}`.trim(),
1602
+ }
1244
1603
  : UnderpostWireguard.API.ensureKeyPair(interfaceName, options.dryRun);
1604
+ if (!publicKey && !options.dryRun) throw new Error('[wireguard] the selected node has no WireGuard public key');
1245
1605
 
1246
1606
  let conf;
1247
- let next;
1248
- if (role === 'server') {
1249
- const address = `${options.cidr || state.address || UNDERPOST_EDGE.cidr}`.trim();
1607
+ let nextTopology;
1608
+ if (state.role === 'hub') {
1609
+ const address = `${options.cidr || state.address}`.trim();
1250
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 };
1251
1620
  conf = wireguardServerConfFactory({
1252
1621
  interfaceName,
1253
1622
  address,
1254
1623
  listenPort,
1255
1624
  keyPath: privateKeyPath,
1256
- peers: state.peers,
1625
+ peers: hub.peers,
1257
1626
  });
1258
- next = { ...state, interfaceName, role, listenPort, address, publicKey, sshForwardPort };
1259
1627
  } else {
1260
- const address = `${options.peerIp || installedClient.address || state.address || ''}`.trim();
1261
- const endpoint = `${options.endpoint || installedClient.endpoint || state.endpoint || ''}`.trim();
1262
- const hubPublicKey = `${options.publicKey || installedClient.hubPublicKey || state.hubPublicKey || ''}`.trim();
1263
- const cidr = `${options.cidr || installedClient.cidr || UNDERPOST_EDGE.tunnelCidr}`.trim();
1264
- if (!address) throw new Error('[wireguard] --client requires --peer-ip');
1265
- if (!endpoint) throw new Error('[wireguard] --client requires --endpoint (e.g. vps.example.com:51820)');
1266
- if (!hubPublicKey) throw new Error('[wireguard] --client requires --public-key (the hub public key)');
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 } };
1267
1635
  conf = wireguardClientConfFactory({
1268
1636
  address,
1269
1637
  keyPath: privateKeyPath,
1270
- publicKey: hubPublicKey,
1271
- endpoint,
1272
- cidr,
1638
+ publicKey: state.hubPublicKey,
1639
+ endpoint: `${state.hubHost}:${listenPort}`,
1640
+ cidr: `${options.cidr || tunnelNetworkCidrFactory(hub.address)}`.trim(),
1273
1641
  keepalive: UNDERPOST_EDGE.keepalive,
1274
1642
  });
1275
- next = { ...state, interfaceName, role, listenPort, address, endpoint, hubPublicKey, publicKey };
1276
1643
  }
1644
+ const next = edgeContextFactory({ topology: nextTopology, identity });
1277
1645
 
1278
1646
  let confChanged = false;
1279
1647
  if (!buildConf) {
1280
- confChanged = writeRootFile({
1648
+ confChanged = installRootFile({
1281
1649
  target: `${UNDERPOST_EDGE.wireguardDir}/${interfaceName}.conf`,
1282
1650
  content: conf,
1283
1651
  mode: '0600',
@@ -1288,7 +1656,7 @@ class UnderpostWireguard {
1288
1656
  `sudo sh -c 'echo net.ipv4.ip_forward=1 > ${UNDERPOST_EDGE.sysctlPath}'`,
1289
1657
  `sudo sysctl -q --system`,
1290
1658
  ...firewallCommandsFactory({
1291
- role,
1659
+ role: next.role,
1292
1660
  interfaceName,
1293
1661
  listenPort,
1294
1662
  tunnelCidr: tunnelNetworkCidrFactory(next.address),
@@ -1298,7 +1666,7 @@ class UnderpostWireguard {
1298
1666
  options.dryRun,
1299
1667
  );
1300
1668
  }
1301
- if (!options.dryRun) writeEdgeState(next);
1669
+ if (!options.dryRun) writeTopology(nextTopology);
1302
1670
  // Writing the config is idempotent; the *running* interface is not. wg-quick
1303
1671
  // reads the file only at start, so a changed config under a live unit is a
1304
1672
  // divergence that stays silent until the next reboot re-reads it.
@@ -1309,9 +1677,11 @@ class UnderpostWireguard {
1309
1677
  systemctlCommandFactory({ action: 'is-active --quiet', name: `wg-quick@${interfaceName}`, sudo: false }),
1310
1678
  { silent: true, silentOnError: true },
1311
1679
  ).code === 0;
1312
- logger.info(buildConf ? 'Registry updated; host untouched' : 'WireGuard interface configured', {
1680
+ logger.info(buildConf ? 'Topology updated; host untouched' : 'WireGuard interface configured', {
1313
1681
  interfaceName,
1314
- role,
1682
+ role: next.role,
1683
+ nodeName: next.nodeName,
1684
+ hubHost: next.hubHost,
1315
1685
  address: next.address,
1316
1686
  publicKey: next.publicKey,
1317
1687
  peers: next.peers.length,
@@ -1319,7 +1689,7 @@ class UnderpostWireguard {
1319
1689
  });
1320
1690
  if (restartRequired)
1321
1691
  logger.warn('Interface config changed while the tunnel is up; the live interface still runs the old one', {
1322
- apply: `--wireguard-stop --wireguard-start --interface ${interfaceName}`,
1692
+ apply: `--wireguard-restart --check --interface ${interfaceName}`,
1323
1693
  });
1324
1694
  return next;
1325
1695
  },
@@ -1329,7 +1699,7 @@ class UnderpostWireguard {
1329
1699
  * @description Registers a spoke and applies it to the running hub.
1330
1700
  *
1331
1701
  * `wg set` installs the peer on the live interface, so an existing tunnel is
1332
- * never interrupted to admit a new one — the registry and the config file
1702
+ * never interrupted to admit a new one — topology and the config file
1333
1703
  * are updated in the same pass so the peer also survives a restart.
1334
1704
  *
1335
1705
  * A spoke that re-keys is registered under the same id with a new public key.
@@ -1339,11 +1709,17 @@ class UnderpostWireguard {
1339
1709
  * could keep handing that traffic to an identity the spoke no longer holds.
1340
1710
  * It is dropped first, which is what makes a reconnect leave no trace.
1341
1711
  * @param {object} options - CLI options.
1342
- * @returns {object} The updated registry.
1712
+ * @returns {object} The updated hub topology.
1343
1713
  * @memberof UnderpostWireguard
1344
1714
  */
1345
1715
  peerAdd(options = {}) {
1346
- const state = readEdgeState();
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');
1347
1723
  const id = `${options.peerAdd || ''}`.trim();
1348
1724
  const address = `${options.peerIp || ''}`.trim();
1349
1725
  const publicKey = `${options.publicKey || ''}`.trim();
@@ -1358,108 +1734,114 @@ class UnderpostWireguard {
1358
1734
  ['allowedIPs', csvFactory(options.allowedIps)],
1359
1735
  ['hosts', csvFactory(options.hosts)],
1360
1736
  ['instances', csvFactory(options.instances)],
1737
+ ['managementHost', options.managementHost ? `${options.managementHost}`.trim() : undefined],
1361
1738
  ['default', options.default === true ? true : undefined],
1362
1739
  ].filter(([, value]) => value !== undefined),
1363
1740
  );
1364
- const current = state.peers.find((entry) => entry.id === id) || {};
1741
+ const current = hub.peers.find((entry) => entry.id === id) || {};
1365
1742
  const peer = peerFactory({ ...current, id, address, publicKey, ...overrides });
1366
1743
  const supersededKey = current.publicKey && current.publicKey !== publicKey ? current.publicKey : '';
1367
- const peers = [...state.peers.filter((entry) => entry.id !== id), peer].sort((a, b) => a.id.localeCompare(b.id));
1368
- const next = { ...state, peers };
1744
+ const peers = [...hub.peers.filter((entry) => entry.id !== id), peer].sort((a, b) => a.id.localeCompare(b.id));
1745
+ const nextHub = { ...hub, peers };
1369
1746
  if (options.buildConf !== true) {
1370
1747
  runHostCommands(
1371
1748
  [
1372
- ...(supersededKey ? [liveWireguardCommand(next.interfaceName, `peer ${supersededKey} remove`)] : []),
1373
- liveWireguardCommand(next.interfaceName, `peer ${publicKey} allowed-ips ${peer.allowedIPs.join(',')}`),
1749
+ ...(supersededKey ? [liveWireguardCommand(state.interfaceName, `peer ${supersededKey} remove`)] : []),
1750
+ liveWireguardCommand(state.interfaceName, `peer ${publicKey} allowed-ips ${peer.allowedIPs.join(',')}`),
1374
1751
  ],
1375
1752
  options.dryRun,
1376
1753
  );
1377
- writeServerInterfaceConf({ state: next, peers, dryRun: options.dryRun });
1754
+ writeServerInterfaceConf({ state, peers, dryRun: options.dryRun });
1378
1755
  }
1379
- if (!options.dryRun) writeEdgeState(next);
1756
+ if (!options.dryRun) writeTopology({ ...topology, [hubHost]: nextHub });
1380
1757
  logger.info(options.buildConf === true ? 'Peer recorded; host untouched' : 'Peer registered', {
1381
1758
  id,
1759
+ hubHost,
1382
1760
  address,
1761
+ managementHost: peer.managementHost || '(unset)',
1383
1762
  allowedIPs: peer.allowedIPs,
1384
1763
  rekeyed: supersededKey !== '',
1385
1764
  });
1386
- warnRegistryHazards(peers);
1387
- return next;
1765
+ warnTopologyHazards(peers);
1766
+ return nextHub;
1388
1767
  },
1389
1768
 
1390
1769
  /**
1391
1770
  * @method peerRemove
1392
- * @description Removes a spoke from the registry and from the running hub.
1771
+ * @description Removes a spoke from topology and from the running hub.
1393
1772
  * @param {object} options - CLI options.
1394
- * @returns {object} The updated registry.
1773
+ * @returns {object} The updated hub topology.
1395
1774
  * @memberof UnderpostWireguard
1396
1775
  */
1397
1776
  peerRemove(options = {}) {
1398
- const state = readEdgeState();
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');
1399
1784
  const id = `${options.peerRemove || ''}`.trim();
1400
- const peer = state.peers.find((entry) => entry.id === id);
1785
+ const peer = hub.peers.find((entry) => entry.id === id);
1401
1786
  if (!peer) {
1402
- logger.warn('No such peer in registry', { id });
1403
- return state;
1787
+ logger.warn('No such peer in topology', { hubHost, id });
1788
+ return hub;
1404
1789
  }
1405
- const peers = state.peers.filter((entry) => entry.id !== id);
1406
- const next = { ...state, peers };
1790
+ const peers = hub.peers.filter((entry) => entry.id !== id);
1791
+ const nextHub = { ...hub, peers };
1407
1792
  if (options.buildConf !== true) {
1408
- runHostCommands([liveWireguardCommand(next.interfaceName, `peer ${peer.publicKey} remove`)], options.dryRun);
1409
- writeServerInterfaceConf({ state: next, peers, dryRun: options.dryRun });
1793
+ runHostCommands([liveWireguardCommand(state.interfaceName, `peer ${peer.publicKey} remove`)], options.dryRun);
1794
+ writeServerInterfaceConf({ state, peers, dryRun: options.dryRun });
1410
1795
  }
1411
- if (!options.dryRun) writeEdgeState(next);
1412
- logger.info(options.buildConf === true ? 'Peer removed from registry; host untouched' : 'Peer removed', { id });
1413
- warnRegistryHazards(peers);
1414
- return next;
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;
1415
1803
  },
1416
1804
 
1417
1805
  /**
1418
1806
  * @method buildConf
1419
- * @description Rewrites the registry in place, normalized, touching no host.
1807
+ * @description Rewrites topology in place, normalized, touching no host.
1420
1808
  *
1421
- * The registry is authored, not derived — its peer bindings and public keys
1809
+ * Topology is authored, not derived — its peer bindings and public keys
1422
1810
  * exist nowhere else, so nothing can regenerate it from other configuration.
1423
1811
  * This is the repair path: it fills in defaults a hand-edited file omitted,
1424
- * drops entries with no id, and reports what it read, so a malformed registry
1812
+ * drops entries with no id, and reports what it read, so malformed topology
1425
1813
  * is corrected before a sync acts on it.
1426
1814
  * @param {object} [options] - CLI options.
1427
- * @returns {object} The normalized registry.
1815
+ * @returns {object} The normalized topology.
1428
1816
  * @memberof UnderpostWireguard
1429
1817
  */
1430
1818
  buildConf(options = {}) {
1431
- const state = readEdgeState();
1432
- const changed = options.dryRun ? false : writeEdgeState(state);
1433
- logger.info('Registry normalized', {
1434
- target: EDGE_STATE_PATH,
1435
- role: state.role,
1436
- address: state.address,
1437
- peers: state.peers.map((peer) => peer.id),
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) })),
1438
1824
  changed,
1439
1825
  });
1440
- if (!state.role)
1441
- logger.warn('Registry records no role for this machine', {
1442
- next: '--build-conf --wireguard-setup --server --cidr 10.0.0.1/24 (or --client …)',
1443
- });
1444
- warnRegistryHazards(state.peers);
1445
- return state;
1826
+ for (const hub of Object.values(topology)) warnTopologyHazards(hub.peers);
1827
+ return topology;
1446
1828
  },
1447
1829
 
1448
1830
  /**
1449
1831
  * @method routeTable
1450
1832
  * @description The resolved hostname-to-spoke table for one deploy, a list,
1451
- * or every deploy in `dd.router`.
1833
+ * or every deploy in `dd.routes`.
1452
1834
  *
1453
1835
  * Reads each deploy's configuration through the same helpers the cluster
1454
1836
  * runners use, so a hostname the edge routes is exactly a hostname the
1455
1837
  * cluster publishes.
1456
1838
  *
1457
- * A deploy listed in `dd.router` whose configuration is not checked out
1839
+ * A deploy listed in `dd.routes` whose configuration is not checked out
1458
1840
  * locally is skipped with a warning rather than failing the run: the private
1459
1841
  * conf of an unrelated deploy is not a precondition for publishing the ones
1460
1842
  * that are present.
1461
1843
  *
1462
- * Omitting the id means `dd` — the whole of `dd.router`. The edge holds one
1844
+ * Omitting the id means `dd` — the whole of `dd.routes`. The edge holds one
1463
1845
  * pair of map files for the cluster, so a complete table is the only default
1464
1846
  * that publishes a working edge; narrowing it is the deliberate act.
1465
1847
  * @param {string} [deployId] - Deploy id, comma-separated list, or `dd` (the default).
@@ -1470,7 +1852,7 @@ class UnderpostWireguard {
1470
1852
  routeTable(deployId) {
1471
1853
  const deployList = deployListFactory(deployId || 'dd');
1472
1854
  if (deployList.length === 0) throw new Error('[wireguard] --deploy-id resolved to no deploys');
1473
- const state = readEdgeState();
1855
+ const state = readEdgeContext();
1474
1856
  const tables = [];
1475
1857
  const missing = [];
1476
1858
  for (const id of deployList) {
@@ -1503,17 +1885,38 @@ class UnderpostWireguard {
1503
1885
  *
1504
1886
  * This is the only read-only entry point. Host probes are skipped under
1505
1887
  * `--build-conf`, which has to run on a workstation with no interface to
1506
- * query, so an off-box run reports registry and routing alone.
1888
+ * query, so an off-box run reports topology and routing alone.
1507
1889
  * @param {object} options - CLI options.
1508
- * @returns {object} Runtime, registry and routing summary.
1890
+ * @returns {object} Runtime, topology and routing summary.
1509
1891
  * @memberof UnderpostWireguard
1510
1892
  */
1511
1893
  status(options = {}) {
1512
- const state = readEdgeState();
1894
+ const state = readEdgeContext();
1513
1895
  const interfaceName = `${options.interface || state.interfaceName}`.trim();
1514
1896
  const probeHost = options.buildConf !== true;
1515
1897
  const read = (command) => shellExec(command, { stdout: true, silent: true, silentOnError: true }).trim();
1516
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
+ });
1517
1920
 
1518
1921
  let table = null;
1519
1922
  try {
@@ -1525,11 +1928,18 @@ class UnderpostWireguard {
1525
1928
  const via = {};
1526
1929
  for (const route of table?.routes || []) via[route.via] = (via[route.via] || 0) + 1;
1527
1930
  const summary = {
1528
- role: state.role || '(unset)',
1931
+ nodeName: state.nodeName,
1932
+ role: state.role,
1933
+ hubHost: state.hubHost,
1934
+ peerId: state.peerId || '(hub)',
1529
1935
  interface: interfaceName,
1530
1936
  address: state.address,
1531
1937
  endpoint: state.endpoint,
1532
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)' } : {}),
1533
1943
  ...(probeHost
1534
1944
  ? {
1535
1945
  wireguard: read(systemdStatusCommandsFactory(`wg-quick@${interfaceName}`).active),
@@ -1541,14 +1951,18 @@ class UnderpostWireguard {
1541
1951
  : '(closed)',
1542
1952
  }
1543
1953
  : {}),
1544
- peers: probeHost
1545
- ? wireguardStatusFactory({
1546
- peers: state.peers,
1547
- latestHandshakes: show('latest-handshakes'),
1548
- transfer: show('transfer'),
1549
- endpoints: show('endpoints'),
1550
- })
1551
- : state.peers.map(peerSummaryFactory),
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
+ : {}),
1552
1966
  routing: table
1553
1967
  ? {
1554
1968
  deployList: table.deployList,
@@ -1571,7 +1985,7 @@ class UnderpostWireguard {
1571
1985
  logger.warn('Hostnames claimed by more than one deploy; only the first is served', {
1572
1986
  conflicts: table.conflicts,
1573
1987
  });
1574
- warnRegistryHazards(state.peers);
1988
+ warnTopologyHazards(state.peers);
1575
1989
  return summary;
1576
1990
  },
1577
1991
 
@@ -1605,7 +2019,8 @@ class UnderpostWireguard {
1605
2019
  });
1606
2020
  if (conflicts.length > 0)
1607
2021
  logger.warn('Hostnames claimed by more than one deploy; only the first is served', { conflicts });
1608
- const state = readEdgeState();
2022
+ const state = readEdgeContext();
2023
+ assertRoleCapability({ role: state.role, capability: 'haproxy', operation: 'wireguard --haproxy-sync' });
1609
2024
  const defaultPeer = defaultPeerFactory(state.peers);
1610
2025
  const maps = haproxyMapsFactory({ routes });
1611
2026
  const conf = haproxyConfFactory({
@@ -1626,12 +2041,12 @@ class UnderpostWireguard {
1626
2041
  const restore = () => {
1627
2042
  for (const entry of previous) {
1628
2043
  if (entry.content === null) shellExec(`sudo rm -f ${entry.target}`, { silent: true });
1629
- else writeRootFile({ target: entry.target, content: entry.content, mode: '0644' });
2044
+ else installRootFile({ target: entry.target, content: entry.content, mode: '0644' });
1630
2045
  }
1631
2046
  };
1632
2047
 
1633
2048
  let changed = false;
1634
- for (const entry of targets) changed = writeRootFile({ ...entry, dryRun: options.dryRun }) || changed;
2049
+ for (const entry of targets) changed = installRootFile({ ...entry, dryRun: options.dryRun }) || changed;
1635
2050
  if (options.dryRun) {
1636
2051
  logger.info('[dry-run] edge routes', { deployList, routes, unresolved, conflicts });
1637
2052
  return { routes, unresolved, conflicts, changed };
@@ -1692,13 +2107,12 @@ class UnderpostWireguard {
1692
2107
  * @memberof UnderpostWireguard
1693
2108
  */
1694
2109
  forwardProxyConfig(options = {}) {
1695
- const state = readEdgeState();
1696
- if (state.role !== 'server')
1697
- throw new Error(
1698
- state.role === 'client'
1699
- ? '[wireguard] --forward-proxy-server runs on the hub: a spoke would relay through its own ISP address'
1700
- : '[wireguard] --forward-proxy-server requires a configured hub; run --wireguard-setup --server first',
1701
- );
2110
+ const state = readEdgeContext();
2111
+ assertRoleCapability({
2112
+ role: state.role,
2113
+ capability: 'forward-proxy',
2114
+ operation: 'wireguard --forward-proxy-server',
2115
+ });
1702
2116
  const config = forwardProxyConfigFactory({
1703
2117
  host: `${options.forwardProxyServerHost || ''}`.trim() || tunnelAddressFactory(state.address),
1704
2118
  port: options.forwardProxyServerPort,
@@ -1706,8 +2120,8 @@ class UnderpostWireguard {
1706
2120
  if (!config.apiKey)
1707
2121
  throw new Error(
1708
2122
  `[wireguard] ${FORWARD_PROXY.env.apiKey} is not set; every proxied request is authenticated with it. ` +
1709
- `Export it, put it in the deploy env selected by \`underpost env <deploy-id> <environment>\` (./.env), ` +
1710
- `or set it with \`underpost env set ${FORWARD_PROXY.env.apiKey} <key>\``,
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>\``,
1711
2125
  );
1712
2126
  if (['0.0.0.0', '::', '*'].includes(config.host))
1713
2127
  logger.warn('Forward proxy is bound to a wildcard address, not the tunnel', {
@@ -1817,7 +2231,7 @@ class UnderpostWireguard {
1817
2231
  }
1818
2232
  runHostCommands(
1819
2233
  firewallCommandsFactory({
1820
- role: 'server',
2234
+ role: 'hub',
1821
2235
  interfaceName: config.interfaceName,
1822
2236
  listenPort: config.listenPort,
1823
2237
  tunnelCidr: config.tunnelCidr,
@@ -1831,7 +2245,7 @@ class UnderpostWireguard {
1831
2245
  interfaceName: config.interfaceName,
1832
2246
  command: forwardProxyCommandFactory({ host: config.host, port: config.port, execPath: node.path }),
1833
2247
  });
1834
- const changed = writeRootFile({
2248
+ const changed = installRootFile({
1835
2249
  target: FORWARD_PROXY.unitPath,
1836
2250
  content: unit,
1837
2251
  mode: '0600',
@@ -1903,13 +2317,13 @@ class UnderpostWireguard {
1903
2317
  * @memberof UnderpostWireguard
1904
2318
  */
1905
2319
  start(options = {}) {
1906
- const state = readEdgeState();
2320
+ const state = readEdgeContext();
1907
2321
  const interfaceName = `${options.interface || state.interfaceName}`.trim();
1908
2322
  runServiceCommands(
1909
2323
  [systemctlCommandFactory({ action: 'enable --now', name: `wg-quick@${interfaceName}` })],
1910
2324
  options.dryRun,
1911
2325
  );
1912
- if (state.role === 'server')
2326
+ if (state.role === 'hub')
1913
2327
  runHostCommands(
1914
2328
  quicForwardCommandsFactory({ interfaceName, target: defaultPeerFactory(state.peers)?.address || '' }).ensure,
1915
2329
  options.dryRun,
@@ -1917,6 +2331,60 @@ class UnderpostWireguard {
1917
2331
  logger.info('WireGuard interface started', { interfaceName });
1918
2332
  },
1919
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
+
1920
2388
  /**
1921
2389
  * @method stop
1922
2390
  * @description Tears the tunnel down and removes the packet rules that only
@@ -1926,7 +2394,7 @@ class UnderpostWireguard {
1926
2394
  * @memberof UnderpostWireguard
1927
2395
  */
1928
2396
  stop(options = {}) {
1929
- const state = readEdgeState();
2397
+ const state = readEdgeContext();
1930
2398
  const interfaceName = `${options.interface || state.interfaceName}`.trim();
1931
2399
  runHostCommands(
1932
2400
  [
@@ -1956,8 +2424,8 @@ class UnderpostWireguard {
1956
2424
  * deleting the maps it reads is worse than leaving both: the daemon then
1957
2425
  * fails to start on a config that references files that no longer exist.
1958
2426
  *
1959
- * The key pair and the registry are deliberately kept: destroying the key
1960
- * invalidates every spoke's peer entry, and the registry is authored source
2427
+ * The key pair and topology are deliberately kept: destroying the key
2428
+ * invalidates every spoke's peer entry, and topology is authored source
1961
2429
  * that nothing can regenerate. A reset is for reconfiguring an edge rather
1962
2430
  * than for re-establishing trust with all of them; re-keying is what
1963
2431
  * {@link UnderpostWireguard.reinstall} is for.
@@ -1966,7 +2434,7 @@ class UnderpostWireguard {
1966
2434
  * @memberof UnderpostWireguard
1967
2435
  */
1968
2436
  reset(options = {}) {
1969
- const state = readEdgeState();
2437
+ const state = readEdgeContext();
1970
2438
  const interfaceName = `${options.interface || state.interfaceName}`.trim();
1971
2439
  UnderpostWireguard.API.stop({ ...options, interface: interfaceName });
1972
2440
  runHostCommands(
@@ -1983,28 +2451,22 @@ class UnderpostWireguard {
1983
2451
  `sudo rm -f ${UNDERPOST_EDGE.haproxyDir}/${UNDERPOST_EDGE.httpMapName}`,
1984
2452
  `sudo rm -f ${UNDERPOST_EDGE.haproxyDir}/${UNDERPOST_EDGE.haproxyConfName}`,
1985
2453
  ...forwardProxyServiceCommandsFactory().remove,
1986
- ...(state.role
1987
- ? firewallCommandsFactory({
1988
- role: state.role,
1989
- interfaceName,
1990
- listenPort: state.listenPort,
1991
- tunnelCidr: tunnelNetworkCidrFactory(state.address),
1992
- sshForwardPort: state.sshForwardPort,
1993
- remove: true,
1994
- })
1995
- : []),
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
+ }),
1996
2462
  ],
1997
2463
  options.dryRun,
1998
2464
  );
1999
- logger.info('Edge host state removed; key pair and peer registry retained', {
2465
+ logger.info('Edge host state removed; key pair, topology and node identity retained', {
2000
2466
  interfaceName,
2001
- role: state.role || '(unset)',
2002
- firewallWithdrawn: state.role !== '',
2467
+ role: state.role,
2468
+ firewallWithdrawn: true,
2003
2469
  });
2004
- if (!state.role)
2005
- logger.warn('Registry records no role, so no firewalld rules were withdrawn', {
2006
- fix: `--wireguard-reset --interface ${interfaceName} after the role is recorded, or withdraw them by hand`,
2007
- });
2008
2470
  },
2009
2471
 
2010
2472
  /**
@@ -2020,7 +2482,7 @@ class UnderpostWireguard {
2020
2482
  * @memberof UnderpostWireguard
2021
2483
  */
2022
2484
  reinstall(options = {}) {
2023
- const state = readEdgeState();
2485
+ const state = readEdgeContext();
2024
2486
  const interfaceName = `${options.interface || state.interfaceName}`.trim();
2025
2487
  UnderpostWireguard.API.reset({ ...options, interface: interfaceName });
2026
2488
  runHostCommands(
@@ -2035,15 +2497,303 @@ class UnderpostWireguard {
2035
2497
  logger.warn('Re-keyed: this machine now presents a new identity, and the far end still expects the old one', {
2036
2498
  interfaceName,
2037
2499
  publicKey: next.publicKey,
2038
- onEverySpoke:
2039
- next.role === 'server'
2040
- ? `--wireguard-setup --client --public-key '${next.publicKey}' (peer-ip and endpoint are remembered)`
2041
- : 'none',
2042
- onTheHub:
2043
- next.role === 'client'
2044
- ? `--peer-add <this spoke id> --peer-ip ${next.address} --public-key '${next.publicKey}'`
2045
- : 'none',
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,
2046
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 };
2047
2797
  },
2048
2798
 
2049
2799
  /**
@@ -2053,7 +2803,7 @@ class UnderpostWireguard {
2053
2803
  *
2054
2804
  * Flags are evaluated in lifecycle order — install, setup, peer changes,
2055
2805
  * route publication, then daemon control — so a single invocation can carry
2056
- * a whole bring-up (`--wireguard-install --wireguard-setup --server
2806
+ * a whole bring-up (`--node-config --wireguard-install --wireguard-setup
2057
2807
  * --haproxy-setup --wireguard-start`) and still execute the steps in the only
2058
2808
  * order that works. `--status` runs last, so it reports what the run left
2059
2809
  * behind.
@@ -2062,6 +2812,28 @@ class UnderpostWireguard {
2062
2812
  * @memberof UnderpostWireguard
2063
2813
  */
2064
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);
2065
2837
  // `--build-conf` is a hard promise, not a modifier: it short-circuits
2066
2838
  // every host action so the run cannot touch /etc, iptables, systemd or a
2067
2839
  // live interface even when other lifecycle flags are also present.
@@ -2075,6 +2847,24 @@ class UnderpostWireguard {
2075
2847
  return;
2076
2848
  }
2077
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
+
2078
2868
  if (options.wireguardReinstall === true) return void UnderpostWireguard.API.reinstall(options);
2079
2869
  if (options.wireguardReset === true) return void UnderpostWireguard.API.reset(options);
2080
2870
  if (options.wireguardInstall === true) UnderpostWireguard.API.install(options);
@@ -2085,41 +2875,56 @@ class UnderpostWireguard {
2085
2875
  else if (options.haproxySync === true) UnderpostWireguard.API.haproxySync(options);
2086
2876
  if (options.wireguardStop === true) UnderpostWireguard.API.stop(options);
2087
2877
  if (options.wireguardStart === true) UnderpostWireguard.API.start(options);
2878
+ if (options.wireguardRestart === true) UnderpostWireguard.API.restart(options);
2088
2879
  // After the tunnel, because the service requires it and its address only
2089
2880
  // exists once the interface is up; before `--status`, so a run that
2090
2881
  // reconciles the service also reports it.
2091
2882
  if (options.forwardProxyServer === true) UnderpostWireguard.API.forwardProxyServer(options);
2883
+ if (options.check === true) UnderpostWireguard.API.check(options);
2092
2884
  if (options.status === true) UnderpostWireguard.API.status(options);
2093
2885
  },
2094
2886
  };
2095
2887
  }
2096
2888
 
2097
2889
  export {
2098
- EDGE_STATE_PATH,
2890
+ EDGE_TOPOLOGY_PATH,
2891
+ ENGINE_SYNC_STEPS,
2099
2892
  UNDERPOST_EDGE,
2100
2893
  allowedIpsConflictsFactory,
2894
+ assertEdgeIdentity,
2101
2895
  backendNameFactory,
2102
2896
  defaultPeerFactory,
2103
2897
  deployListFactory,
2104
2898
  edgeRouteTableFactory,
2105
- edgeStateFactory,
2899
+ edgeContextFactory,
2900
+ endpointHostFactory,
2106
2901
  firewallCommandsFactory,
2107
2902
  haproxyConfFactory,
2108
2903
  haproxyMapsFactory,
2109
2904
  hostProxyEntriesFactory,
2905
+ hostAddressesFactory,
2906
+ hubTunnelAddressFactory,
2110
2907
  instanceProxyEntriesFactory,
2908
+ localPeerFactory,
2111
2909
  mergeRouteTablesFactory,
2112
2910
  peerFactory,
2113
2911
  quicForwardCommandsFactory,
2114
- readEdgeState,
2912
+ readEdgeContext,
2913
+ readNodeConfigs,
2914
+ readTopology,
2115
2915
  redirectHostFactory,
2916
+ sshUriFactory,
2917
+ unregisteredPeersFactory,
2116
2918
  tunnelAddressFactory,
2117
2919
  tunnelNetworkCidrFactory,
2118
2920
  wireguardClientConfFactory,
2119
- wireguardClientSettingsFactory,
2921
+ wireguardHealthFactory,
2120
2922
  wireguardServerConfFactory,
2121
2923
  wireguardStatusFactory,
2122
- writeEdgeState,
2924
+ topologyFactory,
2925
+ hubFactory,
2926
+ nodeIdentityFactory,
2927
+ nodeNameCandidatesFactory,
2123
2928
  };
2124
2929
 
2125
2930
  export default UnderpostWireguard;