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>
131 lines
4.1 KiB
Go
131 lines
4.1 KiB
Go
package grpcserver
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
|
|
"google.golang.org/grpc/codes"
|
|
"google.golang.org/grpc/status"
|
|
|
|
pb "git.wntrmute.dev/kyle/metacrypt/gen/metacrypt/v1"
|
|
internacme "git.wntrmute.dev/kyle/metacrypt/internal/acme"
|
|
"git.wntrmute.dev/kyle/metacrypt/internal/engine"
|
|
)
|
|
|
|
type acmeServer struct {
|
|
pb.UnimplementedACMEServiceServer
|
|
s *GRPCServer
|
|
}
|
|
|
|
func (as *acmeServer) CreateEAB(ctx context.Context, req *pb.CreateEABRequest) (*pb.CreateEABResponse, error) {
|
|
ti := tokenInfoFromContext(ctx)
|
|
h, err := as.getOrCreateHandler(req.Mount)
|
|
if err != nil {
|
|
return nil, status.Error(codes.NotFound, "mount not found")
|
|
}
|
|
cred, err := h.CreateEAB(ctx, ti.Username)
|
|
if err != nil {
|
|
as.s.logger.Error("grpc: acme create EAB", "error", err)
|
|
return nil, status.Error(codes.Internal, "failed to create EAB credentials")
|
|
}
|
|
return &pb.CreateEABResponse{Kid: cred.KID, HmacKey: cred.HMACKey}, nil
|
|
}
|
|
|
|
func (as *acmeServer) SetConfig(ctx context.Context, req *pb.SetACMEConfigRequest) (*pb.SetACMEConfigResponse, error) {
|
|
if req.DefaultIssuer == "" {
|
|
return nil, status.Error(codes.InvalidArgument, "default_issuer is required")
|
|
}
|
|
// Verify mount exists.
|
|
if _, err := as.getOrCreateHandler(req.Mount); err != nil {
|
|
return nil, status.Error(codes.NotFound, "mount not found")
|
|
}
|
|
cfg := &internacme.ACMEConfig{DefaultIssuer: req.DefaultIssuer}
|
|
data, _ := json.Marshal(cfg)
|
|
barrierPath := "acme/" + req.Mount + "/config.json"
|
|
if err := as.s.sealMgr.Barrier().Put(ctx, barrierPath, data); err != nil {
|
|
as.s.logger.Error("grpc: acme set config", "error", err)
|
|
return nil, status.Error(codes.Internal, "failed to save config")
|
|
}
|
|
return &pb.SetACMEConfigResponse{Ok: true}, nil
|
|
}
|
|
|
|
func (as *acmeServer) ListAccounts(ctx context.Context, req *pb.ListACMEAccountsRequest) (*pb.ListACMEAccountsResponse, error) {
|
|
h, err := as.getOrCreateHandler(req.Mount)
|
|
if err != nil {
|
|
return nil, status.Error(codes.NotFound, "mount not found")
|
|
}
|
|
accounts, err := h.ListAccounts(ctx)
|
|
if err != nil {
|
|
as.s.logger.Error("grpc: acme list accounts", "error", err)
|
|
return nil, status.Error(codes.Internal, "internal error")
|
|
}
|
|
pbAccounts := make([]*pb.ACMEAccount, 0, len(accounts))
|
|
for _, a := range accounts {
|
|
contacts := make([]string, len(a.Contact))
|
|
copy(contacts, a.Contact)
|
|
pbAccounts = append(pbAccounts, &pb.ACMEAccount{
|
|
Id: a.ID,
|
|
Status: a.Status,
|
|
Contact: contacts,
|
|
MciasUsername: a.MCIASUsername,
|
|
CreatedAt: a.CreatedAt.String(),
|
|
})
|
|
}
|
|
return &pb.ListACMEAccountsResponse{Accounts: pbAccounts}, nil
|
|
}
|
|
|
|
func (as *acmeServer) ListOrders(ctx context.Context, req *pb.ListACMEOrdersRequest) (*pb.ListACMEOrdersResponse, error) {
|
|
h, err := as.getOrCreateHandler(req.Mount)
|
|
if err != nil {
|
|
return nil, status.Error(codes.NotFound, "mount not found")
|
|
}
|
|
orders, err := h.ListOrders(ctx)
|
|
if err != nil {
|
|
as.s.logger.Error("grpc: acme list orders", "error", err)
|
|
return nil, status.Error(codes.Internal, "internal error")
|
|
}
|
|
pbOrders := make([]*pb.ACMEOrder, 0, len(orders))
|
|
for _, o := range orders {
|
|
identifiers := make([]string, 0, len(o.Identifiers))
|
|
for _, id := range o.Identifiers {
|
|
identifiers = append(identifiers, id.Type+":"+id.Value)
|
|
}
|
|
pbOrders = append(pbOrders, &pb.ACMEOrder{
|
|
Id: o.ID,
|
|
AccountId: o.AccountID,
|
|
Status: o.Status,
|
|
Identifiers: identifiers,
|
|
CreatedAt: o.CreatedAt.String(),
|
|
ExpiresAt: o.ExpiresAt.String(),
|
|
})
|
|
}
|
|
return &pb.ListACMEOrdersResponse{Orders: pbOrders}, nil
|
|
}
|
|
|
|
func (as *acmeServer) getOrCreateHandler(mountName string) (*internacme.Handler, error) {
|
|
as.s.mu.Lock()
|
|
defer as.s.mu.Unlock()
|
|
|
|
// Verify mount is a CA engine.
|
|
mount, err := as.s.engines.GetMount(mountName)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if mount.Type != engine.EngineTypeCA {
|
|
return nil, engine.ErrMountNotFound
|
|
}
|
|
|
|
// Check handler cache on GRPCServer.
|
|
if h, ok := as.s.acmeHandlers[mountName]; ok {
|
|
return h, nil
|
|
}
|
|
|
|
baseURL := as.s.cfg.Server.ExternalURL
|
|
if baseURL == "" {
|
|
baseURL = "https://" + as.s.cfg.Server.ListenAddr
|
|
}
|
|
h := internacme.NewHandler(mountName, as.s.sealMgr.Barrier(), as.s.engines, baseURL, as.s.logger)
|
|
as.s.acmeHandlers[mountName] = h
|
|
return h, nil
|
|
}
|