319 lines
8.8 KiB
Go
319 lines
8.8 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/rand"
|
|
"crypto/rsa"
|
|
"crypto/x509"
|
|
"encoding/json"
|
|
"encoding/pem"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"crypto/x509/pkix"
|
|
"math/big"
|
|
)
|
|
|
|
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"`
|
|
}
|
|
type Config struct {
|
|
NtfyBaseURL string
|
|
NtfyToken string
|
|
NtfyUsername string
|
|
NtfyPassword string
|
|
BindAddress string
|
|
BindPort string
|
|
TLSCertFile string
|
|
TLSKeyFile string
|
|
}
|
|
type Middleware struct {
|
|
config Config
|
|
client *http.Client
|
|
}
|
|
|
|
func NewMiddleware(config Config) *Middleware {
|
|
return &Middleware{
|
|
config: config,
|
|
client: &http.Client{
|
|
Timeout: 10 * time.Second,
|
|
},
|
|
}
|
|
}
|
|
|
|
func (m *Middleware) extractMessage(payload *SlackWebhookPayload) string {
|
|
if payload.Text != "" {
|
|
return payload.Text
|
|
}
|
|
|
|
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"
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
func (m *Middleware) webhookHandler(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
topic := strings.TrimPrefix(r.URL.Path, "/")
|
|
if topic == "" {
|
|
http.Error(w, "Topic is required in URL path", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
topic = path.Clean(topic)
|
|
if strings.Contains(topic, "..") {
|
|
http.Error(w, "Invalid topic name", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
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
|
|
|
|
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 {
|
|
message = string(body)
|
|
if message == "" {
|
|
message = "Slack to ntfy Alert (no content)"
|
|
}
|
|
log.Printf("Received raw webhook for topic '%s': %s", topic, message)
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
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"),
|
|
TLSCertFile: getEnv("TLS_CERT_FILE", ""),
|
|
TLSKeyFile: getEnv("TLS_KEY_FILE", ""),
|
|
}
|
|
}
|
|
|
|
func getEnv(key, defaultValue string) string {
|
|
if value := os.Getenv(key); value != "" {
|
|
return value
|
|
}
|
|
return defaultValue
|
|
}
|
|
|
|
func generateSelfSignedCert(certFile, keyFile string) error {
|
|
log.Printf("Generating self-signed TLS certificate: %s and %s", certFile, keyFile)
|
|
|
|
priv, err := rsa.GenerateKey(rand.Reader, 2048)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to generate private key: %w", err)
|
|
}
|
|
|
|
notBefore := time.Now()
|
|
notAfter := notBefore.Add(365 * 24 * time.Hour)
|
|
|
|
serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128)
|
|
serialNumber, err := rand.Int(rand.Reader, serialNumberLimit)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to generate serial number: %w", err)
|
|
}
|
|
|
|
template := x509.Certificate{
|
|
SerialNumber: serialNumber,
|
|
Subject: pkix.Name{
|
|
Organization: []string{"Slack to ntfy Middleware"},
|
|
CommonName: "localhost",
|
|
},
|
|
NotBefore: notBefore,
|
|
NotAfter: notAfter,
|
|
|
|
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
|
|
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
|
BasicConstraintsValid: true,
|
|
IsCA: true,
|
|
}
|
|
|
|
derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create certificate: %w", err)
|
|
}
|
|
|
|
certDir := filepath.Dir(certFile)
|
|
if err := os.MkdirAll(certDir, 0755); err != nil {
|
|
return fmt.Errorf("failed to create certs directory %s: %w", certDir, err)
|
|
}
|
|
|
|
certOut, err := os.Create(certFile)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to open cert.pem for writing: %w", err)
|
|
}
|
|
if err := pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes}); err != nil {
|
|
return fmt.Errorf("failed to write data to cert.pem: %w", err)
|
|
}
|
|
if err := certOut.Close(); err != nil {
|
|
return fmt.Errorf("error closing cert.pem: %w", err)
|
|
}
|
|
|
|
keyOut, err := os.OpenFile(keyFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to open key.pem for writing: %w", err)
|
|
}
|
|
if err := pem.Encode(keyOut, &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(priv)}); err != nil {
|
|
return fmt.Errorf("failed to write data to key.pem: %w", err)
|
|
}
|
|
if err := keyOut.Close(); err != nil {
|
|
return fmt.Errorf("error closing key.pem: %w", err)
|
|
}
|
|
|
|
log.Println("Self-signed TLS certificate generated successfully.")
|
|
return nil
|
|
}
|
|
|
|
func main() {
|
|
config := loadConfig()
|
|
middleware := NewMiddleware(config)
|
|
|
|
http.HandleFunc("/health", middleware.healthHandler)
|
|
http.HandleFunc("/", middleware.webhookHandler)
|
|
|
|
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 != "")
|
|
|
|
certFile := config.TLSCertFile
|
|
keyFile := config.TLSKeyFile
|
|
|
|
if certFile == "" || keyFile == "" {
|
|
log.Println("TLS_CERT_FILE/TLS_KEY_FILE are not set. Attempting to generate self-signed certificates.")
|
|
|
|
certFile = "/app/certs/generated_server.crt"
|
|
keyFile = "/app/certs/generated_server.key"
|
|
|
|
if err := generateSelfSignedCert(certFile, keyFile); err != nil {
|
|
log.Printf("Failed to generate self-signed TLS certificate: %v. Falling back to HTTP.", err)
|
|
certFile = ""
|
|
keyFile = ""
|
|
} else {
|
|
log.Printf("Using generated TLS certificates: %s and %s", certFile, keyFile)
|
|
}
|
|
}
|
|
|
|
if certFile != "" && keyFile != "" {
|
|
log.Printf("TLS enabled: using %s and %s", certFile, keyFile)
|
|
if err := http.ListenAndServeTLS(bindAddr, certFile, keyFile, nil); err != nil {
|
|
log.Fatalf("Server failed to start with TLS: %v", err)
|
|
}
|
|
} else {
|
|
log.Printf("TLS disabled")
|
|
if err := http.ListenAndServe(bindAddr, nil); err != nil {
|
|
log.Fatalf("Server failed to start: %v", err)
|
|
}
|
|
}
|
|
}
|