From 58db72a7e8d7b098abda4aa4e7e2f7607d277f98 Mon Sep 17 00:00:00 2001 From: Ivan Godwin Date: Mon, 22 Sep 2025 12:26:50 -0700 Subject: [PATCH] Initial commit --- Dockerfile | 38 ++++++++ Makefile | 54 +++++++++++ README.md | 78 +++++++++++++++ docker-compose.yml | 46 +++++++++ go.mod | 3 + go.sum | 0 main.go | 232 +++++++++++++++++++++++++++++++++++++++++++++ scripts/build.sh | 26 +++++ scripts/setup.sh | 56 +++++++++++ scripts/test.sh | 39 ++++++++ 10 files changed, 572 insertions(+) create mode 100644 Dockerfile create mode 100644 Makefile create mode 100644 README.md create mode 100644 docker-compose.yml create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100755 scripts/build.sh create mode 100755 scripts/setup.sh create mode 100755 scripts/test.sh diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c58e064 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,38 @@ + +# Build stage +FROM golang:1.21-alpine AS builder + +WORKDIR /app + +# Copy go mod files +COPY go.mod go.sum* ./ + +# Download dependencies +RUN go mod download + +# Copy source code +COPY main.go . + +# Build the binary +RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o middleware main.go + +# Final stage +FROM alpine:latest + +# Add ca-certificates for HTTPS requests +RUN apk --no-cache add ca-certificates tzdata + +WORKDIR /root/ + +# Copy the binary from builder stage +COPY --from=builder /app/middleware . + +# Expose port +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 + +# Run the binary +CMD ["./middleware"] \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..8ef8ed0 --- /dev/null +++ b/Makefile @@ -0,0 +1,54 @@ +.PHONY: build run test clean docker-build docker-run help + +BINARY_NAME=middleware +DOCKER_IMAGE=slack-to-ntfy + +# Default target +help: + @echo "Available targets:" + @echo " build - Build the Go binary" + @echo " run - Run the application locally" + @echo " test - Run tests" + @echo " clean - Clean build artifacts" + @echo " docker-build - Build Docker image" + @echo " docker-run - Run with Docker Compose" + @echo " docker-stop - Stop Docker Compose" + @echo " logs - Show Docker logs" + +build: + go build -o $(BINARY_NAME) main.go + +run: build + ./$(BINARY_NAME) + +test: + go test -v ./... + +clean: + go clean + rm -f $(BINARY_NAME) + +docker-build: + docker build -t $(DOCKER_IMAGE) . + +docker-run: + docker compose up -d + +docker-stop: + docker compose down + +logs: + docker compose logs -f + +# Development helpers +fmt: + go fmt ./... + +vet: + go vet ./... + +mod-tidy: + go mod tidy + +deps: mod-tidy + go mod download diff --git a/README.md b/README.md new file mode 100644 index 0000000..96ae147 --- /dev/null +++ b/README.md @@ -0,0 +1,78 @@ +# Slack to ntfy Middleware + +A lightweight Go service that acts as a middleware between Slack webhooks and ntfy servers, with Bearer token authentication and basic authentication support. + +## Features + +- ✅ Parses Slack webhook format +- ✅ Forwards alerts to self-hosted ntfy servers +- ✅ Bearer token authentication support +- ✅ Health check endpoint +- ✅ Lightweight Docker container (~8MB) +- ✅ High performance and low resource usage + +## Quick Start + +1. **Configure the service**: + ```bash + # Edit docker-compose.yml and set: + # - NTFY_BASE_URL=https://your-ntfy-server.com + # - NTFY_TOKEN=tk_your_bearer_token + # OR + # - NTFY_USERNAME=your_username + # - NTFY_PASSWORD=your_password + ``` + +2. **Run the service**: + ```bash + docker compose up -d + ``` + +3. **Configure Slack**: + - Go to Slack Integrations → Incoming Webhooks + - Add new webhook + - Webhook URL: `http://your-server-ip:8080/your-topic-name` + +4. **Test the service**: + ```bash + # Test webhook + curl -X POST http://localhost:8080/test-topic \ + -H 'Content-Type: application/json' \ + -d '{"text": "Test alert from Slack to ntfy"}' + + # Check health + curl http://localhost:8080/health + ``` + +## Configuration + +| Environment Variable | Default | Description | +|---------------------|---------|-------------| +| `NTFY_BASE_URL` | `https://ntfy.sh` | Your ntfy server URL (without topic) | +| `NTFY_TOKEN` | `""` | Bearer token for ntfy authentication | +| `NTFY_USERNAME` | `""` | Username for ntfy basic authentication | +| `NTFY_PASSWORD` | `""` | Password for ntfy basic authentication | +| `BIND_ADDRESS` | `0.0.0.0` | Interface to bind to | +| `BIND_PORT` | `8080` | Port to listen on | + +## Development + +### Build locally +```bash +go build -o middleware main.go +./middleware +``` + +### Build Docker image +```bash +docker build -t slack-to-ntfy . +``` + +### Run tests +```bash +go test ./... +``` + +## License + +MIT License diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..7111625 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,46 @@ +version: '3.8' + +services: + slack-to-ntfy: + build: . + container_name: slack-to-ntfy + ports: + - "8080:8080" + environment: + # Replace with your ntfy server URL (without topic path) + - NTFY_BASE_URL=https://ntfy.dangerzone.dev + + # For token-based authentication, uncomment and replace with your ntfy Bearer token (e.g., tk_xxxx) + - NTFY_TOKEN=tk_7hdlipcf02hsaslgmav70ruzkurcp + + # For username/password authentication, uncomment and replace with your ntfy credentials + # - NTFY_USERNAME=your_username + # - NTFY_PASSWORD=your_password + + # Bind configuration + - BIND_ADDRESS=0.0.0.0 + - BIND_PORT=8080 + restart: unless-stopped + + # Optional: Resource limits + deploy: + resources: + limits: + memory: 32M + cpus: '0.1' + reservations: + memory: 16M + cpus: '0.05' + + # Health check using the built-in endpoint + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s + +# Optional: Create a custom network +networks: + default: + name: slack-ntfy-middleware \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..cf738a2 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module slack-to-ntfy + +go 1.21 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..e69de29 diff --git a/main.go b/main.go new file mode 100644 index 0000000..6663cae --- /dev/null +++ b/main.go @@ -0,0 +1,232 @@ +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) + } +} diff --git a/scripts/build.sh b/scripts/build.sh new file mode 100755 index 0000000..18e2ae7 --- /dev/null +++ b/scripts/build.sh @@ -0,0 +1,26 @@ +#!/bin/bash +set -e + +echo "🔨 Building Slack to ntfy middleware..." + +# Build for different platforms +PLATFORMS="linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64" +OUTPUT_DIR="dist" + +mkdir -p $OUTPUT_DIR + +for platform in $PLATFORMS; do + OS=$(echo $platform | cut -d'/' -f1) + ARCH=$(echo $platform | cut -d'/' -f2) + + OUTPUT_NAME="middleware-$OS-$ARCH" + if [ $OS = "windows" ]; then + OUTPUT_NAME="$OUTPUT_NAME.exe" + fi + + echo "Building for $OS/$ARCH..." + CGO_ENABLED=0 GOOS=$OS GOARCH=$ARCH go build -a -installsuffix cgo -o $OUTPUT_DIR/$OUTPUT_NAME main.go +done + +echo "✅ Build complete! Binaries available in $OUTPUT_DIR/" +ls -la $OUTPUT_DIR/ diff --git a/scripts/setup.sh b/scripts/setup.sh new file mode 100755 index 0000000..b7847da --- /dev/null +++ b/scripts/setup.sh @@ -0,0 +1,56 @@ +#!/bin/bash +set -e + +echo "⚙️ Setting up Slack to ntfy middleware..." + +# Check if Docker is installed +if ! command -v docker &> /dev/null; then + echo "❌ Docker is not installed. Please install Docker first." + exit 1 +fi + +# Check if Docker Compose is available +if ! docker compose version &> /dev/null; then + echo "❌ Docker Compose is not available. Please install Docker Compose." + exit 1 +fi + +echo "✅ Docker and Docker Compose are available" + +# Prompt for configuration +echo +echo "📝 Please provide your ntfy server configuration:" +read -p "ntfy server URL (e.g., https://ntfy.example.com): " NTFY_URL +read -p "Bearer token (e.g., tk_xxxxx) [optional]: " NTFY_TOKEN +read -p "ntfy username (for basic auth) [optional]: " NTFY_USERNAME +read -p "ntfy password (for basic auth) [optional]: " NTFY_PASSWORD + +# Update docker-compose.yml +if [ ! -z "$NTFY_URL" ]; then + sed -i '' "s|https://your-ntfy-server.com|$NTFY_URL|g" docker-compose.yml +fi + +# Reset all auth lines to commented placeholders first to ensure a clean state +sed -i '' "s|^ - NTFY_TOKEN=.*| # - NTFY_TOKEN=tk_your_bearer_token_here|" docker-compose.yml +sed -i '' "s|^ - NTFY_USERNAME=.*| # - NTFY_USERNAME=your_username|" docker-compose.yml +sed -i '' "s|^ - NTFY_PASSWORD=.*| # - NTFY_PASSWORD=your_password|" docker-compose.yml + +if [ ! -z "$NTFY_TOKEN" ]; then + sed -i '' "s|^ # - NTFY_TOKEN=tk_your_bearer_token_here| - NTFY_TOKEN=$NTFY_TOKEN|" docker-compose.yml +elif [ ! -z "$NTFY_USERNAME" ] && [ ! -z "$NTFY_PASSWORD" ]; then + sed -i '' "s|^ # - NTFY_USERNAME=your_username| - NTFY_USERNAME=$NTFY_USERNAME|" docker-compose.yml + sed -i '' "s|^ # - NTFY_PASSWORD=your_password| - NTFY_PASSWORD=$NTFY_PASSWORD|" docker-compose.yml +fi + +echo "✅ Configuration updated in docker-compose.yml" + +# Build and start +echo "🐳 Building and starting the service..." +docker compose up -d --build + +echo "✅ Service is starting up..." +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" diff --git a/scripts/test.sh b/scripts/test.sh new file mode 100755 index 0000000..3e765a6 --- /dev/null +++ b/scripts/test.sh @@ -0,0 +1,39 @@ +#!/bin/bash +set -e + +MIDDLEWARE_URL="http://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 + echo "❌ Middleware is not running on $MIDDLEWARE_URL" + echo "Please start it with: docker compose up -d" + exit 1 +fi + +echo "✅ Middleware is running" + +# Test health endpoint +echo "Testing health endpoint..." +HEALTH_RESPONSE=$(curl -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" \ + -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" \ + -H 'Content-Type: text/plain' \ + -d 'Plain text test alert' \ + -w "\nHTTP Status: %{http_code}\n" + +echo "✅ Tests completed!" +echo "Check your ntfy client to see if messages were delivered to topic: $TEST_TOPIC"