299 lines
7.8 KiB
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
|
|
}
|