package config import ( "os" "path/filepath" "reflect" "regexp" "sort" "strings" "github.com/knadh/koanf/parsers/yaml" "github.com/knadh/koanf/providers/file" "github.com/knadh/koanf/v2" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promauto" log "github.com/sirupsen/logrus" ) var ( metricConfigActionCount = promauto.NewGauge(prometheus.GaugeOpts{ Name: "olivetin_config_action_count", Help: "The number of actions in the config file", }) metricConfigReloadedCount = promauto.NewCounter(prometheus.CounterOpts{ Name: "olivetin_config_reloaded_count", Help: "The number of times the config has been reloaded", }) listeners []func() ) func AddListener(l func()) { listeners = append(listeners, l) } // AppendSourceWithIncludes loads base config and any included configs func AppendSourceWithIncludes(cfg *Config, k *koanf.Koanf, configPath string) { // Load base config first AppendSource(cfg, k, configPath) // Load included configs if specified if cfg.Include != "" { LoadIncludedConfigs(cfg, k, configPath) } } func AppendSource(cfg *Config, k *koanf.Koanf, configPath string) { log.Infof("Appending cfg source: %s", configPath) // Unmarshal config - koanf will handle mapstructure tags automatically err := k.Unmarshal(".", cfg) if err != nil { log.Errorf("Error unmarshalling config: %v", err) return } // Fallback for complex nested structures that might not unmarshal correctly // Only attempt manual unmarshaling if the automatic approach didn't populate the fields if len(cfg.Actions) == 0 && k.Exists("actions") { var actions []*Action if err := k.Unmarshal("actions", &actions); err == nil { cfg.Actions = actions log.Debugf("Manually loaded %d actions", len(actions)) } } if len(cfg.Dashboards) == 0 && k.Exists("dashboards") { var dashboards []*DashboardComponent if err := k.Unmarshal("dashboards", &dashboards); err == nil { cfg.Dashboards = dashboards log.Debugf("Manually loaded %d dashboards", len(dashboards)) } } if len(cfg.Entities) == 0 && k.Exists("entities") { var entities []*EntityFile if err := k.Unmarshal("entities", &entities); err == nil { cfg.Entities = entities log.Debugf("Manually loaded %d entities", len(entities)) } } if len(cfg.AuthLocalUsers.Users) == 0 && k.Exists("authLocalUsers") { var authLocalUsers AuthLocalUsersConfig if err := k.Unmarshal("authLocalUsers", &authLocalUsers); err == nil { cfg.AuthLocalUsers = authLocalUsers log.Debugf("Manually loaded local auth config") } } // Map structure tags should handle these automatically, but we keep fallbacks // for fields that might not unmarshal correctly applyConfigOverrides(k, cfg) metricConfigReloadedCount.Inc() metricConfigActionCount.Set(float64(len(cfg.Actions))) cfg.SetDir(filepath.Dir(configPath)) cfg.Sanitize() for _, l := range listeners { l() } } func applyConfigOverrides(k *koanf.Koanf, cfg *Config) { // Override fields that should be read from config // mapstructure tags should make most of this unnecessary, but keep for safety boolVal(k, "showFooter", &cfg.ShowFooter) boolVal(k, "showNavigation", &cfg.ShowNavigation) boolVal(k, "checkForUpdates", &cfg.CheckForUpdates) boolVal(k, "useSingleHTTPFrontend", &cfg.UseSingleHTTPFrontend) stringVal(k, "logLevel", &cfg.LogLevel) stringVal(k, "pageTitle", &cfg.PageTitle) boolVal(k, "authRequireGuestsToLogin", &cfg.AuthRequireGuestsToLogin) stringVal(k, "include", &cfg.Include) // Handle nested defaultPolicy struct if k.Exists("defaultPolicy") { boolVal(k, "defaultPolicy.showDiagnostics", &cfg.DefaultPolicy.ShowDiagnostics) boolVal(k, "defaultPolicy.showLogList", &cfg.DefaultPolicy.ShowLogList) } // Handle nested prometheus struct if k.Exists("prometheus") { boolVal(k, "prometheus.enabled", &cfg.Prometheus.Enabled) boolVal(k, "prometheus.defaultGoMetrics", &cfg.Prometheus.DefaultGoMetrics) } } // LoadIncludedConfigs loads configuration files from an include directory and merges them func LoadIncludedConfigs(cfg *Config, k *koanf.Koanf, baseConfigPath string) { if cfg.Include == "" { return } configDir := filepath.Dir(baseConfigPath) includePath := filepath.Join(configDir, cfg.Include) log.Infof("Loading included configs from: %s", includePath) // Check if the include directory exists dirInfo, err := os.Stat(includePath) if err != nil { log.Warnf("Include directory not found: %s", includePath) return } if !dirInfo.IsDir() { log.Warnf("Include path is not a directory: %s", includePath) return } // Read all .yml files from the directory entries, err := os.ReadDir(includePath) if err != nil { log.Errorf("Error reading include directory: %v", err) return } // Filter and sort .yml files var yamlFiles []string for _, entry := range entries { if !entry.IsDir() && (strings.HasSuffix(entry.Name(), ".yml") || strings.HasSuffix(entry.Name(), ".yaml")) { yamlFiles = append(yamlFiles, entry.Name()) } } if len(yamlFiles) == 0 { log.Infof("No YAML files found in include directory: %s", includePath) return } // Sort files to ensure deterministic load order sort.Strings(yamlFiles) // Load each file and merge into config for _, filename := range yamlFiles { filePath := filepath.Join(includePath, filename) log.Infof("Loading included config file: %s", filePath) includeK := koanf.New(".") f := file.Provider(filePath) if err := includeK.Load(f, yaml.Parser()); err != nil { log.Errorf("Error loading included config file %s: %v", filePath, err) continue } // Unmarshal into a temporary config to process properly tempCfg := &Config{} if err := includeK.Unmarshal(".", tempCfg); err != nil { log.Errorf("Error unmarshalling included config file %s: %v", filePath, err) continue } // Apply the same manual loading workarounds as in AppendSource if len(tempCfg.Actions) == 0 && includeK.Exists("actions") { var actions []*Action if err := includeK.Unmarshal("actions", &actions); err == nil { tempCfg.Actions = actions log.Debugf("Manually loaded %d actions from %s", len(actions), filename) } } // Merge the temp config into the main config // Later files override earlier ones mergeConfig(cfg, tempCfg) log.Infof("Successfully loaded and merged %s", filename) } log.Infof("Finished loading %d included config file(s)", len(yamlFiles)) // Sanitize the merged config cfg.Sanitize() } func mergeConfig(base *Config, overlay *Config) { // Merge Actions - overlay appends to base if len(overlay.Actions) > 0 { base.Actions = append(base.Actions, overlay.Actions...) } // Merge Dashboards - overlay appends to base if len(overlay.Dashboards) > 0 { base.Dashboards = append(base.Dashboards, overlay.Dashboards...) log.Debugf("Merged %d dashboards from include", len(overlay.Dashboards)) } // Merge Entities - overlay appends to base if len(overlay.Entities) > 0 { base.Entities = append(base.Entities, overlay.Entities...) log.Debugf("Merged %d entities from include", len(overlay.Entities)) } // Merge AccessControlLists - overlay appends to base if len(overlay.AccessControlLists) > 0 { base.AccessControlLists = append(base.AccessControlLists, overlay.AccessControlLists...) log.Debugf("Merged %d access control lists from include", len(overlay.AccessControlLists)) } // Merge AuthLocalUsers.Users - overlay appends to base if len(overlay.AuthLocalUsers.Users) > 0 { base.AuthLocalUsers.Users = append(base.AuthLocalUsers.Users, overlay.AuthLocalUsers.Users...) log.Debugf("Merged %d local users from include", len(overlay.AuthLocalUsers.Users)) } // Merge slices by appending if len(overlay.StyleMods) > 0 { base.StyleMods = append(base.StyleMods, overlay.StyleMods...) } if len(overlay.AdditionalNavigationLinks) > 0 { base.AdditionalNavigationLinks = append(base.AdditionalNavigationLinks, overlay.AdditionalNavigationLinks...) } // Override simple fields (later files win) if overlay.LogLevel != "" { base.LogLevel = overlay.LogLevel } if overlay.PageTitle != "" { base.PageTitle = overlay.PageTitle } if overlay.ShowFooter != base.ShowFooter { base.ShowFooter = overlay.ShowFooter } if overlay.ShowNavigation != base.ShowNavigation { base.ShowNavigation = overlay.ShowNavigation } if overlay.CheckForUpdates != base.CheckForUpdates { base.CheckForUpdates = overlay.CheckForUpdates } if overlay.UseSingleHTTPFrontend != base.UseSingleHTTPFrontend { base.UseSingleHTTPFrontend = overlay.UseSingleHTTPFrontend } if overlay.AuthRequireGuestsToLogin != base.AuthRequireGuestsToLogin { base.AuthRequireGuestsToLogin = overlay.AuthRequireGuestsToLogin } // Override nested structs if overlay.DefaultPolicy.ShowDiagnostics != base.DefaultPolicy.ShowDiagnostics { base.DefaultPolicy.ShowDiagnostics = overlay.DefaultPolicy.ShowDiagnostics } if overlay.DefaultPolicy.ShowLogList != base.DefaultPolicy.ShowLogList { base.DefaultPolicy.ShowLogList = overlay.DefaultPolicy.ShowLogList } if overlay.Prometheus.Enabled != base.Prometheus.Enabled { base.Prometheus.Enabled = overlay.Prometheus.Enabled } if overlay.Prometheus.DefaultGoMetrics != base.Prometheus.DefaultGoMetrics { base.Prometheus.DefaultGoMetrics = overlay.Prometheus.DefaultGoMetrics } // Override AuthLocalUsers.Enabled if set if overlay.AuthLocalUsers.Enabled { base.AuthLocalUsers.Enabled = overlay.AuthLocalUsers.Enabled } // Override string fields if non-empty overrideString(&base.BannerMessage, overlay.BannerMessage) overrideString(&base.BannerCSS, overlay.BannerCSS) overrideString(&base.LogLevel, overlay.LogLevel) overrideString(&base.PageTitle, overlay.PageTitle) overrideString(&base.SectionNavigationStyle, overlay.SectionNavigationStyle) overrideString(&base.DefaultPopupOnStart, overlay.DefaultPopupOnStart) } func overrideString(base *string, overlay string) { if overlay != "" { *base = overlay } } func getActionTitles(actions []*Action) []string { titles := make([]string, len(actions)) for i, action := range actions { titles[i] = action.Title } return titles } var envRegex = regexp.MustCompile(`\${{ *?(\S+) *?}}`) // Helper functions to reduce repetitive if/set chains func stringVal(k *koanf.Koanf, key string, dest *string) { if k.Exists(key) { *dest = k.String(key) } } func boolVal(k *koanf.Koanf, key string, dest *bool) { if k.Exists(key) { *dest = k.Bool(key) } } func int64Val(k *koanf.Koanf, key string, dest *int64) { if k.Exists(key) { *dest = k.Int64(key) } } func envDecodeHookFunc(from reflect.Type, to reflect.Type, data any) (any, error) { log.Debugf("envDecodeHookFunc called: from=%v, to=%v, data=%v", from, to, data) if from.Kind() != reflect.String { return data, nil } input := data.(string) log.Debugf("Processing string input: %q", input) output := envRegex.ReplaceAllStringFunc(input, func(match string) string { submatches := envRegex.FindStringSubmatch(match) key := submatches[1] val, set := os.LookupEnv(key) log.Debugf("Environment variable %q: set=%v, value=%q", key, set, val) if !set { log.Warnf("Config file references unset environment variable: \"%s\"", key) } return val }) log.Debugf("Environment variable interpolation result: %q -> %q", input, output) return output, nil }