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
/middleware
/dist/
/certs/
# Docker
.dockerignore

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 http://localhost:8080/health || exit 1
CMD wget --no-verbose --tries=1 --spider https://localhost:8080/health || exit 1
# Run the binary
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
- ✅ Bearer token authentication support
- ✅ Health check endpoint
- ✅ Lightweight Docker container (~8.4MB)
- ✅ Lightweight Docker container (~8.5MB)
- ✅ High performance and low resource usage
## Quick Start
@@ -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

View File

@@ -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

130
main.go
View File

@@ -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)
}
}
}

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: 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
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"