internal/config/config_load.go
Rama main · réplica de lectura del repositorio original.
package config
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"os"
"strings"
"syscall"
log "github.com/sirupsen/logrus"
"gopkg.in/yaml.v3"
)
// LoadConfig reads a YAML configuration file from the given path,
// unmarshals it into a Config struct, applies environment variable overrides,
// and returns it.
//
// Parameters:
// - configFile: The path to the YAML configuration file
//
// Returns:
// - *Config: The loaded configuration
// - error: An error if the configuration could not be loaded
func LoadConfig(configFile string) (*Config, error) {
return LoadConfigOptional(configFile, false)
}
// ComputeSourceHash returns the Config.SourceHash value for config file bytes.
func ComputeSourceHash(data []byte) string {
sum := sha256.Sum256(data)
return hex.EncodeToString(sum[:])
}
// PrepareConfigFileForUpload applies the file rewrites of a startup load: a plaintext
// management secret is replaced with its bcrypt hash and conflicting legacy fields are
// removed. Stores that seed remote storage from configFile before the startup load call
// it first, so the plaintext secret never leaves the machine and the local and remote
// copies stay identical. When the plaintext cannot be removed it returns an error and
// leaves configFile untouched, so every later attempt refuses the file again.
func PrepareConfigFileForUpload(configFile string) error {
data, errRead := os.ReadFile(configFile)
if errRead != nil {
return errRead
}
prepared, errPrepare := prepareConfigForUpload(data)
if errPrepare != nil {
return errPrepare
}
if bytes.Equal(prepared, data) {
return nil
}
if errWrite := os.WriteFile(configFile, prepared, 0600); errWrite != nil {
return fmt.Errorf("failed to write prepared config file: %w", errWrite)
}
return nil
}
// prepareConfigForUpload returns data with the file rewrites of a startup load applied.
// It fails when the result would still hold a plaintext management secret: hashing
// replaces only the effective key and the layout cleanup only drops an overridden legacy
// key, so a YAML anchor or a copy elsewhere keeps the plaintext, and rewriting those could
// change other settings that share them.
func prepareConfigForUpload(data []byte) ([]byte, error) {
var cfg Config
if errParse := yaml.Unmarshal(data, &cfg); errParse != nil {
return nil, fmt.Errorf("failed to parse config file: %w", errParse)
}
prepared := data
if secret := cfg.RemoteManagement.SecretKey; secret != "" && !looksLikeBcrypt(secret) {
hashed, errHash := hashSecret(secret)
if errHash != nil {
return nil, fmt.Errorf("failed to hash remote management key: %w", errHash)
}
updated, errUpdate := updateNestedScalarYAML(data, managementSecretKeyPath(data), hashed)
if errUpdate != nil {
return nil, fmt.Errorf("failed to update remote management key: %w", errUpdate)
}
prepared = updated
}
cleaned, _, errLayout := NormalizeConfigLayout(prepared, false)
if errLayout != nil {
return nil, errLayout
}
if plaintexts := plaintextManagementSecrets(data, cfg.RemoteManagement.SecretKey); len(plaintexts) > 0 {
var doc yaml.Node
if errParse := yaml.Unmarshal(cleaned, &doc); errParse != nil {
return nil, fmt.Errorf("failed to parse prepared config: %w", errParse)
}
for _, plaintext := range plaintexts {
if yamlScalarExists(&doc, plaintext) {
return nil, errors.New("plaintext remote management key is still present elsewhere in the config file, for example through a YAML anchor or as another setting's value")
}
}
}
return cleaned, nil
}
// plaintextManagementSecrets returns the plaintext management secrets of data: the
// effective one and the values of the legacy and v8 secret keys, since an overridden
// legacy key still holds its own. YAML anchors, aliases and merge keys are resolved, so
// data must already decode as a Config, which rejects cyclic aliases.
func plaintextManagementSecrets(data []byte, effective string) []string {
candidates := []string{effective}
var doc yaml.Node
if yaml.Unmarshal(data, &doc) == nil && len(doc.Content) > 0 {
root := expandConfigAliases(doc.Content[0])
for _, path := range []string{"remote-management.secret-key", "management.secret-key"} {
if node := yamlPath(root, path); node != nil && node.Kind == yaml.ScalarNode && node.ShortTag() != "!!null" {
candidates = append(candidates, node.Value)
}
}
}
var plaintexts []string
for _, candidate := range candidates {
if candidate != "" && !looksLikeBcrypt(candidate) {
plaintexts = append(plaintexts, candidate)
}
}
return plaintexts
}
// yamlScalarExists reports whether node or any value below it is a non-null scalar equal
// to value. Mapping keys only name settings and are skipped, as are alias nodes, whose
// anchors are visited where they are defined.
func yamlScalarExists(node *yaml.Node, value string) bool {
if node.Kind == yaml.ScalarNode {
return node.ShortTag() != "!!null" && node.Value == value
}
first, step := 0, 1
if node.Kind == yaml.MappingNode {
first, step = 1, 2
}
for i := first; i < len(node.Content); i += step {
if yamlScalarExists(node.Content[i], value) {
return true
}
}
return false
}
// managementSecretKeyPath returns the management secret key path used by data: the v8
// path when the document defines it, the legacy path otherwise.
func managementSecretKeyPath(data []byte) []string {
var source yaml.Node
if yaml.Unmarshal(data, &source) == nil && len(source.Content) > 0 && yamlPath(expandConfigAliases(source.Content[0]), "management.secret-key") != nil {
return []string{"management", "secret-key"}
}
return []string{"remote-management", "secret-key"}
}
// LoadConfigOptional reads YAML from configFile.
// If optional is true and the file is missing, it returns an empty Config.
// If optional is true and the file is empty or invalid, it returns an empty Config.
func LoadConfigOptional(configFile string, optional bool) (*Config, error) {
// Read the entire configuration file into memory.
data, err := os.ReadFile(configFile)
if err != nil {
if optional {
if os.IsNotExist(err) || errors.Is(err, syscall.EISDIR) {
// Missing and optional: return empty config (cloud deploy standby).
cfg := &Config{CredentialInFlight: DefaultCredentialInFlightConfig()}
cfg.NormalizePluginsConfig()
cfg.NormalizeClaudeConfig()
return cfg, nil
}
}
return nil, fmt.Errorf("failed to read config file: %w", err)
}
// In cloud deploy mode (optional=true), if file is empty or contains only whitespace, return empty config.
if optional && len(bytes.TrimSpace(data)) == 0 {
cfg := &Config{CredentialInFlight: DefaultCredentialInFlightConfig()}
cfg.NormalizePluginsConfig()
cfg.NormalizeClaudeConfig()
return cfg, nil
}
if errValidate := validateCredentialWeightYAML(data); errValidate != nil {
if optional {
cfgOptional := &Config{CredentialInFlight: DefaultCredentialInFlightConfig()}
cfgOptional.NormalizePluginsConfig()
cfgOptional.NormalizeClaudeConfig()
return cfgOptional, nil
}
return nil, errValidate
}
// Unmarshal the YAML data into the Config struct.
var cfg Config
// Set defaults before unmarshal so that absent keys keep defaults.
cfg.Host = "" // Default empty: binds to all interfaces (IPv4 + IPv6)
cfg.LoggingToFile = false
cfg.LogsMaxTotalSizeMB = 0
cfg.ErrorLogsMaxFiles = 10
cfg.UsageStatisticsEnabled = false
cfg.RedisUsageQueueRetentionSeconds = 60
cfg.DisableCooling = false
cfg.SaveCooldownStatus = false
cfg.TransientErrorCooldownSeconds = 0
cfg.DisableImageGeneration = DisableImageGenerationOff
cfg.WebsocketAuth = true
cfg.Pprof.Enable = false
cfg.Pprof.Addr = DefaultPprofAddr
cfg.Discovery.Enabled = false
cfg.Discovery.ServiceType = DefaultDiscoveryServiceType
cfg.Discovery.Subtypes = []string{"_chat-completions", "_responses", "_messages", "_generate-content", "_interactions"}
cfg.RemoteManagement.PanelGitHubRepository = DefaultPanelGitHubRepository
cfg.CredentialInFlight = DefaultCredentialInFlightConfig()
if err = yaml.Unmarshal(data, &cfg); err != nil {
if optional {
// In cloud deploy mode, if YAML parsing fails, return empty config instead of error.
cfgOptional := &Config{CredentialInFlight: DefaultCredentialInFlightConfig()}
cfgOptional.NormalizePluginsConfig()
cfgOptional.NormalizeClaudeConfig()
return cfgOptional, nil
}
return nil, fmt.Errorf("failed to parse config file: %w", err)
}
if errValidate := validateTrustedProxies(cfg.TrustedProxies); errValidate != nil {
return nil, errValidate
}
cfg.CredentialConcurrency = cfg.CredentialConcurrency.WithDefaults()
if errValidate := cfg.CredentialInFlight.Validate(); errValidate != nil {
return nil, errValidate
}
if cfg.Discovery.ServiceType == "" {
cfg.Discovery.ServiceType = DefaultDiscoveryServiceType
}
if len(cfg.Discovery.Subtypes) == 0 {
cfg.Discovery.Subtypes = []string{"_chat-completions", "_responses", "_messages", "_generate-content", "_interactions"}
}
if errValidate := cfg.Codex.LiveMediaRelay.Validate(); errValidate != nil {
return nil, errValidate
}
if errValidate := cfg.ValidateCredentialWeights(); errValidate != nil {
return nil, errValidate
}
// Hash remote management key if plaintext is detected (nested)
// We consider a value to be already hashed if it looks like a bcrypt hash ($2a$, $2b$, or $2y$ prefix).
if cfg.RemoteManagement.SecretKey != "" && !looksLikeBcrypt(cfg.RemoteManagement.SecretKey) {
hashed, errHash := hashSecret(cfg.RemoteManagement.SecretKey)
if errHash != nil {
return nil, fmt.Errorf("failed to hash remote management key: %w", errHash)
}
cfg.RemoteManagement.SecretKey = hashed
// Persist the hashed value back to the config file to avoid re-hashing on next startup.
// Preserve YAML comments and ordering; update only the nested key.
_ = SaveConfigPreserveCommentsUpdateNestedScalar(configFile, managementSecretKeyPath(data), hashed)
}
cfg.RemoteManagement.PanelGitHubRepository = strings.TrimSpace(cfg.RemoteManagement.PanelGitHubRepository)
if cfg.RemoteManagement.PanelGitHubRepository == "" {
cfg.RemoteManagement.PanelGitHubRepository = DefaultPanelGitHubRepository
}
cfg.Pprof.Addr = strings.TrimSpace(cfg.Pprof.Addr)
if cfg.Pprof.Addr == "" {
cfg.Pprof.Addr = DefaultPprofAddr
}
cfg.KVStore.Path = strings.TrimSpace(cfg.KVStore.Path)
if cfg.LogsMaxTotalSizeMB < 0 {
cfg.LogsMaxTotalSizeMB = 0
}
if cfg.ErrorLogsMaxFiles < 0 {
cfg.ErrorLogsMaxFiles = 10
}
if cfg.RedisUsageQueueRetentionSeconds <= 0 {
cfg.RedisUsageQueueRetentionSeconds = 60
} else if cfg.RedisUsageQueueRetentionSeconds > 3600 {
log.WithField("value", cfg.RedisUsageQueueRetentionSeconds).Warn("redis-usage-queue-retention-seconds too large; clamping to 3600")
cfg.RedisUsageQueueRetentionSeconds = 3600
}
if cfg.MaxRetryCredentials < 0 {
cfg.MaxRetryCredentials = 0
}
cfg.NormalizePluginsConfig()
cfg.NormalizeClaudeConfig()
if errResolvePluginsDir := cfg.ResolvePluginsDir(); errResolvePluginsDir != nil && cfg.Plugins.Enabled {
return nil, errResolvePluginsDir
}
// Sanitize Gemini API key configuration and migrate legacy entries.
cfg.SanitizeGeminiKeys()
// Sanitize native Interactions API key configuration.
cfg.SanitizeInteractionsKeys()
// Sanitize Vertex-compatible API keys.
cfg.SanitizeVertexCompatKeys()
// Sanitize Codex keys: drop entries without base-url
cfg.SanitizeCodexKeys()
// Sanitize xAI keys: drop entries without base-url
cfg.SanitizeXAIKeys()
// Sanitize Meta keys.
cfg.SanitizeMetaKeys()
// Sanitize Codex header defaults.
cfg.SanitizeCodexHeaderDefaults()
// Sanitize Claude header defaults.
cfg.SanitizeClaudeHeaderDefaults()
// Sanitize Claude key headers
cfg.SanitizeClaudeKeys()
// Sanitize Antigravity and Devin sensitive-word replacement maps.
cfg.SanitizeAntigravity()
cfg.SanitizeDevin()
// Sanitize OpenAI compatibility providers: drop entries without base-url
cfg.SanitizeOpenAICompatibility()
// Normalize OAuth provider model exclusion map.
cfg.OAuthExcludedModels = NormalizeOAuthExcludedModels(cfg.OAuthExcludedModels)
// Normalize global OAuth model name aliases.
cfg.SanitizeOAuthModelAlias()
// Normalize global OAuth model settings.
cfg.SanitizeOAuthSettings()
// Normalize global OAuth request-scoped error rules.
cfg.SanitizeOAuthRequestScopedErrors()
// Validate raw payload rules and drop invalid entries.
cfg.SanitizePayloadRules()
// Only conflicting legacy fields are removed on load. A legacy-only document
// stays legacy until a v8 configuration write explicitly migrates it.
current, errRead := os.ReadFile(configFile)
if errRead != nil {
return nil, errRead
}
cleaned, changed, errLayout := NormalizeConfigLayout(current, false)
if errLayout != nil {
return nil, errLayout
}
if changed {
if errWrite := os.WriteFile(configFile, cleaned, 0600); errWrite != nil {
return nil, fmt.Errorf("clean conflicting config fields: %w", errWrite)
}
}
// Record the exact bytes this config was parsed from, so a consumer can tell
// it apart from a newer version of the file.
cfg.SourceHash = ComputeSourceHash(data)
// Return the populated configuration struct.
return &cfg, nil
}