init work of uptime

This commit is contained in:
Blake Ridgway
2026-03-22 11:30:31 -05:00
parent 854cba4c24
commit f0db70c840
18 changed files with 2252 additions and 0 deletions

260
internal/config/config.go Normal file
View File

@@ -0,0 +1,260 @@
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
}