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))
}