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,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()