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 }