Separate web UI into standalone metacrypt-web binary
The vault server holds in-memory unsealed state (KEK, engine keys) that is lost on restart, requiring a full unseal ceremony. Previously the web UI ran inside the vault process, so any UI change forced a restart and re-unseal. This change extracts the web UI into a separate metacrypt-web binary that communicates with the vault over an authenticated gRPC connection. The web server carries no sealed state and can be restarted freely. - gen/metacrypt/v1/: generated Go bindings from proto/metacrypt/v1/ - internal/grpcserver/: full gRPC server implementation (System, Auth, Engine, PKI, Policy, ACME services) with seal/auth/admin interceptors - internal/webserver/: web server with gRPC vault client; templates embedded via web/embed.go (no runtime web/ directory needed) - cmd/metacrypt-web/: standalone binary entry point - internal/config: added [web] section (listen_addr, vault_grpc, etc.) - internal/server/routes.go: removed all web UI routes and handlers - cmd/metacrypt/server.go: starts gRPC server alongside HTTP server - Deploy: Dockerfile builds both binaries, docker-compose adds metacrypt-web service, new metacrypt-web.service systemd unit, Makefile gains proto/metacrypt-web targets Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
112
internal/grpcserver/engine.go
Normal file
112
internal/grpcserver/engine.go
Normal file
@@ -0,0 +1,112 @@
|
||||
package grpcserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
"google.golang.org/protobuf/types/known/structpb"
|
||||
|
||||
pb "git.wntrmute.dev/kyle/metacrypt/gen/metacrypt/v1"
|
||||
"git.wntrmute.dev/kyle/metacrypt/internal/engine"
|
||||
)
|
||||
|
||||
type engineServer struct {
|
||||
pb.UnimplementedEngineServiceServer
|
||||
s *GRPCServer
|
||||
}
|
||||
|
||||
func (es *engineServer) Mount(ctx context.Context, req *pb.MountRequest) (*pb.MountResponse, error) {
|
||||
if req.Name == "" || req.Type == "" {
|
||||
return nil, status.Error(codes.InvalidArgument, "name and type are required")
|
||||
}
|
||||
|
||||
var config map[string]interface{}
|
||||
if req.Config != nil {
|
||||
config = req.Config.AsMap()
|
||||
}
|
||||
|
||||
if err := es.s.engines.Mount(ctx, req.Name, engine.EngineType(req.Type), config); err != nil {
|
||||
es.s.logger.Error("grpc: mount engine", "name", req.Name, "type", req.Type, "error", err)
|
||||
switch {
|
||||
case errors.Is(err, engine.ErrMountExists):
|
||||
return nil, status.Error(codes.AlreadyExists, err.Error())
|
||||
case errors.Is(err, engine.ErrUnknownType):
|
||||
return nil, status.Error(codes.InvalidArgument, err.Error())
|
||||
default:
|
||||
return nil, status.Error(codes.Internal, err.Error())
|
||||
}
|
||||
}
|
||||
return &pb.MountResponse{}, nil
|
||||
}
|
||||
|
||||
func (es *engineServer) Unmount(ctx context.Context, req *pb.UnmountRequest) (*pb.UnmountResponse, error) {
|
||||
if req.Name == "" {
|
||||
return nil, status.Error(codes.InvalidArgument, "name is required")
|
||||
}
|
||||
if err := es.s.engines.Unmount(ctx, req.Name); err != nil {
|
||||
if errors.Is(err, engine.ErrMountNotFound) {
|
||||
return nil, status.Error(codes.NotFound, err.Error())
|
||||
}
|
||||
return nil, status.Error(codes.Internal, err.Error())
|
||||
}
|
||||
return &pb.UnmountResponse{}, nil
|
||||
}
|
||||
|
||||
func (es *engineServer) ListMounts(_ context.Context, _ *pb.ListMountsRequest) (*pb.ListMountsResponse, error) {
|
||||
mounts := es.s.engines.ListMounts()
|
||||
pbMounts := make([]*pb.MountInfo, 0, len(mounts))
|
||||
for _, m := range mounts {
|
||||
pbMounts = append(pbMounts, &pb.MountInfo{
|
||||
Name: m.Name,
|
||||
Type: string(m.Type),
|
||||
MountPath: m.MountPath,
|
||||
})
|
||||
}
|
||||
return &pb.ListMountsResponse{Mounts: pbMounts}, nil
|
||||
}
|
||||
|
||||
func (es *engineServer) Request(ctx context.Context, req *pb.EngineRequest) (*pb.EngineResponse, error) {
|
||||
if req.Mount == "" || req.Operation == "" {
|
||||
return nil, status.Error(codes.InvalidArgument, "mount and operation are required")
|
||||
}
|
||||
|
||||
ti := tokenInfoFromContext(ctx)
|
||||
engReq := &engine.Request{
|
||||
Operation: req.Operation,
|
||||
Path: req.Path,
|
||||
Data: nil,
|
||||
}
|
||||
if req.Data != nil {
|
||||
engReq.Data = req.Data.AsMap()
|
||||
}
|
||||
if ti != nil {
|
||||
engReq.CallerInfo = &engine.CallerInfo{
|
||||
Username: ti.Username,
|
||||
Roles: ti.Roles,
|
||||
IsAdmin: ti.IsAdmin,
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := es.s.engines.HandleRequest(ctx, req.Mount, engReq)
|
||||
if err != nil {
|
||||
st := codes.Internal
|
||||
switch {
|
||||
case errors.Is(err, engine.ErrMountNotFound):
|
||||
st = codes.NotFound
|
||||
case strings.Contains(err.Error(), "forbidden"):
|
||||
st = codes.PermissionDenied
|
||||
case strings.Contains(err.Error(), "not found"):
|
||||
st = codes.NotFound
|
||||
}
|
||||
return nil, status.Error(st, err.Error())
|
||||
}
|
||||
|
||||
pbData, err := structpb.NewStruct(resp.Data)
|
||||
if err != nil {
|
||||
return nil, status.Error(codes.Internal, "failed to encode response")
|
||||
}
|
||||
return &pb.EngineResponse{Data: pbData}, nil
|
||||
}
|
||||
Reference in New Issue
Block a user