Enable TLS by default

This commit is contained in:
2025-09-23 00:40:42 -07:00
parent bbe7f2a370
commit a07f52807a
7 changed files with 177 additions and 37 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

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

@@ -8,10 +8,10 @@ services:
- "8080:8080" - "8080:8080"
environment: environment:
# Replace with your ntfy server URL (without topic path) # Replace with your ntfy server URL (without topic path)
- NTFY_BASE_URL=https://ntfy.dangerzone.dev - 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"