package config import ( "fmt" "math" "os" "regexp" "strconv" "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 // PH-019: bounded check inventory keeps {check_id} metric cardinality // predictable. ValidateID already restricts each check_id to a stable // identifier alphabet; this cap bounds the total. Override via // MONLET_AGENT_MAX_CHECKS for fleets that genuinely need more. defaultMaxChecks = 256 AgentVersionLabel = "monlet_agent_version" defaultHeartbeat = 10 * time.Second defaultBatch = 10 * time.Second defaultMetricsAddr = "127.0.0.1:9465" ) type Config struct { AgentID string `toml:"agent_id"` Hostname string `toml:"-"` 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"` 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"` ResourceLimits ResourceLimits `toml:"resource_limits"` } type Duration struct{ time.Duration } // ByteSize stores a parsed TOML byte size. type ByteSize struct{ Bytes int64 } // ResourceLimits defines optional per-check OS resource limits. type ResourceLimits struct { CPUTime Duration `toml:"cpu_time"` Memory ByteSize `toml:"memory"` OpenFiles int `toml:"open_files"` } func (d *Duration) UnmarshalText(b []byte) error { v, err := time.ParseDuration(string(b)) if err != nil { return err } d.Duration = v return nil } func (s *ByteSize) UnmarshalText(b []byte) error { raw := strings.TrimSpace(string(b)) if raw == "" { return fmt.Errorf("empty byte size") } valueEnd := 0 for valueEnd < len(raw) && ((raw[valueEnd] >= '0' && raw[valueEnd] <= '9') || raw[valueEnd] == '.') { valueEnd++ } if valueEnd == 0 { return fmt.Errorf("invalid byte size %q", raw) } value, err := strconv.ParseFloat(raw[:valueEnd], 64) if err != nil || value <= 0 { return fmt.Errorf("invalid byte size %q", raw) } unit := strings.ToLower(strings.TrimSpace(raw[valueEnd:])) mul, ok := map[string]float64{ "": 1, "b": 1, "k": 1000, "kb": 1000, "m": 1000 * 1000, "mb": 1000 * 1000, "g": 1000 * 1000 * 1000, "gb": 1000 * 1000 * 1000, "kib": 1024, "mib": 1024 * 1024, "gib": 1024 * 1024 * 1024, }[unit] if !ok { return fmt.Errorf("unknown byte size unit %q", unit) } bytes := value * mul if bytes > math.MaxInt64 { return fmt.Errorf("byte size %q is too large", raw) } s.Bytes = int64(bytes) 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") } } else if c.Server.URL != "" { return fmt.Errorf("server.url requires 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") } maxChecks := defaultMaxChecks if v, ok := os.LookupEnv("MONLET_AGENT_MAX_CHECKS"); ok { n, err := strconv.Atoi(v) if err != nil || n <= 0 { return fmt.Errorf("MONLET_AGENT_MAX_CHECKS must be a positive integer, got %q", v) } maxChecks = n } if len(c.Checks) > maxChecks { return fmt.Errorf("too many checks: %d (max %d) — bounded for metric cardinality (PH-019)", len(c.Checks), maxChecks) } 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) } if err := validateResourceLimits(ch.ID, ch.ResourceLimits); err != nil { return err } } 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 validateResourceLimits(checkID string, limits ResourceLimits) error { if limits.CPUTime.Duration < 0 { return fmt.Errorf("check %q: resource_limits.cpu_time must be > 0", checkID) } if limits.Memory.Bytes < 0 { return fmt.Errorf("check %q: resource_limits.memory must be > 0", checkID) } if limits.Memory.Bytes > 0 && limits.Memory.Bytes < 1024*1024 { return fmt.Errorf("check %q: resource_limits.memory must be >= 1MiB", checkID) } if limits.OpenFiles < 0 { return fmt.Errorf("check %q: resource_limits.open_files must be > 0", checkID) } if limits.OpenFiles > 0 && limits.OpenFiles < 16 { return fmt.Errorf("check %q: resource_limits.open_files must be >= 16", checkID) } return nil } 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 }