Files
uptop/internal/alert/alert.go
T
lerko 60b30935b3
CI / test (pull_request) Successful in 4m40s
CI / lint (pull_request) Successful in 1m2s
fix(security): phase 1 critical fixes for public release
- Redact PostgreSQL DSN password from stdout/logs
- Harden .dockerignore to exclude .ssh/, .claude/, *.db, *.local files
- SSRF protection: block private/loopback/link-local IPs by default
  (UPTOP_ALLOW_PRIVATE_TARGETS=true to override for homelab use)
- Fix email header injection via CRLF in monitor names
- AES-256-GCM encryption for alert credentials at rest
  (UPTOP_ENCRYPTION_KEY env var, migrate-secrets subcommand)
- TLS support for HTTP server (UPTOP_TLS_CERT/UPTOP_TLS_KEY)
  with HSTS header when TLS enabled
2026-05-25 11:26:47 -04:00

236 lines
5.9 KiB
Go

package alert
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/smtp"
"strconv"
"strings"
"time"
"gitea.lerkolabs.com/lerko/uptop/internal/models"
)
var alertClient = &http.Client{Timeout: 10 * time.Second}
type Provider interface {
Send(ctx context.Context, title, message string) error
}
type PayloadFunc func(title, message string) ([]byte, error)
type HTTPProvider struct {
URL string
Payload PayloadFunc
}
func (h *HTTPProvider) Send(ctx context.Context, title, message string) error {
body, err := h.Payload(title, message)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, "POST", h.URL, bytes.NewBuffer(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := alertClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return fmt.Errorf("alert webhook returned HTTP %d", resp.StatusCode)
}
return nil
}
func discordPayload(title, message string) ([]byte, error) {
return json.Marshal(map[string]string{"content": fmt.Sprintf("**%s**\n%s", title, message)})
}
func slackPayload(title, message string) ([]byte, error) {
return json.Marshal(map[string]string{"text": fmt.Sprintf("*%s*\n%s", title, message)})
}
func webhookPayload(title, message string) ([]byte, error) {
return json.Marshal(map[string]string{"title": title, "message": message, "status": "alert"})
}
func telegramPayload(chatID string) PayloadFunc {
return func(title, message string) ([]byte, error) {
return json.Marshal(map[string]string{
"chat_id": chatID,
"text": fmt.Sprintf("*%s*\n%s", title, message),
"parse_mode": "Markdown",
})
}
}
func pagerdutyPayload(routingKey, severity string) PayloadFunc {
return func(title, message string) ([]byte, error) {
return json.Marshal(map[string]any{
"routing_key": routingKey,
"event_action": "trigger",
"payload": map[string]string{
"summary": fmt.Sprintf("%s: %s", title, message),
"source": "uptop",
"severity": severity,
},
})
}
}
func pushoverPayload(token, user string) PayloadFunc {
return func(title, message string) ([]byte, error) {
return json.Marshal(map[string]string{
"token": token,
"user": user,
"title": title,
"message": message,
})
}
}
func gotifyPayload(priority string) PayloadFunc {
return func(title, message string) ([]byte, error) {
pri, _ := strconv.Atoi(priority)
return json.Marshal(map[string]any{
"title": title,
"message": message,
"priority": pri,
})
}
}
func GetProvider(cfg models.AlertConfig) Provider {
switch cfg.Type {
case "discord":
return &HTTPProvider{URL: cfg.Settings["url"], Payload: discordPayload}
case "slack":
return &HTTPProvider{URL: cfg.Settings["url"], Payload: slackPayload}
case "webhook":
return &HTTPProvider{URL: cfg.Settings["url"], Payload: webhookPayload}
case "email":
port := "25"
if p, ok := cfg.Settings["port"]; ok {
port = p
}
return &EmailProvider{
Host: cfg.Settings["host"],
Port: port,
User: cfg.Settings["user"],
Pass: cfg.Settings["pass"],
To: cfg.Settings["to"],
From: cfg.Settings["from"],
}
case "ntfy":
priority := "3"
if p, ok := cfg.Settings["priority"]; ok && p != "" {
priority = p
}
return &NtfyProvider{
ServerURL: cfg.Settings["url"],
Topic: cfg.Settings["topic"],
Priority: priority,
Username: cfg.Settings["username"],
Password: cfg.Settings["password"],
}
case "telegram":
return &HTTPProvider{
URL: fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", cfg.Settings["token"]),
Payload: telegramPayload(cfg.Settings["chat_id"]),
}
case "pagerduty":
severity := "critical"
if s, ok := cfg.Settings["severity"]; ok && s != "" {
severity = s
}
return &HTTPProvider{
URL: "https://events.pagerduty.com/v2/enqueue",
Payload: pagerdutyPayload(cfg.Settings["routing_key"], severity),
}
case "pushover":
return &HTTPProvider{
URL: "https://api.pushover.net/1/messages.json",
Payload: pushoverPayload(cfg.Settings["token"], cfg.Settings["user"]),
}
case "gotify":
priority := "5"
if p, ok := cfg.Settings["priority"]; ok && p != "" {
priority = p
}
serverURL := strings.TrimRight(cfg.Settings["url"], "/")
return &HTTPProvider{
URL: fmt.Sprintf("%s/message?token=%s", serverURL, cfg.Settings["token"]),
Payload: gotifyPayload(priority),
}
default:
return nil
}
}
type EmailProvider struct {
Host, Port, User, Pass, To, From string
}
func sanitizeHeader(s string) string {
s = strings.ReplaceAll(s, "\r", "")
s = strings.ReplaceAll(s, "\n", "")
return s
}
func (e *EmailProvider) Send(ctx context.Context, title, message string) error {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
auth := smtp.PlainAuth("", e.User, e.Pass, e.Host)
to := sanitizeHeader(e.To)
from := sanitizeHeader(e.From)
subject := sanitizeHeader(title)
body := strings.ReplaceAll(message, "\r", "")
msg := []byte("From: " + from + "\r\n" +
"To: " + to + "\r\n" +
"Subject: uptop: " + subject + "\r\n" +
"MIME-Version: 1.0\r\n" +
"Content-Type: text/plain; charset=utf-8\r\n" +
"\r\n" +
body + "\r\n")
return smtp.SendMail(e.Host+":"+e.Port, auth, from, []string{to}, msg)
}
type NtfyProvider struct {
ServerURL string
Topic string
Priority string
Username string
Password string
}
func (n *NtfyProvider) Send(ctx context.Context, title, message string) error {
url := strings.TrimRight(n.ServerURL, "/") + "/" + n.Topic
req, err := http.NewRequestWithContext(ctx, "POST", url, strings.NewReader(message))
if err != nil {
return err
}
req.Header.Set("Title", title)
req.Header.Set("Priority", n.Priority)
if n.Username != "" && n.Password != "" {
req.SetBasicAuth(n.Username, n.Password)
}
resp, err := alertClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return fmt.Errorf("ntfy returned HTTP %d", resp.StatusCode)
}
return nil
}