feat: implement ntfy-discord bridge in Go
Some checks failed
Build and Push / build (push) Failing after 4m36s
Build and Push / test (push) Has been cancelled

- 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:
2026-02-02 18:13:55 -05:00
parent b325d9bfec
commit f97ad0e7cb
22 changed files with 2678 additions and 0 deletions

215
internal/vault/client.go Normal file
View File

@@ -0,0 +1,215 @@
package vault
import (
"context"
"fmt"
"log/slog"
"os"
"strings"
"sync"
"time"
"github.com/hashicorp/vault/api"
"github.com/hashicorp/vault/api/auth/kubernetes"
)
// Client wraps the Vault API client with auto-renewal
type Client struct {
client *api.Client
mountPath string
secretPath string
mu sync.RWMutex
secret *api.Secret
}
// Config holds Vault client configuration
type Config struct {
// Address is the Vault server address (e.g., http://vault.vault.svc.cluster.local:8200)
Address string
// AuthMethod is either "kubernetes" or "token"
AuthMethod string
// Role is the Vault role for Kubernetes auth
Role string
// MountPath is the secrets engine mount (e.g., "secret")
MountPath string
// SecretPath is the path within the mount (e.g., "data/ntfy-discord")
SecretPath string
// TokenPath is the path to the Kubernetes service account token
TokenPath string
}
// NewClient creates a new Vault client
func NewClient(cfg Config) (*Client, error) {
vaultCfg := api.DefaultConfig()
vaultCfg.Address = cfg.Address
client, err := api.NewClient(vaultCfg)
if err != nil {
return nil, fmt.Errorf("failed to create vault client: %w", err)
}
vc := &Client{
client: client,
mountPath: cfg.MountPath,
secretPath: cfg.SecretPath,
}
// Authenticate based on method
switch strings.ToLower(cfg.AuthMethod) {
case "kubernetes":
if err := vc.authKubernetes(cfg.Role, cfg.TokenPath); err != nil {
return nil, fmt.Errorf("kubernetes auth failed: %w", err)
}
case "token":
// Token should be set via VAULT_TOKEN env var, which the API client reads automatically
if client.Token() == "" {
return nil, fmt.Errorf("VAULT_TOKEN environment variable not set")
}
default:
return nil, fmt.Errorf("unsupported auth method: %s", cfg.AuthMethod)
}
slog.Info("vault client authenticated",
"address", cfg.Address,
"auth_method", cfg.AuthMethod,
"mount_path", cfg.MountPath,
"secret_path", cfg.SecretPath,
)
return vc, nil
}
// authKubernetes authenticates using Kubernetes service account
func (c *Client) authKubernetes(role, tokenPath string) error {
if tokenPath == "" {
tokenPath = "/var/run/secrets/kubernetes.io/serviceaccount/token"
}
// Verify token file exists
if _, err := os.Stat(tokenPath); err != nil {
return fmt.Errorf("service account token not found at %s: %w", tokenPath, err)
}
k8sAuth, err := kubernetes.NewKubernetesAuth(
role,
kubernetes.WithServiceAccountTokenPath(tokenPath),
)
if err != nil {
return fmt.Errorf("failed to create kubernetes auth: %w", err)
}
authInfo, err := c.client.Auth().Login(context.Background(), k8sAuth)
if err != nil {
return fmt.Errorf("failed to login with kubernetes auth: %w", err)
}
if authInfo == nil {
return fmt.Errorf("no auth info returned from vault")
}
slog.Info("authenticated to vault via kubernetes",
"role", role,
"token_ttl", authInfo.Auth.LeaseDuration,
)
return nil
}
// GetSecret retrieves a secret value by key
func (c *Client) GetSecret(ctx context.Context, key string) (string, error) {
c.mu.RLock()
secret := c.secret
c.mu.RUnlock()
// Fetch secret if not cached
if secret == nil {
if err := c.refreshSecret(ctx); err != nil {
return "", err
}
c.mu.RLock()
secret = c.secret
c.mu.RUnlock()
}
// KV v2 stores data under "data" key
data, ok := secret.Data["data"].(map[string]interface{})
if !ok {
// Try KV v1 format
data = secret.Data
}
value, ok := data[key]
if !ok {
return "", fmt.Errorf("key %q not found in secret", key)
}
str, ok := value.(string)
if !ok {
return "", fmt.Errorf("key %q is not a string", key)
}
return str, nil
}
// refreshSecret fetches the secret from Vault
func (c *Client) refreshSecret(ctx context.Context) error {
// Construct full path for KV v2: mount/data/path
fullPath := fmt.Sprintf("%s/data/%s", c.mountPath, c.secretPath)
secret, err := c.client.Logical().ReadWithContext(ctx, fullPath)
if err != nil {
return fmt.Errorf("failed to read secret: %w", err)
}
if secret == nil {
return fmt.Errorf("secret not found at %s", fullPath)
}
c.mu.Lock()
c.secret = secret
c.mu.Unlock()
slog.Debug("refreshed secret from vault", "path", fullPath)
return nil
}
// WatchAndRefresh periodically refreshes the secret and renews the auth token
func (c *Client) WatchAndRefresh(ctx context.Context, interval time.Duration) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
if err := c.refreshSecret(ctx); err != nil {
slog.Error("failed to refresh secret from vault", "error", err)
} else {
slog.Debug("vault secret refreshed")
}
// Renew token if renewable
if c.client.Token() != "" {
if _, err := c.client.Auth().Token().RenewSelf(0); err != nil {
slog.Warn("failed to renew vault token", "error", err)
}
}
case <-ctx.Done():
return
}
}
}
// Close cleans up the client
func (c *Client) Close() error {
if c == nil || c.client == nil {
return nil
}
// Revoke token on shutdown (optional, but good practice)
if c.client.Token() != "" {
if err := c.client.Auth().Token().RevokeSelf(""); err != nil {
slog.Warn("failed to revoke vault token", "error", err)
}
}
return nil
}

View File

@@ -0,0 +1,94 @@
package vault
import (
"testing"
)
func TestConfig_Defaults(t *testing.T) {
cfg := Config{
Address: "http://vault.vault.svc.cluster.local:8200",
AuthMethod: "kubernetes",
Role: "ntfy-discord",
MountPath: "secret",
SecretPath: "ntfy-discord",
}
if cfg.Address == "" {
t.Error("Address should not be empty")
}
if cfg.AuthMethod != "kubernetes" {
t.Errorf("AuthMethod = %s, want kubernetes", cfg.AuthMethod)
}
if cfg.MountPath != "secret" {
t.Errorf("MountPath = %s, want secret", cfg.MountPath)
}
}
func TestNewClient_InvalidAddress(t *testing.T) {
cfg := Config{
Address: "not-a-valid-url",
AuthMethod: "kubernetes",
Role: "test",
MountPath: "secret",
SecretPath: "test",
}
// This should fail because there's no Kubernetes token
_, err := NewClient(cfg)
if err == nil {
t.Error("expected error for kubernetes auth without token")
}
}
func TestNewClient_TokenAuth_NoToken(t *testing.T) {
cfg := Config{
Address: "http://localhost:8200",
AuthMethod: "token",
MountPath: "secret",
SecretPath: "test",
}
// Should fail because VAULT_TOKEN is not set
_, err := NewClient(cfg)
if err == nil {
t.Error("expected error for token auth without VAULT_TOKEN")
}
}
func TestNewClient_UnsupportedAuthMethod(t *testing.T) {
cfg := Config{
Address: "http://localhost:8200",
AuthMethod: "unsupported",
MountPath: "secret",
SecretPath: "test",
}
_, err := NewClient(cfg)
if err == nil {
t.Error("expected error for unsupported auth method")
}
}
func TestClient_Close_Nil(t *testing.T) {
// Test that Close doesn't panic on a partially initialized client
c := &Client{}
// Should not panic
err := c.Close()
if err != nil {
t.Errorf("Close() error = %v", err)
}
}
func TestConfig_TokenPathDefault(t *testing.T) {
cfg := Config{
TokenPath: "",
}
// Default token path should be empty (will be set in authKubernetes)
if cfg.TokenPath != "" {
t.Errorf("TokenPath = %s, want empty", cfg.TokenPath)
}
}