feat: implement ntfy-discord bridge in Go
- 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:
215
internal/vault/client.go
Normal file
215
internal/vault/client.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user