Files
slack-to-ntfy/main.go
2025-09-22 12:26:50 -07:00

233 lines
6.2 KiB
Go

package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"path"
"strings"
"time"
)
// SlackWebhookPayload represents the incoming Slack webhook format
type SlackWebhookPayload struct {
Text string `json:"text"`
Username string `json:"username,omitempty"`
Channel string `json:"channel,omitempty"`
IconEmoji string `json:"icon_emoji,omitempty"`
Attachments []struct {
Color string `json:"color,omitempty"`
Title string `json:"title,omitempty"`
Text string `json:"text,omitempty"`
Footer string `json:"footer,omitempty"`
Timestamp int64 `json:"ts,omitempty"`
} `json:"attachments,omitempty"`
}
// Config holds the middleware configuration
type Config struct {
NtfyBaseURL string
NtfyToken string
NtfyUsername string
NtfyPassword string
BindAddress string
BindPort string
}
// Middleware handles the webhook forwarding
type Middleware struct {
config Config
client *http.Client
}
func NewMiddleware(config Config) *Middleware {
return &Middleware{
config: config,
client: &http.Client{
Timeout: 10 * time.Second,
},
}
}
// extractMessage extracts the alert message from various Slack webhook formats
func (m *Middleware) extractMessage(payload *SlackWebhookPayload) string {
// Primary message text
if payload.Text != "" {
return payload.Text
}
// Check attachments for additional content
if len(payload.Attachments) > 0 {
var parts []string
for _, attachment := range payload.Attachments {
if attachment.Title != "" {
parts = append(parts, attachment.Title)
}
if attachment.Text != "" {
parts = append(parts, attachment.Text)
}
}
if len(parts) > 0 {
return strings.Join(parts, "\n")
}
}
return "Generic Message"
}
// forwardToNtfy sends the message to the ntfy server
func (m *Middleware) forwardToNtfy(topic, message string) error {
ntfyURL := fmt.Sprintf("%s/%s", strings.TrimRight(m.config.NtfyBaseURL, "/"), topic)
req, err := http.NewRequest("POST", ntfyURL, bytes.NewBufferString(message))
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "text/plain; charset=utf-8")
req.Header.Set("User-Agent", "slack-to-ntfy/1.0")
if m.config.NtfyToken != "" {
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", m.config.NtfyToken))
} else if m.config.NtfyUsername != "" && m.config.NtfyPassword != "" {
req.SetBasicAuth(m.config.NtfyUsername, m.config.NtfyPassword)
}
req.Header.Set("Title", "Slack to ntfy Alert")
req.Header.Set("Priority", "high")
req.Header.Set("Tags", "warning,computer")
resp, err := m.client.Do(req)
if err != nil {
return fmt.Errorf("failed to send request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("ntfy request failed with status %d: %s", resp.StatusCode, string(body))
}
log.Printf("Successfully forwarded message to ntfy topic '%s'", topic)
return nil
}
// webhookHandler handles incoming Slack webhook requests
func (m *Middleware) webhookHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Extract topic from URL path
topic := strings.TrimPrefix(r.URL.Path, "/")
if topic == "" {
http.Error(w, "Topic is required in URL path", http.StatusBadRequest)
return
}
// Clean topic (remove any path traversal attempts)
topic = path.Clean(topic)
if strings.Contains(topic, "..") {
http.Error(w, "Invalid topic name", http.StatusBadRequest)
return
}
// Read request body
body, err := io.ReadAll(r.Body)
if err != nil {
log.Printf("Failed to read request body: %v", err)
http.Error(w, "Failed to read request", http.StatusBadRequest)
return
}
var message string
// Try to parse as JSON (Slack webhook format)
var payload SlackWebhookPayload
if err := json.Unmarshal(body, &payload); err == nil {
message = m.extractMessage(&payload)
log.Printf("Received Slack webhook for topic '%s': %s", topic, message)
} else {
// Fallback to raw text
message = string(body)
if message == "" {
message = "Slack to ntfy Alert (no content)"
}
log.Printf("Received raw webhook for topic '%s': %s", topic, message)
}
// Forward to ntfy
if err := m.forwardToNtfy(topic, message); err != nil {
log.Printf("Failed to forward to ntfy: %v", err)
http.Error(w, "Failed to forward alert", http.StatusInternalServerError)
return
}
// Return success response
w.Header().Set("Content-Type", "application/json")
response := map[string]string{
"status": "success",
"message": "Alert forwarded to ntfy",
"topic": topic,
}
json.NewEncoder(w).Encode(response)
}
// healthHandler provides a health check endpoint
func (m *Middleware) healthHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
response := map[string]interface{}{
"status": "healthy",
"ntfy_url": m.config.NtfyBaseURL,
"auth_enabled": m.config.NtfyToken != "" || m.config.NtfyUsername != "",
"timestamp": time.Now().Unix(),
}
json.NewEncoder(w).Encode(response)
}
func loadConfig() Config {
return Config{
NtfyBaseURL: getEnv("NTFY_BASE_URL", "https://ntfy.sh"),
NtfyToken: getEnv("NTFY_TOKEN", ""),
NtfyUsername: getEnv("NTFY_USERNAME", ""),
NtfyPassword: getEnv("NTFY_PASSWORD", ""),
BindAddress: getEnv("BIND_ADDRESS", "0.0.0.0"),
BindPort: getEnv("BIND_PORT", "8080"),
}
}
func getEnv(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
}
return defaultValue
}
func main() {
config := loadConfig()
middleware := NewMiddleware(config)
// Setup routes
http.HandleFunc("/health", middleware.healthHandler)
http.HandleFunc("/", middleware.webhookHandler)
// Start server
bindAddr := fmt.Sprintf("%s:%s", config.BindAddress, config.BindPort)
log.Printf("Starting Slack to ntfy middleware server")
log.Printf("Listening on: %s", bindAddr)
log.Printf("ntfy URL: %s", config.NtfyBaseURL)
log.Printf("Authentication: %t", config.NtfyToken != "" || config.NtfyUsername != "")
if err := http.ListenAndServe(bindAddr, nil); err != nil {
log.Fatalf("Server failed to start: %v", err)
}
}