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:
130
internal/grpcserver/acme.go
Normal file
130
internal/grpcserver/acme.go
Normal 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
|
||||
}
|
||||
56
internal/grpcserver/auth.go
Normal file
56
internal/grpcserver/auth.go
Normal 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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
107
internal/grpcserver/interceptors.go
Normal file
107
internal/grpcserver/interceptors.go
Normal 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
|
||||
}
|
||||
81
internal/grpcserver/pki.go
Normal file
81
internal/grpcserver/pki.go
Normal 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
|
||||
}
|
||||
86
internal/grpcserver/policy.go
Normal file
86
internal/grpcserver/policy.go
Normal 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,
|
||||
}
|
||||
}
|
||||
162
internal/grpcserver/server.go
Normal file
162
internal/grpcserver/server.go
Normal 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,
|
||||
}
|
||||
}
|
||||
80
internal/grpcserver/system.go
Normal file
80
internal/grpcserver/system.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user