feat: TreatVault encrypted secret manager with Docker Swarm sync and console plugin

This commit is contained in:
2026-09-16 16:06:41 -04:00
parent 61a45a4310
commit 63ee4a99e6
25 changed files with 5976 additions and 1 deletions

3414
internal/adminui/dist/assets/entry.js vendored Normal file

File diff suppressed because one or more lines are too long

208
internal/adminui/handler.go Normal file
View File

@@ -0,0 +1,208 @@
// Package adminui serves TreatVault's write-only Barkstack plugin and API.
package adminui
import (
"context"
"embed"
"encoding/json"
"errors"
"io"
"io/fs"
"mime"
"net/http"
"path"
"strings"
barkfile "cloud.campbellwireless.net/git/barkstack/barkfile-parser/v2"
"cloud.campbellwireless.net/git/barkstack/treatvault/internal/manager"
"cloud.campbellwireless.net/git/barkstack/treatvault/internal/vault"
)
//go:embed dist
var embeddedUI embed.FS
const entryPath = "/barkstack/ui/assets/entry.js"
type SecretManager interface {
Status() manager.Status
Set(context.Context, string, []byte) error
Delete(context.Context, string) error
}
type manifest struct {
APIVersion string `json:"apiVersion"`
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Icon string `json:"icon"`
Mount string `json:"mount"`
Entry string `json:"entry"`
Element string `json:"element"`
Navigation []navigationItem `json:"navigation"`
}
type navigationItem struct {
Label string `json:"label"`
Path string `json:"path"`
Icon string `json:"icon"`
}
type handler struct {
assets fs.FS
manager SecretManager
}
func Handler(secretManager SecretManager) http.Handler {
assets, err := fs.Sub(embeddedUI, "dist")
if err != nil {
panic(err)
}
return &handler{assets: assets, manager: secretManager}
}
func (h *handler) ServeHTTP(response http.ResponseWriter, request *http.Request) {
switch {
case request.URL.Path == "/barkstack/ui/manifest.json":
h.serveManifest(response, request)
case request.URL.Path == "/barkstack/api/secrets":
h.serveSecretList(response, request)
case strings.HasPrefix(request.URL.Path, "/barkstack/api/secrets/"):
h.serveSecret(response, request)
case request.URL.Path == "/healthz":
h.serveHealth(response, request)
default:
h.serveAsset(response, request)
}
}
func (h *handler) serveManifest(response http.ResponseWriter, request *http.Request) {
if !allow(response, request, http.MethodGet, http.MethodHead) {
return
}
h.serveJSON(response, request, http.StatusOK, manifest{
APIVersion: "barkstack.dev/ui/v1", ID: "treatvault", Name: "TreatVault",
Description: "Encrypted secret management for Barkstack", Icon: "key", Mount: "/treatvault",
Entry: entryPath, Element: "barkstack-treatvault",
Navigation: []navigationItem{{Label: "TreatVault", Path: "/treatvault", Icon: "key"}},
})
}
func (h *handler) serveSecretList(response http.ResponseWriter, request *http.Request) {
if !allow(response, request, http.MethodGet, http.MethodHead) {
return
}
h.serveJSON(response, request, http.StatusOK, h.manager.Status())
}
func (h *handler) serveSecret(response http.ResponseWriter, request *http.Request) {
name := strings.TrimPrefix(request.URL.Path, "/barkstack/api/secrets/")
if !barkfile.ValidSecretReference(name) || strings.Contains(name, "/") {
h.serveError(response, http.StatusBadRequest, "invalid secret name")
return
}
switch request.Method {
case http.MethodPut:
var body struct {
Value string `json:"value"`
}
decoder := json.NewDecoder(io.LimitReader(request.Body, 512*1024+1))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&body); err != nil {
h.serveError(response, http.StatusBadRequest, "invalid JSON request")
return
}
if body.Value == "" {
h.serveError(response, http.StatusBadRequest, "secret value must not be empty")
return
}
if err := h.manager.Set(request.Context(), name, []byte(body.Value)); err != nil {
h.serveError(response, http.StatusBadGateway, err.Error())
return
}
response.WriteHeader(http.StatusNoContent)
case http.MethodDelete:
if err := h.manager.Delete(request.Context(), name); err != nil {
switch {
case errors.Is(err, vault.ErrSecretNotFound):
h.serveError(response, http.StatusNotFound, "secret not found")
case manager.IsSecretInUse(err):
h.serveError(response, http.StatusConflict, "secret is still referenced by a managed service")
default:
h.serveError(response, http.StatusBadGateway, err.Error())
}
return
}
response.WriteHeader(http.StatusNoContent)
default:
response.Header().Set("Allow", "PUT, DELETE")
h.serveError(response, http.StatusMethodNotAllowed, "method not allowed")
}
}
func (h *handler) serveHealth(response http.ResponseWriter, request *http.Request) {
if !allow(response, request, http.MethodGet, http.MethodHead) {
return
}
status := h.manager.Status()
code := http.StatusOK
if status.State != "ready" {
code = http.StatusServiceUnavailable
}
h.serveJSON(response, request, code, map[string]string{"status": status.State})
}
func (h *handler) serveAsset(response http.ResponseWriter, request *http.Request) {
if !allow(response, request, http.MethodGet, http.MethodHead) {
return
}
const prefix = "/barkstack/ui/assets/"
if !strings.HasPrefix(request.URL.Path, prefix) {
http.NotFound(response, request)
return
}
assetName := strings.TrimPrefix(request.URL.Path, "/barkstack/ui/")
if assetName == "" || !fs.ValidPath(assetName) || path.Clean(assetName) != assetName || strings.Contains(assetName, "\\") {
http.NotFound(response, request)
return
}
contents, err := fs.ReadFile(h.assets, assetName)
if err != nil {
http.NotFound(response, request)
return
}
if contentType := mime.TypeByExtension(path.Ext(assetName)); contentType != "" {
response.Header().Set("Content-Type", contentType)
}
response.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
response.Header().Set("X-Content-Type-Options", "nosniff")
if request.Method != http.MethodHead {
_, _ = response.Write(contents)
}
}
func (h *handler) serveJSON(response http.ResponseWriter, request *http.Request, code int, value any) {
response.Header().Set("Content-Type", "application/json")
response.Header().Set("Cache-Control", "no-store")
response.WriteHeader(code)
if request.Method != http.MethodHead {
_ = json.NewEncoder(response).Encode(value)
}
}
func (h *handler) serveError(response http.ResponseWriter, code int, message string) {
response.Header().Set("Content-Type", "application/json")
response.Header().Set("Cache-Control", "no-store")
response.WriteHeader(code)
_ = json.NewEncoder(response).Encode(map[string]string{"error": message})
}
func allow(response http.ResponseWriter, request *http.Request, methods ...string) bool {
for _, method := range methods {
if request.Method == method {
return true
}
}
response.Header().Set("Allow", strings.Join(methods, ", "))
http.Error(response, "method not allowed", http.StatusMethodNotAllowed)
return false
}

View File

@@ -0,0 +1,96 @@
package adminui
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"cloud.campbellwireless.net/git/barkstack/treatvault/internal/manager"
"cloud.campbellwireless.net/git/barkstack/treatvault/internal/provider"
)
func TestManifestDescribesTreatVaultPlugin(t *testing.T) {
response := httptest.NewRecorder()
Handler(&fakeManager{}).ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/barkstack/ui/manifest.json", nil))
if response.Code != http.StatusOK {
t.Fatalf("status = %d", response.Code)
}
var got manifest
if err := json.Unmarshal(response.Body.Bytes(), &got); err != nil {
t.Fatal(err)
}
if got.ID != "treatvault" || got.Element != "barkstack-treatvault" || got.Entry != entryPath {
t.Fatalf("manifest = %#v", got)
}
}
func TestListNeverReturnsSecretValues(t *testing.T) {
manager := &fakeManager{status: manager.Status{State: "ready", Secrets: []manager.Secret{{Name: "database_password", InUse: true, Synced: true}}}}
response := httptest.NewRecorder()
Handler(manager).ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/barkstack/api/secrets", nil))
if response.Code != http.StatusOK {
t.Fatalf("status = %d", response.Code)
}
if strings.Contains(response.Body.String(), "secret-value") || !strings.Contains(response.Body.String(), "database_password") {
t.Fatalf("body = %s", response.Body.String())
}
}
func TestPutSetsSecretAndReturnsNoValue(t *testing.T) {
manager := &fakeManager{}
response := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPut, "/barkstack/api/secrets/database_password", strings.NewReader(`{"value":"secret-value"}`))
Handler(manager).ServeHTTP(response, request)
if response.Code != http.StatusNoContent {
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
}
if manager.setName != "database_password" || string(manager.setValue) != "secret-value" {
t.Fatalf("set = %q %q", manager.setName, manager.setValue)
}
if strings.Contains(response.Body.String(), "secret-value") {
t.Fatal("response disclosed the secret value")
}
}
func TestDeleteReturnsConflictForInUseSecret(t *testing.T) {
manager := &fakeManager{deleteErr: fmtInUse("database_password")}
response := httptest.NewRecorder()
Handler(manager).ServeHTTP(response, httptest.NewRequest(http.MethodDelete, "/barkstack/api/secrets/database_password", nil))
if response.Code != http.StatusConflict {
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
}
}
func TestInvalidSecretNameIsRejected(t *testing.T) {
response := httptest.NewRecorder()
Handler(&fakeManager{}).ServeHTTP(response, httptest.NewRequest(http.MethodPut, "/barkstack/api/secrets/UPPERCASE", strings.NewReader(`{"value":"value"}`)))
if response.Code != http.StatusBadRequest {
t.Fatalf("status = %d", response.Code)
}
}
type fakeManager struct {
status manager.Status
setName string
setValue []byte
setErr error
deleteErr error
}
func (m *fakeManager) Status() manager.Status { return m.status }
func (m *fakeManager) Set(_ context.Context, name string, value []byte) error {
m.setName = name
m.setValue = append([]byte(nil), value...)
return m.setErr
}
func (m *fakeManager) Delete(context.Context, string) error { return m.deleteErr }
func fmtInUse(name string) error {
return errors.Join(provider.ErrSecretInUse, errors.New(name))
}

170
internal/manager/manager.go Normal file
View File

@@ -0,0 +1,170 @@
// Package manager coordinates encrypted file mutations and provider reconciliation.
package manager
import (
"context"
"crypto/sha256"
"errors"
"fmt"
"os"
"sort"
"sync"
"time"
"cloud.campbellwireless.net/git/barkstack/treatvault/internal/provider"
"cloud.campbellwireless.net/git/barkstack/treatvault/internal/vault"
)
type Provider interface {
Sync(context.Context, vault.Snapshot) ([]provider.SecretState, error)
InUse(context.Context, string) (bool, error)
}
type Secret struct {
Name string `json:"name"`
InUse bool `json:"inUse"`
Synced bool `json:"synced"`
}
type Status struct {
State string `json:"state"`
Secrets []Secret `json:"secrets"`
LastSync time.Time `json:"lastSync,omitempty"`
Error string `json:"error,omitempty"`
}
type Manager struct {
store *vault.Store
provider Provider
pollInterval time.Duration
operationMu sync.Mutex
statusMu sync.RWMutex
status Status
}
func New(store *vault.Store, secretProvider Provider, pollInterval time.Duration) *Manager {
if pollInterval <= 0 {
pollInterval = 500 * time.Millisecond
}
return &Manager{
store: store, provider: secretProvider, pollInterval: pollInterval,
status: Status{State: "starting", Secrets: []Secret{}},
}
}
func (m *Manager) Status() Status {
m.statusMu.RLock()
defer m.statusMu.RUnlock()
status := m.status
status.Secrets = append([]Secret(nil), m.status.Secrets...)
return status
}
func (m *Manager) Sync(ctx context.Context) error {
m.operationMu.Lock()
defer m.operationMu.Unlock()
snapshot, err := m.store.Load()
if err != nil {
m.setFailure(err)
return err
}
return m.syncSnapshot(ctx, snapshot)
}
func (m *Manager) Set(ctx context.Context, name string, value []byte) error {
m.operationMu.Lock()
defer m.operationMu.Unlock()
snapshot, err := m.store.Set(name, value)
if err != nil {
return err
}
return m.syncSnapshot(ctx, snapshot)
}
func (m *Manager) Delete(ctx context.Context, name string) error {
m.operationMu.Lock()
defer m.operationMu.Unlock()
inUse, err := m.provider.InUse(ctx, name)
if err != nil {
return fmt.Errorf("check secret consumers: %w", err)
}
if inUse {
return fmt.Errorf("%w: %s", provider.ErrSecretInUse, name)
}
snapshot, err := m.store.Delete(name)
if err != nil {
return err
}
return m.syncSnapshot(ctx, snapshot)
}
func (m *Manager) Run(ctx context.Context) {
_ = m.Sync(ctx)
fingerprint, _ := fileFingerprint(m.store.Path())
ticker := time.NewTicker(m.pollInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
next, err := fileFingerprint(m.store.Path())
status := m.Status()
if err != nil {
m.setFailure(err)
continue
}
if next == fingerprint && status.Error == "" {
continue
}
fingerprint = next
_ = m.Sync(ctx)
}
}
}
func (m *Manager) syncSnapshot(ctx context.Context, snapshot vault.Snapshot) error {
states, err := m.provider.Sync(ctx, snapshot)
secrets := make([]Secret, 0, len(snapshot.Secrets))
stateByName := make(map[string]provider.SecretState, len(states))
for _, state := range states {
stateByName[state.Name] = state
}
for name := range snapshot.Secrets {
state, synced := stateByName[name]
secrets = append(secrets, Secret{Name: name, InUse: state.InUse, Synced: synced && err == nil})
}
sort.Slice(secrets, func(i, j int) bool { return secrets[i].Name < secrets[j].Name })
status := Status{State: "ready", Secrets: secrets, LastSync: time.Now().UTC()}
if err != nil {
status.State = "degraded"
status.Error = err.Error()
}
m.statusMu.Lock()
m.status = status
m.statusMu.Unlock()
return err
}
func (m *Manager) setFailure(err error) {
if err == nil {
return
}
m.statusMu.Lock()
m.status.State = "degraded"
m.status.Error = err.Error()
m.statusMu.Unlock()
}
func fileFingerprint(path string) ([sha256.Size]byte, error) {
contents, err := os.ReadFile(path)
if err != nil {
return [sha256.Size]byte{}, fmt.Errorf("read encrypted file: %w", err)
}
return sha256.Sum256(contents), nil
}
func IsSecretInUse(err error) bool {
return errors.Is(err, provider.ErrSecretInUse)
}

View File

@@ -0,0 +1,99 @@
package manager
import (
"context"
"sync"
"testing"
"time"
"cloud.campbellwireless.net/git/barkstack/treatvault/internal/provider"
"cloud.campbellwireless.net/git/barkstack/treatvault/internal/vault"
"filippo.io/age"
)
func TestRunSynchronizesExternalEncryptedFileChanges(t *testing.T) {
identity, err := age.GenerateX25519Identity()
if err != nil {
t.Fatal(err)
}
path := t.TempDir() + "/secrets.age"
if err := vault.Initialize(path, identity); err != nil {
t.Fatal(err)
}
store := vault.New(path, identity)
provider := &recordingProvider{synced: make(chan vault.Snapshot, 4)}
manager := New(store, provider, 10*time.Millisecond)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go manager.Run(ctx)
initial := waitSnapshot(t, provider.synced)
if len(initial.Secrets) != 0 {
t.Fatalf("initial secrets = %#v", initial.Secrets)
}
if _, err := store.Set("database_password", []byte("changed outside manager")); err != nil {
t.Fatal(err)
}
changed := waitSnapshot(t, provider.synced)
if string(changed.Secrets["database_password"].Value) != "changed outside manager" {
t.Fatalf("changed snapshot = %#v", changed)
}
}
func TestDeleteRefusesSecretInUse(t *testing.T) {
identity, err := age.GenerateX25519Identity()
if err != nil {
t.Fatal(err)
}
path := t.TempDir() + "/secrets.age"
if err := vault.Initialize(path, identity); err != nil {
t.Fatal(err)
}
store := vault.New(path, identity)
if _, err := store.Set("database_password", []byte("value")); err != nil {
t.Fatal(err)
}
manager := New(store, &recordingProvider{inUse: true}, time.Second)
if err := manager.Delete(context.Background(), "database_password"); !IsSecretInUse(err) {
t.Fatalf("Delete() error = %v", err)
}
loaded, err := store.Load()
if err != nil {
t.Fatal(err)
}
if _, exists := loaded.Secrets["database_password"]; !exists {
t.Fatal("in-use secret was removed from encrypted file")
}
}
type recordingProvider struct {
mu sync.Mutex
synced chan vault.Snapshot
inUse bool
}
func (p *recordingProvider) Sync(_ context.Context, snapshot vault.Snapshot) ([]provider.SecretState, error) {
if p.synced != nil {
p.synced <- snapshot
}
states := make([]provider.SecretState, 0, len(snapshot.Secrets))
for name, record := range snapshot.Secrets {
states = append(states, provider.SecretState{Name: name, Revision: record.Revision, InUse: p.inUse})
}
return states, nil
}
func (p *recordingProvider) InUse(context.Context, string) (bool, error) {
return p.inUse, nil
}
func waitSnapshot(t *testing.T, snapshots <-chan vault.Snapshot) vault.Snapshot {
t.Helper()
select {
case snapshot := <-snapshots:
return snapshot
case <-time.After(time.Second):
t.Fatal("timed out waiting for synchronization")
return vault.Snapshot{}
}
}

312
internal/provider/docker.go Normal file
View File

@@ -0,0 +1,312 @@
// Package provider reconciles TreatVault snapshots with platform secret providers.
package provider
import (
"context"
"encoding/json"
"errors"
"fmt"
"os/exec"
"sort"
"strings"
barkfile "cloud.campbellwireless.net/git/barkstack/barkfile-parser/v2"
"cloud.campbellwireless.net/git/barkstack/treatvault/internal/vault"
)
var ErrSecretInUse = errors.New("secret is in use")
type Runner interface {
Run(context.Context, []byte, ...string) (string, error)
}
type ExecRunner struct {
Path string
}
func (r ExecRunner) Run(ctx context.Context, input []byte, args ...string) (string, error) {
path := r.Path
if path == "" {
path = "docker"
}
command := exec.CommandContext(ctx, path, args...)
if input != nil {
command.Stdin = strings.NewReader(string(input))
}
output, err := command.CombinedOutput()
if err != nil {
return string(output), fmt.Errorf("docker %s: %w: %s", strings.Join(args, " "), err, strings.TrimSpace(string(output)))
}
return string(output), nil
}
type SecretState struct {
Name string
Revision string
PhysicalName string
InUse bool
}
type Docker struct {
Runner Runner
}
// Sync reconciles snapshot with Docker Swarm: it creates one immutable secret
// object per record revision, rotates mounts on labeled consumer services, and
// removes obsolete objects. Names listed in a service's
// io.barkstack.treatvault.names label are mounted at barkstack_<name> targets.
func (d Docker) Sync(ctx context.Context, snapshot vault.Snapshot) ([]SecretState, error) {
if d.Runner == nil {
return nil, errors.New("Docker runner is required")
}
managed, err := d.managedSecrets(ctx, snapshot.VaultID)
if err != nil {
return nil, err
}
for name, record := range snapshot.Secrets {
physical := physicalName(snapshot.VaultID, record.Revision)
if containsSecret(managed, physical) {
continue
}
args := []string{
"secret", "create",
"--label", barkfile.TreatVaultManagedLabel + "=true",
"--label", barkfile.TreatVaultSecretNameLabel + "=" + name,
"--label", barkfile.TreatVaultVaultIDLabel + "=" + snapshot.VaultID,
"--label", barkfile.TreatVaultRevisionLabel + "=" + record.Revision,
physical, "-",
}
if _, err := d.Runner.Run(ctx, record.Value, args...); err != nil {
return nil, fmt.Errorf("create Docker secret for %q: %w", name, err)
}
}
services, err := d.managedServices(ctx)
if err != nil {
return nil, err
}
inUse := make(map[string]bool)
missing := make(map[string]struct{})
desiredTargets := make(map[string]struct{}, len(snapshot.Secrets))
for name := range snapshot.Secrets {
desiredTargets[barkfile.DockerSecretName(name)] = struct{}{}
}
for _, service := range services {
args := []string{"service", "update"}
changed := false
mountedByTarget := make(map[string]serviceSecret, len(service.Secrets))
for _, mounted := range service.Secrets {
mountedByTarget[mounted.Target] = mounted
if _, managedTarget := desiredTargets[mounted.Target]; managedTarget {
inUse[strings.TrimPrefix(mounted.Target, barkfile.DockerSecretPrefix)] = true
}
}
for _, name := range service.DesiredNames {
record, stored := snapshot.Secrets[name]
if !stored {
missing[name] = struct{}{}
continue
}
inUse[name] = true
desiredPhysical := physicalName(snapshot.VaultID, record.Revision)
target := barkfile.DockerSecretName(name)
mounted, isMounted := mountedByTarget[target]
if isMounted && mounted.Source == desiredPhysical {
continue
}
if isMounted {
args = append(args, "--secret-rm", mounted.Source)
}
args = append(args, "--secret-add", secretMount(desiredPhysical, target))
changed = true
}
for _, mounted := range service.Secrets {
logical, managedTarget := strings.CutPrefix(mounted.Target, barkfile.DockerSecretPrefix)
if !managedTarget {
continue
}
if _, desired := desiredTargets[mounted.Target]; desired {
continue
}
// Mounted under a Barkstack target but absent from the vault:
// keep the mount so consumers keep working, surface the gap.
missing[logical] = struct{}{}
}
if changed {
args = append(args, service.Name)
if _, err := d.Runner.Run(ctx, nil, args...); err != nil {
return nil, fmt.Errorf("rotate secrets on service %q: %w", service.Name, err)
}
}
}
desiredPhysical := make(map[string]struct{}, len(snapshot.Secrets))
for _, record := range snapshot.Secrets {
desiredPhysical[physicalName(snapshot.VaultID, record.Revision)] = struct{}{}
}
for _, secret := range managed {
if _, desired := desiredPhysical[secret.Name]; desired {
continue
}
if _, err := d.Runner.Run(ctx, nil, "secret", "rm", secret.Name); err != nil && !isInUseError(err) {
return nil, fmt.Errorf("remove obsolete Docker secret %q: %w", secret.Name, err)
}
}
states := make([]SecretState, 0, len(snapshot.Secrets))
for name, record := range snapshot.Secrets {
states = append(states, SecretState{
Name: name, Revision: record.Revision,
PhysicalName: physicalName(snapshot.VaultID, record.Revision), InUse: inUse[name],
})
}
sort.Slice(states, func(i, j int) bool { return states[i].Name < states[j].Name })
if len(missing) != 0 {
names := make([]string, 0, len(missing))
for name := range missing {
names = append(names, name)
}
sort.Strings(names)
return states, fmt.Errorf("%w: %s", ErrSecretInUse, strings.Join(names, ", "))
}
return states, nil
}
// InUse reports whether any managed service currently mounts the secret at its
// barkstack_<name> target.
func (d Docker) InUse(ctx context.Context, name string) (bool, error) {
services, err := d.managedServices(ctx)
if err != nil {
return false, err
}
target := barkfile.DockerSecretName(name)
for _, service := range services {
for _, secret := range service.Secrets {
if secret.Target == target {
return true, nil
}
}
}
return false, nil
}
type dockerSecret struct {
Name string
LogicalName string
Revision string
VaultID string
}
func containsSecret(secrets []dockerSecret, name string) bool {
for _, secret := range secrets {
if secret.Name == name {
return true
}
}
return false
}
func (d Docker) managedSecrets(ctx context.Context, vaultID string) ([]dockerSecret, error) {
output, err := d.Runner.Run(ctx, nil,
"secret", "ls", "--quiet",
"--filter", "label="+barkfile.TreatVaultManagedLabel+"=true",
"--filter", "label="+barkfile.TreatVaultVaultIDLabel+"="+vaultID,
)
if err != nil {
return nil, fmt.Errorf("list managed Docker secrets: %w", err)
}
ids := strings.Fields(output)
secrets := make([]dockerSecret, 0, len(ids))
for _, id := range ids {
output, err := d.Runner.Run(ctx, nil, "secret", "inspect", "--format", "{{json .Spec}}", id)
if err != nil {
return nil, fmt.Errorf("inspect Docker secret %q: %w", id, err)
}
var spec struct {
Name string `json:"Name"`
Labels map[string]string `json:"Labels"`
}
if err := json.Unmarshal([]byte(output), &spec); err != nil {
return nil, fmt.Errorf("decode Docker secret %q: %w", id, err)
}
if spec.Labels[barkfile.TreatVaultManagedLabel] != "true" || spec.Labels[barkfile.TreatVaultVaultIDLabel] != vaultID {
continue
}
secrets = append(secrets, dockerSecret{
Name: spec.Name, LogicalName: spec.Labels[barkfile.TreatVaultSecretNameLabel],
Revision: spec.Labels[barkfile.TreatVaultRevisionLabel], VaultID: vaultID,
})
}
return secrets, nil
}
type serviceSecret struct {
Source string
Target string
}
type dockerService struct {
Name string
DesiredNames []string
Secrets []serviceSecret
}
func (d Docker) managedServices(ctx context.Context) ([]dockerService, error) {
output, err := d.Runner.Run(ctx, nil,
"service", "ls", "--quiet", "--filter", "label="+barkfile.TreatVaultConsumerLabel+"=true",
)
if err != nil {
return nil, fmt.Errorf("list TreatVault consumer services: %w", err)
}
ids := strings.Fields(output)
services := make([]dockerService, 0, len(ids))
for _, id := range ids {
output, err := d.Runner.Run(ctx, nil, "service", "inspect", "--format", "{{json .Spec}}", id)
if err != nil {
return nil, fmt.Errorf("inspect Docker service %q: %w", id, err)
}
var spec struct {
Name string `json:"Name"`
Labels map[string]string `json:"Labels"`
TaskTemplate struct {
ContainerSpec struct {
Secrets []struct {
SecretName string `json:"SecretName"`
File *struct {
Name string `json:"Name"`
} `json:"File"`
} `json:"Secrets"`
} `json:"ContainerSpec"`
} `json:"TaskTemplate"`
}
if err := json.Unmarshal([]byte(output), &spec); err != nil {
return nil, fmt.Errorf("decode Docker service %q: %w", id, err)
}
names := spec.Labels[barkfile.TreatVaultNamesLabel]
var desiredNames []string
if names != "" {
desiredNames = strings.Split(names, ",")
}
service := dockerService{Name: spec.Name, DesiredNames: desiredNames}
for _, secret := range spec.TaskTemplate.ContainerSpec.Secrets {
if secret.File != nil {
service.Secrets = append(service.Secrets, serviceSecret{Source: secret.SecretName, Target: secret.File.Name})
}
}
services = append(services, service)
}
return services, nil
}
func physicalName(vaultID, revision string) string {
return barkfile.DockerSecretPrefix + "tv_" + vaultID + "_" + revision
}
func secretMount(source, target string) string {
return "source=" + source + ",target=" + target
}
func isInUseError(err error) bool {
message := strings.ToLower(err.Error())
return strings.Contains(message, "in use") || strings.Contains(message, "used by")
}

View File

@@ -0,0 +1,184 @@
package provider
import (
"context"
"errors"
"strings"
"testing"
"cloud.campbellwireless.net/git/barkstack/treatvault/internal/vault"
)
const (
testVaultID = "0011223344556677"
testRevision = "00112233445566778899aabbccddeeff"
)
func serviceSpecJSON(names string, secrets string) string {
return `{"Name":"barkstack-pawsql","Labels":{"io.barkstack.treatvault.secrets":"true","io.barkstack.treatvault.names":"` + names + `"},"TaskTemplate":{"ContainerSpec":{"Secrets":[` + secrets + `]}}}`
}
func TestSyncCreatesVersionedDockerSecret(t *testing.T) {
runner := &fakeRunner{responses: []fakeResponse{{}, {}, {}}}
provider := Docker{Runner: runner}
snapshot := vault.Snapshot{
Version: 1, VaultID: testVaultID,
Secrets: map[string]vault.Record{"database_password": {Revision: testRevision, Value: []byte("super-secret")}},
}
states, err := provider.Sync(context.Background(), snapshot)
if err != nil {
t.Fatal(err)
}
const physical = "barkstack_tv_" + testVaultID + "_" + testRevision
if len(states) != 1 || states[0].PhysicalName != physical {
t.Fatalf("states = %#v", states)
}
create := runner.callContaining(t, "secret create")
for _, want := range []string{
"--label io.barkstack.treatvault=true",
"--label io.barkstack.treatvault.name=database_password",
physical + " -",
} {
if !strings.Contains(create.args, want) {
t.Errorf("create args = %q, missing %q", create.args, want)
}
}
if string(create.input) != "super-secret" || strings.Contains(create.args, "super-secret") {
t.Fatalf("secret was not passed exclusively on stdin: %#v", create)
}
}
func TestSyncRotatesMountedSecretWithoutChangingTarget(t *testing.T) {
const (
oldRevision = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
oldPhysical = "barkstack_tv_" + testVaultID + "_" + oldRevision
newPhysical = "barkstack_tv_" + testVaultID + "_" + testRevision
)
runner := &fakeRunner{responses: []fakeResponse{
{output: "old-id\n"}, // secret ls
{output: secretSpecJSON("database_password", oldRevision, oldPhysical, testVaultID)}, // secret inspect
{}, // secret create (new revision)
{output: "service-id\n"}, // service ls
{output: serviceSpecJSON("database_password", mountJSON(oldPhysical, "barkstack_database_password"))}, // service inspect
{}, // service update
{err: errors.New("secret is in use by old task")}, // secret rm
}}
provider := Docker{Runner: runner}
states, err := provider.Sync(context.Background(), vault.Snapshot{
Version: 1, VaultID: testVaultID,
Secrets: map[string]vault.Record{"database_password": {Revision: testRevision, Value: []byte("rotated")}},
})
if err != nil {
t.Fatal(err)
}
if len(states) != 1 || !states[0].InUse {
t.Fatalf("states = %#v", states)
}
update := runner.callContaining(t, "service update")
for _, want := range []string{
"--secret-rm " + oldPhysical,
"--secret-add source=" + newPhysical + ",target=barkstack_database_password",
"barkstack-pawsql",
} {
if !strings.Contains(update.args, want) {
t.Errorf("update args = %q, missing %q", update.args, want)
}
}
}
func TestSyncMountsMissingDesiredSecretOnConsumer(t *testing.T) {
const physical = "barkstack_tv_" + testVaultID + "_" + testRevision
runner := &fakeRunner{responses: []fakeResponse{
{}, // secret ls (empty vault listing)
{}, // secret create
{output: "service-id\n"},
{output: serviceSpecJSON("database_password", "")},
{}, // service update
}}
provider := Docker{Runner: runner}
states, err := provider.Sync(context.Background(), vault.Snapshot{
Version: 1, VaultID: testVaultID,
Secrets: map[string]vault.Record{"database_password": {Revision: testRevision, Value: []byte("value")}},
})
if err != nil {
t.Fatal(err)
}
update := runner.callContaining(t, "service update")
if want := "--secret-add source=" + physical + ",target=barkstack_database_password"; !strings.Contains(update.args, want) {
t.Errorf("update args = %q, missing %q", update.args, want)
}
if !states[0].InUse {
t.Fatalf("states = %#v, want InUse", states)
}
}
func TestSyncReportsConfiguredSecretMissingFromVault(t *testing.T) {
runner := &fakeRunner{responses: []fakeResponse{
{}, // secret ls (empty)
{output: "service-id\n"},
{output: serviceSpecJSON("database_password", "")},
}}
_, err := (Docker{Runner: runner}).Sync(context.Background(), vault.Snapshot{Version: 1, VaultID: testVaultID, Secrets: map[string]vault.Record{}})
if !errors.Is(err, ErrSecretInUse) {
t.Fatalf("Sync() error = %v", err)
}
}
func TestSyncKeepsOrphanedMountAndReportsGap(t *testing.T) {
const oldPhysical = "barkstack_tv_" + testVaultID + "_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
runner := &fakeRunner{responses: []fakeResponse{
{output: "old-id\n"},
{output: secretSpecJSON("removed_secret", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", oldPhysical, testVaultID)},
{output: "service-id\n"},
{output: serviceSpecJSON("database_password", mountJSON(oldPhysical, "barkstack_database_password"))},
{err: errors.New("secret is in use")},
}}
_, err := (Docker{Runner: runner}).Sync(context.Background(), vault.Snapshot{Version: 1, VaultID: testVaultID, Secrets: map[string]vault.Record{}})
if !errors.Is(err, ErrSecretInUse) {
t.Fatalf("Sync() error = %v", err)
}
}
func secretSpecJSON(name, revision, physical, vaultID string) string {
return `{"Name":"` + physical + `","Labels":{"io.barkstack.treatvault":"true","io.barkstack.treatvault.name":"` + name + `","io.barkstack.treatvault.vault":"` + vaultID + `","io.barkstack.treatvault.revision":"` + revision + `"}}`
}
func mountJSON(source, target string) string {
return `{"SecretName":"` + source + `","File":{"Name":"` + target + `"}}`
}
type fakeResponse struct {
output string
err error
}
type fakeCall struct {
args string
input []byte
}
type fakeRunner struct {
responses []fakeResponse
calls []fakeCall
}
func (r *fakeRunner) Run(_ context.Context, input []byte, args ...string) (string, error) {
r.calls = append(r.calls, fakeCall{args: strings.Join(args, " "), input: append([]byte(nil), input...)})
if len(r.responses) == 0 {
return "", errors.New("unexpected Docker command")
}
response := r.responses[0]
r.responses = r.responses[1:]
return response.output, response.err
}
func (r *fakeRunner) callContaining(t *testing.T, prefix string) fakeCall {
t.Helper()
for _, call := range r.calls {
if strings.HasPrefix(call.args, prefix) {
return call
}
}
t.Fatalf("calls = %#v, missing %q", r.calls, prefix)
return fakeCall{}
}

292
internal/vault/store.go Normal file
View File

@@ -0,0 +1,292 @@
// Package vault owns TreatVault's encrypted source-of-truth file.
package vault
import (
"bytes"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"sync"
barkfile "cloud.campbellwireless.net/git/barkstack/barkfile-parser/v2"
"filippo.io/age"
)
const (
documentVersion = 1
maxSecretBytes = 500 * 1024
maxDocumentBytes = 16 * 1024 * 1024
)
var ErrSecretNotFound = errors.New("secret not found")
type Record struct {
Revision string `json:"revision"`
Value []byte `json:"value"`
}
type Snapshot struct {
Version int `json:"version"`
VaultID string `json:"vaultId"`
Secrets map[string]Record `json:"secrets"`
}
type Metadata struct {
Name string
Revision string
}
type Store struct {
path string
identity *age.X25519Identity
recipient age.Recipient
mu sync.Mutex
}
func Open(path, identityPath string) (*Store, error) {
identity, err := ReadIdentity(identityPath)
if err != nil {
return nil, err
}
store := &Store{path: path, identity: identity, recipient: identity.Recipient()}
if _, err := store.Load(); err != nil {
return nil, err
}
return store, nil
}
func New(path string, identity *age.X25519Identity) *Store {
return &Store{path: path, identity: identity, recipient: identity.Recipient()}
}
func ReadIdentity(path string) (*age.X25519Identity, error) {
contents, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read age identity: %w", err)
}
identity, err := age.ParseX25519Identity(strings.TrimSpace(string(contents)))
if err != nil {
return nil, fmt.Errorf("parse age identity: %w", err)
}
return identity, nil
}
func GenerateIdentity(path string) (string, error) {
identity, err := age.GenerateX25519Identity()
if err != nil {
return "", fmt.Errorf("generate age identity: %w", err)
}
if _, err := os.Stat(path); err == nil {
return "", fmt.Errorf("identity file %q already exists", path)
} else if !errors.Is(err, os.ErrNotExist) {
return "", err
}
if err := writeFileAtomic(path, []byte(identity.String()+"\n"), 0o600); err != nil {
return "", fmt.Errorf("write age identity: %w", err)
}
return identity.Recipient().String(), nil
}
func Initialize(path string, identity *age.X25519Identity) error {
if _, err := os.Stat(path); err == nil {
return fmt.Errorf("encrypted file %q already exists", path)
} else if !errors.Is(err, os.ErrNotExist) {
return err
}
store := New(path, identity)
vaultID, err := randomHex(8)
if err != nil {
return err
}
return store.save(Snapshot{Version: documentVersion, VaultID: vaultID, Secrets: map[string]Record{}})
}
func (s *Store) Path() string { return s.path }
func (s *Store) Load() (Snapshot, error) {
s.mu.Lock()
defer s.mu.Unlock()
return s.load()
}
func (s *Store) Set(name string, value []byte) (Snapshot, error) {
if !barkfile.ValidSecretReference(name) {
return Snapshot{}, fmt.Errorf("invalid secret name %q", name)
}
if len(value) == 0 {
return Snapshot{}, errors.New("secret value must not be empty")
}
if len(value) > maxSecretBytes {
return Snapshot{}, fmt.Errorf("secret value exceeds Docker's %d-byte limit", maxSecretBytes)
}
s.mu.Lock()
defer s.mu.Unlock()
snapshot, err := s.load()
if err != nil {
return Snapshot{}, err
}
if existing, ok := snapshot.Secrets[name]; ok && bytes.Equal(existing.Value, value) {
return snapshot, nil
}
revision, err := randomHex(16)
if err != nil {
return Snapshot{}, err
}
snapshot.Secrets[name] = Record{Revision: revision, Value: append([]byte(nil), value...)}
if err := s.save(snapshot); err != nil {
return Snapshot{}, err
}
return snapshot, nil
}
func (s *Store) Delete(name string) (Snapshot, error) {
s.mu.Lock()
defer s.mu.Unlock()
snapshot, err := s.load()
if err != nil {
return Snapshot{}, err
}
if _, ok := snapshot.Secrets[name]; !ok {
return Snapshot{}, ErrSecretNotFound
}
delete(snapshot.Secrets, name)
if err := s.save(snapshot); err != nil {
return Snapshot{}, err
}
return snapshot, nil
}
func (s *Store) load() (Snapshot, error) {
encrypted, err := os.Open(s.path)
if err != nil {
return Snapshot{}, fmt.Errorf("open encrypted file: %w", err)
}
defer encrypted.Close()
plaintext, err := age.Decrypt(encrypted, s.identity)
if err != nil {
return Snapshot{}, fmt.Errorf("decrypt encrypted file: %w", err)
}
decoder := json.NewDecoder(io.LimitReader(plaintext, maxDocumentBytes+1))
decoder.DisallowUnknownFields()
var snapshot Snapshot
if err := decoder.Decode(&snapshot); err != nil {
return Snapshot{}, fmt.Errorf("decode encrypted file: %w", err)
}
if err := validateSnapshot(snapshot); err != nil {
return Snapshot{}, fmt.Errorf("validate encrypted file: %w", err)
}
return cloneSnapshot(snapshot), nil
}
func (s *Store) save(snapshot Snapshot) error {
if err := validateSnapshot(snapshot); err != nil {
return err
}
var encrypted bytes.Buffer
writer, err := age.Encrypt(&encrypted, s.recipient)
if err != nil {
return fmt.Errorf("initialize encryption: %w", err)
}
encoder := json.NewEncoder(writer)
encoder.SetEscapeHTML(false)
if err := encoder.Encode(snapshot); err != nil {
return fmt.Errorf("encode encrypted file: %w", err)
}
if err := writer.Close(); err != nil {
return fmt.Errorf("finish encryption: %w", err)
}
if err := writeFileAtomic(s.path, encrypted.Bytes(), 0o600); err != nil {
return fmt.Errorf("replace encrypted file: %w", err)
}
return nil
}
func validateSnapshot(snapshot Snapshot) error {
if snapshot.Version != documentVersion {
return fmt.Errorf("unsupported document version %d", snapshot.Version)
}
if len(snapshot.VaultID) != 16 {
return errors.New("vault ID must be 16 hexadecimal characters")
}
if _, err := hex.DecodeString(snapshot.VaultID); err != nil {
return errors.New("vault ID must be hexadecimal")
}
if snapshot.Secrets == nil {
return errors.New("secrets map is required")
}
for name, record := range snapshot.Secrets {
if !barkfile.ValidSecretReference(name) {
return fmt.Errorf("invalid secret name %q", name)
}
if len(record.Value) == 0 || len(record.Value) > maxSecretBytes {
return fmt.Errorf("secret %q has invalid value length", name)
}
if len(record.Revision) != 32 {
return fmt.Errorf("secret %q has invalid revision", name)
}
if _, err := hex.DecodeString(record.Revision); err != nil {
return fmt.Errorf("secret %q has invalid revision", name)
}
}
return nil
}
func cloneSnapshot(snapshot Snapshot) Snapshot {
clone := Snapshot{Version: snapshot.Version, VaultID: snapshot.VaultID, Secrets: make(map[string]Record, len(snapshot.Secrets))}
for name, record := range snapshot.Secrets {
record.Value = append([]byte(nil), record.Value...)
clone.Secrets[name] = record
}
return clone
}
func randomHex(bytesCount int) (string, error) {
value := make([]byte, bytesCount)
if _, err := rand.Read(value); err != nil {
return "", fmt.Errorf("generate random identifier: %w", err)
}
return hex.EncodeToString(value), nil
}
func writeFileAtomic(path string, contents []byte, mode os.FileMode) error {
directory := filepath.Dir(path)
if err := os.MkdirAll(directory, 0o700); err != nil {
return err
}
temporary, err := os.CreateTemp(directory, ".treatvault-*")
if err != nil {
return err
}
temporaryPath := temporary.Name()
defer os.Remove(temporaryPath)
if err := temporary.Chmod(mode); err != nil {
temporary.Close()
return err
}
if _, err := temporary.Write(contents); err != nil {
temporary.Close()
return err
}
if err := temporary.Sync(); err != nil {
temporary.Close()
return err
}
if err := temporary.Close(); err != nil {
return err
}
if err := os.Rename(temporaryPath, path); err != nil {
return err
}
directoryHandle, err := os.Open(directory)
if err != nil {
return err
}
defer directoryHandle.Close()
return directoryHandle.Sync()
}

View File

@@ -0,0 +1,108 @@
package vault
import (
"bytes"
"os"
"path/filepath"
"testing"
"filippo.io/age"
)
func TestStoreEncryptsUpdatesAndDeletesSecrets(t *testing.T) {
identity, err := age.GenerateX25519Identity()
if err != nil {
t.Fatal(err)
}
path := filepath.Join(t.TempDir(), "vault", "secrets.age")
if err := Initialize(path, identity); err != nil {
t.Fatal(err)
}
store := New(path, identity)
first, err := store.Set("database_password", []byte("correct horse battery staple"))
if err != nil {
t.Fatal(err)
}
record := first.Secrets["database_password"]
if record.Revision == "" || string(record.Value) != "correct horse battery staple" {
t.Fatalf("record = %#v", record)
}
encrypted, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if bytes.Contains(encrypted, []byte("correct horse battery staple")) || bytes.Contains(encrypted, []byte("database_password")) {
t.Fatal("encrypted file contains plaintext secret material")
}
unchanged, err := store.Set("database_password", []byte("correct horse battery staple"))
if err != nil {
t.Fatal(err)
}
if unchanged.Secrets["database_password"].Revision != record.Revision {
t.Fatal("setting the same value changed its revision")
}
rotated, err := store.Set("database_password", []byte("new value"))
if err != nil {
t.Fatal(err)
}
if rotated.Secrets["database_password"].Revision == record.Revision {
t.Fatal("rotating a value did not change its revision")
}
if _, err := store.Delete("database_password"); err != nil {
t.Fatal(err)
}
loaded, err := store.Load()
if err != nil {
t.Fatal(err)
}
if len(loaded.Secrets) != 0 {
t.Fatalf("secrets = %#v", loaded.Secrets)
}
}
func TestStoreRejectsWrongIdentityAndInvalidValues(t *testing.T) {
identity, err := age.GenerateX25519Identity()
if err != nil {
t.Fatal(err)
}
path := filepath.Join(t.TempDir(), "secrets.age")
if err := Initialize(path, identity); err != nil {
t.Fatal(err)
}
store := New(path, identity)
if _, err := store.Set("../unsafe", []byte("value")); err == nil {
t.Fatal("Set() accepted unsafe name")
}
if _, err := store.Set("empty", nil); err == nil {
t.Fatal("Set() accepted empty value")
}
wrongIdentity, err := age.GenerateX25519Identity()
if err != nil {
t.Fatal(err)
}
if _, err := New(path, wrongIdentity).Load(); err == nil {
t.Fatal("Load() succeeded with wrong identity")
}
}
func TestGenerateIdentityDoesNotOverwrite(t *testing.T) {
path := filepath.Join(t.TempDir(), "identity.txt")
recipient, err := GenerateIdentity(path)
if err != nil {
t.Fatal(err)
}
if recipient == "" {
t.Fatal("recipient is empty")
}
if _, err := GenerateIdentity(path); err == nil {
t.Fatal("GenerateIdentity() overwrote existing identity")
}
info, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
if info.Mode().Perm() != 0o600 {
t.Fatalf("identity permissions = %o", info.Mode().Perm())
}
}