- 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
216 lines
5.1 KiB
Go
216 lines
5.1 KiB
Go
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
|
|
}
|