Initial commit: IT Nexus Web-App
This commit is contained in:
298
nexus-scanner/internal/modules/sitemon/sitemon.go
Normal file
298
nexus-scanner/internal/modules/sitemon/sitemon.go
Normal file
@@ -0,0 +1,298 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user