From badda3781c53d836b1084e5c2e387e778b3ac8a3 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Fri, 24 Apr 2026 13:13:36 +0100 Subject: [PATCH] Harden hosted mobile proof setup --- cmd/pulse-control-plane/main.go | 1 + cmd/pulse-control-plane/mobile_proof_cmd.go | 361 ++++++++++++++++++ .../mobile_proof_cmd_test.go | 138 +++++++ .../subsystems/deployment-installability.md | 26 +- internal/api/relay_hosted_runtime_test.go | 32 ++ .../cloudcp/tenant_runtime_rollout_test.go | 16 + .../bootstrap-hosted-mobile-onboarding.mjs | 137 +++++-- ...ootstrap-hosted-mobile-onboarding.test.mjs | 76 ++++ .../scripts/hosted-tenant-approval-store.mjs | 29 +- .../scripts/hosted-tenant-runtime-restart.mjs | 81 ++++ .../scripts/hosted-tenant-runtime.test.mjs | 36 ++ 11 files changed, 891 insertions(+), 42 deletions(-) create mode 100644 cmd/pulse-control-plane/mobile_proof_cmd.go create mode 100644 cmd/pulse-control-plane/mobile_proof_cmd_test.go create mode 100644 tests/integration/scripts/bootstrap-hosted-mobile-onboarding.test.mjs create mode 100644 tests/integration/scripts/hosted-tenant-runtime-restart.mjs diff --git a/cmd/pulse-control-plane/main.go b/cmd/pulse-control-plane/main.go index 3b85024e5..69445a188 100644 --- a/cmd/pulse-control-plane/main.go +++ b/cmd/pulse-control-plane/main.go @@ -373,6 +373,7 @@ func printCloudAuditReport(report *cloudcp.CloudAuditReport) { func init() { rootCmd.AddCommand(versionCmd) rootCmd.AddCommand(newCloudCmd()) + rootCmd.AddCommand(newMobileProofCmd()) rootCmd.AddCommand(newTenantRuntimeCmd()) } diff --git a/cmd/pulse-control-plane/mobile_proof_cmd.go b/cmd/pulse-control-plane/mobile_proof_cmd.go new file mode 100644 index 000000000..b2b88ec1b --- /dev/null +++ b/cmd/pulse-control-plane/mobile_proof_cmd.go @@ -0,0 +1,361 @@ +package main + +import ( + "context" + "fmt" + "os" + "strings" + "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/cloudcp" + cpDocker "github.com/rcourtman/pulse-go-rewrite/internal/cloudcp/docker" + "github.com/rcourtman/pulse-go-rewrite/internal/cloudcp/entitlements" + "github.com/rcourtman/pulse-go-rewrite/internal/cloudcp/registry" + cpstripe "github.com/rcourtman/pulse-go-rewrite/internal/cloudcp/stripe" + "github.com/spf13/cobra" +) + +const defaultMobileProofOwnerEmail = "pulse-mobile-ga-proof@pulserelay.pro" + +type mobileProofRuntime struct { + cfg *cloudcp.CPConfig + registry *registry.TenantRegistry + docker *cpDocker.Manager + provisioner *cpstripe.Provisioner +} + +func newMobileProofCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "mobile-proof", + Short: "Operate disposable hosted workspaces for Pulse Mobile release proofs", + Long: "Operate disposable hosted workspaces for Pulse Mobile release proofs.\n\n" + + "These commands intentionally use the normal hosted workspace provisioner and refuse\n" + + "customer-shaped accounts or tenants unless explicitly overridden.", + } + cmd.AddCommand(newMobileProofCreateAccountCmd()) + cmd.AddCommand(newMobileProofCreateWorkspaceCmd()) + cmd.AddCommand(newMobileProofDeleteWorkspaceCmd()) + return cmd +} + +func newMobileProofCreateAccountCmd() *cobra.Command { + var accountID string + var displayName string + var allowNonProofAccount bool + + cmd := &cobra.Command{ + Use: "create-account", + Short: "Create a disposable MSP account for Pulse Mobile proof", + RunE: func(cmd *cobra.Command, args []string) error { + rt, err := newMobileProofRuntime(cmd.Context()) + if err != nil { + return err + } + defer rt.close() + + accountID = strings.TrimSpace(accountID) + if accountID == "" { + generated, err := registry.GenerateAccountID() + if err != nil { + return fmt.Errorf("generate proof account id: %w", err) + } + accountID = generated + } + displayName = strings.TrimSpace(displayName) + if displayName == "" { + displayName = fmt.Sprintf("Pulse Mobile GA Proof Account %s", time.Now().UTC().Format("20060102T150405Z")) + } + + account := ®istry.Account{ + ID: accountID, + Kind: registry.AccountKindMSP, + DisplayName: displayName, + } + if !allowNonProofAccount && !looksLikeMobileProofAccount(account) { + return fmt.Errorf("refusing to create non-proof account %s; display name or account id must contain proof/canary/mobile/rehearsal/seed", accountID) + } + if err := rt.registry.CreateAccount(account); err != nil { + return fmt.Errorf("create mobile proof account: %w", err) + } + + fmt.Printf("account_id=%s\n", account.ID) + fmt.Printf("kind=%s\n", account.Kind) + fmt.Printf("display_name=%s\n", account.DisplayName) + return nil + }, + } + + cmd.Flags().StringVar(&accountID, "account-id", "", "Optional account ID; generated when omitted") + cmd.Flags().StringVar(&displayName, "display-name", "", "Proof account display name; defaults to a Pulse Mobile GA Proof Account timestamp") + cmd.Flags().BoolVar(&allowNonProofAccount, "allow-non-proof-account", false, "Allow creation of an account that does not look like a proof account") + return cmd +} + +func newMobileProofCreateWorkspaceCmd() *cobra.Command { + var accountID string + var displayName string + var ownerEmail string + var allowNonProofAccount bool + + cmd := &cobra.Command{ + Use: "create-workspace", + Short: "Create a disposable hosted workspace for Pulse Mobile proof", + RunE: func(cmd *cobra.Command, args []string) error { + rt, err := newMobileProofRuntime(cmd.Context()) + if err != nil { + return err + } + defer rt.close() + + accountID = strings.TrimSpace(accountID) + if accountID == "" { + return fmt.Errorf("--account-id is required") + } + if strings.TrimSpace(displayName) == "" { + displayName = fmt.Sprintf("Pulse Mobile GA Proof %s", time.Now().UTC().Format("20060102T150405Z")) + } + ownerEmail = strings.ToLower(strings.TrimSpace(ownerEmail)) + if ownerEmail == "" { + ownerEmail = defaultMobileProofOwnerEmail + } + + account, err := rt.registry.GetAccount(accountID) + if err != nil { + return fmt.Errorf("load proof account %s: %w", accountID, err) + } + if account == nil { + return fmt.Errorf("account %s not found", accountID) + } + if !allowNonProofAccount && !looksLikeMobileProofAccount(account) { + return fmt.Errorf("refusing to create mobile proof workspace under non-proof account %s; pass --allow-non-proof-account only after confirming this is not a customer account", accountID) + } + if !strings.Contains(strings.ToLower(displayName), "proof") && !strings.Contains(strings.ToLower(displayName), "canary") { + return fmt.Errorf("--display-name must contain proof or canary") + } + + tenant, err := rt.provisioner.ProvisionWorkspaceForOwner(cmd.Context(), accountID, displayName, ownerEmail) + if err != nil { + return fmt.Errorf("create mobile proof workspace: %w", err) + } + + printMobileProofTenant(rt.cfg, tenant) + return nil + }, + } + + cmd.Flags().StringVar(&accountID, "account-id", "", "Existing proof account ID that owns the disposable workspace") + cmd.Flags().StringVar(&displayName, "display-name", "", "Proof workspace display name; defaults to a Pulse Mobile GA Proof timestamp") + cmd.Flags().StringVar(&ownerEmail, "owner-email", defaultMobileProofOwnerEmail, "Owner email to seed into the hosted workspace metadata") + cmd.Flags().BoolVar(&allowNonProofAccount, "allow-non-proof-account", false, "Allow creation under an account that does not look like a proof account") + return cmd +} + +func newMobileProofDeleteWorkspaceCmd() *cobra.Command { + var tenantID string + var allowNonProofTenant bool + + cmd := &cobra.Command{ + Use: "delete-workspace", + Short: "Soft-delete a disposable hosted workspace created for Pulse Mobile proof", + RunE: func(cmd *cobra.Command, args []string) error { + rt, err := newMobileProofRuntime(cmd.Context()) + if err != nil { + return err + } + defer rt.close() + + tenantID = strings.TrimSpace(tenantID) + if tenantID == "" { + return fmt.Errorf("--tenant-id is required") + } + + tenant, err := rt.registry.Get(tenantID) + if err != nil { + return fmt.Errorf("load tenant %s: %w", tenantID, err) + } + if tenant == nil { + return fmt.Errorf("tenant %s not found", tenantID) + } + account, err := rt.registry.GetAccount(tenant.AccountID) + if err != nil { + return fmt.Errorf("load tenant account %s: %w", tenant.AccountID, err) + } + if !allowNonProofTenant && !looksLikeMobileProofTenant(tenant, account) { + return fmt.Errorf("refusing to delete non-proof tenant %s; pass --allow-non-proof-tenant only after confirming this is not a customer workspace", tenantID) + } + + previousState := tenant.State + tenant.State = registry.TenantStateDeleting + if err := rt.registry.Update(tenant); err != nil { + return fmt.Errorf("mark tenant deleting: %w", err) + } + if err := rt.provisioner.DeprovisionWorkspaceContainer(cmd.Context(), tenant); err != nil { + return fmt.Errorf("deprovision tenant container: %w", err) + } + tenant.ContainerID = "" + tenant.State = registry.TenantStateDeleted + if err := rt.registry.Update(tenant); err != nil { + return fmt.Errorf("mark tenant deleted: %w", err) + } + + fmt.Printf("tenant_id=%s\n", tenant.ID) + fmt.Printf("account_id=%s\n", tenant.AccountID) + fmt.Printf("previous_state=%s\n", previousState) + fmt.Printf("state=%s\n", tenant.State) + return nil + }, + } + + cmd.Flags().StringVar(&tenantID, "tenant-id", "", "Disposable proof tenant ID to delete") + cmd.Flags().BoolVar(&allowNonProofTenant, "allow-non-proof-tenant", false, "Allow deletion of a tenant that does not look like a proof tenant") + return cmd +} + +func newMobileProofRuntime(ctx context.Context) (*mobileProofRuntime, error) { + cfg, err := cloudcp.LoadConfig() + if err != nil { + return nil, fmt.Errorf("load control plane config: %w", err) + } + if err := os.MkdirAll(cfg.TenantsDir(), 0o755); err != nil { + return nil, fmt.Errorf("create tenants dir: %w", err) + } + if err := os.MkdirAll(cfg.ControlPlaneDir(), 0o755); err != nil { + return nil, fmt.Errorf("create control-plane dir: %w", err) + } + + reg, err := registry.NewTenantRegistry(cfg.ControlPlaneDir()) + if err != nil { + return nil, fmt.Errorf("open tenant registry: %w", err) + } + + dockerMgr, err := cpDocker.NewManager(cpDocker.ManagerConfig{ + Image: cfg.PulseImage, + Network: cfg.DockerNetwork, + BaseDomain: mobileProofBaseDomainFromURL(cfg.BaseURL), + TrialActivationPublicKey: cfg.TrialActivationPublicKey, + TrustedProxyCIDRs: cfg.TrustedProxyCIDRs, + MemoryLimit: cfg.TenantMemoryLimit, + CPUShares: cfg.TenantCPUShares, + TenantLogMaxSize: cfg.TenantLogMaxSize, + TenantLogMaxFile: cfg.TenantLogMaxFile, + }) + if err != nil { + reg.Close() + return nil, fmt.Errorf("create docker manager: %w", err) + } + + hostedEntitlements := entitlements.NewService(reg, cfg.BaseURL, cfg.TrialActivationPrivateKey) + provisioner := cpstripe.NewProvisioner( + reg, + cfg.TenantsDir(), + dockerMgr, + nil, + cfg.BaseURL, + nil, + cfg.EmailFrom, + cfg.AllowDockerlessProvisioning, + cpstripe.WithHostedEntitlementService(hostedEntitlements), + cpstripe.WithTrialActivationPrivateKey(cfg.TrialActivationPrivateKey), + cpstripe.WithAdmissionCheck(func(checkCtx context.Context) error { + return cloudcp.EnforceStorageAdmission(checkCtx, cfg, dockerMgr) + }), + ) + + return &mobileProofRuntime{ + cfg: cfg, + registry: reg, + docker: dockerMgr, + provisioner: provisioner, + }, nil +} + +func (rt *mobileProofRuntime) close() { + if rt == nil { + return + } + if rt.docker != nil { + rt.docker.Close() + } + if rt.registry != nil { + rt.registry.Close() + } +} + +func looksLikeMobileProofAccount(account *registry.Account) bool { + if account == nil { + return false + } + return containsMobileProofMarker(account.ID, account.DisplayName) || + strings.HasPrefix(strings.ToLower(strings.TrimSpace(account.ID)), "a_msp_") || + strings.Contains(strings.ToLower(strings.TrimSpace(account.ID)), "ownerseed") +} + +func looksLikeMobileProofTenant(tenant *registry.Tenant, account *registry.Account) bool { + if tenant == nil { + return false + } + return containsMobileProofMarker( + tenant.ID, + tenant.AccountID, + tenant.Email, + tenant.DisplayName, + tenant.StripeCustomerID, + tenant.StripeSubscriptionID, + tenant.StripePriceID, + tenant.PlanVersion, + ) || looksLikeMobileProofAccount(account) +} + +func containsMobileProofMarker(values ...string) bool { + for _, value := range values { + normalized := strings.ToLower(strings.TrimSpace(value)) + if normalized == "" { + continue + } + for _, marker := range []string{"mobile", "proof", "canary", "rehearsal", "seed"} { + if strings.Contains(normalized, marker) { + return true + } + } + } + return false +} + +func mobileProofBaseDomainFromURL(baseURL string) string { + domain := strings.TrimSpace(baseURL) + for _, prefix := range []string{"https://", "http://"} { + if strings.HasPrefix(domain, prefix) { + domain = strings.TrimPrefix(domain, prefix) + break + } + } + if idx := strings.IndexAny(domain, ":/"); idx >= 0 { + domain = domain[:idx] + } + return domain +} + +func printMobileProofTenant(cfg *cloudcp.CPConfig, tenant *registry.Tenant) { + if tenant == nil { + return + } + baseDomain := "" + if cfg != nil { + baseDomain = mobileProofBaseDomainFromURL(cfg.BaseURL) + } + publicURL := "" + if baseDomain != "" { + publicURL = fmt.Sprintf("https://%s.%s", strings.ToLower(tenant.ID), baseDomain) + } + + fmt.Printf("tenant_id=%s\n", tenant.ID) + fmt.Printf("account_id=%s\n", tenant.AccountID) + fmt.Printf("display_name=%s\n", tenant.DisplayName) + fmt.Printf("state=%s\n", tenant.State) + fmt.Printf("plan_version=%s\n", tenant.PlanVersion) + fmt.Printf("container_id=%s\n", tenant.ContainerID) + if publicURL != "" { + fmt.Printf("public_url=%s\n", publicURL) + fmt.Printf("onboarding_url=%s/api/onboarding/qr\n", publicURL) + } +} diff --git a/cmd/pulse-control-plane/mobile_proof_cmd_test.go b/cmd/pulse-control-plane/mobile_proof_cmd_test.go new file mode 100644 index 000000000..3bbb34e1c --- /dev/null +++ b/cmd/pulse-control-plane/mobile_proof_cmd_test.go @@ -0,0 +1,138 @@ +package main + +import ( + "testing" + + "github.com/rcourtman/pulse-go-rewrite/internal/cloudcp/registry" +) + +func TestLooksLikeMobileProofAccount(t *testing.T) { + tests := []struct { + name string + account *registry.Account + want bool + }{ + { + name: "nil", + account: nil, + want: false, + }, + { + name: "explicit proof display", + account: ®istry.Account{ + ID: "a_live", + DisplayName: "Pulse Mobile Proof", + }, + want: true, + }, + { + name: "synthetic msp proof id", + account: ®istry.Account{ + ID: "a_msp_prod_fix_20260313111348", + DisplayName: "Pulse", + }, + want: true, + }, + { + name: "customer-shaped account", + account: ®istry.Account{ + ID: "a_W3PT2W0YFR", + DisplayName: "Customer Account", + }, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := looksLikeMobileProofAccount(tt.account); got != tt.want { + t.Fatalf("looksLikeMobileProofAccount() = %t, want %t", got, tt.want) + } + }) + } +} + +func TestLooksLikeMobileProofTenant(t *testing.T) { + tests := []struct { + name string + tenant *registry.Tenant + account *registry.Account + want bool + }{ + { + name: "nil", + tenant: nil, + want: false, + }, + { + name: "proof display", + tenant: ®istry.Tenant{ + ID: "t-123", + DisplayName: "Pulse Mobile GA Proof", + }, + want: true, + }, + { + name: "proof account fallback", + tenant: ®istry.Tenant{ + ID: "t-123", + AccountID: "a_msp_prod_fix_20260313111348", + }, + account: ®istry.Account{ + ID: "a_msp_prod_fix_20260313111348", + }, + want: true, + }, + { + name: "customer tenant", + tenant: ®istry.Tenant{ + ID: "t-123", + AccountID: "a_customer", + DisplayName: "Customer Workspace", + }, + account: ®istry.Account{ + ID: "a_customer", + DisplayName: "Customer Account", + }, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := looksLikeMobileProofTenant(tt.tenant, tt.account); got != tt.want { + t.Fatalf("looksLikeMobileProofTenant() = %t, want %t", got, tt.want) + } + }) + } +} + +func TestMobileProofBaseDomainFromURL(t *testing.T) { + tests := map[string]string{ + "https://cloud.pulserelay.pro": "cloud.pulserelay.pro", + "http://cloud.pulserelay.pro:8080/x": "cloud.pulserelay.pro", + "cloud.pulserelay.pro/path": "cloud.pulserelay.pro", + " cloud.pulserelay.pro ": "cloud.pulserelay.pro", + } + + for input, want := range tests { + t.Run(input, func(t *testing.T) { + if got := mobileProofBaseDomainFromURL(input); got != want { + t.Fatalf("mobileProofBaseDomainFromURL(%q) = %q, want %q", input, got, want) + } + }) + } +} + +func TestMobileProofCommandExposesAccountAndWorkspaceLifecycle(t *testing.T) { + cmd := newMobileProofCmd() + found := map[string]bool{} + for _, sub := range cmd.Commands() { + found[sub.Name()] = true + } + for _, name := range []string{"create-account", "create-workspace", "delete-workspace"} { + if !found[name] { + t.Fatalf("mobile-proof subcommand %q is not registered", name) + } + } +} diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 2238b6562..f44bf5ca1 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -24,9 +24,10 @@ server-side update execution surfaces. 2. `internal/api/updates.go` 3. `frontend-modern/src/api/updates.ts` 4. `cmd/pulse-control-plane/main.go` -5. `internal/cloudcp/docker/manager.go` -6. `internal/cloudcp/docker/labels.go` -7. `internal/cloudcp/tenant_runtime_rollout.go` +5. `cmd/pulse-control-plane/mobile_proof_cmd.go` +6. `internal/cloudcp/docker/manager.go` +7. `internal/cloudcp/docker/labels.go` +8. `internal/cloudcp/tenant_runtime_rollout.go` 8. `.github/workflows/create-release.yml` 9. `.github/workflows/deploy-demo-server.yml` 10. `.github/workflows/helm-pages.yml` @@ -81,11 +82,13 @@ server-side update execution surfaces. 59. `tests/integration/README.md` 60. `tests/integration/scripts/bootstrap-hosted-mobile-onboarding.mjs` 61. `tests/integration/scripts/hosted-mobile-token-runtime.mjs` -62. `tests/integration/scripts/hosted-tenant-runtime.mjs` -63. `tests/integration/scripts/managed-dev-runtime.mjs` -64. `tests/integration/scripts/relay-mobile-token-helper.go` -65. `tests/integration/tests/helpers.ts` -66. `tests/integration/tests/runtime-defaults.ts` +62. `tests/integration/scripts/hosted-tenant-approval-store.mjs` +63. `tests/integration/scripts/hosted-tenant-runtime.mjs` +64. `tests/integration/scripts/hosted-tenant-runtime-restart.mjs` +65. `tests/integration/scripts/managed-dev-runtime.mjs` +66. `tests/integration/scripts/relay-mobile-token-helper.go` +67. `tests/integration/tests/helpers.ts` +68. `tests/integration/tests/runtime-defaults.ts` 67. `docker-compose.yml` 68. `scripts/install-docker.sh` 69. `scripts/validate-published-release.sh` @@ -197,7 +200,12 @@ server-side update execution surfaces. recorded Docker container is missing, dry-run must classify the tenant for mutation and the live command must recreate the container, prove health, and rewrite the registry runtime identity through the same control-plane path. -10. Add or change the canonical hosted staging smoke operator path through `scripts/run_hosted_staging_smoke.sh`, `tests/integration/scripts/bootstrap-hosted-mobile-onboarding.mjs`, `tests/integration/scripts/hosted-mobile-token-runtime.mjs`, `tests/integration/scripts/hosted-tenant-runtime.mjs`, and `tests/integration/scripts/relay-mobile-token-helper.go` +10. Add or change the canonical hosted staging smoke operator path through `scripts/run_hosted_staging_smoke.sh`, `tests/integration/scripts/bootstrap-hosted-mobile-onboarding.mjs`, `tests/integration/scripts/hosted-mobile-token-runtime.mjs`, `tests/integration/scripts/hosted-tenant-approval-store.mjs`, `tests/integration/scripts/hosted-tenant-runtime.mjs`, `tests/integration/scripts/hosted-tenant-runtime-restart.mjs`, and `tests/integration/scripts/relay-mobile-token-helper.go`. + Hosted mobile proof helpers must create and delete only disposable + proof-shaped workspaces through the normal control-plane provisioner, + fetch onboarding payloads without logging bearer tokens or mobile deep-link + secrets, and seed hosted approvals through a single explicit tenant runtime + restart when a release proof needs transactionally visible approval state. ## Forbidden Paths diff --git a/internal/api/relay_hosted_runtime_test.go b/internal/api/relay_hosted_runtime_test.go index b7a975e48..9fed819e9 100644 --- a/internal/api/relay_hosted_runtime_test.go +++ b/internal/api/relay_hosted_runtime_test.go @@ -4,6 +4,8 @@ import ( "context" "crypto/ed25519" "encoding/base64" + "net/http/httptest" + "net/url" "testing" "github.com/rcourtman/pulse-go-rewrite/internal/config" @@ -81,6 +83,36 @@ func TestLoadRelayConfigForRuntime_DoesNotOverrideExplicitDisabledRelayConfig(t } } +func TestHostedRelayRuntimeBuildsMobileOnboardingAppRelayURL(t *testing.T) { + router, _, _ := newHostedRelayRuntimeTestRouter(t) + + relayCfg, err := router.loadRelayConfigForRuntime(context.Background()) + if err != nil { + t.Fatalf("loadRelayConfigForRuntime() error = %v", err) + } + + req := httptest.NewRequest("GET", "https://t-hostedrelay01.cloud.pulserelay.pro/api/onboarding/qr", nil) + payload, diagnostics := router.buildOnboardingPayload(req, relayCfg, "test-mobile-proof-token") + if hasOnboardingError(diagnostics) { + t.Fatalf("onboarding diagnostics contain errors: %#v", diagnostics) + } + if payload.Relay.URL != "wss://relay.pulserelay.pro/ws/app" { + t.Fatalf("mobile onboarding relay URL = %q", payload.Relay.URL) + } + + deepLink, err := url.Parse(payload.DeepLink) + if err != nil { + t.Fatalf("parse deep link: %v", err) + } + query := deepLink.Query() + if query.Get("relay_url") != payload.Relay.URL { + t.Fatalf("deep-link relay_url = %q, want %q", query.Get("relay_url"), payload.Relay.URL) + } + if query.Get("auth_token") != "test-mobile-proof-token" { + t.Fatalf("deep-link auth_token did not preserve the proof token") + } +} + func newHostedRelayRuntimeTestRouter(t *testing.T) (*Router, string, string) { t.Helper() diff --git a/internal/cloudcp/tenant_runtime_rollout_test.go b/internal/cloudcp/tenant_runtime_rollout_test.go index 627161cf5..2c5a5372d 100644 --- a/internal/cloudcp/tenant_runtime_rollout_test.go +++ b/internal/cloudcp/tenant_runtime_rollout_test.go @@ -369,6 +369,22 @@ func TestTenantRuntimeRollout_RecreatesMissingRuntimeFromTenantData(t *testing.T } } +func TestTenantRuntimeRollout_MobileProofTenantUsesCanonicalRuntimeContract(t *testing.T) { + tenantID := "t-MobileProof01" + docker := newFakeTenantRuntimeRolloutDocker() + routing := docker.DesiredRuntimeRouting(tenantID) + + if got := tenantRuntimeContainerName(tenantID); got != "pulse-t-MobileProof01" { + t.Fatalf("tenant runtime container name = %q, want pulse-t-MobileProof01", got) + } + if routing.Host != "t-mobileproof01.cloud.pulserelay.pro" { + t.Fatalf("proof tenant route host = %q", routing.Host) + } + if routing.PublicURL != "https://t-mobileproof01.cloud.pulserelay.pro" { + t.Fatalf("proof tenant public URL = %q", routing.PublicURL) + } +} + func TestTenantRuntimeRollout_AdmissionFailureStopsBeforeSnapshotOrContainerSwap(t *testing.T) { tenant := ®istry.Tenant{ID: "t-ADMIT01", ContainerID: "old-container"} reg := &fakeTenantRuntimeRolloutRegistry{tenant: tenant} diff --git a/tests/integration/scripts/bootstrap-hosted-mobile-onboarding.mjs b/tests/integration/scripts/bootstrap-hosted-mobile-onboarding.mjs index 68fd7b972..53851251d 100644 --- a/tests/integration/scripts/bootstrap-hosted-mobile-onboarding.mjs +++ b/tests/integration/scripts/bootstrap-hosted-mobile-onboarding.mjs @@ -5,15 +5,17 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import process from 'node:process'; +import { fileURLToPath } from 'node:url'; import { restartHostedTenantRuntime, + shellQuote, } from './hosted-tenant-runtime.mjs'; import { createHostedRelayMobileToken } from './hosted-mobile-token-runtime.mjs'; const DEFAULT_POLL_INTERVAL_MS = 500; const DEFAULT_POLL_TIMEOUT_MS = 15_000; -const REPO_ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..', '..', '..'); +const SCRIPT_PATH = fileURLToPath(import.meta.url); function usage(message) { if (message) { @@ -96,16 +98,7 @@ function parseArgs(argv) { return parsed; } -function runText(command, args, options = {}) { - return execFileSync(command, args, { - encoding: 'utf8', - maxBuffer: 32 * 1024 * 1024, - stdio: 'pipe', - ...options, - }); -} - -function deriveTenantBaseUrl(controlPlaneUrl, tenantId) { +export function deriveTenantBaseUrl(controlPlaneUrl, tenantId) { const parsed = new URL(controlPlaneUrl); parsed.hostname = `${tenantId}.${parsed.hostname}`; parsed.pathname = ''; @@ -114,24 +107,105 @@ function deriveTenantBaseUrl(controlPlaneUrl, tenantId) { return parsed.toString().replace(/\/$/, ''); } -function curlJson(args) { - return JSON.parse(runText('curl', args)); +export function redactBearerTokens(value) { + return String(value).replace(/Bearer\s+[A-Za-z0-9._~+/-]+/g, 'Bearer [REDACTED]'); } -function fetchOnboardingPayload({ rawToken, tenantBaseUrl }) { - return curlJson([ - '-fsS', - '-H', - `Authorization: Bearer ${rawToken}`, - `${tenantBaseUrl}/api/onboarding/qr`, - ]); +export async function fetchOnboardingPayload({ fetchImpl = globalThis.fetch, rawToken, tenantBaseUrl }) { + if (typeof fetchImpl !== 'function') { + throw new Error('fetch is not available in this Node runtime'); + } + + const url = `${tenantBaseUrl}/api/onboarding/qr`; + let response; + try { + response = await fetchImpl(url, { + headers: { + Authorization: `Bearer ${rawToken}`, + }, + }); + } catch (error) { + throw new Error(`failed to fetch hosted onboarding payload from ${tenantBaseUrl}: ${redactBearerTokens(error instanceof Error ? error.message : String(error))}`); + } + + const body = await response.text(); + if (!response.ok) { + throw new Error(`hosted onboarding payload request returned HTTP ${response.status} from ${tenantBaseUrl}: ${redactBearerTokens(body.slice(0, 500))}`); + } + + try { + return JSON.parse(body); + } catch (error) { + throw new Error(`hosted onboarding payload from ${tenantBaseUrl} was not valid JSON: ${redactBearerTokens(error instanceof Error ? error.message : String(error))}`); + } +} + +const REMOTE_ONBOARDING_FETCH_PY = ` +import json +import sys +import urllib.error +import urllib.request + +base_url = sys.argv[1].rstrip("/") +token = sys.stdin.read().strip() +request = urllib.request.Request( + base_url + "/api/onboarding/qr", + headers={"Authorization": "Bearer " + token}, +) +try: + with urllib.request.urlopen(request, timeout=20) as response: + sys.stdout.write(response.read().decode("utf-8")) +except urllib.error.HTTPError as exc: + body = exc.read().decode("utf-8", errors="replace")[:500] + sys.stderr.write(json.dumps({"status": exc.code, "body": body})) + sys.exit(22) +except Exception as exc: + sys.stderr.write(str(exc)) + sys.exit(1) +`; + +export function fetchOnboardingPayloadViaCloudHost({ + cloudHost, + rawToken, + runner = execFileSync, + tenantBaseUrl, +}) { + const host = String(cloudHost ?? '').trim(); + if (!host) { + throw new Error('cloud host is required for hosted onboarding remote fetch'); + } + const command = `python3 -c ${shellQuote(REMOTE_ONBOARDING_FETCH_PY)} ${shellQuote(tenantBaseUrl)}`; + let output; + try { + output = runner('ssh', [host, command], { + encoding: 'utf8', + input: rawToken, + maxBuffer: 32 * 1024 * 1024, + stdio: ['pipe', 'pipe', 'pipe'], + }); + } catch (error) { + const detail = ['stderr', 'stdout'] + .map((field) => error?.[field]) + .filter((value) => value !== undefined && value !== null && String(value).trim() !== '') + .map((value) => String(value).trim()) + .join('\n') || (error instanceof Error ? error.message : String(error)); + throw new Error(`failed to fetch hosted onboarding payload via ${host}: ${redactBearerTokens(detail)}`); + } + + try { + return JSON.parse(output); + } catch (error) { + throw new Error(`hosted onboarding payload fetched via ${host} was not valid JSON: ${redactBearerTokens(error instanceof Error ? error.message : String(error))}`); + } } function sleep(ms) { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); } -function pollOnboardingPayload({ +async function pollOnboardingPayload({ + cloudHost, + fetchImpl, pollIntervalMs, pollTimeoutMs, rawToken, @@ -142,9 +216,16 @@ function pollOnboardingPayload({ while (Date.now() <= deadline) { try { - return fetchOnboardingPayload({ rawToken, tenantBaseUrl }); + return await fetchOnboardingPayload({ fetchImpl, rawToken, tenantBaseUrl }); } catch (error) { lastError = error; + if (String(cloudHost ?? '').trim() !== '') { + try { + return fetchOnboardingPayloadViaCloudHost({ cloudHost, rawToken, tenantBaseUrl }); + } catch (remoteError) { + lastError = remoteError; + } + } if (Date.now() >= deadline) { break; } @@ -155,7 +236,7 @@ function pollOnboardingPayload({ throw new Error(`timed out waiting for hosted onboarding payload from ${tenantBaseUrl}: ${String(lastError instanceof Error ? lastError.message : lastError)}`); } -function main() { +async function main() { const args = parseArgs(process.argv.slice(2)); const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pulse-hosted-mobile-onboarding-')); @@ -173,7 +254,8 @@ function main() { restartHostedTenantRuntime(args.cloudHost, args.tenantId); const tenantBaseUrl = deriveTenantBaseUrl(args.controlPlaneUrl, args.tenantId); - const qrPayload = pollOnboardingPayload({ + const qrPayload = await pollOnboardingPayload({ + cloudHost: args.cloudHost, pollIntervalMs: args.pollIntervalMs, pollTimeoutMs: args.pollTimeoutMs, rawToken, @@ -216,4 +298,9 @@ function main() { } } -main(); +if (process.argv[1] && path.resolve(process.argv[1]) === SCRIPT_PATH) { + main().catch((error) => { + console.error(redactBearerTokens(error instanceof Error ? error.message : String(error))); + process.exit(1); + }); +} diff --git a/tests/integration/scripts/bootstrap-hosted-mobile-onboarding.test.mjs b/tests/integration/scripts/bootstrap-hosted-mobile-onboarding.test.mjs new file mode 100644 index 000000000..bb5709921 --- /dev/null +++ b/tests/integration/scripts/bootstrap-hosted-mobile-onboarding.test.mjs @@ -0,0 +1,76 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + deriveTenantBaseUrl, + fetchOnboardingPayload, + fetchOnboardingPayloadViaCloudHost, + redactBearerTokens, +} from './bootstrap-hosted-mobile-onboarding.mjs'; + +test('deriveTenantBaseUrl maps the control-plane host to the tenant subdomain', () => { + assert.equal( + deriveTenantBaseUrl('https://cloud.pulserelay.pro/', 't-CANARY123'), + 'https://t-canary123.cloud.pulserelay.pro', + ); +}); + +test('fetchOnboardingPayload uses bearer auth without leaking it in failures', async () => { + const token = 'pulse_secret_token.value/with+chars'; + const calls = []; + const fetchImpl = async (url, options) => { + calls.push({ options, url }); + return { + ok: false, + status: 401, + text: async () => `bad Authorization: Bearer ${token}`, + }; + }; + + await assert.rejects( + () => fetchOnboardingPayload({ + fetchImpl, + rawToken: token, + tenantBaseUrl: 'https://t-canary.cloud.pulserelay.pro', + }), + (error) => { + assert.match(error.message, /Bearer \[REDACTED\]/); + assert.doesNotMatch(error.message, new RegExp(token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))); + return true; + }, + ); + assert.equal(calls.length, 1); + assert.equal(calls[0].url, 'https://t-canary.cloud.pulserelay.pro/api/onboarding/qr'); + assert.equal(calls[0].options.headers.Authorization, `Bearer ${token}`); +}); + +test('redactBearerTokens redacts bearer values in arbitrary command text', () => { + assert.equal( + redactBearerTokens('Command failed: curl -H Authorization: Bearer abc.def/ghi'), + 'Command failed: curl -H Authorization: Bearer [REDACTED]', + ); +}); + +test('fetchOnboardingPayloadViaCloudHost passes the bearer token over stdin only', () => { + const token = 'pulse_secret_token.value/with+chars'; + const calls = []; + const payload = fetchOnboardingPayloadViaCloudHost({ + cloudHost: 'root@pulse-cloud', + rawToken: token, + tenantBaseUrl: 'https://t-canary.cloud.pulserelay.pro', + runner: (command, args, options) => { + calls.push({ args, command, options }); + return JSON.stringify({ + deep_link: 'pulse://pair', + instance_id: 'relay_123', + relay: { enabled: true, url: 'wss://relay.example/ws' }, + }); + }, + }); + + assert.equal(payload.instance_id, 'relay_123'); + assert.equal(calls.length, 1); + assert.equal(calls[0].command, 'ssh'); + assert.equal(calls[0].options.input, token); + assert.doesNotMatch(calls[0].args.join(' '), new RegExp(token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))); +}); diff --git a/tests/integration/scripts/hosted-tenant-approval-store.mjs b/tests/integration/scripts/hosted-tenant-approval-store.mjs index eb0681c92..7ccce76e1 100644 --- a/tests/integration/scripts/hosted-tenant-approval-store.mjs +++ b/tests/integration/scripts/hosted-tenant-approval-store.mjs @@ -5,6 +5,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import process from 'node:process'; +import { pathToFileURL } from 'node:url'; import { assertHostedTenantRuntimeExists, @@ -22,12 +23,12 @@ function usage(message) { } console.error( - 'usage: node ./tests/integration/scripts/hosted-tenant-approval-store.mjs --tenant-id [--org-id ] [--cloud-host ] [approval options]', + 'usage: node ./tests/integration/scripts/hosted-tenant-approval-store.mjs --tenant-id [--org-id ] [--cloud-host ] [--no-restart] [approval options]', ); process.exit(1); } -function parseArgs(argv) { +export function parseArgs(argv) { if (argv.length === 0) { usage('missing action'); } @@ -36,6 +37,7 @@ function parseArgs(argv) { action: argv[0], cloudHost: 'root@pulse-cloud', orgId: '', + restartAfterCreate: true, tenantId: '', passthrough: [], }; @@ -55,6 +57,10 @@ function parseArgs(argv) { parsed.orgId = argv[index + 1] ?? usage('missing value for --org-id'); index += 1; break; + case '--no-restart': + case '--skip-restart': + parsed.restartAfterCreate = false; + break; case '--help': case '-h': usage(); @@ -131,12 +137,14 @@ function main() { const output = runRemote(args.cloudHost, remoteArgs.join(' ')); const payload = JSON.parse(output); - if (args.action === 'create') { + if (args.action === 'create' && args.restartAfterCreate) { // Approval store state is loaded in-memory at runtime startup. Hosted proof // seeding edits the backing file out-of-band, so restart the tenant runtime // before returning to ensure the live process serves the seeded approval. restartHostedTenantRuntime(args.cloudHost, args.tenantId); payload.runtimeRestarted = true; + } else if (args.action === 'create') { + payload.runtimeRestarted = false; } process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); @@ -148,9 +156,14 @@ function main() { } } -try { - main(); -} catch (error) { - console.error(error instanceof Error ? error.message : String(error)); - process.exit(1); +const invokedAsScript = process.argv[1] + && import.meta.url === pathToFileURL(process.argv[1]).href; + +if (invokedAsScript) { + try { + main(); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + } } diff --git a/tests/integration/scripts/hosted-tenant-runtime-restart.mjs b/tests/integration/scripts/hosted-tenant-runtime-restart.mjs new file mode 100644 index 000000000..ccdbd2070 --- /dev/null +++ b/tests/integration/scripts/hosted-tenant-runtime-restart.mjs @@ -0,0 +1,81 @@ +#!/usr/bin/env node + +import process from 'node:process'; +import { pathToFileURL } from 'node:url'; + +import { restartHostedTenantRuntime } from './hosted-tenant-runtime.mjs'; + +const DEFAULT_CLOUD_HOST = 'root@pulse-cloud'; + +function usage(message) { + if (message) { + console.error(`error: ${message}`); + console.error(''); + } + + console.error( + 'usage: node ./tests/integration/scripts/hosted-tenant-runtime-restart.mjs --tenant-id [--cloud-host ]', + ); + process.exit(1); +} + +export function parseArgs(argv) { + const parsed = { + cloudHost: DEFAULT_CLOUD_HOST, + tenantId: '', + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + switch (arg) { + case '--cloud-host': + parsed.cloudHost = argv[index + 1] ?? usage('missing value for --cloud-host'); + index += 1; + break; + case '--tenant-id': + parsed.tenantId = argv[index + 1] ?? usage('missing value for --tenant-id'); + index += 1; + break; + case '--help': + case '-h': + usage(); + break; + default: + usage(`unsupported flag ${arg}`); + } + } + + parsed.cloudHost = String(parsed.cloudHost || '').trim(); + parsed.tenantId = String(parsed.tenantId || '').trim(); + + if (!parsed.tenantId) { + usage('--tenant-id is required'); + } + if (!parsed.cloudHost) { + usage('--cloud-host is required'); + } + + return parsed; +} + +export function main(argv = process.argv.slice(2)) { + const args = parseArgs(argv); + restartHostedTenantRuntime(args.cloudHost, args.tenantId); + process.stdout.write(`${JSON.stringify({ + cloudHost: args.cloudHost, + restarted: true, + tenantId: args.tenantId, + }, null, 2)}\n`); +} + +const invokedAsScript = process.argv[1] + && import.meta.url === pathToFileURL(process.argv[1]).href; + +if (invokedAsScript) { + try { + main(); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + } +} diff --git a/tests/integration/scripts/hosted-tenant-runtime.test.mjs b/tests/integration/scripts/hosted-tenant-runtime.test.mjs index 5b3a66301..f318ff226 100644 --- a/tests/integration/scripts/hosted-tenant-runtime.test.mjs +++ b/tests/integration/scripts/hosted-tenant-runtime.test.mjs @@ -7,7 +7,9 @@ import { hostedTenantRuntimeExistsScript, restartHostedTenantRuntime, } from './hosted-tenant-runtime.mjs'; +import { parseArgs as parseApprovalStoreArgs } from './hosted-tenant-approval-store.mjs'; import { parseArgs as parseRuntimeCheckArgs } from './hosted-tenant-runtime-check.mjs'; +import { parseArgs as parseRuntimeRestartArgs } from './hosted-tenant-runtime-restart.mjs'; test('hostedTenantContainerName derives the canonical runtime container name', () => { assert.equal(hostedTenantContainerName(' t-P62TP8K28Y '), 'pulse-t-P62TP8K28Y'); @@ -77,3 +79,37 @@ test('hosted tenant runtime check CLI defaults to the production cloud host', () assert.equal(parsed.cloudHost, 'root@pulse-cloud'); assert.equal(parsed.tenantId, 't-canary'); }); + +test('hosted tenant runtime restart CLI defaults to the production cloud host', () => { + const parsed = parseRuntimeRestartArgs(['--tenant-id', 't-canary']); + + assert.equal(parsed.cloudHost, 'root@pulse-cloud'); + assert.equal(parsed.tenantId, 't-canary'); +}); + +test('hosted approval seeding keeps runtime restart enabled by default', () => { + const parsed = parseApprovalStoreArgs([ + 'create', + '--tenant-id', + 't-canary', + '--approval-id', + 'approval-123', + ]); + + assert.equal(parsed.restartAfterCreate, true); + assert.deepEqual(parsed.passthrough, ['--approval-id', 'approval-123']); +}); + +test('hosted approval seeding can defer runtime restart for transactional proof setup', () => { + const parsed = parseApprovalStoreArgs([ + 'create', + '--tenant-id', + 't-canary', + '--no-restart', + '--approval-id', + 'approval-123', + ]); + + assert.equal(parsed.restartAfterCreate, false); + assert.deepEqual(parsed.passthrough, ['--approval-id', 'approval-123']); +});