Initial commit: Beszel fork with Domain Locker integration

This commit is contained in:
Tomas Dvorak
2026-04-21 15:39:43 +02:00
commit 363d708e91
440 changed files with 160889 additions and 0 deletions
+277
View File
@@ -0,0 +1,277 @@
package notifications
import (
"encoding/json"
"net/http"
"github.com/henrygd/beszel/internal/entities/notification"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
)
// RegisterRoutes registers notification API routes
func RegisterRoutes(app core.App, se *core.ServeEvent) {
api := &NotificationAPI{app: app}
group := se.Router.Group("/api/beszel/notifications")
group.Bind(apis.RequireAuth())
group.GET("/", api.listNotifications)
group.POST("/", api.createNotification)
group.GET("/{id}", api.getNotification)
group.PATCH("/{id}", api.updateNotification)
group.DELETE("/{id}", api.deleteNotification)
group.POST("/{id}/test", api.testNotification)
}
// NotificationAPI handles notification API requests
type NotificationAPI struct {
app core.App
}
// CreateNotificationRequest represents a notification creation request
type CreateNotificationRequest struct {
Name string `json:"name"`
Type string `json:"type"`
Settings map[string]interface{} `json:"settings"`
IsDefault bool `json:"is_default"`
}
// UpdateNotificationRequest represents a notification update request
type UpdateNotificationRequest struct {
Name string `json:"name,omitempty"`
Settings map[string]interface{} `json:"settings,omitempty"`
IsDefault *bool `json:"is_default,omitempty"`
Active *bool `json:"active,omitempty"`
}
// NotificationResponse represents a notification response
type NotificationResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
IsDefault bool `json:"is_default"`
Active bool `json:"active"`
Settings map[string]interface{} `json:"settings"`
Created string `json:"created"`
Updated string `json:"updated"`
}
// listNotifications lists all notifications for the authenticated user
func (api *NotificationAPI) listNotifications(e *core.RequestEvent) error {
authRecord := e.Auth
if authRecord == nil {
return e.UnauthorizedError("unauthorized", nil)
}
records, err := api.app.FindAllRecords("notifications",
dbx.NewExp("user = {:user}", dbx.Params{"user": authRecord.Id}),
)
if err != nil {
return e.InternalServerError("failed to fetch notifications", err)
}
notifications := make([]NotificationResponse, 0, len(records))
for _, record := range records {
notifications = append(notifications, api.recordToResponse(record))
}
return e.JSON(http.StatusOK, notifications)
}
// createNotification creates a new notification
func (api *NotificationAPI) createNotification(e *core.RequestEvent) error {
authRecord := e.Auth
if authRecord == nil {
return e.UnauthorizedError("unauthorized", nil)
}
var req CreateNotificationRequest
if err := json.NewDecoder(e.Request.Body).Decode(&req); err != nil {
return e.BadRequestError("invalid request body", err)
}
if req.Name == "" || req.Type == "" {
return e.BadRequestError("name and type are required", nil)
}
collection, err := api.app.FindCollectionByNameOrId("notifications")
if err != nil {
return e.InternalServerError("failed to find collection", err)
}
settingsJSON, _ := json.Marshal(req.Settings)
record := core.NewRecord(collection)
record.Set("name", req.Name)
record.Set("type", req.Type)
record.Set("settings", string(settingsJSON))
record.Set("is_default", req.IsDefault)
record.Set("active", true)
record.Set("user", authRecord.Id)
if err := api.app.Save(record); err != nil {
return e.InternalServerError("failed to create notification", err)
}
return e.JSON(http.StatusCreated, api.recordToResponse(record))
}
// getNotification gets a single notification
func (api *NotificationAPI) getNotification(e *core.RequestEvent) error {
authRecord := e.Auth
if authRecord == nil {
return e.UnauthorizedError("unauthorized", nil)
}
id := e.Request.PathValue("id")
record, err := api.app.FindRecordById("notifications", id)
if err != nil {
return e.NotFoundError("notification not found", err)
}
if record.GetString("user") != authRecord.Id {
return e.ForbiddenError("not authorized", nil)
}
return e.JSON(http.StatusOK, api.recordToResponse(record))
}
// updateNotification updates a notification
func (api *NotificationAPI) updateNotification(e *core.RequestEvent) error {
authRecord := e.Auth
if authRecord == nil {
return e.UnauthorizedError("unauthorized", nil)
}
id := e.Request.PathValue("id")
record, err := api.app.FindRecordById("notifications", id)
if err != nil {
return e.NotFoundError("notification not found", err)
}
if record.GetString("user") != authRecord.Id {
return e.ForbiddenError("not authorized", nil)
}
var req UpdateNotificationRequest
if err := json.NewDecoder(e.Request.Body).Decode(&req); err != nil {
return e.BadRequestError("invalid request body", err)
}
if req.Name != "" {
record.Set("name", req.Name)
}
if req.Settings != nil {
settingsJSON, _ := json.Marshal(req.Settings)
record.Set("settings", string(settingsJSON))
}
if req.IsDefault != nil {
record.Set("is_default", *req.IsDefault)
}
if req.Active != nil {
record.Set("active", *req.Active)
}
if err := api.app.Save(record); err != nil {
return e.InternalServerError("failed to update notification", err)
}
return e.JSON(http.StatusOK, api.recordToResponse(record))
}
// deleteNotification deletes a notification
func (api *NotificationAPI) deleteNotification(e *core.RequestEvent) error {
authRecord := e.Auth
if authRecord == nil {
return e.UnauthorizedError("unauthorized", nil)
}
id := e.Request.PathValue("id")
record, err := api.app.FindRecordById("notifications", id)
if err != nil {
return e.NotFoundError("notification not found", err)
}
if record.GetString("user") != authRecord.Id {
return e.ForbiddenError("not authorized", nil)
}
if err := api.app.Delete(record); err != nil {
return e.InternalServerError("failed to delete notification", err)
}
return e.NoContent(http.StatusNoContent)
}
// testNotification sends a test notification
func (api *NotificationAPI) testNotification(e *core.RequestEvent) error {
authRecord := e.Auth
if authRecord == nil {
return e.UnauthorizedError("unauthorized", nil)
}
id := e.Request.PathValue("id")
record, err := api.app.FindRecordById("notifications", id)
if err != nil {
return e.NotFoundError("notification not found", err)
}
if record.GetString("user") != authRecord.Id {
return e.ForbiddenError("not authorized", nil)
}
notif := &notification.Notification{
ID: record.Id,
Name: record.GetString("name"),
Type: record.GetString("type"),
Active: record.GetBool("active"),
}
if settingsJSON := record.GetString("settings"); settingsJSON != "" {
var settings map[string]interface{}
if err := json.Unmarshal([]byte(settingsJSON), &settings); err == nil {
notif.Settings = settings
}
}
dispatcher := NewDispatcher(api.app)
msg := &notification.NotificationMessage{
Title: "Test Notification",
Body: "This is a test notification from Beszel.",
MonitorName: "Test Monitor",
Status: "UP",
Message: "Test message",
}
provider, err := dispatcher.getProvider(notif)
if err != nil {
return e.InternalServerError("failed to create provider", err)
}
if err := provider.Send(msg); err != nil {
return e.InternalServerError("failed to send test notification", err)
}
return e.JSON(http.StatusOK, map[string]string{"status": "sent"})
}
// recordToResponse converts a record to a response
func (api *NotificationAPI) recordToResponse(record *core.Record) NotificationResponse {
var settings map[string]interface{}
if settingsJSON := record.GetString("settings"); settingsJSON != "" {
json.Unmarshal([]byte(settingsJSON), &settings)
}
return NotificationResponse{
ID: record.Id,
Name: record.GetString("name"),
Type: record.GetString("type"),
IsDefault: record.GetBool("is_default"),
Active: record.GetBool("active"),
Settings: settings,
Created: record.GetDateTime("created").String(),
Updated: record.GetDateTime("updated").String(),
}
}
+236
View File
@@ -0,0 +1,236 @@
package notifications
import (
"encoding/json"
"fmt"
"log"
"sync"
"github.com/henrygd/beszel/internal/entities/monitor"
"github.com/henrygd/beszel/internal/entities/notification"
"github.com/henrygd/beszel/internal/hub/notifications/providers"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
)
// Dispatcher manages notification sending for monitor events
type Dispatcher struct {
app core.App
mu sync.RWMutex
providers map[string]notification.Provider
}
// NewDispatcher creates a new notification dispatcher
func NewDispatcher(app core.App) *Dispatcher {
return &Dispatcher{
app: app,
providers: make(map[string]notification.Provider),
}
}
// SendNotification sends a notification for a monitor event
func (d *Dispatcher) SendNotification(monitorRecord *monitor.Monitor, heartbeat *monitor.Heartbeat, isRecovery bool) {
// Get linked notifications for this monitor
notifications, err := d.getMonitorNotifications(monitorRecord.ID)
if err != nil {
log.Printf("[notification-dispatcher] Failed to get notifications: %v", err)
return
}
if len(notifications) == 0 {
return
}
// Build the message
msg := d.buildMessage(monitorRecord, heartbeat, isRecovery)
// Send to each notification provider
for _, n := range notifications {
if !n.Active {
continue
}
provider, err := d.getProvider(n)
if err != nil {
log.Printf("[notification-dispatcher] Failed to get provider: %v", err)
d.logNotificationEvent(n.ID, monitorRecord.ID, "failed", "", err.Error())
continue
}
if err := provider.Send(msg); err != nil {
log.Printf("[notification-dispatcher] Failed to send notification: %v", err)
d.logNotificationEvent(n.ID, monitorRecord.ID, "failed", "", err.Error())
} else {
log.Printf("[notification-dispatcher] Sent notification via %s for monitor %s", n.Type, monitorRecord.Name)
d.logNotificationEvent(n.ID, monitorRecord.ID, "sent", "", "")
}
}
}
// getMonitorNotifications retrieves all notifications linked to a monitor
func (d *Dispatcher) getMonitorNotifications(monitorID string) ([]*notification.Notification, error) {
// Find monitor_notification records for this monitor
records, err := d.app.FindAllRecords("monitor_notifications",
dbx.HashExp{"monitor": monitorID},
)
if err != nil {
return nil, err
}
if len(records) == 0 {
return nil, nil
}
var notifications []*notification.Notification
for _, record := range records {
notificationID := record.GetString("notification")
notifRecord, err := d.app.FindRecordById("notifications", notificationID)
if err != nil {
continue
}
notif := &notification.Notification{
ID: notifRecord.Id,
Name: notifRecord.GetString("name"),
Type: notifRecord.GetString("type"),
IsDefault: notifRecord.GetBool("is_default"),
Active: notifRecord.GetBool("active"),
}
// Parse settings from JSON
if settingsJSON := notifRecord.GetString("settings"); settingsJSON != "" {
var settings map[string]interface{}
if err := json.Unmarshal([]byte(settingsJSON), &settings); err == nil {
notif.Settings = settings
}
}
notifications = append(notifications, notif)
}
return notifications, nil
}
// getProvider creates a provider instance for a notification config
func (d *Dispatcher) getProvider(n *notification.Notification) (notification.Provider, error) {
d.mu.RLock()
if provider, ok := d.providers[n.ID]; ok {
d.mu.RUnlock()
return provider, nil
}
d.mu.RUnlock()
var provider notification.Provider
switch n.Type {
case notification.ProviderEmail:
settings := n.GetSettings().(notification.EmailSettings)
provider = providers.NewEmailProvider(settings)
case notification.ProviderWebhook:
settings := n.GetSettings().(notification.WebhookSettings)
provider = providers.NewWebhookProvider(settings)
case notification.ProviderDiscord:
settings := n.GetSettings().(notification.DiscordSettings)
provider = providers.NewDiscordProvider(settings)
case notification.ProviderSlack:
settings := n.GetSettings().(notification.SlackSettings)
provider = providers.NewSlackProvider(settings)
case notification.ProviderTelegram:
settings := n.GetSettings().(notification.TelegramSettings)
provider = providers.NewTelegramProvider(settings)
case notification.ProviderGotify:
settings := n.GetSettings().(notification.GotifySettings)
provider = providers.NewGotifyProvider(settings)
case notification.ProviderPushover:
settings := n.GetSettings().(notification.PushoverSettings)
provider = providers.NewPushoverProvider(settings)
default:
return nil, fmt.Errorf("unknown provider type: %s", n.Type)
}
if err := provider.Validate(); err != nil {
return nil, err
}
d.mu.Lock()
d.providers[n.ID] = provider
d.mu.Unlock()
return provider, nil
}
// buildMessage creates a notification message from monitor data
func (d *Dispatcher) buildMessage(m *monitor.Monitor, h *monitor.Heartbeat, isRecovery bool) *notification.NotificationMessage {
status := "DOWN"
if isRecovery {
status = "UP"
}
title := fmt.Sprintf("%s is %s", m.Name, status)
body := fmt.Sprintf("Monitor %s is %s.", m.Name, status)
if !isRecovery && h.Msg != "" {
body = fmt.Sprintf("Monitor %s is %s. Error: %s", m.Name, status, h.Msg)
}
return &notification.NotificationMessage{
Title: title,
Body: body,
MonitorName: m.Name,
MonitorURL: d.getMonitorURL(m),
Status: status,
Timestamp: h.Time,
Ping: h.Ping,
Message: h.Msg,
}
}
// getMonitorURL returns the URL or hostname for display
func (d *Dispatcher) getMonitorURL(m *monitor.Monitor) string {
if m.URL != "" {
return m.URL
}
if m.Hostname != "" {
if m.Port > 0 {
return fmt.Sprintf("%s:%d", m.Hostname, m.Port)
}
return m.Hostname
}
return ""
}
// logNotificationEvent logs a notification event to the database
func (d *Dispatcher) logNotificationEvent(notificationID, monitorID, status, message, errMsg string) {
collection, findErr := d.app.FindCollectionByNameOrId("notification_events")
if findErr != nil {
return
}
record := core.NewRecord(collection)
record.Set("notification", notificationID)
record.Set("monitor", monitorID)
record.Set("status", status)
record.Set("message", message)
record.Set("error", errMsg)
if saveErr := d.app.Save(record); saveErr != nil {
log.Printf("[notification-dispatcher] Failed to log notification event: %v", saveErr)
}
}
// ClearCache clears the provider cache (call when settings change)
func (d *Dispatcher) ClearCache() {
d.mu.Lock()
d.providers = make(map[string]notification.Provider)
d.mu.Unlock()
}
// Check if we need to send notification for this heartbeat
func (d *Dispatcher) ShouldNotify(m *monitor.Monitor, heartbeat *monitor.Heartbeat) (bool, bool) {
// Check if this is a status change
isDown := heartbeat.Status == monitor.StatusDown
isRecovery := heartbeat.Status == monitor.StatusUp && m.Status == monitor.StatusDown
// Only notify on down (after retries) or recovery
return isDown && m.Status == monitor.StatusDown, isRecovery
}
@@ -0,0 +1,99 @@
package providers
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/henrygd/beszel/internal/entities/notification"
)
type DiscordProvider struct {
settings notification.DiscordSettings
}
func NewDiscordProvider(settings notification.DiscordSettings) *DiscordProvider {
return &DiscordProvider{settings: settings}
}
func (p *DiscordProvider) Validate() error {
if p.settings.WebhookURL == "" {
return fmt.Errorf("Discord webhook URL is required")
}
return nil
}
func (p *DiscordProvider) Send(msg *notification.NotificationMessage) error {
if err := p.Validate(); err != nil {
return err
}
color := 0x00ff00 // Green for UP
if msg.Status == "DOWN" {
color = 0xff0000 // Red for DOWN
}
embed := map[string]interface{}{
"title": msg.Title,
"description": msg.Body,
"color": color,
"timestamp": msg.Timestamp.Format(time.RFC3339),
"fields": []map[string]interface{}{
{
"name": "Monitor",
"value": msg.MonitorName,
"inline": true,
},
{
"name": "Status",
"value": msg.Status,
"inline": true,
},
},
}
if msg.MonitorURL != "" {
embed["fields"] = append(embed["fields"].([]map[string]interface{}), map[string]interface{}{
"name": "URL",
"value": msg.MonitorURL,
"inline": false,
})
}
payload := map[string]interface{}{
"embeds": []map[string]interface{}{embed},
}
if p.settings.Username != "" {
payload["username"] = p.settings.Username
}
if p.settings.AvatarURL != "" {
payload["avatar_url"] = p.settings.AvatarURL
}
jsonData, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("failed to marshal payload: %w", err)
}
req, err := http.NewRequest("POST", p.settings.WebhookURL, bytes.NewBuffer(jsonData))
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("discord webhook request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return fmt.Errorf("discord webhook returned status %d", resp.StatusCode)
}
return nil
}
@@ -0,0 +1,146 @@
package providers
import (
"crypto/tls"
"fmt"
"net/smtp"
"strings"
"github.com/henrygd/beszel/internal/entities/notification"
)
// EmailProvider implements email notifications via SMTP
type EmailProvider struct {
settings notification.EmailSettings
}
// NewEmailProvider creates a new email provider
func NewEmailProvider(settings notification.EmailSettings) *EmailProvider {
return &EmailProvider{settings: settings}
}
// Validate checks if the email settings are valid
func (p *EmailProvider) Validate() error {
if p.settings.SMTPHost == "" {
return fmt.Errorf("SMTP host is required")
}
if p.settings.SMTPPort == 0 {
return fmt.Errorf("SMTP port is required")
}
if p.settings.FromEmail == "" {
return fmt.Errorf("from email is required")
}
if p.settings.ToEmail == "" {
return fmt.Errorf("to email is required")
}
return nil
}
// Send sends an email notification
func (p *EmailProvider) Send(msg *notification.NotificationMessage) error {
if err := p.Validate(); err != nil {
return err
}
subject := fmt.Sprintf("[%s] %s - %s", msg.Status, msg.MonitorName, msg.Title)
body := p.formatBody(msg)
// Build email content
email := fmt.Sprintf(
"From: %s\r\nTo: %s\r\nSubject: %s\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\n%s",
p.settings.FromEmail,
p.settings.ToEmail,
subject,
body,
)
// Connect to SMTP server
addr := fmt.Sprintf("%s:%d", p.settings.SMTPHost, p.settings.SMTPPort)
var auth smtp.Auth
if p.settings.SMTPUser != "" {
auth = smtp.PlainAuth("", p.settings.SMTPUser, p.settings.SMTPPassword, p.settings.SMTPHost)
}
// Send email
if p.settings.UseTLS {
return p.sendTLS(addr, auth, email)
}
return smtp.SendMail(
addr,
auth,
p.settings.FromEmail,
[]string{p.settings.ToEmail},
[]byte(email),
)
}
// sendTLS sends email using TLS
func (p *EmailProvider) sendTLS(addr string, auth smtp.Auth, email string) error {
conn, err := tls.Dial("tcp", addr, &tls.Config{ServerName: p.settings.SMTPHost})
if err != nil {
return fmt.Errorf("failed to connect via TLS: %w", err)
}
defer conn.Close()
client, err := smtp.NewClient(conn, p.settings.SMTPHost)
if err != nil {
return fmt.Errorf("failed to create SMTP client: %w", err)
}
defer client.Close()
if auth != nil {
if err := client.Auth(auth); err != nil {
return fmt.Errorf("authentication failed: %w", err)
}
}
if err := client.Mail(p.settings.FromEmail); err != nil {
return fmt.Errorf("failed to set sender: %w", err)
}
if err := client.Rcpt(p.settings.ToEmail); err != nil {
return fmt.Errorf("failed to set recipient: %w", err)
}
w, err := client.Data()
if err != nil {
return fmt.Errorf("failed to get data writer: %w", err)
}
_, err = w.Write([]byte(email))
if err != nil {
w.Close()
return fmt.Errorf("failed to write email: %w", err)
}
if err := w.Close(); err != nil {
return fmt.Errorf("failed to close data writer: %w", err)
}
return client.Quit()
}
// formatBody formats the email body
func (p *EmailProvider) formatBody(msg *notification.NotificationMessage) string {
var b strings.Builder
b.WriteString(fmt.Sprintf("Monitor: %s\n", msg.MonitorName))
if msg.MonitorURL != "" {
b.WriteString(fmt.Sprintf("URL: %s\n", msg.MonitorURL))
}
b.WriteString(fmt.Sprintf("Status: %s\n", msg.Status))
b.WriteString(fmt.Sprintf("Time: %s\n", msg.Timestamp.Format("2006-01-02 15:04:05")))
if msg.Ping > 0 {
b.WriteString(fmt.Sprintf("Response Time: %dms\n", msg.Ping))
}
if msg.Message != "" {
b.WriteString(fmt.Sprintf("\nMessage: %s\n", msg.Message))
}
b.WriteString(fmt.Sprintf("\n%s\n", msg.Body))
return b.String()
}
@@ -0,0 +1,67 @@
package providers
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/henrygd/beszel/internal/entities/notification"
)
type GotifyProvider struct {
settings notification.GotifySettings
}
func NewGotifyProvider(settings notification.GotifySettings) *GotifyProvider {
return &GotifyProvider{settings: settings}
}
func (p *GotifyProvider) Validate() error {
if p.settings.ServerURL == "" {
return fmt.Errorf("Gotify server URL is required")
}
if p.settings.AppToken == "" {
return fmt.Errorf("Gotify app token is required")
}
return nil
}
func (p *GotifyProvider) Send(msg *notification.NotificationMessage) error {
if err := p.Validate(); err != nil {
return err
}
payload := map[string]interface{}{
"title": msg.Title,
"message": msg.Body,
"priority": p.settings.Priority,
}
jsonData, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("failed to marshal payload: %w", err)
}
apiURL := fmt.Sprintf("%s/message?token=%s", p.settings.ServerURL, p.settings.AppToken)
req, err := http.NewRequest("POST", apiURL, bytes.NewBuffer(jsonData))
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("gotify request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return fmt.Errorf("gotify returned status %d", resp.StatusCode)
}
return nil
}
@@ -0,0 +1,58 @@
package providers
import (
"fmt"
"net/http"
"net/url"
"time"
"github.com/henrygd/beszel/internal/entities/notification"
)
type PushoverProvider struct {
settings notification.PushoverSettings
}
func NewPushoverProvider(settings notification.PushoverSettings) *PushoverProvider {
return &PushoverProvider{settings: settings}
}
func (p *PushoverProvider) Validate() error {
if p.settings.AppToken == "" {
return fmt.Errorf("Pushover app token is required")
}
if p.settings.UserKey == "" {
return fmt.Errorf("Pushover user key is required")
}
return nil
}
func (p *PushoverProvider) Send(msg *notification.NotificationMessage) error {
if err := p.Validate(); err != nil {
return err
}
data := url.Values{}
data.Set("token", p.settings.AppToken)
data.Set("user", p.settings.UserKey)
data.Set("title", msg.Title)
data.Set("message", msg.Body)
data.Set("priority", fmt.Sprintf("%d", p.settings.Priority))
if p.settings.Device != "" {
data.Set("device", p.settings.Device)
}
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.PostForm("https://api.pushover.net/1/messages.json", data)
if err != nil {
return fmt.Errorf("pushover API request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return fmt.Errorf("pushover API returned status %d", resp.StatusCode)
}
return nil
}
@@ -0,0 +1,101 @@
package providers
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/henrygd/beszel/internal/entities/notification"
)
type SlackProvider struct {
settings notification.SlackSettings
}
func NewSlackProvider(settings notification.SlackSettings) *SlackProvider {
return &SlackProvider{settings: settings}
}
func (p *SlackProvider) Validate() error {
if p.settings.WebhookURL == "" {
return fmt.Errorf("Slack webhook URL is required")
}
return nil
}
func (p *SlackProvider) Send(msg *notification.NotificationMessage) error {
if err := p.Validate(); err != nil {
return err
}
color := "good" // Green for UP
if msg.Status == "DOWN" {
color = "danger" // Red for DOWN
}
fields := []map[string]string{
{
"title": "Monitor",
"value": msg.MonitorName,
"short": "true",
},
{
"title": "Status",
"value": msg.Status,
"short": "true",
},
}
if msg.MonitorURL != "" {
fields = append(fields, map[string]string{
"title": "URL",
"value": msg.MonitorURL,
"short": "false",
})
}
payload := map[string]interface{}{
"attachments": []map[string]interface{}{
{
"color": color,
"title": msg.Title,
"text": msg.Body,
"fields": fields,
"timestamp": msg.Timestamp.Unix(),
},
},
}
if p.settings.Username != "" {
payload["username"] = p.settings.Username
}
if p.settings.Channel != "" {
payload["channel"] = p.settings.Channel
}
jsonData, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("failed to marshal payload: %w", err)
}
req, err := http.NewRequest("POST", p.settings.WebhookURL, bytes.NewBuffer(jsonData))
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("slack webhook request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return fmt.Errorf("slack webhook returned status %d", resp.StatusCode)
}
return nil
}
@@ -0,0 +1,82 @@
package providers
import (
"fmt"
"net/http"
"net/url"
"time"
"github.com/henrygd/beszel/internal/entities/notification"
)
type TelegramProvider struct {
settings notification.TelegramSettings
}
func NewTelegramProvider(settings notification.TelegramSettings) *TelegramProvider {
return &TelegramProvider{settings: settings}
}
func (p *TelegramProvider) Validate() error {
if p.settings.BotToken == "" {
return fmt.Errorf("Telegram bot token is required")
}
if p.settings.ChatID == "" {
return fmt.Errorf("Telegram chat ID is required")
}
return nil
}
func (p *TelegramProvider) Send(msg *notification.NotificationMessage) error {
if err := p.Validate(); err != nil {
return err
}
icon := "✅"
if msg.Status == "DOWN" {
icon = "❌"
}
text := fmt.Sprintf("%s *%s*\n\n"+
"*Monitor:* %s\n"+
"*Status:* %s\n"+
"*Time:* %s",
icon,
msg.Title,
msg.MonitorName,
msg.Status,
msg.Timestamp.Format("2006-01-02 15:04:05"),
)
if msg.MonitorURL != "" {
text += fmt.Sprintf("\n*URL:* %s", msg.MonitorURL)
}
if msg.Ping > 0 {
text += fmt.Sprintf("\n*Response Time:* %dms", msg.Ping)
}
if msg.Message != "" {
text += fmt.Sprintf("\n\n*Message:* %s", msg.Message)
}
apiURL := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", p.settings.BotToken)
data := url.Values{}
data.Set("chat_id", p.settings.ChatID)
data.Set("text", text)
data.Set("parse_mode", "Markdown")
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.PostForm(apiURL, data)
if err != nil {
return fmt.Errorf("telegram API request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return fmt.Errorf("telegram API returned status %d", resp.StatusCode)
}
return nil
}
@@ -0,0 +1,83 @@
package providers
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/henrygd/beszel/internal/entities/notification"
)
type WebhookProvider struct {
settings notification.WebhookSettings
}
func NewWebhookProvider(settings notification.WebhookSettings) *WebhookProvider {
return &WebhookProvider{settings: settings}
}
func (p *WebhookProvider) Validate() error {
if p.settings.URL == "" {
return fmt.Errorf("webhook URL is required")
}
return nil
}
func (p *WebhookProvider) Send(msg *notification.NotificationMessage) error {
if err := p.Validate(); err != nil {
return err
}
method := p.settings.Method
if method == "" {
method = "POST"
}
body := p.formatBody(msg)
req, err := http.NewRequest(method, p.settings.URL, bytes.NewBufferString(body))
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
for k, v := range p.settings.Headers {
req.Header.Set(k, v)
}
if req.Header.Get("Content-Type") == "" {
req.Header.Set("Content-Type", "application/json")
}
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("webhook request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return fmt.Errorf("webhook returned status %d", resp.StatusCode)
}
return nil
}
func (p *WebhookProvider) formatBody(msg *notification.NotificationMessage) string {
if p.settings.BodyTemplate != "" {
return p.settings.BodyTemplate
}
data := map[string]interface{}{
"title": msg.Title,
"body": msg.Body,
"monitor": msg.MonitorName,
"url": msg.MonitorURL,
"status": msg.Status,
"timestamp": msg.Timestamp,
"ping": msg.Ping,
"message": msg.Message,
}
b, _ := json.Marshal(data)
return string(b)
}
+203
View File
@@ -0,0 +1,203 @@
package notifications
import (
"encoding/json"
"os"
"time"
webpush "github.com/SherClockHolmes/webpush-go"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
)
// PushNotification represents a push notification message
type PushNotification struct {
Title string `json:"title"`
Body string `json:"body"`
Icon string `json:"icon,omitempty"`
Badge string `json:"badge,omitempty"`
Image string `json:"image,omitempty"`
Tag string `json:"tag,omitempty"`
Data map[string]string `json:"data,omitempty"`
Actions []Action `json:"actions,omitempty"`
RequireInteraction bool `json:"requireInteraction,omitempty"`
}
// Action represents a notification action button
type Action struct {
Action string `json:"action"`
Title string `json:"title"`
Icon string `json:"icon,omitempty"`
}
// PushSubscription represents a browser push subscription
type PushSubscription struct {
ID string `json:"id" db:"id"`
UserID string `json:"user" db:"user"`
Endpoint string `json:"endpoint" db:"endpoint"`
P256dh string `json:"p256dh" db:"p256dh"`
Auth string `json:"auth" db:"auth"`
Created time.Time `json:"created" db:"created"`
}
// PushService handles push notifications
type PushService struct {
app core.App
vapidPriv string
vapidPub string
}
// NewPushService creates a new push notification service
func NewPushService(app core.App) *PushService {
// Generate or load VAPID keys
// In production, load from BESZEL_VAPID_PRIVATE_KEY env var
privKey, pubKey := generateVAPIDKeys()
return &PushService{
app: app,
vapidPriv: privKey,
vapidPub: pubKey,
}
}
// RegisterSubscription registers a push subscription for a user
func (s *PushService) RegisterSubscription(userID string, sub *webpush.Subscription) error {
collection, err := s.app.FindCollectionByNameOrId("push_subscriptions")
if err != nil {
return err
}
// Check if subscription already exists
existing, _ := s.app.FindFirstRecordByFilter("push_subscriptions",
"user = {:user} && endpoint = {:endpoint}",
map[string]interface{}{"user": userID, "endpoint": sub.Endpoint})
if existing != nil {
// Update existing
existing.Set("p256dh", sub.Keys.P256dh)
existing.Set("auth", sub.Keys.Auth)
return s.app.Save(existing)
}
// Create new subscription
record := core.NewRecord(collection)
record.Set("user", userID)
record.Set("endpoint", sub.Endpoint)
record.Set("p256dh", sub.Keys.P256dh)
record.Set("auth", sub.Keys.Auth)
record.Set("created", time.Now())
return s.app.Save(record)
}
// UnregisterSubscription removes a push subscription
func (s *PushService) UnregisterSubscription(userID string, endpoint string) error {
record, err := s.app.FindFirstRecordByFilter("push_subscriptions",
"user = {:user} && endpoint = {:endpoint}",
map[string]interface{}{"user": userID, "endpoint": endpoint})
if err != nil {
return err
}
return s.app.Delete(record)
}
// SendNotification sends a push notification to a user
func (s *PushService) SendNotification(userID string, notification *PushNotification) error {
// Get all subscriptions for user
records, err := s.app.FindAllRecords("push_subscriptions",
dbx.NewExp("user = {:user}", dbx.Params{"user": userID}),
)
if err != nil {
return err
}
payload, err := json.Marshal(notification)
if err != nil {
return err
}
for _, record := range records {
sub := &webpush.Subscription{
Endpoint: record.GetString("endpoint"),
Keys: webpush.Keys{
P256dh: record.GetString("p256dh"),
Auth: record.GetString("auth"),
},
}
resp, err := webpush.SendNotification(payload, sub, &webpush.Options{
Subscriber: "beszel@localhost",
VAPIDPublicKey: s.vapidPub,
VAPIDPrivateKey: s.vapidPriv,
TTL: 30,
})
if err != nil {
// Log error but continue trying other subscriptions
continue
}
resp.Body.Close()
}
return nil
}
// BroadcastNotification sends notification to all users
func (s *PushService) BroadcastNotification(notification *PushNotification) error {
records, err := s.app.FindAllRecords("push_subscriptions")
if err != nil {
return err
}
payload, err := json.Marshal(notification)
if err != nil {
return err
}
for _, record := range records {
sub := &webpush.Subscription{
Endpoint: record.GetString("endpoint"),
Keys: webpush.Keys{
P256dh: record.GetString("p256dh"),
Auth: record.GetString("auth"),
},
}
resp, err := webpush.SendNotification(payload, sub, &webpush.Options{
Subscriber: "beszel@localhost",
VAPIDPublicKey: s.vapidPub,
VAPIDPrivateKey: s.vapidPriv,
TTL: 30,
})
if err != nil {
continue
}
resp.Body.Close()
}
return nil
}
// generateVAPIDKeys generates or loads VAPID keys for web push
func generateVAPIDKeys() (privateKey, publicKey string) {
// Check for environment variable first
if envKey := os.Getenv("BESZEL_VAPID_PRIVATE_KEY"); envKey != "" {
// If private key provided, we need to derive public key
// For now, return empty public key - will be handled by webpush lib
return envKey, ""
}
// Generate new VAPID key pair
privKey, pubKey, err := webpush.GenerateVAPIDKeys()
if err != nil {
// Return empty keys if generation fails
return "", ""
}
return privKey, pubKey
}
// GetVAPIDPublicKey returns the VAPID public key for client subscription
func (s *PushService) GetVAPIDPublicKey() string {
return s.vapidPub
}