- 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
234 lines
5.6 KiB
Go
234 lines
5.6 KiB
Go
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
|
|
}
|