diff --git a/authbridge/go.work b/authbridge/go.work index 5cbd75afe..e8811bd10 100644 --- a/authbridge/go.work +++ b/authbridge/go.work @@ -1,7 +1,8 @@ -go 1.24.0 +go 1.24.3 use ( ./authlib ./authproxy ./cmd/authbridge + ./vault-fetcher ) diff --git a/authbridge/vault-fetcher/.gitignore b/authbridge/vault-fetcher/.gitignore new file mode 100644 index 000000000..813add214 --- /dev/null +++ b/authbridge/vault-fetcher/.gitignore @@ -0,0 +1 @@ +vault-fetcher diff --git a/authbridge/vault-fetcher/Dockerfile b/authbridge/vault-fetcher/Dockerfile new file mode 100644 index 000000000..f03972703 --- /dev/null +++ b/authbridge/vault-fetcher/Dockerfile @@ -0,0 +1,37 @@ +# Build stage +FROM golang:1.24-alpine AS builder + +WORKDIR /build + +# Copy go module files from authlib +COPY authbridge/authlib/go.mod authbridge/authlib/go.sum authbridge/authlib/ +WORKDIR /build/authbridge/authlib +RUN go mod download + +# Copy authlib source +COPY authbridge/authlib/ /build/authbridge/authlib/ + +# Copy vault-fetcher source +WORKDIR /build/authbridge/vault-fetcher +COPY authbridge/vault-fetcher/ ./ + +# Initialize go module for vault-fetcher +RUN go mod init github.com/kagenti/kagenti-extensions/authbridge/vault-fetcher || true +RUN go mod edit -replace github.com/kagenti/kagenti-extensions/authbridge/authlib=../authlib +RUN go get gopkg.in/yaml.v3 +RUN go mod tidy + +# Build binary +RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -ldflags="-s -w" -o vault-fetcher . + +# Runtime stage - distroless for minimal attack surface +FROM gcr.io/distroless/static-debian12:nonroot + +# Copy binary from builder +COPY --from=builder /build/authbridge/vault-fetcher/vault-fetcher /usr/local/bin/vault-fetcher + +# Run as non-root user (automatically set by distroless/nonroot) +# UID/GID: 65532:65532 + +ENTRYPOINT ["/usr/local/bin/vault-fetcher"] +CMD ["--config=/etc/vault-fetcher/config.yaml"] diff --git a/authbridge/vault-fetcher/README.md b/authbridge/vault-fetcher/README.md new file mode 100644 index 000000000..48229e3e2 --- /dev/null +++ b/authbridge/vault-fetcher/README.md @@ -0,0 +1,402 @@ +# vault-fetcher — Vault Secret Fetcher for Kubernetes + +Init container that fetches secrets from Hashicorp Vault using SPIFFE JWT-SVID authentication and writes them to files for application consumption. + +## Features + +- **SPIFFE-native** — Uses JWT-SVID from spiffe-helper for authentication +- **Multiple secrets** — Fetches multiple secrets in one run +- **Flexible output** — Individual files, environment file, or JSON +- **Secure permissions** — Configurable file permissions (default: 0600) +- **Fail-fast** — Clear error messages, exits with non-zero on failure +- **Minimal footprint** — Distroless container image, runs as non-root + +## Quick Start + +### Build + +```bash +cd AuthBridge/vault-fetcher +podman build -t vault-fetcher:latest . +``` + +### Run Locally + +```bash +# With config file +./vault-fetcher --config=config.yaml + +# Show version +./vault-fetcher --version +``` + +### Deploy in Kubernetes + +```yaml +apiVersion: v1 +kind: Pod +metadata: + name: my-agent +spec: + initContainers: + - name: vault-fetcher + image: ghcr.io/kagenti/kagenti-extensions/vault-fetcher:latest + args: + - --config=/etc/vault-fetcher/config.yaml + volumeMounts: + - name: spiffe-svid + mountPath: /opt + readOnly: true + - name: shared-secrets + mountPath: /shared/secrets + - name: vault-fetcher-config + mountPath: /etc/vault-fetcher + readOnly: true + + containers: + - name: app + image: my-app:latest + volumeMounts: + - name: shared-secrets + mountPath: /shared/secrets + readOnly: true + # App reads /shared/secrets/github-token, etc. + + volumes: + - name: spiffe-svid + emptyDir: {} + - name: shared-secrets + emptyDir: {} + - name: vault-fetcher-config + configMap: + name: vault-fetcher-config +``` + +## Configuration + +See [`config.yaml.example`](config.yaml.example) for a complete example. + +### Minimal Configuration + +```yaml +vault: + address: "https://vault.example.com" + auth_method: "jwt" + role: "my-agent-role" + +secrets: + - path: "secret/data/github/token" + field: "token" + output: "/shared/secrets/github-token" +``` + +### Authentication Methods + +#### JWT Auth (SPIFFE — Recommended) + +```yaml +vault: + auth_method: "jwt" + role: "github-agent-role" + jwt_path: "/opt/jwt_svid.token" # Written by spiffe-helper + jwt_audience: "vault" +``` + +**Vault setup:** +```bash +vault write auth/jwt/config \ + oidc_discovery_url="http://spire-server.spire.svc:8081" + +vault write auth/jwt/role/github-agent-role \ + role_type="jwt" \ + bound_audiences="vault" \ + bound_claims='{"sub":"spiffe://localtest.me/ns/*/sa/github-agent/*"}' \ + policies="github-agent" \ + ttl="1h" +``` + +#### Kubernetes SA Auth + +```yaml +vault: + auth_method: "kubernetes" + role: "my-app-role" +``` + +#### Token Auth (Dev/Testing) + +```yaml +vault: + auth_method: "token" + token: "hvs.CAESIAbc..." +``` + +Or via environment variable: +```bash +export VAULT_TOKEN="hvs.CAESIAbc..." +vault-fetcher --config=config.yaml +``` + +### Secret Specification + +```yaml +secrets: + - path: "secret/data/github/token" # KV v2 path + field: "token" # Field name in secret + output: "/shared/secrets/github-token" # Where to write + mode: "0600" # File permissions (optional) +``` + +**Supported secret engines:** +- KV v1: `secret/github/token` +- KV v2: `secret/data/github/token` (auto-detected) + +### Output Formats + +#### Individual Files (Default) + +```yaml +secrets: + - path: "secret/data/github/token" + field: "token" + output: "/shared/secrets/github-token" +``` + +Result: +```bash +/shared/secrets/ +└── github-token # Contains: ghp_xxxxx +``` + +#### Environment File + +```yaml +output_formats: + env_file: + enabled: true + path: "/shared/secrets/.env" +``` + +Result: +```bash +# /shared/secrets/.env +TOKEN=ghp_xxxxx +URL=https://hooks.slack.com/... +PASSWORD=secret123 +``` + +Usage: +```bash +source /shared/secrets/.env +echo $TOKEN +``` + +#### JSON File + +```yaml +output_formats: + json_file: + enabled: true + path: "/shared/secrets/credentials.json" +``` + +Result: +```json +{ + "token": "ghp_xxxxx", + "url": "https://hooks.slack.com/...", + "password": "secret123" +} +``` + +### Environment Variable Overrides + +Configuration values can be overridden via environment variables: + +```bash +export VAULT_ADDR="https://vault.example.com" +export VAULT_ROLE="my-role" +export JWT_PATH="/custom/path/jwt.token" +export VAULT_TOKEN="hvs.CAESIAbc..." + +vault-fetcher --config=config.yaml +``` + +## Usage in Kagenti + +When deployed via the kagenti-webhook, the init container is automatically injected: + +```yaml +apiVersion: v1 +kind: Pod +metadata: + labels: + kagenti.io/type: agent + kagenti.io/inject: "true" + kagenti.io/vault-fetcher-inject: "true" # Enable vault-fetcher +spec: + serviceAccountName: github-agent + # Webhook injects vault-fetcher init container automatically +``` + +ConfigMap in the same namespace: +```yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: vault-fetcher-config +data: + config.yaml: | + vault: + address: "https://vault.example.com" + auth_method: "jwt" + role: "github-agent-role" + secrets: + - path: "secret/data/github/token" + field: "token" + output: "/shared/secrets/github-token" +``` + +## Exit Codes + +- `0` — Success (all secrets fetched and written) +- `1` — Failure (config error, auth failure, secret not found, etc.) + +On failure, the pod will not start (init container failed). Check logs: +```bash +kubectl logs my-pod -c vault-fetcher +``` + +## Security Considerations + +### File Permissions + +- Default file mode: `0600` (owner read/write only) +- Secrets directory: Created with `0755` permissions +- Container runs as non-root (UID 65532) + +### Least Privilege + +Only fetch secrets the application needs: +```yaml +# ✅ Good - minimal +secrets: + - path: "secret/data/github/token" + field: "token" + output: "/shared/secrets/github-token" + +# ❌ Bad - fetches everything under a path +# (auto-fetch not currently supported - by design) +``` + +Vault policy should match: +```hcl +path "secret/data/github/token" { + capabilities = ["read"] +} +``` + +### Token Rotation + +vault-fetcher runs once at pod startup. To get updated secrets: +- Restart the pod +- Or (future) use a sidecar mode with automatic refresh + +## Troubleshooting + +### Authentication Fails + +``` +Failed to authenticate to Vault: JWT auth failed +``` + +**Check:** +1. JWT-SVID exists: `kubectl exec pod -c vault-fetcher -- cat /opt/jwt_svid.token` +2. Vault JWT auth is configured: `vault read auth/jwt/config` +3. Vault role exists: `vault read auth/jwt/role/my-role` +4. SPIFFE ID matches role's `bound_claims` + +### Secret Not Found + +``` +Failed to fetch secret secret/data/github/token: secret not found +``` + +**Check:** +1. Secret exists: `vault kv get secret/github/token` +2. Path is correct (KV v2 uses `secret/data/...`) +3. Vault policy allows read access + +### Permission Denied + +``` +Failed to write secret to /shared/secrets/github-token: permission denied +``` + +**Check:** +1. Volume is mounted and writable +2. Container is not running as root with read-only filesystem +3. SELinux/AppArmor policies + +## Development + +### Local Testing + +```bash +# Start Vault in dev mode +docker run -d --name vault-dev -p 8200:8200 \ + -e VAULT_DEV_ROOT_TOKEN_ID=test-token \ + hashicorp/vault:latest + +# Configure Vault +export VAULT_ADDR=http://localhost:8200 +export VAULT_TOKEN=test-token + +vault kv put secret/github token="ghp_test123" + +# Run vault-fetcher +export VAULT_TOKEN=test-token +./vault-fetcher --config=config.yaml.example + +# Check output +cat /tmp/secrets/github-token +``` + +### Build for Multiple Architectures + +```bash +podman build --platform=linux/amd64,linux/arm64 \ + -t ghcr.io/kagenti/kagenti-extensions/vault-fetcher:latest . +``` + +## Comparison with Other Tools + +### vs. Vault Agent + +| Feature | vault-fetcher | Vault Agent | +|---------|---------------|-------------| +| Purpose | Init container (fetch once) | Sidecar (continuous sync) | +| Complexity | Simple (~300 LOC) | Feature-rich | +| SPIFFE auth | ✅ Native | ➖ Via JWT auth | +| Resource usage | Minimal (exits after fetch) | Ongoing (stays running) | +| Use case | Static credentials at startup | Dynamic secrets, rotation | + +### vs. External Secrets Operator + +| Feature | vault-fetcher | External Secrets | +|---------|---------------|------------------| +| Approach | Init container | K8s Operator | +| Setup | ConfigMap per pod | SecretStore CRD | +| SPIFFE auth | ✅ Yes | ➖ Via service account | +| Dependency | None (self-contained) | Cluster-wide operator | +| Scope | Per-pod secrets | Cluster-wide management | + +**Use vault-fetcher when:** +- You want SPIFFE-native auth +- You need per-pod secret configuration +- You prefer init containers over operators +- You want minimal dependencies + +## License + +Apache License 2.0 — See LICENSE file for details. diff --git a/authbridge/vault-fetcher/config.yaml.example b/authbridge/vault-fetcher/config.yaml.example new file mode 100644 index 000000000..2f4ea11a8 --- /dev/null +++ b/authbridge/vault-fetcher/config.yaml.example @@ -0,0 +1,63 @@ +# vault-fetcher configuration example + +# Vault connection and authentication +vault: + # Vault server address + address: "https://vault.example.com" + + # Authentication method: "jwt" (SPIFFE), "kubernetes", or "token" + auth_method: "jwt" + + # Vault role name (required for jwt and kubernetes auth) + role: "github-agent-role" + + # Path to JWT-SVID file (for jwt auth method) + # Default: /opt/jwt_svid.token + jwt_path: "/opt/jwt_svid.token" + + # JWT audience (for jwt auth method) + # Default: vault + jwt_audience: "vault" + + # Cache TTL for secrets + # Format: duration string (e.g., "5m", "1h") + cache_ttl: "5m" + + # TLS configuration (optional) + # tls_skip_verify: false + # ca_cert: "/etc/ssl/certs/vault-ca.pem" + + # Vault namespace (Vault Enterprise only) + # namespace: "my-namespace" + +# Secrets to fetch from Vault +secrets: + # GitHub personal access token + - path: "secret/data/github/token" + field: "token" + output: "/shared/secrets/github-token" + mode: "0600" # Optional: file permissions (default: 0600) + + # Slack webhook URL + - path: "secret/data/slack/webhook" + field: "url" + output: "/shared/secrets/slack-webhook" + mode: "0600" + + # Database password + - path: "secret/data/database/postgres" + field: "password" + output: "/shared/secrets/db-password" + mode: "0600" + +# Optional: Alternative output formats +output_formats: + # Environment file (shell-compatible) + env_file: + enabled: false + path: "/shared/secrets/.env" + + # JSON file + json_file: + enabled: false + path: "/shared/secrets/credentials.json" diff --git a/authbridge/vault-fetcher/go.mod b/authbridge/vault-fetcher/go.mod new file mode 100644 index 000000000..0764e71ab --- /dev/null +++ b/authbridge/vault-fetcher/go.mod @@ -0,0 +1,32 @@ +module github.com/kagenti/kagenti-extensions/authbridge/vault-fetcher + +go 1.24.3 + +replace github.com/kagenti/kagenti-extensions/authbridge/authlib => ../authlib + +require ( + github.com/kagenti/kagenti-extensions/authbridge/authlib v0.0.0-00010101000000-000000000000 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/go-jose/go-jose/v4 v4.1.1 // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/hashicorp/go-retryablehttp v0.7.8 // indirect + github.com/hashicorp/go-rootcerts v1.0.2 // indirect + github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 // indirect + github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect + github.com/hashicorp/go-sockaddr v1.0.7 // indirect + github.com/hashicorp/hcl v1.0.1-vault-7 // indirect + github.com/hashicorp/vault/api v1.23.0 // indirect + github.com/mitchellh/go-homedir v1.1.0 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/ryanuber/go-glob v1.0.0 // indirect + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/net v0.49.0 // indirect + golang.org/x/text v0.34.0 // indirect + golang.org/x/time v0.12.0 // indirect +) diff --git a/authbridge/vault-fetcher/go.sum b/authbridge/vault-fetcher/go.sum new file mode 100644 index 000000000..918edc08a --- /dev/null +++ b/authbridge/vault-fetcher/go.sum @@ -0,0 +1,61 @@ +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/go-jose/go-jose/v4 v4.1.1 h1:JYhSgy4mXXzAdF3nUx3ygx347LRXJRrpgyU3adRmkAI= +github.com/go-jose/go-jose/v4 v4.1.1/go.mod h1:BdsZGqgdO3b6tTc6LSE56wcDbMMLuPsw5d4ZD5f94kA= +github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= +github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= +github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48= +github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw= +github.com/hashicorp/go-rootcerts v1.0.2 h1:jzhAVGtqPKbwpyCPELlgNWhE1znq+qwJtW5Oi2viEzc= +github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8= +github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 h1:U+kC2dOhMFQctRfhK0gRctKAPTloZdMU5ZJxaesJ/VM= +github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0/go.mod h1:Ll013mhdmsVDuoIXVfBtvgGJsXDYkTw1kooNcoCXuE0= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4= +github.com/hashicorp/go-sockaddr v1.0.7 h1:G+pTkSO01HpR5qCxg7lxfsFEZaG+C0VssTy/9dbT+Fw= +github.com/hashicorp/go-sockaddr v1.0.7/go.mod h1:FZQbEYa1pxkQ7WLpyXJ6cbjpT8q0YgQaK/JakXqGyWw= +github.com/hashicorp/hcl v1.0.1-vault-7 h1:ag5OxFVy3QYTFTJODRzTKVZ6xvdfLLCA1cy/Y6xGI0I= +github.com/hashicorp/hcl v1.0.1-vault-7/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM= +github.com/hashicorp/vault/api v1.23.0 h1:gXgluBsSECfRWTSW9niY2jwg2e9mMJc4WoHNv4g3h6A= +github.com/hashicorp/vault/api v1.23.0/go.mod h1:zransKiB9ftp+kgY8ydjnvCU7Wk8i9L0DYWpXeMj9ko= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk= +github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= +golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/authbridge/vault-fetcher/main.go b/authbridge/vault-fetcher/main.go new file mode 100644 index 000000000..bcbbb6aa8 --- /dev/null +++ b/authbridge/vault-fetcher/main.go @@ -0,0 +1,318 @@ +// Copyright 2026 Kagenti Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "context" + "flag" + "fmt" + "log" + "os" + "path/filepath" + "strconv" + "time" + + "github.com/kagenti/kagenti-extensions/authbridge/authlib/vault" + "gopkg.in/yaml.v3" +) + +const version = "0.1.0" + +// Config represents the vault-fetcher configuration +type Config struct { + Vault vault.Config `yaml:"vault"` + Secrets []SecretSpec `yaml:"secrets"` + OutputFormats OutputFormats `yaml:"output_formats"` +} + +// SecretSpec specifies a secret to fetch and where to write it +type SecretSpec struct { + Path string `yaml:"path"` // Vault secret path (e.g., "secret/data/github/token") + Field string `yaml:"field"` // Field name in the secret + Output string `yaml:"output"` // Output file path (e.g., "/shared/secrets/github-token") + Mode string `yaml:"mode"` // File permissions (e.g., "0600") +} + +// OutputFormats specifies optional output formats +type OutputFormats struct { + EnvFile *EnvFileConfig `yaml:"env_file"` + JSONFile *JSONFileConfig `yaml:"json_file"` +} + +// EnvFileConfig configures environment file output +type EnvFileConfig struct { + Enabled bool `yaml:"enabled"` + Path string `yaml:"path"` +} + +// JSONFileConfig configures JSON file output +type JSONFileConfig struct { + Enabled bool `yaml:"enabled"` + Path string `yaml:"path"` +} + +func main() { + // Parse flags + configFile := flag.String("config", "/etc/vault-fetcher/config.yaml", "Path to configuration file") + showVersion := flag.Bool("version", false, "Show version and exit") + flag.Parse() + + // Handle version flag + if *showVersion { + fmt.Printf("vault-fetcher version %s\n", version) + os.Exit(0) + } + + // Set up logging + log.SetFlags(log.LstdFlags) + log.SetPrefix("[vault-fetcher] ") + + log.Printf("Starting vault-fetcher v%s", version) + + // Load configuration + cfg, err := loadConfig(*configFile) + if err != nil { + log.Fatalf("Failed to load config: %v", err) + } + + log.Printf("Loaded config from %s", *configFile) + + // Apply environment variable overrides + applyEnvOverrides(&cfg.Vault) + + // Validate configuration + if err := cfg.Vault.Validate(); err != nil { + log.Fatalf("Invalid configuration: %v", err) + } + + if len(cfg.Secrets) == 0 { + log.Fatalf("No secrets configured to fetch") + } + + log.Printf("Configured to fetch %d secret(s)", len(cfg.Secrets)) + + // Create Vault client + ctx := context.Background() + client, err := vault.NewClient(&cfg.Vault) + if err != nil { + log.Fatalf("Failed to create Vault client: %v", err) + } + defer client.Close() + + log.Printf("Created Vault client (address=%s, auth_method=%s)", cfg.Vault.Address, cfg.Vault.AuthMethod) + + // Authenticate to Vault + if err := authenticateWithRetry(ctx, client, 3); err != nil { + log.Fatalf("Failed to authenticate to Vault: %v", err) + } + + log.Printf("Successfully authenticated to Vault") + + // Fetch secrets + secretsMap := make(map[string]string) + for i, spec := range cfg.Secrets { + log.Printf("[%d/%d] Fetching secret: %s (field: %s)", i+1, len(cfg.Secrets), spec.Path, spec.Field) + + value, leaseDuration, err := client.ReadSecret(ctx, spec.Path, spec.Field) + if err != nil { + log.Fatalf("Failed to fetch secret %s: %v", spec.Path, err) + } + + log.Printf("[%d/%d] Secret fetched (lease: %ds)", i+1, len(cfg.Secrets), leaseDuration) + + // Write to individual file + if err := writeSecretToFile(spec.Output, value, spec.Mode); err != nil { + log.Fatalf("Failed to write secret to %s: %v", spec.Output, err) + } + + log.Printf("[%d/%d] Written to: %s", i+1, len(cfg.Secrets), spec.Output) + + // Store in map for optional output formats + // Use the field name as the key (e.g., "token" -> "TOKEN" in env file) + secretsMap[spec.Field] = value + } + + // Write optional output formats + if cfg.OutputFormats.EnvFile != nil && cfg.OutputFormats.EnvFile.Enabled { + if err := writeEnvFile(cfg.OutputFormats.EnvFile.Path, secretsMap); err != nil { + log.Fatalf("Failed to write env file: %v", err) + } + log.Printf("Written env file: %s", cfg.OutputFormats.EnvFile.Path) + } + + if cfg.OutputFormats.JSONFile != nil && cfg.OutputFormats.JSONFile.Enabled { + if err := writeJSONFile(cfg.OutputFormats.JSONFile.Path, secretsMap); err != nil { + log.Fatalf("Failed to write JSON file: %v", err) + } + log.Printf("Written JSON file: %s", cfg.OutputFormats.JSONFile.Path) + } + + log.Printf("All secrets fetched successfully") +} + +// loadConfig loads configuration from a YAML file +func loadConfig(path string) (*Config, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("failed to read config file: %w", err) + } + + var cfg Config + if err := yaml.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("failed to parse config: %w", err) + } + + return &cfg, nil +} + +// applyEnvOverrides applies environment variable overrides to vault config +func applyEnvOverrides(cfg *vault.Config) { + if addr := os.Getenv("VAULT_ADDR"); addr != "" { + cfg.Address = addr + log.Printf("Using VAULT_ADDR from environment: %s", addr) + } + + if role := os.Getenv("VAULT_ROLE"); role != "" { + cfg.Role = role + log.Printf("Using VAULT_ROLE from environment: %s", role) + } + + if jwtPath := os.Getenv("JWT_PATH"); jwtPath != "" { + cfg.JWTPath = jwtPath + log.Printf("Using JWT_PATH from environment: %s", jwtPath) + } + + if token := os.Getenv("VAULT_TOKEN"); token != "" { + cfg.Token = token + log.Printf("Using VAULT_TOKEN from environment") + } +} + +// authenticateWithRetry attempts to authenticate with exponential backoff +func authenticateWithRetry(ctx context.Context, client *vault.Client, maxRetries int) error { + var lastErr error + + for attempt := 1; attempt <= maxRetries; attempt++ { + err := client.Authenticate(ctx) + if err == nil { + return nil + } + + lastErr = err + if attempt < maxRetries { + backoff := time.Duration(attempt*2) * time.Second + log.Printf("Authentication failed (attempt %d/%d): %v. Retrying in %s...", + attempt, maxRetries, err, backoff) + time.Sleep(backoff) + } + } + + return fmt.Errorf("authentication failed after %d attempts: %w", maxRetries, lastErr) +} + +// writeSecretToFile writes a secret value to a file with specified permissions +func writeSecretToFile(path, value, mode string) error { + // Create parent directory if it doesn't exist + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("failed to create directory %s: %w", dir, err) + } + + // Parse file mode (default to 0600 for security) + fileMode := os.FileMode(0600) + if mode != "" { + // Parse octal mode string (e.g., "0600") + modeInt, err := strconv.ParseUint(mode, 8, 32) + if err != nil { + return fmt.Errorf("invalid file mode %s: %w", mode, err) + } + fileMode = os.FileMode(modeInt) + } + + // Write file + if err := os.WriteFile(path, []byte(value), fileMode); err != nil { + return fmt.Errorf("failed to write file: %w", err) + } + + return nil +} + +// writeEnvFile writes secrets to a shell-compatible environment file +func writeEnvFile(path string, secrets map[string]string) error { + // Create parent directory + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("failed to create directory %s: %w", dir, err) + } + + // Open file + f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600) + if err != nil { + return fmt.Errorf("failed to create file: %w", err) + } + defer f.Close() + + // Write header + fmt.Fprintf(f, "# Generated by vault-fetcher at %s\n", time.Now().Format(time.RFC3339)) + fmt.Fprintf(f, "# Do not edit manually\n\n") + + // Write secrets + for key, value := range secrets { + // Convert field name to uppercase env var name + envKey := toEnvVarName(key) + fmt.Fprintf(f, "%s=%s\n", envKey, value) + } + + return nil +} + +// writeJSONFile writes secrets to a JSON file +func writeJSONFile(path string, secrets map[string]string) error { + // Create parent directory + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("failed to create directory %s: %w", dir, err) + } + + // Marshal to JSON + data, err := yaml.Marshal(secrets) + if err != nil { + return fmt.Errorf("failed to marshal JSON: %w", err) + } + + // Write file + if err := os.WriteFile(path, data, 0600); err != nil { + return fmt.Errorf("failed to write file: %w", err) + } + + return nil +} + +// toEnvVarName converts a field name to an environment variable name +// Examples: "token" -> "TOKEN", "api-key" -> "API_KEY" +func toEnvVarName(field string) string { + // Simple implementation: uppercase and replace hyphens with underscores + result := "" + for _, ch := range field { + if ch == '-' || ch == '.' { + result += "_" + } else if ch >= 'a' && ch <= 'z' { + result += string(ch - 32) // Convert to uppercase + } else { + result += string(ch) + } + } + return result +}