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 <noreply@anthropic.com>
This commit is contained in:
2026-03-15 00:47:48 -07:00
parent 658d067d78
commit 0f1d58a9b8
11 changed files with 161 additions and 13 deletions

View File

@@ -4,7 +4,8 @@
"Bash(git:*)", "Bash(git:*)",
"Bash(go build:*)", "Bash(go build:*)",
"Bash(go test:*)", "Bash(go test:*)",
"Bash(go vet:*)" "Bash(go vet:*)",
"WebFetch(domain:metacircular.net)"
] ]
} }
} }

View File

@@ -1,7 +1,7 @@
.PHONY: build test vet clean docker all devserver .PHONY: build test vet clean docker all devserver metacrypt
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: build:
go build ./... go build ./...

View File

@@ -6,6 +6,9 @@ import (
"os" "os"
) )
// version is set at build time via -ldflags "-X main.version=<value>".
var version = "dev"
func main() { func main() {
if err := rootCmd.Execute(); err != nil { if err := rootCmd.Execute(); err != nil {
fmt.Fprintln(os.Stderr, err) fmt.Fprintln(os.Stderr, err)

View File

@@ -74,7 +74,7 @@ func runServer(cmd *cobra.Command, args []string) error {
engineRegistry := engine.NewRegistry(b) engineRegistry := engine.NewRegistry(b)
engineRegistry.RegisterFactory(engine.EngineTypeCA, ca.NewCAEngine) 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) ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop() defer stop()

View File

@@ -1020,7 +1020,7 @@ func defaultCAConfig() *CAConfig {
return &CAConfig{ return &CAConfig{
Organization: "Metacircular", Organization: "Metacircular",
KeyAlgorithm: "ecdsa", KeyAlgorithm: "ecdsa",
KeySize: 384, KeySize: 521,
RootExpiry: "87600h", // 10 years RootExpiry: "87600h", // 10 years
} }
} }

View File

@@ -4,8 +4,10 @@ package engine
import ( import (
"context" "context"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"strings"
"sync" "sync"
"git.wntrmute.dev/kyle/metacrypt/internal/barrier" "git.wntrmute.dev/kyle/metacrypt/internal/barrier"
@@ -96,6 +98,14 @@ func (r *Registry) RegisterFactory(t EngineType, f Factory) {
r.factories[t] = f 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. // Mount creates and initializes a new engine mount.
func (r *Registry) Mount(ctx context.Context, name string, engineType EngineType, config map[string]interface{}) error { func (r *Registry) Mount(ctx context.Context, name string, engineType EngineType, config map[string]interface{}) error {
r.mu.Lock() 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) 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{ r.mounts[name] = &Mount{
Name: name, Name: name,
Type: engineType, Type: engineType,
@@ -151,7 +170,7 @@ func (r *Registry) GetMount(name string) (*Mount, error) {
} }
// Unmount removes and seals an engine mount. // 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() r.mu.Lock()
defer r.mu.Unlock() defer r.mu.Unlock()
@@ -164,10 +183,120 @@ func (r *Registry) Unmount(name string) error {
return fmt.Errorf("engine: seal %q: %w", name, err) 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) delete(r.mounts, name)
return nil 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. // ListMounts returns all current mounts.
func (r *Registry) ListMounts() []Mount { func (r *Registry) ListMounts() []Mount {
r.mu.RLock() r.mu.RLock()

View File

@@ -62,7 +62,7 @@ func TestRegistryMountUnmount(t *testing.T) {
t.Fatalf("expected ErrMountExists, got: %v", err) 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) t.Fatalf("Unmount: %v", err)
} }
@@ -74,7 +74,7 @@ func TestRegistryMountUnmount(t *testing.T) {
func TestRegistryUnmountNotFound(t *testing.T) { func TestRegistryUnmountNotFound(t *testing.T) {
reg := NewRegistry(&mockBarrier{}) 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) t.Fatalf("expected ErrMountNotFound, got: %v", err)
} }
} }

View File

@@ -136,6 +136,12 @@ func (s *Server) handleUnseal(w http.ResponseWriter, r *http.Request) {
return 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{}{ writeJSON(w, http.StatusOK, map[string]interface{}{
"state": s.seal.State().String(), "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) http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
return 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) http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusNotFound)
return 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}) s.renderTemplate(w, "unseal.html", map[string]interface{}{"Error": msg})
return 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) http.Redirect(w, r, "/dashboard", http.StatusFound)
default: default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed) 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, "Roles": info.Roles,
"Mounts": mounts, "Mounts": mounts,
"State": s.seal.State().String(), "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() mountName, err := s.findCAMount()
if err != nil { if err != nil {
http.Error(w, "no CA engine mounted", http.StatusNotFound) http.Redirect(w, r, "/dashboard", http.StatusFound)
return return
} }
caEng, err := s.getCAEngine(mountName) caEng, err := s.getCAEngine(mountName)
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusNotFound) http.Redirect(w, r, "/dashboard", http.StatusFound)
return return
} }

View File

@@ -27,11 +27,12 @@ type Server struct {
engines *engine.Registry engines *engine.Registry
httpSrv *http.Server httpSrv *http.Server
logger *slog.Logger logger *slog.Logger
version string
} }
// New creates a new server. // New creates a new server.
func New(cfg *config.Config, sealMgr *seal.Manager, authenticator *auth.Authenticator, 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{ s := &Server{
cfg: cfg, cfg: cfg,
seal: sealMgr, seal: sealMgr,
@@ -39,6 +40,7 @@ func New(cfg *config.Config, sealMgr *seal.Manager, authenticator *auth.Authenti
policy: policyEngine, policy: policyEngine,
engines: engineRegistry, engines: engineRegistry,
logger: logger, logger: logger,
version: version,
} }
return s return s
} }

View File

@@ -61,7 +61,7 @@ func setupTestServer(t *testing.T) (*Server, *seal.Manager, chi.Router) {
} }
logger := slog.Default() logger := slog.Default()
srv := New(cfg, sealMgr, authenticator, policyEngine, engineRegistry, logger) srv := New(cfg, sealMgr, authenticator, policyEngine, engineRegistry, logger, "test")
r := chi.NewRouter() r := chi.NewRouter()
srv.registerRoutes(r) srv.registerRoutes(r)

View File

@@ -5,6 +5,7 @@
<span>Logged in as <strong>{{.Username}}</strong></span> <span>Logged in as <strong>{{.Username}}</strong></span>
{{if .IsAdmin}}<span class="badge">Admin</span>{{end}} {{if .IsAdmin}}<span class="badge">Admin</span>{{end}}
<span>State: <strong>{{.State}}</strong></span> <span>State: <strong>{{.State}}</strong></span>
<span class="version">v{{.Version}}</span>
<a href="/login" onclick="fetch('/v1/auth/logout',{method:'POST'})">Logout</a> <a href="/login" onclick="fetch('/v1/auth/logout',{method:'POST'})">Logout</a>
</div> </div>