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:
@@ -1020,7 +1020,7 @@ func defaultCAConfig() *CAConfig {
|
||||
return &CAConfig{
|
||||
Organization: "Metacircular",
|
||||
KeyAlgorithm: "ecdsa",
|
||||
KeySize: 384,
|
||||
KeySize: 521,
|
||||
RootExpiry: "87600h", // 10 years
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user