747 lines
30 KiB
Go
747 lines
30 KiB
Go
package config
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
EnvironmentLocal = "local"
|
|
EnvironmentTest = "test"
|
|
EnvironmentDev = "dev"
|
|
EnvironmentStage = "stage"
|
|
EnvironmentProd = "prod"
|
|
)
|
|
|
|
type Config struct {
|
|
ServiceName string
|
|
Environment string
|
|
LogLevel string
|
|
HTTPAddr string
|
|
DatabaseURL string
|
|
RedisAddr string
|
|
RedisPassword string
|
|
RedisDB int
|
|
AuthIssuerURL string
|
|
AuthJWKSURL string
|
|
AuthAudience string
|
|
OTLPEndpoint string
|
|
RateLimitRPS float64
|
|
RateLimitBurst int
|
|
RateLimitUserRPS float64
|
|
RateLimitUserBurst int
|
|
RateLimitTenantRPS float64
|
|
RateLimitTenantBurst int
|
|
RateLimitDeviceRPS float64
|
|
RateLimitDeviceBurst int
|
|
ProviderStartRPS float64
|
|
ProviderStartBurst int
|
|
ProviderMaxActiveTasks int
|
|
DeviceLocalSecret string
|
|
DeviceSecretDirectory string
|
|
DeviceClockSkew time.Duration
|
|
RTCAppID string
|
|
RTCAppKey string
|
|
RTCTokenIssuer string
|
|
RTCTokenSecret string
|
|
CursorSigningSecret string
|
|
ShareTokenSecret string
|
|
RTCTokenTTL time.Duration
|
|
AllocationTTL time.Duration
|
|
HeartbeatInterval int
|
|
ProviderCallbackSecret string
|
|
ProviderCallbackSkew time.Duration
|
|
MockProviderBaseURL string
|
|
MockProviderAPIKey string
|
|
VoiceProvider string
|
|
VolcRTCBaseURL string
|
|
VolcAccessKeyID string
|
|
VolcSecretAccessKey string
|
|
VolcSessionToken string
|
|
VolcRTCRegion string
|
|
VolcRTCAPIVersion string
|
|
VolcVoiceChatTemplate string
|
|
ProviderRequestTimeout time.Duration
|
|
ProviderFailureThreshold int
|
|
ProviderCircuitCooldown time.Duration
|
|
DependencyTimeout time.Duration
|
|
ExternalProbeInterval time.Duration
|
|
ShutdownTimeout time.Duration
|
|
WorkerPollInterval time.Duration
|
|
RecoveryScanInterval time.Duration
|
|
RecoveryStartingAfter time.Duration
|
|
RecoveryStoppingAfter time.Duration
|
|
ObjectStoreEndpoint string
|
|
ObjectStorePublic string
|
|
ObjectStoreAccessKey string
|
|
ObjectStoreSecretKey string
|
|
ObjectStoreBucket string
|
|
ObjectStoreRegion string
|
|
ObjectStoreSecure bool
|
|
ExportDownloadTTL time.Duration
|
|
ExportObjectTTL time.Duration
|
|
MediaLocalDeleteGrace time.Duration
|
|
AlertWebhookURL string
|
|
AlertWebhookSecret string
|
|
AlertWebhookTimeout time.Duration
|
|
ContentGenerator string
|
|
ArkBaseURL string
|
|
ArkAPIKey string
|
|
ArkContentModel string
|
|
ArkRequestTimeout time.Duration
|
|
ContentSafetyProvider string
|
|
ContentSafetyEndpoint string
|
|
ContentSafetyAPIKey string
|
|
ContentSafetyPolicyRef string
|
|
ContentSafetyTimeout time.Duration
|
|
EmbeddingProvider string
|
|
ArkEmbeddingModel string
|
|
}
|
|
|
|
func Load(serviceName string) (Config, error) {
|
|
if strings.TrimSpace(serviceName) == "" {
|
|
return Config{}, errors.New("service name is required")
|
|
}
|
|
|
|
redisDB, err := intEnv("REDIS_DB", 0)
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
dependencyTimeout, err := durationEnv("DEPENDENCY_TIMEOUT", 2*time.Second)
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
externalProbeInterval, err := durationEnv("EXTERNAL_PROBE_INTERVAL", 30*time.Second)
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
shutdownTimeout, err := durationEnv("SHUTDOWN_TIMEOUT", 10*time.Second)
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
pollInterval, err := durationEnv("WORKER_POLL_INTERVAL", 2*time.Second)
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
recoveryScanInterval, err := durationEnv("RECOVERY_SCAN_INTERVAL", 5*time.Second)
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
recoveryStartingAfter, err := durationEnv("RECOVERY_STARTING_AFTER", 20*time.Second)
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
recoveryStoppingAfter, err := durationEnv("RECOVERY_STOPPING_AFTER", 20*time.Second)
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
deviceClockSkew, err := durationEnv("DEVICE_CLOCK_SKEW", 5*time.Minute)
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
rtcTokenTTL, err := durationEnv("RTC_TOKEN_TTL", 5*time.Minute)
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
allocationTTL, err := durationEnv("CONVERSATION_ALLOCATION_TTL", 30*time.Second)
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
providerCallbackSkew, err := durationEnv("PROVIDER_CALLBACK_CLOCK_SKEW", 5*time.Minute)
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
providerRequestTimeout, err := durationEnv("PROVIDER_REQUEST_TIMEOUT", 5*time.Second)
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
providerCircuitCooldown, err := durationEnv("PROVIDER_CIRCUIT_COOLDOWN", time.Minute)
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
exportDownloadTTL, err := durationEnv("EXPORT_DOWNLOAD_TTL", 10*time.Minute)
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
exportObjectTTL, err := durationEnv("EXPORT_OBJECT_TTL", 24*time.Hour)
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
mediaLocalDeleteGrace, err := durationEnv("MEDIA_LOCAL_DELETE_GRACE", time.Hour)
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
alertWebhookTimeout, err := durationEnv("ALERT_WEBHOOK_TIMEOUT", 5*time.Second)
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
arkRequestTimeout, err := durationEnv("ARK_REQUEST_TIMEOUT", 30*time.Second)
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
contentSafetyTimeout, err := durationEnv("CONTENT_SAFETY_TIMEOUT", 5*time.Second)
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
objectStoreSecure, err := boolEnv("OBJECT_STORE_SECURE", false)
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
environment := stringEnv("APP_ENV", EnvironmentLocal)
|
|
secretValues, err := loadSecretValues(
|
|
"DATABASE_URL", "REDIS_PASSWORD", "DEVICE_LOCAL_SECRET", "RTC_APP_KEY", "RTC_TOKEN_SIGNING_SECRET",
|
|
"CURSOR_SIGNING_SECRET", "SHARE_TOKEN_SIGNING_SECRET", "PROVIDER_CALLBACK_SECRET",
|
|
"MOCK_PROVIDER_API_KEY", "VOLC_ACCESS_KEY_ID", "VOLC_SECRET_ACCESS_KEY", "VOLC_SESSION_TOKEN",
|
|
"OBJECT_STORE_ACCESS_KEY", "OBJECT_STORE_SECRET_KEY", "ALERT_WEBHOOK_SECRET", "ARK_API_KEY", "CONTENT_SAFETY_API_KEY",
|
|
)
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
deviceLocalSecret := secretValues["DEVICE_LOCAL_SECRET"]
|
|
rtcTokenSecret := secretValues["RTC_TOKEN_SIGNING_SECRET"]
|
|
cursorSigningSecret := secretValues["CURSOR_SIGNING_SECRET"]
|
|
shareTokenSecret := secretValues["SHARE_TOKEN_SIGNING_SECRET"]
|
|
providerCallbackSecret := secretValues["PROVIDER_CALLBACK_SECRET"]
|
|
objectStoreEndpoint := strings.TrimSpace(os.Getenv("OBJECT_STORE_ENDPOINT"))
|
|
objectStorePublic := strings.TrimSpace(os.Getenv("OBJECT_STORE_PUBLIC_ENDPOINT"))
|
|
objectStoreAccessKey := secretValues["OBJECT_STORE_ACCESS_KEY"]
|
|
objectStoreSecretKey := secretValues["OBJECT_STORE_SECRET_KEY"]
|
|
objectStoreBucket := strings.TrimSpace(os.Getenv("OBJECT_STORE_EXPORT_BUCKET"))
|
|
objectStoreRegion := strings.TrimSpace(os.Getenv("OBJECT_STORE_REGION"))
|
|
if deviceLocalSecret == "" && serviceName == "api" && (environment == EnvironmentLocal || environment == EnvironmentTest) {
|
|
deviceLocalSecret = "local-simulated-device-secret-32-bytes-minimum"
|
|
}
|
|
if rtcTokenSecret == "" && serviceName == "api" && (environment == EnvironmentLocal || environment == EnvironmentTest) {
|
|
rtcTokenSecret = "local-simulated-rtc-token-secret-32-bytes-minimum"
|
|
}
|
|
if cursorSigningSecret == "" && serviceName == "api" && (environment == EnvironmentLocal || environment == EnvironmentTest) {
|
|
cursorSigningSecret = "local-signed-pagination-cursor-secret-32-bytes-minimum"
|
|
}
|
|
if shareTokenSecret == "" && serviceName == "api" && (environment == EnvironmentLocal || environment == EnvironmentTest) {
|
|
shareTokenSecret = "local-share-token-signing-secret-32-bytes-minimum"
|
|
}
|
|
if providerCallbackSecret == "" && serviceName == "callbacks" && (environment == EnvironmentLocal || environment == EnvironmentTest) {
|
|
providerCallbackSecret = "local-provider-callback-secret-32-bytes-minimum"
|
|
}
|
|
if (serviceName == "api" || serviceName == "worker") && (environment == EnvironmentLocal || environment == EnvironmentTest) {
|
|
if objectStoreEndpoint == "" {
|
|
objectStoreEndpoint = "localhost:59000"
|
|
}
|
|
if objectStorePublic == "" {
|
|
objectStorePublic = "localhost:59000"
|
|
}
|
|
if objectStoreAccessKey == "" {
|
|
objectStoreAccessKey = "local_minio"
|
|
}
|
|
if objectStoreSecretKey == "" {
|
|
objectStoreSecretKey = "local_minio_password"
|
|
}
|
|
if objectStoreBucket == "" {
|
|
objectStoreBucket = "private-exports"
|
|
}
|
|
if objectStoreRegion == "" {
|
|
objectStoreRegion = "us-east-1"
|
|
}
|
|
}
|
|
|
|
cfg := Config{
|
|
ServiceName: serviceName,
|
|
Environment: environment,
|
|
LogLevel: stringEnv("LOG_LEVEL", "info"),
|
|
HTTPAddr: stringEnv("HTTP_ADDR", defaultHTTPAddr(serviceName)),
|
|
DatabaseURL: secretDefault(secretValues["DATABASE_URL"], "postgres://voice:voice_dev_only@localhost:55432/voice_platform?sslmode=disable"),
|
|
RedisAddr: stringEnv("REDIS_ADDR", "localhost:56379"),
|
|
RedisPassword: secretValues["REDIS_PASSWORD"],
|
|
RedisDB: redisDB,
|
|
AuthIssuerURL: stringEnv("AUTH_ISSUER_URL", "http://localhost:58180/realms/voice-local"),
|
|
AuthJWKSURL: stringEnv("AUTH_JWKS_URL", "http://localhost:58180/realms/voice-local/protocol/openid-connect/certs"),
|
|
AuthAudience: stringEnv("AUTH_AUDIENCE", "voice-platform-web"),
|
|
OTLPEndpoint: stringEnv("OTEL_EXPORTER_OTLP_ENDPOINT", "localhost:54317"),
|
|
DependencyTimeout: dependencyTimeout,
|
|
ExternalProbeInterval: externalProbeInterval,
|
|
ShutdownTimeout: shutdownTimeout,
|
|
WorkerPollInterval: pollInterval,
|
|
RecoveryScanInterval: recoveryScanInterval,
|
|
RecoveryStartingAfter: recoveryStartingAfter,
|
|
RecoveryStoppingAfter: recoveryStoppingAfter,
|
|
DeviceLocalSecret: deviceLocalSecret,
|
|
DeviceSecretDirectory: strings.TrimSpace(os.Getenv("DEVICE_SECRET_DIRECTORY")),
|
|
DeviceClockSkew: deviceClockSkew,
|
|
RTCAppID: stringEnv("RTC_APP_ID", "local-public-rtc-app"),
|
|
RTCAppKey: secretValues["RTC_APP_KEY"],
|
|
RTCTokenIssuer: stringEnv("RTC_TOKEN_ISSUER", "local"),
|
|
RTCTokenSecret: rtcTokenSecret,
|
|
CursorSigningSecret: cursorSigningSecret,
|
|
ShareTokenSecret: shareTokenSecret,
|
|
RTCTokenTTL: rtcTokenTTL,
|
|
AllocationTTL: allocationTTL,
|
|
ProviderCallbackSecret: providerCallbackSecret,
|
|
ProviderCallbackSkew: providerCallbackSkew,
|
|
MockProviderBaseURL: stringEnv("MOCK_PROVIDER_BASE_URL", "http://localhost:59200"),
|
|
MockProviderAPIKey: secretDefault(secretValues["MOCK_PROVIDER_API_KEY"], "local-mock-provider-key"),
|
|
VoiceProvider: stringEnv("VOICE_PROVIDER", "mock"),
|
|
VolcRTCBaseURL: stringEnv("VOLC_RTC_BASE_URL", "https://rtc.volcengineapi.com"),
|
|
VolcAccessKeyID: secretValues["VOLC_ACCESS_KEY_ID"],
|
|
VolcSecretAccessKey: secretValues["VOLC_SECRET_ACCESS_KEY"],
|
|
VolcSessionToken: secretValues["VOLC_SESSION_TOKEN"],
|
|
VolcRTCRegion: stringEnv("VOLC_RTC_REGION", "cn-north-1"),
|
|
VolcRTCAPIVersion: stringEnv("VOLC_RTC_API_VERSION", "2024-12-01"),
|
|
VolcVoiceChatTemplate: strings.TrimSpace(os.Getenv("VOLC_VOICE_CHAT_TEMPLATE_FILE")),
|
|
ProviderRequestTimeout: providerRequestTimeout,
|
|
ProviderCircuitCooldown: providerCircuitCooldown,
|
|
ObjectStoreEndpoint: objectStoreEndpoint,
|
|
ObjectStorePublic: objectStorePublic,
|
|
ObjectStoreAccessKey: objectStoreAccessKey,
|
|
ObjectStoreSecretKey: objectStoreSecretKey,
|
|
ObjectStoreBucket: objectStoreBucket,
|
|
ObjectStoreRegion: objectStoreRegion,
|
|
ObjectStoreSecure: objectStoreSecure,
|
|
ExportDownloadTTL: exportDownloadTTL,
|
|
ExportObjectTTL: exportObjectTTL,
|
|
MediaLocalDeleteGrace: mediaLocalDeleteGrace,
|
|
AlertWebhookURL: strings.TrimSpace(os.Getenv("ALERT_WEBHOOK_URL")),
|
|
AlertWebhookSecret: secretValues["ALERT_WEBHOOK_SECRET"],
|
|
AlertWebhookTimeout: alertWebhookTimeout,
|
|
ContentGenerator: stringEnv("CONTENT_GENERATOR", "local"),
|
|
ArkBaseURL: stringEnv("ARK_BASE_URL", "https://ark.cn-beijing.volces.com/api/v3"),
|
|
ArkAPIKey: secretValues["ARK_API_KEY"],
|
|
ArkContentModel: strings.TrimSpace(os.Getenv("ARK_CONTENT_MODEL")),
|
|
ArkRequestTimeout: arkRequestTimeout,
|
|
ContentSafetyProvider: stringEnv("CONTENT_SAFETY_PROVIDER", "local"),
|
|
ContentSafetyEndpoint: strings.TrimSpace(os.Getenv("CONTENT_SAFETY_ENDPOINT")),
|
|
ContentSafetyAPIKey: secretValues["CONTENT_SAFETY_API_KEY"],
|
|
ContentSafetyPolicyRef: strings.TrimSpace(os.Getenv("CONTENT_SAFETY_POLICY_REF")),
|
|
ContentSafetyTimeout: contentSafetyTimeout,
|
|
EmbeddingProvider: stringEnv("EMBEDDING_PROVIDER", "local"),
|
|
ArkEmbeddingModel: strings.TrimSpace(os.Getenv("ARK_EMBEDDING_MODEL")),
|
|
}
|
|
cfg.RateLimitRPS, err = floatEnv("RATE_LIMIT_RPS", 50)
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
cfg.RateLimitBurst, err = intEnv("RATE_LIMIT_BURST", 100)
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
cfg.RateLimitUserRPS, err = floatEnv("RATE_LIMIT_USER_RPS", 20)
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
cfg.RateLimitUserBurst, err = intEnv("RATE_LIMIT_USER_BURST", 40)
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
cfg.RateLimitTenantRPS, err = floatEnv("RATE_LIMIT_TENANT_RPS", 200)
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
cfg.RateLimitTenantBurst, err = intEnv("RATE_LIMIT_TENANT_BURST", 400)
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
cfg.RateLimitDeviceRPS, err = floatEnv("RATE_LIMIT_DEVICE_RPS", 10)
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
cfg.RateLimitDeviceBurst, err = intEnv("RATE_LIMIT_DEVICE_BURST", 20)
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
cfg.ProviderStartRPS, err = floatEnv("PROVIDER_START_RPS", 20)
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
cfg.ProviderStartBurst, err = intEnv("PROVIDER_START_BURST", 40)
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
cfg.ProviderMaxActiveTasks, err = intEnv("PROVIDER_MAX_ACTIVE_TASKS", 100)
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
cfg.HeartbeatInterval, err = intEnv("HEARTBEAT_INTERVAL_SECONDS", 15)
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
cfg.ProviderFailureThreshold, err = intEnv("PROVIDER_FAILURE_THRESHOLD", 3)
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
|
|
if err := cfg.Validate(); err != nil {
|
|
return Config{}, err
|
|
}
|
|
return cfg, nil
|
|
}
|
|
|
|
func (c Config) Validate() error {
|
|
validEnvironment := map[string]bool{
|
|
EnvironmentLocal: true,
|
|
EnvironmentTest: true,
|
|
EnvironmentDev: true,
|
|
EnvironmentStage: true,
|
|
EnvironmentProd: true,
|
|
}
|
|
if !validEnvironment[c.Environment] {
|
|
return fmt.Errorf("APP_ENV %q is not supported", c.Environment)
|
|
}
|
|
if strings.TrimSpace(c.HTTPAddr) == "" {
|
|
return errors.New("HTTP_ADDR is required")
|
|
}
|
|
if strings.TrimSpace(c.DatabaseURL) == "" {
|
|
return errors.New("DATABASE_URL is required")
|
|
}
|
|
if strings.TrimSpace(c.RedisAddr) == "" {
|
|
return errors.New("REDIS_ADDR is required")
|
|
}
|
|
if strings.TrimSpace(c.AuthIssuerURL) == "" || strings.TrimSpace(c.AuthJWKSURL) == "" || strings.TrimSpace(c.AuthAudience) == "" {
|
|
return errors.New("AUTH_ISSUER_URL, AUTH_JWKS_URL and AUTH_AUDIENCE are required")
|
|
}
|
|
if c.DependencyTimeout <= 0 || c.ExternalProbeInterval <= 0 || c.ShutdownTimeout <= 0 || c.WorkerPollInterval <= 0 ||
|
|
c.RecoveryScanInterval <= 0 || c.RecoveryStartingAfter <= 0 || c.RecoveryStoppingAfter <= 0 {
|
|
return errors.New("timeouts and worker poll interval must be positive")
|
|
}
|
|
if c.RateLimitRPS <= 0 || c.RateLimitBurst <= 0 ||
|
|
c.RateLimitUserRPS <= 0 || c.RateLimitUserBurst <= 0 ||
|
|
c.RateLimitTenantRPS <= 0 || c.RateLimitTenantBurst <= 0 ||
|
|
c.RateLimitDeviceRPS <= 0 || c.RateLimitDeviceBurst <= 0 ||
|
|
c.ProviderStartRPS <= 0 || c.ProviderStartBurst <= 0 || c.ProviderMaxActiveTasks <= 0 {
|
|
return errors.New("rate limits, bursts and provider task capacity must be positive")
|
|
}
|
|
if c.ServiceName == "api" && c.DeviceSecretDirectory == "" && len(c.DeviceLocalSecret) < 32 {
|
|
return errors.New("DEVICE_LOCAL_SECRET must contain at least 32 characters when DEVICE_SECRET_DIRECTORY is not configured")
|
|
}
|
|
if c.ServiceName == "api" && c.DeviceSecretDirectory != "" && !filepath.IsAbs(c.DeviceSecretDirectory) {
|
|
return errors.New("DEVICE_SECRET_DIRECTORY must be an absolute path")
|
|
}
|
|
if c.DeviceClockSkew <= 0 {
|
|
return errors.New("DEVICE_CLOCK_SKEW must be positive")
|
|
}
|
|
if c.ServiceName == "api" {
|
|
if c.RTCTokenIssuer != "local" && c.RTCTokenIssuer != "volcengine" {
|
|
return errors.New("RTC_TOKEN_ISSUER must be local or volcengine")
|
|
}
|
|
if c.RTCTokenIssuer == "local" && len(c.RTCTokenSecret) < 32 {
|
|
return errors.New("RTC_TOKEN_SIGNING_SECRET must contain at least 32 characters for the local issuer")
|
|
}
|
|
if c.RTCTokenIssuer == "volcengine" && len(c.RTCAppKey) < 16 {
|
|
return errors.New("RTC_APP_KEY must contain at least 16 characters for the Volcengine token issuer")
|
|
}
|
|
if c.RTCTokenIssuer == "volcengine" && len(c.RTCAppID) != 24 {
|
|
return errors.New("RTC_APP_ID must contain exactly 24 characters for the Volcengine token issuer")
|
|
}
|
|
}
|
|
if c.ServiceName == "api" && len(c.CursorSigningSecret) < 32 {
|
|
return errors.New("CURSOR_SIGNING_SECRET must contain at least 32 characters")
|
|
}
|
|
if c.ServiceName == "api" && len(c.ShareTokenSecret) < 32 {
|
|
return errors.New("SHARE_TOKEN_SIGNING_SECRET must contain at least 32 characters")
|
|
}
|
|
if c.RTCAppID == "" || c.RTCTokenTTL <= 0 || c.AllocationTTL <= 0 || c.HeartbeatInterval <= 0 {
|
|
return errors.New("RTC app, token TTL, allocation TTL and heartbeat interval must be valid")
|
|
}
|
|
if c.ServiceName == "callbacks" && len(c.ProviderCallbackSecret) < 32 {
|
|
return errors.New("PROVIDER_CALLBACK_SECRET must contain at least 32 characters")
|
|
}
|
|
if c.ServiceName == "worker" {
|
|
if c.VoiceProvider != "mock" && c.VoiceProvider != "volcengine" {
|
|
return errors.New("VOICE_PROVIDER must be mock or volcengine")
|
|
}
|
|
if c.VoiceProvider == "mock" && (c.MockProviderBaseURL == "" || c.MockProviderAPIKey == "") {
|
|
return errors.New("MOCK_PROVIDER_BASE_URL and MOCK_PROVIDER_API_KEY are required for the mock voice provider")
|
|
}
|
|
if c.VoiceProvider == "volcengine" {
|
|
if !isAbsoluteHTTPURL(c.VolcRTCBaseURL) || c.VolcAccessKeyID == "" || c.VolcSecretAccessKey == "" || c.VolcRTCRegion == "" || c.VolcRTCAPIVersion == "" || c.VolcVoiceChatTemplate == "" {
|
|
return errors.New("Volcengine voice provider requires an RTC URL, AK/SK, region, API version and voice-chat template file")
|
|
}
|
|
}
|
|
}
|
|
if c.ProviderCallbackSkew <= 0 || c.ProviderRequestTimeout <= 0 || c.ProviderFailureThreshold < 1 || c.ProviderCircuitCooldown <= 0 {
|
|
return errors.New("provider callback skew and request timeout must be positive")
|
|
}
|
|
if c.AlertWebhookTimeout <= 0 {
|
|
return errors.New("ALERT_WEBHOOK_TIMEOUT must be positive")
|
|
}
|
|
if c.ArkRequestTimeout <= 0 {
|
|
return errors.New("ARK_REQUEST_TIMEOUT must be positive")
|
|
}
|
|
if c.ContentSafetyTimeout <= 0 {
|
|
return errors.New("CONTENT_SAFETY_TIMEOUT must be positive")
|
|
}
|
|
if c.ServiceName == "worker" && c.AlertWebhookURL != "" {
|
|
parsed, err := url.Parse(c.AlertWebhookURL)
|
|
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
|
|
return errors.New("ALERT_WEBHOOK_URL must be an absolute HTTP(S) URL")
|
|
}
|
|
if len(c.AlertWebhookSecret) < 32 {
|
|
return errors.New("ALERT_WEBHOOK_SECRET must contain at least 32 characters when webhook delivery is enabled")
|
|
}
|
|
}
|
|
if c.ServiceName == "worker" {
|
|
if c.ContentSafetyProvider != "local" && c.ContentSafetyProvider != "managed_http" {
|
|
return errors.New("CONTENT_SAFETY_PROVIDER must be local or managed_http")
|
|
}
|
|
if c.ContentSafetyProvider == "managed_http" {
|
|
if !isAbsoluteHTTPURL(c.ContentSafetyEndpoint) || c.ContentSafetyAPIKey == "" || c.ContentSafetyPolicyRef == "" {
|
|
return errors.New("managed content safety requires an HTTP(S) endpoint, API key and policy reference")
|
|
}
|
|
}
|
|
if c.ContentGenerator != "local" && c.ContentGenerator != "ark" {
|
|
return errors.New("CONTENT_GENERATOR must be local or ark")
|
|
}
|
|
if c.ContentGenerator == "ark" {
|
|
parsed, err := url.Parse(c.ArkBaseURL)
|
|
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
|
|
return errors.New("ARK_BASE_URL must be an absolute HTTP(S) URL")
|
|
}
|
|
if c.ArkAPIKey == "" || c.ArkContentModel == "" {
|
|
return errors.New("ARK_API_KEY and ARK_CONTENT_MODEL are required for the Ark content generator")
|
|
}
|
|
}
|
|
}
|
|
if c.ServiceName == "api" || c.ServiceName == "worker" {
|
|
if c.EmbeddingProvider != "local" && c.EmbeddingProvider != "ark" {
|
|
return errors.New("EMBEDDING_PROVIDER must be local or ark")
|
|
}
|
|
if c.EmbeddingProvider == "ark" {
|
|
parsed, err := url.Parse(c.ArkBaseURL)
|
|
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
|
|
return errors.New("ARK_BASE_URL must be an absolute HTTP(S) URL")
|
|
}
|
|
if c.ArkAPIKey == "" || c.ArkEmbeddingModel == "" {
|
|
return errors.New("ARK_API_KEY and ARK_EMBEDDING_MODEL are required for the Ark embedding provider")
|
|
}
|
|
}
|
|
}
|
|
if c.ServiceName == "api" || c.ServiceName == "worker" {
|
|
if c.ObjectStoreEndpoint == "" || c.ObjectStorePublic == "" || c.ObjectStoreAccessKey == "" || c.ObjectStoreSecretKey == "" || c.ObjectStoreBucket == "" || c.ObjectStoreRegion == "" {
|
|
return errors.New("object store endpoint, public endpoint, credentials, region and export bucket are required")
|
|
}
|
|
if c.ExportDownloadTTL <= 0 || c.ExportDownloadTTL > 24*time.Hour || c.ExportObjectTTL <= 0 {
|
|
return errors.New("export download and object TTL must be positive, and download TTL cannot exceed 24 hours")
|
|
}
|
|
if c.MediaLocalDeleteGrace <= 0 || c.MediaLocalDeleteGrace > 7*24*time.Hour {
|
|
return errors.New("media local delete grace must be positive and cannot exceed 7 days")
|
|
}
|
|
}
|
|
if c.Environment == EnvironmentProd {
|
|
if err := c.validateProduction(); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c Config) validateProduction() error {
|
|
if !isHTTPSURL(c.AuthIssuerURL) || !isHTTPSURL(c.AuthJWKSURL) {
|
|
return errors.New("production OIDC issuer and JWKS URLs must use HTTPS")
|
|
}
|
|
databaseURL, err := url.Parse(c.DatabaseURL)
|
|
if err != nil || databaseURL.Scheme == "" || strings.EqualFold(databaseURL.Query().Get("sslmode"), "disable") {
|
|
return errors.New("production DATABASE_URL must enable transport security")
|
|
}
|
|
if strings.TrimSpace(c.RedisPassword) == "" || isPlaceholderSecret(c.RedisPassword) {
|
|
return errors.New("production REDIS_PASSWORD must be a non-placeholder secret")
|
|
}
|
|
|
|
if c.ServiceName == "api" {
|
|
if c.DeviceSecretDirectory == "" {
|
|
return errors.New("production API must use DEVICE_SECRET_DIRECTORY for per-device credentials")
|
|
}
|
|
if isPlaceholderSecret(c.CursorSigningSecret) || isPlaceholderSecret(c.ShareTokenSecret) {
|
|
return errors.New("production API signing secrets must not use local or example values")
|
|
}
|
|
if c.CursorSigningSecret == c.ShareTokenSecret {
|
|
return errors.New("production API signing secrets must be distinct")
|
|
}
|
|
if isLocalValue(c.RTCAppID) || c.RTCTokenIssuer != "volcengine" || isPlaceholderSecret(c.RTCAppKey) {
|
|
return errors.New("production RTC credentials must use a real app ID, Volcengine token issuer and non-placeholder app key")
|
|
}
|
|
if c.EmbeddingProvider != "ark" || !isHTTPSURL(c.ArkBaseURL) || isPlaceholderSecret(c.ArkAPIKey) || isLocalValue(c.ArkEmbeddingModel) {
|
|
return errors.New("production memory retrieval must use Ark HTTPS, a non-placeholder API key and a real embedding model ID")
|
|
}
|
|
}
|
|
if c.ServiceName == "callbacks" && isPlaceholderSecret(c.ProviderCallbackSecret) {
|
|
return errors.New("production PROVIDER_CALLBACK_SECRET must not use a local or example value")
|
|
}
|
|
if c.ServiceName == "worker" {
|
|
if c.VoiceProvider != "volcengine" || len(c.RTCAppID) != 24 || isLocalValue(c.RTCAppID) || !isHTTPSURL(c.VolcRTCBaseURL) || isLocalValue(c.VolcRTCBaseURL) || isPlaceholderSecret(c.VolcAccessKeyID) || isPlaceholderSecret(c.VolcSecretAccessKey) {
|
|
return errors.New("production voice provider must use signed Volcengine RTC HTTPS with non-placeholder AK/SK")
|
|
}
|
|
if c.AlertWebhookURL != "" && (!isHTTPSURL(c.AlertWebhookURL) || isPlaceholderSecret(c.AlertWebhookSecret)) {
|
|
return errors.New("production alert webhook must use HTTPS and a non-placeholder signing secret")
|
|
}
|
|
if c.ContentGenerator != "ark" || !isHTTPSURL(c.ArkBaseURL) || isPlaceholderSecret(c.ArkAPIKey) || isLocalValue(c.ArkContentModel) {
|
|
return errors.New("production content generation must use Ark HTTPS, a non-placeholder API key and a real model ID")
|
|
}
|
|
if c.ContentSafetyProvider != "managed_http" || !isHTTPSURL(c.ContentSafetyEndpoint) || isLocalValue(c.ContentSafetyEndpoint) || isPlaceholderSecret(c.ContentSafetyAPIKey) || isLocalValue(c.ContentSafetyPolicyRef) {
|
|
return errors.New("production content safety must use managed HTTPS with a non-placeholder API key and policy reference")
|
|
}
|
|
if c.EmbeddingProvider != "ark" || isLocalValue(c.ArkEmbeddingModel) {
|
|
return errors.New("production memory indexing must use a real Ark embedding model ID")
|
|
}
|
|
}
|
|
if c.ServiceName == "api" || c.ServiceName == "worker" {
|
|
if !c.ObjectStoreSecure {
|
|
return errors.New("production object storage must use TLS")
|
|
}
|
|
if isLocalValue(c.ObjectStoreEndpoint) || isLocalValue(c.ObjectStorePublic) {
|
|
return errors.New("production object storage endpoints must not target localhost")
|
|
}
|
|
if isPlaceholderSecret(c.ObjectStoreAccessKey) || isPlaceholderSecret(c.ObjectStoreSecretKey) {
|
|
return errors.New("production object storage credentials must not use local or example values")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func isHTTPSURL(value string) bool {
|
|
parsed, err := url.Parse(strings.TrimSpace(value))
|
|
return err == nil && strings.EqualFold(parsed.Scheme, "https") && parsed.Host != ""
|
|
}
|
|
|
|
func isAbsoluteHTTPURL(value string) bool {
|
|
parsed, err := url.Parse(strings.TrimSpace(value))
|
|
return err == nil && (strings.EqualFold(parsed.Scheme, "http") || strings.EqualFold(parsed.Scheme, "https")) && parsed.Host != ""
|
|
}
|
|
|
|
func isLocalValue(value string) bool {
|
|
normalized := strings.ToLower(strings.TrimSpace(value))
|
|
allZeroes := normalized != "" && strings.Trim(normalized, "0") == ""
|
|
return strings.Contains(normalized, "localhost") || strings.Contains(normalized, "127.0.0.1") ||
|
|
strings.Contains(normalized, "mock-provider") || strings.Contains(normalized, "example.invalid") ||
|
|
strings.Contains(normalized, "replace-with") || strings.HasPrefix(normalized, "local-") ||
|
|
strings.HasPrefix(normalized, "local_") || allZeroes
|
|
}
|
|
|
|
func isPlaceholderSecret(value string) bool {
|
|
normalized := strings.ToLower(strings.TrimSpace(value))
|
|
return len(normalized) < 32 || strings.Contains(normalized, "local-") || strings.Contains(normalized, "local_") || strings.Contains(normalized, "dev_only") || strings.Contains(normalized, "changeme") || strings.Contains(normalized, "example")
|
|
}
|
|
|
|
func loadSecretValues(names ...string) (map[string]string, error) {
|
|
values := make(map[string]string, len(names))
|
|
for _, name := range names {
|
|
value, err := secretEnv(name)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
values[name] = value
|
|
}
|
|
return values, nil
|
|
}
|
|
|
|
func secretEnv(name string) (string, error) {
|
|
inline := strings.TrimSpace(os.Getenv(name))
|
|
filePath := strings.TrimSpace(os.Getenv(name + "_FILE"))
|
|
if inline != "" && filePath != "" {
|
|
return "", fmt.Errorf("%s and %s_FILE cannot both be configured", name, name)
|
|
}
|
|
if filePath == "" {
|
|
return inline, nil
|
|
}
|
|
if !filepath.IsAbs(filePath) {
|
|
return "", fmt.Errorf("%s_FILE must be an absolute path", name)
|
|
}
|
|
file, err := os.Open(filepath.Clean(filePath))
|
|
if err != nil {
|
|
return "", fmt.Errorf("read %s_FILE: secret file is unavailable", name)
|
|
}
|
|
defer file.Close()
|
|
info, err := file.Stat()
|
|
if err != nil || !info.Mode().IsRegular() {
|
|
return "", fmt.Errorf("read %s_FILE: secret path must be a regular file", name)
|
|
}
|
|
content, err := io.ReadAll(io.LimitReader(file, 64*1024+1))
|
|
if err != nil || len(content) > 64*1024 {
|
|
return "", fmt.Errorf("read %s_FILE: secret file exceeds 64 KiB", name)
|
|
}
|
|
content = bytes.TrimRight(content, "\r\n")
|
|
if bytes.IndexByte(content, 0) >= 0 {
|
|
return "", fmt.Errorf("read %s_FILE: secret contains a NUL byte", name)
|
|
}
|
|
return string(content), nil
|
|
}
|
|
|
|
func secretDefault(value, fallback string) string {
|
|
if value == "" {
|
|
return fallback
|
|
}
|
|
return value
|
|
}
|
|
|
|
func defaultHTTPAddr(serviceName string) string {
|
|
switch serviceName {
|
|
case "callbacks":
|
|
return "0.0.0.0:58081"
|
|
case "worker":
|
|
return "0.0.0.0:58082"
|
|
default:
|
|
return "0.0.0.0:58080"
|
|
}
|
|
}
|
|
|
|
func stringEnv(key, fallback string) string {
|
|
value := strings.TrimSpace(os.Getenv(key))
|
|
if value == "" {
|
|
return fallback
|
|
}
|
|
return value
|
|
}
|
|
|
|
func intEnv(key string, fallback int) (int, error) {
|
|
value := strings.TrimSpace(os.Getenv(key))
|
|
if value == "" {
|
|
return fallback, nil
|
|
}
|
|
parsed, err := strconv.Atoi(value)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("%s must be an integer: %w", key, err)
|
|
}
|
|
return parsed, nil
|
|
}
|
|
|
|
func floatEnv(key string, fallback float64) (float64, error) {
|
|
value := strings.TrimSpace(os.Getenv(key))
|
|
if value == "" {
|
|
return fallback, nil
|
|
}
|
|
parsed, err := strconv.ParseFloat(value, 64)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("%s must be a number: %w", key, err)
|
|
}
|
|
return parsed, nil
|
|
}
|
|
|
|
func boolEnv(key string, fallback bool) (bool, error) {
|
|
value := strings.TrimSpace(os.Getenv(key))
|
|
if value == "" {
|
|
return fallback, nil
|
|
}
|
|
parsed, err := strconv.ParseBool(value)
|
|
if err != nil {
|
|
return false, fmt.Errorf("%s must be a boolean: %w", key, err)
|
|
}
|
|
return parsed, nil
|
|
}
|
|
|
|
func durationEnv(key string, fallback time.Duration) (time.Duration, error) {
|
|
value := strings.TrimSpace(os.Getenv(key))
|
|
if value == "" {
|
|
return fallback, nil
|
|
}
|
|
parsed, err := time.ParseDuration(value)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("%s must be a duration: %w", key, err)
|
|
}
|
|
return parsed, nil
|
|
}
|