package settings import ( "encoding/base64" "errors" "fmt" "log/slog" "os" "path/filepath" "regexp" "strings" "vctp/internal/utils" "gopkg.in/yaml.v3" ) var ( postgresURIUserInfoPasswordPattern = regexp.MustCompile(`(?i)(postgres(?:ql)?://[^@/\s]*:)([^@/\s]*)(@)`) postgresKVPasswordPattern = regexp.MustCompile(`(?i)(\bpassword\s*=\s*)(?:'[^']*'|"[^"]*"|[^\s]+)`) ) const ( authModeDisabled = "disabled" authModeOptional = "optional" authModeRequired = "required" authRoleAdmin = "admin" authRoleViewer = "viewer" defaultAuthTokenLifespanMinutes = 120 defaultAuthJWTIssuer = "vctp" defaultAuthJWTAudience = "vctp-api" defaultAuthClockSkewSeconds = 60 scheduledAggregationEngineGo = "go" scheduledAggregationEngineSQL = "sql" ) type Settings struct { SettingsPath string Logger *slog.Logger Values *SettingsYML } type ReportSummaryPivot struct { Metric string `yaml:"metric"` Title string `yaml:"title"` PivotName string `yaml:"pivot_name"` PivotRange string `yaml:"pivot_range"` TitleCell string `yaml:"title_cell"` } // SettingsYML struct holds various runtime data that is too cumbersome to specify via command line, eg replacement properties type SettingsYML struct { Settings struct { LogLevel string `yaml:"log_level"` LogOutput string `yaml:"log_output"` DatabaseDriver string `yaml:"database_driver"` DatabaseURL string `yaml:"database_url"` EnableExperimentalPostgres bool `yaml:"enable_experimental_postgres"` BindIP string `yaml:"bind_ip"` BindPort int `yaml:"bind_port"` BindDisableTLS bool `yaml:"bind_disable_tls"` TLSCertFilename string `yaml:"tls_cert_filename"` TLSKeyFilename string `yaml:"tls_key_filename"` EncryptionKey string `yaml:"encryption_key"` VcenterUsername string `yaml:"vcenter_username"` VcenterPassword string `yaml:"vcenter_password"` VcenterInsecure bool `yaml:"vcenter_insecure"` EnableLegacyAPI bool `yaml:"enable_legacy_api"` AuthEnabled bool `yaml:"auth_enabled"` AuthMode string `yaml:"auth_mode"` AuthJWTSigningKey string `yaml:"auth_jwt_signing_key"` AuthTokenLifespanMinutes int `yaml:"auth_token_lifespan_minutes"` AuthJWTIssuer string `yaml:"auth_jwt_issuer"` AuthJWTAudience string `yaml:"auth_jwt_audience"` AuthClockSkewSeconds int `yaml:"auth_clock_skew_seconds"` AuthGroupRoleMappings map[string]string `yaml:"auth_group_role_mappings"` LDAPGroups []string `yaml:"ldap_groups"` LDAPBindAddress string `yaml:"ldap_bind_address"` LDAPBaseDN string `yaml:"ldap_base_dn"` LDAPUserBaseDN string `yaml:"ldap_user_base_dn"` LDAPGroupBaseDN string `yaml:"ldap_group_base_dn"` LDAPTrustCertFile string `yaml:"ldap_trust_cert_file"` LDAPDisableValidation bool `yaml:"ldap_disable_validation"` LDAPInsecure bool `yaml:"ldap_insecure"` EnablePprof bool `yaml:"enable_pprof"` VcenterEventPollingSeconds int `yaml:"vcenter_event_polling_seconds"` VcenterInventoryPollingSeconds int `yaml:"vcenter_inventory_polling_seconds"` VcenterInventorySnapshotSeconds int `yaml:"vcenter_inventory_snapshot_seconds"` VcenterInventoryAggregateSeconds int `yaml:"vcenter_inventory_aggregate_seconds"` HourlySnapshotConcurrency int `yaml:"hourly_snapshot_concurrency"` HourlySnapshotMaxAgeDays int `yaml:"hourly_snapshot_max_age_days"` DailySnapshotMaxAgeMonths int `yaml:"daily_snapshot_max_age_months"` HourlyIndexMaxAgeDays int `yaml:"hourly_index_max_age_days"` SnapshotCleanupCron string `yaml:"snapshot_cleanup_cron"` ReportsDir string `yaml:"reports_dir"` HourlyJobTimeoutSeconds int `yaml:"hourly_job_timeout_seconds"` HourlySnapshotTimeoutSeconds int `yaml:"hourly_snapshot_timeout_seconds"` HourlySnapshotRetrySeconds int `yaml:"hourly_snapshot_retry_seconds"` HourlySnapshotMaxRetries int `yaml:"hourly_snapshot_max_retries"` CaptureWriteBatchSize int `yaml:"capture_write_batch_size"` SnapshotTableCompatMode *bool `yaml:"snapshot_table_compat_mode"` AsyncReportGeneration *bool `yaml:"async_report_generation"` PostgresVmHourlyPartitioning *bool `yaml:"postgres_vm_hourly_partitioning_enabled"` ScheduledAggregationEngine string `yaml:"scheduled_aggregation_engine"` DailyJobTimeoutSeconds int `yaml:"daily_job_timeout_seconds"` MonthlyJobTimeoutSeconds int `yaml:"monthly_job_timeout_seconds"` MonthlyAggregationGranularity string `yaml:"monthly_aggregation_granularity"` MonthlyAggregationCron string `yaml:"monthly_aggregation_cron"` CleanupJobTimeoutSeconds int `yaml:"cleanup_job_timeout_seconds"` TenantsToFilter []string `yaml:"tenants_to_filter"` NodeChargeClusters []string `yaml:"node_charge_clusters"` SrmActiveActiveVms []string `yaml:"srm_activeactive_vms"` VcenterAddresses []string `yaml:"vcenter_addresses"` PostgresWorkMemMB int `yaml:"postgres_work_mem_mb"` ReportSummaryPivots []ReportSummaryPivot `yaml:"report_summary_pivots"` } `yaml:"settings"` } func New(logger *slog.Logger, settingsPath string) *Settings { return &Settings{ SettingsPath: utils.GetFilePath(settingsPath), Logger: logger, } } func (s *Settings) ReadYMLSettings() error { // Create config structure var settings SettingsYML // Check for empty filename if len(s.SettingsPath) == 0 { return errors.New("settings file path not specified") } //path := utils.GetFilePath(settingsPath) // Open config file file, err := os.Open(s.SettingsPath) if err != nil { return fmt.Errorf("unable to open settings file : '%s'", err) } s.Logger.Debug("Opened settings yaml file", "file_path", s.SettingsPath) defer file.Close() // Init new YAML decode d := yaml.NewDecoder(file) d.KnownFields(true) // Start YAML decoding from file if err := d.Decode(&settings); err != nil { return fmt.Errorf("unable to decode settings file : '%s'", err) } if err := applyDefaultsAndValidateSettings(&settings); err != nil { return fmt.Errorf("invalid settings file: %w", err) } // Avoid logging sensitive fields (e.g., credentials). redacted := settings redacted.Settings.VcenterPassword = "REDACTED" if redacted.Settings.EncryptionKey != "" { redacted.Settings.EncryptionKey = "REDACTED" } if redacted.Settings.AuthJWTSigningKey != "" { redacted.Settings.AuthJWTSigningKey = "REDACTED" } if redacted.Settings.DatabaseURL != "" { redacted.Settings.DatabaseURL = redactDatabaseURL(redacted.Settings.DatabaseURL) } s.Logger.Debug("Updating settings", "settings", redacted) s.Values = &settings return nil } func redactDatabaseURL(databaseURL string) string { if strings.TrimSpace(databaseURL) == "" { return databaseURL } redacted := postgresURIUserInfoPasswordPattern.ReplaceAllString(databaseURL, `${1}REDACTED${3}`) redacted = postgresKVPasswordPattern.ReplaceAllString(redacted, `${1}REDACTED`) return redacted } func (s *Settings) WriteYMLSettings() error { if s.Values == nil { return errors.New("settings are not loaded") } if len(s.SettingsPath) == 0 { return errors.New("settings file path not specified") } data, err := yaml.Marshal(s.Values) if err != nil { return fmt.Errorf("unable to encode settings file: %w", err) } mode := os.FileMode(0o600) if info, err := os.Stat(s.SettingsPath); err == nil { mode = secureSettingsFileMode(info.Mode().Perm()) } dir := filepath.Dir(s.SettingsPath) tmp, err := os.CreateTemp(dir, "vctp-settings-*.yml") if err != nil { return fmt.Errorf("unable to create temp settings file: %w", err) } tmpName := tmp.Name() defer func() { _ = os.Remove(tmpName) }() if _, err := tmp.Write(data); err != nil { _ = tmp.Close() return fmt.Errorf("unable to write temp settings file: %w", err) } if err := tmp.Chmod(mode); err != nil { _ = tmp.Close() return fmt.Errorf("unable to set temp settings permissions: %w", err) } if err := tmp.Close(); err != nil { return fmt.Errorf("unable to close temp settings file: %w", err) } if err := os.Rename(tmpName, s.SettingsPath); err != nil { return fmt.Errorf("unable to replace settings file: %w", err) } return nil } func secureSettingsFileMode(mode os.FileMode) os.FileMode { // Ensure owner read/write, strip world permissions and all execute bits. secured := mode & 0o660 secured |= 0o600 return secured } func applyDefaultsAndValidateSettings(cfg *SettingsYML) error { if cfg == nil { return errors.New("settings config is nil") } s := &cfg.Settings s.AuthMode = strings.ToLower(strings.TrimSpace(s.AuthMode)) if s.AuthMode == "" { s.AuthMode = authModeDisabled } if s.AuthTokenLifespanMinutes == 0 { s.AuthTokenLifespanMinutes = defaultAuthTokenLifespanMinutes } s.AuthJWTIssuer = strings.TrimSpace(s.AuthJWTIssuer) if s.AuthJWTIssuer == "" { s.AuthJWTIssuer = defaultAuthJWTIssuer } s.AuthJWTAudience = strings.TrimSpace(s.AuthJWTAudience) if s.AuthJWTAudience == "" { s.AuthJWTAudience = defaultAuthJWTAudience } if s.AuthClockSkewSeconds == 0 { s.AuthClockSkewSeconds = defaultAuthClockSkewSeconds } if s.CaptureWriteBatchSize <= 0 { s.CaptureWriteBatchSize = 1000 } if s.SnapshotTableCompatMode == nil { v := true s.SnapshotTableCompatMode = &v } if s.AsyncReportGeneration == nil { v := true s.AsyncReportGeneration = &v } if s.PostgresVmHourlyPartitioning == nil { v := false s.PostgresVmHourlyPartitioning = &v } s.ScheduledAggregationEngine = strings.ToLower(strings.TrimSpace(s.ScheduledAggregationEngine)) if s.ScheduledAggregationEngine == "" { s.ScheduledAggregationEngine = scheduledAggregationEngineGo } s.MonthlyAggregationGranularity = strings.ToLower(strings.TrimSpace(s.MonthlyAggregationGranularity)) if s.MonthlyAggregationGranularity == "" { s.MonthlyAggregationGranularity = "daily" } s.AuthJWTSigningKey = strings.TrimSpace(s.AuthJWTSigningKey) s.LDAPBindAddress = strings.TrimSpace(s.LDAPBindAddress) s.LDAPBaseDN = strings.TrimSpace(s.LDAPBaseDN) s.LDAPUserBaseDN = strings.TrimSpace(s.LDAPUserBaseDN) s.LDAPGroupBaseDN = strings.TrimSpace(s.LDAPGroupBaseDN) s.LDAPTrustCertFile = strings.TrimSpace(s.LDAPTrustCertFile) s.LDAPGroups = compactTrimmedStrings(s.LDAPGroups) if !isValidAuthMode(s.AuthMode) { return fmt.Errorf("settings.auth_mode must be one of %q, %q, %q", authModeDisabled, authModeOptional, authModeRequired) } if s.AuthTokenLifespanMinutes <= 0 { return errors.New("settings.auth_token_lifespan_minutes must be greater than 0") } if s.AuthClockSkewSeconds < 0 { return errors.New("settings.auth_clock_skew_seconds must be >= 0") } switch s.ScheduledAggregationEngine { case scheduledAggregationEngineGo, scheduledAggregationEngineSQL: default: return fmt.Errorf("settings.scheduled_aggregation_engine must be %q or %q", scheduledAggregationEngineGo, scheduledAggregationEngineSQL) } if len(s.AuthGroupRoleMappings) > 0 { normalized := make(map[string]string, len(s.AuthGroupRoleMappings)) for groupDN, role := range s.AuthGroupRoleMappings { groupDN = strings.TrimSpace(groupDN) role = strings.ToLower(strings.TrimSpace(role)) if groupDN == "" { return errors.New("settings.auth_group_role_mappings contains an empty group DN key") } if !isValidAuthRole(role) { return fmt.Errorf("settings.auth_group_role_mappings[%q] has unsupported role %q", groupDN, role) } normalized[groupDN] = role } s.AuthGroupRoleMappings = normalized } if !s.AuthEnabled { return nil } if s.AuthMode == authModeDisabled { return errors.New("settings.auth_mode must be optional or required when settings.auth_enabled=true") } if s.AuthJWTSigningKey == "" { return errors.New("settings.auth_jwt_signing_key is required when settings.auth_enabled=true") } decodedKey, err := decodeBase64(s.AuthJWTSigningKey) if err != nil { return errors.New("settings.auth_jwt_signing_key must be valid base64") } if len(decodedKey) == 0 { return errors.New("settings.auth_jwt_signing_key cannot decode to an empty value") } if s.LDAPBindAddress == "" { return errors.New("settings.ldap_bind_address is required when settings.auth_enabled=true") } if s.LDAPBaseDN == "" { return errors.New("settings.ldap_base_dn is required when settings.auth_enabled=true") } if s.LDAPUserBaseDN == "" { s.LDAPUserBaseDN = s.LDAPBaseDN } if s.LDAPGroupBaseDN == "" { s.LDAPGroupBaseDN = s.LDAPBaseDN } if len(s.AuthGroupRoleMappings) == 0 { return errors.New("settings.auth_group_role_mappings must define at least one mapping when settings.auth_enabled=true") } return nil } func isValidAuthMode(mode string) bool { switch mode { case authModeDisabled, authModeOptional, authModeRequired: return true default: return false } } func isValidAuthRole(role string) bool { switch role { case authRoleAdmin, authRoleViewer: return true default: return false } } func decodeBase64(value string) ([]byte, error) { encodings := []*base64.Encoding{ base64.StdEncoding, base64.RawStdEncoding, base64.URLEncoding, base64.RawURLEncoding, } for _, encoding := range encodings { decoded, err := encoding.DecodeString(value) if err == nil { return decoded, nil } } return nil, errors.New("invalid base64 encoding") } func compactTrimmedStrings(values []string) []string { if len(values) == 0 { return nil } out := make([]string, 0, len(values)) for _, value := range values { trimmed := strings.TrimSpace(value) if trimmed == "" { continue } out = append(out, trimmed) } if len(out) == 0 { return nil } return out }