package config import ( "errors" "fmt" "os" "strconv" "strings" "time" ) type Config struct { Mode string DBPath string SpoolRoot string RabbitURL string Exchange string OutboxBatch int AgentID string DispatcherID string Version string ControlListen string ControlToken string CellID string GRPCListen string DispatcherGRPCListen string DispatcherGRPCEndpoint string DispatcherGRPCServerName string DispatcherGRPCAllowedAgentIDs string MTLSCAFile string MTLSCertFile string MTLSKeyFile string MTLSServerName string MTLSPeerCertificateFingerprints string OSSRegion string OSSEndpoint string OSSBucket string OSSAccessKeyID string OSSAccessKeySecret string OSSKeyPrefix string OSSGrantTTL time.Duration OSSMaxAssetBytes int64 StaticArtifactPath string AgentEndpointsFile string CallLogPath string CallLogPhoneKey string ARIURL string ARIWebsocketURL string ARIApplication string ARIUsername string ARIPassword string CallTarget string CallTrunkID string CallCallerID string CallMediaBind string CallMediaPort int CallRecordingDirectory string CallAISnapshotPath string CallTenantID string CallTenantKey string CallTaskID string CallTaskItemID string CallExecutionID string } func FromEnv() Config { return Config{ Mode: envOr("SIP_GO_AGENT_MODE", "mock"), DBPath: envOr("DISPATCHER_DB", "dispatcher.db"), SpoolRoot: envOr("AGENT_SPOOL", "./spool"), RabbitURL: os.Getenv("RABBITMQ_URL"), Exchange: envOr("RABBITMQ_EXCHANGE", "agent-call.commands.v1"), OutboxBatch: 50, AgentID: envOr("AGENT_ID", "agent-local"), DispatcherID: envOr("DISPATCHER_ID", "dispatcher-local"), Version: envOr("AGENT_VERSION", "dev"), ControlListen: os.Getenv("DISPATCHER_CONTROL_LISTEN"), ControlToken: os.Getenv("DISPATCHER_CONTROL_TOKEN"), CellID: envOr("CELL_ID", "cell-local"), GRPCListen: os.Getenv("AGENT_GRPC_LISTEN"), DispatcherGRPCListen: os.Getenv("DISPATCHER_GRPC_LISTEN"), DispatcherGRPCEndpoint: os.Getenv("DISPATCHER_GRPC_ENDPOINT"), DispatcherGRPCServerName: envOr("DISPATCHER_GRPC_SERVER_NAME", os.Getenv("MTLS_SERVER_NAME")), DispatcherGRPCAllowedAgentIDs: os.Getenv("DISPATCHER_ALLOWED_AGENT_IDS"), MTLSCAFile: os.Getenv("MTLS_CA_FILE"), MTLSCertFile: os.Getenv("MTLS_CERT_FILE"), MTLSKeyFile: os.Getenv("MTLS_KEY_FILE"), MTLSServerName: os.Getenv("MTLS_SERVER_NAME"), MTLSPeerCertificateFingerprints: os.Getenv("MTLS_PEER_CERT_FINGERPRINTS"), OSSRegion: os.Getenv("DISPATCHER_OSS_REGION"), OSSEndpoint: os.Getenv("DISPATCHER_OSS_ENDPOINT"), OSSBucket: os.Getenv("DISPATCHER_OSS_BUCKET"), OSSAccessKeyID: envOrSecret("DISPATCHER_OSS_ACCESS_KEY_ID", "DISPATCHER_OSS_ACCESS_KEY_ID_FILE"), OSSAccessKeySecret: envOrSecret("DISPATCHER_OSS_ACCESS_KEY_SECRET", "DISPATCHER_OSS_ACCESS_KEY_SECRET_FILE"), OSSKeyPrefix: envOr("DISPATCHER_OSS_KEY_PREFIX", "agent-call/recordings"), OSSGrantTTL: time.Duration(envInt("DISPATCHER_OSS_GRANT_TTL_SECONDS", 900)) * time.Second, OSSMaxAssetBytes: int64(envInt("DISPATCHER_OSS_MAX_ASSET_BYTES", 64<<20)), StaticArtifactPath: os.Getenv("AGENT_STATIC_ARTIFACT"), AgentEndpointsFile: os.Getenv("DISPATCHER_AGENT_ENDPOINTS_FILE"), CallLogPath: os.Getenv("AGENT_CALL_BUSINESS_LOG"), CallLogPhoneKey: os.Getenv("AGENT_CALL_PHONE_LOG_KEY"), ARIURL: envOr("ARI_URL", "http://127.0.0.1:8088/ari"), ARIWebsocketURL: envOr("ARI_WS_URL", "ws://127.0.0.1:8088/ari/events"), ARIApplication: envOr("ARI_APPLICATION", "agent-call"), ARIUsername: os.Getenv("ARI_USERNAME"), ARIPassword: os.Getenv("ARI_PASSWORD"), CallTarget: os.Getenv("AGENT_CALL_TARGET"), CallTrunkID: os.Getenv("AGENT_CALL_TRUNK_ID"), CallCallerID: os.Getenv("AGENT_CALL_CALLER_ID"), CallMediaBind: envOr("AGENT_CALL_MEDIA_BIND", "127.0.0.1"), CallMediaPort: envInt("AGENT_CALL_MEDIA_PORT", 12000), CallRecordingDirectory: envOr("AGENT_CALL_RECORDING_DIR", "./recordings"), CallAISnapshotPath: os.Getenv("AGENT_CALL_AI_SNAPSHOT"), CallTenantID: os.Getenv("AGENT_CALL_TENANT_ID"), CallTenantKey: os.Getenv("AGENT_CALL_TENANT_KEY"), CallTaskID: os.Getenv("AGENT_CALL_TASK_ID"), CallTaskItemID: os.Getenv("AGENT_CALL_TASK_ITEM_ID"), CallExecutionID: os.Getenv("AGENT_CALL_EXECUTION_ID"), } } func (c Config) Validate(role string) error { if c.Mode != "mock" && c.Mode != "mixed" && c.Mode != "real" { return fmt.Errorf("unsupported mode %q", c.Mode) } if role == "dispatcher" && strings.TrimSpace(c.DBPath) == "" { return errors.New("dispatcher DB path is required") } if role == "agent" && strings.TrimSpace(c.SpoolRoot) == "" { return errors.New("agent spool root is required") } if role == "agent" && strings.TrimSpace(c.CallLogPath) != "" && strings.TrimSpace(c.CallLogPhoneKey) == "" { return errors.New("AGENT_CALL_PHONE_LOG_KEY is required when AGENT_CALL_BUSINESS_LOG is set") } if role == "agent" && strings.TrimSpace(c.CallLogPhoneKey) != "" && len([]byte(c.CallLogPhoneKey)) < 16 { return errors.New("AGENT_CALL_PHONE_LOG_KEY must contain at least 16 bytes") } if role == "agent" && strings.TrimSpace(c.GRPCListen) != "" { for name, value := range map[string]string{"MTLS_CA_FILE": c.MTLSCAFile, "MTLS_CERT_FILE": c.MTLSCertFile, "MTLS_KEY_FILE": c.MTLSKeyFile} { if strings.TrimSpace(value) == "" { return fmt.Errorf("%s is required when AGENT_GRPC_LISTEN is enabled", name) } } } if role == "agent" && c.Mode == "real" && strings.TrimSpace(c.GRPCListen) == "" { return errors.New("real agent mode requires AGENT_GRPC_LISTEN") } if role == "agent" && c.Mode == "real" && strings.TrimSpace(c.StaticArtifactPath) == "" { return errors.New("real agent mode requires AGENT_STATIC_ARTIFACT") } if role == "agent" && strings.TrimSpace(c.MTLSPeerCertificateFingerprints) != "" { if _, err := ParseCertificateFingerprints(c.MTLSPeerCertificateFingerprints); err != nil { return fmt.Errorf("invalid MTLS_PEER_CERT_FINGERPRINTS: %w", err) } } if role == "dispatcher" && strings.TrimSpace(c.ControlListen) != "" && strings.TrimSpace(c.ControlToken) == "" { return errors.New("control HTTP requires DISPATCHER_CONTROL_TOKEN") } if role == "agent" && strings.TrimSpace(c.DispatcherGRPCEndpoint) != "" { for name, value := range map[string]string{"MTLS_CA_FILE": c.MTLSCAFile, "MTLS_CERT_FILE": c.MTLSCertFile, "MTLS_KEY_FILE": c.MTLSKeyFile} { if strings.TrimSpace(value) == "" { return fmt.Errorf("%s is required when DISPATCHER_GRPC_ENDPOINT is enabled", name) } } } if role == "dispatcher" && strings.TrimSpace(c.DispatcherGRPCListen) != "" { for name, value := range map[string]string{"MTLS_CA_FILE": c.MTLSCAFile, "MTLS_CERT_FILE": c.MTLSCertFile, "MTLS_KEY_FILE": c.MTLSKeyFile} { if strings.TrimSpace(value) == "" { return fmt.Errorf("%s is required when DISPATCHER_GRPC_LISTEN is enabled", name) } } for name, value := range map[string]string{"DISPATCHER_OSS_REGION": c.OSSRegion, "DISPATCHER_OSS_ENDPOINT": c.OSSEndpoint, "DISPATCHER_OSS_BUCKET": c.OSSBucket, "DISPATCHER_OSS_ACCESS_KEY_ID": c.OSSAccessKeyID, "DISPATCHER_OSS_ACCESS_KEY_SECRET": c.OSSAccessKeySecret} { if strings.TrimSpace(value) == "" { return fmt.Errorf("%s is required when Dispatcher gRPC is enabled", name) } } if strings.TrimSpace(c.DispatcherGRPCAllowedAgentIDs) == "" { return errors.New("DISPATCHER_ALLOWED_AGENT_IDS is required when Dispatcher gRPC is enabled") } fingerprints, err := ParseCertificateFingerprints(c.MTLSPeerCertificateFingerprints) if err != nil { return fmt.Errorf("invalid MTLS_PEER_CERT_FINGERPRINTS: %w", err) } if len(fingerprints) == 0 { return errors.New("MTLS_PEER_CERT_FINGERPRINTS is required when Dispatcher gRPC is enabled") } } if role == "dispatcher" && strings.TrimSpace(c.AgentEndpointsFile) != "" { for name, value := range map[string]string{"MTLS_CA_FILE": c.MTLSCAFile, "MTLS_CERT_FILE": c.MTLSCertFile, "MTLS_KEY_FILE": c.MTLSKeyFile} { if strings.TrimSpace(value) == "" { return fmt.Errorf("%s is required when DISPATCHER_AGENT_ENDPOINTS_FILE is enabled", name) } } } if c.Mode == "real" && strings.TrimSpace(c.RabbitURL) == "" && role == "dispatcher" { return errors.New("real dispatcher mode requires RABBITMQ_URL") } return nil } func envOr(key, fallback string) string { if value := strings.TrimSpace(os.Getenv(key)); value != "" { return value } return fallback } func envOrSecret(valueKey, fileKey string) string { if value := strings.TrimSpace(os.Getenv(valueKey)); value != "" { return value } path := strings.TrimSpace(os.Getenv(fileKey)) if path == "" { return "" } data, err := os.ReadFile(path) if err != nil { return "" } return strings.TrimSpace(string(data)) } func envInt(key string, fallback int) int { value := strings.TrimSpace(os.Getenv(key)) if value == "" { return fallback } parsed, err := strconv.Atoi(value) if err != nil { return fallback } return parsed }