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:
@@ -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)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
4
Makefile
4
Makefile
@@ -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 ./...
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user