209 lines
6.6 KiB
Go
209 lines
6.6 KiB
Go
// 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
|
|
}
|