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) } }