@underpostnet/cyberia 3.2.90 → 3.3.73
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.dockerignore +1 -0
- package/.env.example +16 -0
- package/.github/workflows/coverall.cyberia.ci.yml +107 -0
- package/.github/workflows/cyberia-client.cd.yml +6 -18
- package/.github/workflows/cyberia-server.cd.yml +6 -18
- 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 +6 -78
- package/.github/workflows/engine-cyberia.ci.yml +4 -5
- package/.github/workflows/ghpkg.ci.yml +42 -21
- package/.github/workflows/hardhat.ci.yml +12 -11
- package/.github/workflows/npmpkg.ci.yml +17 -15
- package/.github/workflows/pwa-microservices-template-page.cd.yml +1 -16
- package/.github/workflows/pwa-microservices-template-test.ci.yml +2 -2
- package/.github/workflows/release.cd.yml +2 -9
- package/.prettierignore +1 -0
- package/AGENTS.md +47 -0
- package/CHANGELOG.md +581 -2417
- package/CLI-HELP.md +416 -127
- package/Dockerfile +25 -9
- package/Dockerfile.dev +25 -9
- package/Dockerfile.test +4 -4
- package/README.md +21 -1
- package/bin/build.js +70 -43
- package/bin/build.template.js +8 -3
- package/bin/cyberia.js +372 -135
- package/bin/deploy.js +18 -104
- package/bin/index.js +372 -135
- package/compose.env +16 -0
- package/conf.js +93 -4
- package/deploy/cyberia-client/deploy.sh +47 -0
- package/deploy/cyberia-client/package.sh +18 -0
- package/deploy/cyberia-client/state.sh +14 -0
- package/deploy/cyberia-server/deploy.sh +47 -0
- package/deploy/cyberia-server/package.sh +18 -0
- package/deploy/cyberia-server/state.sh +14 -0
- package/deploy/dd-cyberia/deploy.sh +22 -0
- package/deploy/dd-cyberia/init.sh +55 -0
- package/deploy/dd-cyberia/package.sh +17 -0
- package/deploy/dd-cyberia/state.sh +14 -0
- package/deploy/dd-cyberia/sync-deploy.sh +116 -0
- package/deploy/lib/github-actions-logging.sh +594 -0
- package/deploy/lib/host.sh +185 -0
- package/deploy/lib/state.sh +62 -0
- package/deploy/pwa-microservices-template/deploy.sh +45 -0
- package/deploy/release/deploy.sh +32 -0
- package/deployment.yaml +7 -2
- package/docker-compose.yml +31 -37
- 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 +104 -43
- package/hardhat/package.json +3 -3
- package/manifests/alertmanager/deployment.yaml +81 -0
- package/manifests/blackbox-exporter/deployment.yaml +75 -0
- package/manifests/cronjobs/dd-cron/dd-cron-backup.yaml +36 -7
- package/manifests/cronjobs/dd-cron/dd-cron-vultr.yaml +77 -0
- package/manifests/deployment/dd-cyberia-development/deployment.yaml +7 -2
- package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
- 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 +83 -10
- package/scripts/audit-selinux.sh +64 -0
- package/scripts/event-monitor.sh +56 -0
- package/scripts/gpu-diag.sh +0 -0
- package/scripts/ip-info.sh +0 -0
- package/scripts/k3s-node-setup.sh +30 -14
- package/scripts/kubeadm-node-setup.sh +40 -31
- package/scripts/lxd-vm-setup.sh +0 -0
- package/scripts/maas-nat-firewalld.sh +0 -0
- package/scripts/maas-setup.sh +8 -8
- package/scripts/nat-iptables.sh +2 -0
- package/scripts/rhel-grpc-setup.sh +0 -0
- package/scripts/rocky-kickstart.sh +25 -9
- package/scripts/selinux-normalize.sh +907 -0
- package/scripts/shutdown-machine.sh +843 -0
- package/scripts/test-monitor.sh +3 -3
- package/src/api/atlas-sprite-sheet/atlas-sprite-sheet.controller.js +1 -1
- package/src/api/atlas-sprite-sheet/atlas-sprite-sheet.router.js +2 -2
- package/src/api/atlas-sprite-sheet/atlas-sprite-sheet.service.js +2 -2
- 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 +1 -1
- package/src/api/cyberia-action/cyberia-action.service.js +2 -2
- package/src/api/cyberia-client-hints/cyberia-client-hints.controller.js +1 -1
- package/src/api/cyberia-client-hints/cyberia-client-hints.router.js +1 -1
- package/src/api/cyberia-client-hints/cyberia-client-hints.service.js +2 -2
- package/src/api/cyberia-dialogue/cyberia-dialogue.controller.js +1 -1
- package/src/api/cyberia-dialogue/cyberia-dialogue.router.js +1 -1
- 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.router.js +1 -1
- package/src/api/cyberia-entity/cyberia-entity.service.js +2 -2
- package/src/api/cyberia-entity-type-default/cyberia-entity-type-default.controller.js +1 -1
- package/src/api/cyberia-entity-type-default/cyberia-entity-type-default.router.js +1 -1
- package/src/api/cyberia-entity-type-default/cyberia-entity-type-default.service.js +2 -2
- package/src/api/cyberia-instance/cyberia-fallback-capture.js +466 -0
- package/src/api/cyberia-instance/cyberia-fallback-default-items.js +63 -0
- package/src/api/cyberia-instance/cyberia-fallback-world.js +16 -1
- package/src/api/cyberia-instance/cyberia-instance-map.service.js +1 -1
- package/src/api/cyberia-instance/cyberia-instance.controller.js +4 -1
- package/src/api/cyberia-instance/cyberia-instance.router.js +15 -2
- package/src/api/cyberia-instance/cyberia-instance.service.js +45 -2
- package/src/api/cyberia-instance-conf/cyberia-instance-conf.controller.js +1 -1
- package/src/api/cyberia-instance-conf/cyberia-instance-conf.model.js +1 -0
- package/src/api/cyberia-instance-conf/cyberia-instance-conf.router.js +1 -1
- package/src/api/cyberia-instance-conf/cyberia-instance-conf.service.js +2 -2
- 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 +2 -2
- package/src/api/cyberia-quest/cyberia-quest.controller.js +1 -1
- package/src/api/cyberia-quest/cyberia-quest.router.js +1 -1
- package/src/api/cyberia-quest/cyberia-quest.service.js +2 -2
- package/src/api/cyberia-quest-progress/cyberia-quest-progress.controller.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 +16 -29
- package/src/api/cyberia-skill/cyberia-skill.controller.js +1 -1
- 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.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 +204 -3
- package/src/api/object-layer/object-layer.router.js +2 -2
- package/src/api/object-layer/object-layer.service.js +4 -17
- 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 +391 -0
- package/src/cli/baremetal.js +30 -36
- package/src/cli/client.js +306 -0
- package/src/cli/cloud-init.js +3 -3
- package/src/cli/cluster.js +242 -135
- package/src/cli/db.js +126 -67
- package/src/cli/deploy.js +158 -60
- 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.js +3 -3
- package/src/cli/host.js +672 -0
- package/src/cli/image.js +4 -7
- package/src/cli/index.js +426 -134
- 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 +73 -34
- package/src/cli/repository.js +564 -367
- package/src/cli/run.js +362 -654
- package/src/cli/secrets.js +2158 -964
- package/src/cli/ssh.js +651 -331
- package/src/cli/state.js +394 -0
- package/src/cli/static.js +2 -2
- package/src/cli/system.js +26 -13
- package/src/cli/test.js +199 -125
- package/src/cli/vultr.js +623 -0
- package/src/cli/wireguard.js +2930 -0
- package/src/client/components/core/CalendarCore.js +53 -96
- package/src/client/components/core/Docs.js +258 -211
- package/src/client/components/core/FileExplorer.js +0 -21
- 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/FallbackWorldEngineCyberia.js +368 -0
- package/src/client/components/cyberia-portal/AppShellCyberiaPortal.js +35 -1
- package/src/client/components/cyberia-portal/RouterCyberiaPortal.js +4 -0
- package/src/client/components/cyberia-portal/SettingsCyberiaPortal.js +2 -2
- package/src/client/components/cyberia-portal/TranslateCyberiaPortal.js +4 -0
- 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 +17 -3
- package/src/client/public/cyberia-docs/CYBERIA-CLI.md +32 -5
- package/src/client/public/cyberia-docs/CYBERIA-SERVER.md +1 -1
- package/src/client/public/cyberia-docs/CYBERIA.md +21 -1
- 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/WHITE-PAPER.md +1 -1
- 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-instance/cyberia-instance.service.js +40 -0
- package/src/client/services/object-layer/object-layer.management.js +4 -4
- 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 +61 -46
- package/src/client-builder/client-build-live.js +2 -2
- package/src/client-builder/client-build.js +58 -44
- 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/MongooseDB.js +11 -8
- package/src/{server/valkey.js → db/valkey/Valkey.js} +5 -4
- package/src/grpc/cyberia/grpc-server.js +1 -1
- package/src/index.js +103 -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 +1 -1
- package/src/projects/cyberia/besu-genesis-generator.js +3 -3
- package/src/projects/cyberia/catalog-cyberia.js +54 -6
- package/src/projects/cyberia/gemini-client.js +1 -1
- package/src/projects/cyberia/generate-saga.js +15 -24
- package/src/projects/cyberia/hot-reload-trigger.js +1 -1
- package/src/projects/cyberia/instance-data.js +22 -3
- package/src/projects/cyberia/ipfs-client.js +4 -4
- package/src/projects/cyberia/map-preview-generator.js +1 -1
- package/src/projects/cyberia/object-layer.js +12 -22
- package/src/projects/cyberia/semantic-layer-generator.js +1 -1
- package/src/projects/underpost/catalog-underpost.js +13 -2
- package/src/proxy.js +4 -7
- package/src/runtime/cyberia-client/Dockerfile +9 -4
- package/src/runtime/cyberia-client/Dockerfile.dev +8 -2
- package/src/runtime/cyberia-server/Dockerfile +9 -4
- package/src/runtime/cyberia-server/Dockerfile.dev +8 -2
- package/src/runtime/engine-cyberia/Dockerfile +25 -9
- package/src/runtime/engine-cyberia/Dockerfile.dev +25 -9
- package/src/runtime/engine-cyberia/Dockerfile.test +4 -4
- package/src/runtime/engine-cyberia/compose.env +16 -0
- package/src/runtime/engine-cyberia/docker-compose.yml +31 -37
- 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 +117 -0
- package/src/server/build/execution.js +402 -0
- package/src/server/build/package.js +465 -0
- package/src/server/build/testing.js +528 -0
- package/src/server/{dns.js → network/dns.js} +241 -14
- package/src/server/network/forward-proxy.js +541 -0
- package/src/server/{middlewares.js → network/middlewares.js} +59 -4
- 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/network/underpost-compression.js +186 -0
- package/src/server/{underpost-gateway.js → network/underpost-gateway.js} +26 -16
- package/src/server/{underpost-ingress.js → network/underpost-ingress.js} +19 -3
- package/src/server/{cri.js → ops/cri.js} +2 -2
- package/src/server/ops/cron.js +949 -0
- package/src/server/ops/event-notification.js +284 -0
- package/src/server/{logger.js → ops/logger.js} +108 -23
- package/src/server/ops/monitoring.js +1724 -0
- package/src/server/ops/systemd.js +259 -0
- package/src/server/{conf.js → runtime/conf.js} +521 -520
- 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 -13
- 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/security/selinux.js +185 -0
- package/src/server/{backup.js → storage/backup.js} +17 -8
- package/src/server/{data-query.js → storage/data-query.js} +1 -1
- package/src/server/{downloader.js → storage/downloader.js} +2 -2
- package/src/server/storage/repository.js +68 -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/cyberia-cli-plain-reads.test.js +30 -0
- package/test/{cyberia-instance-conf-defaults.test.js → integration/app/cyberia/cyberia-instance-conf-defaults.test.js} +2 -1
- package/test/integration/app/cyberia/cyberia-load.test.js +467 -0
- package/test/integration/app/cyberia/fallback-world-capture.test.js +253 -0
- package/test/integration/app/cyberia/object-layer-item-id.test.js +327 -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/integration/infra/1-security/selinux.test.js +69 -0
- 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 +1163 -0
- package/test/integration/infra/2-network/wireguard-edge.test.js +1731 -0
- 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/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} +47 -6
- package/test/{underpost-ingress.test.js → integration/infra/4-ingress/underpost-ingress.test.js} +56 -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 +334 -0
- package/test/unit/conf-loading.test.js +303 -0
- package/test/unit/conf-resolution.test.js +979 -0
- package/test/unit/coverage-artifact.test.js +130 -0
- package/test/{crypto.test.js → unit/crypto.test.js} +1 -1
- package/test/unit/deploy-env-resolution.test.js +38 -0
- package/test/unit/deploy-log-table.test.js +447 -0
- package/test/unit/execution-profiles.test.js +219 -0
- package/test/unit/fleet-sync-source.test.js +149 -0
- package/test/unit/logger-redaction.test.js +112 -0
- package/test/unit/package.test.js +403 -0
- package/test/unit/prepare-host.test.js +299 -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 +193 -0
- package/vitest.config.js +58 -0
- package/.nycrc +0 -9
- package/manifests/cronjobs/dd-cron/dd-cron-dns.yaml +0 -48
- package/manifests/mongodb/pv-pvc.yaml +0 -59
- package/scripts/link-local-underpost-cli.sh +0 -6
- 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/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/cron.js +0 -476
package/src/cli/secrets.js
CHANGED
|
@@ -4,50 +4,63 @@
|
|
|
4
4
|
* @namespace UnderpostSecret
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import { shellExec } from '../server/process.js';
|
|
7
|
+
import { shellExec } from '../server/runtime/process.js';
|
|
8
|
+
import { generateRandomPasswordSelection } from '../client/components/core/CommonJs.js';
|
|
8
9
|
import fs from 'fs-extra';
|
|
9
10
|
import os from 'os';
|
|
10
11
|
import dotenv from 'dotenv';
|
|
11
12
|
import Underpost from '../index.js';
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
13
|
+
import { domainContextFactory } from './domains.js';
|
|
14
|
+
import { readDeployRoutes, resolveDeployList } from '../server/network/router.js';
|
|
15
|
+
import { loggerFactory } from '../server/ops/logger.js';
|
|
16
|
+
import { activeExecutionProfile } from '../server/build/execution.js';
|
|
17
|
+
import { scopeValuesFactory } from '../server/runtime/config-scope.js';
|
|
14
18
|
|
|
15
19
|
const logger = loggerFactory(import.meta);
|
|
16
20
|
|
|
17
|
-
// Git-tracked store of SOPS/Age encrypted Kubernetes Secret manifests, laid out as
|
|
18
|
-
// `<SOPS_SECRETS_DIR>/<namespace>/<name>.enc.yaml`. Lives inside the per-deploy private
|
|
19
|
-
// repo so encrypted manifests version alongside the conf they belong to. Only the
|
|
20
|
-
// ciphertext is committed; the Age private key never enters this tree.
|
|
21
21
|
const SOPS_SECRETS_DIR = './engine-private/secrets';
|
|
22
22
|
const SOPS_MANIFEST_EXT = '.enc.yaml';
|
|
23
|
-
// `creation_rules[].path_regex` is matched against the manifest path **relative to the directory
|
|
24
|
-
// holding .sops.yaml**, not the repo root. Since .sops.yaml lives at the store root, an
|
|
25
|
-
// `engine-private/secrets/` prefix here can never match — sops sees `<namespace>/<name>.enc.yaml`
|
|
26
|
-
// and reports "no matching creation rules found".
|
|
27
23
|
const SOPS_MANIFEST_PATH_REGEX = `.*${SOPS_MANIFEST_EXT.replace(/\./g, '\\.')}$`;
|
|
28
|
-
|
|
29
|
-
// reversible. Dot-prefixed so it is never mistaken for a namespace directory.
|
|
24
|
+
const SOPS_STAGE_DIR = './engine-private/secrets/.stage';
|
|
30
25
|
const SOPS_ARCHIVE_DIR = `${SOPS_SECRETS_DIR}/.archive`;
|
|
31
|
-
// Encrypting only the value tree under `data`/`stringData` keeps apiVersion/kind/metadata
|
|
32
|
-
// readable, so kubectl, kustomize, and code review still work on the encrypted file.
|
|
33
26
|
const SOPS_ENCRYPTED_REGEX = '^(data|stringData)$';
|
|
34
|
-
// Pinned SOPS/Age release binaries. Neither has an RHEL package, so both are installed
|
|
35
|
-
// from upstream static builds. Single source of truth for host provisioning: both
|
|
36
|
-
// `underpost secret --install-tools` and `underpost cluster --init-host` resolve here.
|
|
37
27
|
const SOPS_VERSION = 'v3.10.2';
|
|
38
28
|
const AGE_VERSION = 'v1.2.1';
|
|
39
|
-
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
//
|
|
50
|
-
//
|
|
29
|
+
|
|
30
|
+
// The GitHub-side credential CI workflows and cross-repository checkouts authenticate with. The
|
|
31
|
+
// stored manifest keeps this file's kebab-case naming, while the data key keeps the environment
|
|
32
|
+
// spelling so a workload consumes it through `envFrom` unchanged. Deliberately absent from
|
|
33
|
+
// MANAGED_SECRETS: its authoritative home is the GitHub Actions secret store, and the encrypted
|
|
34
|
+
// manifest is an optional mirror rather than the origin.
|
|
35
|
+
const GIT_AUTH_TOKEN_KEY = 'GIT_AUTH_TOKEN';
|
|
36
|
+
const GIT_AUTH_TOKEN_SECRET = 'git-auth-token';
|
|
37
|
+
const GIT_AUTH_TOKEN_STAGE_DIR = '/dev/shm/underpost-git-auth';
|
|
38
|
+
|
|
39
|
+
// The second meta id alongside `dd`. The template lineage is not a deploy, so it carries no conf
|
|
40
|
+
// id and resolves to its own repositories rather than through the `engine-<conf-id>` naming.
|
|
41
|
+
const TEMPLATE_ALIAS = 'template';
|
|
42
|
+
const TEMPLATE_REPOS = ['pwa-microservices-template', 'pwa-microservices-template-ghpkg', 'engine'];
|
|
43
|
+
// Plaintext staging lives on tmpfs and is created at its final mode rather than created and then
|
|
44
|
+
// chmod'ed: between the `mkdir`/`open` and the `chmod` the entry exists at the process umask
|
|
45
|
+
// (0755 / 0644), and that window is enough for any local user to read a credential. The mode
|
|
46
|
+
// argument is only masked by umask, never widened, so it is safe under any umask.
|
|
47
|
+
const STAGE_DIR_MODE = 0o700;
|
|
48
|
+
const STAGE_FILE_MODE = 0o600;
|
|
49
|
+
|
|
50
|
+
const stageDirSync = (path) => {
|
|
51
|
+
fs.mkdirSync(path, { recursive: true, mode: STAGE_DIR_MODE });
|
|
52
|
+
// A directory left behind by an interrupted run keeps whatever mode it had, so tighten it —
|
|
53
|
+
// guarded, because `mkdirSync` is a no-op for a path that already exists and the guard is what
|
|
54
|
+
// keeps this from throwing where the filesystem is not the real one.
|
|
55
|
+
if (fs.existsSync(path)) fs.chmodSync(path, STAGE_DIR_MODE);
|
|
56
|
+
return path;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const writeStageFileSync = (path, value) => {
|
|
60
|
+
fs.writeFileSync(path, value, { mode: STAGE_FILE_MODE });
|
|
61
|
+
return path;
|
|
62
|
+
};
|
|
63
|
+
|
|
51
64
|
const MANAGED_SECRETS = [
|
|
52
65
|
'postgres-secret',
|
|
53
66
|
'mariadb-secret',
|
|
@@ -55,25 +68,80 @@ const MANAGED_SECRETS = [
|
|
|
55
68
|
'mongodb-secret',
|
|
56
69
|
'mongodb-keyfile',
|
|
57
70
|
'ipfs-cluster-secret',
|
|
71
|
+
'grafana-admin',
|
|
58
72
|
];
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
73
|
+
// Origin credentials for the workload secret system, the first link in
|
|
74
|
+
// `origin secret -> SOPS/Age -> Kubernetes Secret -> workload`. They live in the deploy
|
|
75
|
+
// secret area alongside the other deploy-scoped material (SSH keys, node and route lists).
|
|
76
|
+
const DEPLOY_SECRET_DIR = './engine-private/deploy';
|
|
77
|
+
const LEGACY_SEED_DIR = './engine-private';
|
|
78
|
+
// A data key maps either to a whole file, or to `{ file, json }` when the credential is one
|
|
79
|
+
// field of a structured origin — the IPFS peer private key lives inside the identity document
|
|
80
|
+
// next to the peer id, which is public and belongs in a ConfigMap rather than a Secret.
|
|
81
|
+
const ORIGIN_SEED_FILES = {
|
|
82
|
+
'mariadb-secret': { username: 'mariadb-username', password: 'mariadb-password' },
|
|
83
|
+
'mysql-secret': { username: 'mysql-username', password: 'mysql-password' },
|
|
84
|
+
'postgres-secret': { password: 'postgresql-password' },
|
|
85
|
+
'mongodb-secret': { username: 'mongodb-username', password: 'mongodb-password' },
|
|
86
|
+
// Shared replica-set auth keyfile, mounted as a volume rather than injected as env.
|
|
87
|
+
'mongodb-keyfile': { 'mongodb-keyfile': 'mongodb-keyfile' },
|
|
88
|
+
'ipfs-cluster-secret': {
|
|
89
|
+
'cluster-secret': 'ipfs-cluster-secret',
|
|
90
|
+
'bootstrap-peer-priv-key': { file: 'ipfs-cluster-identity.json', json: 'private_key' },
|
|
67
91
|
},
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
92
|
+
// Optional: when these files are absent the values fall back to the environment the host CLI
|
|
93
|
+
// already carries (see SECRET_ENV_KEYS), which is where they live today.
|
|
94
|
+
'underpost-cron-env': { GITHUB_TOKEN: 'github-token', GITHUB_USERNAME: 'github-username' },
|
|
95
|
+
// The connection key the cron workloads SSH out with, mounted as a volume rather than injected
|
|
96
|
+
// as env: ssh authenticates with a file, and a private key in the environment is readable from
|
|
97
|
+
// every process listing and every log that dumps it.
|
|
98
|
+
'underpost-ssh-key': { 'id-rsa': 'id_rsa' },
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/** Normalizes a registry entry to `{ file, json }`. */
|
|
102
|
+
const seedDescriptor = (entry) => (typeof entry === 'string' ? { file: entry, json: '' } : { json: '', ...entry });
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Resolves an origin seed file, preferring the deploy secret area and falling back to the
|
|
106
|
+
* pre-move location.
|
|
107
|
+
*
|
|
108
|
+
* The fallback is what keeps `setup` idempotent on a host onboarded before the move: without
|
|
109
|
+
* it the existing credential would look absent and a second one would be generated, encrypted
|
|
110
|
+
* and applied over the password the running data tier is already using.
|
|
111
|
+
* @param {string} fileName - Seed file basename.
|
|
112
|
+
* @returns {string} Path to read the origin credential from.
|
|
113
|
+
* @memberof UnderpostSecret
|
|
114
|
+
*/
|
|
115
|
+
const originSeedPath = (fileName) => {
|
|
116
|
+
const current = `${DEPLOY_SECRET_DIR}/${fileName}`;
|
|
117
|
+
if (fs.existsSync(current)) return current;
|
|
118
|
+
const legacy = `${LEGACY_SEED_DIR}/${fileName}`;
|
|
119
|
+
return fs.existsSync(legacy) ? legacy : current;
|
|
120
|
+
};
|
|
121
|
+
const SECRET_ENV_KEYS = {
|
|
122
|
+
'grafana-admin': {
|
|
123
|
+
'admin-user': 'GF_SECURITY_ADMIN_USER',
|
|
124
|
+
'admin-password': 'GF_SECURITY_ADMIN_PASSWORD',
|
|
72
125
|
},
|
|
73
|
-
//
|
|
74
|
-
|
|
126
|
+
// Credentials the cron workloads consume as plain environment variables. They previously
|
|
127
|
+
// reached the CronJob pods by bind-mounting the operator's global underpost directory out of
|
|
128
|
+
// root's home, which no unprivileged container can read once SELinux is Enforcing — and which
|
|
129
|
+
// exposed far more of that tree than the two values actually needed.
|
|
75
130
|
};
|
|
76
131
|
|
|
132
|
+
/**
|
|
133
|
+
* Secrets whose key set is a configuration scope rather than a fixed list.
|
|
134
|
+
*
|
|
135
|
+
* The cron workloads receive their whole runtime environment this way, so the Secret has to say
|
|
136
|
+
* *which* environment rather than enumerate it: a hand-written list drifts from the jobs that read
|
|
137
|
+
* it, and every key it gains is a key nobody reviewed. {@link scopeValuesFactory} answers it from
|
|
138
|
+
* {@link ConfigScope.CONFIG_OWNERSHIP}, which is where widening a workload's access is a visible
|
|
139
|
+
* edit. Everything outside the scope — the provisioning credentials, the registry identities, the
|
|
140
|
+
* deployments' own databases — is not in the projection and cannot be.
|
|
141
|
+
* @memberof UnderpostSecret
|
|
142
|
+
*/
|
|
143
|
+
const SECRET_ENV_SCOPES = Object.freeze({ 'underpost-cron-env': 'cron' });
|
|
144
|
+
|
|
77
145
|
// Shell/runtime-critical and Kubernetes-injected env keys that must never be persisted as
|
|
78
146
|
// application secrets nor injected into a pod via `envFrom`. An injected PATH (or HOME, etc.)
|
|
79
147
|
// overrides the container image's own and breaks coreutils/sudo resolution inside the pod
|
|
@@ -123,8 +191,65 @@ const RESERVED_ENV_KEYS = new Set([
|
|
|
123
191
|
'which_declare',
|
|
124
192
|
]);
|
|
125
193
|
const RESERVED_ENV_KEY_PREFIXES = ['KUBERNETES_', 'npm_', 'NODE_'];
|
|
194
|
+
// `NODE_` covers the image's own NODE_VERSION/NODE_OPTIONS, but NODE_ENV is the deployment
|
|
195
|
+
// environment itself: the value `underpost-config` exists to carry onto the node, and the one
|
|
196
|
+
// `loadConf` reads to pick conf.*.json and .env.<env>. Stripping it made every deploy fall back
|
|
197
|
+
// to `development` regardless of the requested environment.
|
|
198
|
+
const PRESERVED_ENV_KEYS = new Set(['NODE_ENV']);
|
|
126
199
|
const isReservedEnvKey = (key) =>
|
|
127
|
-
|
|
200
|
+
!PRESERVED_ENV_KEYS.has(key) &&
|
|
201
|
+
(RESERVED_ENV_KEYS.has(key) || RESERVED_ENV_KEY_PREFIXES.some((prefix) => key.startsWith(prefix)));
|
|
202
|
+
|
|
203
|
+
// Secrets `setup` onboards when no explicit list is passed: the full self-hosted data tier.
|
|
204
|
+
// `mongodb-keyfile` is listed alongside `mongodb-secret` because the MongoDB StatefulSet mounts
|
|
205
|
+
// it as a volume for intra-replica-set auth and will not start without it, so onboarding the
|
|
206
|
+
// credentials alone would leave Mongo broken.
|
|
207
|
+
const SOPS_SETUP_DEFAULT_SECRETS = ['postgres-secret', 'mariadb-secret', 'mongodb-secret', 'mongodb-keyfile'];
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Produces a value for a Secret data key that has no origin seed file and no `--args` override.
|
|
211
|
+
* Key-aware because the data tier does not want one shape of secret: a replica-set keyfile is a
|
|
212
|
+
* long base64 blob, a username is an identifier, and everything else is a password.
|
|
213
|
+
* @param {string} key - Secret data key (e.g. 'password', 'username', 'mongodb-keyfile').
|
|
214
|
+
* @returns {string} Generated value.
|
|
215
|
+
* @memberof UnderpostSecret
|
|
216
|
+
*/
|
|
217
|
+
const generateSeedValue = (key) => {
|
|
218
|
+
if (key === 'username' || key === 'admin-user') return 'admin';
|
|
219
|
+
// MongoDB keyfile: 6-1024 base64 characters shared by every replica-set member. Newlines are
|
|
220
|
+
// stripped so the value round-trips identically through YAML and through
|
|
221
|
+
// MongoBootstrap.readCredential, which strips them too.
|
|
222
|
+
if (key === 'mongodb-keyfile')
|
|
223
|
+
return shellExec(`openssl rand -base64 756`, { stdout: true, silent: true, disableLog: true }).replace(
|
|
224
|
+
/\r?\n/g,
|
|
225
|
+
'',
|
|
226
|
+
);
|
|
227
|
+
return generateRandomPasswordSelection(24);
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Whether a value carries the shape of a GitHub personal access, OAuth, app or refresh token.
|
|
232
|
+
* Advisory only: GitHub has changed token formats before, so an unrecognized shape warns rather
|
|
233
|
+
* than blocks a rotation the operator has already decided on.
|
|
234
|
+
* @param {string} token - Candidate token.
|
|
235
|
+
* @returns {boolean} True when the value matches a known GitHub token prefix and length.
|
|
236
|
+
* @memberof UnderpostSecret
|
|
237
|
+
*/
|
|
238
|
+
/**
|
|
239
|
+
* Whether fd 0 carries piped or redirected data, as opposed to a terminal or `/dev/null`.
|
|
240
|
+
* @returns {boolean} True when stdin can be read to EOF without blocking on a user.
|
|
241
|
+
* @memberof UnderpostSecret
|
|
242
|
+
*/
|
|
243
|
+
const stdinIsRedirected = () => {
|
|
244
|
+
try {
|
|
245
|
+
const stat = fs.fstatSync(0);
|
|
246
|
+
return stat.isFIFO() || stat.isFile();
|
|
247
|
+
} catch {
|
|
248
|
+
return false;
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
const looksLikeGitHubToken = (token) => /^(?:gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,})$/.test(token);
|
|
128
253
|
|
|
129
254
|
/**
|
|
130
255
|
* @class UnderpostSecret
|
|
@@ -133,45 +258,200 @@ const isReservedEnvKey = (key) =>
|
|
|
133
258
|
*/
|
|
134
259
|
class UnderpostSecret {
|
|
135
260
|
static API = {
|
|
261
|
+
// ── canonical domain actions ──────────────────────────────────────────────────────────
|
|
262
|
+
// The seven verbs every domain implements, taking the same normalized context. Everything
|
|
263
|
+
// below them in this file is implementation the CLI no longer reaches directly: tooling
|
|
264
|
+
// install and key generation are automated inside `setup`, and the per-secret operations
|
|
265
|
+
// are addressed through `--args` rather than through flags of their own.
|
|
266
|
+
|
|
136
267
|
/**
|
|
137
|
-
*
|
|
138
|
-
*
|
|
268
|
+
* Onboards the workload secret store end to end: installs the SOPS and Age binaries,
|
|
269
|
+
* generates the key and creation rules, encrypts the managed Secrets into the Git-tracked
|
|
270
|
+
* store, then validates and applies them.
|
|
271
|
+
*
|
|
272
|
+
* Every step is idempotent. Notably it never regenerates a credential that already exists —
|
|
273
|
+
* an origin seed file is read through rather than replaced, so re-running this on a live
|
|
274
|
+
* cluster cannot rotate the password the data tier is running on.
|
|
275
|
+
* @param {object} context - Normalized domain context. `--args names=a,b` narrows the set.
|
|
276
|
+
* @returns {*} The onboarding report.
|
|
139
277
|
* @memberof UnderpostSecret
|
|
140
278
|
*/
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
279
|
+
setup(context = {}) {
|
|
280
|
+
context = domainContextFactory(context);
|
|
281
|
+
return Underpost.secret.setupStore(`${context.args.names ?? ''}`, {
|
|
282
|
+
namespace: context.namespace,
|
|
283
|
+
dryRun: context.dryRun,
|
|
284
|
+
force: context.force,
|
|
285
|
+
args: Object.entries(context.args)
|
|
286
|
+
.filter(([key]) => !['names', 'keys'].includes(key))
|
|
287
|
+
.map(([key, value]) => `${key}=${value}`),
|
|
288
|
+
});
|
|
289
|
+
},
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Loads decrypted secret values into the host store, so a local runtime can read a workload
|
|
293
|
+
* credential without a plaintext file ever touching disk.
|
|
294
|
+
*
|
|
295
|
+
* The local-development half of the propagation contract: production workloads receive these
|
|
296
|
+
* same values through `envFrom` off the Secret `apply` projects, and `npm run dev` receives
|
|
297
|
+
* them here. This domain has no local store of its own — a decrypted credential is node-local
|
|
298
|
+
* configuration, so it lands in the host domain's store rather than in a fourth one.
|
|
299
|
+
* `--args names=` narrows to one secret.
|
|
300
|
+
* @param {object} context - Normalized domain context.
|
|
301
|
+
* @returns {{loaded: Array<string>, keys: number}} Which secrets were loaded, and how many keys.
|
|
302
|
+
* @memberof UnderpostSecret
|
|
303
|
+
*/
|
|
304
|
+
load(context = {}) {
|
|
305
|
+
context = domainContextFactory(context);
|
|
306
|
+
const requested = `${context.args.names ?? ''}`.split(/[,\s]+/).filter(Boolean);
|
|
307
|
+
const names = (requested.length > 0 ? requested : Underpost.secret.managedSecrets()).filter((name) =>
|
|
308
|
+
Underpost.secret.has(name, context.namespace),
|
|
309
|
+
);
|
|
310
|
+
const values = {};
|
|
311
|
+
for (const name of names)
|
|
312
|
+
for (const [key, value] of Object.entries(Underpost.secret.readData(name, context.namespace) ?? {}))
|
|
313
|
+
values[`${name.replace(/-/g, '_').toUpperCase()}_${key.replace(/-/g, '_').toUpperCase()}`] = value;
|
|
314
|
+
if (context.dryRun) {
|
|
315
|
+
logger.info('[dry-run] secret load would populate the host store', {
|
|
316
|
+
loaded: names,
|
|
317
|
+
keys: Object.keys(values).length,
|
|
318
|
+
});
|
|
319
|
+
return { loaded: names, keys: Object.keys(values).length };
|
|
320
|
+
}
|
|
321
|
+
for (const [key, value] of Object.entries(values)) Underpost.host.store.set(key, value);
|
|
322
|
+
logger.info('Workload secrets loaded', { loaded: names, keys: Object.keys(values).length });
|
|
323
|
+
return { loaded: names, keys: Object.keys(values).length };
|
|
324
|
+
},
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Writes a plaintext Secret manifest into the encrypted store and shreds the source, making
|
|
328
|
+
* SOPS/Age the durable record for it.
|
|
329
|
+
* @param {object} context - Normalized domain context. Requires `--args path=<plaintext.yaml>`.
|
|
330
|
+
* @returns {*} The encryption result.
|
|
331
|
+
* @memberof UnderpostSecret
|
|
332
|
+
*/
|
|
333
|
+
publish(context = {}) {
|
|
334
|
+
context = domainContextFactory(context);
|
|
335
|
+
const path = `${context.args.path ?? ''}`.trim();
|
|
336
|
+
if (!path) throw new Error('[secret] publish requires --args path=<plaintext-manifest>');
|
|
337
|
+
return Underpost.secret.encrypt(path, context.namespace, context);
|
|
338
|
+
},
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Projects the encrypted store into the cluster: decrypts stored manifests and streams them
|
|
342
|
+
* straight into `kubectl apply`, so plaintext never reaches persistent storage.
|
|
343
|
+
* Idempotent — re-applying converges on the stored values.
|
|
344
|
+
* @param {object} context - Normalized domain context. `--args names=a,b` narrows the set.
|
|
345
|
+
* @returns {*} The apply result.
|
|
346
|
+
* @memberof UnderpostSecret
|
|
347
|
+
*/
|
|
348
|
+
apply(context = {}) {
|
|
349
|
+
context = domainContextFactory(context);
|
|
350
|
+
const names = `${context.args.names ?? ''}`.trim();
|
|
351
|
+
if (names) return Underpost.secret.applySelected(names, context.namespace, { dryRun: context.dryRun });
|
|
352
|
+
return Underpost.secret.applyStore(context.namespace, { dryRun: context.dryRun });
|
|
170
353
|
},
|
|
171
354
|
|
|
172
355
|
/**
|
|
173
|
-
*
|
|
174
|
-
*
|
|
356
|
+
* Read-only report of the store: tooling, key and recipients, creation rules, stored
|
|
357
|
+
* manifests with decryptability and cluster drift.
|
|
358
|
+
* @param {object} context - Normalized domain context. `--args keys=mongo` narrows by substring.
|
|
359
|
+
* @returns {*} The status report.
|
|
360
|
+
* @memberof UnderpostSecret
|
|
361
|
+
*/
|
|
362
|
+
status(context = {}) {
|
|
363
|
+
context = domainContextFactory(context);
|
|
364
|
+
return Underpost.secret.statusReport(`${context.args.keys ?? ''}`, { namespace: context.namespace });
|
|
365
|
+
},
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Replaces the current projection: either the Age identity the store is sealed to, or the
|
|
369
|
+
* value of a credential itself.
|
|
370
|
+
*
|
|
371
|
+
* `--args secret=GIT_AUTH_TOKEN` selects the credential rotation — the GitHub Actions secret
|
|
372
|
+
* every repository of a deploy authenticates with, mirrored into the encrypted store.
|
|
373
|
+
* Without it the recipient rotation runs, which re-keys stored manifests onto a new Age
|
|
374
|
+
* recipient: secret values are unchanged there, so no workload restart is needed.
|
|
375
|
+
* @param {object} context - Normalized domain context. Requires either
|
|
376
|
+
* `--args secret=GIT_AUTH_TOKEN` (with `token=`, `deploy-id=`, `owner=`, `repos=`,
|
|
377
|
+
* `store=true`, `apply=true`) or `--args recipient=age1...` (with `prune=true` to revoke
|
|
378
|
+
* previous recipients and `keep=` to retain named ones).
|
|
379
|
+
* @returns {*} The rotation result.
|
|
380
|
+
* @memberof UnderpostSecret
|
|
381
|
+
*/
|
|
382
|
+
rotate(context = {}) {
|
|
383
|
+
context = domainContextFactory(context);
|
|
384
|
+
const secret = `${context.args.secret ?? ''}`.trim();
|
|
385
|
+
if (secret) {
|
|
386
|
+
if (secret.toUpperCase() !== GIT_AUTH_TOKEN_KEY)
|
|
387
|
+
// Phrased without a `secret=<value>` pair on purpose: the log redactor treats one as a
|
|
388
|
+
// credential and would replace the very name the operator needs to read back.
|
|
389
|
+
throw new Error(
|
|
390
|
+
`[secret] rotate does not know the credential '${secret}'; ${GIT_AUTH_TOKEN_KEY} is the only one ` +
|
|
391
|
+
`it rotates. Re-key the store's encryption identity with --args recipient=age1... instead.`,
|
|
392
|
+
);
|
|
393
|
+
return Underpost.secret.rotateGitAuthToken({
|
|
394
|
+
namespace: context.namespace,
|
|
395
|
+
dryRun: context.dryRun,
|
|
396
|
+
token: context.args.token,
|
|
397
|
+
deployId: context.args['deploy-id'] ?? context.args.deployId,
|
|
398
|
+
owner: context.args.owner,
|
|
399
|
+
repos: context.args.repos,
|
|
400
|
+
store: context.args.store,
|
|
401
|
+
apply: context.args.apply,
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
const recipient = `${context.args.recipient ?? ''}`.trim();
|
|
405
|
+
if (!recipient)
|
|
406
|
+
throw new Error(
|
|
407
|
+
'[secret] rotate requires a target: --args recipient=<age-public-key> re-keys the store onto a new ' +
|
|
408
|
+
`Age identity, and the 'secret' parameter rotates a credential value (${GIT_AUTH_TOKEN_KEY} is the ` +
|
|
409
|
+
'one it supports).',
|
|
410
|
+
);
|
|
411
|
+
return Underpost.secret.rotateRecipient(recipient, {
|
|
412
|
+
namespace: context.namespace,
|
|
413
|
+
dryRun: context.dryRun,
|
|
414
|
+
force: context.force,
|
|
415
|
+
pruneRecipients: context.args.prune === true || context.args.prune === 'true',
|
|
416
|
+
keepRecipients: `${context.args.keep ?? ''}`,
|
|
417
|
+
});
|
|
418
|
+
},
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Withdraws local plaintext traces of workload secrets. The Age private key is kept: the node
|
|
422
|
+
* needs it to re-apply the store on restart. `--args names=` with `--force` additionally
|
|
423
|
+
* purges those secrets from the cluster and takes their manifests out of the store.
|
|
424
|
+
*
|
|
425
|
+
* A purge archives the manifest under `.archive/` by default, so it stays reversible;
|
|
426
|
+
* `--args delete=true` is the irreversible variant. `--force` gates the cluster deletion
|
|
427
|
+
* itself, so it cannot double as the disposition — that would make the archive path
|
|
428
|
+
* unreachable from the CLI and every purge permanent.
|
|
429
|
+
* @param {object} context - Normalized domain context.
|
|
430
|
+
* @returns {{staged: number, purged: Array<string>, disposition: string}} What was withdrawn.
|
|
431
|
+
* @memberof UnderpostSecret
|
|
432
|
+
*/
|
|
433
|
+
clean(context = {}) {
|
|
434
|
+
context = domainContextFactory(context);
|
|
435
|
+
const purge = `${context.args.names ?? ''}`.split(/[,\s]+/).filter(Boolean);
|
|
436
|
+
if (purge.length > 0 && !context.force)
|
|
437
|
+
throw new Error('[secret] clean --args names=<secret> removes cluster state; re-run with --force');
|
|
438
|
+
const deleteManifest = context.args.delete === true || `${context.args.delete}` === 'true';
|
|
439
|
+
const disposition = deleteManifest ? 'delete' : 'archive';
|
|
440
|
+
const staged = fs.existsSync(SOPS_STAGE_DIR) ? fs.readdirSync(SOPS_STAGE_DIR).length : 0;
|
|
441
|
+
if (context.dryRun) {
|
|
442
|
+
logger.info('[dry-run] secret clean would withdraw', { staged, purge, disposition });
|
|
443
|
+
return { staged, purged: purge, disposition };
|
|
444
|
+
}
|
|
445
|
+
if (fs.existsSync(SOPS_STAGE_DIR)) fs.removeSync(SOPS_STAGE_DIR);
|
|
446
|
+
for (const name of purge)
|
|
447
|
+
Underpost.secret.purge(name, { namespace: context.namespace, dryRun: false, force: deleteManifest });
|
|
448
|
+
logger.info('Workload secret traces withdrawn', { staged, purged: purge, disposition });
|
|
449
|
+
return { staged, purged: purge, disposition };
|
|
450
|
+
},
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* @method store
|
|
454
|
+
* @description Git-native encrypted credential management backed by Mozilla SOPS and Age, for
|
|
175
455
|
* fully self-hosted clusters with no cloud KMS or external secret store. Encrypted manifests
|
|
176
456
|
* live in `engine-private/secrets/<namespace>/<name>.enc.yaml` and are safe to commit; the Age
|
|
177
457
|
* private key stays at `~/.config/sops/age/keys.txt` (or `$SOPS_AGE_KEY_FILE`) and is never
|
|
@@ -179,940 +459,1854 @@ class UnderpostSecret {
|
|
|
179
459
|
* streamed straight into `kubectl apply -f -`, so plaintext never reaches persistent storage.
|
|
180
460
|
* @memberof UnderpostSecret
|
|
181
461
|
*/
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
462
|
+
/**
|
|
463
|
+
* @method setup
|
|
464
|
+
* @description End-to-end SOPS/Age onboarding for a host: installs tooling, generates the Age
|
|
465
|
+
* keypair and creation rules, pins the key path for non-interactive runs, encrypts the
|
|
466
|
+
* requested Secrets into the Git-tracked store, then validates and applies them.
|
|
467
|
+
*
|
|
468
|
+
* Every step is idempotent and re-runnable. Notably it delegates key generation to
|
|
469
|
+
* `secret setup` rather than calling `age-keygen` directly: a bare `age-keygen -o`
|
|
470
|
+
* overwrites an existing key, which would orphan every manifest already encrypted to the
|
|
471
|
+
* previous recipient with no way to recover them.
|
|
472
|
+
*
|
|
473
|
+
* On a host that pulled a store created elsewhere, the freshly generated key is not a recipient
|
|
474
|
+
* of the inherited manifests. `init()` registers this host in the creation rules so what it
|
|
475
|
+
* encrypts from here on stays readable, but existing manifests can only be re-keyed from a host
|
|
476
|
+
* that still holds a decrypting key. That case is reported per secret and then raised by the
|
|
477
|
+
* apply pre-flight with the available remedies, rather than surfacing as a sops decrypt error.
|
|
478
|
+
*
|
|
479
|
+
* Onboards the whole self-hosted data tier by default — PostgreSQL, MariaDB, and MongoDB
|
|
480
|
+
* (`postgres-secret`, `mariadb-secret`, `mongodb-secret`, `mongodb-keyfile`). The MongoDB
|
|
481
|
+
* keyfile is included because the StatefulSet mounts it for intra-replica-set auth and will
|
|
482
|
+
* not start without it. Pass an explicit comma-separated list to narrow the set.
|
|
483
|
+
*
|
|
484
|
+
* Secret values are resolved per data key, in order:
|
|
485
|
+
* 1. the origin seed file, when one exists (`engine-private/postgresql-password`) — this is
|
|
486
|
+
* the real onboarding path, carrying the credential the cluster already runs on;
|
|
487
|
+
* 2. `--args` as `key=value` pairs, for a value supplied by the operator;
|
|
488
|
+
* 3. a freshly generated value: a base64 keyfile for `mongodb-keyfile`, `admin` for a
|
|
489
|
+
* `username`, otherwise a 24-character secure password.
|
|
490
|
+
*
|
|
491
|
+
* Plaintext manifests are written by Node under `/dev/shm` at mode 600 and shredded by
|
|
492
|
+
* `encrypt()`. They are never emitted through a shell heredoc, which would place the
|
|
493
|
+
* credential in the command string and therefore in the process table and the command log.
|
|
494
|
+
*
|
|
495
|
+
* Usage:
|
|
496
|
+
* underpost secret setup # postgres + mariadb + mongo
|
|
497
|
+
* underpost secret setup --args names=mongodb-secret,mongodb-keyfile --namespace prod
|
|
498
|
+
* underpost secret setup --args "names=postgres-secret,password=s3cr3t"
|
|
499
|
+
* underpost secret setup --dry-run # stop before mutating cluster
|
|
500
|
+
* underpost secret setup --force # replace stored manifests
|
|
501
|
+
* @param {string} names - Comma-separated Secret names to onboard. Defaults to the full data
|
|
502
|
+
* tier: postgres-secret, mariadb-secret, mongodb-secret, mongodb-keyfile.
|
|
503
|
+
* @param {object} options - Onboarding options
|
|
504
|
+
* @param {string} options.namespace - Target namespace for the store and the apply (default: 'default').
|
|
505
|
+
* @param {string} options.args - Comma-separated `key=value` overrides for Secret data keys.
|
|
506
|
+
* @param {boolean} options.dryRun - Validate and server-dry-run only; never apply.
|
|
507
|
+
* @param {boolean} options.force - Replace encrypted manifests that already exist.
|
|
508
|
+
* @memberof UnderpostSecret
|
|
509
|
+
*/
|
|
510
|
+
setupStore(names = '', options = {}) {
|
|
511
|
+
const namespace = options.namespace || 'default';
|
|
512
|
+
const secretNames = (names || SOPS_SETUP_DEFAULT_SECRETS.join(','))
|
|
513
|
+
.split(',')
|
|
514
|
+
.map((name) => name.trim())
|
|
515
|
+
.filter(Boolean);
|
|
516
|
+
|
|
517
|
+
// `--args key=value,key2=value2` overrides, applied to any secret that declares that key.
|
|
518
|
+
const overrides = `${options.args || ''}`.split(',').reduce((acc, pair) => {
|
|
519
|
+
const separator = pair.indexOf('=');
|
|
520
|
+
if (separator > 0) acc[pair.slice(0, separator).trim()] = pair.slice(separator + 1).trim();
|
|
521
|
+
return acc;
|
|
522
|
+
}, {});
|
|
523
|
+
|
|
524
|
+
logger.info('[secret setup]', { secretNames, namespace, dryRun: !!options.dryRun, force: !!options.force });
|
|
525
|
+
|
|
526
|
+
// 1. Host tooling, then keypair + creation rules. Both no-op when already present.
|
|
527
|
+
Underpost.secret.installTooling();
|
|
528
|
+
Underpost.secret.init();
|
|
529
|
+
|
|
530
|
+
// 2. Pin the resolved key path for non-interactive runs (systemd units, CronJobs, sudo).
|
|
531
|
+
// Written with the concrete path rather than a guessed default, because `sudo` resets
|
|
532
|
+
// HOME and a wrong guess surfaces later as an opaque decrypt failure.
|
|
533
|
+
const keyFile = Underpost.secret.keyFile();
|
|
534
|
+
shellExec(
|
|
535
|
+
`sudo tee /etc/profile.d/underpost-sops.sh >/dev/null <<'UNDERPOST_SOPS_ENV_EOF'
|
|
536
|
+
export SOPS_AGE_KEY_FILE="\${SOPS_AGE_KEY_FILE:-${keyFile}}"
|
|
537
|
+
UNDERPOST_SOPS_ENV_EOF`,
|
|
538
|
+
);
|
|
539
|
+
shellExec(`sudo chmod 644 /etc/profile.d/underpost-sops.sh`);
|
|
540
|
+
|
|
541
|
+
// 3. Build and encrypt each requested Secret.
|
|
542
|
+
const stageDir = '/dev/shm/underpost-secrets';
|
|
543
|
+
const held = Underpost.secret.localRecipients();
|
|
544
|
+
stageDirSync(stageDir);
|
|
545
|
+
try {
|
|
546
|
+
for (const name of secretNames) {
|
|
547
|
+
const stored = Underpost.secret.has(name, namespace);
|
|
548
|
+
if (stored && !options.force) {
|
|
549
|
+
// A stored manifest this host cannot open is present but unusable here, so reporting it
|
|
550
|
+
// as onboarded would send the operator on to an apply that is guaranteed to fail.
|
|
551
|
+
if (Underpost.secret.decryptable(Underpost.secret.manifestPath(name, namespace), held))
|
|
552
|
+
logger.info(`${name} is already onboarded in ns/${namespace}; skipping (use --force to replace)`);
|
|
553
|
+
else
|
|
554
|
+
logger.warn(
|
|
555
|
+
`${name} is stored in ns/${namespace} but is sealed to an Age recipient this host does not hold; ` +
|
|
556
|
+
`skipping. Adopt the store's key, re-key it from a host that holds one, or re-onboard from the ` +
|
|
557
|
+
`origin seed files with --force.`,
|
|
558
|
+
);
|
|
559
|
+
continue;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// Data keys come from the secret's origin seed contract, so an onboarded manifest
|
|
563
|
+
// carries exactly the keys the workload's secretKeyRef already expects.
|
|
564
|
+
const seedSources = Underpost.secret.seedSources(name);
|
|
565
|
+
const envKeys = Underpost.secret.seedEnvKeys(name);
|
|
566
|
+
const envValues = Underpost.secret.seedEnvValues(name, options);
|
|
567
|
+
const mappedKeys = [...new Set([...Object.keys(seedSources), ...Object.keys(envKeys)])];
|
|
568
|
+
const dataKeys = mappedKeys.length > 0 ? mappedKeys : ['password'];
|
|
569
|
+
const stringData = {};
|
|
570
|
+
for (const key of dataKeys) {
|
|
571
|
+
const seedPath = seedSources[key];
|
|
572
|
+
if (seedPath && fs.existsSync(seedPath)) {
|
|
573
|
+
stringData[key] = fs.readFileSync(seedPath, 'utf8').trim();
|
|
574
|
+
logger.info(`${name}.${key} seeded from ${seedPath}`);
|
|
575
|
+
} else if (envValues[key] !== undefined) {
|
|
576
|
+
stringData[key] = envValues[key];
|
|
577
|
+
logger.info(`${name}.${key} seeded from the cron deploy environment`);
|
|
578
|
+
} else if (overrides[key] !== undefined) {
|
|
579
|
+
stringData[key] = overrides[key];
|
|
580
|
+
logger.info(`${name}.${key} taken from --args`);
|
|
581
|
+
} else {
|
|
582
|
+
stringData[key] = generateSeedValue(key);
|
|
583
|
+
// Replacing a stored manifest with a value nothing seeded means the credential the
|
|
584
|
+
// running datastore still authenticates against is being thrown away.
|
|
585
|
+
if (stored)
|
|
586
|
+
logger.warn(
|
|
587
|
+
`${name}.${key} generated while replacing the stored manifest — no seed file at ` +
|
|
588
|
+
`${seedPath || '(unmapped)'} and no --args override. The running datastore keeps its old ` +
|
|
589
|
+
`credential until this value is applied to it; pass --args "${key}=<value>" to keep the ` +
|
|
590
|
+
`existing one.`,
|
|
591
|
+
);
|
|
592
|
+
else logger.info(`${name}.${key} generated`);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
const stagePath = `${stageDir}/${name}.yaml`;
|
|
597
|
+
writeStageFileSync(
|
|
598
|
+
stagePath,
|
|
599
|
+
[
|
|
600
|
+
'apiVersion: v1',
|
|
601
|
+
'kind: Secret',
|
|
602
|
+
'metadata:',
|
|
603
|
+
` name: ${name}`,
|
|
604
|
+
` namespace: ${namespace}`,
|
|
605
|
+
' labels:',
|
|
606
|
+
' app.kubernetes.io/managed-by: underpost',
|
|
607
|
+
'type: Opaque',
|
|
608
|
+
'stringData:',
|
|
609
|
+
// Single-quoted YAML scalars with doubled internal quotes: values are generated or
|
|
610
|
+
// operator-supplied and may contain characters YAML would otherwise interpret.
|
|
611
|
+
...Object.entries(stringData).map(([key, value]) => ` ${key}: '${`${value}`.replace(/'/g, "''")}'`),
|
|
612
|
+
'',
|
|
613
|
+
].join('\n'),
|
|
241
614
|
);
|
|
615
|
+
// encrypt() stages, validates, moves into place, and shreds the plaintext source.
|
|
616
|
+
Underpost.secret.encrypt(stagePath, namespace, options);
|
|
242
617
|
}
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
618
|
+
} finally {
|
|
619
|
+
// Defense in depth: encrypt() shreds each source, but a throw mid-loop must not leave a
|
|
620
|
+
// plaintext manifest sitting in shared memory.
|
|
621
|
+
fs.removeSync(stageDir);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
Underpost.secret.list();
|
|
625
|
+
|
|
626
|
+
// 4. Validate only the requested manifests, then apply unless this is a dry run.
|
|
627
|
+
Underpost.secret.applySelected(secretNames, namespace, { dryRun: true });
|
|
628
|
+
if (options.dryRun) return logger.info('--dry-run: validated only, cluster left unchanged');
|
|
629
|
+
Underpost.secret.applySelected(secretNames, namespace);
|
|
630
|
+
},
|
|
631
|
+
/**
|
|
632
|
+
* @method status
|
|
633
|
+
* @description Reports the live state of the SOPS/Age secret system: host tooling, the Age
|
|
634
|
+
* key and its recipient, the committed creation rules, every stored manifest with whether the
|
|
635
|
+
* local key can open it and whether the cluster still matches, and which managed Secrets are
|
|
636
|
+
* onboarded versus still seeding from their origin path.
|
|
637
|
+
*
|
|
638
|
+
* Read-only and safe to run anywhere. Decryption happens only for the drift check, only for
|
|
639
|
+
* manifests the local key is a recipient of, and only into `kubectl diff` with its output
|
|
640
|
+
* discarded — no secret value is ever printed or written to disk.
|
|
641
|
+
*
|
|
642
|
+
* Usage:
|
|
643
|
+
* underpost secret status # every managed key, ns default
|
|
644
|
+
* underpost secret status --args keys=mongo # partial match: both mongo keys
|
|
645
|
+
* underpost secret status --namespace prod # every managed key in ns prod
|
|
646
|
+
* @param {string} filter - Comma-separated managed Secret keys to report on; empty reports all.
|
|
647
|
+
* Matched as case-insensitive substrings (`mongo` selects mongodb-secret and mongodb-keyfile).
|
|
648
|
+
* Filters both the stored-manifest listing and the coverage table.
|
|
649
|
+
* @param {object} options - Reporting options
|
|
650
|
+
* @param {string} options.namespace - Namespace to inspect (default: 'default').
|
|
651
|
+
* @memberof UnderpostSecret
|
|
652
|
+
*/
|
|
653
|
+
statusReport(filter = '', options = {}) {
|
|
654
|
+
const sops = Underpost.secret;
|
|
655
|
+
// `--namespace` selects the namespace; `path` narrows which managed
|
|
656
|
+
// Secret keys to report on, so the two axes stay independent.
|
|
657
|
+
const namespace = options.namespace || 'default';
|
|
658
|
+
const manageSecretKeyFilter = `${filter || ''}`
|
|
659
|
+
.split(',')
|
|
660
|
+
.map((key) => key.trim().toLowerCase())
|
|
661
|
+
.filter(Boolean);
|
|
662
|
+
// Partial, case-insensitive substring match, so `mongo` reaches both `mongodb-secret` and
|
|
663
|
+
// `mongodb-keyfile` without having to spell either out.
|
|
664
|
+
const matchesKeyFilter = (name) =>
|
|
665
|
+
manageSecretKeyFilter.length === 0 || manageSecretKeyFilter.some((key) => name.toLowerCase().includes(key));
|
|
666
|
+
const mark = (ok) => (ok ? 'yes' : 'no');
|
|
667
|
+
|
|
668
|
+
// ── Tooling ────────────────────────────────────────────────────────────
|
|
669
|
+
const version = (bin, flag) =>
|
|
670
|
+
sops.hasBinary(bin)
|
|
671
|
+
? shellExec(`${bin} ${flag} 2>/dev/null | head -1`, { stdout: true, silent: true, disableLog: true }).trim()
|
|
672
|
+
: '(not installed)';
|
|
673
|
+
logger.info(
|
|
674
|
+
'[secret status] Tooling\n' +
|
|
675
|
+
` sops ${version('sops', '--version')}\n` +
|
|
676
|
+
` age ${version('age', '--version')}\n` +
|
|
677
|
+
` age-keygen ${sops.hasBinary('age-keygen') ? 'installed' : '(not installed)'}`,
|
|
678
|
+
);
|
|
679
|
+
|
|
680
|
+
// ── Age key ────────────────────────────────────────────────────────────
|
|
681
|
+
const keyFile = sops.keyFile();
|
|
682
|
+
const keyExists = fs.existsSync(keyFile);
|
|
683
|
+
// A key file may hold several identities — that is how a host joins a store it did not
|
|
684
|
+
// create — so every check below works against the whole held set, not one recipient.
|
|
685
|
+
const held = sops.localRecipients();
|
|
686
|
+
const keyMode = keyExists ? (fs.statSync(keyFile).mode & 0o777).toString(8) : '';
|
|
687
|
+
logger.info(
|
|
688
|
+
'[secret status] Age key\n' +
|
|
689
|
+
` path ${keyFile}\n` +
|
|
690
|
+
` present ${mark(keyExists)}${keyExists ? ` (mode ${keyMode}${keyMode === '600' || keyMode === '400' ? '' : ' — INSECURE, run chmod 600'})` : ''}\n` +
|
|
691
|
+
` recipients ${held.join(', ') || (keyExists ? '(none — unreadable key file)' : '(none)')}` +
|
|
692
|
+
(keyExists ? '' : `\n searched ${sops.keyFileCandidates().join(', ')}`),
|
|
693
|
+
);
|
|
694
|
+
|
|
695
|
+
// ── Creation rules ─────────────────────────────────────────────────────
|
|
696
|
+
const confPath = './engine-private/secrets/.sops.yaml';
|
|
697
|
+
const ruleRecipients = sops.creationRecipients();
|
|
698
|
+
logger.info(
|
|
699
|
+
'[secret status] Creation rules\n' +
|
|
700
|
+
` config ${confPath} ${fs.existsSync(confPath) ? '' : '(missing — run: underpost secret setup)'}\n` +
|
|
701
|
+
` recipients ${ruleRecipients.length > 0 ? ruleRecipients.join(', ') : '(none)'}\n` +
|
|
702
|
+
` local key listed ${mark(held.some((recipient) => ruleRecipients.includes(recipient)))}`,
|
|
703
|
+
);
|
|
704
|
+
|
|
705
|
+
// ── Stored manifests ───────────────────────────────────────────────────
|
|
706
|
+
const manifests = sops.manifests(namespace).filter((manifest) => matchesKeyFilter(manifest.name));
|
|
707
|
+
const onboarded = new Set();
|
|
708
|
+
if (manifests.length === 0)
|
|
709
|
+
logger.warn(
|
|
710
|
+
`[secret status] Store\n no encrypted manifests in ns/${namespace}` +
|
|
711
|
+
(manageSecretKeyFilter.length > 0 ? ` matching ${manageSecretKeyFilter.join(', ')}` : ''),
|
|
712
|
+
);
|
|
713
|
+
else {
|
|
714
|
+
const rows = manifests.map((manifest) => {
|
|
715
|
+
onboarded.add(manifest.name);
|
|
716
|
+
const recipients = sops.manifestRecipients(manifest.path);
|
|
717
|
+
const decryptable = sops.decryptable(manifest.path, held);
|
|
718
|
+
const live = shellExec(
|
|
719
|
+
`kubectl get secret ${manifest.name} -n ${manifest.namespace} --ignore-not-found -o name 2>/dev/null || true`,
|
|
720
|
+
{ stdout: true, silent: true, silentOnError: true, disableLog: true },
|
|
721
|
+
).trim();
|
|
722
|
+
// Drift is decided by kubectl's exit code; its stdout would contain the decrypted
|
|
723
|
+
// values, so it is discarded rather than captured.
|
|
724
|
+
let sync = 'n/a';
|
|
725
|
+
if (live && decryptable) {
|
|
726
|
+
const result = shellExec(
|
|
727
|
+
`bash -c 'set -o pipefail; SOPS_AGE_KEY_FILE="${keyFile}" sops --decrypt "${manifest.path}" ` +
|
|
728
|
+
`| kubectl diff -f - -n "${manifest.namespace}" >/dev/null 2>&1'`,
|
|
729
|
+
{ silentOnError: true, disableLog: true, stdout: false },
|
|
730
|
+
);
|
|
731
|
+
sync = result.code === 0 ? 'in-sync' : result.code === 1 ? 'DRIFT' : 'error';
|
|
732
|
+
} else if (!live) sync = 'not applied';
|
|
733
|
+
else if (!decryptable) sync = 'no local key';
|
|
734
|
+
return (
|
|
735
|
+
` ${`${manifest.namespace}/${manifest.name}`.padEnd(34)} ` +
|
|
736
|
+
`recipients=${String(recipients.length).padEnd(3)} ` +
|
|
737
|
+
`decryptable=${mark(decryptable).padEnd(4)} ` +
|
|
738
|
+
`live=${mark(!!live).padEnd(4)} ` +
|
|
739
|
+
`${sync}`
|
|
248
740
|
);
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
741
|
+
});
|
|
742
|
+
logger.info(`[secret status] Store — ns/${namespace} (${manifests.length} manifest(s))\n` + rows.join('\n'));
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
// ── Coverage ───────────────────────────────────────────────────────────
|
|
746
|
+
const coverage = sops
|
|
747
|
+
.managedSecrets()
|
|
748
|
+
.filter(matchesKeyFilter)
|
|
749
|
+
.map((name) => {
|
|
750
|
+
const seeds = Object.values(sops.seedSources(name));
|
|
751
|
+
const seedPresent = seeds.length > 0 && seeds.every((seed) => fs.existsSync(seed));
|
|
752
|
+
const envKeys = Object.keys(sops.seedEnvKeys(name));
|
|
753
|
+
const envValues = sops.seedEnvValues(name);
|
|
754
|
+
const envPresent = envKeys.length > 0 && envKeys.every((key) => envValues[key] !== undefined);
|
|
755
|
+
let source = 'unmapped';
|
|
756
|
+
if (onboarded.has(name)) source = 'sops';
|
|
757
|
+
else if (seedPresent) source = 'origin seed';
|
|
758
|
+
else if (envPresent) source = 'cron env';
|
|
759
|
+
else if (seeds.length || envKeys.length) source = 'MISSING';
|
|
760
|
+
return ` ${name.padEnd(24)} ${source.padEnd(12)} ${
|
|
761
|
+
seeds.length ? `seed=${mark(seedPresent)}` : envKeys.length ? `env=${mark(envPresent)}` : ''
|
|
762
|
+
}`;
|
|
763
|
+
});
|
|
764
|
+
if (coverage.length === 0)
|
|
765
|
+
logger.warn(
|
|
766
|
+
`[secret status] Coverage\n no managed Secret matches ${manageSecretKeyFilter.join(', ')}\n` +
|
|
767
|
+
` known keys: ${sops.managedSecrets().join(', ')}`,
|
|
768
|
+
);
|
|
769
|
+
else
|
|
770
|
+
logger.info('[secret status] Coverage (which source each managed Secret deploys from)\n' + coverage.join('\n'));
|
|
771
|
+
},
|
|
772
|
+
/**
|
|
773
|
+
* @method keyFileCandidates
|
|
774
|
+
* @description Ordered paths the Age private key is looked for, matching what sops itself
|
|
775
|
+
* resolves. Surfaced separately so a "key not found" error can name every location tried —
|
|
776
|
+
* the identity-context trap is a key generated as an unprivileged user but read back under
|
|
777
|
+
* `sudo`, where `os.homedir()` points at root's home instead.
|
|
778
|
+
* @returns {Array<string>} Candidate paths, highest precedence first.
|
|
779
|
+
* @memberof UnderpostSecret
|
|
780
|
+
*/
|
|
781
|
+
keyFileCandidates() {
|
|
782
|
+
const candidates = [];
|
|
783
|
+
if (process.env.SOPS_AGE_KEY_FILE) candidates.push(process.env.SOPS_AGE_KEY_FILE);
|
|
784
|
+
if (process.env.XDG_CONFIG_HOME) candidates.push(`${process.env.XDG_CONFIG_HOME}/sops/age/keys.txt`);
|
|
785
|
+
candidates.push(`${os.homedir()}/.config/sops/age/keys.txt`);
|
|
786
|
+
// Under `sudo`, the invoking user's key is the one the operator actually generated.
|
|
787
|
+
// Offered as a diagnostic hint only — never resolved implicitly, since silently reading a
|
|
788
|
+
// different user's private key would make the effective identity non-obvious.
|
|
789
|
+
if (process.env.SUDO_USER) candidates.push(`/home/${process.env.SUDO_USER}/.config/sops/age/keys.txt`);
|
|
790
|
+
return [...new Set(candidates)];
|
|
791
|
+
},
|
|
792
|
+
|
|
793
|
+
/**
|
|
794
|
+
* @method keyFile
|
|
795
|
+
* @description Resolves the Age private key path. Honors `SOPS_AGE_KEY_FILE` then
|
|
796
|
+
* `XDG_CONFIG_HOME`, matching sops' own resolution so key location has a single source of
|
|
797
|
+
* truth. Deliberately resolves a *path*, never key material — `SOPS_AGE_KEY` would expose the
|
|
798
|
+
* private key in `/proc/<pid>/environ` and in any process listing.
|
|
799
|
+
* @returns {string} Path to the Age private key file (may not exist yet).
|
|
800
|
+
* @memberof UnderpostSecret
|
|
801
|
+
*/
|
|
802
|
+
keyFile() {
|
|
803
|
+
if (process.env.SOPS_AGE_KEY_FILE) return process.env.SOPS_AGE_KEY_FILE;
|
|
804
|
+
if (process.env.XDG_CONFIG_HOME) return `${process.env.XDG_CONFIG_HOME}/sops/age/keys.txt`;
|
|
805
|
+
return `${os.homedir()}/.config/sops/age/keys.txt`;
|
|
806
|
+
},
|
|
807
|
+
|
|
808
|
+
/**
|
|
809
|
+
* @method assertKeyFile
|
|
810
|
+
* @description Resolves the Age private key and refuses to proceed unless it exists and is
|
|
811
|
+
* unreadable by group/other. A key at mode 0644 is a disclosed key, so this fails closed
|
|
812
|
+
* rather than warning. When the resolved path is missing it names every candidate checked,
|
|
813
|
+
* including the invoking user's home under `sudo`, so the identity mismatch is diagnosable
|
|
814
|
+
* instead of presenting as a decrypt failure.
|
|
815
|
+
* @returns {string} Verified key file path.
|
|
816
|
+
* @memberof UnderpostSecret
|
|
817
|
+
*/
|
|
818
|
+
assertKeyFile() {
|
|
819
|
+
const keyFile = Underpost.secret.keyFile();
|
|
820
|
+
if (!fs.existsSync(keyFile)) {
|
|
821
|
+
const alternatives = Underpost.secret
|
|
822
|
+
.keyFileCandidates()
|
|
823
|
+
.filter((candidate) => candidate !== keyFile && fs.existsSync(candidate));
|
|
824
|
+
throw new Error(
|
|
825
|
+
`Age private key not found: ${keyFile} (running as uid ${process.getuid?.() ?? '?'})` +
|
|
826
|
+
(alternatives.length
|
|
827
|
+
? `. A key does exist at ${alternatives.join(', ')} — re-run with ` +
|
|
828
|
+
`SOPS_AGE_KEY_FILE=<path>, or copy it to ${keyFile}.`
|
|
829
|
+
: `. Run: underpost secret setup`),
|
|
830
|
+
);
|
|
831
|
+
}
|
|
832
|
+
const mode = fs.statSync(keyFile).mode & 0o777;
|
|
833
|
+
if (mode & 0o077)
|
|
834
|
+
throw new Error(
|
|
835
|
+
`Age private key ${keyFile} is group/world accessible (mode ${mode.toString(8)}). ` +
|
|
836
|
+
`Run: chmod 600 ${keyFile}`,
|
|
837
|
+
);
|
|
838
|
+
return keyFile;
|
|
839
|
+
},
|
|
840
|
+
|
|
841
|
+
/**
|
|
842
|
+
* @method managedSecrets
|
|
843
|
+
* @description Names of every Secret wired to prefer the encrypted store, with the origin
|
|
844
|
+
* seed path as fallback. Used for coverage reporting.
|
|
845
|
+
* @returns {Array<string>} Managed Secret names.
|
|
846
|
+
* @memberof UnderpostSecret
|
|
847
|
+
*/
|
|
848
|
+
managedSecrets() {
|
|
849
|
+
return [...MANAGED_SECRETS];
|
|
850
|
+
},
|
|
851
|
+
|
|
852
|
+
/**
|
|
853
|
+
* @method seedSources
|
|
854
|
+
* @description Origin seed files a secret can be onboarded from, as `{ dataKey: path }`.
|
|
855
|
+
* The mapping is the contract between the plaintext seeding in cluster init
|
|
856
|
+
* (`--from-file=<key>=<path>`) and the keys a workload's `secretKeyRef` expects, so an
|
|
857
|
+
* onboarded manifest carries exactly the keys the workload already reads.
|
|
858
|
+
* @param {string} name - Secret name (e.g. 'postgres-secret').
|
|
859
|
+
* @returns {Object<string, string>} Data key to seed file path; empty for unknown secrets.
|
|
860
|
+
* @memberof UnderpostSecret
|
|
861
|
+
*/
|
|
862
|
+
seedSources(name) {
|
|
863
|
+
return Object.fromEntries(
|
|
864
|
+
Object.entries(ORIGIN_SEED_FILES[name] || {}).map(([dataKey, entry]) => [
|
|
865
|
+
dataKey,
|
|
866
|
+
originSeedPath(seedDescriptor(entry).file),
|
|
867
|
+
]),
|
|
868
|
+
);
|
|
869
|
+
},
|
|
870
|
+
|
|
871
|
+
/**
|
|
872
|
+
* @method seedValues
|
|
873
|
+
* @description Reads a secret's origin credentials as `{ dataKey: value }`, extracting the
|
|
874
|
+
* named field where the origin is a structured document rather than a bare credential file.
|
|
875
|
+
* Returns only the keys whose origin file is present, so callers can distinguish a partial
|
|
876
|
+
* origin from an absent one.
|
|
877
|
+
* @param {string} name - Managed secret name.
|
|
878
|
+
* @returns {Object<string, string>} Data key to credential value.
|
|
879
|
+
* @memberof UnderpostSecret
|
|
880
|
+
*/
|
|
881
|
+
seedValues(name) {
|
|
882
|
+
const values = {};
|
|
883
|
+
for (const [dataKey, entry] of Object.entries(ORIGIN_SEED_FILES[name] || {})) {
|
|
884
|
+
const { file, json } = seedDescriptor(entry);
|
|
885
|
+
const path = originSeedPath(file);
|
|
886
|
+
if (!fs.existsSync(path)) continue;
|
|
887
|
+
const raw = fs.readFileSync(path, 'utf8');
|
|
888
|
+
if (!json) {
|
|
889
|
+
values[dataKey] = raw.trim();
|
|
890
|
+
continue;
|
|
891
|
+
}
|
|
892
|
+
let parsed;
|
|
893
|
+
try {
|
|
894
|
+
parsed = JSON.parse(raw);
|
|
895
|
+
} catch (error) {
|
|
896
|
+
throw new Error(`[${name}] origin ${path} is not valid JSON: ${error.message}`);
|
|
897
|
+
}
|
|
898
|
+
if (parsed?.[json] === undefined) throw new Error(`[${name}] origin ${path} has no '${json}' field`);
|
|
899
|
+
values[dataKey] = `${parsed[json]}`;
|
|
900
|
+
}
|
|
901
|
+
return values;
|
|
902
|
+
},
|
|
903
|
+
|
|
904
|
+
/**
|
|
905
|
+
* Environment keys that seed a managed Secret: a fixed mapping, or every key the Secret's
|
|
906
|
+
* configuration scope entitles it to.
|
|
907
|
+
*/
|
|
908
|
+
seedEnvKeys(name) {
|
|
909
|
+
const scope = SECRET_ENV_SCOPES[name];
|
|
910
|
+
if (!scope) return { ...(SECRET_ENV_KEYS[name] || {}) };
|
|
911
|
+
return Object.fromEntries(Object.keys(scopeValuesFactory(process.env, scope)).map((key) => [key, key]));
|
|
912
|
+
},
|
|
913
|
+
|
|
914
|
+
/** Resolves present environment-backed seed values without logging them. */
|
|
915
|
+
seedEnvValues(name, options = {}) {
|
|
916
|
+
if (name === 'grafana-admin') {
|
|
917
|
+
const credentials = Underpost.host.grafanaAdmin({ ...options, required: false });
|
|
317
918
|
return {
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
namespace: field(/^\s{2,}namespace:\s*(\S+)/m),
|
|
321
|
-
encrypted: /^sops:/m.test(content) && /ENC\[AES256_GCM/.test(content),
|
|
919
|
+
...(credentials.username ? { 'admin-user': credentials.username } : {}),
|
|
920
|
+
...(credentials.password ? { 'admin-password': credentials.password } : {}),
|
|
322
921
|
};
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
922
|
+
}
|
|
923
|
+
// Every other env-mapped secret reads straight from the process environment, which on the
|
|
924
|
+
// host already carries the global underpost `.env` (loaded with override in src/cli/index.js).
|
|
925
|
+
return Object.entries(Underpost.secret.seedEnvKeys(name)).reduce((values, [dataKey, envKey]) => {
|
|
926
|
+
const value = process.env[envKey];
|
|
927
|
+
if (value !== undefined && `${value}`.length > 0) values[dataKey] = value;
|
|
928
|
+
return values;
|
|
929
|
+
}, {});
|
|
930
|
+
},
|
|
931
|
+
|
|
932
|
+
/**
|
|
933
|
+
* @method applyFromOriginSeed
|
|
934
|
+
* @description Projects a workload secret straight from its origin seed files into a
|
|
935
|
+
* Kubernetes Secret, for a cluster whose credentials are not onboarded into the encrypted
|
|
936
|
+
* store yet.
|
|
937
|
+
*
|
|
938
|
+
* The fallback half of `applyIfPresent(name) || applyFromOriginSeed(name)`: SOPS/Age stays
|
|
939
|
+
* the source of truth wherever a manifest exists, and this keeps a not-yet-onboarded
|
|
940
|
+
* cluster deploying exactly as before. Paths come from {@link UnderpostSecret.workload.seedSources},
|
|
941
|
+
* so callers never spell out a credential path of their own.
|
|
942
|
+
*
|
|
943
|
+
* Idempotent: `--dry-run=client | kubectl apply` converges on the seed contents.
|
|
944
|
+
* @param {string} name - Managed secret name (e.g. 'postgres-secret').
|
|
945
|
+
* @param {string} [namespace='default'] - Target namespace.
|
|
946
|
+
* @returns {boolean} True when the Secret was applied; false when no seed file is present.
|
|
947
|
+
* @memberof UnderpostSecret
|
|
948
|
+
*/
|
|
949
|
+
applyFromOriginSeed(name, namespace = 'default') {
|
|
950
|
+
const sources = Underpost.secret.seedSources(name);
|
|
951
|
+
const envKeys = Underpost.secret.seedEnvKeys(name);
|
|
952
|
+
const fileKeys = Object.keys(sources);
|
|
953
|
+
if (fileKeys.length === 0 && Object.keys(envKeys).length === 0) {
|
|
954
|
+
logger.warn('No origin seed registered for secret; nothing to project', { name });
|
|
955
|
+
return false;
|
|
956
|
+
}
|
|
957
|
+
// Seed files first, then the environment — the same precedence `setup` applies, so a
|
|
958
|
+
// credential that only ever lived in the host CLI's environment can still reach a workload
|
|
959
|
+
// as a Kubernetes Secret instead of as a bind mount of the directory holding it.
|
|
960
|
+
const values = { ...Underpost.secret.seedEnvValues(name), ...Underpost.secret.seedValues(name) };
|
|
961
|
+
const dataKeys = [...new Set([...fileKeys, ...Object.keys(envKeys)])].filter((key) => values[key] !== undefined);
|
|
962
|
+
if (dataKeys.length === 0) {
|
|
963
|
+
logger.warn('No origin seed present for secret; nothing to project', { name, sources, envKeys });
|
|
964
|
+
return false;
|
|
965
|
+
}
|
|
966
|
+
// A key backed only by a seed file is a contract: half a database credential is worse than
|
|
967
|
+
// none, so a partial set of those still fails. A key with an environment fallback is
|
|
968
|
+
// ambient — a host that has registered no SSH connection simply contributes nothing, and
|
|
969
|
+
// the workload reports its own missing configuration rather than the apply refusing to run.
|
|
970
|
+
// A scope-backed secret is ambient in whole: its key set is whatever the host environment
|
|
971
|
+
// carries within the scope, so a key absent from this host is a host that does not set it,
|
|
972
|
+
// never an incomplete credential. Only a fixed file mapping can be half-present.
|
|
973
|
+
const missingRequired = SECRET_ENV_SCOPES[name]
|
|
974
|
+
? []
|
|
975
|
+
: fileKeys.filter((key) => !(key in envKeys) && values[key] === undefined);
|
|
976
|
+
if (missingRequired.length > 0)
|
|
977
|
+
throw new Error(
|
|
978
|
+
`[${name}] incomplete origin seed: ${missingRequired.map((key) => sources[key]).join(', ')} missing`,
|
|
979
|
+
);
|
|
980
|
+
const missingOptional = Object.keys(envKeys).filter((key) => values[key] === undefined);
|
|
981
|
+
if (missingOptional.length > 0)
|
|
982
|
+
logger.warn('Secret projected without environment-only keys this host does not set', {
|
|
983
|
+
name,
|
|
984
|
+
missing: missingOptional.map((key) => `$${envKeys[key]}`),
|
|
985
|
+
});
|
|
986
|
+
// Values are staged on tmpfs and projected with `--from-file` rather than passed as
|
|
987
|
+
// `--from-literal`: a literal puts the credential in the command string, where it is
|
|
988
|
+
// visible in the process table for the life of the call.
|
|
989
|
+
const stageDir = `/dev/shm/underpost-origin-seed-${name}`;
|
|
990
|
+
try {
|
|
991
|
+
stageDirSync(stageDir);
|
|
992
|
+
const fromFile = dataKeys
|
|
993
|
+
.map((key) => `--from-file=${key}=${writeStageFileSync(`${stageDir}/${key}`, values[key])}`)
|
|
994
|
+
.join(' ');
|
|
995
|
+
// No `sudo`: the staged files are owned by this user at 0600, and elevating only to read
|
|
996
|
+
// them would make the manifest generation run as root for no gain.
|
|
997
|
+
shellExec(
|
|
998
|
+
`kubectl create secret generic ${name} ${fromFile} --dry-run=client -o yaml | kubectl apply -f - -n ${namespace}`,
|
|
999
|
+
);
|
|
1000
|
+
} finally {
|
|
1001
|
+
fs.removeSync(stageDir);
|
|
1002
|
+
}
|
|
1003
|
+
logger.info('Workload secret projected from origin seed', { name, namespace, keys: dataKeys });
|
|
1004
|
+
return true;
|
|
1005
|
+
},
|
|
1006
|
+
|
|
1007
|
+
/**
|
|
1008
|
+
* @method manifestPath
|
|
1009
|
+
* @description Builds the canonical store path for an encrypted Secret manifest.
|
|
1010
|
+
* @param {string} name - Secret name (e.g. 'postgres-secret').
|
|
1011
|
+
* @param {string} [namespace='default'] - Kubernetes namespace.
|
|
1012
|
+
* @returns {string} Path to the `.enc.yaml` manifest.
|
|
1013
|
+
* @memberof UnderpostSecret
|
|
1014
|
+
*/
|
|
1015
|
+
manifestPath(name, namespace = 'default') {
|
|
1016
|
+
return `${SOPS_SECRETS_DIR}/${namespace}/${name}${SOPS_MANIFEST_EXT}`;
|
|
1017
|
+
},
|
|
1018
|
+
|
|
1019
|
+
/**
|
|
1020
|
+
* @method has
|
|
1021
|
+
* @description Reports whether an encrypted manifest exists for a secret. Lets callers
|
|
1022
|
+
* prefer the SOPS store while keeping the origin seed path for clusters not yet onboarded.
|
|
1023
|
+
* Existence only — integrity is {@link assertManifest}'s job, so a corrupt manifest is a
|
|
1024
|
+
* hard failure rather than a silent slide back to the seed path.
|
|
1025
|
+
* @param {string} name - Secret name.
|
|
1026
|
+
* @param {string} [namespace='default'] - Kubernetes namespace.
|
|
1027
|
+
* @returns {boolean} True when the encrypted manifest is present.
|
|
1028
|
+
* @memberof UnderpostSecret
|
|
1029
|
+
*/
|
|
1030
|
+
has(name, namespace = 'default') {
|
|
1031
|
+
return fs.existsSync(Underpost.secret.manifestPath(name, namespace));
|
|
1032
|
+
},
|
|
1033
|
+
|
|
1034
|
+
/**
|
|
1035
|
+
* @method manifestMeta
|
|
1036
|
+
* @description Reads the unencrypted envelope of a stored manifest: `kind`, `metadata.name`,
|
|
1037
|
+
* `metadata.namespace`, and whether a `sops:` block is present. `encrypted_regex` leaves all
|
|
1038
|
+
* of this in plaintext by design, so the check needs no private key and can run before any
|
|
1039
|
+
* decrypt is attempted.
|
|
1040
|
+
* @param {string} manifestPath - Path to the `.enc.yaml` manifest.
|
|
1041
|
+
* @returns {{kind: string, name: string, namespace: string, encrypted: boolean}} Envelope facts.
|
|
1042
|
+
* @memberof UnderpostSecret
|
|
1043
|
+
*/
|
|
1044
|
+
manifestMeta(manifestPath) {
|
|
1045
|
+
const content = fs.readFileSync(manifestPath, 'utf8');
|
|
1046
|
+
const field = (pattern) => (content.match(pattern) || [])[1] || '';
|
|
1047
|
+
return {
|
|
1048
|
+
kind: field(/^kind:\s*(\S+)/m),
|
|
1049
|
+
name: field(/^\s{2,}name:\s*(\S+)/m),
|
|
1050
|
+
namespace: field(/^\s{2,}namespace:\s*(\S+)/m),
|
|
1051
|
+
encrypted: /^sops:/m.test(content) && /ENC\[AES256_GCM/.test(content),
|
|
1052
|
+
};
|
|
1053
|
+
},
|
|
1054
|
+
|
|
1055
|
+
/**
|
|
1056
|
+
* @method assertManifest
|
|
1057
|
+
* @description Fails closed on a manifest that exists but is not what the caller asked for.
|
|
1058
|
+
* Guards three silent-failure modes that a plain existence check misses: a plaintext file
|
|
1059
|
+
* that was never encrypted (credential disclosure in Git), a non-Secret resource, and a
|
|
1060
|
+
* name/namespace mismatch — the last of which would otherwise apply cleanly while leaving
|
|
1061
|
+
* the workload's `secretKeyRef` permanently unresolvable.
|
|
1062
|
+
* @param {string} manifestPath - Path to the `.enc.yaml` manifest.
|
|
1063
|
+
* @param {object} [expect={}] - Expected envelope.
|
|
1064
|
+
* @param {string} [expect.name] - Required `metadata.name`.
|
|
1065
|
+
* @param {string} [expect.namespace] - Required `metadata.namespace` when the manifest sets one.
|
|
1066
|
+
* @memberof UnderpostSecret
|
|
1067
|
+
*/
|
|
1068
|
+
assertManifest(manifestPath, expect = {}) {
|
|
1069
|
+
if (!fs.existsSync(manifestPath)) throw new Error(`Encrypted manifest not found: ${manifestPath}`);
|
|
1070
|
+
const meta = Underpost.secret.manifestMeta(manifestPath);
|
|
1071
|
+
if (!meta.encrypted)
|
|
1072
|
+
throw new Error(
|
|
1073
|
+
`${manifestPath} carries no sops metadata — it is not encrypted. Refusing to apply; ` +
|
|
1074
|
+
`treat any credential in it as disclosed and rotate it.`,
|
|
1075
|
+
);
|
|
1076
|
+
if (meta.kind && meta.kind !== 'Secret') throw new Error(`${manifestPath} is a ${meta.kind}, not a Secret`);
|
|
1077
|
+
if (expect.name && meta.name && meta.name !== expect.name)
|
|
1078
|
+
throw new Error(
|
|
1079
|
+
`${manifestPath} declares metadata.name "${meta.name}" but is stored as "${expect.name}". ` +
|
|
1080
|
+
`Applying it would leave secretKeyRef "${expect.name}" unresolved.`,
|
|
1081
|
+
);
|
|
1082
|
+
if (expect.namespace && meta.namespace && meta.namespace !== expect.namespace)
|
|
1083
|
+
throw new Error(
|
|
1084
|
+
`${manifestPath} declares metadata.namespace "${meta.namespace}" but is being applied to ` +
|
|
1085
|
+
`"${expect.namespace}".`,
|
|
1086
|
+
);
|
|
1087
|
+
},
|
|
1088
|
+
|
|
1089
|
+
/**
|
|
1090
|
+
* @method localRecipients
|
|
1091
|
+
* @description Every Age recipient this host holds a private key for. A key file may carry
|
|
1092
|
+
* more than one identity — that is exactly how a host joins a store it did not create, by
|
|
1093
|
+
* appending the origin host's key alongside its own — so this returns all of them rather
|
|
1094
|
+
* than assuming one. Never throws: an absent or unreadable key file is a legitimate state
|
|
1095
|
+
* for a host that has not been onboarded yet, reported as an empty set.
|
|
1096
|
+
* @returns {Array<string>} The `age1…` recipients derived from the local key file.
|
|
1097
|
+
* @memberof UnderpostSecret
|
|
1098
|
+
*/
|
|
1099
|
+
localRecipients() {
|
|
1100
|
+
const keyFile = Underpost.secret.keyFile();
|
|
1101
|
+
if (!fs.existsSync(keyFile)) return [];
|
|
1102
|
+
const output = shellExec(`age-keygen -y "${keyFile}"`, {
|
|
1103
|
+
stdout: true,
|
|
1104
|
+
silent: true,
|
|
1105
|
+
silentOnError: true,
|
|
1106
|
+
disableLog: true,
|
|
1107
|
+
});
|
|
1108
|
+
return [...new Set(`${output || ''}`.match(/age1[0-9a-z]+/g) || [])];
|
|
1109
|
+
},
|
|
1110
|
+
|
|
1111
|
+
/**
|
|
1112
|
+
* @method recipient
|
|
1113
|
+
* @description Derives the primary Age public recipient from the private key — the one new
|
|
1114
|
+
* manifests are encrypted to. The reverse is not possible, so this is safe to log and to
|
|
1115
|
+
* commit into `.sops.yaml`.
|
|
1116
|
+
* @returns {string} The `age1…` public recipient.
|
|
1117
|
+
* @memberof UnderpostSecret
|
|
1118
|
+
*/
|
|
1119
|
+
recipient() {
|
|
1120
|
+
const keyFile = Underpost.secret.keyFile();
|
|
1121
|
+
if (!fs.existsSync(keyFile)) throw new Error(`Age private key not found: ${keyFile}`);
|
|
1122
|
+
const recipients = Underpost.secret.localRecipients();
|
|
1123
|
+
if (recipients.length === 0)
|
|
1124
|
+
throw new Error(`No Age identity could be read from ${keyFile}. Run: underpost secret setup`);
|
|
1125
|
+
return recipients[0];
|
|
1126
|
+
},
|
|
1127
|
+
|
|
1128
|
+
/**
|
|
1129
|
+
* @method decryptable
|
|
1130
|
+
* @description Reports whether the local key can open a stored manifest, by set-intersecting
|
|
1131
|
+
* the manifest's plaintext `sops:` recipients with the identities this host holds. Needs no
|
|
1132
|
+
* decrypt attempt and no private key material, so it is safe to call as a pre-flight on every
|
|
1133
|
+
* manifest before the first mutation.
|
|
1134
|
+
* @param {string} manifestPath - Path to the `.enc.yaml` manifest.
|
|
1135
|
+
* @param {Array<string>} [held] - Locally held recipients; resolved from the key file when omitted.
|
|
1136
|
+
* @returns {boolean} True when at least one recipient of the manifest is held locally.
|
|
1137
|
+
* @memberof UnderpostSecret
|
|
1138
|
+
*/
|
|
1139
|
+
decryptable(manifestPath, held = Underpost.secret.localRecipients()) {
|
|
1140
|
+
if (held.length === 0) return false;
|
|
1141
|
+
return Underpost.secret.manifestRecipients(manifestPath).some((recipient) => held.includes(recipient));
|
|
1142
|
+
},
|
|
1143
|
+
|
|
1144
|
+
/**
|
|
1145
|
+
* @method assertDecryptable
|
|
1146
|
+
* @description Fails closed, and legibly, on the store-adoption trap: a host that pulled an
|
|
1147
|
+
* encrypted store created elsewhere holds a key that is not among the manifests' recipients.
|
|
1148
|
+
* sops reports that as "no identity matched any of the recipients" from inside a decrypt
|
|
1149
|
+
* pipe, which names neither the manifest nor a way out; this raises first, listing every
|
|
1150
|
+
* unreadable manifest, the recipients it is sealed to, the identities this host actually
|
|
1151
|
+
* holds, and the three ways to resolve it.
|
|
1152
|
+
* @param {Array<{namespace: string, name: string, path: string}>} manifests - Manifests to check.
|
|
1153
|
+
* @memberof UnderpostSecret
|
|
1154
|
+
*/
|
|
1155
|
+
assertDecryptable(manifests) {
|
|
1156
|
+
const held = Underpost.secret.localRecipients();
|
|
1157
|
+
const unreadable = manifests.filter((manifest) => !Underpost.secret.decryptable(manifest.path, held));
|
|
1158
|
+
if (unreadable.length === 0) return;
|
|
1159
|
+
const local =
|
|
1160
|
+
held.length > 0 ? held.join(', ') : `(none — no readable Age identity at ${Underpost.secret.keyFile()})`;
|
|
1161
|
+
throw new Error(
|
|
1162
|
+
`${unreadable.length} encrypted manifest(s) are sealed to Age recipients this host does not hold, ` +
|
|
1163
|
+
`so they cannot be decrypted here:\n` +
|
|
1164
|
+
unreadable
|
|
1165
|
+
.map(
|
|
1166
|
+
(manifest) =>
|
|
1167
|
+
` ${manifest.namespace}/${manifest.name} -> ` +
|
|
1168
|
+
`${Underpost.secret.manifestRecipients(manifest.path).join(', ') || 'no age recipients'}`,
|
|
1169
|
+
)
|
|
1170
|
+
.join('\n') +
|
|
1171
|
+
`\n this host holds: ${local}\n` +
|
|
1172
|
+
`Resolve with exactly one of:\n` +
|
|
1173
|
+
` 1. Install the key that already opens them — append the origin host's ` +
|
|
1174
|
+
`${Underpost.secret.keyFile()} to this host's own (one file may hold several identities), ` +
|
|
1175
|
+
`chmod 600 it, then re-run.\n` +
|
|
1176
|
+
` 2. Re-key the store from a host that still holds that key: ` +
|
|
1177
|
+
`underpost secret rotate --args recipient=<this host's recipient>, commit engine-private/secrets, ` +
|
|
1178
|
+
`pull here, then re-run.\n` +
|
|
1179
|
+
` 3. Re-onboard from this host's origin seed files, replacing the stored manifests: ` +
|
|
1180
|
+
`underpost secret setup --force. Valid only when those seed files carry the credentials the ` +
|
|
1181
|
+
`cluster already runs on — any regenerated value must also be applied to the running datastore.`,
|
|
1182
|
+
);
|
|
1183
|
+
},
|
|
1184
|
+
|
|
1185
|
+
/**
|
|
1186
|
+
* @method init
|
|
1187
|
+
* @description Generates the Age keypair and the `.sops.yaml` creation rule when absent.
|
|
1188
|
+
* Idempotent, and never overwrites an existing key: regenerating would orphan every manifest
|
|
1189
|
+
* already encrypted to the previous recipient, with no way to recover them.
|
|
1190
|
+
* @memberof UnderpostSecret
|
|
1191
|
+
*/
|
|
1192
|
+
init() {
|
|
1193
|
+
Underpost.secret.assertTooling(['age-keygen', 'sops']);
|
|
1194
|
+
const keyFile = Underpost.secret.keyFile();
|
|
1195
|
+
if (fs.existsSync(keyFile)) logger.info(`Age key already present; reusing ${keyFile}`);
|
|
1196
|
+
else {
|
|
1197
|
+
fs.ensureDirSync(keyFile.slice(0, keyFile.lastIndexOf('/')));
|
|
1198
|
+
shellExec(`umask 077 && age-keygen -o "${keyFile}"`);
|
|
1199
|
+
}
|
|
1200
|
+
shellExec(`chmod 600 "${keyFile}"`);
|
|
1201
|
+
|
|
1202
|
+
const recipient = Underpost.secret.recipient();
|
|
1203
|
+
const sopsConfPath = `${SOPS_SECRETS_DIR}/.sops.yaml`;
|
|
1204
|
+
if (fs.existsSync(sopsConfPath)) {
|
|
1205
|
+
logger.info(`Creation rules already present; leaving ${sopsConfPath} intact`);
|
|
1206
|
+
Underpost.secret.repairCreationRules();
|
|
1207
|
+
Underpost.secret.ensureCreationRecipient(recipient);
|
|
1208
|
+
} else {
|
|
1209
|
+
fs.outputFileSync(
|
|
1210
|
+
sopsConfPath,
|
|
1211
|
+
[
|
|
1212
|
+
'creation_rules:',
|
|
1213
|
+
` - path_regex: ${SOPS_MANIFEST_PATH_REGEX}`,
|
|
1214
|
+
` encrypted_regex: '${SOPS_ENCRYPTED_REGEX}'`,
|
|
1215
|
+
` age: ${recipient}`,
|
|
1216
|
+
'',
|
|
1217
|
+
].join('\n'),
|
|
1218
|
+
'utf8',
|
|
1219
|
+
);
|
|
1220
|
+
logger.info(`Created ${sopsConfPath}`);
|
|
1221
|
+
}
|
|
1222
|
+
logger.info(`Age recipient: ${recipient}`);
|
|
1223
|
+
logger.warn(`Back up ${keyFile} offline. Without it every encrypted manifest is unrecoverable.`);
|
|
1224
|
+
},
|
|
1225
|
+
|
|
1226
|
+
/**
|
|
1227
|
+
* @method ensureCreationRecipient
|
|
1228
|
+
* @description Registers this host's recipient in an inherited `.sops.yaml` so anything it
|
|
1229
|
+
* encrypts from now on, it can also decrypt. Without this, a host that pulled a store created
|
|
1230
|
+
* elsewhere encrypts to the *other* host's recipient only, producing manifests it cannot read
|
|
1231
|
+
* back — a failure that surfaces later as an opaque decrypt error rather than at write time.
|
|
1232
|
+
*
|
|
1233
|
+
* Strictly additive: no existing recipient loses access, and existing manifests are left
|
|
1234
|
+
* untouched, since re-keying them requires a private key that can still decrypt (see
|
|
1235
|
+
* {@link rotate}). Left alone when the rule lists no `age:` recipients at all, which means a
|
|
1236
|
+
* deliberately non-Age rule rather than a store this host should join.
|
|
1237
|
+
* @param {string} recipient - This host's `age1…` public recipient.
|
|
1238
|
+
* @returns {boolean} True when the creation rule was rewritten.
|
|
1239
|
+
* @memberof UnderpostSecret
|
|
1240
|
+
*/
|
|
1241
|
+
ensureCreationRecipient(recipient) {
|
|
1242
|
+
const confPath = `${SOPS_SECRETS_DIR}/.sops.yaml`;
|
|
1243
|
+
if (!fs.existsSync(confPath) || !recipient) return false;
|
|
1244
|
+
const current = Underpost.secret.creationRecipients();
|
|
1245
|
+
if (current.includes(recipient)) return false;
|
|
1246
|
+
if (current.length === 0) {
|
|
1247
|
+
logger.warn(
|
|
1248
|
+
`${confPath} declares no age recipients; leaving it untouched. Add ${recipient} manually if this ` +
|
|
1249
|
+
`host is meant to encrypt into this store.`,
|
|
1250
|
+
);
|
|
1251
|
+
return false;
|
|
1252
|
+
}
|
|
1253
|
+
Underpost.secret.writeCreationRecipients([...current, recipient]);
|
|
1254
|
+
logger.warn(
|
|
1255
|
+
`Registered this host's recipient in ${confPath} so manifests it encrypts stay readable here. ` +
|
|
1256
|
+
`Existing manifests are NOT re-keyed by this — run \`underpost secret rotate --args recipient=` +
|
|
1257
|
+
`${recipient}\` from a host that can still decrypt them, then commit ${SOPS_SECRETS_DIR}.`,
|
|
1258
|
+
{ added: recipient, recipients: [...current, recipient] },
|
|
1259
|
+
);
|
|
1260
|
+
return true;
|
|
1261
|
+
},
|
|
1262
|
+
|
|
1263
|
+
/**
|
|
1264
|
+
* @method repairCreationRules
|
|
1265
|
+
* @description Rewrites a `path_regex` that can never match, in place, preserving recipients
|
|
1266
|
+
* and every other setting. Configs written before the relative-path semantics were understood
|
|
1267
|
+
* carry an `engine-private/secrets/` prefix; because sops matches relative to the directory
|
|
1268
|
+
* holding `.sops.yaml`, that rule matches nothing and every encrypt fails with
|
|
1269
|
+
* "no matching creation rules found". Repairs only that known-broken form, so a deliberately
|
|
1270
|
+
* customized rule is left alone.
|
|
1271
|
+
* @returns {boolean} True when the file was rewritten.
|
|
1272
|
+
* @memberof UnderpostSecret
|
|
1273
|
+
*/
|
|
1274
|
+
repairCreationRules() {
|
|
1275
|
+
const confPath = `${SOPS_SECRETS_DIR}/.sops.yaml`;
|
|
1276
|
+
if (!fs.existsSync(confPath)) return false;
|
|
1277
|
+
const lines = fs.readFileSync(confPath, 'utf8').split('\n');
|
|
1278
|
+
// The rule is the first key of a YAML list item, so the line carries a `- ` marker that
|
|
1279
|
+
// has to be preserved: ` - path_regex: …`.
|
|
1280
|
+
const brokenRule = /^(\s*(?:-\s*)?)path_regex:\s*.*engine-private\/secrets\//;
|
|
1281
|
+
const index = lines.findIndex((line) => brokenRule.test(line));
|
|
1282
|
+
if (index === -1) return false;
|
|
1283
|
+
const prefix = lines[index].match(brokenRule)[1];
|
|
1284
|
+
const previous = lines[index].trim();
|
|
1285
|
+
lines[index] = `${prefix}path_regex: ${SOPS_MANIFEST_PATH_REGEX}`;
|
|
1286
|
+
fs.writeFileSync(confPath, lines.join('\n'), 'utf8');
|
|
1287
|
+
logger.warn(
|
|
1288
|
+
`Repaired an unmatchable creation rule in ${confPath}: sops matches path_regex relative to ` +
|
|
1289
|
+
`that file's own directory, so the store prefix never matched.`,
|
|
1290
|
+
{ from: previous, to: `path_regex: ${SOPS_MANIFEST_PATH_REGEX}` },
|
|
1291
|
+
);
|
|
1292
|
+
return true;
|
|
1293
|
+
},
|
|
1294
|
+
|
|
1295
|
+
/**
|
|
1296
|
+
* @method encrypt
|
|
1297
|
+
* @description Encrypts a plaintext Secret manifest into the Git-tracked store and shreds the
|
|
1298
|
+
* source. Recipients resolve from the committed `.sops.yaml`, so a manifest cannot be
|
|
1299
|
+
* encrypted to an unlisted key. Author the plaintext under `/dev/shm` so it never touches
|
|
1300
|
+
* persistent storage.
|
|
1301
|
+
* Written via a staged temp file and moved into place only after the output validates. A
|
|
1302
|
+
* bare `sops … > out` redirect has the shell truncate `out` before sops runs, so a failed
|
|
1303
|
+
* encrypt would destroy an existing manifest and leave a zero-byte file in its place.
|
|
1304
|
+
* @param {string} plaintextPath - Path to the plaintext Secret manifest.
|
|
1305
|
+
* @param {string} [namespace='default'] - Target namespace directory in the store.
|
|
1306
|
+
* @param {object} [options={}] - Encryption options.
|
|
1307
|
+
* @param {boolean} [options.force=false] - Replace an existing manifest at the target path.
|
|
1308
|
+
* @returns {string} Path of the written encrypted manifest.
|
|
1309
|
+
* @memberof UnderpostSecret
|
|
1310
|
+
*/
|
|
1311
|
+
encrypt(plaintextPath, namespace = 'default', options = {}) {
|
|
1312
|
+
Underpost.secret.assertTooling(['sops']);
|
|
1313
|
+
if (!plaintextPath || !fs.existsSync(plaintextPath))
|
|
1314
|
+
throw new Error(`Plaintext manifest not found: ${plaintextPath}`);
|
|
1315
|
+
const sopsConfPath = `${SOPS_SECRETS_DIR}/.sops.yaml`;
|
|
1316
|
+
if (!fs.existsSync(sopsConfPath))
|
|
1317
|
+
throw new Error(`Missing creation rules: ${sopsConfPath} (run: underpost secret setup)`);
|
|
1318
|
+
|
|
1319
|
+
const sourceMeta = Underpost.secret.manifestMeta(plaintextPath);
|
|
1320
|
+
if (sourceMeta.encrypted)
|
|
1321
|
+
throw new Error(
|
|
1322
|
+
`${plaintextPath} already carries sops metadata. Re-encrypting would double-wrap it; ` +
|
|
1323
|
+
`edit it in place with: sops ${plaintextPath}`,
|
|
1324
|
+
);
|
|
1325
|
+
|
|
1326
|
+
const name = plaintextPath
|
|
1327
|
+
.split('/')
|
|
1328
|
+
.pop()
|
|
1329
|
+
.replace(/\.ya?ml$/, '');
|
|
1330
|
+
const outPath = Underpost.secret.manifestPath(name, namespace);
|
|
1331
|
+
if (fs.existsSync(outPath) && !options.force)
|
|
1332
|
+
throw new Error(`${outPath} already exists. Edit it with \`sops ${outPath}\`, or pass --force to replace.`);
|
|
1333
|
+
fs.ensureDirSync(`${SOPS_SECRETS_DIR}/${namespace}`);
|
|
1334
|
+
|
|
1335
|
+
// Encrypt to a temp file and move into place only on success. A bare `sops … > out` has the
|
|
1336
|
+
// shell truncate `out` before sops runs, so a failed encrypt destroys the manifest that was
|
|
1337
|
+
// already there and leaves an empty file the apply path would happily skip over.
|
|
1338
|
+
const stagePath = `${outPath}.staged`;
|
|
1339
|
+
try {
|
|
1340
|
+
// `--filename-override` makes sops match creation_rules against the destination path
|
|
1341
|
+
// rather than the tmpfs source. Without it the rule never matches, because the plaintext
|
|
1342
|
+
// deliberately lives outside the store (in /dev/shm) and is not named `*.enc.yaml`.
|
|
1343
|
+
shellExec(
|
|
1344
|
+
`sops --config "${sopsConfPath}" --filename-override "${outPath}" ` +
|
|
1345
|
+
`--encrypt "${plaintextPath}" > "${stagePath}"`,
|
|
1346
|
+
);
|
|
1347
|
+
Underpost.secret.assertManifest(stagePath, { name });
|
|
1348
|
+
// Sealing to a recipient held elsewhere is legitimate (encrypting *for* another host),
|
|
1349
|
+
// so this warns rather than fails — but it is also the shape of the store-adoption trap,
|
|
1350
|
+
// where it would otherwise only surface at the next apply.
|
|
1351
|
+
if (!Underpost.secret.decryptable(stagePath))
|
|
1352
|
+
logger.warn(
|
|
1353
|
+
`${outPath} is sealed to ${Underpost.secret.manifestRecipients(stagePath).join(', ')}, none of ` +
|
|
1354
|
+
`which this host holds a private key for — it cannot be decrypted here. Add this host's recipient ` +
|
|
1355
|
+
`to ${sopsConfPath} and re-encrypt if that is not intended.`,
|
|
345
1356
|
);
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
1357
|
+
fs.moveSync(stagePath, outPath, { overwrite: true });
|
|
1358
|
+
} finally {
|
|
1359
|
+
fs.removeSync(stagePath);
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
shellExec(`shred -u "${plaintextPath}" 2>/dev/null || rm -f "${plaintextPath}"`, { silentOnError: true });
|
|
1363
|
+
logger.info(`Encrypted -> ${outPath}`);
|
|
1364
|
+
return outPath;
|
|
1365
|
+
},
|
|
1366
|
+
|
|
1367
|
+
/**
|
|
1368
|
+
* @method apply
|
|
1369
|
+
* @description Decrypts every encrypted manifest for a namespace and streams each one
|
|
1370
|
+
* directly into `kubectl apply -f -`. Plaintext exists only in an anonymous kernel pipe.
|
|
1371
|
+
* @param {string} [namespace='default'] - Target namespace.
|
|
1372
|
+
* @param {object} [options={}] - Apply options.
|
|
1373
|
+
* @param {boolean} [options.dryRun=false] - Perform a server-side dry run instead of applying.
|
|
1374
|
+
* @returns {number} Count of manifests applied.
|
|
1375
|
+
* @memberof UnderpostSecret
|
|
1376
|
+
*/
|
|
1377
|
+
applyStore(namespace = 'default', options = {}) {
|
|
1378
|
+
const dir = `${SOPS_SECRETS_DIR}/${namespace}`;
|
|
1379
|
+
if (!fs.existsSync(dir)) throw new Error(`No encrypted secrets for namespace: ${namespace}`);
|
|
1380
|
+
const manifests = Underpost.secret.manifests(namespace);
|
|
1381
|
+
if (manifests.length === 0) throw new Error(`No *${SOPS_MANIFEST_EXT} manifests under ${dir}`);
|
|
1382
|
+
|
|
1383
|
+
// Validate-then-commit. Applying in a single pass means manifest N failing to decrypt
|
|
1384
|
+
// leaves 1..N-1 already live — a half-rotated namespace nobody asked for. The envelope
|
|
1385
|
+
// check plus a server dry run of every manifest catches wrong-key, malformed-YAML, schema
|
|
1386
|
+
// and RBAC failures before the first mutation.
|
|
1387
|
+
for (const manifest of manifests)
|
|
1388
|
+
Underpost.secret.assertManifest(manifest.path, { name: manifest.name, namespace });
|
|
1389
|
+
Underpost.secret.assertDecryptable(manifests);
|
|
1390
|
+
if (!options.dryRun)
|
|
1391
|
+
for (const manifest of manifests)
|
|
1392
|
+
Underpost.secret.applyManifest(manifest.path, namespace, {
|
|
1393
|
+
...options,
|
|
1394
|
+
dryRun: true,
|
|
1395
|
+
quiet: true,
|
|
1396
|
+
});
|
|
1397
|
+
|
|
1398
|
+
for (const manifest of manifests) Underpost.secret.applyManifest(manifest.path, namespace, options);
|
|
1399
|
+
logger.info(`${options.dryRun ? 'Validated' : 'Applied'} ${manifests.length} manifest(s) in ns/${namespace}`);
|
|
1400
|
+
return manifests.length;
|
|
1401
|
+
},
|
|
1402
|
+
|
|
1403
|
+
/** Applies a named subset without requiring access to unrelated manifests in the namespace. */
|
|
1404
|
+
applySelected(names, namespace = 'default', options = {}) {
|
|
1405
|
+
const manifests = [...new Set(names)]
|
|
1406
|
+
.filter((name) => Underpost.secret.has(name, namespace))
|
|
1407
|
+
.map((name) => ({
|
|
1408
|
+
name,
|
|
1409
|
+
namespace,
|
|
1410
|
+
path: Underpost.secret.manifestPath(name, namespace),
|
|
1411
|
+
}));
|
|
1412
|
+
if (manifests.length === 0) throw new Error(`No requested encrypted secrets for namespace: ${namespace}`);
|
|
1413
|
+
for (const manifest of manifests)
|
|
1414
|
+
Underpost.secret.assertManifest(manifest.path, { name: manifest.name, namespace });
|
|
1415
|
+
Underpost.secret.assertDecryptable(manifests);
|
|
1416
|
+
for (const manifest of manifests)
|
|
1417
|
+
Underpost.secret.applyManifest(manifest.path, namespace, {
|
|
1418
|
+
...options,
|
|
1419
|
+
expectName: manifest.name,
|
|
1420
|
+
quiet: true,
|
|
1421
|
+
});
|
|
1422
|
+
logger.info(
|
|
1423
|
+
`${options.dryRun ? 'Validated' : 'Applied'} ${manifests.length} selected manifest(s) in ns/${namespace}`,
|
|
1424
|
+
);
|
|
1425
|
+
return manifests.length;
|
|
1426
|
+
},
|
|
1427
|
+
|
|
1428
|
+
/**
|
|
1429
|
+
* @method applyManifest
|
|
1430
|
+
* @description Streams one encrypted manifest through `sops --decrypt` into `kubectl apply`.
|
|
1431
|
+
* Runs under an explicit `bash -c` with `pipefail`, which is load-bearing: without it a sops
|
|
1432
|
+
* failure yields an empty stream and `kubectl apply -f -` exits 0, silently applying nothing.
|
|
1433
|
+
* `disableLog` keeps the command (and therefore the key path) out of the log stream.
|
|
1434
|
+
* @param {string} manifestPath - Path to the `.enc.yaml` manifest.
|
|
1435
|
+
* @param {string} [namespace='default'] - Target namespace.
|
|
1436
|
+
* @param {object} [options={}] - Apply options.
|
|
1437
|
+
* @param {boolean} [options.dryRun=false] - Perform a server-side dry run instead of applying.
|
|
1438
|
+
* @param {string} [options.expectName] - Require this `metadata.name` in the manifest envelope.
|
|
1439
|
+
* @param {boolean} [options.quiet=false] - Suppress the per-manifest log line.
|
|
1440
|
+
* @memberof UnderpostSecret
|
|
1441
|
+
*/
|
|
1442
|
+
applyManifest(manifestPath, namespace = 'default', options = {}) {
|
|
1443
|
+
// Envelope first: it needs no private key, so a malformed or unencrypted store is reported
|
|
1444
|
+
// as such even on a host whose key is missing or wrongly permissioned.
|
|
1445
|
+
Underpost.secret.assertManifest(manifestPath, { name: options.expectName, namespace });
|
|
1446
|
+
// Recipient set next: a manifest sealed to a key this host does not hold fails inside the
|
|
1447
|
+
// decrypt pipe with an error that names neither the file nor a remedy.
|
|
1448
|
+
Underpost.secret.assertDecryptable([
|
|
1449
|
+
{ namespace, name: options.expectName || manifestPath.split('/').pop(), path: manifestPath },
|
|
1450
|
+
]);
|
|
1451
|
+
Underpost.secret.assertTooling(['sops']);
|
|
1452
|
+
const keyFile = Underpost.secret.assertKeyFile();
|
|
1453
|
+
const dryRun = options.dryRun ? ' --dry-run=server' : '';
|
|
1454
|
+
shellExec(
|
|
1455
|
+
`bash -c 'set -o pipefail; SOPS_AGE_KEY_FILE="${keyFile}" sops --decrypt "${manifestPath}" ` +
|
|
1456
|
+
`| kubectl apply -f -${dryRun} -n "${namespace}"'`,
|
|
1457
|
+
{ disableLog: true },
|
|
1458
|
+
);
|
|
1459
|
+
if (!options.quiet) logger.info(`${options.dryRun ? 'Dry-run' : 'Applied'} ${manifestPath} -> ns/${namespace}`);
|
|
1460
|
+
},
|
|
1461
|
+
|
|
1462
|
+
/** Decrypts one stored Secret through an anonymous pipe and returns its data in memory. */
|
|
1463
|
+
readData(name, namespace = 'default') {
|
|
1464
|
+
const manifestPath = Underpost.secret.manifestPath(name, namespace);
|
|
1465
|
+
Underpost.secret.assertManifest(manifestPath, { name, namespace });
|
|
1466
|
+
Underpost.secret.assertDecryptable([{ namespace, name, path: manifestPath }]);
|
|
1467
|
+
Underpost.secret.assertTooling(['sops']);
|
|
1468
|
+
const keyFile = Underpost.secret.assertKeyFile();
|
|
1469
|
+
const source = shellExec(
|
|
1470
|
+
`bash -c 'set -o pipefail; SOPS_AGE_KEY_FILE="${keyFile}" sops --decrypt "${manifestPath}" ` +
|
|
1471
|
+
`| kubectl create --dry-run=client -f - -o json'`,
|
|
1472
|
+
{ stdout: true, silent: true, disableLog: true },
|
|
1473
|
+
);
|
|
1474
|
+
const secret = JSON.parse(source);
|
|
1475
|
+
const data = Object.fromEntries(
|
|
1476
|
+
Object.entries(secret.data || {}).map(([key, value]) => [
|
|
1477
|
+
key,
|
|
1478
|
+
Buffer.from(`${value}`, 'base64').toString('utf8'),
|
|
1479
|
+
]),
|
|
1480
|
+
);
|
|
1481
|
+
return { ...data, ...(secret.stringData || {}) };
|
|
1482
|
+
},
|
|
1483
|
+
|
|
1484
|
+
/**
|
|
1485
|
+
* @method applyIfPresent
|
|
1486
|
+
* @description Applies a secret from the SOPS store when an encrypted manifest exists,
|
|
1487
|
+
* reporting whether it did. Single decision point for callers that keep an origin seed path
|
|
1488
|
+
* for clusters not yet onboarded to the encrypted store.
|
|
1489
|
+
*
|
|
1490
|
+
* Falls back to the seed path only when the manifest is *absent*. A manifest that exists but
|
|
1491
|
+
* is corrupt, unencrypted, or names a different Secret raises instead: sliding back to the
|
|
1492
|
+
* seed path there would mask a tampered store and silently deploy stale credentials the
|
|
1493
|
+
* operator believes were replaced.
|
|
1494
|
+
* @param {string} name - Secret name.
|
|
1495
|
+
* @param {string} [namespace='default'] - Kubernetes namespace.
|
|
1496
|
+
* @param {object} [options={}] - Apply options forwarded to {@link applyManifest}.
|
|
1497
|
+
* @returns {boolean} True when the encrypted manifest was applied.
|
|
1498
|
+
* @memberof UnderpostSecret
|
|
1499
|
+
*/
|
|
1500
|
+
applyIfPresent(name, namespace = 'default', options = {}) {
|
|
1501
|
+
if (!Underpost.secret.has(name, namespace)) return false;
|
|
1502
|
+
Underpost.secret.applyManifest(Underpost.secret.manifestPath(name, namespace), namespace, {
|
|
1503
|
+
...options,
|
|
1504
|
+
expectName: name,
|
|
1505
|
+
});
|
|
1506
|
+
return true;
|
|
1507
|
+
},
|
|
1508
|
+
|
|
1509
|
+
/**
|
|
1510
|
+
* @method list
|
|
1511
|
+
* @description Lists encrypted manifests with their Age recipients. Reads only the plaintext
|
|
1512
|
+
* `sops:` metadata block, so no private key is required and this is safe to run anywhere.
|
|
1513
|
+
* @memberof UnderpostSecret
|
|
1514
|
+
*/
|
|
1515
|
+
list() {
|
|
1516
|
+
const manifests = Underpost.secret.manifests();
|
|
1517
|
+
if (manifests.length === 0) return logger.warn(`No encrypted manifests under ${SOPS_SECRETS_DIR}`);
|
|
1518
|
+
for (const manifest of manifests) {
|
|
1519
|
+
const recipients = Underpost.secret.manifestRecipients(manifest.path);
|
|
1520
|
+
console.log(
|
|
1521
|
+
`${manifest.namespace}/${manifest.name}${SOPS_MANIFEST_EXT} -> ${
|
|
1522
|
+
recipients.join(', ') || 'no age recipients'
|
|
1523
|
+
}`,
|
|
1524
|
+
);
|
|
1525
|
+
}
|
|
1526
|
+
},
|
|
1527
|
+
|
|
1528
|
+
/**
|
|
1529
|
+
* @method manifestRecipients
|
|
1530
|
+
* @description Extracts the Age recipients an encrypted manifest is sealed to, from its
|
|
1531
|
+
* plaintext `sops:` metadata block. Requires no private key.
|
|
1532
|
+
* @param {string} manifestPath - Path to the `.enc.yaml` manifest.
|
|
1533
|
+
* @returns {Array<string>} Recipients that can decrypt the manifest.
|
|
1534
|
+
* @memberof UnderpostSecret
|
|
1535
|
+
*/
|
|
1536
|
+
manifestRecipients(manifestPath) {
|
|
1537
|
+
const content = fs.readFileSync(manifestPath, 'utf8');
|
|
1538
|
+
return [...content.matchAll(/recipient:\s*(age1\S+)/g)].map((match) => match[1]);
|
|
1539
|
+
},
|
|
1540
|
+
|
|
1541
|
+
/**
|
|
1542
|
+
* @method rotate
|
|
1543
|
+
* @description Re-keys every encrypted manifest onto a new Age recipient after key
|
|
1544
|
+
* compromise or scheduled rotation. Secret *values* are untouched: `sops updatekeys` only
|
|
1545
|
+
* re-wraps each file's data key, so no workload restart is needed. Requires a private key
|
|
1546
|
+
* that can still decrypt, so rotation must run before the outgoing key is destroyed.
|
|
1547
|
+
*
|
|
1548
|
+
* Additive by default (the outgoing recipient keeps working, which is what a scheduled
|
|
1549
|
+
* rotation wants). `options.pruneRecipients` makes the new recipient the only one, which is
|
|
1550
|
+
* what a compromise wants — and which also revokes every *other* operator and CI/CD key in
|
|
1551
|
+
* the rule, so it additionally requires `options.force` after showing exactly what is lost.
|
|
1552
|
+
* @param {string} recipient - Incoming `age1…` public recipient.
|
|
1553
|
+
* @param {object} [options={}] - Rotation options.
|
|
1554
|
+
* @param {boolean} [options.pruneRecipients=false] - Drop all existing recipients.
|
|
1555
|
+
* @param {Array<string>|string} [options.keepRecipients] - Recipients to retain while pruning
|
|
1556
|
+
* (e.g. the CI/CD key), as an array or comma-separated list.
|
|
1557
|
+
* @param {boolean} [options.force=false] - Confirm an irreversible prune.
|
|
1558
|
+
* @param {boolean} [options.dryRun=false] - Report the plan without rewriting anything.
|
|
1559
|
+
* @returns {{recipients: Array<string>, revoked: Array<string>, rekeyed: number}} Outcome.
|
|
1560
|
+
* @memberof UnderpostSecret
|
|
1561
|
+
*/
|
|
1562
|
+
rotateRecipient(recipient, options = {}) {
|
|
1563
|
+
if (!recipient) throw new Error('Rotation requires --args recipient=<age-public-key>');
|
|
1564
|
+
if (!/^age1[0-9a-z]{20,}$/.test(recipient))
|
|
1565
|
+
throw new Error(`Not a valid Age public recipient: ${recipient} (expected age1…)`);
|
|
1566
|
+
|
|
1567
|
+
Underpost.secret.assertTooling(['sops']);
|
|
1568
|
+
const keyFile = Underpost.secret.assertKeyFile();
|
|
1569
|
+
|
|
1570
|
+
const confPath = `${SOPS_SECRETS_DIR}/.sops.yaml`;
|
|
1571
|
+
if (!fs.existsSync(confPath))
|
|
1572
|
+
throw new Error(`Missing creation rules: ${confPath} (run: underpost secret setup)`);
|
|
1573
|
+
|
|
1574
|
+
// `--args` itself splits on commas, so a multi-recipient keep list cannot use one. Pipe,
|
|
1575
|
+
// semicolon and whitespace all separate here, and a comma still works for a direct caller.
|
|
1576
|
+
const keep = (
|
|
1577
|
+
Array.isArray(options.keepRecipients)
|
|
1578
|
+
? options.keepRecipients
|
|
1579
|
+
: `${options.keepRecipients || ''}`.split(/[,|;\s]+/)
|
|
1580
|
+
)
|
|
1581
|
+
.map((value) => value.trim())
|
|
1582
|
+
.filter(Boolean);
|
|
1583
|
+
const current = Underpost.secret.creationRecipients();
|
|
1584
|
+
const next = options.pruneRecipients ? [...new Set([recipient, ...keep])] : [...new Set([...current, recipient])];
|
|
1585
|
+
const revoked = current.filter((existing) => !next.includes(existing));
|
|
1586
|
+
const manifests = Underpost.secret.manifests();
|
|
1587
|
+
// `updatekeys` re-wraps each data key, which means decrypting it first. A host that cannot
|
|
1588
|
+
// read the store cannot rotate it, dry run included — reporting a plan that can never run
|
|
1589
|
+
// is what sends an operator down the wrong remedy.
|
|
1590
|
+
Underpost.secret.assertDecryptable(manifests);
|
|
1591
|
+
|
|
1592
|
+
if (options.dryRun) {
|
|
1593
|
+
logger.info('Rotation plan (dry run)', { from: current, to: next, revoked, manifests: manifests.length });
|
|
1594
|
+
if (revoked.length)
|
|
1595
|
+
logger.warn(
|
|
1596
|
+
`${revoked.length} recipient(s) would permanently lose access. Confirm none is a CI/CD or ` +
|
|
1597
|
+
`co-operator key before re-running with --force.`,
|
|
1598
|
+
{ revoked },
|
|
351
1599
|
);
|
|
352
|
-
|
|
1600
|
+
return { recipients: next, revoked, rekeyed: 0 };
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
// A prune revokes every recipient not explicitly retained — including CI/CD keys the
|
|
1604
|
+
// operator may not have in mind. Irreversible for anyone holding only a revoked key, so it
|
|
1605
|
+
// is gated behind an explicit confirmation that has to be made after seeing the list.
|
|
1606
|
+
if (revoked.length && !options.force)
|
|
1607
|
+
throw new Error(
|
|
1608
|
+
`Refusing to revoke ${revoked.length} recipient(s) without --force: ${revoked.join(', ')}. ` +
|
|
1609
|
+
`Review with --dry-run, retain any CI/CD key via --keep-recipients <age1…>, then re-run with --force.`,
|
|
1610
|
+
);
|
|
1611
|
+
|
|
1612
|
+
Underpost.secret.writeCreationRecipients(next);
|
|
1613
|
+
for (const manifest of manifests) {
|
|
1614
|
+
// `updatekeys` decrypts the data key with a held private key and re-wraps it for the
|
|
1615
|
+
// recipients now in `.sops.yaml`. disableLog keeps the key path out of the log stream.
|
|
1616
|
+
shellExec(
|
|
1617
|
+
`bash -c 'set -o pipefail; SOPS_AGE_KEY_FILE="${keyFile}" ` +
|
|
1618
|
+
`sops --config "${confPath}" updatekeys --yes "${manifest.path}"'`,
|
|
1619
|
+
{ disableLog: true, silent: true },
|
|
1620
|
+
);
|
|
1621
|
+
// updatekeys is a no-op when it decides nothing changed, and its exit code does not
|
|
1622
|
+
// distinguish that from a successful re-key. Confirm against the file itself, so a
|
|
1623
|
+
// rotation can never be reported as done while a manifest stays on the old recipient.
|
|
1624
|
+
const sealed = Underpost.secret.manifestRecipients(manifest.path);
|
|
1625
|
+
if (!sealed.includes(recipient))
|
|
353
1626
|
throw new Error(
|
|
354
|
-
`${
|
|
355
|
-
`
|
|
1627
|
+
`${manifest.path} is still sealed to ${sealed.join(', ') || 'no recipients'} after updatekeys; ` +
|
|
1628
|
+
`expected ${recipient}. Rotation aborted with the store partially re-keyed — re-run once resolved.`,
|
|
356
1629
|
);
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
1630
|
+
logger.info(`Re-keyed ${manifest.namespace}/${manifest.name}`);
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
logger.info(`Rotated ${manifests.length} manifest(s)`, { recipients: next, revoked });
|
|
1634
|
+
if (revoked.length)
|
|
1635
|
+
logger.warn(
|
|
1636
|
+
`Revoked ${revoked.length} recipient(s); those keys can no longer decrypt any manifest. ` +
|
|
1637
|
+
`Every host applying these secrets now needs a private key for one of: ${next.join(', ')}.`,
|
|
1638
|
+
{ revoked },
|
|
1639
|
+
);
|
|
1640
|
+
return { recipients: next, revoked, rekeyed: manifests.length };
|
|
1641
|
+
},
|
|
1642
|
+
|
|
1643
|
+
/**
|
|
1644
|
+
* @method gitAuthTokenTargets
|
|
1645
|
+
* @description Resolves every GitHub repository that carries a deploy's `GIT_AUTH_TOKEN`:
|
|
1646
|
+
* the private configuration repository its conf lives in, and the engine source repositories
|
|
1647
|
+
* it deploys from — production and test, which are one deploy under two names.
|
|
1648
|
+
*
|
|
1649
|
+
* Naming is delegated to {@link UnderpostRepository} rather than re-derived here, so a
|
|
1650
|
+
* rotation targets exactly the repositories `run pull` and `deploy/lib/host.sh` resolve for
|
|
1651
|
+
* the same deploy. Any reference naming it works — `dd-cyberia`, `engine-cyberia`,
|
|
1652
|
+
* `engine-test-cyberia`, `engine-cyberia-private` or a clone URL — because they all reduce to
|
|
1653
|
+
* the one conf id.
|
|
1654
|
+
*
|
|
1655
|
+
* Each deploy contributes its private conf repository, its production and test engine sources,
|
|
1656
|
+
* its `engine-ghpkg-<conf-id>` package mirror, and every `metadata.repository` its
|
|
1657
|
+
* `conf.instances.json` declares — an instance is a separate product with its own workflows
|
|
1658
|
+
* reading the same token. Derived names that do not exist are dropped by the reachability
|
|
1659
|
+
* probe, so no separate existence check is needed here.
|
|
1660
|
+
*
|
|
1661
|
+
* `dd` fans out across `engine-private/deploy/dd.routes`, so one rotation covers the whole
|
|
1662
|
+
* fleet. `template` is the second meta id: the template lineage carries no conf id, so it
|
|
1663
|
+
* resolves to `pwa-microservices-template`, its `-ghpkg` mirror, and `engine`. The union is
|
|
1664
|
+
* deduplicated: deploys share repositories, and one listed twice would be rotated twice.
|
|
1665
|
+
* @param {object} [options={}] - Resolution options.
|
|
1666
|
+
* @param {string} [options.deployId] - Deploy id, any repository reference naming it, a list
|
|
1667
|
+
* separated by `|`, `;` or whitespace, the meta id `dd` for every deploy in
|
|
1668
|
+
* `engine-private/deploy/dd.routes`, or `template` for the template lineage. Falls back to
|
|
1669
|
+
* `ENGINE_SRC_REPO`, then the monorepo pair.
|
|
1670
|
+
* @param {string} [options.owner] - GitHub owner. Falls back to the owner of `ENGINE_SRC_REPO`,
|
|
1671
|
+
* then `GITHUB_USERNAME`, then `underpostnet`.
|
|
1672
|
+
* @param {string} [options.repos] - Extra targets separated by `|`, `;` or whitespace —
|
|
1673
|
+
* `--args` itself splits on commas, so a list there cannot use one.
|
|
1674
|
+
* @returns {Array<string>} Deduplicated `owner/repo` slugs, private configuration first.
|
|
1675
|
+
* @memberof UnderpostSecret
|
|
1676
|
+
*/
|
|
1677
|
+
gitAuthTokenTargets(options = {}) {
|
|
1678
|
+
const envSource = `${process.env.ENGINE_SRC_REPO ?? ''}`.trim();
|
|
1679
|
+
const owner =
|
|
1680
|
+
`${options.owner ?? ''}`.trim() ||
|
|
1681
|
+
(envSource.includes('/') ? envSource.split('/')[0] : '') ||
|
|
1682
|
+
process.env.GITHUB_USERNAME ||
|
|
1683
|
+
'underpostnet';
|
|
1684
|
+
const requested = `${options.deployId ?? ''}`.trim() || envSource;
|
|
1685
|
+
// `dd` is the meta id every runner reads as "all of dd.routes", resolved through the one
|
|
1686
|
+
// reader the cluster deploys from — a rotation that parsed the route table itself could
|
|
1687
|
+
// cover a different fleet than the one running. An explicit list separates on `|`, `;` or
|
|
1688
|
+
// whitespace, because `--args` has already claimed the comma.
|
|
1689
|
+
const references = requested === 'dd' ? resolveDeployList('dd') : requested.split(/[,|;\s]+/).filter(Boolean);
|
|
1690
|
+
// The fallback fires for an absent route table *and* for an empty one, so it is detected on
|
|
1691
|
+
// the table itself rather than on the file's existence: rotating one invented deploy while
|
|
1692
|
+
// reporting a fleet rotation is the failure this warning exists to prevent.
|
|
1693
|
+
if (requested === 'dd' && readDeployRoutes().length === 0)
|
|
1694
|
+
logger.warn(
|
|
1695
|
+
`No deploy ids in ./engine-private/deploy/dd.routes; 'dd' fell back to ${references.join(', ')} ` +
|
|
1696
|
+
`rather than the fleet. Check out engine-private, or name the deploys explicitly with ` +
|
|
1697
|
+
`--args "deploy-id=dd-one|dd-two".`,
|
|
1698
|
+
);
|
|
1699
|
+
const candidates = [];
|
|
1700
|
+
// Unioned and deduplicated: deploys share repositories (every one of them pairs with the
|
|
1701
|
+
// same engine-private when ENGINE_SRC_PRIVATE_REPO names it), and a repository listed twice
|
|
1702
|
+
// would be rotated twice.
|
|
1703
|
+
if (requested === TEMPLATE_ALIAS) candidates.push(...TEMPLATE_REPOS.map((repo) => `${owner}/${repo}`));
|
|
1704
|
+
else
|
|
1705
|
+
for (const reference of references.length > 0 ? references : ['']) {
|
|
1706
|
+
const confId = Underpost.repo.confIdFactory(reference);
|
|
1707
|
+
const source = `${owner}/${Underpost.repo.engineRepoFactory(confId)}`;
|
|
1708
|
+
const ghpkg = Underpost.repo.ghpkgRepoFactory(confId);
|
|
1709
|
+
candidates.push(
|
|
1710
|
+
// Paired off the source rather than named on its own, so the conf repository and the
|
|
1711
|
+
// engine it configures can never be resolved apart.
|
|
1712
|
+
Underpost.repo.enginePairFactory({ engine: source, account: owner }).enginePrivate,
|
|
1713
|
+
source,
|
|
1714
|
+
`${owner}/${Underpost.repo.engineRepoFactory(confId, { test: true })}`,
|
|
1715
|
+
// The ghpkg mirror and the instance repositories run their own workflows against the
|
|
1716
|
+
// same token, so a rotation that skipped them would leave half the deploy behind.
|
|
1717
|
+
...(ghpkg ? [`${owner}/${ghpkg}`] : []),
|
|
1718
|
+
...(confId ? Underpost.repo.instanceRepos(`dd-${confId}`) : []),
|
|
1719
|
+
);
|
|
1720
|
+
}
|
|
1721
|
+
candidates.push(
|
|
1722
|
+
`${process.env.ENGINE_SRC_PRIVATE_REPO ?? ''}`.trim(),
|
|
1723
|
+
envSource,
|
|
1724
|
+
...`${options.repos ?? ''}`.split(/[,|;\s]+/),
|
|
1725
|
+
);
|
|
1726
|
+
const targets = [];
|
|
1727
|
+
for (const candidate of candidates.filter(Boolean)) {
|
|
1728
|
+
let slug;
|
|
1729
|
+
try {
|
|
1730
|
+
slug = Underpost.repo.repoSlugFactory(candidate);
|
|
1731
|
+
} catch (error) {
|
|
1732
|
+
// One malformed extra target must not take down the rotation of the resolvable ones.
|
|
1733
|
+
logger.warn(`Ignoring unresolvable rotation target: ${candidate}`, { error: error.message });
|
|
1734
|
+
continue;
|
|
1735
|
+
}
|
|
1736
|
+
if (!targets.includes(slug)) targets.push(slug);
|
|
1737
|
+
}
|
|
1738
|
+
return targets;
|
|
1739
|
+
},
|
|
1740
|
+
|
|
1741
|
+
/**
|
|
1742
|
+
* @method plannedTokenSource
|
|
1743
|
+
* @description Names the source a rotation would take its token from, without reading,
|
|
1744
|
+
* minting or prompting for anything. Pure, so `--dry-run` can report the plan truthfully.
|
|
1745
|
+
* @param {object} [options={}] - Rotation options.
|
|
1746
|
+
* @param {string} [options.token] - Token supplied through `--args token=`.
|
|
1747
|
+
* @returns {string} Human-readable source name.
|
|
1748
|
+
* @memberof UnderpostSecret
|
|
1749
|
+
*/
|
|
1750
|
+
plannedTokenSource(options = {}) {
|
|
1751
|
+
if (`${options.token ?? ''}`.trim()) return '--args token';
|
|
1752
|
+
if (stdinIsRedirected()) return 'piped stdin';
|
|
1753
|
+
if (`${process.env[GIT_AUTH_TOKEN_KEY] ?? ''}`.trim()) return `${GIT_AUTH_TOKEN_KEY} environment`;
|
|
1754
|
+
return process.stdin.isTTY ? 'interactive prompt' : '(unavailable: no token, nothing piped, no terminal)';
|
|
1755
|
+
},
|
|
1756
|
+
|
|
1757
|
+
/**
|
|
1758
|
+
* @method probeGitAuthTokenTargets
|
|
1759
|
+
* @description Splits resolved targets into those the current `gh` credential can actually
|
|
1760
|
+
* reach and those it cannot, without writing anything.
|
|
1761
|
+
*
|
|
1762
|
+
* A deploy does not necessarily own every repository its naming implies — a test source repo
|
|
1763
|
+
* often does not exist — so this is what keeps a fleet fan-out from aborting on the first
|
|
1764
|
+
* absent one, and what lets `--dry-run` report the real target set rather than the derived one.
|
|
1765
|
+
* @param {Array<string>} targets - `owner/repo` slugs.
|
|
1766
|
+
* @returns {{reachable: Array<string>, unreachable: Array<string>}} The split.
|
|
1767
|
+
* @memberof UnderpostSecret
|
|
1768
|
+
*/
|
|
1769
|
+
probeGitAuthTokenTargets(targets = []) {
|
|
1770
|
+
const reachable = [];
|
|
1771
|
+
const unreachable = [];
|
|
1772
|
+
for (const repo of targets) {
|
|
1773
|
+
const view = shellExec(`gh repo view "${repo}" --json nameWithOwner,viewerPermission`, {
|
|
373
1774
|
stdout: true,
|
|
374
1775
|
silent: true,
|
|
375
1776
|
silentOnError: true,
|
|
376
1777
|
disableLog: true,
|
|
377
1778
|
});
|
|
378
|
-
|
|
379
|
-
},
|
|
380
|
-
|
|
381
|
-
/**
|
|
382
|
-
* @method recipient
|
|
383
|
-
* @description Derives the primary Age public recipient from the private key — the one new
|
|
384
|
-
* manifests are encrypted to. The reverse is not possible, so this is safe to log and to
|
|
385
|
-
* commit into `.sops.yaml`.
|
|
386
|
-
* @returns {string} The `age1…` public recipient.
|
|
387
|
-
* @memberof UnderpostSecret
|
|
388
|
-
*/
|
|
389
|
-
recipient() {
|
|
390
|
-
const keyFile = Underpost.secret.sops.keyFile();
|
|
391
|
-
if (!fs.existsSync(keyFile)) throw new Error(`Age private key not found: ${keyFile}`);
|
|
392
|
-
const recipients = Underpost.secret.sops.localRecipients();
|
|
393
|
-
if (recipients.length === 0)
|
|
394
|
-
throw new Error(`No Age identity could be read from ${keyFile}. Run: underpost secret sops --init`);
|
|
395
|
-
return recipients[0];
|
|
396
|
-
},
|
|
397
|
-
|
|
398
|
-
/**
|
|
399
|
-
* @method decryptable
|
|
400
|
-
* @description Reports whether the local key can open a stored manifest, by set-intersecting
|
|
401
|
-
* the manifest's plaintext `sops:` recipients with the identities this host holds. Needs no
|
|
402
|
-
* decrypt attempt and no private key material, so it is safe to call as a pre-flight on every
|
|
403
|
-
* manifest before the first mutation.
|
|
404
|
-
* @param {string} manifestPath - Path to the `.enc.yaml` manifest.
|
|
405
|
-
* @param {Array<string>} [held] - Locally held recipients; resolved from the key file when omitted.
|
|
406
|
-
* @returns {boolean} True when at least one recipient of the manifest is held locally.
|
|
407
|
-
* @memberof UnderpostSecret
|
|
408
|
-
*/
|
|
409
|
-
decryptable(manifestPath, held = Underpost.secret.sops.localRecipients()) {
|
|
410
|
-
if (held.length === 0) return false;
|
|
411
|
-
return Underpost.secret.sops.manifestRecipients(manifestPath).some((recipient) => held.includes(recipient));
|
|
412
|
-
},
|
|
413
|
-
|
|
414
|
-
/**
|
|
415
|
-
* @method assertDecryptable
|
|
416
|
-
* @description Fails closed, and legibly, on the store-adoption trap: a host that pulled an
|
|
417
|
-
* encrypted store created elsewhere holds a key that is not among the manifests' recipients.
|
|
418
|
-
* sops reports that as "no identity matched any of the recipients" from inside a decrypt
|
|
419
|
-
* pipe, which names neither the manifest nor a way out; this raises first, listing every
|
|
420
|
-
* unreadable manifest, the recipients it is sealed to, the identities this host actually
|
|
421
|
-
* holds, and the three ways to resolve it.
|
|
422
|
-
* @param {Array<{namespace: string, name: string, path: string}>} manifests - Manifests to check.
|
|
423
|
-
* @memberof UnderpostSecret
|
|
424
|
-
*/
|
|
425
|
-
assertDecryptable(manifests) {
|
|
426
|
-
const held = Underpost.secret.sops.localRecipients();
|
|
427
|
-
const unreadable = manifests.filter((manifest) => !Underpost.secret.sops.decryptable(manifest.path, held));
|
|
428
|
-
if (unreadable.length === 0) return;
|
|
429
|
-
const local =
|
|
430
|
-
held.length > 0 ? held.join(', ') : `(none — no readable Age identity at ${Underpost.secret.sops.keyFile()})`;
|
|
431
|
-
throw new Error(
|
|
432
|
-
`${unreadable.length} encrypted manifest(s) are sealed to Age recipients this host does not hold, ` +
|
|
433
|
-
`so they cannot be decrypted here:\n` +
|
|
434
|
-
unreadable
|
|
435
|
-
.map(
|
|
436
|
-
(manifest) =>
|
|
437
|
-
` ${manifest.namespace}/${manifest.name} -> ` +
|
|
438
|
-
`${Underpost.secret.sops.manifestRecipients(manifest.path).join(', ') || 'no age recipients'}`,
|
|
439
|
-
)
|
|
440
|
-
.join('\n') +
|
|
441
|
-
`\n this host holds: ${local}\n` +
|
|
442
|
-
`Resolve with exactly one of:\n` +
|
|
443
|
-
` 1. Install the key that already opens them — append the origin host's ` +
|
|
444
|
-
`${Underpost.secret.sops.keyFile()} to this host's own (one file may hold several identities), ` +
|
|
445
|
-
`chmod 600 it, then re-run.\n` +
|
|
446
|
-
` 2. Re-key the store from a host that still holds that key: ` +
|
|
447
|
-
`underpost secret sops --rotate --recipient <this host's recipient>, commit engine-private/secrets, ` +
|
|
448
|
-
`pull here, then re-run.\n` +
|
|
449
|
-
` 3. Re-onboard from this host's origin seed files, replacing the stored manifests: ` +
|
|
450
|
-
`underpost run sops-setup --force. Valid only when those seed files carry the credentials the ` +
|
|
451
|
-
`cluster already runs on — any regenerated value must also be applied to the running datastore.`,
|
|
452
|
-
);
|
|
453
|
-
},
|
|
454
|
-
|
|
455
|
-
/**
|
|
456
|
-
* @method init
|
|
457
|
-
* @description Generates the Age keypair and the `.sops.yaml` creation rule when absent.
|
|
458
|
-
* Idempotent, and never overwrites an existing key: regenerating would orphan every manifest
|
|
459
|
-
* already encrypted to the previous recipient, with no way to recover them.
|
|
460
|
-
* @memberof UnderpostSecret
|
|
461
|
-
*/
|
|
462
|
-
init() {
|
|
463
|
-
Underpost.secret.sops.assertTooling(['age-keygen', 'sops']);
|
|
464
|
-
const keyFile = Underpost.secret.sops.keyFile();
|
|
465
|
-
if (fs.existsSync(keyFile)) logger.info(`Age key already present; reusing ${keyFile}`);
|
|
1779
|
+
if (`${view}`.trim()) reachable.push(repo);
|
|
466
1780
|
else {
|
|
467
|
-
|
|
468
|
-
|
|
1781
|
+
unreachable.push(repo);
|
|
1782
|
+
logger.warn(`${repo} does not resolve with the current gh credential; skipping.`);
|
|
469
1783
|
}
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
logger.warn(
|
|
518
|
-
`${confPath} declares no age recipients; leaving it untouched. Add ${recipient} manually if this ` +
|
|
519
|
-
`host is meant to encrypt into this store.`,
|
|
1784
|
+
}
|
|
1785
|
+
return { reachable, unreachable };
|
|
1786
|
+
},
|
|
1787
|
+
|
|
1788
|
+
/**
|
|
1789
|
+
* @method stageGitAuthToken
|
|
1790
|
+
* @description Materializes the replacement token onto tmpfs at mode 600 — the single source
|
|
1791
|
+
* both the GitHub write and the manifest write read from.
|
|
1792
|
+
*
|
|
1793
|
+
* The value never travels as a command argument: `gh secret set` takes it on stdin and the
|
|
1794
|
+
* manifest is built by Node, so it reaches neither the process table nor the command log. An
|
|
1795
|
+
* interactive prompt writes straight into the staged file for the same reason — captured
|
|
1796
|
+
* stdout is logged, a file is not.
|
|
1797
|
+
*
|
|
1798
|
+
* Sources, in order: `--args token=`, piped stdin, the `GIT_AUTH_TOKEN` environment, then a
|
|
1799
|
+
* no-echo terminal prompt. Piping is the one that keeps a token out of both the process table
|
|
1800
|
+
* and the shell history, so it is what automation should use.
|
|
1801
|
+
* @param {string} stagePath - tmpfs path to write the token to.
|
|
1802
|
+
* @param {object} [options={}] - Token sources.
|
|
1803
|
+
* @param {string} [options.token] - The token itself, from `--args token=`.
|
|
1804
|
+
* @returns {{token: string, source: string}} The staged token and where it came from.
|
|
1805
|
+
* @memberof UnderpostSecret
|
|
1806
|
+
*/
|
|
1807
|
+
stageGitAuthToken(stagePath, options = {}) {
|
|
1808
|
+
// `GITHUB_TOKEN` is deliberately not a source: it is the credential `gh` authenticates
|
|
1809
|
+
// *with*, which during a rotation is the outgoing token. Reading it here would re-set the
|
|
1810
|
+
// value being replaced and report a rotation that never happened.
|
|
1811
|
+
const provided = `${options.token ?? ''}`.trim();
|
|
1812
|
+
const inherited = `${process.env[GIT_AUTH_TOKEN_KEY] ?? ''}`.trim();
|
|
1813
|
+
let source;
|
|
1814
|
+
if (provided) {
|
|
1815
|
+
writeStageFileSync(stagePath, provided);
|
|
1816
|
+
source = '--args token';
|
|
1817
|
+
} else if (stdinIsRedirected()) {
|
|
1818
|
+
// Ahead of the environment: a pipe is what the operator chose for this run, while
|
|
1819
|
+
// GIT_AUTH_TOKEN may be an inherited export still holding the outgoing token.
|
|
1820
|
+
writeStageFileSync(stagePath, fs.readFileSync(0, 'utf8'));
|
|
1821
|
+
source = 'piped stdin';
|
|
1822
|
+
} else if (inherited) {
|
|
1823
|
+
writeStageFileSync(stagePath, inherited);
|
|
1824
|
+
source = `${GIT_AUTH_TOKEN_KEY} environment`;
|
|
1825
|
+
} else {
|
|
1826
|
+
if (!process.stdin.isTTY)
|
|
1827
|
+
throw new Error(
|
|
1828
|
+
`[secret] rotate needs the replacement token: pipe it in ` +
|
|
1829
|
+
`(printf %s "$TOKEN" | node bin secret rotate …), pass --args token=<token>, export ` +
|
|
1830
|
+
`${GIT_AUTH_TOKEN_KEY}, or run this from a terminal to be prompted.`,
|
|
520
1831
|
);
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
`${recipient}\` from a host that can still decrypt them, then commit ${SOPS_SECRETS_DIR}.`,
|
|
528
|
-
{ added: recipient, recipients: [...current, recipient] },
|
|
1832
|
+
// Created empty first so the file exists at mode 600 before anything is read into it.
|
|
1833
|
+
writeStageFileSync(stagePath, '');
|
|
1834
|
+
shellExec(
|
|
1835
|
+
`bash -c 'set -o pipefail; umask 077; read -rsp "New ${GIT_AUTH_TOKEN_KEY}: " value </dev/tty; ` +
|
|
1836
|
+
`echo >/dev/tty; printf %s "$value" > "${stagePath}"'`,
|
|
1837
|
+
{ disableLog: true },
|
|
529
1838
|
);
|
|
530
|
-
|
|
531
|
-
}
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
* customized rule is left alone.
|
|
541
|
-
* @returns {boolean} True when the file was rewritten.
|
|
542
|
-
* @memberof UnderpostSecret
|
|
543
|
-
*/
|
|
544
|
-
repairCreationRules() {
|
|
545
|
-
const confPath = `${SOPS_SECRETS_DIR}/.sops.yaml`;
|
|
546
|
-
if (!fs.existsSync(confPath)) return false;
|
|
547
|
-
const lines = fs.readFileSync(confPath, 'utf8').split('\n');
|
|
548
|
-
// The rule is the first key of a YAML list item, so the line carries a `- ` marker that
|
|
549
|
-
// has to be preserved: ` - path_regex: …`.
|
|
550
|
-
const brokenRule = /^(\s*(?:-\s*)?)path_regex:\s*.*engine-private\/secrets\//;
|
|
551
|
-
const index = lines.findIndex((line) => brokenRule.test(line));
|
|
552
|
-
if (index === -1) return false;
|
|
553
|
-
const prefix = lines[index].match(brokenRule)[1];
|
|
554
|
-
const previous = lines[index].trim();
|
|
555
|
-
lines[index] = `${prefix}path_regex: ${SOPS_MANIFEST_PATH_REGEX}`;
|
|
556
|
-
fs.writeFileSync(confPath, lines.join('\n'), 'utf8');
|
|
1839
|
+
source = 'interactive prompt';
|
|
1840
|
+
}
|
|
1841
|
+
|
|
1842
|
+
const raw = fs.readFileSync(stagePath, 'utf8');
|
|
1843
|
+
const token = raw.replace(/\r?\n$/, '');
|
|
1844
|
+
if (!token) throw new Error(`[secret] the replacement ${GIT_AUTH_TOKEN_KEY} is empty`);
|
|
1845
|
+
if (/\s/.test(token))
|
|
1846
|
+
throw new Error(`[secret] the replacement ${GIT_AUTH_TOKEN_KEY} contains whitespace; it is not a token`);
|
|
1847
|
+
if (token !== raw) writeStageFileSync(stagePath, token);
|
|
1848
|
+
if (!looksLikeGitHubToken(token))
|
|
557
1849
|
logger.warn(
|
|
558
|
-
`
|
|
559
|
-
`
|
|
560
|
-
{ from: previous, to: `path_regex: ${SOPS_MANIFEST_PATH_REGEX}` },
|
|
1850
|
+
`The replacement ${GIT_AUTH_TOKEN_KEY} does not match a known GitHub token shape ` +
|
|
1851
|
+
`(ghp_…, gho_…, github_pat_…). Continuing — GitHub token formats have changed before.`,
|
|
561
1852
|
);
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
/**
|
|
566
|
-
* @method encrypt
|
|
567
|
-
* @description Encrypts a plaintext Secret manifest into the Git-tracked store and shreds the
|
|
568
|
-
* source. Recipients resolve from the committed `.sops.yaml`, so a manifest cannot be
|
|
569
|
-
* encrypted to an unlisted key. Author the plaintext under `/dev/shm` so it never touches
|
|
570
|
-
* persistent storage.
|
|
571
|
-
* Written via a staged temp file and moved into place only after the output validates. A
|
|
572
|
-
* bare `sops … > out` redirect has the shell truncate `out` before sops runs, so a failed
|
|
573
|
-
* encrypt would destroy an existing manifest and leave a zero-byte file in its place.
|
|
574
|
-
* @param {string} plaintextPath - Path to the plaintext Secret manifest.
|
|
575
|
-
* @param {string} [namespace='default'] - Target namespace directory in the store.
|
|
576
|
-
* @param {object} [options={}] - Encryption options.
|
|
577
|
-
* @param {boolean} [options.force=false] - Replace an existing manifest at the target path.
|
|
578
|
-
* @returns {string} Path of the written encrypted manifest.
|
|
579
|
-
* @memberof UnderpostSecret
|
|
580
|
-
*/
|
|
581
|
-
encrypt(plaintextPath, namespace = 'default', options = {}) {
|
|
582
|
-
Underpost.secret.sops.assertTooling(['sops']);
|
|
583
|
-
if (!plaintextPath || !fs.existsSync(plaintextPath))
|
|
584
|
-
throw new Error(`Plaintext manifest not found: ${plaintextPath}`);
|
|
585
|
-
const sopsConfPath = `${SOPS_SECRETS_DIR}/.sops.yaml`;
|
|
586
|
-
if (!fs.existsSync(sopsConfPath))
|
|
587
|
-
throw new Error(`Missing creation rules: ${sopsConfPath} (run: underpost secret sops --init)`);
|
|
588
|
-
|
|
589
|
-
const sourceMeta = Underpost.secret.sops.manifestMeta(plaintextPath);
|
|
590
|
-
if (sourceMeta.encrypted)
|
|
591
|
-
throw new Error(
|
|
592
|
-
`${plaintextPath} already carries sops metadata. Re-encrypting would double-wrap it; ` +
|
|
593
|
-
`edit it in place with: sops ${plaintextPath}`,
|
|
594
|
-
);
|
|
1853
|
+
return { token, source };
|
|
1854
|
+
},
|
|
595
1855
|
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
1856
|
+
/**
|
|
1857
|
+
* @method writeGitAuthTokenManifest
|
|
1858
|
+
* @description Records the token in the encrypted store as `git-auth-token`, replacing the
|
|
1859
|
+
* stored manifest.
|
|
1860
|
+
*
|
|
1861
|
+
* The plaintext is written by Node onto tmpfs at mode 600 and handed to {@link encrypt},
|
|
1862
|
+
* which stages to a temp file, validates the envelope, moves into place only on success and
|
|
1863
|
+
* shreds the source — so a failed encrypt cannot leave a truncated manifest where a readable
|
|
1864
|
+
* one was. The data key keeps the `GIT_AUTH_TOKEN` spelling, so a workload reading it through
|
|
1865
|
+
* `envFrom` gets the environment variable its tooling already expects.
|
|
1866
|
+
* @param {string} token - The token to store.
|
|
1867
|
+
* @param {string} [namespace='default'] - Store namespace.
|
|
1868
|
+
* @returns {string} Path of the written encrypted manifest.
|
|
1869
|
+
* @memberof UnderpostSecret
|
|
1870
|
+
*/
|
|
1871
|
+
writeGitAuthTokenManifest(token, namespace = 'default') {
|
|
1872
|
+
const stageDir = stageDirSync(GIT_AUTH_TOKEN_STAGE_DIR);
|
|
1873
|
+
const plaintextPath = `${stageDir}/${GIT_AUTH_TOKEN_SECRET}.yaml`;
|
|
1874
|
+
writeStageFileSync(
|
|
1875
|
+
plaintextPath,
|
|
1876
|
+
[
|
|
1877
|
+
'apiVersion: v1',
|
|
1878
|
+
'kind: Secret',
|
|
1879
|
+
'metadata:',
|
|
1880
|
+
` name: ${GIT_AUTH_TOKEN_SECRET}`,
|
|
1881
|
+
` namespace: ${namespace}`,
|
|
1882
|
+
' labels:',
|
|
1883
|
+
' app.kubernetes.io/managed-by: underpost',
|
|
1884
|
+
'type: Opaque',
|
|
1885
|
+
'stringData:',
|
|
1886
|
+
// Single-quoted YAML scalar with doubled internal quotes: a token is opaque and may
|
|
1887
|
+
// carry characters YAML would otherwise interpret.
|
|
1888
|
+
` ${GIT_AUTH_TOKEN_KEY}: '${`${token}`.replace(/'/g, "''")}'`,
|
|
1889
|
+
'',
|
|
1890
|
+
].join('\n'),
|
|
1891
|
+
);
|
|
1892
|
+
return Underpost.secret.encrypt(plaintextPath, namespace, { force: true });
|
|
1893
|
+
},
|
|
631
1894
|
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
* @param {boolean} [options.quiet=false] - Suppress the per-manifest log line.
|
|
681
|
-
* @memberof UnderpostSecret
|
|
682
|
-
*/
|
|
683
|
-
applyManifest(manifestPath, namespace = 'default', options = {}) {
|
|
684
|
-
// Envelope first: it needs no private key, so a malformed or unencrypted store is reported
|
|
685
|
-
// as such even on a host whose key is missing or wrongly permissioned.
|
|
686
|
-
Underpost.secret.sops.assertManifest(manifestPath, { name: options.expectName, namespace });
|
|
687
|
-
// Recipient set next: a manifest sealed to a key this host does not hold fails inside the
|
|
688
|
-
// decrypt pipe with an error that names neither the file nor a remedy.
|
|
689
|
-
Underpost.secret.sops.assertDecryptable([
|
|
690
|
-
{ namespace, name: options.expectName || manifestPath.split('/').pop(), path: manifestPath },
|
|
691
|
-
]);
|
|
692
|
-
Underpost.secret.sops.assertTooling(['sops']);
|
|
693
|
-
const keyFile = Underpost.secret.sops.assertKeyFile();
|
|
694
|
-
const dryRun = options.dryRun ? ' --dry-run=server' : '';
|
|
695
|
-
shellExec(
|
|
696
|
-
`bash -c 'set -o pipefail; SOPS_AGE_KEY_FILE="${keyFile}" sops --decrypt "${manifestPath}" ` +
|
|
697
|
-
`| kubectl apply -f -${dryRun} -n "${namespace}"'`,
|
|
698
|
-
{ disableLog: true },
|
|
1895
|
+
/**
|
|
1896
|
+
* @method rotateGitAuthToken
|
|
1897
|
+
* @description Replaces the `GIT_AUTH_TOKEN` Actions secret on every repository a deploy
|
|
1898
|
+
* authenticates with, and records the new value in the encrypted store.
|
|
1899
|
+
*
|
|
1900
|
+
* GitHub is written first and the store second, because the token is only real once GitHub
|
|
1901
|
+
* holds it: a store that leads GitHub records a credential no workflow can use, while a
|
|
1902
|
+
* GitHub that leads the store converges on the next run. Every write is idempotent, so a run
|
|
1903
|
+
* that failed part way is re-runnable with the same token.
|
|
1904
|
+
*
|
|
1905
|
+
* A target that does not resolve is reported and skipped rather than aborting the rotation —
|
|
1906
|
+
* a deploy does not necessarily own every repository its naming implies, and a missing test
|
|
1907
|
+
* source repo must not leave the private conf repo un-rotated. A target that resolves but
|
|
1908
|
+
* fails to write is collected and raised at the end, after the repositories that did succeed
|
|
1909
|
+
* are on record.
|
|
1910
|
+
*
|
|
1911
|
+
* The token never appears as a command argument: `gh secret set` reads it from a tmpfs file
|
|
1912
|
+
* on stdin, so it reaches neither the process table nor the command log.
|
|
1913
|
+
*
|
|
1914
|
+
* Usage:
|
|
1915
|
+
* node bin secret rotate --args "secret=GIT_AUTH_TOKEN,token=<new>,deploy-id=dd-cyberia"
|
|
1916
|
+
* node bin secret rotate --args "secret=GIT_AUTH_TOKEN,deploy-id=dd" --dry-run # whole fleet
|
|
1917
|
+
* node bin secret rotate --args "secret=GIT_AUTH_TOKEN,token=<new>,deploy-id=dd"
|
|
1918
|
+
* node bin secret rotate --args "secret=GIT_AUTH_TOKEN,store=true,apply=true"
|
|
1919
|
+
* @param {object} [options={}] - Rotation options.
|
|
1920
|
+
* @param {string} [options.token] - Replacement token. When omitted: piped stdin, then the
|
|
1921
|
+
* `GIT_AUTH_TOKEN` environment, then a no-echo terminal prompt.
|
|
1922
|
+
* @param {string} [options.deployId] - Deploy id, a list separated by `|`, `;` or whitespace,
|
|
1923
|
+
* `dd` for every deploy in `engine-private/deploy/dd.routes`, or `template`.
|
|
1924
|
+
* @param {string} [options.owner] - GitHub owner for the resolved repository names.
|
|
1925
|
+
* @param {string} [options.repos] - Extra `owner/repo` targets, separated by `|`, `;` or space.
|
|
1926
|
+
* @param {string} [options.namespace='default'] - Store namespace for the mirrored manifest.
|
|
1927
|
+
* @param {boolean} [options.store=false] - Mirror into the encrypted store even when no
|
|
1928
|
+
* manifest is stored yet. An existing manifest is always updated.
|
|
1929
|
+
* @param {boolean} [options.apply=false] - Project the updated manifest into the cluster.
|
|
1930
|
+
* @param {boolean} [options.dryRun=false] - Report the plan without contacting GitHub,
|
|
1931
|
+
* prompting, or writing anything.
|
|
1932
|
+
* @returns {{targets: Array<string>, rotated: Array<string>, unreachable: Array<string>,
|
|
1933
|
+
* failed: Array<string>, manifest: string, store: boolean, tokenSource: string}} Outcome.
|
|
1934
|
+
* @memberof UnderpostSecret
|
|
1935
|
+
*/
|
|
1936
|
+
rotateGitAuthToken(options = {}) {
|
|
1937
|
+
const namespace = options.namespace || 'default';
|
|
1938
|
+
const targets = Underpost.secret.gitAuthTokenTargets(options);
|
|
1939
|
+
if (targets.length === 0)
|
|
1940
|
+
throw new Error(
|
|
1941
|
+
`[secret] no repository resolved for ${GIT_AUTH_TOKEN_KEY} rotation. Name the deploy with ` +
|
|
1942
|
+
`--args deploy-id=<id>, or the repositories with --args "repos=owner/repo|owner/other".`,
|
|
699
1943
|
);
|
|
700
|
-
|
|
701
|
-
}
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
1944
|
+
const stored = Underpost.secret.has(GIT_AUTH_TOKEN_SECRET, namespace);
|
|
1945
|
+
const store = stored || options.store === true || `${options.store}` === 'true';
|
|
1946
|
+
const ghReady = Underpost.secret.hasBinary('gh');
|
|
1947
|
+
// Advisory, not a gate: `gh auth status` also exits non-zero for a logged-in account whose
|
|
1948
|
+
// token merely lacks an optional scope. Reachability of the targets is the real
|
|
1949
|
+
// precondition, so this is captured to explain a failure rather than to cause one.
|
|
1950
|
+
const ghAuth = ghReady
|
|
1951
|
+
? shellExec(`gh auth status 2>&1`, { silent: true, silentOnError: true, disableLog: true })
|
|
1952
|
+
: null;
|
|
1953
|
+
const ghAuthenticated = ghAuth?.code === 0;
|
|
1954
|
+
const ghAuthOutput = `${ghAuth?.stdout ?? ''}`.trim() || '(no output)';
|
|
1955
|
+
// gh prefers GH_TOKEN/GITHUB_TOKEN over the account `gh auth login` stored, and this engine's
|
|
1956
|
+
// own host store exports GITHUB_TOKEN — so a stale one silently shadows a working login and
|
|
1957
|
+
// every probe fails against a credential the operator never chose.
|
|
1958
|
+
const shadowing = ['GH_TOKEN', 'GITHUB_TOKEN'].filter((key) => `${process.env[key] ?? ''}`.trim());
|
|
1959
|
+
|
|
1960
|
+
if (options.dryRun) {
|
|
1961
|
+
// Probing is a read, so the plan reports the targets that actually exist rather than the
|
|
1962
|
+
// ones the naming derived. Nothing is minted, prompted for, or written.
|
|
1963
|
+
const probed = ghReady ? Underpost.secret.probeGitAuthTokenTargets(targets) : null;
|
|
1964
|
+
logger.info(`[dry-run] ${GIT_AUTH_TOKEN_KEY} rotation plan`, {
|
|
1965
|
+
targets,
|
|
1966
|
+
wouldRotate: probed ? probed.reachable : '(not probed)',
|
|
1967
|
+
unreachable: probed ? probed.unreachable : '(not probed)',
|
|
1968
|
+
namespace,
|
|
1969
|
+
from: Underpost.secret.plannedTokenSource(options),
|
|
1970
|
+
gh: ghReady ? (ghAuthenticated ? 'authenticated' : 'not authenticated') : 'missing',
|
|
1971
|
+
manifest: store ? Underpost.secret.manifestPath(GIT_AUTH_TOKEN_SECRET, namespace) : '(store untouched)',
|
|
1972
|
+
storedManifest: stored,
|
|
724
1973
|
});
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
}
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
throw new Error(`Not a valid Age public recipient: ${recipient} (expected age1…)`);
|
|
785
|
-
|
|
786
|
-
Underpost.secret.sops.assertTooling(['sops']);
|
|
787
|
-
const keyFile = Underpost.secret.sops.assertKeyFile();
|
|
788
|
-
|
|
789
|
-
const confPath = `${SOPS_SECRETS_DIR}/.sops.yaml`;
|
|
790
|
-
if (!fs.existsSync(confPath))
|
|
791
|
-
throw new Error(`Missing creation rules: ${confPath} (run: underpost secret sops --init)`);
|
|
792
|
-
|
|
793
|
-
const keep = (
|
|
794
|
-
Array.isArray(options.keepRecipients) ? options.keepRecipients : `${options.keepRecipients || ''}`.split(',')
|
|
795
|
-
)
|
|
796
|
-
.map((value) => value.trim())
|
|
797
|
-
.filter(Boolean);
|
|
798
|
-
const current = Underpost.secret.sops.creationRecipients();
|
|
799
|
-
const next = options.pruneRecipients
|
|
800
|
-
? [...new Set([recipient, ...keep])]
|
|
801
|
-
: [...new Set([...current, recipient])];
|
|
802
|
-
const revoked = current.filter((existing) => !next.includes(existing));
|
|
803
|
-
const manifests = Underpost.secret.sops.manifests();
|
|
804
|
-
// `updatekeys` re-wraps each data key, which means decrypting it first. A host that cannot
|
|
805
|
-
// read the store cannot rotate it, dry run included — reporting a plan that can never run
|
|
806
|
-
// is what sends an operator down the wrong remedy.
|
|
807
|
-
Underpost.secret.sops.assertDecryptable(manifests);
|
|
808
|
-
|
|
809
|
-
if (options.dryRun) {
|
|
810
|
-
logger.info('Rotation plan (dry run)', { from: current, to: next, revoked, manifests: manifests.length });
|
|
811
|
-
if (revoked.length)
|
|
812
|
-
logger.warn(
|
|
813
|
-
`${revoked.length} recipient(s) would permanently lose access. Confirm none is a CI/CD or ` +
|
|
814
|
-
`co-operator key before re-running with --force.`,
|
|
815
|
-
{ revoked },
|
|
1974
|
+
if (!ghReady) logger.warn('gh is not on PATH; this plan cannot run until the GitHub CLI is installed.');
|
|
1975
|
+
else if (!ghAuthenticated)
|
|
1976
|
+
logger.warn('gh is not authenticated, so no target could be probed. Run `gh auth login`.');
|
|
1977
|
+
return {
|
|
1978
|
+
targets,
|
|
1979
|
+
rotated: [],
|
|
1980
|
+
unreachable: probed ? probed.unreachable : [],
|
|
1981
|
+
failed: [],
|
|
1982
|
+
manifest: '',
|
|
1983
|
+
store,
|
|
1984
|
+
tokenSource: '',
|
|
1985
|
+
};
|
|
1986
|
+
}
|
|
1987
|
+
|
|
1988
|
+
if (!ghReady)
|
|
1989
|
+
throw new Error(
|
|
1990
|
+
`gh not found in PATH. Install the GitHub CLI (https://cli.github.com), then authenticate it with ` +
|
|
1991
|
+
`\`gh auth login\` before rotating ${GIT_AUTH_TOKEN_KEY}.`,
|
|
1992
|
+
);
|
|
1993
|
+
if (!ghAuthenticated)
|
|
1994
|
+
logger.warn(`\`gh auth status\` exited non-zero; continuing if the targets are reachable.`, {
|
|
1995
|
+
status: ghAuthOutput,
|
|
1996
|
+
});
|
|
1997
|
+
|
|
1998
|
+
// Probed before the token is staged: nothing can be written to an unreachable set, and
|
|
1999
|
+
// prompting for a credential that has nowhere to go wastes the operator's paste.
|
|
2000
|
+
const probed = Underpost.secret.probeGitAuthTokenTargets(targets);
|
|
2001
|
+
if (probed.reachable.length === 0)
|
|
2002
|
+
throw new Error(
|
|
2003
|
+
`None of the ${targets.length} target(s) is reachable with the current gh credential, so ` +
|
|
2004
|
+
`${GIT_AUTH_TOKEN_KEY} was not rotated and nothing was written. Writing an Actions secret ` +
|
|
2005
|
+
`needs the \`repo\` scope and admin on each repository.\n` +
|
|
2006
|
+
`Targets: ${targets.join(', ')}\n` +
|
|
2007
|
+
(shadowing.length
|
|
2008
|
+
? `${shadowing.join(' and ')} is set here, and gh uses it in preference to the account ` +
|
|
2009
|
+
`\`gh auth login\` stored. If gh calls it invalid below, run \`unset ` +
|
|
2010
|
+
`${shadowing.join(' ')}\` and try again.\n`
|
|
2011
|
+
: '') +
|
|
2012
|
+
`\`gh auth status\` reports:\n${ghAuthOutput}`,
|
|
2013
|
+
);
|
|
2014
|
+
|
|
2015
|
+
stageDirSync(GIT_AUTH_TOKEN_STAGE_DIR);
|
|
2016
|
+
const stagePath = `${GIT_AUTH_TOKEN_STAGE_DIR}/${GIT_AUTH_TOKEN_KEY}`;
|
|
2017
|
+
const rotated = [];
|
|
2018
|
+
const unreachable = probed.unreachable;
|
|
2019
|
+
const failed = [];
|
|
2020
|
+
let manifest = '';
|
|
2021
|
+
let tokenSource = '';
|
|
2022
|
+
try {
|
|
2023
|
+
const staged = Underpost.secret.stageGitAuthToken(stagePath, options);
|
|
2024
|
+
tokenSource = staged.source;
|
|
2025
|
+
logger.info(`Rotating ${GIT_AUTH_TOKEN_KEY}`, { targets, namespace, from: tokenSource, store });
|
|
2026
|
+
|
|
2027
|
+
for (const repo of probed.reachable) {
|
|
2028
|
+
try {
|
|
2029
|
+
// The token arrives on stdin from the staged file, never as an argument.
|
|
2030
|
+
shellExec(
|
|
2031
|
+
`bash -c 'set -o pipefail; gh secret set ${GIT_AUTH_TOKEN_KEY} --repo "${repo}" < "${stagePath}"'`,
|
|
2032
|
+
{ silent: true, disableLog: true },
|
|
816
2033
|
);
|
|
817
|
-
|
|
2034
|
+
rotated.push(repo);
|
|
2035
|
+
logger.info(`${GIT_AUTH_TOKEN_KEY} set on ${repo}`);
|
|
2036
|
+
} catch (error) {
|
|
2037
|
+
failed.push(repo);
|
|
2038
|
+
logger.error(`${GIT_AUTH_TOKEN_KEY} could not be set on ${repo}`, { error: error.message });
|
|
2039
|
+
}
|
|
818
2040
|
}
|
|
819
2041
|
|
|
820
|
-
|
|
821
|
-
// operator may not have in mind. Irreversible for anyone holding only a revoked key, so it
|
|
822
|
-
// is gated behind an explicit confirmation that has to be made after seeing the list.
|
|
823
|
-
if (revoked.length && !options.force)
|
|
2042
|
+
if (rotated.length === 0)
|
|
824
2043
|
throw new Error(
|
|
825
|
-
|
|
826
|
-
`
|
|
2044
|
+
`${GIT_AUTH_TOKEN_KEY} could not be written to any of the ${probed.reachable.length} reachable ` +
|
|
2045
|
+
`target(s): ${probed.reachable.join(', ')}. The encrypted store was left untouched, so it still ` +
|
|
2046
|
+
`records the credential GitHub is running on.`,
|
|
827
2047
|
);
|
|
828
2048
|
|
|
829
|
-
Underpost.secret.
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
`bash -c 'set -o pipefail; SOPS_AGE_KEY_FILE="${keyFile}" ` +
|
|
835
|
-
`sops --config "${confPath}" updatekeys --yes "${manifest.path}"'`,
|
|
836
|
-
{ disableLog: true, silent: true },
|
|
2049
|
+
if (store) manifest = Underpost.secret.writeGitAuthTokenManifest(staged.token, namespace);
|
|
2050
|
+
else
|
|
2051
|
+
logger.info(
|
|
2052
|
+
`No ${GIT_AUTH_TOKEN_SECRET} manifest in ns/${namespace}; the store was left untouched. ` +
|
|
2053
|
+
`Pass --args store=true to mirror this token into it.`,
|
|
837
2054
|
);
|
|
838
|
-
// updatekeys is a no-op when it decides nothing changed, and its exit code does not
|
|
839
|
-
// distinguish that from a successful re-key. Confirm against the file itself, so a
|
|
840
|
-
// rotation can never be reported as done while a manifest stays on the old recipient.
|
|
841
|
-
const sealed = Underpost.secret.sops.manifestRecipients(manifest.path);
|
|
842
|
-
if (!sealed.includes(recipient))
|
|
843
|
-
throw new Error(
|
|
844
|
-
`${manifest.path} is still sealed to ${sealed.join(', ') || 'no recipients'} after updatekeys; ` +
|
|
845
|
-
`expected ${recipient}. Rotation aborted with the store partially re-keyed — re-run once resolved.`,
|
|
846
|
-
);
|
|
847
|
-
logger.info(`Re-keyed ${manifest.namespace}/${manifest.name}`);
|
|
848
|
-
}
|
|
849
2055
|
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
/**
|
|
861
|
-
* @method purge
|
|
862
|
-
* @description Emergency removal of one secret: deletes the live Kubernetes Secret and takes
|
|
863
|
-
* its encrypted manifest out of the store. The manifest is archived rather than deleted so
|
|
864
|
-
* the purge stays reversible; `options.force` deletes it outright.
|
|
865
|
-
*
|
|
866
|
-
* Removing the manifest is what re-arms the origin seed path — with no `.enc.yaml`,
|
|
867
|
-
* `applyIfPresent` returns false and cluster init seeds the secret from the plaintext
|
|
868
|
-
* credential files instead. Whether that seed path is actually available is reported, not
|
|
869
|
-
* assumed: purging a secret whose seed files are gone leaves workloads with an unresolvable
|
|
870
|
-
* `secretKeyRef`, so the gap is surfaced at purge time rather than at the next deploy.
|
|
871
|
-
* @param {string} name - Secret name (e.g. 'postgres-secret').
|
|
872
|
-
* @param {object} [options={}] - Purge options.
|
|
873
|
-
* @param {string} [options.namespace='default'] - Namespace of the live Secret.
|
|
874
|
-
* @param {boolean} [options.force=false] - Delete the manifest instead of archiving it.
|
|
875
|
-
* @param {boolean} [options.dryRun=false] - Report what would happen without changing anything.
|
|
876
|
-
* @returns {{deleted: boolean, archived: string, seedFallback: boolean}} Purge outcome.
|
|
877
|
-
* @memberof UnderpostSecret
|
|
878
|
-
*/
|
|
879
|
-
purge(name, options = {}) {
|
|
880
|
-
if (!name) throw new Error('Purge requires a secret name');
|
|
881
|
-
const namespace = options.namespace || 'default';
|
|
882
|
-
const manifestPath = Underpost.secret.sops.manifestPath(name, namespace);
|
|
883
|
-
const seedSources = Object.values(Underpost.secret.sops.seedSources(name));
|
|
884
|
-
const seedFallback = seedSources.length > 0 && seedSources.every((source) => fs.existsSync(source));
|
|
885
|
-
|
|
886
|
-
if (options.dryRun) {
|
|
887
|
-
logger.info('Purge plan (dry run)', {
|
|
888
|
-
secret: `${namespace}/${name}`,
|
|
889
|
-
manifest: fs.existsSync(manifestPath) ? manifestPath : 'absent',
|
|
890
|
-
disposition: options.force ? 'delete' : 'archive',
|
|
891
|
-
seedFallbackAvailable: seedFallback,
|
|
892
|
-
});
|
|
893
|
-
return { deleted: false, archived: '', seedFallback };
|
|
894
|
-
}
|
|
2056
|
+
if (manifest && (options.apply === true || `${options.apply}` === 'true'))
|
|
2057
|
+
Underpost.secret.applyIfPresent(GIT_AUTH_TOKEN_SECRET, namespace);
|
|
2058
|
+
} finally {
|
|
2059
|
+
shellExec(`shred -u "${stagePath}" 2>/dev/null || rm -f "${stagePath}"`, {
|
|
2060
|
+
silentOnError: true,
|
|
2061
|
+
silent: true,
|
|
2062
|
+
disableLog: true,
|
|
2063
|
+
});
|
|
2064
|
+
fs.removeSync(GIT_AUTH_TOKEN_STAGE_DIR);
|
|
2065
|
+
}
|
|
895
2066
|
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
2067
|
+
const report = { targets, rotated, unreachable, failed, manifest, store, tokenSource };
|
|
2068
|
+
if (failed.length > 0)
|
|
2069
|
+
throw new Error(
|
|
2070
|
+
`${GIT_AUTH_TOKEN_KEY} was rotated on ${rotated.join(', ')} but failed on ${failed.join(', ')}. ` +
|
|
2071
|
+
`Those repositories still hold the previous token, so the fleet is split across two credentials. ` +
|
|
2072
|
+
`Re-run once resolved: every target is written again, and a minted token is reissued, so the ` +
|
|
2073
|
+
`fleet converges on one value either way.`,
|
|
2074
|
+
);
|
|
2075
|
+
// Built explicitly rather than spread from `report`: the redactor blanks any field whose
|
|
2076
|
+
// name carries "token", which would hide the source label behind [REDACTED].
|
|
2077
|
+
logger.info(`${GIT_AUTH_TOKEN_KEY} rotation complete`, {
|
|
2078
|
+
targets,
|
|
2079
|
+
rotated,
|
|
2080
|
+
unreachable,
|
|
2081
|
+
failed,
|
|
2082
|
+
manifest,
|
|
2083
|
+
store,
|
|
2084
|
+
from: tokenSource,
|
|
2085
|
+
});
|
|
2086
|
+
return report;
|
|
2087
|
+
},
|
|
910
2088
|
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
2089
|
+
/**
|
|
2090
|
+
* @method purge
|
|
2091
|
+
* @description Emergency removal of one secret: deletes the live Kubernetes Secret and takes
|
|
2092
|
+
* its encrypted manifest out of the store. The manifest is archived rather than deleted so
|
|
2093
|
+
* the purge stays reversible; `options.force` deletes it outright.
|
|
2094
|
+
*
|
|
2095
|
+
* Removing the manifest is what re-arms the origin seed path — with no `.enc.yaml`,
|
|
2096
|
+
* `applyIfPresent` returns false and cluster init seeds the secret from the plaintext
|
|
2097
|
+
* credential files instead. Whether that seed path is actually available is reported, not
|
|
2098
|
+
* assumed: purging a secret whose seed files are gone leaves workloads with an unresolvable
|
|
2099
|
+
* `secretKeyRef`, so the gap is surfaced at purge time rather than at the next deploy.
|
|
2100
|
+
* @param {string} name - Secret name (e.g. 'postgres-secret').
|
|
2101
|
+
* @param {object} [options={}] - Purge options.
|
|
2102
|
+
* @param {string} [options.namespace='default'] - Namespace of the live Secret.
|
|
2103
|
+
* @param {boolean} [options.force=false] - Delete the manifest instead of archiving it.
|
|
2104
|
+
* @param {boolean} [options.dryRun=false] - Report what would happen without changing anything.
|
|
2105
|
+
* @returns {{deleted: boolean, archived: string, seedFallback: boolean}} Purge outcome.
|
|
2106
|
+
* @memberof UnderpostSecret
|
|
2107
|
+
*/
|
|
2108
|
+
purge(name, options = {}) {
|
|
2109
|
+
if (!name) throw new Error('Purge requires a secret name');
|
|
2110
|
+
const namespace = options.namespace || 'default';
|
|
2111
|
+
const manifestPath = Underpost.secret.manifestPath(name, namespace);
|
|
2112
|
+
const seedSources = Object.values(Underpost.secret.seedSources(name));
|
|
2113
|
+
const envKeys = Object.keys(Underpost.secret.seedEnvKeys(name));
|
|
2114
|
+
const envValues = Underpost.secret.seedEnvValues(name);
|
|
2115
|
+
const seedFallback =
|
|
2116
|
+
(seedSources.length > 0 && seedSources.every((source) => fs.existsSync(source))) ||
|
|
2117
|
+
(envKeys.length > 0 && envKeys.every((key) => envValues[key] !== undefined));
|
|
921
2118
|
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
}
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
* @description Reads the Age recipients from the committed `.sops.yaml` creation rule,
|
|
957
|
-
* accepting both the single-line (`age: k1,k2`) and folded (`age: >-`) forms sops permits.
|
|
958
|
-
* @returns {Array<string>} Recipients currently configured for encryption.
|
|
959
|
-
* @memberof UnderpostSecret
|
|
960
|
-
*/
|
|
961
|
-
creationRecipients() {
|
|
962
|
-
const confPath = `${SOPS_SECRETS_DIR}/.sops.yaml`;
|
|
963
|
-
if (!fs.existsSync(confPath)) return [];
|
|
964
|
-
const lines = fs.readFileSync(confPath, 'utf8').split('\n');
|
|
965
|
-
const index = lines.findIndex((line) => /^\s*age:/.test(line));
|
|
966
|
-
if (index === -1) return [];
|
|
967
|
-
const indent = lines[index].match(/^\s*/)[0].length;
|
|
968
|
-
const chunk = [lines[index].replace(/^\s*age:\s*>?-?\s*/, '')];
|
|
969
|
-
for (let i = index + 1; i < lines.length; i++) {
|
|
970
|
-
if (!lines[i].trim()) break;
|
|
971
|
-
if (lines[i].match(/^\s*/)[0].length <= indent) break;
|
|
972
|
-
chunk.push(lines[i].trim());
|
|
973
|
-
}
|
|
974
|
-
return chunk
|
|
975
|
-
.join(',')
|
|
976
|
-
.split(',')
|
|
977
|
-
.map((value) => value.trim())
|
|
978
|
-
.filter(Boolean);
|
|
979
|
-
},
|
|
980
|
-
|
|
981
|
-
/**
|
|
982
|
-
* @method writeCreationRecipients
|
|
983
|
-
* @description Rewrites the `age:` recipients of the `.sops.yaml` creation rule in place,
|
|
984
|
-
* collapsing any folded form to a single canonical line. Line-scoped on purpose: a YAML
|
|
985
|
-
* round-trip would strip the comments operators keep in this file.
|
|
986
|
-
* @param {Array<string>} recipients - Recipients to encrypt to from now on.
|
|
987
|
-
* @memberof UnderpostSecret
|
|
988
|
-
*/
|
|
989
|
-
writeCreationRecipients(recipients) {
|
|
990
|
-
const confPath = `${SOPS_SECRETS_DIR}/.sops.yaml`;
|
|
991
|
-
if (!fs.existsSync(confPath))
|
|
992
|
-
throw new Error(`Missing creation rules: ${confPath} (run: underpost secret sops --init)`);
|
|
993
|
-
const lines = fs.readFileSync(confPath, 'utf8').split('\n');
|
|
994
|
-
const index = lines.findIndex((line) => /^\s*age:/.test(line));
|
|
995
|
-
if (index === -1) throw new Error(`No 'age:' recipients entry in ${confPath}`);
|
|
996
|
-
const indent = lines[index].match(/^\s*/)[0];
|
|
997
|
-
let end = index + 1;
|
|
998
|
-
while (end < lines.length && lines[end].trim() && lines[end].match(/^\s*/)[0].length > indent.length) end++;
|
|
999
|
-
lines.splice(index, end - index, `${indent}age: ${recipients.join(',')}`);
|
|
1000
|
-
fs.writeFileSync(confPath, lines.join('\n'), 'utf8');
|
|
1001
|
-
},
|
|
1002
|
-
|
|
1003
|
-
/**
|
|
1004
|
-
* @method hasBinary
|
|
1005
|
-
* @description Reports whether a binary resolves on PATH. Single probe reused by
|
|
1006
|
-
* {@link assertTooling} and {@link installTooling} so both agree on what "installed" means.
|
|
1007
|
-
* @param {string} bin - Binary name.
|
|
1008
|
-
* @returns {boolean} True when the binary is on PATH.
|
|
1009
|
-
* @memberof UnderpostSecret
|
|
1010
|
-
*/
|
|
1011
|
-
hasBinary(bin) {
|
|
1012
|
-
return (
|
|
1013
|
-
shellExec(`command -v ${bin} >/dev/null 2>&1 && echo exists || echo missing`, {
|
|
1014
|
-
stdout: true,
|
|
1015
|
-
silent: true,
|
|
1016
|
-
disableLog: true,
|
|
1017
|
-
}).trim() === 'exists'
|
|
2119
|
+
if (options.dryRun) {
|
|
2120
|
+
logger.info('Purge plan (dry run)', {
|
|
2121
|
+
secret: `${namespace}/${name}`,
|
|
2122
|
+
manifest: fs.existsSync(manifestPath) ? manifestPath : 'absent',
|
|
2123
|
+
disposition: options.force ? 'delete' : 'archive',
|
|
2124
|
+
seedFallbackAvailable: seedFallback,
|
|
2125
|
+
});
|
|
2126
|
+
return { deleted: false, archived: '', seedFallback };
|
|
2127
|
+
}
|
|
2128
|
+
|
|
2129
|
+
shellExec(`kubectl delete secret ${name} -n ${namespace} --ignore-not-found`);
|
|
2130
|
+
|
|
2131
|
+
let archived = '';
|
|
2132
|
+
if (!fs.existsSync(manifestPath)) logger.warn(`No encrypted manifest to remove at ${manifestPath}`);
|
|
2133
|
+
else if (options.force) {
|
|
2134
|
+
fs.removeSync(manifestPath);
|
|
2135
|
+
logger.warn(`Deleted ${manifestPath}`);
|
|
2136
|
+
} else {
|
|
2137
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
2138
|
+
archived = `${SOPS_ARCHIVE_DIR}/${namespace}/${name}.${stamp}${SOPS_MANIFEST_EXT}`;
|
|
2139
|
+
fs.ensureDirSync(`${SOPS_ARCHIVE_DIR}/${namespace}`);
|
|
2140
|
+
fs.moveSync(manifestPath, archived);
|
|
2141
|
+
logger.info(`Archived ${manifestPath} -> ${archived}`);
|
|
2142
|
+
}
|
|
2143
|
+
|
|
2144
|
+
if (seedFallback)
|
|
2145
|
+
logger.info(`Origin seed path is available for ${name}; cluster init will seed from it.`, {
|
|
2146
|
+
sources: seedSources,
|
|
2147
|
+
});
|
|
2148
|
+
else if (seedSources.length > 0)
|
|
2149
|
+
logger.warn(
|
|
2150
|
+
`No origin seed path for ${name}. Re-encrypt a manifest or create the secret manually ` +
|
|
2151
|
+
`before redeploying workloads that mount it.`,
|
|
2152
|
+
{ expected: seedSources },
|
|
1018
2153
|
);
|
|
1019
|
-
},
|
|
1020
|
-
|
|
1021
|
-
/**
|
|
1022
|
-
* @method assertTooling
|
|
1023
|
-
* @description Fails fast with an actionable message when a required binary is missing,
|
|
1024
|
-
* rather than surfacing an opaque shell exit code mid-apply.
|
|
1025
|
-
* @param {Array<string>} bins - Binaries that must resolve on PATH.
|
|
1026
|
-
* @memberof UnderpostSecret
|
|
1027
|
-
*/
|
|
1028
|
-
assertTooling(bins) {
|
|
1029
|
-
for (const bin of bins)
|
|
1030
|
-
if (!Underpost.secret.sops.hasBinary(bin))
|
|
1031
|
-
throw new Error(`${bin} not found in PATH (install via: underpost secret --install-tools)`);
|
|
1032
|
-
},
|
|
1033
|
-
|
|
1034
|
-
/**
|
|
1035
|
-
* @method installTooling
|
|
1036
|
-
* @description Installs the `sops` and `age` host binaries from their pinned upstream static
|
|
1037
|
-
* builds. Idempotent: an already-resolvable binary is left untouched, so this is safe to
|
|
1038
|
-
* re-run and safe to call from both the secrets CLI and cluster host initialization.
|
|
1039
|
-
* Verifies both binaries resolve before returning, so a partial install fails loudly here
|
|
1040
|
-
* rather than mid-decrypt.
|
|
1041
|
-
* @returns {{sops: boolean, age: boolean}} Which binaries this run actually installed.
|
|
1042
|
-
* @memberof UnderpostSecret
|
|
1043
|
-
*/
|
|
1044
|
-
installTooling() {
|
|
1045
|
-
const archData = Underpost.baremetal.getHostArch();
|
|
1046
|
-
logger.info('Installing SOPS and Age host tooling...', { ...archData, SOPS_VERSION, AGE_VERSION });
|
|
1047
|
-
const installed = { sops: false, age: false };
|
|
1048
|
-
|
|
1049
|
-
if (Underpost.secret.sops.hasBinary('sops')) logger.info('SOPS is already installed; skipping.');
|
|
1050
|
-
else {
|
|
1051
|
-
shellExec(
|
|
1052
|
-
`curl -fsSL -o /tmp/sops https://github.com/getsops/sops/releases/download/${SOPS_VERSION}/sops-${SOPS_VERSION}.linux.${archData.alias}`,
|
|
1053
|
-
);
|
|
1054
|
-
shellExec(`sudo install -m 0755 /tmp/sops /usr/local/bin/sops`);
|
|
1055
|
-
shellExec(`sudo ln -sf /usr/local/bin/sops /bin/sops`);
|
|
1056
|
-
shellExec(`sudo rm -f /tmp/sops`);
|
|
1057
|
-
installed.sops = true;
|
|
1058
|
-
}
|
|
1059
2154
|
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
shellExec(
|
|
1063
|
-
`curl -fsSL -o /tmp/age.tar.gz https://github.com/FiloSottile/age/releases/download/${AGE_VERSION}/age-${AGE_VERSION}-linux-${archData.alias}.tar.gz`,
|
|
1064
|
-
);
|
|
1065
|
-
shellExec(`tar -xzf /tmp/age.tar.gz -C /tmp`);
|
|
1066
|
-
shellExec(`sudo install -m 0755 /tmp/age/age /usr/local/bin/age`);
|
|
1067
|
-
shellExec(`sudo install -m 0755 /tmp/age/age-keygen /usr/local/bin/age-keygen`);
|
|
1068
|
-
shellExec(`sudo ln -sf /usr/local/bin/age /bin/age`);
|
|
1069
|
-
shellExec(`sudo ln -sf /usr/local/bin/age-keygen /bin/age-keygen`);
|
|
1070
|
-
shellExec(`sudo rm -rf /tmp/age /tmp/age.tar.gz`);
|
|
1071
|
-
installed.age = true;
|
|
1072
|
-
}
|
|
2155
|
+
return { deleted: true, archived, seedFallback };
|
|
2156
|
+
},
|
|
1073
2157
|
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
2158
|
+
/**
|
|
2159
|
+
* @method manifests
|
|
2160
|
+
* @description Enumerates every encrypted manifest in the store, or in one namespace.
|
|
2161
|
+
* Dot-prefixed entries (`.archive`, `.sops.yaml`) are never treated as namespaces.
|
|
2162
|
+
* @param {string} [namespace] - Restrict to one namespace; omit for the whole store.
|
|
2163
|
+
* @returns {Array<{namespace: string, name: string, path: string}>} Manifest descriptors, sorted.
|
|
2164
|
+
* @memberof UnderpostSecret
|
|
2165
|
+
*/
|
|
2166
|
+
manifests(namespace) {
|
|
2167
|
+
if (!fs.existsSync(SOPS_SECRETS_DIR)) return [];
|
|
2168
|
+
const namespaces = namespace
|
|
2169
|
+
? [namespace]
|
|
2170
|
+
: fs
|
|
2171
|
+
.readdirSync(SOPS_SECRETS_DIR)
|
|
2172
|
+
.filter((entry) => !entry.startsWith('.') && fs.statSync(`${SOPS_SECRETS_DIR}/${entry}`).isDirectory())
|
|
2173
|
+
.sort();
|
|
2174
|
+
const found = [];
|
|
2175
|
+
for (const ns of namespaces) {
|
|
2176
|
+
const dir = `${SOPS_SECRETS_DIR}/${ns}`;
|
|
2177
|
+
if (!fs.existsSync(dir)) continue;
|
|
2178
|
+
for (const file of fs
|
|
2179
|
+
.readdirSync(dir)
|
|
2180
|
+
.filter((entry) => entry.endsWith(SOPS_MANIFEST_EXT))
|
|
2181
|
+
.sort())
|
|
2182
|
+
found.push({ namespace: ns, name: file.slice(0, -SOPS_MANIFEST_EXT.length), path: `${dir}/${file}` });
|
|
2183
|
+
}
|
|
2184
|
+
return found;
|
|
1078
2185
|
},
|
|
1079
2186
|
|
|
1080
2187
|
/**
|
|
1081
|
-
* @method
|
|
1082
|
-
* @description
|
|
1083
|
-
*
|
|
1084
|
-
*
|
|
1085
|
-
* preserved. Uses the same {@link RESERVED_ENV_KEYS} blocklist as container-env capture.
|
|
1086
|
-
* @param {string} envFileContent - Raw contents of a `.env.<env>` file.
|
|
1087
|
-
* @returns {string} Filtered env-file content.
|
|
2188
|
+
* @method creationRecipients
|
|
2189
|
+
* @description Reads the Age recipients from the committed `.sops.yaml` creation rule,
|
|
2190
|
+
* accepting both the single-line (`age: k1,k2`) and folded (`age: >-`) forms sops permits.
|
|
2191
|
+
* @returns {Array<string>} Recipients currently configured for encryption.
|
|
1088
2192
|
* @memberof UnderpostSecret
|
|
1089
2193
|
*/
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
.
|
|
2194
|
+
creationRecipients() {
|
|
2195
|
+
const confPath = `${SOPS_SECRETS_DIR}/.sops.yaml`;
|
|
2196
|
+
if (!fs.existsSync(confPath)) return [];
|
|
2197
|
+
const lines = fs.readFileSync(confPath, 'utf8').split('\n');
|
|
2198
|
+
const index = lines.findIndex((line) => /^\s*age:/.test(line));
|
|
2199
|
+
if (index === -1) return [];
|
|
2200
|
+
const indent = lines[index].match(/^\s*/)[0].length;
|
|
2201
|
+
const chunk = [lines[index].replace(/^\s*age:\s*>?-?\s*/, '')];
|
|
2202
|
+
for (let i = index + 1; i < lines.length; i++) {
|
|
2203
|
+
if (!lines[i].trim()) break;
|
|
2204
|
+
if (lines[i].match(/^\s*/)[0].length <= indent) break;
|
|
2205
|
+
chunk.push(lines[i].trim());
|
|
2206
|
+
}
|
|
2207
|
+
return chunk
|
|
2208
|
+
.join(',')
|
|
2209
|
+
.split(',')
|
|
2210
|
+
.map((value) => value.trim())
|
|
2211
|
+
.filter(Boolean);
|
|
1100
2212
|
},
|
|
1101
2213
|
|
|
1102
2214
|
/**
|
|
1103
|
-
*
|
|
1104
|
-
*
|
|
1105
|
-
*
|
|
1106
|
-
*
|
|
2215
|
+
* @method writeCreationRecipients
|
|
2216
|
+
* @description Rewrites the `age:` recipients of the `.sops.yaml` creation rule in place,
|
|
2217
|
+
* collapsing any folded form to a single canonical line. Line-scoped on purpose: a YAML
|
|
2218
|
+
* round-trip would strip the comments operators keep in this file.
|
|
2219
|
+
* @param {Array<string>} recipients - Recipients to encrypt to from now on.
|
|
1107
2220
|
* @memberof UnderpostSecret
|
|
1108
2221
|
*/
|
|
1109
|
-
|
|
1110
|
-
const
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
});
|
|
2222
|
+
writeCreationRecipients(recipients) {
|
|
2223
|
+
const confPath = `${SOPS_SECRETS_DIR}/.sops.yaml`;
|
|
2224
|
+
if (!fs.existsSync(confPath))
|
|
2225
|
+
throw new Error(`Missing creation rules: ${confPath} (run: underpost secret setup)`);
|
|
2226
|
+
const lines = fs.readFileSync(confPath, 'utf8').split('\n');
|
|
2227
|
+
const index = lines.findIndex((line) => /^\s*age:/.test(line));
|
|
2228
|
+
if (index === -1) throw new Error(`No 'age:' recipients entry in ${confPath}`);
|
|
2229
|
+
const indent = lines[index].match(/^\s*/)[0];
|
|
2230
|
+
let end = index + 1;
|
|
2231
|
+
while (end < lines.length && lines[end].trim() && lines[end].match(/^\s*/)[0].length > indent.length) end++;
|
|
2232
|
+
lines.splice(index, end - index, `${indent}age: ${recipients.join(',')}`);
|
|
2233
|
+
fs.writeFileSync(confPath, lines.join('\n'), 'utf8');
|
|
2234
|
+
},
|
|
2235
|
+
|
|
2236
|
+
/**
|
|
2237
|
+
* @method hasBinary
|
|
2238
|
+
* @description Reports whether a binary resolves on PATH. Single probe reused by
|
|
2239
|
+
* {@link assertTooling} and {@link installTooling} so both agree on what "installed" means.
|
|
2240
|
+
* @param {string} bin - Binary name.
|
|
2241
|
+
* @returns {boolean} True when the binary is on PATH.
|
|
2242
|
+
* @memberof UnderpostSecret
|
|
2243
|
+
*/
|
|
2244
|
+
hasBinary(bin) {
|
|
2245
|
+
return (
|
|
2246
|
+
shellExec(`command -v ${bin} >/dev/null 2>&1 && echo exists || echo missing`, {
|
|
2247
|
+
stdout: true,
|
|
2248
|
+
silent: true,
|
|
2249
|
+
disableLog: true,
|
|
2250
|
+
}).trim() === 'exists'
|
|
2251
|
+
);
|
|
2252
|
+
},
|
|
2253
|
+
|
|
2254
|
+
/**
|
|
2255
|
+
* @method assertTooling
|
|
2256
|
+
* @description Fails fast with an actionable message when a required binary is missing,
|
|
2257
|
+
* rather than surfacing an opaque shell exit code mid-apply.
|
|
2258
|
+
* @param {Array<string>} bins - Binaries that must resolve on PATH.
|
|
2259
|
+
* @memberof UnderpostSecret
|
|
2260
|
+
*/
|
|
2261
|
+
assertTooling(bins) {
|
|
2262
|
+
for (const bin of bins)
|
|
2263
|
+
if (!Underpost.secret.hasBinary(bin))
|
|
2264
|
+
throw new Error(`${bin} not found in PATH (install via: underpost secret --install-tools)`);
|
|
2265
|
+
},
|
|
2266
|
+
|
|
2267
|
+
/**
|
|
2268
|
+
* @method installTooling
|
|
2269
|
+
* @description Installs the `sops` and `age` host binaries from their pinned upstream static
|
|
2270
|
+
* builds. Idempotent: an already-resolvable binary is left untouched, so this is safe to
|
|
2271
|
+
* re-run and safe to call from both the secrets CLI and cluster host initialization.
|
|
2272
|
+
* Verifies both binaries resolve before returning, so a partial install fails loudly here
|
|
2273
|
+
* rather than mid-decrypt.
|
|
2274
|
+
* @returns {{sops: boolean, age: boolean}} Which binaries this run actually installed.
|
|
2275
|
+
* @memberof UnderpostSecret
|
|
2276
|
+
*/
|
|
2277
|
+
installTooling() {
|
|
2278
|
+
const archData = Underpost.baremetal.getHostArch();
|
|
2279
|
+
logger.info('Installing SOPS and Age host tooling...', { ...archData, SOPS_VERSION, AGE_VERSION });
|
|
2280
|
+
const installed = { sops: false, age: false };
|
|
2281
|
+
|
|
2282
|
+
if (Underpost.secret.hasBinary('sops')) logger.info('SOPS is already installed; skipping.');
|
|
2283
|
+
else {
|
|
2284
|
+
shellExec(
|
|
2285
|
+
`curl -fsSL -o /tmp/sops https://github.com/getsops/sops/releases/download/${SOPS_VERSION}/sops-${SOPS_VERSION}.linux.${archData.alias}`,
|
|
2286
|
+
);
|
|
2287
|
+
shellExec(`sudo install -m 0755 /tmp/sops /usr/local/bin/sops`);
|
|
2288
|
+
shellExec(`sudo ln -sf /usr/local/bin/sops /bin/sops`);
|
|
2289
|
+
shellExec(`sudo rm -f /tmp/sops`);
|
|
2290
|
+
installed.sops = true;
|
|
2291
|
+
}
|
|
2292
|
+
|
|
2293
|
+
if (Underpost.secret.hasBinary('age-keygen')) logger.info('Age is already installed; skipping.');
|
|
2294
|
+
else {
|
|
2295
|
+
shellExec(
|
|
2296
|
+
`curl -fsSL -o /tmp/age.tar.gz https://github.com/FiloSottile/age/releases/download/${AGE_VERSION}/age-${AGE_VERSION}-linux-${archData.alias}.tar.gz`,
|
|
2297
|
+
);
|
|
2298
|
+
shellExec(`tar -xzf /tmp/age.tar.gz -C /tmp`);
|
|
2299
|
+
shellExec(`sudo install -m 0755 /tmp/age/age /usr/local/bin/age`);
|
|
2300
|
+
shellExec(`sudo install -m 0755 /tmp/age/age-keygen /usr/local/bin/age-keygen`);
|
|
2301
|
+
shellExec(`sudo ln -sf /usr/local/bin/age /bin/age`);
|
|
2302
|
+
shellExec(`sudo ln -sf /usr/local/bin/age-keygen /bin/age-keygen`);
|
|
2303
|
+
shellExec(`sudo rm -rf /tmp/age /tmp/age.tar.gz`);
|
|
2304
|
+
installed.age = true;
|
|
2305
|
+
}
|
|
2306
|
+
|
|
2307
|
+
Underpost.secret.assertTooling(['sops', 'age', 'age-keygen']);
|
|
2308
|
+
logger.info('SOPS and Age tooling ready.', { installedThisRun: installed });
|
|
2309
|
+
return installed;
|
|
1116
2310
|
},
|
|
1117
2311
|
};
|
|
1118
2312
|
}
|