// Package config loads and validates statuspanel YAML configuration. package config import ( "context" "errors" "fmt" "os" "time" "gopkg.in/yaml.v3" ) const ( // DefaultPath is the default configuration path used by statuspanel. DefaultPath = "/etc/statuspanel.yaml" ) // Duration wraps time.Duration with YAML string unmarshalling. type Duration struct { time.Duration } // UnmarshalYAML decodes duration strings such as "2s" or "500ms". func (d *Duration) UnmarshalYAML(value *yaml.Node) error { if value.Kind != yaml.ScalarNode { return fmt.Errorf("duration must be a scalar") } parsed, err := time.ParseDuration(value.Value) if err != nil { return fmt.Errorf("parsing duration %q: %w", value.Value, err) } d.Duration = parsed return nil } // Config describes statuspanel runtime configuration. type Config struct { Interface string `yaml:"interface"` Gateway string `yaml:"gateway"` Refresh Duration `yaml:"refresh"` DNS DNSConfig `yaml:"dns"` Ping PingConfig `yaml:"ping"` Targets []PingTarget `yaml:"targets"` } // DNSConfig describes the DNS probe. type DNSConfig struct { Server string `yaml:"server"` Domain string `yaml:"domain"` Timeout Duration `yaml:"timeout"` } // PingConfig describes ICMP ping behavior. type PingConfig struct { Timeout Duration `yaml:"timeout"` } // PingTarget describes a host displayed in the reachability table. type PingTarget struct { Name string `yaml:"name"` Address string `yaml:"address"` } // Default returns a configuration with safe runtime defaults. func Default() Config { return Config{ Refresh: Duration{Duration: 2 * time.Second}, DNS: DNSConfig{ Timeout: Duration{Duration: time.Second}, }, Ping: PingConfig{ Timeout: Duration{Duration: time.Second}, }, } } // Load reads and validates a YAML configuration file. func Load(ctx context.Context, path string) (Config, error) { if path == "" { path = DefaultPath } if err := ctx.Err(); err != nil { return Config{}, fmt.Errorf("checking context before loading config: %w", err) } data, err := os.ReadFile(path) if err != nil { return Config{}, fmt.Errorf("reading config %s: %w", path, err) } if err := ctx.Err(); err != nil { return Config{}, fmt.Errorf("checking context after loading config: %w", err) } cfg := Default() if err := yaml.Unmarshal(data, &cfg); err != nil { return Config{}, fmt.Errorf("parsing config %s: %w", path, err) } if err := cfg.Validate(); err != nil { return Config{}, fmt.Errorf("validating config %s: %w", path, err) } return cfg, nil } // Validate checks whether the configuration can drive all probes. func (c Config) Validate() error { var errs []error if c.Interface == "" { errs = append(errs, errors.New("interface is required")) } if c.Refresh.Duration <= 0 { errs = append(errs, errors.New("refresh must be greater than zero")) } if c.DNS.Server == "" { errs = append(errs, errors.New("dns.server is required")) } if c.DNS.Domain == "" { errs = append(errs, errors.New("dns.domain is required")) } if c.DNS.Timeout.Duration <= 0 { errs = append(errs, errors.New("dns.timeout must be greater than zero")) } if c.Ping.Timeout.Duration <= 0 { errs = append(errs, errors.New("ping.timeout must be greater than zero")) } for i, target := range c.Targets { if target.Address == "" { errs = append(errs, fmt.Errorf("targets[%d].address is required", i)) } } return errors.Join(errs...) }