Initial commit

This commit is contained in:
2025-09-22 12:26:50 -07:00
commit 4d6870897d
15 changed files with 572 additions and 0 deletions
+38
View File
@@ -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"]
+54
View File
@@ -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
+78
View File
@@ -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
Vendored Executable
BIN
View File
Binary file not shown.
Vendored Executable
BIN
View File
Binary file not shown.
Vendored Executable
BIN
View File
Binary file not shown.
Vendored Executable
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+46
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
module slack-to-ntfy
go 1.21
View File
+232
View File
@@ -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)
}
}
+26
View File
@@ -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/
+56
View File
@@ -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"
+39
View File
@@ -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"