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