365 lines
13 KiB
Go
365 lines
13 KiB
Go
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
|
|
)
|
|
|
|
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"`
|
|
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"`
|
|
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
|
|
}
|
|
s.AuthJWTSigningKey = strings.TrimSpace(s.AuthJWTSigningKey)
|
|
s.LDAPBindAddress = strings.TrimSpace(s.LDAPBindAddress)
|
|
s.LDAPBaseDN = strings.TrimSpace(s.LDAPBaseDN)
|
|
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")
|
|
}
|
|
|
|
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 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
|
|
}
|