From 53154ccb8a9231ebc6b0d44ecd4bd68d1f5fe67c Mon Sep 17 00:00:00 2001 From: rcourtman Date: Fri, 27 Mar 2026 10:27:52 +0000 Subject: [PATCH] fix(cloudcp): inject hosted entitlement public key --- internal/cloudcp/config.go | 23 +++++++++++++++++++++++ internal/cloudcp/config_test.go | 15 ++++++++++++++- internal/cloudcp/docker/manager.go | 22 +++++++++++++--------- internal/cloudcp/docker/manager_test.go | 5 +++-- internal/cloudcp/server.go | 13 +++++++------ 5 files changed, 60 insertions(+), 18 deletions(-) diff --git a/internal/cloudcp/config.go b/internal/cloudcp/config.go index 6950cd1709..2d63e02db6 100644 --- a/internal/cloudcp/config.go +++ b/internal/cloudcp/config.go @@ -1,6 +1,8 @@ package cloudcp import ( + "crypto/ed25519" + "encoding/base64" "fmt" "net/url" "os" @@ -9,6 +11,7 @@ import ( "strings" "github.com/joho/godotenv" + pkglicensing "github.com/rcourtman/pulse-go-rewrite/pkg/licensing" ) // CPConfig holds all configuration for the control plane. @@ -39,6 +42,7 @@ type CPConfig struct { CloudPowerPriceID string // Cloud Power tier price ID (optional) CloudMaxPriceID string // Cloud Max tier price ID (optional) TrialActivationPrivateKey string + TrialActivationPublicKey string RequireEmailProvider bool ResendAPIKey string // Resend API key (optional — if empty, emails are logged) EmailFrom string // Sender email address (e.g. "noreply@pulserelay.pro") @@ -128,6 +132,13 @@ func LoadConfig() (*CPConfig, error) { ResendAPIKey: strings.TrimSpace(os.Getenv("RESEND_API_KEY")), EmailFrom: envOrDefault("PULSE_EMAIL_FROM", "noreply@pulserelay.pro"), } + if strings.TrimSpace(cfg.TrialActivationPrivateKey) != "" { + publicKey, err := deriveTrialActivationPublicKey(cfg.TrialActivationPrivateKey) + if err != nil { + return nil, fmt.Errorf("derive trial activation public key: %w", err) + } + cfg.TrialActivationPublicKey = publicKey + } if err := cfg.validate(); err != nil { return nil, fmt.Errorf("validate control plane config: %w", err) @@ -135,6 +146,18 @@ func LoadConfig() (*CPConfig, error) { return cfg, nil } +func deriveTrialActivationPublicKey(encodedPrivateKey string) (string, error) { + privateKey, err := pkglicensing.DecodeEd25519PrivateKey(strings.TrimSpace(encodedPrivateKey)) + if err != nil { + return "", err + } + publicKey, ok := privateKey.Public().(ed25519.PublicKey) + if !ok || len(publicKey) != ed25519.PublicKeySize { + return "", fmt.Errorf("invalid derived trial activation public key") + } + return base64.StdEncoding.EncodeToString(publicKey), nil +} + func (c *CPConfig) validate() error { var missing []string if c.AdminKey == "" { diff --git a/internal/cloudcp/config_test.go b/internal/cloudcp/config_test.go index 732566c87f..dd31b9bbde 100644 --- a/internal/cloudcp/config_test.go +++ b/internal/cloudcp/config_test.go @@ -16,7 +16,7 @@ func setRequiredCPEnv(t *testing.T) { func setTrialSigningEnv(t *testing.T) { t.Helper() - t.Setenv("CP_TRIAL_ACTIVATION_PRIVATE_KEY", "test-activation-key") + t.Setenv("CP_TRIAL_ACTIVATION_PRIVATE_KEY", "A8medgdNdm12GXfTXWo6+TMZ2BeHPCLg2kd0znn6ZUk=") } func TestLoadConfig_MissingRequired(t *testing.T) { @@ -94,6 +94,19 @@ func TestLoadConfig_CustomValues(t *testing.T) { } } +func TestLoadConfig_DerivesTrialActivationPublicKey(t *testing.T) { + setRequiredCPEnv(t) + setTrialSigningEnv(t) + + cfg, err := LoadConfig() + if err != nil { + t.Fatalf("LoadConfig: %v", err) + } + if strings.TrimSpace(cfg.TrialActivationPublicKey) == "" { + t.Fatal("TrialActivationPublicKey = empty, want derived public key") + } +} + func TestLoadConfig_TrustedProxyCIDRs(t *testing.T) { setRequiredCPEnv(t) t.Setenv("CP_TRUSTED_PROXY_CIDRS", "172.18.0.0/16, 127.0.0.1/32") diff --git a/internal/cloudcp/docker/manager.go b/internal/cloudcp/docker/manager.go index 7918e0a106..f471441804 100644 --- a/internal/cloudcp/docker/manager.go +++ b/internal/cloudcp/docker/manager.go @@ -21,13 +21,14 @@ import ( // ManagerConfig holds Docker manager settings. type ManagerConfig struct { - Image string - Network string - BaseDomain string - TrustedProxyCIDRs []string - MemoryLimit int64 // bytes - CPUShares int64 - ContainerPort int // port inside the container (default 7655) + Image string + Network string + BaseDomain string + TrialActivationPublicKey string + TrustedProxyCIDRs []string + MemoryLimit int64 // bytes + CPUShares int64 + ContainerPort int // port inside the container (default 7655) } // Manager orchestrates Docker containers for tenant lifecycle. @@ -92,7 +93,7 @@ func (m *Manager) CreateAndStart(ctx context.Context, tenantID, tenantDataDir st &container.Config{ Image: m.cfg.Image, Labels: labels, - Env: tenantEnv(tenantID, m.cfg.BaseDomain, m.tenantTrustedProxyCIDRs(ctx)), + Env: tenantEnv(tenantID, m.cfg.BaseDomain, m.cfg.TrialActivationPublicKey, m.tenantTrustedProxyCIDRs(ctx)), }, &container.HostConfig{ RestartPolicy: container.RestartPolicy{Name: "unless-stopped"}, @@ -142,7 +143,7 @@ func tenantRuntimeOwnershipPaths() []string { } } -func tenantEnv(tenantID, baseDomain string, trustedProxyCIDRs []string) []string { +func tenantEnv(tenantID, baseDomain, trialActivationPublicKey string, trustedProxyCIDRs []string) []string { publicURL := "" tenantID = strings.TrimSpace(tenantID) baseDomain = strings.TrimSpace(baseDomain) @@ -162,6 +163,9 @@ func tenantEnv(tenantID, baseDomain string, trustedProxyCIDRs []string) []string if publicURL != "" { env = append(env, "PULSE_PUBLIC_URL="+publicURL) } + if strings.TrimSpace(trialActivationPublicKey) != "" { + env = append(env, "PULSE_TRIAL_ACTIVATION_PUBLIC_KEY="+strings.TrimSpace(trialActivationPublicKey)) + } if len(trustedProxyCIDRs) > 0 { env = append(env, "PULSE_TRUSTED_PROXY_CIDRS="+strings.Join(trustedProxyCIDRs, ",")) } diff --git a/internal/cloudcp/docker/manager_test.go b/internal/cloudcp/docker/manager_test.go index e5a7142fff..8b726ccf1d 100644 --- a/internal/cloudcp/docker/manager_test.go +++ b/internal/cloudcp/docker/manager_test.go @@ -70,7 +70,7 @@ func TestTenantMountsKeepImmutableFilesReadOnly(t *testing.T) { func TestTenantEnvIncludesImmutableOwnershipContract(t *testing.T) { t.Parallel() - env := tenantEnv("t-example", "cloud.pulserelay.pro", []string{"172.18.0.0/16", "127.0.0.1/32"}) + env := tenantEnv("t-example", "cloud.pulserelay.pro", "pubkey-123", []string{"172.18.0.0/16", "127.0.0.1/32"}) want := map[string]bool{ "PULSE_DATA_DIR=/etc/pulse": true, "PULSE_HOSTED_MODE=true": true, @@ -79,6 +79,7 @@ func TestTenantEnvIncludesImmutableOwnershipContract(t *testing.T) { "PUID=1000": true, "PGID=1000": true, "PULSE_PUBLIC_URL=https://t-example.cloud.pulserelay.pro": true, + "PULSE_TRIAL_ACTIVATION_PUBLIC_KEY=pubkey-123": true, "PULSE_TRUSTED_PROXY_CIDRS=172.18.0.0/16,127.0.0.1/32": true, immutableOwnershipPathsEnv + "=/etc/pulse/secrets/handoff.key:/etc/pulse/.cloud_handoff_key": true, } @@ -95,7 +96,7 @@ func TestTenantEnvIncludesImmutableOwnershipContract(t *testing.T) { func TestTenantEnvOmitsPublicURLWithoutTenantContext(t *testing.T) { t.Parallel() - env := tenantEnv("", "", nil) + env := tenantEnv("", "", "", nil) sawTenantID := false for _, item := range env { if strings.HasPrefix(item, "PULSE_PUBLIC_URL=") { diff --git a/internal/cloudcp/server.go b/internal/cloudcp/server.go index 09474b8af7..0570a834cc 100644 --- a/internal/cloudcp/server.go +++ b/internal/cloudcp/server.go @@ -53,12 +53,13 @@ func Run(ctx context.Context, version string) error { // Initialize Docker manager (best-effort — control plane can run without Docker for dev/testing) var dockerMgr *cpDocker.Manager dockerMgr, err = cpDocker.NewManager(cpDocker.ManagerConfig{ - Image: cfg.PulseImage, - Network: cfg.DockerNetwork, - BaseDomain: baseDomainFromURL(cfg.BaseURL), - TrustedProxyCIDRs: cfg.TrustedProxyCIDRs, - MemoryLimit: cfg.TenantMemoryLimit, - CPUShares: cfg.TenantCPUShares, + Image: cfg.PulseImage, + Network: cfg.DockerNetwork, + BaseDomain: baseDomainFromURL(cfg.BaseURL), + TrialActivationPublicKey: cfg.TrialActivationPublicKey, + TrustedProxyCIDRs: cfg.TrustedProxyCIDRs, + MemoryLimit: cfg.TenantMemoryLimit, + CPUShares: cfg.TenantCPUShares, }) if err != nil { log.Warn().Err(err).Msg("Docker unavailable — container management disabled")