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:
2026-03-15 09:07:12 -07:00
parent b8e348db03
commit cc1ac2e255
37 changed files with 5668 additions and 647 deletions

130
internal/grpcserver/acme.go Normal file
View File

@@ -0,0 +1,130 @@
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
}

View File

@@ -0,0 +1,56 @@
package grpcserver
import (
"context"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
mcias "git.wntrmute.dev/kyle/mcias/clients/go"
pb "git.wntrmute.dev/kyle/metacrypt/gen/metacrypt/v1"
)
type authServer struct {
pb.UnimplementedAuthServiceServer
s *GRPCServer
}
func (as *authServer) Login(_ context.Context, req *pb.LoginRequest) (*pb.LoginResponse, error) {
token, expiresAt, err := as.s.auth.Login(req.Username, req.Password, req.TotpCode)
if err != nil {
return nil, status.Error(codes.Unauthenticated, "invalid credentials")
}
return &pb.LoginResponse{Token: token, ExpiresAt: expiresAt}, nil
}
func (as *authServer) Logout(ctx context.Context, _ *pb.LogoutRequest) (*pb.LogoutResponse, error) {
token := extractToken(ctx)
client, err := mcias.New(as.s.cfg.MCIAS.ServerURL, mcias.Options{
CACertPath: as.s.cfg.MCIAS.CACert,
Token: token,
})
if err == nil {
as.s.auth.Logout(client)
}
return &pb.LogoutResponse{}, nil
}
func (as *authServer) TokenInfo(ctx context.Context, _ *pb.TokenInfoRequest) (*pb.TokenInfoResponse, error) {
ti := tokenInfoFromContext(ctx)
if ti == nil {
// Shouldn't happen — authInterceptor runs first — but guard anyway.
token := extractToken(ctx)
var err error
ti, err = as.s.auth.ValidateToken(token)
if err != nil {
return nil, status.Error(codes.Unauthenticated, "invalid token")
}
}
return &pb.TokenInfoResponse{
Username: ti.Username,
Roles: ti.Roles,
IsAdmin: ti.IsAdmin,
}, nil
}

View 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
}

View File

@@ -0,0 +1,107 @@
package grpcserver
import (
"context"
"strings"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
"git.wntrmute.dev/kyle/metacrypt/internal/auth"
"git.wntrmute.dev/kyle/metacrypt/internal/seal"
)
type contextKey int
const tokenInfoKey contextKey = iota
func tokenInfoFromContext(ctx context.Context) *auth.TokenInfo {
v, _ := ctx.Value(tokenInfoKey).(*auth.TokenInfo)
return v
}
// authInterceptor validates the Bearer token from gRPC metadata and injects
// *auth.TokenInfo into the context. The set of method full names that require
// auth is passed in; all others pass through without validation.
func authInterceptor(authenticator *auth.Authenticator, methods map[string]bool) grpc.UnaryServerInterceptor {
return func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
if !methods[info.FullMethod] {
return handler(ctx, req)
}
token := extractToken(ctx)
if token == "" {
return nil, status.Error(codes.Unauthenticated, "missing authorization token")
}
tokenInfo, err := authenticator.ValidateToken(token)
if err != nil {
return nil, status.Error(codes.Unauthenticated, "invalid token")
}
ctx = context.WithValue(ctx, tokenInfoKey, tokenInfo)
return handler(ctx, req)
}
}
// adminInterceptor requires IsAdmin on the token info for the listed methods.
// Must run after authInterceptor.
func adminInterceptor(methods map[string]bool) grpc.UnaryServerInterceptor {
return func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
if !methods[info.FullMethod] {
return handler(ctx, req)
}
ti := tokenInfoFromContext(ctx)
if ti == nil || !ti.IsAdmin {
return nil, status.Error(codes.PermissionDenied, "admin required")
}
return handler(ctx, req)
}
}
// sealInterceptor rejects calls with FailedPrecondition when the vault is
// sealed, for the listed methods.
func sealInterceptor(sealMgr *seal.Manager, methods map[string]bool) grpc.UnaryServerInterceptor {
return func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
if !methods[info.FullMethod] {
return handler(ctx, req)
}
if sealMgr.State() != seal.StateUnsealed {
return nil, status.Error(codes.FailedPrecondition, "vault is sealed")
}
return handler(ctx, req)
}
}
// chainInterceptors reduces a slice of interceptors into a single one.
func chainInterceptors(interceptors ...grpc.UnaryServerInterceptor) grpc.UnaryServerInterceptor {
return func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
chain := handler
for i := len(interceptors) - 1; i >= 0; i-- {
next := chain
ic := interceptors[i]
chain = func(ctx context.Context, req interface{}) (interface{}, error) {
return ic(ctx, req, info, next)
}
}
return chain(ctx, req)
}
}
func extractToken(ctx context.Context) string {
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return ""
}
vals := md.Get("authorization")
if len(vals) == 0 {
return ""
}
v := vals[0]
if strings.HasPrefix(v, "Bearer ") {
return strings.TrimPrefix(v, "Bearer ")
}
return v
}

View File

@@ -0,0 +1,81 @@
package grpcserver
import (
"context"
"errors"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
pb "git.wntrmute.dev/kyle/metacrypt/gen/metacrypt/v1"
"git.wntrmute.dev/kyle/metacrypt/internal/engine"
"git.wntrmute.dev/kyle/metacrypt/internal/engine/ca"
)
type pkiServer struct {
pb.UnimplementedPKIServiceServer
s *GRPCServer
}
func (ps *pkiServer) GetRootCert(_ context.Context, req *pb.GetRootCertRequest) (*pb.GetRootCertResponse, error) {
caEng, err := ps.getCAEngine(req.Mount)
if err != nil {
return nil, err
}
certPEM, err := caEng.GetRootCertPEM()
if err != nil {
return nil, status.Error(codes.Unavailable, "sealed")
}
return &pb.GetRootCertResponse{CertPem: certPEM}, nil
}
func (ps *pkiServer) GetChain(_ context.Context, req *pb.GetChainRequest) (*pb.GetChainResponse, error) {
if req.Issuer == "" {
return nil, status.Error(codes.InvalidArgument, "issuer is required")
}
caEng, err := ps.getCAEngine(req.Mount)
if err != nil {
return nil, err
}
chainPEM, err := caEng.GetChainPEM(req.Issuer)
if err != nil {
if errors.Is(err, ca.ErrIssuerNotFound) {
return nil, status.Error(codes.NotFound, "issuer not found")
}
return nil, status.Error(codes.Unavailable, "sealed")
}
return &pb.GetChainResponse{ChainPem: chainPEM}, nil
}
func (ps *pkiServer) GetIssuerCert(_ context.Context, req *pb.GetIssuerCertRequest) (*pb.GetIssuerCertResponse, error) {
if req.Issuer == "" {
return nil, status.Error(codes.InvalidArgument, "issuer is required")
}
caEng, err := ps.getCAEngine(req.Mount)
if err != nil {
return nil, err
}
certPEM, err := caEng.GetIssuerCertPEM(req.Issuer)
if err != nil {
if errors.Is(err, ca.ErrIssuerNotFound) {
return nil, status.Error(codes.NotFound, "issuer not found")
}
return nil, status.Error(codes.Unavailable, "sealed")
}
return &pb.GetIssuerCertResponse{CertPem: certPEM}, nil
}
func (ps *pkiServer) getCAEngine(mountName string) (*ca.CAEngine, error) {
mount, err := ps.s.engines.GetMount(mountName)
if err != nil {
return nil, status.Error(codes.NotFound, err.Error())
}
if mount.Type != engine.EngineTypeCA {
return nil, status.Error(codes.NotFound, "mount is not a CA engine")
}
caEng, ok := mount.Engine.(*ca.CAEngine)
if !ok {
return nil, status.Error(codes.NotFound, "mount is not a CA engine")
}
return caEng, nil
}

View File

@@ -0,0 +1,86 @@
package grpcserver
import (
"context"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
pb "git.wntrmute.dev/kyle/metacrypt/gen/metacrypt/v1"
"git.wntrmute.dev/kyle/metacrypt/internal/policy"
)
type policyServer struct {
pb.UnimplementedPolicyServiceServer
s *GRPCServer
}
func (ps *policyServer) CreatePolicy(ctx context.Context, req *pb.CreatePolicyRequest) (*pb.PolicyRule, error) {
if req.Rule == nil || req.Rule.Id == "" {
return nil, status.Error(codes.InvalidArgument, "rule.id is required")
}
rule := pbToRule(req.Rule)
if err := ps.s.policy.CreateRule(ctx, rule); err != nil {
ps.s.logger.Error("grpc: create policy", "error", err)
return nil, status.Error(codes.Internal, "internal error")
}
return ruleToPB(rule), nil
}
func (ps *policyServer) ListPolicies(ctx context.Context, _ *pb.ListPoliciesRequest) (*pb.ListPoliciesResponse, error) {
rules, err := ps.s.policy.ListRules(ctx)
if err != nil {
ps.s.logger.Error("grpc: list policies", "error", err)
return nil, status.Error(codes.Internal, "internal error")
}
pbRules := make([]*pb.PolicyRule, 0, len(rules))
for i := range rules {
pbRules = append(pbRules, ruleToPB(&rules[i]))
}
return &pb.ListPoliciesResponse{Rules: pbRules}, nil
}
func (ps *policyServer) GetPolicy(ctx context.Context, req *pb.GetPolicyRequest) (*pb.PolicyRule, error) {
if req.Id == "" {
return nil, status.Error(codes.InvalidArgument, "id is required")
}
rule, err := ps.s.policy.GetRule(ctx, req.Id)
if err != nil {
return nil, status.Error(codes.NotFound, "not found")
}
return ruleToPB(rule), nil
}
func (ps *policyServer) DeletePolicy(ctx context.Context, req *pb.DeletePolicyRequest) (*pb.DeletePolicyResponse, error) {
if req.Id == "" {
return nil, status.Error(codes.InvalidArgument, "id is required")
}
if err := ps.s.policy.DeleteRule(ctx, req.Id); err != nil {
return nil, status.Error(codes.NotFound, "not found")
}
return &pb.DeletePolicyResponse{}, nil
}
func pbToRule(r *pb.PolicyRule) *policy.Rule {
return &policy.Rule{
ID: r.Id,
Priority: int(r.Priority),
Effect: policy.Effect(r.Effect),
Usernames: r.Usernames,
Roles: r.Roles,
Resources: r.Resources,
Actions: r.Actions,
}
}
func ruleToPB(r *policy.Rule) *pb.PolicyRule {
return &pb.PolicyRule{
Id: r.ID,
Priority: int32(r.Priority),
Effect: string(r.Effect),
Usernames: r.Usernames,
Roles: r.Roles,
Resources: r.Resources,
Actions: r.Actions,
}
}

View File

@@ -0,0 +1,162 @@
// Package grpcserver implements the gRPC server for Metacrypt.
package grpcserver
import (
"crypto/tls"
"fmt"
"log/slog"
"net"
"sync"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
pb "git.wntrmute.dev/kyle/metacrypt/gen/metacrypt/v1"
internacme "git.wntrmute.dev/kyle/metacrypt/internal/acme"
"git.wntrmute.dev/kyle/metacrypt/internal/auth"
"git.wntrmute.dev/kyle/metacrypt/internal/config"
"git.wntrmute.dev/kyle/metacrypt/internal/engine"
"git.wntrmute.dev/kyle/metacrypt/internal/policy"
"git.wntrmute.dev/kyle/metacrypt/internal/seal"
)
// GRPCServer wraps the gRPC server and all service implementations.
type GRPCServer struct {
cfg *config.Config
sealMgr *seal.Manager
auth *auth.Authenticator
policy *policy.Engine
engines *engine.Registry
logger *slog.Logger
srv *grpc.Server
mu sync.Mutex
acmeHandlers map[string]*internacme.Handler
}
// New creates a new GRPCServer.
func New(cfg *config.Config, sealMgr *seal.Manager, authenticator *auth.Authenticator,
policyEngine *policy.Engine, engineRegistry *engine.Registry, logger *slog.Logger) *GRPCServer {
return &GRPCServer{
cfg: cfg,
sealMgr: sealMgr,
auth: authenticator,
policy: policyEngine,
engines: engineRegistry,
logger: logger,
acmeHandlers: make(map[string]*internacme.Handler),
}
}
// Start starts the gRPC server on cfg.Server.GRPCAddr.
func (s *GRPCServer) Start() error {
if s.cfg.Server.GRPCAddr == "" {
s.logger.Info("grpc_addr not configured, gRPC server disabled")
return nil
}
tlsCert, err := tls.LoadX509KeyPair(s.cfg.Server.TLSCert, s.cfg.Server.TLSKey)
if err != nil {
return fmt.Errorf("grpc: load TLS cert: %w", err)
}
tlsCfg := &tls.Config{
Certificates: []tls.Certificate{tlsCert},
MinVersion: tls.VersionTLS12,
}
creds := credentials.NewTLS(tlsCfg)
interceptor := chainInterceptors(
sealInterceptor(s.sealMgr, sealRequiredMethods()),
authInterceptor(s.auth, authRequiredMethods()),
adminInterceptor(adminRequiredMethods()),
)
s.srv = grpc.NewServer(
grpc.Creds(creds),
grpc.UnaryInterceptor(interceptor),
)
pb.RegisterSystemServiceServer(s.srv, &systemServer{s: s})
pb.RegisterAuthServiceServer(s.srv, &authServer{s: s})
pb.RegisterEngineServiceServer(s.srv, &engineServer{s: s})
pb.RegisterPKIServiceServer(s.srv, &pkiServer{s: s})
pb.RegisterPolicyServiceServer(s.srv, &policyServer{s: s})
pb.RegisterACMEServiceServer(s.srv, &acmeServer{s: s})
lis, err := net.Listen("tcp", s.cfg.Server.GRPCAddr)
if err != nil {
return fmt.Errorf("grpc: listen %s: %w", s.cfg.Server.GRPCAddr, err)
}
s.logger.Info("starting gRPC server", "addr", s.cfg.Server.GRPCAddr)
if err := s.srv.Serve(lis); err != nil {
return fmt.Errorf("grpc: serve: %w", err)
}
return nil
}
// Shutdown gracefully stops the gRPC server.
func (s *GRPCServer) Shutdown() {
if s.srv != nil {
s.srv.GracefulStop()
}
}
// sealRequiredMethods returns the set of RPC full names that require the vault
// to be unsealed.
func sealRequiredMethods() map[string]bool {
return map[string]bool{
"/metacrypt.v1.AuthService/Login": true,
"/metacrypt.v1.AuthService/Logout": true,
"/metacrypt.v1.AuthService/TokenInfo": true,
"/metacrypt.v1.EngineService/Mount": true,
"/metacrypt.v1.EngineService/Unmount": true,
"/metacrypt.v1.EngineService/ListMounts": true,
"/metacrypt.v1.EngineService/Request": true,
"/metacrypt.v1.PKIService/GetRootCert": true,
"/metacrypt.v1.PKIService/GetChain": true,
"/metacrypt.v1.PKIService/GetIssuerCert": true,
"/metacrypt.v1.PolicyService/CreatePolicy": true,
"/metacrypt.v1.PolicyService/ListPolicies": true,
"/metacrypt.v1.PolicyService/GetPolicy": true,
"/metacrypt.v1.PolicyService/DeletePolicy": true,
"/metacrypt.v1.ACMEService/CreateEAB": true,
"/metacrypt.v1.ACMEService/SetConfig": true,
"/metacrypt.v1.ACMEService/ListAccounts": true,
"/metacrypt.v1.ACMEService/ListOrders": true,
}
}
// authRequiredMethods returns the set of RPC full names that require a valid token.
func authRequiredMethods() map[string]bool {
return map[string]bool{
"/metacrypt.v1.AuthService/Logout": true,
"/metacrypt.v1.AuthService/TokenInfo": true,
"/metacrypt.v1.EngineService/Mount": true,
"/metacrypt.v1.EngineService/Unmount": true,
"/metacrypt.v1.EngineService/ListMounts": true,
"/metacrypt.v1.EngineService/Request": true,
"/metacrypt.v1.PolicyService/CreatePolicy": true,
"/metacrypt.v1.PolicyService/ListPolicies": true,
"/metacrypt.v1.PolicyService/GetPolicy": true,
"/metacrypt.v1.PolicyService/DeletePolicy": true,
"/metacrypt.v1.ACMEService/CreateEAB": true,
"/metacrypt.v1.ACMEService/SetConfig": true,
"/metacrypt.v1.ACMEService/ListAccounts": true,
"/metacrypt.v1.ACMEService/ListOrders": true,
}
}
// adminRequiredMethods returns the set of RPC full names that require admin.
func adminRequiredMethods() map[string]bool {
return map[string]bool{
"/metacrypt.v1.SystemService/Seal": true,
"/metacrypt.v1.EngineService/Mount": true,
"/metacrypt.v1.EngineService/Unmount": true,
"/metacrypt.v1.PolicyService/CreatePolicy": true,
"/metacrypt.v1.PolicyService/DeletePolicy": true,
"/metacrypt.v1.ACMEService/SetConfig": true,
"/metacrypt.v1.ACMEService/ListAccounts": true,
"/metacrypt.v1.ACMEService/ListOrders": true,
}
}

View File

@@ -0,0 +1,80 @@
package grpcserver
import (
"context"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
pb "git.wntrmute.dev/kyle/metacrypt/gen/metacrypt/v1"
"git.wntrmute.dev/kyle/metacrypt/internal/crypto"
"git.wntrmute.dev/kyle/metacrypt/internal/seal"
)
type systemServer struct {
pb.UnimplementedSystemServiceServer
s *GRPCServer
}
func (ss *systemServer) Status(_ context.Context, _ *pb.StatusRequest) (*pb.StatusResponse, error) {
return &pb.StatusResponse{State: ss.s.sealMgr.State().String()}, nil
}
func (ss *systemServer) Init(ctx context.Context, req *pb.InitRequest) (*pb.InitResponse, error) {
if req.Password == "" {
return nil, status.Error(codes.InvalidArgument, "password is required")
}
params := crypto.Argon2Params{
Time: ss.s.cfg.Seal.Argon2Time,
Memory: ss.s.cfg.Seal.Argon2Memory,
Threads: ss.s.cfg.Seal.Argon2Threads,
}
if err := ss.s.sealMgr.Initialize(ctx, []byte(req.Password), params); err != nil {
switch err {
case seal.ErrAlreadyInitialized:
return nil, status.Error(codes.AlreadyExists, "already initialized")
default:
ss.s.logger.Error("grpc: init failed", "error", err)
return nil, status.Error(codes.Internal, "initialization failed")
}
}
return &pb.InitResponse{State: ss.s.sealMgr.State().String()}, nil
}
func (ss *systemServer) Unseal(ctx context.Context, req *pb.UnsealRequest) (*pb.UnsealResponse, error) {
if err := ss.s.sealMgr.Unseal([]byte(req.Password)); err != nil {
switch err {
case seal.ErrNotInitialized:
return nil, status.Error(codes.FailedPrecondition, "not initialized")
case seal.ErrInvalidPassword:
return nil, status.Error(codes.Unauthenticated, "invalid password")
case seal.ErrRateLimited:
return nil, status.Error(codes.ResourceExhausted, "too many attempts, try again later")
case seal.ErrNotSealed:
return nil, status.Error(codes.FailedPrecondition, "already unsealed")
default:
ss.s.logger.Error("grpc: unseal failed", "error", err)
return nil, status.Error(codes.Internal, "unseal failed")
}
}
if err := ss.s.engines.UnsealAll(ctx); err != nil {
ss.s.logger.Error("grpc: engine unseal failed", "error", err)
return nil, status.Error(codes.Internal, "engine unseal failed")
}
return &pb.UnsealResponse{State: ss.s.sealMgr.State().String()}, nil
}
func (ss *systemServer) Seal(_ context.Context, _ *pb.SealRequest) (*pb.SealResponse, error) {
if err := ss.s.engines.SealAll(); err != nil {
ss.s.logger.Error("grpc: seal engines failed", "error", err)
}
if err := ss.s.sealMgr.Seal(); err != nil {
ss.s.logger.Error("grpc: seal failed", "error", err)
return nil, status.Error(codes.Internal, "seal failed")
}
ss.s.auth.ClearCache()
return &pb.SealResponse{State: ss.s.sealMgr.State().String()}, nil
}