mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-09 16:00:59 +00:00
Provider-hosted MSP client workspaces previously sat at Community tier forever: the runtime refreshed leases against the built-in Pulse Cloud URL (hibernated, 522) instead of the provider control plane, and release-build images verify leases only against the embedded Pulse key, which an operator-generated CP_TRIAL_ACTIVATION_PRIVATE_KEY can never satisfy. - Inject PULSE_PRO_TRIAL_SIGNUP_URL=CP_BASE_URL into client containers so lease refresh targets the provider control plane. - Chain trust through the Pulse-signed provider MSP license: the license binds the provider's lease signing public key (entitlement_signing_public_key claim); the control plane embeds the license in every lease (provider_license claim); release-build runtimes verify embedded Pulse root -> provider license -> lease signature. - Cap chain-verified leases at ProviderChainedLeaseCapabilities: MSP tier plus white_label (branded per-client reports), minus Pulse-service-backed relay/mobile_app/push_notifications, which otherwise loop doomed registrations against Pulse's relay. - Fail fast at control-plane startup when the license does not bind the configured signing key, instead of provisioning silently unlicensed client workspaces. Verified live on a Colima harness: release-tagged tenant image with test embedded root, Traefik TLS, full provider-msp proof, tenant reports valid=true plan_version=msp_growth with white_label and zero relay failures.
813 lines
32 KiB
Go
813 lines
32 KiB
Go
package cloudcp
|
|
|
|
import (
|
|
"crypto/ed25519"
|
|
"encoding/base64"
|
|
"fmt"
|
|
"io"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/joho/godotenv"
|
|
runtimeconfig "github.com/rcourtman/pulse-go-rewrite/internal/config"
|
|
pkglicensing "github.com/rcourtman/pulse-go-rewrite/pkg/licensing"
|
|
)
|
|
|
|
// ControlPlaneMode selects the business/runtime shape exposed by cloudcp.
|
|
type ControlPlaneMode string
|
|
|
|
const (
|
|
ControlPlaneModePulseHosted ControlPlaneMode = "pulse_hosted"
|
|
ControlPlaneModePulseHostedMSP ControlPlaneMode = "pulse_hosted_msp"
|
|
ControlPlaneModeProviderHostedMSP ControlPlaneMode = "provider_hosted_msp"
|
|
defaultProviderHostedMSPPlanVersion = "msp_starter"
|
|
ProviderMSPPlanSourceLicenseFile = "license_file"
|
|
ProviderMSPPlanSourceEnvFallback = "environment_fallback"
|
|
maxProviderMSPLicenseFileBytes = 64 * 1024
|
|
)
|
|
|
|
// CPConfig holds all configuration for the control plane.
|
|
type CPConfig struct {
|
|
DataDir string
|
|
Environment string
|
|
ControlPlaneMode ControlPlaneMode
|
|
BindAddress string
|
|
Port int
|
|
AdminKey string
|
|
BaseURL string
|
|
PublicStatus bool
|
|
PublicMetrics bool
|
|
WebhookRateLimitPerMinute int
|
|
MagicLinkVerifyRateLimitPerMinute int
|
|
SessionAuthRateLimitPerMinute int
|
|
AdminRateLimitPerMinute int
|
|
AccountAPIRateLimitPerMinute int
|
|
PortalAPIRateLimitPerMinute int
|
|
PulseImage string
|
|
DockerNetwork string
|
|
TrustedProxyCIDRs []string
|
|
TenantMemoryLimit int64 // bytes
|
|
TenantCPUShares int64
|
|
TenantLogMaxSize string
|
|
TenantLogMaxFile int
|
|
ReportBrandDisplayName string // Provider-level default for tenant report PDFs
|
|
ReportBrandLogoPath string
|
|
ReportBrandLogoBase64 string
|
|
ReportBrandLogoFormat string
|
|
AllowDockerlessProvisioning bool
|
|
StorageGuardrailsEnabled bool
|
|
StorageRootPath string
|
|
StorageDataPath string
|
|
StorageDockerPath string
|
|
StorageMinRootAvailableBytes int64
|
|
StorageMinDataAvailableBytes int64
|
|
StorageMinDockerAvailableBytes int64
|
|
StorageMaxDockerBuildCacheBytes int64
|
|
ProofTenantMaxAge time.Duration
|
|
ProofTenantMatchers []string
|
|
StripeWebhookSecret string
|
|
StripeAPIKey string
|
|
PublicCloudSignupEnabled bool
|
|
TrialSignupPriceID string // Cloud Starter (default tier) price ID
|
|
CloudPowerPriceID string // Cloud Power tier price ID (optional)
|
|
CloudMaxPriceID string // Cloud Max tier price ID (optional)
|
|
CloudMSPStarterPriceID string // MSP Starter tier price ID (optional)
|
|
CloudMSPGrowthPriceID string // MSP Growth tier price ID (optional)
|
|
CloudMSPScalePriceID string // MSP Scale tier price ID (optional)
|
|
ProviderMSPPlanVersion string // Local provider-hosted MSP workspace limit plan
|
|
ProviderMSPPlanSource string // How ProviderMSPPlanVersion was resolved
|
|
ProviderMSPLicenseFile string // Signed provider MSP license source
|
|
ProviderMSPLicenseID string // Validated provider MSP license ID
|
|
ProviderMSPLicenseEmail string // Validated provider MSP license holder
|
|
ProviderMSPLicenseKey string // Raw validated provider MSP license (chained into tenant leases)
|
|
ProviderMSPLeaseSigningPublicKey string // entitlement_signing_public_key claim from the provider MSP license
|
|
LicenseServerURL string
|
|
LicenseAdminToken string
|
|
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")
|
|
EmailReplyTo string // Support reply address for transactional email
|
|
}
|
|
|
|
// TenantsDir returns the directory where per-tenant data is stored.
|
|
func (c *CPConfig) TenantsDir() string {
|
|
return filepath.Join(c.DataDir, "tenants")
|
|
}
|
|
|
|
// ControlPlaneDir returns the directory for control plane's own data (registry DB, etc).
|
|
func (c *CPConfig) ControlPlaneDir() string {
|
|
return filepath.Join(c.DataDir, "control-plane")
|
|
}
|
|
|
|
func (c *CPConfig) IsProviderHostedMSP() bool {
|
|
return c != nil && c.ControlPlaneMode == ControlPlaneModeProviderHostedMSP
|
|
}
|
|
|
|
func (c *CPConfig) IsMSPControlPlane() bool {
|
|
return c != nil && isMSPControlPlaneMode(c.ControlPlaneMode)
|
|
}
|
|
|
|
func (c *CPConfig) UsesStripeBilling() bool {
|
|
return c != nil && !c.IsMSPControlPlane()
|
|
}
|
|
|
|
// LoadConfig loads control plane configuration from environment variables.
|
|
// A .env file is loaded if present but not required.
|
|
func LoadConfig() (*CPConfig, error) {
|
|
// Best-effort .env loading (not required)
|
|
_ = godotenv.Load()
|
|
|
|
dataDir := envOrDefault("CP_DATA_DIR", "/data")
|
|
environment := normalizeCPEnvironment(envOrDefault("CP_ENV", "production"))
|
|
controlPlaneMode := normalizeControlPlaneMode(envOrDefault("CP_CONTROL_PLANE_MODE", string(ControlPlaneModePulseHosted)))
|
|
port, err := envOrDefaultInt("CP_PORT", 8443)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
tenantMemoryLimit, err := envOrDefaultInt64("CP_TENANT_MEMORY_LIMIT", 512*1024*1024) // 512 MiB
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
tenantCPUShares, err := envOrDefaultInt64("CP_TENANT_CPU_SHARES", 256)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
tenantLogMaxFile, err := envOrDefaultInt("CP_TENANT_LOG_MAX_FILE", 3)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
webhookRPS, err := envOrDefaultInt("CP_RL_WEBHOOK_PER_MINUTE", 120)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
magicVerifyRPS, err := envOrDefaultInt("CP_RL_MAGIC_VERIFY_PER_MINUTE", 30)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
sessionAuthRPS, err := envOrDefaultInt("CP_RL_SESSION_PER_MINUTE", 60)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
adminRPS, err := envOrDefaultInt("CP_RL_ADMIN_PER_MINUTE", 120)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
accountRPS, err := envOrDefaultInt("CP_RL_ACCOUNT_PER_MINUTE", 300)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
portalRPS, err := envOrDefaultInt("CP_RL_PORTAL_PER_MINUTE", 300)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
storageMinRootAvailable, err := envOrDefaultBytes("CP_STORAGE_MIN_ROOT_AVAILABLE", 10*1024*1024*1024)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
storageMinDataAvailable, err := envOrDefaultBytes("CP_STORAGE_MIN_DATA_AVAILABLE", 5*1024*1024*1024)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
storageMinDockerAvailable, err := envOrDefaultBytes("CP_STORAGE_MIN_DOCKER_AVAILABLE", 10*1024*1024*1024)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
storageMaxDockerBuildCache, err := envOrDefaultBytes("CP_STORAGE_MAX_DOCKER_BUILD_CACHE", 2*1024*1024*1024)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
proofTenantMaxAge, err := envOrDefaultDuration("CP_PROOF_TENANT_MAX_AGE", 24*time.Hour)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
providerMSPLicenseFile := strings.TrimSpace(os.Getenv("CP_PROVIDER_MSP_LICENSE_FILE"))
|
|
providerMSPPlanVersion := pkglicensing.CanonicalizePlanVersion(envOrDefault("CP_PROVIDER_MSP_PLAN_VERSION", defaultProviderHostedMSPPlanVersion))
|
|
providerMSPPlanSource := ProviderMSPPlanSourceEnvFallback
|
|
providerMSPLicenseID := ""
|
|
providerMSPLicenseEmail := ""
|
|
providerMSPLicenseKey := ""
|
|
providerMSPLeaseSigningPublicKey := ""
|
|
if isMSPControlPlaneMode(controlPlaneMode) && providerMSPLicenseFile != "" {
|
|
resolved, err := resolveProviderMSPPlanFromLicenseFile(providerMSPLicenseFile)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("resolve provider MSP license: %w", err)
|
|
}
|
|
providerMSPPlanVersion = resolved.PlanVersion
|
|
providerMSPPlanSource = ProviderMSPPlanSourceLicenseFile
|
|
providerMSPLicenseID = resolved.LicenseID
|
|
providerMSPLicenseEmail = resolved.LicenseEmail
|
|
providerMSPLicenseKey = resolved.LicenseKey
|
|
providerMSPLeaseSigningPublicKey = resolved.LeaseSigningPublicKey
|
|
}
|
|
|
|
cfg := &CPConfig{
|
|
DataDir: dataDir,
|
|
Environment: environment,
|
|
ControlPlaneMode: controlPlaneMode,
|
|
BindAddress: envOrDefault("CP_BIND_ADDRESS", "0.0.0.0"),
|
|
Port: port,
|
|
AdminKey: strings.TrimSpace(os.Getenv("CP_ADMIN_KEY")),
|
|
BaseURL: strings.TrimSpace(os.Getenv("CP_BASE_URL")),
|
|
PublicStatus: envOrDefaultBool("CP_PUBLIC_STATUS", false),
|
|
PublicMetrics: envOrDefaultBool("CP_PUBLIC_METRICS", false),
|
|
WebhookRateLimitPerMinute: webhookRPS,
|
|
MagicLinkVerifyRateLimitPerMinute: magicVerifyRPS,
|
|
SessionAuthRateLimitPerMinute: sessionAuthRPS,
|
|
AdminRateLimitPerMinute: adminRPS,
|
|
AccountAPIRateLimitPerMinute: accountRPS,
|
|
PortalAPIRateLimitPerMinute: portalRPS,
|
|
PulseImage: envOrDefault("CP_PULSE_IMAGE", "ghcr.io/rcourtman/pulse:latest"),
|
|
DockerNetwork: envOrDefault("CP_DOCKER_NETWORK", "pulse-cloud"),
|
|
TrustedProxyCIDRs: parseTrustedProxyCIDRValues("CP_TRUSTED_PROXY_CIDRS", "PULSE_TRUSTED_PROXY_CIDRS"),
|
|
TenantMemoryLimit: tenantMemoryLimit,
|
|
TenantCPUShares: tenantCPUShares,
|
|
TenantLogMaxSize: envOrDefault("CP_TENANT_LOG_MAX_SIZE", "10m"),
|
|
TenantLogMaxFile: tenantLogMaxFile,
|
|
ReportBrandDisplayName: strings.TrimSpace(os.Getenv("CP_REPORT_BRAND_DISPLAY_NAME")),
|
|
ReportBrandLogoPath: strings.TrimSpace(os.Getenv("CP_REPORT_BRAND_LOGO_PATH")),
|
|
ReportBrandLogoBase64: strings.TrimSpace(os.Getenv("CP_REPORT_BRAND_LOGO_BASE64")),
|
|
ReportBrandLogoFormat: strings.TrimSpace(os.Getenv("CP_REPORT_BRAND_LOGO_FORMAT")),
|
|
AllowDockerlessProvisioning: envOrDefaultBool("CP_ALLOW_DOCKERLESS_PROVISIONING", false),
|
|
StorageGuardrailsEnabled: envOrDefaultBool("CP_STORAGE_GUARDRAILS_ENABLED", environment != "development"),
|
|
StorageRootPath: envOrDefault("CP_STORAGE_ROOT_PATH", "/"),
|
|
StorageDataPath: envOrDefault("CP_STORAGE_DATA_PATH", dataDir),
|
|
StorageDockerPath: envOrDefault("CP_STORAGE_DOCKER_PATH", "/var/lib/docker"),
|
|
StorageMinRootAvailableBytes: storageMinRootAvailable,
|
|
StorageMinDataAvailableBytes: storageMinDataAvailable,
|
|
StorageMinDockerAvailableBytes: storageMinDockerAvailable,
|
|
StorageMaxDockerBuildCacheBytes: storageMaxDockerBuildCache,
|
|
ProofTenantMaxAge: proofTenantMaxAge,
|
|
ProofTenantMatchers: parseCSVEnv("CP_PROOF_TENANT_MATCHERS", "proof,canary,rehearsal,msp_prod,ownerseed,owner_seed"),
|
|
StripeWebhookSecret: strings.TrimSpace(os.Getenv("STRIPE_WEBHOOK_SECRET")),
|
|
StripeAPIKey: strings.TrimSpace(os.Getenv("STRIPE_API_KEY")),
|
|
PublicCloudSignupEnabled: envOrDefaultBool("CP_PUBLIC_CLOUD_SIGNUP_ENABLED", false),
|
|
TrialSignupPriceID: strings.TrimSpace(os.Getenv("CP_TRIAL_SIGNUP_PRICE_ID")),
|
|
CloudPowerPriceID: strings.TrimSpace(os.Getenv("CP_CLOUD_POWER_PRICE_ID")),
|
|
CloudMaxPriceID: strings.TrimSpace(os.Getenv("CP_CLOUD_MAX_PRICE_ID")),
|
|
CloudMSPStarterPriceID: strings.TrimSpace(os.Getenv("CP_MSP_STARTER_PRICE_ID")),
|
|
CloudMSPGrowthPriceID: strings.TrimSpace(os.Getenv("CP_MSP_GROWTH_PRICE_ID")),
|
|
CloudMSPScalePriceID: strings.TrimSpace(os.Getenv("CP_MSP_SCALE_PRICE_ID")),
|
|
ProviderMSPPlanVersion: providerMSPPlanVersion,
|
|
ProviderMSPPlanSource: providerMSPPlanSource,
|
|
ProviderMSPLicenseFile: providerMSPLicenseFile,
|
|
ProviderMSPLicenseKey: providerMSPLicenseKey,
|
|
ProviderMSPLeaseSigningPublicKey: providerMSPLeaseSigningPublicKey,
|
|
ProviderMSPLicenseID: providerMSPLicenseID,
|
|
ProviderMSPLicenseEmail: providerMSPLicenseEmail,
|
|
LicenseServerURL: envOrDefault("PULSE_LICENSE_SERVER_URL", "https://license.pulserelay.pro"),
|
|
LicenseAdminToken: strings.TrimSpace(os.Getenv("PULSE_LICENSE_ADMIN_TOKEN")),
|
|
TrialActivationPrivateKey: strings.TrimSpace(os.Getenv("CP_TRIAL_ACTIVATION_PRIVATE_KEY")),
|
|
RequireEmailProvider: envOrDefaultBool("CP_REQUIRE_EMAIL_PROVIDER", true),
|
|
ResendAPIKey: strings.TrimSpace(os.Getenv("RESEND_API_KEY")),
|
|
EmailFrom: envOrDefault("PULSE_EMAIL_FROM", "noreply@pulserelay.pro"),
|
|
EmailReplyTo: envOrDefault("PULSE_EMAIL_REPLY_TO", "support@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)
|
|
}
|
|
|
|
if err := cfg.validateProviderMSPLeaseSigningBinding(); err != nil {
|
|
return nil, err
|
|
}
|
|
return cfg, nil
|
|
}
|
|
|
|
// validateProviderMSPLeaseSigningBinding fails fast when a provider-hosted
|
|
// MSP control plane cannot mint entitlement leases that client runtimes will
|
|
// verify. Release-build tenant runtimes only trust lease signing keys bound
|
|
// into the Pulse-signed provider MSP license, so a missing or mismatched
|
|
// binding would provision client workspaces that silently run unlicensed.
|
|
func (c *CPConfig) validateProviderMSPLeaseSigningBinding() error {
|
|
if c == nil || !c.IsProviderHostedMSP() || strings.TrimSpace(c.ProviderMSPLicenseKey) == "" {
|
|
// Pulse-hosted modes sign with Pulse's own key, and the env-fallback
|
|
// plan path (dev/install-proof without a license file) has nothing
|
|
// to bind against.
|
|
return nil
|
|
}
|
|
boundKey := strings.TrimSpace(c.ProviderMSPLeaseSigningPublicKey)
|
|
if boundKey == "" {
|
|
return fmt.Errorf("CP_PROVIDER_MSP_LICENSE_FILE does not bind an entitlement lease signing key (entitlement_signing_public_key); request an updated provider MSP license bound to this control plane's CP_TRIAL_ACTIVATION_PRIVATE_KEY public key %q", c.TrialActivationPublicKey)
|
|
}
|
|
bound, err := pkglicensing.DecodePublicKey(boundKey)
|
|
if err != nil {
|
|
return fmt.Errorf("CP_PROVIDER_MSP_LICENSE_FILE entitlement_signing_public_key is malformed: %w", err)
|
|
}
|
|
derived, err := pkglicensing.DecodePublicKey(c.TrialActivationPublicKey)
|
|
if err != nil {
|
|
return fmt.Errorf("derive trial activation public key: %w", err)
|
|
}
|
|
if !bound.Equal(derived) {
|
|
return fmt.Errorf("CP_TRIAL_ACTIVATION_PRIVATE_KEY does not match the entitlement lease signing key bound in CP_PROVIDER_MSP_LICENSE_FILE (license binds %q, control plane derives %q); use the keypair the license was issued for, or request a license bound to this key", boundKey, c.TrialActivationPublicKey)
|
|
}
|
|
return 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 == "" {
|
|
missing = append(missing, "CP_ADMIN_KEY")
|
|
}
|
|
if c.BaseURL == "" {
|
|
missing = append(missing, "CP_BASE_URL")
|
|
}
|
|
if c.UsesStripeBilling() && c.StripeWebhookSecret == "" {
|
|
missing = append(missing, "STRIPE_WEBHOOK_SECRET")
|
|
}
|
|
if len(missing) > 0 {
|
|
return fmt.Errorf("missing required environment variables: %s", strings.Join(missing, ", "))
|
|
}
|
|
|
|
if c.Port < 1 || c.Port > 65535 {
|
|
return fmt.Errorf("CP_PORT must be between 1 and 65535, got %d", c.Port)
|
|
}
|
|
if c.TenantMemoryLimit <= 0 {
|
|
return fmt.Errorf("CP_TENANT_MEMORY_LIMIT must be greater than 0, got %d", c.TenantMemoryLimit)
|
|
}
|
|
if c.TenantCPUShares <= 0 {
|
|
return fmt.Errorf("CP_TENANT_CPU_SHARES must be greater than 0, got %d", c.TenantCPUShares)
|
|
}
|
|
if strings.TrimSpace(c.TenantLogMaxSize) == "" {
|
|
return fmt.Errorf("CP_TENANT_LOG_MAX_SIZE must not be empty")
|
|
}
|
|
if c.TenantLogMaxFile <= 0 {
|
|
return fmt.Errorf("CP_TENANT_LOG_MAX_FILE must be greater than 0")
|
|
}
|
|
if len(strings.TrimSpace(c.ReportBrandDisplayName)) > runtimeconfig.ReportBrandDisplayNameMaxLength {
|
|
return fmt.Errorf("CP_REPORT_BRAND_DISPLAY_NAME must be <= %d characters", runtimeconfig.ReportBrandDisplayNameMaxLength)
|
|
}
|
|
if len(strings.TrimSpace(c.ReportBrandLogoPath)) > runtimeconfig.ReportBrandLogoPathMaxLength {
|
|
return fmt.Errorf("CP_REPORT_BRAND_LOGO_PATH must be <= %d characters", runtimeconfig.ReportBrandLogoPathMaxLength)
|
|
}
|
|
if _, err := runtimeconfig.DecodeReportBrandLogoBase64(c.ReportBrandLogoBase64); err != nil {
|
|
return fmt.Errorf("CP_REPORT_BRAND_LOGO_BASE64 %s", err.Error())
|
|
}
|
|
if _, ok := runtimeconfig.CanonicalReportBrandLogoFormat(c.ReportBrandLogoFormat); !ok {
|
|
return fmt.Errorf("CP_REPORT_BRAND_LOGO_FORMAT must be png, jpg, jpeg, gif, or empty")
|
|
}
|
|
if c.WebhookRateLimitPerMinute <= 0 {
|
|
return fmt.Errorf("CP_RL_WEBHOOK_PER_MINUTE must be greater than 0")
|
|
}
|
|
if c.MagicLinkVerifyRateLimitPerMinute <= 0 {
|
|
return fmt.Errorf("CP_RL_MAGIC_VERIFY_PER_MINUTE must be greater than 0")
|
|
}
|
|
if c.SessionAuthRateLimitPerMinute <= 0 {
|
|
return fmt.Errorf("CP_RL_SESSION_PER_MINUTE must be greater than 0")
|
|
}
|
|
if c.AdminRateLimitPerMinute <= 0 {
|
|
return fmt.Errorf("CP_RL_ADMIN_PER_MINUTE must be greater than 0")
|
|
}
|
|
if c.AccountAPIRateLimitPerMinute <= 0 {
|
|
return fmt.Errorf("CP_RL_ACCOUNT_PER_MINUTE must be greater than 0")
|
|
}
|
|
if c.PortalAPIRateLimitPerMinute <= 0 {
|
|
return fmt.Errorf("CP_RL_PORTAL_PER_MINUTE must be greater than 0")
|
|
}
|
|
if c.Environment != "development" && c.Environment != "staging" && c.Environment != "production" {
|
|
return fmt.Errorf("CP_ENV must be one of development, staging, production (got %q)", c.Environment)
|
|
}
|
|
if !isKnownControlPlaneMode(c.ControlPlaneMode) {
|
|
return fmt.Errorf("CP_CONTROL_PLANE_MODE must be one of %s, %s, %s (got %q)", ControlPlaneModePulseHosted, ControlPlaneModeProviderHostedMSP, ControlPlaneModePulseHostedMSP, c.ControlPlaneMode)
|
|
}
|
|
if c.Environment == "production" && c.AllowDockerlessProvisioning {
|
|
return fmt.Errorf("CP_ALLOW_DOCKERLESS_PROVISIONING must be false in production")
|
|
}
|
|
if c.IsMSPControlPlane() {
|
|
if strings.TrimSpace(c.StripeWebhookSecret) != "" {
|
|
return fmt.Errorf("STRIPE_WEBHOOK_SECRET must not be configured when CP_CONTROL_PLANE_MODE=%s", c.ControlPlaneMode)
|
|
}
|
|
if strings.TrimSpace(c.StripeAPIKey) != "" {
|
|
return fmt.Errorf("STRIPE_API_KEY must not be configured when CP_CONTROL_PLANE_MODE=%s", c.ControlPlaneMode)
|
|
}
|
|
if c.PublicCloudSignupEnabled {
|
|
return fmt.Errorf("CP_PUBLIC_CLOUD_SIGNUP_ENABLED must be false when CP_CONTROL_PLANE_MODE=%s", c.ControlPlaneMode)
|
|
}
|
|
if strings.TrimSpace(c.CloudMSPStarterPriceID) != "" || strings.TrimSpace(c.CloudMSPGrowthPriceID) != "" || strings.TrimSpace(c.CloudMSPScalePriceID) != "" {
|
|
return fmt.Errorf("CP_MSP_*_PRICE_ID must not be configured when CP_CONTROL_PLANE_MODE=%s", c.ControlPlaneMode)
|
|
}
|
|
if strings.TrimSpace(c.TrialActivationPrivateKey) == "" {
|
|
return fmt.Errorf("CP_TRIAL_ACTIVATION_PRIVATE_KEY is required when CP_CONTROL_PLANE_MODE=%s", c.ControlPlaneMode)
|
|
}
|
|
if c.Environment == "production" && strings.TrimSpace(c.ProviderMSPLicenseFile) == "" {
|
|
return fmt.Errorf("CP_PROVIDER_MSP_LICENSE_FILE is required in production when CP_CONTROL_PLANE_MODE=%s", c.ControlPlaneMode)
|
|
}
|
|
if !strings.HasPrefix(strings.ToLower(c.ProviderMSPPlanVersion), "msp_") {
|
|
return fmt.Errorf("CP_PROVIDER_MSP_PLAN_VERSION must be a canonical MSP plan version, got %q", c.ProviderMSPPlanVersion)
|
|
}
|
|
if _, known := pkglicensing.WorkspaceLimitForPlan(c.ProviderMSPPlanVersion); !known {
|
|
return fmt.Errorf("CP_PROVIDER_MSP_PLAN_VERSION must have a known workspace limit, got %q", c.ProviderMSPPlanVersion)
|
|
}
|
|
}
|
|
if c.StorageGuardrailsEnabled {
|
|
if strings.TrimSpace(c.StorageRootPath) == "" {
|
|
return fmt.Errorf("CP_STORAGE_ROOT_PATH is required when CP_STORAGE_GUARDRAILS_ENABLED=true")
|
|
}
|
|
if strings.TrimSpace(c.StorageDataPath) == "" {
|
|
return fmt.Errorf("CP_STORAGE_DATA_PATH is required when CP_STORAGE_GUARDRAILS_ENABLED=true")
|
|
}
|
|
if strings.TrimSpace(c.StorageDockerPath) == "" {
|
|
return fmt.Errorf("CP_STORAGE_DOCKER_PATH is required when CP_STORAGE_GUARDRAILS_ENABLED=true")
|
|
}
|
|
if c.StorageMinRootAvailableBytes <= 0 {
|
|
return fmt.Errorf("CP_STORAGE_MIN_ROOT_AVAILABLE must be greater than 0")
|
|
}
|
|
if c.StorageMinDataAvailableBytes <= 0 {
|
|
return fmt.Errorf("CP_STORAGE_MIN_DATA_AVAILABLE must be greater than 0")
|
|
}
|
|
if c.StorageMinDockerAvailableBytes <= 0 {
|
|
return fmt.Errorf("CP_STORAGE_MIN_DOCKER_AVAILABLE must be greater than 0")
|
|
}
|
|
if c.StorageMaxDockerBuildCacheBytes <= 0 {
|
|
return fmt.Errorf("CP_STORAGE_MAX_DOCKER_BUILD_CACHE must be greater than 0")
|
|
}
|
|
}
|
|
if c.ProofTenantMaxAge <= 0 {
|
|
return fmt.Errorf("CP_PROOF_TENANT_MAX_AGE must be greater than 0")
|
|
}
|
|
if c.RequireEmailProvider {
|
|
if strings.TrimSpace(c.ResendAPIKey) == "" {
|
|
return fmt.Errorf("RESEND_API_KEY is required when CP_REQUIRE_EMAIL_PROVIDER=true")
|
|
}
|
|
if strings.TrimSpace(c.EmailFrom) == "" {
|
|
return fmt.Errorf("PULSE_EMAIL_FROM is required when CP_REQUIRE_EMAIL_PROVIDER=true")
|
|
}
|
|
if strings.TrimSpace(c.EmailReplyTo) == "" {
|
|
return fmt.Errorf("PULSE_EMAIL_REPLY_TO is required when CP_REQUIRE_EMAIL_PROVIDER=true")
|
|
}
|
|
}
|
|
if strings.TrimSpace(c.StripeAPIKey) != "" && c.PublicCloudSignupEnabled && strings.TrimSpace(c.TrialSignupPriceID) == "" {
|
|
return fmt.Errorf("CP_TRIAL_SIGNUP_PRICE_ID is required when STRIPE_API_KEY is configured")
|
|
}
|
|
if strings.TrimSpace(c.StripeAPIKey) != "" && strings.TrimSpace(c.TrialActivationPrivateKey) == "" {
|
|
return fmt.Errorf("CP_TRIAL_ACTIVATION_PRIVATE_KEY is required when STRIPE_API_KEY is configured")
|
|
}
|
|
if err := validateCloudStripePriceID(c.Environment, c.StripeAPIKey, "CP_TRIAL_SIGNUP_PRICE_ID", c.TrialSignupPriceID, "cloud_starter"); err != nil {
|
|
return err
|
|
}
|
|
if err := validateCloudStripePriceID(c.Environment, c.StripeAPIKey, "CP_CLOUD_POWER_PRICE_ID", c.CloudPowerPriceID, "cloud_power"); err != nil {
|
|
return err
|
|
}
|
|
if err := validateCloudStripePriceID(c.Environment, c.StripeAPIKey, "CP_CLOUD_MAX_PRICE_ID", c.CloudMaxPriceID, "cloud_max"); err != nil {
|
|
return err
|
|
}
|
|
if err := validateCloudStripePriceID(c.Environment, c.StripeAPIKey, "CP_MSP_STARTER_PRICE_ID", c.CloudMSPStarterPriceID, "msp_starter"); err != nil {
|
|
return err
|
|
}
|
|
if err := validateCloudStripePriceID(c.Environment, c.StripeAPIKey, "CP_MSP_GROWTH_PRICE_ID", c.CloudMSPGrowthPriceID, "msp_growth"); err != nil {
|
|
return err
|
|
}
|
|
if err := validateCloudStripePriceID(c.Environment, c.StripeAPIKey, "CP_MSP_SCALE_PRICE_ID", c.CloudMSPScalePriceID, "msp_scale"); err != nil {
|
|
return err
|
|
}
|
|
if strings.TrimSpace(c.LicenseServerURL) == "" && strings.TrimSpace(c.LicenseAdminToken) != "" {
|
|
return fmt.Errorf("PULSE_LICENSE_SERVER_URL is required when PULSE_LICENSE_ADMIN_TOKEN is configured")
|
|
}
|
|
if strings.TrimSpace(c.StripeAPIKey) != "" {
|
|
stripeMode := stripeSecretKeyMode(c.StripeAPIKey)
|
|
switch c.Environment {
|
|
case "production":
|
|
if stripeMode != "live" {
|
|
return fmt.Errorf("STRIPE_API_KEY must be a live key (sk_live_...) when CP_ENV=production")
|
|
}
|
|
case "staging":
|
|
if stripeMode != "test" {
|
|
return fmt.Errorf("STRIPE_API_KEY must be a test key (sk_test_...) when CP_ENV=staging")
|
|
}
|
|
}
|
|
}
|
|
|
|
parsedBaseURL, err := url.Parse(c.BaseURL)
|
|
if err != nil {
|
|
return fmt.Errorf("CP_BASE_URL must be a valid URL: %w", err)
|
|
}
|
|
if parsedBaseURL.Scheme != "http" && parsedBaseURL.Scheme != "https" {
|
|
return fmt.Errorf("CP_BASE_URL must use http or https scheme")
|
|
}
|
|
if parsedBaseURL.Host == "" {
|
|
return fmt.Errorf("CP_BASE_URL must include a host")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateCloudStripePriceID(environment, stripeAPIKey, envName, priceID, wantPlanVersion string) error {
|
|
trimmed := strings.TrimSpace(priceID)
|
|
if trimmed == "" {
|
|
return nil
|
|
}
|
|
|
|
if environment != "production" && stripeSecretKeyMode(stripeAPIKey) == "test" {
|
|
if strings.HasPrefix(trimmed, "price_") {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("%s must be a Stripe price id (price_...) in %s test mode, got %q", envName, environment, trimmed)
|
|
}
|
|
|
|
planVersion, ok := pkglicensing.PlanVersionForPriceID(trimmed)
|
|
if !ok {
|
|
return fmt.Errorf("%s must map to the canonical %s Stripe price, got unknown price id %q", envName, wantPlanVersion, trimmed)
|
|
}
|
|
if planVersion != wantPlanVersion {
|
|
return fmt.Errorf("%s must map to the canonical %s Stripe price, got %q (%s)", envName, wantPlanVersion, trimmed, planVersion)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func normalizeCPEnvironment(raw string) string {
|
|
switch strings.ToLower(strings.TrimSpace(raw)) {
|
|
case "dev":
|
|
return "development"
|
|
case "prod":
|
|
return "production"
|
|
default:
|
|
return strings.ToLower(strings.TrimSpace(raw))
|
|
}
|
|
}
|
|
|
|
func normalizeControlPlaneMode(raw string) ControlPlaneMode {
|
|
switch strings.ToLower(strings.TrimSpace(raw)) {
|
|
case "", "pulse_hosted", "pulse-hosted", "pulse_cloud", "pulse-cloud", "cloud":
|
|
return ControlPlaneModePulseHosted
|
|
case "pulse_hosted_msp", "pulse-hosted-msp", "pulse_msp", "pulse-msp", "hosted_msp", "hosted-msp":
|
|
return ControlPlaneModePulseHostedMSP
|
|
case "provider_hosted_msp", "provider-hosted-msp", "provider_msp", "provider-msp", "msp_provider", "msp-provider":
|
|
return ControlPlaneModeProviderHostedMSP
|
|
default:
|
|
return ControlPlaneMode(strings.ToLower(strings.TrimSpace(raw)))
|
|
}
|
|
}
|
|
|
|
func isKnownControlPlaneMode(mode ControlPlaneMode) bool {
|
|
switch mode {
|
|
case ControlPlaneModePulseHosted, ControlPlaneModeProviderHostedMSP, ControlPlaneModePulseHostedMSP:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func isMSPControlPlaneMode(mode ControlPlaneMode) bool {
|
|
switch mode {
|
|
case ControlPlaneModeProviderHostedMSP, ControlPlaneModePulseHostedMSP:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
type providerMSPLicenseResolution struct {
|
|
PlanVersion string
|
|
LicenseID string
|
|
LicenseEmail string
|
|
LicenseKey string // raw signed license, chained into tenant entitlement leases
|
|
LeaseSigningPublicKey string // entitlement_signing_public_key claim, empty when the license predates lease chaining
|
|
}
|
|
|
|
func resolveProviderMSPPlanFromLicenseFile(path string) (*providerMSPLicenseResolution, error) {
|
|
path = strings.TrimSpace(path)
|
|
if path == "" {
|
|
return nil, fmt.Errorf("CP_PROVIDER_MSP_LICENSE_FILE is required")
|
|
}
|
|
licenseKey, err := readProviderMSPLicenseFile(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
pkglicensing.InitEmbeddedPublicKey()
|
|
license, err := pkglicensing.ValidateLicense(licenseKey)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("validate CP_PROVIDER_MSP_LICENSE_FILE: %w", err)
|
|
}
|
|
if license == nil {
|
|
return nil, fmt.Errorf("validate CP_PROVIDER_MSP_LICENSE_FILE: no license returned")
|
|
}
|
|
if license.Claims.Tier != pkglicensing.TierMSP {
|
|
return nil, fmt.Errorf("CP_PROVIDER_MSP_LICENSE_FILE must contain an MSP license tier, got %q", license.Claims.Tier)
|
|
}
|
|
plan := license.Claims.EntitlementPlanVersion()
|
|
if strings.TrimSpace(plan) == "" {
|
|
return nil, fmt.Errorf("CP_PROVIDER_MSP_LICENSE_FILE must contain plan_version")
|
|
}
|
|
if !strings.HasPrefix(strings.ToLower(plan), "msp_") {
|
|
return nil, fmt.Errorf("CP_PROVIDER_MSP_LICENSE_FILE plan_version must be a canonical MSP plan version, got %q", plan)
|
|
}
|
|
if _, known := pkglicensing.WorkspaceLimitForPlan(plan); !known {
|
|
return nil, fmt.Errorf("CP_PROVIDER_MSP_LICENSE_FILE plan_version must have a known workspace limit, got %q", plan)
|
|
}
|
|
return &providerMSPLicenseResolution{
|
|
PlanVersion: plan,
|
|
LicenseID: strings.TrimSpace(license.Claims.LicenseID),
|
|
LicenseEmail: strings.ToLower(strings.TrimSpace(license.Claims.Email)),
|
|
LicenseKey: licenseKey,
|
|
LeaseSigningPublicKey: strings.TrimSpace(license.Claims.EntitlementSigningPublicKey),
|
|
}, nil
|
|
}
|
|
|
|
func readProviderMSPLicenseFile(path string) (string, error) {
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return "", fmt.Errorf("read CP_PROVIDER_MSP_LICENSE_FILE: %w", err)
|
|
}
|
|
defer f.Close()
|
|
|
|
buf, err := io.ReadAll(io.LimitReader(f, maxProviderMSPLicenseFileBytes+1))
|
|
if err != nil {
|
|
return "", fmt.Errorf("read CP_PROVIDER_MSP_LICENSE_FILE: %w", err)
|
|
}
|
|
if len(buf) > maxProviderMSPLicenseFileBytes {
|
|
return "", fmt.Errorf("CP_PROVIDER_MSP_LICENSE_FILE exceeds %d bytes", maxProviderMSPLicenseFileBytes)
|
|
}
|
|
licenseKey := strings.TrimSpace(string(buf))
|
|
if licenseKey == "" {
|
|
return "", fmt.Errorf("CP_PROVIDER_MSP_LICENSE_FILE is empty")
|
|
}
|
|
return licenseKey, nil
|
|
}
|
|
|
|
func stripeSecretKeyMode(raw string) string {
|
|
key := strings.TrimSpace(raw)
|
|
switch {
|
|
case strings.HasPrefix(key, "sk_live_"):
|
|
return "live"
|
|
case strings.HasPrefix(key, "sk_test_"):
|
|
return "test"
|
|
default:
|
|
return "unknown"
|
|
}
|
|
}
|
|
|
|
func envOrDefault(key, fallback string) string {
|
|
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
|
|
return v
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
func envOrDefaultInt(key string, fallback int) (int, error) {
|
|
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
|
|
n, err := strconv.Atoi(v)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("%s must be a valid integer: %w", key, err)
|
|
}
|
|
return n, nil
|
|
}
|
|
return fallback, nil
|
|
}
|
|
|
|
func envOrDefaultInt64(key string, fallback int64) (int64, error) {
|
|
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
|
|
n, err := strconv.ParseInt(v, 10, 64)
|
|
if err != nil {
|
|
return fallback, fmt.Errorf("%s must be a valid integer: %w", key, err)
|
|
}
|
|
return n, nil
|
|
}
|
|
return fallback, nil
|
|
}
|
|
|
|
func envOrDefaultBytes(key string, fallback int64) (int64, error) {
|
|
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
|
|
n, err := parseByteSize(v)
|
|
if err != nil {
|
|
return fallback, fmt.Errorf("%s must be a valid byte size: %w", key, err)
|
|
}
|
|
return n, nil
|
|
}
|
|
return fallback, nil
|
|
}
|
|
|
|
func envOrDefaultDuration(key string, fallback time.Duration) (time.Duration, error) {
|
|
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
|
|
duration, err := time.ParseDuration(v)
|
|
if err != nil {
|
|
return fallback, fmt.Errorf("%s must be a valid duration: %w", key, err)
|
|
}
|
|
return duration, nil
|
|
}
|
|
return fallback, nil
|
|
}
|
|
|
|
func envOrDefaultBool(key string, fallback bool) bool {
|
|
v := strings.ToLower(strings.TrimSpace(os.Getenv(key)))
|
|
switch v {
|
|
case "1", "true", "yes", "on":
|
|
return true
|
|
case "0", "false", "no", "off":
|
|
return false
|
|
default:
|
|
return fallback
|
|
}
|
|
}
|
|
|
|
func parseByteSize(raw string) (int64, error) {
|
|
value := strings.ToUpper(strings.ReplaceAll(strings.TrimSpace(raw), " ", ""))
|
|
if value == "" {
|
|
return 0, fmt.Errorf("empty value")
|
|
}
|
|
if n, err := strconv.ParseInt(value, 10, 64); err == nil {
|
|
return n, nil
|
|
}
|
|
|
|
i := 0
|
|
for i < len(value) {
|
|
ch := value[i]
|
|
if (ch >= '0' && ch <= '9') || ch == '.' {
|
|
i++
|
|
continue
|
|
}
|
|
break
|
|
}
|
|
if i == 0 || i == len(value) {
|
|
return 0, fmt.Errorf("expected number followed by unit")
|
|
}
|
|
number, err := strconv.ParseFloat(value[:i], 64)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("parse number: %w", err)
|
|
}
|
|
if number < 0 {
|
|
return 0, fmt.Errorf("must not be negative")
|
|
}
|
|
multiplier := int64(0)
|
|
switch value[i:] {
|
|
case "B":
|
|
multiplier = 1
|
|
case "K", "KB", "KIB":
|
|
multiplier = 1024
|
|
case "M", "MB", "MIB":
|
|
multiplier = 1024 * 1024
|
|
case "G", "GB", "GIB":
|
|
multiplier = 1024 * 1024 * 1024
|
|
case "T", "TB", "TIB":
|
|
multiplier = 1024 * 1024 * 1024 * 1024
|
|
default:
|
|
return 0, fmt.Errorf("unsupported unit %q", value[i:])
|
|
}
|
|
return int64(number * float64(multiplier)), nil
|
|
}
|
|
|
|
func parseCSVEnv(key, fallback string) []string {
|
|
raw := strings.TrimSpace(os.Getenv(key))
|
|
if raw == "" {
|
|
raw = fallback
|
|
}
|
|
values := make([]string, 0)
|
|
seen := make(map[string]struct{})
|
|
for _, entry := range strings.Split(raw, ",") {
|
|
entry = strings.ToLower(strings.TrimSpace(entry))
|
|
if entry == "" {
|
|
continue
|
|
}
|
|
if _, ok := seen[entry]; ok {
|
|
continue
|
|
}
|
|
seen[entry] = struct{}{}
|
|
values = append(values, entry)
|
|
}
|
|
return values
|
|
}
|
|
|
|
func parseTrustedProxyCIDRValues(keys ...string) []string {
|
|
values := make([]string, 0)
|
|
seen := make(map[string]struct{})
|
|
for _, key := range keys {
|
|
raw := strings.TrimSpace(os.Getenv(key))
|
|
if raw == "" {
|
|
continue
|
|
}
|
|
for _, entry := range strings.Split(raw, ",") {
|
|
entry = strings.TrimSpace(entry)
|
|
if entry == "" {
|
|
continue
|
|
}
|
|
if _, ok := seen[entry]; ok {
|
|
continue
|
|
}
|
|
seen[entry] = struct{}{}
|
|
values = append(values, entry)
|
|
}
|
|
}
|
|
return values
|
|
}
|