package config import ( "fmt" "os" "regexp" "strings" "time" "unicode/utf8" "github.com/BurntSushi/toml" ) var idPattern = regexp.MustCompile(`^[A-Za-z0-9._:-]+$`) const ( maxIDLen = 128 maxLabelKeyLen = 64 maxLabelValueLen = 256 maxLabels = 32 AgentVersionLabel = "monlet_agent_version" defaultHeartbeat = 30 * time.Second defaultBatch = 10 * time.Second defaultMetricsAddr = "127.0.0.1:9465" ) type Config struct { AgentID string `toml:"agent_id"` Hostname string `toml:"hostname"` StateDir string `toml:"state_dir"` Labels map[string]string `toml:"labels"` Server ServerConfig `toml:"server"` Metrics MetricsConfig `toml:"metrics"` Checks []CheckConfig `toml:"checks"` } type ServerConfig struct { Enabled *bool `toml:"enabled"` URL string `toml:"url"` Token string `toml:"token"` HeartbeatInterval Duration `toml:"heartbeat_interval"` BatchInterval Duration `toml:"batch_interval"` } type MetricsConfig struct { Enabled bool `toml:"enabled"` Listen string `toml:"listen"` } type CheckConfig struct { ID string `toml:"id"` Name string `toml:"name"` Command string `toml:"command"` Interval Duration `toml:"interval"` Timeout Duration `toml:"timeout"` NotificationsEnabled *bool `toml:"notifications_enabled"` DedupeKey string `toml:"dedupe_key"` } type Duration struct{ time.Duration } func (d *Duration) UnmarshalText(b []byte) error { v, err := time.ParseDuration(string(b)) if err != nil { return err } d.Duration = v return nil } func Load(path string) (*Config, error) { var c Config md, err := toml.DecodeFile(path, &c) if err != nil { return nil, fmt.Errorf("read config: %w", err) } if undecoded := md.Undecoded(); len(undecoded) > 0 { keys := make([]string, len(undecoded)) for i, key := range undecoded { keys[i] = key.String() } return nil, fmt.Errorf("unknown config keys: %s", strings.Join(keys, ", ")) } c.applyDefaults() if err := c.Validate(); err != nil { return nil, err } return &c, nil } func (c *Config) applyDefaults() { if c.Hostname == "" { if h, err := os.Hostname(); err == nil { c.Hostname = h } } if c.Server.HeartbeatInterval.Duration == 0 { c.Server.HeartbeatInterval.Duration = defaultHeartbeat } if c.Server.BatchInterval.Duration == 0 { c.Server.BatchInterval.Duration = defaultBatch } if c.Metrics.Listen == "" { c.Metrics.Listen = defaultMetricsAddr } } func (c *Config) Validate() error { if c.AgentID != "" { if err := ValidateID("agent_id", c.AgentID); err != nil { return err } } if c.Hostname == "" { return fmt.Errorf("hostname is required") } if c.StateDir == "" { return fmt.Errorf("state_dir is required") } if err := validateLabels(c.Labels); err != nil { return err } if c.PushesToServer() { if c.Server.URL == "" { return fmt.Errorf("server.url is required when server.enabled = true") } if c.Server.Token == "" { return fmt.Errorf("server.token is required when server.enabled = true") } } else if c.Server.URL != "" || c.Server.Token != "" { return fmt.Errorf("server.url/token require server.enabled = true") } if !c.PushesToServer() && !c.ExposesMetrics() { return fmt.Errorf("server.enabled or metrics.enabled must be true") } if len(c.Checks) == 0 { return fmt.Errorf("at least one check is required") } seen := make(map[string]struct{}, len(c.Checks)) for i := range c.Checks { ch := &c.Checks[i] if err := ValidateID("checks[].id", ch.ID); err != nil { return err } if _, dup := seen[ch.ID]; dup { return fmt.Errorf("duplicate check id %q", ch.ID) } seen[ch.ID] = struct{}{} if strings.TrimSpace(ch.Command) == "" { return fmt.Errorf("check %q: command is required", ch.ID) } if ch.Interval.Duration <= 0 { return fmt.Errorf("check %q: interval must be > 0", ch.ID) } if ch.Timeout.Duration <= 0 { return fmt.Errorf("check %q: timeout must be > 0", ch.ID) } if ch.Timeout.Duration > ch.Interval.Duration { return fmt.Errorf("check %q: timeout must be <= interval", ch.ID) } if len(ch.DedupeKey) > 256 { return fmt.Errorf("check %q: dedupe_key too long", ch.ID) } } return nil } // PushesToServer is true when heartbeat/events push is enabled. func (c *Config) PushesToServer() bool { return c.Server.Enabled != nil && *c.Server.Enabled } // ExposesMetrics is true when the agent should serve /metrics. func (c *Config) ExposesMetrics() bool { return c.Metrics.Enabled } func (c *Config) HeartbeatLabels(version string) map[string]string { labels := make(map[string]string, len(c.Labels)+1) for k, v := range c.Labels { labels[k] = v } labels[AgentVersionLabel] = version return labels } func (c CheckConfig) NotificationsOn() bool { return c.NotificationsEnabled == nil || *c.NotificationsEnabled } func (c CheckConfig) Argv() []string { return []string{"/bin/sh", "-c", c.Command} } func ValidateID(field, v string) error { if v == "" { return fmt.Errorf("%s is empty", field) } if len(v) > maxIDLen { return fmt.Errorf("%s exceeds %d chars", field, maxIDLen) } if !idPattern.MatchString(v) { return fmt.Errorf("%s has invalid characters", field) } return nil } func validateLabels(labels map[string]string) error { if len(labels) > maxLabels { return fmt.Errorf("labels exceed %d entries", maxLabels) } if _, hasVersion := labels[AgentVersionLabel]; !hasVersion && len(labels) >= maxLabels { return fmt.Errorf("labels leave no room for reserved %q label", AgentVersionLabel) } for k, v := range labels { if k == "" { return fmt.Errorf("label key is empty") } if len(k) > maxLabelKeyLen { return fmt.Errorf("label key %q exceeds %d chars", k, maxLabelKeyLen) } if !idPattern.MatchString(k) { return fmt.Errorf("label key %q has invalid characters", k) } if isSensitiveLabelKey(k) { return fmt.Errorf("label key %q is not allowed", k) } if utf8.RuneCountInString(v) > maxLabelValueLen { return fmt.Errorf("label %q value exceeds %d chars", k, maxLabelValueLen) } } return nil } func isSensitiveLabelKey(k string) bool { normalized := strings.NewReplacer("-", "_", ".", "_", ":", "_").Replace(strings.ToLower(k)) for _, word := range []string{"token", "secret", "password", "credential", "authorization", "cookie", "api_key", "apikey"} { if strings.Contains(normalized, word) { return true } } return false }