diff --git a/.gitignore b/.gitignore index a7903ba..88eda5d 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,7 @@ go.work # Build artifacts /middleware /dist/ +/certs/ # Docker .dockerignore diff --git a/Dockerfile b/Dockerfile index c58e064..381fa50 100644 --- a/Dockerfile +++ b/Dockerfile @@ -32,7 +32,7 @@ EXPOSE 8080 # Health check HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ - CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1 + CMD wget --no-verbose --tries=1 --spider https://localhost:8080/health || exit 1 # Run the binary CMD ["./middleware"] \ No newline at end of file diff --git a/README.md b/README.md index b77118b..976104d 100644 --- a/README.md +++ b/README.md @@ -31,17 +31,25 @@ A lightweight Go service that acts as a middleware between Slack webhooks and nt 3. **Configure Slack**: - Go to Slack Integrations โ†’ Incoming Webhooks - Add new webhook - - Webhook URL: `http://your-server-ip:8080/your-topic-name` + - Webhook URL: `https://your-server-ip:8080/your-topic-name` 4. **Test the service**: ```bash - # Test webhook - curl -X POST http://localhost:8080/test-topic \ + # Test webhook with HTTP (if TLS is disabled) + curl -X POST https://localhost:8080/test-topic \ -H 'Content-Type: application/json' \ -d '{"text": "Test alert from Slack to ntfy"}' - - # Check health + + # Test webhook with HTTPS (if TLS is enabled, and if using self-signed certs, add -k or --insecure) + curl -k -X POST https://localhost:8080/test-topic -k \ + -H 'Content-Type: application/json' \ + -d '{"text": "Test alert from Slack to ntfy (TLS)"}' + + # Check health with HTTP (if TLS is disabled) curl http://localhost:8080/health + + # Check health with HTTPS (if TLS is enabled, and if using self-signed certs, add -k or --insecure) + curl https://localhost:8080/health -k ``` ## Configuration @@ -54,6 +62,43 @@ A lightweight Go service that acts as a middleware between Slack webhooks and nt | `NTFY_PASSWORD` | `""` | Password for ntfy basic authentication | | `BIND_ADDRESS` | `0.0.0.0` | Interface to bind to | | `BIND_PORT` | `8080` | Port to listen on | +| `TLS_CERT_FILE` | `""` | Path to TLS certificate file (e.g., `/app/certs/server.crt`) | +| `TLS_KEY_FILE` | `""` | Path to TLS private key file (e.g., `/app/certs/server.key`) | + +### Enabling TLS + +TLS is enabled by default. If `TLS_CERT_FILE` and `TLS_KEY_FILE` environment variables are not set, a self-signed certificate and key will be automatically generated on startup. + +**To provide your own certificate and key files (optional)**: + +1. **Create a `certs` directory** in the root of your project: + ```bash + mkdir certs + # Copy your server.crt and server.key into the certs/ directory + ``` +2. **Uncomment and set `TLS_CERT_FILE` and `TLS_KEY_FILE`** in your `docker-compose.yml` (e.g., pointing to `/app/certs/server.crt` and `/app/certs/server.key`): + ```yaml + environment: + # ... existing environment variables ... + - TLS_CERT_FILE=/app/certs/server.crt + - TLS_KEY_FILE=/app/certs/server.key + ``` +3. Ensure the `volumes` section is uncommented and correctly mounts the `certs` directory: + ```yaml + volumes: + - ./certs:/app/certs + ``` + +**Important:** Regardless of whether you use generated or custom certificates: + +* **Update your Slack webhook URL** to use `https`. +* **Restart your Docker service**: + ```bash + docker compose down + docker compose up -d + ``` +* When testing with `curl` against a self-signed certificate, you may need to add the `-k` or `--insecure` flag to bypass certificate validation. +* **Exposing on standard HTTPS port (443) in production**: While the service runs on port 8080 internally, it's common to map it to port 443 externally (e.g., `- "443:8080"` in `docker-compose.yml`) or use a reverse proxy to handle TLS termination on port 443 and forward traffic to the container's port 8080. ## Development diff --git a/docker-compose.yml b/docker-compose.yml index f4788e4..2838c92 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,7 +11,7 @@ services: - NTFY_BASE_URL=https://your-ntfy-server.com # For token-based authentication, uncomment and replace with your ntfy Bearer token (e.g., tk_xxxx) - - NTFY_TOKEN=tk_your_bearer_token_here + # - NTFY_TOKEN=tk_your_bearer_token_here # For username/password authentication, uncomment and replace with your ntfy credentials # - NTFY_USERNAME=your_username @@ -20,7 +20,15 @@ services: # Bind configuration - BIND_ADDRESS=0.0.0.0 - BIND_PORT=8080 + # TLS Configuration + # Uncomment and replace with your certificate and key file paths (relative to the container) + # - TLS_CERT_FILE=/app/certs/server.crt + # - TLS_KEY_FILE=/app/certs/server.key restart: unless-stopped + volumes: + # Mount the local 'certs' directory into the container to provide TLS certificates + # Ensure you have server.crt and server.key in a 'certs' directory next to docker-compose.yml + - ./certs:/app/certs # Optional: Resource limits deploy: @@ -34,7 +42,7 @@ services: # Health check using the built-in endpoint healthcheck: - test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/health"] + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "https://localhost:8080/health"] interval: 30s timeout: 10s retries: 3 diff --git a/main.go b/main.go index 6663cae..248ddc9 100644 --- a/main.go +++ b/main.go @@ -2,18 +2,25 @@ 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" ) -// SlackWebhookPayload represents the incoming Slack webhook format type SlackWebhookPayload struct { Text string `json:"text"` Username string `json:"username,omitempty"` @@ -27,8 +34,6 @@ type SlackWebhookPayload struct { Timestamp int64 `json:"ts,omitempty"` } `json:"attachments,omitempty"` } - -// Config holds the middleware configuration type Config struct { NtfyBaseURL string NtfyToken string @@ -36,9 +41,9 @@ type Config struct { NtfyPassword string BindAddress string BindPort string + TLSCertFile string + TLSKeyFile string } - -// Middleware handles the webhook forwarding type Middleware struct { config Config client *http.Client @@ -53,14 +58,11 @@ func NewMiddleware(config Config) *Middleware { } } -// 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 @@ -81,7 +83,6 @@ func (m *Middleware) extractMessage(payload *SlackWebhookPayload) string { 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) @@ -118,28 +119,24 @@ func (m *Middleware) forwardToNtfy(topic, message string) error { 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) @@ -149,13 +146,11 @@ func (m *Middleware) webhookHandler(w http.ResponseWriter, r *http.Request) { 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)" @@ -163,14 +158,12 @@ func (m *Middleware) webhookHandler(w http.ResponseWriter, r *http.Request) { 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", @@ -180,7 +173,6 @@ func (m *Middleware) webhookHandler(w http.ResponseWriter, r *http.Request) { 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{}{ @@ -200,6 +192,8 @@ func loadConfig() Config { 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", ""), } } @@ -210,15 +204,81 @@ func getEnv(key, defaultValue string) string { 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) - // 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") @@ -226,7 +286,33 @@ func main() { 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) + 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) + } } } diff --git a/scripts/setup.sh b/scripts/setup.sh index b7847da..3bd033f 100755 --- a/scripts/setup.sh +++ b/scripts/setup.sh @@ -53,4 +53,4 @@ echo echo "Next steps:" echo "1. Test the service: ./scripts/test.sh" echo "2. Check logs: docker compose logs -f" -echo "3. Configure Slack webhook URL: http://your-server-ip:8080/your-topic-name" +echo "3. Configure Slack webhook URL: https://your-server-ip:8080/your-topic-name" diff --git a/scripts/test.sh b/scripts/test.sh index 3e765a6..00d380e 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -1,14 +1,14 @@ #!/bin/bash set -e -MIDDLEWARE_URL="http://localhost:8080" +MIDDLEWARE_URL="https://localhost:8080" TEST_TOPIC="test-topic" echo "๐Ÿงช Testing Slack to ntfy middleware..." # Check if service is running echo "Checking if middleware is running..." -if ! curl -s "$MIDDLEWARE_URL/health" > /dev/null; then +if ! curl -k -s "$MIDDLEWARE_URL/health" > /dev/null; then echo "โŒ Middleware is not running on $MIDDLEWARE_URL" echo "Please start it with: docker compose up -d" exit 1 @@ -18,19 +18,19 @@ echo "โœ… Middleware is running" # Test health endpoint echo "Testing health endpoint..." -HEALTH_RESPONSE=$(curl -s "$MIDDLEWARE_URL/health") +HEALTH_RESPONSE=$(curl -k -s "$MIDDLEWARE_URL/health") echo "Health response: $HEALTH_RESPONSE" # Test Slack webhook format echo "Testing Slack webhook format..." -curl -X POST "$MIDDLEWARE_URL/$TEST_TOPIC" \ +curl -k -X POST "$MIDDLEWARE_URL/$TEST_TOPIC" \ -H 'Content-Type: application/json' \ -d '{"text": "๐Ÿงช Test alert from Slack to ntfy middleware test script"}' \ -w "\nHTTP Status: %{http_code}\n" # Test plain text format echo "Testing plain text format..." -curl -X POST "$MIDDLEWARE_URL/$TEST_TOPIC" \ +curl -k -X POST "$MIDDLEWARE_URL/$TEST_TOPIC" \ -H 'Content-Type: text/plain' \ -d 'Plain text test alert' \ -w "\nHTTP Status: %{http_code}\n"