Files
vantage/custom-secrets-vault-gin-guide.md
T
domrichardson 19596ff2a3
Server Deploy / deploy (push) Successful in 1m33s
feat: Secret management
2026-07-03 10:37:43 +01:00

17 KiB

Custom Secrets Vault + ESO Webhook Integration

Self-hosted secrets API at https://keymanager.hostxtra.co.uk/secrets backed by MongoDB with Gin, exposed to K3s via ESO's Webhook provider.


Architecture

MongoDB (encrypted at rest)
  └── secrets collection: { group, key, encryptedValue, updatedAt }
        ↑ CRUD via Go API (Gin)
Go API (keymanager secrets service)
  └── GET    /secrets/:group        ← ESO webhook calls this
  └── PUT    /secrets/:group        ← admin writes secrets to a group
  └── DELETE /secrets/:group        ← admin deletes entire group
  └── DELETE /secrets/:group/:key   ← admin deletes one key from a group
        ↓ bearer token auth (read token for ESO, admin token for writes)
ESO Webhook ClusterSecretStore
  └── ExternalSecret (per namespace)
        └── K8s Secret
              └── Deployment env vars

Data model: A "group" is a logical namespace for a set of related secrets — e.g. myapp-prod, postgres, infra. Each group contains one or more key/value pairs stored individually as encrypted documents in MongoDB.

Encryption: AES-256-GCM per value, random nonce per write, master key loaded from the MASTER_KEY environment variable (32-byte hex string).


Part 1 — The Go API

1.1 — Project structure

keymanager/
├── cmd/
│   └── server/
│       └── main.go
├── internal/
│   ├── crypto/
│   │   └── crypto.go
│   ├── store/
│   │   └── store.go
│   └── api/
│       └── api.go
├── go.mod
└── Dockerfile

1.2 — go.mod

module github.com/yourusername/keymanager

go 1.23

require (
    go.mongodb.org/mongo-driver v1.17.0
    github.com/gin-gonic/gin v1.10.0
)

1.3 — internal/crypto/crypto.go

AES-256-GCM encryption. Each value gets a unique random nonce so identical plaintext values produce different ciphertext on every write.

package crypto

import (
	"crypto/aes"
	"crypto/cipher"
	"crypto/rand"
	"encoding/base64"
	"errors"
	"io"
)

// Encrypt encrypts plaintext using AES-256-GCM.
// Returns base64(nonce + ciphertext).
func Encrypt(key []byte, plaintext string) (string, error) {
	block, err := aes.NewCipher(key)
	if err != nil {
		return "", err
	}
	gcm, err := cipher.NewGCM(block)
	if err != nil {
		return "", err
	}
	nonce := make([]byte, gcm.NonceSize())
	if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
		return "", err
	}
	ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
	return base64.StdEncoding.EncodeToString(ciphertext), nil
}

// Decrypt decrypts a base64(nonce + ciphertext) produced by Encrypt.
func Decrypt(key []byte, encoded string) (string, error) {
	data, err := base64.StdEncoding.DecodeString(encoded)
	if err != nil {
		return "", err
	}
	block, err := aes.NewCipher(key)
	if err != nil {
		return "", err
	}
	gcm, err := cipher.NewGCM(block)
	if err != nil {
		return "", err
	}
	if len(data) < gcm.NonceSize() {
		return "", errors.New("ciphertext too short")
	}
	nonce, ciphertext := data[:gcm.NonceSize()], data[gcm.NonceSize():]
	plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
	if err != nil {
		return "", err
	}
	return string(plaintext), nil
}

1.4 — internal/store/store.go

MongoDB storage. Each document represents one key within a group.

package store

import (
	"context"
	"time"

	"go.mongodb.org/mongo-driver/bson"
	"go.mongodb.org/mongo-driver/mongo"
	"go.mongodb.org/mongo-driver/mongo/options"
)

type SecretDoc struct {
	Group          string    `bson:"group"`
	Key            string    `bson:"key"`
	EncryptedValue string    `bson:"encryptedValue"`
	UpdatedAt      time.Time `bson:"updatedAt"`
}

type Store struct {
	col *mongo.Collection
}

func New(client *mongo.Client, dbName string) (*Store, error) {
	col := client.Database(dbName).Collection("secrets")

	// Unique compound index on (group, key)
	_, err := col.Indexes().CreateOne(context.Background(), mongo.IndexModel{
		Keys:    bson.D{{Key: "group", Value: 1}, {Key: "key", Value: 1}},
		Options: options.Index().SetUnique(true),
	})
	if err != nil {
		return nil, err
	}

	return &Store{col: col}, nil
}

// GetGroup returns all SecretDocs belonging to the given group.
func (s *Store) GetGroup(ctx context.Context, group string) ([]SecretDoc, error) {
	cursor, err := s.col.Find(ctx, bson.M{"group": group})
	if err != nil {
		return nil, err
	}
	var docs []SecretDoc
	if err := cursor.All(ctx, &docs); err != nil {
		return nil, err
	}
	return docs, nil
}

// Upsert writes or updates a single key within a group.
func (s *Store) Upsert(ctx context.Context, group, key, encryptedValue string) error {
	filter := bson.M{"group": group, "key": key}
	update := bson.M{"$set": bson.M{
		"encryptedValue": encryptedValue,
		"updatedAt":      time.Now(),
	}}
	_, err := s.col.UpdateOne(ctx, filter, update, options.Update().SetUpsert(true))
	return err
}

// DeleteGroup removes all keys belonging to a group.
func (s *Store) DeleteGroup(ctx context.Context, group string) error {
	_, err := s.col.DeleteMany(ctx, bson.M{"group": group})
	return err
}

// DeleteKey removes a single key from a group.
func (s *Store) DeleteKey(ctx context.Context, group, key string) error {
	_, err := s.col.DeleteOne(ctx, bson.M{"group": group, "key": key})
	return err
}

1.5 — internal/api/api.go

Gin handlers. Two token tiers: ESO gets a read-only token, admins get a write token.

package api

import (
	"encoding/hex"
	"net/http"
	"os"

	"github.com/gin-gonic/gin"
	"github.com/yourusername/keymanager/internal/crypto"
	"github.com/yourusername/keymanager/internal/store"
)

type API struct {
	store      *store.Store
	masterKey  []byte
	esoToken   string
	adminToken string
}

func New(s *store.Store) *API {
	keyHex := os.Getenv("MASTER_KEY")
	key, err := hex.DecodeString(keyHex)
	if err != nil || len(key) != 32 {
		panic("MASTER_KEY must be a 64-character hex string (32 bytes)")
	}
	return &API{
		store:      s,
		masterKey:  key,
		esoToken:   os.Getenv("ESO_TOKEN"),
		adminToken: os.Getenv("ADMIN_TOKEN"),
	}
}

func (a *API) RegisterRoutes(r *gin.Engine) {
	secrets := r.Group("/secrets")

	// Read routes — ESO token
	secrets.GET("/:group", a.bearerAuth(a.esoToken), a.getGroup)

	// Write routes — admin token
	secrets.PUT("/:group", a.bearerAuth(a.adminToken), a.putGroup)
	secrets.DELETE("/:group", a.bearerAuth(a.adminToken), a.deleteGroup)
	secrets.DELETE("/:group/:key", a.bearerAuth(a.adminToken), a.deleteKey)
}

// bearerAuth returns a Gin middleware that validates a Bearer token.
func (a *API) bearerAuth(expected string) gin.HandlerFunc {
	return func(c *gin.Context) {
		auth := c.GetHeader("Authorization")
		const prefix = "Bearer "
		if len(auth) <= len(prefix) || auth[:len(prefix)] != prefix {
			c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing bearer token"})
			return
		}
		token := auth[len(prefix):]
		if token != expected {
			c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
			return
		}
		c.Next()
	}
}

// getGroup handles GET /secrets/:group
// Returns a flat JSON object { "KEY": "value", ... } for ESO to consume.
// Returns 404 if the group has no secrets — ESO treats 404 as "deleted".
func (a *API) getGroup(c *gin.Context) {
	group := c.Param("group")

	docs, err := a.store.GetGroup(c.Request.Context(), group)
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "store error"})
		return
	}
	if len(docs) == 0 {
		c.JSON(http.StatusNotFound, gin.H{"error": "group not found"})
		return
	}

	result := make(map[string]string, len(docs))
	for _, doc := range docs {
		val, err := crypto.Decrypt(a.masterKey, doc.EncryptedValue)
		if err != nil {
			c.JSON(http.StatusInternalServerError, gin.H{"error": "decrypt error"})
			return
		}
		result[doc.Key] = val
	}

	c.JSON(http.StatusOK, result)
}

// putGroup handles PUT /secrets/:group
// Body: { "KEY": "value", ... } — upserts each key in the group.
func (a *API) putGroup(c *gin.Context) {
	group := c.Param("group")

	var payload map[string]string
	if err := c.ShouldBindJSON(&payload); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON body"})
		return
	}
	if len(payload) == 0 {
		c.JSON(http.StatusBadRequest, gin.H{"error": "body must contain at least one key"})
		return
	}

	for key, val := range payload {
		encrypted, err := crypto.Encrypt(a.masterKey, val)
		if err != nil {
			c.JSON(http.StatusInternalServerError, gin.H{"error": "encrypt error"})
			return
		}
		if err := a.store.Upsert(c.Request.Context(), group, key, encrypted); err != nil {
			c.JSON(http.StatusInternalServerError, gin.H{"error": "store error"})
			return
		}
	}

	c.Status(http.StatusNoContent)
}

// deleteGroup handles DELETE /secrets/:group
// Removes the entire group and all its keys.
func (a *API) deleteGroup(c *gin.Context) {
	group := c.Param("group")

	if err := a.store.DeleteGroup(c.Request.Context(), group); err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "store error"})
		return
	}
	c.Status(http.StatusNoContent)
}

// deleteKey handles DELETE /secrets/:group/:key
// Removes a single key from a group.
func (a *API) deleteKey(c *gin.Context) {
	group := c.Param("group")
	key := c.Param("key")

	if err := a.store.DeleteKey(c.Request.Context(), group, key); err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "store error"})
		return
	}
	c.Status(http.StatusNoContent)
}

1.6 — cmd/server/main.go

package main

import (
	"context"
	"log"
	"os"
	"time"

	"github.com/gin-gonic/gin"
	"go.mongodb.org/mongo-driver/mongo"
	"go.mongodb.org/mongo-driver/mongo/options"

	"github.com/yourusername/keymanager/internal/api"
	"github.com/yourusername/keymanager/internal/store"
)

func main() {
	mongoURI := os.Getenv("MONGODB_URI")
	if mongoURI == "" {
		mongoURI = "mongodb://localhost:27017"
	}

	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()

	client, err := mongo.Connect(ctx, options.Client().ApplyURI(mongoURI))
	if err != nil {
		log.Fatalf("MongoDB connect: %v", err)
	}
	if err := client.Ping(ctx, nil); err != nil {
		log.Fatalf("MongoDB ping: %v", err)
	}
	log.Println("Connected to MongoDB")

	s, err := store.New(client, "keymanager")
	if err != nil {
		log.Fatalf("Store init: %v", err)
	}

	gin.SetMode(gin.ReleaseMode)
	r := gin.New()
	r.Use(gin.Logger(), gin.Recovery())

	a := api.New(s)
	a.RegisterRoutes(r)

	log.Println("Secrets API listening on :8080")
	if err := r.Run(":8080"); err != nil {
		log.Fatalf("Server error: %v", err)
	}
}

1.7 — Dockerfile

FROM golang:1.23-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o secrets-api ./cmd/server

FROM alpine:3.20
RUN apk add --no-cache ca-certificates
WORKDIR /app
COPY --from=builder /app/secrets-api .
EXPOSE 8080
CMD ["./secrets-api"]

Part 2 — Deploying the API

2.1 — Generate your secrets

# 32-byte master key — back this up in your password manager
openssl rand -hex 32

# ESO read token
openssl rand -hex 32

# Admin token
openssl rand -hex 32

2.2 — Docker Compose

services:
  secrets-api:
    image: ghcr.io/yourusername/keymanager-secrets:latest
    restart: unless-stopped
    environment:
      MONGODB_URI: mongodb://mongo:27017
      MASTER_KEY: "<your-32-byte-hex-key>"
      ESO_TOKEN: "<your-eso-token>"
      ADMIN_TOKEN: "<your-admin-token>"
    ports:
      - "127.0.0.1:8082:8080"

2.3 — Caddyfile

keymanager.hostxtra.co.uk {
    # ... existing KeyManager routes ...

    handle /secrets* {
        reverse_proxy localhost:8082
    }
}
caddy reload --config /etc/caddy/Caddyfile

2.4 — Smoke test

export ADMIN="<your-admin-token>"
export ESO="<your-eso-token>"
export BASE="https://keymanager.hostxtra.co.uk/secrets"

# Write a group
curl -s -X PUT $BASE/myapp-prod \
  -H "Authorization: Bearer $ADMIN" \
  -H "Content-Type: application/json" \
  -d '{"DB_PASSWORD": "supersecret123", "API_KEY": "myapikey456"}'
# → 204 No Content

# Read back (as ESO would)
curl -s $BASE/myapp-prod \
  -H "Authorization: Bearer $ESO"
# → {"API_KEY":"myapikey456","DB_PASSWORD":"supersecret123"}

# Delete a single key
curl -s -X DELETE $BASE/myapp-prod/API_KEY \
  -H "Authorization: Bearer $ADMIN"
# → 204 No Content

# Confirm it's gone
curl -s $BASE/myapp-prod \
  -H "Authorization: Bearer $ESO"
# → {"DB_PASSWORD":"supersecret123"}

# Delete the whole group
curl -s -X DELETE $BASE/myapp-prod \
  -H "Authorization: Bearer $ADMIN"
# → 204 No Content

# Confirm 404
curl -s -o /dev/null -w "%{http_code}" $BASE/myapp-prod \
  -H "Authorization: Bearer $ESO"
# → 404

Part 3 — ESO Webhook Integration

3.1 — Install ESO

helm repo add external-secrets https://charts.external-secrets.io
helm repo update
helm upgrade --install external-secrets external-secrets/external-secrets \
  --namespace external-secrets \
  --create-namespace \
  --set installCRDs=true \
  --wait

3.2 — Store ESO token as a K8s Secret

The external-secrets.io/type=webhook label is required — without it the webhook provider is not permitted to read the secret.

kubectl create secret generic keymanager-eso-token \
  --namespace external-secrets \
  --from-literal=token="<your-eso-token>"

kubectl label secret keymanager-eso-token \
  --namespace external-secrets \
  external-secrets.io/type=webhook

3.3 — ClusterSecretStore

# cluster-secret-store.yaml
apiVersion: external-secrets.io/v1
kind: ClusterSecretStore
metadata:
  name: keymanager-store
spec:
  provider:
    webhook:
      url: "https://keymanager.hostxtra.co.uk/secrets/{{ .remoteRef.key }}"
      method: GET
      result:
        jsonPath: "$"
      headers:
        Content-Type: "application/json"
        Authorization: "Bearer {{ .auth.token }}"
      secrets:
        - name: auth
          secretRef:
            name: keymanager-eso-token
            namespace: external-secrets
kubectl apply -f cluster-secret-store.yaml
kubectl get clustersecretstore keymanager-store

3.4 — ExternalSecret

The remoteRef.key value is the group name — ESO substitutes it into the URL template, calling GET /secrets/myapp-prod.

# external-secret.yaml
apiVersion: v1
kind: Namespace
metadata:
  name: myapp
---
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
  name: myapp-secrets
  namespace: myapp
spec:
  refreshInterval: 15m
  secretStoreRef:
    name: keymanager-store
    kind: ClusterSecretStore
  target:
    name: myapp-secrets
    creationPolicy: Owner
  dataFrom:
    - extract:
        key: myapp-prod        # group name → GET /secrets/myapp-prod

Or to pull specific keys from a group:

  data:
    - secretKey: DB_PASSWORD
      remoteRef:
        key: myapp-prod        # group name
        property: DB_PASSWORD  # key within the group's JSON response
kubectl apply -f external-secret.yaml

kubectl get externalsecret myapp-secrets -n myapp
# STATUS: SecretSynced

# Decode and verify values
kubectl get secret myapp-secrets -n myapp -o json | \
  jq '.data | map_values(@base64d)'

3.5 — Deployment

apiVersion: apps/v1
kind: Deployment
metadata:
  name: myapp
  namespace: myapp
spec:
  replicas: 1
  selector:
    matchLabels:
      app: myapp
  template:
    metadata:
      labels:
        app: myapp
      annotations:
        reloader.stakater.com/auto: "true"   # optional: auto-restart on secret change
    spec:
      containers:
        - name: myapp
          image: your-image:latest
          envFrom:
            - secretRef:
                name: myapp-secrets

Part 4 — Day-to-Day Secret Management

export ADMIN="<your-admin-token>"
export BASE="https://keymanager.hostxtra.co.uk/secrets"

# Create or update a group (upserts — safe to re-run)
curl -s -X PUT $BASE/postgres \
  -H "Authorization: Bearer $ADMIN" \
  -H "Content-Type: application/json" \
  -d '{"POSTGRES_PASSWORD": "dbpass", "POSTGRES_USER": "app"}'

# Add a new key to an existing group (existing keys are untouched)
curl -s -X PUT $BASE/postgres \
  -H "Authorization: Bearer $ADMIN" \
  -H "Content-Type: application/json" \
  -d '{"POSTGRES_DB": "mydb"}'

# Remove a single key from a group
curl -s -X DELETE $BASE/postgres/POSTGRES_USER \
  -H "Authorization: Bearer $ADMIN"

# Remove an entire group
curl -s -X DELETE $BASE/postgres \
  -H "Authorization: Bearer $ADMIN"

# Force ESO to re-sync immediately after a rotation
kubectl annotate externalsecret myapp-secrets -n myapp \
  force-sync=$(date +%s) --overwrite

Quick Reference

Endpoint Token Description
GET /secrets/:group ESO token Returns all keys in group as JSON
PUT /secrets/:group Admin token Upserts keys into group
DELETE /secrets/:group Admin token Deletes entire group
DELETE /secrets/:group/:key Admin token Deletes one key from group