Files
IT-Nexus/nexus-scanner/internal/modules/sitemon/sitemon.go

299 lines
7.8 KiB
Go

// 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
}