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 func() { _ = 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 }