Pulse/internal/cloudcp/config.go

369 lines
13 KiB
Go

package cloudcp
import (
"crypto/ed25519"
"encoding/base64"
"fmt"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/joho/godotenv"
pkglicensing "github.com/rcourtman/pulse-go-rewrite/pkg/licensing"
)
// CPConfig holds all configuration for the control plane.
type CPConfig struct {
DataDir string
Environment string
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
AllowDockerlessProvisioning bool
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)
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")
}
// 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")
}
// 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()
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
}
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
}
cfg := &CPConfig{
DataDir: envOrDefault("CP_DATA_DIR", "/data"),
Environment: normalizeCPEnvironment(envOrDefault("CP_ENV", "production")),
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,
AllowDockerlessProvisioning: envOrDefaultBool("CP_ALLOW_DOCKERLESS_PROVISIONING", false),
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")),
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"),
}
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)
}
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 == "" {
missing = append(missing, "CP_ADMIN_KEY")
}
if c.BaseURL == "" {
missing = append(missing, "CP_BASE_URL")
}
if 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 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 c.Environment == "production" && c.AllowDockerlessProvisioning {
return fmt.Errorf("CP_ALLOW_DOCKERLESS_PROVISIONING must be false in production")
}
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.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("CP_TRIAL_SIGNUP_PRICE_ID", c.TrialSignupPriceID, "cloud_starter"); err != nil {
return err
}
if err := validateCloudStripePriceID("CP_CLOUD_POWER_PRICE_ID", c.CloudPowerPriceID, "cloud_power"); err != nil {
return err
}
if err := validateCloudStripePriceID("CP_CLOUD_MAX_PRICE_ID", c.CloudMaxPriceID, "cloud_max"); 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(envName, priceID, wantPlanVersion string) error {
trimmed := strings.TrimSpace(priceID)
if trimmed == "" {
return nil
}
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 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 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 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
}