CoreControl/agent/main.go

454 lines
11 KiB
Go
Raw Normal View History

2025-04-12 16:45:01 +02:00
package main
import (
"bytes"
2025-04-12 16:45:01 +02:00
"context"
"crypto/x509"
2025-04-12 16:45:01 +02:00
"database/sql"
"encoding/json"
"errors"
2025-04-12 16:45:01 +02:00
"fmt"
"net"
2025-04-12 16:45:01 +02:00
"net/http"
"net/url"
2025-04-12 16:45:01 +02:00
"os"
2025-04-17 16:12:10 +02:00
"strings"
"sync"
2025-04-12 16:45:01 +02:00
"time"
2025-04-12 16:51:40 +02:00
_ "github.com/jackc/pgx/v4/stdlib"
2025-04-12 16:45:01 +02:00
"github.com/joho/godotenv"
2025-04-17 16:12:10 +02:00
"gopkg.in/gomail.v2"
2025-04-12 16:45:01 +02:00
)
type Application struct {
ID int
Name string
2025-04-12 16:45:01 +02:00
PublicURL string
Online bool
}
2025-04-17 16:12:10 +02:00
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
GotifyUrl sql.NullString
GotifyToken sql.NullString
NtfyUrl sql.NullString
NtfyToken sql.NullString
2025-04-17 16:12:10 +02:00
}
var (
notifications []Notification
notifMutex sync.RWMutex
)
2025-04-17 16:12:10 +02:00
2025-04-12 16:45:01 +02:00
func main() {
if err := godotenv.Load(); err != nil {
fmt.Println("No env vars found")
}
dbURL := os.Getenv("DATABASE_URL")
if dbURL == "" {
panic("DATABASE_URL not set")
}
db, err := sql.Open("pgx", dbURL)
if err != nil {
panic(fmt.Sprintf("Database connection failed: %v\n", err))
}
defer db.Close()
// initial load
notifs, err := loadNotifications(db)
2025-04-17 16:12:10 +02:00
if err != nil {
panic(fmt.Sprintf("Failed to load notifications: %v", err))
}
notifMutex.Lock()
notifications = notifMutexCopy(notifs)
notifMutex.Unlock()
// reload notification configs every minute
go func() {
reloadTicker := time.NewTicker(time.Minute)
defer reloadTicker.Stop()
for range reloadTicker.C {
newNotifs, err := loadNotifications(db)
if err != nil {
fmt.Printf("Failed to reload notifications: %v\n", err)
continue
}
notifMutex.Lock()
notifications = notifMutexCopy(newNotifs)
notifMutex.Unlock()
fmt.Println("Reloaded notification configurations")
}
}()
2025-04-17 16:12:10 +02:00
// clean up old entries hourly
2025-04-15 11:37:30 +02:00
go func() {
2025-04-17 16:12:10 +02:00
deletionTicker := time.NewTicker(time.Hour)
2025-04-15 11:37:30 +02:00
defer deletionTicker.Stop()
for range deletionTicker.C {
if err := deleteOldEntries(db); err != nil {
fmt.Printf("Error deleting old entries: %v\n", err)
}
}
}()
2025-04-17 16:12:10 +02:00
ticker := time.NewTicker(time.Second)
2025-04-12 16:45:01 +02:00
defer ticker.Stop()
client := &http.Client{
Timeout: 4 * time.Second,
}
2025-04-15 11:37:30 +02:00
for now := range ticker.C {
if now.Second()%10 != 0 {
continue
}
2025-04-12 16:45:01 +02:00
apps := getApplications(db)
checkAndUpdateStatus(db, client, apps)
}
}
// helper to safely copy slice
func notifMutexCopy(src []Notification) []Notification {
copyDst := make([]Notification, len(src))
copy(copyDst, src)
return copyDst
}
func isIPAddress(host string) bool {
ip := net.ParseIP(host)
return ip != nil
}
2025-04-17 16:12:10 +02:00
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", "gotifyUrl", "gotifyToken", "ntfyUrl", "ntfyToken"
2025-04-17 16:12:10 +02:00
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, &n.GotifyUrl, &n.GotifyToken, &n.NtfyUrl, &n.NtfyToken,
2025-04-17 16:12:10 +02:00
); err != nil {
fmt.Printf("Error scanning notification: %v\n", err)
continue
}
configs = append(configs, n)
}
return configs, nil
}
2025-04-15 11:37:30 +02:00
func deleteOldEntries(db *sql.DB) error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
res, err := db.ExecContext(ctx,
2025-04-17 16:12:10 +02:00
`DELETE FROM uptime_history WHERE "createdAt" < now() - interval '30 days'`,
)
2025-04-15 11:37:30 +02:00
if err != nil {
return err
}
affected, _ := res.RowsAffected()
fmt.Printf("Deleted %d old entries from uptime_history\n", affected)
return nil
}
2025-04-12 16:45:01 +02:00
func getApplications(db *sql.DB) []Application {
2025-04-17 16:12:10 +02:00
rows, err := db.Query(
`SELECT id, name, "publicURL", online FROM application WHERE "publicURL" IS NOT NULL`,
2025-04-17 16:12:10 +02:00
)
2025-04-12 16:45:01 +02:00
if err != nil {
fmt.Printf("Error fetching applications: %v\n", err)
return nil
}
defer rows.Close()
var apps []Application
for rows.Next() {
var app Application
if err := rows.Scan(&app.ID, &app.Name, &app.PublicURL, &app.Online); err != nil {
2025-04-12 16:45:01 +02:00
fmt.Printf("Error scanning row: %v\n", err)
continue
}
apps = append(apps, app)
}
return apps
}
func checkAndUpdateStatus(db *sql.DB, client *http.Client, apps []Application) {
var notificationTemplate string
err := db.QueryRow("SELECT notification_text FROM settings LIMIT 1").Scan(&notificationTemplate)
2025-04-18 11:29:14 +02:00
if err != nil || notificationTemplate == "" {
2025-04-19 16:23:53 +02:00
notificationTemplate = "The application !name (!url) went !status!"
}
2025-04-12 16:45:01 +02:00
for _, app := range apps {
2025-04-18 11:29:14 +02:00
logPrefix := fmt.Sprintf("[App %s (%s)]", app.Name, app.PublicURL)
fmt.Printf("%s Checking...\n", logPrefix)
parsedURL, parseErr := url.Parse(app.PublicURL)
if parseErr != nil {
fmt.Printf("%s Invalid URL: %v\n", logPrefix, parseErr)
continue
}
hostIsIP := isIPAddress(parsedURL.Hostname())
2025-04-17 11:36:35 +02:00
httpCtx, httpCancel := context.WithTimeout(context.Background(), 4*time.Second)
2025-04-18 11:29:14 +02:00
req, err := http.NewRequestWithContext(httpCtx, "HEAD", app.PublicURL, nil)
2025-04-12 16:45:01 +02:00
if err != nil {
2025-04-18 11:29:14 +02:00
fmt.Printf("%s Request creation failed: %v\n", logPrefix, err)
httpCancel()
2025-04-12 16:45:01 +02:00
continue
}
resp, err := client.Do(req)
2025-04-18 11:29:14 +02:00
if err != nil || (resp != nil && (resp.StatusCode == http.StatusMethodNotAllowed || resp.StatusCode == http.StatusNotImplemented)) {
if resp != nil && resp.Body != nil {
resp.Body.Close()
}
fmt.Printf("%s HEAD failed, trying GET...\n", logPrefix)
req.Method = "GET"
resp, err = client.Do(req)
}
var isOnline bool
2025-04-18 11:29:14 +02:00
if err == nil && resp != nil {
isOnline = (resp.StatusCode >= 200 && resp.StatusCode < 300) || resp.StatusCode == 405
resp.Body.Close()
2025-04-18 11:29:14 +02:00
} else {
if err != nil {
fmt.Printf("%s HTTP error: %v\n", logPrefix, err)
// Sonderbehandlung für IP-Adressen + TLS-Zertifikatfehler
if hostIsIP {
var urlErr *url.Error
if errors.As(err, &urlErr) {
var certErr x509.HostnameError
var unknownAuthErr x509.UnknownAuthorityError
if errors.As(urlErr.Err, &certErr) || errors.As(urlErr.Err, &unknownAuthErr) {
fmt.Printf("%s Ignoring TLS error for IP, marking as online.\n", logPrefix)
isOnline = true
}
}
}
}
}
2025-04-17 16:12:10 +02:00
2025-04-18 11:29:14 +02:00
httpCancel()
2025-04-17 16:12:10 +02:00
if isOnline != app.Online {
status := "offline"
if isOnline {
status = "online"
}
message := notificationTemplate
message = strings.ReplaceAll(message, "!name", app.Name)
message = strings.ReplaceAll(message, "!url", app.PublicURL)
message = strings.ReplaceAll(message, "!status", status)
sendNotifications(message)
2025-04-12 16:45:01 +02:00
}
2025-04-17 11:36:35 +02:00
dbCtx, dbCancel := context.WithTimeout(context.Background(), 5*time.Second)
_, err = db.ExecContext(dbCtx,
2025-04-15 11:37:30 +02:00
`UPDATE application SET online = $1 WHERE id = $2`,
2025-04-17 16:12:10 +02:00
isOnline, app.ID,
2025-04-12 16:45:01 +02:00
)
if err != nil {
2025-04-18 11:29:14 +02:00
fmt.Printf("%s DB update failed: %v\n", logPrefix, err)
2025-04-12 16:45:01 +02:00
}
2025-04-18 11:29:14 +02:00
dbCancel()
2025-04-15 11:37:30 +02:00
dbCtx2, dbCancel2 := context.WithTimeout(context.Background(), 5*time.Second)
_, err = db.ExecContext(dbCtx2,
`INSERT INTO uptime_history("applicationId", online, "createdAt") VALUES ($1, $2, now())`,
2025-04-17 16:12:10 +02:00
app.ID, isOnline,
2025-04-15 11:37:30 +02:00
)
if err != nil {
2025-04-18 11:29:14 +02:00
fmt.Printf("%s Insert into history failed: %v\n", logPrefix, err)
2025-04-15 11:37:30 +02:00
}
2025-04-18 11:29:14 +02:00
dbCancel2()
2025-04-12 16:45:01 +02:00
}
}
2025-04-17 16:12:10 +02:00
func sendNotifications(message string) {
notifMutex.RLock()
notifs := notifMutexCopy(notifications)
notifMutex.RUnlock()
for _, n := range notifs {
2025-04-17 16:12:10 +02:00
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)
}
case "gotify":
if n.GotifyUrl.Valid && n.GotifyToken.Valid {
sendGotify(n, message)
}
case "ntfy":
if n.NtfyUrl.Valid && n.NtfyToken.Valid {
sendNtfy(n, message)
}
2025-04-17 16:12:10 +02:00
}
}
}
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()
}
func sendGotify(n Notification, message string) {
baseURL := strings.TrimSuffix(n.GotifyUrl.String, "/")
targetURL := fmt.Sprintf("%s/message", baseURL)
form := url.Values{}
form.Add("message", message)
form.Add("priority", "5")
req, err := http.NewRequest("POST", targetURL, strings.NewReader(form.Encode()))
if err != nil {
fmt.Printf("Gotify: ERROR creating request: %v\n", err)
return
}
req.Header.Set("X-Gotify-Key", n.GotifyToken.String)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Do(req)
if err != nil {
fmt.Printf("Gotify: ERROR sending request: %v\n", err)
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
fmt.Printf("Gotify: ERROR status code: %d\n", resp.StatusCode)
}
}
func sendNtfy(n Notification, message string) {
baseURL := strings.TrimSuffix(n.NtfyUrl.String, "/")
topic := "corecontrol"
requestURL := fmt.Sprintf("%s/%s", baseURL, topic)
payload := map[string]string{"message": message}
jsonData, err := json.Marshal(payload)
if err != nil {
fmt.Printf("Ntfy: ERROR marshaling JSON: %v\n", err)
return
}
req, err := http.NewRequest("POST", requestURL, bytes.NewBuffer(jsonData))
if err != nil {
fmt.Printf("Ntfy: ERROR creating request: %v\n", err)
return
}
if n.NtfyToken.Valid {
req.Header.Set("Authorization", "Bearer "+n.NtfyToken.String)
}
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Do(req)
if err != nil {
fmt.Printf("Ntfy: ERROR sending request: %v\n", err)
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
fmt.Printf("Ntfy: ERROR status code: %d\n", resp.StatusCode)
}
}