Files
uptime/internal/config/config.go
2026-03-22 11:30:31 -05:00

261 lines
6.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package config
import (
"fmt"
"os"
"strconv"
"strings"
"time"
"gopkg.in/yaml.v3"
)
type Config struct {
Global GlobalConfig `yaml:"global"`
Alerts []AlertConfig `yaml:"alerts"`
Monitors []MonitorConfig `yaml:"monitors"`
Dashboard DashboardConfig `yaml:"dashboard"`
}
type GlobalConfig struct {
CheckInterval int `yaml:"check_interval"` // seconds
Timeout int `yaml:"timeout"` // seconds
AlertCooldown int `yaml:"alert_cooldown"` // seconds
RetentionDays int `yaml:"retention_days"` // 0 = keep forever
LogFormat string `yaml:"log_format"` // "text" (default) | "json"
}
// StringSlice accepts either a single YAML string or a YAML sequence.
type StringSlice []string
func (s *StringSlice) UnmarshalYAML(value *yaml.Node) error {
if value.Kind == yaml.ScalarNode {
*s = StringSlice{value.Value}
return nil
}
var strs []string
if err := value.Decode(&strs); err != nil {
return err
}
*s = strs
return nil
}
type AlertConfig struct {
Name string `yaml:"name"` // optional; used for per-monitor routing
Type string `yaml:"type"` // "discord" | "slack" | "email" | "ntfy" | "gotify"
WebhookURL string `yaml:"webhook_url"` // discord / slack
SMTPHost string `yaml:"smtp_host"`
SMTPPort int `yaml:"smtp_port"`
From string `yaml:"from"`
To StringSlice `yaml:"to"` // one or more email addresses
Username string `yaml:"username"`
Password string `yaml:"password"`
URL string `yaml:"url"` // ntfy topic URL or gotify server base URL
Token string `yaml:"token"` // ntfy bearer token or gotify app token
Priority int `yaml:"priority"` // gotify priority; default 5
}
// MaintenanceWindow suppresses alerts during a recurring time range.
type MaintenanceWindow struct {
Days []string `yaml:"days"` // abbreviated weekday names ("mon"…"sun"), or "*" for all
Start string `yaml:"start"` // "HH:MM" in server local time
End string `yaml:"end"` // "HH:MM"
}
// Active reports whether the window covers t.
func (w *MaintenanceWindow) Active(t time.Time) bool {
if len(w.Days) > 0 {
day := strings.ToLower(t.Weekday().String()[:3])
found := false
for _, d := range w.Days {
if d == "*" || strings.ToLower(d) == day {
found = true
break
}
}
if !found {
return false
}
}
startH, startM, err1 := parseHHMM(w.Start)
endH, endM, err2 := parseHHMM(w.End)
if err1 != nil || err2 != nil {
return false
}
now := t.Hour()*60 + t.Minute()
start := startH*60 + startM
end := endH*60 + endM
if start <= end {
return now >= start && now < end
}
// Crosses midnight (e.g. 23:0002:00)
return now >= start || now < end
}
func parseHHMM(s string) (int, int, error) {
parts := strings.SplitN(s, ":", 2)
if len(parts) != 2 {
return 0, 0, fmt.Errorf("invalid time %q", s)
}
h, err := strconv.Atoi(parts[0])
if err != nil {
return 0, 0, err
}
m, err := strconv.Atoi(parts[1])
if err != nil {
return 0, 0, err
}
return h, m, nil
}
type MonitorConfig struct {
Name string `yaml:"name"`
Type string `yaml:"type"` // "http" | "tcp" | "tls" | "dns"
// HTTP
URL string `yaml:"url"`
Method string `yaml:"method"` // default "GET"
Body string `yaml:"body"`
Headers map[string]string `yaml:"headers"`
ExpectedStatus int `yaml:"expected_status"` // default 200
Contains string `yaml:"contains"`
// TCP / TLS / DNS
Host string `yaml:"host"`
Port int `yaml:"port"`
// TLS
ExpiryWarningDays int `yaml:"expiry_warning_days"` // default 14
// DNS
ExpectedIP string `yaml:"expected_ip"` // optional; assert resolved IPs include this
// Thresholds
MaxResponseMS int64 `yaml:"max_response_ms"` // 0 = no threshold
// Per-monitor scheduling
Interval int `yaml:"interval"` // seconds; 0 = global default
Timeout int `yaml:"timeout"` // seconds; 0 = global default
// Alerting
AlertNames []string `yaml:"alert_names"` // empty = all alerters
Maintenance []MaintenanceWindow `yaml:"maintenance"`
}
type DashboardConfig struct {
Enabled bool `yaml:"enabled"`
Listen string `yaml:"listen"`
Username string `yaml:"username"`
Password string `yaml:"password"`
}
func Load(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read config: %w", err)
}
var cfg Config
if err := yaml.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("parse config: %w", err)
}
applyDefaults(&cfg)
if err := validate(&cfg); err != nil {
return nil, err
}
return &cfg, nil
}
func applyDefaults(cfg *Config) {
if cfg.Global.CheckInterval == 0 {
cfg.Global.CheckInterval = 60
}
if cfg.Global.Timeout == 0 {
cfg.Global.Timeout = 10
}
if cfg.Global.AlertCooldown == 0 {
cfg.Global.AlertCooldown = 300
}
if cfg.Global.LogFormat == "" {
cfg.Global.LogFormat = "text"
}
if cfg.Dashboard.Listen == "" {
cfg.Dashboard.Listen = ":8081"
}
for i := range cfg.Monitors {
m := &cfg.Monitors[i]
if m.Type == "http" {
if m.ExpectedStatus == 0 {
m.ExpectedStatus = 200
}
if m.Method == "" {
m.Method = "GET"
}
}
if m.Type == "tls" {
if m.ExpiryWarningDays == 0 {
m.ExpiryWarningDays = 14
}
if m.Port == 0 {
m.Port = 443
}
}
if m.Interval == 0 {
m.Interval = cfg.Global.CheckInterval
}
if m.Timeout == 0 {
m.Timeout = cfg.Global.Timeout
}
}
for i := range cfg.Alerts {
if cfg.Alerts[i].Type == "gotify" && cfg.Alerts[i].Priority == 0 {
cfg.Alerts[i].Priority = 5
}
}
}
func validate(cfg *Config) error {
names := make(map[string]bool)
for _, m := range cfg.Monitors {
if m.Name == "" {
return fmt.Errorf("monitor missing name")
}
if names[m.Name] {
return fmt.Errorf("duplicate monitor name: %q", m.Name)
}
names[m.Name] = true
switch m.Type {
case "http":
if m.URL == "" {
return fmt.Errorf("monitor %q: missing url", m.Name)
}
case "tcp", "tls":
if m.Host == "" || m.Port == 0 {
return fmt.Errorf("monitor %q: missing host or port", m.Name)
}
case "dns":
if m.Host == "" {
return fmt.Errorf("monitor %q: missing host", m.Name)
}
default:
return fmt.Errorf("monitor %q: unknown type %q", m.Name, m.Type)
}
}
// Build set of named alerters to validate monitor alert_names references.
alertNames := make(map[string]bool)
for _, a := range cfg.Alerts {
if a.Name != "" {
alertNames[a.Name] = true
}
}
for _, m := range cfg.Monitors {
for _, an := range m.AlertNames {
if !alertNames[an] {
return fmt.Errorf("monitor %q: alert_name %q not defined", m.Name, an)
}
}
}
return nil
}