2 Commits

Author SHA1 Message Date
78f2ebde84 Update container size 2025-09-23 00:57:03 -07:00
b59fd7dcc4 Enable TLS by default 2025-09-23 00:48:57 -07:00
8 changed files with 177 additions and 46 deletions

1
.gitignore vendored
View File

@@ -20,6 +20,7 @@ go.work
# Build artifacts # Build artifacts
/middleware /middleware
/dist/ /dist/
/certs/
# Docker # Docker
.dockerignore .dockerignore

View File

@@ -32,7 +32,7 @@ EXPOSE 8080
# Health check # Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ 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 # Run the binary
CMD ["./middleware"] CMD ["./middleware"]

View File

@@ -1,9 +0,0 @@
MIT License
Copyright (c) 2025 igodwin
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

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 - ✅ Forwards alerts to self-hosted ntfy servers
- ✅ Bearer token authentication support - ✅ Bearer token authentication support
- ✅ Health check endpoint - ✅ Health check endpoint
- ✅ Lightweight Docker container (~8.4MB) - ✅ Lightweight Docker container (~8.5MB)
- ✅ High performance and low resource usage - ✅ High performance and low resource usage
## Quick Start ## Quick Start
@@ -31,17 +31,25 @@ A lightweight Go service that acts as a middleware between Slack webhooks and nt
3. **Configure Slack**: 3. **Configure Slack**:
- Go to Slack Integrations → Incoming Webhooks - Go to Slack Integrations → Incoming Webhooks
- Add new webhook - 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**: 4. **Test the service**:
```bash ```bash
# Test webhook # Test webhook with HTTP (if TLS is disabled)
curl -X POST http://localhost:8080/test-topic \ curl -X POST https://localhost:8080/test-topic \
-H 'Content-Type: application/json' \ -H 'Content-Type: application/json' \
-d '{"text": "Test alert from Slack to ntfy"}' -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 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 ## 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 | | `NTFY_PASSWORD` | `""` | Password for ntfy basic authentication |
| `BIND_ADDRESS` | `0.0.0.0` | Interface to bind to | | `BIND_ADDRESS` | `0.0.0.0` | Interface to bind to |
| `BIND_PORT` | `8080` | Port to listen on | | `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 ## Development

View File

@@ -11,7 +11,7 @@ services:
- NTFY_BASE_URL=https://your-ntfy-server.com - NTFY_BASE_URL=https://your-ntfy-server.com
# For token-based authentication, uncomment and replace with your ntfy Bearer token (e.g., tk_xxxx) # 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 # For username/password authentication, uncomment and replace with your ntfy credentials
# - NTFY_USERNAME=your_username # - NTFY_USERNAME=your_username
@@ -20,7 +20,15 @@ services:
# Bind configuration # Bind configuration
- BIND_ADDRESS=0.0.0.0 - BIND_ADDRESS=0.0.0.0
- BIND_PORT=8080 - 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 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 # Optional: Resource limits
deploy: deploy:
@@ -34,7 +42,7 @@ services:
# Health check using the built-in endpoint # Health check using the built-in endpoint
healthcheck: 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 interval: 30s
timeout: 10s timeout: 10s
retries: 3 retries: 3

130
main.go
View File

@@ -2,18 +2,25 @@ package main
import ( import (
"bytes" "bytes"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/json" "encoding/json"
"encoding/pem"
"fmt" "fmt"
"io" "io"
"log" "log"
"net/http" "net/http"
"os" "os"
"path" "path"
"path/filepath"
"strings" "strings"
"time" "time"
"crypto/x509/pkix"
"math/big"
) )
// SlackWebhookPayload represents the incoming Slack webhook format
type SlackWebhookPayload struct { type SlackWebhookPayload struct {
Text string `json:"text"` Text string `json:"text"`
Username string `json:"username,omitempty"` Username string `json:"username,omitempty"`
@@ -27,8 +34,6 @@ type SlackWebhookPayload struct {
Timestamp int64 `json:"ts,omitempty"` Timestamp int64 `json:"ts,omitempty"`
} `json:"attachments,omitempty"` } `json:"attachments,omitempty"`
} }
// Config holds the middleware configuration
type Config struct { type Config struct {
NtfyBaseURL string NtfyBaseURL string
NtfyToken string NtfyToken string
@@ -36,9 +41,9 @@ type Config struct {
NtfyPassword string NtfyPassword string
BindAddress string BindAddress string
BindPort string BindPort string
TLSCertFile string
TLSKeyFile string
} }
// Middleware handles the webhook forwarding
type Middleware struct { type Middleware struct {
config Config config Config
client *http.Client 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 { func (m *Middleware) extractMessage(payload *SlackWebhookPayload) string {
// Primary message text
if payload.Text != "" { if payload.Text != "" {
return payload.Text return payload.Text
} }
// Check attachments for additional content
if len(payload.Attachments) > 0 { if len(payload.Attachments) > 0 {
var parts []string var parts []string
@@ -81,7 +83,6 @@ func (m *Middleware) extractMessage(payload *SlackWebhookPayload) string {
return "Generic Message" return "Generic Message"
} }
// forwardToNtfy sends the message to the ntfy server
func (m *Middleware) forwardToNtfy(topic, message string) error { func (m *Middleware) forwardToNtfy(topic, message string) error {
ntfyURL := fmt.Sprintf("%s/%s", strings.TrimRight(m.config.NtfyBaseURL, "/"), topic) 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 return nil
} }
// webhookHandler handles incoming Slack webhook requests
func (m *Middleware) webhookHandler(w http.ResponseWriter, r *http.Request) { func (m *Middleware) webhookHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return return
} }
// Extract topic from URL path
topic := strings.TrimPrefix(r.URL.Path, "/") topic := strings.TrimPrefix(r.URL.Path, "/")
if topic == "" { if topic == "" {
http.Error(w, "Topic is required in URL path", http.StatusBadRequest) http.Error(w, "Topic is required in URL path", http.StatusBadRequest)
return return
} }
// Clean topic (remove any path traversal attempts)
topic = path.Clean(topic) topic = path.Clean(topic)
if strings.Contains(topic, "..") { if strings.Contains(topic, "..") {
http.Error(w, "Invalid topic name", http.StatusBadRequest) http.Error(w, "Invalid topic name", http.StatusBadRequest)
return return
} }
// Read request body
body, err := io.ReadAll(r.Body) body, err := io.ReadAll(r.Body)
if err != nil { if err != nil {
log.Printf("Failed to read request body: %v", err) 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 var message string
// Try to parse as JSON (Slack webhook format)
var payload SlackWebhookPayload var payload SlackWebhookPayload
if err := json.Unmarshal(body, &payload); err == nil { if err := json.Unmarshal(body, &payload); err == nil {
message = m.extractMessage(&payload) message = m.extractMessage(&payload)
log.Printf("Received Slack webhook for topic '%s': %s", topic, message) log.Printf("Received Slack webhook for topic '%s': %s", topic, message)
} else { } else {
// Fallback to raw text
message = string(body) message = string(body)
if message == "" { if message == "" {
message = "Slack to ntfy Alert (no content)" 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) log.Printf("Received raw webhook for topic '%s': %s", topic, message)
} }
// Forward to ntfy
if err := m.forwardToNtfy(topic, message); err != nil { if err := m.forwardToNtfy(topic, message); err != nil {
log.Printf("Failed to forward to ntfy: %v", err) log.Printf("Failed to forward to ntfy: %v", err)
http.Error(w, "Failed to forward alert", http.StatusInternalServerError) http.Error(w, "Failed to forward alert", http.StatusInternalServerError)
return return
} }
// Return success response
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
response := map[string]string{ response := map[string]string{
"status": "success", "status": "success",
@@ -180,7 +173,6 @@ func (m *Middleware) webhookHandler(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(response) json.NewEncoder(w).Encode(response)
} }
// healthHandler provides a health check endpoint
func (m *Middleware) healthHandler(w http.ResponseWriter, r *http.Request) { func (m *Middleware) healthHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
response := map[string]interface{}{ response := map[string]interface{}{
@@ -200,6 +192,8 @@ func loadConfig() Config {
NtfyPassword: getEnv("NTFY_PASSWORD", ""), NtfyPassword: getEnv("NTFY_PASSWORD", ""),
BindAddress: getEnv("BIND_ADDRESS", "0.0.0.0"), BindAddress: getEnv("BIND_ADDRESS", "0.0.0.0"),
BindPort: getEnv("BIND_PORT", "8080"), 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 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() { func main() {
config := loadConfig() config := loadConfig()
middleware := NewMiddleware(config) middleware := NewMiddleware(config)
// Setup routes
http.HandleFunc("/health", middleware.healthHandler) http.HandleFunc("/health", middleware.healthHandler)
http.HandleFunc("/", middleware.webhookHandler) http.HandleFunc("/", middleware.webhookHandler)
// Start server
bindAddr := fmt.Sprintf("%s:%s", config.BindAddress, config.BindPort) bindAddr := fmt.Sprintf("%s:%s", config.BindAddress, config.BindPort)
log.Printf("Starting Slack to ntfy middleware server") log.Printf("Starting Slack to ntfy middleware server")
@@ -226,7 +286,33 @@ func main() {
log.Printf("ntfy URL: %s", config.NtfyBaseURL) log.Printf("ntfy URL: %s", config.NtfyBaseURL)
log.Printf("Authentication: %t", config.NtfyToken != "" || config.NtfyUsername != "") log.Printf("Authentication: %t", config.NtfyToken != "" || config.NtfyUsername != "")
if err := http.ListenAndServe(bindAddr, nil); err != nil { certFile := config.TLSCertFile
log.Fatalf("Server failed to start: %v", err) 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)
}
} }
} }

View File

@@ -53,4 +53,4 @@ echo
echo "Next steps:" echo "Next steps:"
echo "1. Test the service: ./scripts/test.sh" echo "1. Test the service: ./scripts/test.sh"
echo "2. Check logs: docker compose logs -f" 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"

View File

@@ -1,14 +1,14 @@
#!/bin/bash #!/bin/bash
set -e set -e
MIDDLEWARE_URL="http://localhost:8080" MIDDLEWARE_URL="https://localhost:8080"
TEST_TOPIC="test-topic" TEST_TOPIC="test-topic"
echo "🧪 Testing Slack to ntfy middleware..." echo "🧪 Testing Slack to ntfy middleware..."
# Check if service is running # Check if service is running
echo "Checking if middleware 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 "❌ Middleware is not running on $MIDDLEWARE_URL"
echo "Please start it with: docker compose up -d" echo "Please start it with: docker compose up -d"
exit 1 exit 1
@@ -18,19 +18,19 @@ echo "✅ Middleware is running"
# Test health endpoint # Test health endpoint
echo "Testing 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" echo "Health response: $HEALTH_RESPONSE"
# Test Slack webhook format # Test Slack webhook format
echo "Testing 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' \ -H 'Content-Type: application/json' \
-d '{"text": "🧪 Test alert from Slack to ntfy middleware test script"}' \ -d '{"text": "🧪 Test alert from Slack to ntfy middleware test script"}' \
-w "\nHTTP Status: %{http_code}\n" -w "\nHTTP Status: %{http_code}\n"
# Test plain text format # Test plain text format
echo "Testing 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' \ -H 'Content-Type: text/plain' \
-d 'Plain text test alert' \ -d 'Plain text test alert' \
-w "\nHTTP Status: %{http_code}\n" -w "\nHTTP Status: %{http_code}\n"