mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-21 02:33:32 +00:00
6be2af1c19
Two mandatory round-trips stood between an interested MSP and their first screen, and neither was technical. setup.sh required four image digests shipped as literal <pin> placeholders, so the only way to get them was to ask. All four images are publicly readable, so there was never anything to hand out. setup.sh now resolves each blank pin to an immutable digest from its published tag via buildx imagetools and writes it back to .env; hand-set values are left alone. setup.sh then died outright without a licence file, so nobody could start the stack, create a workspace, or see the portal until a human minted a licence for them. The control plane already ran unlicensed via ProviderMSPPlanSourceEnvFallback; only the installer refused. A licence path that is set but missing is still a hard failure, since that is a misconfiguration rather than a choice. Unlicensed now means evaluation rather than the cheapest paid tier. The env fallback defaulted to msp_starter, handing every unlicensed deployment the full 5-client Starter allowance and leaving no boundary between evaluating and buying. Adds msp_eval at 2 workspaces: same capabilities, smaller cap, not purchasable, not on the public ladder. An isolation guarantee is the one claim a provider cannot evaluate from a screenshot, and both MSP leads this year went quiet at exactly this step. Contracts: cloud-paid records the unlicensed plan rule and the strictly-below-paid invariant; deployment-installability records credential-free, correspondence-free installability. Verification: TestMSPEvalCapStaysBelowCheapestPaidTier, TestCanonicalizePlanVersion_MSPEval, TestProviderMSPSetupScriptSupportsUnlicensedEvaluation. The last was negative-tested by reintroducing a <pin> placeholder and confirming it fails. ensure_image_pins exercised against the live registries. licensing, cloudcp, control-plane and installtests all green.
854 lines
34 KiB
Go
854 lines
34 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"
|
|
// No licence file means evaluation, not the cheapest paid tier. This used
|
|
// to default to msp_starter, which meant an unlicensed control plane ran
|
|
// on the full 5-client Starter allowance and there was nothing left to
|
|
// buy. See pkglicensing.PlanVersionMSPEval.
|
|
defaultProviderHostedMSPPlanVersion = pkglicensing.PlanVersionMSPEval
|
|
ProviderMSPPlanSourceLicenseFile = "license_file"
|
|
ProviderMSPPlanSourceEnvFallback = "environment_fallback"
|
|
maxProviderMSPLicenseFileBytes = 64 * 1024
|
|
|
|
// cpauthDefaultSessionTTL mirrors cpauth.SessionTTL for Pulse-hosted
|
|
// control planes; providerHostedSessionTTL is the default for
|
|
// provider-operated MSP portals (see CP_SESSION_TTL).
|
|
cpauthDefaultSessionTTL = 12 * time.Hour
|
|
providerHostedSessionTTL = 7 * 24 * time.Hour
|
|
)
|
|
|
|
// 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
|
|
SessionTTL time.Duration // Portal session lifetime (CP_SESSION_TTL)
|
|
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
|
|
}
|
|
defaultSessionTTL := cpauthDefaultSessionTTL
|
|
if controlPlaneMode == ControlPlaneModeProviderHostedMSP {
|
|
// Provider-hosted portals are operated by the provider themselves and
|
|
// often run without an email provider, so re-login means minting a
|
|
// link on the host. A longer session keeps day-2 use painless.
|
|
defaultSessionTTL = providerHostedSessionTTL
|
|
}
|
|
sessionTTL, err := envOrDefaultDuration("CP_SESSION_TTL", defaultSessionTTL)
|
|
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")),
|
|
// Canonical name first; CP_TRIAL_ACTIVATION_PRIVATE_KEY is the
|
|
// legacy spelling from the retired trial-activation era, kept as a
|
|
// fallback for already-deployed control planes.
|
|
TrialActivationPrivateKey: envFirstNonEmpty("CP_ENTITLEMENT_SIGNING_PRIVATE_KEY", "CP_TRIAL_ACTIVATION_PRIVATE_KEY"),
|
|
SessionTTL: sessionTTL,
|
|
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_ENTITLEMENT_SIGNING_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_ENTITLEMENT_SIGNING_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_ENTITLEMENT_SIGNING_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.SessionTTL <= 0 {
|
|
return fmt.Errorf("CP_SESSION_TTL 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_ENTITLEMENT_SIGNING_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"
|
|
}
|
|
}
|
|
|
|
// envFirstNonEmpty returns the first environment variable in keys with a
|
|
// non-empty trimmed value.
|
|
func envFirstNonEmpty(keys ...string) string {
|
|
for _, key := range keys {
|
|
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
|
|
return v
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
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
|
|
}
|