Files
monlet/agent/internal/config/config.go
2026-05-27 10:01:59 +04:00

244 lines
6.4 KiB
Go

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
}