Compare commits

..

2 Commits

Author SHA1 Message Date
igodwin be4e84b7e3 Update Makefile targets and doc 2025-09-22 13:06:26 -07:00
igodwin 4d6870897d Initial commit 2025-09-22 12:26:50 -07:00
12 changed files with 38 additions and 221 deletions
-44
View File
@@ -1,44 +0,0 @@
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test binary, built with `go test -c`
*.test
# Output of the go coverage tool, specifically when used with LiteIDE
*.out
# Dependency directories (remove the comment below to include it)
# vendor/
# Go workspace file
go.work
# Build artifacts
/middleware
/dist/
/certs/
# Docker
.dockerignore
# Environment files
.env
.env.local
# IDE files
.vscode/
.idea/
*.swp
*.swo
*~
# OS files
.DS_Store
Thumbs.db
# Logs
*.log
+1 -1
View File
@@ -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 https://localhost:8080/health || exit 1
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1
# Run the binary
CMD ["./middleware"]
+6 -51
View File
@@ -8,7 +8,7 @@ A lightweight Go service that acts as a middleware between Slack webhooks and nt
- ✅ Forwards alerts to self-hosted ntfy servers
- ✅ Bearer token authentication support
- ✅ Health check endpoint
- ✅ Lightweight Docker container (~8.5MB)
- ✅ Lightweight Docker container (~8.4MB)
- ✅ High performance and low resource usage
## Quick Start
@@ -31,25 +31,17 @@ 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: `https://your-server-ip:8080/your-topic-name`
- Webhook URL: `http://your-server-ip:8080/your-topic-name`
4. **Test the service**:
```bash
# Test webhook with HTTP (if TLS is disabled)
curl -X POST https://localhost:8080/test-topic \
# Test webhook
curl -X POST http://localhost:8080/test-topic \
-H 'Content-Type: application/json' \
-d '{"text": "Test alert from Slack to ntfy"}'
# 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)
# Check health
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
@@ -62,43 +54,6 @@ 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
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.
+3 -11
View File
@@ -8,10 +8,10 @@ services:
- "8080:8080"
environment:
# Replace with your ntfy server URL (without topic path)
- NTFY_BASE_URL=https://your-ntfy-server.com
- 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_your_bearer_token_here
- NTFY_TOKEN=tk_7hdlipcf02hsaslgmav70ruzkurcp
# For username/password authentication, uncomment and replace with your ntfy credentials
# - NTFY_USERNAME=your_username
@@ -20,15 +20,7 @@ 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:
@@ -42,7 +34,7 @@ services:
# Health check using the built-in endpoint
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "https://localhost:8080/health"]
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/health"]
interval: 30s
timeout: 10s
retries: 3
+22 -108
View File
@@ -2,25 +2,18 @@ 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"`
@@ -34,6 +27,8 @@ type SlackWebhookPayload struct {
Timestamp int64 `json:"ts,omitempty"`
} `json:"attachments,omitempty"`
}
// Config holds the middleware configuration
type Config struct {
NtfyBaseURL string
NtfyToken string
@@ -41,9 +36,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
@@ -58,11 +53,14 @@ 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
@@ -83,6 +81,7 @@ 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)
@@ -119,24 +118,28 @@ 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)
@@ -146,11 +149,13 @@ 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)"
@@ -158,12 +163,14 @@ 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",
@@ -173,6 +180,7 @@ 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{}{
@@ -192,8 +200,6 @@ 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", ""),
}
}
@@ -204,81 +210,15 @@ 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")
@@ -286,33 +226,7 @@ func main() {
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)
}
if err := http.ListenAndServe(bindAddr, nil); err != nil {
log.Fatalf("Server failed to start: %v", err)
}
}
+1 -1
View File
@@ -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: https://your-server-ip:8080/your-topic-name"
echo "3. Configure Slack webhook URL: http://your-server-ip:8080/your-topic-name"
+5 -5
View File
@@ -1,14 +1,14 @@
#!/bin/bash
set -e
MIDDLEWARE_URL="https://localhost:8080"
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 -k -s "$MIDDLEWARE_URL/health" > /dev/null; then
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
@@ -18,19 +18,19 @@ echo "✅ Middleware is running"
# Test health endpoint
echo "Testing health endpoint..."
HEALTH_RESPONSE=$(curl -k -s "$MIDDLEWARE_URL/health")
HEALTH_RESPONSE=$(curl -s "$MIDDLEWARE_URL/health")
echo "Health response: $HEALTH_RESPONSE"
# Test Slack webhook format
echo "Testing Slack webhook format..."
curl -k -X POST "$MIDDLEWARE_URL/$TEST_TOPIC" \
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 -k -X POST "$MIDDLEWARE_URL/$TEST_TOPIC" \
curl -X POST "$MIDDLEWARE_URL/$TEST_TOPIC" \
-H 'Content-Type: text/plain' \
-d 'Plain text test alert' \
-w "\nHTTP Status: %{http_code}\n"