@underpostnet/cyberia 3.3.0 → 3.3.76
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.dockerignore +1 -0
- package/.env.example +17 -5
- package/.github/workflows/coverall.cyberia.ci.yml +39 -14
- package/.github/workflows/cyberia-client.cd.yml +5 -0
- package/.github/workflows/cyberia-server.cd.yml +5 -0
- package/.github/workflows/docker-image.cyberia-client.ci.yml +8 -5
- package/.github/workflows/docker-image.cyberia-client.dev.ci.yml +8 -3
- package/.github/workflows/docker-image.cyberia-server.ci.yml +8 -5
- package/.github/workflows/docker-image.cyberia-server.dev.ci.yml +8 -5
- package/.github/workflows/docker-image.engine-cyberia.ci.yml +15 -1
- package/.github/workflows/docker-image.engine-cyberia.dev.ci.yml +15 -1
- package/.github/workflows/engine-cyberia.cd.yml +3 -0
- package/.github/workflows/engine-cyberia.ci.yml +4 -5
- package/.github/workflows/ghpkg.ci.yml +35 -20
- package/.github/workflows/hardhat.ci.yml +12 -11
- package/.github/workflows/npmpkg.ci.yml +17 -15
- package/.github/workflows/publish.cyberia.ci.yml +44 -0
- package/.github/workflows/pwa-microservices-template-test.ci.yml +2 -2
- package/.github/workflows/release.cd.yml +1 -0
- package/.prettierignore +1 -0
- package/AGENTS.md +96 -0
- package/CHANGELOG.md +653 -2513
- package/CLI-HELP.md +323 -157
- package/Dockerfile +25 -9
- package/Dockerfile.dev +25 -9
- package/Dockerfile.test +5 -5
- package/README.md +48 -28
- package/bin/build.js +86 -41
- package/bin/build.template.js +8 -3
- package/bin/cyberia.js +1494 -1403
- package/bin/deploy.js +18 -104
- package/bin/index.js +1494 -1403
- package/compose.env +17 -5
- package/conf.js +98 -7
- package/deploy/cyberia-client/deploy.sh +46 -0
- package/deploy/cyberia-client/package.sh +17 -0
- package/deploy/cyberia-client/state.sh +15 -0
- package/deploy/cyberia-server/deploy.sh +46 -0
- package/deploy/cyberia-server/package.sh +17 -0
- package/deploy/cyberia-server/state.sh +15 -0
- package/deploy/dd-cyberia/deploy.sh +9 -29
- package/deploy/dd-cyberia/init.sh +38 -45
- package/deploy/dd-cyberia/package.sh +16 -0
- package/deploy/dd-cyberia/state.sh +14 -0
- package/deploy/dd-cyberia/sync-deploy.sh +133 -147
- package/deploy/lib/config.sh +29 -0
- package/deploy/lib/github-actions-logging.sh +610 -0
- package/deploy/lib/host.sh +194 -0
- package/deploy/lib/state.sh +65 -0
- package/deploy/pwa-microservices-template/deploy.sh +17 -42
- package/deploy/release/deploy.sh +9 -41
- package/deployment.yaml +7 -2
- package/docker-compose.yml +23 -17
- package/examples/static-page/README.md +19 -19
- package/examples/static-page/ssr-components/CustomPage.js +2 -2
- package/hardhat/hardhat.config.js +1 -6
- package/hardhat/package-lock.json +101 -40
- package/hardhat/package.json +2 -2
- package/manifests/alertmanager/deployment.yaml +81 -0
- package/manifests/blackbox-exporter/deployment.yaml +75 -0
- package/manifests/cronjobs/dd-cron/dd-cron-backup.yaml +32 -7
- package/manifests/cronjobs/dd-cron/dd-cron-vultr.yaml +32 -7
- package/manifests/deployment/dd-cyberia-development/deployment.yaml +7 -2
- package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
- package/manifests/deployment/mongo-express/deployment.yaml +46 -27
- package/manifests/deployment/mongo-express/kustomization.yaml +9 -0
- package/manifests/deployment/mongo-express/mongo-express-nodeport.yaml +18 -0
- package/manifests/deployment/mongo-express/service.yaml +16 -0
- package/manifests/deployment/mongo-express-no-auth/disable-mongodb-auth.yaml +16 -0
- package/manifests/deployment/mongo-express-no-auth/kustomization.yaml +16 -0
- package/manifests/grafana/deployment.yaml +26 -7
- package/manifests/grafana/kustomization.yaml +1 -0
- package/manifests/grafana/service.yaml +6 -1
- package/manifests/mariadb/pv.yaml +1 -0
- package/manifests/mongodb/statefulset.yaml +9 -2
- package/manifests/mysql/pv-pvc.yaml +14 -1
- package/manifests/postgresql/pv.yaml +12 -1
- package/manifests/postgresql/pvc.yaml +1 -0
- package/manifests/prometheus/deployment.yaml +18 -22
- package/package.json +87 -12
- package/scripts/event-monitor.sh +56 -0
- package/scripts/k3s-node-setup.sh +27 -14
- package/scripts/kubeadm-node-setup.sh +31 -11
- package/scripts/maas-setup.sh +8 -8
- package/scripts/selinux-normalize.sh +907 -0
- package/scripts/shutdown-machine.sh +843 -0
- package/scripts/test-monitor.sh +5 -5
- package/src/api/atlas-sprite-sheet/atlas-sprite-sheet.controller.js +15 -10
- package/src/api/atlas-sprite-sheet/atlas-sprite-sheet.model.js +36 -5
- package/src/api/atlas-sprite-sheet/atlas-sprite-sheet.router.js +7 -2
- package/src/api/atlas-sprite-sheet/atlas-sprite-sheet.service.js +90 -186
- package/src/api/core/core.controller.js +1 -1
- package/src/api/core/core.router.js +2 -2
- package/src/api/core/core.service.js +2 -2
- package/src/api/crypto/crypto.controller.js +1 -1
- package/src/api/crypto/crypto.service.js +1 -1
- package/src/api/cyberia-action/cyberia-action.controller.js +1 -1
- package/src/api/cyberia-action/cyberia-action.router.js +3 -3
- package/src/api/cyberia-action/cyberia-action.service.js +5 -6
- package/src/api/cyberia-audio/cyberia-audio.controller.js +6 -0
- package/src/api/cyberia-audio/cyberia-audio.model.js +61 -0
- package/src/api/cyberia-audio/cyberia-audio.router.js +21 -0
- package/src/api/cyberia-audio/cyberia-audio.service.js +220 -0
- package/src/api/cyberia-client-hints/cyberia-client-hints.controller.js +2 -4
- package/src/api/cyberia-client-hints/cyberia-client-hints.model.js +10 -19
- package/src/api/cyberia-client-hints/cyberia-client-hints.router.js +13 -35
- package/src/api/cyberia-client-hints/cyberia-client-hints.service.js +17 -34
- package/src/api/cyberia-dialogue/cyberia-dialogue.controller.js +1 -1
- package/src/api/cyberia-dialogue/cyberia-dialogue.model.js +1 -1
- package/src/api/cyberia-dialogue/cyberia-dialogue.router.js +2 -2
- package/src/api/cyberia-dialogue/cyberia-dialogue.service.js +2 -2
- package/src/api/cyberia-entity/cyberia-entity.controller.js +1 -1
- package/src/api/cyberia-entity/cyberia-entity.model.js +2 -0
- package/src/api/cyberia-entity/cyberia-entity.router.js +1 -1
- package/src/api/cyberia-entity/cyberia-entity.service.js +3 -3
- package/src/api/cyberia-entity-type-default/cyberia-entity-type-default.controller.js +8 -2
- package/src/api/cyberia-entity-type-default/cyberia-entity-type-default.model.js +31 -20
- package/src/api/cyberia-entity-type-default/cyberia-entity-type-default.router.js +17 -2
- package/src/api/cyberia-entity-type-default/cyberia-entity-type-default.service.js +468 -5
- package/src/api/cyberia-instance/cyberia-fallback-capture.js +502 -0
- package/src/api/cyberia-instance/cyberia-fallback-world.js +29 -40
- package/src/api/cyberia-instance/cyberia-instance-boot.service.js +5 -5
- package/src/api/cyberia-instance/cyberia-instance-items.js +98 -0
- package/src/api/cyberia-instance/cyberia-instance-map.service.js +35 -142
- package/src/api/cyberia-instance/cyberia-instance.controller.js +2 -5
- package/src/api/cyberia-instance/cyberia-instance.model.js +0 -14
- package/src/api/cyberia-instance/cyberia-instance.router.js +5 -18
- package/src/api/cyberia-instance/cyberia-instance.service.js +5 -45
- package/src/api/cyberia-instance/cyberia-world-generator.js +8 -13
- package/src/api/cyberia-instance-conf/cyberia-instance-conf.controller.js +1 -1
- package/src/api/cyberia-instance-conf/cyberia-instance-conf.model.js +49 -75
- package/src/api/cyberia-instance-conf/cyberia-instance-conf.router.js +1 -1
- package/src/api/cyberia-instance-conf/cyberia-instance-conf.service.js +76 -3
- package/src/api/cyberia-map/cyberia-map.controller.js +1 -1
- package/src/api/cyberia-map/cyberia-map.router.js +1 -1
- package/src/api/cyberia-map/cyberia-map.service.js +8 -4
- package/src/api/cyberia-map-audio-conf/cyberia-map-audio-conf.controller.js +31 -0
- package/src/api/cyberia-map-audio-conf/cyberia-map-audio-conf.model.js +56 -0
- package/src/api/cyberia-map-audio-conf/cyberia-map-audio-conf.router.js +33 -0
- package/src/api/cyberia-map-audio-conf/cyberia-map-audio-conf.service.js +189 -0
- package/src/api/cyberia-quest/cyberia-quest.controller.js +1 -1
- package/src/api/cyberia-quest/cyberia-quest.router.js +4 -4
- package/src/api/cyberia-quest/cyberia-quest.service.js +5 -5
- package/src/api/cyberia-quest-progress/cyberia-quest-progress.controller.js +1 -1
- package/src/api/cyberia-quest-progress/cyberia-quest-progress.model.js +1 -1
- package/src/api/cyberia-quest-progress/cyberia-quest-progress.router.js +1 -1
- package/src/api/cyberia-quest-progress/cyberia-quest-progress.service.js +2 -2
- package/src/api/cyberia-saga/cyberia-saga.controller.js +1 -1
- package/src/api/cyberia-saga/cyberia-saga.router.js +1 -1
- package/src/api/cyberia-saga/cyberia-saga.service.js +2 -2
- package/src/api/cyberia-server-defaults/cyberia-server-defaults.js +403 -214
- package/src/api/cyberia-skill/cyberia-skill.controller.js +1 -1
- package/src/api/cyberia-skill/cyberia-skill.model.js +6 -6
- package/src/api/cyberia-skill/cyberia-skill.router.js +1 -1
- package/src/api/cyberia-skill/cyberia-skill.service.js +2 -2
- package/src/api/default/default.controller.js +1 -1
- package/src/api/default/default.router.js +1 -1
- package/src/api/default/default.service.js +2 -2
- package/src/api/document/document.controller.js +1 -1
- package/src/api/document/document.router.js +1 -1
- package/src/api/document/document.service.js +2 -2
- package/src/api/file/file.controller.js +1 -1
- package/src/api/file/file.ref.js +153 -0
- package/src/api/file/file.ref.json +11 -2
- package/src/api/file/file.router.js +1 -1
- package/src/api/file/file.service.js +2 -2
- package/src/api/instance/instance.controller.js +1 -1
- package/src/api/instance/instance.router.js +1 -1
- package/src/api/instance/instance.service.js +2 -2
- package/src/api/ipfs/ipfs.controller.js +1 -1
- package/src/api/ipfs/ipfs.router.js +2 -2
- package/src/api/ipfs/ipfs.service.js +2 -2
- package/src/api/object-layer/object-layer.controller.js +1 -1
- package/src/api/object-layer/object-layer.model.js +47 -12
- package/src/api/object-layer/object-layer.router.js +12 -5
- package/src/api/object-layer/object-layer.service.js +116 -123
- package/src/api/object-layer-render-frames/object-layer-render-frames.controller.js +1 -1
- package/src/api/object-layer-render-frames/object-layer-render-frames.router.js +1 -1
- package/src/api/object-layer-render-frames/object-layer-render-frames.service.js +2 -2
- package/src/api/test/test.controller.js +1 -1
- package/src/api/test/test.service.js +1 -1
- package/src/api/user/guest.service.js +3 -3
- package/src/api/user/user.controller.js +1 -1
- package/src/api/user/user.router.js +2 -2
- package/src/api/user/user.service.js +4 -4
- package/src/api.js +5 -9
- package/src/cli/app.js +402 -0
- package/src/cli/baremetal.js +30 -35
- package/src/cli/client.js +306 -0
- package/src/cli/cloud-init.js +3 -3
- package/src/cli/cluster.js +191 -82
- package/src/cli/db.js +120 -68
- package/src/cli/deploy.js +157 -75
- package/src/cli/docker-compose.js +5 -5
- package/src/cli/domains.js +184 -0
- package/src/cli/dotenv-store.js +143 -0
- package/src/cli/event.js +2281 -0
- package/src/cli/fs-selection.js +115 -0
- package/src/cli/fs.js +159 -343
- package/src/cli/host.js +672 -0
- package/src/cli/image.js +4 -6
- package/src/cli/index.js +343 -151
- package/src/cli/ipfs.js +34 -30
- package/src/cli/kickstart.js +1 -1
- package/src/cli/kubectl.js +218 -41
- package/src/cli/lxd.js +3 -3
- package/src/cli/monitor.js +1359 -26
- package/src/cli/package.js +90 -0
- package/src/cli/release.js +16 -12
- package/src/cli/repository.js +586 -363
- package/src/cli/run.js +398 -843
- package/src/cli/secrets.js +2158 -964
- package/src/cli/ssh.js +478 -279
- package/src/cli/state.js +394 -0
- package/src/cli/static.js +2 -2
- package/src/cli/system.js +1 -1
- package/src/cli/test.js +203 -125
- package/src/cli/vultr.js +61 -21
- package/src/cli/wireguard.js +1112 -306
- package/src/client/components/core/CalendarCore.js +53 -96
- package/src/client/components/core/Docs.js +327 -303
- package/src/client/components/core/FileExplorer.js +0 -21
- package/src/client/components/core/Modal.js +56 -1
- package/src/client/components/core/PanelForm.js +4 -4
- package/src/client/components/core/Repository.js +56 -0
- package/src/client/components/core/Translate.js +7 -0
- package/src/client/components/core/{FullScreen.js → ViewModeController.js} +42 -39
- package/src/client/components/core/Wallet.js +0 -11
- package/src/client/components/core/Worker.js +7 -1
- package/src/client/components/cryptokoyn/SettingsCryptokoyn.js +2 -2
- package/src/client/components/cyberia/ActionEngineCyberia.js +12 -36
- package/src/client/components/cyberia/EntityEngineCyberia.js +492 -121
- package/src/client/components/cyberia/InstanceEngineCyberia.js +129 -160
- package/src/client/components/cyberia/InstanceSelectionView.js +7 -1
- package/src/client/components/cyberia/MapEngineCyberia.js +175 -69
- package/src/client/components/cyberia/ObjectLayerEngine.js +1011 -97
- package/src/client/components/cyberia/ObjectLayerEngineModal.js +81 -155
- package/src/client/components/cyberia/ObjectLayerEngineViewer.js +113 -98
- package/src/client/components/cyberia/SharedDefaultsCyberia.js +215 -56
- package/src/client/components/cyberia-portal/AppShellCyberiaPortal.js +2 -35
- package/src/client/components/cyberia-portal/CssCyberiaPortal.js +1 -1
- package/src/client/components/cyberia-portal/MainBodyCyberiaPortal.js +712 -76
- package/src/client/components/cyberia-portal/SettingsCyberiaPortal.js +2 -2
- package/src/client/components/cyberia-portal/TranslateCyberiaPortal.js +0 -4
- package/src/client/components/default/AppShellDefault.js +1 -1
- package/src/client/components/default/SettingsDefault.js +2 -2
- package/src/client/components/itemledger/SettingsItemledger.js +2 -2
- package/src/client/components/underpost/AppShellUnderpost.js +3 -2
- package/src/client/components/underpost/SettingsUnderpost.js +2 -2
- package/src/client/public/cyberia-docs/ARCHITECTURE.md +15 -15
- package/src/client/public/cyberia-docs/CYBERIA-CLI.md +313 -30
- package/src/client/public/cyberia-docs/CYBERIA-CLIENT.md +51 -29
- package/src/client/public/cyberia-docs/CYBERIA-SAGA.md +52 -53
- package/src/client/public/cyberia-docs/CYBERIA-SERVER.md +16 -11
- package/src/client/public/cyberia-docs/CYBERIA.md +48 -28
- package/src/client/public/cyberia-docs/ENTITY-PROFILE.md +11 -5
- package/src/client/public/cyberia-docs/HARDHAT-MODULE.md +23 -6
- package/src/client/public/cyberia-docs/ROADMAP.md +1 -1
- package/src/client/public/cyberia-docs/STATS-PROGRESSION.md +217 -0
- package/src/client/public/cyberia-docs/WHITE-PAPER.md +20 -12
- package/src/client/public/default/android-chrome-144x144.png +0 -0
- package/src/client/public/default/android-chrome-192x192.png +0 -0
- package/src/client/public/default/android-chrome-256x256.png +0 -0
- package/src/client/public/default/android-chrome-36x36.png +0 -0
- package/src/client/public/default/android-chrome-384x384.png +0 -0
- package/src/client/public/default/android-chrome-48x48.png +0 -0
- package/src/client/public/default/android-chrome-512x512.png +0 -0
- package/src/client/public/default/android-chrome-72x72.png +0 -0
- package/src/client/public/default/android-chrome-96x96.png +0 -0
- package/src/client/public/default/apple-touch-icon-1024x1024.png +0 -0
- package/src/client/public/default/apple-touch-icon-114x114.png +0 -0
- package/src/client/public/default/apple-touch-icon-120x120.png +0 -0
- package/src/client/public/default/apple-touch-icon-144x144.png +0 -0
- package/src/client/public/default/apple-touch-icon-152x152.png +0 -0
- package/src/client/public/default/apple-touch-icon-167x167.png +0 -0
- package/src/client/public/default/apple-touch-icon-180x180.png +0 -0
- package/src/client/public/default/apple-touch-icon-57x57.png +0 -0
- package/src/client/public/default/apple-touch-icon-60x60.png +0 -0
- package/src/client/public/default/apple-touch-icon-72x72.png +0 -0
- package/src/client/public/default/apple-touch-icon-76x76.png +0 -0
- package/src/client/public/default/apple-touch-icon-precomposed.png +0 -0
- package/src/client/public/default/apple-touch-icon.png +0 -0
- package/src/client/public/default/apple-touch-startup-image-1125x2436.png +0 -0
- package/src/client/public/default/apple-touch-startup-image-1136x640.png +0 -0
- package/src/client/public/default/apple-touch-startup-image-1170x2532.png +0 -0
- package/src/client/public/default/apple-touch-startup-image-1179x2556.png +0 -0
- package/src/client/public/default/apple-touch-startup-image-1242x2208.png +0 -0
- package/src/client/public/default/apple-touch-startup-image-1242x2688.png +0 -0
- package/src/client/public/default/apple-touch-startup-image-1284x2778.png +0 -0
- package/src/client/public/default/apple-touch-startup-image-1290x2796.png +0 -0
- package/src/client/public/default/apple-touch-startup-image-1334x750.png +0 -0
- package/src/client/public/default/apple-touch-startup-image-1488x2266.png +0 -0
- package/src/client/public/default/apple-touch-startup-image-1536x2048.png +0 -0
- package/src/client/public/default/apple-touch-startup-image-1620x2160.png +0 -0
- package/src/client/public/default/apple-touch-startup-image-1640x2160.png +0 -0
- package/src/client/public/default/apple-touch-startup-image-1668x2224.png +0 -0
- package/src/client/public/default/apple-touch-startup-image-1668x2388.png +0 -0
- package/src/client/public/default/apple-touch-startup-image-1792x828.png +0 -0
- package/src/client/public/default/apple-touch-startup-image-2048x1536.png +0 -0
- package/src/client/public/default/apple-touch-startup-image-2048x2732.png +0 -0
- package/src/client/public/default/apple-touch-startup-image-2160x1620.png +0 -0
- package/src/client/public/default/apple-touch-startup-image-2160x1640.png +0 -0
- package/src/client/public/default/apple-touch-startup-image-2208x1242.png +0 -0
- package/src/client/public/default/apple-touch-startup-image-2224x1668.png +0 -0
- package/src/client/public/default/apple-touch-startup-image-2266x1488.png +0 -0
- package/src/client/public/default/apple-touch-startup-image-2388x1668.png +0 -0
- package/src/client/public/default/apple-touch-startup-image-2436x1125.png +0 -0
- package/src/client/public/default/apple-touch-startup-image-2532x1170.png +0 -0
- package/src/client/public/default/apple-touch-startup-image-2556x1179.png +0 -0
- package/src/client/public/default/apple-touch-startup-image-2688x1242.png +0 -0
- package/src/client/public/default/apple-touch-startup-image-2732x2048.png +0 -0
- package/src/client/public/default/apple-touch-startup-image-2778x1284.png +0 -0
- package/src/client/public/default/apple-touch-startup-image-2796x1290.png +0 -0
- package/src/client/public/default/apple-touch-startup-image-640x1136.png +0 -0
- package/src/client/public/default/apple-touch-startup-image-750x1334.png +0 -0
- package/src/client/public/default/apple-touch-startup-image-828x1792.png +0 -0
- package/src/client/public/default/assets/background/white.jpg +0 -0
- package/src/client/public/default/browserconfig.xml +1 -1
- package/src/client/public/default/favicon-16x16.png +0 -0
- package/src/client/public/default/favicon-32x32.png +0 -0
- package/src/client/public/default/favicon-48x48.png +0 -0
- package/src/client/public/default/favicon.ico +0 -0
- package/src/client/public/default/manifest.webmanifest +5 -5
- package/src/client/public/default/mstile-144x144.png +0 -0
- package/src/client/public/default/mstile-150x150.png +0 -0
- package/src/client/public/default/mstile-310x150.png +0 -0
- package/src/client/public/default/mstile-310x310.png +0 -0
- package/src/client/public/default/mstile-70x70.png +0 -0
- package/src/client/public/default/yandex-browser-50x50.png +0 -0
- package/src/client/public/default/yandex-browser-manifest.json +1 -1
- package/src/client/services/cyberia-audio/cyberia-audio.service.js +99 -0
- package/src/client/services/cyberia-entity-type-default/cyberia-entity-type-default.service.js +38 -0
- package/src/client/services/cyberia-instance/cyberia-instance.management.js +5 -4
- package/src/client/services/cyberia-instance/cyberia-instance.service.js +0 -40
- package/src/client/services/cyberia-map/cyberia-map.management.js +5 -4
- package/src/client/services/cyberia-map-audio-conf/cyberia-map-audio-conf.service.js +138 -0
- package/src/client/services/object-layer/object-layer.management.js +6 -10
- package/src/client/services/object-layer/object-layer.service.js +3 -1
- package/src/client/ssr/RootDocument.js +1 -1
- package/src/client/ssr/body/404.js +1 -1
- package/src/client/ssr/body/500.js +1 -1
- package/src/client/ssr/body/CacheControl.js +1 -1
- package/src/client/ssr/body/CyberiaDefaultSplashScreen.js +1 -1
- package/src/client/ssr/body/DefaultSplashScreen.js +1 -1
- package/src/client/ssr/body/SwaggerDarkMode.js +1 -1
- package/src/client/ssr/body/UnderpostDefaultSplashScreen.js +1 -1
- package/src/client/ssr/head/CryptokoynScripts.js +1 -1
- package/src/client/ssr/head/Css.js +1 -1
- package/src/client/ssr/head/CyberiaPortalScripts.js +1 -1
- package/src/client/ssr/head/DefaultScripts.js +1 -1
- package/src/client/ssr/head/ItemledgerScripts.js +1 -1
- package/src/client/ssr/head/Microdata.js +1 -1
- package/src/client/ssr/head/Production.js +1 -1
- package/src/client/ssr/head/Pwa.js +1 -1
- package/src/client/ssr/head/PwaItemledger.js +1 -1
- package/src/client/ssr/head/Seo.js +1 -1
- package/src/client/ssr/head/UnderpostScripts.js +1 -1
- package/src/client/ssr/mailer/DefaultRecoverEmail.js +1 -1
- package/src/client/ssr/mailer/DefaultVerifyEmail.js +1 -1
- package/src/client/ssr/views/Cyberia404.js +7 -5
- package/src/client/ssr/views/CyberiaServerMetrics.js +1 -1
- package/src/client/ssr/views/Maintenance.js +1 -1
- package/src/client/ssr/views/NoNetworkConnection.js +1 -1
- package/src/client/ssr/views/Test.js +1 -1
- package/src/client/sw/core.sw.js +20 -14
- package/src/client-builder/client-build-docs.js +92 -77
- package/src/client-builder/client-build-live.js +2 -2
- package/src/client-builder/client-build.js +105 -74
- package/src/client-builder/client-bundle.js +347 -0
- package/src/client-builder/client-dev-server.js +3 -3
- package/src/client-builder/client-icons.js +2 -2
- package/src/client-builder/ssr.js +3 -3
- package/src/client.build.js +3 -6
- package/src/client.dev.js +3 -6
- package/src/db/DataBaseProvider.js +186 -3
- package/src/db/mariadb/MariaDB.js +3 -2
- package/src/db/mongo/MongoBootstrap.js +273 -101
- package/src/db/mongo/MongoExpress.js +187 -0
- package/src/db/mongo/MongooseDB.js +7 -8
- package/src/{server/valkey.js → db/valkey/Valkey.js} +5 -4
- package/src/grpc/cyberia/grpc-server.js +6 -8
- package/src/index.js +79 -23
- package/src/mailer/EmailRender.js +2 -2
- package/src/mailer/MailerInterceptor.js +87 -0
- package/src/mailer/MailerProvider.js +50 -6
- package/src/projects/cyberia/atlas-sprite-sheet-generator.js +144 -147
- package/src/projects/cyberia/atlas-sprite-sheet-store.js +391 -0
- package/src/projects/cyberia/besu-genesis-generator.js +8 -17
- package/src/projects/cyberia/boot-contract-fixtures.js +32 -0
- package/src/projects/cyberia/catalog-cyberia.js +54 -10
- package/src/projects/cyberia/gemini-client.js +4 -4
- package/src/projects/cyberia/generate-saga.js +5 -12
- package/src/projects/cyberia/hot-reload-trigger.js +18 -29
- package/src/projects/cyberia/instance-backup.js +210 -0
- package/src/projects/cyberia/instance-data.js +178 -258
- package/src/projects/cyberia/ipfs-client.js +4 -4
- package/src/projects/cyberia/map-preview-generator.js +33 -69
- package/src/projects/cyberia/object-layer.js +153 -320
- package/src/projects/cyberia/seed-audio.js +139 -0
- package/src/projects/cyberia/semantic-layer-generator-skin.js +2 -8
- package/src/projects/cyberia/semantic-layer-generator.js +3 -19
- package/src/projects/cyberia/stat-balance.js +81 -0
- package/src/projects/cyberia/stat-commands.js +23 -0
- package/src/projects/cyberia/stat-contract-generator.js +101 -0
- package/src/projects/underpost/catalog-underpost.js +9 -1
- package/src/proxy.js +4 -7
- package/src/runtime/cyberia-client/Dockerfile +17 -8
- package/src/runtime/cyberia-client/Dockerfile.dev +13 -4
- package/src/runtime/cyberia-server/Dockerfile +18 -6
- package/src/runtime/cyberia-server/Dockerfile.dev +16 -3
- package/src/runtime/engine-cyberia/Dockerfile +25 -9
- package/src/runtime/engine-cyberia/Dockerfile.dev +25 -9
- package/src/runtime/engine-cyberia/Dockerfile.test +5 -5
- package/src/runtime/engine-cyberia/compose.env +17 -5
- package/src/runtime/engine-cyberia/docker-compose.yml +23 -17
- package/src/runtime/express/Express.js +32 -27
- package/src/runtime/lampp/Lampp.js +22 -70
- package/src/runtime/nginx/Nginx.js +1 -1
- package/src/runtime/wp/Wp.js +16 -48
- package/src/server/{catalog.js → build/catalog.js} +15 -5
- package/src/server/build/coverage.js +188 -0
- package/src/server/build/execution.js +402 -0
- package/src/server/build/package.js +508 -0
- package/src/server/build/testing.js +578 -0
- package/src/server/{dns.js → network/dns.js} +144 -11
- package/src/server/{forward-proxy.js → network/forward-proxy.js} +10 -18
- package/src/server/{middlewares.js → network/middlewares.js} +11 -5
- package/src/server/network/node-capability.js +98 -0
- package/src/server/{peer.js → network/peer.js} +3 -3
- package/src/server/{proxy.js → network/proxy.js} +7 -13
- package/src/server/network/router.js +356 -0
- package/src/server/{tls.js → network/tls.js} +2 -2
- package/src/server/{underpost-compression.js → network/underpost-compression.js} +1 -1
- package/src/server/{underpost-gateway.js → network/underpost-gateway.js} +7 -7
- package/src/server/{underpost-ingress.js → network/underpost-ingress.js} +2 -2
- package/src/server/{cri.js → ops/cri.js} +2 -2
- package/src/server/{cron.js → ops/cron.js} +349 -74
- package/src/server/ops/event-notification.js +284 -0
- package/src/server/{logger.js → ops/logger.js} +143 -23
- package/src/server/ops/monitoring.js +1724 -0
- package/src/server/{systemd.js → ops/systemd.js} +55 -1
- package/src/server/{conf.js → runtime/conf.js} +516 -423
- package/src/server/runtime/config-scope.js +215 -0
- package/src/server/runtime/environment.js +149 -0
- package/src/server/{process.js → runtime/process.js} +153 -12
- package/src/server/{runtime-status.js → runtime/runtime-status.js} +155 -16
- package/src/server/{runtime.js → runtime/runtime.js} +12 -8
- package/src/server/{start.js → runtime/start.js} +128 -39
- package/src/server/{auth.js → security/auth.js} +7 -8
- package/src/server/security/container-storage.js +382 -0
- package/src/server/{crypto.js → security/crypto.js} +1 -1
- package/src/server/{selinux.js → security/selinux.js} +1 -1
- package/src/server/{backup.js → storage/backup.js} +17 -8
- package/src/server/{data-query.js → storage/data-query.js} +17 -1
- package/src/server/storage/downloader.js +121 -0
- package/src/server/storage/repository.js +68 -0
- package/src/server/storage/zip.js +153 -0
- package/src/server.js +4 -7
- package/src/ws/IoInterface.js +1 -1
- package/src/ws/IoServer.js +1 -1
- package/src/ws/core/core.ws.connection.js +1 -1
- package/src/ws/core/core.ws.emit.js +1 -1
- package/src/ws/core/core.ws.server.js +1 -1
- package/src/ws/default/default.ws.connection.js +1 -1
- package/src/ws/default/default.ws.emit.js +1 -1
- package/src/ws/default/default.ws.server.js +1 -1
- package/test/e2e/event-e2e-public-ingress-down.js +45 -0
- package/test/e2e/event-e2e-wireguard-server-down.js +38 -0
- package/test/e2e/event-e2e-wireguard-spoke-down.js +40 -0
- package/test/{api.test.js → integration/app/api.test.js} +33 -14
- package/test/integration/app/cyberia/atlas-sprite-sheet-store.test.js +402 -0
- package/test/integration/app/cyberia/atlas-sprite-sheet.test.js +86 -0
- package/test/integration/app/cyberia/cyberia-cli-plain-reads.test.js +64 -0
- package/test/integration/app/cyberia/cyberia-entity-type-default.test.js +842 -0
- package/test/integration/app/cyberia/cyberia-instance-conf-coerce.test.js +53 -0
- package/test/{cyberia-instance-conf-defaults.test.js → integration/app/cyberia/cyberia-instance-conf-defaults.test.js} +17 -23
- package/test/integration/app/cyberia/cyberia-instance-items.test.js +90 -0
- package/test/integration/app/cyberia/cyberia-load.test.js +466 -0
- package/test/integration/app/cyberia/cyberia-map-audio-conf.test.js +256 -0
- package/test/integration/app/cyberia/cyberia-stats.test.js +150 -0
- package/test/integration/app/cyberia/fallback-world-capture.test.js +338 -0
- package/test/integration/app/cyberia/object-layer-item-id.test.js +327 -0
- package/test/integration/app/cyberia/object-layer-item-selection.test.js +56 -0
- package/test/integration/app/cyberia/object-layer-natural-key.test.js +32 -0
- package/test/integration/app/cyberia/seed-audio.test.js +275 -0
- package/test/{shape-generator.test.js → integration/app/cyberia/shape-generator.test.js} +1 -1
- package/test/integration/infra/1-security/config-scope.test.js +305 -0
- package/test/integration/infra/1-security/container-storage.test.js +356 -0
- package/test/integration/infra/1-security/in-pod-cli-surface.test.js +86 -0
- package/test/integration/infra/1-security/secret-onboarding.test.js +930 -0
- package/test/{selinux.test.js → integration/infra/1-security/selinux.test.js} +2 -4
- package/test/{sops-secret-store.test.js → integration/infra/1-security/sops-secret-store.test.js} +415 -61
- package/test/integration/infra/1-security/systemd-service.test.js +70 -0
- package/test/integration/infra/1-security/underpost-config-secret.test.js +367 -0
- package/test/integration/infra/2-network/dns-firewall.test.js +566 -0
- package/test/integration/infra/2-network/node-capability.test.js +182 -0
- package/test/integration/infra/2-network/wireguard-cli.test.js +1187 -0
- package/test/{wireguard-edge.test.js → integration/infra/2-network/wireguard-edge.test.js} +649 -55
- package/test/{cluster-instances.test.js → integration/infra/3-cluster/cluster-instances.test.js} +11 -15
- package/test/{deploy-node-placement.test.js → integration/infra/3-cluster/deploy-node-placement.test.js} +3 -3
- package/test/integration/infra/3-cluster/docker-compose-stack.test.js +313 -0
- package/test/integration/infra/3-cluster/mongo-express-deploy.test.js +156 -0
- package/test/integration/infra/4-ingress/api-cross-origin.test.js +79 -0
- package/test/integration/infra/4-ingress/deploy-routes.test.js +379 -0
- package/test/integration/infra/4-ingress/gateway-static-assets.test.js +453 -0
- package/test/{instance-traffic-plan.test.js → integration/infra/4-ingress/instance-traffic-plan.test.js} +10 -6
- package/test/{underpost-gateway.test.js → integration/infra/4-ingress/underpost-gateway.test.js} +6 -6
- package/test/{underpost-ingress.test.js → integration/infra/4-ingress/underpost-ingress.test.js} +4 -4
- package/test/integration/infra/5-observability/cron-jobs.test.js +652 -0
- package/test/{deploy-monitor.test.js → integration/infra/5-observability/deploy-monitor.test.js} +29 -31
- package/test/integration/infra/5-observability/event-notification.test.js +197 -0
- package/test/integration/infra/5-observability/event-remediation.test.js +502 -0
- package/test/integration/infra/5-observability/event-targets.test.js +1174 -0
- package/test/integration/infra/5-observability/monitoring-stack.test.js +902 -0
- package/test/support/shell-harness.js +73 -0
- package/test/unit/build-template.test.js +97 -0
- package/test/unit/build-workflow-offline.test.js +132 -0
- package/test/unit/catalog.test.js +71 -0
- package/test/unit/client-build-docs.test.js +361 -0
- package/test/unit/client-bundle.test.js +313 -0
- package/test/unit/conf-loading.test.js +303 -0
- package/test/unit/conf-resolution.test.js +996 -0
- package/test/unit/coverage-artifact.test.js +221 -0
- package/test/{crypto.test.js → unit/crypto.test.js} +1 -1
- package/test/unit/cyberia/instance-backup.test.js +183 -0
- package/test/unit/cyberia/instance-object-layer-items.test.js +46 -0
- package/test/unit/cyberia/publish-workflow.test.js +22 -0
- package/test/unit/cyberia/stat-balance.test.js +69 -0
- package/test/unit/deploy-env-resolution.test.js +38 -0
- package/test/unit/deploy-log-table.test.js +518 -0
- package/test/unit/downloader-integrity.test.js +98 -0
- package/test/unit/execution-profiles.test.js +219 -0
- package/test/unit/file-reference-registry.test.js +141 -0
- package/test/unit/fleet-sync-source.test.js +149 -0
- package/test/unit/fs-storage-paths.test.js +596 -0
- package/test/unit/logger-redaction.test.js +141 -0
- package/test/unit/package.test.js +457 -0
- package/test/unit/prepare-host.test.js +382 -0
- package/test/unit/process-environment.test.js +18 -0
- package/test/unit/propagation-message.test.js +78 -0
- package/test/unit/redeploy-plan.test.js +24 -0
- package/test/unit/release-bump.test.js +474 -0
- package/test/unit/ssh-output-redaction.test.js +28 -0
- package/test/unit/start-options.test.js +46 -0
- package/test/unit/test-tiers.test.js +220 -0
- package/test/unit/zip-archive.test.js +142 -0
- package/vitest.config.js +66 -0
- package/.nycrc +0 -9
- package/deploy/lib/logging.sh +0 -96
- package/manifests/cronjobs/dd-cron/dd-cron-dns.yaml +0 -48
- package/manifests/mongodb/pv-pvc.yaml +0 -59
- package/scripts/coverall-test-cyberia.sh +0 -24
- package/scripts/coverall-test.sh +0 -24
- package/scripts/link-local-underpost-cli.sh +0 -6
- package/src/api/cyberia-instance/cyberia-fallback-default-items.js +0 -63
- package/src/api/user/user.build.js +0 -16
- package/src/cli/env.js +0 -177
- package/src/client/components/core/Blockchain.js +0 -41
- package/src/client/components/core/Blog.js +0 -9
- package/src/client/components/core/KeyboardAvoidance.js +0 -145
- package/src/client/components/cyberia/FallbackWorldEngineCyberia.js +0 -368
- package/src/client/public/default/assets/background/dark.jpg +0 -0
- package/src/client/public/default/assets/background/white0-min.jpg +0 -0
- package/src/client/public/default/assets/background/white0.jpg +0 -0
- package/src/client/public/doc/favicon.ico +0 -0
- package/src/client/public/doc/sitemap +0 -148
- package/src/server/downloader.js +0 -73
- package/src/server/environment.js +0 -98
package/src/cli/wireguard.js
CHANGED
|
@@ -1,9 +1,17 @@
|
|
|
1
|
-
/**
|
|
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,
|
|
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 {
|
|
19
|
-
import {
|
|
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
|
|
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,50 @@ 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 Sequence of steps to bring a node's checkout up to date.
|
|
99
|
+
*
|
|
100
|
+
* The sync runs the node's own CLI, bracketed by two critical installs:
|
|
101
|
+
*
|
|
102
|
+
* 1. **Bootstrap Install:** Restores the original `package.json` from git and installs it.
|
|
103
|
+
* This ensures the CLI remains runnable with the current code tree before any changes are applied.
|
|
104
|
+
* 2. **Deploy Install:** Installs the deploy's `package.json` and links the CLI. This happens
|
|
105
|
+
* after both the configuration and source code switches have landed.
|
|
106
|
+
*
|
|
107
|
+
* @memberof UnderpostWireguard
|
|
108
|
+
*/
|
|
109
|
+
const ENGINE_SYNC_STEPS = [
|
|
110
|
+
{ command: 'git checkout -- package.json; npm install', halt: false },
|
|
111
|
+
{ command: 'node bin host set GITHUB_TOKEN <github-token>' },
|
|
112
|
+
{ command: 'node bin run clean', halt: true },
|
|
113
|
+
{
|
|
114
|
+
command: 'node bin cmt ./engine-private --switch-repo <engine-private> --target-branch <engine-private-branch>',
|
|
115
|
+
halt: true,
|
|
116
|
+
},
|
|
117
|
+
{ command: 'node bin cmt --switch-repo <engine> --target-branch <engine-branch>', halt: true },
|
|
118
|
+
{ command: '<package-install-command>', halt: true },
|
|
119
|
+
{ command: 'node bin host set ENGINE_SRC_REPO <engine>', halt: true },
|
|
120
|
+
{ command: 'node bin host set ENGINE_SRC_PRIVATE_REPO <engine-private>', halt: true },
|
|
121
|
+
{ command: '<cron-reconcile-command>', halt: false },
|
|
122
|
+
{ command: `test -f ${EVENT_SERVICE_UNIT_PATH} && <event-service-command>`, halt: false },
|
|
123
|
+
{ command: `test -f ${FORWARD_PROXY.unitPath} && <forward-proxy-command>`, halt: false },
|
|
124
|
+
];
|
|
125
|
+
|
|
70
126
|
/**
|
|
71
127
|
* @method deployIdFactory
|
|
72
128
|
* @description Normalizes a deploy id to the `dd-<conf-id>` convention.
|
|
@@ -76,17 +132,53 @@ const UNDERPOST_EDGE = {
|
|
|
76
132
|
*/
|
|
77
133
|
const deployIdFactory = (deployId) => {
|
|
78
134
|
const value = `${deployId || ''}`.trim();
|
|
79
|
-
// `dd` is the meta id every runner reads as "all of dd.
|
|
135
|
+
// `dd` is the meta id every runner reads as "all of dd.routes"; prefixing it
|
|
80
136
|
// would turn it into a deploy that does not exist.
|
|
81
137
|
if (!value || value === 'dd') return value;
|
|
82
138
|
return value.startsWith('dd-') ? value : `dd-${value}`;
|
|
83
139
|
};
|
|
84
140
|
|
|
141
|
+
/**
|
|
142
|
+
* @method deployPackageScriptFactory
|
|
143
|
+
* @description The package script of the deploy an engine source belongs to, when the source
|
|
144
|
+
* being synced actually ships one.
|
|
145
|
+
*
|
|
146
|
+
* Both halves have to hold. A monorepo sync belongs to no deploy, and a deploy that ships no
|
|
147
|
+
* script of its own would leave the node running `bash` against a path that does not exist. The
|
|
148
|
+
* check reads this checkout because it is the tree the node lands on once the switch completes,
|
|
149
|
+
* which is the only tree whose contents the step can be resolved against.
|
|
150
|
+
* @param {string} engine - Engine source repository the node is switched onto.
|
|
151
|
+
* @returns {string} Path to that deploy's package script, or `''` when there is none to run.
|
|
152
|
+
* @memberof UnderpostWireguard
|
|
153
|
+
*/
|
|
154
|
+
const deployPackageScriptFactory = (engine = '') => {
|
|
155
|
+
const deployId = deployIdFactory(Underpost.repo.confIdFactory(engine));
|
|
156
|
+
const path = deployId ? `./deploy/${deployId}/package.sh` : '';
|
|
157
|
+
return path && fs.existsSync(path) ? path : '';
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* @method packageInstallCommandFactory
|
|
162
|
+
* @description How a node installs against the source a switch just landed.
|
|
163
|
+
*
|
|
164
|
+
* A deploy's own script is preferred because it installs that deploy's manifest and links the
|
|
165
|
+
* CLI. Where there is none — a monorepo sync, or a deploy that ships no script — the checkout's
|
|
166
|
+
* own manifest is installed instead, so the step is never dropped: every sync replaces the
|
|
167
|
+
* source, and source the installed packages predate is what breaks the next command.
|
|
168
|
+
* @param {string} engine - Engine source repository the node is switched onto.
|
|
169
|
+
* @returns {string} The install command for that source.
|
|
170
|
+
* @memberof UnderpostWireguard
|
|
171
|
+
*/
|
|
172
|
+
const packageInstallCommandFactory = (engine = '') => {
|
|
173
|
+
const packageScript = deployPackageScriptFactory(engine);
|
|
174
|
+
return packageScript ? `bash ${packageScript}` : 'npm install';
|
|
175
|
+
};
|
|
176
|
+
|
|
85
177
|
/**
|
|
86
178
|
* @method deployListFactory
|
|
87
179
|
* @description The deploys whose hostnames a run publishes.
|
|
88
180
|
*
|
|
89
|
-
* `dd` expands through the same `dd.
|
|
181
|
+
* `dd` expands through the same `dd.routes` read every other runner uses, so
|
|
90
182
|
* the edge routes exactly the set the cluster deploys — a hostname cannot be
|
|
91
183
|
* live in the cluster and absent from the edge because two lists drifted.
|
|
92
184
|
* @param {string} deployId - Deploy id, comma-separated list, or `dd`.
|
|
@@ -101,10 +193,10 @@ const deployListFactory = (deployId) => {
|
|
|
101
193
|
};
|
|
102
194
|
|
|
103
195
|
/**
|
|
104
|
-
* @constant
|
|
105
|
-
* @description Location of the
|
|
196
|
+
* @constant EDGE_TOPOLOGY_PATH
|
|
197
|
+
* @description Location of the deployment topology.
|
|
106
198
|
*
|
|
107
|
-
* Cluster-wide rather than per deploy, and stored beside `dd.
|
|
199
|
+
* Cluster-wide rather than per deploy, and stored beside `dd.routes` for that
|
|
108
200
|
* reason: the hub has one interface, one address and one peer table, and those
|
|
109
201
|
* are properties of the machine. A copy per deploy would be several records of
|
|
110
202
|
* one fact, free to disagree. `--deploy-id` selects which hostnames are routed
|
|
@@ -114,17 +206,74 @@ const deployListFactory = (deployId) => {
|
|
|
114
206
|
* the host that generated it.
|
|
115
207
|
* @memberof UnderpostWireguard
|
|
116
208
|
*/
|
|
117
|
-
const
|
|
209
|
+
const EDGE_TOPOLOGY_PATH = './engine-private/deploy/conf.wireguard.json';
|
|
210
|
+
const EDGE_NODES_PATH = './engine-private/deploy/nodes';
|
|
211
|
+
const EDGE_NODE_ROLES = Object.freeze(['control', 'worker', 'hub']);
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* @constant NODE_ROLE_STEPS
|
|
215
|
+
* @description The supervised work each node role owns, as the command that reconciles it.
|
|
216
|
+
*
|
|
217
|
+
* A role owns a subsystem or it does not, and every one of these runs the checkout a sync
|
|
218
|
+
* replaces. The control node is the cluster's control plane, so it alone publishes the CronJobs —
|
|
219
|
+
* elsewhere that step rewrote the cron deploy's manifests and relabeled host directories before
|
|
220
|
+
* failing on a cluster it cannot reach. It is also the only node the dispatcher may run on, and
|
|
221
|
+
* the hub the only one the forward proxy may run on; both refuse to configure themselves
|
|
222
|
+
* anywhere else. A role that does not own the dispatcher withdraws it, rather than leaving a unit
|
|
223
|
+
* that holds a port and answers with code no one reconciles.
|
|
224
|
+
*
|
|
225
|
+
* Each is reconciled through its own generator rather than `systemctl restart`, because the units
|
|
226
|
+
* are generated: their `ExecStart` names a Node binary probed under systemd's own constraints,
|
|
227
|
+
* and a checkout switch can leave that path unexecutable. Restarting reruns a broken unit until
|
|
228
|
+
* systemd rate-limits it; only the generator re-probes, rewrites and clears the failed state — or
|
|
229
|
+
* says why no Node on the node can run the service.
|
|
230
|
+
* @memberof UnderpostWireguard
|
|
231
|
+
*/
|
|
232
|
+
const NODE_ROLE_STEPS = Object.freeze({
|
|
233
|
+
control: {
|
|
234
|
+
'<cron-reconcile-command>': 'node bin cron --setup-start --git --apply',
|
|
235
|
+
'<event-service-command>': 'node bin event --service',
|
|
236
|
+
},
|
|
237
|
+
hub: {
|
|
238
|
+
'<event-service-command>': 'node bin event --service-stop',
|
|
239
|
+
'<forward-proxy-command>': 'node bin wireguard --forward-proxy-server',
|
|
240
|
+
},
|
|
241
|
+
worker: { '<event-service-command>': 'node bin event --service-stop' },
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* @method nodeRoleStepsFactory
|
|
246
|
+
* @description {@link NODE_ROLE_STEPS} for one role, with every step a role does not own resolved
|
|
247
|
+
* to nothing so the sequence drops it rather than running it empty.
|
|
248
|
+
* @param {string} nodeRole - `control`, `worker` or `hub`.
|
|
249
|
+
* @returns {Object<string, string>} One value per role-scoped step.
|
|
250
|
+
* @memberof UnderpostWireguard
|
|
251
|
+
*/
|
|
252
|
+
const nodeRoleStepsFactory = (nodeRole = '') => {
|
|
253
|
+
const owned = NODE_ROLE_STEPS[nodeRole] || {};
|
|
254
|
+
const steps = [...new Set(Object.values(NODE_ROLE_STEPS).flatMap((role) => Object.keys(role)))];
|
|
255
|
+
return Object.fromEntries(steps.map((step) => [step, owned[step] || '']));
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
/** The node role a sync target carries: a hub is its own role, a spoke's is in its node document. */
|
|
259
|
+
const targetRoleFactory = (target = {}) => `${target.nodeRole || target.role || ''}`.trim();
|
|
260
|
+
|
|
261
|
+
const nodeNameFactory = (value = '') => {
|
|
262
|
+
const nodeName = `${value || ''}`.trim();
|
|
263
|
+
if (nodeName && !/^[a-zA-Z0-9._-]+$/.test(nodeName))
|
|
264
|
+
throw new Error('[wireguard] node names may contain only letters, numbers, dot, underscore, and hyphen');
|
|
265
|
+
return nodeName;
|
|
266
|
+
};
|
|
118
267
|
|
|
119
268
|
/**
|
|
120
269
|
* @method peerFactory
|
|
121
270
|
* @description Normalizes one spoke entry, filling the fields a partially
|
|
122
|
-
* written
|
|
271
|
+
* written topology may omit.
|
|
123
272
|
*
|
|
124
273
|
* `allowedIPs` defaults to the peer's own tunnel address alone: a spoke routes
|
|
125
|
-
* its LAN only when
|
|
274
|
+
* its LAN only when topology says so, so a mistyped entry cannot silently
|
|
126
275
|
* claim a subnet another spoke already answers for.
|
|
127
|
-
* @param {object} peer - Raw
|
|
276
|
+
* @param {object} peer - Raw topology entry.
|
|
128
277
|
* @returns {object} Normalized entry.
|
|
129
278
|
* @memberof UnderpostWireguard
|
|
130
279
|
*/
|
|
@@ -136,6 +285,7 @@ const peerFactory = (peer = {}) => {
|
|
|
136
285
|
return {
|
|
137
286
|
id: `${peer.id || ''}`.trim(),
|
|
138
287
|
address,
|
|
288
|
+
managementHost: `${peer.managementHost || ''}`.trim(),
|
|
139
289
|
publicKey: `${peer.publicKey || ''}`.trim(),
|
|
140
290
|
allowedIPs: allowedIPs.length > 0 ? allowedIPs : address ? [`${address}/32`] : [],
|
|
141
291
|
hosts: (Array.isArray(peer.hosts) ? peer.hosts : []).map((host) => `${host}`.trim().toLowerCase()).filter(Boolean),
|
|
@@ -153,74 +303,213 @@ const peerFactory = (peer = {}) => {
|
|
|
153
303
|
* a hostname, and requiring `"default": true` there would only be ceremony.
|
|
154
304
|
* With several peers and none nominated there is no fallback at all, so nothing
|
|
155
305
|
* is dispatched somewhere arbitrary.
|
|
156
|
-
* @param {Array<object>} [peers] - Normalized
|
|
306
|
+
* @param {Array<object>} [peers] - Normalized topology entries.
|
|
157
307
|
* @returns {?object} The fallback peer, or null.
|
|
158
308
|
* @memberof UnderpostWireguard
|
|
159
309
|
*/
|
|
160
310
|
const defaultPeerFactory = (peers = []) =>
|
|
161
311
|
peers.find((peer) => peer.default === true) || (peers.length === 1 ? peers[0] : null);
|
|
162
312
|
|
|
313
|
+
/** Returns the host portion of a WireGuard endpoint. */
|
|
314
|
+
const endpointHostFactory = (endpoint = '') => {
|
|
315
|
+
const value = `${endpoint || ''}`.trim();
|
|
316
|
+
if (!value) return '';
|
|
317
|
+
try {
|
|
318
|
+
return new URL(`udp://${value}`).hostname.replace(/^\[|\]$/g, '');
|
|
319
|
+
} catch {
|
|
320
|
+
return '';
|
|
321
|
+
}
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
const hubFactory = (hub = {}) => ({
|
|
325
|
+
interfaceName: `${hub.interfaceName || UNDERPOST_EDGE.interfaceName}`.trim(),
|
|
326
|
+
listenPort: Number(hub.listenPort) > 0 ? Number(hub.listenPort) : UNDERPOST_EDGE.listenPort,
|
|
327
|
+
address: `${hub.address || UNDERPOST_EDGE.cidr}`.trim(),
|
|
328
|
+
publicKey: `${hub.publicKey || ''}`.trim(),
|
|
329
|
+
sshForwardPort: Number(hub.sshForwardPort) > 0 ? Number(hub.sshForwardPort) : 0,
|
|
330
|
+
peers: (Array.isArray(hub.peers) ? hub.peers : []).map(peerFactory).filter((peer) => peer.id),
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
const topologyFactory = (topology = {}) =>
|
|
334
|
+
Object.fromEntries(
|
|
335
|
+
Object.entries(topology)
|
|
336
|
+
.map(([host, hub]) => [endpointHostFactory(host), hubFactory(hub)])
|
|
337
|
+
.filter(([host]) => isIP(host) === 4)
|
|
338
|
+
.sort(([a], [b]) => a.localeCompare(b)),
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
const nodeIdentityFactory = (identity = {}) => {
|
|
342
|
+
const role = EDGE_NODE_ROLES.includes(`${identity.role || ''}`.trim()) ? `${identity.role}`.trim() : '';
|
|
343
|
+
return {
|
|
344
|
+
nodeName: nodeNameFactory(identity.nodeName),
|
|
345
|
+
role,
|
|
346
|
+
hubHost: endpointHostFactory(identity.hubHost || identity.hub),
|
|
347
|
+
peerId: role === 'hub' ? '' : `${identity.peerId || ''}`.trim(),
|
|
348
|
+
};
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
const readTopology = () => {
|
|
352
|
+
if (!fs.existsSync(EDGE_TOPOLOGY_PATH)) return {};
|
|
353
|
+
try {
|
|
354
|
+
const source = JSON.parse(fs.readFileSync(EDGE_TOPOLOGY_PATH, 'utf8'));
|
|
355
|
+
const topology = topologyFactory(source);
|
|
356
|
+
const invalidHosts = Object.keys(source).filter((host) => isIP(endpointHostFactory(host)) !== 4);
|
|
357
|
+
if (invalidHosts.length > 0)
|
|
358
|
+
throw new Error(`top-level keys must be static IPv4 addresses: ${invalidHosts.join(', ')}`);
|
|
359
|
+
return topology;
|
|
360
|
+
} catch (error) {
|
|
361
|
+
throw new Error(`[wireguard] invalid topology ${EDGE_TOPOLOGY_PATH}: ${error.message}`);
|
|
362
|
+
}
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
const writeTopology = (topology) => {
|
|
366
|
+
const target = EDGE_TOPOLOGY_PATH;
|
|
367
|
+
const next = `${JSON.stringify(topologyFactory(topology), null, 2)}\n`;
|
|
368
|
+
const current = fs.existsSync(target) ? fs.readFileSync(target, 'utf8') : '';
|
|
369
|
+
if (current === next) return false;
|
|
370
|
+
fs.mkdirpSync(nodePath.dirname(target));
|
|
371
|
+
fs.writeFileSync(target, next, 'utf8');
|
|
372
|
+
return true;
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
const nodeConfigPathFactory = (nodeName) => `${EDGE_NODES_PATH}/${nodeName}.json`;
|
|
376
|
+
|
|
377
|
+
const readNodeConfig = (nodeName) => {
|
|
378
|
+
const selected = nodeNameFactory(nodeName);
|
|
379
|
+
const target = nodeConfigPathFactory(selected);
|
|
380
|
+
if (!selected || !fs.existsSync(target)) return nodeIdentityFactory({ nodeName: selected });
|
|
381
|
+
try {
|
|
382
|
+
return nodeIdentityFactory({ nodeName: selected, ...JSON.parse(fs.readFileSync(target, 'utf8')) });
|
|
383
|
+
} catch (error) {
|
|
384
|
+
throw new Error(`[wireguard] invalid node identity ${target}: ${error.message}`);
|
|
385
|
+
}
|
|
386
|
+
};
|
|
387
|
+
|
|
163
388
|
/**
|
|
164
|
-
* @method
|
|
165
|
-
* @description
|
|
166
|
-
*
|
|
389
|
+
* @method nodeNameCandidatesFactory
|
|
390
|
+
* @description The node document names a hostname may be recorded under, in
|
|
391
|
+
* precedence order.
|
|
167
392
|
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
*
|
|
172
|
-
*
|
|
173
|
-
* @param {object} [state] - Raw registry contents.
|
|
174
|
-
* @returns {object} Normalized registry.
|
|
393
|
+
* The short name follows the full one because an FQDN is a property of the
|
|
394
|
+
* resolver domain, not of the node: `vultr` and `vultr.guest` are one host, and
|
|
395
|
+
* its record must not stop matching when a search domain is added.
|
|
396
|
+
* @param {string} [hostname] - Hostname to resolve; defaults to this machine's.
|
|
397
|
+
* @returns {Array<string>} Candidate node names, most specific first.
|
|
175
398
|
* @memberof UnderpostWireguard
|
|
176
399
|
*/
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
400
|
+
/**
|
|
401
|
+
* @method hostAddressesFactory
|
|
402
|
+
* @description This machine's own routable IPv4 addresses, read from its interfaces.
|
|
403
|
+
* @returns {Set<string>} Addresses, excluding loopback.
|
|
404
|
+
* @memberof UnderpostWireguard
|
|
405
|
+
*/
|
|
406
|
+
const hostAddressesFactory = () =>
|
|
407
|
+
new Set(
|
|
408
|
+
Object.values(os.networkInterfaces())
|
|
409
|
+
.flat()
|
|
410
|
+
.filter((entry) => entry?.family === 'IPv4' && !entry.internal)
|
|
411
|
+
.map((entry) => entry.address),
|
|
412
|
+
);
|
|
190
413
|
|
|
191
414
|
/**
|
|
192
|
-
* @method
|
|
193
|
-
* @description
|
|
194
|
-
*
|
|
195
|
-
*
|
|
196
|
-
*
|
|
415
|
+
* @method localPeerFactory
|
|
416
|
+
* @description Whether a peer is this machine.
|
|
417
|
+
*
|
|
418
|
+
* Decided from the peer's registered management address, not from the node
|
|
419
|
+
* document that named it: a document is named after a hostname, and a generic
|
|
420
|
+
* one — `localhost.localdomain` — names every machine that kept the default.
|
|
421
|
+
* Concluding locality from that would run a repair, or a checkout switch, on
|
|
422
|
+
* whichever host happened to load the config instead of on the peer. An
|
|
423
|
+
* unverifiable address is remote, so the check can only ever fail towards SSH.
|
|
424
|
+
* @param {string} managementHost - Peer management address from topology.
|
|
425
|
+
* @param {Set<string>} [addresses] - This machine's addresses.
|
|
426
|
+
* @returns {boolean} True when the peer is this machine.
|
|
197
427
|
* @memberof UnderpostWireguard
|
|
198
428
|
*/
|
|
199
|
-
const
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
429
|
+
const localPeerFactory = (managementHost = '', addresses = hostAddressesFactory()) =>
|
|
430
|
+
Boolean(managementHost) && addresses.has(`${managementHost}`.trim());
|
|
431
|
+
|
|
432
|
+
const nodeNameCandidatesFactory = (hostname = os.hostname()) => {
|
|
433
|
+
const name = nodeNameFactory(hostname);
|
|
434
|
+
return [...new Set([name, name.split('.')[0]])].filter(Boolean);
|
|
435
|
+
};
|
|
436
|
+
|
|
437
|
+
const hostNodeNameFactory = () => {
|
|
438
|
+
const candidates = nodeNameCandidatesFactory();
|
|
439
|
+
return candidates.find((name) => fs.existsSync(nodeConfigPathFactory(name))) || candidates[0] || '';
|
|
207
440
|
};
|
|
208
441
|
|
|
209
442
|
/**
|
|
210
|
-
* @method
|
|
211
|
-
* @description
|
|
212
|
-
*
|
|
213
|
-
*
|
|
443
|
+
* @method readNodeIdentity
|
|
444
|
+
* @description This machine's identity: the tracked node document named after it.
|
|
445
|
+
*
|
|
446
|
+
* The document's filename is the node name, so the hostname resolves it. A
|
|
447
|
+
* host-local record of which node this is would be a second copy of a fact the
|
|
448
|
+
* machine already knows, free to disagree with it after a rename.
|
|
449
|
+
* @returns {object} Normalized identity; empty when this host has no document.
|
|
214
450
|
* @memberof UnderpostWireguard
|
|
215
451
|
*/
|
|
216
|
-
const
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
452
|
+
const readNodeIdentity = () => readNodeConfig(hostNodeNameFactory());
|
|
453
|
+
|
|
454
|
+
const readNodeConfigs = () => {
|
|
455
|
+
if (!fs.existsSync(EDGE_NODES_PATH)) return [];
|
|
456
|
+
return fs
|
|
457
|
+
.readdirSync(EDGE_NODES_PATH)
|
|
458
|
+
.filter((name) => name.endsWith('.json'))
|
|
459
|
+
.map((name) => readNodeConfig(name.slice(0, -5)));
|
|
460
|
+
};
|
|
461
|
+
|
|
462
|
+
const writeNodeIdentity = (identity, { dryRun = false } = {}) => {
|
|
463
|
+
const normalized = nodeIdentityFactory(identity);
|
|
464
|
+
if (dryRun) return normalized;
|
|
465
|
+
fs.mkdirpSync(EDGE_NODES_PATH);
|
|
466
|
+
const target = nodeConfigPathFactory(normalized.nodeName);
|
|
467
|
+
const content = `${JSON.stringify(
|
|
468
|
+
{
|
|
469
|
+
role: normalized.role,
|
|
470
|
+
hubHost: normalized.hubHost,
|
|
471
|
+
...(normalized.role === 'hub' ? {} : { peerId: normalized.peerId }),
|
|
472
|
+
},
|
|
473
|
+
null,
|
|
474
|
+
2,
|
|
475
|
+
)}\n`;
|
|
476
|
+
if (!fs.existsSync(target) || fs.readFileSync(target, 'utf8') !== content) fs.writeFileSync(target, content, 'utf8');
|
|
477
|
+
return normalized;
|
|
478
|
+
};
|
|
479
|
+
|
|
480
|
+
const edgeContextFactory = ({ topology = {}, identity = {} } = {}) => {
|
|
481
|
+
const node = nodeIdentityFactory(identity);
|
|
482
|
+
const hub = topologyFactory(topology)[node.hubHost];
|
|
483
|
+
if (!node.nodeName || !node.role || !node.hubHost)
|
|
484
|
+
throw new Error(
|
|
485
|
+
`[wireguard] host '${os.hostname()}' has no identity in ${EDGE_NODES_PATH}; ` +
|
|
486
|
+
'run --node-config --node-role <control|worker|hub> --hub-host <public-ip>',
|
|
487
|
+
);
|
|
488
|
+
if (!hub) throw new Error(`[wireguard] hub '${node.hubHost}' is not registered in ${EDGE_TOPOLOGY_PATH}`);
|
|
489
|
+
const peer = node.role === 'hub' ? null : hub.peers.find((entry) => entry.id === node.peerId);
|
|
490
|
+
if (node.role !== 'hub' && !peer)
|
|
491
|
+
throw new Error(`[wireguard] node '${node.nodeName}' references unknown peer '${node.peerId}' on ${node.hubHost}`);
|
|
492
|
+
return {
|
|
493
|
+
...node,
|
|
494
|
+
interfaceName: hub.interfaceName,
|
|
495
|
+
listenPort: hub.listenPort,
|
|
496
|
+
address: node.role === 'hub' ? hub.address : peer.address,
|
|
497
|
+
publicKey: node.role === 'hub' ? hub.publicKey : peer.publicKey,
|
|
498
|
+
hubPublicKey: hub.publicKey,
|
|
499
|
+
endpoint: `${node.hubHost}:${hub.listenPort}`,
|
|
500
|
+
sshForwardPort: hub.sshForwardPort,
|
|
501
|
+
peers: hub.peers,
|
|
502
|
+
};
|
|
503
|
+
};
|
|
504
|
+
|
|
505
|
+
const readEdgeContext = () => edgeContextFactory({ topology: readTopology(), identity: readNodeIdentity() });
|
|
506
|
+
|
|
507
|
+
const hubHostResolve = ({ topology = readTopology(), identity = readNodeIdentity(), hubHost = '' } = {}) => {
|
|
508
|
+
const selected = endpointHostFactory(hubHost || identity.hubHost);
|
|
509
|
+
if (selected) return selected;
|
|
510
|
+
const hosts = Object.keys(topology);
|
|
511
|
+
if (hosts.length === 1) return hosts[0];
|
|
512
|
+
throw new Error('[wireguard] --hub-host is required when no current identity selects one hub');
|
|
224
513
|
};
|
|
225
514
|
|
|
226
515
|
/**
|
|
@@ -283,7 +572,7 @@ const hostProxyEntriesFactory = ({ confServer = {} } = {}) => {
|
|
|
283
572
|
* Instances declare no `proxy` array — their hostname reaches the same cluster
|
|
284
573
|
* ingress the deploy's own hosts do, so it terminates the same two ports. Every
|
|
285
574
|
* variant of a family resolves through both its own id and its template id, so
|
|
286
|
-
*
|
|
575
|
+
* topology can bind a whole family with one entry.
|
|
287
576
|
* @param {Array<object>} [instances] - Expanded entries from `loadConfInstances`.
|
|
288
577
|
* @returns {Array<{host: string, ports: Array<number>, instances: Array<string>}>} One entry per instance hostname.
|
|
289
578
|
* @memberof UnderpostWireguard
|
|
@@ -337,7 +626,7 @@ const edgeRouteTableFactory = ({ confServer = {}, instances = [], peers = [] } =
|
|
|
337
626
|
const byHost = new Map();
|
|
338
627
|
const byInstance = new Map();
|
|
339
628
|
// First declaration wins, so a duplicated binding is deterministic rather
|
|
340
|
-
// than dependent on
|
|
629
|
+
// than dependent on topology order changing under an edit.
|
|
341
630
|
for (const peer of list) {
|
|
342
631
|
for (const host of peer.hosts) if (!byHost.has(host)) byHost.set(host, peer);
|
|
343
632
|
for (const id of peer.instances) if (!byInstance.has(id)) byInstance.set(id, peer);
|
|
@@ -379,7 +668,7 @@ const edgeRouteTableFactory = ({ confServer = {}, instances = [], peers = [] } =
|
|
|
379
668
|
* every peer's `AllowedIPs`, so a CIDR claimed twice is not shared — one peer
|
|
380
669
|
* wins and the other silently never receives that traffic. Homelabs routinely
|
|
381
670
|
* sit on the same `192.168.1.0/24`, which makes this the likeliest way a
|
|
382
|
-
* multi-spoke
|
|
671
|
+
* multi-spoke topology breaks.
|
|
383
672
|
*
|
|
384
673
|
* Duplicate tunnel addresses are caught by the same pass, because an address
|
|
385
674
|
* with no explicit `allowedIPs` contributes its own `/32`.
|
|
@@ -403,7 +692,7 @@ const allowedIpsConflictsFactory = ({ peers = [] } = {}) => {
|
|
|
403
692
|
* publishes.
|
|
404
693
|
*
|
|
405
694
|
* The edge is one machine holding one pair of map files, so every deploy in
|
|
406
|
-
* `dd.
|
|
695
|
+
* `dd.routes` has to be compiled together — publishing one deploy's table alone
|
|
407
696
|
* would overwrite the maps and take every other deploy's hostname off the
|
|
408
697
|
* internet. Each route records the deploy that contributed it, which is the
|
|
409
698
|
* only way to attribute a hostname once the tables are merged.
|
|
@@ -616,7 +905,7 @@ ${backends}${backends ? '\n\n' : ''}${defaults}${sshBackend}
|
|
|
616
905
|
* The key is deliberately not inlined into the rendered config: keeping it in
|
|
617
906
|
* its own 0600 file means no rendered configuration, dry-run print, diff or log
|
|
618
907
|
* line can ever carry it, and the config itself becomes a pure function of the
|
|
619
|
-
*
|
|
908
|
+
* topology. `wg-quick` runs `PostUp` after `wg setconf`, so the interface is
|
|
620
909
|
* keyed before it forwards anything.
|
|
621
910
|
* @param {string} keyPath - Path of the private key file.
|
|
622
911
|
* @returns {string} `PostUp` directive.
|
|
@@ -648,28 +937,6 @@ const wireguardClientForwardingDirectivesFactory = (cidr) => {
|
|
|
648
937
|
].join('\n');
|
|
649
938
|
};
|
|
650
939
|
|
|
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
940
|
/**
|
|
674
941
|
* @method wireguardServerConfFactory
|
|
675
942
|
* @description Renders the hub interface and its spoke table.
|
|
@@ -703,7 +970,7 @@ PublicKey = ${peer.publicKey}
|
|
|
703
970
|
AllowedIPs = ${peer.allowedIPs.join(', ')}`,
|
|
704
971
|
)
|
|
705
972
|
.join('\n');
|
|
706
|
-
return `# Generated by \`underpost wireguard --wireguard-setup
|
|
973
|
+
return `# Generated by \`underpost wireguard --wireguard-setup\` on the registered hub. Do not edit by hand.
|
|
707
974
|
[Interface]
|
|
708
975
|
Address = ${address}
|
|
709
976
|
ListenPort = ${listenPort}
|
|
@@ -741,7 +1008,7 @@ const wireguardClientConfFactory = ({
|
|
|
741
1008
|
endpoint,
|
|
742
1009
|
cidr = UNDERPOST_EDGE.tunnelCidr,
|
|
743
1010
|
keepalive = UNDERPOST_EDGE.keepalive,
|
|
744
|
-
} = {}) => `# Generated by \`underpost wireguard --wireguard-setup
|
|
1011
|
+
} = {}) => `# Generated by \`underpost wireguard --wireguard-setup\` on a registered node. Do not edit by hand.
|
|
745
1012
|
[Interface]
|
|
746
1013
|
Address = ${`${address}`.includes('/') ? address : `${address}/32`}
|
|
747
1014
|
${wireguardPrivateKeyDirective(keyPath)}
|
|
@@ -823,7 +1090,7 @@ const quicForwardCommandsFactory = ({
|
|
|
823
1090
|
* The same list drives the teardown, flipped to `--remove-*`, so a reset cannot
|
|
824
1091
|
* leave behind a permanent rule the setup added — the two directions are one
|
|
825
1092
|
* declaration rather than two that drift.
|
|
826
|
-
* @param {string} role - `
|
|
1093
|
+
* @param {string} role - `hub`, `control`, or `worker`.
|
|
827
1094
|
* @param {string} [interfaceName] - Tunnel interface.
|
|
828
1095
|
* @param {number} [listenPort] - UDP listen port.
|
|
829
1096
|
* @param {string} [tunnelCidr] - Tunnel source admitted to the forward proxy.
|
|
@@ -841,8 +1108,13 @@ const firewallCommandsFactory = ({
|
|
|
841
1108
|
remove = false,
|
|
842
1109
|
} = {}) => {
|
|
843
1110
|
const verb = remove ? 'remove' : 'add';
|
|
844
|
-
const rules =
|
|
845
|
-
role
|
|
1111
|
+
const rules = [
|
|
1112
|
+
// Every role, the hub most of all: firewalld's forward chain ends in
|
|
1113
|
+
// `reject with icmpx admin-prohibited`, so an unzoned tunnel interface has
|
|
1114
|
+
// its forwarded traffic dropped while the hub itself still answers on it —
|
|
1115
|
+
// spoke-to-spoke stops working over a tunnel that looks perfectly healthy.
|
|
1116
|
+
`--zone=trusted --${verb}-interface=${interfaceName}`,
|
|
1117
|
+
...(role === 'hub'
|
|
846
1118
|
? [
|
|
847
1119
|
`--${verb}-port=${UNDERPOST_EDGE.httpPort}/tcp`,
|
|
848
1120
|
`--${verb}-port=${UNDERPOST_EDGE.httpsPort}/tcp`,
|
|
@@ -857,7 +1129,8 @@ const firewallCommandsFactory = ({
|
|
|
857
1129
|
`--${verb}-rich-rule="rule family=ipv4 source address=${tunnelCidr} port port=${FORWARD_PROXY.port} protocol=tcp accept"`,
|
|
858
1130
|
`--${verb}-masquerade`,
|
|
859
1131
|
]
|
|
860
|
-
: [
|
|
1132
|
+
: []),
|
|
1133
|
+
];
|
|
861
1134
|
const guard =
|
|
862
1135
|
'command -v firewall-cmd >/dev/null 2>&1 && ' +
|
|
863
1136
|
systemctlCommandFactory({ action: 'is-active --quiet', name: 'firewalld', sudo: false });
|
|
@@ -869,24 +1142,65 @@ const firewallCommandsFactory = ({
|
|
|
869
1142
|
|
|
870
1143
|
/**
|
|
871
1144
|
* @method peerSummaryFactory
|
|
872
|
-
* @description One spoke as
|
|
1145
|
+
* @description One spoke as topology declares it: its transport address and
|
|
873
1146
|
* the three bindings that route hostnames to it.
|
|
874
|
-
* @param {object} peer - Normalized
|
|
875
|
-
* @returns {object}
|
|
1147
|
+
* @param {object} peer - Normalized topology entry.
|
|
1148
|
+
* @returns {object} Topology view of the peer.
|
|
876
1149
|
* @memberof UnderpostWireguard
|
|
877
1150
|
*/
|
|
878
1151
|
const peerSummaryFactory = (peer) => ({
|
|
879
1152
|
id: peer.id,
|
|
880
1153
|
address: peer.address,
|
|
1154
|
+
managementHost: peer.managementHost,
|
|
1155
|
+
// The identity the far end actually presents. Registry-only fields say what a
|
|
1156
|
+
// spoke is *called*; this is what `wg` matches on, so it is the one field that
|
|
1157
|
+
// settles "is the hub expecting the key this machine holds". Public by
|
|
1158
|
+
// definition — the private half never leaves the host that generated it.
|
|
1159
|
+
publicKey: peer.publicKey,
|
|
881
1160
|
allowedIPs: peer.allowedIPs,
|
|
882
1161
|
hosts: peer.hosts,
|
|
883
1162
|
instances: peer.instances,
|
|
884
1163
|
default: peer.default,
|
|
885
1164
|
});
|
|
886
1165
|
|
|
1166
|
+
/**
|
|
1167
|
+
* @method unregisteredPeersFactory
|
|
1168
|
+
* @description Public keys the live interface carries that topology does not.
|
|
1169
|
+
*
|
|
1170
|
+
* `wg set` adds a peer and never removes the one it supersedes, so re-keying a
|
|
1171
|
+
* spoke or re-running `--peer-add` with a corrected key leaves the previous
|
|
1172
|
+
* identity on the interface, still holding its old handshake. Every other view
|
|
1173
|
+
* here is keyed by topology, which makes those leftovers invisible — and an
|
|
1174
|
+
* invisible peer that still claims an address is exactly what makes a tunnel
|
|
1175
|
+
* "configured correctly" and dead at the same time.
|
|
1176
|
+
* @param {Array<object>} [peers] - Registry entries.
|
|
1177
|
+
* @param {string} [latestHandshakes] - `wg show <iface> latest-handshakes` output.
|
|
1178
|
+
* @param {number} [now] - Current time in epoch seconds.
|
|
1179
|
+
* @returns {Array<{publicKey: string, handshakeAgeSeconds: ?number}>} Peers on the wire but not in topology.
|
|
1180
|
+
* @memberof UnderpostWireguard
|
|
1181
|
+
*/
|
|
1182
|
+
const unregisteredPeersFactory = ({ peers = [], latestHandshakes = '', now = Math.floor(Date.now() / 1000) } = {}) => {
|
|
1183
|
+
const known = new Set(
|
|
1184
|
+
peers
|
|
1185
|
+
.map(peerFactory)
|
|
1186
|
+
.map((peer) => peer.publicKey)
|
|
1187
|
+
.filter(Boolean),
|
|
1188
|
+
);
|
|
1189
|
+
return `${latestHandshakes || ''}`
|
|
1190
|
+
.split('\n')
|
|
1191
|
+
.map((line) => line.trim())
|
|
1192
|
+
.filter(Boolean)
|
|
1193
|
+
.map((line) => line.split(/\s+/))
|
|
1194
|
+
.filter(([publicKey]) => publicKey && !known.has(publicKey))
|
|
1195
|
+
.map(([publicKey, seconds]) => ({
|
|
1196
|
+
publicKey,
|
|
1197
|
+
handshakeAgeSeconds: Number(seconds) > 0 ? now - Number(seconds) : null,
|
|
1198
|
+
}));
|
|
1199
|
+
};
|
|
1200
|
+
|
|
887
1201
|
/**
|
|
888
1202
|
* @method wireguardStatusFactory
|
|
889
|
-
* @description Folds `wg show` output onto
|
|
1203
|
+
* @description Folds `wg show` output onto topology, one row per spoke.
|
|
890
1204
|
*
|
|
891
1205
|
* Built from the per-peer sub-commands rather than `wg show <iface> dump`,
|
|
892
1206
|
* because the dump's first line contains the interface private key — this
|
|
@@ -936,33 +1250,43 @@ const wireguardStatusFactory = ({
|
|
|
936
1250
|
};
|
|
937
1251
|
|
|
938
1252
|
/**
|
|
939
|
-
*
|
|
940
|
-
*
|
|
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
|
|
1253
|
+
* Resolves the local interface health without reading private key material.
|
|
1254
|
+
* A hub must have a fresh handshake with its default routing peer. Control and
|
|
1255
|
+
* worker nodes must have a fresh handshake with the configured hub identity.
|
|
952
1256
|
*/
|
|
953
|
-
const
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
const
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
1257
|
+
const wireguardHealthFactory = ({
|
|
1258
|
+
context = {},
|
|
1259
|
+
active = '',
|
|
1260
|
+
latestHandshakes = '',
|
|
1261
|
+
now = Math.floor(Date.now() / 1000),
|
|
1262
|
+
} = {}) => {
|
|
1263
|
+
const edge = context;
|
|
1264
|
+
const serviceActive = active === 'active';
|
|
1265
|
+
const expectedPeer =
|
|
1266
|
+
edge.role === 'hub'
|
|
1267
|
+
? defaultPeerFactory(edge.peers)
|
|
1268
|
+
: { id: 'hub', address: hubTunnelAddressFactory(edge), publicKey: edge.hubPublicKey };
|
|
1269
|
+
const [peer = {}] = wireguardStatusFactory({
|
|
1270
|
+
peers: expectedPeer ? [expectedPeer] : [],
|
|
1271
|
+
latestHandshakes,
|
|
1272
|
+
now,
|
|
1273
|
+
});
|
|
1274
|
+
return {
|
|
1275
|
+
ok: serviceActive && Boolean(expectedPeer?.publicKey) && peer.online === true,
|
|
1276
|
+
role: edge.role || '(unset)',
|
|
1277
|
+
serviceActive,
|
|
1278
|
+
peerId: expectedPeer?.id || '(unset)',
|
|
1279
|
+
handshakeAgeSeconds: peer.handshakeAgeSeconds ?? null,
|
|
1280
|
+
};
|
|
1281
|
+
};
|
|
1282
|
+
|
|
1283
|
+
/** Refuses a remediation command when SSH reached a different edge host. */
|
|
1284
|
+
const assertEdgeIdentity = (edge = {}, { expectedRole = '', expectedId = '' } = {}) => {
|
|
1285
|
+
if (expectedRole && edge.role !== expectedRole)
|
|
1286
|
+
throw new Error(`[wireguard] expected role '${expectedRole}', reached '${edge.role || '(unset)'}'`);
|
|
1287
|
+
if (expectedId && edge.peerId !== expectedId)
|
|
1288
|
+
throw new Error(`[wireguard] expected peer '${expectedId}', reached '${edge.peerId || '(unset)'}'`);
|
|
1289
|
+
return edge;
|
|
966
1290
|
};
|
|
967
1291
|
|
|
968
1292
|
/**
|
|
@@ -978,8 +1302,8 @@ const writeRootFile = ({ target, content, mode = '0644', dryRun = false }) => {
|
|
|
978
1302
|
* @memberof UnderpostWireguard
|
|
979
1303
|
*/
|
|
980
1304
|
const writeServerInterfaceConf = ({ state, peers, dryRun = false }) => {
|
|
981
|
-
if (state.role !== '
|
|
982
|
-
return
|
|
1305
|
+
if (state.role !== 'hub') return false;
|
|
1306
|
+
return installRootFile({
|
|
983
1307
|
target: `${UNDERPOST_EDGE.wireguardDir}/${state.interfaceName}.conf`,
|
|
984
1308
|
content: wireguardServerConfFactory({
|
|
985
1309
|
interfaceName: state.interfaceName,
|
|
@@ -1050,8 +1374,8 @@ const liveWireguardCommand = (interfaceName, args) =>
|
|
|
1050
1374
|
`sudo sh -c 'wg show ${interfaceName} >/dev/null 2>&1 && wg set ${interfaceName} ${args} || true'`;
|
|
1051
1375
|
|
|
1052
1376
|
/**
|
|
1053
|
-
* @method
|
|
1054
|
-
* @description Reports the two
|
|
1377
|
+
* @method warnTopologyHazards
|
|
1378
|
+
* @description Reports the two topology states that break routing silently.
|
|
1055
1379
|
*
|
|
1056
1380
|
* A peer with no `hosts` and no `instances` claims nothing. It still serves
|
|
1057
1381
|
* every hostname while it is the *only* peer, because a lone peer is its own
|
|
@@ -1062,11 +1386,11 @@ const liveWireguardCommand = (interfaceName, args) =>
|
|
|
1062
1386
|
* That cliff arrives when the peer is registered, not when routes are published,
|
|
1063
1387
|
* so it is reported at both points rather than left for the next sync to
|
|
1064
1388
|
* discover.
|
|
1065
|
-
* @param {Array<object>} [peers] - The
|
|
1389
|
+
* @param {Array<object>} [peers] - The topology peers.
|
|
1066
1390
|
* @returns {void}
|
|
1067
1391
|
* @memberof UnderpostWireguard
|
|
1068
1392
|
*/
|
|
1069
|
-
const
|
|
1393
|
+
const warnTopologyHazards = (peers = []) => {
|
|
1070
1394
|
if (peers.length === 0) {
|
|
1071
1395
|
logger.warn('Registry has no peers: no hostname can resolve until at least one is registered', {
|
|
1072
1396
|
next: '--peer-add <id> --peer-ip <10.0.0.x> --public-key <key>',
|
|
@@ -1107,6 +1431,30 @@ const tunnelNetworkCidrFactory = (address, fallback = UNDERPOST_EDGE.tunnelCidr)
|
|
|
1107
1431
|
return `${[network >>> 24, (network >>> 16) & 255, (network >>> 8) & 255, network & 255].join('.')}/${prefix}`;
|
|
1108
1432
|
};
|
|
1109
1433
|
|
|
1434
|
+
/**
|
|
1435
|
+
* @method hubTunnelAddressFactory
|
|
1436
|
+
* @description The hub's address inside the tunnel, as seen from either end.
|
|
1437
|
+
*
|
|
1438
|
+
* A spoke's topology entry never records it: WireGuard identifies the hub by
|
|
1439
|
+
* public key and public endpoint, and the spoke stores only the network it
|
|
1440
|
+
* routes back through it. So a spoke derives the address the same way
|
|
1441
|
+
* the hub topology assigns it — the tunnel network's first host,
|
|
1442
|
+
* which is what `UNDERPOST_EDGE.cidr` declares. Probing the hub from inside the
|
|
1443
|
+
* tunnel is the one check that distinguishes a dead tunnel from a hub that is
|
|
1444
|
+
* merely busy, so the address has to be resolvable without one.
|
|
1445
|
+
* @param {object} [state] - Derived runtime context.
|
|
1446
|
+
* @returns {string} Hub tunnel address, or an empty string when underivable.
|
|
1447
|
+
* @memberof UnderpostWireguard
|
|
1448
|
+
*/
|
|
1449
|
+
const hubTunnelAddressFactory = (state = {}) => {
|
|
1450
|
+
if (state.role === 'hub') return tunnelAddressFactory(state.address);
|
|
1451
|
+
const [network, prefix] = tunnelNetworkCidrFactory(state.address).split('/');
|
|
1452
|
+
const octets = network.split('.').map(Number);
|
|
1453
|
+
if (octets.length !== 4 || octets.some((octet) => !Number.isInteger(octet)) || Number(prefix) > 30) return '';
|
|
1454
|
+
const value = (octets.reduce((result, octet) => ((result << 8) | octet) >>> 0, 0) + 1) >>> 0;
|
|
1455
|
+
return [value >>> 24, (value >>> 16) & 255, (value >>> 8) & 255, value & 255].join('.');
|
|
1456
|
+
};
|
|
1457
|
+
|
|
1110
1458
|
/**
|
|
1111
1459
|
* @class UnderpostWireguard
|
|
1112
1460
|
* @description Lifecycle for the edge: the WireGuard transport that reaches the
|
|
@@ -1190,94 +1538,115 @@ class UnderpostWireguard {
|
|
|
1190
1538
|
return { privateKeyPath, publicKeyPath, publicKey, generated: !exists };
|
|
1191
1539
|
},
|
|
1192
1540
|
|
|
1541
|
+
nodeConfig(options = {}) {
|
|
1542
|
+
const nodeName = nodeNameFactory(options.nodeName || os.hostname());
|
|
1543
|
+
const current = readNodeConfig(nodeName);
|
|
1544
|
+
const identity = nodeIdentityFactory({
|
|
1545
|
+
nodeName,
|
|
1546
|
+
role: options.nodeRole || current.role,
|
|
1547
|
+
hubHost: options.hubHost || current.hubHost,
|
|
1548
|
+
peerId: options.peerId || current.peerId,
|
|
1549
|
+
});
|
|
1550
|
+
if (!identity.role) throw new Error(`[wireguard] --node-role must be one of: ${EDGE_NODE_ROLES.join(', ')}`);
|
|
1551
|
+
if (isIP(identity.hubHost) !== 4) throw new Error('[wireguard] --hub-host must be the hub static IPv4 address');
|
|
1552
|
+
const topology = readTopology();
|
|
1553
|
+
const hub = topology[identity.hubHost];
|
|
1554
|
+
if (!hub) throw new Error(`[wireguard] hub '${identity.hubHost}' is not registered in ${EDGE_TOPOLOGY_PATH}`);
|
|
1555
|
+
if (identity.role !== 'hub' && !identity.peerId)
|
|
1556
|
+
throw new Error('[wireguard] --peer-id is required for control and worker nodes');
|
|
1557
|
+
if (identity.role !== 'hub' && !hub.peers.some((peer) => peer.id === identity.peerId))
|
|
1558
|
+
throw new Error(
|
|
1559
|
+
`[wireguard] peer '${identity.peerId}' is not registered on ${identity.hubHost}; add it with --peer-add first`,
|
|
1560
|
+
);
|
|
1561
|
+
const duplicate = readNodeConfigs().find(
|
|
1562
|
+
(node) =>
|
|
1563
|
+
node.nodeName !== identity.nodeName &&
|
|
1564
|
+
node.hubHost === identity.hubHost &&
|
|
1565
|
+
((identity.role === 'hub' && node.role === 'hub') ||
|
|
1566
|
+
(identity.role !== 'hub' && node.peerId === identity.peerId)),
|
|
1567
|
+
);
|
|
1568
|
+
if (duplicate)
|
|
1569
|
+
throw new Error(
|
|
1570
|
+
`[wireguard] ${identity.role === 'hub' ? `hub '${identity.hubHost}'` : `peer '${identity.peerId}'`} ` +
|
|
1571
|
+
`is already assigned to node '${duplicate.nodeName}'`,
|
|
1572
|
+
);
|
|
1573
|
+
const saved = writeNodeIdentity(identity, options);
|
|
1574
|
+
logger.info(options.dryRun ? '[dry-run] WireGuard node identity' : 'WireGuard node identity configured', saved);
|
|
1575
|
+
return saved;
|
|
1576
|
+
},
|
|
1577
|
+
|
|
1193
1578
|
/**
|
|
1194
1579
|
* @method setup
|
|
1195
|
-
* @description Builds
|
|
1196
|
-
*
|
|
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.
|
|
1580
|
+
* @description Builds this selected node's interface and updates only its
|
|
1581
|
+
* public key in deployment topology.
|
|
1202
1582
|
* @param {object} options - CLI options.
|
|
1203
|
-
* @returns {object} The
|
|
1583
|
+
* @returns {object} The derived runtime context.
|
|
1204
1584
|
* @memberof UnderpostWireguard
|
|
1205
1585
|
*/
|
|
1206
1586
|
setup(options = {}) {
|
|
1207
|
-
const state = readEdgeState();
|
|
1208
1587
|
const buildConf = options.buildConf === true;
|
|
1588
|
+
const topology = readTopology();
|
|
1589
|
+
const identity = readNodeIdentity();
|
|
1590
|
+
const state = edgeContextFactory({ topology, identity });
|
|
1209
1591
|
const interfaceName = `${options.interface || state.interfaceName}`.trim();
|
|
1210
|
-
if (
|
|
1211
|
-
throw new Error('[wireguard] --
|
|
1212
|
-
const
|
|
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".
|
|
1592
|
+
if (state.role !== 'hub' && (options.port !== undefined || options.sshForwardPort !== undefined))
|
|
1593
|
+
throw new Error('[wireguard] --port and --ssh-forward-port configure the hub only');
|
|
1594
|
+
const listenPort = state.role === 'hub' && Number(options.port) > 0 ? Number(options.port) : state.listenPort;
|
|
1235
1595
|
const sshForwardPort =
|
|
1236
1596
|
options.sshForwardPort === undefined || options.sshForwardPort === null || `${options.sshForwardPort}` === ''
|
|
1237
1597
|
? state.sshForwardPort
|
|
1238
1598
|
: 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
1599
|
const { privateKeyPath, publicKey } = buildConf
|
|
1243
|
-
? {
|
|
1600
|
+
? {
|
|
1601
|
+
privateKeyPath: `${UNDERPOST_EDGE.wireguardDir}/${interfaceName}.key`,
|
|
1602
|
+
publicKey: `${options.publicKey || state.publicKey}`.trim(),
|
|
1603
|
+
}
|
|
1244
1604
|
: UnderpostWireguard.API.ensureKeyPair(interfaceName, options.dryRun);
|
|
1605
|
+
if (!publicKey && !options.dryRun) throw new Error('[wireguard] the selected node has no WireGuard public key');
|
|
1245
1606
|
|
|
1246
1607
|
let conf;
|
|
1247
|
-
let
|
|
1248
|
-
if (role === '
|
|
1249
|
-
const address = `${options.cidr || state.address
|
|
1608
|
+
let nextTopology;
|
|
1609
|
+
if (state.role === 'hub') {
|
|
1610
|
+
const address = `${options.cidr || state.address}`.trim();
|
|
1250
1611
|
if (!address.includes('/')) throw new Error('[wireguard] --cidr must carry a prefix length (e.g. 10.0.0.1/24)');
|
|
1612
|
+
const hub = {
|
|
1613
|
+
...topology[state.hubHost],
|
|
1614
|
+
interfaceName,
|
|
1615
|
+
listenPort,
|
|
1616
|
+
address,
|
|
1617
|
+
publicKey,
|
|
1618
|
+
sshForwardPort,
|
|
1619
|
+
};
|
|
1620
|
+
nextTopology = { ...topology, [state.hubHost]: hub };
|
|
1251
1621
|
conf = wireguardServerConfFactory({
|
|
1252
1622
|
interfaceName,
|
|
1253
1623
|
address,
|
|
1254
1624
|
listenPort,
|
|
1255
1625
|
keyPath: privateKeyPath,
|
|
1256
|
-
peers:
|
|
1626
|
+
peers: hub.peers,
|
|
1257
1627
|
});
|
|
1258
|
-
next = { ...state, interfaceName, role, listenPort, address, publicKey, sshForwardPort };
|
|
1259
1628
|
} else {
|
|
1260
|
-
const address = `${options.peerIp ||
|
|
1261
|
-
|
|
1262
|
-
const
|
|
1263
|
-
const
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1629
|
+
const address = `${options.peerIp || state.address}`.trim();
|
|
1630
|
+
if (!state.hubPublicKey) throw new Error(`[wireguard] hub '${state.hubHost}' has no publicKey`);
|
|
1631
|
+
const hub = topology[state.hubHost];
|
|
1632
|
+
const peers = hub.peers.map((peer) =>
|
|
1633
|
+
peer.id === state.peerId ? peerFactory({ ...peer, address, publicKey }) : peer,
|
|
1634
|
+
);
|
|
1635
|
+
nextTopology = { ...topology, [state.hubHost]: { ...hub, peers } };
|
|
1267
1636
|
conf = wireguardClientConfFactory({
|
|
1268
1637
|
address,
|
|
1269
1638
|
keyPath: privateKeyPath,
|
|
1270
|
-
publicKey: hubPublicKey,
|
|
1271
|
-
endpoint
|
|
1272
|
-
cidr,
|
|
1639
|
+
publicKey: state.hubPublicKey,
|
|
1640
|
+
endpoint: `${state.hubHost}:${listenPort}`,
|
|
1641
|
+
cidr: `${options.cidr || tunnelNetworkCidrFactory(hub.address)}`.trim(),
|
|
1273
1642
|
keepalive: UNDERPOST_EDGE.keepalive,
|
|
1274
1643
|
});
|
|
1275
|
-
next = { ...state, interfaceName, role, listenPort, address, endpoint, hubPublicKey, publicKey };
|
|
1276
1644
|
}
|
|
1645
|
+
const next = edgeContextFactory({ topology: nextTopology, identity });
|
|
1277
1646
|
|
|
1278
1647
|
let confChanged = false;
|
|
1279
1648
|
if (!buildConf) {
|
|
1280
|
-
confChanged =
|
|
1649
|
+
confChanged = installRootFile({
|
|
1281
1650
|
target: `${UNDERPOST_EDGE.wireguardDir}/${interfaceName}.conf`,
|
|
1282
1651
|
content: conf,
|
|
1283
1652
|
mode: '0600',
|
|
@@ -1288,7 +1657,7 @@ class UnderpostWireguard {
|
|
|
1288
1657
|
`sudo sh -c 'echo net.ipv4.ip_forward=1 > ${UNDERPOST_EDGE.sysctlPath}'`,
|
|
1289
1658
|
`sudo sysctl -q --system`,
|
|
1290
1659
|
...firewallCommandsFactory({
|
|
1291
|
-
role,
|
|
1660
|
+
role: next.role,
|
|
1292
1661
|
interfaceName,
|
|
1293
1662
|
listenPort,
|
|
1294
1663
|
tunnelCidr: tunnelNetworkCidrFactory(next.address),
|
|
@@ -1298,7 +1667,7 @@ class UnderpostWireguard {
|
|
|
1298
1667
|
options.dryRun,
|
|
1299
1668
|
);
|
|
1300
1669
|
}
|
|
1301
|
-
if (!options.dryRun)
|
|
1670
|
+
if (!options.dryRun) writeTopology(nextTopology);
|
|
1302
1671
|
// Writing the config is idempotent; the *running* interface is not. wg-quick
|
|
1303
1672
|
// reads the file only at start, so a changed config under a live unit is a
|
|
1304
1673
|
// divergence that stays silent until the next reboot re-reads it.
|
|
@@ -1309,9 +1678,11 @@ class UnderpostWireguard {
|
|
|
1309
1678
|
systemctlCommandFactory({ action: 'is-active --quiet', name: `wg-quick@${interfaceName}`, sudo: false }),
|
|
1310
1679
|
{ silent: true, silentOnError: true },
|
|
1311
1680
|
).code === 0;
|
|
1312
|
-
logger.info(buildConf ? '
|
|
1681
|
+
logger.info(buildConf ? 'Topology updated; host untouched' : 'WireGuard interface configured', {
|
|
1313
1682
|
interfaceName,
|
|
1314
|
-
role,
|
|
1683
|
+
role: next.role,
|
|
1684
|
+
nodeName: next.nodeName,
|
|
1685
|
+
hubHost: next.hubHost,
|
|
1315
1686
|
address: next.address,
|
|
1316
1687
|
publicKey: next.publicKey,
|
|
1317
1688
|
peers: next.peers.length,
|
|
@@ -1319,7 +1690,7 @@ class UnderpostWireguard {
|
|
|
1319
1690
|
});
|
|
1320
1691
|
if (restartRequired)
|
|
1321
1692
|
logger.warn('Interface config changed while the tunnel is up; the live interface still runs the old one', {
|
|
1322
|
-
apply: `--wireguard-
|
|
1693
|
+
apply: `--wireguard-restart --check --interface ${interfaceName}`,
|
|
1323
1694
|
});
|
|
1324
1695
|
return next;
|
|
1325
1696
|
},
|
|
@@ -1329,7 +1700,7 @@ class UnderpostWireguard {
|
|
|
1329
1700
|
* @description Registers a spoke and applies it to the running hub.
|
|
1330
1701
|
*
|
|
1331
1702
|
* `wg set` installs the peer on the live interface, so an existing tunnel is
|
|
1332
|
-
* never interrupted to admit a new one —
|
|
1703
|
+
* never interrupted to admit a new one — topology and the config file
|
|
1333
1704
|
* are updated in the same pass so the peer also survives a restart.
|
|
1334
1705
|
*
|
|
1335
1706
|
* A spoke that re-keys is registered under the same id with a new public key.
|
|
@@ -1339,11 +1710,17 @@ class UnderpostWireguard {
|
|
|
1339
1710
|
* could keep handing that traffic to an identity the spoke no longer holds.
|
|
1340
1711
|
* It is dropped first, which is what makes a reconnect leave no trace.
|
|
1341
1712
|
* @param {object} options - CLI options.
|
|
1342
|
-
* @returns {object} The updated
|
|
1713
|
+
* @returns {object} The updated hub topology.
|
|
1343
1714
|
* @memberof UnderpostWireguard
|
|
1344
1715
|
*/
|
|
1345
1716
|
peerAdd(options = {}) {
|
|
1346
|
-
const
|
|
1717
|
+
const topology = readTopology();
|
|
1718
|
+
const hubHost = hubHostResolve({ topology, hubHost: options.hubHost });
|
|
1719
|
+
const hub = topology[hubHost];
|
|
1720
|
+
if (!hub) throw new Error(`[wireguard] hub '${hubHost}' is not registered in ${EDGE_TOPOLOGY_PATH}`);
|
|
1721
|
+
const state = options.buildConf === true ? null : readEdgeContext();
|
|
1722
|
+
if (state && (state.role !== 'hub' || state.hubHost !== hubHost))
|
|
1723
|
+
throw new Error('[wireguard] live peer changes can run only on the selected hub; use --build-conf off-host');
|
|
1347
1724
|
const id = `${options.peerAdd || ''}`.trim();
|
|
1348
1725
|
const address = `${options.peerIp || ''}`.trim();
|
|
1349
1726
|
const publicKey = `${options.publicKey || ''}`.trim();
|
|
@@ -1358,108 +1735,114 @@ class UnderpostWireguard {
|
|
|
1358
1735
|
['allowedIPs', csvFactory(options.allowedIps)],
|
|
1359
1736
|
['hosts', csvFactory(options.hosts)],
|
|
1360
1737
|
['instances', csvFactory(options.instances)],
|
|
1738
|
+
['managementHost', options.managementHost ? `${options.managementHost}`.trim() : undefined],
|
|
1361
1739
|
['default', options.default === true ? true : undefined],
|
|
1362
1740
|
].filter(([, value]) => value !== undefined),
|
|
1363
1741
|
);
|
|
1364
|
-
const current =
|
|
1742
|
+
const current = hub.peers.find((entry) => entry.id === id) || {};
|
|
1365
1743
|
const peer = peerFactory({ ...current, id, address, publicKey, ...overrides });
|
|
1366
1744
|
const supersededKey = current.publicKey && current.publicKey !== publicKey ? current.publicKey : '';
|
|
1367
|
-
const peers = [...
|
|
1368
|
-
const
|
|
1745
|
+
const peers = [...hub.peers.filter((entry) => entry.id !== id), peer].sort((a, b) => a.id.localeCompare(b.id));
|
|
1746
|
+
const nextHub = { ...hub, peers };
|
|
1369
1747
|
if (options.buildConf !== true) {
|
|
1370
1748
|
runHostCommands(
|
|
1371
1749
|
[
|
|
1372
|
-
...(supersededKey ? [liveWireguardCommand(
|
|
1373
|
-
liveWireguardCommand(
|
|
1750
|
+
...(supersededKey ? [liveWireguardCommand(state.interfaceName, `peer ${supersededKey} remove`)] : []),
|
|
1751
|
+
liveWireguardCommand(state.interfaceName, `peer ${publicKey} allowed-ips ${peer.allowedIPs.join(',')}`),
|
|
1374
1752
|
],
|
|
1375
1753
|
options.dryRun,
|
|
1376
1754
|
);
|
|
1377
|
-
writeServerInterfaceConf({ state
|
|
1755
|
+
writeServerInterfaceConf({ state, peers, dryRun: options.dryRun });
|
|
1378
1756
|
}
|
|
1379
|
-
if (!options.dryRun)
|
|
1757
|
+
if (!options.dryRun) writeTopology({ ...topology, [hubHost]: nextHub });
|
|
1380
1758
|
logger.info(options.buildConf === true ? 'Peer recorded; host untouched' : 'Peer registered', {
|
|
1381
1759
|
id,
|
|
1760
|
+
hubHost,
|
|
1382
1761
|
address,
|
|
1762
|
+
managementHost: peer.managementHost || '(unset)',
|
|
1383
1763
|
allowedIPs: peer.allowedIPs,
|
|
1384
1764
|
rekeyed: supersededKey !== '',
|
|
1385
1765
|
});
|
|
1386
|
-
|
|
1387
|
-
return
|
|
1766
|
+
warnTopologyHazards(peers);
|
|
1767
|
+
return nextHub;
|
|
1388
1768
|
},
|
|
1389
1769
|
|
|
1390
1770
|
/**
|
|
1391
1771
|
* @method peerRemove
|
|
1392
|
-
* @description Removes a spoke from
|
|
1772
|
+
* @description Removes a spoke from topology and from the running hub.
|
|
1393
1773
|
* @param {object} options - CLI options.
|
|
1394
|
-
* @returns {object} The updated
|
|
1774
|
+
* @returns {object} The updated hub topology.
|
|
1395
1775
|
* @memberof UnderpostWireguard
|
|
1396
1776
|
*/
|
|
1397
1777
|
peerRemove(options = {}) {
|
|
1398
|
-
const
|
|
1778
|
+
const topology = readTopology();
|
|
1779
|
+
const hubHost = hubHostResolve({ topology, hubHost: options.hubHost });
|
|
1780
|
+
const hub = topology[hubHost];
|
|
1781
|
+
if (!hub) throw new Error(`[wireguard] hub '${hubHost}' is not registered in ${EDGE_TOPOLOGY_PATH}`);
|
|
1782
|
+
const state = options.buildConf === true ? null : readEdgeContext();
|
|
1783
|
+
if (state && (state.role !== 'hub' || state.hubHost !== hubHost))
|
|
1784
|
+
throw new Error('[wireguard] live peer changes can run only on the selected hub; use --build-conf off-host');
|
|
1399
1785
|
const id = `${options.peerRemove || ''}`.trim();
|
|
1400
|
-
const peer =
|
|
1786
|
+
const peer = hub.peers.find((entry) => entry.id === id);
|
|
1401
1787
|
if (!peer) {
|
|
1402
|
-
logger.warn('No such peer in
|
|
1403
|
-
return
|
|
1788
|
+
logger.warn('No such peer in topology', { hubHost, id });
|
|
1789
|
+
return hub;
|
|
1404
1790
|
}
|
|
1405
|
-
const peers =
|
|
1406
|
-
const
|
|
1791
|
+
const peers = hub.peers.filter((entry) => entry.id !== id);
|
|
1792
|
+
const nextHub = { ...hub, peers };
|
|
1407
1793
|
if (options.buildConf !== true) {
|
|
1408
|
-
runHostCommands([liveWireguardCommand(
|
|
1409
|
-
writeServerInterfaceConf({ state
|
|
1794
|
+
runHostCommands([liveWireguardCommand(state.interfaceName, `peer ${peer.publicKey} remove`)], options.dryRun);
|
|
1795
|
+
writeServerInterfaceConf({ state, peers, dryRun: options.dryRun });
|
|
1410
1796
|
}
|
|
1411
|
-
if (!options.dryRun)
|
|
1412
|
-
logger.info(options.buildConf === true ? 'Peer removed from
|
|
1413
|
-
|
|
1414
|
-
|
|
1797
|
+
if (!options.dryRun) writeTopology({ ...topology, [hubHost]: nextHub });
|
|
1798
|
+
logger.info(options.buildConf === true ? 'Peer removed from topology; host untouched' : 'Peer removed', {
|
|
1799
|
+
hubHost,
|
|
1800
|
+
id,
|
|
1801
|
+
});
|
|
1802
|
+
warnTopologyHazards(peers);
|
|
1803
|
+
return nextHub;
|
|
1415
1804
|
},
|
|
1416
1805
|
|
|
1417
1806
|
/**
|
|
1418
1807
|
* @method buildConf
|
|
1419
|
-
* @description Rewrites
|
|
1808
|
+
* @description Rewrites topology in place, normalized, touching no host.
|
|
1420
1809
|
*
|
|
1421
|
-
*
|
|
1810
|
+
* Topology is authored, not derived — its peer bindings and public keys
|
|
1422
1811
|
* exist nowhere else, so nothing can regenerate it from other configuration.
|
|
1423
1812
|
* 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
|
|
1813
|
+
* drops entries with no id, and reports what it read, so malformed topology
|
|
1425
1814
|
* is corrected before a sync acts on it.
|
|
1426
1815
|
* @param {object} [options] - CLI options.
|
|
1427
|
-
* @returns {object} The normalized
|
|
1816
|
+
* @returns {object} The normalized topology.
|
|
1428
1817
|
* @memberof UnderpostWireguard
|
|
1429
1818
|
*/
|
|
1430
1819
|
buildConf(options = {}) {
|
|
1431
|
-
const
|
|
1432
|
-
const changed = options.dryRun ? false :
|
|
1433
|
-
logger.info('
|
|
1434
|
-
target:
|
|
1435
|
-
|
|
1436
|
-
address: state.address,
|
|
1437
|
-
peers: state.peers.map((peer) => peer.id),
|
|
1820
|
+
const topology = readTopology();
|
|
1821
|
+
const changed = options.dryRun ? false : writeTopology(topology);
|
|
1822
|
+
logger.info('WireGuard topology normalized', {
|
|
1823
|
+
target: EDGE_TOPOLOGY_PATH,
|
|
1824
|
+
hubs: Object.entries(topology).map(([host, hub]) => ({ host, peers: hub.peers.map((peer) => peer.id) })),
|
|
1438
1825
|
changed,
|
|
1439
1826
|
});
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
next: '--build-conf --wireguard-setup --server --cidr 10.0.0.1/24 (or --client …)',
|
|
1443
|
-
});
|
|
1444
|
-
warnRegistryHazards(state.peers);
|
|
1445
|
-
return state;
|
|
1827
|
+
for (const hub of Object.values(topology)) warnTopologyHazards(hub.peers);
|
|
1828
|
+
return topology;
|
|
1446
1829
|
},
|
|
1447
1830
|
|
|
1448
1831
|
/**
|
|
1449
1832
|
* @method routeTable
|
|
1450
1833
|
* @description The resolved hostname-to-spoke table for one deploy, a list,
|
|
1451
|
-
* or every deploy in `dd.
|
|
1834
|
+
* or every deploy in `dd.routes`.
|
|
1452
1835
|
*
|
|
1453
1836
|
* Reads each deploy's configuration through the same helpers the cluster
|
|
1454
1837
|
* runners use, so a hostname the edge routes is exactly a hostname the
|
|
1455
1838
|
* cluster publishes.
|
|
1456
1839
|
*
|
|
1457
|
-
* A deploy listed in `dd.
|
|
1840
|
+
* A deploy listed in `dd.routes` whose configuration is not checked out
|
|
1458
1841
|
* locally is skipped with a warning rather than failing the run: the private
|
|
1459
1842
|
* conf of an unrelated deploy is not a precondition for publishing the ones
|
|
1460
1843
|
* that are present.
|
|
1461
1844
|
*
|
|
1462
|
-
* Omitting the id means `dd` — the whole of `dd.
|
|
1845
|
+
* Omitting the id means `dd` — the whole of `dd.routes`. The edge holds one
|
|
1463
1846
|
* pair of map files for the cluster, so a complete table is the only default
|
|
1464
1847
|
* that publishes a working edge; narrowing it is the deliberate act.
|
|
1465
1848
|
* @param {string} [deployId] - Deploy id, comma-separated list, or `dd` (the default).
|
|
@@ -1470,7 +1853,7 @@ class UnderpostWireguard {
|
|
|
1470
1853
|
routeTable(deployId) {
|
|
1471
1854
|
const deployList = deployListFactory(deployId || 'dd');
|
|
1472
1855
|
if (deployList.length === 0) throw new Error('[wireguard] --deploy-id resolved to no deploys');
|
|
1473
|
-
const state =
|
|
1856
|
+
const state = readEdgeContext();
|
|
1474
1857
|
const tables = [];
|
|
1475
1858
|
const missing = [];
|
|
1476
1859
|
for (const id of deployList) {
|
|
@@ -1503,17 +1886,38 @@ class UnderpostWireguard {
|
|
|
1503
1886
|
*
|
|
1504
1887
|
* This is the only read-only entry point. Host probes are skipped under
|
|
1505
1888
|
* `--build-conf`, which has to run on a workstation with no interface to
|
|
1506
|
-
* query, so an off-box run reports
|
|
1889
|
+
* query, so an off-box run reports topology and routing alone.
|
|
1507
1890
|
* @param {object} options - CLI options.
|
|
1508
|
-
* @returns {object} Runtime,
|
|
1891
|
+
* @returns {object} Runtime, topology and routing summary.
|
|
1509
1892
|
* @memberof UnderpostWireguard
|
|
1510
1893
|
*/
|
|
1511
1894
|
status(options = {}) {
|
|
1512
|
-
const state =
|
|
1895
|
+
const state = readEdgeContext();
|
|
1513
1896
|
const interfaceName = `${options.interface || state.interfaceName}`.trim();
|
|
1514
1897
|
const probeHost = options.buildConf !== true;
|
|
1515
1898
|
const read = (command) => shellExec(command, { stdout: true, silent: true, silentOnError: true }).trim();
|
|
1516
1899
|
const show = (subCommand) => (probeHost ? read(`sudo wg show ${interfaceName} ${subCommand}`) : '');
|
|
1900
|
+
// Sampled once: the topology view and the leftover-peer view must describe
|
|
1901
|
+
// the same instant, or a peer can appear in neither.
|
|
1902
|
+
const handshakes = show('latest-handshakes');
|
|
1903
|
+
const livePeers =
|
|
1904
|
+
state.role === 'hub'
|
|
1905
|
+
? wireguardStatusFactory({
|
|
1906
|
+
peers: state.peers,
|
|
1907
|
+
latestHandshakes: handshakes,
|
|
1908
|
+
transfer: show('transfer'),
|
|
1909
|
+
endpoints: show('endpoints'),
|
|
1910
|
+
})
|
|
1911
|
+
: [];
|
|
1912
|
+
const [liveHub] =
|
|
1913
|
+
state.role === 'hub'
|
|
1914
|
+
? []
|
|
1915
|
+
: wireguardStatusFactory({
|
|
1916
|
+
peers: [{ id: 'hub', address: hubTunnelAddressFactory(state), publicKey: state.hubPublicKey }],
|
|
1917
|
+
latestHandshakes: handshakes,
|
|
1918
|
+
transfer: show('transfer'),
|
|
1919
|
+
endpoints: show('endpoints'),
|
|
1920
|
+
});
|
|
1517
1921
|
|
|
1518
1922
|
let table = null;
|
|
1519
1923
|
try {
|
|
@@ -1525,11 +1929,18 @@ class UnderpostWireguard {
|
|
|
1525
1929
|
const via = {};
|
|
1526
1930
|
for (const route of table?.routes || []) via[route.via] = (via[route.via] || 0) + 1;
|
|
1527
1931
|
const summary = {
|
|
1528
|
-
|
|
1932
|
+
nodeName: state.nodeName,
|
|
1933
|
+
role: state.role,
|
|
1934
|
+
hubHost: state.hubHost,
|
|
1935
|
+
peerId: state.peerId || '(hub)',
|
|
1529
1936
|
interface: interfaceName,
|
|
1530
1937
|
address: state.address,
|
|
1531
1938
|
endpoint: state.endpoint,
|
|
1532
1939
|
publicKey: state.publicKey,
|
|
1940
|
+
// What this machine dials, next to what it presents. A spoke that cannot
|
|
1941
|
+
// handshake is usually holding one of these two wrong, and reading them
|
|
1942
|
+
// off the same report is what makes that a five-second check.
|
|
1943
|
+
...(state.role !== 'hub' ? { hubPublicKey: state.hubPublicKey || '(unset)' } : {}),
|
|
1533
1944
|
...(probeHost
|
|
1534
1945
|
? {
|
|
1535
1946
|
wireguard: read(systemdStatusCommandsFactory(`wg-quick@${interfaceName}`).active),
|
|
@@ -1541,14 +1952,18 @@ class UnderpostWireguard {
|
|
|
1541
1952
|
: '(closed)',
|
|
1542
1953
|
}
|
|
1543
1954
|
: {}),
|
|
1544
|
-
|
|
1545
|
-
?
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1955
|
+
...(state.role === 'hub'
|
|
1956
|
+
? { peers: probeHost ? livePeers : state.peers.map(peerSummaryFactory) }
|
|
1957
|
+
: { hub: liveHub }),
|
|
1958
|
+
topologyPeers: state.peers.map(peerSummaryFactory),
|
|
1959
|
+
...(probeHost
|
|
1960
|
+
? {
|
|
1961
|
+
unregisteredPeers: unregisteredPeersFactory({
|
|
1962
|
+
peers: state.role === 'hub' ? state.peers : [{ publicKey: state.hubPublicKey }],
|
|
1963
|
+
latestHandshakes: handshakes,
|
|
1964
|
+
}),
|
|
1965
|
+
}
|
|
1966
|
+
: {}),
|
|
1552
1967
|
routing: table
|
|
1553
1968
|
? {
|
|
1554
1969
|
deployList: table.deployList,
|
|
@@ -1571,7 +1986,7 @@ class UnderpostWireguard {
|
|
|
1571
1986
|
logger.warn('Hostnames claimed by more than one deploy; only the first is served', {
|
|
1572
1987
|
conflicts: table.conflicts,
|
|
1573
1988
|
});
|
|
1574
|
-
|
|
1989
|
+
warnTopologyHazards(state.peers);
|
|
1575
1990
|
return summary;
|
|
1576
1991
|
},
|
|
1577
1992
|
|
|
@@ -1605,7 +2020,8 @@ class UnderpostWireguard {
|
|
|
1605
2020
|
});
|
|
1606
2021
|
if (conflicts.length > 0)
|
|
1607
2022
|
logger.warn('Hostnames claimed by more than one deploy; only the first is served', { conflicts });
|
|
1608
|
-
const state =
|
|
2023
|
+
const state = readEdgeContext();
|
|
2024
|
+
assertRoleCapability({ role: state.role, capability: 'haproxy', operation: 'wireguard --haproxy-sync' });
|
|
1609
2025
|
const defaultPeer = defaultPeerFactory(state.peers);
|
|
1610
2026
|
const maps = haproxyMapsFactory({ routes });
|
|
1611
2027
|
const conf = haproxyConfFactory({
|
|
@@ -1626,12 +2042,12 @@ class UnderpostWireguard {
|
|
|
1626
2042
|
const restore = () => {
|
|
1627
2043
|
for (const entry of previous) {
|
|
1628
2044
|
if (entry.content === null) shellExec(`sudo rm -f ${entry.target}`, { silent: true });
|
|
1629
|
-
else
|
|
2045
|
+
else installRootFile({ target: entry.target, content: entry.content, mode: '0644' });
|
|
1630
2046
|
}
|
|
1631
2047
|
};
|
|
1632
2048
|
|
|
1633
2049
|
let changed = false;
|
|
1634
|
-
for (const entry of targets) changed =
|
|
2050
|
+
for (const entry of targets) changed = installRootFile({ ...entry, dryRun: options.dryRun }) || changed;
|
|
1635
2051
|
if (options.dryRun) {
|
|
1636
2052
|
logger.info('[dry-run] edge routes', { deployList, routes, unresolved, conflicts });
|
|
1637
2053
|
return { routes, unresolved, conflicts, changed };
|
|
@@ -1692,13 +2108,12 @@ class UnderpostWireguard {
|
|
|
1692
2108
|
* @memberof UnderpostWireguard
|
|
1693
2109
|
*/
|
|
1694
2110
|
forwardProxyConfig(options = {}) {
|
|
1695
|
-
const state =
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
);
|
|
2111
|
+
const state = readEdgeContext();
|
|
2112
|
+
assertRoleCapability({
|
|
2113
|
+
role: state.role,
|
|
2114
|
+
capability: 'forward-proxy',
|
|
2115
|
+
operation: 'wireguard --forward-proxy-server',
|
|
2116
|
+
});
|
|
1702
2117
|
const config = forwardProxyConfigFactory({
|
|
1703
2118
|
host: `${options.forwardProxyServerHost || ''}`.trim() || tunnelAddressFactory(state.address),
|
|
1704
2119
|
port: options.forwardProxyServerPort,
|
|
@@ -1706,8 +2121,8 @@ class UnderpostWireguard {
|
|
|
1706
2121
|
if (!config.apiKey)
|
|
1707
2122
|
throw new Error(
|
|
1708
2123
|
`[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 <
|
|
1710
|
-
`or set it with \`underpost
|
|
2124
|
+
`Export it, put it in the deploy env selected by \`underpost app load --env <environment>\` (./.env), ` +
|
|
2125
|
+
`or set it with \`underpost host set ${FORWARD_PROXY.env.apiKey} <key>\``,
|
|
1711
2126
|
);
|
|
1712
2127
|
if (['0.0.0.0', '::', '*'].includes(config.host))
|
|
1713
2128
|
logger.warn('Forward proxy is bound to a wildcard address, not the tunnel', {
|
|
@@ -1817,7 +2232,7 @@ class UnderpostWireguard {
|
|
|
1817
2232
|
}
|
|
1818
2233
|
runHostCommands(
|
|
1819
2234
|
firewallCommandsFactory({
|
|
1820
|
-
role: '
|
|
2235
|
+
role: 'hub',
|
|
1821
2236
|
interfaceName: config.interfaceName,
|
|
1822
2237
|
listenPort: config.listenPort,
|
|
1823
2238
|
tunnelCidr: config.tunnelCidr,
|
|
@@ -1831,7 +2246,7 @@ class UnderpostWireguard {
|
|
|
1831
2246
|
interfaceName: config.interfaceName,
|
|
1832
2247
|
command: forwardProxyCommandFactory({ host: config.host, port: config.port, execPath: node.path }),
|
|
1833
2248
|
});
|
|
1834
|
-
const changed =
|
|
2249
|
+
const changed = installRootFile({
|
|
1835
2250
|
target: FORWARD_PROXY.unitPath,
|
|
1836
2251
|
content: unit,
|
|
1837
2252
|
mode: '0600',
|
|
@@ -1903,13 +2318,13 @@ class UnderpostWireguard {
|
|
|
1903
2318
|
* @memberof UnderpostWireguard
|
|
1904
2319
|
*/
|
|
1905
2320
|
start(options = {}) {
|
|
1906
|
-
const state =
|
|
2321
|
+
const state = readEdgeContext();
|
|
1907
2322
|
const interfaceName = `${options.interface || state.interfaceName}`.trim();
|
|
1908
2323
|
runServiceCommands(
|
|
1909
2324
|
[systemctlCommandFactory({ action: 'enable --now', name: `wg-quick@${interfaceName}` })],
|
|
1910
2325
|
options.dryRun,
|
|
1911
2326
|
);
|
|
1912
|
-
if (state.role === '
|
|
2327
|
+
if (state.role === 'hub')
|
|
1913
2328
|
runHostCommands(
|
|
1914
2329
|
quicForwardCommandsFactory({ interfaceName, target: defaultPeerFactory(state.peers)?.address || '' }).ensure,
|
|
1915
2330
|
options.dryRun,
|
|
@@ -1917,6 +2332,60 @@ class UnderpostWireguard {
|
|
|
1917
2332
|
logger.info('WireGuard interface started', { interfaceName });
|
|
1918
2333
|
},
|
|
1919
2334
|
|
|
2335
|
+
/** Restarts the interface so an active but stale tunnel is actually renegotiated. */
|
|
2336
|
+
restart(options = {}) {
|
|
2337
|
+
const state = assertEdgeIdentity(readEdgeContext(), options);
|
|
2338
|
+
const interfaceName = `${options.interface || state.interfaceName}`.trim();
|
|
2339
|
+
runServiceCommands(
|
|
2340
|
+
[
|
|
2341
|
+
systemctlCommandFactory({ action: 'enable', name: `wg-quick@${interfaceName}` }),
|
|
2342
|
+
systemctlCommandFactory({ action: 'restart', name: `wg-quick@${interfaceName}` }),
|
|
2343
|
+
],
|
|
2344
|
+
options.dryRun,
|
|
2345
|
+
);
|
|
2346
|
+
if (state.role === 'hub')
|
|
2347
|
+
runHostCommands(
|
|
2348
|
+
quicForwardCommandsFactory({ interfaceName, target: defaultPeerFactory(state.peers)?.address || '' }).ensure,
|
|
2349
|
+
options.dryRun,
|
|
2350
|
+
);
|
|
2351
|
+
logger.info('WireGuard interface restarted', { interfaceName });
|
|
2352
|
+
},
|
|
2353
|
+
|
|
2354
|
+
/** Waits for the local interface to become healthy and sets a failing exit code otherwise. */
|
|
2355
|
+
check(options = {}) {
|
|
2356
|
+
const state = assertEdgeIdentity(readEdgeContext(), options);
|
|
2357
|
+
const interfaceName = `${options.interface || state.interfaceName}`.trim();
|
|
2358
|
+
const timeoutSeconds = options.checkTimeout === undefined ? 30 : Math.max(0, Number(options.checkTimeout) || 0);
|
|
2359
|
+
const deadline = Date.now() + timeoutSeconds * 1000;
|
|
2360
|
+
let health;
|
|
2361
|
+
do {
|
|
2362
|
+
const read = (command) =>
|
|
2363
|
+
`${
|
|
2364
|
+
shellExec(command, {
|
|
2365
|
+
stdout: true,
|
|
2366
|
+
silent: true,
|
|
2367
|
+
silentOnError: true,
|
|
2368
|
+
disableLog: true,
|
|
2369
|
+
}) || ''
|
|
2370
|
+
}`.trim();
|
|
2371
|
+
health = wireguardHealthFactory({
|
|
2372
|
+
context: state,
|
|
2373
|
+
active: read(systemdStatusCommandsFactory(`wg-quick@${interfaceName}`).active),
|
|
2374
|
+
latestHandshakes: read(`sudo wg show ${interfaceName} latest-handshakes`),
|
|
2375
|
+
});
|
|
2376
|
+
if (health.ok || Date.now() >= deadline) break;
|
|
2377
|
+
sleepSync(1000);
|
|
2378
|
+
} while (true);
|
|
2379
|
+
|
|
2380
|
+
const report = { interface: interfaceName, ...health };
|
|
2381
|
+
if (health.ok) logger.info('WireGuard health check passed', report);
|
|
2382
|
+
else {
|
|
2383
|
+
logger.error('WireGuard health check failed', report);
|
|
2384
|
+
process.exitCode = 1;
|
|
2385
|
+
}
|
|
2386
|
+
return report;
|
|
2387
|
+
},
|
|
2388
|
+
|
|
1920
2389
|
/**
|
|
1921
2390
|
* @method stop
|
|
1922
2391
|
* @description Tears the tunnel down and removes the packet rules that only
|
|
@@ -1926,7 +2395,7 @@ class UnderpostWireguard {
|
|
|
1926
2395
|
* @memberof UnderpostWireguard
|
|
1927
2396
|
*/
|
|
1928
2397
|
stop(options = {}) {
|
|
1929
|
-
const state =
|
|
2398
|
+
const state = readEdgeContext();
|
|
1930
2399
|
const interfaceName = `${options.interface || state.interfaceName}`.trim();
|
|
1931
2400
|
runHostCommands(
|
|
1932
2401
|
[
|
|
@@ -1956,8 +2425,8 @@ class UnderpostWireguard {
|
|
|
1956
2425
|
* deleting the maps it reads is worse than leaving both: the daemon then
|
|
1957
2426
|
* fails to start on a config that references files that no longer exist.
|
|
1958
2427
|
*
|
|
1959
|
-
* The key pair and
|
|
1960
|
-
* invalidates every spoke's peer entry, and
|
|
2428
|
+
* The key pair and topology are deliberately kept: destroying the key
|
|
2429
|
+
* invalidates every spoke's peer entry, and topology is authored source
|
|
1961
2430
|
* that nothing can regenerate. A reset is for reconfiguring an edge rather
|
|
1962
2431
|
* than for re-establishing trust with all of them; re-keying is what
|
|
1963
2432
|
* {@link UnderpostWireguard.reinstall} is for.
|
|
@@ -1966,7 +2435,7 @@ class UnderpostWireguard {
|
|
|
1966
2435
|
* @memberof UnderpostWireguard
|
|
1967
2436
|
*/
|
|
1968
2437
|
reset(options = {}) {
|
|
1969
|
-
const state =
|
|
2438
|
+
const state = readEdgeContext();
|
|
1970
2439
|
const interfaceName = `${options.interface || state.interfaceName}`.trim();
|
|
1971
2440
|
UnderpostWireguard.API.stop({ ...options, interface: interfaceName });
|
|
1972
2441
|
runHostCommands(
|
|
@@ -1983,28 +2452,22 @@ class UnderpostWireguard {
|
|
|
1983
2452
|
`sudo rm -f ${UNDERPOST_EDGE.haproxyDir}/${UNDERPOST_EDGE.httpMapName}`,
|
|
1984
2453
|
`sudo rm -f ${UNDERPOST_EDGE.haproxyDir}/${UNDERPOST_EDGE.haproxyConfName}`,
|
|
1985
2454
|
...forwardProxyServiceCommandsFactory().remove,
|
|
1986
|
-
...(
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
})
|
|
1995
|
-
: []),
|
|
2455
|
+
...firewallCommandsFactory({
|
|
2456
|
+
role: state.role,
|
|
2457
|
+
interfaceName,
|
|
2458
|
+
listenPort: state.listenPort,
|
|
2459
|
+
tunnelCidr: tunnelNetworkCidrFactory(state.address),
|
|
2460
|
+
sshForwardPort: state.sshForwardPort,
|
|
2461
|
+
remove: true,
|
|
2462
|
+
}),
|
|
1996
2463
|
],
|
|
1997
2464
|
options.dryRun,
|
|
1998
2465
|
);
|
|
1999
|
-
logger.info('Edge host state removed; key pair and
|
|
2466
|
+
logger.info('Edge host state removed; key pair, topology and node identity retained', {
|
|
2000
2467
|
interfaceName,
|
|
2001
|
-
role: state.role
|
|
2002
|
-
firewallWithdrawn:
|
|
2468
|
+
role: state.role,
|
|
2469
|
+
firewallWithdrawn: true,
|
|
2003
2470
|
});
|
|
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
2471
|
},
|
|
2009
2472
|
|
|
2010
2473
|
/**
|
|
@@ -2020,7 +2483,7 @@ class UnderpostWireguard {
|
|
|
2020
2483
|
* @memberof UnderpostWireguard
|
|
2021
2484
|
*/
|
|
2022
2485
|
reinstall(options = {}) {
|
|
2023
|
-
const state =
|
|
2486
|
+
const state = readEdgeContext();
|
|
2024
2487
|
const interfaceName = `${options.interface || state.interfaceName}`.trim();
|
|
2025
2488
|
UnderpostWireguard.API.reset({ ...options, interface: interfaceName });
|
|
2026
2489
|
runHostCommands(
|
|
@@ -2035,17 +2498,305 @@ class UnderpostWireguard {
|
|
|
2035
2498
|
logger.warn('Re-keyed: this machine now presents a new identity, and the far end still expects the old one', {
|
|
2036
2499
|
interfaceName,
|
|
2037
2500
|
publicKey: next.publicKey,
|
|
2038
|
-
|
|
2039
|
-
next.role === '
|
|
2040
|
-
?
|
|
2041
|
-
: '
|
|
2042
|
-
onTheHub:
|
|
2043
|
-
next.role === 'client'
|
|
2044
|
-
? `--peer-add <this spoke id> --peer-ip ${next.address} --public-key '${next.publicKey}'`
|
|
2045
|
-
: 'none',
|
|
2501
|
+
topology:
|
|
2502
|
+
next.role === 'hub'
|
|
2503
|
+
? `hub ${next.hubHost} now has publicKey '${next.publicKey}'`
|
|
2504
|
+
: `peer ${next.peerId} on hub ${next.hubHost} now has publicKey '${next.publicKey}'`,
|
|
2046
2505
|
});
|
|
2047
2506
|
},
|
|
2048
2507
|
|
|
2508
|
+
/**
|
|
2509
|
+
* @method syncTargets
|
|
2510
|
+
* @description Every node the engine is deployed on, with the identity that
|
|
2511
|
+
* reaches it.
|
|
2512
|
+
*
|
|
2513
|
+
* Both registries are joined through the same resolvers remediation uses,
|
|
2514
|
+
* so a node the engine can be synced on is exactly a node it can be repaired
|
|
2515
|
+
* on — a second host list here would be free to disagree with that.
|
|
2516
|
+
* @param {object} [options] - CLI options (`nodes`).
|
|
2517
|
+
* @returns {Array<object>} Resolved targets, hubs first.
|
|
2518
|
+
* @throws {Error} When a selector matches no registered node.
|
|
2519
|
+
* @memberof UnderpostWireguard
|
|
2520
|
+
*/
|
|
2521
|
+
syncTargets(options = {}) {
|
|
2522
|
+
const selectors = parseList(options.nodes);
|
|
2523
|
+
const matched = new Set();
|
|
2524
|
+
const selected = (nodeName, id) => {
|
|
2525
|
+
const hit = selectors.find((selector) => selector === nodeName || selector === id);
|
|
2526
|
+
if (hit) matched.add(hit);
|
|
2527
|
+
return selectors.length === 0 || Boolean(hit);
|
|
2528
|
+
};
|
|
2529
|
+
|
|
2530
|
+
const targets = [
|
|
2531
|
+
...Underpost.event
|
|
2532
|
+
.hubs()
|
|
2533
|
+
.filter((hub) => selected(hub.nodeName, hub.hubHost))
|
|
2534
|
+
.map((hub) => ({ ...Underpost.event.hubTarget(hub.hubHost), nodeName: hub.nodeName || hub.hubHost })),
|
|
2535
|
+
...Underpost.event
|
|
2536
|
+
.spokes()
|
|
2537
|
+
.filter((spoke) => selected(spoke.nodeName, spoke.id))
|
|
2538
|
+
.map((spoke) => ({ ...Underpost.event.spokeTarget(spoke.id), nodeName: spoke.nodeName || spoke.id })),
|
|
2539
|
+
];
|
|
2540
|
+
|
|
2541
|
+
const unknown = selectors.filter((selector) => !matched.has(selector));
|
|
2542
|
+
if (unknown.length > 0) throw new Error(`[wireguard] no registered node matches: ${unknown.join(', ')}`);
|
|
2543
|
+
return targets;
|
|
2544
|
+
},
|
|
2545
|
+
|
|
2546
|
+
/**
|
|
2547
|
+
* @method connectUri
|
|
2548
|
+
* @description The SSH command that reaches each selected node, built from
|
|
2549
|
+
* the same node-to-identity join every fleet action uses.
|
|
2550
|
+
*
|
|
2551
|
+
* A node is named by its document (`deploy/nodes/<node-name>.json`), and the
|
|
2552
|
+
* address that document resolves to — a hub's public host, a spoke's
|
|
2553
|
+
* management host — is the key `conf.users.json` is registered under, so the
|
|
2554
|
+
* URI is the connection the cluster already holds rather than one composed
|
|
2555
|
+
* by hand. A node that is this machine resolves to no URI: there is no hop
|
|
2556
|
+
* to make.
|
|
2557
|
+
* @param {object} [options] - CLI options (`nodes`).
|
|
2558
|
+
* @returns {Array<{nodeName: string, via: string, uri: string}>} One entry per selected node, hubs first.
|
|
2559
|
+
* @throws {Error} When no node is registered, or a selector matches none.
|
|
2560
|
+
* @memberof UnderpostWireguard
|
|
2561
|
+
*/
|
|
2562
|
+
connectUri(options = {}) {
|
|
2563
|
+
const targets = Underpost.wireguard.syncTargets(options);
|
|
2564
|
+
if (targets.length === 0) throw new Error(`[wireguard] no node is registered in ${EDGE_TOPOLOGY_PATH}`);
|
|
2565
|
+
return targets.map((target) => ({
|
|
2566
|
+
nodeName: target.nodeName,
|
|
2567
|
+
via: target.via,
|
|
2568
|
+
uri: sshUriFactory(target),
|
|
2569
|
+
}));
|
|
2570
|
+
},
|
|
2571
|
+
|
|
2572
|
+
/**
|
|
2573
|
+
* @method nodeExporter
|
|
2574
|
+
* @description Provisions the host metrics collector on the nodes the
|
|
2575
|
+
* cluster cannot schedule it onto.
|
|
2576
|
+
*
|
|
2577
|
+
* Only hubs: every cluster node already runs the collector as a DaemonSet
|
|
2578
|
+
* pod, so a second copy there would bind a port the pod holds. The
|
|
2579
|
+
* collector listens on the node's tunnel address, which is the address
|
|
2580
|
+
* Prometheus already scrapes it at.
|
|
2581
|
+
* @param {object} [options] - CLI options (`nodes`, `dryRun`).
|
|
2582
|
+
* @returns {Promise<{ok: boolean, nodes: Array<object>}>} Per-node outcome.
|
|
2583
|
+
* @memberof UnderpostWireguard
|
|
2584
|
+
*/
|
|
2585
|
+
async nodeExporter(options = {}) {
|
|
2586
|
+
const targets = Underpost.wireguard.syncTargets(options).filter((target) => target.role === 'hub');
|
|
2587
|
+
if (targets.length === 0)
|
|
2588
|
+
throw new Error(
|
|
2589
|
+
`[wireguard] no hub node ${
|
|
2590
|
+
parseList(options.nodes).length > 0 ? 'matches --nodes' : `is registered in ${EDGE_TOPOLOGY_PATH}`
|
|
2591
|
+
}; cluster nodes run the collector as a DaemonSet, so only hubs are provisioned here`,
|
|
2592
|
+
);
|
|
2593
|
+
const nodes = [];
|
|
2594
|
+
|
|
2595
|
+
for (const target of targets) {
|
|
2596
|
+
logger.info('Provisioning host metrics collector', { node: target.nodeName, address: target.address });
|
|
2597
|
+
const result = await Underpost.event.runCommand(
|
|
2598
|
+
nodeExporterServiceScriptFactory({ host: target.address, interfaceName: options.interface || 'wg0' }),
|
|
2599
|
+
{ ...options, user: target.user, host: target.host },
|
|
2600
|
+
);
|
|
2601
|
+
if (!result.ok)
|
|
2602
|
+
logger.error('Collector provisioning failed', {
|
|
2603
|
+
node: target.nodeName,
|
|
2604
|
+
output: `${result.error || result.output || ''}`.trim().slice(-1500),
|
|
2605
|
+
});
|
|
2606
|
+
nodes.push({ nodeName: target.nodeName, via: target.via, ok: result.ok });
|
|
2607
|
+
}
|
|
2608
|
+
|
|
2609
|
+
return { ok: nodes.every((node) => node.ok), nodes };
|
|
2610
|
+
},
|
|
2611
|
+
|
|
2612
|
+
/**
|
|
2613
|
+
* @method localRole
|
|
2614
|
+
* @description This machine's node role, or `''` when it is not a fleet node.
|
|
2615
|
+
*
|
|
2616
|
+
* The empty answer is the meaningful one: a workstation or CI runner has no node document, and
|
|
2617
|
+
* is not a node whose capabilities can be checked. Callers gate on a role when there is one and
|
|
2618
|
+
* leave an off-fleet machine alone, so authorization follows where an operation executes rather
|
|
2619
|
+
* than which module it lives in.
|
|
2620
|
+
* @returns {string} `hub`, `control`, `worker`, or `''`.
|
|
2621
|
+
* @memberof UnderpostWireguard
|
|
2622
|
+
*/
|
|
2623
|
+
localRole() {
|
|
2624
|
+
try {
|
|
2625
|
+
return readEdgeContext().role || '';
|
|
2626
|
+
} catch {
|
|
2627
|
+
return '';
|
|
2628
|
+
}
|
|
2629
|
+
},
|
|
2630
|
+
|
|
2631
|
+
/**
|
|
2632
|
+
* @method syncCommands
|
|
2633
|
+
* @description The sync sequence, bound to the repositories it pulls from.
|
|
2634
|
+
*
|
|
2635
|
+
* `--repo-engine` accepts `owner/repo` or a clone URL and defaults to the
|
|
2636
|
+
* configured GitHub account's `engine`. The private repository is derived
|
|
2637
|
+
* from the conf id the two share rather than named separately, so syncing a
|
|
2638
|
+
* node onto `engine-test-lampp` brings `engine-lampp-private` with it —
|
|
2639
|
+
* the pair {@link UnderpostRun.pull} resolves, resolved the same way.
|
|
2640
|
+
* `--repo-engine-private` overrides that derivation with its own
|
|
2641
|
+
* `owner/repo` or clone URL.
|
|
2642
|
+
*
|
|
2643
|
+
* `--cmd` replaces the whole sequence with the given comma-separated
|
|
2644
|
+
* commands, each running as a fail-fast step, so `--sync --cmd 'uptime'` is
|
|
2645
|
+
* a fleet-wide shell over the same SSH identity rather than a sync.
|
|
2646
|
+
*
|
|
2647
|
+
* Both default branches are resolved here, once, and named explicitly in the
|
|
2648
|
+
* commands. The node is about to replace the checkout it is running from, so
|
|
2649
|
+
* asking it to work out which branch to fetch would depend on the very
|
|
2650
|
+
* tooling the step exists to renew: a node carrying an older `underpost`
|
|
2651
|
+
* resolves it differently, or not at all, and fetches a ref that does not
|
|
2652
|
+
* exist — after origin has already been repointed.
|
|
2653
|
+
* @param {object} [options] - CLI options (`cmd`, `repoEngine`, `repoEnginePrivate`), plus the
|
|
2654
|
+
* `nodeRole` of the node the steps are for.
|
|
2655
|
+
* @returns {Array<{command: string, halt: boolean}>} Steps in execution order.
|
|
2656
|
+
* @memberof UnderpostWireguard
|
|
2657
|
+
*/
|
|
2658
|
+
syncCommands(options = {}) {
|
|
2659
|
+
const custom = parseList(options.cmd);
|
|
2660
|
+
if (custom.length > 0) return custom.map((command) => ({ command, halt: true }));
|
|
2661
|
+
const { engine, enginePrivate } = Underpost.repo.enginePairFactory({
|
|
2662
|
+
engine: options.repoEngine,
|
|
2663
|
+
enginePrivate: options.repoEnginePrivate,
|
|
2664
|
+
});
|
|
2665
|
+
const values = {
|
|
2666
|
+
'<engine-private-branch>': Underpost.repo.getDefaultBranch(enginePrivate),
|
|
2667
|
+
'<engine-private>': enginePrivate,
|
|
2668
|
+
'<engine-branch>': Underpost.repo.getDefaultBranch(engine),
|
|
2669
|
+
'<engine>': engine,
|
|
2670
|
+
'<package-install-command>': packageInstallCommandFactory(engine),
|
|
2671
|
+
...nodeRoleStepsFactory(options.nodeRole),
|
|
2672
|
+
'<github-token>': process.env.GITHUB_TOKEN || '',
|
|
2673
|
+
};
|
|
2674
|
+
const entries = Object.entries(values);
|
|
2675
|
+
return ENGINE_SYNC_STEPS.filter(({ command }) =>
|
|
2676
|
+
entries.every(([placeholder, value]) => value || !command.includes(placeholder)),
|
|
2677
|
+
).map((step) => ({
|
|
2678
|
+
...step,
|
|
2679
|
+
command: entries.reduce(
|
|
2680
|
+
(command, [placeholder, value]) => command.replaceAll(placeholder, value),
|
|
2681
|
+
step.command,
|
|
2682
|
+
),
|
|
2683
|
+
}));
|
|
2684
|
+
},
|
|
2685
|
+
|
|
2686
|
+
/**
|
|
2687
|
+
* @method syncScript
|
|
2688
|
+
* @description The whole sequence as one command.
|
|
2689
|
+
*
|
|
2690
|
+
* A node is reached once, not once per step: each SSH session re-reads the
|
|
2691
|
+
* credential store, re-authenticates and re-enters the checkout, so running
|
|
2692
|
+
* six of them per host is six times the handshake for one unit of work — and
|
|
2693
|
+
* a step could land on a different session than the one before it. `&&`
|
|
2694
|
+
* carries the halt order; the advisory step is neutralized in place so a
|
|
2695
|
+
* remaining audit finding cannot stop the install behind it.
|
|
2696
|
+
* When `--cmd` is set the label becomes `[cmd]` so a fleet run reads as what
|
|
2697
|
+
* it actually executed rather than a sync.
|
|
2698
|
+
* @param {object} [options] - CLI options (`cmd`, `repoEngine`, `repoEnginePrivate`), plus the
|
|
2699
|
+
* `nodeRole` of the node the script is for.
|
|
2700
|
+
* @returns {string} Composed remote command.
|
|
2701
|
+
* @memberof UnderpostWireguard
|
|
2702
|
+
*/
|
|
2703
|
+
syncScript(options = {}) {
|
|
2704
|
+
const commands = Underpost.wireguard.syncCommands(options);
|
|
2705
|
+
const label = parseList(options.cmd).length > 0 ? 'cmd' : 'sync';
|
|
2706
|
+
return commands
|
|
2707
|
+
.map(
|
|
2708
|
+
({ command, halt }) =>
|
|
2709
|
+
`echo '[${label}] ${redactSensitiveText(command)}' && ${halt ? command : `{ ${command} || true; }`}`,
|
|
2710
|
+
)
|
|
2711
|
+
.join(' && ');
|
|
2712
|
+
},
|
|
2713
|
+
|
|
2714
|
+
/**
|
|
2715
|
+
* @method sync
|
|
2716
|
+
* @description Brings every node's engine checkout to the current sources,
|
|
2717
|
+
* over the SSH identity registered for it.
|
|
2718
|
+
*
|
|
2719
|
+
* One node failing does not stop the others: they are independent hosts, and
|
|
2720
|
+
* a partially synced fleet is reported rather than hidden. Each node is one
|
|
2721
|
+
* session, and the `[sync]` line it last echoed names the step it stopped at. The dispatcher
|
|
2722
|
+
* step each node receives is the one its own role calls for.
|
|
2723
|
+
* @param {object} [options] - CLI options (`cmd`, `nodes`, `repoEngine`, `repoEnginePrivate`, `dryRun`).
|
|
2724
|
+
* @returns {Promise<{ok: boolean, nodes: Array<object>}>} Per-node outcome.
|
|
2725
|
+
* @memberof UnderpostWireguard
|
|
2726
|
+
*/
|
|
2727
|
+
async sync(options = {}) {
|
|
2728
|
+
// The engine repositories are private, and their credentials live in the
|
|
2729
|
+
// cron deploy environment rather than the host's.
|
|
2730
|
+
loadCronDeployEnv();
|
|
2731
|
+
const allTargets = Underpost.wireguard.syncTargets(options);
|
|
2732
|
+
if (allTargets.length === 0) throw new Error(`[wireguard] no node is registered in ${EDGE_TOPOLOGY_PATH}`);
|
|
2733
|
+
|
|
2734
|
+
// This sequence repoints origin and hard-resets the checkout it runs in, so the machine
|
|
2735
|
+
// driving it is the source and never a destination. Which machine that is, is decided
|
|
2736
|
+
// under the hub by the address a peer is registered at — `managementHost` is unique,
|
|
2737
|
+
// where a hostname is not: the control plane and a workstation are both
|
|
2738
|
+
// `localhost.localdomain`, and matching on that name would aim the switch at whichever
|
|
2739
|
+
// of them happened to run the command. Dispatch is SSH-only for the same reason.
|
|
2740
|
+
const addresses = hostAddressesFactory();
|
|
2741
|
+
const runsHere = (target) => !target.user || addresses.has(`${target.host || ''}`.trim());
|
|
2742
|
+
const targets = allTargets.filter((target) => !runsHere(target));
|
|
2743
|
+
for (const target of allTargets.filter(runsHere))
|
|
2744
|
+
logger.warn('Skipping the node this command runs from; it is the sync source, not a destination', {
|
|
2745
|
+
node: target.nodeName,
|
|
2746
|
+
via: target.via,
|
|
2747
|
+
hint: 'update this checkout with git, or run the sync from another node',
|
|
2748
|
+
});
|
|
2749
|
+
if (targets.length === 0)
|
|
2750
|
+
throw new Error(
|
|
2751
|
+
`[wireguard] every selected node resolves to this machine, which is the sync source; ` +
|
|
2752
|
+
`nothing would be synced.`,
|
|
2753
|
+
);
|
|
2754
|
+
|
|
2755
|
+
const custom = parseList(options.cmd).length > 0;
|
|
2756
|
+
if (!custom && options.dryRun !== true) {
|
|
2757
|
+
const { engine } = Underpost.repo.enginePairFactory({
|
|
2758
|
+
engine: options.repoEngine,
|
|
2759
|
+
enginePrivate: options.repoEnginePrivate,
|
|
2760
|
+
});
|
|
2761
|
+
const deployId = deployIdFactory(Underpost.repo.confIdFactory(engine));
|
|
2762
|
+
if (deployId) {
|
|
2763
|
+
await syncDeployPackages({ deployIds: [deployId] });
|
|
2764
|
+
const { privateConfPaths } = await loadDeployCatalog(deployId);
|
|
2765
|
+
syncPrivateConf(deployId, privateConfPaths);
|
|
2766
|
+
}
|
|
2767
|
+
}
|
|
2768
|
+
|
|
2769
|
+
// One script per role, not per node: the branches it names are resolved against the remotes,
|
|
2770
|
+
// and every node of a role is sent the same sequence.
|
|
2771
|
+
const scripts = new Map();
|
|
2772
|
+
const scriptFor = (nodeRole) => {
|
|
2773
|
+
if (!scripts.has(nodeRole)) scripts.set(nodeRole, Underpost.wireguard.syncScript({ ...options, nodeRole }));
|
|
2774
|
+
return scripts.get(nodeRole);
|
|
2775
|
+
};
|
|
2776
|
+
const nodes = [];
|
|
2777
|
+
|
|
2778
|
+
for (const target of targets) {
|
|
2779
|
+
logger.info(custom ? 'Running custom commands' : 'Syncing engine checkout', {
|
|
2780
|
+
node: target.nodeName,
|
|
2781
|
+
via: target.via,
|
|
2782
|
+
});
|
|
2783
|
+
const result = await Underpost.event.runCommand(scriptFor(targetRoleFactory(target)), {
|
|
2784
|
+
...options,
|
|
2785
|
+
user: target.user,
|
|
2786
|
+
host: target.host,
|
|
2787
|
+
requireRemote: true,
|
|
2788
|
+
});
|
|
2789
|
+
if (!result.ok)
|
|
2790
|
+
logger.error('Sync failed', {
|
|
2791
|
+
node: target.nodeName,
|
|
2792
|
+
output: `${result.error || result.output || ''}`.trim().slice(-1500),
|
|
2793
|
+
});
|
|
2794
|
+
nodes.push({ nodeName: target.nodeName, via: target.via, ok: result.ok });
|
|
2795
|
+
}
|
|
2796
|
+
|
|
2797
|
+
return { ok: nodes.every((node) => node.ok), nodes };
|
|
2798
|
+
},
|
|
2799
|
+
|
|
2049
2800
|
/**
|
|
2050
2801
|
* @method callback
|
|
2051
2802
|
* @description CLI entry point for both `underpost wireguard` and
|
|
@@ -2053,7 +2804,7 @@ class UnderpostWireguard {
|
|
|
2053
2804
|
*
|
|
2054
2805
|
* Flags are evaluated in lifecycle order — install, setup, peer changes,
|
|
2055
2806
|
* route publication, then daemon control — so a single invocation can carry
|
|
2056
|
-
* a whole bring-up (`--wireguard-install --wireguard-setup
|
|
2807
|
+
* a whole bring-up (`--node-config --wireguard-install --wireguard-setup
|
|
2057
2808
|
* --haproxy-setup --wireguard-start`) and still execute the steps in the only
|
|
2058
2809
|
* order that works. `--status` runs last, so it reports what the run left
|
|
2059
2810
|
* behind.
|
|
@@ -2062,6 +2813,28 @@ class UnderpostWireguard {
|
|
|
2062
2813
|
* @memberof UnderpostWireguard
|
|
2063
2814
|
*/
|
|
2064
2815
|
async callback(options = {}) {
|
|
2816
|
+
// Fleet-wide and identity-independent: they reach other machines rather
|
|
2817
|
+
// than reconciling this one, so they never fall through to a host action.
|
|
2818
|
+
// `--cmd` alone is a fleet run too; given with `--sync` it replaces the
|
|
2819
|
+
// sync steps inside the same `sync()` runner, so one dispatch covers both.
|
|
2820
|
+
if (options.sync === true || options.cmd) return reportFleetOutcome(await UnderpostWireguard.API.sync(options));
|
|
2821
|
+
if (options.nodeExporter === true) return reportFleetOutcome(await UnderpostWireguard.API.nodeExporter(options));
|
|
2822
|
+
if (options.connectUri === true) {
|
|
2823
|
+
const entries = UnderpostWireguard.API.connectUri(options);
|
|
2824
|
+
// One node prints a pasteable command and nothing else; a fleet listing
|
|
2825
|
+
// names the node each command belongs to.
|
|
2826
|
+
const output = entries
|
|
2827
|
+
.map(({ nodeName, uri, via }) => {
|
|
2828
|
+
const value = uri || `# ${nodeName} is this machine (${via})`;
|
|
2829
|
+
return entries.length === 1 ? value : `${nodeName.padEnd(28)} ${value}`;
|
|
2830
|
+
})
|
|
2831
|
+
.join('\n');
|
|
2832
|
+
if (options.copy === true) pbcopy(output);
|
|
2833
|
+
else console.log(output);
|
|
2834
|
+
return;
|
|
2835
|
+
}
|
|
2836
|
+
|
|
2837
|
+
if (options.nodeConfig === true) UnderpostWireguard.API.nodeConfig(options);
|
|
2065
2838
|
// `--build-conf` is a hard promise, not a modifier: it short-circuits
|
|
2066
2839
|
// every host action so the run cannot touch /etc, iptables, systemd or a
|
|
2067
2840
|
// live interface even when other lifecycle flags are also present.
|
|
@@ -2075,6 +2848,24 @@ class UnderpostWireguard {
|
|
|
2075
2848
|
return;
|
|
2076
2849
|
}
|
|
2077
2850
|
|
|
2851
|
+
const hostActions = [
|
|
2852
|
+
options.wireguardInstall,
|
|
2853
|
+
options.wireguardSetup,
|
|
2854
|
+
options.peerAdd,
|
|
2855
|
+
options.peerRemove,
|
|
2856
|
+
options.haproxySetup,
|
|
2857
|
+
options.haproxySync,
|
|
2858
|
+
options.wireguardStop,
|
|
2859
|
+
options.wireguardStart,
|
|
2860
|
+
options.wireguardRestart,
|
|
2861
|
+
options.forwardProxyServer,
|
|
2862
|
+
options.status,
|
|
2863
|
+
options.check,
|
|
2864
|
+
options.wireguardReset,
|
|
2865
|
+
options.wireguardReinstall,
|
|
2866
|
+
];
|
|
2867
|
+
if (options.nodeConfig === true && !hostActions.some(Boolean)) return;
|
|
2868
|
+
|
|
2078
2869
|
if (options.wireguardReinstall === true) return void UnderpostWireguard.API.reinstall(options);
|
|
2079
2870
|
if (options.wireguardReset === true) return void UnderpostWireguard.API.reset(options);
|
|
2080
2871
|
if (options.wireguardInstall === true) UnderpostWireguard.API.install(options);
|
|
@@ -2085,41 +2876,56 @@ class UnderpostWireguard {
|
|
|
2085
2876
|
else if (options.haproxySync === true) UnderpostWireguard.API.haproxySync(options);
|
|
2086
2877
|
if (options.wireguardStop === true) UnderpostWireguard.API.stop(options);
|
|
2087
2878
|
if (options.wireguardStart === true) UnderpostWireguard.API.start(options);
|
|
2879
|
+
if (options.wireguardRestart === true) UnderpostWireguard.API.restart(options);
|
|
2088
2880
|
// After the tunnel, because the service requires it and its address only
|
|
2089
2881
|
// exists once the interface is up; before `--status`, so a run that
|
|
2090
2882
|
// reconciles the service also reports it.
|
|
2091
2883
|
if (options.forwardProxyServer === true) UnderpostWireguard.API.forwardProxyServer(options);
|
|
2884
|
+
if (options.check === true) UnderpostWireguard.API.check(options);
|
|
2092
2885
|
if (options.status === true) UnderpostWireguard.API.status(options);
|
|
2093
2886
|
},
|
|
2094
2887
|
};
|
|
2095
2888
|
}
|
|
2096
2889
|
|
|
2097
2890
|
export {
|
|
2098
|
-
|
|
2891
|
+
EDGE_TOPOLOGY_PATH,
|
|
2892
|
+
ENGINE_SYNC_STEPS,
|
|
2099
2893
|
UNDERPOST_EDGE,
|
|
2100
2894
|
allowedIpsConflictsFactory,
|
|
2895
|
+
assertEdgeIdentity,
|
|
2101
2896
|
backendNameFactory,
|
|
2102
2897
|
defaultPeerFactory,
|
|
2103
2898
|
deployListFactory,
|
|
2104
2899
|
edgeRouteTableFactory,
|
|
2105
|
-
|
|
2900
|
+
edgeContextFactory,
|
|
2901
|
+
endpointHostFactory,
|
|
2106
2902
|
firewallCommandsFactory,
|
|
2107
2903
|
haproxyConfFactory,
|
|
2108
2904
|
haproxyMapsFactory,
|
|
2109
2905
|
hostProxyEntriesFactory,
|
|
2906
|
+
hostAddressesFactory,
|
|
2907
|
+
hubTunnelAddressFactory,
|
|
2110
2908
|
instanceProxyEntriesFactory,
|
|
2909
|
+
localPeerFactory,
|
|
2111
2910
|
mergeRouteTablesFactory,
|
|
2112
2911
|
peerFactory,
|
|
2113
2912
|
quicForwardCommandsFactory,
|
|
2114
|
-
|
|
2913
|
+
readEdgeContext,
|
|
2914
|
+
readNodeConfigs,
|
|
2915
|
+
readTopology,
|
|
2115
2916
|
redirectHostFactory,
|
|
2917
|
+
sshUriFactory,
|
|
2918
|
+
unregisteredPeersFactory,
|
|
2116
2919
|
tunnelAddressFactory,
|
|
2117
2920
|
tunnelNetworkCidrFactory,
|
|
2118
2921
|
wireguardClientConfFactory,
|
|
2119
|
-
|
|
2922
|
+
wireguardHealthFactory,
|
|
2120
2923
|
wireguardServerConfFactory,
|
|
2121
2924
|
wireguardStatusFactory,
|
|
2122
|
-
|
|
2925
|
+
topologyFactory,
|
|
2926
|
+
hubFactory,
|
|
2927
|
+
nodeIdentityFactory,
|
|
2928
|
+
nodeNameCandidatesFactory,
|
|
2123
2929
|
};
|
|
2124
2930
|
|
|
2125
2931
|
export default UnderpostWireguard;
|