From e925f37b194d5b1419ce55a1e31f7c599796969b Mon Sep 17 00:00:00 2001 From: headlessdev Date: Thu, 17 Apr 2025 16:12:10 +0200 Subject: [PATCH] Notification Agent System --- agent/go.mod | 2 + agent/go.sum | 4 ++ agent/main.go | 168 ++++++++++++++++++++++++++++++++++++++++++++------ 3 files changed, 155 insertions(+), 19 deletions(-) diff --git a/agent/go.mod b/agent/go.mod index 0693b03..551dcaa 100644 --- a/agent/go.mod +++ b/agent/go.mod @@ -17,4 +17,6 @@ require ( github.com/jackc/pgtype v1.14.0 // indirect golang.org/x/crypto v0.20.0 // indirect golang.org/x/text v0.14.0 // indirect + gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect + gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df // indirect ) diff --git a/agent/go.sum b/agent/go.sum index e7903f5..b2a3b40 100644 --- a/agent/go.sum +++ b/agent/go.sum @@ -171,9 +171,13 @@ golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk= +gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df h1:n7WqCuqOuCbNr617RXOY0AWRXxgwEyPp2z+p0+hgMuE= +gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df/go.mod h1:LRQQ+SO6ZHR7tOkpBDuZnXENFzX8qRjMDMyPD6BRkCw= gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/agent/main.go b/agent/main.go index ed08058..0557c2f 100644 --- a/agent/main.go +++ b/agent/main.go @@ -6,10 +6,12 @@ import ( "fmt" "net/http" "os" + "strings" "time" _ "github.com/jackc/pgx/v4/stdlib" "github.com/joho/godotenv" + "gopkg.in/gomail.v2" ) type Application struct { @@ -18,6 +20,24 @@ type Application struct { Online bool } +type Notification struct { + ID int + Enabled bool + Type string + SMTPHost sql.NullString + SMTPPort sql.NullInt64 + SMTPFrom sql.NullString + SMTPUser sql.NullString + SMTPPass sql.NullString + SMTPSecure sql.NullBool + SMTPTo sql.NullString + TelegramChatID sql.NullString + TelegramToken sql.NullString + DiscordWebhook sql.NullString +} + +var notifications []Notification + func main() { if err := godotenv.Load(); err != nil { fmt.Println("No env vars found") @@ -34,8 +54,14 @@ func main() { } defer db.Close() + // Load notification configs + notifications, err = loadNotifications(db) + if err != nil { + panic(fmt.Sprintf("Failed to load notifications: %v", err)) + } + go func() { - deletionTicker := time.NewTicker(1 * time.Hour) + deletionTicker := time.NewTicker(time.Hour) defer deletionTicker.Stop() for range deletionTicker.C { @@ -45,7 +71,7 @@ func main() { } }() - ticker := time.NewTicker(1 * time.Second) + ticker := time.NewTicker(time.Second) defer ticker.Stop() client := &http.Client{ @@ -62,12 +88,41 @@ func main() { } } +func loadNotifications(db *sql.DB) ([]Notification, error) { + rows, err := db.Query( + `SELECT id, enabled, type, "smtpHost", "smtpPort", "smtpFrom", "smtpUser", "smtpPass", "smtpSecure", "smtpTo", + "telegramChatId", "telegramToken", "discordWebhook" + FROM notification + WHERE enabled = true`, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var configs []Notification + for rows.Next() { + var n Notification + if err := rows.Scan( + &n.ID, &n.Enabled, &n.Type, + &n.SMTPHost, &n.SMTPPort, &n.SMTPFrom, &n.SMTPUser, &n.SMTPPass, &n.SMTPSecure, &n.SMTPTo, + &n.TelegramChatID, &n.TelegramToken, &n.DiscordWebhook, + ); err != nil { + fmt.Printf("Error scanning notification: %v\n", err) + continue + } + configs = append(configs, n) + } + return configs, nil +} + func deleteOldEntries(db *sql.DB) error { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() res, err := db.ExecContext(ctx, - `DELETE FROM uptime_history WHERE "createdAt" < now() - interval '30 days'`) + `DELETE FROM uptime_history WHERE "createdAt" < now() - interval '30 days'`, + ) if err != nil { return err } @@ -77,11 +132,9 @@ func deleteOldEntries(db *sql.DB) error { } func getApplications(db *sql.DB) []Application { - rows, err := db.Query(` - SELECT id, "publicURL", online - FROM application - WHERE "publicURL" IS NOT NULL - `) + rows, err := db.Query( + `SELECT id, "publicURL", online FROM application WHERE "publicURL" IS NOT NULL`, + ) if err != nil { fmt.Printf("Error fetching applications: %v\n", err) return nil @@ -91,8 +144,7 @@ func getApplications(db *sql.DB) []Application { var apps []Application for rows.Next() { var app Application - err := rows.Scan(&app.ID, &app.PublicURL, &app.Online) - if err != nil { + if err := rows.Scan(&app.ID, &app.PublicURL, &app.Online); err != nil { fmt.Printf("Error scanning row: %v\n", err) continue } @@ -103,7 +155,7 @@ func getApplications(db *sql.DB) []Application { func checkAndUpdateStatus(db *sql.DB, client *http.Client, apps []Application) { for _, app := range apps { - // Context for HTTP request + // HTTP request context httpCtx, httpCancel := context.WithTimeout(context.Background(), 4*time.Second) defer httpCancel() @@ -114,20 +166,26 @@ func checkAndUpdateStatus(db *sql.DB, client *http.Client, apps []Application) { } resp, err := client.Do(req) - isOnline := false - if err == nil && resp.StatusCode >= 200 && resp.StatusCode < 300 { - isOnline = true + isOnline := err == nil && resp.StatusCode >= 200 && resp.StatusCode < 300 || resp.StatusCode == 405 + + // Notify on status change + if isOnline != app.Online { + status := "offline" + if isOnline { + status = "online" + } + message := fmt.Sprintf("Application %d (%s) is now %s", app.ID, app.PublicURL, status) + sendNotifications(message) } - // Create a new context for database operations with a separate timeout + // DB context dbCtx, dbCancel := context.WithTimeout(context.Background(), 5*time.Second) defer dbCancel() // Update application status _, err = db.ExecContext(dbCtx, `UPDATE application SET online = $1 WHERE id = $2`, - isOnline, - app.ID, + isOnline, app.ID, ) if err != nil { fmt.Printf("Update failed for app %d: %v\n", app.ID, err) @@ -136,11 +194,83 @@ func checkAndUpdateStatus(db *sql.DB, client *http.Client, apps []Application) { // Insert into uptime_history _, err = db.ExecContext(dbCtx, `INSERT INTO uptime_history ("applicationId", online, "createdAt") VALUES ($1, $2, now())`, - app.ID, - isOnline, + app.ID, isOnline, ) if err != nil { fmt.Printf("Insert into uptime_history failed for app %d: %v\n", app.ID, err) } } } + +func sendNotifications(message string) { + for _, n := range notifications { + switch n.Type { + case "email": + if n.SMTPHost.Valid && n.SMTPTo.Valid { + sendEmail(n, message) + } + case "telegram": + if n.TelegramToken.Valid && n.TelegramChatID.Valid { + sendTelegram(n, message) + } + case "discord": + if n.DiscordWebhook.Valid { + sendDiscord(n, message) + } + } + } +} + +func sendEmail(n Notification, body string) { + // Initialize SMTP dialer with host, port, user, pass + d := gomail.NewDialer( + n.SMTPHost.String, + int(n.SMTPPort.Int64), + n.SMTPUser.String, + n.SMTPPass.String, + ) + if n.SMTPSecure.Valid && n.SMTPSecure.Bool { + d.SSL = true + } + + m := gomail.NewMessage() + m.SetHeader("From", n.SMTPFrom.String) + m.SetHeader("To", n.SMTPTo.String) + m.SetHeader("Subject", "Uptime Notification") + m.SetBody("text/plain", body) + + if err := d.DialAndSend(m); err != nil { + fmt.Printf("Email send failed: %v\n", err) + } +} + +func sendTelegram(n Notification, message string) { + url := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage?chat_id=%s&text=%s", + n.TelegramToken.String, + n.TelegramChatID.String, + message, + ) + resp, err := http.Get(url) + if err != nil { + fmt.Printf("Telegram send failed: %v\n", err) + return + } + resp.Body.Close() +} + +func sendDiscord(n Notification, message string) { + payload := fmt.Sprintf(`{"content": "%s"}`, message) + req, err := http.NewRequest("POST", n.DiscordWebhook.String, strings.NewReader(payload)) + if err != nil { + fmt.Printf("Discord request creation failed: %v\n", err) + return + } + req.Header.Set("Content-Type", "application/json") + client := &http.Client{Timeout: 5 * time.Second} + resp, err := client.Do(req) + if err != nil { + fmt.Printf("Discord send failed: %v\n", err) + return + } + resp.Body.Close() +}