feat: implement ntfy-discord bridge in Go
- SSE subscription to ntfy with auto-reconnect - Discord webhook integration with embed formatting - Priority to color mapping, tag to emoji conversion - Native HashiCorp Vault support (Kubernetes + token auth) - Hot reload secrets via fsnotify or Vault polling - Prometheus metrics (/metrics endpoint) - Health/ready endpoints for Kubernetes probes - Comprehensive unit tests and fuzz tests - Multi-stage Docker build (~10MB scratch image) - CI/CD pipeline for Gitea Actions
This commit is contained in:
233
internal/config/config.go
Normal file
233
internal/config/config.go
Normal file
@@ -0,0 +1,233 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/fsnotify/fsnotify"
|
||||
|
||||
"git.daviestechlabs.io/daviestechlabs/ntfy-discord/internal/vault"
|
||||
)
|
||||
|
||||
// Config holds the application configuration
|
||||
type Config struct {
|
||||
NtfyURL string
|
||||
NtfyTopics []string
|
||||
SecretsPath string
|
||||
HTTPPort string
|
||||
|
||||
// Vault configuration
|
||||
VaultEnabled bool
|
||||
vaultClient *vault.Client
|
||||
|
||||
mu sync.RWMutex
|
||||
webhookURL string
|
||||
}
|
||||
|
||||
// Load creates a new Config from environment variables
|
||||
func Load(ctx context.Context) (*Config, error) {
|
||||
cfg := &Config{
|
||||
NtfyURL: getEnv("NTFY_URL", "http://ntfy.observability.svc.cluster.local"),
|
||||
SecretsPath: getEnv("SECRETS_PATH", ""),
|
||||
HTTPPort: getEnv("HTTP_PORT", "8080"),
|
||||
VaultEnabled: getEnv("VAULT_ENABLED", "false") == "true",
|
||||
}
|
||||
|
||||
// Parse topics
|
||||
topics := getEnv("NTFY_TOPICS", "")
|
||||
if topics != "" {
|
||||
cfg.NtfyTopics = strings.Split(topics, ",")
|
||||
for i := range cfg.NtfyTopics {
|
||||
cfg.NtfyTopics[i] = strings.TrimSpace(cfg.NtfyTopics[i])
|
||||
}
|
||||
}
|
||||
|
||||
// Try Vault first if enabled
|
||||
if cfg.VaultEnabled {
|
||||
if err := cfg.initVault(ctx); err != nil {
|
||||
slog.Warn("vault init failed, falling back to file/env", "error", err)
|
||||
} else {
|
||||
// Load webhook from Vault
|
||||
if webhookURL, err := cfg.vaultClient.GetSecret(ctx, "webhook-url"); err == nil {
|
||||
cfg.mu.Lock()
|
||||
cfg.webhookURL = webhookURL
|
||||
cfg.mu.Unlock()
|
||||
slog.Info("loaded webhook URL from vault")
|
||||
} else {
|
||||
slog.Warn("failed to get webhook from vault", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to file-based secret
|
||||
if cfg.webhookURL == "" && cfg.SecretsPath != "" {
|
||||
if err := cfg.loadWebhookFromSecret(); err != nil {
|
||||
slog.Warn("failed to load webhook from secret, trying env", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to environment variable
|
||||
if cfg.webhookURL == "" {
|
||||
cfg.webhookURL = getEnv("DISCORD_WEBHOOK_URL", "")
|
||||
}
|
||||
|
||||
if cfg.webhookURL == "" {
|
||||
slog.Warn("no Discord webhook URL configured")
|
||||
}
|
||||
|
||||
slog.Info("config loaded",
|
||||
"ntfy_url", cfg.NtfyURL,
|
||||
"topics", cfg.NtfyTopics,
|
||||
"secrets_path", cfg.SecretsPath,
|
||||
"vault_enabled", cfg.VaultEnabled,
|
||||
)
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// initVault initializes the Vault client
|
||||
func (c *Config) initVault(ctx context.Context) error {
|
||||
vaultCfg := vault.Config{
|
||||
Address: getEnv("VAULT_ADDR", "http://vault.vault.svc.cluster.local:8200"),
|
||||
AuthMethod: getEnv("VAULT_AUTH_METHOD", "kubernetes"),
|
||||
Role: getEnv("VAULT_ROLE", "ntfy-discord"),
|
||||
MountPath: getEnv("VAULT_MOUNT_PATH", "secret"),
|
||||
SecretPath: getEnv("VAULT_SECRET_PATH", "ntfy-discord"),
|
||||
TokenPath: getEnv("VAULT_TOKEN_PATH", ""),
|
||||
}
|
||||
|
||||
client, err := vault.NewClient(vaultCfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.vaultClient = client
|
||||
return nil
|
||||
}
|
||||
|
||||
// WebhookURL returns the current webhook URL (thread-safe)
|
||||
func (c *Config) WebhookURL() string {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return c.webhookURL
|
||||
}
|
||||
|
||||
// loadWebhookFromSecret reads the webhook URL from the mounted secret
|
||||
func (c *Config) loadWebhookFromSecret() error {
|
||||
path := filepath.Join(c.SecretsPath, "webhook-url")
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
c.webhookURL = strings.TrimSpace(string(data))
|
||||
c.mu.Unlock()
|
||||
|
||||
slog.Debug("loaded webhook URL from secret")
|
||||
return nil
|
||||
}
|
||||
|
||||
// WatchSecrets watches the secrets directory for changes and reloads
|
||||
func (c *Config) WatchSecrets(ctx context.Context) {
|
||||
// Start Vault watcher if enabled
|
||||
if c.vaultClient != nil {
|
||||
go c.watchVaultSecrets(ctx)
|
||||
}
|
||||
|
||||
// Start file watcher if secrets path is set
|
||||
if c.SecretsPath != "" {
|
||||
go c.watchFileSecrets(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// watchVaultSecrets periodically refreshes secrets from Vault
|
||||
func (c *Config) watchVaultSecrets(ctx context.Context) {
|
||||
interval := 30 * time.Second
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
slog.Info("watching vault secrets", "interval", interval)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
if webhookURL, err := c.vaultClient.GetSecret(ctx, "webhook-url"); err == nil {
|
||||
c.mu.Lock()
|
||||
if c.webhookURL != webhookURL {
|
||||
c.webhookURL = webhookURL
|
||||
slog.Info("webhook URL updated from vault")
|
||||
}
|
||||
c.mu.Unlock()
|
||||
} else {
|
||||
slog.Error("failed to refresh webhook from vault", "error", err)
|
||||
}
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// watchFileSecrets watches the secrets directory for changes
|
||||
func (c *Config) watchFileSecrets(ctx context.Context) {
|
||||
watcher, err := fsnotify.NewWatcher()
|
||||
if err != nil {
|
||||
slog.Error("failed to create fsnotify watcher", "error", err)
|
||||
return
|
||||
}
|
||||
defer watcher.Close()
|
||||
|
||||
// Watch the secrets directory
|
||||
// Kubernetes updates secrets by changing the symlink, so watch the parent
|
||||
if err := watcher.Add(c.SecretsPath); err != nil {
|
||||
slog.Error("failed to watch secrets path", "error", err, "path", c.SecretsPath)
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("watching secrets for changes", "path", c.SecretsPath)
|
||||
|
||||
for {
|
||||
select {
|
||||
case event, ok := <-watcher.Events:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// Kubernetes updates secrets via symlink, which triggers Create events
|
||||
if event.Has(fsnotify.Create) || event.Has(fsnotify.Write) {
|
||||
slog.Info("secret changed, reloading", "event", event.Name)
|
||||
if err := c.loadWebhookFromSecret(); err != nil {
|
||||
slog.Error("failed to reload webhook from secret", "error", err)
|
||||
} else {
|
||||
slog.Info("webhook URL reloaded successfully")
|
||||
}
|
||||
}
|
||||
case err, ok := <-watcher.Errors:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
slog.Error("fsnotify error", "error", err)
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Close cleans up resources
|
||||
func (c *Config) Close() error {
|
||||
if c.vaultClient != nil {
|
||||
return c.vaultClient.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func getEnv(key, defaultVal string) string {
|
||||
if val := os.Getenv(key); val != "" {
|
||||
return val
|
||||
}
|
||||
return defaultVal
|
||||
}
|
||||
223
internal/config/config_test.go
Normal file
223
internal/config/config_test.go
Normal file
@@ -0,0 +1,223 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGetEnv(t *testing.T) {
|
||||
// Set a test env var
|
||||
os.Setenv("TEST_CONFIG_VAR", "test_value")
|
||||
defer os.Unsetenv("TEST_CONFIG_VAR")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
key string
|
||||
defaultVal string
|
||||
want string
|
||||
}{
|
||||
{"existing var", "TEST_CONFIG_VAR", "default", "test_value"},
|
||||
{"non-existing var", "NON_EXISTING_VAR", "default", "default"},
|
||||
{"empty default", "NON_EXISTING_VAR_2", "", ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := getEnv(tt.key, tt.defaultVal)
|
||||
if got != tt.want {
|
||||
t.Errorf("getEnv(%s, %s) = %s, want %s", tt.key, tt.defaultVal, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfig_WebhookURL_ThreadSafe(t *testing.T) {
|
||||
cfg := &Config{}
|
||||
cfg.webhookURL = "https://discord.com/api/webhooks/test"
|
||||
|
||||
// Test concurrent reads and writes
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 100; i++ {
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_ = cfg.WebhookURL()
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
cfg.mu.Lock()
|
||||
cfg.webhookURL = "updated"
|
||||
cfg.mu.Unlock()
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
// Should not race
|
||||
}
|
||||
|
||||
func TestConfig_LoadWebhookFromSecret(t *testing.T) {
|
||||
// Create temp directory with secret file
|
||||
tmpDir := t.TempDir()
|
||||
secretPath := filepath.Join(tmpDir, "webhook-url")
|
||||
webhookURL := "https://discord.com/api/webhooks/123/abc"
|
||||
|
||||
if err := os.WriteFile(secretPath, []byte(webhookURL+"\n"), 0644); err != nil {
|
||||
t.Fatalf("failed to write secret file: %v", err)
|
||||
}
|
||||
|
||||
cfg := &Config{
|
||||
SecretsPath: tmpDir,
|
||||
}
|
||||
|
||||
if err := cfg.loadWebhookFromSecret(); err != nil {
|
||||
t.Fatalf("loadWebhookFromSecret() error = %v", err)
|
||||
}
|
||||
|
||||
if cfg.webhookURL != webhookURL {
|
||||
t.Errorf("webhookURL = %s, want %s", cfg.webhookURL, webhookURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfig_LoadWebhookFromSecret_NotFound(t *testing.T) {
|
||||
cfg := &Config{
|
||||
SecretsPath: "/nonexistent/path",
|
||||
}
|
||||
|
||||
err := cfg.loadWebhookFromSecret()
|
||||
if err == nil {
|
||||
t.Error("expected error for non-existent secret")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfig_LoadWebhookFromSecret_TrimsWhitespace(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
secretPath := filepath.Join(tmpDir, "webhook-url")
|
||||
|
||||
// Write with extra whitespace
|
||||
if err := os.WriteFile(secretPath, []byte(" https://example.com \n\n"), 0644); err != nil {
|
||||
t.Fatalf("failed to write secret file: %v", err)
|
||||
}
|
||||
|
||||
cfg := &Config{SecretsPath: tmpDir}
|
||||
if err := cfg.loadWebhookFromSecret(); err != nil {
|
||||
t.Fatalf("loadWebhookFromSecret() error = %v", err)
|
||||
}
|
||||
|
||||
if cfg.webhookURL != "https://example.com" {
|
||||
t.Errorf("webhookURL = %q, want %q", cfg.webhookURL, "https://example.com")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_ParsesTopics(t *testing.T) {
|
||||
os.Setenv("NTFY_TOPICS", "alerts, updates , notifications")
|
||||
os.Setenv("VAULT_ENABLED", "false")
|
||||
defer func() {
|
||||
os.Unsetenv("NTFY_TOPICS")
|
||||
os.Unsetenv("VAULT_ENABLED")
|
||||
}()
|
||||
|
||||
cfg, err := Load(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
|
||||
expected := []string{"alerts", "updates", "notifications"}
|
||||
if len(cfg.NtfyTopics) != len(expected) {
|
||||
t.Errorf("NtfyTopics length = %d, want %d", len(cfg.NtfyTopics), len(expected))
|
||||
}
|
||||
|
||||
for i, topic := range cfg.NtfyTopics {
|
||||
if topic != expected[i] {
|
||||
t.Errorf("NtfyTopics[%d] = %s, want %s", i, topic, expected[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_Defaults(t *testing.T) {
|
||||
// Clear any existing env vars
|
||||
os.Unsetenv("NTFY_URL")
|
||||
os.Unsetenv("HTTP_PORT")
|
||||
os.Unsetenv("VAULT_ENABLED")
|
||||
|
||||
cfg, err := Load(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
|
||||
if cfg.NtfyURL != "http://ntfy.observability.svc.cluster.local" {
|
||||
t.Errorf("NtfyURL = %s, want default", cfg.NtfyURL)
|
||||
}
|
||||
|
||||
if cfg.HTTPPort != "8080" {
|
||||
t.Errorf("HTTPPort = %s, want 8080", cfg.HTTPPort)
|
||||
}
|
||||
|
||||
if cfg.VaultEnabled {
|
||||
t.Error("VaultEnabled should be false by default")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_VaultEnabled(t *testing.T) {
|
||||
os.Setenv("VAULT_ENABLED", "true")
|
||||
defer os.Unsetenv("VAULT_ENABLED")
|
||||
|
||||
// This will fail to init Vault (no server), but should gracefully fall back
|
||||
cfg, err := Load(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
|
||||
if !cfg.VaultEnabled {
|
||||
t.Error("VaultEnabled should be true")
|
||||
}
|
||||
|
||||
// vaultClient should be nil (failed to connect)
|
||||
if cfg.vaultClient != nil {
|
||||
t.Error("vaultClient should be nil when Vault unavailable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_FallsBackToEnvVar(t *testing.T) {
|
||||
webhookURL := "https://discord.com/api/webhooks/env/test"
|
||||
os.Setenv("DISCORD_WEBHOOK_URL", webhookURL)
|
||||
os.Setenv("VAULT_ENABLED", "false")
|
||||
defer func() {
|
||||
os.Unsetenv("DISCORD_WEBHOOK_URL")
|
||||
os.Unsetenv("VAULT_ENABLED")
|
||||
}()
|
||||
|
||||
cfg, err := Load(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
|
||||
if cfg.WebhookURL() != webhookURL {
|
||||
t.Errorf("WebhookURL() = %s, want %s", cfg.WebhookURL(), webhookURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfig_Close_NoVault(t *testing.T) {
|
||||
cfg := &Config{}
|
||||
|
||||
// Should not panic with nil vaultClient
|
||||
err := cfg.Close()
|
||||
if err != nil {
|
||||
t.Errorf("Close() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfig_WatchSecrets_NoPath(t *testing.T) {
|
||||
cfg := &Config{
|
||||
SecretsPath: "",
|
||||
vaultClient: nil,
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
// Should return immediately, not block or panic
|
||||
cfg.WatchSecrets(ctx)
|
||||
}
|
||||
72
internal/config/fuzz_test.go
Normal file
72
internal/config/fuzz_test.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// FuzzParseTopics tests topic parsing doesn't panic on arbitrary input
|
||||
func FuzzParseTopics(f *testing.F) {
|
||||
// Normal cases
|
||||
f.Add("alerts")
|
||||
f.Add("alerts,updates")
|
||||
f.Add("alerts, updates, notifications")
|
||||
f.Add("")
|
||||
|
||||
// Edge cases
|
||||
f.Add(",,,")
|
||||
f.Add(" , , ")
|
||||
f.Add("a]topic-with-special_chars.123")
|
||||
f.Add("\x00\x00\x00")
|
||||
f.Add("topic\nwith\nnewlines")
|
||||
|
||||
f.Fuzz(func(t *testing.T, input string) {
|
||||
// Simulate topic parsing logic
|
||||
if input == "" {
|
||||
return
|
||||
}
|
||||
|
||||
topics := make([]string, 0)
|
||||
for _, topic := range splitAndTrim(input) {
|
||||
topics = append(topics, topic)
|
||||
}
|
||||
|
||||
// Accessing results should not panic
|
||||
_ = len(topics)
|
||||
})
|
||||
}
|
||||
|
||||
// splitAndTrim mimics the topic parsing in Load()
|
||||
func splitAndTrim(s string) []string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
var result []string
|
||||
start := 0
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] == ',' {
|
||||
part := trimSpace(s[start:i])
|
||||
if part != "" {
|
||||
result = append(result, part)
|
||||
}
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
// Last part
|
||||
part := trimSpace(s[start:])
|
||||
if part != "" {
|
||||
result = append(result, part)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func trimSpace(s string) string {
|
||||
start := 0
|
||||
end := len(s)
|
||||
for start < end && (s[start] == ' ' || s[start] == '\t' || s[start] == '\n' || s[start] == '\r') {
|
||||
start++
|
||||
}
|
||||
for end > start && (s[end-1] == ' ' || s[end-1] == '\t' || s[end-1] == '\n' || s[end-1] == '\r') {
|
||||
end--
|
||||
}
|
||||
return s[start:end]
|
||||
}
|
||||
Reference in New Issue
Block a user