@mesh-tech/mesh-cli 0.1.0 → 0.12.0
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/assets/intent/agents-fence.md +10 -0
- package/assets/intent/intent-claude-gate.mjs +255 -0
- package/assets/log-shipper.mjs +94 -0
- package/bin/mesh.mjs +80 -0
- package/dist/bin/mesh.d.ts +7 -9
- package/dist/bin/mesh.d.ts.map +1 -1
- package/dist/bin/mesh.js +20826 -37
- package/dist/bin/mesh.js.map +7 -1
- package/dist/build-info.json +4 -0
- package/dist/src/commands/agent-api-client.d.ts +79 -0
- package/dist/src/commands/agent-api-client.d.ts.map +1 -0
- package/dist/src/commands/agent-api-client.js +170 -0
- package/dist/src/commands/agent-api-client.js.map +1 -0
- package/dist/src/commands/artifacts.d.ts +13 -0
- package/dist/src/commands/artifacts.d.ts.map +1 -0
- package/dist/src/commands/artifacts.js +140 -0
- package/dist/src/commands/artifacts.js.map +1 -0
- package/dist/src/commands/cluster.d.ts +45 -0
- package/dist/src/commands/cluster.d.ts.map +1 -0
- package/dist/src/commands/cluster.js +259 -0
- package/dist/src/commands/cluster.js.map +1 -0
- package/dist/src/commands/conversations.d.ts +61 -0
- package/dist/src/commands/conversations.d.ts.map +1 -0
- package/dist/src/commands/conversations.js +239 -0
- package/dist/src/commands/conversations.js.map +1 -0
- package/dist/src/commands/create-app.d.ts +26 -3
- package/dist/src/commands/create-app.d.ts.map +1 -1
- package/dist/src/commands/create-app.js +465 -107
- package/dist/src/commands/create-app.js.map +1 -1
- package/dist/src/commands/deploy.d.ts +19 -0
- package/dist/src/commands/deploy.d.ts.map +1 -1
- package/dist/src/commands/deploy.js +83 -142
- package/dist/src/commands/deploy.js.map +1 -1
- package/dist/src/commands/dev-doctor.d.ts +137 -0
- package/dist/src/commands/dev-doctor.d.ts.map +1 -0
- package/dist/src/commands/dev-doctor.js +671 -0
- package/dist/src/commands/dev-doctor.js.map +1 -0
- package/dist/src/commands/dev-launch.d.ts +59 -0
- package/dist/src/commands/dev-launch.d.ts.map +1 -0
- package/dist/src/commands/dev-launch.js +118 -0
- package/dist/src/commands/dev-launch.js.map +1 -0
- package/dist/src/commands/dev-token-server.d.ts +32 -0
- package/dist/src/commands/dev-token-server.d.ts.map +1 -0
- package/dist/src/commands/dev-token-server.js +68 -0
- package/dist/src/commands/dev-token-server.js.map +1 -0
- package/dist/src/commands/dev.d.ts +289 -2
- package/dist/src/commands/dev.d.ts.map +1 -1
- package/dist/src/commands/dev.js +2501 -574
- package/dist/src/commands/dev.js.map +1 -1
- package/dist/src/commands/docs.d.ts +3 -0
- package/dist/src/commands/docs.d.ts.map +1 -0
- package/dist/src/commands/docs.js +262 -0
- package/dist/src/commands/docs.js.map +1 -0
- package/dist/src/commands/hub/index.d.ts +99 -0
- package/dist/src/commands/hub/index.d.ts.map +1 -0
- package/dist/src/commands/hub/index.js +452 -0
- package/dist/src/commands/hub/index.js.map +1 -0
- package/dist/src/commands/init.d.ts +65 -0
- package/dist/src/commands/init.d.ts.map +1 -0
- package/dist/src/commands/init.js +370 -0
- package/dist/src/commands/init.js.map +1 -0
- package/dist/src/commands/install-shim.d.ts +21 -0
- package/dist/src/commands/install-shim.d.ts.map +1 -0
- package/dist/src/commands/install-shim.js +132 -0
- package/dist/src/commands/install-shim.js.map +1 -0
- package/dist/src/commands/local/auth-provision.d.ts +71 -0
- package/dist/src/commands/local/auth-provision.d.ts.map +1 -0
- package/dist/src/commands/local/auth-provision.js +286 -0
- package/dist/src/commands/local/auth-provision.js.map +1 -0
- package/dist/src/commands/local/dev-local.d.ts +97 -0
- package/dist/src/commands/local/dev-local.d.ts.map +1 -0
- package/dist/src/commands/local/dev-local.js +306 -0
- package/dist/src/commands/local/dev-local.js.map +1 -0
- package/dist/src/commands/local/docker-runner.d.ts +37 -0
- package/dist/src/commands/local/docker-runner.d.ts.map +1 -0
- package/dist/src/commands/local/docker-runner.js +104 -0
- package/dist/src/commands/local/docker-runner.js.map +1 -0
- package/dist/src/commands/local/helpers.d.ts +22 -0
- package/dist/src/commands/local/helpers.d.ts.map +1 -0
- package/dist/src/commands/local/helpers.js +46 -0
- package/dist/src/commands/local/helpers.js.map +1 -0
- package/dist/src/commands/local/hub-local.d.ts +51 -0
- package/dist/src/commands/local/hub-local.d.ts.map +1 -0
- package/dist/src/commands/local/hub-local.js +197 -0
- package/dist/src/commands/local/hub-local.js.map +1 -0
- package/dist/src/commands/local/index.d.ts +23 -0
- package/dist/src/commands/local/index.d.ts.map +1 -0
- package/dist/src/commands/local/index.js +386 -0
- package/dist/src/commands/local/index.js.map +1 -0
- package/dist/src/commands/local/mocks.d.ts +361 -0
- package/dist/src/commands/local/mocks.d.ts.map +1 -0
- package/dist/src/commands/local/mocks.js +756 -0
- package/dist/src/commands/local/mocks.js.map +1 -0
- package/dist/src/commands/local/seed-zitadel.d.ts +151 -0
- package/dist/src/commands/local/seed-zitadel.d.ts.map +1 -0
- package/dist/src/commands/local/seed-zitadel.js +408 -0
- package/dist/src/commands/local/seed-zitadel.js.map +1 -0
- package/dist/src/commands/local/seed.d.ts +106 -0
- package/dist/src/commands/local/seed.d.ts.map +1 -0
- package/dist/src/commands/local/seed.js +252 -0
- package/dist/src/commands/local/seed.js.map +1 -0
- package/dist/src/commands/local/stack.d.ts +173 -0
- package/dist/src/commands/local/stack.d.ts.map +1 -0
- package/dist/src/commands/local/stack.js +499 -0
- package/dist/src/commands/local/stack.js.map +1 -0
- package/dist/src/commands/login.d.ts +256 -12
- package/dist/src/commands/login.d.ts.map +1 -1
- package/dist/src/commands/login.js +978 -50
- package/dist/src/commands/login.js.map +1 -1
- package/dist/src/commands/peer-addressing.d.ts +51 -0
- package/dist/src/commands/peer-addressing.d.ts.map +1 -0
- package/dist/src/commands/peer-addressing.js +64 -0
- package/dist/src/commands/peer-addressing.js.map +1 -0
- package/dist/src/commands/port-ownership.d.ts +69 -0
- package/dist/src/commands/port-ownership.d.ts.map +1 -0
- package/dist/src/commands/port-ownership.js +161 -0
- package/dist/src/commands/port-ownership.js.map +1 -0
- package/dist/src/commands/registry-publish.d.ts +3 -0
- package/dist/src/commands/registry-publish.d.ts.map +1 -0
- package/dist/src/commands/registry-publish.js +161 -0
- package/dist/src/commands/registry-publish.js.map +1 -0
- package/dist/src/commands/registry.d.ts +146 -2
- package/dist/src/commands/registry.d.ts.map +1 -1
- package/dist/src/commands/registry.js +541 -85
- package/dist/src/commands/registry.js.map +1 -1
- package/dist/src/commands/secrets/index.d.ts.map +1 -1
- package/dist/src/commands/secrets/index.js +15 -1
- package/dist/src/commands/secrets/index.js.map +1 -1
- package/dist/src/commands/secrets/migrate-config.d.ts +24 -0
- package/dist/src/commands/secrets/migrate-config.d.ts.map +1 -0
- package/dist/src/commands/secrets/migrate-config.js +277 -0
- package/dist/src/commands/secrets/migrate-config.js.map +1 -0
- package/dist/src/commands/secrets/set.d.ts +4 -0
- package/dist/src/commands/secrets/set.d.ts.map +1 -1
- package/dist/src/commands/secrets/set.js +243 -64
- package/dist/src/commands/secrets/set.js.map +1 -1
- package/dist/src/commands/skills.d.ts +34 -0
- package/dist/src/commands/skills.d.ts.map +1 -0
- package/dist/src/commands/skills.js +207 -0
- package/dist/src/commands/skills.js.map +1 -0
- package/dist/src/commands/stack.d.ts.map +1 -1
- package/dist/src/commands/stack.js +361 -23
- package/dist/src/commands/stack.js.map +1 -1
- package/dist/src/commands/temporal.d.ts +15 -0
- package/dist/src/commands/temporal.d.ts.map +1 -0
- package/dist/src/commands/temporal.js +781 -0
- package/dist/src/commands/temporal.js.map +1 -0
- package/dist/src/commands/tenant.d.ts +56 -0
- package/dist/src/commands/tenant.d.ts.map +1 -0
- package/dist/src/commands/tenant.js +233 -0
- package/dist/src/commands/tenant.js.map +1 -0
- package/dist/src/commands/tunnel/index.d.ts +49 -3
- package/dist/src/commands/tunnel/index.d.ts.map +1 -1
- package/dist/src/commands/tunnel/index.js +331 -8
- package/dist/src/commands/tunnel/index.js.map +1 -1
- package/dist/src/commands/vcs/clone.d.ts +3 -0
- package/dist/src/commands/vcs/clone.d.ts.map +1 -0
- package/dist/src/commands/vcs/clone.js +20 -0
- package/dist/src/commands/vcs/clone.js.map +1 -0
- package/dist/src/commands/vcs/common.d.ts +21 -0
- package/dist/src/commands/vcs/common.d.ts.map +1 -0
- package/dist/src/commands/vcs/common.js +62 -0
- package/dist/src/commands/vcs/common.js.map +1 -0
- package/dist/src/commands/vcs/drafts.d.ts +13 -0
- package/dist/src/commands/vcs/drafts.d.ts.map +1 -0
- package/dist/src/commands/vcs/drafts.js +45 -0
- package/dist/src/commands/vcs/drafts.js.map +1 -0
- package/dist/src/commands/vcs/get.d.ts +8 -0
- package/dist/src/commands/vcs/get.d.ts.map +1 -0
- package/dist/src/commands/vcs/get.js +61 -0
- package/dist/src/commands/vcs/get.js.map +1 -0
- package/dist/src/commands/vcs/index.d.ts +3 -0
- package/dist/src/commands/vcs/index.d.ts.map +1 -0
- package/dist/src/commands/vcs/index.js +58 -0
- package/dist/src/commands/vcs/index.js.map +1 -0
- package/dist/src/commands/vcs/propose.d.ts +23 -0
- package/dist/src/commands/vcs/propose.d.ts.map +1 -0
- package/dist/src/commands/vcs/propose.js +94 -0
- package/dist/src/commands/vcs/propose.js.map +1 -0
- package/dist/src/commands/vcs/review.d.ts +15 -0
- package/dist/src/commands/vcs/review.d.ts.map +1 -0
- package/dist/src/commands/vcs/review.js +35 -0
- package/dist/src/commands/vcs/review.js.map +1 -0
- package/dist/src/commands/vcs-enrich.d.ts +15 -0
- package/dist/src/commands/vcs-enrich.d.ts.map +1 -0
- package/dist/src/commands/vcs-enrich.js +28 -0
- package/dist/src/commands/vcs-enrich.js.map +1 -0
- package/dist/src/commands/vpn/index.d.ts +24 -0
- package/dist/src/commands/vpn/index.d.ts.map +1 -0
- package/dist/src/commands/vpn/index.js +543 -0
- package/dist/src/commands/vpn/index.js.map +1 -0
- package/dist/src/commands/vpn/tunnel.d.ts +11 -0
- package/dist/src/commands/vpn/tunnel.d.ts.map +1 -0
- package/dist/src/commands/vpn/tunnel.js +188 -0
- package/dist/src/commands/vpn/tunnel.js.map +1 -0
- package/dist/src/commands/workflow.d.ts +24 -0
- package/dist/src/commands/workflow.d.ts.map +1 -0
- package/dist/src/commands/workflow.js +577 -0
- package/dist/src/commands/workflow.js.map +1 -0
- package/dist/src/docs/assemble.d.ts +217 -0
- package/dist/src/docs/assemble.d.ts.map +1 -0
- package/dist/src/docs/assemble.js +739 -0
- package/dist/src/docs/assemble.js.map +1 -0
- package/dist/src/docs/cli-reference.d.ts +93 -0
- package/dist/src/docs/cli-reference.d.ts.map +1 -0
- package/dist/src/docs/cli-reference.js +256 -0
- package/dist/src/docs/cli-reference.js.map +1 -0
- package/dist/src/docs/discover.d.ts +60 -0
- package/dist/src/docs/discover.d.ts.map +1 -0
- package/dist/src/docs/discover.js +233 -0
- package/dist/src/docs/discover.js.map +1 -0
- package/dist/src/docs/portal.d.ts +79 -0
- package/dist/src/docs/portal.d.ts.map +1 -0
- package/dist/src/docs/portal.js +242 -0
- package/dist/src/docs/portal.js.map +1 -0
- package/dist/src/docs/registry-docs.d.ts +43 -0
- package/dist/src/docs/registry-docs.d.ts.map +1 -0
- package/dist/src/docs/registry-docs.js +138 -0
- package/dist/src/docs/registry-docs.js.map +1 -0
- package/dist/src/docs/schema.d.ts +48 -0
- package/dist/src/docs/schema.d.ts.map +1 -0
- package/dist/src/docs/schema.js +96 -0
- package/dist/src/docs/schema.js.map +1 -0
- package/dist/src/docs/serve.d.ts +27 -0
- package/dist/src/docs/serve.d.ts.map +1 -0
- package/dist/src/docs/serve.js +102 -0
- package/dist/src/docs/serve.js.map +1 -0
- package/dist/src/docs/start.d.ts +80 -0
- package/dist/src/docs/start.d.ts.map +1 -0
- package/dist/src/docs/start.js +313 -0
- package/dist/src/docs/start.js.map +1 -0
- package/dist/src/eval/agent-eval.d.mts +14 -0
- package/dist/src/eval/agent-eval.d.mts.map +1 -0
- package/dist/src/eval/agent-eval.mjs +273 -0
- package/dist/src/eval/agent-eval.mjs.map +1 -0
- package/dist/src/index.js +624 -7
- package/dist/src/index.js.map +7 -1
- package/dist/src/program.d.ts +26 -0
- package/dist/src/program.d.ts.map +1 -0
- package/dist/src/program.js +76 -0
- package/dist/src/program.js.map +1 -0
- package/dist/src/utils/auth-preflight.d.ts +159 -0
- package/dist/src/utils/auth-preflight.d.ts.map +1 -0
- package/dist/src/utils/auth-preflight.js +298 -0
- package/dist/src/utils/auth-preflight.js.map +1 -0
- package/dist/src/utils/aws-auth.d.ts +181 -0
- package/dist/src/utils/aws-auth.d.ts.map +1 -0
- package/dist/src/utils/aws-auth.js +527 -0
- package/dist/src/utils/aws-auth.js.map +1 -0
- package/dist/src/utils/bastion.d.ts +1 -1
- package/dist/src/utils/bastion.d.ts.map +1 -1
- package/dist/src/utils/bastion.js +29 -8
- package/dist/src/utils/bastion.js.map +1 -1
- package/dist/src/utils/build-info.d.ts +33 -0
- package/dist/src/utils/build-info.d.ts.map +1 -0
- package/dist/src/utils/build-info.js +102 -0
- package/dist/src/utils/build-info.js.map +1 -0
- package/dist/src/utils/capture-history.d.ts +62 -0
- package/dist/src/utils/capture-history.d.ts.map +1 -0
- package/dist/src/utils/capture-history.js +94 -0
- package/dist/src/utils/capture-history.js.map +1 -0
- package/dist/src/utils/deploy-preflight.d.ts +52 -0
- package/dist/src/utils/deploy-preflight.d.ts.map +1 -0
- package/dist/src/utils/deploy-preflight.js +183 -0
- package/dist/src/utils/deploy-preflight.js.map +1 -0
- package/dist/src/utils/errors.d.ts +75 -0
- package/dist/src/utils/errors.d.ts.map +1 -0
- package/dist/src/utils/errors.js +106 -0
- package/dist/src/utils/errors.js.map +1 -0
- package/dist/src/utils/first-party-contexts.d.ts +40 -0
- package/dist/src/utils/first-party-contexts.d.ts.map +1 -0
- package/dist/src/utils/first-party-contexts.js +53 -0
- package/dist/src/utils/first-party-contexts.js.map +1 -0
- package/dist/src/utils/index.d.ts +2 -0
- package/dist/src/utils/index.d.ts.map +1 -1
- package/dist/src/utils/index.js +2 -0
- package/dist/src/utils/index.js.map +1 -1
- package/dist/src/utils/kubeconfig.d.ts +62 -0
- package/dist/src/utils/kubeconfig.d.ts.map +1 -0
- package/dist/src/utils/kubeconfig.js +118 -0
- package/dist/src/utils/kubeconfig.js.map +1 -0
- package/dist/src/utils/log.d.ts +27 -1
- package/dist/src/utils/log.d.ts.map +1 -1
- package/dist/src/utils/log.js +59 -5
- package/dist/src/utils/log.js.map +1 -1
- package/dist/src/utils/pid.d.ts +47 -0
- package/dist/src/utils/pid.d.ts.map +1 -0
- package/dist/src/utils/pid.js +83 -0
- package/dist/src/utils/pid.js.map +1 -0
- package/dist/src/utils/pulumi-run.d.ts +23 -0
- package/dist/src/utils/pulumi-run.d.ts.map +1 -0
- package/dist/src/utils/pulumi-run.js +112 -0
- package/dist/src/utils/pulumi-run.js.map +1 -0
- package/dist/src/utils/pulumi.d.ts +15 -0
- package/dist/src/utils/pulumi.d.ts.map +1 -1
- package/dist/src/utils/pulumi.js +35 -0
- package/dist/src/utils/pulumi.js.map +1 -1
- package/dist/src/utils/reachability.d.ts +29 -0
- package/dist/src/utils/reachability.d.ts.map +1 -0
- package/dist/src/utils/reachability.js +81 -0
- package/dist/src/utils/reachability.js.map +1 -0
- package/dist/src/utils/recover-conversation.d.ts +154 -0
- package/dist/src/utils/recover-conversation.d.ts.map +1 -0
- package/dist/src/utils/recover-conversation.js +314 -0
- package/dist/src/utils/recover-conversation.js.map +1 -0
- package/dist/src/utils/registry-broker.d.ts +148 -0
- package/dist/src/utils/registry-broker.d.ts.map +1 -0
- package/dist/src/utils/registry-broker.js +180 -0
- package/dist/src/utils/registry-broker.js.map +1 -0
- package/dist/src/utils/socks-forward.d.ts +40 -0
- package/dist/src/utils/socks-forward.d.ts.map +1 -0
- package/dist/src/utils/socks-forward.js +97 -0
- package/dist/src/utils/socks-forward.js.map +1 -0
- package/dist/src/utils/stack-flag.d.ts +23 -0
- package/dist/src/utils/stack-flag.d.ts.map +1 -0
- package/dist/src/utils/stack-flag.js +41 -0
- package/dist/src/utils/stack-flag.js.map +1 -0
- package/dist/src/utils/tailscale-targets.d.ts +52 -0
- package/dist/src/utils/tailscale-targets.d.ts.map +1 -0
- package/dist/src/utils/tailscale-targets.js +93 -0
- package/dist/src/utils/tailscale-targets.js.map +1 -0
- package/dist/src/utils/tailscale.d.ts +277 -0
- package/dist/src/utils/tailscale.d.ts.map +1 -0
- package/dist/src/utils/tailscale.js +1007 -0
- package/dist/src/utils/tailscale.js.map +1 -0
- package/dist/src/utils/temporal-auth.d.ts +43 -0
- package/dist/src/utils/temporal-auth.d.ts.map +1 -0
- package/dist/src/utils/temporal-auth.js +176 -0
- package/dist/src/utils/temporal-auth.js.map +1 -0
- package/dist/src/utils/temporal-codec.d.ts +101 -0
- package/dist/src/utils/temporal-codec.d.ts.map +1 -0
- package/dist/src/utils/temporal-codec.js +319 -0
- package/dist/src/utils/temporal-codec.js.map +1 -0
- package/dist/src/utils/tunnel-ownership.d.ts +130 -0
- package/dist/src/utils/tunnel-ownership.d.ts.map +1 -0
- package/dist/src/utils/tunnel-ownership.js +274 -0
- package/dist/src/utils/tunnel-ownership.js.map +1 -0
- package/dist/src/utils/vpn-join.d.ts +32 -0
- package/dist/src/utils/vpn-join.d.ts.map +1 -0
- package/dist/src/utils/vpn-join.js +62 -0
- package/dist/src/utils/vpn-join.js.map +1 -0
- package/dist/src/utils/vpn.d.ts +62 -0
- package/dist/src/utils/vpn.d.ts.map +1 -0
- package/dist/src/utils/vpn.js +183 -0
- package/dist/src/utils/vpn.js.map +1 -0
- package/dist/src/utils/workflow-fingerprint.d.ts +49 -0
- package/dist/src/utils/workflow-fingerprint.d.ts.map +1 -0
- package/dist/src/utils/workflow-fingerprint.js +135 -0
- package/dist/src/utils/workflow-fingerprint.js.map +1 -0
- package/dist/src/utils/worktree-identity.d.ts +86 -0
- package/dist/src/utils/worktree-identity.d.ts.map +1 -0
- package/dist/src/utils/worktree-identity.js +164 -0
- package/dist/src/utils/worktree-identity.js.map +1 -0
- package/fragments/agents/AGENTS.md.hbs +92 -0
- package/fragments/agents/CLAUDE.md.hbs +8 -0
- package/fragments/base/Pulumi.dev.yaml.hbs +10 -0
- package/fragments/base/Pulumi.yaml.hbs +9 -0
- package/fragments/base/index.ts.hbs +107 -0
- package/fragments/base/package.json.hbs +22 -0
- package/{templates/api-web-db/api → fragments/base}/tsconfig.json +3 -4
- package/fragments/bucket/api/src/storage.ts.hbs +81 -0
- package/fragments/database/api/src/db.ts.hbs +23 -0
- package/fragments/database/prisma/schema.prisma.hbs +18 -0
- package/{templates/api-web-db → fragments/service}/api/.dockerignore +2 -0
- package/fragments/service/api/package.json.hbs +40 -0
- package/fragments/service/api/src/index.ts.hbs +58 -0
- package/{templates/api-web-db → fragments/service/api}/tsconfig.json +5 -3
- package/fragments/temporal/worker/.dockerignore +4 -0
- package/fragments/temporal/worker/Dockerfile +15 -0
- package/{templates/temporal-api-worker → fragments/temporal}/worker/package.json.hbs +3 -2
- package/fragments/temporal/worker/src/activities.ts.hbs +14 -0
- package/fragments/temporal/worker/src/worker.ts.hbs +27 -0
- package/fragments/temporal/worker/src/workflows.ts.hbs +24 -0
- package/fragments/temporal/worker/tsconfig.json +13 -0
- package/package.json +35 -21
- package/skills/core/SKILL.md +365 -0
- package/skills/pull-remote-agent-context/SKILL.md +194 -0
- package/stack/docker-compose.hub.yml +134 -0
- package/stack/docker-compose.yml +447 -0
- package/stack/hub/Dockerfile.api +18 -0
- package/stack/hub/Dockerfile.auth +10 -0
- package/stack/hub/Dockerfile.ui +10 -0
- package/stack/init/01-databases.sql +12 -0
- package/stack/observability/blackbox.yml +20 -0
- package/stack/observability/otel-collector.yaml +73 -0
- package/stack/observability/prometheus.yml +36 -0
- package/stack/observability/tempo.yaml +25 -0
- package/stack/temporal/local.yaml +44 -0
- package/templates/api-auth/AGENTS.md.hbs +16 -0
- package/templates/api-auth/Pulumi.dev.yaml.hbs +8 -0
- package/templates/api-auth/Pulumi.yaml.hbs +8 -0
- package/templates/api-auth/README.md.hbs +67 -0
- package/templates/api-auth/api/package.json.hbs +28 -0
- package/templates/api-auth/api/src/index.ts.hbs +96 -0
- package/templates/api-auth/api/src/schema.ts.hbs +35 -0
- package/templates/api-auth/api/src/seed-authz.ts.hbs +48 -0
- package/templates/api-auth/api/src/spicedb-http-provider.ts.hbs +89 -0
- package/templates/api-auth/index.ts.hbs +65 -0
- package/templates/api-auth/package.json.hbs +20 -0
- package/templates/api-auth/skills/develop/SKILL.md.hbs +32 -0
- package/templates/api-role-gating/AGENTS.md.hbs +20 -0
- package/templates/api-role-gating/Pulumi.dev.yaml.hbs +8 -0
- package/templates/api-role-gating/Pulumi.yaml.hbs +8 -0
- package/templates/api-role-gating/README.md.hbs +64 -0
- package/templates/api-role-gating/api/Dockerfile +17 -0
- package/templates/api-role-gating/api/package.json.hbs +28 -0
- package/templates/api-role-gating/api/src/authn.ts.hbs +29 -0
- package/templates/api-role-gating/api/src/authz.ts.hbs +12 -0
- package/templates/api-role-gating/api/src/main.ts.hbs +58 -0
- package/templates/api-role-gating/api/src/schema.ts.hbs +22 -0
- package/templates/api-role-gating/api/tsconfig.json +13 -0
- package/templates/api-role-gating/index.ts.hbs +107 -0
- package/templates/api-role-gating/package.json.hbs +21 -0
- package/templates/api-role-gating/skills/develop/SKILL.md.hbs +60 -0
- package/templates/api-role-gating/tsconfig.json +10 -0
- package/templates/apps-repo/README.md.hbs +24 -0
- package/templates/apps-repo/gitignore +41 -0
- package/templates/apps-repo/npmrc.hbs +7 -0
- package/templates/apps-repo/package.json.hbs +15 -0
- package/templates/apps-repo/pnpm-workspace.yaml +3 -0
- package/templates/apps-repo/tsconfig.json +18 -0
- package/templates/external-service/AGENTS.md.hbs +20 -0
- package/templates/external-service/Pulumi.dev.yaml.hbs +8 -0
- package/templates/external-service/Pulumi.yaml.hbs +8 -0
- package/templates/external-service/README.md.hbs +56 -0
- package/templates/external-service/api/Dockerfile +15 -0
- package/templates/external-service/api/package.json.hbs +25 -0
- package/templates/external-service/api/src/index.ts.hbs +81 -0
- package/templates/external-service/index.ts.hbs +82 -0
- package/templates/external-service/mocks/vendorpay/package.json.hbs +9 -0
- package/templates/external-service/mocks/vendorpay/server.mjs.hbs +61 -0
- package/templates/external-service/package.json.hbs +34 -0
- package/templates/external-service/skills/develop/SKILL.md.hbs +55 -0
- package/templates/external-service/tsconfig.json +10 -0
- package/templates/platform-repo/AGENTS.md.hbs +18 -0
- package/templates/platform-repo/README.md.hbs +38 -0
- package/templates/platform-repo/core/Pulumi.yaml.hbs +12 -0
- package/templates/platform-repo/core/Pulumi.{{tenant}}-{{env}}.yaml.hbs +57 -0
- package/templates/platform-repo/core/index.ts +62 -0
- package/templates/platform-repo/core/package.json.hbs +22 -0
- package/templates/platform-repo/core/tsconfig.json +8 -0
- package/templates/platform-repo/package.json.hbs +15 -0
- package/templates/platform-repo/platform/Pulumi.yaml.hbs +12 -0
- package/templates/platform-repo/platform/Pulumi.{{tenant}}-{{env}}.yaml.hbs +59 -0
- package/templates/platform-repo/platform/index.ts +95 -0
- package/templates/platform-repo/platform/package.json.hbs +26 -0
- package/templates/platform-repo/platform/tsconfig.json +8 -0
- package/templates/platform-repo/pnpm-workspace.yaml +3 -0
- package/templates/workflow/AGENTS.md.hbs +15 -0
- package/templates/workflow/Pulumi.dev.yaml.hbs +8 -0
- package/templates/workflow/Pulumi.yaml.hbs +8 -0
- package/templates/workflow/README.md.hbs +45 -0
- package/templates/workflow/api/.dockerignore +4 -0
- package/templates/workflow/api/Dockerfile +15 -0
- package/templates/{temporal-api-worker → workflow}/api/package.json.hbs +8 -6
- package/templates/workflow/api/src/index.ts.hbs +130 -0
- package/templates/workflow/api/tsconfig.json +14 -0
- package/templates/workflow/index.ts.hbs +67 -0
- package/templates/workflow/package.json.hbs +20 -0
- package/templates/workflow/skills/develop/SKILL.md.hbs +32 -0
- package/templates/workflow/tsconfig.json +10 -0
- package/templates/workflow/worker/.dockerignore +4 -0
- package/templates/workflow/worker/Dockerfile +13 -0
- package/templates/workflow/worker/package.json.hbs +25 -0
- package/templates/workflow/worker/src/worker.ts.hbs +24 -0
- package/templates/workflow/worker/tsconfig.json +14 -0
- package/dist/src/commands/devbox.d.ts +0 -22
- package/dist/src/commands/devbox.d.ts.map +0 -1
- package/dist/src/commands/devbox.js +0 -463
- package/dist/src/commands/devbox.js.map +0 -1
- package/templates/api-web-db/api/Dockerfile +0 -16
- package/templates/api-web-db/api/package.json.hbs +0 -21
- package/templates/api-web-db/api/src/index.ts.hbs +0 -123
- package/templates/api-web-db/package.json.hbs +0 -27
- package/templates/api-web-db/sst.config.ts.hbs +0 -151
- package/templates/api-web-db/web/.dockerignore +0 -3
- package/templates/api-web-db/web/Dockerfile +0 -17
- package/templates/api-web-db/web/app/lib/db.server.ts +0 -36
- package/templates/api-web-db/web/app/root.tsx +0 -29
- package/templates/api-web-db/web/app/routes/home.tsx.hbs +0 -114
- package/templates/api-web-db/web/app/routes.ts +0 -3
- package/templates/api-web-db/web/package.json.hbs +0 -30
- package/templates/api-web-db/web/prisma/schema.prisma +0 -25
- package/templates/api-web-db/web/prisma.config.ts +0 -18
- package/templates/api-web-db/web/react-router.config.ts +0 -5
- package/templates/api-web-db/web/tsconfig.json +0 -19
- package/templates/api-web-db/web/vite.config.ts +0 -9
- package/templates/temporal-api-worker/README.md.hbs +0 -133
- package/templates/temporal-api-worker/api/src/index.ts.hbs +0 -145
- package/templates/temporal-api-worker/package.json.hbs +0 -29
- package/templates/temporal-api-worker/sst.config.ts.hbs +0 -161
- package/templates/temporal-api-worker/worker/Dockerfile +0 -11
- package/templates/temporal-api-worker/worker/src/bundle-workflows.ts +0 -38
- package/templates/temporal-api-worker/worker/src/worker.ts.hbs +0 -93
- /package/templates/{temporal-api-worker → api-auth}/api/.dockerignore +0 -0
- /package/templates/{temporal-api-worker → api-auth}/api/Dockerfile +0 -0
- /package/templates/{temporal-api-worker → api-auth}/api/tsconfig.json +0 -0
- /package/templates/{temporal-api-worker → api-auth}/tsconfig.json +0 -0
- /package/templates/{temporal-api-worker/worker → api-role-gating/api}/.dockerignore +0 -0
- /package/templates/{temporal-api-worker/worker → external-service/api}/tsconfig.json +0 -0
- /package/templates/{temporal-api-worker → workflow}/api/src/encryption-codec.ts +0 -0
- /package/templates/{temporal-api-worker → workflow}/worker/src/activities.ts.hbs +0 -0
- /package/templates/{temporal-api-worker → workflow}/worker/src/encryption-codec.ts +0 -0
- /package/templates/{temporal-api-worker → workflow}/worker/src/workflows.ts.hbs +0 -0
|
@@ -0,0 +1,1007 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Userspace-Tailscale daemon management + tunnel-session state for
|
|
3
|
+
* `mesh vpn tunnel` (MESH-2040 / Slice 1 of MESH-2014).
|
|
4
|
+
*
|
|
5
|
+
* A per-tenant `tailscaled` runs in userspace-networking mode (brew formula,
|
|
6
|
+
* NOT the GUI app) exposing a SOCKS5 proxy; forwarders bridge `localhost:PORT`
|
|
7
|
+
* to VPC services through it. State lives under `~/.config/mesh/tailscale/<tenant>/`
|
|
8
|
+
* (r+w-granted under safehouse — see the MESH-2036 spike).
|
|
9
|
+
*/
|
|
10
|
+
import * as fs from "node:fs";
|
|
11
|
+
import * as net from "node:net";
|
|
12
|
+
import * as os from "node:os";
|
|
13
|
+
import * as path from "node:path";
|
|
14
|
+
import { spawn, execFileSync } from "node:child_process";
|
|
15
|
+
import { CONFIG_DIR, atomicWriteFileSync } from "../commands/login.js";
|
|
16
|
+
import { resolveStableMeshBin } from "./aws-auth.js";
|
|
17
|
+
import { logInfo, logWarn } from "./log.js";
|
|
18
|
+
import { MeshCliError, renderErrorBody } from "./errors.js";
|
|
19
|
+
import { assertSignalablePid } from "./pid.js";
|
|
20
|
+
import { getPlatformBastionInfo } from "./bastion.js";
|
|
21
|
+
import { resolveTunnelTargets, resolveElbIp, tenantPortIndex, PORT_BLOCK } from "./tailscale-targets.js";
|
|
22
|
+
import { createForwarder } from "./socks-forward.js";
|
|
23
|
+
import { mintRunnerToken, readRunnerManifest, writeRunnerManifest, clearRunnerManifest, getProcessGroupId, discoverRunnerPids, startControlServer, verifyRunnerOwnership, pollOwnership, findSquattedPorts, buildSquatterError, } from "./tunnel-ownership.js";
|
|
24
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
25
|
+
// ============================================================================
|
|
26
|
+
// Paths
|
|
27
|
+
// ============================================================================
|
|
28
|
+
export function tenantStateDir(tenant) {
|
|
29
|
+
return path.join(CONFIG_DIR, "tailscale", tenant);
|
|
30
|
+
}
|
|
31
|
+
export function socketPath(tenant) {
|
|
32
|
+
return path.join(tenantStateDir(tenant), "tailscaled.sock");
|
|
33
|
+
}
|
|
34
|
+
export function logPath(tenant) {
|
|
35
|
+
return path.join(tenantStateDir(tenant), "tailscaled.log");
|
|
36
|
+
}
|
|
37
|
+
export function statePath(tenant) {
|
|
38
|
+
return path.join(tenantStateDir(tenant), "tunnel-state.json");
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Daemon-scoped metadata, written by `ensureDaemon` when it actually spawns the
|
|
42
|
+
* daemon. Unlike `tunnel-state.json` (which `down` clears), this survives `down`
|
|
43
|
+
* so a later `up` reuses the RUNNING daemon's SOCKS port instead of minting a
|
|
44
|
+
* fresh one the daemon isn't listening on (which would leave every forwarder
|
|
45
|
+
* dialing a dead port). Also records the daemon pid so `stopDaemon` can kill it.
|
|
46
|
+
*/
|
|
47
|
+
export function daemonMetaPath(tenant) {
|
|
48
|
+
return path.join(tenantStateDir(tenant), "daemon.json");
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Ownership manifest written by the RUNNER (the process that binds the
|
|
52
|
+
* forwarder ports) — pid, process-group id, per-run token, control port. The
|
|
53
|
+
* ownership handshake (MESH-2407) verifies against it; teardown signals the
|
|
54
|
+
* recorded process group.
|
|
55
|
+
*/
|
|
56
|
+
export function runnerManifestPath(tenant) {
|
|
57
|
+
return path.join(tenantStateDir(tenant), "runner.json");
|
|
58
|
+
}
|
|
59
|
+
export function readTunnelState(tenant) {
|
|
60
|
+
const p = statePath(tenant);
|
|
61
|
+
if (!fs.existsSync(p))
|
|
62
|
+
return null;
|
|
63
|
+
try {
|
|
64
|
+
return JSON.parse(fs.readFileSync(p, "utf8"));
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
export function writeTunnelState(tenant, state) {
|
|
71
|
+
fs.mkdirSync(tenantStateDir(tenant), { recursive: true });
|
|
72
|
+
atomicWriteFileSync(statePath(tenant), JSON.stringify(state, null, 2), 0o600);
|
|
73
|
+
}
|
|
74
|
+
export function clearTunnelState(tenant) {
|
|
75
|
+
try {
|
|
76
|
+
fs.unlinkSync(statePath(tenant));
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
/* already gone */
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
export function readDaemonMeta(tenant) {
|
|
83
|
+
const p = daemonMetaPath(tenant);
|
|
84
|
+
if (!fs.existsSync(p))
|
|
85
|
+
return null;
|
|
86
|
+
try {
|
|
87
|
+
return JSON.parse(fs.readFileSync(p, "utf8"));
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
export function writeDaemonMeta(tenant, meta) {
|
|
94
|
+
fs.mkdirSync(tenantStateDir(tenant), { recursive: true });
|
|
95
|
+
atomicWriteFileSync(daemonMetaPath(tenant), JSON.stringify(meta, null, 2), 0o600);
|
|
96
|
+
}
|
|
97
|
+
export function clearDaemonMeta(tenant) {
|
|
98
|
+
try {
|
|
99
|
+
fs.unlinkSync(daemonMetaPath(tenant));
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
/* already gone */
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
// ============================================================================
|
|
106
|
+
// Daemon status parsing
|
|
107
|
+
// ============================================================================
|
|
108
|
+
/** Parse `tailscale status --json` → the fields we care about. */
|
|
109
|
+
export function parseDaemonState(statusJson) {
|
|
110
|
+
try {
|
|
111
|
+
const j = JSON.parse(statusJson);
|
|
112
|
+
return { backendState: j.BackendState ?? "Unknown", selfName: j.Self?.HostName };
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return { backendState: "Unknown" };
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
// ============================================================================
|
|
119
|
+
// Binary resolution (macOS / brew — Slice 1)
|
|
120
|
+
// ============================================================================
|
|
121
|
+
/** The brew `tailscaled` (NOT the GUI app, which ships no standalone daemon). */
|
|
122
|
+
export function tailscaledBinPath() {
|
|
123
|
+
return resolveBrewBin("tailscaled");
|
|
124
|
+
}
|
|
125
|
+
/** The brew `tailscale` CLI (prefer it over the GUI shim, which aborts under sandbox). */
|
|
126
|
+
export function tailscaleBinPath() {
|
|
127
|
+
return resolveBrewBin("tailscale");
|
|
128
|
+
}
|
|
129
|
+
function resolveBrewBin(name) {
|
|
130
|
+
const brew = `/opt/homebrew/bin/${name}`;
|
|
131
|
+
if (fs.existsSync(brew))
|
|
132
|
+
return brew;
|
|
133
|
+
try {
|
|
134
|
+
const p = execFileSync("which", [name], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
135
|
+
if (p)
|
|
136
|
+
return p;
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
/* fall through */
|
|
140
|
+
}
|
|
141
|
+
throw new Error(`${name} not found — install the standalone binary: brew install tailscale`);
|
|
142
|
+
}
|
|
143
|
+
// ============================================================================
|
|
144
|
+
// Daemon lifecycle
|
|
145
|
+
// ============================================================================
|
|
146
|
+
/** Backend state via the tenant's control socket; "Down" when no daemon answers. */
|
|
147
|
+
export function daemonState(tenant) {
|
|
148
|
+
if (!fs.existsSync(socketPath(tenant)))
|
|
149
|
+
return { backendState: "Down" };
|
|
150
|
+
try {
|
|
151
|
+
const out = execFileSync(tailscaleBinPath(), ["--socket", socketPath(tenant), "status", "--json"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
152
|
+
return parseDaemonState(out);
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
return { backendState: "Down" };
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Extract the `--socks5-server` port from a `tailscaled` command line. Pure so
|
|
160
|
+
* it's unit-testable. Accepts both `--socks5-server=127.0.0.1:PORT` and the
|
|
161
|
+
* space-separated form, and a `localhost` host literal. Returns null if absent
|
|
162
|
+
* or malformed.
|
|
163
|
+
*/
|
|
164
|
+
export function parseSocksPortFromArgv(argv) {
|
|
165
|
+
const m = argv.match(/--socks5-server[=\s]+(?:127\.0\.0\.1|localhost):(\d+)/);
|
|
166
|
+
if (!m)
|
|
167
|
+
return null;
|
|
168
|
+
const port = Number(m[1]);
|
|
169
|
+
return Number.isInteger(port) && port > 0 && port <= 65535 ? port : null;
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Find the running userspace `tailscaled` for a tenant by its unique
|
|
173
|
+
* `--socket=<path>` argument, returning its pid and the SOCKS port it ACTUALLY
|
|
174
|
+
* bound (parsed from argv). Authoritative when `daemon.json` is missing or stale
|
|
175
|
+
* (e.g. a daemon left by an older build that predates the meta file). Returns
|
|
176
|
+
* null when no matching process is found or `ps` is unavailable.
|
|
177
|
+
*/
|
|
178
|
+
export function findRunningDaemon(tenant) {
|
|
179
|
+
const sock = socketPath(tenant);
|
|
180
|
+
let out;
|
|
181
|
+
try {
|
|
182
|
+
out = execFileSync("ps", ["ax", "-o", "pid=,command="], {
|
|
183
|
+
encoding: "utf8",
|
|
184
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
for (const line of out.split("\n")) {
|
|
191
|
+
// Match this tenant's daemon by its unique --socket path (skips the ps line,
|
|
192
|
+
// other tenants' daemons, and the GUI app).
|
|
193
|
+
if (!line.includes("tailscaled") || !line.includes(`--socket=${sock}`))
|
|
194
|
+
continue;
|
|
195
|
+
const m = line.trim().match(/^(\d+)\s+(.*)$/);
|
|
196
|
+
if (!m)
|
|
197
|
+
continue;
|
|
198
|
+
return { pid: Number(m[1]), socksPort: parseSocksPortFromArgv(m[2]) };
|
|
199
|
+
}
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
/** True if something accepts TCP on 127.0.0.1:port within `timeoutMs`. */
|
|
203
|
+
export function portAccepts(port, timeoutMs = 400) {
|
|
204
|
+
return new Promise((resolve) => {
|
|
205
|
+
const s = new net.Socket();
|
|
206
|
+
let done = false;
|
|
207
|
+
const fin = (v) => {
|
|
208
|
+
if (done)
|
|
209
|
+
return;
|
|
210
|
+
done = true;
|
|
211
|
+
s.destroy();
|
|
212
|
+
resolve(v);
|
|
213
|
+
};
|
|
214
|
+
s.setTimeout(timeoutMs);
|
|
215
|
+
s.once("connect", () => fin(true));
|
|
216
|
+
s.once("timeout", () => fin(false));
|
|
217
|
+
s.once("error", () => fin(false));
|
|
218
|
+
s.connect(port, "127.0.0.1");
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Ensure a userspace `tailscaled` is running for the tenant and return the SOCKS
|
|
223
|
+
* port the forwarders should dial. Idempotent — reuses a live daemon, but
|
|
224
|
+
* RECONCILES the port: a daemon that's up on a different port than requested
|
|
225
|
+
* (older build with no `daemon.json`, or stale meta) would otherwise leave every
|
|
226
|
+
* forwarder dialing a dead port (MESH-2169). Trust `opts.socksPort` only if it
|
|
227
|
+
* truly accepts; else adopt the running daemon's real port; else restart fresh.
|
|
228
|
+
*/
|
|
229
|
+
export async function ensureDaemon(tenant, opts) {
|
|
230
|
+
if (daemonState(tenant).backendState !== "Down") {
|
|
231
|
+
// The daemon's SOCKS proxy is only useful on the exact port the forwarders
|
|
232
|
+
// will dial. If the requested port already accepts, record it and use it.
|
|
233
|
+
if (await portAccepts(opts.socksPort)) {
|
|
234
|
+
writeDaemonMeta(tenant, {
|
|
235
|
+
socksPort: opts.socksPort,
|
|
236
|
+
pid: readDaemonMeta(tenant)?.pid ?? findRunningDaemon(tenant)?.pid ?? 0,
|
|
237
|
+
});
|
|
238
|
+
return opts.socksPort;
|
|
239
|
+
}
|
|
240
|
+
// Requested port is dead — the daemon is on a different one. Discover its
|
|
241
|
+
// real SOCKS port from argv and adopt it (no restart → no severing other
|
|
242
|
+
// sessions), rewriting daemon.json so future runs read the right port.
|
|
243
|
+
const running = findRunningDaemon(tenant);
|
|
244
|
+
if (running?.socksPort && (await portAccepts(running.socksPort))) {
|
|
245
|
+
writeDaemonMeta(tenant, { socksPort: running.socksPort, pid: running.pid });
|
|
246
|
+
return running.socksPort;
|
|
247
|
+
}
|
|
248
|
+
// Can't discover/verify a working SOCKS port — the daemon is unusable to us.
|
|
249
|
+
// Tear it down (state dir keeps the node identity → no re-login) and fall
|
|
250
|
+
// through to a fresh start on opts.socksPort.
|
|
251
|
+
stopDaemon(tenant);
|
|
252
|
+
for (let i = 0; i < 12 && daemonState(tenant).backendState !== "Down"; i++) {
|
|
253
|
+
await sleep(250);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
const dir = tenantStateDir(tenant);
|
|
257
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
258
|
+
const log = fs.openSync(logPath(tenant), "a");
|
|
259
|
+
try {
|
|
260
|
+
const child = spawn(tailscaledBinPath(), [
|
|
261
|
+
"--tun=userspace-networking",
|
|
262
|
+
`--socket=${socketPath(tenant)}`,
|
|
263
|
+
`--statedir=${dir}`,
|
|
264
|
+
`--socks5-server=127.0.0.1:${opts.socksPort}`,
|
|
265
|
+
"--port=0",
|
|
266
|
+
], {
|
|
267
|
+
detached: true,
|
|
268
|
+
stdio: ["ignore", log, log],
|
|
269
|
+
env: { ...process.env, AWS_REGION: opts.region },
|
|
270
|
+
});
|
|
271
|
+
child.unref();
|
|
272
|
+
// Persist the socks port + pid so `down` → `up` reuses this daemon's real
|
|
273
|
+
// port (not a fresh one it isn't listening on) and `stopDaemon` can kill it.
|
|
274
|
+
if (child.pid)
|
|
275
|
+
writeDaemonMeta(tenant, { socksPort: opts.socksPort, pid: child.pid });
|
|
276
|
+
}
|
|
277
|
+
finally {
|
|
278
|
+
fs.closeSync(log);
|
|
279
|
+
}
|
|
280
|
+
for (let i = 0; i < 40; i++) {
|
|
281
|
+
await sleep(250);
|
|
282
|
+
if (daemonState(tenant).backendState !== "Down")
|
|
283
|
+
return opts.socksPort;
|
|
284
|
+
}
|
|
285
|
+
throw new Error(`tailscaled did not start for tenant '${tenant}' — see ${logPath(tenant)}`);
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* Stop the detached `tailscaled` for a tenant (SIGTERM its pid) and remove the
|
|
289
|
+
* socket + daemon metadata. Leaves the Headscale node identity in `tailscaled.state`
|
|
290
|
+
* so a later `up` re-spawns and rejoins without an interactive re-login — unless
|
|
291
|
+
* the caller logged out first.
|
|
292
|
+
*
|
|
293
|
+
* Best-effort, and that includes a corrupt `daemon.json`: an unsignalable
|
|
294
|
+
* recorded pid is REFUSED (never handed to `kill`) but only warns — the socket
|
|
295
|
+
* and meta are still cleared. `ensureDaemon` calls this on the UP path to
|
|
296
|
+
* recycle a daemon it can't verify, so throwing here would dead-end
|
|
297
|
+
* `mesh vpn tunnel up` / `mesh dev` on the very file this function deletes.
|
|
298
|
+
* Safe to call when nothing is running.
|
|
299
|
+
*/
|
|
300
|
+
export function stopDaemon(tenant) {
|
|
301
|
+
// Prefer the recorded pid; fall back to discovering it from argv so a daemon
|
|
302
|
+
// left without a daemon.json (older build / cleared meta) can still be killed.
|
|
303
|
+
const meta = readDaemonMeta(tenant) ?? (() => {
|
|
304
|
+
const running = findRunningDaemon(tenant);
|
|
305
|
+
return running ? { socksPort: running.socksPort ?? 0, pid: running.pid } : null;
|
|
306
|
+
})();
|
|
307
|
+
// `ensureDaemon` records `pid: 0` when it adopts a running daemon whose pid it
|
|
308
|
+
// could not attribute, so absent-or-0 is this file's "no pid known" sentinel —
|
|
309
|
+
// skip it, don't signal and don't fail. EVERY other unsignalable value (1, a
|
|
310
|
+
// negative, a fraction, a string from a truncated/hand-edited daemon.json) is
|
|
311
|
+
// corruption and must be refused rather than handed to kill. See utils/pid.ts.
|
|
312
|
+
if (meta?.pid) {
|
|
313
|
+
try {
|
|
314
|
+
assertSignalablePid(meta.pid, {
|
|
315
|
+
what: `the VPN daemon pid recorded for tenant '${tenant}'`,
|
|
316
|
+
file: daemonMetaPath(tenant),
|
|
317
|
+
});
|
|
318
|
+
process.kill(meta.pid, "SIGTERM");
|
|
319
|
+
}
|
|
320
|
+
catch (err) {
|
|
321
|
+
// Refusing to SIGNAL is the whole point; refusing to CLEAN UP is not.
|
|
322
|
+
// The unlink + clearDaemonMeta below are what make the corrupt file
|
|
323
|
+
// self-healing, and `ensureDaemon` (the UP path) depends on them. Warn
|
|
324
|
+
// with the `rm` remediation intact and fall through.
|
|
325
|
+
if (err instanceof MeshCliError)
|
|
326
|
+
logWarn(renderErrorBody(err));
|
|
327
|
+
/* else: ESRCH/EPERM from process.kill — already gone, as before */
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
for (const p of [socketPath(tenant)]) {
|
|
331
|
+
try {
|
|
332
|
+
fs.unlinkSync(p);
|
|
333
|
+
}
|
|
334
|
+
catch {
|
|
335
|
+
/* already gone */
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
clearDaemonMeta(tenant);
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* Build the `tailscale up` argument vector. Pure so it can be unit-tested and so
|
|
342
|
+
* the interactive (browser-register) and zero-touch (pre-auth key) forms differ
|
|
343
|
+
* only by the trailing `--authkey`.
|
|
344
|
+
*/
|
|
345
|
+
export function buildTailscaleUpArgs(opts) {
|
|
346
|
+
const args = [
|
|
347
|
+
"--socket", opts.socket, "up",
|
|
348
|
+
"--login-server", opts.loginServer,
|
|
349
|
+
"--accept-routes",
|
|
350
|
+
"--hostname", opts.hostname,
|
|
351
|
+
];
|
|
352
|
+
if (opts.preAuthKey)
|
|
353
|
+
args.push("--authkey", opts.preAuthKey);
|
|
354
|
+
return args;
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* Join the tenant's Headscale tailnet via Zitadel OIDC if not already Running.
|
|
358
|
+
* Prints the device-flow registration URL and polls until registered. When a
|
|
359
|
+
* pre-auth key is supplied the join is non-interactive: no `/register/` URL is
|
|
360
|
+
* emitted, so the scrape/relay is skipped and we just wait for Running.
|
|
361
|
+
*/
|
|
362
|
+
export async function joinHeadscale(tenant, loginServer, opts = {}) {
|
|
363
|
+
if (daemonState(tenant).backendState === "Running")
|
|
364
|
+
return;
|
|
365
|
+
const hostname = `mesh-${tenant}-${os.hostname().split(".")[0]}`;
|
|
366
|
+
const child = spawn(tailscaleBinPath(), buildTailscaleUpArgs({ socket: socketPath(tenant), loginServer, hostname, preAuthKey: opts.preAuthKey }), { stdio: ["ignore", "pipe", "pipe"] });
|
|
367
|
+
if (!opts.preAuthKey) {
|
|
368
|
+
let buf = "";
|
|
369
|
+
let urlShown = false;
|
|
370
|
+
const capture = (d) => {
|
|
371
|
+
buf += d.toString();
|
|
372
|
+
if (urlShown)
|
|
373
|
+
return;
|
|
374
|
+
const m = buf.match(/https?:\/\/\S+\/register\/\S+/);
|
|
375
|
+
if (m) {
|
|
376
|
+
urlShown = true;
|
|
377
|
+
// Relay the registration URL for headless/SSH machines (there's no local
|
|
378
|
+
// browser to pop). One-time per machine — the node registration persists.
|
|
379
|
+
logInfo(`\n This machine isn't registered with the platform VPN yet.\n` +
|
|
380
|
+
` Open this URL in a browser to authorize it (one-time, sign in with Zitadel):\n\n` +
|
|
381
|
+
` ${m[0]}\n\n` +
|
|
382
|
+
` Waiting for approval (up to 5 min)…`);
|
|
383
|
+
}
|
|
384
|
+
};
|
|
385
|
+
child.stdout.on("data", capture);
|
|
386
|
+
child.stderr.on("data", capture);
|
|
387
|
+
}
|
|
388
|
+
// Poll up to ~5 min — the join is an interactive browser approval.
|
|
389
|
+
for (let i = 0; i < 600; i++) {
|
|
390
|
+
await sleep(500);
|
|
391
|
+
if (daemonState(tenant).backendState === "Running") {
|
|
392
|
+
child.kill();
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
child.kill();
|
|
397
|
+
throw new Error(`VPN join not completed for tenant '${tenant}' within 5 min. Re-run \`mesh vpn tunnel up\` to resume — the daemon is still up.`);
|
|
398
|
+
}
|
|
399
|
+
/** Log the tenant daemon out of Headscale (disconnect). Daemon process persists. */
|
|
400
|
+
export function logout(tenant) {
|
|
401
|
+
try {
|
|
402
|
+
execFileSync(tailscaleBinPath(), ["--socket", socketPath(tenant), "logout"], { stdio: "ignore" });
|
|
403
|
+
}
|
|
404
|
+
catch {
|
|
405
|
+
/* best-effort */
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
// ============================================================================
|
|
409
|
+
// Forwarder supervisor (shared per tenant) + devOutput rewrite
|
|
410
|
+
// ============================================================================
|
|
411
|
+
/**
|
|
412
|
+
* True iff two target sets are identical down to targetIp + remotePort. A live
|
|
413
|
+
* runner pins each forwarder's target at startup, so if the ELB IP rotated (or a
|
|
414
|
+
* port changed) the running forwarders point at a dead address and must be
|
|
415
|
+
* respawned, not reused. Pid-agnostic (a launchd runner records no supervisor pid).
|
|
416
|
+
*/
|
|
417
|
+
export function tunnelTargetsEqual(a, b) {
|
|
418
|
+
const key = (ts) => ts.map((t) => `${t.name}:${t.localPort}:${t.targetIp}:${t.remotePort}`).sort().join(",");
|
|
419
|
+
return key(a) === key(b);
|
|
420
|
+
}
|
|
421
|
+
/** True iff `state` has a supervisor pid and its target set equals `targets`. */
|
|
422
|
+
export function supervisorTargetsMatch(state, targets) {
|
|
423
|
+
if (!state || !state.supervisorPid)
|
|
424
|
+
return false;
|
|
425
|
+
return tunnelTargetsEqual(state.tunnels, targets);
|
|
426
|
+
}
|
|
427
|
+
function pidAlive(pid) {
|
|
428
|
+
if (!pid)
|
|
429
|
+
return false;
|
|
430
|
+
try {
|
|
431
|
+
process.kill(pid, 0);
|
|
432
|
+
return true;
|
|
433
|
+
}
|
|
434
|
+
catch {
|
|
435
|
+
return false;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
/**
|
|
439
|
+
* SIGTERM the supervisor's whole process GROUP, not the bare pid.
|
|
440
|
+
*
|
|
441
|
+
* `state.supervisorPid` is the pid of the detached spawn — which, under the
|
|
442
|
+
* canonical source launcher, is `bin/mesh.mjs`. That launcher blocks in
|
|
443
|
+
* `execFileSync` around the *real* runner (a `node --import tsx … mesh.ts`
|
|
444
|
+
* grandchild that actually binds the forwarder ports), and `execFileSync` does
|
|
445
|
+
* not forward signals. So signalling the bare pid kills only the launcher and
|
|
446
|
+
* ORPHANS the runner, which keeps its ports bound — the orphan then answers
|
|
447
|
+
* `allForwardersListening`'s ownership-less liveness dial and silently pins
|
|
448
|
+
* every tunnel to a stale target (the MESH-2169 / MESH-2014 failure class).
|
|
449
|
+
*
|
|
450
|
+
* The launcher is spawned `detached: true`, so it is a process-group leader
|
|
451
|
+
* (`pgid === pid`); `process.kill(-pid, …)` reaps the launcher and the runner
|
|
452
|
+
* together. Falls back to the bare pid for a legacy state file whose pid was
|
|
453
|
+
* not a group leader, and tolerates ESRCH (already gone).
|
|
454
|
+
*
|
|
455
|
+
* `pid` is always recovered — from `tunnel-state.json`, `runner.json`, or a
|
|
456
|
+
* `ps` scan — never constructed here, so it is guarded first. The guard rejects
|
|
457
|
+
* anything below 2, which is what makes the NEGATED form safe: `pid >= 2` ⇒
|
|
458
|
+
* `-pid <= -2`, a real process group, never `kill(0)` (this shell's own group)
|
|
459
|
+
* or `kill(-1)` (broadcast to everything the user can signal). See utils/pid.ts.
|
|
460
|
+
*
|
|
461
|
+
* @throws {MeshCliError} when `pid` is unsignalable. Callers reaping SEVERAL
|
|
462
|
+
* independently-recovered pids must catch per-pid (see `stopTunnelRunners`) —
|
|
463
|
+
* letting one refusal propagate would skip the other pids and leave a live
|
|
464
|
+
* runner holding ports.
|
|
465
|
+
*/
|
|
466
|
+
export function killSupervisor(pid, source) {
|
|
467
|
+
// MUST be outside the try — both signals below are wrapped in catch-alls, so a
|
|
468
|
+
// guard placed inside would have its refusal swallowed and fall through to the
|
|
469
|
+
// bare-pid kill it was meant to prevent.
|
|
470
|
+
assertSignalablePid(pid, source ?? { what: "tunnel supervisor pid" });
|
|
471
|
+
try {
|
|
472
|
+
process.kill(-pid, "SIGTERM"); // detached spawn ⇒ pid is the group leader
|
|
473
|
+
}
|
|
474
|
+
catch {
|
|
475
|
+
try {
|
|
476
|
+
process.kill(pid, "SIGTERM"); // not a group leader (old state file) — best effort
|
|
477
|
+
}
|
|
478
|
+
catch {
|
|
479
|
+
/* already gone (ESRCH) */
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
/**
|
|
484
|
+
* The ownership handshake for a tenant's tunnel runner (MESH-2407): manifest →
|
|
485
|
+
* live pid → control endpoint → token + SOCKS port + port coverage. This is
|
|
486
|
+
* the ONLY readiness/reuse signal — a bare TCP dial proves a listener exists,
|
|
487
|
+
* not that it is ours, and adopting a foreign listener is exactly the
|
|
488
|
+
* silent-dead-tunnel class (MESH-2169 / MESH-2014 / #2991). As a bonus the
|
|
489
|
+
* handshake reads listening state from the runner's in-process servers, so it
|
|
490
|
+
* opens no SOCKS→VPC data connection the way port dials do.
|
|
491
|
+
*/
|
|
492
|
+
export async function verifyTunnelOwnership(tenant, expected) {
|
|
493
|
+
return verifyRunnerOwnership({
|
|
494
|
+
manifest: readRunnerManifest(runnerManifestPath(tenant)),
|
|
495
|
+
expectedSocksPort: expected.socksPort,
|
|
496
|
+
expectedPorts: expected.ports,
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
/**
|
|
500
|
+
* Poll the ownership handshake until it passes or `deadlineMs` of WALL CLOCK
|
|
501
|
+
* elapses (each check can cost up to the control-query timeout, so the bound
|
|
502
|
+
* must be time, not rounds); returns the last check.
|
|
503
|
+
*/
|
|
504
|
+
function awaitOwnership(tenant, expected, deadlineMs) {
|
|
505
|
+
return pollOwnership(() => verifyTunnelOwnership(tenant, expected), deadlineMs);
|
|
506
|
+
}
|
|
507
|
+
/**
|
|
508
|
+
* Tear down EVERY runner we can attribute to the tenant: the recorded
|
|
509
|
+
* supervisor pid (as a process group — see `killSupervisor`), the launchd
|
|
510
|
+
* agent, the manifest's process group, and any `__run`/`__supervise` process
|
|
511
|
+
* discovered from argv (a runner from an older build, or one whose state file
|
|
512
|
+
* was lost). This is the single teardown path — the #2991 lesson generalized:
|
|
513
|
+
* no kill path may leave a grandchild with ports still bound.
|
|
514
|
+
*/
|
|
515
|
+
export function stopTunnelRunners(tenant, prior) {
|
|
516
|
+
if (prior?.managed === "launchd")
|
|
517
|
+
uninstallLaunchAgent(tenant);
|
|
518
|
+
// Each pid below is RECOVERED (two state files and a ps scan), so each names
|
|
519
|
+
// its own provenance — the refusal has to point at the exact file to delete,
|
|
520
|
+
// or a corrupt state dir bricks the very command that would have cleared it.
|
|
521
|
+
//
|
|
522
|
+
// The three sources are INDEPENDENT, so a refusal on one must not skip the
|
|
523
|
+
// others or `clearRunnerManifest`. Aborting would let one corrupt integer in
|
|
524
|
+
// `tunnel-state.json` strand a live, uncorrupted runner from `runner.json`
|
|
525
|
+
// with its forwarder ports still bound — the silent-squatter class this
|
|
526
|
+
// teardown exists to prevent ("no kill path may leave a grandchild with ports
|
|
527
|
+
// still bound", MESH-2407). Refuse the bad pid, reap the rest;
|
|
528
|
+
// `assertTunnelPortsFree` is the backstop that still fails loudly if an
|
|
529
|
+
// un-reaped runner really is holding ports.
|
|
530
|
+
const killOrWarn = (pid, source) => {
|
|
531
|
+
try {
|
|
532
|
+
killSupervisor(pid, source);
|
|
533
|
+
}
|
|
534
|
+
catch (err) {
|
|
535
|
+
if (!(err instanceof MeshCliError))
|
|
536
|
+
throw err;
|
|
537
|
+
logWarn(renderErrorBody(err));
|
|
538
|
+
}
|
|
539
|
+
};
|
|
540
|
+
if (prior?.supervisorPid)
|
|
541
|
+
killOrWarn(prior.supervisorPid, {
|
|
542
|
+
what: `the tunnel supervisor pid recorded for tenant '${tenant}'`,
|
|
543
|
+
file: statePath(tenant),
|
|
544
|
+
});
|
|
545
|
+
const manifest = readRunnerManifest(runnerManifestPath(tenant));
|
|
546
|
+
if (manifest)
|
|
547
|
+
killOrWarn(manifest.pgid || manifest.pid, {
|
|
548
|
+
what: `the tunnel runner process group recorded for tenant '${tenant}'`,
|
|
549
|
+
file: runnerManifestPath(tenant),
|
|
550
|
+
});
|
|
551
|
+
for (const pid of discoverRunnerPids(tenant, process.pid))
|
|
552
|
+
killOrWarn(pid, { what: `a tunnel runner pid discovered from 'ps' for tenant '${tenant}'` });
|
|
553
|
+
clearRunnerManifest(runnerManifestPath(tenant));
|
|
554
|
+
}
|
|
555
|
+
/**
|
|
556
|
+
* After teardown, wait for the tenant's forwarder ports to actually free. Any
|
|
557
|
+
* port still accepting is held by a process we could not kill — a foreign
|
|
558
|
+
* squatter — and spawning a runner over it would produce forwarders that
|
|
559
|
+
* silently fail to bind (the false-green readiness gate). Fail LOUDLY instead.
|
|
560
|
+
*/
|
|
561
|
+
async function assertTunnelPortsFree(tenant, ports) {
|
|
562
|
+
let held = ports;
|
|
563
|
+
for (let i = 0; i < 8; i++) {
|
|
564
|
+
const stillHeld = [];
|
|
565
|
+
for (const p of held) {
|
|
566
|
+
if (await portAccepts(p, 250))
|
|
567
|
+
stillHeld.push(p);
|
|
568
|
+
}
|
|
569
|
+
held = stillHeld;
|
|
570
|
+
if (held.length === 0)
|
|
571
|
+
return;
|
|
572
|
+
await sleep(250);
|
|
573
|
+
}
|
|
574
|
+
const squatted = await findSquattedPorts(held, (p) => portAccepts(p, 250));
|
|
575
|
+
throw new Error(buildSquatterError(tenant, squatted, "still bound after teardown"));
|
|
576
|
+
}
|
|
577
|
+
/**
|
|
578
|
+
* Ensure the per-tenant forwarder supervisor is running for `state.tunnels`.
|
|
579
|
+
* Idempotent: reuses a live runner ONLY when it passes the ownership handshake
|
|
580
|
+
* (our manifest token, our SOCKS port, every target port listening in-process);
|
|
581
|
+
* otherwise tears down every attributable runner, verifies the ports actually
|
|
582
|
+
* freed (a still-held port is a foreign squatter → loud failure, never spawn
|
|
583
|
+
* over it), and (re)spawns a fresh runner — whose readiness is again the
|
|
584
|
+
* handshake, not a bare port dial. Persists the state and returns the pid.
|
|
585
|
+
*/
|
|
586
|
+
export async function ensureForwarders(state) {
|
|
587
|
+
const prior = readTunnelState(state.tenant);
|
|
588
|
+
const expected = { socksPort: state.socksPort, ports: state.tunnels.map((t) => t.localPort) };
|
|
589
|
+
// Reuse-if-healthy must be mode-aware. A launchd runner records no supervisorPid
|
|
590
|
+
// (KeepAlive owns liveness), so gating reuse on pidAlive would tear down + rebind
|
|
591
|
+
// the shared tunnels on EVERY `up`/`mesh dev` — severing other concurrent sessions,
|
|
592
|
+
// the exact failure MESH-2014 exists to kill. Treat the launchd agent as "alive"
|
|
593
|
+
// when its plist is installed; keep the pid check for the detached path.
|
|
594
|
+
const runnerAlive = prior?.managed === "launchd"
|
|
595
|
+
? fs.existsSync(launchAgentPath(state.tenant))
|
|
596
|
+
: pidAlive(prior?.supervisorPid);
|
|
597
|
+
if (prior &&
|
|
598
|
+
runnerAlive &&
|
|
599
|
+
tunnelTargetsEqual(prior.tunnels, state.tunnels) &&
|
|
600
|
+
// A prior supervisor spawned for a DIFFERENT SOCKS port still binds the same
|
|
601
|
+
// local ports but dials a dead upstream — reusing it silently strands every
|
|
602
|
+
// tunnel (MESH-2169). The port must match, and the runner must PROVE it is
|
|
603
|
+
// ours via the ownership handshake (liveness ≠ ownership — a port answering
|
|
604
|
+
// a dial can be another session's SSM tunnel or an orphaned runner, #2991).
|
|
605
|
+
// Poll briefly rather than sample once: `ports-not-listening` during the
|
|
606
|
+
// runner's own rebind window and `no-control-answer` under load are
|
|
607
|
+
// transient, and a single miss here would tear down a healthy runner SHARED
|
|
608
|
+
// by every concurrent session on the tenant (MESH-2014). This branch is
|
|
609
|
+
// only reached when the runner is alive with matching targets, so the
|
|
610
|
+
// retry costs nothing on cold-start or genuinely-dead paths.
|
|
611
|
+
prior.socksPort === state.socksPort &&
|
|
612
|
+
(await awaitOwnership(state.tenant, expected, 1000)).ok) {
|
|
613
|
+
state.supervisorPid = prior.supervisorPid;
|
|
614
|
+
state.managed = prior.managed;
|
|
615
|
+
writeTunnelState(state.tenant, state);
|
|
616
|
+
return prior.supervisorPid ?? 0;
|
|
617
|
+
}
|
|
618
|
+
// Tear down every attributable runner (state pid group, launchd agent,
|
|
619
|
+
// manifest process group, argv-discovered strays), then require the ports to
|
|
620
|
+
// actually free — a still-held port is foreign and must fail loudly here,
|
|
621
|
+
// not surface later as a forwarder that silently failed to bind.
|
|
622
|
+
stopTunnelRunners(state.tenant, prior);
|
|
623
|
+
await assertTunnelPortsFree(state.tenant, expected.ports);
|
|
624
|
+
// Write state first so the runner reads the correct targets on start.
|
|
625
|
+
state.supervisorPid = null;
|
|
626
|
+
// Prefer a launchd-supervised runner (survives reboot/crash); fall back to a
|
|
627
|
+
// detached runner where launchd isn't usable (e.g. sandboxed shells). Both run
|
|
628
|
+
// the same self-healing `__run`.
|
|
629
|
+
// argv[1] under the canonical source launcher is bin/mesh.ts, which plain
|
|
630
|
+
// node cannot execute (same failure class as the credential_process fix in
|
|
631
|
+
// aws-auth.ts) — resolve to the stable launcher, or the bare `mesh` fallback.
|
|
632
|
+
const meshBin = resolveStableMeshBin(process.argv[1]);
|
|
633
|
+
if (launchdAvailable()) {
|
|
634
|
+
state.managed = "launchd";
|
|
635
|
+
writeTunnelState(state.tenant, state);
|
|
636
|
+
// launchd does no PATH lookup, so the bare `mesh` fallback can't back a
|
|
637
|
+
// LaunchAgent (it would become `node mesh …`, crash-looping under KeepAlive).
|
|
638
|
+
// Require an absolute path; otherwise fall through to the detached runner,
|
|
639
|
+
// where spawn *does* resolve `mesh` on PATH.
|
|
640
|
+
if (path.isAbsolute(meshBin) && installLaunchAgent(state.tenant, meshBin)) {
|
|
641
|
+
// Installing the agent proves nothing about the forwarders — wait for the
|
|
642
|
+
// runner's ownership handshake (this path used to return success blind).
|
|
643
|
+
if ((await awaitOwnership(state.tenant, expected, 8000)).ok)
|
|
644
|
+
return 0;
|
|
645
|
+
uninstallLaunchAgent(state.tenant);
|
|
646
|
+
// `launchctl bootout` signals the job but doesn't wait for it — spawning
|
|
647
|
+
// the detached runner immediately would race the dying launchd runner for
|
|
648
|
+
// the ports (EADDRINUSE → a transient turned into a hard failure). Every
|
|
649
|
+
// teardown path must be followed by the ports-actually-freed check.
|
|
650
|
+
await assertTunnelPortsFree(state.tenant, expected.ports);
|
|
651
|
+
logInfo("launchd runner did not take ownership of the tunnel ports — using a detached runner.");
|
|
652
|
+
}
|
|
653
|
+
else {
|
|
654
|
+
logInfo("launchd install failed — using a detached self-healing runner.");
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
state.managed = "detached";
|
|
658
|
+
writeTunnelState(state.tenant, state);
|
|
659
|
+
// meshBin is the stable launcher, or the bare `mesh` fallback which spawn
|
|
660
|
+
// resolves on PATH here (no PATH-lookup ternary needed — that was a no-op).
|
|
661
|
+
const child = spawn(meshBin, ["vpn", "tunnel", "__run", state.tenant], {
|
|
662
|
+
detached: true,
|
|
663
|
+
stdio: "ignore",
|
|
664
|
+
});
|
|
665
|
+
// ENOENT from the bare `mesh` fallback (mesh-cli not installed globally)
|
|
666
|
+
// arrives asynchronously — without this handler an unhandled 'error' throws
|
|
667
|
+
// an uncaught exception and takes down `mesh dev` instead of degrading.
|
|
668
|
+
child.on("error", (err) => logWarn(`tunnel supervisor failed to start (${err.message}) — falling back to SSM.`));
|
|
669
|
+
child.unref();
|
|
670
|
+
state.supervisorPid = child.pid ?? null;
|
|
671
|
+
writeTunnelState(state.tenant, state);
|
|
672
|
+
// Readiness = the ownership handshake, not a port dial: the fresh runner must
|
|
673
|
+
// present its manifest token and report every forwarder listening from its
|
|
674
|
+
// own in-process servers. A dial would also pass for another session's SSM
|
|
675
|
+
// tunnel on the same 2XXXX port — the false-green this contract kills.
|
|
676
|
+
// Throwing here lands in startServices' catch → clean SSM fallback.
|
|
677
|
+
const last = await awaitOwnership(state.tenant, expected, 5000);
|
|
678
|
+
if (last.ok)
|
|
679
|
+
return child.pid ?? -1;
|
|
680
|
+
// Diagnose before failing: a port that accepts TCP while ownership failed is
|
|
681
|
+
// held by someone else — name it and say exactly what to do.
|
|
682
|
+
const squatted = await findSquattedPorts(expected.ports, (p) => portAccepts(p, 250));
|
|
683
|
+
if (squatted.length > 0) {
|
|
684
|
+
throw new Error(buildSquatterError(state.tenant, squatted, `ownership check: ${last.reason}`));
|
|
685
|
+
}
|
|
686
|
+
const bindErrors = Object.entries(last.resp?.bindErrors ?? {})
|
|
687
|
+
.map(([port, code]) => `port ${port}: ${code}`)
|
|
688
|
+
.join(", ");
|
|
689
|
+
throw new Error(`Tailscale forwarders did not take ownership for tenant '${state.tenant}' ` +
|
|
690
|
+
`(${last.reason}${bindErrors ? `; ${bindErrors}` : ""}) — see ${logPath(state.tenant)}.`);
|
|
691
|
+
}
|
|
692
|
+
/**
|
|
693
|
+
* Rewrite `devOutput.tunnels` so each resolved target points at its local
|
|
694
|
+
* forwarder (`localhost:localPort`) — the same shape `startSsmTunnels`
|
|
695
|
+
* produces. Unmatched tunnels are left untouched. Pure (no mutation of input).
|
|
696
|
+
*/
|
|
697
|
+
export function rewriteTunnelsForTargets(devOutput, targets) {
|
|
698
|
+
const tunnels = { ...devOutput.tunnels };
|
|
699
|
+
for (const t of targets) {
|
|
700
|
+
tunnels[t.name] = { host: "localhost", port: t.localPort };
|
|
701
|
+
}
|
|
702
|
+
return { ...devOutput, tunnels };
|
|
703
|
+
}
|
|
704
|
+
/** True when the brew `tailscaled` binary is resolvable (Tailscale transport is usable). */
|
|
705
|
+
export function tailscaleAvailable() {
|
|
706
|
+
try {
|
|
707
|
+
tailscaledBinPath();
|
|
708
|
+
return true;
|
|
709
|
+
}
|
|
710
|
+
catch {
|
|
711
|
+
return false;
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
// ============================================================================
|
|
715
|
+
// Shared engine: ensure/start Tailscale tunnels (used by the command + mesh dev)
|
|
716
|
+
// ============================================================================
|
|
717
|
+
/**
|
|
718
|
+
* Ensure the tenant's Tailscale tunnels are fully up: the shared per-tenant
|
|
719
|
+
* daemon (started + OIDC-joined if needed), the resolved VPC targets, and the
|
|
720
|
+
* forwarder supervisor. Idempotent (shared across worktrees/invocations of the
|
|
721
|
+
* same tenant). Returns the resolved targets. Used by both `mesh vpn tunnel up`
|
|
722
|
+
* and `mesh dev --transport=tailscale`.
|
|
723
|
+
*/
|
|
724
|
+
export async function ensureTailscaleTunnels(tenant, env, opts) {
|
|
725
|
+
// Reconcile the daemon's SOCKS port — an already-running daemon may be on a
|
|
726
|
+
// different port than requested; the forwarders MUST dial the real one (MESH-2169).
|
|
727
|
+
const socksPort = await ensureDaemon(tenant, { region: opts.region, socksPort: opts.socksPort });
|
|
728
|
+
await joinHeadscale(tenant, opts.loginServer, { preAuthKey: opts.preAuthKey });
|
|
729
|
+
// Pass the region explicitly rather than clobbering process.env.AWS_REGION —
|
|
730
|
+
// this runs mid `mesh dev` launch and must not mutate the global env.
|
|
731
|
+
const bastion = await getPlatformBastionInfo(tenant, env, opts.region);
|
|
732
|
+
if (!bastion) {
|
|
733
|
+
throw new Error(`Could not read the platform bastion for ${tenant}/${env} (check AWS credentials).`);
|
|
734
|
+
}
|
|
735
|
+
const portOffset = tenantPortIndex(tenant) * PORT_BLOCK;
|
|
736
|
+
const targets = resolveTunnelTargets(bastion.services, resolveElbIp, portOffset);
|
|
737
|
+
if (targets.length === 0) {
|
|
738
|
+
throw new Error(`No resolvable VPC tunnels found for ${tenant}/${env}.`);
|
|
739
|
+
}
|
|
740
|
+
const state = {
|
|
741
|
+
tenant,
|
|
742
|
+
env,
|
|
743
|
+
socksPort,
|
|
744
|
+
loginServer: opts.loginServer,
|
|
745
|
+
supervisorPid: null,
|
|
746
|
+
startedAt: new Date().toISOString(),
|
|
747
|
+
tunnels: targets,
|
|
748
|
+
};
|
|
749
|
+
await ensureForwarders(state);
|
|
750
|
+
return targets;
|
|
751
|
+
}
|
|
752
|
+
/**
|
|
753
|
+
* `startSsmTunnels` drop-in for `mesh dev`: ensure the tenant's Tailscale
|
|
754
|
+
* tunnels, then rewrite `devOutput.tunnels` to the local forwarder ports.
|
|
755
|
+
*/
|
|
756
|
+
export async function startTailscaleTunnels(devOutput, ctx) {
|
|
757
|
+
const targets = await ensureTailscaleTunnels(ctx.tenant, ctx.env, {
|
|
758
|
+
region: ctx.region,
|
|
759
|
+
loginServer: ctx.loginServer,
|
|
760
|
+
socksPort: ctx.socksPort,
|
|
761
|
+
preAuthKey: ctx.preAuthKey,
|
|
762
|
+
});
|
|
763
|
+
return rewriteTunnelsForTargets(devOutput, targets);
|
|
764
|
+
}
|
|
765
|
+
// ============================================================================
|
|
766
|
+
// launchd supervision + self-heal (Slice 2a)
|
|
767
|
+
// ============================================================================
|
|
768
|
+
export function launchAgentLabel(tenant) {
|
|
769
|
+
return `tech.mesh.tunnel.${tenant}`;
|
|
770
|
+
}
|
|
771
|
+
/** Minimal XML-escape for plist string values (controlled inputs, but paths can vary). */
|
|
772
|
+
function xmlEscape(s) {
|
|
773
|
+
return s
|
|
774
|
+
.replace(/&/g, "&")
|
|
775
|
+
.replace(/</g, "<")
|
|
776
|
+
.replace(/>/g, ">");
|
|
777
|
+
}
|
|
778
|
+
/** Render the per-tenant LaunchAgent plist (pure). */
|
|
779
|
+
export function renderLaunchAgentPlist(args) {
|
|
780
|
+
const pathEnv = args.pathEnv ?? "/usr/bin:/bin:/usr/sbin:/sbin";
|
|
781
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
782
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
783
|
+
<plist version="1.0">
|
|
784
|
+
<dict>
|
|
785
|
+
<key>Label</key>
|
|
786
|
+
<string>${launchAgentLabel(args.tenant)}</string>
|
|
787
|
+
<key>ProgramArguments</key>
|
|
788
|
+
<array>
|
|
789
|
+
<string>${xmlEscape(args.nodeBin)}</string>
|
|
790
|
+
<string>${xmlEscape(args.meshBin)}</string>
|
|
791
|
+
<string>vpn</string>
|
|
792
|
+
<string>tunnel</string>
|
|
793
|
+
<string>__run</string>
|
|
794
|
+
<string>${xmlEscape(args.tenant)}</string>
|
|
795
|
+
</array>
|
|
796
|
+
<key>EnvironmentVariables</key>
|
|
797
|
+
<dict>
|
|
798
|
+
<key>PATH</key>
|
|
799
|
+
<string>${xmlEscape(pathEnv)}</string>
|
|
800
|
+
</dict>
|
|
801
|
+
<key>RunAtLoad</key>
|
|
802
|
+
<true/>
|
|
803
|
+
<key>KeepAlive</key>
|
|
804
|
+
<dict>
|
|
805
|
+
<key>SuccessfulExit</key>
|
|
806
|
+
<false/>
|
|
807
|
+
</dict>
|
|
808
|
+
<key>StandardOutPath</key>
|
|
809
|
+
<string>${xmlEscape(args.logPath)}</string>
|
|
810
|
+
<key>StandardErrorPath</key>
|
|
811
|
+
<string>${xmlEscape(args.logPath)}</string>
|
|
812
|
+
</dict>
|
|
813
|
+
</plist>
|
|
814
|
+
`;
|
|
815
|
+
}
|
|
816
|
+
/** Targets whose local forwarder port isn't currently accepting connections. */
|
|
817
|
+
export function forwardersNeedingRebind(targets, listening) {
|
|
818
|
+
return targets.filter((t) => !listening.has(t.localPort));
|
|
819
|
+
}
|
|
820
|
+
export function launchAgentPath(tenant) {
|
|
821
|
+
return path.join(os.homedir(), "Library", "LaunchAgents", `${launchAgentLabel(tenant)}.plist`);
|
|
822
|
+
}
|
|
823
|
+
function guiDomain() {
|
|
824
|
+
return `gui/${process.getuid?.() ?? 0}`;
|
|
825
|
+
}
|
|
826
|
+
let _launchdAvailable;
|
|
827
|
+
/** True when launchctl works for this user (blocked under the seatbelt sandbox → false). */
|
|
828
|
+
export function launchdAvailable() {
|
|
829
|
+
if (_launchdAvailable !== undefined)
|
|
830
|
+
return _launchdAvailable;
|
|
831
|
+
try {
|
|
832
|
+
execFileSync("which", ["launchctl"], { stdio: "ignore" });
|
|
833
|
+
execFileSync("launchctl", ["print", guiDomain()], { stdio: "ignore" });
|
|
834
|
+
_launchdAvailable = true;
|
|
835
|
+
}
|
|
836
|
+
catch {
|
|
837
|
+
_launchdAvailable = false;
|
|
838
|
+
}
|
|
839
|
+
return _launchdAvailable;
|
|
840
|
+
}
|
|
841
|
+
/**
|
|
842
|
+
* Write + (re)load the per-tenant LaunchAgent. Returns true iff it fully
|
|
843
|
+
* succeeded — any failure (plist write blocked by the sandbox, `launchctl`
|
|
844
|
+
* bootstrap denied, …) returns false so the caller falls back to a detached
|
|
845
|
+
* runner. Never throws.
|
|
846
|
+
*/
|
|
847
|
+
export function installLaunchAgent(tenant, meshBin) {
|
|
848
|
+
const plistPath = launchAgentPath(tenant);
|
|
849
|
+
try {
|
|
850
|
+
// Resolve symlinks so the plist embeds a stable path (a since-deleted
|
|
851
|
+
// worktree symlink would leave a KeepAlive agent respawning a dead binary).
|
|
852
|
+
let realMeshBin = meshBin;
|
|
853
|
+
try {
|
|
854
|
+
realMeshBin = fs.realpathSync(meshBin);
|
|
855
|
+
}
|
|
856
|
+
catch {
|
|
857
|
+
/* keep the raw path */
|
|
858
|
+
}
|
|
859
|
+
// Bake a PATH that includes the tailscaled dir so the runner's daemon reheal
|
|
860
|
+
// works under launchd's minimal PATH (Intel installs live in /usr/local/bin).
|
|
861
|
+
let pathEnv = "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin";
|
|
862
|
+
try {
|
|
863
|
+
pathEnv = `${path.dirname(tailscaledBinPath())}:${pathEnv}`;
|
|
864
|
+
}
|
|
865
|
+
catch {
|
|
866
|
+
/* fall back to the default PATH */
|
|
867
|
+
}
|
|
868
|
+
fs.mkdirSync(path.dirname(plistPath), { recursive: true });
|
|
869
|
+
fs.writeFileSync(plistPath, renderLaunchAgentPlist({
|
|
870
|
+
tenant,
|
|
871
|
+
meshBin: realMeshBin,
|
|
872
|
+
nodeBin: process.execPath,
|
|
873
|
+
logPath: path.join(tenantStateDir(tenant), "runner.log"),
|
|
874
|
+
pathEnv,
|
|
875
|
+
}));
|
|
876
|
+
try {
|
|
877
|
+
execFileSync("launchctl", ["bootout", guiDomain(), plistPath], { stdio: "ignore" });
|
|
878
|
+
}
|
|
879
|
+
catch {
|
|
880
|
+
/* not loaded — fine */
|
|
881
|
+
}
|
|
882
|
+
execFileSync("launchctl", ["bootstrap", guiDomain(), plistPath], { stdio: "ignore" });
|
|
883
|
+
return true;
|
|
884
|
+
}
|
|
885
|
+
catch {
|
|
886
|
+
return false;
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
/** Unload + remove the per-tenant LaunchAgent (best-effort). */
|
|
890
|
+
export function uninstallLaunchAgent(tenant) {
|
|
891
|
+
const plistPath = launchAgentPath(tenant);
|
|
892
|
+
try {
|
|
893
|
+
execFileSync("launchctl", ["bootout", guiDomain(), plistPath], { stdio: "ignore" });
|
|
894
|
+
}
|
|
895
|
+
catch {
|
|
896
|
+
/* not loaded */
|
|
897
|
+
}
|
|
898
|
+
try {
|
|
899
|
+
fs.unlinkSync(plistPath);
|
|
900
|
+
}
|
|
901
|
+
catch {
|
|
902
|
+
/* already gone */
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
// ============================================================================
|
|
906
|
+
// The runner (__run): forwarders + continuous self-heal
|
|
907
|
+
// ============================================================================
|
|
908
|
+
/**
|
|
909
|
+
* Long-lived runner for a tenant's tunnels — used both under launchd and as the
|
|
910
|
+
* detached fallback. Runs a forwarder per target and, every ~5s, self-heals:
|
|
911
|
+
* respawns the daemon if its process died, and rebinds any forwarder whose port
|
|
912
|
+
* stopped listening. As the process that actually binds the ports, it is the
|
|
913
|
+
* sole author of the ownership manifest + control endpoint (MESH-2407) that
|
|
914
|
+
* callers verify against. Needs no AWS (targets come from tunnel-state). Never
|
|
915
|
+
* returns.
|
|
916
|
+
*/
|
|
917
|
+
export async function runTunnels(tenant) {
|
|
918
|
+
const st = readTunnelState(tenant);
|
|
919
|
+
if (!st) {
|
|
920
|
+
// State is gone (torn down). Under launchd's KeepAlive a non-zero exit would
|
|
921
|
+
// respawn every ~10s forever (surviving reboots) — so self-uninstall the
|
|
922
|
+
// agent (no-op when detached), drop any manifest a previous incarnation
|
|
923
|
+
// left (it describes a runner that no longer exists), and exit cleanly.
|
|
924
|
+
uninstallLaunchAgent(tenant);
|
|
925
|
+
clearRunnerManifest(runnerManifestPath(tenant));
|
|
926
|
+
process.exit(0);
|
|
927
|
+
}
|
|
928
|
+
const token = mintRunnerToken();
|
|
929
|
+
const servers = new Map();
|
|
930
|
+
// port → last bind error code. Surfaced through the control endpoint so a
|
|
931
|
+
// failed bind (EADDRINUSE from a squatted port) is a visible, attributable
|
|
932
|
+
// failure at the caller — never a silently-dead forwarder.
|
|
933
|
+
const bindErrors = new Map();
|
|
934
|
+
const spawnFor = (t) => {
|
|
935
|
+
servers.get(t.localPort)?.close();
|
|
936
|
+
bindErrors.delete(t.localPort);
|
|
937
|
+
const server = createForwarder({
|
|
938
|
+
listenPort: t.localPort,
|
|
939
|
+
socksPort: st.socksPort,
|
|
940
|
+
targetIp: t.targetIp,
|
|
941
|
+
targetPort: t.remotePort,
|
|
942
|
+
onError: (err) => {
|
|
943
|
+
bindErrors.set(t.localPort, err.code ?? err.message);
|
|
944
|
+
logWarn(`forwarder ${t.name} failed on 127.0.0.1:${t.localPort}: ${err.code ?? err.message}`);
|
|
945
|
+
},
|
|
946
|
+
});
|
|
947
|
+
server.on("listening", () => bindErrors.delete(t.localPort));
|
|
948
|
+
servers.set(t.localPort, server);
|
|
949
|
+
};
|
|
950
|
+
for (const t of st.tunnels)
|
|
951
|
+
spawnFor(t);
|
|
952
|
+
const control = await startControlServer(() => ({
|
|
953
|
+
token,
|
|
954
|
+
tenant,
|
|
955
|
+
pid: process.pid,
|
|
956
|
+
socksPort: st.socksPort,
|
|
957
|
+
listening: st.tunnels.filter((t) => servers.get(t.localPort)?.listening).map((t) => t.localPort),
|
|
958
|
+
bindErrors: Object.fromEntries([...bindErrors].map(([p, c]) => [String(p), c])),
|
|
959
|
+
}));
|
|
960
|
+
writeRunnerManifest(runnerManifestPath(tenant), {
|
|
961
|
+
tenant,
|
|
962
|
+
pid: process.pid,
|
|
963
|
+
pgid: getProcessGroupId(process.pid) ?? process.pid,
|
|
964
|
+
token,
|
|
965
|
+
controlPort: control.port,
|
|
966
|
+
socksPort: st.socksPort,
|
|
967
|
+
ports: st.tunnels.map((t) => t.localPort),
|
|
968
|
+
startedAt: new Date().toISOString(),
|
|
969
|
+
});
|
|
970
|
+
const shutdown = () => {
|
|
971
|
+
control.server.close();
|
|
972
|
+
for (const s of servers.values())
|
|
973
|
+
s.close();
|
|
974
|
+
// Token-guarded: never clobber the manifest of a newer runner that already
|
|
975
|
+
// replaced us (teardown kills groups, and signal delivery order is loose).
|
|
976
|
+
clearRunnerManifest(runnerManifestPath(tenant), token);
|
|
977
|
+
process.exit(0);
|
|
978
|
+
};
|
|
979
|
+
process.on("SIGTERM", shutdown);
|
|
980
|
+
process.on("SIGINT", shutdown);
|
|
981
|
+
for (;;) {
|
|
982
|
+
await sleep(5000);
|
|
983
|
+
try {
|
|
984
|
+
if (daemonState(tenant).backendState === "Down") {
|
|
985
|
+
await ensureDaemon(tenant, {
|
|
986
|
+
region: process.env.AWS_REGION ?? "us-east-2",
|
|
987
|
+
socksPort: st.socksPort,
|
|
988
|
+
});
|
|
989
|
+
}
|
|
990
|
+
// Check the in-process net.Server directly — do NOT dial the forwarder.
|
|
991
|
+
// A dial makes the forwarder open a real SOCKS→VPC-target connection, and
|
|
992
|
+
// the probe's clean FIN doesn't tear the upstream leg down, leaking a
|
|
993
|
+
// connection to Temporal/RDS/etc. every 5s per target.
|
|
994
|
+
const listening = new Set();
|
|
995
|
+
for (const t of st.tunnels) {
|
|
996
|
+
if (servers.get(t.localPort)?.listening)
|
|
997
|
+
listening.add(t.localPort);
|
|
998
|
+
}
|
|
999
|
+
for (const t of forwardersNeedingRebind(st.tunnels, listening))
|
|
1000
|
+
spawnFor(t);
|
|
1001
|
+
}
|
|
1002
|
+
catch {
|
|
1003
|
+
/* keep the loop alive across transient errors */
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
//# sourceMappingURL=tailscale.js.map
|