From 0f1d58a9b8d8675651f830cd03fae62e3dd7e6da Mon Sep 17 00:00:00 2001 From: Kyle Isom Date: Sun, 15 Mar 2026 00:47:48 -0700 Subject: [PATCH] Persist engine mounts across seal/unseal cycles - Add Registry.UnsealAll() that rediscovers mounted engines from the barrier on unseal, using stored metadata at engine/_mounts/ with a fallback discovery scan for pre-existing mounts (migration path) - Registry.Mount() now persists mount metadata to the barrier; Registry.Unmount() cleans it up - Call UnsealAll() from both REST and web unseal handlers - Change Unmount() signature to accept context.Context - Default CA key size changed from P-384 to P-521 - Add build-time version stamp via ldflags; display in dashboard status bar - Make metacrypt target .PHONY so make devserver always rebuilds - Redirect /pki to /dashboard when no CA engine is mounted Co-Authored-By: Claude Sonnet 4.6 --- .claude/settings.local.json | 3 +- Makefile | 4 +- cmd/metacrypt/main.go | 3 + cmd/metacrypt/server.go | 2 +- internal/engine/ca/ca.go | 2 +- internal/engine/engine.go | 131 ++++++++++++++++++++++++++++++++- internal/engine/engine_test.go | 4 +- internal/server/routes.go | 18 ++++- internal/server/server.go | 4 +- internal/server/server_test.go | 2 +- web/templates/dashboard.html | 1 + 11 files changed, 161 insertions(+), 13 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 8f41a30..49f4e74 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -4,7 +4,8 @@ "Bash(git:*)", "Bash(go build:*)", "Bash(go test:*)", - "Bash(go vet:*)" + "Bash(go vet:*)", + "WebFetch(domain:metacircular.net)" ] } } diff --git a/Makefile b/Makefile index 50afc4c..ffc8db7 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ -.PHONY: build test vet clean docker all devserver +.PHONY: build test vet clean docker all devserver metacrypt metacrypt: - go build -trimpath -ldflags="-s -w" -o metacrypt ./cmd/metacrypt + go build -trimpath -ldflags="-s -w -X main.version=$(shell git describe --tags --always --dirty 2>/dev/null || echo dev)" -o metacrypt ./cmd/metacrypt build: go build ./... diff --git a/cmd/metacrypt/main.go b/cmd/metacrypt/main.go index 2942c6c..4006ce2 100644 --- a/cmd/metacrypt/main.go +++ b/cmd/metacrypt/main.go @@ -6,6 +6,9 @@ import ( "os" ) +// version is set at build time via -ldflags "-X main.version=". +var version = "dev" + func main() { if err := rootCmd.Execute(); err != nil { fmt.Fprintln(os.Stderr, err) diff --git a/cmd/metacrypt/server.go b/cmd/metacrypt/server.go index 369a577..16687e6 100644 --- a/cmd/metacrypt/server.go +++ b/cmd/metacrypt/server.go @@ -74,7 +74,7 @@ func runServer(cmd *cobra.Command, args []string) error { engineRegistry := engine.NewRegistry(b) engineRegistry.RegisterFactory(engine.EngineTypeCA, ca.NewCAEngine) - srv := server.New(cfg, sealMgr, authenticator, policyEngine, engineRegistry, logger) + srv := server.New(cfg, sealMgr, authenticator, policyEngine, engineRegistry, logger, version) ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() diff --git a/internal/engine/ca/ca.go b/internal/engine/ca/ca.go index b2a3e95..e1fe0a3 100644 --- a/internal/engine/ca/ca.go +++ b/internal/engine/ca/ca.go @@ -1020,7 +1020,7 @@ func defaultCAConfig() *CAConfig { return &CAConfig{ Organization: "Metacircular", KeyAlgorithm: "ecdsa", - KeySize: 384, + KeySize: 521, RootExpiry: "87600h", // 10 years } } diff --git a/internal/engine/engine.go b/internal/engine/engine.go index ab889a6..3da5d62 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -4,8 +4,10 @@ package engine import ( "context" + "encoding/json" "errors" "fmt" + "strings" "sync" "git.wntrmute.dev/kyle/metacrypt/internal/barrier" @@ -96,6 +98,14 @@ func (r *Registry) RegisterFactory(t EngineType, f Factory) { r.factories[t] = f } +// mountMeta is persisted to the barrier so mounts survive seal/unseal cycles. +type mountMeta struct { + Name string `json:"name"` + Type EngineType `json:"type"` +} + +const mountsPrefix = "engine/_mounts/" + // Mount creates and initializes a new engine mount. func (r *Registry) Mount(ctx context.Context, name string, engineType EngineType, config map[string]interface{}) error { r.mu.Lock() @@ -117,6 +127,15 @@ func (r *Registry) Mount(ctx context.Context, name string, engineType EngineType return fmt.Errorf("engine: initialize %q: %w", name, err) } + // Persist mount metadata so it can be reloaded after unseal. + meta, err := json.Marshal(mountMeta{Name: name, Type: engineType}) + if err != nil { + return fmt.Errorf("engine: marshal mount metadata: %w", err) + } + if err := r.barrier.Put(ctx, mountsPrefix+name+".json", meta); err != nil { + return fmt.Errorf("engine: persist mount metadata: %w", err) + } + r.mounts[name] = &Mount{ Name: name, Type: engineType, @@ -151,7 +170,7 @@ func (r *Registry) GetMount(name string) (*Mount, error) { } // Unmount removes and seals an engine mount. -func (r *Registry) Unmount(name string) error { +func (r *Registry) Unmount(ctx context.Context, name string) error { r.mu.Lock() defer r.mu.Unlock() @@ -164,10 +183,120 @@ func (r *Registry) Unmount(name string) error { return fmt.Errorf("engine: seal %q: %w", name, err) } + // Remove persisted mount metadata. + if err := r.barrier.Delete(ctx, mountsPrefix+name+".json"); err != nil { + return fmt.Errorf("engine: delete mount metadata %q: %w", name, err) + } + delete(r.mounts, name) return nil } +// UnsealAll discovers persisted mounts from the barrier and reloads them into +// memory. It must be called after the barrier is unsealed. +// +// It first loads mounts recorded in engine/_mounts/, then does a discovery +// pass over each registered engine type to pick up any mounts that predate +// the metadata mechanism (one-time migration). +func (r *Registry) UnsealAll(ctx context.Context) error { + r.mu.Lock() + defer r.mu.Unlock() + + // Load mounts from persisted metadata. + if err := r.loadFromMetadata(ctx); err != nil { + return err + } + + // Discovery pass: scan each registered engine type for mounts that exist + // in the barrier but have no metadata entry yet (pre-migration mounts). + for engineType, factory := range r.factories { + prefix := fmt.Sprintf("engine/%s/", engineType) + paths, err := r.barrier.List(ctx, prefix) + if err != nil { + continue // no entries for this type + } + + // Extract unique mount names from paths like "myca/config.json". + seen := make(map[string]bool) + for _, p := range paths { + parts := strings.SplitN(p, "/", 2) + if len(parts) == 0 || parts[0] == "" || parts[0] == "_mounts" { + continue + } + seen[parts[0]] = true + } + + for name := range seen { + if _, exists := r.mounts[name]; exists { + continue // already loaded + } + + eng := factory() + mountPath := fmt.Sprintf("engine/%s/%s/", engineType, name) + if err := eng.Unseal(ctx, r.barrier, mountPath); err != nil { + return fmt.Errorf("engine: unseal (discovered) %q: %w", name, err) + } + + // Write metadata so future restarts don't need the discovery pass. + meta, _ := json.Marshal(mountMeta{Name: name, Type: engineType}) + _ = r.barrier.Put(ctx, mountsPrefix+name+".json", meta) + + r.mounts[name] = &Mount{ + Name: name, + Type: engineType, + MountPath: mountPath, + Engine: eng, + } + } + } + + return nil +} + +// loadFromMetadata loads mounts recorded under engine/_mounts/. Caller must hold r.mu. +func (r *Registry) loadFromMetadata(ctx context.Context) error { + paths, err := r.barrier.List(ctx, mountsPrefix) + if err != nil { + return nil // no metadata written yet + } + + for _, p := range paths { + if !strings.HasSuffix(p, ".json") { + continue + } + + data, err := r.barrier.Get(ctx, mountsPrefix+p) + if err != nil { + return fmt.Errorf("engine: read mount metadata %q: %w", p, err) + } + + var meta mountMeta + if err := json.Unmarshal(data, &meta); err != nil { + return fmt.Errorf("engine: parse mount metadata %q: %w", p, err) + } + + factory, ok := r.factories[meta.Type] + if !ok { + return fmt.Errorf("%w: %s (mount %q)", ErrUnknownType, meta.Type, meta.Name) + } + + eng := factory() + mountPath := fmt.Sprintf("engine/%s/%s/", meta.Type, meta.Name) + if err := eng.Unseal(ctx, r.barrier, mountPath); err != nil { + return fmt.Errorf("engine: unseal %q: %w", meta.Name, err) + } + + r.mounts[meta.Name] = &Mount{ + Name: meta.Name, + Type: meta.Type, + MountPath: mountPath, + Engine: eng, + } + } + + return nil +} + // ListMounts returns all current mounts. func (r *Registry) ListMounts() []Mount { r.mu.RLock() diff --git a/internal/engine/engine_test.go b/internal/engine/engine_test.go index 8361065..55fb2a1 100644 --- a/internal/engine/engine_test.go +++ b/internal/engine/engine_test.go @@ -62,7 +62,7 @@ func TestRegistryMountUnmount(t *testing.T) { t.Fatalf("expected ErrMountExists, got: %v", err) } - if err := reg.Unmount("default"); err != nil { + if err := reg.Unmount(ctx, "default"); err != nil { t.Fatalf("Unmount: %v", err) } @@ -74,7 +74,7 @@ func TestRegistryMountUnmount(t *testing.T) { func TestRegistryUnmountNotFound(t *testing.T) { reg := NewRegistry(&mockBarrier{}) - if err := reg.Unmount("nonexistent"); err != ErrMountNotFound { + if err := reg.Unmount(context.Background(), "nonexistent"); err != ErrMountNotFound { t.Fatalf("expected ErrMountNotFound, got: %v", err) } } diff --git a/internal/server/routes.go b/internal/server/routes.go index 64a614f..a655779 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -136,6 +136,12 @@ func (s *Server) handleUnseal(w http.ResponseWriter, r *http.Request) { return } + if err := s.engines.UnsealAll(r.Context()); err != nil { + s.logger.Error("engine unseal failed", "error", err) + http.Error(w, `{"error":"engine unseal failed"}`, http.StatusInternalServerError) + return + } + writeJSON(w, http.StatusOK, map[string]interface{}{ "state": s.seal.State().String(), }) @@ -255,7 +261,7 @@ func (s *Server) handleEngineUnmount(w http.ResponseWriter, r *http.Request) { http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest) return } - if err := s.engines.Unmount(req.Name); err != nil { + if err := s.engines.Unmount(r.Context(), req.Name); err != nil { http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusNotFound) return } @@ -552,6 +558,11 @@ func (s *Server) handleWebUnseal(w http.ResponseWriter, r *http.Request) { s.renderTemplate(w, "unseal.html", map[string]interface{}{"Error": msg}) return } + if err := s.engines.UnsealAll(r.Context()); err != nil { + s.logger.Error("engine unseal failed", "error", err) + s.renderTemplate(w, "unseal.html", map[string]interface{}{"Error": "Engine reload failed: " + err.Error()}) + return + } http.Redirect(w, r, "/dashboard", http.StatusFound) default: http.Error(w, "method not allowed", http.StatusMethodNotAllowed) @@ -599,6 +610,7 @@ func (s *Server) handleWebDashboard(w http.ResponseWriter, r *http.Request) { "Roles": info.Roles, "Mounts": mounts, "State": s.seal.State().String(), + "Version": s.version, }) } @@ -666,13 +678,13 @@ func (s *Server) handleWebPKI(w http.ResponseWriter, r *http.Request) { mountName, err := s.findCAMount() if err != nil { - http.Error(w, "no CA engine mounted", http.StatusNotFound) + http.Redirect(w, r, "/dashboard", http.StatusFound) return } caEng, err := s.getCAEngine(mountName) if err != nil { - http.Error(w, err.Error(), http.StatusNotFound) + http.Redirect(w, r, "/dashboard", http.StatusFound) return } diff --git a/internal/server/server.go b/internal/server/server.go index ebbc1f4..15abfe1 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -27,11 +27,12 @@ type Server struct { engines *engine.Registry httpSrv *http.Server logger *slog.Logger + version string } // New creates a new server. func New(cfg *config.Config, sealMgr *seal.Manager, authenticator *auth.Authenticator, - policyEngine *policy.Engine, engineRegistry *engine.Registry, logger *slog.Logger) *Server { + policyEngine *policy.Engine, engineRegistry *engine.Registry, logger *slog.Logger, version string) *Server { s := &Server{ cfg: cfg, seal: sealMgr, @@ -39,6 +40,7 @@ func New(cfg *config.Config, sealMgr *seal.Manager, authenticator *auth.Authenti policy: policyEngine, engines: engineRegistry, logger: logger, + version: version, } return s } diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 198f495..702d531 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -61,7 +61,7 @@ func setupTestServer(t *testing.T) (*Server, *seal.Manager, chi.Router) { } logger := slog.Default() - srv := New(cfg, sealMgr, authenticator, policyEngine, engineRegistry, logger) + srv := New(cfg, sealMgr, authenticator, policyEngine, engineRegistry, logger, "test") r := chi.NewRouter() srv.registerRoutes(r) diff --git a/web/templates/dashboard.html b/web/templates/dashboard.html index 3493234..a870d7f 100644 --- a/web/templates/dashboard.html +++ b/web/templates/dashboard.html @@ -5,6 +5,7 @@ Logged in as {{.Username}} {{if .IsAdmin}}Admin{{end}} State: {{.State}} + v{{.Version}} Logout