372 lines
10 KiB
Go
372 lines
10 KiB
Go
package config
|
|
|
|
import (
|
|
"fmt"
|
|
"math"
|
|
"os"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"github.com/BurntSushi/toml"
|
|
"github.com/robfig/cron/v3"
|
|
)
|
|
|
|
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"`
|
|
Cron string `toml:"cron"`
|
|
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.Timeout.Duration <= 0 {
|
|
return fmt.Errorf("check %q: timeout must be > 0", ch.ID)
|
|
}
|
|
if ch.Interval.Duration < 0 {
|
|
return fmt.Errorf("check %q: interval must be > 0", ch.ID)
|
|
}
|
|
ch.Cron = strings.TrimSpace(ch.Cron)
|
|
hasInterval := ch.Interval.Duration > 0
|
|
hasCron := ch.Cron != ""
|
|
switch {
|
|
case hasInterval == hasCron:
|
|
return fmt.Errorf("check %q: exactly one of interval or cron is required", ch.ID)
|
|
case hasInterval && ch.Timeout.Duration > ch.Interval.Duration:
|
|
return fmt.Errorf("check %q: timeout must be <= interval", ch.ID)
|
|
case hasCron:
|
|
if _, err := ParseCronSchedule(ch.Cron); err != nil {
|
|
return fmt.Errorf("check %q: invalid cron: %w", ch.ID, err)
|
|
}
|
|
}
|
|
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 (c CheckConfig) UsesInterval() bool {
|
|
return c.Interval.Duration > 0
|
|
}
|
|
|
|
func (c CheckConfig) UsesCron() bool {
|
|
return strings.TrimSpace(c.Cron) != ""
|
|
}
|
|
|
|
func ParseCronSchedule(spec string) (cron.Schedule, error) {
|
|
spec = strings.TrimSpace(spec)
|
|
if spec == "" {
|
|
return nil, fmt.Errorf("cron is empty")
|
|
}
|
|
withoutTZ := specWithoutTZ(spec)
|
|
lower := strings.ToLower(withoutTZ)
|
|
if strings.HasPrefix(lower, "@every") {
|
|
return nil, fmt.Errorf("@every is not supported; use interval")
|
|
}
|
|
if lower == "@reboot" {
|
|
return nil, fmt.Errorf("@reboot is not supported")
|
|
}
|
|
return cron.ParseStandard(spec)
|
|
}
|
|
|
|
func specWithoutTZ(spec string) string {
|
|
fields := strings.Fields(spec)
|
|
if len(fields) == 0 {
|
|
return ""
|
|
}
|
|
first := fields[0]
|
|
if strings.HasPrefix(first, "CRON_TZ=") || strings.HasPrefix(first, "TZ=") {
|
|
return strings.ToLower(strings.Join(fields[1:], " "))
|
|
}
|
|
return strings.ToLower(strings.Join(fields, " "))
|
|
}
|
|
|
|
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
|
|
}
|