package config import ( "errors" "fmt" "io/fs" "os" "path/filepath" "sort" "strings" "time" "github.com/TwiN/deepmerge" "github.com/TwiN/gatus/v5/alerting" "github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/provider" "github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/config/announcement" "github.com/TwiN/gatus/v5/config/connectivity" "github.com/TwiN/gatus/v5/config/endpoint" "github.com/TwiN/gatus/v5/config/key" "github.com/TwiN/gatus/v5/config/maintenance" "github.com/TwiN/gatus/v5/config/remote" "github.com/TwiN/gatus/v5/config/suite" "github.com/TwiN/gatus/v5/config/tunneling" "github.com/TwiN/gatus/v5/config/ui" "github.com/TwiN/gatus/v5/config/web" "github.com/TwiN/gatus/v5/security" "github.com/TwiN/gatus/v5/storage" "github.com/TwiN/logr" "gopkg.in/yaml.v3" ) const ( // DefaultConfigurationFilePath is the default path that will be used to search for the configuration file // if a custom path isn't configured through the GATUS_CONFIG_PATH environment variable DefaultConfigurationFilePath = "config/config.yaml" // DefaultFallbackConfigurationFilePath is the default fallback path that will be used to search for the // configuration file if DefaultConfigurationFilePath didn't work DefaultFallbackConfigurationFilePath = "config/config.yml" // DefaultConcurrency is the default number of endpoints/suites that can be monitored concurrently DefaultConcurrency = 3 ) var ( // ErrNoEndpointOrSuiteInConfig is an error returned when a configuration file or directory has no endpoints configured ErrNoEndpointOrSuiteInConfig = errors.New("configuration should contain at least one endpoint or suite") // ErrConfigFileNotFound is an error returned when a configuration file could not be found ErrConfigFileNotFound = errors.New("configuration file not found") // ErrInvalidSecurityConfig is an error returned when the security configuration is invalid ErrInvalidSecurityConfig = errors.New("invalid security configuration") // errEarlyReturn is returned to break out of a loop from a callback early errEarlyReturn = errors.New("early escape") ) // Config is the main configuration structure type Config struct { // Debug Whether to enable debug logs // Deprecated: Use the GATUS_LOG_LEVEL environment variable instead Debug bool `yaml:"debug,omitempty"` // Metrics Whether to expose metrics at /metrics Metrics bool `yaml:"metrics,omitempty"` // SkipInvalidConfigUpdate Whether to make the application ignore invalid configuration // if the configuration file is updated while the application is running SkipInvalidConfigUpdate bool `yaml:"skip-invalid-config-update,omitempty"` // DisableMonitoringLock Whether to disable the monitoring lock // The monitoring lock is what prevents multiple endpoints from being processed at the same time. // Disabling this may lead to inaccurate response times // // Deprecated: Use Concurrency instead TODO: REMOVE THIS IN v6.0.0 DisableMonitoringLock bool `yaml:"disable-monitoring-lock,omitempty"` // Concurrency is the maximum number of endpoints/suites that can be monitored concurrently // Defaults to DefaultConcurrency. Set to 0 for unlimited concurrency. Concurrency int `yaml:"concurrency,omitempty"` // Security is the configuration for securing access to Gatus Security *security.Config `yaml:"security,omitempty"` // Alerting is the configuration for alerting providers Alerting *alerting.Config `yaml:"alerting,omitempty"` // Endpoints is the list of endpoints to monitor Endpoints []*endpoint.Endpoint `yaml:"endpoints,omitempty"` // ExternalEndpoints is the list of all external endpoints ExternalEndpoints []*endpoint.ExternalEndpoint `yaml:"external-endpoints,omitempty"` // Suites is the list of suites to monitor Suites []*suite.Suite `yaml:"suites,omitempty"` // Storage is the configuration for how the data is stored Storage *storage.Config `yaml:"storage,omitempty"` // Web is the web configuration for the application Web *web.Config `yaml:"web,omitempty"` // UI is the configuration for the UI UI *ui.Config `yaml:"ui,omitempty"` // Maintenance is the configuration for creating a maintenance window in which no alerts are sent Maintenance *maintenance.Config `yaml:"maintenance,omitempty"` // Remote is the configuration for remote Gatus instances // WARNING: This is in ALPHA and may change or be completely removed in the future Remote *remote.Config `yaml:"remote,omitempty"` // Connectivity is the configuration for connectivity Connectivity *connectivity.Config `yaml:"connectivity,omitempty"` // Tunneling is the configuration for SSH tunneling Tunneling *tunneling.Config `yaml:"tunneling,omitempty"` // Announcements is the list of system-wide announcements Announcements []*announcement.Announcement `yaml:"announcements,omitempty"` configPath string // path to the file or directory from which config was loaded lastFileModTime time.Time // last modification time } // GetUniqueExtraMetricLabels returns a slice of unique metric labels from all enabled endpoints // in the configuration. It iterates through each endpoint, checks if it is enabled, // and then collects unique labels from the endpoint's labels map. func (config *Config) GetUniqueExtraMetricLabels() []string { labels := make([]string, 0) for _, ep := range config.Endpoints { if !ep.IsEnabled() { continue } for label := range ep.ExtraLabels { if contains(labels, label) { continue } labels = append(labels, label) } } if len(labels) > 1 { sort.Strings(labels) } return labels } func (config *Config) GetEndpointByKey(key string) *endpoint.Endpoint { for i := 0; i < len(config.Endpoints); i++ { ep := config.Endpoints[i] if ep.Key() == strings.ToLower(key) { return ep } } return nil } func (config *Config) GetExternalEndpointByKey(key string) *endpoint.ExternalEndpoint { for i := 0; i < len(config.ExternalEndpoints); i++ { ee := config.ExternalEndpoints[i] if ee.Key() == strings.ToLower(key) { return ee } } return nil } // HasLoadedConfigurationBeenModified returns whether one of the file that the // configuration has been loaded from has been modified since it was last read func (config *Config) HasLoadedConfigurationBeenModified() bool { lastMod := config.lastFileModTime.Unix() fileInfo, err := os.Stat(config.configPath) if err != nil { return false } if fileInfo.IsDir() { err = walkConfigDir(config.configPath, func(path string, d fs.DirEntry, err error) error { if info, err := d.Info(); err == nil && lastMod < info.ModTime().Unix() { return errEarlyReturn } return nil }) return errors.Is(err, errEarlyReturn) } return !fileInfo.ModTime().IsZero() && config.lastFileModTime.Unix() < fileInfo.ModTime().Unix() } // UpdateLastFileModTime refreshes Config.lastFileModTime func (config *Config) UpdateLastFileModTime() { config.lastFileModTime = time.Now() } // LoadConfiguration loads the full configuration composed of the main configuration file // and all composed configuration files func LoadConfiguration(configPath string) (*Config, error) { var configBytes []byte var fileInfo os.FileInfo var usedConfigPath string // Figure out what config path we'll use (either configPath or the default config path) for _, configurationPath := range []string{configPath, DefaultConfigurationFilePath, DefaultFallbackConfigurationFilePath} { if len(configurationPath) == 0 { continue } var err error fileInfo, err = os.Stat(configurationPath) if err != nil { continue } usedConfigPath = configurationPath break } if len(usedConfigPath) == 0 { return nil, ErrConfigFileNotFound } var config *Config if fileInfo.IsDir() { err := walkConfigDir(configPath, func(path string, d fs.DirEntry, err error) error { if err != nil { return fmt.Errorf("error walking path %s: %w", path, err) } if strings.Contains(path, "..") { logr.Warnf("[config.LoadConfiguration] Ignoring configuration from %s", path) return nil } logr.Infof("[config.LoadConfiguration] Reading configuration from %s", path) data, err := os.ReadFile(path) if err != nil { return fmt.Errorf("error reading configuration from file %s: %w", path, err) } configBytes, err = deepmerge.YAML(configBytes, data) return err }) if err != nil { return nil, fmt.Errorf("error reading configuration from directory %s: %w", usedConfigPath, err) } } else { logr.Infof("[config.LoadConfiguration] Reading configuration from configFile=%s", usedConfigPath) if data, err := os.ReadFile(usedConfigPath); err != nil { return nil, fmt.Errorf("error reading configuration from directory %s: %w", usedConfigPath, err) } else { configBytes = data } } if len(configBytes) == 0 { return nil, ErrConfigFileNotFound } config, err := parseAndValidateConfigBytes(configBytes) if err != nil { return nil, fmt.Errorf("error parsing config: %w", err) } config.configPath = usedConfigPath config.UpdateLastFileModTime() return config, nil } // walkConfigDir is a wrapper for filepath.WalkDir that strips directories and non-config files func walkConfigDir(path string, fn fs.WalkDirFunc) error { if len(path) == 0 { // If the user didn't provide a directory, we'll just use the default config file, so we can return nil now. return nil } return filepath.WalkDir(path, func(path string, d fs.DirEntry, err error) error { if err != nil { return nil } if d == nil || d.IsDir() { return nil } ext := filepath.Ext(path) if ext != ".yml" && ext != ".yaml" { return nil } return fn(path, d, err) }) } // parseAndValidateConfigBytes parses a Gatus configuration file into a Config struct and validates its parameters func parseAndValidateConfigBytes(yamlBytes []byte) (config *Config, err error) { // Replace $$ with __GATUS_LITERAL_DOLLAR_SIGN__ to prevent os.ExpandEnv from treating "$$" as if it was an // environment variable. This allows Gatus to support literal "$" in the configuration file. yamlBytes = []byte(strings.ReplaceAll(string(yamlBytes), "$$", "__GATUS_LITERAL_DOLLAR_SIGN__")) // Expand environment variables yamlBytes = []byte(os.ExpandEnv(string(yamlBytes))) // Replace __GATUS_LITERAL_DOLLAR_SIGN__ with "$" to restore the literal "$" in the configuration file yamlBytes = []byte(strings.ReplaceAll(string(yamlBytes), "__GATUS_LITERAL_DOLLAR_SIGN__", "$")) // Parse configuration file if err = yaml.Unmarshal(yamlBytes, &config); err != nil { return } // Check if the configuration file at least has endpoints configured if config == nil || (len(config.Endpoints) == 0 && len(config.Suites) == 0) { err = ErrNoEndpointOrSuiteInConfig } else { // XXX: Remove this in v6.0.0 if config.Debug { logr.Warn("WARNING: The 'debug' configuration has been deprecated and will be removed in v6.0.0") logr.Warn("WARNING: Please use the GATUS_LOG_LEVEL environment variable instead") } // XXX: End of v6.0.0 removals ValidateAlertingConfig(config.Alerting, config.Endpoints, config.ExternalEndpoints) if err := ValidateSecurityConfig(config); err != nil { return nil, err } if err := ValidateEndpointsConfig(config); err != nil { return nil, err } if err := ValidateWebConfig(config); err != nil { return nil, err } if err := ValidateUIConfig(config); err != nil { return nil, err } if err := ValidateMaintenanceConfig(config); err != nil { return nil, err } if err := ValidateStorageConfig(config); err != nil { return nil, err } if err := ValidateRemoteConfig(config); err != nil { return nil, err } if err := ValidateConnectivityConfig(config); err != nil { return nil, err } if err := ValidateTunnelingConfig(config); err != nil { return nil, err } if err := ValidateAnnouncementsConfig(config); err != nil { return nil, err } if err := ValidateSuitesConfig(config); err != nil { return nil, err } if err := ValidateUniqueKeys(config); err != nil { return nil, err } ValidateAndSetConcurrencyDefaults(config) // Cross-config changes config.UI.MaximumNumberOfResults = config.Storage.MaximumNumberOfResults } return } func ValidateConnectivityConfig(config *Config) error { if config.Connectivity != nil { return config.Connectivity.ValidateAndSetDefaults() } return nil } // ValidateTunnelingConfig validates the tunneling configuration and resolves tunnel references // NOTE: This must be called after ValidateEndpointsConfig and ValidateSuitesConfig // because it resolves tunnel references in endpoint and suite client configurations func ValidateTunnelingConfig(config *Config) error { if config.Tunneling != nil { if err := config.Tunneling.ValidateAndSetDefaults(); err != nil { return err } // Resolve tunnel references in all endpoints for _, ep := range config.Endpoints { if err := resolveTunnelForClientConfig(config, ep.ClientConfig); err != nil { return fmt.Errorf("endpoint '%s': %w", ep.Key(), err) } } // Resolve tunnel references in suite endpoints for _, s := range config.Suites { for _, ep := range s.Endpoints { if err := resolveTunnelForClientConfig(config, ep.ClientConfig); err != nil { return fmt.Errorf("suite '%s' endpoint '%s': %w", s.Key(), ep.Key(), err) } } } // TODO: Add tunnel support for alert providers when needed } return nil } // resolveTunnelForClientConfig resolves tunnel references in a client configuration func resolveTunnelForClientConfig(config *Config, clientConfig *client.Config) error { if clientConfig == nil || clientConfig.Tunnel == "" { return nil } // Validate tunnel name tunnelName := strings.TrimSpace(clientConfig.Tunnel) if tunnelName == "" { return fmt.Errorf("tunnel name cannot be empty") } if config.Tunneling == nil { return fmt.Errorf("tunnel '%s' referenced but no tunneling configuration defined", tunnelName) } _, exists := config.Tunneling.Tunnels[tunnelName] if !exists { return fmt.Errorf("tunnel '%s' not found in tunneling configuration", tunnelName) } // Get or create the SSH tunnel instance and store it directly in client config tunnel, err := config.Tunneling.GetTunnel(tunnelName) if err != nil { return fmt.Errorf("failed to get tunnel '%s': %w", tunnelName, err) } clientConfig.ResolvedTunnel = tunnel return nil } func ValidateAnnouncementsConfig(config *Config) error { if config.Announcements != nil { if err := announcement.ValidateAndSetDefaults(config.Announcements); err != nil { return err } // Sort announcements by timestamp (newest first) for API response announcement.SortByTimestamp(config.Announcements) } return nil } func ValidateRemoteConfig(config *Config) error { if config.Remote != nil { if err := config.Remote.ValidateAndSetDefaults(); err != nil { return err } } return nil } func ValidateStorageConfig(config *Config) error { if config.Storage == nil { config.Storage = &storage.Config{ Type: storage.TypeMemory, MaximumNumberOfResults: storage.DefaultMaximumNumberOfResults, MaximumNumberOfEvents: storage.DefaultMaximumNumberOfEvents, } } else { if err := config.Storage.ValidateAndSetDefaults(); err != nil { return err } } return nil } func ValidateMaintenanceConfig(config *Config) error { if config.Maintenance == nil { config.Maintenance = maintenance.GetDefaultConfig() } else { if err := config.Maintenance.ValidateAndSetDefaults(); err != nil { return err } } return nil } func ValidateUIConfig(config *Config) error { if config.UI == nil { config.UI = ui.GetDefaultConfig() } else { if err := config.UI.ValidateAndSetDefaults(); err != nil { return err } } return nil } func ValidateWebConfig(config *Config) error { if config.Web == nil { config.Web = web.GetDefaultConfig() } else { return config.Web.ValidateAndSetDefaults() } return nil } func ValidateEndpointsConfig(config *Config) error { duplicateValidationMap := make(map[string]bool) // Validate endpoints for _, ep := range config.Endpoints { logr.Debugf("[config.ValidateEndpointsConfig] Validating endpoint with key %s", ep.Key()) if endpointKey := ep.Key(); duplicateValidationMap[endpointKey] { return fmt.Errorf("invalid endpoint %s: name and group combination must be unique", ep.Key()) } else { duplicateValidationMap[endpointKey] = true } if err := ep.ValidateAndSetDefaults(); err != nil { return fmt.Errorf("invalid endpoint %s: %w", ep.Key(), err) } } logr.Infof("[config.ValidateEndpointsConfig] Validated %d endpoints", len(config.Endpoints)) // Validate external endpoints for _, ee := range config.ExternalEndpoints { logr.Debugf("[config.ValidateEndpointsConfig] Validating external endpoint '%s'", ee.Key()) if endpointKey := ee.Key(); duplicateValidationMap[endpointKey] { return fmt.Errorf("invalid external endpoint %s: name and group combination must be unique", ee.Key()) } else { duplicateValidationMap[endpointKey] = true } if err := ee.ValidateAndSetDefaults(); err != nil { return fmt.Errorf("invalid external endpoint %s: %w", ee.Key(), err) } } logr.Infof("[config.ValidateEndpointsConfig] Validated %d external endpoints", len(config.ExternalEndpoints)) return nil } func ValidateSuitesConfig(config *Config) error { if config.Suites == nil || len(config.Suites) == 0 { logr.Info("[config.ValidateSuitesConfig] No suites configured") return nil } suiteNames := make(map[string]bool) for _, suite := range config.Suites { // Check for duplicate suite names if suiteNames[suite.Name] { return fmt.Errorf("duplicate suite name: %s", suite.Key()) } suiteNames[suite.Name] = true // Validate the suite configuration if err := suite.ValidateAndSetDefaults(); err != nil { return fmt.Errorf("invalid suite '%s': %w", suite.Key(), err) } // Check that endpoints referenced in Store mappings use valid placeholders for _, suiteEndpoint := range suite.Endpoints { if suiteEndpoint.Store != nil { for contextKey, placeholder := range suiteEndpoint.Store { // Basic validation that the context key is a valid identifier if len(contextKey) == 0 { return fmt.Errorf("suite '%s' endpoint '%s' has empty context key in store mapping", suite.Key(), suiteEndpoint.Key()) } if len(placeholder) == 0 { return fmt.Errorf("suite '%s' endpoint '%s' has empty placeholder in store mapping for key '%s'", suite.Key(), suiteEndpoint.Key(), contextKey) } } } } } logr.Infof("[config.ValidateSuitesConfig] Validated %d suite(s)", len(config.Suites)) return nil } func ValidateUniqueKeys(config *Config) error { keyMap := make(map[string]string) // key -> description for error messages // Check all endpoints for _, ep := range config.Endpoints { epKey := ep.Key() if existing, exists := keyMap[epKey]; exists { return fmt.Errorf("duplicate key '%s': endpoint '%s' conflicts with %s", epKey, ep.Key(), existing) } keyMap[epKey] = fmt.Sprintf("endpoint '%s'", ep.Key()) } // Check all external endpoints for _, ee := range config.ExternalEndpoints { eeKey := ee.Key() if existing, exists := keyMap[eeKey]; exists { return fmt.Errorf("duplicate key '%s': external endpoint '%s' conflicts with %s", eeKey, ee.Key(), existing) } keyMap[eeKey] = fmt.Sprintf("external endpoint '%s'", ee.Key()) } // Check all suites for _, suite := range config.Suites { suiteKey := suite.Key() if existing, exists := keyMap[suiteKey]; exists { return fmt.Errorf("duplicate key '%s': suite '%s' conflicts with %s", suiteKey, suite.Key(), existing) } keyMap[suiteKey] = fmt.Sprintf("suite '%s'", suite.Key()) // Check endpoints within suites (they generate keys using suite group + endpoint name) for _, ep := range suite.Endpoints { epKey := key.ConvertGroupAndNameToKey(suite.Group, ep.Name) if existing, exists := keyMap[epKey]; exists { return fmt.Errorf("duplicate key '%s': endpoint '%s' in suite '%s' conflicts with %s", epKey, epKey, suite.Key(), existing) } keyMap[epKey] = fmt.Sprintf("endpoint '%s' in suite '%s'", epKey, suite.Key()) } } return nil } func ValidateSecurityConfig(config *Config) error { if config.Security != nil { if !config.Security.ValidateAndSetDefaults() { logr.Debug("[config.ValidateSecurityConfig] Basic security configuration has been validated") return ErrInvalidSecurityConfig } } return nil } // ValidateAlertingConfig validates the alerting configuration // Note that the alerting configuration has to be validated before the endpoint configuration, because the default alert // returned by provider.AlertProvider.GetDefaultAlert() must be parsed before endpoint.Endpoint.ValidateAndSetDefaults() // sets the default alert values when none are set. func ValidateAlertingConfig(alertingConfig *alerting.Config, endpoints []*endpoint.Endpoint, externalEndpoints []*endpoint.ExternalEndpoint) { if alertingConfig == nil { logr.Info("[config.ValidateAlertingConfig] Alerting is not configured") return } alertTypes := []alert.Type{ alert.TypeAWSSES, alert.TypeCustom, alert.TypeDatadog, alert.TypeDiscord, alert.TypeEmail, alert.TypeGitHub, alert.TypeGitLab, alert.TypeGitea, alert.TypeGoogleChat, alert.TypeGotify, alert.TypeHomeAssistant, alert.TypeIFTTT, alert.TypeIlert, alert.TypeIncidentIO, alert.TypeLine, alert.TypeMatrix, alert.TypeMattermost, alert.TypeMessagebird, alert.TypeN8N, alert.TypeNewRelic, alert.TypeNtfy, alert.TypeOpsgenie, alert.TypePagerDuty, alert.TypePlivo, alert.TypePushover, alert.TypeRocketChat, alert.TypeSendGrid, alert.TypeSignal, alert.TypeSIGNL4, alert.TypeSlack, alert.TypeSplunk, alert.TypeSquadcast, alert.TypeTeams, alert.TypeTeamsWorkflows, alert.TypeTelegram, alert.TypeTwilio, alert.TypeVonage, alert.TypeWebex, alert.TypeZapier, alert.TypeZulip, } var validProviders, invalidProviders []alert.Type for _, alertType := range alertTypes { alertProvider := alertingConfig.GetAlertingProviderByAlertType(alertType) if alertProvider != nil { if err := alertProvider.Validate(); err == nil { // Parse alerts with the provider's default alert if alertProvider.GetDefaultAlert() != nil { for _, ep := range endpoints { for alertIndex, endpointAlert := range ep.Alerts { if alertType == endpointAlert.Type { logr.Debugf("[config.ValidateAlertingConfig] Parsing alert %d with default alert for provider=%s in endpoint with key=%s", alertIndex, alertType, ep.Key()) provider.MergeProviderDefaultAlertIntoEndpointAlert(alertProvider.GetDefaultAlert(), endpointAlert) // Validate the endpoint alert's overrides, if applicable if len(endpointAlert.ProviderOverride) > 0 { if err = alertProvider.ValidateOverrides(ep.Group, endpointAlert); err != nil { logr.Warnf("[config.ValidateAlertingConfig] endpoint with key=%s has invalid overrides for provider=%s: %s", ep.Key(), alertType, err.Error()) } } } } } for _, ee := range externalEndpoints { for alertIndex, endpointAlert := range ee.Alerts { if alertType == endpointAlert.Type { logr.Debugf("[config.ValidateAlertingConfig] Parsing alert %d with default alert for provider=%s in endpoint with key=%s", alertIndex, alertType, ee.Key()) provider.MergeProviderDefaultAlertIntoEndpointAlert(alertProvider.GetDefaultAlert(), endpointAlert) // Validate the endpoint alert's overrides, if applicable if len(endpointAlert.ProviderOverride) > 0 { if err = alertProvider.ValidateOverrides(ee.Group, endpointAlert); err != nil { logr.Warnf("[config.ValidateAlertingConfig] endpoint with key=%s has invalid overrides for provider=%s: %s", ee.Key(), alertType, err.Error()) } } } } } } validProviders = append(validProviders, alertType) } else { logr.Warnf("[config.ValidateAlertingConfig] Ignoring provider=%s due to error=%s", alertType, err.Error()) invalidProviders = append(invalidProviders, alertType) alertingConfig.SetAlertingProviderToNil(alertProvider) } } else { invalidProviders = append(invalidProviders, alertType) } } logr.Infof("[config.ValidateAlertingConfig] configuredProviders=%s; ignoredProviders=%s", validProviders, invalidProviders) } func ValidateAndSetConcurrencyDefaults(config *Config) { if config.DisableMonitoringLock { config.Concurrency = 0 logr.Warn("WARNING: The 'disable-monitoring-lock' configuration has been deprecated and will be removed in v6.0.0") logr.Warn("WARNING: Please set 'concurrency: 0' instead") logr.Debug("[config.ValidateAndSetConcurrencyDefaults] DisableMonitoringLock is true, setting unlimited (0) concurrency") } else if config.Concurrency <= 0 && !config.DisableMonitoringLock { config.Concurrency = DefaultConcurrency logr.Debugf("[config.ValidateAndSetConcurrencyDefaults] Setting default concurrency to %d", config.Concurrency) } else { logr.Debugf("[config.ValidateAndSetConcurrencyDefaults] Using configured concurrency of %d", config.Concurrency) } }