// Package sitemon checks the reachability of configured targets (ping, http, tcp). // Each scanner instance monitors from its own site perspective, giving you // per-site uptime visibility across all your locations. package sitemon import ( "bytes" "context" "crypto/tls" "encoding/json" "fmt" "log/slog" "net" "net/http" "net/smtp" "os/exec" "runtime" "strings" "time" "github.com/cereda-systems/nexus-scanner/internal/config" "github.com/cereda-systems/nexus-scanner/internal/db" ) const modName = "site_monitoring" // Module runs periodic availability checks and stores results in the DB. type Module struct { site string cfg config.SiteMonConfig alertCfg config.AlertConfig nexusCfg config.Config store *db.Store client *http.Client alertHTTP *http.Client // track previous state per check ID to detect transitions prevState map[int64]string } // New creates a new site monitoring module. func New(site string, cfg config.SiteMonConfig, alertCfg config.AlertConfig, nexusCfg config.Config, store *db.Store) *Module { return &Module{ site: site, cfg: cfg, alertCfg: alertCfg, nexusCfg: nexusCfg, store: store, prevState: make(map[int64]string), alertHTTP: &http.Client{Timeout: 10 * time.Second}, client: &http.Client{ Timeout: 10 * time.Second, CheckRedirect: func(req *http.Request, via []*http.Request) error { if len(via) >= 5 { return fmt.Errorf("too many redirects") } return nil }, }, } } func (m *Module) Name() string { return modName } func (m *Module) Interval() time.Duration { return m.cfg.Interval } // Run executes all enabled checks once and stores results. func (m *Module) Run(ctx context.Context) error { checks, err := m.store.ListMonitorChecks() if err != nil { return fmt.Errorf("list checks: %w", err) } if len(checks) == 0 { return nil } scanID, err := m.store.BeginScan(modName) if err != nil { return fmt.Errorf("begin scan: %w", err) } for _, check := range checks { if !check.Enabled { continue } result := m.runCheck(ctx, check) if err := m.store.InsertMonitorResult(result); err != nil { slog.Error("insert monitor result", "check", check.Name, "err", err) } slog.Debug("monitor check done", "name", check.Name, "status", result.Status, "latency_ms", result.LatencyMS) // Detect state transitions and fire alerts. if prev, seen := m.prevState[check.ID]; seen && prev != result.Status { go m.sendAlert(check, result) } m.prevState[check.ID] = result.Status } _ = m.store.EndScan(scanID, nil) _ = m.store.CleanupMonitorResults(100) return nil } func (m *Module) runCheck(ctx context.Context, check db.MonitorCheck) db.MonitorResult { result := db.MonitorResult{ CheckID: check.ID, CheckedAt: time.Now(), } start := time.Now() var checkErr error switch check.Type { case "ping": checkErr = doPing(ctx, check.Target) case "http", "https": checkErr = doHTTP(ctx, m.client, check.Target) case "tcp": checkErr = doTCP(ctx, check.Target) default: checkErr = fmt.Errorf("unknown type: %s", check.Type) } result.LatencyMS = int(time.Since(start).Milliseconds()) if checkErr != nil { result.Status = "offline" result.Error = checkErr.Error() } else { result.Status = "online" } return result } func doPing(ctx context.Context, target string) error { var args []string if runtime.GOOS == "windows" { args = []string{"-n", "1", "-w", "2000", target} } else { args = []string{"-c", "1", "-W", "2", target} } cmd := exec.CommandContext(ctx, "ping", args...) out, err := cmd.Output() if err != nil { return fmt.Errorf("ping: %w", err) } lower := strings.ToLower(string(out)) if strings.Contains(lower, "unreachable") || strings.Contains(lower, "timed out") || strings.Contains(lower, "100% loss") || strings.Contains(lower, "100% packet loss") { return fmt.Errorf("host unreachable") } return nil } func doHTTP(ctx context.Context, client *http.Client, target string) error { if !strings.HasPrefix(target, "http://") && !strings.HasPrefix(target, "https://") { target = "https://" + target } req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil) if err != nil { return err } req.Header.Set("User-Agent", "NexusScanner/1.0") resp, err := client.Do(req) if err != nil { return err } resp.Body.Close() if resp.StatusCode >= 500 { return fmt.Errorf("HTTP %d", resp.StatusCode) } return nil } func doTCP(ctx context.Context, target string) error { d := net.Dialer{Timeout: 5 * time.Second} conn, err := d.DialContext(ctx, "tcp", target) if err != nil { return err } conn.Close() return nil } /* ── Alerts ───────────────────────────────────────────────────────── */ func (m *Module) sendAlert(check db.MonitorCheck, result db.MonitorResult) { emoji := "🔴" word := "OFFLINE" if result.Status == "online" { emoji = "✅" word = "WIEDER ONLINE" } subject := fmt.Sprintf("[Nexus Scanner %s] %s %s: %s", m.site, emoji, word, check.Name) body := fmt.Sprintf( "Standort: %s\nCheck: %s (%s)\nZiel: %s\nStatus: %s\nZeit: %s\n", m.site, check.Name, check.Type, check.Target, strings.ToUpper(result.Status), result.CheckedAt.Format("02.01.2006 15:04:05"), ) if result.Error != "" { body += "Fehler: " + result.Error + "\n" } if m.alertCfg.SMTP.Enabled { if err := m.sendMail(subject, body); err != nil { slog.Error("alert mail failed", "err", err) } else { slog.Info("alert mail sent", "check", check.Name, "status", result.Status) } } if m.alertCfg.NexusEnabled && m.nexusCfg.Nexus.URL != "" { if err := m.sendNexusAlert(check, result, subject, body); err != nil { slog.Error("nexus alert failed", "err", err) } else { slog.Info("nexus alert sent", "check", check.Name, "status", result.Status) } } } func (m *Module) sendMail(subject, body string) error { sc := m.alertCfg.SMTP addr := fmt.Sprintf("%s:%d", sc.Host, sc.Port) msg := []byte("From: " + sc.From + "\r\n" + "To: " + sc.To + "\r\n" + "Subject: " + subject + "\r\n" + "Content-Type: text/plain; charset=UTF-8\r\n\r\n" + body) var auth smtp.Auth if sc.Username != "" { auth = smtp.PlainAuth("", sc.Username, sc.Password, sc.Host) } // Try STARTTLS first, fall back to plain. tlsCfg := &tls.Config{ServerName: sc.Host, InsecureSkipVerify: false} c, err := smtp.Dial(addr) if err != nil { return fmt.Errorf("dial smtp: %w", err) } defer c.Close() if ok, _ := c.Extension("STARTTLS"); ok { if err := c.StartTLS(tlsCfg); err != nil { return fmt.Errorf("starttls: %w", err) } } if auth != nil { if err := c.Auth(auth); err != nil { return fmt.Errorf("smtp auth: %w", err) } } if err := c.Mail(sc.From); err != nil { return err } if err := c.Rcpt(sc.To); err != nil { return err } wc, err := c.Data() if err != nil { return err } defer wc.Close() _, err = wc.Write(msg) return err } func (m *Module) sendNexusAlert(check db.MonitorCheck, result db.MonitorResult, subject, body string) error { payload := map[string]any{ "site": m.site, "check": check.Name, "type": check.Type, "target": check.Target, "status": result.Status, "latency": result.LatencyMS, "error": result.Error, "timestamp": result.CheckedAt, "subject": subject, "body": body, } b, _ := json.Marshal(payload) url := m.nexusCfg.Nexus.URL + "/api/scanner/alert" req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(b)) if err != nil { return err } req.Header.Set("Content-Type", "application/json") req.Header.Set("X-Scanner-Key", m.nexusCfg.Nexus.APIKey) resp, err := m.alertHTTP.Do(req) if err != nil { return err } resp.Body.Close() if resp.StatusCode >= 400 { return fmt.Errorf("nexus returned %d", resp.StatusCode) } return nil }